This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git
The following commit(s) were added to refs/heads/main by this push:
new 66b14a0d Extend native cross-partition and postpone writes (#1017)
66b14a0d is described below
commit 66b14a0d74c12f98c4ee5ccb4a2239797be8c456
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Oct 4 08:39:55 2026 +0800
Extend native cross-partition and postpone writes (#1017)
---
bindings/python/README.md | 34 ++
.../python/python/pypaimon_rust/datafusion.pyi | 21 ++
bindings/python/src/context.rs | 1 +
bindings/python/src/table.rs | 8 +-
bindings/python/src/write.rs | 133 +++++++-
crates/paimon/src/spec/core_options.rs | 12 +
crates/paimon/src/table/bucket_assigner_cross.rs | 246 ++++++++-------
crates/paimon/src/table/postpone_bucket_plan.rs | 54 +++-
crates/paimon/src/table/postpone_file_writer.rs | 120 ++++---
.../src/table/postpone_fixed_bucket_router.rs | 8 +
.../src/table/postpone_fixed_bucket_write.rs | 346 ++++++++++++++++++++-
.../table/postpone_fixed_bucket_write_builder.rs | 36 ++-
crates/paimon/src/table/table_commit.rs | 35 ---
crates/paimon/src/table/table_write.rs | 173 ++++++-----
crates/paimon/tests/cross_partition_write_test.rs | 323 +++++++++++++++++++
crates/paimon/tests/postpone_native_write_test.rs | 137 ++++++++
16 files changed, 1397 insertions(+), 290 deletions(-)
diff --git a/bindings/python/README.md b/bindings/python/README.md
index 496aecc9..56613434 100644
--- a/bindings/python/README.md
+++ b/bindings/python/README.md
@@ -139,6 +139,40 @@ committer. Batch empty commits follow
`snapshot.ignore-empty-commit` (default
true). `truncate_table()` shares the batch commit guard; `truncate_partitions`
accepts a nonempty list of partition specs as in Java.
+For fixed-bucket writes to a primary-key table with `bucket=-2`, use the
+specialized builder. Routing, merge functions, sequence initialization and
+failure cleanup all run in the core writer:
+
+```python
+import pyarrow as pa
+
+plan = pa.record_batch(
+ [pa.array(["2026-10-01", "2026-10-02"]), pa.array([3, 5],
type=pa.int32())],
+ names=["dt", "total_buckets"],
+)
+builder =
table.new_postpone_fixed_bucket_write_builder().with_bucket_plan(plan)
+writer = builder.new_write()
+try:
+ writer.write_arrow(batch)
+ messages = writer.prepare_commit()
+finally:
+ writer.close()
+builder.new_commit().commit(messages)
+```
+
+The plan contains partition columns in table order and an `int32` count.
+Every input partition must be present. Workers may share a plan, but all rows
+for a partition and bucket must have a single writer owner. Both duplicate
+owners in a commit and stale overlapping writers are rejected. Pending files
+(`bucket=-2`) can coexist with real buckets; they remain pending until
assigned.
+
+Without an explicit plan, set `postpone.default-bucket-num` to a positive
integer.
+The count is used exactly for new partitions, and append reuses existing
layouts
+from the same snapshot used to initialize sequence numbers. Overwrite uses the
+configured default for replaced partitions. No staging, automatic rescaling or
+compaction is performed. Deletion-vector tables are rejected by this writer.
+A failed write cannot be prepared, and close deletes only unprepared output.
+
Configure overwrite on the batch builder:
```python
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index cb707319..5b94ab35 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -186,6 +186,7 @@ class Table:
def schema(self) -> TableSchema: ...
def new_read_builder(self, options: Optional[Dict[str, str]] = None) ->
ReadBuilder: ...
def new_batch_write_builder(self) -> "BatchWriteBuilder": ...
+ def new_postpone_fixed_bucket_write_builder(self) ->
"PostponeFixedBucketWriteBuilder": ...
def new_stream_write_builder(self) -> "StreamWriteBuilder": ...
def latest_snapshot(self) -> Optional[Snapshot]:
"""
@@ -336,6 +337,26 @@ class BatchWriteBuilder:
def new_update(self) -> BatchTableUpdate: ...
def new_commit(self) -> BatchTableCommit: ...
+class PostponeFixedBucketWriteBuilder:
+ """Real-bucket batch writes for a bucket=-2 primary-key table.
+
+ Supply a shared Arrow plan or configure postpone.default-bucket-num.
+ The default applies exactly to new partitions; append reuses existing
+ layouts from the writer's baseline snapshot. Overwrite uses the new
default.
+ Automatic staging, rescaling and compaction are not performed.
+ """
+ def _with_commit_user(self, commit_user: str) ->
"PostponeFixedBucketWriteBuilder": ...
+ def with_bucket_plan(self, plan: pyarrow.RecordBatch) ->
"PostponeFixedBucketWriteBuilder":
+ """Partition columns in schema order followed by total_buckets: int32.
+
+ Counts must be positive and non-null. An explicit plan must contain
+ every input partition; the table default does not fill missing entries.
+ """
+ ...
+ def with_overwrite(self, static_partition: Optional[Dict[str, Any]] = {})
-> "PostponeFixedBucketWriteBuilder": ...
+ def new_write(self) -> BatchTableWrite: ...
+ def new_commit(self) -> BatchTableCommit: ...
+
class StreamWriteBuilder:
def commit_user(self) -> str: ...
def with_commit_user(self, commit_user: str) -> "StreamWriteBuilder": ...
diff --git a/bindings/python/src/context.rs b/bindings/python/src/context.rs
index c0628460..b661f14b 100644
--- a/bindings/python/src/context.rs
+++ b/bindings/python/src/context.rs
@@ -675,6 +675,7 @@ pub fn register_module(py: Python<'_>, m: &Bound<'_,
PyModule>) -> PyResult<()>
this.add_class::<PyPythonScalarUDFObject>()?;
this.add_class::<PySQLContext>()?;
this.add_class::<crate::write::PyBatchWriteBuilder>()?;
+ this.add_class::<crate::write::PyPostponeFixedBucketWriteBuilder>()?;
this.add_class::<crate::write::PyStreamWriteBuilder>()?;
this.add_class::<crate::write::PyBatchTableWrite>()?;
this.add_class::<crate::write::PyBatchTableUpdate>()?;
diff --git a/bindings/python/src/table.rs b/bindings/python/src/table.rs
index aa474a4d..95e1d82f 100644
--- a/bindings/python/src/table.rs
+++ b/bindings/python/src/table.rs
@@ -34,7 +34,7 @@ use crate::read::PyReadBuilder;
use crate::schema::PyTableSchema;
use crate::snapshot::PySnapshot;
use crate::tag::PyTag;
-use crate::write::{PyBatchWriteBuilder, PyStreamWriteBuilder};
+use crate::write::{PyBatchWriteBuilder, PyPostponeFixedBucketWriteBuilder,
PyStreamWriteBuilder};
#[pyclass(name = "Table", module = "pypaimon_rust.datafusion")]
pub struct PyTable {
@@ -175,6 +175,12 @@ impl PyTable {
PyBatchWriteBuilder::new(Arc::clone(&self.inner))
}
+ fn new_postpone_fixed_bucket_write_builder(
+ &self,
+ ) -> PyResult<PyPostponeFixedBucketWriteBuilder> {
+ PyPostponeFixedBucketWriteBuilder::new(Arc::clone(&self.inner))
+ }
+
fn new_stream_write_builder(&self) -> PyStreamWriteBuilder {
PyStreamWriteBuilder::new(Arc::clone(&self.inner))
}
diff --git a/bindings/python/src/write.rs b/bindings/python/src/write.rs
index bb02e891..6c619e65 100644
--- a/bindings/python/src/write.rs
+++ b/bindings/python/src/write.rs
@@ -23,8 +23,8 @@ use arrow::pyarrow::FromPyArrow;
use arrow::record_batch::RecordBatch;
use paimon::spec::{CoreOptions, DataType, Datum};
use paimon::table::{
- CommitMessage, Table, TableCommit, TableUpdate, TableUpdateByRowId,
TableWrite,
- COMMIT_MESSAGE_SERIALIZER_VERSION,
+ CommitMessage, PostponeBucketPlan, PostponeFixedBucketTableWrite, Table,
TableCommit,
+ TableUpdate, TableUpdateByRowId, TableWrite,
COMMIT_MESSAGE_SERIALIZER_VERSION,
};
use paimon_datafusion::runtime::runtime;
use pyo3::exceptions::{PyRuntimeError, PyTypeError, PyValueError};
@@ -61,7 +61,9 @@ impl WriteContext {
builder
};
Ok(WriteState {
- inner: Some(builder.new_write().map_err(to_py_err)?),
+ inner: Some(WriteTarget::Table(Box::new(
+ builder.new_write().map_err(to_py_err)?,
+ ))),
table_location: self.table.location().to_string(),
commit_user: self.commit_user.clone(),
})
@@ -270,8 +272,131 @@ impl PyStreamWriteBuilder {
}
}
+/// Keep conversion, ownership and commit-message wrapping shared by both
writers.
+/// All routing and validation remain in the Rust core implementations.
+enum WriteTarget {
+ Table(Box<TableWrite>),
+ PostponeFixed(Box<PostponeFixedBucketTableWrite>),
+}
+
+impl WriteTarget {
+ async fn write_arrow_batch(&mut self, batch: &RecordBatch) ->
paimon::Result<()> {
+ match self {
+ Self::Table(writer) => writer.write_arrow_batch(batch).await,
+ Self::PostponeFixed(writer) =>
writer.write_arrow_batch(batch).await,
+ }
+ }
+
+ async fn prepare_commit(&mut self) -> paimon::Result<Vec<CommitMessage>> {
+ match self {
+ Self::Table(writer) => writer.prepare_commit().await,
+ Self::PostponeFixed(writer) => writer.prepare_commit().await,
+ }
+ }
+
+ async fn close(&mut self) {
+ match self {
+ Self::Table(writer) => writer.close().await,
+ Self::PostponeFixed(writer) => writer.close().await,
+ }
+ }
+}
+
+/// The public batch contract is shared with PyPaimon's specialized builder.
+#[pyclass(
+ name = "PostponeFixedBucketWriteBuilder",
+ module = "pypaimon_rust.datafusion"
+)]
+pub struct PyPostponeFixedBucketWriteBuilder {
+ batch: PyBatchWriteBuilder,
+ plan: Option<PostponeBucketPlan>,
+}
+
+impl PyPostponeFixedBucketWriteBuilder {
+ pub fn new(table: Arc<Table>) -> PyResult<Self> {
+ table
+ .new_postpone_fixed_bucket_write_builder()
+ .map_err(to_py_err)?;
+ Ok(Self {
+ batch: PyBatchWriteBuilder::new(table),
+ plan: None,
+ })
+ }
+}
+
+#[pymethods]
+impl PyPostponeFixedBucketWriteBuilder {
+ fn _with_commit_user(
+ mut slf: PyRefMut<'_, Self>,
+ commit_user: String,
+ ) -> PyResult<PyRefMut<'_, Self>> {
+ slf.batch
+ .context
+ .table
+ .new_write_builder()
+ .with_commit_user(commit_user.clone())
+ .map_err(to_py_err)?;
+ slf.batch.context.commit_user = commit_user;
+ Ok(slf)
+ }
+
+ /// Partition fields followed by a non-null Int32 total_buckets column.
+ fn with_bucket_plan<'py>(
+ mut slf: PyRefMut<'py, Self>,
+ plan: &Bound<'py, PyAny>,
+ ) -> PyResult<PyRefMut<'py, Self>> {
+ let plan = RecordBatch::from_pyarrow_bound(plan)?;
+ slf.plan = Some(
+ PostponeBucketPlan::from_arrow(&slf.batch.context.table,
&plan).map_err(to_py_err)?,
+ );
+ Ok(slf)
+ }
+
+ #[pyo3(signature = (static_partition=Some(HashMap::new())))]
+ fn with_overwrite<'py>(
+ mut slf: PyRefMut<'py, Self>,
+ py: Python<'py>,
+ static_partition: Option<PythonPartitionSpec>,
+ ) -> PyResult<PyRefMut<'py, Self>> {
+ slf.batch.static_partition = static_partition
+ .map(|spec| partition_spec(py, &slf.batch.context.table, spec))
+ .transpose()?;
+ Ok(slf)
+ }
+
+ fn new_write(&self) -> PyResult<PyBatchTableWrite> {
+ let context = &self.batch.context;
+ let mut builder = context
+ .table
+ .new_postpone_fixed_bucket_write_builder()
+ .map_err(to_py_err)?
+ .with_commit_user(context.commit_user.clone())
+ .map_err(to_py_err)?;
+ if let Some(plan) = &self.plan {
+ builder = builder.with_bucket_plan(plan.clone());
+ }
+ if self.batch.static_partition.is_some() {
+ builder = builder.with_overwrite();
+ }
+ Ok(PyBatchTableWrite {
+ state: WriteState {
+ inner: Some(WriteTarget::PostponeFixed(Box::new(
+ builder.new_write().map_err(to_py_err)?,
+ ))),
+ table_location: context.table.location().to_string(),
+ commit_user: context.commit_user.clone(),
+ },
+ prepared: false,
+ })
+ }
+
+ fn new_commit(&self) -> PyResult<PyBatchTableCommit> {
+ self.batch.new_commit()
+ }
+}
+
struct WriteState {
- inner: Option<TableWrite>,
+ inner: Option<WriteTarget>,
table_location: String,
commit_user: String,
}
diff --git a/crates/paimon/src/spec/core_options.rs
b/crates/paimon/src/spec/core_options.rs
index f842ed2e..8aabb1d3 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -1417,6 +1417,18 @@ impl<'a> CoreOptions<'a> {
/// Java leaves `target-file-size` without a default and documents 128 MB
for
/// primary-key tables and 256 MB for append tables; this returns the
append
/// value for both.
+ /// Java's exact default for postpone partitions without an existing
layout.
+ /// Unlike inferred counts this is not capped or rounded to a power of two.
+ pub fn postpone_default_bucket_num(&self) -> crate::Result<Option<i32>> {
+ self.options.get("postpone.default-bucket-num").map(|raw| {
+ raw.parse::<i32>().ok().filter(|count| *count > 0).ok_or_else(|| {
+ crate::Error::ConfigInvalid {
+ message: format!("postpone.default-bucket-num must be a
positive 32-bit integer, got '{raw}'"),
+ }
+ })
+ }).transpose()
+ }
+
pub fn target_file_size(&self) -> i64 {
self.options
.get("target-file-size")
diff --git a/crates/paimon/src/table/bucket_assigner_cross.rs
b/crates/paimon/src/table/bucket_assigner_cross.rs
index 4be9800e..5abb9c33 100644
--- a/crates/paimon/src/table/bucket_assigner_cross.rs
+++ b/crates/paimon/src/table/bucket_assigner_cross.rs
@@ -26,7 +26,7 @@ use crate::table::Table;
use crate::Result;
use arrow_array::RecordBatch;
use futures::TryStreamExt;
-use std::collections::{HashMap, HashSet};
+use std::collections::{BTreeMap, HashMap};
/// Result of assigning a bucket for a key in cross-partition mode.
enum AssignResult {
@@ -51,19 +51,23 @@ enum AssignResult {
struct GlobalPartitionIndex {
/// pk_bytes -> (partition_bytes, bucket)
key_to_location: HashMap<Vec<u8>, (Vec<u8>, i32)>,
- /// partition -> { bucket -> row_count } (only non-full buckets)
- partition_non_full_buckets: HashMap<Vec<u8>, HashMap<i32, i64>>,
- /// partition -> all known bucket ids
- partition_all_buckets: HashMap<Vec<u8>, HashSet<i32>>,
- /// partition -> next bucket id to allocate (avoids linear scan)
- partition_next_bucket_id: HashMap<Vec<u8>, i32>,
- /// (partition, bucket) -> total row count
- bucket_row_counts: HashMap<(Vec<u8>, i32), i64>,
+ /// Java BucketAssigner uses a TreeMap: reuse the first non-full bucket,
+ /// then allocate the smallest unused non-negative ID.
+ partition_bucket_counts: HashMap<Vec<u8>, BTreeMap<i32, i64>>,
target_bucket_row_number: i64,
merge_engine: MergeEngine,
}
impl GlobalPartitionIndex {
+ fn empty(target_bucket_row_number: i64, merge_engine: MergeEngine) -> Self
{
+ Self {
+ key_to_location: HashMap::new(),
+ partition_bucket_counts: HashMap::new(),
+ target_bucket_row_number,
+ merge_engine,
+ }
+ }
+
/// Build the global partition index by scanning all data files from the
latest snapshot.
///
/// Uses TableRead to get deduplicated PK rows per split, so DELETE records
@@ -78,7 +82,7 @@ impl GlobalPartitionIndex {
crate::spec::CoreOptions::new(table.schema().options()).ensure_read_authorized()?;
let mut key_to_location: HashMap<Vec<u8>, (Vec<u8>, i32)> =
HashMap::new();
- let mut bucket_row_counts: HashMap<(Vec<u8>, i32), i64> =
HashMap::new();
+ let mut partition_bucket_counts: HashMap<Vec<u8>, BTreeMap<i32, i64>>
= HashMap::new();
let fields = table.schema().fields();
let pk_field_names: Vec<&str> = primary_key_indices
@@ -101,55 +105,33 @@ impl GlobalPartitionIndex {
let partition_bytes = split.partition().to_serialized_bytes();
let bucket = split.bucket();
- let batches: Vec<RecordBatch> = read
- .to_arrow(std::slice::from_ref(split))?
- .try_collect()
- .await?;
-
- let pb_key = (partition_bytes.clone(), bucket);
- for batch in &batches {
+ let mut batches = read.to_arrow(std::slice::from_ref(split))?;
+ while let Some(batch) = batches.try_next().await? {
let pk_bytes_vec =
- batch_to_serialized_bytes(batch, &projected_pk_indices,
&pk_fields)?;
- let count = pk_bytes_vec.len() as i64;
+ batch_to_serialized_bytes(&batch, &projected_pk_indices,
&pk_fields)?;
for pk_bytes in pk_bytes_vec {
- key_to_location.insert(pk_bytes, (partition_bytes.clone(),
bucket));
+ if key_to_location
+ .insert(pk_bytes, (partition_bytes.clone(), bucket))
+ .is_some()
+ {
+ return Err(crate::Error::DataInvalid {
+ message: "Duplicate primary key found while
bootstrapping the cross-partition index".into(),
+ source: None,
+ });
+ }
+ *partition_bucket_counts
+ .entry(partition_bytes.clone())
+ .or_default()
+ .entry(bucket)
+ .or_default() += 1;
}
- *bucket_row_counts.entry(pb_key.clone()).or_insert(0) += count;
}
}
- let mut partition_all_buckets: HashMap<Vec<u8>, HashSet<i32>> =
HashMap::new();
- let mut partition_non_full_buckets: HashMap<Vec<u8>, HashMap<i32,
i64>> = HashMap::new();
- for ((partition, bucket), count) in &bucket_row_counts {
- partition_all_buckets
- .entry(partition.clone())
- .or_default()
- .insert(*bucket);
- if *count < target_bucket_row_number {
- partition_non_full_buckets
- .entry(partition.clone())
- .or_default()
- .insert(*bucket, *count);
- }
- }
-
- let partition_next_bucket_id: HashMap<Vec<u8>, i32> =
partition_all_buckets
- .iter()
- .map(|(p, buckets)| {
- let next = buckets.iter().copied().max().map_or(0, |m| m + 1);
- (p.clone(), next)
- })
- .collect();
-
- Ok(Self {
- key_to_location,
- partition_non_full_buckets,
- partition_all_buckets,
- partition_next_bucket_id,
- bucket_row_counts,
- target_bucket_row_number,
- merge_engine,
- })
+ let mut index = Self::empty(target_bucket_row_number, merge_engine);
+ index.key_to_location = key_to_location;
+ index.partition_bucket_counts = partition_bucket_counts;
+ Ok(index)
}
/// Assign a bucket for the given primary key targeting `new_partition`.
@@ -171,19 +153,14 @@ impl GlobalPartitionIndex {
let old_partition = existing_partition.clone();
let old_bucket = *existing_bucket;
- // Decrement row count for old bucket
- let old_key = (old_partition.clone(), old_bucket);
- let old_count =
self.bucket_row_counts.get(&old_key).copied().unwrap_or(0);
- let new_count = (old_count - 1).max(0);
- self.bucket_row_counts.insert(old_key, new_count);
- if new_count < self.target_bucket_row_number {
- self.partition_non_full_buckets
- .entry(old_partition.clone())
- .or_default()
- .insert(old_bucket, new_count);
- }
-
- let new_bucket =
self.assign_bucket_in_partition(new_partition);
+ let old_count = self
+ .partition_bucket_counts
+ .entry(old_partition.clone())
+ .or_default()
+ .entry(old_bucket)
+ .or_default();
+ *old_count -= 1;
+ let new_bucket =
self.assign_bucket_in_partition(new_partition)?;
self.key_to_location
.insert(pk_bytes.to_vec(), (new_partition.to_vec(),
new_bucket));
@@ -202,60 +179,34 @@ impl GlobalPartitionIndex {
}
}
- let bucket = self.assign_bucket_in_partition(new_partition);
+ let bucket = self.assign_bucket_in_partition(new_partition)?;
self.key_to_location
.insert(pk_bytes.to_vec(), (new_partition.to_vec(), bucket));
Ok(AssignResult::SamePartition { bucket })
}
- fn assign_bucket_in_partition(&mut self, partition: &[u8]) -> i32 {
- let non_full = self
- .partition_non_full_buckets
+ fn assign_bucket_in_partition(&mut self, partition: &[u8]) -> Result<i32> {
+ let buckets = self
+ .partition_bucket_counts
.entry(partition.to_vec())
.or_default();
-
- let mut full_buckets = Vec::new();
- let mut assigned_bucket = None;
- for (&bucket, count) in non_full.iter_mut() {
+ for (&bucket, count) in buckets.iter_mut() {
if *count < self.target_bucket_row_number {
*count += 1;
- assigned_bucket = Some(bucket);
- break;
- } else {
- full_buckets.push(bucket);
+ return Ok(bucket);
}
}
- for b in full_buckets {
- non_full.remove(&b);
+ let mut next = 0;
+ while buckets.contains_key(&next) {
+ next = next
+ .checked_add(1)
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: "No bucket IDs available for cross-partition
writes".into(),
+ source: None,
+ })?;
}
-
- let bucket = if let Some(b) = assigned_bucket {
- b
- } else {
- let all = self
- .partition_all_buckets
- .entry(partition.to_vec())
- .or_default();
- let next_id = self
- .partition_next_bucket_id
- .entry(partition.to_vec())
- .or_insert(0);
- let new_bucket = *next_id;
- *next_id += 1;
- all.insert(new_bucket);
- self.partition_non_full_buckets
- .entry(partition.to_vec())
- .or_default()
- .insert(new_bucket, 1);
- new_bucket
- };
-
- *self
- .bucket_row_counts
- .entry((partition.to_vec(), bucket))
- .or_insert(0) += 1;
-
- bucket
+ buckets.insert(next, 1);
+ Ok(next)
}
}
@@ -280,15 +231,41 @@ impl CrossPartitionAssigner {
primary_key_indices: Vec<usize>,
target_bucket_row_number: i64,
merge_engine: MergeEngine,
- ) -> Self {
- Self {
+ ) -> Result<Self> {
+ if !crate::spec::CoreOptions::new(table.schema().options())
+ .sequence_fields()
+ .is_empty()
+ {
+ return Err(crate::Error::DataInvalid {
+ message: "Cannot define 'sequence.field' for cross-partition
update tables".into(),
+ source: None,
+ });
+ }
+ if table.schema().options().contains_key("bucket-key") {
+ return Err(crate::Error::DataInvalid {
+ message: "Cannot define 'bucket-key' for dynamic bucket
tables".into(),
+ source: None,
+ });
+ }
+ if table
+ .schema()
+ .options()
+ .contains_key("cross-partition-upsert.index-ttl")
+ {
+ return Err(crate::Error::Unsupported {
+ message:
+ "Cross-partition writes do not support
'cross-partition-upsert.index-ttl' yet"
+ .into(),
+ });
+ }
+ Ok(Self {
table,
partition_field_indices,
primary_key_indices,
global_partition_index: None,
target_bucket_row_number,
merge_engine,
- }
+ })
}
}
@@ -359,3 +336,50 @@ impl BucketAssigner for CrossPartitionAssigner {
Ok(HashMap::new())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn bucket_assignment_reuses_lowest_nonfull_bucket_then_first_unused_id() {
+ let mut index = GlobalPartitionIndex::empty(2,
MergeEngine::Deduplicate);
+ index
+ .partition_bucket_counts
+ .insert(vec![1], [(0, 2), (2, 1), (4, 1)].into());
+ assert_eq!(index.assign_bucket_in_partition(&[1]).unwrap(), 2);
+ assert_eq!(index.assign_bucket_in_partition(&[1]).unwrap(), 4);
+ assert_eq!(index.assign_bucket_in_partition(&[1]).unwrap(), 1);
+ assert_eq!(index.assign_bucket_in_partition(&[1]).unwrap(), 1);
+ assert_eq!(index.assign_bucket_in_partition(&[1]).unwrap(), 3);
+ }
+
+ #[test]
+ fn migration_releases_old_bucket_capacity_without_forgetting_other_keys() {
+ let mut index = GlobalPartitionIndex::empty(1,
MergeEngine::Deduplicate);
+ assert!(matches!(
+ index.assign(&[1], &[1]).unwrap(),
+ AssignResult::SamePartition { bucket: 0 }
+ ));
+ assert!(matches!(
+ index.assign(&[2], &[1]).unwrap(),
+ AssignResult::SamePartition { bucket: 1 }
+ ));
+ assert!(matches!(
+ index.assign(&[1], &[2]).unwrap(),
+ AssignResult::CrossPartition {
+ old_bucket: 0,
+ new_bucket: 0,
+ ..
+ }
+ ));
+ assert!(matches!(
+ index.assign(&[3], &[1]).unwrap(),
+ AssignResult::SamePartition { bucket: 0 }
+ ));
+ assert!(matches!(
+ index.assign(&[2], &[1]).unwrap(),
+ AssignResult::SamePartition { bucket: 1 }
+ ));
+ }
+}
diff --git a/crates/paimon/src/table/postpone_bucket_plan.rs
b/crates/paimon/src/table/postpone_bucket_plan.rs
index 6b6d867e..613ba6b4 100644
--- a/crates/paimon/src/table/postpone_bucket_plan.rs
+++ b/crates/paimon/src/table/postpone_bucket_plan.rs
@@ -16,7 +16,7 @@
// under the License.
use crate::spec::batch_to_serialized_bytes;
-use crate::table::Table;
+use crate::table::{SnapshotManager, Table, TableScan};
use crate::Result;
use arrow_array::{Array, Int32Array, RecordBatch};
use std::collections::HashMap;
@@ -34,6 +34,7 @@ pub(crate) fn data_invalid(message: impl Into<String>) ->
crate::Error {
#[derive(Debug, Clone, Default)]
pub struct PostponeBucketPlan {
bucket_counts: Arc<HashMap<Vec<u8>, i32>>,
+ default_bucket_num: Option<i32>,
}
impl PostponeBucketPlan {
@@ -113,10 +114,59 @@ impl PostponeBucketPlan {
}
Ok(Self {
bucket_counts: Arc::new(bucket_counts),
+ default_bucket_num: None,
})
}
+ pub(crate) fn with_default_bucket_num(default_bucket_num: i32) -> Self {
+ Self {
+ default_bucket_num: Some(default_bucket_num),
+ ..Self::default()
+ }
+ }
+
+ /// Read the same baseline used for sequence numbers and commit conflict
checks.
+ /// Pending (-2) files have no real layout and never override the
configured default.
+ pub(crate) async fn load_existing_buckets(
+ &mut self,
+ table: &Table,
+ snapshot_id: i64,
+ ) -> Result<()> {
+ if snapshot_id == 0 {
+ return Ok(());
+ }
+ let snapshot = SnapshotManager::new(table.file_io().clone(),
table.location().to_string())
+ .get_snapshot(snapshot_id)
+ .await?;
+ let entries = TableScan::new(table, None, vec![], None, None, None)
+ .with_scan_all_files()
+ .plan_manifest_entries(&snapshot)
+ .await?;
+ let mut counts = HashMap::new();
+ for entry in entries {
+ if entry.bucket() < 0 {
+ continue;
+ }
+ let count = entry.total_buckets();
+ if count <= 0 || entry.bucket() >= count {
+ return Err(data_invalid(
+ "Invalid total bucket count in existing postpone files",
+ ));
+ }
+ if let Some(previous) = counts.insert(entry.partition().to_vec(),
count) {
+ if previous != count {
+ return Err(data_invalid("Existing postpone files contain
conflicting total bucket counts for one partition"));
+ }
+ }
+ }
+ self.bucket_counts = Arc::new(counts);
+ Ok(())
+ }
+
pub(crate) fn total_buckets(&self, partition: &[u8]) -> Option<i32> {
- self.bucket_counts.get(partition).copied()
+ self.bucket_counts
+ .get(partition)
+ .copied()
+ .or(self.default_bucket_num)
}
}
diff --git a/crates/paimon/src/table/postpone_file_writer.rs
b/crates/paimon/src/table/postpone_file_writer.rs
index ea117501..e89235cb 100644
--- a/crates/paimon/src/table/postpone_file_writer.rs
+++ b/crates/paimon/src/table/postpone_file_writer.rs
@@ -30,7 +30,8 @@ use crate::io::FileIO;
use crate::resource::ResourceContext;
use crate::spec::stats::BinaryTableStats;
use crate::spec::{
- data_file_to_file_index_file_name, DataFileMeta, EMPTY_SERIALIZED_ROW,
VALUE_KIND_FIELD_NAME,
+ data_file_to_file_index_file_name, BinaryRow, DataField, DataFileMeta,
RowKind,
+ EMPTY_SERIALIZED_ROW, VALUE_KIND_FIELD_NAME,
};
use crate::table::data_file_index_writer::{DataFileIndexWriter,
FileIndexOptions};
use crate::table::kv_file_writer::build_physical_schema;
@@ -48,6 +49,9 @@ pub(crate) struct PostponeWriteConfig {
pub bucket: i32,
pub schema_id: i64,
pub target_file_size: i64,
+ pub target_file_row_num: i64,
+ pub primary_key_indices: Vec<usize>,
+ pub value_fields: Vec<DataField>,
pub file_compression: String,
pub file_compression_zstd_level: i32,
pub write_buffer_size: i64,
@@ -61,7 +65,7 @@ pub(crate) struct PostponeWriteConfig {
///
/// Streams data directly to a FormatFileWriter in arrival order (no
sort/dedup),
/// prepending `_SEQUENCE_NUMBER` and `_VALUE_KIND` columns to each batch.
-/// Rolls to a new file when `target_file_size` is reached.
+/// Rolls after a batch reaches the configured size or row limit.
pub(crate) struct PostponeFileWriter {
paths: DataFilePathFactory,
file_io: FileIO,
@@ -71,9 +75,7 @@ pub(crate) struct PostponeFileWriter {
current_index: Option<DataFileIndexWriter>,
current_file_name: Option<String>,
current_file_path: Option<DataFilePath>,
- current_row_count: i64,
- /// Sequence number at which the current file started.
- current_file_start_seq: i64,
+ current_stats: PostponeFileStats,
/// Timestamp captured when the current file was opened (used for
deterministic replay order).
current_file_creation_time: DateTime<Utc>,
written_files: Vec<DataFileMeta>,
@@ -100,8 +102,7 @@ impl PostponeFileWriter {
current_index: None,
current_file_name: None,
current_file_path: None,
- current_row_count: 0,
- current_file_start_seq: 0,
+ current_stats: PostponeFileStats::default(),
current_file_creation_time: Utc::now(),
written_files: Vec::new(),
created_paths: Vec::new(),
@@ -186,10 +187,12 @@ impl PostponeFileWriter {
index.write(&logical_batch)?;
}
self.next_sequence_number = end_seq + 1;
- self.current_row_count += num_rows as i64;
+ self.current_stats
+ .add_batch(batch, &self.config, start_seq, end_seq)?;
// Roll to a new file if target size is reached — close in background
if self.current_writer.as_ref().unwrap().num_bytes() as i64 >=
self.config.target_file_size
+ || self.current_stats.row_count >= self.config.target_file_row_num
{
self.roll_file();
}
@@ -235,6 +238,10 @@ impl PostponeFileWriter {
})??;
self.written_files.push(meta);
}
+ // Java replays postpone files by millisecond creation time, retaining
+ // manifest order for ties. Background closes must not reorder
arrivals.
+ self.written_files
+ .sort_by_key(|file| file.min_sequence_number);
self.created_paths.clear();
Ok(std::mem::take(&mut self.written_files))
}
@@ -255,10 +262,7 @@ impl PostponeFileWriter {
.file_index_options
.as_ref()
.map(|options| options.in_manifest_threshold);
- let row_count = self.current_row_count;
- let min_seq = self.current_file_start_seq;
- let max_seq = self.next_sequence_number - 1;
- self.current_row_count = 0;
+ let stats = std::mem::take(&mut self.current_stats);
let schema_id = self.config.schema_id;
// Capture creation_time from when the file was opened, not when the
async close finishes.
// Java's postpone compaction sorts by creationTime for replay order.
@@ -266,15 +270,7 @@ impl PostponeFileWriter {
self.in_flight_closes.spawn(async move {
let file_size = writer.close().await?.file_size as i64;
- let mut meta = build_meta(
- file_name,
- file_size,
- row_count,
- min_seq,
- max_seq,
- schema_id,
- creation_time,
- );
+ let mut meta = build_meta(file_name, file_size, stats, schema_id,
creation_time);
meta.external_path = location.external_path;
write_index(index, threshold, &file_io, &bucket_dir, &mut
meta).await?;
Ok(meta)
@@ -322,8 +318,7 @@ impl PostponeFileWriter {
self.current_index = index;
self.current_file_name = Some(file_name);
self.current_file_path = Some(location);
- self.current_row_count = 0;
- self.current_file_start_seq = self.next_sequence_number;
+ self.current_stats = PostponeFileStats::default();
self.current_file_creation_time = Utc::now();
Ok(())
}
@@ -335,19 +330,13 @@ impl PostponeFileWriter {
};
let file_name = self.current_file_name.take().unwrap();
let index = self.current_index.take();
- let row_count = self.current_row_count;
- self.current_row_count = 0;
+ let stats = std::mem::take(&mut self.current_stats);
let file_size = writer.close().await?.file_size as i64;
- let min_seq = self.current_file_start_seq;
- let max_seq = self.next_sequence_number - 1;
-
let mut meta = build_meta(
file_name,
file_size,
- row_count,
- min_seq,
- max_seq,
+ stats,
self.config.schema_id,
self.current_file_creation_time,
);
@@ -388,21 +377,72 @@ async fn write_index(
Ok(())
}
+/// Java KeyValueDataFileWriter retains the first/last keys even for unsorted
+/// postpone input. These bounds must still be decodable using the key schema.
+#[derive(Default)]
+struct PostponeFileStats {
+ row_count: i64,
+ delete_row_count: i64,
+ first_key: Vec<u8>,
+ last_key: Vec<u8>,
+ min_sequence_number: i64,
+ max_sequence_number: i64,
+}
+
+impl PostponeFileStats {
+ fn add_batch(
+ &mut self,
+ batch: &RecordBatch,
+ config: &PostponeWriteConfig,
+ min_seq: i64,
+ max_seq: i64,
+ ) -> Result<()> {
+ let key_at = |row| -> Result<Vec<u8>> {
+ Ok(BinaryRow::from_arrow(
+ batch,
+ row,
+ &config.primary_key_indices,
+ &config.value_fields,
+ )?
+ .to_serialized_bytes())
+ };
+ if self.row_count == 0 {
+ self.first_key = key_at(0)?;
+ self.min_sequence_number = min_seq;
+ }
+ self.last_key = key_at(batch.num_rows() - 1)?;
+ self.max_sequence_number = max_seq;
+ self.row_count += batch.num_rows() as i64;
+ if let Some(column) = batch.column_by_name(VALUE_KIND_FIELD_NAME) {
+ let kinds =
column.as_any().downcast_ref::<Int8Array>().ok_or_else(|| {
+ crate::Error::DataInvalid {
+ message: "_VALUE_KIND column must be Int8".into(),
+ source: None,
+ }
+ })?;
+ for kind in kinds.iter().flatten() {
+ if !RowKind::from_value(kind)?.is_add() {
+ self.delete_row_count += 1;
+ }
+ }
+ }
+ Ok(())
+ }
+}
+
fn build_meta(
file_name: String,
file_size: i64,
- row_count: i64,
- min_seq: i64,
- max_seq: i64,
+ stats: PostponeFileStats,
schema_id: i64,
creation_time: DateTime<Utc>,
) -> DataFileMeta {
DataFileMeta {
file_name,
file_size,
- row_count,
- min_key: EMPTY_SERIALIZED_ROW.clone(),
- max_key: EMPTY_SERIALIZED_ROW.clone(),
+ row_count: stats.row_count,
+ min_key: stats.first_key,
+ max_key: stats.last_key,
key_stats: BinaryTableStats::new(
EMPTY_SERIALIZED_ROW.clone(),
EMPTY_SERIALIZED_ROW.clone(),
@@ -413,13 +453,13 @@ fn build_meta(
EMPTY_SERIALIZED_ROW.clone(),
vec![],
),
- min_sequence_number: min_seq,
- max_sequence_number: max_seq,
+ min_sequence_number: stats.min_sequence_number,
+ max_sequence_number: stats.max_sequence_number,
schema_id,
level: 0,
extra_files: vec![],
creation_time: Some(creation_time),
- delete_row_count: Some(0),
+ delete_row_count: Some(stats.delete_row_count),
embedded_index: None,
file_source: Some(0), // FileSource.APPEND
value_stats_cols: Some(vec![]),
diff --git a/crates/paimon/src/table/postpone_fixed_bucket_router.rs
b/crates/paimon/src/table/postpone_fixed_bucket_router.rs
index 27b9cb88..56b2c2e2 100644
--- a/crates/paimon/src/table/postpone_fixed_bucket_router.rs
+++ b/crates/paimon/src/table/postpone_fixed_bucket_router.rs
@@ -65,6 +65,14 @@ impl PostponeFixedBucketRouter {
})
}
+ pub(crate) async fn load_existing_buckets(
+ &mut self,
+ table: &Table,
+ snapshot_id: i64,
+ ) -> Result<()> {
+ self.plan.load_existing_buckets(table, snapshot_id).await
+ }
+
pub(crate) fn route(&self, batch: &RecordBatch) ->
Result<Vec<PostponeBucketBatch>> {
let mut output = Vec::new();
for (partition, batch) in
diff --git a/crates/paimon/src/table/postpone_fixed_bucket_write.rs
b/crates/paimon/src/table/postpone_fixed_bucket_write.rs
index beb20bcc..32ba67fe 100644
--- a/crates/paimon/src/table/postpone_fixed_bucket_write.rs
+++ b/crates/paimon/src/table/postpone_fixed_bucket_write.rs
@@ -27,6 +27,10 @@ use arrow_array::RecordBatch;
pub struct PostponeFixedBucketTableWrite {
inner: TableWrite,
+ table: Table,
+ load_existing_buckets: bool,
+ failed: bool,
+ closed: bool,
router: PostponeFixedBucketRouter,
overwrite: bool,
check_from_snapshot: Option<i64>,
@@ -38,6 +42,7 @@ impl PostponeFixedBucketTableWrite {
table: &Table,
commit_user: String,
plan: PostponeBucketPlan,
+ load_existing_buckets: bool,
overwrite: bool,
resources: Option<ResourceContext>,
) -> Result<Self> {
@@ -47,6 +52,10 @@ impl PostponeFixedBucketTableWrite {
inner = inner.with_resources(resources);
}
Ok(Self {
+ table: table.clone(),
+ load_existing_buckets,
+ failed: false,
+ closed: false,
inner: if overwrite {
inner.with_overwrite()
} else {
@@ -61,11 +70,27 @@ impl PostponeFixedBucketTableWrite {
pub async fn write_arrow_batch(&mut self, batch: &RecordBatch) ->
Result<()> {
self.ensure_writable()?;
+ let result = self.write_batch(batch).await;
+ if result.is_err() {
+ // A rejected batch must never leave earlier batches committable.
+ self.failed = true;
+ self.inner.close().await;
+ }
+ result
+ }
+
+ async fn write_batch(&mut self, batch: &RecordBatch) -> Result<()> {
let Some(batch) = self.inner.normalize_write_batch(batch)? else {
return Ok(());
};
if self.check_from_snapshot.is_none() {
- self.check_from_snapshot =
Some(self.inner.pin_sequence_snapshot().await?);
+ let snapshot_id = self.inner.pin_sequence_snapshot().await?;
+ if self.load_existing_buckets {
+ self.router
+ .load_existing_buckets(&self.table, snapshot_id)
+ .await?;
+ }
+ self.check_from_snapshot = Some(snapshot_id);
}
for routed in self.router.route(&batch)? {
self.inner
@@ -96,7 +121,18 @@ impl PostponeFixedBucketTableWrite {
Ok(messages)
}
+ /// Discard unprepared files. Prepared messages and their files belong to
the caller.
+ pub async fn close(&mut self) {
+ self.closed = true;
+ self.inner.close().await;
+ }
+
fn ensure_writable(&self) -> Result<()> {
+ if self.closed || self.failed {
+ return Err(data_invalid(
+ "Fixed-bucket postpone TableWrite is closed or failed",
+ ));
+ }
if self.prepare_started {
return Err(data_invalid("Fixed-bucket postpone TableWrite only
supports one prepare_commit call; create a new writer for the next batch"));
}
@@ -675,8 +711,38 @@ mod tests {
.unwrap();
}
+ async fn assert_pending_and_fixed_files(table: &Table, pending_rows: i64,
fixed_rows: i64) {
+ let snapshot = table
+ .snapshot_manager()
+ .get_latest_snapshot()
+ .await
+ .unwrap()
+ .unwrap();
+ let entries = TableScan::new(table, None, vec![], None, None, None)
+ .with_scan_all_files()
+ .plan_manifest_entries(&snapshot)
+ .await
+ .unwrap();
+ assert_eq!(
+ entries
+ .iter()
+ .filter(|entry| entry.bucket() == -2)
+ .map(|entry| entry.file().row_count)
+ .sum::<i64>(),
+ pending_rows
+ );
+ assert_eq!(
+ entries
+ .iter()
+ .filter(|entry| entry.bucket() >= 0)
+ .map(|entry| entry.file().row_count)
+ .sum::<i64>(),
+ fixed_rows
+ );
+ }
+
#[tokio::test]
- async fn test_postpone_rejects_negative_bucket_after_fixed_layout() {
+ async fn test_postpone_preserves_negative_bucket_after_fixed_layout() {
let file_io = test_file_io();
let table_path =
"memory:/test_postpone_rejects_negative_bucket_after_fixed";
setup_dirs(&file_io, table_path).await;
@@ -700,17 +766,13 @@ mod tests {
.unwrap();
let messages = normal_write.prepare_commit().await.unwrap();
assert!(messages.iter().all(|message| message.bucket == -2));
- let error = normal_builder
- .new_commit()
- .commit(messages)
- .await
- .unwrap_err();
- assert!(error.to_string().contains("Cannot mix bucket=-2 files"));
+ normal_builder.new_commit().commit(messages).await.unwrap();
+ assert_pending_and_fixed_files(&table, 1, 1).await;
assert_eq!(read_id_value_rows(&table).await, vec![(1, 10)]);
}
#[tokio::test]
- async fn test_postpone_fixed_append_rejects_existing_negative_bucket() {
+ async fn test_postpone_fixed_append_preserves_existing_negative_bucket() {
let file_io = test_file_io();
let table_path =
"memory:/test_postpone_fixed_rejects_existing_negative_bucket";
setup_dirs(&file_io, table_path).await;
@@ -733,11 +795,12 @@ mod tests {
let fixed_messages =
write_fixed_batch(&table, "fixed-append", 1, &make_batch(vec![1],
vec![100])).await;
- let error = TableCommit::new(table.clone(), "fixed-append".to_string())
+ TableCommit::new(table.clone(), "fixed-append".to_string())
.commit(fixed_messages)
.await
- .unwrap_err();
- assert!(error.to_string().contains("Cannot mix bucket=-2 files"));
+ .unwrap();
+ assert_pending_and_fixed_files(&table, 4, 1).await;
+ assert_eq!(read_id_value_rows(&table).await, vec![(1, 100)]);
let overwrite_builder = table
.new_postpone_fixed_bucket_write_builder()
@@ -829,7 +892,7 @@ mod tests {
}
#[tokio::test]
- async fn test_postpone_fixed_commit_rejects_concurrent_negative_bucket() {
+ async fn test_postpone_fixed_commit_preserves_concurrent_negative_bucket()
{
let file_io = test_file_io();
let table_path =
"memory:/test_postpone_fixed_rejects_concurrent_negative_bucket";
setup_dirs(&file_io, table_path).await;
@@ -863,12 +926,13 @@ mod tests {
.await
.unwrap();
- let error = fixed_builder
+ fixed_builder
.new_commit()
.commit(fixed_messages)
.await
- .unwrap_err();
- assert!(error.to_string().contains("Cannot mix bucket=-2 files"));
+ .unwrap();
+ assert_pending_and_fixed_files(&table, 1, 1).await;
+ assert_eq!(read_id_value_rows(&table).await, vec![(1, 10)]);
}
#[tokio::test]
@@ -1008,4 +1072,254 @@ mod tests {
assert_eq!(messages[0].new_files[0].row_count, 1);
assert_eq!(messages[0].new_files[0].delete_row_count, Some(1));
}
+ fn with_default_count(table: &Table, count: &str) -> Table {
+ table.copy_with_options(std::collections::HashMap::from([(
+ "postpone.default-bucket-num".into(),
+ count.into(),
+ )]))
+ }
+
+ #[tokio::test]
+ async fn default_count_reuses_committed_layout_but_overwrite_replaces_it()
{
+ let io = test_file_io();
+ let path = "memory:/fixed_default_reuse";
+ setup_dirs(&io, path).await;
+ let table = with_default_count(&test_postpone_pk_table(&io, path),
"3");
+ for (count, overwrite, value, expected_count) in
+ [("3", false, 10, 3), ("5", false, 20, 3), ("5", true, 30, 5)]
+ {
+ let table = with_default_count(&table, count);
+ let mut builder =
table.new_postpone_fixed_bucket_write_builder().unwrap();
+ if overwrite {
+ builder = builder.with_overwrite();
+ }
+ let mut writer = builder.new_write().unwrap();
+ writer
+ .write_arrow_batch(&make_batch(vec![1], vec![value]))
+ .await
+ .unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ assert!(messages
+ .iter()
+ .all(|message| message.total_buckets == Some(expected_count)));
+ builder.new_commit().commit(messages).await.unwrap();
+ writer.close().await;
+ assert_eq!(read_id_value_rows(&table).await, vec![(1, value)]);
+ }
+ }
+
+ #[tokio::test]
+ async fn default_count_uses_one_snapshot_for_all_input_partitions() {
+ let io = test_file_io();
+ let path = "memory:/fixed_default_snapshot";
+ setup_dirs(&io, path).await;
+ let table = with_default_count(&test_postpone_partitioned_table(&io,
path), "3");
+ let builder = table.new_postpone_fixed_bucket_write_builder().unwrap();
+ let mut writer = builder.new_write().unwrap();
+ writer
+ .write_arrow_batch(&make_partitioned_batch_3col(vec!["a"],
vec![1], vec![10]))
+ .await
+ .unwrap();
+
+ // A newer snapshot must not change the plan of a writer that already
started.
+ let changed = with_default_count(&table, "5");
+ let concurrent =
changed.new_postpone_fixed_bucket_write_builder().unwrap();
+ let mut concurrent_writer = concurrent.new_write().unwrap();
+ concurrent_writer
+ .write_arrow_batch(&make_partitioned_batch_3col(vec!["b"],
vec![2], vec![20]))
+ .await
+ .unwrap();
+ concurrent
+ .new_commit()
+ .commit(concurrent_writer.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+
+ writer
+ .write_arrow_batch(&make_partitioned_batch_3col(vec!["b"],
vec![3], vec![30]))
+ .await
+ .unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ assert!(messages
+ .iter()
+ .all(|message| message.total_buckets == Some(3)
+ && message.check_from_snapshot == Some(0)));
+ let error = builder
+ .new_commit()
+ .commit(messages.clone())
+ .await
+ .unwrap_err();
+ assert!(error.to_string().contains("different total bucket counts"));
+ builder.new_commit().abort(&messages).await.unwrap();
+ }
+
+ #[tokio::test]
+ async fn fixed_plan_failure_poisoning_discards_earlier_batches() {
+ let io = test_file_io();
+ let path = "memory:/fixed_plan_failure";
+ setup_dirs(&io, path).await;
+ let table = with_default_count(&test_postpone_partitioned_table(&io,
path), "3");
+ let builder = table
+ .new_postpone_fixed_bucket_write_builder()
+ .unwrap()
+ .with_bucket_plan(make_partition_bucket_plan(&table, vec!["a"],
vec![2]));
+ let mut writer = builder.new_write().unwrap();
+ writer
+ .write_arrow_batch(&make_partitioned_batch_3col(vec!["a"],
vec![1], vec![10]))
+ .await
+ .unwrap();
+ let error = writer
+ .write_arrow_batch(&make_partitioned_batch_3col(
+ vec!["missing"],
+ vec![2],
+ vec![20],
+ ))
+ .await
+ .unwrap_err();
+ assert!(error
+ .to_string()
+ .contains("does not contain an input partition"));
+ assert!(writer
+ .prepare_commit()
+ .await
+ .unwrap_err()
+ .to_string()
+ .contains("closed or failed"));
+ assert!(writer
+ .write_arrow_batch(&make_partitioned_batch_3col(vec!["a"],
vec![3], vec![30]))
+ .await
+ .is_err());
+ writer.close().await;
+ assert!(table
+ .snapshot_manager()
+ .get_latest_snapshot()
+ .await
+ .unwrap()
+ .is_none());
+ }
+
+ #[tokio::test]
+ async fn fixed_writer_close_preserves_only_prepared_output() {
+ let io = test_file_io();
+ let path = "memory:/fixed_close_ownership";
+ setup_dirs(&io, path).await;
+ let table = with_default_count(&test_postpone_pk_table(&io, path),
"1");
+ let builder = table.new_postpone_fixed_bucket_write_builder().unwrap();
+ let mut discarded = builder.new_write().unwrap();
+ discarded
+ .write_arrow_batch(&make_batch(vec![1], vec![10]))
+ .await
+ .unwrap();
+ discarded.close().await;
+ discarded.close().await;
+ assert!(discarded
+ .prepare_commit()
+ .await
+ .unwrap_err()
+ .to_string()
+ .contains("closed or failed"));
+ let mut writer = builder.new_write().unwrap();
+ writer
+ .write_arrow_batch(&make_batch(vec![2], vec![20]))
+ .await
+ .unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ writer.close().await;
+ builder.new_commit().commit(messages).await.unwrap();
+ assert_eq!(read_id_value_rows(&table).await, vec![(2, 20)]);
+ }
+
+ #[tokio::test]
+ async fn default_count_ignores_pending_files_and_inference_options() {
+ let io = test_file_io();
+ let path = "memory:/fixed_default_pending";
+ setup_dirs(&io, path).await;
+ let table = with_default_count(&test_postpone_pk_table(&io, path),
"3").copy_with_options(
+ std::collections::HashMap::from([
+ ("postpone.target-row-num-per-bucket".into(), "0".into()),
+ ("postpone.target-size-per-bucket".into(), "invalid".into()),
+ (
+ "postpone.batch-write-fixed-bucket.max-parallelism".into(),
+ "1".into(),
+ ),
+ ]),
+ );
+ let pending_builder = table.new_write_builder();
+ let mut pending_writer = pending_builder.new_write().unwrap();
+ pending_writer
+ .write_arrow_batch(&make_batch(vec![1, 2, 3], vec![10, 20, 30]))
+ .await
+ .unwrap();
+ pending_builder
+ .new_commit()
+ .commit(pending_writer.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+ let builder = table.new_postpone_fixed_bucket_write_builder().unwrap();
+ let mut writer = builder.new_write().unwrap();
+ writer
+ .write_arrow_batch(&make_batch(vec![4], vec![40]))
+ .await
+ .unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ assert!(messages
+ .iter()
+ .all(|message| message.total_buckets == Some(3)
+ && message.check_from_snapshot == Some(1)));
+ builder.new_commit().commit(messages).await.unwrap();
+ assert_pending_and_fixed_files(&table, 3, 1).await;
+ assert_eq!(read_id_value_rows(&table).await, vec![(4, 40)]);
+ }
+
+ #[tokio::test]
+ async fn default_count_restores_level_zero_first_row_layout() {
+ let io = test_file_io();
+ let path = "memory:/fixed_default_first_row";
+ setup_dirs(&io, path).await;
+ let table = with_default_count(&test_postpone_pk_table(&io, path),
"3").copy_with_options(
+ std::collections::HashMap::from([("merge-engine".into(),
"first-row".into())]),
+ );
+ let first = table.new_postpone_fixed_bucket_write_builder().unwrap();
+ let mut writer = first.new_write().unwrap();
+ writer
+ .write_arrow_batch(&make_batch(vec![1], vec![10]))
+ .await
+ .unwrap();
+ first
+ .new_commit()
+ .commit(writer.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+ let changed = with_default_count(&table, "5");
+ let builder =
changed.new_postpone_fixed_bucket_write_builder().unwrap();
+ let mut writer = builder.new_write().unwrap();
+ writer
+ .write_arrow_batch(&make_batch(vec![2], vec![20]))
+ .await
+ .unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ assert!(messages
+ .iter()
+ .all(|message| message.total_buckets == Some(3)));
+ builder.new_commit().commit(messages).await.unwrap();
+ }
+
+ #[tokio::test]
+ async fn default_count_rejects_invalid_configuration_before_io() {
+ let io = test_file_io();
+ let table = test_postpone_pk_table(&io,
"memory:/invalid_default_bucket");
+ for raw in ["0", "-1", "2147483648", "3.5", "abc"] {
+ let changed = with_default_count(&table, raw);
+ let error = changed
+ .new_postpone_fixed_bucket_write_builder()
+ .unwrap()
+ .new_write()
+ .err()
+ .unwrap();
+ assert!(
+ error.to_string().contains("postpone.default-bucket-num"),
+ "{error}"
+ );
+ }
+ }
}
diff --git a/crates/paimon/src/table/postpone_fixed_bucket_write_builder.rs
b/crates/paimon/src/table/postpone_fixed_bucket_write_builder.rs
index 3439e155..bc1537cf 100644
--- a/crates/paimon/src/table/postpone_fixed_bucket_write_builder.rs
+++ b/crates/paimon/src/table/postpone_fixed_bucket_write_builder.rs
@@ -18,6 +18,7 @@
use super::postpone_bucket_plan::data_invalid;
use super::postpone_fixed_bucket_router::validate_postpone_fixed_bucket_table;
use crate::resource::ResourceContext;
+use crate::spec::CoreOptions;
use crate::table::write_builder::{ensure_table_write_allowed,
validate_commit_user};
use crate::table::{
PostponeBucketPlan, PostponeFixedBucketTableCommit,
PostponeFixedBucketTableWrite, Table,
@@ -82,14 +83,19 @@ impl<'a> PostponeFixedBucketWriteBuilder<'a> {
pub fn new_write(&self) -> Result<PostponeFixedBucketTableWrite> {
ensure_table_write_allowed(self.table)?;
- let plan = self
- .bucket_plan
- .clone()
- .ok_or_else(|| data_invalid("A resolved postpone bucket plan is
required"))?;
+ let load_existing_buckets = self.bucket_plan.is_none() &&
!self.overwrite;
+ let plan = match &self.bucket_plan {
+ Some(plan) => plan.clone(),
+ None => PostponeBucketPlan::with_default_bucket_num(
+
CoreOptions::new(self.table.schema().options()).postpone_default_bucket_num()?
+ .ok_or_else(|| data_invalid("A resolved postpone bucket
plan is required unless postpone.default-bucket-num is configured"))?,
+ ),
+ };
PostponeFixedBucketTableWrite::new(
self.table,
self.commit_user.clone(),
plan,
+ load_existing_buckets,
self.overwrite,
self.resources.clone(),
)
@@ -165,4 +171,26 @@ mod tests {
if message.contains("postpone fixed-bucket writes")
&& message.contains("deletion-vectors.enabled=true")));
}
+
+ #[tokio::test]
+ async fn default_bucket_count_routes_without_an_explicit_plan() {
+ let file_io = test_file_io();
+ let table_path = "memory:/default_postpone_bucket_count";
+ setup_dirs(&file_io, table_path).await;
+ let table = test_postpone_pk_table(&file_io,
table_path).copy_with_options(
+
std::collections::HashMap::from([("postpone.default-bucket-num".into(),
"3".into())]),
+ );
+ let builder = table.new_postpone_fixed_bucket_write_builder().unwrap();
+ let mut writer = builder.new_write().unwrap();
+ writer
+ .write_arrow_batch(&make_batch(vec![1, 2, 3, 4], vec![10, 20, 30,
40]))
+ .await
+ .unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ assert!(!messages.is_empty());
+ assert!(messages
+ .iter()
+ .all(|message| message.total_buckets == Some(3) &&
(0..3).contains(&message.bucket)));
+ builder.new_commit().commit(messages).await.unwrap();
+ }
}
diff --git a/crates/paimon/src/table/table_commit.rs
b/crates/paimon/src/table/table_commit.rs
index eef8c266..9b2f48e9 100644
--- a/crates/paimon/src/table/table_commit.rs
+++ b/crates/paimon/src/table/table_commit.rs
@@ -2379,7 +2379,6 @@ impl TableCommit {
all_entries.extend(delta_entries.iter().cloned());
let merged_entries = merge_active_entries(all_entries);
self.check_total_bucket_conflicts(&merged_entries)?;
- self.check_postpone_bucket_mixing(&merged_entries)?;
self.check_fixed_bucket_ownership_conflicts(
latest_snapshot,
delta_entries,
@@ -2457,15 +2456,6 @@ impl TableCommit {
if *entry.kind() != FileKind::Add {
continue;
}
- if entry.bucket() == POSTPONE_BUCKET &&
owned_partitions.contains(entry.partition())
- {
- return Err(crate::Error::DataInvalid {
- message: format!(
- "Postpone fixed-bucket writer conflict: another
commit wrote bucket=-2 files for the same partition after snapshot
{check_from_snapshot}"
- ),
- source: None,
- });
- }
if owned_buckets.contains(&(entry.partition(),
entry.bucket())) {
return Err(crate::Error::DataInvalid {
message: format!(
@@ -2480,31 +2470,6 @@ impl TableCommit {
Ok(())
}
- fn check_postpone_bucket_mixing(&self, active_entries: &[ManifestEntry])
-> Result<()> {
- if self.total_buckets != POSTPONE_BUCKET {
- return Ok(());
- }
- let fixed_partitions = active_entries
- .iter()
- .filter(|entry| {
- *entry.kind() == FileKind::Add && entry.bucket() >= 0 &&
entry.total_buckets() > 0
- })
- .map(|entry| entry.partition())
- .collect::<HashSet<_>>();
- if active_entries.iter().any(|entry| {
- *entry.kind() == FileKind::Add
- && entry.bucket() == POSTPONE_BUCKET
- && fixed_partitions.contains(entry.partition())
- }) {
- return Err(crate::Error::DataInvalid {
- message: "Cannot mix bucket=-2 files and fixed buckets in the
same partition; use a complete overwrite to migrate the partition"
- .to_string(),
- source: None,
- });
- }
- Ok(())
- }
-
fn check_total_bucket_conflicts(&self, entries: &[ManifestEntry]) ->
Result<()> {
let mut bucket_counts: HashMap<Vec<u8>, i32> = HashMap::new();
for entry in entries {
diff --git a/crates/paimon/src/table/table_write.rs
b/crates/paimon/src/table/table_write.rs
index 3325a9c6..814b5c46 100644
--- a/crates/paimon/src/table/table_write.rs
+++ b/crates/paimon/src/table/table_write.rs
@@ -29,7 +29,7 @@ use crate::spec::{
DataType, MergeEngine, RowKind, RowKindFilter, EMPTY_SERIALIZED_ROW,
POSTPONE_BUCKET,
VALUE_KIND_FIELD_NAME,
};
-use crate::table::bucket_assigner::{BucketAssignerEnum, PartitionBucketKey};
+use crate::table::bucket_assigner::{BatchAssignOutput, BucketAssignerEnum,
PartitionBucketKey};
use crate::table::bucket_assigner_constant::ConstantBucketAssigner;
use crate::table::bucket_assigner_cross::CrossPartitionAssigner;
use crate::table::bucket_assigner_dynamic::DynamicBucketAssigner;
@@ -406,10 +406,19 @@ impl TableWrite {
BucketAssignerEnum::CrossPartition(Box::new(CrossPartitionAssigner::new(
table.clone(),
partition_field_indices,
- primary_key_indices.clone(),
+ schema
+ .primary_keys()
+ .iter()
+ .map(|name| {
+ fields
+ .iter()
+ .position(|field| field.name() == name)
+ .unwrap()
+ })
+ .collect(),
target_bucket_row_number,
merge_engine,
- )))
+ )?))
} else if is_dynamic_bucket {
BucketAssignerEnum::Dynamic(Box::new(DynamicBucketAssigner::new(
table.clone(),
@@ -773,13 +782,11 @@ impl TableWrite {
let fields = self.table.schema().fields().to_vec();
let output = self.bucket_assigner.assign_batch(batch, &fields).await?;
-
+ if matches!(self.bucket_assigner,
BucketAssignerEnum::CrossPartition(_)) {
+ return self.divide_cross_partition_batch(batch, output);
+ }
let mut groups: HashMap<PartitionBucketKey, Vec<usize>> =
HashMap::new();
- let skip_set: HashSet<usize> = output.skips.into_iter().collect();
for row_idx in 0..batch.num_rows() {
- if skip_set.contains(&row_idx) {
- continue;
- }
groups
.entry((
output.partition_bytes[row_idx].clone(),
@@ -788,52 +795,73 @@ impl TableWrite {
.or_default()
.push(row_idx);
}
+ groups
+ .into_iter()
+ .map(|(key, rows)| Ok((key, take_rows(batch, &rows)?)))
+ .collect()
+ }
- let mut result = Vec::with_capacity(groups.len());
- let batch_has_value_kind = batch
- .schema()
- .column_with_name(VALUE_KIND_FIELD_NAME)
- .is_some();
- // Cross-partition writers must always include _VALUE_KIND to keep the
- // Arrow schema stable across batches (some batches may have deletes,
- // others may not — KeyValueFileWriter's concat_batches requires a
- // consistent schema).
- let needs_value_kind = batch_has_value_kind
- || matches!(self.bucket_assigner,
BucketAssignerEnum::CrossPartition(_))
- || !output.deletes.is_empty();
- for (key, row_indices) in groups {
- let sub_batch = take_rows(batch, &row_indices)?;
- let sub_batch = if matches!(self.bucket_assigner,
BucketAssignerEnum::CrossPartition(_))
- {
- self.with_partition_values(&sub_batch, &key.0)?
- } else {
- sub_batch
- };
- let sub_batch = if needs_value_kind && !batch_has_value_kind {
- Self::add_value_kind_column(&sub_batch, 0)?
- } else {
- sub_batch
- };
- result.push((key, sub_batch));
- }
-
- if !output.deletes.is_empty() {
- let mut delete_groups: HashMap<PartitionBucketKey, Vec<usize>> =
HashMap::new();
- for (row_idx, old_partition, old_bucket) in &output.deletes {
- delete_groups
- .entry((old_partition.clone(), *old_bucket))
- .or_default()
- .push(*row_idx);
+ /// Preserve Java GlobalIndexAssigner's DELETE-before-input event order
+ /// within each bucket, including migrations back to an earlier partition.
+ fn divide_cross_partition_batch(
+ &self,
+ batch: &RecordBatch,
+ output: BatchAssignOutput,
+ ) -> Result<Vec<(PartitionBucketKey, RecordBatch)>> {
+ let mut groups: HashMap<PartitionBucketKey, (Vec<usize>, Vec<i8>)> =
HashMap::new();
+ let skips: HashSet<usize> = output.skips.into_iter().collect();
+ let mut deletes = output.deletes.into_iter().peekable();
+ let kinds = batch.column_by_name(VALUE_KIND_FIELD_NAME).map(|column| {
+ column
+ .as_any()
+ .downcast_ref::<arrow_array::Int8Array>()
+ .unwrap()
+ });
+ for row_idx in 0..batch.num_rows() {
+ if skips.contains(&row_idx) {
+ continue;
}
- for (key, row_indices) in delete_groups {
- let sub_batch = take_rows(batch, &row_indices)?;
- let sub_batch = self.with_partition_values(&sub_batch,
&key.0)?;
- let delete_batch = Self::add_value_kind_column(&sub_batch, 3)?;
- result.push((key, delete_batch));
+ if deletes
+ .peek()
+ .is_some_and(|(index, _, _)| *index == row_idx)
+ {
+ let (_, partition, bucket) = deletes.next().unwrap();
+ let (rows, kinds) = groups.entry((partition,
bucket)).or_default();
+ rows.push(row_idx);
+ kinds.push(RowKind::Delete.to_value());
}
+ let key = (
+ output.partition_bytes[row_idx].clone(),
+ output.buckets[row_idx],
+ );
+ let (rows, row_kinds) = groups.entry(key).or_default();
+ rows.push(row_idx);
+ row_kinds.push(kinds.map_or(RowKind::Insert.to_value(), |kinds|
kinds.value(row_idx)));
}
-
- Ok(result)
+ // Generated row kinds are replaced below; migration DELETEs must not
+ // append a second _VALUE_KIND column to a rowkind.field batch.
+ let data = if let Some((index, _)) =
batch.schema().column_with_name(VALUE_KIND_FIELD_NAME)
+ {
+ batch
+ .project(
+ &(0..batch.num_columns())
+ .filter(|i| *i != index)
+ .collect::<Vec<_>>(),
+ )
+ .map_err(|error| crate::Error::DataInvalid {
+ message: format!("Failed to project cross-partition input:
{error}"),
+ source: Some(Box::new(error)),
+ })?
+ } else {
+ batch.clone()
+ };
+ groups
+ .into_iter()
+ .map(|(key, (rows, kinds))| {
+ let routed = self.with_partition_values(&take_rows(&data,
&rows)?, &key.0)?;
+ Ok((key, Self::add_per_row_value_kind_column(&routed, kinds)?))
+ })
+ .collect()
}
/// Keep physical partition columns consistent with the routed partition.
@@ -867,30 +895,6 @@ impl TableWrite {
})
}
- /// Add a `_VALUE_KIND` column to a batch with the given value for all
rows.
- fn add_value_kind_column(batch: &RecordBatch, value_kind: i8) ->
Result<RecordBatch> {
- use arrow_array::Int8Array;
- use arrow_schema::{DataType as ArrowDataType, Field as ArrowField};
-
- let vk_array = Arc::new(Int8Array::from(vec![value_kind;
batch.num_rows()]));
- let vk_field = Arc::new(ArrowField::new(
- crate::spec::VALUE_KIND_FIELD_NAME,
- ArrowDataType::Int8,
- false,
- ));
-
- let mut fields = batch.schema().fields().to_vec();
- let mut columns: Vec<Arc<dyn arrow_array::Array>> =
batch.columns().to_vec();
- fields.push(vk_field);
- columns.push(vk_array);
-
- let schema = Arc::new(arrow_schema::Schema::new(fields));
- RecordBatch::try_new(schema, columns).map_err(|e|
crate::Error::DataInvalid {
- message: format!("Failed to add _VALUE_KIND column: {e}"),
- source: None,
- })
- }
-
fn enrich_rowkind_batch(&self, batch: &RecordBatch) -> Result<RecordBatch>
{
let Some(generator) = &self.row_kind_generator else {
return self.filter_rowkind_batch(batch);
@@ -1224,6 +1228,9 @@ impl TableWrite {
bucket,
schema_id: self.schema_id,
target_file_size: self.target_file_size,
+ target_file_row_num: self.target_file_row_num,
+ primary_key_indices: self.primary_key_indices.clone(),
+ value_fields: self.table.schema().fields().to_vec(),
file_compression: self.file_compression.clone(),
file_compression_zstd_level:
self.file_compression_zstd_level,
write_buffer_size: self.write_buffer_size,
@@ -4542,9 +4549,21 @@ pub(in crate::table) mod tests {
"Postpone mode should preserve arrival order"
);
- // Empty key stats for postpone mode
- assert_eq!(file.min_key, EMPTY_SERIALIZED_ROW.clone());
- assert_eq!(file.max_key, EMPTY_SERIALIZED_ROW.clone());
+ // Java records the first and last keys without sorting postpone input.
+ assert_eq!(
+ BinaryRow::from_serialized_bytes(&file.min_key)
+ .unwrap()
+ .get_int(0)
+ .unwrap(),
+ 3
+ );
+ assert_eq!(
+ BinaryRow::from_serialized_bytes(&file.max_key)
+ .unwrap()
+ .get_int(0)
+ .unwrap(),
+ 2
+ );
}
// -----------------------------------------------------------------------
diff --git a/crates/paimon/tests/cross_partition_write_test.rs
b/crates/paimon/tests/cross_partition_write_test.rs
new file mode 100644
index 00000000..36b82988
--- /dev/null
+++ b/crates/paimon/tests/cross_partition_write_test.rs
@@ -0,0 +1,323 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+mod common;
+
+use arrow_array::{ArrayRef, Int32Array, RecordBatch, StringArray};
+use common::incremental_helpers::{memory_table, persist_table_schema,
setup_dirs, write_batch};
+use futures::TryStreamExt;
+use paimon::spec::{DataType, IntType, Schema, TableSchema, VarCharType};
+use paimon::table::Table;
+use std::sync::Arc;
+
+async fn table(partition_in_key: bool, options: &[(&str, &str)]) -> Table {
+ let mut schema = Schema::builder()
+ .column("tenant", DataType::VarChar(VarCharType::string_type()))
+ .column("p", DataType::VarChar(VarCharType::string_type()))
+ .column("id", DataType::Int(IntType::new()))
+ .column("v", DataType::Int(IntType::new()))
+ .partition_keys(["tenant", "p"])
+ .primary_key(if partition_in_key {
+ vec!["tenant", "id"]
+ } else {
+ vec!["id"]
+ })
+ .option("bucket", "-1");
+ if options.iter().any(|(key, _)| *key == "rowkind.field") {
+ schema = schema.column("op",
DataType::VarChar(VarCharType::string_type()));
+ }
+ for (key, value) in options {
+ schema = schema.option(*key, *value);
+ }
+ let schema = TableSchema::new(0, &schema.build().unwrap());
+ let (io, table) = memory_table("memory:/cross_partition_write", schema);
+ setup_dirs(&io, table.location()).await;
+ persist_table_schema(&io, table.location(), table.schema()).await;
+ table
+}
+
+fn batch(rows: &[(&str, &str, i32, i32)]) -> RecordBatch {
+ RecordBatch::try_from_iter([
+ (
+ "tenant",
+ Arc::new(StringArray::from_iter_values(rows.iter().map(|r| r.0)))
as ArrayRef,
+ ),
+ (
+ "p",
+ Arc::new(StringArray::from_iter_values(rows.iter().map(|r| r.1)))
as ArrayRef,
+ ),
+ (
+ "id",
+ Arc::new(Int32Array::from_iter_values(rows.iter().map(|r| r.2)))
as ArrayRef,
+ ),
+ (
+ "v",
+ Arc::new(Int32Array::from_iter_values(rows.iter().map(|r| r.3)))
as ArrayRef,
+ ),
+ ])
+ .unwrap()
+}
+
+async fn rows(table: &Table) -> Vec<(String, String, i32, i32)> {
+ let read = table.new_read_builder();
+ let plan = read.new_scan().with_scan_all_files().plan().await.unwrap();
+ let batches: Vec<RecordBatch> = read
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ let mut rows = Vec::new();
+ for batch in batches {
+ let tenant = batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<StringArray>()
+ .unwrap();
+ let p = batch
+ .column(1)
+ .as_any()
+ .downcast_ref::<StringArray>()
+ .unwrap();
+ let id = batch
+ .column(2)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ let v = batch
+ .column(3)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ rows.extend((0..batch.num_rows()).map(|i| {
+ (
+ tenant.value(i).into(),
+ p.value(i).into(),
+ id.value(i),
+ v.value(i),
+ )
+ }));
+ }
+ rows.sort();
+ rows
+}
+
+#[tokio::test]
+async fn
repeated_migrations_keep_the_last_input_even_when_returning_to_a_partition() {
+ for chunk_size in [1, 2, 3, 4] {
+ let table = table(false, &[]).await;
+ let input = [
+ ("t", "a", 1, 10),
+ ("t", "b", 1, 20),
+ ("t", "a", 1, 30),
+ ("t", "a", 1, 40),
+ ];
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ for chunk in input.chunks(chunk_size) {
+ writer.write_arrow_batch(&batch(chunk)).await.unwrap();
+ }
+ builder
+ .new_commit()
+ .commit(writer.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+ assert_eq!(
+ rows(&table).await,
+ vec![("t".into(), "a".into(), 1, 40)],
+ "chunk_size={chunk_size}"
+ );
+ }
+}
+
+#[tokio::test]
+async fn global_index_keeps_partition_columns_of_composite_primary_keys() {
+ let table = table(true, &[]).await;
+ write_batch(&table, &batch(&[("x", "a", 1, 10), ("y", "a", 1, 20)])).await;
+ assert_eq!(
+ rows(&table).await,
+ vec![
+ ("x".into(), "a".into(), 1, 10),
+ ("y".into(), "a".into(), 1, 20)
+ ]
+ );
+ // Reconstructing the global index must retain both tenants too.
+ write_batch(&table, &batch(&[("x", "b", 1, 30)])).await;
+ assert_eq!(
+ rows(&table).await,
+ vec![
+ ("x".into(), "b".into(), 1, 30),
+ ("y".into(), "a".into(), 1, 20)
+ ]
+ );
+}
+
+#[tokio::test]
+async fn dynamic_overwrite_keeps_global_index_for_migration_deletes() {
+ let table = table(false, &[]).await;
+ write_batch(&table, &batch(&[("t", "a", 1, 10), ("t", "c", 2, 20)])).await;
+ let builder = table.new_write_builder().with_overwrite();
+ let mut writer = builder.new_write().unwrap();
+ writer
+ .write_arrow_batch(&batch(&[("t", "b", 1, 30)]))
+ .await
+ .unwrap();
+ builder
+ .new_commit()
+ .overwrite(writer.prepare_commit().await.unwrap(), None)
+ .await
+ .unwrap();
+ assert_eq!(
+ rows(&table).await,
+ vec![
+ ("t".into(), "b".into(), 1, 30),
+ ("t".into(), "c".into(), 2, 20)
+ ]
+ );
+ write_batch(&table, &batch(&[("t", "d", 1, 40)])).await;
+ assert_eq!(
+ rows(&table).await,
+ vec![
+ ("t".into(), "c".into(), 2, 20),
+ ("t".into(), "d".into(), 1, 40)
+ ]
+ );
+}
+
+#[tokio::test]
+async fn merge_engines_route_updates_like_java_after_restart() {
+ for engine in ["deduplicate", "first-row", "partial-update",
"aggregation"] {
+ for restart in [false, true] {
+ let mut options = vec![("merge-engine", engine)];
+ if engine == "aggregation" {
+ options.push(("fields.v.aggregate-function", "sum"));
+ }
+ let table = table(true, &options).await;
+ let first = batch(&[("x", "a", 1, 10), ("y", "a", 1, 100)]);
+ let second = batch(&[("x", "b", 1, 20), ("y", "b", 1, 200)]);
+ if restart {
+ write_batch(&table, &first).await;
+ write_batch(&table, &second).await;
+ } else {
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ writer.write_arrow_batch(&first).await.unwrap();
+ writer.write_arrow_batch(&second).await.unwrap();
+ builder
+ .new_commit()
+ .commit(writer.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+ }
+ let (partition, x, y) = match engine {
+ "deduplicate" => ("b", 20, 200),
+ "first-row" => ("a", 10, 100),
+ "partial-update" => ("a", 20, 200),
+ "aggregation" => ("a", 30, 300),
+ _ => unreachable!(),
+ };
+ assert_eq!(
+ rows(&table).await,
+ vec![
+ ("x".into(), partition.into(), 1, x),
+ ("y".into(), partition.into(), 1, y)
+ ],
+ "engine={engine}, restart={restart}"
+ );
+ }
+ }
+}
+
+fn with_kinds(rows: &[(&str, &str, i32, i32)], kinds: &[&str]) -> RecordBatch {
+ let data = batch(rows);
+ let mut fields = data.schema().fields().to_vec();
+ fields.push(Arc::new(arrow_schema::Field::new(
+ "op",
+ arrow_schema::DataType::Utf8,
+ true,
+ )));
+ let mut columns = data.columns().to_vec();
+ columns.push(Arc::new(StringArray::from(kinds.to_vec())));
+ RecordBatch::try_new(Arc::new(arrow_schema::Schema::new(fields)),
columns).unwrap()
+}
+
+#[tokio::test]
+async fn generated_row_kinds_keep_delete_and_update_order_during_migrations() {
+ let table = table(false, &[("rowkind.field", "op")]).await;
+ write_batch(&table, &with_kinds(&[("t", "a", 1, 10)], &["+I"])).await;
+ write_batch(
+ &table,
+ &with_kinds(
+ &[
+ ("t", "b", 1, 20),
+ ("t", "a", 1, 30),
+ ("t", "b", 2, 40),
+ ("t", "c", 2, 50),
+ ],
+ &["+U", "+U", "+I", "-D"],
+ ),
+ )
+ .await;
+ assert_eq!(rows(&table).await, vec![("t".into(), "a".into(), 1, 30)]);
+ // The deleted key must not reappear when the global index is rebuilt.
+ write_batch(&table, &with_kinds(&[("t", "c", 2, 60)], &["+I"])).await;
+ assert_eq!(
+ rows(&table).await,
+ vec![
+ ("t".into(), "a".into(), 1, 30),
+ ("t".into(), "c".into(), 2, 60)
+ ]
+ );
+}
+
+#[tokio::test]
+async fn bootstrap_rejects_duplicate_global_keys_before_writing() {
+ let table = table(false, &[]).await;
+ // Simulate existing files with the same full primary key in two
partitions.
+ // Such a table cannot supply an unambiguous global location on restart.
+ let fixed = table.copy_with_options([("bucket".into(),
"1".into())].into());
+ write_batch(&fixed, &batch(&[("t", "a", 1, 10), ("t", "b", 1, 20)])).await;
+ let mut writer = table.new_write_builder().new_write().unwrap();
+ let error = writer
+ .write_arrow_batch(&batch(&[("t", "c", 1, 30)]))
+ .await
+ .unwrap_err();
+ assert!(
+ error.to_string().contains("Duplicate primary key"),
+ "{error}"
+ );
+ assert!(writer.prepare_commit().await.is_err());
+ assert_eq!(rows(&table).await.len(), 2);
+}
+
+#[tokio::test]
+async fn cross_partition_rejects_unsupported_index_configuration() {
+ let table = table(false, &[]).await;
+ for (key, value, message) in [
+ ("bucket-key", "id", "Cannot define 'bucket-key'"),
+ ("sequence.field", "v", "Cannot define 'sequence.field'"),
+ ("cross-partition-upsert.index-ttl", "1 h", "index-ttl"),
+ ] {
+ let configured = table.copy_with_options([(key.into(),
value.into())].into());
+ match configured.new_write_builder().new_write() {
+ Ok(_) => panic!("should reject {key}"),
+ Err(error) => assert!(error.to_string().contains(message),
"{error}"),
+ }
+ }
+}
diff --git a/crates/paimon/tests/postpone_native_write_test.rs
b/crates/paimon/tests/postpone_native_write_test.rs
new file mode 100644
index 00000000..1063e104
--- /dev/null
+++ b/crates/paimon/tests/postpone_native_write_test.rs
@@ -0,0 +1,137 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+mod common;
+
+use arrow_array::{Array, Int32Array, Int64Array, Int8Array};
+use common::incremental_helpers::{make_batch_with_kinds, memory_table,
pk_schema, setup_dirs};
+use paimon::spec::{BinaryRow, POSTPONE_BUCKET};
+use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
+
+#[tokio::test]
+async fn postpone_rolls_by_batch_row_limit_and_preserves_unsorted_retracts() {
+ let (io, table) = memory_table(
+ "memory:/postpone_row_limit",
+ pk_schema(&[("bucket", "-2"), ("target-file-row-num", "3")]),
+ );
+ setup_dirs(&io, table.location()).await;
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ let ids = [5, 1, 3, 2, 1];
+ let kinds = [0, 1, 2, 3, 0];
+ for range in [0..2, 2..4, 4..5] {
+ writer
+ .write_arrow_batch(&make_batch_with_kinds(
+ ids[range.clone()].to_vec(),
+ vec![10; range.len()],
+ kinds[range].to_vec(),
+ ))
+ .await
+ .unwrap();
+ }
+ let messages = writer.prepare_commit().await.unwrap();
+ assert_eq!(messages.len(), 1);
+ assert_eq!(messages[0].bucket, POSTPONE_BUCKET);
+ let files = messages[0].new_files.clone();
+ assert_eq!(
+ files.iter().map(|f| f.row_count).collect::<Vec<_>>(),
+ vec![4, 1]
+ );
+ assert_eq!(
+ files.iter().map(|f| f.delete_row_count).collect::<Vec<_>>(),
+ vec![Some(2), Some(0)]
+ );
+ assert_eq!(
+ files
+ .iter()
+ .map(|f| (f.min_sequence_number, f.max_sequence_number))
+ .collect::<Vec<_>>(),
+ vec![(0, 3), (4, 4)]
+ );
+ let bounds: Vec<_> = files
+ .iter()
+ .map(|file| {
+ (
+ BinaryRow::from_serialized_bytes(&file.min_key)
+ .unwrap()
+ .get_int(0)
+ .unwrap(),
+ BinaryRow::from_serialized_bytes(&file.max_key)
+ .unwrap()
+ .get_int(0)
+ .unwrap(),
+ )
+ })
+ .collect();
+ // Java records the first and last arriving keys for pending files.
+ assert_eq!(bounds, vec![(5, 2), (1, 1)]);
+ let mut actual = Vec::new();
+ for file in files {
+ let path = format!("{}/bucket-postpone/{}", table.location(),
file.file_name);
+ let bytes = io.new_input(&path).unwrap().read().await.unwrap();
+ let reader = ParquetRecordBatchReaderBuilder::try_new(bytes)
+ .unwrap()
+ .build()
+ .unwrap();
+ for batch in reader {
+ let batch = batch.unwrap();
+ let seq = batch
+ .column_by_name("_SEQUENCE_NUMBER")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .unwrap();
+ let kind = batch
+ .column_by_name("_VALUE_KIND")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int8Array>()
+ .unwrap();
+ let id = batch
+ .column_by_name("id")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ actual
+ .extend((0..batch.num_rows()).map(|i| (seq.value(i),
kind.value(i), id.value(i))));
+ }
+ }
+ assert_eq!(
+ actual,
+ vec![(0, 0, 5), (1, 1, 1), (2, 2, 3), (3, 3, 2), (4, 0, 1)]
+ );
+}
+
+#[tokio::test]
+async fn postpone_delete_counts_reset_after_prepare_commit() {
+ let (io, table) = memory_table(
+ "memory:/postpone_delete_counts",
+ pk_schema(&[("bucket", "-2")]),
+ );
+ setup_dirs(&io, table.location()).await;
+ let mut writer = table.new_write_builder().new_write().unwrap();
+ for (kinds, expected) in [(vec![0, 3, 1], 2), (vec![0, 2], 0)] {
+ let len = kinds.len();
+ writer
+ .write_arrow_batch(&make_batch_with_kinds(vec![1; len], vec![10;
len], kinds))
+ .await
+ .unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ assert_eq!(messages[0].new_files[0].delete_row_count, Some(expected));
+ }
+}