JingsongLi commented on code in PR #659:
URL: https://github.com/apache/paimon-rust/pull/659#discussion_r3719132961


##########
crates/paimon/src/table/table_commit.rs:
##########
@@ -1853,6 +1857,7 @@ impl TableCommit {
         check_from_snapshot: Option<i64>,
     ) -> Result<()> {
         self.check_delete_entries_against_base(base_entries, delta_entries)?;
+        self.check_total_bucket_conflicts(base_entries, delta_entries)?;

Review Comment:
   This bucket-count check runs unconditionally before considering 
`commit_kind`, and it compares raw base and delta entries. An overwrite 
contains DELETE entries for the old layout followed by ADD entries for the 
replacement layout, so a valid rescale such as 1 -> 3 buckets is rejected as a 
conflict. The upstream Java implementation skips the old-layout consistency 
check for `OVERWRITE`, and PyPaimon has 
`test_postpone_overwrite_allows_bucket_rescale`. Please continue checking that 
all new ADD entries in the delta agree, but compare them with the base layout 
only for non-overwrite commits (or apply ADD/DELETE changes before checking the 
final active entries).



##########
crates/paimon/src/table/postpone_batch_table_write.rs:
##########
@@ -0,0 +1,370 @@
+// 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.
+
+//! One-shot fixed-bucket planning for batch writes to postpone tables.
+//!
+//! This mirrors pypaimon's `PostponeFixedBucketBatchTableWrite`: partitions
+//! with an existing real-bucket count stream directly to their writers, while
+//! new partitions are buffered until `prepare_commit` can infer one bucket
+//! count from the complete batch.
+
+use crate::spec::{
+    batch_to_serialized_bytes, BucketFunctionType, CoreOptions, DataField, 
EMPTY_SERIALIZED_ROW,
+    POSTPONE_BUCKET,
+};
+use crate::table::bucket_function::{batch_bucket_ids, 
validate_bucket_function};
+use crate::table::postpone_bucket::binary_row_batch_size;
+use crate::table::{SnapshotManager, Table, TableScan};
+use crate::Result;
+use arrow_array::{RecordBatch, UInt32Array};
+use std::collections::HashMap;
+
+pub(super) struct PostponeBucketBatch {
+    pub(super) partition: Vec<u8>,
+    pub(super) bucket: i32,
+    pub(super) batch: RecordBatch,
+}
+
+/// Planning state for a single fixed-bucket batch write to a postpone table.
+pub(super) struct PostponeFixedBucketWriter {
+    partition_field_indices: Vec<usize>,
+    bucket_key_indices: Vec<usize>,
+    bucket_function_type: BucketFunctionType,
+    max_parallelism: i32,
+    target_rows_per_bucket: Option<i64>,
+    target_size_per_bucket: i64,
+    metadata_loaded: bool,
+    known_bucket_counts: HashMap<Vec<u8>, i32>,
+    postpone_row_counts: HashMap<Vec<u8>, i64>,
+    buffered_batches: HashMap<Vec<u8>, Vec<RecordBatch>>,
+    /// Bucket counts used by this prepare-commit round.
+    bucket_counts: HashMap<Vec<u8>, i32>,
+    prepare_started: bool,
+}
+
+impl PostponeFixedBucketWriter {
+    pub(super) fn new(
+        table: &Table,
+        partition_field_indices: Vec<usize>,
+        bucket_key_indices: Vec<usize>,
+        bucket_function_type: BucketFunctionType,
+    ) -> Result<Self> {
+        let schema = table.schema();
+        let options = CoreOptions::new(schema.options());
+        let total_buckets = options.bucket();
+        if total_buckets != POSTPONE_BUCKET || 
schema.primary_keys().is_empty() {
+            return Err(crate::Error::Unsupported {
+                message: format!(
+                    "Postpone fixed-bucket writes require a primary-key table 
with bucket=-2, but table '{}' has bucket={total_buckets}",
+                    table.identifier().full_name()
+                ),
+            });
+        }
+        if options.deletion_vectors_enabled() {
+            return Err(crate::Error::Unsupported {
+                message: format!(
+                    "Table '{}' cannot use postpone fixed-bucket writes with 
deletion-vectors.enabled=true because deletion-vector scans skip the level-0 
files produced by batch writers; use the normal postpone writer or disable 
deletion vectors",
+                    table.identifier().full_name()
+                ),
+            });
+        }
+
+        let bucket_key_fields: Vec<DataField> = bucket_key_indices
+            .iter()
+            .map(|&index| schema.fields()[index].clone())
+            .collect();
+        if !bucket_key_fields.is_empty() {
+            validate_bucket_function(bucket_function_type, 
&bucket_key_fields)?;
+        }
+
+        Ok(Self {
+            partition_field_indices,
+            bucket_key_indices,
+            bucket_function_type,
+            max_parallelism: 
options.postpone_batch_write_fixed_bucket_max_parallelism()?,
+            target_rows_per_bucket: 
options.postpone_target_row_num_per_bucket()?,
+            target_size_per_bucket: options.postpone_target_size_per_bucket()?,
+            metadata_loaded: false,
+            known_bucket_counts: HashMap::new(),
+            postpone_row_counts: HashMap::new(),
+            buffered_batches: HashMap::new(),
+            bucket_counts: HashMap::new(),
+            prepare_started: false,
+        })
+    }
+
+    pub(super) fn ensure_writable(&self) -> Result<()> {
+        if self.prepare_started {
+            return Err(Self::one_shot_error());
+        }
+        Ok(())
+    }
+
+    pub(super) fn start_prepare(&mut self) -> Result<()> {
+        self.ensure_writable()?;
+        // A failed prepare may already have consumed buffered batches or
+        // closed file writers, so the same writer cannot be retried safely.
+        self.prepare_started = true;
+        Ok(())
+    }
+
+    pub(super) async fn write_batch(
+        &mut self,
+        table: &Table,
+        batch: &RecordBatch,
+    ) -> Result<Vec<PostponeBucketBatch>> {
+        self.ensure_metadata_loaded(table).await?;
+
+        let partitions = if self.partition_field_indices.is_empty() {
+            vec![EMPTY_SERIALIZED_ROW.clone(); batch.num_rows()]
+        } else {
+            batch_to_serialized_bytes(
+                batch,
+                &self.partition_field_indices,
+                table.schema().fields(),
+            )?
+        };
+
+        let mut groups: HashMap<Vec<u8>, Vec<usize>> = HashMap::new();
+        for (row, partition) in partitions.into_iter().enumerate() {
+            groups.entry(partition).or_default().push(row);
+        }
+
+        let mut output = Vec::new();
+        for (partition, rows) in groups {
+            let sub_batch = take_rows(batch, &rows)?;
+            if let Some(total_buckets) = 
self.known_bucket_counts.get(&partition).copied() {
+                self.bucket_counts.insert(partition.clone(), total_buckets);
+                output.extend(self.route_batch(table, partition, sub_batch, 
total_buckets)?);
+            } else {
+                self.buffered_batches
+                    .entry(partition)
+                    .or_default()
+                    .push(sub_batch);
+            }
+        }
+        Ok(output)
+    }
+
+    pub(super) async fn prepare_batch(
+        &mut self,
+        table: &Table,
+        is_overwrite: bool,
+    ) -> Result<Vec<PostponeBucketBatch>> {
+        if self.buffered_batches.is_empty() {
+            return Ok(Vec::new());
+        }
+
+        let buffered_batches = std::mem::take(&mut self.buffered_batches);
+        let mut output = Vec::new();
+        for (partition, batches) in buffered_batches {
+            let input_rows = batches.iter().fold(0_i64, |rows, batch| {
+                rows.saturating_add(batch.num_rows() as i64)
+            });
+            // Match pypaimon: row-count planning does not inspect row sizes.
+            // Size planning ignores the trailing internal `_VALUE_KIND` field
+            // appended by TableWrite after row-kind generation.
+            let input_size = if self.target_rows_per_bucket.is_none() {
+                batches.iter().try_fold(0_i64, |size, batch| {
+                    Ok::<_, crate::Error>(
+                        size.saturating_add(binary_row_batch_size(batch, 
table.schema().fields())?),
+                    )
+                })?
+            } else {
+                0
+            };
+            let postpone_rows = if is_overwrite {
+                0
+            } else {
+                self.postpone_row_counts
+                    .get(&partition)
+                    .copied()
+                    .unwrap_or(0)
+            };
+            let total_buckets = infer_bucket_count(

Review Comment:
   Each `TableWrite` derives `total_buckets` from only its local buffered rows. 
However, the C API supports merging messages from multiple fixed-bucket writers 
that share a `commit_user`, and there is no way to provide those writers with 
one precomputed bucket plan. Two workers writing the same new partition can 
therefore infer different counts and make the whole commit fail; even when they 
infer the same count, the plan is based on shard-local rather than global batch 
statistics and can under-bucket the partition. PyPaimon avoids this by 
aggregating partition statistics on the driver and injecting the same 
`PostponeBucketPlan` into every worker. Please expose and validate a 
precomputed `partition -> total_buckets` plan in the Rust builder/writer and C 
API, or explicitly reject this multi-writer mode.



##########
crates/paimon/src/table/postpone_batch_table_write.rs:
##########
@@ -0,0 +1,370 @@
+// 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.
+
+//! One-shot fixed-bucket planning for batch writes to postpone tables.
+//!
+//! This mirrors pypaimon's `PostponeFixedBucketBatchTableWrite`: partitions
+//! with an existing real-bucket count stream directly to their writers, while
+//! new partitions are buffered until `prepare_commit` can infer one bucket
+//! count from the complete batch.
+
+use crate::spec::{
+    batch_to_serialized_bytes, BucketFunctionType, CoreOptions, DataField, 
EMPTY_SERIALIZED_ROW,
+    POSTPONE_BUCKET,
+};
+use crate::table::bucket_function::{batch_bucket_ids, 
validate_bucket_function};
+use crate::table::postpone_bucket::binary_row_batch_size;
+use crate::table::{SnapshotManager, Table, TableScan};
+use crate::Result;
+use arrow_array::{RecordBatch, UInt32Array};
+use std::collections::HashMap;
+
+pub(super) struct PostponeBucketBatch {
+    pub(super) partition: Vec<u8>,
+    pub(super) bucket: i32,
+    pub(super) batch: RecordBatch,
+}
+
+/// Planning state for a single fixed-bucket batch write to a postpone table.
+pub(super) struct PostponeFixedBucketWriter {
+    partition_field_indices: Vec<usize>,
+    bucket_key_indices: Vec<usize>,
+    bucket_function_type: BucketFunctionType,
+    max_parallelism: i32,
+    target_rows_per_bucket: Option<i64>,
+    target_size_per_bucket: i64,
+    metadata_loaded: bool,
+    known_bucket_counts: HashMap<Vec<u8>, i32>,
+    postpone_row_counts: HashMap<Vec<u8>, i64>,
+    buffered_batches: HashMap<Vec<u8>, Vec<RecordBatch>>,
+    /// Bucket counts used by this prepare-commit round.
+    bucket_counts: HashMap<Vec<u8>, i32>,
+    prepare_started: bool,
+}
+
+impl PostponeFixedBucketWriter {
+    pub(super) fn new(
+        table: &Table,
+        partition_field_indices: Vec<usize>,
+        bucket_key_indices: Vec<usize>,
+        bucket_function_type: BucketFunctionType,
+    ) -> Result<Self> {
+        let schema = table.schema();
+        let options = CoreOptions::new(schema.options());
+        let total_buckets = options.bucket();
+        if total_buckets != POSTPONE_BUCKET || 
schema.primary_keys().is_empty() {
+            return Err(crate::Error::Unsupported {
+                message: format!(
+                    "Postpone fixed-bucket writes require a primary-key table 
with bucket=-2, but table '{}' has bucket={total_buckets}",
+                    table.identifier().full_name()
+                ),
+            });
+        }
+        if options.deletion_vectors_enabled() {
+            return Err(crate::Error::Unsupported {
+                message: format!(
+                    "Table '{}' cannot use postpone fixed-bucket writes with 
deletion-vectors.enabled=true because deletion-vector scans skip the level-0 
files produced by batch writers; use the normal postpone writer or disable 
deletion vectors",
+                    table.identifier().full_name()
+                ),
+            });
+        }
+
+        let bucket_key_fields: Vec<DataField> = bucket_key_indices
+            .iter()
+            .map(|&index| schema.fields()[index].clone())
+            .collect();
+        if !bucket_key_fields.is_empty() {
+            validate_bucket_function(bucket_function_type, 
&bucket_key_fields)?;
+        }
+
+        Ok(Self {
+            partition_field_indices,
+            bucket_key_indices,
+            bucket_function_type,
+            max_parallelism: 
options.postpone_batch_write_fixed_bucket_max_parallelism()?,
+            target_rows_per_bucket: 
options.postpone_target_row_num_per_bucket()?,
+            target_size_per_bucket: options.postpone_target_size_per_bucket()?,

Review Comment:
   `postpone.target-size-per-bucket` is parsed and validated even when 
`postpone.target-row-num-per-bucket` is configured. The option contract says 
that the size target is ignored in that case, and the PyPaimon planner only 
reads it in the `row target is None` branch. With a valid row target plus an 
invalid or zero size target, Rust currently rejects writer creation even though 
the size value is unused. Please parse and validate the size target only when 
no row-count target is present.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to