This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 750aaea265 [python] Enable native dynamic bucket writes and align HASH 
commits (#10353)
750aaea265 is described below

commit 750aaea26544a77e670ca8d2d2c2ae303ce3e93a
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Oct 3 21:52:36 2026 +0800

    [python] Enable native dynamic bucket writes and align HASH commits (#10353)
---
 paimon-python/README.md                            |  22 +-
 paimon-python/pypaimon/index/dynamic_bucket.py     |  20 +-
 .../pypaimon/manifest/index_manifest_file.py       |  50 ++--
 .../pypaimon/tests/deletion_vector_path_test.py    | 123 ++++++---
 .../pypaimon/tests/native_dynamic_write_test.py    | 280 +++++++++++++++++++++
 .../tests/native_plan_dynamic_bucket_test.py       |   4 +-
 paimon-python/pypaimon/tests/native_write_test.py  |  11 +-
 .../pypaimon/tests/write/dynamic_bucket_test.py    |  54 ++--
 .../pypaimon/utils/file_store_path_factory.py      |  47 ++--
 .../pypaimon/write/commit/conflict_detection.py    |  27 +-
 paimon-python/pypaimon/write/file_store_commit.py  |   2 +-
 paimon-python/pypaimon/write/native_write.py       |   2 +-
 paimon-python/pypaimon/write/row_key_extractor.py  |   3 +
 13 files changed, 461 insertions(+), 184 deletions(-)

diff --git a/paimon-python/README.md b/paimon-python/README.md
index 1b7c200b89..b1b904d6c1 100644
--- a/paimon-python/README.md
+++ b/paimon-python/README.md
@@ -168,6 +168,15 @@ path select the Python writer before native data is 
written. If the runtime or t
 unavailable, write uses Python. Once Rust starts writing a batch, errors
 propagate without retrying that batch through Python.
 
+Primary-key dynamic buckets (`bucket=-1`, with partition fields included in the
+primary key) also support native writes. HASH indexes are restored across 
writer
+restarts; `dynamic-bucket.max-buckets` bounds bucket growth and reuses existing
+buckets after that limit. Dynamic buckets use the trimmed primary key, so
+`bucket-key` must not be configured. As in Java, each bucket must have one 
writer
+owner: a HASH ADD replaces the complete previous index and does not carry a
+concurrent-writer baseline. Partitions with existing data but missing HASH 
indexes
+must be rewritten before incremental writes.
+
 Native writes honor `data-file.path-directory` and the configured
 `data-file.external-paths` strategy. Existing files keep their recorded 
locations
 when the write destinations change. Python and native readers and committers
@@ -317,14 +326,15 @@ Explicit row ranges on data-evolution tables require 
`ReadBuilder.with_row_range
 Watermark time travel requires Rust 0.4 or newer. Branch reads require the
 branch-aware binding exposing `Table.branch()`, and the resolved branch is
 checked before planning. Deletion-vector scans require `pypaimon-rust>=0.4.0`,
-which includes schema-aware decoding of Python-written index manifests and
-legacy bucket-index path compatibility. The reader honors explicit paths, then
-bucket paths, and can read older Python files placed in `table/index`.
+which includes schema-aware decoding of index manifests. Index paths follow
+Java: an explicit external path takes precedence; otherwise
+`index-file-in-data-file-dir` selects the bucket or table index directory.
 Bucket paths use the partition field types and `partition.legacy-name` to match
 Java formatting, including timestamp precision and different JVM float 
spellings.
-New Python writes honor `index-file-in-data-file-dir` and retain explicit paths
-when Python and Java partition-directory formatting differs. Older releases
-and prereleases before 0.4.0 use the Python planner for deletion vectors.
+Python bucket-index writes use Java partition paths. Explicit paths identify
+external files and floating partition directories whose spelling depends on the
+JDK version. Missing bucket indexes fail instead of searching Python layouts. 
Older releases and prereleases
+before 0.4.0 use the Python planner for deletion vectors.
 When using an unreleased 0.4.0 development wheel, rebuild it with these fixes;
 package version checks cannot distinguish local builds with identical versions.
 
diff --git a/paimon-python/pypaimon/index/dynamic_bucket.py 
b/paimon-python/pypaimon/index/dynamic_bucket.py
index 90a1c49230..f89862875c 100644
--- a/paimon-python/pypaimon/index/dynamic_bucket.py
+++ b/paimon-python/pypaimon/index/dynamic_bucket.py
@@ -20,6 +20,7 @@
 
 import random
 import struct
+import uuid
 from dataclasses import dataclass
 from typing import Dict, Iterator, List, Optional, Set, Tuple
 
@@ -83,12 +84,9 @@ def validate_bucket_id(bucket: int) -> None:
 
 def _iter_hashes(table, entry: IndexManifestEntry) -> Iterator[int]:
     meta = entry.index_file
-    path = meta.external_path
-    if path is None:
-        # Files without an external path always live in the table's index
-        # directory, even if global-index.external-path was configured later.
-        index_path = 
table.path_factory().global_index_path_factory().index_path()
-        path = f"{index_path}/{meta.file_name}"
+    path = table.path_factory().bucket_index_path(
+        tuple(entry.partition.values), entry.bucket, meta, table.file_io
+    )
     with table.file_io.new_input_stream(path) as stream:
         remainder = b""
         while True:
@@ -507,9 +505,11 @@ class DynamicBucketIndexMaintainer:
     def _write_index(
         self, partition: Tuple, bucket: int, hashes: Set[int]
     ) -> IndexManifestEntry:
-        path_factory = self.table.path_factory().global_index_path_factory()
-        
self.table.file_io.check_or_mkdirs(path_factory.global_index_root_path())
-        path = path_factory.new_path()
+        file_name = f"index-{uuid.uuid4()}-0"
+        path, external = self.table.path_factory().new_bucket_index_path(
+            partition, bucket, file_name
+        )
+        self.table.file_io.check_or_mkdirs(path.rsplit("/", 1)[0])
         payload = b"".join(struct.pack(">i", value) for value in 
sorted(hashes))
         try:
             with self.table.file_io.new_output_stream(path) as stream:
@@ -524,7 +524,7 @@ class DynamicBucketIndexMaintainer:
             file_name=path.rsplit("/", 1)[-1],
             file_size=self.table.file_io.get_file_size(path),
             row_count=len(hashes),
-            external_path=path if path_factory.is_external_path() else None,
+            external_path=path if external else None,
         )
         return IndexManifestEntry(
             kind=_ADD,
diff --git a/paimon-python/pypaimon/manifest/index_manifest_file.py 
b/paimon-python/pypaimon/manifest/index_manifest_file.py
index 3c1c9448ba..ee78f08d4a 100644
--- a/paimon-python/pypaimon/manifest/index_manifest_file.py
+++ b/paimon-python/pypaimon/manifest/index_manifest_file.py
@@ -255,8 +255,7 @@ class IndexManifestFile:
         delete_names = {e.index_file.file_name for e in deletes}
         survivors = [e for e in previous if e.index_file.file_name not in 
delete_names]
         _validate_retained_global_index_files(survivors, adds)
-        _validate_hash_index_changes(survivors, adds)
-        combined = survivors + adds
+        combined = _combine_hash_indexes(survivors, adds, deletes)
         if not combined:
             return None
         return self.write(combined)
@@ -353,33 +352,26 @@ def _validate_retained_global_index_files(
             )
 
 
-def _validate_hash_index_changes(
-    retained_entries: List[IndexManifestEntry],
-    added_entries: List[IndexManifestEntry],
-) -> None:
-    """Keep the one-HASH-file-per-bucket manifest invariant."""
-    by_bucket = {
-        (tuple(entry.partition.values), entry.bucket): entry
-        for entry in retained_entries
-        if entry.index_file.index_type == _HASH_INDEX
-    }
-    for added in added_entries:
-        if added.kind != _ADD or added.index_file.index_type != _HASH_INDEX:
-            continue
-        key = (tuple(added.partition.values), added.bucket)
-        previous = by_bucket.get(key)
-        if previous is not None:
-            raise RuntimeError(
-                "Trying to add HASH index file {} for partition {}, bucket {}, 
"
-                "but HASH index file {} still exists. Remove the previous file 
"
-                "first.".format(
-                    added.index_file.file_name,
-                    key[0],
-                    key[1],
-                    previous.index_file.file_name,
-                )
-            )
-        by_bucket[key] = added
+def _combine_hash_indexes(retained_entries, added_entries, deleted_entries):
+    """Java BucketedCombiner replaces HASH by (partition, bucket, index type).
+
+    Writers submit a complete replacement file, so an ADD need not carry the
+    previous file as a DELETE. Each bucket must have a single writer owner.
+    """
+    def key(entry):
+        return tuple(entry.partition.values), entry.bucket
+
+    hash_indexes = {key(entry): entry for entry in retained_entries
+                    if entry.index_file.index_type == _HASH_INDEX}
+    for entry in deleted_entries:
+        if entry.index_file.index_type == _HASH_INDEX:
+            hash_indexes.pop(key(entry), None)
+    for entry in added_entries:
+        if entry.index_file.index_type == _HASH_INDEX:
+            hash_indexes[key(entry)] = entry
+    return ([entry for entry in retained_entries + added_entries
+             if entry.index_file.index_type != _HASH_INDEX]
+            + list(hash_indexes.values()))
 
 
 def _is_global_index(index_type: str) -> bool:
diff --git a/paimon-python/pypaimon/tests/deletion_vector_path_test.py 
b/paimon-python/pypaimon/tests/deletion_vector_path_test.py
index bfa2100448..9ed9b6179d 100644
--- a/paimon-python/pypaimon/tests/deletion_vector_path_test.py
+++ b/paimon-python/pypaimon/tests/deletion_vector_path_test.py
@@ -68,9 +68,8 @@ def _table(tmp_path, layout, first_partition='a', 
partition_type=None, legacy_pa
     catalog.create_table('db.t', Schema.from_pyarrow_schema(
         schema, partition_keys=['p'], options=options), False)
     table = catalog.get_table('db.t')
-    # Seed the legacy Python partition paths and deterministic row-id ordering
-    # which these compatibility/decoy tests deliberately reference. Updates and
-    # reads below still exercise the configured native backend.
+    # Seed through Python with deterministic row-id ordering. Updates and
+    # reads below exercise the configured backend against Java index paths.
     builder = table.copy({'write.native.enabled': 
'false'}).new_batch_write_builder()
     writer, commit = builder.new_write(), builder.new_commit()
     try:
@@ -150,30 +149,64 @@ def 
test_delete_paths_preserve_repeated_deletes_and_historical_reads(tmp_path, p
 
 
 @pytest.mark.parametrize('planner', _PLANNERS)
-def 
test_legacy_python_index_directory_remains_readable_and_new_deletes_use_bucket(tmp_path,
 planner):
-    table = _table(tmp_path, 'bucket')
-    # Old Python writers ignored the option and placed the index under 
table/index.
-    legacy_factory = table.path_factory()
-    legacy_factory.index_file_in_data_file_dir = False
-    legacy_table = table.copy({'write.native.enabled': 'false'})
-    with patch.object(legacy_table, 'path_factory', 
return_value=legacy_factory):
-        _delete(legacy_table, [0, 2])
-    old_paths = [Path(table.table_path) / 'index' / entry.index_file.file_name 
for entry in _entries(table, 2)]
-    assert all(path.is_file() for path in old_paths)
-    _read(table, planner, 2, [1, 3])
-    _delete(table, [1])
-    _read(table, planner, 3, [3])
-    _read(table, planner, 2, [1, 3])
-    for entry in _entries(table, 3):
-        if entry.partition.values == ['a']:
-            assert (Path(table.path_factory().bucket_path(('a',), 
entry.bucket)) /
-                    entry.index_file.file_name).is_file()
-    assert all(path.is_file() for path in old_paths)
[email protected]('wrong_directory', ['table-index', 
'unescaped-partition'])
+def test_bucket_dv_requires_java_directory(tmp_path, planner, wrong_directory):
+    table = _table(tmp_path, 'bucket', 'a/b')
+    _delete(table, [0])
+    entry = _entries(table, 2)[0]
+    factory = table.path_factory()
+    file = entry.index_file
+    assert file.external_path is None
+    partition = tuple(entry.partition.values)
+    path = factory.bucket_index_path(partition, entry.bucket, file, 
table.file_io)
+    wrong_parent = (factory.index_path() if wrong_directory == 'table-index'
+                    else factory.bucket_path(partition, entry.bucket))
+    wrong_path = wrong_parent + '/' + file.file_name
+    with table.file_io.new_input_stream(path) as stream:
+        data = stream.read()
+    table.file_io.delete_quietly(path)
+    with table.file_io.new_output_stream(wrong_path) as stream:
+        stream.write(data)
+    with pytest.raises((FileNotFoundError, ValueError)) as error:
+        _read(table, planner, 2, [1, 2, 3])
+    assert file.file_name in str(error.value)
+    with pytest.raises((FileNotFoundError, ValueError)) as error:
+        _delete(table, [1])
+    assert file.file_name in str(error.value)
+    assert table.snapshot_manager().get_latest_snapshot().id == 2
+    assert table.file_io.exists(wrong_path)
+
+
[email protected]('file_uri', [False, True])
[email protected]('escaped_root', [False, True])
+def test_floating_bucket_dv_does_not_search_python_directory(tmp_path, 
file_uri, escaped_root):
+    if escaped_root:
+        tmp_path = tmp_path / 'root%2Fdir'
+    table = _table(tmp_path, 'bucket', 0.1, 
pa.float32()).copy({'read.native.enabled': 'false'})
+    _delete(table, [0])
+    entry = _entries(table, 2)[0]
+    factory = table.path_factory()
+    if file_uri:
+        factory._root = 'file://' + table.table_path
+    partition = tuple(entry.partition.values)
+    file = replace(entry.index_file, external_path=None)
+    canonical = factory.bucket_index_path(partition, entry.bucket, file, 
table.file_io)
+    python_path = factory.bucket_path(partition, entry.bucket) + '/' + 
file.file_name
+    with table.file_io.new_input_stream(canonical) as stream:
+        data = stream.read()
+    table.file_io.delete_quietly(canonical)
+    with table.file_io.new_output_stream(python_path) as stream:
+        stream.write(data)
+    assert factory.bucket_index_path(partition, entry.bucket, file, 
table.file_io) == canonical
+    with patch.object(table, 'path_factory', return_value=factory):
+        with pytest.raises(FileNotFoundError, match=file.file_name):
+            _read(table, 'python', 2, [1, 2, 3])
+    assert table.file_io.exists(python_path)
 
 
 @pytest.mark.parametrize('layout', ['bucket', 'bucket-external', 
'global-external'])
 def test_abort_removes_uncommitted_dv_from_its_actual_directory(tmp_path, 
layout):
-    table = _table(tmp_path, layout)
+    table = _table(tmp_path, layout, 'a/b')
     _delete(table, [0])
     builder = table.new_batch_write_builder()
     messages = builder.new_update().delete_by_row_id([1])
@@ -208,28 +241,39 @@ def 
test_missing_explicit_dv_is_not_replaced_by_a_local_copy(tmp_path, planner):
 
 
 @pytest.mark.parametrize('planner', _PLANNERS)
[email protected]('partition', ['a/b', 'a%2Fb', 'a=b', 'a#b', 'a b', 
'中文'])
-def test_bucket_dv_in_partition_requiring_path_escaping(tmp_path, planner, 
partition):
-    table = _table(tmp_path, 'bucket', partition)
[email protected]('layout', ['bucket', 'bucket-external'])
[email protected]('partition,escaped', [
+    ('a/b', 'a%2Fb'), ('a%2Fb', 'a%252Fb'), ('a=b', 'a%3Db'),
+    ('a#b', 'a%23b'), ('a b', 'a b'), ('中文', '中文')])
+def test_bucket_dv_in_partition_requiring_path_escaping(tmp_path, planner, 
layout, partition, escaped):
+    table = _table(tmp_path, layout, partition)
     _delete(table, [0])
-    file = _entries(table, 2)[0].index_file
-    if partition in ['a b', '中文']:
-        assert file.external_path is None
-    else:
-        assert file.external_path is not None
-        assert table.file_io.exists(file.external_path)
+    entry = _entries(table, 2)[0]
+    file = entry.index_file
+    assert (file.external_path is not None) == (layout == 'bucket-external')
+    root = tmp_path / 'external-data' if layout == 'bucket-external' else 
Path(table.table_path)
+    path = root / ('p=' + escaped) / ('bucket-' + str(entry.bucket)) / 
file.file_name
+    assert path.is_file()
     _read(table, planner, 2, [1, 2, 3])
 
 
 @pytest.mark.parametrize('planner', _PLANNERS)
[email protected]('partition_type,value', [(pa.bool_(), True), 
(pa.float32(), 0.1), (pa.float64(), 0.1)],
-                         ids=['BOOLEAN', 'FLOAT', 'DOUBLE'])
-def test_bucket_dv_preserves_python_typed_partition_directory(tmp_path, 
planner, partition_type, value):
[email protected]('partition_type,value,canonical_name', [
+    (pa.bool_(), True, 'true'), (pa.float32(), 0.1, '0.1'), (pa.float64(), 
0.1, '0.1'),
+    (pa.float32(), 1.1754943508222875e-38, '1.1754944E-38'),
+    (pa.float64(), 1e23, '1.0E23')])
+def test_bucket_dv_writes_java_typed_partition_directory(tmp_path, planner, 
partition_type, value, canonical_name):
     table = _table(tmp_path, 'bucket', value, partition_type)
     _delete(table, [0])
-    file = _entries(table, 2)[0].index_file
-    assert file.external_path is not None
-    assert table.file_io.exists(file.external_path)
+    entry = _entries(table, 2)[0]
+    file = entry.index_file
+    assert (file.external_path is not None) == 
pa.types.is_floating(partition_type)
+    path = Path(table.table_path) / ('p=' + canonical_name) / ('bucket-' + 
str(entry.bucket)) / file.file_name
+    assert path.is_file()
+    if file.external_path is not None:
+        # Java IndexInDataFileDirPathFactory uses the explicit path even when
+        # this JDK would render the same float with different digits.
+        assert Path(file.external_path).samefile(path)
     effective_planner = planner
     if planner == 'native' and pa.types.is_floating(partition_type):
         # Rust intentionally rejects floating partition formatting until it can
@@ -384,9 +428,6 @@ def _check_java_bucket_dv(tmp_path, planner, value, 
partition_type, canonical_na
     # Case-insensitive local filesystems consider p=True and p=true identical.
     if not Path(old_path).samefile(canonical_path):
         table.file_io.delete_quietly(old_path)
-        # Both previous Python locations must lose to every Java spelling.
-        with table.file_io.new_output_stream(old_path) as stream:
-            stream.write(b'not the canonical deletion vector')
     with table.file_io.new_output_stream(table.path_factory().index_path() + 
'/' + old.index_file.file_name) as stream:
         stream.write(b'not a bucket deletion vector')
     if partition_type is not None and pa.types.is_floating(partition_type):
diff --git a/paimon-python/pypaimon/tests/native_dynamic_write_test.py 
b/paimon-python/pypaimon/tests/native_dynamic_write_test.py
new file mode 100644
index 0000000000..11ec98ddbf
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_dynamic_write_test.py
@@ -0,0 +1,280 @@
+# 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.
+
+"""Dynamic bucket writer and HASH index interoperability with Rust core."""
+
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.index.index_file_handler import IndexFileHandler
+from pypaimon.write.native_commit import from_native_commit_messages
+from pypaimon.write.native_write import NativeTableWrite
+
+pytestmark = pytest.mark.native_plan
+SCHEMA = pa.schema([('id', pa.int64()), ('p', pa.string()), ('v', 
pa.string())])
+
+
+def _table(tmp_path, options=None, catalog=None):
+    catalog = catalog or CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    opts = {'bucket': '-1', 'file.format': 'parquet',
+            'dynamic-bucket.target-row-num': '2', 'write.native.enabled': 
'true'}
+    opts.update(options or {})
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(
+        SCHEMA, primary_keys=['id', 'p'], partition_keys=['p'], options=opts), 
False)
+    return catalog.get_table('default.t')
+
+
+def _batch(ids, p='a', value='v'):
+    return pa.RecordBatch.from_pylist(
+        [{'id': i, 'p': p, 'v': value} for i in ids], schema=SCHEMA)
+
+
+def _indexes(table):
+    return 
IndexFileHandler(table).scan(table.snapshot_manager().get_latest_snapshot())
+
+
+def _write(table, batch, native=True):
+    table = table.copy({'write.native.enabled': str(native).lower()})
+    builder = table.new_batch_write_builder()
+    writer, commit = builder.new_write(), builder.new_commit()
+    assert isinstance(writer, NativeTableWrite) == native
+    try:
+        writer.write_arrow_batch(batch)
+        if native:
+            assert writer._python_writer is None
+        messages = writer.prepare_commit()
+        commit.commit(messages)
+        return messages
+    finally:
+        writer.close()
+        commit.close()
+
+
+def _rows(table, native):
+    builder = table.copy({'scan.native-plan.enabled': str(native).lower(),
+                          'read.native.enabled': 
str(native).lower()}).new_read_builder()
+    splits = builder.new_scan().plan().splits()
+    reader = builder.new_read()
+    if native:
+        with patch.object(reader, '_create_split_read', 
side_effect=AssertionError('fallback')):
+            rows = reader.to_arrow(splits).to_pylist()
+    else:
+        rows = reader.to_arrow(splits).to_pylist()
+    return sorted(rows, key=lambda row: (row['p'], row['id']))
+
+
+def test_python_commit_accepts_java_hash_replacement(tmp_path):
+    from pypaimon_rust.datafusion import PaimonCatalog
+
+    table = _table(tmp_path, {'dynamic-bucket.target-row-num': '100'})
+    for ids in ([1], [2]):
+        native = PaimonCatalog({'warehouse': 
str(tmp_path)}).get_table('default.t')
+        builder = native.new_batch_write_builder()
+        writer = builder.new_write()
+        writer.write_arrow(_batch(ids))
+        messages = from_native_commit_messages(table, writer.prepare_commit())
+        assert len(messages) == 1
+        assert len(messages[0].index_adds) == 1
+        assert messages[0].index_deletes == []
+        table.new_batch_write_builder().new_commit().commit(messages)
+        writer.close()
+    assert len(_indexes(table)) == 1
+    assert _indexes(table)[0].index_file.row_count == 2
+    for native in (False, True):
+        assert _rows(table, native) == _batch([1, 2]).to_pylist()
+
+
[email protected]('bucket_local', [False, True])
[email protected]('first_native', [False, True])
+def test_dynamic_cross_backend_restart(tmp_path, bucket_local, first_native):
+    table = _table(tmp_path, {
+        'index-file-in-data-file-dir': str(bucket_local).lower(),
+        'data-file.path-directory': 'data',
+        'dynamic-bucket.max-buckets': '1',
+    })
+    for native, ids, value in [(first_native, [1, 2, 3], 'old'),
+                               (not first_native, [2, 4], 'new'),
+                               (first_native, [1, 5], 'last')]:
+        messages = _write(table, _batch(ids, 'a/b', value), native)
+        assert {m.bucket for m in messages} == {0}
+        indexes = _indexes(table)
+        assert len(indexes) == 1
+        assert indexes[0].index_file.external_path is None
+        path = table.path_factory().bucket_index_path(
+            tuple(indexes[0].partition.values), 0, indexes[0].index_file, 
table.file_io)
+        assert table.file_io.exists(path)
+    assert _indexes(table)[0].index_file.row_count == 5
+    expected = [{'id': i, 'p': 'a/b', 'v': v} for i, v in
+                [(1, 'last'), (2, 'new'), (3, 'old'), (4, 'new'), (5, 'last')]]
+    for native in (False, True):
+        assert _rows(table, native) == expected
+
+
[email protected]('native_commit', [False, True])
+def test_stream_dynamic_index_checkpoints(tmp_path, native_rest_catalog, 
native_commit):
+    table = _table(tmp_path, {'commit.native.enabled': 
str(native_commit).lower(),
+                              'dynamic-bucket.max-buckets': '1'}, 
native_rest_catalog)
+    builder = table.new_stream_write_builder()
+    writer, commit = builder.new_write(), builder.new_commit()
+    assert isinstance(writer, NativeTableWrite)
+    try:
+        for checkpoint, ids in enumerate(([1, 2], [2], [3]), 1):
+            writer.write_arrow_batch(_batch(ids, value=str(checkpoint)))
+            messages = writer.prepare_commit(checkpoint)
+            adds = [entry for m in messages for entry in m.index_adds]
+            assert len(adds) == (0 if checkpoint == 2 else 1)
+            if native_commit:
+                with patch.object(commit.file_store_commit, 'commit',
+                                  side_effect=AssertionError('Python commit 
fallback')):
+                    commit.commit(messages, checkpoint)
+            else:
+                commit.commit(messages, checkpoint)
+        assert writer.prepare_commit(4) == []
+        assert _indexes(table)[0].index_file.row_count == 3
+    finally:
+        writer.close()
+        commit.close()
+    for native in (False, True):
+        assert _rows(table, native) == [
+            {'id': 1, 'p': 'a', 'v': '1'}, {'id': 2, 'p': 'a', 'v': '2'},
+            {'id': 3, 'p': 'a', 'v': '3'}]
+
+
[email protected]('bucket_local', [False, True])
[email protected]('commit_first', [False, True])
+def test_dynamic_abort_index_ownership(tmp_path, bucket_local, commit_first):
+    table = _table(tmp_path, {'index-file-in-data-file-dir': 
str(bucket_local).lower()})
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write()
+    assert isinstance(writer, NativeTableWrite)
+    writer.write_arrow_batch(_batch([1]))
+    messages = writer.prepare_commit()
+    entry = messages[0].index_adds[0]
+    path = table.path_factory().bucket_index_path(('a',), entry.bucket, 
entry.index_file)
+    assert table.file_io.exists(path)
+    if commit_first:
+        builder.new_commit().commit(messages)
+    writer.abort()
+    assert table.file_io.exists(path) == commit_first
+    assert all(table.file_io.exists(f.file_path) == commit_first
+               for m in messages for f in m.new_files)
+
+
[email protected]('native', [False, True])
+def test_hash_index_requires_configured_directory(tmp_path, native):
+    # A file in table/index cannot replace a missing bucket-local index.
+    table = _table(tmp_path, {'index-file-in-data-file-dir': 'false'})
+    _write(table, _batch([1]), False)
+    old = _indexes(table)[0]
+    assert old.index_file.external_path is None
+    table = table.copy({'index-file-in-data-file-dir': 'true'})
+    with pytest.raises((FileNotFoundError, ValueError)) as error:
+        _write(table, _batch([2]), native)
+    assert old.index_file.file_name in str(error.value)
+    assert table.snapshot_manager().get_latest_snapshot().id == 1
+    assert _indexes(table) == [old]
+
+
[email protected]('missing_all', [False, True])
+def test_native_rejects_incomplete_hash_index(tmp_path, missing_all):
+    table = _table(tmp_path, {'dynamic-bucket.target-row-num': '1'})
+    builder = table.copy({'write.native.enabled': 
'false'}).new_batch_write_builder()
+    writer = builder.new_write()
+    writer.write_arrow_batch(_batch([1, 2]))
+    messages = writer.prepare_commit()
+    for message in messages:
+        if missing_all or message.bucket == 1:
+            message.index_adds.clear()
+    builder.new_commit().commit(messages)
+    writer.close()
+    writer = table.new_batch_write_builder().new_write()
+    assert isinstance(writer, NativeTableWrite)
+    try:
+        with pytest.raises(Exception, match='complete HASH index'):
+            writer.write_arrow_batch(_batch([2], value='update'))
+        with pytest.raises(Exception, match='cannot be reused'):
+            writer.prepare_commit()
+    finally:
+        writer.close()
+    assert table.snapshot_manager().get_latest_snapshot().id == 1
+
+
[email protected]('native_commit', [False, True])
[email protected]('dynamic', [False, True])
+def test_dynamic_overwrite_resets_only_replaced_indexes(
+        tmp_path, native_rest_catalog, native_commit, dynamic):
+    table = _table(
+        tmp_path, {'commit.native.enabled': str(native_commit).lower(),
+                   'dynamic-partition-overwrite': str(dynamic).lower()}, 
native_rest_catalog)
+    _write(table, _batch([1, 2], 'a'))
+    _write(table, _batch([3], 'b'))
+    previous_b = next(e.index_file.file_name for e in _indexes(table) if 
e.partition.values == ['b'])
+    builder = table.new_batch_write_builder().overwrite({'p': 'a'})
+    writer = builder.new_write()
+    assert isinstance(writer, NativeTableWrite)
+    writer.write_arrow_batch(_batch([4], 'a'))
+    builder.new_commit().commit(writer.prepare_commit())
+    writer.close()
+    counts = sorted((tuple(e.partition.values), e.index_file.row_count) for e 
in _indexes(table))
+    assert counts == [(('a',), 1), (('b',), 1)]
+    assert next(e.index_file.file_name for e in _indexes(table) if 
e.partition.values == ['b']) == previous_b
+    _write(table, _batch([4, 5], 'a', 'next'))
+    for native in (False, True):
+        assert _rows(table, native) == _batch([4, 5], 'a', 'next').to_pylist() 
+ _batch([3], 'b').to_pylist()
+    # Static empty overwrite removes the old partition's HASH as well as data.
+    builder = table.copy({'dynamic-partition-overwrite': 
'false'}).new_batch_write_builder().overwrite({'p': 'a'})
+    writer = builder.new_write()
+    builder.new_commit().commit(writer.prepare_commit())
+    writer.close()
+    assert [e.partition.values for e in _indexes(table)] == [['b']]
+
+
[email protected]('native', [False, True])
[email protected]('bucket_key', ['id', ''])
+def test_dynamic_writer_rejects_custom_bucket_key(tmp_path, native, 
bucket_key):
+    table = _table(tmp_path).copy({'write.native.enabled': str(native).lower(),
+                                  'bucket-key': bucket_key})
+    with pytest.raises(Exception, match="Cannot define 'bucket-key'"):
+        table.new_batch_write_builder().new_write()
+
+
[email protected]('bucket_local', [False, True])
+def test_dynamic_external_indexes_cross_backends(tmp_path, bucket_local):
+    external = tmp_path / 'external-index'
+    options = {'index-file-in-data-file-dir': str(bucket_local).lower(),
+               'data-file.path-directory': 'data',
+               'dynamic-bucket.max-buckets': '1'}
+    if bucket_local:
+        options.update({'data-file.external-paths': external.as_uri(),
+                        'data-file.external-paths.strategy': 'round-robin'})
+    else:
+        options['global-index.external-path'] = external.as_uri()
+    table = _table(tmp_path, options)
+    for native, ids in [(True, [1]), (False, [2]), (True, [3])]:
+        _write(table, _batch(ids), native)
+        indexes = _indexes(table)
+        assert len(indexes) == 1
+        path = indexes[0].index_file.external_path
+        assert path is not None
+        assert '/external-index/' in path
+        assert ('/data/p=a/bucket-0/' in path) == bucket_local
+        assert table.file_io.exists(path)
+    for native in (False, True):
+        assert _rows(table, native) == _batch([1, 2, 3]).to_pylist()
diff --git a/paimon-python/pypaimon/tests/native_plan_dynamic_bucket_test.py 
b/paimon-python/pypaimon/tests/native_plan_dynamic_bucket_test.py
index f4d58b6422..beb241f6df 100644
--- a/paimon-python/pypaimon/tests/native_plan_dynamic_bucket_test.py
+++ b/paimon-python/pypaimon/tests/native_plan_dynamic_bucket_test.py
@@ -29,7 +29,7 @@ from pypaimon.read.native_plan import native_method_available
 
 
 pytestmark = [pytest.mark.native_plan, pytest.mark.skipif(
-    not native_method_available('Table', 'new_write_builder'),
+    not native_method_available('Table', 'new_batch_write_builder'),
     reason='Rust main writer and planner required')]
 
 
@@ -84,7 +84,7 @@ def 
test_native_pk_bucket_growth_and_partition_migration(tmp_path, cross_partiti
         # The Rust writer maintains the dynamic/cross-partition hash index and
         # emits the old-partition DELETE when a key moves.
         table = PaimonCatalog({'warehouse': 
str(tmp_path)}).get_table('default.t')
-        builder = table.new_write_builder()
+        builder = table.new_batch_write_builder()
         writer = builder.new_write()
         writer.write_arrow(pa.RecordBatch.from_pylist(rows, schema=schema))
         builder.new_commit().commit(writer.prepare_commit())
diff --git a/paimon-python/pypaimon/tests/native_write_test.py 
b/paimon-python/pypaimon/tests/native_write_test.py
index ea6646e457..8403d06c4d 100644
--- a/paimon-python/pypaimon/tests/native_write_test.py
+++ b/paimon-python/pypaimon/tests/native_write_test.py
@@ -366,15 +366,8 @@ def 
test_external_data_paths_fall_back_before_native_write(tmp_path):
     writer.close()
 
 
[email protected]('options', [
-    {'bucket': '-1'},
-    {'changelog-file.format': 'orc'},
-])
-def test_unsupported_primary_key_write_falls_back_before_native_reconstruction(
-        tmp_path, options):
-    table = (_table(tmp_path, primary_key=True, table_options=options)
-             if 'bucket' in options else
-             _table(tmp_path, primary_key=True).copy(options))
+def 
test_unsupported_primary_key_write_falls_back_before_native_reconstruction(tmp_path):
+    table = _table(tmp_path, primary_key=True).copy({'changelog-file.format': 
'orc'})
     with patch('pypaimon.write.native_write.native_write_available', 
return_value=True), \
             patch('pypaimon.write.native_write.create_native_write_table',
                   side_effect=AssertionError('must not reconstruct')):
diff --git a/paimon-python/pypaimon/tests/write/dynamic_bucket_test.py 
b/paimon-python/pypaimon/tests/write/dynamic_bucket_test.py
index 7eb7f2049e..e32d5f5a72 100644
--- a/paimon-python/pypaimon/tests/write/dynamic_bucket_test.py
+++ b/paimon-python/pypaimon/tests/write/dynamic_bucket_test.py
@@ -23,6 +23,7 @@ from decimal import Decimal
 from unittest.mock import Mock, patch
 
 import pyarrow as pa
+import pytest
 
 from pypaimon import CatalogFactory, Schema
 from pypaimon.index.dynamic_bucket import (
@@ -343,6 +344,7 @@ class DynamicBucketTest(unittest.TestCase):
             result = self._read_arrow(table).sort_by('id').to_pydict()
             self.assertEqual({'id': [1, 2], 'value': ['new', 'other']}, result)
 
+    @pytest.mark.python_write
     def test_regular_dynamic_writer_retains_only_requested_index_hashes(self):
         with tempfile.TemporaryDirectory() as root:
             table = self._create_table(root, 'bounded_restore')
@@ -357,6 +359,7 @@ class DynamicBucketTest(unittest.TestCase):
             self.assertEqual(1, len(partition_index.hash_to_bucket))
             self.assertEqual({}, 
writer.row_key_extractor._index_maintainer._states)
 
+    @pytest.mark.python_write
     def test_legacy_dynamic_data_without_hash_index_fails_fast(self):
         with tempfile.TemporaryDirectory() as root:
             table = self._create_table(root, 'legacy_no_index')
@@ -416,6 +419,7 @@ class DynamicBucketTest(unittest.TestCase):
 
             self.assertFalse(table.file_io.exists(index_path))
 
+    @pytest.mark.python_write
     def test_stream_writer_releases_prepared_hash_index_ownership(self):
         with tempfile.TemporaryDirectory() as root:
             table = self._create_table(root, 'stream_prepared')
@@ -481,44 +485,21 @@ class DynamicBucketTest(unittest.TestCase):
                 [call.args[0] for call in hash_code.call_args_list],
             )
 
-    def test_concurrent_initial_hash_index_add_conflicts(self):
+    def test_hash_add_replaces_previous_index_without_explicit_delete(self):
+        # Java DynamicBucketIndexMaintainer sends only the complete new HASH
+        # file. Its bucket-owner protocol does not require an old-file DELETE.
         with tempfile.TemporaryDirectory() as root:
-            table = self._create_table(root, 'concurrent_initial')
-            writer1, commit1, messages1 = self._prepare_indexed_write(
-                table, [1]
-            )
-            writer2, commit2, messages2 = self._prepare_indexed_write(
-                table, [2]
-            )
-
-            commit1.commit(messages1)
-            stale_data_paths = [
-                file.file_path for message in messages2 for file in 
message.new_files
-            ]
-            stale_index_paths = [
-                entry.index_file.external_path
-                or table.path_factory().global_index_path_factory().to_path(
-                    entry.index_file.file_name
-                )
-                for message in messages2 for entry in message.index_adds
-            ]
-            with self.assertRaisesRegex(
-                RuntimeError, 'HASH index conflict detected'
-            ):
-                commit2.commit(messages2)
-
-            self.assertTrue(all(
-                table.file_io.exists(path)
-                for path in stale_data_paths + stale_index_paths
-            ))
-
+            table = self._create_table(root, 'implicit_hash_replace')
+            self._commit_arrow(table, [1], ['one'])
+            writer, commit, messages = self._prepare_indexed_write(table, [2])
+            self.assertEqual(1, len(messages[0].index_deletes))
+            messages[0].index_deletes.clear()
+            commit.commit(messages)
             indexes = self._hash_indexes(table)
             self.assertEqual(1, len(indexes))
-            self.assertEqual(1, indexes[0].index_file.row_count)
-            writer1.close()
-            writer2.close()
-            commit1.close()
-            commit2.close()
+            self.assertEqual(2, indexes[0].index_file.row_count)
+            writer.close()
+            commit.close()
 
     def test_concurrent_hash_index_replacement_conflicts(self):
         with tempfile.TemporaryDirectory() as root:
@@ -638,6 +619,7 @@ class DynamicBucketTest(unittest.TestCase):
     def test_retry_then_hash_index_conflict_preserves_prepared_files(self):
         with tempfile.TemporaryDirectory() as root:
             table = self._create_table(root, 'retry_hash_conflict')
+            self._commit_arrow(table, [0], ['seed'])
             writer, commit, messages = self._prepare_indexed_write(table, [1])
             prepared_paths = [
                 file.file_path
@@ -694,7 +676,7 @@ class DynamicBucketTest(unittest.TestCase):
             invalid_schema = table.table_schema.copy(options)
 
             with self.assertRaisesRegex(
-                ValueError, 'bucket-key references unknown columns'
+                ValueError, "Cannot define 'bucket-key' in dynamic bucket mode"
             ):
                 DynamicBucketRowKeyExtractor(invalid_schema)
 
diff --git a/paimon-python/pypaimon/utils/file_store_path_factory.py 
b/paimon-python/pypaimon/utils/file_store_path_factory.py
index 15cfcceb22..1536ffd93c 100644
--- a/paimon-python/pypaimon/utils/file_store_path_factory.py
+++ b/paimon-python/pypaimon/utils/file_store_path_factory.py
@@ -245,12 +245,12 @@ class FileStorePathFactory:
         return to_file_io_path(path) if self.data_file_path_directory is not 
None else path
 
     def create_external_path_provider(
-        self, partition: Tuple, bucket: int
+        self, partition: Tuple, bucket: int, canonical_partition: bool = False
     ) -> Optional[ExternalPathProvider]:
         if not self.external_paths:
             return None
 
-        relative_bucket_path = self.relative_bucket_path(partition, bucket)
+        relative_bucket_path = self.relative_bucket_path(partition, bucket, 
canonical_partition)
         return ExternalPathProvider.create(
             self.external_path_strategy,
             self.external_paths,
@@ -268,42 +268,33 @@ class FileStorePathFactory:
     def new_bucket_index_path(self, partition: Tuple, bucket: int, file_name: 
str) -> Tuple[str, bool]:
         """Return a new bucket index's path and whether to persist its 
external location."""
         if self.index_file_in_data_file_dir:
-            external = self.create_external_path_provider(partition, bucket)
+            external = self.create_external_path_provider(partition, bucket, 
canonical_partition=True)
             if external is not None:
                 return external.get_next_external_data_path(file_name), True
-            # Python data directories historically use str(value) without
-            # escaping. Record the actual location when Java renders it
-            # differently, so its readers can find the DV beside those files.
-            return (f"{self.bucket_path(partition, bucket)}/{file_name}",
-                    self._partition_path_requires_explicit_location(partition))
+            path = f"{self.bucket_path(partition, bucket, 
canonical_partition=True)}/{file_name}"
+            # Java Float/Double.toString differs across JDK versions. Record
+            # the actual Java-style directory so every Java reader resolves
+            # the same file through IndexFileMeta.externalPath.
+            floating = any(value is not None and str(data_type).split()[0] in 
('FLOAT', 'REAL', 'DOUBLE')
+                           for value, data_type in zip(partition, 
self.partition_types or []))
+            return to_file_io_path(path), floating
         factory = self.global_index_path_factory()
         return factory.to_path(file_name), factory.is_external_path()
 
-    def _partition_path_requires_explicit_location(self, partition: Tuple) -> 
bool:
-        # FLOAT/DOUBLE spellings also vary between JVM versions, so persist
-        # their actual Python location even if one Java spelling matches it.
-        return (any(isinstance(value, float) for value in partition)
-                or self.relative_bucket_path(partition, 0) != 
self.relative_bucket_path(partition, 0, True))
-
     def bucket_index_path(self, partition: Tuple, bucket: int, index_file, 
file_io=None) -> str:
-        """Resolve an existing bucket index, including the legacy Python DV 
layout."""
+        """Resolve a bucket index using Java partition paths and explicit 
locations."""
         if index_file.external_path:
             return to_file_io_path(index_file.external_path)
-        legacy_path = f"{self.index_path()}/{index_file.file_name}"
         if not self.index_file_in_data_file_dir:
-            return legacy_path
-        path = f"{self.bucket_path(partition, bucket, 
True)}/{index_file.file_name}"
-        # Older Python DV writers ignored the option. Prefer the Java location
-        # when present, and use the old directory only for an existing DV file.
-        if file_io is not None and index_file.index_type == 'DELETION_VECTORS' 
and not file_io.exists(path):
-            python_path = f"{self.bucket_path(partition, 
bucket)}/{index_file.file_name}"
+            return f"{self.index_path()}/{index_file.file_name}"
+        path = to_file_io_path(f"{self.bucket_path(partition, bucket, 
True)}/{index_file.file_name}")
+        # Java Float/Double.toString can produce different digits across JDK
+        # versions. Only these floating partition spellings need a lookup.
+        if file_io is not None and index_file.index_type in 
('DELETION_VECTORS', 'HASH') and not file_io.exists(path):
+            python_path = to_file_io_path(f"{self.bucket_path(partition, 
bucket)}/{index_file.file_name}")
             alternate = self._find_floating_bucket_index(partition, bucket, 
index_file.file_name, file_io, python_path)
             if alternate is not None:
                 return alternate
-            if python_path != path and file_io.exists(python_path):
-                return python_path
-            if file_io.exists(legacy_path):
-                return legacy_path
         return path
 
     def _find_floating_bucket_index(self, partition, bucket, file_name, 
file_io, python_path):
@@ -314,7 +305,7 @@ class FileStorePathFactory:
         # Float/Double.toString changed across JDK releases. Only if the usual
         # path is missing, inspect floating partition components and compare
         # their exact encoded values (including the sign of zero).
-        paths = [self.data_file_path()]
+        paths = [to_file_io_path(resolve_path(self.data_file_path(), '.'))]
         for i, text in enumerate(self._canonical_partition(partition)):
             prefix = _escape_partition_component(self.partition_keys[i]) + '='
             if not floating[i] or partition[i] is None:
@@ -358,7 +349,7 @@ class IndexPathFactory:
         self._file_count = 0
 
     def index_path(self) -> str:
-        """Return the table index path used as a read fallback."""
+        """Return the default table index directory."""
         return self._index_path
 
     def global_index_root_path(self) -> str:
diff --git a/paimon-python/pypaimon/write/commit/conflict_detection.py 
b/paimon-python/pypaimon/write/commit/conflict_detection.py
index 2792c84fca..f15917c3c2 100644
--- a/paimon-python/pypaimon/write/commit/conflict_detection.py
+++ b/paimon-python/pypaimon/write/commit/conflict_detection.py
@@ -301,7 +301,12 @@ class ConflictDetection:
 
     def check_hash_index_conflicts(
             self, latest_snapshot, delta_index_entries=None):
-        """Detect stale full-file replacements of dynamic-bucket HASH 
indexes."""
+        """Validate explicit HASH deletes and reject competing additions.
+
+        Java/Rust ADDs replace the complete bucket index without a DELETE.
+        A retained file alone does not prove a conflict; that protocol requires
+        one writer owner per bucket. Explicit DELETEs still detect stale 
writers.
+        """
         hash_entries = [
             entry for entry in (delta_index_entries or [])
             if entry.index_file.index_type == IndexManifestFile.HASH_INDEX
@@ -311,10 +316,6 @@ class ConflictDetection:
 
         delete_entries = [entry for entry in hash_entries if entry.kind == 1]
         add_entries = [entry for entry in hash_entries if entry.kind == 0]
-        delete_names = {
-            entry.index_file.file_name for entry in delete_entries
-        }
-
         current_entries = []
         if latest_snapshot is not None and latest_snapshot.index_manifest is 
not None:
             current_entries = [
@@ -353,22 +354,6 @@ class ConflictDetection:
                 )
             additions_by_bucket[key] = add
 
-            retained = [
-                entry for entry in current_entries
-                if entry.index_file.file_name not in delete_names
-                and tuple(entry.partition.values) == key[0]
-                and entry.bucket == key[1]
-            ]
-            if retained:
-                return RuntimeError(
-                    "HASH index conflict detected: partition {}, bucket {} "
-                    "already has newer index file {}.".format(
-                        key[0],
-                        key[1],
-                        retained[0].index_file.file_name,
-                    )
-                )
-
         return None
 
     def check_deletion_vector_index_conflicts(self,
diff --git a/paimon-python/pypaimon/write/file_store_commit.py 
b/paimon-python/pypaimon/write/file_store_commit.py
index 1b70cf8a53..d7396d367a 100644
--- a/paimon-python/pypaimon/write/file_store_commit.py
+++ b/paimon-python/pypaimon/write/file_store_commit.py
@@ -108,7 +108,7 @@ def _abort_commit_messages(table, commit_messages: 
List[CommitMessage]):
             try:
                 index_file = entry.index_file
                 file_name = index_file.file_name
-                if index_file.index_type == 'DELETION_VECTORS':
+                if index_file.index_type in ('DELETION_VECTORS', 'HASH'):
                     path = table.path_factory().bucket_index_path(
                         tuple(entry.partition.values), entry.bucket, 
index_file, table.file_io)
                 else:
diff --git a/paimon-python/pypaimon/write/native_write.py 
b/paimon-python/pypaimon/write/native_write.py
index 8e00e13171..ca56bd6f7e 100644
--- a/paimon-python/pypaimon/write/native_write.py
+++ b/paimon-python/pypaimon/write/native_write.py
@@ -71,7 +71,7 @@ def create_native_write(table, commit_user, 
static_partition=None, stream=False)
             # Rust does not produce the optional random-access .row sidecars.
             or (table.options.data_evolution_enabled()
                 and table.options.data_evolution_row_sidecar_enabled())
-            or table.bucket_mode() not in (BucketMode.HASH_FIXED,
+            or table.bucket_mode() not in (BucketMode.HASH_FIXED, 
BucketMode.HASH_DYNAMIC,
                                            BucketMode.BUCKET_UNAWARE)
             or (table.options.deletion_vectors_enabled()
                 and table.options.merge_engine() in 
(MergeEngine.PARTIAL_UPDATE,
diff --git a/paimon-python/pypaimon/write/row_key_extractor.py 
b/paimon-python/pypaimon/write/row_key_extractor.py
index d5053e983f..16c46fa6f2 100644
--- a/paimon-python/pypaimon/write/row_key_extractor.py
+++ b/paimon-python/pypaimon/write/row_key_extractor.py
@@ -443,6 +443,9 @@ class DynamicBucketRowKeyExtractor(RowKeyExtractor):
                 f"'DynamicBucketRowKeyExtractor', but found: {num_buckets}"
             )
 
+        if table_schema.options.get(CoreOptions.BUCKET_KEY.key()) is not None:
+            raise ValueError("Cannot define 'bucket-key' in dynamic bucket 
mode")
+
         opts = CoreOptions.from_dict(table_schema.options)
         self._table = table
         self.base_snapshot_id = 0

Reply via email to