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 b0af45681f [python] Expand native cross-partition and postpone write
support (#10354)
b0af45681f is described below
commit b0af45681fffbb1827a23b2f2435098c479dbefa
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Oct 4 09:03:46 2026 +0800
[python] Expand native cross-partition and postpone write support (#10354)
---
docs/docs/pypaimon/ray-data.md | 7 +-
paimon-python/README.md | 49 ++
.../pypaimon/common/options/core_options.py | 16 +
paimon-python/pypaimon/ray/ray_paimon.py | 4 +-
paimon-python/pypaimon/ray/shuffle.py | 57 +-
.../pypaimon/read/scanner/file_scanner.py | 5 +
.../tests/native_cross_partition_write_test.py | 193 +++++++
.../tests/native_postpone_fixed_write_test.py | 573 +++++++++++++++++++++
.../pypaimon/tests/native_postpone_write_test.py | 186 +++++++
.../pypaimon/tests/ray_repartition_test.py | 36 +-
.../pypaimon/tests/write/dynamic_bucket_test.py | 1 +
.../pypaimon/tests/write/table_write_test.py | 19 +-
.../pypaimon/write/commit/commit_scanner.py | 4 +-
.../pypaimon/write/commit/conflict_detection.py | 8 +
.../write/commit/fixed_bucket_commit_check.py | 72 +++
.../write/commit/overwrite_changes_provider.py | 1 +
paimon-python/pypaimon/write/file_store_commit.py | 14 +-
paimon-python/pypaimon/write/file_store_write.py | 24 +-
paimon-python/pypaimon/write/native_commit.py | 4 +-
paimon-python/pypaimon/write/native_write.py | 51 +-
.../pypaimon/write/postpone_batch_table_write.py | 11 +-
paimon-python/pypaimon/write/postpone_bucket.py | 76 ++-
paimon-python/pypaimon/write/ray_datasink.py | 80 +--
paimon-python/pypaimon/write/table_update.py | 2 +-
paimon-python/pypaimon/write/write_builder.py | 4 +-
25 files changed, 1300 insertions(+), 197 deletions(-)
diff --git a/docs/docs/pypaimon/ray-data.md b/docs/docs/pypaimon/ray-data.md
index 241c8014cc..5566ee9e36 100644
--- a/docs/docs/pypaimon/ray-data.md
+++ b/docs/docs/pypaimon/ray-data.md
@@ -267,9 +267,10 @@ For non-HASH_FIXED append-only tables, the dataset is
written as-is.
Postpone-bucket tables (`bucket = -2`) follow
`postpone.batch-write-fixed-bucket` (default: `true`). Existing partitions
reuse their bucket count; new partitions infer one from the configured target
-row count or size. Ray materializes the input for this global plan, then sorts
-by partition, bucket, and primary-key hash. One primary key stays with one
-writer, while a large bucket can span multiple Ray blocks. Set
+row count or size. Ray materializes the input for this global plan, then groups
+by partition and bucket. Each complete group is written by one writer inside
+`map_groups()`, including when its input spans multiple Ray blocks. Each group
+must fit in memory on one node. Set
`hash_fixed_precluster="off"` to retain `bucket-postpone` writes. Fixed-bucket
postpone writes support `bucket-function.type=default` only. HASH_DYNAMIC and
CROSS_PARTITION primary-key Ray writes are not supported and fail fast,
diff --git a/paimon-python/README.md b/paimon-python/README.md
index b1b904d6c1..45151f5cab 100644
--- a/paimon-python/README.md
+++ b/paimon-python/README.md
@@ -177,6 +177,55 @@ 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.
+Cross-partition primary keys (`bucket=-1`, with some partition columns outside
+of the primary key) also support native writes. Rust rebuilds the global index
+from live rows on writer startup and preserves the full primary key.
Deduplicate
+moves a key by deleting its old location; first-row keeps its first partition;
+partial-update and aggregation apply changes in the existing partition,
matching
+Java. Use one writer owner for the global key space. Index TTL is not
supported,
+and `sequence.field` and `bucket-key` are invalid for this mode. The Python
writer
+does not implement cross-partition routing, so this mode requires the native
runtime.
+
+Ordinary postpone writes (`bucket=-2`) support native Parquet deduplicate
writes
+without BLOB columns. Pending files retain input order, row kinds and
duplicates, and
+roll at a batch boundary after reaching `target-file-row-num`. Normal scans
expose
+only real buckets after deferred bucket assignment. Other postpone merge
engines,
+BLOB writes use the Python writer.
+
+The fixed-bucket postpone builder uses native Parquet writes for deduplicate,
+first-row, partial-update and aggregation when supplied with a shared bucket
plan
+or `postpone.default-bucket-num`. The default count is used exactly, without
+rounding or applying the inference limit. Append reuses each existing
partition's
+bucket count; overwrite uses the new count for overwritten partitions. Pending
+files may coexist with real buckets and remain available for later assignment.
+
+```python
+from pypaimon.write.postpone_bucket import PostponeBucketPlan
+
+# A driver can serialize this small plan and give it to every worker.
+plan = PostponeBucketPlan({("2026-10-01",): 3, ("2026-10-02",): 5})
+builder =
table.new_postpone_fixed_bucket_write_builder().with_bucket_plan(plan)
+writer = builder.new_write()
+try:
+ writer.write_arrow(arrow_table)
+ messages = writer.prepare_commit()
+finally:
+ writer.close()
+builder.new_commit().commit(messages)
+```
+
+All rows for one partition/bucket must have one writer owner. Python and native
+committers reject overlapping owners or writes that became stale after their
+baseline snapshot. Different real buckets and pending files can be appended
+concurrently. Overwrite detects concurrent changes to any overwritten
partition.
+A shared plan must cover every input partition; a configured default does not
+fill missing entries in an explicit plan. Ray workers use native routing and
+writing with the driver's shared plan.
+
+Local automatic size estimation still uses the Python planner. Fixed-bucket
+native writes currently exclude BLOB and deletion-vector tables. They do not
+perform staging, compaction or automatic rescaling of existing partitions.
+
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
diff --git a/paimon-python/pypaimon/common/options/core_options.py
b/paimon-python/pypaimon/common/options/core_options.py
index ef8c553c28..e419a70415 100644
--- a/paimon-python/pypaimon/common/options/core_options.py
+++ b/paimon-python/pypaimon/common/options/core_options.py
@@ -264,6 +264,16 @@ class CoreOptions:
)
)
+ POSTPONE_DEFAULT_BUCKET_NUM: ConfigOption[int] = (
+ ConfigOptions.key("postpone.default-bucket-num")
+ .int_type()
+ .no_default_value()
+ .with_description(
+ "Exact bucket count for new postpone partitions and overwrite
writes. "
+ "Takes precedence over automatic bucket estimation."
+ )
+ )
+
POSTPONE_TARGET_ROW_NUM_PER_BUCKET: ConfigOption[int] = (
ConfigOptions.key("postpone.target-row-num-per-bucket")
.long_type()
@@ -1308,6 +1318,12 @@ class CoreOptions:
default,
)
+ def postpone_default_bucket_num(self):
+ value = self.options.get(CoreOptions.POSTPONE_DEFAULT_BUCKET_NUM)
+ if value is not None and not 0 < value <= 2147483647:
+ raise ValueError('postpone.default-bucket-num must be a positive
32-bit integer')
+ return value
+
def postpone_target_row_num_per_bucket(self, default=None):
return self.options.get(
CoreOptions.POSTPONE_TARGET_ROW_NUM_PER_BUCKET, default
diff --git a/paimon-python/pypaimon/ray/ray_paimon.py
b/paimon-python/pypaimon/ray/ray_paimon.py
index f14bc5a0ae..65380919d6 100644
--- a/paimon-python/pypaimon/ray/ray_paimon.py
+++ b/paimon-python/pypaimon/ray/ray_paimon.py
@@ -547,8 +547,8 @@ def write_paimon(
writer. For primary-key tables, ``map_groups`` writes each complete
``(partition_keys..., bucket)`` group in one Ray task. Postpone-bucket
writes follow ``postpone.batch-write-fixed-bucket`` by default. Their
- bucket plan is resolved once on the driver and workers write sorted
- blocks to real buckets.
+ bucket plan is resolved once on the driver, then each complete
+ partition/bucket group is written by one task to a real bucket.
HASH_DYNAMIC and CROSS_PARTITION primary-key Ray writes are rejected
because Ray write tasks create independent Paimon writers.
diff --git a/paimon-python/pypaimon/ray/shuffle.py
b/paimon-python/pypaimon/ray/shuffle.py
index f1a7fd6898..c256cc3ad7 100644
--- a/paimon-python/pypaimon/ray/shuffle.py
+++ b/paimon-python/pypaimon/ray/shuffle.py
@@ -22,8 +22,9 @@ The legacy ``map_groups`` strategy groups rows by
``(partition_keys..., bucket)`` so every distinct group lands in a
single Ray task. Primary-key writes consume the complete group in that
task; append-only writes use the regrouped rows as a file-count
-optimization. Ray requires each ``map_groups`` group to fit in memory
-on one node, so keep that strategy behind an explicit opt-in.
+optimization. Postpone fixed-bucket writes use the same grouping to keep
+one writer per partition/bucket. Ray requires each ``map_groups`` group
+to fit in memory on one node.
For append-only tables in any other bucket mode the dataset is returned
unchanged.
@@ -45,7 +46,6 @@ if TYPE_CHECKING:
# runtime by ``_pick_bucket_col_name`` so user tables that happen to
# contain a column with this name still work correctly.
BUCKET_KEY_COL = "__paimon_bucket__"
-WRITER_KEY_COL = "__paimon_writer_key__"
HASH_FIXED_PRECLUSTER_AUTO = "auto"
HASH_FIXED_PRECLUSTER_OFF = "off"
HASH_FIXED_PRECLUSTER_MAP_GROUPS = "map_groups"
@@ -146,57 +146,6 @@ def _group_by_partition_bucket(
return ds_with_bucket.groupby(group_keys), bucket_col
-def _sort_by_partition_bucket_primary_key(
- dataset: "ray.data.Dataset",
- table: "Table",
- extractor,
-):
- """Sort rows so one primary key is owned by one Ray block."""
- partition_keys = list(table.table_schema.partition_keys or [])
- existing_names = set(f.name for f in table.table_schema.fields)
- bucket_col = _pick_bucket_col_name(existing_names)
- existing_names.add(bucket_col)
- writer_key_col = _pick_internal_col_name(
- existing_names, WRITER_KEY_COL
- )
- key_columns = list(table.trimmed_primary_keys)
- key_fields = table.trimmed_primary_keys_fields
-
- def _routing_keys(batch: pa.Table) -> pa.Table:
- if batch.num_rows == 0:
- buckets = []
- writer_keys = []
- else:
- record_batch = batch.combine_chunks().to_batches()[0]
- _, buckets = extractor.extract_partition_bucket_batch(
- record_batch
- )
- columns = [batch.column(name) for name in key_columns]
- writer_keys = [
- extractor._binary_row_hash_code(
- tuple(
- column[row_index].as_py()
- for column in columns
- ),
- key_fields,
- )
- for row_index in range(batch.num_rows)
- ]
- return batch.append_column(
- bucket_col, pa.array(buckets, type=pa.int32())
- ).append_column(
- writer_key_col, pa.array(writer_keys, type=pa.uint32())
- )
-
- with_keys = dataset.map_batches(
- _routing_keys, batch_format="pyarrow", zero_copy_batch=True,
- )
- # Ray keeps equal sort keys in one block. Hash collisions only
- # co-locate additional primary keys.
- sort_keys: List[str] = partition_keys + [bucket_col, writer_key_col]
- return with_keys.sort(sort_keys), [bucket_col, writer_key_col]
-
-
def _identity_batch(batch: pa.Table) -> pa.Table:
return batch
diff --git a/paimon-python/pypaimon/read/scanner/file_scanner.py
b/paimon-python/pypaimon/read/scanner/file_scanner.py
index 17599d970d..503cc78d0a 100755
--- a/paimon-python/pypaimon/read/scanner/file_scanner.py
+++ b/paimon-python/pypaimon/read/scanner/file_scanner.py
@@ -653,6 +653,11 @@ class FileScanner:
return _filter
+ def with_all_buckets(self) -> 'FileScanner':
+ """Include pending buckets for commit metadata scans, as in Java."""
+ self.only_read_real_buckets = False
+ return self
+
def with_shard(self, idx_of_this_subtask: int, number_of_para_subtasks:
int) -> 'FileScanner':
validate_shard(idx_of_this_subtask, number_of_para_subtasks)
if self.start_pos_of_this_subtask is not None:
diff --git a/paimon-python/pypaimon/tests/native_cross_partition_write_test.py
b/paimon-python/pypaimon/tests/native_cross_partition_write_test.py
new file mode 100644
index 0000000000..04b6ae1059
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_cross_partition_write_test.py
@@ -0,0 +1,193 @@
+# 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 cross-partition writes use the Java global-key routing contract."""
+
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.write.native_write import NativeTableWrite
+
+pytestmark = pytest.mark.native_plan
+_SCHEMA = pa.schema([('tenant', pa.string()), ('p', pa.string()),
+ ('id', pa.int32()), ('v', pa.int32())])
+
+
+def _table(tmp_path, options=None, catalog=None, rowkind=False):
+ 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 {})
+ schema = _SCHEMA.append(pa.field('op', pa.string())) if rowkind else
_SCHEMA
+ catalog.create_table('default.t', Schema.from_pyarrow_schema(
+ schema, primary_keys=['tenant', 'id'], partition_keys=['tenant', 'p'],
options=opts), False)
+ return catalog.get_table('default.t')
+
+
+def _batch(rows, rowkind=False):
+ schema = _SCHEMA.append(pa.field('op', pa.string())) if rowkind else
_SCHEMA
+ return pa.RecordBatch.from_pylist([dict(zip(schema.names, row)) for row in
rows], schema=schema)
+
+
+def _rows(table, native):
+ builder = table.copy({'scan.native-plan.enabled': str(native).lower(),
+ 'read.native.enabled':
str(native).lower()}).new_read_builder()
+ # Java first-row scans hide un-compacted L0 files; a write scan includes
them.
+ splits = builder.new_scan().plan_for_write().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(tuple(row[name] for name in _SCHEMA.names) for row in rows)
+
+
+def _commit(commit, messages, native_commit=False, checkpoint=None):
+ def publish():
+ if checkpoint is None:
+ commit.commit(messages)
+ else:
+ commit.commit(messages, checkpoint)
+ if native_commit:
+ with patch.object(commit.file_store_commit, 'commit',
+ side_effect=AssertionError('Python commit
fallback')):
+ publish()
+ else:
+ publish()
+
+
[email protected]('native_commit', [False, True])
[email protected]('engine,expected', [
+ ('deduplicate', [('x', 'b', 1, 20), ('y', 'b', 1, 200)]),
+ ('first-row', [('x', 'a', 1, 10), ('y', 'a', 1, 100)]),
+ ('partial-update', [('x', 'a', 1, 20), ('y', 'a', 1, 200)]),
+ ('aggregation', [('x', 'a', 1, 30), ('y', 'a', 1, 300)]),
+])
+def test_cross_partition_engines_rebuild_full_primary_keys(
+ tmp_path, native_rest_catalog, native_commit, engine, expected):
+ opts = {'merge-engine': engine, 'commit.native.enabled':
str(native_commit).lower()}
+ if engine == 'aggregation':
+ opts['fields.v.aggregate-function'] = 'sum'
+ table = _table(tmp_path, opts, native_rest_catalog)
+ for data in ([('x', 'a', 1, 10), ('y', 'a', 1, 100)],
+ [('x', 'b', 1, 20), ('y', 'b', 1, 200)]):
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ assert isinstance(writer, NativeTableWrite)
+ try:
+ writer.write_arrow_batch(_batch(data))
+ assert writer._python_writer is None
+ messages = writer.prepare_commit()
+ # Global index is rebuilt from data, not the HASH index of local
dynamic buckets.
+ assert all(not m.index_adds and not m.index_deletes for m in
messages)
+ _commit(commit, messages, native_commit)
+ finally:
+ writer.close()
+ commit.close()
+ for native in (False, True):
+ assert _rows(table, native) == expected
+
+
[email protected]('chunk_size', [1, 2, 3, 4])
[email protected]('stream', [False, True])
+def test_cross_partition_migration_event_order(tmp_path, chunk_size, stream):
+ table = _table(tmp_path)
+ builder = table.new_stream_write_builder() if stream else
table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ assert isinstance(writer, NativeTableWrite)
+ data = [('x', 'a/b', 1, 10), ('x', 'c=d', 1, 20),
+ ('x', 'a/b', 1, 30), ('x', 'a/b', 1, 40)]
+ try:
+ for start in range(0, len(data), chunk_size):
+ writer.write_arrow_batch(_batch(data[start:start + chunk_size]))
+ _commit(commit, writer.prepare_commit(1) if stream else
writer.prepare_commit(),
+ checkpoint=1 if stream else None)
+ if stream:
+ # The same assigner must retain locations after preparing a
checkpoint.
+ writer.write_arrow_batch(_batch([('x', 'c=d', 1, 50)]))
+ _commit(commit, writer.prepare_commit(2), checkpoint=2)
+ assert writer.prepare_commit(3) == []
+ finally:
+ writer.close()
+ commit.close()
+ expected = [('x', 'c=d', 1, 50)] if stream else [data[-1]]
+ for native in (False, True):
+ assert _rows(table, native) == expected
+
+
+def test_cross_partition_rowkind_and_row_input(tmp_path):
+ table = _table(tmp_path, {'rowkind.field': 'op'}, rowkind=True)
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ assert isinstance(writer, NativeTableWrite)
+ try:
+ writer.write_row(GenericRow(['x', 'a', 1, 10, '+I'], table.fields))
+ writer.write_arrow_batch(_batch([
+ ('x', 'b', 1, 20, '+U'), ('x', 'a', 1, 30, '+U'),
+ ('x', 'a', 2, 40, '+I'), ('x', 'b', 2, 50, '-D')], True))
+ assert writer._python_writer is None
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+ for native in (False, True):
+ assert _rows(table, native) == [('x', 'a', 1, 30)]
+
+
[email protected]('committed', [False, True])
+def test_cross_partition_abort_preserves_committed_files(tmp_path, committed):
+ table = _table(tmp_path)
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ assert isinstance(writer, NativeTableWrite)
+ try:
+ writer.write_arrow_batch(_batch([('x', 'a', 1, 10), ('x', 'b', 1,
20)]))
+ messages = writer.prepare_commit()
+ paths = [f.file_path for m in messages for f in m.new_files]
+ assert paths and all(table.file_io.exists(p) for p in paths)
+ if committed:
+ commit.commit(messages)
+ writer.abort()
+ assert all(table.file_io.exists(p) == committed for p in paths)
+ finally:
+ writer.close()
+ commit.close()
+ assert _rows(table, True) == ([('x', 'b', 1, 20)] if committed else [])
+
+
+def test_cross_partition_dynamic_overwrite_keeps_migration_deletes(tmp_path):
+ table = _table(tmp_path, {'dynamic-partition-overwrite': 'true'})
+ for data, overwrite in [([('x', 'a', 1, 10), ('y', 'c', 2, 20)], False),
+ ([('x', 'b', 1, 30)], True)]:
+ builder = table.new_batch_write_builder()
+ if overwrite:
+ builder.overwrite()
+ writer, commit = builder.new_write(), builder.new_commit()
+ assert isinstance(writer, NativeTableWrite)
+ try:
+ writer.write_arrow_batch(_batch(data))
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+ for native in (False, True):
+ assert _rows(table, native) == [('x', 'b', 1, 30), ('y', 'c', 2, 20)]
diff --git a/paimon-python/pypaimon/tests/native_postpone_fixed_write_test.py
b/paimon-python/pypaimon/tests/native_postpone_fixed_write_test.py
new file mode 100644
index 0000000000..d9dafe6148
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_postpone_fixed_write_test.py
@@ -0,0 +1,573 @@
+# 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.
+
+"""Fixed-bucket native writes use shared routing and Java commit semantics."""
+
+import pickle
+from unittest.mock import Mock, patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.write.native_write import NativePostponeFixedBucketTableWrite
+from pypaimon.write.postpone_bucket import PostponeBucketPlan,
PostponeBucketPlanner
+
+pytestmark = pytest.mark.native_plan
+_SCHEMA = pa.schema([('id', pa.int32()), ('p', pa.string()), ('v',
pa.int32())])
+
+
+def _table(tmp_path, options=None, catalog=None, schema=_SCHEMA,
partition_keys=None):
+ catalog = catalog or CatalogFactory.create({'warehouse': str(tmp_path)})
+ catalog.create_database('default', True)
+ opts = {'bucket': '-2', 'file.format': 'parquet', 'write.native.enabled':
'true',
+ 'postpone.default-bucket-num': '3'}
+ opts.update(options or {})
+ keys = ['p'] if partition_keys is None else partition_keys
+ catalog.create_table('default.t', Schema.from_pyarrow_schema(
+ schema, primary_keys=['id'] + keys, partition_keys=keys,
options=opts), False)
+ return catalog.get_table('default.t')
+
+
+def _batch(rows):
+ return pa.RecordBatch.from_pylist([dict(zip(_SCHEMA.names, row)) for row
in rows], schema=_SCHEMA)
+
+
+def _rows(table):
+ read = table.new_read_builder()
+ return
sorted(read.new_read().to_arrow(read.new_scan().plan().splits()).to_pylist(),
+ key=lambda row: (row['p'], row['id']))
+
+
+def _write(table, rows, plan=None, overwrite=False):
+ builder = table.new_postpone_fixed_bucket_write_builder()
+ if plan is not None:
+ builder.with_bucket_plan(plan)
+ if overwrite:
+ builder.overwrite()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ writer.write_arrow_batch(_batch(rows))
+ messages = writer.prepare_commit()
+ commit.commit(messages)
+ return messages
+ finally:
+ writer.close()
+ commit.close()
+
+
[email protected]('native_commit', [False, True])
[email protected]('explicit_plan', [False, True])
+def test_fixed_buckets_are_visible_to_both_readers(
+ tmp_path, native_rest_catalog, native_commit, explicit_plan):
+ table = _table(tmp_path, {'commit.native.enabled':
str(native_commit).lower()}, native_rest_catalog)
+ plan = PostponeBucketPlan({('a/b',): 3, ('b',): 5}) if explicit_plan else
None
+ builder = table.new_postpone_fixed_bucket_write_builder()
+ if plan is not None:
+ builder.with_bucket_plan(plan)
+ writer, commit = builder.new_write(), builder.new_commit()
+ assert isinstance(writer, NativePostponeFixedBucketTableWrite)
+ expected = [dict(id=i, p=p, v=i * 10) for p in ['a/b', 'b'] for i in
range(12)]
+ try:
+ writer.write_arrow(pa.Table.from_pylist(expected, schema=_SCHEMA))
+ writer.write_row(GenericRow([11, 'b', 999], table.fields))
+ messages = writer.prepare_commit()
+ assert writer._python_writer is None
+ assert messages and all(m.bucket >= 0 for m in messages)
+ for message in messages:
+ count = 5 if explicit_plan and message.partition == ('b',) else 3
+ assert message.total_buckets == count
+ assert 0 <= message.bucket < count
+ assert message.check_from_snapshot == 0
+ writer.close()
+ if native_commit:
+ with patch.object(commit.file_store_commit, 'commit',
+ side_effect=AssertionError('Python commit
fallback')):
+ commit.commit(messages)
+ else:
+ commit.commit(messages)
+ finally:
+ writer.close()
+ commit.close()
+ expected[-1]['v'] = 999
+ for native in (False, True):
+ copied = table.copy({'read.native.enabled': str(native).lower(),
+ 'scan.native-plan.enabled': str(native).lower()})
+ assert _rows(copied) == expected
+
+
[email protected]('native', [False, True])
+def test_default_is_exact_and_overwrite_replaces_old_layout(tmp_path, native):
+ table = _table(tmp_path, {'write.native.enabled': str(native).lower(),
+
'postpone.batch-write-fixed-bucket.max-parallelism': '1',
+ 'postpone.target-row-num-per-bucket': '0',
+ 'postpone.target-size-per-bucket': 'invalid'})
+ first = _write(table, [(1, 'a', 10), (2, 'b', 20)])
+ assert {message.total_buckets for message in first} == {3}
+ changed = table.copy({'postpone.default-bucket-num': '5'})
+ appended = _write(changed, [(1, 'a', 11), (3, 'c', 30)])
+ assert {m.partition: m.total_buckets for m in appended} == {('a',): 3,
('c',): 5}
+ replaced = _write(changed, [(4, 'a', 40)], overwrite=True)
+ assert {message.total_buckets for message in replaced} == {5}
+ assert _rows(changed) == [dict(id=4, p='a', v=40), dict(id=2, p='b',
v=20), dict(id=3, p='c', v=30)]
+
+
[email protected]('engine,expected', [('deduplicate', 30),
('partial-update', 30),
+ ('aggregation', 60),
('first-row', 10)])
+def test_fixed_writer_uses_core_merge_engine(tmp_path, engine, expected):
+ options = {'merge-engine': engine}
+ if engine == 'aggregation':
+ options['fields.v.aggregate-function'] = 'sum'
+ table = _table(tmp_path, options)
+ builder = table.new_postpone_fixed_bucket_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ assert isinstance(writer, NativePostponeFixedBucketTableWrite)
+ try:
+ for value in [10, 20, 30]:
+ writer.write_arrow_batch(_batch([(1, 'a', value)]))
+ messages = writer.prepare_commit()
+ assert sum(f.row_count for m in messages for f in m.new_files) == 1
+ # Inspect output even for first-row, whose normal read hides level
zero.
+ import pyarrow.parquet as pq
+ values = []
+ for message in messages:
+ for file in message.new_files:
+ with table.file_io.new_input_stream(file.file_path) as source:
+
values.extend(pq.ParquetFile(source).read().column('v').to_pylist())
+ assert values == [expected]
+ commit.commit(messages)
+ finally:
+ writer.close()
+ commit.close()
+ if engine != 'first-row':
+ assert _rows(table) == [dict(id=1, p='a', v=expected)]
+
+
[email protected]('native', [False, True])
[email protected]('count', ['0', '-1', '2147483648', 'x'])
+def test_default_bucket_validation_is_not_silently_ignored(tmp_path, native,
count):
+ table = _table(tmp_path, {'write.native.enabled': str(native).lower(),
+ 'postpone.default-bucket-num': count})
+ with pytest.raises((ValueError, TypeError),
match='(postpone.default-bucket-num|invalid literal)'):
+ table.new_postpone_fixed_bucket_write_builder().new_write()
+
+
+def test_provided_plan_does_not_fill_missing_partitions_from_default(tmp_path):
+ table = _table(tmp_path)
+ builder = table.new_postpone_fixed_bucket_write_builder().with_bucket_plan(
+ PostponeBucketPlan({('a',): 2}))
+ writer = builder.new_write()
+ assert isinstance(writer, NativePostponeFixedBucketTableWrite)
+ try:
+ writer.write_arrow_batch(_batch([(1, 'a', 10)]))
+ with pytest.raises(Exception, match='does not contain an input
partition'):
+ writer.write_arrow_batch(_batch([(2, 'missing', 20)]))
+ with pytest.raises(Exception, match='closed or failed'):
+ writer.prepare_commit()
+ assert table.snapshot_manager().get_latest_snapshot() is None
+ assert writer._python_writer is None
+ finally:
+ writer.abort()
+
+
[email protected]('prepared', [False, True])
+def test_abort_and_close_keep_commit_message_ownership(tmp_path, prepared):
+ table = _table(tmp_path)
+ builder = table.new_postpone_fixed_bucket_write_builder()
+ writer = builder.new_write()
+ writer.write_arrow_batch(_batch([(1, 'a', 10)]))
+ paths = []
+ if prepared:
+ messages = writer.prepare_commit()
+ paths = [f.file_path for m in messages for f in m.new_files]
+ assert paths and all(table.file_io.exists(path) for path in paths)
+ writer.abort()
+ writer.close()
+ assert all(not table.file_io.exists(path) for path in paths)
+ assert table.snapshot_manager().get_latest_snapshot() is None
+
+
+def test_close_preserves_prepared_and_committed_files(tmp_path):
+ table = _table(tmp_path)
+ builder = table.new_postpone_fixed_bucket_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ writer.write_arrow_batch(_batch([(1, 'a', 10)]))
+ messages = writer.prepare_commit()
+ writer.close()
+ paths = [f.file_path for m in messages for f in m.new_files]
+ assert paths and all(table.file_io.exists(path) for path in paths)
+ commit.commit(messages)
+ writer.abort()
+ assert all(table.file_io.exists(path) for path in paths)
+ assert _rows(table) == [dict(id=1, p='a', v=10)]
+ finally:
+ writer.close()
+ commit.close()
+
+
+def test_fixed_writer_is_one_shot(tmp_path):
+ table = _table(tmp_path)
+ writer = table.new_postpone_fixed_bucket_write_builder().new_write()
+ try:
+ assert writer.prepare_commit() == []
+ with pytest.raises(Exception, match='one-time|one prepare_commit'):
+ writer.prepare_commit()
+ with pytest.raises(Exception, match='one prepare_commit'):
+ writer.write_arrow_batch(_batch([(1, 'a', 10)]))
+ finally:
+ writer.close()
+
+
+def test_python_fallback_retains_specialized_writer_and_plan(tmp_path):
+ from pypaimon.write.postpone_batch_table_write import
PostponeFixedBucketBatchTableWrite
+
+ table = _table(tmp_path)
+ plan = PostponeBucketPlan({('a',): 7})
+ writer =
table.new_postpone_fixed_bucket_write_builder().with_bucket_plan(plan).new_write()
+ assert isinstance(writer, NativePostponeFixedBucketTableWrite)
+ try:
+ # Distributed coordinators and advanced methods may select Python
before data.
+ writer.file_store_write
+ assert isinstance(writer._python_writer,
PostponeFixedBucketBatchTableWrite)
+ writer.write_row(GenericRow([1, 'a', 10], table.fields))
+ messages = writer.prepare_commit()
+ assert {message.total_buckets for message in messages} == {7}
+ finally:
+ writer.abort()
+
+
+def test_cannot_fallback_after_native_data(tmp_path):
+ table = _table(tmp_path)
+ writer = table.new_postpone_fixed_bucket_write_builder().new_write()
+ try:
+ writer.write_arrow_batch(_batch([(1, 'a', 10)]))
+ with pytest.raises(RuntimeError, match='Cannot switch'):
+ writer.file_store_write
+ assert writer._python_writer is None
+ finally:
+ writer.abort()
+
+
+def test_distributed_worker_uses_pickled_plan_without_python_routing(tmp_path):
+ from pypaimon.write.ray_datasink import PaimonDatasink
+ from pypaimon.write.row_key_extractor import
PostponeFixedBucketRowKeyExtractor
+
+ table = _table(tmp_path)
+ plan = pickle.loads(pickle.dumps(PostponeBucketPlan({('a',): 3})))
+ sink = PaimonDatasink(table, postpone_bucket_plan=plan)
+ with patch.object(PostponeBucketPlanner, '_load_bucket_metadata',
+ side_effect=AssertionError('worker planner scan')), \
+ patch.object(PostponeFixedBucketRowKeyExtractor, 'extract',
+ side_effect=AssertionError('Python routing'),
create=True):
+ messages = sink.write([pa.Table.from_batches([_batch([(i, 'a', i) for
i in range(20)])])], Mock())
+ assert {m.total_buckets for m in messages} == {3}
+ assert {m.bucket for m in messages} == {0, 1, 2}
+ assert sum(f.row_count for m in messages for f in m.new_files) == 20
+
+
+def test_plan_arrow_encoding_preserves_colliding_partition_name(tmp_path):
+ schema = pa.schema([('id', pa.int32()), ('total_buckets', pa.string()),
('v', pa.int32())])
+ table = _table(tmp_path, schema=schema, partition_keys=['total_buckets'])
+ plan = PostponeBucketPlan({('a',): 3})
+ encoded = plan.to_arrow(table)
+ assert encoded.column(0).to_pylist() == ['a']
+ assert encoded.column(1).to_pylist() == [3]
+ builder =
table.new_postpone_fixed_bucket_write_builder().with_bucket_plan(plan)
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ assert isinstance(writer, NativePostponeFixedBucketTableWrite)
+ writer.write_arrow_batch(pa.RecordBatch.from_pylist(
+ [dict(id=1, total_buckets='a', v=10)], schema=schema))
+ messages = writer.prepare_commit()
+ assert [m.partition for m in messages] == [('a',)]
+ assert {m.total_buckets for m in messages} == {3}
+ commit.commit(messages)
+ finally:
+ writer.close()
+ commit.close()
+
+
[email protected]('partition, count, message', [
+ ((), 1, 'arity'), (('a',), 0, 'positive'), (('a',), -1, 'positive'),
+ (('a',), True, 'positive'), (('a',), 1.5, 'positive'),
+ (('a',), 2147483648, 'positive'),
+])
+def test_invalid_shared_plan_fails_before_writing(tmp_path, partition, count,
message):
+ table = _table(tmp_path)
+ with pytest.raises(ValueError, match=message):
+ PostponeBucketPlan({partition: count}).to_arrow(table)
+
+
+def test_empty_plan_has_typed_schema(tmp_path):
+ table = _table(tmp_path)
+ plan = PostponeBucketPlan({}).to_arrow(table)
+ assert plan.num_rows == 0
+ assert plan.schema.names == ['p', 'total_buckets']
+ assert plan.schema.field(0).type == pa.string()
+ assert plan.schema.field(1).type == pa.int32()
+
+
+def test_unpartitioned_default_writer(tmp_path):
+ table = _table(tmp_path, partition_keys=[])
+ messages = _write(table, [(i, 'a', i) for i in range(20)])
+ assert {m.total_buckets for m in messages} == {3}
+ assert {m.bucket for m in messages} == {0, 1, 2}
+ assert all(m.partition == () for m in messages)
+ assert len(_rows(table)) == 20
+
+
[email protected]('native_commit', [False, True])
[email protected]('combined', [False, True])
+def test_concurrent_bucket_owners_cannot_silently_lose_update(
+ tmp_path, native_rest_catalog, native_commit, combined):
+ table = _table(tmp_path, {'commit.native.enabled':
str(native_commit).lower(),
+ 'postpone.default-bucket-num': '1'},
native_rest_catalog)
+ builders = [table.new_postpone_fixed_bucket_write_builder() for _ in
range(2)]
+ writers = [builder.new_write() for builder in builders]
+ commits = [builder.new_commit() for builder in builders]
+ try:
+ for i in range(10):
+ writers[0].write_arrow_batch(_batch([(1, 'a', i)]))
+ writers[1].write_arrow_batch(_batch([(1, 'a', 999)]))
+ messages = [writer.prepare_commit() for writer in writers]
+ assert {m.check_from_snapshot for group in messages for m in group} ==
{0}
+ if combined:
+ with pytest.raises(Exception, match='ownership conflict'):
+ commits[0].commit(messages[0] + messages[1])
+ assert table.snapshot_manager().get_latest_snapshot() is None
+ else:
+ commits[0].commit(messages[0])
+ with pytest.raises(Exception, match='ownership conflict'):
+ commits[1].commit(messages[1])
+ assert _rows(table) == [dict(id=1, p='a', v=9)]
+ assert table.snapshot_manager().get_latest_snapshot().id == 1
+ finally:
+ for writer in writers:
+ writer.close()
+ for commit in commits:
+ commit.close()
+
+
[email protected]('native_commit', [False, True])
+def test_disjoint_bucket_owners_can_commit_concurrently(tmp_path,
native_rest_catalog, native_commit):
+ from pypaimon.write.row_key_extractor import
PostponeFixedBucketRowKeyExtractor
+
+ table = _table(tmp_path, {'commit.native.enabled':
str(native_commit).lower()}, native_rest_catalog)
+ extractor = PostponeFixedBucketRowKeyExtractor(table,
PostponeBucketPlan({('a',): 3}))
+ representatives = {}
+ for i in range(50):
+ _, bucket = extractor.extract_partition_bucket_row(dict(id=i, p='a',
v=i))
+ representatives.setdefault(bucket, i)
+ assert set(representatives) == {0, 1, 2}
+ builders = [table.new_postpone_fixed_bucket_write_builder() for _ in
representatives]
+ writers = [builder.new_write() for builder in builders]
+ commits = [builder.new_commit() for builder in builders]
+ try:
+ for writer, i in zip(writers, representatives.values()):
+ writer.write_arrow_batch(_batch([(i, 'a', i)]))
+ messages = [writer.prepare_commit() for writer in writers]
+ assert len({m.bucket for group in messages for m in group}) == 3
+ for commit, group in zip(commits, messages):
+ commit.commit(group)
+ assert _rows(table) == [dict(id=i, p='a', v=i) for i in
sorted(representatives.values())]
+ finally:
+ for writer in writers:
+ writer.close()
+ for commit in commits:
+ commit.close()
+
+
[email protected]('native_commit', [False, True])
[email protected]('order', ['pending-first', 'fixed-first',
'concurrent'])
+def test_pending_and_real_buckets_coexist_without_rewriting_history(
+ tmp_path, native_rest_catalog, native_commit, order):
+ table = _table(tmp_path, {'commit.native.enabled':
str(native_commit).lower()}, native_rest_catalog)
+ fixed = table.new_postpone_fixed_bucket_write_builder()
+ pending = table.new_batch_write_builder()
+ fixed_writer, pending_writer = fixed.new_write(), pending.new_write()
+ fixed_commit, pending_commit = fixed.new_commit(), pending.new_commit()
+ try:
+ if order == 'pending-first':
+ pending_writer.write_arrow_batch(_batch([(2, 'a', 20)]))
+ pending_messages = pending_writer.prepare_commit()
+ pending_commit.commit(pending_messages)
+ fixed_writer.write_arrow_batch(_batch([(1, 'a', 10)]))
+ fixed_messages = fixed_writer.prepare_commit()
+ if order == 'fixed-first':
+ fixed_commit.commit(fixed_messages)
+ if order != 'pending-first':
+ pending_writer.write_arrow_batch(_batch([(2, 'a', 20)]))
+ pending_messages = pending_writer.prepare_commit()
+ pending_commit.commit(pending_messages)
+ if order != 'fixed-first':
+ fixed_commit.commit(fixed_messages)
+ snapshot = table.snapshot_manager().get_latest_snapshot()
+ assert snapshot.total_record_count == 2
+ for message in pending_messages + fixed_messages:
+ for file in message.new_files:
+ assert table.file_io.exists(file.file_path)
+ # Ordinary reads expose real buckets; committed pending rows await
assignment.
+ assert _rows(table) == [dict(id=1, p='a', v=10)]
+ scanner = table.new_read_builder().new_scan().file_scanner
+ manifests, _ = scanner.manifest_scanner()
+ entries = scanner.with_all_buckets().read_manifest_entries(manifests)
+ assert {entry.bucket for entry in entries} == {-2,
fixed_messages[0].bucket}
+ assert sum(entry.file.row_count for entry in entries) == 2
+ finally:
+ fixed_writer.close()
+ pending_writer.close()
+ fixed_commit.close()
+ pending_commit.close()
+
+
[email protected]('native_commit', [False, True])
+def test_fixed_overwrite_rejects_concurrent_pending_rows(tmp_path,
native_rest_catalog, native_commit):
+ table = _table(tmp_path, {'commit.native.enabled':
str(native_commit).lower()}, native_rest_catalog)
+ _write(table, [(1, 'a', 10)])
+ builder = table.new_postpone_fixed_bucket_write_builder().overwrite()
+ writer, commit = builder.new_write(), builder.new_commit()
+ pending = table.new_batch_write_builder()
+ pending_writer, pending_commit = pending.new_write(), pending.new_commit()
+ try:
+ writer.write_arrow_batch(_batch([(1, 'a', 30)]))
+ messages = writer.prepare_commit()
+ pending_writer.write_arrow_batch(_batch([(2, 'a', 20)]))
+ pending_commit.commit(pending_writer.prepare_commit())
+ with pytest.raises(Exception, match='overwrite conflict'):
+ commit.commit(messages)
+ assert
table.snapshot_manager().get_latest_snapshot().total_record_count == 2
+ assert _rows(table) == [dict(id=1, p='a', v=10)]
+ finally:
+ writer.close()
+ commit.close()
+ pending_writer.close()
+ pending_commit.close()
+
+
[email protected]('overwrite,static_partition,expected', [
+ (False, None, 3), (True, None, 5), (False, {}, 5), (False, {'p': 'a'}, 5),
+])
+def test_ray_coordinator_uses_new_default_for_overwrite(
+ tmp_path, overwrite, static_partition, expected):
+ from pypaimon.write.ray_datasink import write_paimon_dataset
+
+ table = _table(tmp_path)
+ _write(table, [(1, 'a', 10)])
+ changed = table.copy({'postpone.default-bucket-num': '5'})
+ dataset = Mock()
+ with patch('pypaimon.write.ray_datasink._collect_partition_stats',
+ return_value=(dataset, {('a',): (1, 0)})), \
+ patch('pypaimon.write.ray_datasink._write_primary_key_groups') as
write:
+ write_paimon_dataset(dataset, changed, overwrite=overwrite,
static_partition=static_partition)
+ assert write.call_args.kwargs['postpone_bucket_plan'].as_dict() ==
{('a',): expected}
+
+
[email protected]('native', [False, True])
+def test_fixed_writer_baseline_survives_message_serialization(tmp_path,
native):
+ from pypaimon.write.commit_message_serializer import
serialize_commit_message, deserialize_commit_message
+
+ table = _table(tmp_path, {'write.native.enabled': str(native).lower()})
+ _write(table, [(1, 'a', 10)])
+ writer = table.new_postpone_fixed_bucket_write_builder().new_write()
+ try:
+ writer.write_arrow_batch(_batch([(1, 'a', 20)]))
+ messages = writer.prepare_commit()
+ for message in messages:
+ imported = deserialize_commit_message(
+ serialize_commit_message(message, table.partition_keys_fields),
+ table.partition_keys_fields, table.trimmed_primary_keys_fields)
+ assert imported.check_from_snapshot == 1
+ assert imported.total_buckets == 3
+ assert imported.bucket == message.bucket
+ finally:
+ writer.abort()
+
+
[email protected]('bucket,count', [(-1, 3), (3, 3), (0, 0)])
+def test_python_commit_rejects_invalid_fixed_bucket_metadata(tmp_path, bucket,
count):
+ table = _table(tmp_path, {'commit.native.enabled': 'false'})
+ builder = table.new_postpone_fixed_bucket_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ writer.write_arrow_batch(_batch([(1, 'a', 10)]))
+ messages = writer.prepare_commit()
+ messages[0].bucket = bucket
+ messages[0].total_buckets = count
+ with pytest.raises((ValueError, RuntimeError), match='Invalid fixed
bucket'):
+ commit.commit(messages)
+ assert table.snapshot_manager().get_latest_snapshot() is None
+ finally:
+ writer.close()
+ commit.close()
+
+
[email protected]('kind', ['int', 'date', 'decimal', 'timestamp',
'unicode'])
+def test_shared_plan_partition_encoding_matches_python(tmp_path, kind):
+ from datetime import date, datetime
+ from decimal import Decimal
+
+ types = {'int': (pa.int32(), [-7, 9]),
+ 'date': (pa.date32(), [date(1969, 12, 31), date(2026, 10, 3)]),
+ 'decimal': (pa.decimal128(12, 2), [Decimal('-1.25'),
Decimal('9.50')]),
+ 'timestamp': (pa.timestamp('us'), [datetime(1969, 12, 31, 23, 59,
59),
+ datetime(2026, 10, 3, 12,
30)]),
+ 'unicode': (pa.string(), ['a/b=%', '中文'])}
+ type_, values = types[kind]
+ schema = pa.schema([('id', pa.int32()), ('p', type_), ('v', pa.int32())])
+ table = _table(tmp_path, schema=schema)
+ plan = PostponeBucketPlan({(value,): i + 2 for i, value in
enumerate(values)})
+ builder =
table.new_postpone_fixed_bucket_write_builder().with_bucket_plan(plan)
+ writer, commit = builder.new_write(), builder.new_commit()
+ expected = [dict(id=i, p=value, v=i * 10) for i, value in
enumerate(values)]
+ try:
+ assert isinstance(writer, NativePostponeFixedBucketTableWrite)
+ writer.write_arrow(pa.Table.from_pylist(expected, schema=schema))
+ messages = writer.prepare_commit()
+ assert {m.partition: m.total_buckets for m in messages} ==
plan.as_dict()
+ commit.commit(messages)
+ for native in (False, True):
+ copied = table.copy({'read.native.enabled': str(native).lower(),
+ 'scan.native-plan.enabled':
str(native).lower()})
+ assert _rows(copied) == expected
+ finally:
+ writer.close()
+ commit.close()
+
+
[email protected]('native', [False, True])
+def test_reused_python_committer_drops_prior_fixed_bucket_baseline(tmp_path,
native):
+ table = _table(tmp_path, {'write.native.enabled': str(native).lower()})
+ first = table.new_postpone_fixed_bucket_write_builder()
+ writer = first.new_write()
+ try:
+ writer.write_arrow_batch(_batch([(1, 'a', 10)]))
+ messages = writer.prepare_commit()
+ # Exercise FileStoreCommit reuse directly: the public batch facade is
one-shot.
+ commit = first.new_commit().file_store_commit
+ commit.commit(messages, 1)
+ pending = table.new_batch_write_builder().new_write()
+ try:
+ pending.write_arrow_batch(_batch([(2, 'a', 20)]))
+ commit.commit(pending.prepare_commit(), 2)
+ finally:
+ pending.close()
+ assert commit.conflict_detection.fixed_bucket_commit_check is None
+ assert
table.snapshot_manager().get_latest_snapshot().total_record_count == 2
+ assert _rows(table) == [dict(id=1, p='a', v=10)]
+ finally:
+ writer.close()
diff --git a/paimon-python/pypaimon/tests/native_postpone_write_test.py
b/paimon-python/pypaimon/tests/native_postpone_write_test.py
new file mode 100644
index 0000000000..2c77b224ac
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_postpone_write_test.py
@@ -0,0 +1,186 @@
+# 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 pending-bucket writes preserve Java replay order and file
metadata."""
+
+from unittest.mock import patch
+
+import pyarrow as pa
+import pyarrow.parquet as pq
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.write.native_write import NativeTableWrite
+
+pytestmark = pytest.mark.native_plan
+_SCHEMA = pa.schema([('id', pa.int32()), ('p', pa.string()),
+ ('v', pa.int32()), ('op', 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': '-2', 'file.format': 'parquet', 'rowkind.field': 'op',
+ 'target-file-row-num': '3', '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(rows):
+ return pa.RecordBatch.from_pylist([dict(zip(_SCHEMA.names, row)) for row
in rows], schema=_SCHEMA)
+
+
+def _file_rows(table, files):
+ rows = []
+ for file in files:
+ # Inspect the unsorted physical file, before the deferred compaction.
+ with table.file_io.new_input_stream(file.file_path) as source:
+ values = pq.ParquetFile(source).read().to_pylist()
+ assert len(values) == file.row_count
+ assert sum(row['_VALUE_KIND'] in (1, 3) for row in values) ==
file.delete_row_count
+ assert [row['_SEQUENCE_NUMBER'] for row in values] == list(range(
+ file.min_sequence_number, file.max_sequence_number + 1))
+ rows.extend(tuple(row[name] for name in _SCHEMA.names) for row in
values)
+ return rows
+
+
[email protected]('native_commit', [False, True])
[email protected]('stream', [False, True])
+def test_postpone_native_files_roll_and_preserve_replay_order(
+ tmp_path, native_rest_catalog, native_commit, stream):
+ table = _table(tmp_path, {'commit.native.enabled':
str(native_commit).lower()}, native_rest_catalog)
+ builder = table.new_stream_write_builder() if stream else
table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ assert isinstance(writer, NativeTableWrite)
+ try:
+ for checkpoint in range(1, 3 if stream else 2):
+ data = [(5, 'a/b', 10, '+I'), (1, 'a/b', 20, '-U'),
+ (3, 'a/b', 30, '+U'), (2, 'a/b', 40, '-D'), (1, 'a/b', 50,
'+I')]
+ for start in (0, 2, 4):
+ writer.write_arrow_batch(_batch(data[start:start + 2]))
+ messages = writer.prepare_commit(checkpoint) if stream else
writer.prepare_commit()
+ assert writer._python_writer is None
+ assert {m.bucket for m in messages} == {-2}
+ files = [f for m in messages for f in m.new_files]
+ assert [f.row_count for f in files] == [4, 1]
+ assert all('-u-' + builder.commit_user + '-s-' in f.file_name for
f in files)
+ assert [(f.min_key.values, f.max_key.values) for f in files] ==
[([5], [2]), ([1], [1])]
+ assert _file_rows(table, files) == data
+ if native_commit:
+ with patch.object(commit.file_store_commit, 'commit',
+ side_effect=AssertionError('Python commit
fallback')):
+ commit.commit(messages, checkpoint) if stream else
commit.commit(messages)
+ else:
+ commit.commit(messages, checkpoint) if stream else
commit.commit(messages)
+ if stream:
+ assert writer.prepare_commit(3) == []
+ finally:
+ writer.close()
+ commit.close()
+ # Postpone records become visible only after assignment to real buckets.
+ for native in (False, True):
+ read = table.copy({'scan.native-plan.enabled': str(native).lower(),
+ 'read.native.enabled':
str(native).lower()}).new_read_builder()
+ assert read.new_scan().plan().splits() == []
+
+
[email protected]('native_commit', [False, True])
+def test_postpone_native_overwrite_and_abort(tmp_path, native_rest_catalog,
native_commit):
+ table = _table(tmp_path, {'commit.native.enabled':
str(native_commit).lower()}, native_rest_catalog)
+ all_files = []
+ for overwrite in (False, True):
+ builder = table.new_batch_write_builder()
+ if overwrite:
+ builder.overwrite()
+ writer, commit = builder.new_write(), builder.new_commit()
+ assert isinstance(writer, NativeTableWrite)
+ try:
+ writer.write_arrow_batch(_batch([(1, 'a', 20 if overwrite else 10,
'+I')]))
+ messages = writer.prepare_commit()
+ files = [f for m in messages for f in m.new_files]
+ assert _file_rows(table, files) == [(1, 'a', 20 if overwrite else
10, '+I')]
+ all_files.extend(files)
+ if native_commit:
+ with patch.object(commit.file_store_commit, 'commit',
+ side_effect=AssertionError('Python commit
fallback')):
+ commit.commit(messages)
+ else:
+ commit.commit(messages)
+ writer.abort()
+ assert all(table.file_io.exists(f.file_path) for f in files)
+ finally:
+ writer.close()
+ commit.close()
+ assert table.snapshot_manager().get_latest_snapshot().total_record_count
== 1
+ assert len({f.file_name.split('-w-')[0] for f in all_files}) == 2
+ writer = table.new_batch_write_builder().new_write()
+ try:
+ writer.write_arrow_batch(_batch([(2, 'b', 30, '+I')]))
+ messages = writer.prepare_commit()
+ paths = [f.file_path for m in messages for f in m.new_files]
+ writer.abort()
+ assert paths and all(not table.file_io.exists(path) for path in paths)
+ finally:
+ writer.close()
+
+
[email protected]('engine', ['first-row', 'partial-update',
'aggregation'])
+def test_postpone_engine_retract_validation_stays_on_python(tmp_path, engine):
+ table = _table(tmp_path, {'merge-engine': engine})
+ writer = table.new_batch_write_builder().new_write()
+ try:
+ assert not isinstance(writer, NativeTableWrite)
+ finally:
+ writer.close()
+
+
[email protected]('operation', ['truncate_table', 'truncate_partition',
'delete_partition'])
+def test_pending_files_participate_in_partition_metadata_deletes(tmp_path,
operation):
+ table = _table(tmp_path, {'commit.native.enabled': 'false'})
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ writer.write_arrow_batch(_batch([(1, 'a', 10, '+I'), (2, 'b', 20,
'+I')]))
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+ builder = table.new_batch_write_builder()
+ commit = builder.new_commit()
+ try:
+ if operation == 'truncate_table':
+ commit.truncate_table()
+ elif operation == 'truncate_partition':
+ commit.truncate_partitions([{'p': 'a'}])
+ else:
+ predicate =
table.new_read_builder().new_predicate_builder().equal('p', 'a')
+ messages = builder.new_update().delete_by_predicate(predicate)
+ assert len(messages) == 1
+ assert messages[0].bucket == -2
+ assert len(messages[0].deleted_files) == 1
+ commit.commit(messages)
+ finally:
+ commit.close()
+ expected = 0 if operation == 'truncate_table' else 1
+ assert table.snapshot_manager().get_latest_snapshot().total_record_count
== expected
+ from pypaimon.manifest.manifest_list_manager import ManifestListManager
+ from pypaimon.read.scanner.file_scanner import FileScanner
+ snapshot = table.snapshot_manager().get_latest_snapshot()
+ entries = FileScanner(table, lambda: ([],
None)).with_all_buckets().read_manifest_entries(
+ ManifestListManager(table).read_all(snapshot))
+ assert [(tuple(e.partition.values), e.bucket) for e in entries] == ([] if
expected == 0 else [(('b',), -2)])
diff --git a/paimon-python/pypaimon/tests/ray_repartition_test.py
b/paimon-python/pypaimon/tests/ray_repartition_test.py
index 6cbbc1053d..3b17784216 100644
--- a/paimon-python/pypaimon/tests/ray_repartition_test.py
+++ b/paimon-python/pypaimon/tests/ray_repartition_test.py
@@ -84,6 +84,10 @@ class RayShuffleTest(unittest.TestCase):
def _make_table(self, table_name, pa_schema, *, primary_keys=None,
partition_keys=None, options=None):
identifier = 'default.{}'.format(table_name)
+ options = dict(options or {})
+ # Pytest's patched defaults do not propagate to Ray worker processes.
+ options.setdefault('write.native.enabled', str(
+ os.environ.get('PYPAIMON_TEST_NATIVE_WRITE') == '1').lower())
schema = Schema.from_pyarrow_schema(
pa_schema,
primary_keys=primary_keys,
@@ -384,7 +388,7 @@ class RayShuffleTest(unittest.TestCase):
)
self.assertEqual(len(self._read_table(identifier)), 100)
- def test_postpone_large_bucket_spans_writer_blocks(self):
+ def test_postpone_large_bucket_keeps_one_writer_across_input_blocks(self):
from ray.data import DataContext
from pypaimon.ray import write_paimon
@@ -411,8 +415,10 @@ class RayShuffleTest(unittest.TestCase):
previous_target = context.target_max_block_size
context.target_max_block_size = 64 * 1024
try:
+ dataset = ray.data.from_arrow(rows).repartition(8).materialize()
+ self.assertGreater(dataset.num_blocks(), 1)
write_paimon(
- ray.data.from_arrow(rows).repartition(8),
+ dataset,
identifier,
self.catalog_options,
concurrency=4,
@@ -421,12 +427,14 @@ class RayShuffleTest(unittest.TestCase):
context.target_max_block_size = previous_target
files = self._count_data_files(table_name)
- self.assertGreater(len(files), 1)
+ self.assertTrue(files)
self.assertEqual(
{'bucket-0'},
{os.path.basename(os.path.dirname(path)) for path in files},
)
- self.assertEqual(400, len(self._read_table(identifier)))
+ result = self._read_table(identifier)
+ self.assertEqual(list(range(400)), sorted(result['id'].tolist()))
+ self.assertEqual({'x' * 4096}, set(result['value']))
def test_explicit_postpone_writer_uses_real_buckets(self):
pa_schema = pa.schema([
@@ -598,16 +606,26 @@ class RayShuffleTest(unittest.TestCase):
for data_file in split.files
]
self.assertEqual(len(data_files), 1)
- self.assertEqual(
- (data_files[0].min_sequence_number,
- data_files[0].max_sequence_number),
- (1, 801),
- )
+ self.assertEqual(data_files[0].row_count, 1)
result = self._read_table(identifier)
self.assertEqual(len(result), 1)
self.assertTrue(result.iloc[0]['value'].startswith('0799-'))
+ # A subsequent writer must continue after the persisted sequence,
+ # including when native buffering removed the earlier duplicate rows.
+ write_paimon(
+ ray.data.from_arrow(pa.Table.from_pydict(
+ {'id': [1], 'value': ['updated']}, schema=pa_schema)),
+ identifier,
+ self.catalog_options,
+ hash_fixed_precluster='map_groups',
+ )
+ self.assertEqual(
+ self._read_table(identifier).to_dict('records'),
+ [{'id': 1, 'value': 'updated'}],
+ )
+
def test_primary_key_group_writer_overwrite(self):
from pypaimon.ray import write_paimon
diff --git a/paimon-python/pypaimon/tests/write/dynamic_bucket_test.py
b/paimon-python/pypaimon/tests/write/dynamic_bucket_test.py
index e32d5f5a72..1ce9541e8c 100644
--- a/paimon-python/pypaimon/tests/write/dynamic_bucket_test.py
+++ b/paimon-python/pypaimon/tests/write/dynamic_bucket_test.py
@@ -379,6 +379,7 @@ class DynamicBucketTest(unittest.TestCase):
):
new_writer.write_arrow(pa.table({'id': [1], 'value': ['new']}))
+ @pytest.mark.python_write
def test_cross_partition_write_requires_global_index(self):
with tempfile.TemporaryDirectory() as root:
catalog = CatalogFactory.create({'warehouse': root})
diff --git a/paimon-python/pypaimon/tests/write/table_write_test.py
b/paimon-python/pypaimon/tests/write/table_write_test.py
index ee0ee42fd9..8006269bea 100644
--- a/paimon-python/pypaimon/tests/write/table_write_test.py
+++ b/paimon-python/pypaimon/tests/write/table_write_test.py
@@ -862,16 +862,12 @@ class TableWriteTest(unittest.TestCase):
'dt': ['p1'],
}, schema=self.pk_pa_schema)
- self._commit_arrow(table, expected)
-
- self.assertEqual(
- 1,
- len(glob.glob(
- self.warehouse
- + "/default.db/test_postpone_default_builder/user_id=1/"
- + "bucket-postpone/*.avro"
- )),
- )
+ messages = self._commit_arrow(table, expected)
+ self.assertEqual({-2}, {message.bucket for message in messages})
+ files = [file for message in messages for file in message.new_files]
+ self.assertEqual(1, len(files))
+ self.assertIn('/bucket-postpone/', files[0].file_path)
+ self.assertTrue(table.file_io.exists(files[0].file_path))
splits = table.new_read_builder().new_scan().plan().splits()
self.assertTrue(not
table.new_read_builder().new_read().to_arrow(splits))
@@ -1752,7 +1748,8 @@ class TableWriteTest(unittest.TestCase):
# Verify file name format:
{table_prefix}-u-{commit_user}-s-{random_number}-w--{uuid}-0.{format}
# Expected pattern: data--u-{user}-s-{random}-w--{uuid}-0.{format}
- expected_pattern = r'^data--u-.+-s-\d+-w-.+-0\.avro$'
+ # Native postpone writes use Parquet; the Python writer may select
Avro.
+ expected_pattern = r'^data--u-.+-s-\d+-w-.+-0\.(avro|parquet)$'
for file_name in data_files:
self.assertRegex(file_name, expected_pattern,
diff --git a/paimon-python/pypaimon/write/commit/commit_scanner.py
b/paimon-python/pypaimon/write/commit/commit_scanner.py
index 835df4bf61..77a7c9c74b 100644
--- a/paimon-python/pypaimon/write/commit/commit_scanner.py
+++ b/paimon-python/pypaimon/write/commit/commit_scanner.py
@@ -72,7 +72,7 @@ class CommitScanner:
all_manifests = self.manifest_list_manager.read_all(latest_snapshot)
return FileScanner(
self.table, lambda: ([], None),
partition_predicate=partition_filter
- ).read_manifest_entries(all_manifests)
+ ).with_all_buckets().read_manifest_entries(all_manifests)
def read_incremental_entries_from_changed_partitions(self,
snapshot: Snapshot,
@@ -101,7 +101,7 @@ class CommitScanner:
return FileScanner(
self.table, lambda: ([], None),
partition_predicate=partition_filter
- ).read_manifest_entries(delta_manifests)
+ ).with_all_buckets().read_manifest_entries(delta_manifests)
def read_incremental_raw_entries_from_changed_partitions(self, snapshot:
Snapshot,
commit_entries:
List[ManifestEntry],
diff --git a/paimon-python/pypaimon/write/commit/conflict_detection.py
b/paimon-python/pypaimon/write/commit/conflict_detection.py
index f15917c3c2..19d831cf60 100644
--- a/paimon-python/pypaimon/write/commit/conflict_detection.py
+++ b/paimon-python/pypaimon/write/commit/conflict_detection.py
@@ -192,6 +192,7 @@ class ConflictDetection:
self.manifest_list_manager = manifest_list_manager
self.table = table
self._row_id_check_from_snapshot = None
+ self.fixed_bucket_commit_check = None
self.commit_scanner = commit_scanner
def should_be_overwrite_commit(self, append_file_entries=None,
append_index_files=None):
@@ -248,6 +249,13 @@ class ConflictDetection:
if conflict is not None:
return conflict
+ if self.fixed_bucket_commit_check is not None:
+ conflict = self.fixed_bucket_commit_check.check(
+ latest_snapshot, delta_entries, commit_kind,
+ self.snapshot_manager, self.commit_scanner)
+ if conflict is not None:
+ return conflict
+
conflict = self.check_overwrite_from_snapshot(
latest_snapshot, delta_entries, commit_kind)
if conflict is not None:
diff --git a/paimon-python/pypaimon/write/commit/fixed_bucket_commit_check.py
b/paimon-python/pypaimon/write/commit/fixed_bucket_commit_check.py
new file mode 100644
index 0000000000..322da1eb16
--- /dev/null
+++ b/paimon-python/pypaimon/write/commit/fixed_bucket_commit_check.py
@@ -0,0 +1,72 @@
+# 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.
+
+"""Conflict checks for batches that assign real buckets to postpone tables."""
+
+
+class FixedBucketCommitCheck:
+ """Retain the native writer's single-owner requirement across
serialization.
+
+ A writer seeds automatic sequence numbers from its baseline snapshot. Two
+ owners of the same bucket can overlap those sequences and silently lose an
+ update, even when their total bucket counts agree. Pending files do not own
+ a real bucket and can coexist, as in Java's postpone writes.
+ """
+
+ def __init__(self, messages):
+ self.owners = set()
+ baselines = []
+ for message in messages:
+ count = message.total_buckets
+ if count is None:
+ continue
+ if (isinstance(count, bool) or not isinstance(count, int)
+ or not 0 < count <= 2147483647
+ or not 0 <= message.bucket < count):
+ raise ValueError('Invalid fixed bucket {} for
total_buckets={}'.format(message.bucket, count))
+ if not message.new_files:
+ continue
+ owner = (tuple(message.partition), message.bucket)
+ if owner in self.owners:
+ raise ValueError('Postpone fixed-bucket writer ownership
conflict: '
+ 'one partition and bucket must have a single
writer')
+ self.owners.add(owner)
+ if message.check_from_snapshot is not None:
+ if message.check_from_snapshot < 0:
+ raise ValueError('Invalid fixed-bucket check snapshot')
+ baselines.append(message.check_from_snapshot)
+ self.baseline = min(baselines) if baselines else None
+
+ def check(self, latest, entries, commit_kind, snapshot_manager, scanner):
+ if (not self.owners or self.baseline is None or latest is None
+ or latest.id <= self.baseline):
+ return None
+ partitions = {partition for partition, _ in self.owners}
+ for snapshot_id in range(self.baseline + 1, latest.id + 1):
+ snapshot = snapshot_manager.get_snapshot_by_id(snapshot_id)
+ if snapshot is None:
+ return RuntimeError('Fixed-bucket conflict check cannot find
snapshot {}'.format(snapshot_id))
+ changes =
scanner.read_incremental_raw_entries_from_changed_partitions(snapshot, entries)
+ for change in changes:
+ partition = tuple(change.partition.values)
+ if commit_kind == 'OVERWRITE' and partition in partitions:
+ return RuntimeError('Postpone fixed-bucket overwrite
conflict: '
+ 'target partition changed after
snapshot {}'.format(self.baseline))
+ if change.kind == 0 and (partition, change.bucket) in
self.owners:
+ return RuntimeError('Postpone fixed-bucket writer
ownership conflict: '
+ 'bucket changed after snapshot
{}'.format(self.baseline))
+ return None
diff --git a/paimon-python/pypaimon/write/commit/overwrite_changes_provider.py
b/paimon-python/pypaimon/write/commit/overwrite_changes_provider.py
index 39460acc61..94241aaed7 100644
--- a/paimon-python/pypaimon/write/commit/overwrite_changes_provider.py
+++ b/paimon-python/pypaimon/write/commit/overwrite_changes_provider.py
@@ -75,6 +75,7 @@ class OverwriteChangesProvider:
self.full_scan_count += 1
return (FileScanner(self.table, lambda: ([], None),
partition_predicate=self.partition_filter)
+ .with_all_buckets()
.read_manifest_entries(self.manifest_list_manager.read_all(latest_snapshot)))
def _update_cache(self, latest_snapshot: Snapshot) -> bool:
diff --git a/paimon-python/pypaimon/write/file_store_commit.py
b/paimon-python/pypaimon/write/file_store_commit.py
index d7396d367a..7f45040246 100644
--- a/paimon-python/pypaimon/write/file_store_commit.py
+++ b/paimon-python/pypaimon/write/file_store_commit.py
@@ -222,6 +222,14 @@ class FileStoreCommit:
table_rollback = table.catalog_environment.catalog_table_rollback()
self.rollback = CommitRollback(table_rollback) if table_rollback is
not None else None
+ def _set_fixed_bucket_commit_check(self, messages):
+ from pypaimon.write.commit.fixed_bucket_commit_check import
FixedBucketCommitCheck
+
+ self.conflict_detection.fixed_bucket_commit_check = (
+ FixedBucketCommitCheck(messages)
+ if any(message.total_buckets is not None for message in messages)
+ else None)
+
def commit(
self,
commit_messages: List[CommitMessage],
@@ -234,6 +242,7 @@ class FileStoreCommit:
_reject_compact_increment(commit_messages)
check_from_snapshot = _row_id_check_from_messages(commit_messages)
+ self._set_fixed_bucket_commit_check(commit_messages)
# A committer can be reused; an untagged commit clears the prior
baseline.
self.conflict_detection._row_id_check_from_snapshot =
check_from_snapshot
@@ -317,8 +326,9 @@ class FileStoreCommit:
snapshot_properties: Optional[Dict[str, str]] = None):
"""Commit the given commit messages in overwrite mode."""
_reject_compact_increment(commit_messages)
- self.conflict_detection._row_id_check_from_snapshot = (
- _row_id_check_from_messages(commit_messages))
+ check_from_snapshot = _row_id_check_from_messages(commit_messages)
+ self._set_fixed_bucket_commit_check(commit_messages)
+ self.conflict_detection._row_id_check_from_snapshot =
check_from_snapshot
logger.info(
"Ready to overwrite to table %s, number of commit messages: %d",
self.table.identifier,
diff --git a/paimon-python/pypaimon/write/file_store_write.py
b/paimon-python/pypaimon/write/file_store_write.py
index 28e9f8f67d..16f0884516 100644
--- a/paimon-python/pypaimon/write/file_store_write.py
+++ b/paimon-python/pypaimon/write/file_store_write.py
@@ -365,8 +365,11 @@ class FileStoreWrite:
self.max_seq_numbers[partition] = buckets
return buckets
+ def _sequence_read_table(self):
+ return self.table
+
def _load_seq_number_stats(self, partition: Tuple) -> dict:
- read_builder = self.table.new_read_builder()
+ read_builder = self._sequence_read_table().new_read_builder()
predicate_builder = read_builder.new_predicate_builder()
sub_predicates = []
for key, value in zip(self.table.partition_keys, partition):
@@ -388,5 +391,24 @@ class FileStoreWrite:
class PostponeFixedBucketFileStoreWrite(FileStoreWrite):
"""File store write with runtime bucket counts for postpone tables."""
+ def __init__(self, table, commit_user):
+ super().__init__(table, commit_user)
+ snapshot = table.snapshot_manager().get_latest_snapshot()
+ self._check_from_snapshot = snapshot.id if snapshot is not None else 0
+
+ def _sequence_read_table(self):
+ return self.table.copy({'scan.snapshot-id':
str(self._check_from_snapshot)})
+
+ def _load_seq_number_stats(self, partition):
+ if self._check_from_snapshot == 0:
+ return {}
+ return super()._load_seq_number_stats(partition)
+
+ def prepare_commit(self, commit_identifier):
+ messages = super().prepare_commit(commit_identifier)
+ for message in messages:
+ message.check_from_snapshot = self._check_from_snapshot
+ return messages
+
def _configure_data_file_prefix(self, commit_user):
pass
diff --git a/paimon-python/pypaimon/write/native_commit.py
b/paimon-python/pypaimon/write/native_commit.py
index 46de93bcb6..297442a22d 100644
--- a/paimon-python/pypaimon/write/native_commit.py
+++ b/paimon-python/pypaimon/write/native_commit.py
@@ -140,15 +140,13 @@ def create_native_write_table(table):
from pypaimon.filesystem.local_file_io import LocalFileIO
from pypaimon.filesystem.pyarrow_file_io import PyArrowFileIO
from pypaimon.filesystem.resolving_file_io import ResolvingFileIO
- from pypaimon.table.bucket_mode import BucketMode
from pypaimon.table.file_store_table import FileStoreTable
- # Native branch and postpone writes are not supported.
+ # Native writes currently require the main branch.
environment = table.catalog_environment
if (type(table) is not FileStoreTable
or type(environment) is not CatalogEnvironment
or table.current_branch() != 'main'
- or table.bucket_mode() == BucketMode.POSTPONE_MODE
or table.options.query_auth_enabled):
return None
diff --git a/paimon-python/pypaimon/write/native_write.py
b/paimon-python/pypaimon/write/native_write.py
index ca56bd6f7e..d0ca273479 100644
--- a/paimon-python/pypaimon/write/native_write.py
+++ b/paimon-python/pypaimon/write/native_write.py
@@ -60,7 +60,8 @@ def _native_map_layouts_supported(table, schema):
return True
-def create_native_write(table, commit_user, static_partition=None,
stream=False):
+def create_native_write(table, commit_user, static_partition=None,
stream=False,
+ *, fixed_bucket=False, bucket_plan=None):
"""Return a native writer if the table can use the filesystem write
path."""
schema = PyarrowFieldParser.from_paimon_schema(table.table_schema.fields)
sequence_fields = table.options.sequence_field()
@@ -72,7 +73,17 @@ def create_native_write(table, commit_user,
static_partition=None, stream=False)
or (table.options.data_evolution_enabled()
and table.options.data_evolution_row_sidecar_enabled())
or table.bucket_mode() not in (BucketMode.HASH_FIXED,
BucketMode.HASH_DYNAMIC,
- BucketMode.BUCKET_UNAWARE)
+ BucketMode.BUCKET_UNAWARE,
+ BucketMode.CROSS_PARTITION,
+ BucketMode.POSTPONE_MODE)
+ # Postpone bypasses the KV merger. Native retract validation for
+ # other engines and managed BLOB externalization are still missing.
+ or (table.bucket_mode() == BucketMode.POSTPONE_MODE and not
fixed_bucket
+ and (table.options.merge_engine() != MergeEngine.DEDUPLICATE
+ or any(is_blob_file_field(field) for field in
table.table_schema.fields)))
+ or (fixed_bucket and (table.options.deletion_vectors_enabled()
+ or any(is_blob_file_field(field)
+ for field in
table.table_schema.fields)))
or (table.options.deletion_vectors_enabled()
and table.options.merge_engine() in
(MergeEngine.PARTIAL_UPDATE,
MergeEngine.AGGREGATE))
@@ -90,6 +101,14 @@ def create_native_write(table, commit_user,
static_partition=None, stream=False)
native_table = create_native_write_table(table)
if native_table is None:
return None
+ if fixed_bucket:
+ builder =
native_table.new_postpone_fixed_bucket_write_builder()._with_commit_user(commit_user)
+ if bucket_plan is not None:
+ builder = builder.with_bucket_plan(bucket_plan.to_arrow(table))
+ if static_partition is not None:
+ builder = builder.with_overwrite(static_partition)
+ return NativePostponeFixedBucketTableWrite(
+ table, commit_user, static_partition, bucket_plan,
builder.new_write())
if stream:
builder =
native_table.new_stream_write_builder().with_commit_user(commit_user)
else:
@@ -127,13 +146,14 @@ class NativeTableWrite:
'Cannot switch to the Python writer after native data was
written')
self._native_writer.close()
self._native_writer = None
+ self._python_writer = self._new_python_writer()
+ return self._python_writer
+
+ def _new_python_writer(self):
from pypaimon.write.table_write import BatchTableWrite,
StreamTableWrite
if self.stream:
- self._python_writer = StreamTableWrite(self.table,
self.commit_user)
- else:
- self._python_writer = BatchTableWrite(
- self.table, self.commit_user, self.static_partition)
- return self._python_writer
+ return StreamTableWrite(self.table, self.commit_user)
+ return BatchTableWrite(self.table, self.commit_user,
self.static_partition)
def __getattr__(self, name):
if name.startswith('_'):
@@ -222,3 +242,20 @@ class NativeTableWrite:
self.close()
finally:
_abort_commit_messages(self.table, messages)
+
+
+class NativePostponeFixedBucketTableWrite(NativeTableWrite):
+ """Fixed-bucket batches retain the standard native ownership contract.
+
+ A distributed coordinator can use Python planning before receiving data;
+ workers use the same shared plan through the Rust fixed-bucket writer.
+ """
+
+ def __init__(self, table, commit_user, static_partition, bucket_plan,
native_writer):
+ super().__init__(table, commit_user, static_partition, False,
native_writer)
+ self._bucket_plan = bucket_plan
+
+ def _new_python_writer(self):
+ from pypaimon.write.postpone_batch_table_write import
PostponeFixedBucketBatchTableWrite
+ return PostponeFixedBucketBatchTableWrite(
+ self.table, self.commit_user, self.static_partition,
self._bucket_plan)
diff --git a/paimon-python/pypaimon/write/postpone_batch_table_write.py
b/paimon-python/pypaimon/write/postpone_batch_table_write.py
index f4fc24f70b..25227a06bf 100644
--- a/paimon-python/pypaimon/write/postpone_batch_table_write.py
+++ b/paimon-python/pypaimon/write/postpone_batch_table_write.py
@@ -50,6 +50,14 @@ class PostponeFixedBucketWriteBuilder(BatchWriteBuilder):
return self
def new_write(self):
+ # Automatic size estimation still belongs to the Python coordinator.
+ # Native direct writes accept a resolved plan or Java's explicit
default.
+ if (self._bucket_plan is not None
+ or self.table.options.postpone_default_bucket_num() is not
None):
+ native = self._native_write(
+ self.static_partition, fixed_bucket=True,
bucket_plan=self._bucket_plan)
+ if native is not None:
+ return native
return PostponeFixedBucketBatchTableWrite(
self.table,
self.commit_user,
@@ -71,7 +79,8 @@ class PostponeFixedBucketBatchTableWrite(BatchTableWrite):
self._planner = PostponeBucketPlanner(
table,
known_num_buckets=(
- bucket_plan.as_dict() if bucket_plan is not None else None
+ bucket_plan.as_dict() if bucket_plan is not None
+ else {} if static_partition is not None else None
),
)
self._bucket_plan = (
diff --git a/paimon-python/pypaimon/write/postpone_bucket.py
b/paimon-python/pypaimon/write/postpone_bucket.py
index 95b86c6ce1..ca6c9a8100 100644
--- a/paimon-python/pypaimon/write/postpone_bucket.py
+++ b/paimon-python/pypaimon/write/postpone_bucket.py
@@ -17,6 +17,8 @@
from typing import Dict, Tuple
+import pyarrow as pa
+
from pypaimon.schema.data_types import (
ArrayType,
AtomicType,
@@ -178,6 +180,26 @@ class PostponeBucketPlan:
def __init__(self, num_buckets: Dict[Tuple, int]):
self._num_buckets = dict(num_buckets)
+ def to_arrow(self, table) -> pa.RecordBatch:
+ """Encode only the per-partition plan for the native core router."""
+ from pypaimon.schema.data_types import PyarrowFieldParser
+
+ fields = [table.field_dict[name] for name in table.partition_keys]
+ schema = PyarrowFieldParser.from_paimon_schema(fields)
+ schema = schema.append(pa.field('total_buckets', pa.int32(),
nullable=False))
+ partitions = list(self._num_buckets)
+ counts = list(self._num_buckets.values())
+ if any(len(partition) != len(fields) for partition in partitions):
+ raise ValueError('Bucket plan partition arity does not match the
table schema')
+ if any(isinstance(count, bool) or not isinstance(count, int)
+ or not 0 < count <= 2147483647 for count in counts):
+ raise ValueError('Bucket plan total_buckets must be a positive
32-bit integer')
+ # Use positional columns: a partition may itself be named
total_buckets.
+ columns = [pa.array([partition[i] for partition in partitions],
type=field.type)
+ for i, field in enumerate(list(schema)[:-1])]
+ columns.append(pa.array(counts, type=pa.int32()))
+ return pa.RecordBatch.from_arrays(columns, schema=schema)
+
def contains(self, partition: Tuple) -> bool:
return tuple(partition) in self._num_buckets
@@ -216,33 +238,37 @@ class PostponeBucketPlanner:
.format(bucket_function)
)
- self.max_num_buckets = (
- options.postpone_batch_write_fixed_bucket_max_parallelism()
- )
- if self.max_num_buckets <= 0:
- raise ValueError(
- "postpone.batch-write-fixed-bucket.max-parallelism must be "
- "positive, got {}".format(self.max_num_buckets)
+ self.default_bucket_num = options.postpone_default_bucket_num()
+ self.target_row_num_per_bucket = None
+ self.target_size_per_bucket = None
+ if self.default_bucket_num is None:
+ self.max_num_buckets = (
+ options.postpone_batch_write_fixed_bucket_max_parallelism()
)
- self.target_row_num_per_bucket = (
- options.postpone_target_row_num_per_bucket()
- )
- if self.target_row_num_per_bucket is not None:
- if self.target_row_num_per_bucket <= 0:
+ if self.max_num_buckets <= 0:
raise ValueError(
- "postpone.target-row-num-per-bucket must be positive, "
- "got {}".format(self.target_row_num_per_bucket)
+ "postpone.batch-write-fixed-bucket.max-parallelism must be
"
+ "positive, got {}".format(self.max_num_buckets)
)
- self.target_size_per_bucket = None
- else:
- self.target_size_per_bucket = (
- options.postpone_target_size_per_bucket()
+ self.target_row_num_per_bucket = (
+ options.postpone_target_row_num_per_bucket()
)
- if self.target_size_per_bucket <= 0:
- raise ValueError(
- "postpone.target-size-per-bucket must be positive, got "
- "{}".format(self.target_size_per_bucket)
+ if self.target_row_num_per_bucket is not None:
+ if self.target_row_num_per_bucket <= 0:
+ raise ValueError(
+ "postpone.target-row-num-per-bucket must be positive, "
+ "got {}".format(self.target_row_num_per_bucket)
+ )
+ self.target_size_per_bucket = None
+ else:
+ self.target_size_per_bucket = (
+ options.postpone_target_size_per_bucket()
)
+ if self.target_size_per_bucket <= 0:
+ raise ValueError(
+ "postpone.target-size-per-bucket must be positive, got
"
+ "{}".format(self.target_size_per_bucket)
+ )
self._partition_keys = list(table.partition_keys)
self._field_dict = dict(table.field_dict)
@@ -298,7 +324,8 @@ class PostponeBucketPlanner:
stats = {}
fields = [self._field_dict[name] for name in data.schema.names]
columns = [data.column(i) for i in range(len(fields))]
- collect_size = self.target_row_num_per_bucket is None
+ collect_size = (self.default_bucket_num is None
+ and self.target_row_num_per_bucket is None)
for row, partition in enumerate(partitions):
if partition in self._known_num_buckets:
continue
@@ -319,6 +346,9 @@ class PostponeBucketPlanner:
partition = tuple(partition)
if partition in self._known_num_buckets:
continue
+ if self.default_bucket_num is not None:
+ self._known_num_buckets[partition] = self.default_bucket_num
+ continue
postpone_rows = (
self._postpone_row_counts.get(partition, 0)
if include_postpone_rows
diff --git a/paimon-python/pypaimon/write/ray_datasink.py
b/paimon-python/pypaimon/write/ray_datasink.py
index 1fb56bbcac..796f69947e 100644
--- a/paimon-python/pypaimon/write/ray_datasink.py
+++ b/paimon-python/pypaimon/write/ray_datasink.py
@@ -331,7 +331,9 @@ def write_paimon_dataset(
planner = (
postpone_bucket_planner
if postpone_bucket_planner is not None
- else PostponeBucketPlanner(table)
+ else PostponeBucketPlanner(
+ table, known_num_buckets={}
+ if overwrite or static_partition is not None else None)
)
plan = planner.current_plan()
if table.partition_keys or not plan.contains(()):
@@ -344,7 +346,7 @@ def write_paimon_dataset(
overwrite or static_partition is not None
),
)
- return _write_postpone_primary_key_blocks(
+ return _write_primary_key_groups(
dataset,
table,
overwrite=overwrite,
@@ -383,80 +385,6 @@ def write_paimon_dataset(
return datasink.commit_result
-def _write_postpone_primary_key_blocks(
- dataset,
- table,
- *,
- overwrite: bool,
- static_partition: Optional[Dict[str, Any]],
- concurrency: Optional[int],
- ray_remote_args: Optional[Dict[str, Any]],
- bucket_extractor,
- postpone_bucket_plan,
-) -> Optional[PaimonWriteResult]:
- import pickle
-
- from pypaimon.ray.shuffle import (
- _sort_by_partition_bucket_primary_key,
- )
-
- sorted_dataset, routing_columns = (
- _sort_by_partition_bucket_primary_key(
- dataset, table, bucket_extractor
- )
- )
- message_col = "__paimon_commit_messages__"
- error_col = "__paimon_write_error__"
- captured_table = table
-
- def _write_block(batch: pa.Table) -> pa.Table:
- if batch.num_rows == 0:
- return pa.table({
- message_col: pa.array([], type=pa.binary()),
- error_col: pa.array([], type=pa.string()),
- })
-
- rows = batch.drop_columns(routing_columns)
- worker_sink = PaimonDatasink(
- captured_table,
- overwrite=overwrite,
- static_partition=static_partition,
- postpone_bucket_plan=postpone_bucket_plan,
- )
- try:
- commit_messages = worker_sink.write([rows], None)
- error = None
- except Exception:
- commit_messages = []
- error = traceback.format_exc()
- return pa.table({
- message_col: pa.array(
- [pickle.dumps(commit_messages)], type=pa.binary()
- ),
- error_col: pa.array([error], type=pa.string()),
- })
-
- map_kwargs = _ray_map_kwargs(
- sorted_dataset.map_batches,
- concurrency,
- ray_remote_args,
- batch_size=None,
- batch_format="pyarrow",
- zero_copy_batch=True,
- )
-
- results = sorted_dataset.map_batches(_write_block, **map_kwargs)
- coordinator = PaimonDatasink(
- table,
- overwrite=overwrite,
- static_partition=static_partition,
- )
- coordinator.on_write_start()
- return _consume_write_results(
- results, coordinator, message_col, error_col
- )
-
-
def _ray_map_kwargs(method, concurrency, ray_remote_args, **kwargs):
import inspect
diff --git a/paimon-python/pypaimon/write/table_update.py
b/paimon-python/pypaimon/write/table_update.py
index 38589a9215..e90b45195e 100644
--- a/paimon-python/pypaimon/write/table_update.py
+++ b/paimon-python/pypaimon/write/table_update.py
@@ -558,7 +558,7 @@ class TableUpdate:
self.table,
lambda: ([], None),
partition_predicate=partition_filter,
- ).read_manifest_entries(manifest_list_manager.read_all(snapshot))
+
).with_all_buckets().read_manifest_entries(manifest_list_manager.read_all(snapshot))
for entry in data_entries:
message = self._partition_delete_message(
diff --git a/paimon-python/pypaimon/write/write_builder.py
b/paimon-python/pypaimon/write/write_builder.py
index bd518d826d..4cd4f56d64 100644
--- a/paimon-python/pypaimon/write/write_builder.py
+++ b/paimon-python/pypaimon/write/write_builder.py
@@ -58,7 +58,7 @@ class WriteBuilder(ABC):
else:
return str(uuid.uuid4())
- def _native_write(self, static_partition=None, stream=False):
+ def _native_write(self, static_partition=None, stream=False, **kwargs):
from pypaimon.read.merge_engine_support import
check_sequence_field_supported
# Keep invalid configurations outside the native fallback handler and
@@ -69,7 +69,7 @@ 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)
+ static_partition, stream, **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)