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 3f297583a9 [Python] Keep grouped bucket writes on Native (#10412)
3f297583a9 is described below
commit 3f297583a936d52150224ea66c420e44c80672e3
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Oct 7 16:39:01 2026 +0800
[Python] Keep grouped bucket writes on Native (#10412)
---
paimon-python/conftest.py | 21 +-
paimon-python/pypaimon/daft/daft_datasink.py | 47 +--
paimon-python/pypaimon/daft/daft_paimon.py | 13 -
paimon-python/pypaimon/index/dynamic_bucket.py | 37 +-
paimon-python/pypaimon/table/file_store_table.py | 7 +-
paimon-python/pypaimon/tests/arrow_schema_test.py | 2 +-
.../tests/daft/daft_pk_distributed_write_test.py | 57 +++
.../tests/native_precomputed_bucket_test.py | 420 +++++++++++++++++++++
.../pypaimon/tests/write/dynamic_bucket_test.py | 88 ++---
paimon-python/pypaimon/write/file_store_write.py | 13 +-
paimon-python/pypaimon/write/native_write.py | 44 ++-
.../pypaimon/write/postpone_batch_table_write.py | 8 +-
paimon-python/pypaimon/write/row_key_extractor.py | 61 +--
paimon-python/pypaimon/write/table_write.py | 114 ++----
paimon-python/pypaimon/write/write_builder.py | 20 +-
paimon-python/pypaimon/write/writer/data_writer.py | 11 +-
16 files changed, 681 insertions(+), 282 deletions(-)
diff --git a/paimon-python/conftest.py b/paimon-python/conftest.py
index 7aa11d1fc6..4288d9b997 100644
--- a/paimon-python/conftest.py
+++ b/paimon-python/conftest.py
@@ -130,17 +130,20 @@ def pytest_configure(config):
if _native_write_enabled():
from pypaimon.write.native_write import NativeTableWrite
- original_write = NativeTableWrite.write_arrow_batch
+ def track_write(method):
+ original_write = getattr(NativeTableWrite, method)
- def tracked_write(self, data):
- global _native_write_count
- native = self._native_writer is not None
- result = original_write(self, data)
- if native and data.num_rows and _force_native_write_for_test:
- _native_write_count += 1
- return result
+ def tracked_write(self, data, *args, **kwargs):
+ global _native_write_count
+ native = self._native_writer is not None
+ result = original_write(self, data, *args, **kwargs)
+ if native and data.num_rows and _force_native_write_for_test:
+ _native_write_count += 1
+ return result
- NativeTableWrite.write_arrow_batch = tracked_write
+ setattr(NativeTableWrite, method, tracked_write)
+
+ track_write('write_arrow_batch')
if _native_commit_enabled():
from pypaimon.write.table_commit import TableCommit
diff --git a/paimon-python/pypaimon/daft/daft_datasink.py
b/paimon-python/pypaimon/daft/daft_datasink.py
index 1376eebe2b..9bf0dbe8ad 100644
--- a/paimon-python/pypaimon/daft/daft_datasink.py
+++ b/paimon-python/pypaimon/daft/daft_datasink.py
@@ -519,7 +519,6 @@ def make_dynamic_bucket_assignment_udf(
num_assigners: int,
bucket_column: str,
key_hash_column: str,
- new_mapping_column: str,
base_snapshot_column: str,
ignore_existing: bool = False,
base_snapshot_id: int | None = None,
@@ -549,9 +548,7 @@ def make_dynamic_bucket_assignment_udf(
field.name: DataType.from_arrow_type(field.type)
for field in output_fields
}
- return_fields[key_hash_column] = DataType.int32()
return_fields[bucket_column] = DataType.int32()
- return_fields[new_mapping_column] = DataType.bool()
return_fields[base_snapshot_column] = DataType.int64()
@daft.func.batch(
@@ -566,9 +563,7 @@ def make_dynamic_bucket_assignment_udf(
pa.array([], type=pa.struct([
pa.field(field.name, field.type) for field in output_fields
] + [
- pa.field(key_hash_column, pa.int32()),
pa.field(bucket_column, pa.int32()),
- pa.field(new_mapping_column, pa.bool_()),
pa.field(base_snapshot_column, pa.int64()),
]))
)
@@ -598,25 +593,21 @@ def make_dynamic_bucket_assignment_udf(
base_snapshot_id=base_snapshot_id,
)
buckets = []
- new_mappings = []
offset = 0
for batch in arrow_table.to_batches():
batch_hashes = key_hash_values[offset:offset + batch.num_rows]
- _, batch_buckets, batch_new_mappings = (
+ _, batch_buckets, _ = (
extractor.extract_partition_bucket_status_from_hashes_batch(
batch, batch_hashes
)
)
buckets.extend(batch_buckets)
- new_mappings.extend(batch_new_mappings)
offset += batch.num_rows
arrays = [
arrow_table.column(field.name).combine_chunks()
for field in output_fields
]
- arrays.append(pa.array(key_hash_values, type=pa.int32()))
arrays.append(pa.array(buckets, type=pa.int32()))
- arrays.append(pa.array(new_mappings, type=pa.bool_()))
arrays.append(pa.array(
[extractor.base_snapshot_id] * arrow_table.num_rows,
type=pa.int64(),
@@ -625,9 +616,7 @@ def make_dynamic_bucket_assignment_udf(
pa.StructArray.from_arrays(
arrays,
names=[field.name for field in output_fields] + [
- key_hash_column,
bucket_column,
- new_mapping_column,
base_snapshot_column,
],
)
@@ -667,8 +656,6 @@ def make_group_write_udf(
def _write_group(*columns) -> Series:
data_columns = columns[:data_column_count]
bucket = None
- key_hash_values = None
- new_mapping_values = None
group_base_snapshot_id = base_snapshot_id
if precomputed_bucket:
bucket_values = columns[data_column_count].to_pylist()
@@ -680,11 +667,7 @@ def make_group_write_udf(
"A Paimon write group contained multiple buckets"
)
if dynamic_bucket:
- key_hash_values = columns[data_column_count + 1].to_pylist()
- new_mapping_values = columns[data_column_count + 2].to_pylist()
- base_snapshot_values = columns[
- data_column_count + 3
- ].to_pylist()
+ base_snapshot_values = columns[data_column_count +
1].to_pylist()
observed_base_snapshot_id = base_snapshot_values[0]
if any(
value != observed_base_snapshot_id
@@ -714,33 +697,13 @@ def make_group_write_udf(
write_builder.commit_user = commit_user
if mode == "overwrite":
write_builder.overwrite({})
- table_write = write_builder.new_write()
if precomputed_bucket and dynamic_bucket:
- table_write.with_dynamic_bucket_index(
- ignore_existing=mode == "overwrite",
- base_snapshot_id=group_base_snapshot_id,
- )
+ write_builder.with_restore_snapshot(group_base_snapshot_id)
+ table_write = write_builder.new_write()
commit_messages = []
try:
if precomputed_bucket:
- offset = 0
- for batch in arrow_table.to_batches():
- batch_hashes = None
- batch_new_mappings = None
- if key_hash_values is not None:
- batch_hashes = key_hash_values[
- offset:offset + batch.num_rows
- ]
- batch_new_mappings = new_mapping_values[
- offset:offset + batch.num_rows
- ]
- table_write.write_arrow_batch_to_bucket(
- batch,
- bucket,
- batch_hashes,
- batch_new_mappings,
- )
- offset += batch.num_rows
+ table_write.write_arrow(arrow_table, bucket=bucket)
else:
table_write.write_arrow(arrow_table)
commit_messages = table_write.prepare_commit()
diff --git a/paimon-python/pypaimon/daft/daft_paimon.py
b/paimon-python/pypaimon/daft/daft_paimon.py
index b294ede3cd..765c009a33 100644
--- a/paimon-python/pypaimon/daft/daft_paimon.py
+++ b/paimon-python/pypaimon/daft/daft_paimon.py
@@ -324,22 +324,12 @@ def _write_table(
],
"__paimon_key_hash__",
)
- new_mapping_column = _pick_internal_column(
- df.column_names + [
- commit_column,
- assigner_column,
- bucket_column,
- key_hash_column,
- ],
- "__paimon_new_mapping__",
- )
base_snapshot_column = _pick_internal_column(
df.column_names + [
commit_column,
assigner_column,
bucket_column,
key_hash_column,
- new_mapping_column,
],
"__paimon_base_snapshot__",
)
@@ -360,7 +350,6 @@ def _write_table(
num_assigners,
bucket_column,
key_hash_column,
- new_mapping_column,
base_snapshot_column,
ignore_existing=mode == "overwrite",
base_snapshot_id=base_snapshot_id,
@@ -388,8 +377,6 @@ def _write_table(
group_write(
*data_columns,
daft.col(bucket_column),
- daft.col(key_hash_column),
- daft.col(new_mapping_column),
daft.col(base_snapshot_column),
).alias(commit_column)
)
diff --git a/paimon-python/pypaimon/index/dynamic_bucket.py
b/paimon-python/pypaimon/index/dynamic_bucket.py
index a2838a9494..707b03e724 100644
--- a/paimon-python/pypaimon/index/dynamic_bucket.py
+++ b/paimon-python/pypaimon/index/dynamic_bucket.py
@@ -22,7 +22,7 @@ import random
import struct
import uuid
from dataclasses import dataclass
-from typing import Dict, Iterator, List, Optional, Set, Tuple
+from typing import Dict, Iterator, List, Set, Tuple
from pypaimon.index.index_file_handler import IndexFileHandler
from pypaimon.index.index_file_meta import IndexFileMeta
@@ -31,7 +31,6 @@ from pypaimon.table.row.generic_row import GenericRow
HASH_INDEX = "HASH"
_ADD = 0
-_DELETE = 1
SHORT_MAX_VALUE = 32767
MAX_DYNAMIC_BUCKETS = SHORT_MAX_VALUE + 1
_SNAPSHOT_UNSET = object()
@@ -425,7 +424,7 @@ class DynamicBucketIndexMaintainer:
self.snapshot.id if self.snapshot is not None else 0
)
self._states: Dict[
- Tuple[Tuple, int], Tuple[Set[int], Optional[IndexManifestEntry],
bool]
+ Tuple[Tuple, int], Tuple[Set[int], bool]
] = {}
self._new_paths: List[str] = []
@@ -435,33 +434,27 @@ class DynamicBucketIndexMaintainer:
key = (partition, bucket)
state = self._states.get(key)
if state is None:
- hashes, old_entry = self._load_bucket(partition, bucket)
- state = (hashes, old_entry, False)
- hashes, old_entry, modified = state
+ state = (self._load_bucket(partition, bucket), False)
+ hashes, modified = state
previous_size = len(hashes)
hashes.add(to_signed_int32(key_hash))
- self._states[key] = (hashes, old_entry, modified or len(hashes) !=
previous_size)
+ self._states[key] = (hashes, modified or len(hashes) != previous_size)
def prepare_commit(self) -> Dict[Tuple[Tuple, int],
DynamicBucketIndexChanges]:
changes: Dict[Tuple[Tuple, int], DynamicBucketIndexChanges] = {}
prepared_states = {}
- for key, (hashes, old_entry, modified) in self._states.items():
+ for key, (hashes, modified) in self._states.items():
if not modified:
continue
partition, bucket = key
new_entry = self._write_index(partition, bucket, hashes)
changes[key] = DynamicBucketIndexChanges(
additions=[new_entry],
- deletions=[
- IndexManifestEntry(
- kind=_DELETE,
- partition=old_entry.partition,
- bucket=old_entry.bucket,
- index_file=old_entry.index_file,
- )
- ] if old_entry is not None else [],
+ # Java sends only the full new HASH file. BucketedCombiner
+ # replaces the previous entry by partition and bucket.
+ deletions=[],
)
- prepared_states[key] = (hashes, new_entry, False)
+ prepared_states[key] = (hashes, False)
# A failed bucket must not make a retry forget earlier additions.
self._states.update(prepared_states)
return changes
@@ -477,7 +470,7 @@ class DynamicBucketIndexMaintainer:
def _load_bucket(
self, partition: Tuple, bucket: int
- ) -> Tuple[Set[int], Optional[IndexManifestEntry]]:
+ ) -> Set[int]:
"""Load a complete bucket index for full-file replacement.
This matches Java's HASH index lifecycle. A modified bucket therefore
@@ -485,10 +478,10 @@ class DynamicBucketIndexMaintainer:
format is introduced.
"""
if self.ignore_existing:
- return set(), None
+ return set()
snapshot = self.snapshot
if snapshot is None:
- return set(), None
+ return set()
entries = IndexFileHandler(self.table).scan(
snapshot,
lambda entry: entry.index_file.index_type == HASH_INDEX
@@ -502,8 +495,8 @@ class DynamicBucketIndexMaintainer:
f"{[entry.index_file.file_name for entry in entries]}"
)
if not entries:
- return set(), None
- return _read_hashes(self.table, entries[0]), entries[0]
+ return set()
+ return _read_hashes(self.table, entries[0])
def _write_index(
self, partition: Tuple, bucket: int, hashes: Set[int]
diff --git a/paimon-python/pypaimon/table/file_store_table.py
b/paimon-python/pypaimon/table/file_store_table.py
index 26bad0deda..76f2d019a1 100644
--- a/paimon-python/pypaimon/table/file_store_table.py
+++ b/paimon-python/pypaimon/table/file_store_table.py
@@ -520,7 +520,12 @@ class FileStoreTable(Table):
def _copy_with_snapshot(self, snapshot):
"""Keep one resolved read view, including tag metadata and empty
tables."""
from pypaimon.snapshot.time_travel_util import SCAN_KEYS
- options = {key: None for key in SCAN_KEYS if key in
self.table_schema.options}
+ selectors = SCAN_KEYS + [
+ CoreOptions.INCREMENTAL_BETWEEN_TIMESTAMP.key(),
+ CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS.key(),
+ CoreOptions.SCAN_CREATION_TIME_MILLIS.key(),
+ ]
+ options = {key: None for key in selectors if key in
self.table_schema.options}
options[CoreOptions.SCAN_MODE.key()] = "from-snapshot" if snapshot is
not None else "default"
if snapshot is not None:
options[CoreOptions.SCAN_SNAPSHOT_ID.key()] = str(snapshot.id)
diff --git a/paimon-python/pypaimon/tests/arrow_schema_test.py
b/paimon-python/pypaimon/tests/arrow_schema_test.py
index 5abf03e75a..274973a5cf 100644
--- a/paimon-python/pypaimon/tests/arrow_schema_test.py
+++ b/paimon-python/pypaimon/tests/arrow_schema_test.py
@@ -102,7 +102,7 @@ class ArrowSchemaTest(unittest.TestCase):
if entry in ('batch', 'postpone_batch'):
writer.write_arrow_batch(source.to_batches()[0])
elif entry == 'bucket':
- writer.write_arrow_batch_to_bucket(source.to_batches()[0],
0)
+ writer.write_arrow(source.to_batches()[0], bucket=0)
else:
writer.write_arrow(source)
commit.commit(writer.prepare_commit())
diff --git
a/paimon-python/pypaimon/tests/daft/daft_pk_distributed_write_test.py
b/paimon-python/pypaimon/tests/daft/daft_pk_distributed_write_test.py
index c96ad3c20c..e087277d0e 100644
--- a/paimon-python/pypaimon/tests/daft/daft_pk_distributed_write_test.py
+++ b/paimon-python/pypaimon/tests/daft/daft_pk_distributed_write_test.py
@@ -312,6 +312,63 @@ def
test_hash_dynamic_pk_restores_bucket_mapping_across_commits(catalog):
)
[email protected]_plan
[email protected]('bucket', ['4', '-1'])
[email protected]('partitioned', [False, True])
[email protected]('overwrite', [False, True])
+def test_grouped_pk_writes_use_native_without_python_fallback(
+ catalog, monkeypatch, bucket, partitioned, overwrite):
+ from pypaimon.write.native_write import NativeTableWrite
+
+ def reject_fallback(self):
+ raise AssertionError('Grouped Daft write fell back to Python')
+
+ monkeypatch.setattr(NativeTableWrite, '_switch_to_python', reject_fallback)
+ table = _create_pk_table(catalog, 'native_grouped',
partitioned=partitioned, options={
+ 'bucket': bucket, 'dynamic-bucket.target-row-num': '1',
+ 'write.native.enabled': 'true', 'read.native.enabled': 'true',
+ })
+ initial = {'id': [1, 2], 'value': ['old', 'keep']}
+ updated = {'id': [1, 3], 'value': ['new', 'added']}
+ if partitioned:
+ initial['dt'] = ['a', 'b']
+ updated['dt'] = ['a', 'c']
+ _write_table(daft.from_pydict(initial).into_partitions(2),
table).to_pydict()
+ _write_table(daft.from_pydict(updated).into_partitions(2), table,
+ mode='overwrite' if overwrite else 'append').to_pydict()
+ # Partitioned tables overwrite only the incoming partitions by default.
+ expected = updated if overwrite and not partitioned else {
+ 'id': [1, 2, 3], 'value': ['new', 'keep', 'added']}
+ if partitioned:
+ expected['dt'] = ['a', 'b', 'c']
+ assert _read_table(table).sort('id').to_pydict() == expected
+ if bucket == '-1':
+ indexes = [entry for entry in IndexFileHandler(table).scan(
+ table.snapshot_manager().get_latest_snapshot())
+ if entry.index_file.index_type == 'HASH']
+ assert sum(entry.index_file.row_count for entry in indexes) ==
len(expected['id'])
+ assert len({(tuple(entry.partition.values), entry.bucket) for entry in
indexes}) == len(indexes)
+
+
[email protected]('native', [False, pytest.param(True,
marks=pytest.mark.native_plan)])
[email protected]('ignore_option,kind', [
+ ('ignore-delete', '-D'), ('ignore-update-before', '-U')])
+def test_grouped_dynamic_row_kind_filter_keeps_surviving_key_indexed(catalog,
native, ignore_option, kind):
+ options = {'bucket': '-1', 'file.format': 'parquet', 'rowkind.field': 'op',
+ ignore_option: 'true', 'write.native.enabled':
str(native).lower()}
+ catalog.create_table('test_db.filtered_group',
pypaimon.Schema.from_pyarrow_schema(
+ pa.schema([('id', pa.int64()), ('value', pa.string()), ('op',
pa.string())]),
+ primary_keys=['id'], options=options), False)
+ table = catalog.get_table('test_db.filtered_group')
+ _write_table(daft.from_pydict({'id': [1, 1], 'value': ['ignored',
'retained'],
+ 'op': [kind, '+I']}), table).to_pydict()
+ assert _read_table(table).to_pydict() == {'id': [1], 'value':
['retained'], 'op': ['+I']}
+ indexes = [entry for entry in IndexFileHandler(table).scan(
+ table.snapshot_manager().get_latest_snapshot()) if
entry.index_file.index_type == 'HASH']
+ assert len(indexes) == 1
+ assert indexes[0].index_file.row_count == 1
+
+
def test_hash_dynamic_pins_one_driver_snapshot_for_all_group_udfs(
catalog, monkeypatch
):
diff --git a/paimon-python/pypaimon/tests/native_precomputed_bucket_test.py
b/paimon-python/pypaimon/tests/native_precomputed_bucket_test.py
new file mode 100644
index 0000000000..8b62c6b221
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_precomputed_bucket_test.py
@@ -0,0 +1,420 @@
+# 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.
+
+"""Native writes of the partition/bucket groups produced by Daft."""
+
+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_write import NativeTableWrite
+
+
+pytestmark = pytest.mark.native_plan
+
+
+def _table(tmp_path, bucket, partitioned=False, options=None,
primary_key=True):
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+ catalog.create_database('default', True)
+ catalog.create_table('default.t', Schema.from_pyarrow_schema(
+ pa.schema([('id', pa.int64()), ('value', pa.string()), ('pt',
pa.string())]),
+ primary_keys=(['id', 'pt'] if partitioned else ['id']) if primary_key
else [],
+ partition_keys=['pt'] if partitioned else [],
+ options={'bucket': str(bucket), 'file.format': 'parquet',
+ 'write.native.enabled': 'true', 'read.native.enabled': 'true',
+ 'dynamic-bucket.target-row-num': '1', **(options or {})}),
False)
+ return catalog.get_table('default.t')
+
+
+def _batch(ids, values, partitions=None):
+ return pa.record_batch([pa.array(ids, pa.int64()), pa.array(values,
pa.string()),
+ pa.array(partitions or ['a'] * len(ids),
pa.string())],
+ names=['id', 'value', 'pt'])
+
+
+def _rows(table):
+ builder = table.new_read_builder()
+ return sorted(builder.new_read().to_arrow(
+ builder.new_scan().plan().splits()).to_pylist(), key=lambda row:
(row['pt'], row['id']))
+
+
+def _indexes(table):
+ snapshot = table.snapshot_manager().get_latest_snapshot()
+ return [entry for entry in IndexFileHandler(table).scan(snapshot)
+ if entry.index_file.index_type == 'HASH']
+
+
[email protected]('streaming', [False, True])
+def test_native_precomputed_fixed_bucket_after_regular_write(tmp_path,
streaming):
+ table = _table(tmp_path, 4)
+ builder = (table.new_stream_write_builder() if streaming
+ else table.new_batch_write_builder())
+ writer = builder.new_write()
+ assert isinstance(writer, NativeTableWrite)
+ try:
+ with patch.object(writer, '_switch_to_python',
side_effect=AssertionError('Python fallback')):
+ writer.write_arrow_batch(_batch([1], ['regular']))
+ writer.write_arrow(_batch([2, 2], ['old', 'new']), bucket=3)
+ messages = writer.prepare_commit(7) if streaming else
writer.prepare_commit()
+ assert any(message.bucket == 3 for message in messages)
+ commit = builder.new_commit()
+ try:
+ commit.commit(messages, 7) if streaming else
commit.commit(messages)
+ finally:
+ commit.close()
+ finally:
+ writer.close()
+ assert _rows(table) == [{'id': 1, 'value': 'regular', 'pt': 'a'},
+ {'id': 2, 'value': 'new', 'pt': 'a'}]
+
+
[email protected]('partitioned', [False, True])
+def test_native_dynamic_index_keeps_upstream_bucket_on_restart(tmp_path,
partitioned):
+ table = _table(tmp_path, -1, partitioned)
+ for ids, values in [([1, 2], ['one', 'two']), ([2, 3], ['updated',
'three'])]:
+ builder = table.new_batch_write_builder()
+ writer = builder.new_write()
+ assert isinstance(writer, NativeTableWrite)
+ try:
+ with patch.object(writer, '_switch_to_python',
side_effect=AssertionError('Python fallback')):
+ writer.write_arrow(_batch(ids, values), bucket=17)
+ messages = writer.prepare_commit()
+ assert {message.bucket for message in messages} == {17}
+ builder.new_commit().commit(messages)
+ finally:
+ writer.close()
+ indexes = _indexes(table)
+ assert len(indexes) == 1
+ assert indexes[0].bucket == 17
+ assert indexes[0].index_file.row_count == 3
+ assert _rows(table) == [{'id': 1, 'value': 'one', 'pt': 'a'},
+ {'id': 2, 'value': 'updated', 'pt': 'a'},
+ {'id': 3, 'value': 'three', 'pt': 'a'}]
+
+
[email protected]('streaming', [False, True])
[email protected]('partitioned', [False, True])
+def test_native_computes_java_hashes_for_multiple_batches(tmp_path, streaming,
partitioned):
+ table = _table(tmp_path, -1, partitioned)
+ builder = (table.new_stream_write_builder() if streaming
+ else table.new_batch_write_builder())
+ builder.with_restore_snapshot(0)
+ writer = builder.new_write()
+ first = _batch([1, 2], ['one', 'two'])
+ try:
+ with patch.object(writer, '_switch_to_python',
side_effect=AssertionError('Python fallback')):
+ writer.write_arrow(first, bucket=12)
+ # A second batch can refer to this writer's uncommitted mappings.
+ updated = _batch([2, 3], ['updated', 'three'])
+ writer.write_arrow(updated, bucket=12)
+ messages = writer.prepare_commit(8) if streaming else
writer.prepare_commit()
+ assert len(messages) == 1
+ assert messages[0].index_adds[0].index_file.row_count == 3
+ commit = builder.new_commit()
+ try:
+ commit.commit(messages, 8) if streaming else
commit.commit(messages)
+ finally:
+ commit.close()
+ finally:
+ writer.close()
+ assert _rows(table) == [{'id': 1, 'value': 'one', 'pt': 'a'},
+ {'id': 2, 'value': 'updated', 'pt': 'a'},
+ {'id': 3, 'value': 'three', 'pt': 'a'}]
+ # Restored hashes and indexes remain readable by Python's assigner.
+ from pypaimon.write.row_key_extractor import DynamicBucketRowKeyExtractor
+
+ extractor = DynamicBucketRowKeyExtractor(table.table_schema, table=table)
+ assert extractor.extract_partition_bucket_batch(_batch([1, 2, 3], ['a',
'b', 'c']))[1] == [12, 12, 12]
+
+
[email protected]('bucket', [4, -1])
[email protected]('invalid', ['bucket', 'partition'])
+def test_invalid_precomputed_group_cannot_stage_half_a_partition(tmp_path,
bucket, invalid):
+ table = _table(tmp_path, bucket, partitioned=True)
+ writer = table.new_batch_write_builder().new_write()
+ batch = _batch([1, 2], ['a', 'b'])
+ if invalid == 'partition':
+ batch = _batch([1, 2], ['a', 'b'], ['a', 'b'])
+ try:
+ with patch.object(writer, '_switch_to_python',
side_effect=AssertionError('Python fallback')):
+ with pytest.raises(ValueError):
+ writer.write_arrow(batch, bucket=-1 if invalid == 'bucket'
else 0)
+ assert writer.prepare_commit() == []
+ finally:
+ writer.close()
+ assert table.snapshot_manager().get_latest_snapshot() is None
+
+
+def test_precomputed_append_projects_full_or_selected_schema(tmp_path):
+ table = _table(tmp_path, 4, partitioned=True, primary_key=False,
+ options={'bucket-key': 'id'})
+ builder = table.new_batch_write_builder()
+ writer = builder.new_write()
+ try:
+ with patch.object(writer, '_switch_to_python',
side_effect=AssertionError('Python fallback')):
+ writer.with_write_type(['pt', 'id'])
+ writer.write_arrow(_batch([1], ['omitted']), bucket=3)
+ writer.write_arrow(_batch([2], ['omitted']).select(['pt', 'id']),
bucket=3)
+ messages = writer.prepare_commit()
+ assert {message.bucket for message in messages} == {3}
+ assert all(file.write_cols == ['pt', 'id'] for message in messages for
file in message.new_files)
+ builder.new_commit().commit(messages)
+ finally:
+ writer.close()
+ assert _rows(table) == [{'id': 1, 'value': None, 'pt': 'a'},
+ {'id': 2, 'value': None, 'pt': 'a'}]
+
+
[email protected]('scan_mode', ['default', 'latest-full',
'incremental'])
+def
test_pinned_dynamic_configuration_survives_early_python_selection(tmp_path,
scan_mode):
+ options = {'scan.mode': scan_mode}
+ if scan_mode == 'incremental':
+ options['incremental-between-timestamp'] = '0,9999999999999'
+ table = _table(tmp_path, -1, options=options)
+ initial = table.new_batch_write_builder()
+ first = initial.new_write()
+ first.write_arrow(_batch([1], ['old']), bucket=0)
+ initial.new_commit().commit(first.prepare_commit())
+ first.close()
+ pinned_id = table.snapshot_manager().get_latest_snapshot().id
+ writer =
table.new_batch_write_builder().with_restore_snapshot(pinned_id).new_write()
+ try:
+ later = table.new_batch_write_builder()
+ second = later.new_write()
+ second.write_arrow(_batch([2], ['later']), bucket=0)
+ later.new_commit().commit(second.prepare_commit())
+ second.close()
+ # This existing advanced API still selects Python before any write.
+ writer.with_blob_consumer(lambda field, blob: blob)
+ assert writer._python_writer.row_key_extractor.base_snapshot_id ==
pinned_id
+ writer.write_arrow(_batch([1], ['restored']), bucket=0)
+ messages = writer.prepare_commit()
+ assert messages[0].new_files[0].min_sequence_number == 1
+ # Never submit this historical restore after another bucket owner's
write.
+ table.new_batch_write_builder().new_commit().abort(messages)
+ finally:
+ writer.close()
+
+
[email protected]('streaming', [False, True])
+def test_builder_snapshot_configuration_is_copied_to_each_writer(tmp_path,
streaming):
+ table = _table(tmp_path, -1)
+ builder = (table.new_stream_write_builder() if streaming
+ else table.new_batch_write_builder())
+ assert builder.with_restore_snapshot(0) is builder
+ writer = builder.new_write()
+ try:
+ writer.write_arrow(_batch([], []), bucket=0)
+ # Later builder configuration must not alter a writer already created.
+ builder.with_restore_snapshot(99)
+ writer.write_arrow(_batch([1], ['one']), bucket=0)
+ messages = writer.prepare_commit(1) if streaming else
writer.prepare_commit()
+ assert messages[0].index_adds[0].index_file.row_count == 1
+ finally:
+ writer.close()
+
+
+def
test_native_hash_mapping_conflict_poisoned_writer_preserves_prepared_files(tmp_path):
+ table = _table(tmp_path, -1)
+ builder = table.new_stream_write_builder()
+ writer = builder.new_write()
+ try:
+ writer.write_arrow(_batch([1], ['published']), bucket=7)
+ messages = writer.prepare_commit(1)
+ builder.new_commit().commit(messages, 1)
+ paths = [file.file_path for message in messages for file in
message.new_files]
+ writer.write_arrow(_batch([2], ['pending']), bucket=7)
+ with pytest.raises(ValueError, match='belongs to bucket 7'):
+ writer.write_arrow(_batch([2], ['conflict']), bucket=8)
+ with pytest.raises(ValueError, match='cannot be reused'):
+ writer.prepare_commit(2)
+ assert all(table.file_io.exists(path) for path in paths)
+ finally:
+ writer.close()
+ assert _rows(table) == [{'id': 1, 'value': 'published', 'pt': 'a'}]
+
+
[email protected]('publish_first', [False, True])
+def test_native_checkpoint_sequence_advances_with_pinned_index_base(tmp_path,
publish_first):
+ table = _table(tmp_path, -1)
+ builder = table.new_stream_write_builder().with_restore_snapshot(0)
+ writer = builder.new_write()
+ commit = builder.new_commit()
+ try:
+ writer.write_arrow(_batch([2, 1], ['other', 'old']), bucket=7)
+ first = writer.prepare_commit(1)
+ if publish_first:
+ commit.commit(first, 1)
+ writer.write_arrow(_batch([1], ['new']), bucket=7)
+ second = writer.prepare_commit(2)
+ assert second[0].new_files[0].min_sequence_number == 2
+ if not publish_first:
+ commit.commit(first, 1)
+ commit.commit(second, 2)
+ finally:
+ writer.close()
+ commit.close()
+ assert _rows(table) == [{'id': 1, 'value': 'new', 'pt': 'a'},
+ {'id': 2, 'value': 'other', 'pt': 'a'}]
+
+
+def test_restore_snapshot_pins_sequences_and_default_restores_latest(tmp_path):
+ table = _table(tmp_path, -1)
+ first_builder = table.new_batch_write_builder()
+ first = first_builder.new_write()
+ first.write_arrow(_batch([1], ['old']), bucket=7)
+ first_builder.new_commit().commit(first.prepare_commit())
+ first.close()
+ builder = table.new_batch_write_builder().with_restore_snapshot(1)
+ pinned = builder.new_write()
+ try:
+ later_builder = table.new_batch_write_builder()
+ later = later_builder.new_write()
+ later.write_arrow(_batch([1, 1, 1], ['b', 'c', 'd']), bucket=7)
+ later_builder.new_commit().commit(later.prepare_commit())
+ later.close()
+ pinned.write_arrow(_batch([1], ['new']), bucket=7)
+ messages = pinned.prepare_commit()
+ assert messages[0].new_files[0].min_sequence_number == 1
+ # These messages were never submitted. Preserve the newer bucket
owner's
+ # data and verify a default writer still restores the latest snapshot.
+ builder.new_commit().abort(messages)
+ finally:
+ pinned.close()
+ assert _rows(table) == [{'id': 1, 'value': 'd', 'pt': 'a'}]
+ current_builder = table.new_batch_write_builder()
+ current = current_builder.new_write()
+ try:
+ current.write_arrow(_batch([1], ['new']), bucket=7)
+ messages = current.prepare_commit()
+ assert messages[0].new_files[0].min_sequence_number == 4
+ current_builder.new_commit().commit(messages)
+ finally:
+ current.close()
+ assert _rows(table) == [{'id': 1, 'value': 'new', 'pt': 'a'}]
+
+
[email protected]('streaming', [False, True])
[email protected]('bucket_mode', [4, -1])
+def test_arrow_table_keeps_explicit_zero_bucket_across_chunks(tmp_path,
streaming, bucket_mode):
+ table = _table(tmp_path, bucket_mode)
+ builder = (table.new_stream_write_builder() if streaming
+ else table.new_batch_write_builder())
+ writer = builder.new_write()
+ data = pa.Table.from_batches([_batch([1, 2], ['old', 'other']),
_batch([1], ['new'])])
+ assert len(data.to_batches()) == 2
+ try:
+ with patch.object(writer, '_switch_to_python',
side_effect=AssertionError('Python fallback')):
+ writer.write_arrow(data, bucket=0)
+ messages = writer.prepare_commit(1) if streaming else
writer.prepare_commit()
+ assert {message.bucket for message in messages} == {0}
+ commit = builder.new_commit()
+ try:
+ commit.commit(messages, 1) if streaming else
commit.commit(messages)
+ finally:
+ commit.close()
+ finally:
+ writer.close()
+ assert _rows(table) == [{'id': 1, 'value': 'new', 'pt': 'a'},
+ {'id': 2, 'value': 'other', 'pt': 'a'}]
+
+
[email protected]('streaming', [False, True])
+def
test_builder_rejects_invalid_index_snapshot_before_native_selection(tmp_path,
streaming):
+ table = _table(tmp_path, -1)
+ builder = (table.new_stream_write_builder() if streaming
+ else table.new_batch_write_builder())
+ for value in [-1, True, None, '1']:
+ with pytest.raises(ValueError, match='nonnegative integer'):
+ builder.with_restore_snapshot(value)
+ fixed = _table(tmp_path / 'fixed', 4)
+ with pytest.raises(ValueError, match='HASH_DYNAMIC'):
+ fixed.new_batch_write_builder().with_restore_snapshot(0)
+ assert table.snapshot_manager().get_latest_snapshot() is None
+
+
[email protected]('python_fallback', [False, True])
+def test_empty_restore_ignores_existing_sequences_and_hashes(tmp_path,
python_fallback):
+ table = _table(tmp_path, -1)
+ initial = table.new_batch_write_builder()
+ first = initial.new_write()
+ first.write_arrow(_batch([1, 2], ['one', 'two']), bucket=0)
+ initial.new_commit().commit(first.prepare_commit())
+ first.close()
+ builder = table.new_batch_write_builder().with_restore_snapshot(0)
+ writer = builder.new_write()
+ try:
+ if python_fallback:
+ writer.with_blob_consumer(lambda field, blob: blob)
+ writer.write_arrow(_batch([3], ['empty-base']), bucket=0)
+ messages = writer.prepare_commit()
+ assert messages[0].new_files[0].min_sequence_number == 0
+ assert messages[0].index_adds[0].index_file.row_count == 1
+ # Explicit abort is safe: these messages have never been submitted.
+ builder.new_commit().abort(messages)
+ finally:
+ writer.close()
+ assert _rows(table) == [{'id': 1, 'value': 'one', 'pt': 'a'},
+ {'id': 2, 'value': 'two', 'pt': 'a'}]
+
+
[email protected]('overwrite', [False, True])
+def test_empty_restore_preserves_python_write_authorization(tmp_path,
overwrite):
+ from pypaimon.catalog.catalog_exception import TableNoPermissionException
+ from pypaimon.catalog.table_query_auth import TableQueryAuthResult
+
+ table = _table(tmp_path, -1)
+ builder = table.new_batch_write_builder().with_restore_snapshot(0)
+ if overwrite:
+ builder.overwrite()
+ writer = builder.new_write()
+ # Exercise the ordinary writer selected before any Native data is staged.
+ writer.with_blob_consumer(lambda field, blob: blob)
+ auth = TableQueryAuthResult(None, {'value': '{"name":"NULL"}'})
+ try:
+ with patch.object(type(table.catalog_environment), 'table_query_auth',
+ return_value=lambda _: auth):
+ with pytest.raises(TableNoPermissionException):
+ writer.write_arrow(_batch([1], ['forbidden']), bucket=0)
+ finally:
+ writer.close()
+ assert table.snapshot_manager().get_latest_snapshot() is None
+
+
+def test_python_pk_file_sequence_range_matches_deduplicated_rows(tmp_path):
+ import pyarrow.parquet as pq
+
+ table = _table(tmp_path, -1)
+ builder = table.new_batch_write_builder().with_restore_snapshot(0)
+ writer = builder.new_write()
+ try:
+ writer.with_blob_consumer(lambda field, blob: blob)
+ writer.write_arrow(_batch([1, 1], ['old', 'new']), bucket=0)
+ messages = writer.prepare_commit()
+ file = messages[0].new_files[0]
+ sequences = pq.read_table(file.file_path,
columns=['_SEQUENCE_NUMBER']).column(0).to_pylist()
+ assert sequences == [1]
+ assert file.row_count == 1
+ assert file.min_sequence_number == min(sequences)
+ assert file.max_sequence_number == max(sequences)
+ builder.new_commit().commit(messages)
+ finally:
+ writer.close()
+ assert _rows(table) == [{'id': 1, 'value': 'new', 'pt': 'a'}]
diff --git a/paimon-python/pypaimon/tests/write/dynamic_bucket_test.py
b/paimon-python/pypaimon/tests/write/dynamic_bucket_test.py
index 218532bbbf..a8b71f6b73 100644
--- a/paimon-python/pypaimon/tests/write/dynamic_bucket_test.py
+++ b/paimon-python/pypaimon/tests/write/dynamic_bucket_test.py
@@ -69,13 +69,17 @@ class DynamicBucketTest(unittest.TestCase):
return catalog.get_table(f'default.{name}')
@staticmethod
- def _prepare_indexed_write(table, ids):
+ def _prepare_indexed_write(table, ids, bucket=None):
builder = table.new_batch_write_builder()
- writer = builder.new_write().with_dynamic_bucket_index()
- writer.write_arrow_batch(pa.RecordBatch.from_pydict({
+ writer = builder.new_write()
+ batch = pa.RecordBatch.from_pydict({
'id': ids,
'value': [f'v-{value}' for value in ids],
- }))
+ })
+ if bucket is None:
+ writer.write_arrow_batch(batch)
+ else:
+ writer.write_arrow(batch, bucket=bucket)
return writer, builder.new_commit(), writer.prepare_commit()
@staticmethod
@@ -538,8 +542,7 @@ class DynamicBucketTest(unittest.TestCase):
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()
+ self.assertEqual([], messages[0].index_deletes)
commit.commit(messages)
indexes = self._hash_indexes(table)
self.assertEqual(1, len(indexes))
@@ -547,7 +550,7 @@ class DynamicBucketTest(unittest.TestCase):
writer.close()
commit.close()
- def test_concurrent_hash_index_replacement_conflicts(self):
+ def test_sequential_hash_index_replacements_keep_one_complete_index(self):
with tempfile.TemporaryDirectory() as root:
table = self._create_table(root, 'concurrent_replace')
seed_writer, seed_commit, seed_messages = (
@@ -560,24 +563,17 @@ class DynamicBucketTest(unittest.TestCase):
writer1, commit1, messages1 = self._prepare_indexed_write(
table, [2]
)
+ commit1.commit(messages1)
writer2, commit2, messages2 = self._prepare_indexed_write(
table, [3]
)
- old_index = messages1[0].index_deletes[0].index_file.file_name
- self.assertEqual(
- old_index,
- messages2[0].index_deletes[0].index_file.file_name,
- )
-
- commit1.commit(messages1)
- with self.assertRaisesRegex(
- RuntimeError, 'HASH index conflict detected'
- ):
- commit2.commit(messages2)
+ self.assertEqual([], messages1[0].index_deletes)
+ self.assertEqual([], messages2[0].index_deletes)
+ commit2.commit(messages2)
indexes = self._hash_indexes(table)
self.assertEqual(1, len(indexes))
- self.assertEqual(2, indexes[0].index_file.row_count)
+ self.assertEqual(3, indexes[0].index_file.row_count)
writer1.close()
writer2.close()
commit1.close()
@@ -598,20 +594,12 @@ class DynamicBucketTest(unittest.TestCase):
seed_writer.close()
seed_commit.close()
- with patch(
- 'pypaimon.index.dynamic_bucket.random.choice',
- return_value=0,
- ):
- writer1, commit1, messages1 = self._prepare_indexed_write(
- table, [3]
- )
- with patch(
- 'pypaimon.index.dynamic_bucket.random.choice',
- return_value=1,
- ):
- writer2, commit2, messages2 = self._prepare_indexed_write(
- table, [4]
- )
+ writer1, commit1, messages1 = self._prepare_indexed_write(
+ table, [3], bucket=0
+ )
+ writer2, commit2, messages2 = self._prepare_indexed_write(
+ table, [4], bucket=1
+ )
self.assertNotEqual(messages1[0].bucket, messages2[0].bucket)
commit1.commit(messages1)
@@ -662,11 +650,13 @@ class DynamicBucketTest(unittest.TestCase):
stale_writer.close()
stale_commit.close()
- def test_retry_then_hash_index_conflict_preserves_prepared_files(self):
+ def
test_retry_after_disjoint_hash_index_commit_preserves_prepared_files(self):
with tempfile.TemporaryDirectory() as root:
- table = self._create_table(root, 'retry_hash_conflict')
+ table = self._create_table(root, 'retry_hash_commit',
target_row_num=1)
self._commit_arrow(table, [0], ['seed'])
- writer, commit, messages = self._prepare_indexed_write(table, [1])
+ # The pending upsert owns bucket 0. A new key is assigned bucket 1,
+ # so the competing commit obeys Java's single owner per bucket.
+ writer, commit, messages = self._prepare_indexed_write(table, [0])
prepared_paths = [
file.file_path
for message in messages
@@ -680,19 +670,22 @@ class DynamicBucketTest(unittest.TestCase):
for entry in message.index_adds
]
original_snapshot_commit = commit.file_store_commit.snapshot_commit
+ snapshot_commit = original_snapshot_commit.commit
calls = 0
def lose_first_compare_and_set(
base_snapshot_uuid, snapshot, statistics):
nonlocal calls
calls += 1
- concurrent_writer, concurrent_commit, concurrent_messages = (
- self._prepare_indexed_write(table, [2])
- )
- concurrent_commit.commit(concurrent_messages)
- concurrent_writer.close()
- concurrent_commit.close()
- return False
+ if calls == 1:
+ concurrent_writer, concurrent_commit, concurrent_messages
= (
+ self._prepare_indexed_write(table, [2])
+ )
+ concurrent_commit.commit(concurrent_messages)
+ concurrent_writer.close()
+ concurrent_commit.close()
+ return False
+ return snapshot_commit(base_snapshot_uuid, snapshot,
statistics)
with patch.object(
original_snapshot_commit,
@@ -702,15 +695,14 @@ class DynamicBucketTest(unittest.TestCase):
commit.file_store_commit,
'_commit_retry_wait',
):
- with self.assertRaisesRegex(
- RuntimeError, 'HASH index conflict detected'
- ):
- commit.commit(messages)
+ commit.commit(messages)
- self.assertEqual(1, calls)
+ self.assertEqual(2, calls)
self.assertTrue(all(
table.file_io.exists(path) for path in prepared_paths
))
+ self.assertEqual({'id': [0, 2], 'value': ['v-0', 'v-2']},
+ self._read_arrow(table).sort_by('id').to_pydict())
writer.close()
commit.close()
diff --git a/paimon-python/pypaimon/write/file_store_write.py
b/paimon-python/pypaimon/write/file_store_write.py
index 599e301eeb..16290ff864 100644
--- a/paimon-python/pypaimon/write/file_store_write.py
+++ b/paimon-python/pypaimon/write/file_store_write.py
@@ -57,6 +57,7 @@ class FileStoreWrite:
self.data_writers: Dict[Tuple, DataWriter] = {}
self._runtime_total_buckets: Dict[Tuple, int] = {}
self.max_seq_numbers: dict = {}
+ self.restore_snapshot_id = None
self.write_cols = None
self.blob_consumer = None
self.blob_uri_reader_factory = None
@@ -173,7 +174,8 @@ class FileStoreWrite:
"unset it or write with Java/Flink/Spark.")
def max_seq_number():
- return self._seq_number_stats(partition).get(bucket, 1)
+ default = -1 if self.table.is_primary_key_table else 1
+ return self._seq_number_stats(partition).get(bucket, default)
# Dedicated Blob files are an append-table layout. PK tables require
# managed packs and references attached to their key-value Parquet
files.
@@ -375,6 +377,15 @@ class FileStoreWrite:
return buckets
def _sequence_read_table(self):
+ if self.restore_snapshot_id is not None:
+ snapshot = None
+ if self.restore_snapshot_id != 0:
+ snapshot =
self.table.snapshot_manager().get_snapshot_by_id(self.restore_snapshot_id)
+ if snapshot is None:
+ raise ValueError("Snapshot id '{}' doesn't
exist".format(self.restore_snapshot_id))
+ # Replace inherited selectors and mode, including an explicit empty
+ # view. plan_for_write still validates row filters and column
masks.
+ return self.table._copy_with_snapshot(snapshot)
return self.table
def _load_seq_number_stats(self, partition: Tuple) -> dict:
diff --git a/paimon-python/pypaimon/write/native_write.py
b/paimon-python/pypaimon/write/native_write.py
index 317940226d..1a53d25f6e 100644
--- a/paimon-python/pypaimon/write/native_write.py
+++ b/paimon-python/pypaimon/write/native_write.py
@@ -48,7 +48,7 @@ def _native_partition_types_supported(schema, partition_keys):
def create_native_write(table, commit_user, static_partition=None,
stream=False,
- *, fixed_bucket=False, bucket_plan=None):
+ *, fixed_bucket=False, bucket_plan=None,
restore_snapshot_id=None):
"""Return a native writer if the table can use the filesystem write
path."""
schema = PyarrowFieldParser.from_paimon_schema(table.table_schema.fields)
if (not native_write_available()
@@ -83,8 +83,10 @@ def create_native_write(table, commit_user,
static_partition=None, stream=False,
builder =
native_table.new_batch_write_builder()._with_commit_user(commit_user)
if static_partition is not None:
builder = builder.with_overwrite(static_partition)
+ if restore_snapshot_id is not None:
+ builder.with_restore_snapshot(restore_snapshot_id)
return NativeTableWrite(table, commit_user, static_partition, stream,
- builder.new_write())
+ builder.new_write(), restore_snapshot_id)
class NativeTableWrite:
@@ -95,7 +97,8 @@ class NativeTableWrite:
logical write across two writers, so it is rejected.
"""
- def __init__(self, table, commit_user, static_partition, stream,
native_writer):
+ def __init__(self, table, commit_user, static_partition, stream,
native_writer,
+ restore_snapshot_id=None):
self.table = table
self.commit_user = commit_user
self.static_partition = static_partition
@@ -106,6 +109,7 @@ class NativeTableWrite:
self._table_schema =
PyarrowFieldParser.from_paimon_schema(table.table_schema.fields)
self._schema = self._table_schema
self._write_cols = None
+ self._restore_snapshot_id = restore_snapshot_id
def _switch_to_python(self):
if self._python_writer is not None:
@@ -121,9 +125,11 @@ class NativeTableWrite:
def _new_python_writer(self):
from pypaimon.write.table_write import BatchTableWrite,
StreamTableWrite
if self.stream:
- writer = StreamTableWrite(self.table, self.commit_user)
+ writer = StreamTableWrite(self.table, self.commit_user,
+
restore_snapshot_id=self._restore_snapshot_id)
else:
- writer = BatchTableWrite(self.table, self.commit_user,
self.static_partition)
+ writer = BatchTableWrite(self.table, self.commit_user,
self.static_partition,
+
restore_snapshot_id=self._restore_snapshot_id)
if self._write_cols is not None:
writer.with_write_type(self._write_cols)
return writer
@@ -148,17 +154,26 @@ class NativeTableWrite:
self._write_cols = names
return self
- def write_arrow(self, data):
+ def write_arrow(self, data, bucket=None):
if self._python_writer is not None:
- return self._python_writer.write_arrow(data)
+ return self._python_writer.write_arrow(data, bucket)
+ data = self._prepare_native_arrow_data(data)
if isinstance(data, pa.RecordBatch):
- return self.write_arrow_batch(data)
+ return self.write_arrow_batch(data, bucket)
for batch in data.to_batches():
- self.write_arrow_batch(batch)
+ self.write_arrow_batch(batch, bucket)
- def write_arrow_batch(self, data):
+ def write_arrow_batch(self, data, bucket=None):
if self._python_writer is not None:
- return self._python_writer.write_arrow_batch(data)
+ return self._python_writer.write_arrow_batch(data, bucket)
+ data = self._prepare_native_arrow_data(data)
+ if data.num_rows:
+ # A failed native write may already have produced files. Never
+ # retry that batch through Python after this point.
+ self._written = True
+ self._native_writer.write_arrow(data, bucket)
+
+ def _prepare_native_arrow_data(self, data):
if self._write_cols is not None and arrow_schemas_compatible(
data.schema, self._table_schema,
check_top_level_nullability=False,
allow_binary_compatibility=True):
@@ -172,12 +187,7 @@ class NativeTableWrite:
"Input schema isn't consistent with table schema and write
cols. "
f"Input schema is: {data.schema} Table schema is:
{self._schema} "
f"Write cols is: {self._write_cols}")
- data = normalize_arrow_strings(data)
- if data.num_rows:
- # A failed native write may already have produced files. Never
- # retry that batch through Python after this point.
- self._written = True
- self._native_writer.write_arrow(data)
+ return normalize_arrow_strings(data)
def write_pandas(self, dataframe):
if self._python_writer is not None:
diff --git a/paimon-python/pypaimon/write/postpone_batch_table_write.py
b/paimon-python/pypaimon/write/postpone_batch_table_write.py
index 25227a06bf..f00f15b544 100644
--- a/paimon-python/pypaimon/write/postpone_batch_table_write.py
+++ b/paimon-python/pypaimon/write/postpone_batch_table_write.py
@@ -126,14 +126,18 @@ class PostponeFixedBucketBatchTableWrite(BatchTableWrite):
for partition in self._planner.input_partition_stats(data)
)
- def write_arrow(self, table: pa.Table):
+ def write_arrow(self, table: pa.Table, bucket=None):
+ if bucket is not None:
+ raise ValueError('Precomputed bucket writes require HASH_FIXED or
HASH_DYNAMIC tables')
table = self._prepare_arrow_data(table)
if not self._buffer_input(table):
return super().write_arrow(table)
self._pending_inputs.extend(
("batch", batch) for batch in table.to_batches())
- def write_arrow_batch(self, data: pa.RecordBatch):
+ def write_arrow_batch(self, data: pa.RecordBatch, bucket=None):
+ if bucket is not None:
+ raise ValueError('Precomputed bucket writes require HASH_FIXED or
HASH_DYNAMIC tables')
data = self._prepare_arrow_data(data)
if not self._buffer_input(data):
return super().write_arrow_batch(data)
diff --git a/paimon-python/pypaimon/write/row_key_extractor.py
b/paimon-python/pypaimon/write/row_key_extractor.py
index 1cb629b7b2..53c62bf52a 100644
--- a/paimon-python/pypaimon/write/row_key_extractor.py
+++ b/paimon-python/pypaimon/write/row_key_extractor.py
@@ -492,6 +492,8 @@ class DynamicBucketRowKeyExtractor(RowKeyExtractor):
snapshot = table.snapshot_manager().get_snapshot_by_id(
base_snapshot_id
)
+ if snapshot is None:
+ raise ValueError("Snapshot id '{}' doesn't
exist".format(base_snapshot_id))
self.base_snapshot_id = snapshot.id if snapshot is not None else 0
self._assigner = HashBucketAssigner(
table=table,
@@ -662,58 +664,25 @@ class DynamicBucketRowKeyExtractor(RowKeyExtractor):
def notify_precomputed_bucket_batch(
self, data: pa.RecordBatch, bucket: int
) -> Optional[Tuple]:
- """Maintain HASH indexes for rows assigned by an upstream
coordinator."""
- partitions, _, key_hashes = self.extract_hashes_batch(data)
- return self.notify_precomputed_bucket_hashes_batch(
- data, bucket, key_hashes, partitions=partitions
- )
-
- def notify_precomputed_bucket_hashes_batch(
- self,
- data: pa.RecordBatch,
- bucket: int,
- key_hashes: List[int],
- partitions: Optional[List[Tuple]] = None,
- new_mappings: Optional[List[bool]] = None,
- ) -> Optional[Tuple]:
- """Maintain HASH indexes using hashes carried through the shuffle."""
+ """Maintain HASH indexes for every row in an upstream bucket."""
from pypaimon.index.dynamic_bucket import to_signed_int32
if self._index_maintainer is None:
- raise RuntimeError(
- "Precomputed dynamic buckets require a persistent table
extractor"
- )
- if len(key_hashes) != data.num_rows:
- raise ValueError(
- "Precomputed key hash count {} does not match row count
{}".format(
- len(key_hashes), data.num_rows
- )
- )
- if partitions is None:
- partitions = self._extract_partitions_batch(data)
- if new_mappings is not None and len(new_mappings) != data.num_rows:
- raise ValueError(
- "Precomputed new-mapping count {} does not match row count
{}".format(
- len(new_mappings), data.num_rows
- )
- )
+ raise RuntimeError('Precomputed dynamic buckets require a
persistent table extractor')
+ partitions, _, key_hashes = self.extract_hashes_batch(data)
if not partitions:
return None
partition = tuple(partitions[0])
- if new_mappings is None:
- new_mappings = [True] * data.num_rows
- for actual_partition, key_hash, is_new in zip(
- partitions, key_hashes, new_mappings
- ):
- if tuple(actual_partition) != tuple(partition):
- raise RuntimeError(
- "A precomputed dynamic-bucket group contained multiple "
- f"partitions: expected {partition}, got {actual_partition}"
- )
- if is_new:
- self._index_maintainer.notify_new_record(
- partition, bucket, to_signed_int32(key_hash)
- )
+ if any(tuple(actual_partition) != partition for actual_partition in
partitions):
+ raise RuntimeError(
+ "A precomputed dynamic-bucket group contained multiple "
+ f"partitions: expected {partition}, got {partitions}"
+ )
+ # Java's maintainer observes every written key after RowKind filtering.
+ for key_hash in key_hashes:
+ self._index_maintainer.notify_new_record(
+ partition, bucket, to_signed_int32(key_hash)
+ )
return partition
def release_prepared(self) -> None:
diff --git a/paimon-python/pypaimon/write/table_write.py
b/paimon-python/pypaimon/write/table_write.py
index e982248dbd..04e7b4df01 100644
--- a/paimon-python/pypaimon/write/table_write.py
+++ b/paimon-python/pypaimon/write/table_write.py
@@ -36,14 +36,19 @@ if TYPE_CHECKING:
class TableWrite:
- def __init__(self, table, commit_user, static_partition: Optional[dict] =
None):
+ def __init__(self, table, commit_user, static_partition: Optional[dict] =
None,
+ *, restore_snapshot_id=None):
from pypaimon.table.file_store_table import FileStoreTable
self.table: FileStoreTable = table
self.table_pyarrow_schema =
PyarrowFieldParser.from_paimon_schema(self.table.table_schema.fields)
self.commit_user = commit_user
self.static_partition = static_partition
+ self.restore_snapshot_id = restore_snapshot_id
self.file_store_write = self._create_file_store_write(commit_user)
+ self.file_store_write.restore_snapshot_id = (
+ 0 if static_partition is not None and restore_snapshot_id is not
None
+ else restore_snapshot_id)
if static_partition is not None:
# An overwrite replaces state, not an input changelog. Java's
# overwrite commit does not publish changelog manifests; avoid
@@ -55,17 +60,27 @@ class TableWrite:
return FileStoreWrite(self.table, commit_user)
def _create_row_key_extractor(self, static_partition):
+ if self.restore_snapshot_id is not None:
+ from pypaimon.write.row_key_extractor import
DynamicBucketRowKeyExtractor
+ return DynamicBucketRowKeyExtractor(
+ self.table.table_schema, table=self.table,
+ ignore_existing=static_partition is not None,
+ base_snapshot_id=self.restore_snapshot_id)
return self.table.create_row_key_extractor(
ignore_existing=static_partition is not None
)
- def write_arrow(self, table: pa.Table):
- table = self._prepare_arrow_data(table)
- batches_iterator = table.to_batches()
- for batch in batches_iterator:
- self.write_arrow_batch(batch)
-
- def write_arrow_batch(self, data: pa.RecordBatch):
+ def write_arrow(self, data, bucket: Optional[int] = None):
+ """Write Arrow data, optionally to a bucket assigned upstream."""
+ self._validate_pyarrow_schema(data.schema)
+ if isinstance(data, pa.RecordBatch):
+ return self.write_arrow_batch(data, bucket)
+ for batch in data.to_batches():
+ self.write_arrow_batch(batch, bucket)
+
+ def write_arrow_batch(self, data: pa.RecordBatch, bucket: Optional[int] =
None):
+ if bucket is not None:
+ return self._write_precomputed_bucket(data, bucket)
data = self._prepare_arrow_data(data)
for partition, bucket, row_indices in \
@@ -102,40 +117,8 @@ class TableWrite:
def _write_partition_bucket_batch(self, partition, bucket, data):
self.file_store_write.write(partition, bucket, data)
- def with_dynamic_bucket_index(
- self,
- ignore_existing: bool = False,
- base_snapshot_id: Optional[int] = None,
- ):
- """Enable persistent HASH-index maintenance for coordinated writes."""
- from pypaimon.table.bucket_mode import BucketMode
- from pypaimon.write.row_key_extractor import
DynamicBucketRowKeyExtractor
-
- if self.table.bucket_mode() != BucketMode.HASH_DYNAMIC:
- raise ValueError(
- "Dynamic bucket index maintenance is only valid for "
- "HASH_DYNAMIC tables"
- )
- if self.file_store_write.data_writers:
- raise RuntimeError(
- "Dynamic bucket index maintenance must be enabled before
writing"
- )
- self.row_key_extractor = DynamicBucketRowKeyExtractor(
- self.table.table_schema,
- table=self.table,
- ignore_existing=ignore_existing,
- base_snapshot_id=base_snapshot_id,
- )
- return self
-
- def write_arrow_batch_to_bucket(
- self,
- data: pa.RecordBatch,
- bucket: int,
- key_hashes: Optional[List[int]] = None,
- new_mappings: Optional[List[bool]] = None,
- ):
- """Write one complete group whose bucket was computed upstream."""
+ def _write_precomputed_bucket(self, data, bucket):
+ """Write one partition/bucket group using normal index maintenance."""
from pypaimon.table.bucket_mode import BucketMode
from pypaimon.write.row_key_extractor import
DynamicBucketRowKeyExtractor
@@ -148,42 +131,20 @@ class TableWrite:
if not isinstance(self.row_key_extractor,
DynamicBucketRowKeyExtractor):
if bucket_mode == BucketMode.HASH_DYNAMIC:
raise RuntimeError("Dynamic bucket extractor is not
configured")
+ from pypaimon.index.dynamic_bucket import MAX_DYNAMIC_BUCKETS
+ upper_bound = MAX_DYNAMIC_BUCKETS if bucket_mode ==
BucketMode.HASH_DYNAMIC else self.table.options.bucket()
+ if not 0 <= bucket < upper_bound:
+ raise ValueError('Bucket id must be between 0 and
{}'.format(upper_bound - 1))
data = self._prepare_arrow_data(data)
if bucket_mode == BucketMode.HASH_DYNAMIC:
- if key_hashes is None:
- partition =
self.row_key_extractor.notify_precomputed_bucket_batch(
- data, bucket
- )
- else:
- partition = (
- self.row_key_extractor
- .notify_precomputed_bucket_hashes_batch(
- data,
- bucket,
- key_hashes,
- new_mappings=new_mappings,
- )
- )
+ partition =
self.row_key_extractor.notify_precomputed_bucket_batch(data, bucket)
else:
- if key_hashes is not None:
- raise ValueError(
- "Precomputed key hashes are only valid for HASH_DYNAMIC
tables"
- )
- if new_mappings is not None:
- raise ValueError(
- "Precomputed new-mapping flags are only valid for "
- "HASH_DYNAMIC tables"
- )
partitions = self.row_key_extractor.extract_partitions_batch(data)
if not partitions:
return
partition = tuple(partitions[0])
- for actual_partition in partitions[1:]:
- if tuple(actual_partition) != partition:
- raise RuntimeError(
- "A precomputed fixed-bucket group contained multiple "
- f"partitions: expected {partition}, got
{actual_partition}"
- )
+ if any(tuple(actual) != partition for actual in partitions):
+ raise ValueError('A precomputed bucket group contained
multiple partitions')
if partition is None:
return
self._write_partition_bucket_batch(partition, bucket, data)
@@ -387,8 +348,10 @@ class TableWrite:
class BatchTableWrite(TableWrite):
- def __init__(self, table, commit_user, static_partition: Optional[dict] =
None):
- super().__init__(table, commit_user, static_partition)
+ def __init__(self, table, commit_user, static_partition: Optional[dict] =
None,
+ *, restore_snapshot_id=None):
+ super().__init__(table, commit_user, static_partition,
+ restore_snapshot_id=restore_snapshot_id)
self.batch_committed = False
def prepare_commit(self) -> List[CommitMessage]:
@@ -400,8 +363,9 @@ class BatchTableWrite(TableWrite):
class StreamTableWrite(TableWrite):
- def __init__(self, table, commit_user):
- super().__init__(table, commit_user, None)
+ def __init__(self, table, commit_user, *, restore_snapshot_id=None):
+ super().__init__(table, commit_user, None,
+ restore_snapshot_id=restore_snapshot_id)
def prepare_commit(self, commit_identifier) -> List[CommitMessage]:
return self._prepare_commit(commit_identifier)
diff --git a/paimon-python/pypaimon/write/write_builder.py
b/paimon-python/pypaimon/write/write_builder.py
index 4cd4f56d64..ba03648b11 100644
--- a/paimon-python/pypaimon/write/write_builder.py
+++ b/paimon-python/pypaimon/write/write_builder.py
@@ -36,6 +36,7 @@ class WriteBuilder(ABC):
self.table: FileStoreTable = table
self.commit_user = self._create_commit_user()
+ self.restore_snapshot_id = None
def new_write(self) -> TableWrite:
"""Returns a table write."""
@@ -46,6 +47,16 @@ class WriteBuilder(ABC):
def new_commit(self) -> TableCommit:
"""Returns a table commit."""
+ def with_restore_snapshot(self, snapshot_id: int):
+ """Restore dynamic-bucket data and HASH state from a snapshot; 0 is
empty."""
+ from pypaimon.table.bucket_mode import BucketMode
+ if self.table.bucket_mode() != BucketMode.HASH_DYNAMIC:
+ raise ValueError('Restore snapshots are only valid for
HASH_DYNAMIC tables')
+ if isinstance(snapshot_id, bool) or not isinstance(snapshot_id, int)
or snapshot_id < 0:
+ raise ValueError('Restore snapshot id must be a nonnegative
integer')
+ self.restore_snapshot_id = snapshot_id
+ return self
+
def _with_commit_user(self, commit_user: str):
"""Reuse the identity of an existing write or commit operation."""
self.commit_user = commit_user
@@ -69,7 +80,8 @@ class WriteBuilder(ABC):
try:
from pypaimon.write.native_write import create_native_write
return create_native_write(self.table, self.commit_user,
- static_partition, stream, **kwargs)
+ static_partition, stream,
+
restore_snapshot_id=self.restore_snapshot_id, **kwargs)
except Exception as error:
# Construction has not written any data; the normal writer is safe.
logger.debug('Native writer preparation failed; using Python: %s',
error)
@@ -88,7 +100,8 @@ class BatchWriteBuilder(WriteBuilder):
def new_write(self) -> BatchTableWrite:
return (self._native_write(self.static_partition)
- or BatchTableWrite(self.table, self.commit_user,
self.static_partition))
+ or BatchTableWrite(self.table, self.commit_user,
self.static_partition,
+
restore_snapshot_id=self.restore_snapshot_id))
def new_update(self) -> BatchTableUpdate:
return BatchTableUpdate(self.table, self.commit_user)
@@ -102,7 +115,8 @@ class StreamWriteBuilder(WriteBuilder):
def new_write(self) -> StreamTableWrite:
return (self._native_write(stream=True)
- or StreamTableWrite(self.table, self.commit_user))
+ or StreamTableWrite(self.table, self.commit_user,
+
restore_snapshot_id=self.restore_snapshot_id))
def new_update(self) -> StreamTableUpdate:
return StreamTableUpdate(self.table, self.commit_user)
diff --git a/paimon-python/pypaimon/write/writer/data_writer.py
b/paimon-python/pypaimon/write/writer/data_writer.py
index e8f178b974..8c7e7fa45a 100644
--- a/paimon-python/pypaimon/write/writer/data_writer.py
+++ b/paimon-python/pypaimon/write/writer/data_writer.py
@@ -341,8 +341,15 @@ class DataWriter(ABC):
# Read the range without advancing it: the advance belongs with the
# append below, so a retried flush derives the same range.
- min_seq = self.sequence_generator.start
- max_seq = self.sequence_generator.current
+ if self.table.is_primary_key_table and '_SEQUENCE_NUMBER' in
data.schema.names:
+ # PK files can contain a deduplicated or split subset of the
+ # buffer. Java records the range of the rows actually written.
+ sequences = data.column('_SEQUENCE_NUMBER')
+ min_seq = pc.min(sequences).as_py()
+ max_seq = pc.max(sequences).as_py()
+ else:
+ min_seq = self.sequence_generator.start
+ max_seq = self.sequence_generator.current
creation_time = Timestamp.now()
data_meta = self._create_data_file_meta(
file_name=file_name,