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 0a4470e8 Align dynamic bucket writes and HASH index recovery with Java 
(#1016)
0a4470e8 is described below

commit 0a4470e85102665aa4f166197648cd25c6cb7ba8
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Oct 3 21:52:26 2026 +0800

    Align dynamic bucket writes and HASH index recovery with Java (#1016)
---
 crates/paimon/src/spec/core_options.rs             |  20 +-
 crates/paimon/src/table/bucket_assigner_dynamic.rs | 622 +++++++++++++++++----
 crates/paimon/src/table/data_evolution_writer.rs   |   8 +-
 crates/paimon/src/table/index_file_path.rs         | 250 +--------
 crates/paimon/src/table/table_scan.rs              |   7 -
 crates/paimon/src/table/table_write.rs             |  42 +-
 crates/paimon/tests/dynamic_bucket_write_test.rs   | 202 +++++++
 crates/paimon/tests/scan_planning_parity_test.rs   |  33 +-
 crates/paimon/tests/table_update_paths_test.rs     |  52 +-
 9 files changed, 844 insertions(+), 392 deletions(-)

diff --git a/crates/paimon/src/spec/core_options.rs 
b/crates/paimon/src/spec/core_options.rs
index 8760c454..f842ed2e 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -156,7 +156,9 @@ const DEFAULT_PARQUET_ROW_GROUP_MAX_INFLIGHT_BYTES: i64 = 
256 * 1024 * 1024;
 const DEFAULT_MOSAIC_READ_PREFETCH_ROW_GROUPS: usize = 8;
 const DEFAULT_MOSAIC_READ_PREFETCH_MAX_BYTES: i64 = 64 * 1024 * 1024;
 const DYNAMIC_BUCKET_TARGET_ROW_NUM_OPTION: &str = 
"dynamic-bucket.target-row-num";
-const DEFAULT_DYNAMIC_BUCKET_TARGET_ROW_NUM: i64 = 200_000;
+const DEFAULT_DYNAMIC_BUCKET_TARGET_ROW_NUM: i64 = 2_000_000;
+pub(crate) const MAX_DYNAMIC_BUCKETS: i32 = 32768;
+const DYNAMIC_BUCKET_MAX_BUCKETS_OPTION: &str = "dynamic-bucket.max-buckets";
 const DEFAULT_GLOBAL_INDEX_ROW_COUNT_PER_SHARD: i64 = 100_000;
 const DEFAULT_GLOBAL_INDEX_THREAD_NUM: i64 = 32;
 pub(crate) const DEFAULT_GLOBAL_INDEX_VINDEX_READ_THREAD_NUM: usize = 64;
@@ -1714,7 +1716,7 @@ impl<'a> CoreOptions<'a> {
 
     /// Target row number per bucket for dynamic bucket mode (bucket=-1).
     /// When a bucket reaches this number, a new bucket is created.
-    /// Default is 200,000. Java Paimon defaults this to 2,000,000.
+    /// Default is 2,000,000, matching Java Paimon.
     pub fn dynamic_bucket_target_row_num(&self) -> i64 {
         self.options
             .get(DYNAMIC_BUCKET_TARGET_ROW_NUM_OPTION)
@@ -1722,6 +1724,20 @@ impl<'a> CoreOptions<'a> {
             .unwrap_or(DEFAULT_DYNAMIC_BUCKET_TARGET_ROW_NUM)
     }
 
+    /// Maximum number of dynamic buckets in each partition. `-1` grows up to
+    /// Java's signed-short bucket id limit; an explicit limit reuses full 
buckets.
+    pub fn dynamic_bucket_max_buckets(&self) -> crate::Result<i32> {
+        let value = self
+            .parse_i64_option(DYNAMIC_BUCKET_MAX_BUCKETS_OPTION)?
+            .unwrap_or(-1);
+        if value != -1 && 
!(1..=i64::from(MAX_DYNAMIC_BUCKETS)).contains(&value) {
+            return Err(crate::Error::ConfigInvalid {
+                message: format!("'{DYNAMIC_BUCKET_MAX_BUCKETS_OPTION}' must 
be -1 or between 1 and {MAX_DYNAMIC_BUCKETS}, but was {value}"),
+            });
+        }
+        Ok(value as i32)
+    }
+
     /// When true, blob field reads return serialized BlobDescriptor bytes
     /// instead of actual blob bytes. Default is false.
     pub fn blob_as_descriptor(&self) -> bool {
diff --git a/crates/paimon/src/table/bucket_assigner_dynamic.rs 
b/crates/paimon/src/table/bucket_assigner_dynamic.rs
index 2919d599..6dbc50aa 100644
--- a/crates/paimon/src/table/bucket_assigner_dynamic.rs
+++ b/crates/paimon/src/table/bucket_assigner_dynamic.rs
@@ -21,15 +21,20 @@
 //! and per-partition index (`PartitionIndex`) used by both dynamic and 
cross-partition modes.
 
 use crate::io::FileIO;
+use crate::spec::MAX_DYNAMIC_BUCKETS;
 use crate::spec::{
-    batch_hash_codes, batch_to_serialized_bytes, bucket_path_under, BinaryRow, 
DataField,
-    IndexFileMeta, IndexManifest, IndexManifestEntry, PartitionComputer, 
EMPTY_SERIALIZED_ROW,
+    batch_hash_codes, batch_to_serialized_bytes, bucket_path_under, BinaryRow, 
CoreOptions,
+    DataField, IndexFileMeta, IndexManifest, IndexManifestEntry, 
PartitionComputer,
+    EMPTY_SERIALIZED_ROW,
 };
 use crate::table::bucket_assigner::{BatchAssignOutput, BucketAssigner, 
PartitionBucketKey};
+use crate::table::data_file_path_factory::{DataFilePath, DataFilePathFactory};
 use crate::table::index_file_path::IndexFileLocation;
-use crate::table::SnapshotManager;
+use crate::table::partition_filter::PartitionFilter;
+use crate::table::{Snapshot, SnapshotManager, Table, TableScan};
 use crate::Result;
 use arrow_array::RecordBatch;
+use rand::seq::SliceRandom;
 use std::collections::{HashMap, HashSet};
 use uuid::Uuid;
 
@@ -51,11 +56,15 @@ impl HashIndexFile {
     async fn read(file_io: &FileIO, path: &str) -> Result<Vec<i32>> {
         let input = file_io.new_input(path)?;
         let content = input.read().await?;
-        debug_assert!(
-            content.len() % 4 == 0,
-            "hash index file size {} is not aligned to 4 bytes",
-            content.len()
-        );
+        if content.len() % 4 != 0 {
+            return Err(crate::Error::DataInvalid {
+                message: format!(
+                    "Corrupt HASH index {path}: expected a multiple of 4 
bytes, got {}",
+                    content.len()
+                ),
+                source: None,
+            });
+        }
         let count = content.len() / 4;
         let mut hashes = Vec::with_capacity(count);
         for i in 0..count {
@@ -72,10 +81,13 @@ impl HashIndexFile {
     }
 
     /// Write key hashes to a new hash index file, returning its metadata.
-    async fn write(file_io: &FileIO, dir: &str, hashes: &[i32]) -> 
Result<IndexFileMeta> {
-        let file_name = format!("index-{}-0", Uuid::new_v4());
-        let path = format!("{dir}/{file_name}");
-
+    async fn write_at(
+        file_io: &FileIO,
+        file_name: String,
+        location: DataFilePath,
+        hashes: &[i32],
+    ) -> Result<IndexFileMeta> {
+        file_io.mkdirs(location.parent()).await?;
         let mut buf = Vec::with_capacity(hashes.len() * 4);
         for &h in hashes {
             buf.extend_from_slice(&h.to_be_bytes());
@@ -85,8 +97,11 @@ impl HashIndexFile {
             .len()
             .try_into()
             .expect("hash index file size exceeds i64::MAX");
-        let output = file_io.new_output(&path)?;
-        output.write(bytes::Bytes::from(buf)).await?;
+        let output = file_io.new_output(&location.path)?;
+        if let Err(error) = output.write(bytes::Bytes::from(buf)).await {
+            let _ = file_io.delete_file(&location.path).await;
+            return Err(error);
+        }
 
         Ok(IndexFileMeta {
             index_type: HASH_INDEX.to_string(),
@@ -97,7 +112,7 @@ impl HashIndexFile {
                 .try_into()
                 .expect("hash index row count exceeds i32::MAX"),
             deletion_vectors_ranges: None,
-            external_path: None,
+            external_path: location.external_path,
             global_index_meta: None,
         })
     }
@@ -113,11 +128,13 @@ impl HashIndexFile {
 /// `prepare_commit` writes the full hash set to a new hash index file.
 ///
 /// Reference: 
[org.apache.paimon.index.DynamicBucketIndexMaintainer](https://github.com/apache/paimon/blob/master/paimon-core/src/main/java/org/apache/paimon/index/DynamicBucketIndexMaintainer.java)
-pub(crate) struct DynamicBucketIndexMaintainer {
+struct DynamicBucketIndexMaintainer {
     /// All key hashes in this bucket (restored + new).
     hashes: HashSet<i32>,
     /// Whether any new hashes were added since last commit.
     modified: bool,
+    /// Java caches a per-bucket index path factory across checkpoints.
+    paths: Option<DataFilePathFactory>,
 }
 
 impl DynamicBucketIndexMaintainer {
@@ -127,6 +144,7 @@ impl DynamicBucketIndexMaintainer {
         Self {
             hashes,
             modified: false,
+            paths: None,
         }
     }
 
@@ -141,13 +159,34 @@ impl DynamicBucketIndexMaintainer {
     pub async fn prepare_commit(
         &mut self,
         file_io: &FileIO,
-        index_dir: &str,
+        layout: &HashIndexLayout<'_>,
+        bucket: i32,
+        options: &HashMap<String, String>,
     ) -> Result<Vec<IndexFileMeta>> {
         if !self.modified {
             return Ok(Vec::new());
         }
         let hashes: Vec<i32> = self.hashes.iter().copied().collect();
-        let meta = HashIndexFile::write(file_io, index_dir, &hashes).await?;
+        let file_name = format!("index-{}-0", Uuid::new_v4());
+        let location = if layout.index_file_in_data_file_dir {
+            if self.paths.is_none() {
+                self.paths = Some(DataFilePathFactory::new(
+                    layout.table_path,
+                    layout.partition_path,
+                    bucket,
+                    options,
+                )?);
+            }
+            self.paths.as_ref().unwrap().new_path(&file_name)?
+        } else {
+            let external_path =
+                super::external_path::new_index_external_path(options, false, 
"", &file_name)?;
+            DataFilePath {
+                path: layout.resolve(bucket, &file_name, 
external_path.as_deref()),
+                external_path,
+            }
+        };
+        let meta = HashIndexFile::write_at(file_io, file_name, location, 
&hashes).await?;
         self.modified = false;
         Ok(vec![meta])
     }
@@ -187,7 +226,8 @@ impl HashIndexLayout<'_> {
         bucket_path_under(&data_root, self.partition_path, bucket)
     }
 
-    /// The directory a new hash index file for `bucket` is written into.
+    /// The default directory for a hash index without an external location.
+    #[cfg(test)]
     fn directory(&self, bucket: i32) -> String {
         let bucket_path = self.bucket_path(bucket);
         self.location(&bucket_path).directory()
@@ -214,8 +254,9 @@ struct PartitionIndex {
     non_full_buckets: HashMap<i32, i64>,
     /// All known bucket ids
     all_buckets: HashSet<i32>,
-    /// Next bucket id to allocate (avoids linear scan).
+    /// Next unused bucket id to try, including holes in a restored index.
     next_bucket_id: i32,
+    bucket_ids: Vec<i32>,
     target_bucket_row_number: i64,
     /// Per-bucket index maintainers for writing hash index files at commit 
time.
     bucket_maintainers: HashMap<i32, DynamicBucketIndexMaintainer>,
@@ -229,6 +270,7 @@ impl PartitionIndex {
             non_full_buckets: HashMap::new(),
             all_buckets: HashSet::new(),
             next_bucket_id: 0,
+            bucket_ids: Vec::new(),
             target_bucket_row_number,
             bucket_maintainers: HashMap::new(),
         }
@@ -253,6 +295,21 @@ impl PartitionIndex {
                 continue;
             }
             let bucket = entry.bucket;
+            if !(0..MAX_DYNAMIC_BUCKETS).contains(&bucket) {
+                return Err(crate::Error::DataInvalid {
+                    message: format!(
+                        "Dynamic bucket id must be between 0 and {}, but was 
{bucket}",
+                        MAX_DYNAMIC_BUCKETS - 1
+                    ),
+                    source: None,
+                });
+            }
+            if bucket_hashes.contains_key(&bucket) {
+                return Err(crate::Error::DataInvalid {
+                    message: format!("Multiple HASH indexes for dynamic bucket 
{bucket}"),
+                    source: None,
+                });
+            }
             let path = layout.resolve(
                 bucket,
                 &entry.index_file.file_name,
@@ -260,8 +317,23 @@ impl PartitionIndex {
             );
             let hashes = HashIndexFile::read(file_io, &path).await?;
             let count = hashes.len() as i64;
+            if count != entry.index_file.row_count {
+                return Err(crate::Error::DataInvalid {
+                    message: format!(
+                        "Corrupt HASH index {path}: expected {} hashes, got 
{count}",
+                        entry.index_file.row_count
+                    ),
+                    source: None,
+                });
+            }
             for &h in &hashes {
-                hash_to_bucket.insert(h, bucket);
+                if let Some(previous) = hash_to_bucket.insert(h, bucket) {
+                    if previous != bucket {
+                        return Err(crate::Error::DataInvalid {
+                            message: format!("HASH index assigns hash {h} to 
both buckets {previous} and {bucket}"), source: None,
+                        });
+                    }
+                }
             }
             *bucket_row_counts.entry(bucket).or_insert(0) += count;
             bucket_hashes.entry(bucket).or_default().extend(hashes);
@@ -278,13 +350,14 @@ impl PartitionIndex {
             .map(|(bucket, hashes)| (bucket, 
DynamicBucketIndexMaintainer::new(hashes)))
             .collect();
 
-        let next_bucket_id = all_buckets.iter().copied().max().map_or(0, |m| m 
+ 1);
+        let bucket_ids = all_buckets.iter().copied().collect();
 
         Ok(Self {
             hash_to_bucket,
             non_full_buckets,
             all_buckets,
-            next_bucket_id,
+            next_bucket_id: 0,
+            bucket_ids,
             target_bucket_row_number,
             bucket_maintainers,
         })
@@ -295,10 +368,10 @@ impl PartitionIndex {
     /// 1. If the hash was seen before, return its existing bucket.
     /// 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) -> i32 {
+    fn assign(&mut self, hash: i32, max_buckets: i32) -> Result<i32> {
         // 1. Already assigned
         if let Some(&bucket) = self.hash_to_bucket.get(&hash) {
-            return bucket;
+            return Ok(bucket);
         }
 
         // 2. Find a non-full bucket
@@ -322,20 +395,45 @@ impl PartitionIndex {
                 .entry(bucket)
                 .or_insert_with(|| DynamicBucketIndexMaintainer::new(vec![]))
                 .notify_new_record(hash);
-            return bucket;
+            return Ok(bucket);
         }
 
-        // 3. Create a new bucket
-        let new_bucket = self.next_bucket_id;
-        self.next_bucket_id += 1;
-        self.all_buckets.insert(new_bucket);
-        self.non_full_buckets.insert(new_bucket, 1);
-        self.hash_to_bucket.insert(hash, new_bucket);
+        // Java PartitionIndex first allocates an unused id in range. A 
configured
+        // maximum is a soft row-count limit: once reached, reuse an existing 
bucket.
+        let limit = if max_buckets == -1 {
+            MAX_DYNAMIC_BUCKETS
+        } else {
+            max_buckets
+        };
+        while self.next_bucket_id < limit && 
self.all_buckets.contains(&self.next_bucket_id) {
+            self.next_bucket_id += 1;
+        }
+        let bucket = if self.next_bucket_id < limit {
+            let bucket = self.next_bucket_id;
+            self.next_bucket_id += 1;
+            self.all_buckets.insert(bucket);
+            self.bucket_ids.push(bucket);
+            self.non_full_buckets.insert(bucket, 1);
+            bucket
+        } else if max_buckets == -1 {
+            return Err(crate::Error::DataInvalid {
+                message: "No dynamic bucket id remains below Java 
Short.MAX_VALUE. Increase dynamic-bucket.target-row-num.".to_string(), source: 
None,
+            });
+        } else {
+            *self
+                .bucket_ids
+                .choose(&mut rand::thread_rng())
+                .ok_or_else(|| crate::Error::DataInvalid {
+                    message: "No dynamic bucket is available".to_string(),
+                    source: None,
+                })?
+        };
+        self.hash_to_bucket.insert(hash, bucket);
         self.bucket_maintainers
-            .entry(new_bucket)
+            .entry(bucket)
             .or_insert_with(|| DynamicBucketIndexMaintainer::new(vec![]))
             .notify_new_record(hash);
-        new_bucket
+        Ok(bucket)
     }
 
     /// Write hash index files for all modified buckets, returning (bucket, 
index_files) pairs.
@@ -343,13 +441,23 @@ impl PartitionIndex {
         &mut self,
         file_io: &FileIO,
         layout: &HashIndexLayout<'_>,
+        options: &HashMap<String, String>,
+        created_paths: &mut Vec<String>,
     ) -> Result<Vec<(i32, Vec<IndexFileMeta>)>> {
         let mut result = Vec::new();
         let buckets: Vec<i32> = 
self.bucket_maintainers.keys().copied().collect();
         for bucket in buckets {
             if let Some(maintainer) = self.bucket_maintainers.get_mut(&bucket) 
{
-                let index_dir = layout.directory(bucket);
-                let files = maintainer.prepare_commit(file_io, 
&index_dir).await?;
+                let files = maintainer
+                    .prepare_commit(file_io, layout, bucket, options)
+                    .await?;
+                for file in &files {
+                    created_paths.push(layout.resolve(
+                        bucket,
+                        &file.file_name,
+                        file.external_path.as_deref(),
+                    ));
+                }
                 if !files.is_empty() {
                     result.push((bucket, files));
                 }
@@ -373,9 +481,9 @@ pub(crate) struct DynamicBucketAssigner {
     fields: Vec<DataField>,
     partition_indexes: HashMap<Vec<u8>, PartitionIndex>,
     target_bucket_row_number: i64,
-    file_io: FileIO,
-    table_location: String,
-    data_file_path_directory: Option<String>,
+    table: Table,
+    max_buckets: i32,
+    snapshot: Option<Snapshot>,
     /// Cached index manifest entries from the latest snapshot (loaded once).
     cached_index_entries: Option<Vec<IndexManifestEntry>>,
     /// Overwrite mode: skip loading existing index entries.
@@ -389,37 +497,33 @@ pub(crate) struct DynamicBucketAssigner {
 }
 
 impl DynamicBucketAssigner {
-    #[allow(clippy::too_many_arguments)]
     pub fn new(
+        table: Table,
         partition_field_indices: Vec<usize>,
         primary_key_indices: Vec<usize>,
-        fields: Vec<DataField>,
-        target_bucket_row_number: i64,
-        file_io: FileIO,
-        table_location: String,
         is_overwrite: bool,
         partition_computer: PartitionComputer,
-        index_file_in_data_file_dir: bool,
-    ) -> Self {
-        Self {
+    ) -> Result<Self> {
+        let options = CoreOptions::new(table.schema().options());
+        if table.schema().options().contains_key("bucket-key") {
+            return Err(crate::Error::ConfigInvalid {
+                message: "Cannot define 'bucket-key' in dynamic bucket 
mode".to_string(),
+            });
+        }
+        Ok(Self {
             partition_field_indices,
             primary_key_indices,
-            fields,
+            fields: table.schema().fields().to_vec(),
             partition_indexes: HashMap::new(),
-            target_bucket_row_number,
-            file_io,
-            table_location,
-            data_file_path_directory: None,
+            target_bucket_row_number: options.dynamic_bucket_target_row_num(),
+            max_buckets: options.dynamic_bucket_max_buckets()?,
+            snapshot: None,
             cached_index_entries: None,
             is_overwrite,
             partition_computer,
-            index_file_in_data_file_dir,
-        }
-    }
-
-    pub(super) fn with_data_file_path_directory(mut self, directory: 
Option<&str>) -> Self {
-        self.data_file_path_directory = directory.map(str::to_string);
-        self
+            index_file_in_data_file_dir: options.index_file_in_data_file_dir(),
+            table,
+        })
     }
 
     pub fn set_overwrite(&mut self, is_overwrite: bool) {
@@ -436,21 +540,24 @@ impl DynamicBucketAssigner {
             self.cached_index_entries = Some(Vec::new());
             return Ok(());
         }
-        let snapshot_manager =
-            SnapshotManager::new(self.file_io.clone(), 
self.table_location.clone());
+        let snapshot_manager = SnapshotManager::new(
+            self.table.file_io().clone(),
+            self.table.location().to_string(),
+        );
         let latest_snapshot = snapshot_manager.get_latest_snapshot().await?;
 
-        let entries = if let Some(snapshot) = latest_snapshot {
+        let entries = if let Some(snapshot) = &latest_snapshot {
             if let Some(index_manifest_name) = snapshot.index_manifest() {
                 let manifest_dir = snapshot_manager.manifest_dir();
                 let index_manifest_path = 
format!("{manifest_dir}/{index_manifest_name}");
-                IndexManifest::read(&self.file_io, &index_manifest_path).await?
+                IndexManifest::read(self.table.file_io(), 
&index_manifest_path).await?
             } else {
                 Vec::new()
             }
         } else {
             Vec::new()
         };
+        self.snapshot = latest_snapshot;
         self.cached_index_entries = Some(entries);
         Ok(())
     }
@@ -472,16 +579,19 @@ impl DynamicBucketAssigner {
             .cloned()
             .collect();
 
+        self.validate_data_buckets(partition_bytes, &partition_entries)
+            .await?;
         if !partition_entries.is_empty() {
             let partition_path = self.partition_path(partition_bytes)?;
+            let options = CoreOptions::new(self.table.schema().options());
             let layout = HashIndexLayout {
-                data_file_path_directory: 
self.data_file_path_directory.as_deref(),
-                table_path: self.table_location.trim_end_matches('/'),
+                data_file_path_directory: options.data_file_path_directory(),
+                table_path: self.table.location().trim_end_matches('/'),
                 partition_path: &partition_path,
                 index_file_in_data_file_dir: self.index_file_in_data_file_dir,
             };
             return PartitionIndex::load(
-                &self.file_io,
+                self.table.file_io(),
                 &layout,
                 &partition_entries,
                 self.target_bucket_row_number,
@@ -491,6 +601,39 @@ impl DynamicBucketAssigner {
 
         Ok(PartitionIndex::empty(self.target_bucket_row_number))
     }
+
+    /// Use the same snapshot as index restoration. Treating existing data 
without
+    /// its HASH index as a new partition can assign an old key to a different 
bucket.
+    async fn validate_data_buckets(
+        &self,
+        partition: &[u8],
+        indexes: &[IndexManifestEntry],
+    ) -> Result<()> {
+        let Some(snapshot) = &self.snapshot else {
+            return Ok(());
+        };
+        let fields = self.table.schema().partition_fields();
+        let filter = if fields.is_empty() {
+            None
+        } else {
+            Some(PartitionFilter::from_partition_set(
+                HashSet::from([partition.to_vec()]),
+                &fields,
+            )?)
+        };
+        let scan =
+            TableScan::new(&self.table, filter, vec![], None, None, 
None).with_scan_all_files();
+        let indexed: HashSet<i32> = indexes.iter().map(|entry| 
entry.bucket).collect();
+        for entry in scan.plan_manifest_entries(snapshot).await? {
+            if !indexed.contains(&entry.bucket()) {
+                return Err(crate::Error::DataInvalid {
+                    message: format!("Dynamic-bucket partition has data files 
but no complete HASH index for bucket {}. Rewrite the partition before 
incremental writes.", entry.bucket()),
+                    source: None,
+                });
+            }
+        }
+        Ok(())
+    }
 }
 
 impl BucketAssigner for DynamicBucketAssigner {
@@ -527,7 +670,7 @@ impl BucketAssigner for DynamicBucketAssigner {
         let mut buckets = Vec::with_capacity(batch.num_rows());
         for (row_idx, pb) in partition_bytes_vec.iter().enumerate() {
             let partition_index = self.partition_indexes.get_mut(pb).unwrap();
-            buckets.push(partition_index.assign(hash_codes[row_idx]));
+            buckets.push(partition_index.assign(hash_codes[row_idx], 
self.max_buckets)?);
         }
 
         Ok(BatchAssignOutput {
@@ -542,29 +685,48 @@ impl BucketAssigner for DynamicBucketAssigner {
         &mut self,
         file_io: &FileIO,
     ) -> Result<HashMap<PartitionBucketKey, Vec<IndexFileMeta>>> {
-        let mut result = HashMap::new();
-        let table_path = self.table_location.trim_end_matches('/').to_string();
-        let index_file_in_data_file_dir = self.index_file_in_data_file_dir;
-        let partition_keys: Vec<Vec<u8>> = 
self.partition_indexes.keys().cloned().collect();
-        let mut partition_paths = Vec::with_capacity(partition_keys.len());
-        for partition_bytes in &partition_keys {
-            partition_paths.push(self.partition_path(partition_bytes)?);
-        }
-        for (partition_bytes, partition_path) in 
partition_keys.into_iter().zip(partition_paths) {
-            let layout = HashIndexLayout {
-                data_file_path_directory: 
self.data_file_path_directory.as_deref(),
-                table_path: &table_path,
-                partition_path: &partition_path,
-                index_file_in_data_file_dir,
-            };
-            if let Some(partition_index) = 
self.partition_indexes.get_mut(&partition_bytes) {
-                let bucket_files = partition_index.prepare_commit(file_io, 
&layout).await?;
-                for (bucket, idx_files) in bucket_files {
-                    result.insert((partition_bytes.clone(), bucket), 
idx_files);
+        let mut created_paths = Vec::new();
+        let result = async {
+            let mut result = HashMap::new();
+            let table_path = 
self.table.location().trim_end_matches('/').to_string();
+            let index_file_in_data_file_dir = self.index_file_in_data_file_dir;
+            let partition_keys: Vec<Vec<u8>> = 
self.partition_indexes.keys().cloned().collect();
+            let mut partition_paths = Vec::with_capacity(partition_keys.len());
+            for partition_bytes in &partition_keys {
+                partition_paths.push(self.partition_path(partition_bytes)?);
+            }
+            for (partition_bytes, partition_path) in 
partition_keys.into_iter().zip(partition_paths)
+            {
+                let options = CoreOptions::new(self.table.schema().options());
+                let layout = HashIndexLayout {
+                    data_file_path_directory: 
options.data_file_path_directory(),
+                    table_path: &table_path,
+                    partition_path: &partition_path,
+                    index_file_in_data_file_dir,
+                };
+                if let Some(partition_index) = 
self.partition_indexes.get_mut(&partition_bytes) {
+                    let bucket_files = partition_index
+                        .prepare_commit(
+                            file_io,
+                            &layout,
+                            self.table.schema().options(),
+                            &mut created_paths,
+                        )
+                        .await?;
+                    for (bucket, idx_files) in bucket_files {
+                        result.insert((partition_bytes.clone(), bucket), 
idx_files);
+                    }
                 }
             }
+            Ok(result)
         }
-        Ok(result)
+        .await;
+        if result.is_err() {
+            for path in created_paths {
+                let _ = file_io.delete_file(&path).await;
+            }
+        }
+        result
     }
 }
 
@@ -572,6 +734,28 @@ impl BucketAssigner for DynamicBucketAssigner {
 mod tests {
     use super::*;
 
+    fn table_layout(table_path: &str) -> HashIndexLayout<'_> {
+        HashIndexLayout {
+            table_path,
+            partition_path: "",
+            data_file_path_directory: None,
+            index_file_in_data_file_dir: false,
+        }
+    }
+
+    async fn write_hash_index(
+        file_io: &FileIO,
+        dir: &str,
+        hashes: &[i32],
+    ) -> Result<IndexFileMeta> {
+        let file_name = format!("index-{}-0", Uuid::new_v4());
+        let location = DataFilePath {
+            path: format!("{dir}/{file_name}"),
+            external_path: None,
+        };
+        HashIndexFile::write_at(file_io, file_name, location, hashes).await
+    }
+
     // -- DynamicBucketIndexMaintainer tests --
 
     #[tokio::test]
@@ -582,20 +766,29 @@ mod tests {
 
         let mut m = DynamicBucketIndexMaintainer::new(vec![]);
         // No modification → empty
-        let files = m.prepare_commit(&file_io, &dir).await.unwrap();
+        let files = m
+            .prepare_commit(&file_io, &table_layout(&dir), 0, &HashMap::new())
+            .await
+            .unwrap();
         assert!(files.is_empty());
 
         // Add hashes
         m.notify_new_record(1);
         m.notify_new_record(2);
         m.notify_new_record(1); // duplicate, no effect
-        let files = m.prepare_commit(&file_io, &dir).await.unwrap();
+        let files = m
+            .prepare_commit(&file_io, &table_layout(&dir), 0, &HashMap::new())
+            .await
+            .unwrap();
         assert_eq!(files.len(), 1);
         assert_eq!(files[0].index_type, HASH_INDEX);
         assert_eq!(files[0].row_count, 2);
 
         // No new modification → empty again
-        let files = m.prepare_commit(&file_io, &dir).await.unwrap();
+        let files = m
+            .prepare_commit(&file_io, &table_layout(&dir), 0, &HashMap::new())
+            .await
+            .unwrap();
         assert!(files.is_empty());
     }
 
@@ -607,46 +800,261 @@ mod tests {
 
         let mut m = DynamicBucketIndexMaintainer::new(vec![10, 20]);
         // Restored hashes don't count as modified
-        let files = m.prepare_commit(&file_io, &dir).await.unwrap();
+        let files = m
+            .prepare_commit(&file_io, &table_layout(&dir), 0, &HashMap::new())
+            .await
+            .unwrap();
         assert!(files.is_empty());
 
         // Adding a new hash triggers write (includes restored + new)
         m.notify_new_record(30);
-        let files = m.prepare_commit(&file_io, &dir).await.unwrap();
+        let files = m
+            .prepare_commit(&file_io, &table_layout(&dir), 0, &HashMap::new())
+            .await
+            .unwrap();
         assert_eq!(files.len(), 1);
         assert_eq!(files[0].row_count, 3);
     }
 
+    #[tokio::test]
+    async fn test_hash_external_round_robin_survives_checkpoints() {
+        let tmp = tempfile::tempdir().unwrap();
+        let root = format!("file://{}", tmp.path().display());
+        let io = FileIO::from_url(&root).unwrap().build().unwrap();
+        let options = HashMap::from([
+            (
+                "data-file.external-paths".to_string(),
+                format!("{root}/one,{root}/two"),
+            ),
+            (
+                "data-file.external-paths.strategy".to_string(),
+                "round-robin".to_string(),
+            ),
+            ("data-file.path-directory".to_string(), "data".to_string()),
+        ]);
+        let layout = HashIndexLayout {
+            table_path: &root,
+            partition_path: "p=a/",
+            data_file_path_directory: Some("data"),
+            index_file_in_data_file_dir: true,
+        };
+        let mut maintainer = DynamicBucketIndexMaintainer::new(vec![]);
+        let mut locations = Vec::new();
+        for hash in [1, 2, 3] {
+            maintainer.notify_new_record(hash);
+            let files = maintainer
+                .prepare_commit(&io, &layout, 0, &options)
+                .await
+                .unwrap();
+            let path = files[0].external_path.as_ref().unwrap();
+            assert!(path.contains("/data/p=a/bucket-0/index-"));
+            assert_eq!(
+                HashIndexFile::read(&io, path).await.unwrap().len(),
+                hash as usize
+            );
+            locations.push(path.rsplit_once("/data/").unwrap().0.to_string());
+        }
+        assert_ne!(locations[0], locations[1]);
+        assert_eq!(locations[0], locations[2]);
+    }
+
     // -- PartitionIndex tests --
 
     #[test]
     fn test_assign_new_keys() {
         let mut index = PartitionIndex::empty(3);
-        assert_eq!(index.assign(100), 0);
-        assert_eq!(index.assign(200), 0);
-        assert_eq!(index.assign(300), 0);
+        assert_eq!(index.assign(100, -1).unwrap(), 0);
+        assert_eq!(index.assign(200, -1).unwrap(), 0);
+        assert_eq!(index.assign(300, -1).unwrap(), 0);
         // Bucket 0 is full, next key goes to bucket 1
-        assert_eq!(index.assign(400), 1);
+        assert_eq!(index.assign(400, -1).unwrap(), 1);
     }
 
     #[test]
     fn test_assign_existing_key() {
         let mut index = PartitionIndex::empty(10);
-        assert_eq!(index.assign(42), 0);
+        assert_eq!(index.assign(42, -1).unwrap(), 0);
         // Same hash returns same bucket
-        assert_eq!(index.assign(42), 0);
+        assert_eq!(index.assign(42, -1).unwrap(), 0);
     }
 
     #[test]
     fn test_multiple_buckets() {
         let mut index = PartitionIndex::empty(2);
-        assert_eq!(index.assign(1), 0);
-        assert_eq!(index.assign(2), 0);
+        assert_eq!(index.assign(1, -1).unwrap(), 0);
+        assert_eq!(index.assign(2, -1).unwrap(), 0);
         // Bucket 0 full
-        assert_eq!(index.assign(3), 1);
-        assert_eq!(index.assign(4), 1);
+        assert_eq!(index.assign(3, -1).unwrap(), 1);
+        assert_eq!(index.assign(4, -1).unwrap(), 1);
         // Bucket 1 full
-        assert_eq!(index.assign(5), 2);
+        assert_eq!(index.assign(5, -1).unwrap(), 2);
+    }
+
+    #[test]
+    fn test_dynamic_bucket_short_limit_and_explicit_reuse() {
+        let mut index = PartitionIndex::empty(1);
+        for bucket in 0..MAX_DYNAMIC_BUCKETS {
+            assert_eq!(index.assign(bucket, -1).unwrap(), bucket);
+        }
+        assert!(index
+            .assign(MAX_DYNAMIC_BUCKETS, -1)
+            .unwrap_err()
+            .to_string()
+            .contains("Short.MAX_VALUE"));
+        // Existing keys remain usable at the upper bound.
+        assert_eq!(
+            index.assign(MAX_DYNAMIC_BUCKETS - 1, -1).unwrap(),
+            MAX_DYNAMIC_BUCKETS - 1
+        );
+        let bucket = index
+            .assign(MAX_DYNAMIC_BUCKETS, MAX_DYNAMIC_BUCKETS)
+            .unwrap();
+        assert!((0..MAX_DYNAMIC_BUCKETS).contains(&bucket));
+        assert_eq!(
+            index
+                .assign(MAX_DYNAMIC_BUCKETS, MAX_DYNAMIC_BUCKETS)
+                .unwrap(),
+            bucket
+        );
+    }
+
+    #[tokio::test]
+    async fn test_dynamic_bucket_restore_reuses_unused_ids() {
+        let tmp = tempfile::tempdir().unwrap();
+        let root = format!("file://{}", tmp.path().display());
+        let io = FileIO::from_url(&root).unwrap().build().unwrap();
+        let meta = write_hash_index(&io, &format!("{root}/index"), &[42])
+            .await
+            .unwrap();
+        let entries = vec![IndexManifestEntry {
+            version: 1,
+            kind: crate::spec::FileKind::Add,
+            partition: EMPTY_SERIALIZED_ROW.to_vec(),
+            bucket: 2,
+            index_file: meta,
+        }];
+        let layout = HashIndexLayout {
+            table_path: &root,
+            partition_path: "",
+            data_file_path_directory: None,
+            index_file_in_data_file_dir: false,
+        };
+        let mut index = PartitionIndex::load(&io, &layout, &entries, 1)
+            .await
+            .unwrap();
+        assert_eq!(index.assign(42, 3).unwrap(), 2);
+        assert_eq!(index.assign(43, 3).unwrap(), 0);
+        assert_eq!(index.assign(44, 3).unwrap(), 1);
+        assert!((0..3).contains(&index.assign(45, 3).unwrap()));
+    }
+
+    #[tokio::test]
+    async fn test_dynamic_bucket_rejects_invalid_restored_indexes() {
+        let tmp = tempfile::tempdir().unwrap();
+        let root = format!("file://{}", tmp.path().display());
+        let io = FileIO::from_url(&root).unwrap().build().unwrap();
+        let meta = write_hash_index(&io, &format!("{root}/index"), &[42])
+            .await
+            .unwrap();
+        let entry = IndexManifestEntry {
+            version: 1,
+            kind: crate::spec::FileKind::Add,
+            partition: EMPTY_SERIALIZED_ROW.to_vec(),
+            bucket: 0,
+            index_file: meta,
+        };
+        let layout = HashIndexLayout {
+            table_path: &root,
+            partition_path: "",
+            data_file_path_directory: None,
+            index_file_in_data_file_dir: false,
+        };
+        for bucket in [-1, MAX_DYNAMIC_BUCKETS] {
+            let mut invalid = entry.clone();
+            invalid.bucket = bucket;
+            let result = PartitionIndex::load(&io, &layout, &[invalid], 
1).await;
+            assert!(result
+                .err()
+                .unwrap()
+                .to_string()
+                .contains("Dynamic bucket id"));
+        }
+        let duplicate = vec![entry.clone(), entry.clone()];
+        assert!(PartitionIndex::load(&io, &layout, &duplicate, 1)
+            .await
+            .err()
+            .unwrap()
+            .to_string()
+            .contains("Multiple HASH"));
+        let mut conflicting = entry.clone();
+        conflicting.bucket = 1;
+        assert!(
+            PartitionIndex::load(&io, &layout, &[entry.clone(), conflicting], 
1)
+                .await
+                .err()
+                .unwrap()
+                .to_string()
+                .contains("both buckets")
+        );
+        let mut truncated = entry.clone();
+        truncated.index_file.row_count = 2;
+        assert!(PartitionIndex::load(&io, &layout, &[truncated], 1)
+            .await
+            .err()
+            .unwrap()
+            .to_string()
+            .contains("expected 2 hashes"));
+        let path = layout.resolve(0, &entry.index_file.file_name, None);
+        io.new_output(&path)
+            .unwrap()
+            .write(bytes::Bytes::from_static(&[0, 0, 0]))
+            .await
+            .unwrap();
+        assert!(PartitionIndex::load(&io, &layout, &[entry], 1)
+            .await
+            .err()
+            .unwrap()
+            .to_string()
+            .contains("multiple of 4"));
+    }
+
+    #[tokio::test]
+    async fn test_dynamic_bucket_requires_configured_index_location() {
+        let tmp = tempfile::tempdir().unwrap();
+        let root = format!("file://{}", tmp.path().display());
+        let io = FileIO::from_url(&root).unwrap().build().unwrap();
+        let meta = write_hash_index(&io, &format!("{root}/index"), &[42])
+            .await
+            .unwrap();
+        let layout = HashIndexLayout {
+            table_path: &root,
+            partition_path: "p=a/",
+            data_file_path_directory: Some("data"),
+            index_file_in_data_file_dir: true,
+        };
+        let canonical = layout.resolve(0, &meta.file_name, None);
+        let entry = IndexManifestEntry {
+            version: 1,
+            kind: crate::spec::FileKind::Add,
+            partition: EMPTY_SERIALIZED_ROW.to_vec(),
+            bucket: 0,
+            index_file: meta,
+        };
+        let error = PartitionIndex::load(&io, &layout, 
std::slice::from_ref(&entry), 1)
+            .await
+            .err()
+            .unwrap();
+        assert!(error.to_string().contains(&entry.index_file.file_name));
+        io.new_output(&canonical)
+            .unwrap()
+            .write(bytes::Bytes::from_static(&[0, 0, 0, 43]))
+            .await
+            .unwrap();
+        let index = PartitionIndex::load(&io, &layout, &[entry], 1)
+            .await
+            .unwrap();
+        assert_eq!(index.hash_to_bucket.get(&43), Some(&0));
+        assert!(!index.hash_to_bucket.contains_key(&42));
     }
 
     // -- HashIndexFile tests --
@@ -670,7 +1078,7 @@ mod tests {
             let dir = layout.directory(3);
             file_io.mkdirs(&dir).await.unwrap();
             let hashes = vec![7i32, 8, 9];
-            let meta = HashIndexFile::write(&file_io, &dir, 
&hashes).await.unwrap();
+            let meta = write_hash_index(&file_io, &dir, 
&hashes).await.unwrap();
             let entries = vec![IndexManifestEntry {
                 version: 1,
                 kind: crate::spec::FileKind::Add,
@@ -702,7 +1110,7 @@ mod tests {
         let file_io = FileIO::from_url(&table_path).unwrap().build().unwrap();
         let external_dir = format!("{table_path}/elsewhere");
         file_io.mkdirs(&external_dir).await.unwrap();
-        let mut index_file = HashIndexFile::write(&file_io, &external_dir, 
&[42i32])
+        let mut index_file = write_hash_index(&file_io, &external_dir, 
&[42i32])
             .await
             .unwrap();
         index_file.external_path = Some(format!("{external_dir}/{}", 
index_file.file_name));
@@ -735,7 +1143,7 @@ mod tests {
         let file_io = FileIO::from_url(&dir).unwrap().build().unwrap();
 
         let hashes = vec![42, -1, 0, i32::MAX, i32::MIN];
-        let meta = HashIndexFile::write(&file_io, &dir, 
&hashes).await.unwrap();
+        let meta = write_hash_index(&file_io, &dir, &hashes).await.unwrap();
 
         assert_eq!(meta.index_type, HASH_INDEX);
         assert_eq!(meta.row_count, 5);
@@ -752,7 +1160,7 @@ mod tests {
         let dir = format!("file://{}", tmp.path().display());
         let file_io = FileIO::from_url(&dir).unwrap().build().unwrap();
 
-        let meta = HashIndexFile::write(&file_io, &dir, &[]).await.unwrap();
+        let meta = write_hash_index(&file_io, &dir, &[]).await.unwrap();
         assert_eq!(meta.row_count, 0);
         assert_eq!(meta.file_size, 0);
 
diff --git a/crates/paimon/src/table/data_evolution_writer.rs 
b/crates/paimon/src/table/data_evolution_writer.rs
index 7976bbf3..760f07ba 100644
--- a/crates/paimon/src/table/data_evolution_writer.rs
+++ b/crates/paimon/src/table/data_evolution_writer.rs
@@ -770,7 +770,7 @@ impl DataEvolutionDeleteWriter {
         let mut bitmaps = IndexMap::new();
         let mut deleted_index_files = Vec::new();
 
-        for mut entry in index_entries {
+        for entry in index_entries {
             if entry.kind != FileKind::Add
                 || entry.bucket != bucket
                 || entry.partition != partition
@@ -779,12 +779,6 @@ impl DataEvolutionDeleteWriter {
                 continue;
             }
             deleted_index_files.push(entry.index_file.clone());
-            // Preserve the original manifest identity in deleted_index_files.
-            // Only the copy used for reading acquires the legacy physical 
path.
-            layout
-                .location()
-                .resolve_legacy_deletion_vector(self.table.file_io(), &mut 
entry.index_file)
-                .await?;
             let Some(ranges) = 
entry.index_file.deletion_vectors_ranges.as_ref() else {
                 continue;
             };
diff --git a/crates/paimon/src/table/index_file_path.rs 
b/crates/paimon/src/table/index_file_path.rs
index 8523e03e..0b740385 100644
--- a/crates/paimon/src/table/index_file_path.rs
+++ b/crates/paimon/src/table/index_file_path.rs
@@ -34,9 +34,7 @@
 //! index kind it reads. Cleanup after a failed commit does not — see
 //! [`committed_index_file_path`].
 
-use crate::io::FileIO;
-use crate::spec::{bucket_path, BinaryRow, IndexFileMeta, IndexManifestEntry, 
PartitionComputer};
-use crate::table::Table;
+use crate::spec::IndexFileMeta;
 
 const INDEX_DIR: &str = "index";
 
@@ -80,35 +78,6 @@ impl IndexFileLocation<'_> {
         }
     }
 
-    /// Older Python DV writers ignored the bucket-directory option. Resolve
-    /// their existing files without changing the manifest or masking missing
-    /// canonical files with a path that does not exist either.
-    pub(super) async fn resolve_legacy_deletion_vector(
-        &self,
-        file_io: &FileIO,
-        file: &mut IndexFileMeta,
-    ) -> crate::Result<()> {
-        let Self::BucketLocal {
-            table_path,
-            index_file_in_data_file_dir: true,
-            ..
-        } = self
-        else {
-            return Ok(());
-        };
-        if file.index_type != "DELETION_VECTORS" || 
file.external_path.is_some() {
-            return Ok(());
-        }
-        let canonical_path = self.resolve(&file.file_name, None);
-        if !file_io.exists(&canonical_path).await? {
-            let legacy_path = format!("{table_path}/{INDEX_DIR}/{}", 
file.file_name);
-            if file_io.exists(&legacy_path).await? {
-                file.external_path = Some(legacy_path);
-            }
-        }
-        Ok(())
-    }
-
     /// Resolve the full path of `file_name`, honoring an explicit
     /// `external_path` when present.
     pub(crate) fn resolve(&self, file_name: &str, external_path: Option<&str>) 
-> String {
@@ -119,53 +88,6 @@ impl IndexFileLocation<'_> {
     }
 }
 
-/// Resolve each retained DV index once, before either split planning or a
-/// global-index evaluator consumes it. Explicit paths and ordinary table/index
-/// layouts require no additional filesystem requests.
-pub(crate) async fn resolve_legacy_deletion_vector_entries(
-    table: &Table,
-    entries: &mut [IndexManifestEntry],
-) -> crate::Result<()> {
-    let schema = table.schema();
-    let options = schema.core_options();
-    if !options.index_file_in_data_file_dir() {
-        return Ok(());
-    }
-    let partition_computer = if schema.partition_keys().is_empty() {
-        None
-    } else {
-        Some(PartitionComputer::new(
-            schema.partition_keys(),
-            schema.fields(),
-            options.partition_default_name(),
-            options.legacy_partition_name(),
-        )?)
-    };
-    let table_path = table.location().trim_end_matches('/');
-    for entry in entries {
-        if entry.index_file.index_type != "DELETION_VECTORS"
-            || entry.index_file.external_path.is_some()
-        {
-            continue;
-        }
-        let partition = BinaryRow::from_serialized_bytes(&entry.partition)?;
-        let bucket_path = bucket_path(
-            &table.data_file_location(),
-            partition_computer.as_ref(),
-            &partition,
-            entry.bucket,
-        )?;
-        IndexFileLocation::BucketLocal {
-            table_path,
-            bucket_path: &bucket_path,
-            index_file_in_data_file_dir: true,
-        }
-        .resolve_legacy_deletion_vector(table.file_io(), &mut entry.index_file)
-        .await?;
-    }
-    Ok(())
-}
-
 /// `"DEIX"` as a big-endian int, Java `DataEvolutionIndexSourceMeta`'s marker.
 /// `PrimaryKeyIndexSourceMeta` starts with its own version instead, so the 
marker
 /// tells the two apart — which is what Java added it for.
@@ -312,94 +234,6 @@ mod tests {
         }
     }
 
-    #[tokio::test]
-    async fn timestamp_dvs_use_canonical_paths_and_explicit_precedence() {
-        use crate::catalog::Identifier;
-        use crate::io::FileIOBuilder;
-        use crate::spec::{
-            bucket_path_under, BinaryRowBuilder, DataType, FileKind, Schema, 
TableSchema,
-            TimestampType,
-        };
-        for (legacy, millis, java_partition) in [
-            (false, 0, "ts=1970-01-01 00%3A00%3A00.000/"),
-            (true, 100, "ts=1970-01-01T00%3A00%3A00.100/"),
-        ] {
-            let dir = tempfile::tempdir().unwrap();
-            let table_path = dir.path().to_str().unwrap();
-            let schema = Schema::builder()
-                .column("ts", 
DataType::Timestamp(TimestampType::new(3).unwrap()))
-                .partition_keys(vec!["ts".to_string()])
-                .option("partition.legacy-name", legacy.to_string())
-                .option("index-file-in-data-file-dir", "true")
-                .build()
-                .unwrap();
-            let schema = TableSchema::new(0, &schema);
-            let file_io = FileIOBuilder::new("file").build().unwrap();
-            let table = Table::new(
-                file_io,
-                Identifier::new("db", "t"),
-                table_path.to_string(),
-                schema,
-                None,
-            );
-            let java_bucket = bucket_path_under(table_path, java_partition, 7);
-            let table_index = format!("{table_path}/index");
-            for path in [&java_bucket, &table_index] {
-                std::fs::create_dir_all(path).unwrap();
-            }
-            for (parent, name, bytes) in [
-                (&java_bucket, "idx-java", b"Java".as_slice()),
-                (&table_index, "idx-java", b"wrong table copy".as_slice()),
-                (&table_index, "idx-table", b"table".as_slice()),
-                (&java_bucket, "idx-explicit", b"wrong Java copy".as_slice()),
-                (&table_index, "idx-explicit", b"wrong table copy".as_slice()),
-            ] {
-                std::fs::write(format!("{parent}/{name}"), bytes).unwrap();
-            }
-            let mut row = BinaryRowBuilder::new(1);
-            row.write_timestamp_compact(0, millis);
-            let partition = row.build_serialized();
-            let mut entries: Vec<_> = ["idx-java", "idx-table", 
"idx-explicit", "idx-missing"]
-                .into_iter()
-                .map(|name| {
-                    let mut file = committed_file(None);
-                    file.file_name = name.to_string();
-                    file.index_type = "DELETION_VECTORS".to_string();
-                    IndexManifestEntry {
-                        version: 1,
-                        kind: FileKind::Add,
-                        partition: partition.clone(),
-                        bucket: 7,
-                        index_file: file,
-                    }
-                })
-                .collect();
-            let explicit = 
format!("{table_path}/missing-explicit/idx-explicit");
-            entries[2].index_file.external_path = Some(explicit.clone());
-            resolve_legacy_deletion_vector_entries(&table, &mut entries)
-                .await
-                .unwrap();
-            assert_eq!(entries[0].index_file.external_path, None);
-            assert_eq!(
-                entries[1].index_file.external_path,
-                Some(format!("{table_index}/idx-table"))
-            );
-            assert_eq!(entries[2].index_file.external_path, 
Some(explicit.clone()));
-            assert_eq!(entries[3].index_file.external_path, None);
-            let location = IndexFileLocation::BucketLocal {
-                table_path,
-                bucket_path: &java_bucket,
-                index_file_in_data_file_dir: true,
-            };
-            for (entry, expected) in 
entries.iter().take(2).zip([b"Java".as_slice(), b"table"]) {
-                let file = &entry.index_file;
-                let path = location.resolve(&file.file_name, 
file.external_path.as_deref());
-                assert_eq!(std::fs::read(path).unwrap(), expected);
-            }
-            assert!(!table.file_io().exists(&explicit).await.unwrap());
-        }
-    }
-
     /// A committed index file with the given `_GLOBAL_INDEX` and 
`_SOURCE_META`.
     fn committed_file(global_index_meta: Option<Option<Vec<u8>>>) -> 
IndexFileMeta {
         IndexFileMeta {
@@ -420,75 +254,27 @@ mod tests {
         }
     }
 
-    #[tokio::test]
-    async fn 
legacy_dv_directory_fallback_preserves_canonical_and_external_precedence() {
-        use crate::io::FileIOBuilder;
-
+    #[test]
+    fn missing_bucket_index_does_not_use_table_directory() {
         let dir = tempfile::tempdir().unwrap();
         let table_path = dir.path().to_str().unwrap();
         let bucket_path = format!("{table_path}/pt=1/bucket-7");
-        let legacy_path = format!("{table_path}/index/idx-0");
-        let canonical_path = format!("{bucket_path}/idx-0");
-        let external_path = format!("{table_path}/external/idx-0");
-        for parent in [
-            format!("{table_path}/index"),
-            bucket_path.clone(),
-            format!("{table_path}/external"),
-        ] {
-            std::fs::create_dir_all(parent).unwrap();
+        let table_index = format!("{table_path}/index/idx-0");
+        std::fs::create_dir_all(format!("{table_path}/index")).unwrap();
+        std::fs::write(&table_index, b"wrong directory").unwrap();
+        for index_type in ["DELETION_VECTORS", "HASH"] {
+            let mut file = committed_file(None);
+            file.index_type = index_type.to_string();
+            let path = committed_index_file_path(table_path, &bucket_path, 
true, &file);
+            assert_eq!(path, format!("{bucket_path}/idx-0"));
+            assert_eq!(
+                std::fs::read(path).unwrap_err().kind(),
+                std::io::ErrorKind::NotFound
+            );
+            file.external_path = Some(table_index.clone());
+            let path = committed_index_file_path(table_path, &bucket_path, 
true, &file);
+            assert_eq!(std::fs::read(path).unwrap(), b"wrong directory");
         }
-        let file_io = FileIOBuilder::new("file").build().unwrap();
-        let location = IndexFileLocation::BucketLocal {
-            table_path,
-            bucket_path: &bucket_path,
-            index_file_in_data_file_dir: true,
-        };
-        let mut original = committed_file(None);
-        original.index_type = "DELETION_VECTORS".to_string();
-
-        // Missing files retain the canonical path and therefore fail on read.
-        let mut missing = original.clone();
-        location
-            .resolve_legacy_deletion_vector(&file_io, &mut missing)
-            .await
-            .unwrap();
-        assert_eq!(missing.external_path, None);
-
-        std::fs::write(&legacy_path, b"legacy DV").unwrap();
-        let mut legacy = original.clone();
-        location
-            .resolve_legacy_deletion_vector(&file_io, &mut legacy)
-            .await
-            .unwrap();
-        assert_eq!(legacy.external_path.as_deref(), 
Some(legacy_path.as_str()));
-
-        std::fs::write(&canonical_path, b"canonical DV").unwrap();
-        let mut canonical = original.clone();
-        location
-            .resolve_legacy_deletion_vector(&file_io, &mut canonical)
-            .await
-            .unwrap();
-        assert_eq!(canonical.external_path, None);
-        assert_eq!(
-            std::fs::read(location.resolve("idx-0", None)).unwrap(),
-            b"canonical DV"
-        );
-
-        // An explicit path is never substituted, even when the target is 
absent.
-        original.external_path = Some(external_path.clone());
-        location
-            .resolve_legacy_deletion_vector(&file_io, &mut original)
-            .await
-            .unwrap();
-        assert_eq!(original.external_path, Some(external_path));
-
-        std::fs::remove_file(&canonical_path).unwrap();
-        let mut other_index = committed_file(None);
-        location
-            .resolve_legacy_deletion_vector(&file_io, &mut other_index)
-            .await
-            .unwrap();
-        assert_eq!(other_index.external_path, None);
     }
 
     fn committed_path(file: &IndexFileMeta) -> String {
diff --git a/crates/paimon/src/table/table_scan.rs 
b/crates/paimon/src/table/table_scan.rs
index 6205cd11..cf88f85d 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -1804,13 +1804,6 @@ impl<'a> PaimonTableScan<'a> {
                 entries.push(entry);
             }
         }
-        if deletion_vectors_needed {
-            super::index_file_path::resolve_legacy_deletion_vector_entries(
-                self.table,
-                &mut entries,
-            )
-            .await?;
-        }
         Ok(Some(entries))
     }
 
diff --git a/crates/paimon/src/table/table_write.rs 
b/crates/paimon/src/table/table_write.rs
index 22a42356..3325a9c6 100644
--- a/crates/paimon/src/table/table_write.rs
+++ b/crates/paimon/src/table/table_write.rs
@@ -411,23 +411,13 @@ impl TableWrite {
                 merge_engine,
             )))
         } else if is_dynamic_bucket {
-            BucketAssignerEnum::Dynamic(Box::new(
-                DynamicBucketAssigner::new(
-                    partition_field_indices,
-                    primary_key_indices.clone(),
-                    schema.fields().to_vec(),
-                    target_bucket_row_number,
-                    table.file_io().clone(),
-                    table.location().to_string(),
-                    is_overwrite,
-                    // The same computer this writer already built: a hash 
index kept in
-                    // the data-file directory must land in the directory the 
writer and
-                    // the reader both derive, so both must agree on partition 
naming.
-                    partition_computer.clone(),
-                    core_options.index_file_in_data_file_dir(),
-                )
-                
.with_data_file_path_directory(core_options.data_file_path_directory()),
-            ))
+            BucketAssignerEnum::Dynamic(Box::new(DynamicBucketAssigner::new(
+                table.clone(),
+                partition_field_indices,
+                primary_key_indices.clone(),
+                is_overwrite,
+                partition_computer.clone(),
+            )?))
         } else if total_buckets == POSTPONE_BUCKET {
             BucketAssignerEnum::Constant(ConstantBucketAssigner::new(
                 partition_field_indices,
@@ -605,7 +595,13 @@ impl TableWrite {
             return Ok(());
         };
 
-        let grouped = self.divide_by_partition_bucket(&batch).await?;
+        let grouped = match self.divide_by_partition_bucket(&batch).await {
+            Ok(grouped) => grouped,
+            Err(error) => {
+                self.fail_write().await;
+                return Err(error);
+            }
+        };
         for ((partition_bytes, bucket), sub_batch) in grouped {
             self.write_bucket(partition_bytes, bucket, sub_batch)
                 .await?;
@@ -1008,12 +1004,18 @@ impl TableWrite {
         }
         .await;
         if result.is_err() {
-            self.close().await;
-            self.failed = true;
+            self.fail_write().await;
         }
         result
     }
 
+    /// Routing can update persistent bucket-index state before it fails, just
+    /// as file writes can produce output. Neither failure permits a later 
commit.
+    async fn fail_write(&mut self) {
+        self.close().await;
+        self.failed = true;
+    }
+
     fn ensure_active(&self) -> Result<()> {
         if self.failed {
             return Err(crate::Error::DataInvalid {
diff --git a/crates/paimon/tests/dynamic_bucket_write_test.rs 
b/crates/paimon/tests/dynamic_bucket_write_test.rs
new file mode 100644
index 00000000..8e29704a
--- /dev/null
+++ b/crates/paimon/tests/dynamic_bucket_write_test.rs
@@ -0,0 +1,202 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+mod common;
+
+use common::incremental_helpers::{make_batch, memory_table, pk_schema, 
setup_dirs};
+
+#[tokio::test]
+async fn dynamic_bucket_limit_survives_writer_restart() {
+    let (io, table) = memory_table(
+        "memory:/dynamic_limit",
+        pk_schema(&[
+            ("bucket", "-1"),
+            ("dynamic-bucket.target-row-num", "1"),
+            ("dynamic-bucket.max-buckets", "1"),
+        ]),
+    );
+    setup_dirs(&io, table.location()).await;
+    for ids in [vec![1, 2, 3], vec![3, 4, 5]] {
+        let builder = table.new_write_builder();
+        let mut writer = builder.new_write().unwrap();
+        writer
+            .write_arrow_batch(&make_batch(ids.clone(), ids))
+            .await
+            .unwrap();
+        let messages = writer.prepare_commit().await.unwrap();
+        assert_eq!(messages.len(), 1);
+        assert_eq!(messages[0].bucket, 0);
+        builder.new_commit().commit(messages).await.unwrap();
+    }
+}
+
+#[tokio::test]
+async fn dynamic_write_rejects_data_without_complete_hash_index() {
+    let (io, table) = memory_table(
+        "memory:/dynamic_missing",
+        pk_schema(&[("bucket", "-1"), ("dynamic-bucket.target-row-num", "1")]),
+    );
+    setup_dirs(&io, table.location()).await;
+    let builder = table.new_write_builder();
+    let mut writer = builder.new_write().unwrap();
+    writer
+        .write_arrow_batch(&make_batch(vec![1, 2], vec![10, 20]))
+        .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();
+    let mut writer = table.new_write_builder().new_write().unwrap();
+    let error = writer
+        .write_arrow_batch(&make_batch(vec![2], vec![200]))
+        .await
+        .unwrap_err();
+    assert!(error.to_string().contains("complete HASH index"), "{error}");
+    assert!(writer
+        .prepare_commit()
+        .await
+        .unwrap_err()
+        .to_string()
+        .contains("cannot be reused"));
+    assert!(writer
+        .write_arrow_batch(&make_batch(vec![3], vec![30]))
+        .await
+        .unwrap_err()
+        .to_string()
+        .contains("cannot be reused"));
+}
+
+#[test]
+fn dynamic_write_rejects_invalid_max_buckets() {
+    for value in ["0", "-2", "32769", "2147483648", "invalid"] {
+        let (_, table) = memory_table(
+            "memory:/dynamic_invalid",
+            pk_schema(&[("bucket", "-1"), ("dynamic-bucket.max-buckets", 
value)]),
+        );
+        let result = table.new_write_builder().new_write();
+        assert!(result.is_err(), "accepted max-buckets={value}");
+        assert!(result
+            .err()
+            .unwrap()
+            .to_string()
+            .contains("dynamic-bucket.max-buckets"));
+    }
+}
+
+#[test]
+fn dynamic_write_rejects_custom_bucket_keys() {
+    for key in ["id", ""] {
+        let (_, table) = memory_table(
+            "memory:/dynamic_key",
+            pk_schema(&[("bucket", "-1"), ("bucket-key", key)]),
+        );
+        let error = table.new_write_builder().new_write().err().unwrap();
+        assert!(error.to_string().contains("Cannot define 'bucket-key'"));
+    }
+}
+
+/// Inject failure after at least one bucket HASH was written, independently of
+/// HashMap iteration order. Record physical data/index paths to verify 
ownership.
+#[derive(Debug)]
+struct FailSecondHash {
+    operator: opendal::Operator,
+    indexes: std::sync::Mutex<Vec<String>>,
+    data: std::sync::Mutex<Vec<String>>,
+}
+
+#[async_trait::async_trait]
+impl paimon::io::FileIOProvider for FailSecondHash {
+    async fn create(&self, path: &str) -> paimon::Result<(opendal::Operator, 
String)> {
+        let path = path.strip_prefix("file://").unwrap_or(path);
+        let name = path.rsplit('/').next().unwrap();
+        if name.starts_with("index-") {
+            let mut indexes = self.indexes.lock().unwrap();
+            if !indexes.iter().any(|item| item == path) {
+                indexes.push(path.to_string());
+            }
+            if indexes.get(1).is_some_and(|item| item == path) {
+                return Err(paimon::Error::DataInvalid {
+                    message: "injected HASH write failure".into(),
+                    source: None,
+                });
+            }
+        }
+        if name.ends_with(".parquet") {
+            self.data.lock().unwrap().push(path.to_string());
+        }
+        Ok((
+            self.operator.clone(),
+            path.trim_start_matches('/').to_string(),
+        ))
+    }
+}
+
+#[tokio::test]
+async fn failed_hash_prepare_cleans_indexes_and_data() {
+    use paimon::catalog::Identifier;
+    use paimon::io::FileIOBuilder;
+    use paimon::table::Table;
+    use std::sync::Arc;
+
+    let tmp = tempfile::tempdir().unwrap();
+    let mut config = opendal_service_fs::FsConfig::default();
+    config.root = Some("/".into());
+    let provider = Arc::new(FailSecondHash {
+        operator: opendal::Operator::from_config(config).unwrap(),
+        indexes: Default::default(),
+        data: Default::default(),
+    });
+    let io = FileIOBuilder::new("file")
+        .with_provider(provider.clone())
+        .build()
+        .unwrap();
+    let path = tmp.path().to_str().unwrap();
+    let table = Table::new(
+        io.clone(),
+        Identifier::new("default", "failure"),
+        path.into(),
+        pk_schema(&[("bucket", "-1"), ("dynamic-bucket.target-row-num", "1")]),
+        None,
+    );
+    setup_dirs(&io, path).await;
+    let mut writer = table.new_write_builder().new_write().unwrap();
+    writer
+        .write_arrow_batch(&make_batch(vec![1, 2], vec![10, 20]))
+        .await
+        .unwrap();
+    let error = writer.prepare_commit().await.unwrap_err();
+    assert!(
+        error.to_string().contains("injected HASH write failure"),
+        "{error}"
+    );
+    let indexes = provider.indexes.lock().unwrap().clone();
+    assert_eq!(indexes.len(), 2);
+    assert!(indexes
+        .iter()
+        .all(|path| !std::path::Path::new(path).exists()));
+    let data = provider.data.lock().unwrap().clone();
+    assert!(!data.is_empty());
+    assert!(data.iter().all(|path| !std::path::Path::new(path).exists()));
+    assert!(writer
+        .prepare_commit()
+        .await
+        .unwrap_err()
+        .to_string()
+        .contains("cannot be reused"));
+}
diff --git a/crates/paimon/tests/scan_planning_parity_test.rs 
b/crates/paimon/tests/scan_planning_parity_test.rs
index a5836aaf..f08134db 100644
--- a/crates/paimon/tests/scan_planning_parity_test.rs
+++ b/crates/paimon/tests/scan_planning_parity_test.rs
@@ -734,9 +734,14 @@ async fn 
float_primary_key_snapshot_merges_versions_while_delta_keeps_events() {
 }
 
 #[tokio::test]
-async fn 
python_dv_manifest_layout_resolves_legacy_canonical_and_external_files() {
+async fn python_dv_manifest_resolves_java_index_locations() {
     use paimon::spec::IndexManifest;
-    for layout in ["legacy", "canonical", "external", "missing-external"] {
+    for layout in [
+        "missing-canonical",
+        "canonical",
+        "external",
+        "missing-external",
+    ] {
         let table = evolution_table_with_options(
             "memory:/planning_parity/python_dv",
             &[
@@ -773,7 +778,7 @@ async fn 
python_dv_manifest_layout_resolves_legacy_canonical_and_external_files(
             .copy_file(&source, &canonical)
             .await
             .unwrap();
-        let legacy = format!(
+        let table_index = format!(
             "{}/index/{}",
             table.location(),
             entries[0].index_file.file_name
@@ -781,22 +786,22 @@ async fn 
python_dv_manifest_layout_resolves_legacy_canonical_and_external_files(
         let external = format!("{}/external/dv", table.location());
         table
             .file_io()
-            .copy_file(&canonical, &legacy)
+            .copy_file(&canonical, &table_index)
             .await
             .unwrap();
         let expected_path = match layout {
-            "legacy" => {
+            "missing-canonical" => {
                 table.file_io().delete_file(&canonical).await.unwrap();
                 entries[0].index_file.external_path = None;
-                legacy
+                canonical
             }
             "canonical" => {
-                // The canonical file must win even if a stale legacy file 
exists.
+                // A same-name file in table/index must not affect 
bucket-local reads.
                 table
                     .file_io()
-                    .new_output(&legacy)
+                    .new_output(&table_index)
                     .unwrap()
-                    .write(bytes::Bytes::from_static(b"stale legacy DV"))
+                    .write(bytes::Bytes::from_static(b"wrong index directory"))
                     .await
                     .unwrap();
                 entries[0].index_file.external_path = None;
@@ -855,11 +860,15 @@ async fn 
python_dv_manifest_layout_resolves_legacy_canonical_and_external_files(
             .unwrap()
             .try_collect::<Vec<RecordBatch>>()
             .await;
-        if layout == "missing-external" {
+        if matches!(layout, "missing-canonical" | "missing-external") {
+            let error = batches.expect_err("a missing DV must not fall back to 
another directory");
             assert!(
-                batches.is_err(),
-                "an explicit missing DV must not fall back to a local copy"
+                error
+                    .to_string()
+                    .contains(expected_path.trim_start_matches("memory:/")),
+                "{error}"
             );
+            assert!(table.file_io().exists(&table_index).await.unwrap());
         } else {
             assert!(batches.is_ok(), "layout={layout}: {batches:?}");
             assert_eq!(read_ids(&table, &plan).await, vec![0, 2]);
diff --git a/crates/paimon/tests/table_update_paths_test.rs 
b/crates/paimon/tests/table_update_paths_test.rs
index 2c7590d0..18cded19 100644
--- a/crates/paimon/tests/table_update_paths_test.rs
+++ b/crates/paimon/tests/table_update_paths_test.rs
@@ -420,7 +420,7 @@ async fn 
external_deletion_vectors_repeat_time_travel_and_abort() {
 }
 
 #[tokio::test]
-async fn 
delete_reads_legacy_index_directory_without_changing_manifest_identity() {
+async fn delete_requires_bucket_index_and_preserves_manifest_on_failure() {
     let table = table(&[("index-file-in-data-file-dir", "true")]).await;
     seed(&table).await;
     let ids = row_ids(&table).await;
@@ -428,7 +428,7 @@ async fn 
delete_reads_legacy_index_directory_without_changing_manifest_identity(
     let old = messages[0].new_index_files[0].clone();
     assert!(old.external_path.is_none());
     let bucket_path = format!("{}/p=a/q=1/bucket-0/{}", table.location(), 
old.file_name);
-    let legacy_path = format!("{}/index/{}", table.location(), old.file_name);
+    let table_index_path = format!("{}/index/{}", table.location(), 
old.file_name);
     let bytes = table
         .file_io()
         .new_input(&bucket_path)
@@ -438,18 +438,60 @@ async fn 
delete_reads_legacy_index_directory_without_changing_manifest_identity(
         .unwrap();
     table
         .file_io()
-        .new_output(&legacy_path)
+        .new_output(&table_index_path)
         .unwrap()
-        .write(bytes)
+        .write(bytes.clone())
         .await
         .unwrap();
     table.file_io().delete_file(&bucket_path).await.unwrap();
     commit(&table, messages).await;
+
+    let mut writer = table.new_write_builder().new_delete().unwrap();
+    writer.add_row_ids(vec![ids[&1]]).unwrap();
+    let before = files(&table).await;
+    let error = writer.prepare_commit().await.unwrap_err();
+    assert!(
+        error
+            .to_string()
+            .contains(bucket_path.trim_start_matches("memory:/")),
+        "{error}"
+    );
+    assert_eq!(files(&table).await, before);
+    assert_eq!(
+        table
+            .snapshot_manager()
+            .get_latest_snapshot_id()
+            .await
+            .unwrap(),
+        Some(2)
+    );
+    let snapshot = table.snapshot_manager().get_snapshot(2).await.unwrap();
+    let entries = paimon::spec::IndexManifest::read(
+        table.file_io(),
+        &format!(
+            "{}/manifest/{}",
+            table.location(),
+            snapshot.index_manifest().unwrap()
+        ),
+    )
+    .await
+    .unwrap();
+    assert_eq!(entries.len(), 1);
+    assert_eq!(entries[0].index_file, old);
+
+    // Restoring the required Java location allows a fresh writer to proceed.
+    table
+        .file_io()
+        .new_output(&bucket_path)
+        .unwrap()
+        .write(bytes)
+        .await
+        .unwrap();
     let messages = delete(&table, vec![ids[&1]]).await;
     assert_eq!(messages[0].deleted_index_files, vec![old]);
     commit(&table, messages).await;
     assert_eq!(row_ids(&table).await.len(), 2);
-    assert!(table.file_io().exists(&legacy_path).await.unwrap());
+    assert!(table.file_io().exists(&table_index_path).await.unwrap());
     let historical = table
         .copy_with_time_travel(HashMap::from([("scan.snapshot-id".into(), 
"2".into())]))
         .await

Reply via email to