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 4595e4b4 Support distributed dynamic bucket input execution (#1208)
4595e4b4 is described below

commit 4595e4b410b0c3e7ebd34b3b068d895bc2d51b0f
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Oct 23 19:33:47 2026 +0800

    Support distributed dynamic bucket input execution (#1208)
---
 bindings/python/README.md                          |  24 +
 .../python/python/pypaimon_rust/datafusion.pyi     |  31 +
 bindings/python/src/context.rs                     |   2 +
 bindings/python/src/write_routing.rs               | 226 +++++++-
 crates/paimon/src/table/bucket_assigner.rs         |   4 +-
 crates/paimon/src/table/bucket_router.rs           |  64 +-
 crates/paimon/src/table/dynamic_bucket_assigner.rs | 642 +++++++++++++++++++++
 .../src/table/dynamic_bucket_assigner_router.rs    | 464 +++++++++++++++
 ...assigner_dynamic.rs => dynamic_bucket_index.rs} | 339 ++++++++++-
 crates/paimon/src/table/mod.rs                     |   8 +-
 crates/paimon/src/table/table_write.rs             |   4 +-
 11 files changed, 1748 insertions(+), 60 deletions(-)

diff --git a/bindings/python/README.md b/bindings/python/README.md
index 981474f7..fc9cc1d2 100644
--- a/bindings/python/README.md
+++ b/bindings/python/README.md
@@ -367,6 +367,30 @@ Statistics contain partition columns followed by 
`row_count: int64` and
 may coincide with statistics names. Existing layouts remain unchanged. The
 shared result can configure every worker with `with_bucket_plan(plan)`.
 
+For HASH_DYNAMIC primary-key inputs (`bucket=-1`), route rows to assigners
+before allocating buckets:
+
+```python
+from pypaimon_rust.datafusion import DynamicBucketAssigner, 
DynamicBucketAssignerRouter
+
+routing = DynamicBucketAssignerRouter(table.schema(), num_channels=4, 
num_assigners=4).route(batch)
+# Group rows and key_hash by routing's assigner column, then on that worker:
+assigner = DynamicBucketAssigner(table, num_channels=4, num_assigners=4, 
assign_id=worker_id,
+                                restore_snapshot_id=base_snapshot_id)
+buckets = assigner.assign(worker_batch, worker_key_hashes)
+```
+
+The assigner captures the latest write snapshot when none is supplied; zero
+selects an empty base. `overwrite=True` starts with empty assignments. Every 
worker
+must use the same baseline, available as `assigner.restore_snapshot_id`.
+Configure the eventual file writers with that restore snapshot and the same
+overwrite mode.
+Assignment reads HASH indexes without creating files. Regroup its output by
+partition and bucket before writing, so one writer publishes the complete
+HASH index for each bucket. Key hashes can be omitted for Core to compute
+them; when supplied, the batch only needs partition columns. Existing keys
+retain their original buckets even when assigner parallelism changes.
+
 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 9be54bfa..073f583d 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -891,6 +891,37 @@ class BucketRouter:
     def with_bucket_plan(self, plan: pyarrow.RecordBatch) -> BucketRouter: ...
     def route(self, batch: pyarrow.RecordBatch) -> pyarrow.Int32Array: ...
 
+class DynamicBucketAssignerRouter:
+    """Pure Java HASH_DYNAMIC routing before grouping input by assigner."""
+    def __init__(self, schema: TableSchema, num_channels: int, num_assigners: 
int) -> None: ...
+    def route(self, batch: pyarrow.RecordBatch) -> pyarrow.RecordBatch:
+        """Return non-null assigner and key_hash Int32 columns in input 
order."""
+        ...
+
+class DynamicBucketAssigner:
+    """Stateful bucket assignment for distributed HASH_DYNAMIC inputs.
+
+    Existing mappings remain stable across assigner parallelism changes.
+    New keys use buckets owned by this assigner. Input must already be
+    grouped by the DynamicBucketAssignerRouter's assigner column.
+    """
+    def __init__(self, table: Table, num_channels: int, num_assigners: int, 
assign_id: int,
+                 *, restore_snapshot_id: Optional[int] = None, overwrite: bool 
= False) -> None:
+        """Capture one write-authorized restore point; None is latest and zero 
is empty.
+
+        Overwrite starts empty. This stage creates no data or index files;
+        group its output by partition and bucket before writing.
+        """
+        ...
+    @property
+    def restore_snapshot_id(self) -> int:
+        """Captured restore snapshot; zero denotes an empty base."""
+        ...
+    def assign(self, batch: pyarrow.RecordBatch,
+               key_hashes: Optional[pyarrow.Int32Array] = None) -> 
pyarrow.Int32Array:
+        """Return buckets in input order, optionally reusing routed key 
hashes."""
+        ...
+
 class PostponeBucketPlanner:
     """Pure per-partition statistics and bucket-count inference.
 
diff --git a/bindings/python/src/context.rs b/bindings/python/src/context.rs
index c1179061..10a952fd 100644
--- a/bindings/python/src/context.rs
+++ b/bindings/python/src/context.rs
@@ -1040,6 +1040,8 @@ pub fn register_module(py: Python<'_>, m: &Bound<'_, 
PyModule>) -> PyResult<()>
     this.add_class::<crate::write::PyBatchWriteBuilder>()?;
     this.add_class::<crate::write::PyPostponeFixedBucketWriteBuilder>()?;
     this.add_class::<crate::write_routing::PyBucketRouter>()?;
+    this.add_class::<crate::write_routing::PyDynamicBucketAssignerRouter>()?;
+    this.add_class::<crate::write_routing::PyDynamicBucketAssigner>()?;
     this.add_class::<crate::write_routing::PyPostponeBucketPlanner>()?;
     this.add_class::<crate::write::PyStreamWriteBuilder>()?;
     this.add_class::<crate::write::PyBatchTableWrite>()?;
diff --git a/bindings/python/src/write_routing.rs 
b/bindings/python/src/write_routing.rs
index 0eb20ceb..f053051c 100644
--- a/bindings/python/src/write_routing.rs
+++ b/bindings/python/src/write_routing.rs
@@ -17,15 +17,23 @@
 
 //! Arrow transport for pure Core input routing and bucket planning.
 
-use crate::arrow_input::write_batch_from_pyarrow;
-use crate::error::to_py_err;
+use crate::arrow_input::{array_from_pyarrow, write_batch_from_pyarrow};
+use crate::error::{to_catalog_py_err, to_py_err};
 use crate::schema::PyTableSchema;
-use arrow::array::Array;
+use crate::table::PyTable;
+use arrow::array::{Array, Int32Array};
+use arrow::datatypes::{DataType, Field, Schema};
 use arrow::pyarrow::{FromPyArrow, ToPyArrow};
 use arrow::record_batch::RecordBatch;
 use paimon::spec::TableSchema;
-use paimon::table::{BucketRouter, PostponeBucketPlan, PostponeBucketPlanner};
+use paimon::table::{
+    BucketRouter, DynamicBucketAssigner, DynamicBucketAssignerRouter, 
PostponeBucketPlan,
+    PostponeBucketPlanner,
+};
+use paimon_datafusion::runtime::runtime;
+use pyo3::exceptions::PyTypeError;
 use pyo3::prelude::*;
+use std::sync::Arc;
 
 #[pyclass(name = "BucketRouter", module = "pypaimon_rust.datafusion")]
 pub struct PyBucketRouter {
@@ -65,6 +73,107 @@ impl PyBucketRouter {
     }
 }
 
+#[pyclass(
+    name = "DynamicBucketAssignerRouter",
+    module = "pypaimon_rust.datafusion"
+)]
+pub struct PyDynamicBucketAssignerRouter {
+    inner: DynamicBucketAssignerRouter,
+}
+
+#[pymethods]
+impl PyDynamicBucketAssignerRouter {
+    #[new]
+    fn new(schema: &PyTableSchema, num_channels: i32, num_assigners: i32) -> 
PyResult<Self> {
+        Ok(Self {
+            inner: DynamicBucketAssignerRouter::new(&schema.inner, 
num_channels, num_assigners)
+                .map_err(to_py_err)?,
+        })
+    }
+
+    fn route(&self, py: Python<'_>, batch: &Bound<'_, PyAny>) -> 
PyResult<Py<PyAny>> {
+        let batch = write_batch_from_pyarrow(batch)?;
+        let routing = py.detach(|| 
self.inner.route(&batch)).map_err(to_py_err)?;
+        RecordBatch::try_new(
+            Arc::new(Schema::new(vec![
+                Field::new("assigner", DataType::Int32, false),
+                Field::new("key_hash", DataType::Int32, false),
+            ])),
+            vec![Arc::new(routing.assigners), Arc::new(routing.key_hashes)],
+        )
+        .map_err(|error| PyTypeError::new_err(error.to_string()))?
+        .to_pyarrow(py)
+        .map(Bound::unbind)
+    }
+}
+
+#[pyclass(name = "DynamicBucketAssigner", module = "pypaimon_rust.datafusion")]
+pub struct PyDynamicBucketAssigner {
+    inner: DynamicBucketAssigner,
+}
+
+#[pymethods]
+impl PyDynamicBucketAssigner {
+    #[new]
+    #[pyo3(signature = (table, num_channels, num_assigners, assign_id, *, 
restore_snapshot_id=None, overwrite=false))]
+    fn new(
+        py: Python<'_>,
+        table: &PyTable,
+        num_channels: i32,
+        num_assigners: i32,
+        assign_id: i32,
+        restore_snapshot_id: Option<i64>,
+        overwrite: bool,
+    ) -> PyResult<Self> {
+        let builder =
+            DynamicBucketAssigner::builder(&table.inner, num_channels, 
num_assigners, assign_id);
+        let builder = match restore_snapshot_id {
+            Some(id) => builder
+                .with_restore_snapshot(id)
+                .map_err(to_catalog_py_err)?,
+            None => builder,
+        };
+        let builder = if overwrite {
+            builder.with_overwrite()
+        } else {
+            builder
+        };
+        let inner = py
+            .detach(|| runtime().block_on(builder.build()))
+            .map_err(to_catalog_py_err)?;
+        Ok(Self { inner })
+    }
+
+    #[getter]
+    fn restore_snapshot_id(&self) -> i64 {
+        self.inner.restore_snapshot_id()
+    }
+
+    #[pyo3(signature = (batch, key_hashes=None))]
+    fn assign(
+        &mut self,
+        py: Python<'_>,
+        batch: &Bound<'_, PyAny>,
+        key_hashes: Option<&Bound<'_, PyAny>>,
+    ) -> PyResult<Py<PyAny>> {
+        let batch = write_batch_from_pyarrow(batch)?;
+        let hashes = key_hashes.map(array_from_pyarrow).transpose()?;
+        let hashes = hashes
+            .as_ref()
+            .map(|hashes| {
+                hashes
+                    .as_any()
+                    .downcast_ref::<Int32Array>()
+                    .ok_or_else(|| PyTypeError::new_err("key_hashes must be an 
Int32Array"))
+            })
+            .transpose()?;
+        let buckets = py
+            .detach(|| runtime().block_on(self.inner.assign(&batch, hashes)))
+            .map_err(to_catalog_py_err)?;
+        buckets.to_data().to_pyarrow(py).map(Bound::unbind)
+    }
+}
+
 #[pyclass(name = "PostponeBucketPlanner", module = "pypaimon_rust.datafusion")]
 pub struct PyPostponeBucketPlanner {
     schema: TableSchema,
@@ -125,6 +234,115 @@ mod tests {
     use pyo3::ffi::c_str;
     use pyo3::types::{PyDict, PyModule};
 
+    #[test]
+    fn dynamic_routing_and_assignment_preserve_arrow_rows_and_write_baseline() 
{
+        use paimon::catalog::Identifier;
+        use paimon::io::FileIOBuilder;
+        use paimon::table::Table;
+
+        Python::attach(|py| {
+            let schema = Schema::builder()
+                .column("pt", DataType::VarChar(VarCharType::string_type()))
+                .column("id", DataType::Int(IntType::new()))
+                .column("payload", 
DataType::VarChar(VarCharType::string_type()))
+                .partition_keys(["pt"])
+                .primary_key(["pt", "id"])
+                .option("bucket", "-1")
+                .option("dynamic-bucket.target-row-num", "1")
+                .build()
+                .unwrap();
+            let table = Arc::new(Table::new(
+                FileIOBuilder::new("memory").build().unwrap(),
+                Identifier::new("default", "dynamic_arrow_binding"),
+                "memory:/dynamic_arrow_binding".into(),
+                TableSchema::new(0, &schema),
+                None,
+            ));
+            let locals = PyDict::new(py);
+            locals
+                .set_item(
+                    "table",
+                    Py::new(py, crate::table::PyTable::new(table)).unwrap(),
+                )
+                .unwrap();
+            locals
+                .set_item(
+                    "DynamicBucketAssignerRouter",
+                    py.get_type::<PyDynamicBucketAssignerRouter>(),
+                )
+                .unwrap();
+            locals
+                .set_item(
+                    "DynamicBucketAssigner",
+                    py.get_type::<PyDynamicBucketAssigner>(),
+                )
+                .unwrap();
+            py.run(
+                c_str!(
+                    r#"
+import pyarrow as pa
+router = DynamicBucketAssignerRouter(table.schema(), 1, 1)
+batch = pa.record_batch([
+    pa.array(['unused', 'a', 'a', 'a', 'unused']),
+    pa.array([0, 10, 20, 10, 0], type=pa.int32()),
+    pa.array(['x', 'v1', 'v2', 'v3', 'x']),
+], names=['pt', 'id', 'payload']).slice(1, 3)
+routing = router.route(batch)
+assert routing.schema.names == ['assigner', 'key_hash']
+assert routing.column(0).to_pylist() == [0, 0, 0]
+hashes = routing.column(1)
+assert hashes.type == pa.int32() and hashes.null_count == 0
+assert hashes[0] == hashes[2] and hashes[0] != hashes[1]
+
+builder = table.new_batch_write_builder().with_restore_snapshot(0)
+assert not hasattr(builder, 'new_dynamic_bucket_assigner')
+assigner = DynamicBucketAssigner(table, 1, 1, 0, restore_snapshot_id=0)
+assert assigner.restore_snapshot_id == 0
+try:
+    DynamicBucketAssigner(table, 1, 1, 0, restore_snapshot_id=-1)
+except ValueError:
+    pass
+else:
+    raise AssertionError('negative restore snapshots must be rejected')
+for invalid in [pa.array([None, 1, 2], type=pa.int32()), hashes.slice(0, 2),
+                pa.array([1, 2, 3], type=pa.int64())]:
+    try:
+        assigner.assign(batch, invalid)
+    except (ValueError, TypeError):
+        pass
+    else:
+        raise AssertionError('invalid hash transport must fail before 
assignment')
+buckets = assigner.assign(batch, hashes)
+assert buckets.to_pylist() == [0, 1, 0]
+assert assigner.assign(batch.slice(1), hashes.slice(1)).to_pylist() == [1, 0]
+assert DynamicBucketAssigner(table, 1, 1, 0, 
restore_snapshot_id=0).assign(batch).equals(buckets)
+
+writer = builder.new_write()
+writer.write_arrow(batch.take(pa.array([0, 2])), bucket=0)
+writer.write_arrow(batch.slice(1, 1), bucket=1)
+messages = writer.prepare_commit()
+builder.new_commit().commit(messages)
+
+restored = DynamicBucketAssigner(table, 1, 1, 0)
+assert restored.restore_snapshot_id == 1
+assert restored.assign(batch, hashes).equals(buckets)
+# A captured empty base stays empty after publication of a newer snapshot.
+assert assigner.restore_snapshot_id == 0
+empty = DynamicBucketAssigner(table, 1, 1, 0, restore_snapshot_id=0)
+assert empty.restore_snapshot_id == 0
+assert empty.assign(batch.slice(1, 1)).to_pylist() == [0]
+overwritten = DynamicBucketAssigner(table, 1, 1, 0, overwrite=True)
+assert overwritten.restore_snapshot_id == 0
+assert overwritten.assign(batch.slice(1, 1)).to_pylist() == [0]
+"#
+                ),
+                Some(&locals),
+                Some(&locals),
+            )
+            .unwrap();
+        });
+    }
+
     #[test]
     fn routing_and_planning_use_typed_arrow_inputs_without_table_access() {
         Python::attach(|py| {
diff --git a/crates/paimon/src/table/bucket_assigner.rs 
b/crates/paimon/src/table/bucket_assigner.rs
index 64abf810..ca42f352 100644
--- a/crates/paimon/src/table/bucket_assigner.rs
+++ b/crates/paimon/src/table/bucket_assigner.rs
@@ -24,8 +24,8 @@ use crate::io::FileIO;
 use crate::spec::{DataField, IndexFileMeta};
 use crate::table::bucket_assigner_constant::ConstantBucketAssigner;
 use crate::table::bucket_assigner_cross::CrossPartitionAssigner;
-use crate::table::bucket_assigner_dynamic::DynamicBucketAssigner;
 use crate::table::bucket_assigner_fixed::FixedBucketAssigner;
+use crate::table::dynamic_bucket_index::DynamicBucketIndex;
 use crate::Result;
 use arrow_array::RecordBatch;
 use std::collections::HashMap;
@@ -66,7 +66,7 @@ pub(crate) trait BucketAssigner: Send {
 pub(crate) enum BucketAssignerEnum {
     Constant(ConstantBucketAssigner),
     Fixed(FixedBucketAssigner),
-    Dynamic(Box<DynamicBucketAssigner>),
+    Dynamic(Box<DynamicBucketIndex>),
     CrossPartition(Box<CrossPartitionAssigner>),
 }
 
diff --git a/crates/paimon/src/table/bucket_router.rs 
b/crates/paimon/src/table/bucket_router.rs
index af1f581c..38b8c0c1 100644
--- a/crates/paimon/src/table/bucket_router.rs
+++ b/crates/paimon/src/table/bucket_router.rs
@@ -28,7 +28,7 @@ use crate::spec::{
 };
 use crate::table::PostponeBucketPlan;
 use crate::Result;
-use arrow_array::{Int32Array, RecordBatch};
+use arrow_array::{Int32Array, RecordBatch, RecordBatchOptions};
 use arrow_schema::SchemaRef;
 use std::collections::{HashMap, HashSet};
 
@@ -146,31 +146,7 @@ impl BucketRouter {
         &self,
         batch: &RecordBatch,
     ) -> Result<(Vec<Vec<u8>>, Int32Array)> {
-        let input_schema = batch.schema();
-        let mut names = HashSet::new();
-        for field in input_schema.fields() {
-            if !names.insert(field.name()) {
-                return Err(data_invalid(format!(
-                    "Duplicate input column '{}' in bucket routing",
-                    field.name()
-                )));
-            }
-        }
-        let columns = self
-            .fields
-            .iter()
-            .zip(self.arrow_schema.fields())
-            .map(|(field, expected)| {
-                let index = input_schema.index_of(field.name()).map_err(|_| {
-                    data_invalid(format!("Missing routing column '{}'", 
field.name()))
-                })?;
-                normalize_write_array(batch.column(index), 
expected.data_type())
-            })
-            .collect::<Result<Vec<_>>>()?;
-        // Project by name before applying indices resolved from the table
-        // schema. Unused payloads do not participate in routing validation.
-        let input = RecordBatch::try_new(self.arrow_schema.clone(), columns)
-            .map_err(|error| data_invalid(error.to_string()))?;
+        let input = project_routing_input(batch, &self.fields, 
&self.arrow_schema)?;
         let partitions = batch_to_serialized_bytes(&input, 
&self.partition_indices, &self.fields)?;
         if self.total_buckets > 0 {
             let buckets = self.route_count(&input, self.total_buckets)?;
@@ -212,6 +188,42 @@ impl BucketRouter {
     }
 }
 
+/// Resolve required columns once, keeping unrelated payloads out of 
validation.
+pub(super) fn project_routing_input(
+    batch: &RecordBatch,
+    fields: &[DataField],
+    arrow_schema: &SchemaRef,
+) -> Result<RecordBatch> {
+    let input_schema = batch.schema();
+    let mut names = HashSet::new();
+    for field in input_schema.fields() {
+        if !names.insert(field.name()) {
+            return Err(data_invalid(format!(
+                "Duplicate input column '{}' in bucket routing",
+                field.name()
+            )));
+        }
+    }
+    let columns = fields
+        .iter()
+        .zip(arrow_schema.fields())
+        .map(|(field, expected)| {
+            let index = input_schema
+                .index_of(field.name())
+                .map_err(|_| data_invalid(format!("Missing routing column 
'{}'", field.name())))?;
+            normalize_write_array(batch.column(index), expected.data_type())
+        })
+        .collect::<Result<Vec<_>>>()?;
+    // Empty projections still represent the original number of rows, for
+    // example when upstream hashes route an unpartitioned input.
+    RecordBatch::try_new_with_options(
+        arrow_schema.clone(),
+        columns,
+        &RecordBatchOptions::new().with_row_count(Some(batch.num_rows())),
+    )
+    .map_err(|error| data_invalid(error.to_string()))
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
diff --git a/crates/paimon/src/table/dynamic_bucket_assigner.rs 
b/crates/paimon/src/table/dynamic_bucket_assigner.rs
new file mode 100644
index 00000000..c4979ba4
--- /dev/null
+++ b/crates/paimon/src/table/dynamic_bucket_assigner.rs
@@ -0,0 +1,642 @@
+// 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.
+
+//! Snapshot-bound dynamic bucket assignment before distributed file writing.
+
+use std::collections::{HashMap, HashSet};
+
+use arrow_array::{Int32Array, RecordBatch};
+
+use super::dynamic_bucket_assigner_router::DynamicBucketAssignerRouter;
+use super::dynamic_bucket_index::DynamicBucketIndex;
+use super::write_builder::ensure_table_write_authorized;
+use crate::spec::{CoreOptions, PartitionComputer};
+use crate::table::Table;
+use crate::Result;
+
+/// Assign dynamic buckets using the same key ownership and bucket allocation
+/// as Java's HashBucketAssigner. This stage produces no files or commit
+/// messages. The final table writer maintains complete HASH index files after
+/// inputs have been regrouped by their assigned partition and bucket.
+pub struct DynamicBucketAssigner {
+    router: DynamicBucketAssignerRouter,
+    index: DynamicBucketIndex,
+    num_assigners: i32,
+    assign_id: i32,
+    restore_snapshot_id: i64,
+}
+
+/// Configuration for a distributed dynamic-bucket assigner.
+///
+/// The restore snapshot is captured by [`Self::build`]. It is independent of
+/// writer configuration and must also be supplied to the eventual file 
writers.
+pub struct DynamicBucketAssignerBuilder<'a> {
+    table: &'a Table,
+    num_channels: i32,
+    num_assigners: i32,
+    assign_id: i32,
+    restore_snapshot_id: Option<i64>,
+    overwrite: bool,
+}
+
+impl DynamicBucketAssignerBuilder<'_> {
+    /// Restore a specific snapshot. Zero denotes an empty restore state;
+    /// without this option, [`Self::build`] captures the latest snapshot.
+    pub fn with_restore_snapshot(mut self, snapshot_id: i64) -> Result<Self> {
+        if snapshot_id < 0 {
+            return Err(crate::Error::DataInvalid {
+                message: "Restore snapshot id must not be negative".into(),
+                source: None,
+            });
+        }
+        self.restore_snapshot_id = Some(snapshot_id);
+        Ok(self)
+    }
+
+    /// Assign against empty state, ignoring any configured restore snapshot.
+    pub fn with_overwrite(mut self) -> Self {
+        self.overwrite = true;
+        self
+    }
+
+    /// Authorize and capture the immutable restore point used by this 
assigner.
+    pub async fn build(self) -> Result<DynamicBucketAssigner> {
+        DynamicBucketAssigner::new(
+            self.table,
+            self.num_channels,
+            self.num_assigners,
+            self.assign_id,
+            self.restore_snapshot_id,
+            self.overwrite,
+        )
+        .await
+    }
+}
+
+impl DynamicBucketAssigner {
+    /// Configure the dynamic-bucket assignment stage separately from file 
writing.
+    pub fn builder(
+        table: &Table,
+        num_channels: i32,
+        num_assigners: i32,
+        assign_id: i32,
+    ) -> DynamicBucketAssignerBuilder<'_> {
+        DynamicBucketAssignerBuilder {
+            table,
+            num_channels,
+            num_assigners,
+            assign_id,
+            restore_snapshot_id: None,
+            overwrite: false,
+        }
+    }
+
+    async fn new(
+        table: &Table,
+        num_channels: i32,
+        num_assigners: i32,
+        assign_id: i32,
+        restore_snapshot_id: Option<i64>,
+        overwrite: bool,
+    ) -> Result<Self> {
+        ensure_table_write_authorized(table)?;
+        let router = DynamicBucketAssignerRouter::new(table.schema(), 
num_channels, num_assigners)?;
+        if !(0..num_channels).contains(&assign_id) {
+            return Err(crate::Error::DataInvalid {
+                message: format!("assign_id must be in [0, {num_channels}), 
got {assign_id}"),
+                source: None,
+            });
+        }
+        let schema = table.schema();
+        let fields = schema.fields();
+        let indices = |names: &[String]| {
+            names
+                .iter()
+                .map(|name| {
+                    fields
+                        .iter()
+                        .position(|field| field.name() == name)
+                        .unwrap()
+                })
+                .collect::<Vec<_>>()
+        };
+        let options = CoreOptions::new(schema.options());
+        let partition_computer = PartitionComputer::new(
+            schema.partition_keys(),
+            fields,
+            options.partition_default_name(),
+            options.legacy_partition_name(),
+        )?;
+        let mut index = DynamicBucketIndex::new(
+            table.clone(),
+            indices(schema.partition_keys()),
+            indices(&schema.trimmed_primary_keys()),
+            overwrite,
+            partition_computer,
+        )?;
+        let manager = table.snapshot_manager();
+        let snapshot = match (overwrite, restore_snapshot_id) {
+            (true, _) | (_, Some(0)) => None,
+            (_, Some(id)) => Some(manager.get_snapshot(id).await?),
+            (_, None) => manager.get_latest_snapshot().await?,
+        };
+        let restore_snapshot_id = snapshot.as_ref().map_or(0, |snapshot| 
snapshot.id());
+        index.pin_restore_snapshot(snapshot);
+        Ok(Self {
+            router,
+            index,
+            num_assigners,
+            assign_id,
+            restore_snapshot_id,
+        })
+    }
+
+    /// Fixed restore point shared with the eventual file writers. Zero means
+    /// empty restore state, including overwrite, and must not resolve latest.
+    pub fn restore_snapshot_id(&self) -> i64 {
+        self.restore_snapshot_id
+    }
+
+    /// Return non-null bucket IDs in input order. Supplied hashes avoid
+    /// reading or normalizing primary-key columns again; only partition
+    /// columns are then required. Validate the entire input's owner and hash
+    /// arrays before restoring any index state or assigning any new key.
+    pub async fn assign(
+        &mut self,
+        batch: &RecordBatch,
+        key_hashes: Option<&Int32Array>,
+    ) -> Result<Int32Array> {
+        let (partitions, routing) = self.router.route_with_partitions(batch, 
key_hashes)?;
+        for owner in routing.assigners.values() {
+            if *owner != self.assign_id {
+                return Err(crate::Error::DataInvalid {
+                    message: format!(
+                        "Record assigner {owner} does not match writer 
assigner {}",
+                        self.assign_id
+                    ),
+                    source: None,
+                });
+            }
+        }
+        let mut requested: HashMap<Vec<u8>, HashSet<i32>> = HashMap::new();
+        for (partition, hash) in 
partitions.iter().zip(routing.key_hashes.values()) {
+            requested
+                .entry(partition.clone())
+                .or_default()
+                .insert(*hash);
+        }
+        self.index
+            .restore_input_hashes(&requested, self.num_assigners, 
self.assign_id)
+            .await?;
+        partitions
+            .iter()
+            .zip(routing.key_hashes.values())
+            .map(|(partition, hash)| {
+                self.index
+                    .assign_input_hash(partition, *hash, self.num_assigners, 
self.assign_id)
+            })
+            .collect::<Result<Vec<_>>>()
+            .map(Int32Array::from)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::catalog::Identifier;
+    use crate::io::FileIOBuilder;
+    use crate::spec::{DataType, IntType, Schema, TableSchema};
+    use arrow_array::{ArrayRef, RecordBatchOptions};
+    use arrow_schema::Schema as ArrowSchema;
+    use std::sync::Arc;
+
+    fn table(name: &str) -> Table {
+        let schema = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .column("value", DataType::Int(IntType::new()))
+            .primary_key(["id"])
+            .option("bucket", "-1")
+            .option("dynamic-bucket.target-row-num", "1")
+            .option("dynamic-bucket.max-buckets", "4")
+            .build()
+            .unwrap();
+        Table::new(
+            FileIOBuilder::new("memory").build().unwrap(),
+            Identifier::new("db", name),
+            format!("memory:/{name}"),
+            TableSchema::new(0, &schema),
+            None,
+        )
+    }
+
+    fn batch(ids: &[i32]) -> RecordBatch {
+        RecordBatch::try_from_iter([
+            ("id", Arc::new(Int32Array::from(ids.to_vec())) as ArrayRef),
+            (
+                "value",
+                Arc::new(Int32Array::from(vec![10; ids.len()])) as ArrayRef,
+            ),
+        ])
+        .unwrap()
+    }
+
+    fn keys_for_owner(table: &Table, owner: i32) -> (Vec<i32>, Int32Array) {
+        let input = batch(&(0..32).collect::<Vec<_>>());
+        let routing = DynamicBucketAssignerRouter::new(table.schema(), 2, 2)
+            .unwrap()
+            .route(&input)
+            .unwrap();
+        let positions = routing
+            .assigners
+            .values()
+            .iter()
+            .enumerate()
+            .filter_map(|(index, assigner)| (*assigner == 
owner).then_some(index))
+            .collect::<Vec<_>>();
+        assert!(positions.len() >= 3);
+        (
+            positions.iter().map(|index| *index as i32).collect(),
+            Int32Array::from_iter_values(
+                positions
+                    .iter()
+                    .map(|index| routing.key_hashes.value(*index)),
+            ),
+        )
+    }
+
+    async fn seed(table: &Table, ids: &[i32], bucket: i32) {
+        let builder = table.new_write_builder();
+        let mut writer = builder.new_write().unwrap();
+        writer
+            .write_arrow(&[batch(ids)], Some(bucket))
+            .await
+            .unwrap();
+        let messages = writer.prepare_commit().await.unwrap();
+        builder.new_commit().commit(messages).await.unwrap();
+        writer.close().await;
+    }
+
+    #[tokio::test]
+    async fn 
input_assignment_preserves_owned_holes_foreign_keys_and_later_batches() {
+        let table = table("input_owned_holes");
+        let (keys, hashes) = keys_for_owner(&table, 0);
+        // A changed parallelism can route this historical key to assigner 0
+        // even though it was originally written to foreign bucket 3.
+        seed(&table, &keys[..1], 3).await;
+        let mut assigner = DynamicBucketAssigner::builder(&table, 2, 2, 0)
+            .build()
+            .await
+            .unwrap();
+        assert_eq!(assigner.restore_snapshot_id(), 1);
+        let routed = assigner
+            .assign(&batch(&keys[..2]), Some(&hashes.slice(0, 2)))
+            .await
+            .unwrap();
+        assert_eq!(routed.values(), &[3, 0]);
+        // Repeating the historical key does not increment a bucket's count;
+        // the next new key fills unused owned bucket 2 despite global id 3.
+        let routed = assigner
+            .assign(&batch(&[keys[0], keys[2], keys[1]]), None)
+            .await
+            .unwrap();
+        assert_eq!(routed.values(), &[3, 2, 0]);
+        assert_eq!(
+            table
+                .snapshot_manager()
+                .get_latest_snapshot()
+                .await
+                .unwrap()
+                .unwrap()
+                .id(),
+            1
+        );
+        // Input assignment never writes HASH files or exposes commit messages.
+        let fresh = DynamicBucketAssigner::builder(&table, 2, 2, 0)
+            .build()
+            .await
+            .unwrap();
+        assert_eq!(fresh.restore_snapshot_id(), 1);
+    }
+
+    #[tokio::test]
+    async fn 
input_assignment_restores_new_requested_hashes_without_resetting_counts() {
+        let table = table("input_requested_hashes");
+        let (keys, _) = keys_for_owner(&table, 0);
+        seed(&table, &keys[..1], 3).await;
+        seed(&table, &keys[1..2], 2).await;
+        let mut assigner = DynamicBucketAssigner::builder(&table, 2, 2, 0)
+            .build()
+            .await
+            .unwrap();
+        assert_eq!(
+            assigner
+                .assign(&batch(&keys[..1]), None)
+                .await
+                .unwrap()
+                .values(),
+            &[3]
+        );
+        // This hash was not retained during the first call's restore.
+        assert_eq!(
+            assigner
+                .assign(&batch(&keys[1..2]), None)
+                .await
+                .unwrap()
+                .values(),
+            &[2]
+        );
+        assert_eq!(
+            assigner
+                .assign(&batch(&keys[2..3]), None)
+                .await
+                .unwrap()
+                .values(),
+            &[0]
+        );
+    }
+
+    #[tokio::test]
+    async fn input_assignment_pins_latest_and_empty_restore_points() {
+        let table = table("input_snapshot_pin");
+        let (keys, _) = keys_for_owner(&table, 0);
+        assert!(DynamicBucketAssigner::builder(&table, 2, 2, 0)
+            .with_restore_snapshot(-1)
+            .err()
+            .unwrap()
+            .to_string()
+            .contains("must not be negative"));
+        let mut empty = DynamicBucketAssigner::builder(&table, 2, 2, 0)
+            .build()
+            .await
+            .unwrap();
+        assert_eq!(empty.restore_snapshot_id(), 0);
+        // Creating the builder does not capture an earlier restore point.
+        let pending = DynamicBucketAssigner::builder(&table, 2, 2, 0);
+        seed(&table, &keys[..1], 3).await;
+        let mut pinned = pending.build().await.unwrap();
+        assert_eq!(pinned.restore_snapshot_id(), 1);
+        seed(&table, &keys[1..2], 2).await;
+        assert_eq!(
+            empty
+                .assign(&batch(&keys[..1]), None)
+                .await
+                .unwrap()
+                .values(),
+            &[0]
+        );
+        assert_eq!(
+            pinned
+                .assign(&batch(&keys[1..2]), None)
+                .await
+                .unwrap()
+                .values(),
+            &[0]
+        );
+        let mut explicit = DynamicBucketAssigner::builder(&table, 2, 2, 0)
+            .with_restore_snapshot(1)
+            .unwrap()
+            .build()
+            .await
+            .unwrap();
+        assert_eq!(explicit.restore_snapshot_id(), 1);
+        assert_eq!(
+            explicit
+                .assign(&batch(&keys[1..2]), None)
+                .await
+                .unwrap()
+                .values(),
+            &[0]
+        );
+        let mut latest = DynamicBucketAssigner::builder(&table, 2, 2, 0)
+            .build()
+            .await
+            .unwrap();
+        assert_eq!(latest.restore_snapshot_id(), 2);
+        assert_eq!(
+            latest
+                .assign(&batch(&keys[1..2]), None)
+                .await
+                .unwrap()
+                .values(),
+            &[2]
+        );
+        let mut explicit_empty = DynamicBucketAssigner::builder(&table, 2, 2, 
0)
+            .with_restore_snapshot(0)
+            .unwrap()
+            .build()
+            .await
+            .unwrap();
+        assert_eq!(explicit_empty.restore_snapshot_id(), 0);
+        assert_eq!(
+            explicit_empty
+                .assign(&batch(&keys[..1]), None)
+                .await
+                .unwrap()
+                .values(),
+            &[0]
+        );
+        let mut overwrite = DynamicBucketAssigner::builder(&table, 2, 2, 0)
+            .with_restore_snapshot(999)
+            .unwrap()
+            .with_overwrite()
+            .build()
+            .await
+            .unwrap();
+        assert_eq!(overwrite.restore_snapshot_id(), 0);
+        assert_eq!(
+            overwrite
+                .assign(&batch(&keys[..1]), None)
+                .await
+                .unwrap()
+                .values(),
+            &[0]
+        );
+    }
+
+    #[tokio::test]
+    async fn 
input_assignment_checks_all_owners_and_hashes_before_state_changes() {
+        let table = table("input_validate_before_assignment");
+        let (keys, hashes) = keys_for_owner(&table, 0);
+        let (foreign, _) = keys_for_owner(&table, 1);
+        let mut assigner = DynamicBucketAssigner::builder(&table, 2, 2, 0)
+            .build()
+            .await
+            .unwrap();
+        let error = assigner
+            .assign(&batch(&[keys[0], foreign[0]]), None)
+            .await
+            .unwrap_err();
+        assert!(error.to_string().contains("does not match"));
+        let error = assigner
+            .assign(&batch(&keys[..2]), Some(&hashes.slice(0, 1)))
+            .await
+            .unwrap_err();
+        assert!(error.to_string().contains("length") || 
error.to_string().contains("row"));
+        let error = assigner
+            .assign(
+                &batch(&keys[..2]),
+                Some(&Int32Array::from(vec![Some(hashes.value(0)), None])),
+            )
+            .await
+            .unwrap_err();
+        assert!(error.to_string().contains("null") || 
error.to_string().contains("NULL"));
+        // None of the rejected calls consumed the first bucket with keys[0].
+        assert_eq!(
+            assigner
+                .assign(&batch(&keys[1..2]), None)
+                .await
+                .unwrap()
+                .values(),
+            &[0]
+        );
+    }
+
+    #[tokio::test]
+    async fn prehashed_unpartitioned_input_keeps_zero_column_batch_rows() {
+        let table = table("input_prehashed_zero_columns");
+        let (_, hashes) = keys_for_owner(&table, 0);
+        let input = RecordBatch::try_new_with_options(
+            Arc::new(ArrowSchema::empty()),
+            vec![],
+            &RecordBatchOptions::default().with_row_count(Some(2)),
+        )
+        .unwrap();
+        let mut assigner = DynamicBucketAssigner::builder(&table, 2, 2, 0)
+            .build()
+            .await
+            .unwrap();
+        assert_eq!(
+            assigner
+                .assign(&input, Some(&hashes.slice(0, 2)))
+                .await
+                .unwrap()
+                .values(),
+            &[0, 2]
+        );
+        assert!(assigner.assign(&input, None).await.is_err());
+        assert!(!table
+            .file_io()
+            .exists_dir("memory:/input_prehashed_zero_columns/index")
+            .await
+            .unwrap());
+    }
+
+    #[tokio::test]
+    async fn input_assignment_rejects_data_without_complete_hash_indexes() {
+        let table = table("input_missing_hash_index");
+        let builder = table.new_write_builder();
+        let mut writer = builder.new_write().unwrap();
+        writer.write_arrow(&[batch(&[1])], Some(0)).await.unwrap();
+        let mut messages = writer.prepare_commit().await.unwrap();
+        for message in &mut messages {
+            message.new_index_files.clear();
+        }
+        builder.new_commit().commit(messages).await.unwrap();
+        writer.close().await;
+        let mut assigner = DynamicBucketAssigner::builder(&table, 1, 1, 0)
+            .build()
+            .await
+            .unwrap();
+        assert!(assigner
+            .assign(&batch(&[1]), None)
+            .await
+            .unwrap_err()
+            .to_string()
+            .contains("no complete HASH"));
+        let mut overwrite = DynamicBucketAssigner::builder(&table, 1, 1, 0)
+            .with_overwrite()
+            .build()
+            .await
+            .unwrap();
+        assert_eq!(
+            overwrite.assign(&batch(&[1]), None).await.unwrap().values(),
+            &[0]
+        );
+    }
+
+    #[tokio::test]
+    async fn 
input_assignment_validates_duplicate_and_corrupt_restored_hash_indexes() {
+        for duplicate in [true, false] {
+            let table = table(if duplicate {
+                "input_duplicate_hash"
+            } else {
+                "input_corrupt_hash"
+            });
+            seed(&table, &[1], 0).await;
+            let manager = table.snapshot_manager();
+            let snapshot = 
manager.get_latest_snapshot().await.unwrap().unwrap();
+            let path = 
manager.manifest_path(snapshot.index_manifest().unwrap());
+            let mut entries = 
crate::spec::IndexManifest::read(table.file_io(), &path)
+                .await
+                .unwrap();
+            assert_eq!(entries.len(), 1);
+            if duplicate {
+                entries.push(entries[0].clone());
+            } else {
+                entries[0].index_file.row_count += 1;
+            }
+            crate::spec::IndexManifest::write(table.file_io(), &path, &entries)
+                .await
+                .unwrap();
+            let mut assigner = DynamicBucketAssigner::builder(&table, 1, 1, 0)
+                .build()
+                .await
+                .unwrap();
+            let error = assigner.assign(&batch(&[1]), None).await.unwrap_err();
+            assert!(error.to_string().contains(if duplicate {
+                "Multiple HASH"
+            } else {
+                "Corrupt HASH"
+            }));
+        }
+    }
+
+    #[tokio::test]
+    async fn input_assignment_uses_write_identity_without_a_select_grant() {
+        let unbound = 
table("input_unbound_auth").copy_with_options(HashMap::from([(
+            "query-auth.enabled".into(),
+            "true".into(),
+        )]));
+        assert!(DynamicBucketAssigner::builder(&unbound, 1, 1, 0)
+            .with_restore_snapshot(0)
+            .unwrap()
+            .build()
+            .await
+            .err()
+            .unwrap()
+            .to_string()
+            .contains("catalog-loaded REST"));
+        // This REST identity intentionally has no running query endpoint or
+        // SELECT grant. Empty restore state must not issue a query-auth call.
+        let rest = crate::table::rest_query_auth_table().await;
+        let bound = Table {
+            rest_env: rest.rest_env,
+            ..unbound
+        }
+        .with_query_auth_session();
+        let mut assigner = DynamicBucketAssigner::builder(&bound, 1, 1, 0)
+            .with_restore_snapshot(0)
+            .unwrap()
+            .build()
+            .await
+            .unwrap();
+        assert_eq!(
+            assigner.assign(&batch(&[1]), None).await.unwrap().values(),
+            &[0]
+        );
+    }
+}
diff --git a/crates/paimon/src/table/dynamic_bucket_assigner_router.rs 
b/crates/paimon/src/table/dynamic_bucket_assigner_router.rs
new file mode 100644
index 00000000..218bbbc2
--- /dev/null
+++ b/crates/paimon/src/table/dynamic_bucket_assigner_router.rs
@@ -0,0 +1,464 @@
+// 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.
+
+//! Stateless input routing before distributed dynamic bucket assignment.
+
+use super::bucket_router::project_routing_input;
+use super::postpone_bucket_plan::data_invalid;
+use crate::spec::{
+    batch_build_binary_rows, batch_hash_codes, BinaryRow, CoreOptions, 
DataField, TableSchema,
+};
+use crate::Result;
+use arrow_array::{Array, Int32Array, RecordBatch};
+use arrow_schema::SchemaRef;
+use std::collections::HashSet;
+
+/// Assigner IDs and Java BinaryRow key hashes in the original input order.
+/// The assigner subsequently uses its persisted HASH index to choose buckets.
+#[derive(Debug, Clone)]
+pub struct DynamicBucketAssignerRouting {
+    pub assigners: Int32Array,
+    pub key_hashes: Int32Array,
+}
+
+/// Hash only partition and primary-key columns without table access or I/O.
+///
+/// Java's `BucketAssigner.computeAssigner` combines the partition hash with
+/// the trimmed primary-key hash. Neither hash is a bucket ID: one assigner
+/// must own the persisted HASH-index state for each routed input.
+#[derive(Debug, Clone)]
+pub struct DynamicBucketAssignerRouter {
+    fields: Vec<DataField>,
+    arrow_schema: SchemaRef,
+    partition_indices: Vec<usize>,
+    key_indices: Vec<usize>,
+    partition_fields: Vec<DataField>,
+    partition_schema: SchemaRef,
+    num_channels: i32,
+    num_assigners: i32,
+}
+
+impl DynamicBucketAssignerRouter {
+    pub fn new(schema: &TableSchema, num_channels: i32, num_assigners: i32) -> 
Result<Self> {
+        if num_channels <= 0 || num_assigners <= 0 {
+            return Err(data_invalid(
+                "num_channels and num_assigners must be positive",
+            ));
+        }
+        schema.validate_resolved_structure()?;
+        let options = CoreOptions::new(schema.options());
+        if options.is_format_table()
+            || options.bucket() != -1
+            || schema.primary_keys().is_empty()
+            || schema
+                .partition_keys()
+                .iter()
+                .any(|key| !schema.primary_keys().contains(key))
+        {
+            return Err(crate::Error::Unsupported {
+                message: "Dynamic bucket routing requires a Paimon primary-key 
table with bucket=-1 and partition columns included in the primary key".into(),
+            });
+        }
+        if schema.options().contains_key("bucket-key") {
+            return Err(data_invalid(
+                "Cannot define 'bucket-key' in dynamic bucket mode",
+            ));
+        }
+
+        let keys = schema.trimmed_primary_keys();
+        let required = schema
+            .partition_keys()
+            .iter()
+            .chain(&keys)
+            .map(String::as_str)
+            .collect::<HashSet<_>>();
+        let fields = schema
+            .fields()
+            .iter()
+            .filter(|field| required.contains(field.name()))
+            .cloned()
+            .collect::<Vec<_>>();
+        let indices = |names: &[String]| {
+            names
+                .iter()
+                .map(|name| {
+                    fields
+                        .iter()
+                        .position(|field| field.name() == name)
+                        .ok_or_else(|| data_invalid(format!("Unknown routing 
column '{name}'")))
+                })
+                .collect::<Result<Vec<_>>>()
+        };
+        let partition_indices = indices(schema.partition_keys())?;
+        let key_indices = indices(&keys)?;
+        let partition_fields = schema.partition_fields();
+        Ok(Self {
+            arrow_schema: crate::arrow::build_target_arrow_schema(&fields)?,
+            partition_schema: 
crate::arrow::build_target_arrow_schema(&partition_fields)?,
+            fields,
+            partition_fields,
+            partition_indices,
+            key_indices,
+            num_channels,
+            num_assigners,
+        })
+    }
+
+    pub fn route(&self, batch: &RecordBatch) -> 
Result<DynamicBucketAssignerRouting> {
+        Ok(self.route_input(batch, None)?.1)
+    }
+
+    /// Prehashed input needs only partition columns. Its payload and key
+    /// arrays are deliberately not read or normalized a second time.
+    pub(super) fn route_with_partitions(
+        &self,
+        batch: &RecordBatch,
+        key_hashes: Option<&Int32Array>,
+    ) -> Result<(Vec<Vec<u8>>, DynamicBucketAssignerRouting)> {
+        let (partitions, routing) = self.route_input(batch, key_hashes)?;
+        Ok((
+            partitions
+                .into_iter()
+                .map(|row| row.to_serialized_bytes())
+                .collect(),
+            routing,
+        ))
+    }
+
+    fn route_input(
+        &self,
+        batch: &RecordBatch,
+        key_hashes: Option<&Int32Array>,
+    ) -> Result<(Vec<BinaryRow>, DynamicBucketAssignerRouting)> {
+        if let Some(hashes) = key_hashes {
+            if hashes.len() != batch.num_rows() || hashes.null_count() != 0 {
+                return Err(data_invalid(
+                    "Dynamic key hashes must be non-null and match the input 
row count",
+                ));
+            }
+        }
+        let (input, fields, partition_indices, hashes) = if let Some(hashes) = 
key_hashes {
+            (
+                project_routing_input(batch, &self.partition_fields, 
&self.partition_schema)?,
+                &self.partition_fields,
+                (0..self.partition_fields.len()).collect::<Vec<_>>(),
+                hashes.clone(),
+            )
+        } else {
+            let input = project_routing_input(batch, &self.fields, 
&self.arrow_schema)?;
+            let hashes =
+                Int32Array::from(batch_hash_codes(&input, &self.key_indices, 
&self.fields)?);
+            (input, &self.fields, self.partition_indices.clone(), hashes)
+        };
+        let partitions = batch_build_binary_rows(&input, &partition_indices, 
fields)?;
+        let assigners = 
Int32Array::from_iter_values(partitions.iter().zip(hashes.values()).map(
+            |(partition, hash)| {
+                compute_assigner(
+                    partition.hash_code(),
+                    *hash,
+                    self.num_channels,
+                    self.num_assigners,
+                )
+            },
+        ));
+        Ok((
+            partitions,
+            DynamicBucketAssignerRouting {
+                assigners,
+                key_hashes: hashes,
+            },
+        ))
+    }
+}
+
+/// Java signed remainder and `int` addition, including overflow before the
+/// final remainder. The router constructor validates both divisors.
+pub(super) fn compute_assigner(
+    partition_hash: i32,
+    key_hash: i32,
+    num_channels: i32,
+    num_assigners: i32,
+) -> i32 {
+    let start = (partition_hash % num_channels).wrapping_abs();
+    let assigner = (key_hash % num_assigners).wrapping_abs();
+    start.wrapping_add(assigner) % num_channels
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::spec::{
+        DataType, DecimalType, IntType, LocalZonedTimestampType, Schema, 
TimestampType, VarCharType,
+    };
+    use arrow_array::{
+        ArrayRef, Decimal128Array, LargeStringArray, StringArray, 
TimestampMicrosecondArray,
+    };
+    use arrow_schema::{DataType as ArrowType, Field, Schema as ArrowSchema};
+    use std::collections::HashMap;
+    use std::sync::Arc;
+
+    fn schema() -> TableSchema {
+        TableSchema::new(
+            0,
+            &Schema::builder()
+                .column("id", DataType::Int(IntType::new()))
+                .column("p", DataType::VarChar(VarCharType::string_type()))
+                .column("label", DataType::VarChar(VarCharType::string_type()))
+                .column("payload", 
DataType::VarChar(VarCharType::string_type()))
+                .primary_key(["p", "id", "label"])
+                .partition_keys(["p"])
+                .option("bucket", "-1")
+                .option("primary-key.nullable", "true")
+                .build()
+                .unwrap(),
+        )
+    }
+
+    fn input() -> RecordBatch {
+        RecordBatch::try_from_iter([
+            (
+                "label",
+                Arc::new(LargeStringArray::from(vec![
+                    Some("hello"),
+                    Some(""),
+                    Some("abcdefghijk"),
+                    Some("你好"),
+                    None,
+                ])) as ArrayRef,
+            ),
+            (
+                "ignored",
+                Arc::new(Int32Array::from(vec![0; 5])) as ArrayRef,
+            ),
+            (
+                "p",
+                Arc::new(StringArray::from(vec![
+                    Some("east"),
+                    Some("west"),
+                    None,
+                    Some("abcdefghijk"),
+                    Some("east"),
+                ])) as ArrayRef,
+            ),
+            (
+                "id",
+                Arc::new(Int32Array::from(vec![
+                    Some(5),
+                    Some(-3),
+                    None,
+                    Some(i32::MIN),
+                    Some(i32::MAX),
+                ])) as ArrayRef,
+            ),
+        ])
+        .unwrap()
+    }
+
+    #[test]
+    fn routes_reordered_key_only_rows_using_current_java_hashes() {
+        // Current BinaryRowWriter + BucketAssigner.computeAssigner(7, 3).
+        // The partition columns do not participate in the trimmed key hash.
+        let router = DynamicBucketAssignerRouter::new(&schema(), 7, 
3).unwrap();
+        let batch = input();
+        let routed = router.route(&batch).unwrap();
+        assert_eq!(
+            routed.key_hashes.values(),
+            &[
+                -1_451_184_867,
+                556_362_117,
+                1_425_286_886,
+                308_673_824,
+                -1_856_341_548
+            ]
+        );
+        assert_eq!(routed.assigners.values(), &[6, 6, 5, 2, 6]);
+        assert_eq!(routed.assigners.null_count(), 0);
+        assert_eq!(routed.key_hashes.null_count(), 0);
+        // SDK Tables arrive as batches: boundaries must not change ownership.
+        let pieces = [
+            router.route(&batch.slice(0, 2)).unwrap(),
+            router.route(&batch.slice(2, 3)).unwrap(),
+        ];
+        assert_eq!(
+            pieces
+                .iter()
+                .flat_map(|part| part.assigners.values().iter().copied())
+                .collect::<Vec<_>>(),
+            routed.assigners.values().to_vec()
+        );
+        let empty = router.route(&batch.slice(0, 0)).unwrap();
+        assert_eq!(empty.assigners.len(), 0);
+        assert_eq!(empty.key_hashes.len(), 0);
+    }
+
+    #[test]
+    fn prehashed_routing_reads_only_partition_columns() {
+        let router = DynamicBucketAssignerRouter::new(&schema(), 7, 
3).unwrap();
+        let batch = input();
+        let expected = router.route(&batch).unwrap();
+        let partition_only = batch.project(&[2]).unwrap();
+        let (partitions, actual) = router
+            .route_with_partitions(&partition_only, Some(&expected.key_hashes))
+            .unwrap();
+        assert_eq!(actual.assigners, expected.assigners);
+        assert_eq!(actual.key_hashes, expected.key_hashes);
+        assert_eq!(partitions.len(), batch.num_rows());
+        assert!(router.route(&partition_only).is_err());
+        for hashes in [Int32Array::from(vec![0]), Int32Array::from(vec![None; 
5])] {
+            assert!(router
+                .route_with_partitions(&partition_only, Some(&hashes))
+                .is_err());
+        }
+    }
+
+    #[test]
+    fn unpartitioned_prehashed_rows_retain_their_count() {
+        let schema = TableSchema::new(
+            0,
+            &Schema::builder()
+                .column("id", DataType::Int(IntType::new()))
+                .primary_key(["id"])
+                .option("bucket", "-1")
+                .build()
+                .unwrap(),
+        );
+        let router = DynamicBucketAssignerRouter::new(&schema, 7, 3).unwrap();
+        let batch = RecordBatch::try_from_iter([(
+            "payload",
+            Arc::new(StringArray::from(vec!["ignored"; 4])) as ArrayRef,
+        )])
+        .unwrap();
+        let hashes = Int32Array::from(vec![i32::MIN, -1, 0, i32::MAX]);
+        let (partitions, routed) = router.route_with_partitions(&batch, 
Some(&hashes)).unwrap();
+        // The empty Java BinaryRow hash is -1670924195, whose abs(% 7) is 3.
+        assert_eq!(partitions.len(), 4);
+        assert_eq!(routed.assigners.values(), &[5, 4, 3, 4]);
+    }
+
+    #[test]
+    fn nullable_noncompact_keys_keep_java_variable_slots() {
+        for (data_type, values, expected_hash) in [
+            (
+                DataType::Decimal(DecimalType::new(20, 2).unwrap()),
+                Arc::new(
+                    Decimal128Array::from(vec![None::<i128>])
+                        .with_precision_and_scale(20, 2)
+                        .unwrap(),
+                ) as ArrayRef,
+                -1_380_280_760,
+            ),
+            (
+                DataType::Timestamp(TimestampType::new(6).unwrap()),
+                Arc::new(TimestampMicrosecondArray::from(vec![None::<i64>])) 
as ArrayRef,
+                -340_882_661,
+            ),
+            (
+                
DataType::LocalZonedTimestamp(LocalZonedTimestampType::new(6).unwrap()),
+                
Arc::new(TimestampMicrosecondArray::from(vec![None::<i64>]).with_timezone("UTC"))
+                    as ArrayRef,
+                -340_882_661,
+            ),
+        ] {
+            let schema = TableSchema::new(
+                0,
+                &Schema::builder()
+                    .column("id", data_type)
+                    .primary_key(["id"])
+                    .option("bucket", "-1")
+                    .option("primary-key.nullable", "true")
+                    .build()
+                    .unwrap(),
+            );
+            let router = DynamicBucketAssignerRouter::new(&schema, 7, 
3).unwrap();
+            let batch = RecordBatch::try_from_iter([("id", values)]).unwrap();
+            assert_eq!(
+                router.route(&batch).unwrap().key_hashes.values(),
+                &[expected_hash]
+            );
+        }
+    }
+
+    #[test]
+    fn invalid_schemas_parallelism_and_required_inputs_fail_before_routing() {
+        for (channels, assigners) in [(0, 1), (1, 0), (-1, 1), (1, -1)] {
+            assert!(DynamicBucketAssignerRouter::new(&schema(), channels, 
assigners).is_err());
+        }
+        for options in [
+            HashMap::from([("bucket".into(), "4".into())]),
+            HashMap::from([("bucket-key".into(), "id".into())]),
+            HashMap::from([("bucket-key".into(), "".into())]),
+        ] {
+            assert!(
+                
DynamicBucketAssignerRouter::new(&schema().copy_with_options(options), 7, 3)
+                    .is_err()
+            );
+        }
+        let no_primary_key = TableSchema::new(
+            0,
+            &Schema::builder()
+                .column("id", DataType::Int(IntType::new()))
+                .build()
+                .unwrap(),
+        );
+        assert!(DynamicBucketAssignerRouter::new(&no_primary_key, 7, 
3).is_err());
+        let cross_partition = TableSchema::new(
+            0,
+            &Schema::builder()
+                .column("id", DataType::Int(IntType::new()))
+                .column("p", DataType::VarChar(VarCharType::string_type()))
+                .primary_key(["id"])
+                .partition_keys(["p"])
+                .build()
+                .unwrap(),
+        );
+        assert!(DynamicBucketAssignerRouter::new(&cross_partition, 7, 
3).is_err());
+        let router = DynamicBucketAssignerRouter::new(&schema(), 7, 
3).unwrap();
+        let batch = input();
+        assert!(router.route(&batch.project(&[0, 2]).unwrap()).is_err());
+        let duplicate = RecordBatch::try_new(
+            Arc::new(ArrowSchema::new(vec![
+                Field::new("id", ArrowType::Int32, true),
+                Field::new("id", ArrowType::Int32, true),
+            ])),
+            vec![batch.column(3).clone(), batch.column(3).clone()],
+        )
+        .unwrap();
+        assert!(router.route(&duplicate).is_err());
+        let incompatible = RecordBatch::try_from_iter([
+            ("p", batch.column(2).clone()),
+            ("label", batch.column(0).clone()),
+            ("id", Arc::new(StringArray::from(vec!["1"; 5])) as ArrayRef),
+        ])
+        .unwrap();
+        assert!(router.route(&incompatible).is_err());
+    }
+
+    #[test]
+    fn java_assigner_remainder_keeps_negative_hashes_and_int_min() {
+        for (partition, expected) in [
+            (i32::MIN, [4, 3, 2, 3]),
+            (-1, [3, 2, 1, 2]),
+            (0, [2, 1, 0, 1]),
+            (i32::MAX, [3, 2, 1, 2]),
+        ] {
+            for (key, expected) in [i32::MIN, -1, 0, 
i32::MAX].into_iter().zip(expected) {
+                assert_eq!(compute_assigner(partition, key, 7, 3), expected);
+            }
+        }
+        assert_eq!(compute_assigner(-1, i32::MIN, 1, 1), 0);
+    }
+}
diff --git a/crates/paimon/src/table/bucket_assigner_dynamic.rs 
b/crates/paimon/src/table/dynamic_bucket_index.rs
similarity index 79%
rename from crates/paimon/src/table/bucket_assigner_dynamic.rs
rename to crates/paimon/src/table/dynamic_bucket_index.rs
index d6422b60..316670ff 100644
--- a/crates/paimon/src/table/bucket_assigner_dynamic.rs
+++ b/crates/paimon/src/table/dynamic_bucket_index.rs
@@ -15,12 +15,12 @@
 // specific language governing permissions and limitations
 // under the License.
 
-//! Dynamic bucket assigner for PK tables with bucket=-1 where PK includes 
partition fields.
+//! Dynamic bucket index state for PK tables whose primary key includes 
partition fields.
 //!
 //! Also contains the per-bucket index maintainer 
(`DynamicBucketIndexMaintainer`)
 //! and per-partition index (`PartitionIndex`) used by both dynamic and 
cross-partition modes.
 
-use crate::io::FileIO;
+use crate::io::{FileIO, FileRead};
 use crate::spec::MAX_DYNAMIC_BUCKETS;
 use crate::spec::{
     batch_hash_codes, batch_to_serialized_bytes, bucket_path_under, BinaryRow, 
CoreOptions,
@@ -44,6 +44,7 @@ use uuid::Uuid;
 
 /// Index type identifier for hash index files, matching Java's 
`HashIndexFile.HASH_INDEX`.
 const HASH_INDEX: &str = "HASH";
+const HASH_INDEX_READ_CHUNK_SIZE: u64 = 8 * 1024 * 1024;
 
 /// Read/write hash index files.
 ///
@@ -80,6 +81,49 @@ impl HashIndexFile {
         Ok(hashes)
     }
 
+    /// Distributed input assigners retain only hashes requested by their
+    /// current batch. Scan bounded ranges rather than materializing a whole
+    /// partition's index or constructing writer-side index maintainers.
+    async fn read_requested(
+        file_io: &FileIO,
+        path: &str,
+        expected_count: i64,
+        requested: &HashSet<i32>,
+    ) -> Result<HashSet<i32>> {
+        let input = file_io.new_input(path)?;
+        let size = input.metadata().await?.size;
+        let count = u64::try_from(expected_count).ok();
+        if size % 4 != 0 || count.and_then(|count| count.checked_mul(4)) != 
Some(size) {
+            return Err(crate::Error::DataInvalid {
+                message: format!(
+                    "Corrupt HASH index {path}: expected {expected_count} 
hashes, got {size} bytes"
+                ),
+                source: None,
+            });
+        }
+        let reader = input.reader().await?;
+        let mut found = HashSet::new();
+        let mut offset = 0;
+        while offset < size {
+            let end = (offset + HASH_INDEX_READ_CHUNK_SIZE).min(size);
+            let content = reader.read(offset..end).await?;
+            if content.len() as u64 != end - offset {
+                return Err(crate::Error::DataInvalid {
+                    message: format!("Short read in HASH index {path}"),
+                    source: None,
+                });
+            }
+            for bytes in content.as_chunks::<4>().0 {
+                let hash = i32::from_be_bytes(*bytes);
+                if requested.contains(&hash) {
+                    found.insert(hash);
+                }
+            }
+            offset = end;
+        }
+        Ok(found)
+    }
+
     /// Write key hashes to a new hash index file, returning its metadata.
     async fn write_at(
         file_io: &FileIO,
@@ -430,15 +474,36 @@ impl PartitionIndex {
     /// 2. Otherwise, find a non-full bucket and assign the hash there.
     /// 3. If all buckets are full, create a new bucket.
     fn assign(&mut self, hash: i32, max_buckets: i32) -> Result<i32> {
+        let (bucket, new) = self.allocate(hash, max_buckets, |_| true)?;
+        if new {
+            self.bucket_maintainers
+                .entry(bucket)
+                .or_insert_with(|| DynamicBucketIndexMaintainer::new(vec![]))
+                .notify_new_record(hash);
+        }
+        Ok(bucket)
+    }
+
+    /// Java PartitionIndex allocation, shared by writers and distributed
+    /// input assignment. Only writers notify HASH-file maintainers.
+    fn allocate(
+        &mut self,
+        hash: i32,
+        max_buckets: i32,
+        bucket_filter: impl Fn(i32) -> bool,
+    ) -> Result<(i32, bool)> {
         // 1. Already assigned
         if let Some(&bucket) = self.hash_to_bucket.get(&hash) {
-            return Ok(bucket);
+            return Ok((bucket, false));
         }
 
         // 2. Find a non-full bucket
         let mut full_buckets = Vec::new();
         let mut assigned_bucket = None;
         for (&bucket, count) in &mut self.non_full_buckets {
+            if !bucket_filter(bucket) {
+                continue;
+            }
             if *count < self.target_bucket_row_number {
                 *count += 1;
                 self.hash_to_bucket.insert(hash, bucket);
@@ -452,11 +517,7 @@ impl PartitionIndex {
             self.non_full_buckets.remove(&b);
         }
         if let Some(bucket) = assigned_bucket {
-            self.bucket_maintainers
-                .entry(bucket)
-                .or_insert_with(|| DynamicBucketIndexMaintainer::new(vec![]))
-                .notify_new_record(hash);
-            return Ok(bucket);
+            return Ok((bucket, true));
         }
 
         // Java PartitionIndex first allocates an unused id in range. A 
configured
@@ -466,7 +527,10 @@ impl PartitionIndex {
         } else {
             max_buckets
         };
-        while self.next_bucket_id < limit && 
self.all_buckets.contains(&self.next_bucket_id) {
+        while self.next_bucket_id < limit
+            && (self.all_buckets.contains(&self.next_bucket_id)
+                || !bucket_filter(self.next_bucket_id))
+        {
             self.next_bucket_id += 1;
         }
         let bucket = if self.next_bucket_id < limit {
@@ -485,16 +549,12 @@ impl PartitionIndex {
                 .bucket_ids
                 .choose(&mut rand::thread_rng())
                 .ok_or_else(|| crate::Error::DataInvalid {
-                    message: "No dynamic bucket is available".to_string(),
+                    message: "No dynamic bucket is available for this 
assigner".to_string(),
                     source: None,
                 })?
         };
         self.hash_to_bucket.insert(hash, bucket);
-        self.bucket_maintainers
-            .entry(bucket)
-            .or_insert_with(|| DynamicBucketIndexMaintainer::new(vec![]))
-            .notify_new_record(hash);
-        Ok(bucket)
+        Ok((bucket, true))
     }
 
     /// Write hash index files for all modified buckets, returning (bucket, 
index_files) pairs.
@@ -529,13 +589,15 @@ impl PartitionIndex {
 }
 
 // ---------------------------------------------------------------------------
-// DynamicBucketAssigner
+// DynamicBucketIndex
 // ---------------------------------------------------------------------------
 
-/// Bucket assigner for dynamic bucket mode (bucket=-1) where PK includes 
partition fields.
+/// HASH index state for dynamic bucket mode (bucket=-1).
 ///
-/// Maintains a per-partition `PartitionIndex` that maps key hashes to bucket 
ids.
-pub(crate) struct DynamicBucketAssigner {
+/// Restores and maintains per-partition key-to-bucket mappings. Distributed
+/// assigners restore requested mappings; file writers additionally maintain
+/// complete HASH indexes for publication.
+pub(crate) struct DynamicBucketIndex {
     partition_field_indices: Vec<usize>,
     primary_key_indices: Vec<usize>,
     /// Schema fields for BinaryRow extraction.
@@ -561,7 +623,7 @@ pub(crate) struct DynamicBucketAssigner {
     index_file_in_data_file_dir: bool,
 }
 
-impl DynamicBucketAssigner {
+impl DynamicBucketIndex {
     pub fn new(
         table: Table,
         partition_field_indices: Vec<usize>,
@@ -602,6 +664,156 @@ impl DynamicBucketAssigner {
         self.restore_snapshot_id = Some(snapshot_id);
     }
 
+    /// Input assignment and the eventual file writer must restore the exact
+    /// same snapshot, including an explicitly captured empty table.
+    pub(super) fn pin_restore_snapshot(&mut self, snapshot: Option<Snapshot>) {
+        self.restore_snapshot_id = Some(snapshot.as_ref().map_or(0, 
Snapshot::id));
+        self.snapshot = snapshot;
+    }
+
+    /// Restore only mappings requested by this input, without constructing
+    /// writer-side maintainers. Historical mappings may belong to a bucket
+    /// owned by another current assigner after a parallelism change.
+    pub(super) async fn restore_input_hashes(
+        &mut self,
+        requested: &HashMap<Vec<u8>, HashSet<i32>>,
+        num_assigners: i32,
+        assign_id: i32,
+    ) -> Result<()> {
+        let mut missing = Vec::new();
+        for (partition, hashes) in requested {
+            let index = self.partition_indexes.get(partition);
+            let hashes = hashes
+                .iter()
+                .copied()
+                .filter(|hash| index.is_none_or(|index| 
!index.hash_to_bucket.contains_key(hash)))
+                .collect::<HashSet<_>>();
+            if !hashes.is_empty() {
+                missing.push((partition, hashes));
+            }
+        }
+        if missing.is_empty() {
+            return Ok(());
+        }
+        self.ensure_index_entries_loaded().await?;
+        // Apply restored maps only after every requested partition was read
+        // successfully. Input owner/shape checks run before this method.
+        let mut restored = Vec::with_capacity(missing.len());
+        for (partition, hashes) in missing {
+            let entries = self
+                .cached_index_entries
+                .as_deref()
+                .unwrap_or(&[])
+                .iter()
+                .filter(|entry| {
+                    entry.partition == *partition && 
entry.index_file.index_type == HASH_INDEX
+                })
+                .collect::<Vec<_>>();
+            let mut buckets = HashSet::new();
+            for entry in &entries {
+                if !(0..MAX_DYNAMIC_BUCKETS).contains(&entry.bucket) {
+                    return Err(crate::Error::DataInvalid {
+                        message: format!(
+                            "Dynamic bucket id must be between 0 and {}, but 
was {}",
+                            MAX_DYNAMIC_BUCKETS - 1,
+                            entry.bucket
+                        ),
+                        source: None,
+                    });
+                }
+                if !buckets.insert(entry.bucket) {
+                    return Err(crate::Error::DataInvalid {
+                        message: format!(
+                            "Multiple HASH indexes for dynamic bucket {}",
+                            entry.bucket
+                        ),
+                        source: None,
+                    });
+                }
+            }
+            let new_partition = 
!self.partition_indexes.contains_key(partition);
+            if new_partition {
+                self.validate_data_buckets(
+                    partition,
+                    &entries
+                        .iter()
+                        .map(|entry| (*entry).clone())
+                        .collect::<Vec<_>>(),
+                )
+                .await?;
+            }
+            let mut index = 
PartitionIndex::empty(self.target_bucket_row_number);
+            let partition_path = self.partition_path(partition)?;
+            let options = CoreOptions::new(self.table.schema().options());
+            let layout = HashIndexLayout {
+                table_path: self.table.location().trim_end_matches('/'),
+                partition_path: &partition_path,
+                data_file_path_directory: options.data_file_path_directory(),
+                index_file_in_data_file_dir: self.index_file_in_data_file_dir,
+            };
+            for entry in entries {
+                let path = layout.resolve(
+                    entry.bucket,
+                    &entry.index_file.file_name,
+                    entry.index_file.external_path.as_deref(),
+                );
+                for hash in HashIndexFile::read_requested(
+                    self.table.file_io(),
+                    &path,
+                    entry.index_file.row_count,
+                    &hashes,
+                )
+                .await?
+                {
+                    if let Some(previous) = index.hash_to_bucket.insert(hash, 
entry.bucket) {
+                        if previous != entry.bucket {
+                            return Err(crate::Error::DataInvalid {
+                                message: format!("HASH index assigns hash 
{hash} to both buckets {previous} and {}", entry.bucket),
+                                source: None,
+                            });
+                        }
+                    }
+                }
+                // Restored foreign buckets only supply historical mappings.
+                // New keys can use or grow buckets owned by this assigner.
+                if new_partition && entry.bucket % num_assigners == assign_id 
% num_assigners {
+                    index.all_buckets.insert(entry.bucket);
+                    index.bucket_ids.push(entry.bucket);
+                    if entry.index_file.row_count < 
self.target_bucket_row_number {
+                        index
+                            .non_full_buckets
+                            .insert(entry.bucket, entry.index_file.row_count);
+                    }
+                }
+            }
+            restored.push((partition.clone(), index));
+        }
+        for (partition, index) in restored {
+            if let Some(existing) = self.partition_indexes.get_mut(&partition) 
{
+                existing.hash_to_bucket.extend(index.hash_to_bucket);
+            } else {
+                self.partition_indexes.insert(partition, index);
+            }
+        }
+        Ok(())
+    }
+
+    pub(super) fn assign_input_hash(
+        &mut self,
+        partition: &[u8],
+        hash: i32,
+        num_assigners: i32,
+        assign_id: i32,
+    ) -> Result<i32> {
+        self.partition_indexes
+            .get_mut(partition)
+            .expect("requested input hashes were restored before assignment")
+            .allocate(hash, self.max_buckets, |bucket| {
+                bucket % num_assigners == assign_id % num_assigners
+            })
+            .map(|(bucket, _)| bucket)
+    }
+
     /// Java's DynamicBucketIndexMaintainer sees keys after bucket assignment.
     /// Keep that same full-file replacement lifecycle for precomputed groups.
     pub async fn notify_precomputed_batch(
@@ -661,10 +873,14 @@ impl DynamicBucketAssigner {
             return Ok(());
         }
         let snapshot_manager = self.table.snapshot_manager();
-        let latest_snapshot = match self.restore_snapshot_id {
-            Some(0) => None,
-            Some(id) => Some(snapshot_manager.get_snapshot(id).await?),
-            None => snapshot_manager.get_latest_snapshot().await?,
+        let latest_snapshot = if self.snapshot.is_some() {
+            self.snapshot.clone()
+        } else {
+            match self.restore_snapshot_id {
+                Some(0) => None,
+                Some(id) => Some(snapshot_manager.get_snapshot(id).await?),
+                None => snapshot_manager.get_latest_snapshot().await?,
+            }
         };
 
         let entries = if let Some(snapshot) = &latest_snapshot {
@@ -769,7 +985,7 @@ impl DynamicBucketAssigner {
     }
 }
 
-impl BucketAssigner for DynamicBucketAssigner {
+impl BucketAssigner for DynamicBucketIndex {
     async fn assign_batch(
         &mut self,
         batch: &RecordBatch,
@@ -1005,6 +1221,79 @@ mod tests {
 
     // -- PartitionIndex tests --
 
+    #[test]
+    fn input_allocation_uses_owned_holes_without_writer_maintainers() {
+        let mut index = PartitionIndex::empty(1);
+        index.hash_to_bucket.insert(10, 3);
+        index.hash_to_bucket.insert(20, 0);
+        index.all_buckets.insert(0);
+        index.bucket_ids.push(0);
+        // Java restores foreign mappings but only keeps owned buckets as
+        // candidates for new keys. Historical max bucket 3 cannot hide id 2.
+        assert_eq!(
+            index.allocate(10, 4, |bucket| bucket % 2 == 0).unwrap(),
+            (3, false)
+        );
+        assert_eq!(
+            index.allocate(30, 4, |bucket| bucket % 2 == 0).unwrap(),
+            (2, true)
+        );
+        let (bucket, new) = index.allocate(40, 4, |bucket| bucket % 2 == 
0).unwrap();
+        assert!(new);
+        assert!([0, 2].contains(&bucket));
+        assert_eq!(
+            index.allocate(40, 4, |bucket| bucket % 2 == 0).unwrap(),
+            (bucket, false)
+        );
+        assert!(index.bucket_maintainers.is_empty());
+    }
+
+    #[tokio::test]
+    async fn 
requested_hash_reads_cross_chunks_and_validate_complete_file_size() {
+        let io = crate::io::FileIOBuilder::new("memory").build().unwrap();
+        let count = HASH_INDEX_READ_CHUNK_SIZE as usize / 4 + 3;
+        let hashes = (0..count as i32).collect::<Vec<_>>();
+        let meta = write_hash_index(&io, "memory:/input-hash-chunks/index", 
&hashes)
+            .await
+            .unwrap();
+        let path = format!("memory:/input-hash-chunks/index/{}", 
meta.file_name);
+        let requested =
+            HashSet::from([0, count as i32 - 4, count as i32 - 3, count as i32 
- 1, -1]);
+        let found = HashIndexFile::read_requested(&io, &path, meta.row_count, 
&requested)
+            .await
+            .unwrap();
+        assert_eq!(
+            found,
+            requested
+                .difference(&HashSet::from([-1]))
+                .copied()
+                .collect()
+        );
+        assert_eq!(found.len(), 4);
+        assert!(
+            HashIndexFile::read_requested(&io, &path, meta.row_count - 1, 
&requested)
+                .await
+                .unwrap_err()
+                .to_string()
+                .contains("Corrupt HASH")
+        );
+        let output = 
io.new_output("memory:/input-hash-chunks/trailing").unwrap();
+        output
+            .write(bytes::Bytes::from_static(&[0, 0, 0, 1, 2]))
+            .await
+            .unwrap();
+        assert!(HashIndexFile::read_requested(
+            &io,
+            "memory:/input-hash-chunks/trailing",
+            1,
+            &requested
+        )
+        .await
+        .unwrap_err()
+        .to_string()
+        .contains("Corrupt HASH"));
+    }
+
     #[test]
     fn test_assign_new_keys() {
         let mut index = PartitionIndex::empty(3);
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 51999aff..86b8f54d 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -37,7 +37,6 @@ mod branch_write_tests;
 mod bucket_assigner;
 mod bucket_assigner_constant;
 mod bucket_assigner_cross;
-mod bucket_assigner_dynamic;
 mod bucket_assigner_fixed;
 mod bucket_filter;
 mod bucket_function;
@@ -58,6 +57,9 @@ mod data_file_writer;
 mod de_vector_read;
 mod de_vector_scan;
 mod dedicated_format_file_writer;
+mod dynamic_bucket_assigner;
+mod dynamic_bucket_assigner_router;
+mod dynamic_bucket_index;
 mod external_path;
 mod format_partition;
 mod format_partition_location;
@@ -214,6 +216,10 @@ pub use consumer_manager::ConsumerManager;
 pub use cow_writer::{CopyOnWriteMergeWriter, FileInfo};
 pub use data_evolution_writer::{DataEvolutionDeleteWriter, 
DataEvolutionWriter};
 pub use de_vector_scan::PreparedVectorSearchFilter;
+pub use dynamic_bucket_assigner::{DynamicBucketAssigner, 
DynamicBucketAssignerBuilder};
+pub use dynamic_bucket_assigner_router::{
+    DynamicBucketAssignerRouter, DynamicBucketAssignerRouting,
+};
 pub use format_partition::{
     format_partition_value, parse_format_partition_value, 
FormatTablePartitionPaths,
 };
diff --git a/crates/paimon/src/table/table_write.rs 
b/crates/paimon/src/table/table_write.rs
index 71e20b9a..9a18df92 100644
--- a/crates/paimon/src/table/table_write.rs
+++ b/crates/paimon/src/table/table_write.rs
@@ -32,13 +32,13 @@ use crate::spec::{
 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;
 use crate::table::bucket_assigner_fixed::FixedBucketAssigner;
 use crate::table::bucket_function::validate_bucket_function;
 use crate::table::commit_message::CommitMessage;
 use crate::table::data_file_index_writer::FileIndexOptions;
 use crate::table::data_file_writer::DataFileWriter;
 use 
crate::table::dedicated_format_file_writer::AppendDedicatedFormatFileWriter;
+use crate::table::dynamic_bucket_index::DynamicBucketIndex;
 use crate::table::format_table_writer::FormatTableWriter;
 use crate::table::kv_file_writer::{KeyValueFileWriter, KeyValueWriteConfig};
 use crate::table::partition_filter::PartitionFilter;
@@ -470,7 +470,7 @@ impl TableWrite {
                 merge_engine,
             )?))
         } else if is_dynamic_bucket {
-            BucketAssignerEnum::Dynamic(Box::new(DynamicBucketAssigner::new(
+            BucketAssignerEnum::Dynamic(Box::new(DynamicBucketIndex::new(
                 table.clone(),
                 partition_field_indices,
                 primary_key_indices.clone(),

Reply via email to