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 702810b32b [Python] Complete native writer routing and guarded REST
publication (#10645)
702810b32b is described below
commit 702810b32b1b0c7189e31191e9234fca3d107c32
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Oct 19 14:30:16 2026 +0800
[Python] Complete native writer routing and guarded REST publication
(#10645)
---
docs/docs/pypaimon/writing.md | 26 ++
paimon-python/conftest.py | 4 +-
paimon-python/pypaimon/index/dynamic_bucket.py | 10 +-
.../pypaimon/read/scanner/file_scanner.py | 4 +-
.../tests/native_precomputed_bucket_test.py | 17 +-
.../tests/native_read_path_completion_test.py | 3 +-
.../tests/native_sorted_index_build_test.py | 13 +-
.../tests/native_test_instrumentation_test.py | 71 +++++
paimon-python/pypaimon/tests/native_update_test.py | 2 +
.../tests/native_write_path_completion_test.py | 82 ++++--
.../pypaimon/tests/native_write_routing_test.py | 326 +++++++++++++++++++++
paimon-python/pypaimon/tests/native_write_test.py | 5 +-
.../pypaimon/tests/query_auth_validation_test.py | 2 +
.../tests/ray_vector_filter_exactness_test.py | 1 +
.../scanner/file_scanner_schema_fields_test.py | 40 +++
paimon-python/pypaimon/tests/table_update_test.py | 1 +
.../pypaimon/tests/write/table_write_test.py | 4 +-
paimon-python/pypaimon/write/file_store_write.py | 17 +-
paimon-python/pypaimon/write/native_commit.py | 3 +-
paimon-python/pypaimon/write/native_update.py | 3 +
paimon-python/pypaimon/write/native_write.py | 35 ++-
.../pypaimon/write/postpone_batch_table_write.py | 4 +-
paimon-python/pypaimon/write/table_write.py | 33 +--
23 files changed, 621 insertions(+), 85 deletions(-)
diff --git a/docs/docs/pypaimon/writing.md b/docs/docs/pypaimon/writing.md
index 68d2c8aa0f..c74deca7c8 100644
--- a/docs/docs/pypaimon/writing.md
+++ b/docs/docs/pypaimon/writing.md
@@ -72,6 +72,32 @@ Use `new_postpone_fixed_bucket_write_builder()` to write
real buckets. New
partitions are buffered until `prepare_commit()`; existing ones are
incremental.
This builder currently supports `bucket-function.type=default` only.
+### Native Write and Commit
+
+With the `pypaimon-rust` extension installed, set `write.native.enabled=true`
+to use Rust for Parquet/BLOB writes and `commit.native.enabled=true` to use
+Rust for REST snapshot publication. Batch and stream writers retain the same
+prepare-and-commit lifecycle. Native commits also support overwrite,
+`truncate_table()`, `truncate_partitions()` and commit callbacks.
+
+Standard REST tables with `query-auth.enabled=true` can write and commit
+without requesting SELECT permission. The REST server still authorizes snapshot
+publication. Row filters and column masks continue to apply to explicit reads;
+internal sequence-number and HASH-index restoration reads manifest metadata.
+Predicate updates and deletes retain their existing read authorization checks.
+Query-authorized cross-partition index bootstrap remains unsupported.
+
+`write_ray()` uses the shared Python coordinator and lets workers select Native
+writers. For postpone fixed-bucket writes, overwrite plans use the new input
+unless an explicit bucket plan was supplied. A complete primary-key
+`with_write_type()` leaves the writer unchanged; primary-key column subsets
+remain unsupported.
+
+Native commit selection requires a standard REST catalog and a reconstructible
+built-in FileIO. Other catalogs and custom Python FileIO implementations retain
+the Python path. Binary partition keys require `partition.legacy-name=false`
for
+portable names across implementations.
+
To replace data, configure overwrite on the write builder before creating the
writer and commit object:
diff --git a/paimon-python/conftest.py b/paimon-python/conftest.py
index ee07dc8313..eeb1602b72 100644
--- a/paimon-python/conftest.py
+++ b/paimon-python/conftest.py
@@ -163,9 +163,9 @@ def pytest_configure(config):
original_prepare = TableCommit._prepare_native_commit
- def tracked_prepare(self, messages):
+ def tracked_prepare(self, messages, *args, **kwargs):
global _native_commit_count
- prepared = original_prepare(self, messages)
+ prepared = original_prepare(self, messages, *args, **kwargs)
if prepared is not None and _force_native_commit_for_test:
_native_commit_count += 1
return prepared
diff --git a/paimon-python/pypaimon/index/dynamic_bucket.py
b/paimon-python/pypaimon/index/dynamic_bucket.py
index d233cec1e0..dcface6d69 100644
--- a/paimon-python/pypaimon/index/dynamic_bucket.py
+++ b/paimon-python/pypaimon/index/dynamic_bucket.py
@@ -392,11 +392,11 @@ class HashBucketAssigner:
read_builder.with_filter(
predicate_builder.and_predicates(predicates)
)
- return {
- split.bucket
- for split in read_builder.new_scan().plan_all_files().splits()
- if split.files
- }
+ # HASH validation needs all live data-file buckets, not the subset a
+ # SELECT policy exposes. This internal manifest lookup returns no rows.
+ scanner = read_builder.new_scan().file_scanner
+ scanner.skip_level0 = False
+ return {entry.bucket for entry in scanner.plan_files()}
@dataclass
diff --git a/paimon-python/pypaimon/read/scanner/file_scanner.py
b/paimon-python/pypaimon/read/scanner/file_scanner.py
index b24624e69f..6fc822fe7f 100755
--- a/paimon-python/pypaimon/read/scanner/file_scanner.py
+++ b/paimon-python/pypaimon/read/scanner/file_scanner.py
@@ -613,7 +613,9 @@ class FileScanner:
return self.manifest_file_manager.read_entries_parallel(
manifest_files,
self._filter_manifest_entry,
- drop_stats=not keep_stats,
+ # Transported guarded splits must match the snapshot metadata when
+ # Rust restores their catalog grant, even if Python planned them.
+ drop_stats=not (keep_stats or
self.table.options.query_auth_enabled),
max_workers=max_workers,
early_entry_filter=self._build_early_bucket_filter(),
early_record_filter=early_row_filter,
diff --git a/paimon-python/pypaimon/tests/native_precomputed_bucket_test.py
b/paimon-python/pypaimon/tests/native_precomputed_bucket_test.py
index 5d070bccec..d096a609ce 100644
--- a/paimon-python/pypaimon/tests/native_precomputed_bucket_test.py
+++ b/paimon-python/pypaimon/tests/native_precomputed_bucket_test.py
@@ -17,7 +17,7 @@
"""Native writes of the partition/bucket groups produced by Daft."""
-from unittest.mock import patch
+from unittest.mock import Mock, patch
import pyarrow as pa
import pytest
@@ -377,7 +377,7 @@ def
test_empty_restore_ignores_existing_sequences_and_hashes(tmp_path, python_fa
@pytest.mark.parametrize('overwrite', [False, True])
-def test_empty_restore_preserves_python_write_authorization(tmp_path,
overwrite):
+def test_empty_restore_does_not_apply_select_policy_to_python_writes(tmp_path,
overwrite):
from pypaimon.catalog.catalog_exception import TableNoPermissionException
from pypaimon.catalog.table_query_auth import TableQueryAuthResult
@@ -389,14 +389,21 @@ def
test_empty_restore_preserves_python_write_authorization(tmp_path, overwrite)
# Exercise the ordinary writer selected before any Native data is staged.
writer._switch_to_python()
auth = TableQueryAuthResult(None, {'value': '{"name":"NULL"}'})
+ authorize = Mock(return_value=auth)
try:
with patch.object(type(table.catalog_environment), 'table_query_auth',
- return_value=lambda _: auth):
+ return_value=authorize):
+ writer.write_arrow(_batch([1], ['written']), bucket=0)
+ messages = writer.prepare_commit()
+ assert messages[0].new_files[0].min_sequence_number == 0
+ authorize.assert_not_called()
+ # Only the explicit whole-table read requires an unrestricted
grant.
with pytest.raises(TableNoPermissionException):
- writer.write_arrow(_batch([1], ['forbidden']), bucket=0)
+ table.new_read_builder().new_scan().plan_all_files()
+ builder.new_commit().commit(messages)
finally:
writer.close()
- assert table.snapshot_manager().get_latest_snapshot() is None
+ assert _rows(table) == [{'id': 1, 'value': 'written', 'pt': 'a'}]
def test_python_pk_file_sequence_range_matches_deduplicated_rows(tmp_path):
diff --git a/paimon-python/pypaimon/tests/native_read_path_completion_test.py
b/paimon-python/pypaimon/tests/native_read_path_completion_test.py
index 851d1b7d0b..d86e3040db 100644
--- a/paimon-python/pypaimon/tests/native_read_path_completion_test.py
+++ b/paimon-python/pypaimon/tests/native_read_path_completion_test.py
@@ -258,7 +258,8 @@ def
test_authorized_python_and_serialized_splits_use_native_reader(guarded_table
source = table.copy({'scan.native-plan.enabled': str(transport ==
'wire').lower(), 'read.native.enabled': 'false'})
splits = source.new_read_builder().new_scan().plan().splits()
if transport == 'wire':
- splits = [deserialize_split_v1(serialize_split_v1(split), []) for
split in splits]
+ splits = [deserialize_split_v1(serialize_split_v1(split), [],
table.trimmed_primary_keys_fields)
+ for split in splits]
assert all(getattr(split, '_native_split', None) is None for split in
splits)
builder = table.copy({'read.native.enabled':
'true'}).new_read_builder().with_projection(['id', 'value'])
with patch('pypaimon.read.table_read.TableRead._create_split_read',
diff --git a/paimon-python/pypaimon/tests/native_sorted_index_build_test.py
b/paimon-python/pypaimon/tests/native_sorted_index_build_test.py
index 1005659916..1242773d16 100644
--- a/paimon-python/pypaimon/tests/native_sorted_index_build_test.py
+++ b/paimon-python/pypaimon/tests/native_sorted_index_build_test.py
@@ -471,19 +471,18 @@ def
test_partition_set_predicates_follow_java_null_semantics(rest_catalog, kind,
@pytest.mark.parametrize('restricted', [False, True])
def test_query_authorization_remains_on_python_build_path(rest_catalog,
restricted):
- from pypaimon.catalog.rest.rest_catalog import RESTCatalog
- from pypaimon.catalog.table_query_auth import TableQueryAuthResult
+ from pypaimon.api.api_response import AuthTableQueryResponse
from pypaimon.catalog.catalog_exception import TableNoPermissionException
- catalog, _ = rest_catalog
+ catalog, server = rest_catalog
table = _create(catalog)
_append(table, ROWS)
table = table.copy({'query-auth.enabled': 'true'})
- auth = TableQueryAuthResult(None, {'name': '{"name":"NULL"}'} if
restricted else None)
+ server.table_query_auth_store[table.identifier.get_full_name()] =
AuthTableQueryResponse(
+ column_masking={'name': '{"name":"NULL"}'} if restricted else None)
original = GlobalIndexBuilder._build_sorted_index
- with patch.object(RESTCatalog, 'auth_table_query', return_value=auth), \
- patch('pypaimon.globalindex.native_index_build._native_table',
- side_effect=AssertionError('native auth build')), \
+ with patch('pypaimon.globalindex.native_index_build._native_table',
+ side_effect=AssertionError('native auth build')), \
patch.object(GlobalIndexBuilder, '_build_sorted_index',
autospec=True, side_effect=original) as build:
if restricted:
with pytest.raises(TableNoPermissionException):
diff --git a/paimon-python/pypaimon/tests/native_test_instrumentation_test.py
b/paimon-python/pypaimon/tests/native_test_instrumentation_test.py
new file mode 100644
index 0000000000..6faf44dc20
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_test_instrumentation_test.py
@@ -0,0 +1,71 @@
+# 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 CI instrumentation preserves the public mutation API."""
+
+import importlib.util
+from pathlib import Path
+from unittest.mock import Mock
+
+import pytest
+
+from pypaimon.write.table_commit import BatchTableCommit, TableCommit
+
+pytestmark = [pytest.mark.python_plan, pytest.mark.python_read,
+ pytest.mark.python_write, pytest.mark.python_commit]
+
+
[email protected]('operation,args', [
+ ('truncate_table', ()), ('truncate_partitions', ([{'pt': 'a'}],)),
+])
[email protected]('fail', [False, True])
+def test_native_commit_tracking_forwards_batch_truncate(monkeypatch,
operation, args, fail):
+ # Load the actual CI hook in isolation. Its patches are scoped to this test
+ # and must not replace the surrounding pytest session's counters.
+ path = Path(__file__).resolve().parents[2] / 'conftest.py'
+ spec = importlib.util.spec_from_file_location('native_instrumentation',
str(path))
+ instrumentation = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(instrumentation)
+ for key in ('PLAN', 'READ', 'WRITE', 'UPDATE'):
+ monkeypatch.setenv('PYPAIMON_TEST_NATIVE_' + key, '0')
+ monkeypatch.setenv('PYPAIMON_TEST_NATIVE_COMMIT', '1')
+ backend = Mock()
+ calls = []
+
+ def prepare(self, messages, *, batch=False):
+ calls.append((messages, batch))
+ return backend, []
+
+ monkeypatch.setattr(TableCommit, '_prepare_native_commit', prepare)
+ instrumentation.pytest_configure(Mock())
+ instrumentation._force_native_commit_for_test = True
+ commit = object.__new__(BatchTableCommit)
+ commit.batch_committed = False
+ commit.file_store_commit = Mock()
+ error = RuntimeError('snapshot may already have published')
+ if fail:
+ getattr(backend, operation).side_effect = error
+ with pytest.raises(RuntimeError) as raised:
+ getattr(commit, operation)(*args)
+ assert raised.value is error
+ else:
+ getattr(commit, operation)(*args)
+ assert calls == [([], True)]
+ assert instrumentation._native_commit_count == 1
+ getattr(backend, operation).assert_called_once_with(*args)
+ commit.file_store_commit.truncate_table.assert_not_called()
+ commit.file_store_commit.drop_partitions.assert_not_called()
diff --git a/paimon-python/pypaimon/tests/native_update_test.py
b/paimon-python/pypaimon/tests/native_update_test.py
index 8ed01dbf62..709c79d540 100644
--- a/paimon-python/pypaimon/tests/native_update_test.py
+++ b/paimon-python/pypaimon/tests/native_update_test.py
@@ -655,6 +655,8 @@ def test_predicate_assignment_order_with_later_partial_file(
'row-tracking.enabled': 'true',
'data-evolution.enabled': 'true',
'write.native.enabled': str(native).lower(),
+ # Compare against Python's scan order, independently of the CI lane.
+ 'scan.native-plan.enabled': 'false', 'read.native.enabled': 'false',
}), False)
table = catalog.get_table('default.t')
for ids in ([1, 2], [3, 4]):
diff --git a/paimon-python/pypaimon/tests/native_write_path_completion_test.py
b/paimon-python/pypaimon/tests/native_write_path_completion_test.py
index 939a7b3183..760d735978 100644
--- a/paimon-python/pypaimon/tests/native_write_path_completion_test.py
+++ b/paimon-python/pypaimon/tests/native_write_path_completion_test.py
@@ -453,41 +453,59 @@ def
test_native_truncate_partition_specs_use_java_typed_strings(tmp_path, native
commit.close()
-@native
-def
test_automatic_postpone_blob_rows_keep_unknown_sources_until_prepare(tmp_path):
[email protected]_plan
[email protected]_read
[email protected]('fail', [False, True])
+def test_postpone_blob_row_bridge_keeps_sources_until_prepare(fail):
+ from types import SimpleNamespace
+ from pypaimon.common.options.core_options import CoreOptions
+ from pypaimon.common.options.options import Options
from pypaimon.schema.data_types import AtomicType, DataField
from pypaimon.table.row.blob import Blob, BlobDescriptor
from pypaimon.table.row.generic_row import GenericRow
from pypaimon.write.native_write import NativePostponeFixedBucketTableWrite
- catalog = CatalogFactory.create({'warehouse': str(tmp_path /
'blob-postpone')})
- catalog.create_database('default', True)
+
+ # Exercise the bridge's deferred input lifetime without creating a table:
+ # Python catalog validation does not allow a primary-key BLOB schema.
fields = [DataField(0, 'id', AtomicType('INT')), DataField(1, 'pt',
AtomicType('STRING')),
DataField(2, 'payload', AtomicType('BLOB'))]
- catalog.create_table('default.t', Schema(
- fields, partition_keys=['pt'], primary_keys=['id', 'pt'], options={
- 'bucket': '-2', 'write.native.enabled': 'true',
- 'postpone.target-row-num-per-bucket': '1'}), False)
- table = catalog.get_table('default.t')
-
- def write(rows, buffered):
- builder = table.new_postpone_fixed_bucket_write_builder()
- writer, commit = builder.new_write(), builder.new_commit()
- try:
- assert isinstance(writer, NativePostponeFixedBucketTableWrite)
- for identifier, partition in rows:
- writer.write_row(GenericRow(
- [identifier, partition,
Blob.from_data(str(identifier).encode())], table.fields))
- assert len(writer._blob_rows.readers) == buffered
- messages = writer.prepare_commit()
- assert not writer._blob_rows.readers
- commit.commit(messages)
- finally:
- writer.close()
- commit.close()
-
- write([(1, 'known')], 1)
- write([(2, 'known'), (3, 'new')], 1)
- rows = _rows(table)
- assert [row['id'] for row in rows] == [1, 2, 3]
- assert [Blob.from_descriptor_bytes(row['payload'],
table.file_io).to_data() for row in rows] == [b'1', b'2', b'3']
- assert all(not
BlobDescriptor.deserialize(row['payload']).uri.startswith('pypaimon-blob-row:')
for row in rows)
+ table = SimpleNamespace(
+ table_schema=SimpleNamespace(fields=fields), fields=fields,
+ field_dict={field.name: field for field in fields},
partition_keys=['pt'],
+ is_primary_key_table=True,
+ options=CoreOptions(Options({'bucket': '-2',
'postpone.target-row-num-per-bucket': '1'})))
+ backend = Mock()
+ staged, payloads = [], []
+ backend.write_arrow.side_effect = lambda batch, bucket:
staged.append(batch)
+ writer = NativePostponeFixedBucketTableWrite(table, 'job', None, None,
backend)
+ factory = backend.with_blob_uri_reader_factory.call_args[0][0]
+ error = RuntimeError('prepare failed')
+
+ def prepare():
+ for batch in staged:
+ for encoded in batch.column('payload').to_pylist():
+ uri = BlobDescriptor.deserialize(encoded).uri
+ with factory.create(uri).new_input_stream(uri) as stream:
+ payloads.append(stream.read())
+ if fail:
+ raise error
+ return []
+
+ backend.prepare_commit.side_effect = prepare
+ try:
+ for identifier in [1, 2]:
+ writer.write_row(GenericRow(
+ [identifier, 'new', Blob.from_data(str(identifier).encode())],
fields))
+ assert len(factory.readers) == 2
+ assert not payloads
+ if fail:
+ with pytest.raises(RuntimeError) as raised:
+ writer.prepare_commit()
+ assert raised.value is error
+ else:
+ assert writer.prepare_commit() == []
+ assert payloads == [b'1', b'2']
+ assert not factory.readers
+ backend.abort.assert_not_called()
+ finally:
+ writer.close()
diff --git a/paimon-python/pypaimon/tests/native_write_routing_test.py
b/paimon-python/pypaimon/tests/native_write_routing_test.py
new file mode 100644
index 0000000000..b2871d506f
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_write_routing_test.py
@@ -0,0 +1,326 @@
+# 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.
+
+"""Existing writer APIs stay native without changing query-read policies."""
+
+import json
+from contextlib import ExitStack
+from types import ModuleType
+from unittest.mock import Mock, patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.api.api_response import AuthTableQueryResponse, ErrorResponse
+from pypaimon.catalog.catalog_exception import TableNoPermissionException
+from pypaimon.read.native_plan import native_runtime_available
+from pypaimon.tests import native_plan_rest_test
+from pypaimon.write.native_commit import create_native_write_table
+from pypaimon.write.native_write import NativePostponeFixedBucketTableWrite,
NativeTableWrite
+from pypaimon.write.postpone_bucket import PostponeBucketPlan
+
+rest_catalog = native_plan_rest_test.rest_catalog
+pytestmark = [pytest.mark.python_write, pytest.mark.python_commit]
+native = pytest.mark.skipif(not native_runtime_available(),
reason='pypaimon-rust not installed')
+SCHEMA = pa.schema([('id', pa.int64()), ('pt', pa.string()), ('value',
pa.string())])
+
+
+def _table(tmp_path, mode='append', catalog=None, use_native=False,
guarded=False):
+ catalog = catalog or CatalogFactory.create({'warehouse': str(tmp_path)})
+ catalog.create_database('default', True)
+ options = {
+ 'write.native.enabled': str(use_native).lower(),
'commit.native.enabled': str(use_native).lower(),
+ 'query-auth.enabled': str(guarded).lower(), 'read.native.enabled':
'false',
+ 'scan.native-plan.enabled': 'false',
+ }
+ if mode == 'data-evolution':
+ options.update({'row-tracking.enabled': 'true',
'data-evolution.enabled': 'true'})
+ if mode == 'postpone':
+ options.update({'bucket': '-2', 'postpone.default-bucket-num': '2'})
+ elif mode in ('fixed', 'dynamic'):
+ options['bucket'] = '1' if mode == 'fixed' else '-1'
+ catalog.create_table('default.t', Schema.from_pyarrow_schema(
+ SCHEMA, partition_keys=['pt'], primary_keys=['pt', 'id']
+ if mode in ('fixed', 'dynamic', 'postpone') else [], options=options),
False)
+ return catalog.get_table('default.t')
+
+
+def _writer(table, native_writer, partition=None):
+ return NativeTableWrite(table, 'job', partition, False, native_writer)
+
+
[email protected]('mode', ['append', 'fixed'])
[email protected]('staged', [False, True])
[email protected]('partition', [None, {'pt': 'a'}, {}])
+def
test_write_ray_reuses_coordination_without_switching_local_writer(tmp_path,
mode, staged, partition):
+ table = _table(tmp_path, mode)
+ underlying = Mock()
+ writer = _writer(table, underlying, {'pt': 'builder'})
+ if staged:
+ writer.write_arrow_batch(pa.RecordBatch.from_pylist(
+ [{'id': 1, 'pt': 'a', 'value': 'local'}], schema=SCHEMA))
+ module = ModuleType('pypaimon.write.ray_datasink')
+ module.write_paimon_dataset = Mock()
+ dataset = object()
+ with patch.dict('sys.modules', {'pypaimon.write.ray_datasink': module}), \
+ patch.object(writer, '_new_python_writer',
side_effect=AssertionError('Python writer')):
+ assert writer.write_ray(dataset, overwrite=True, concurrency=3,
+ ray_remote_args={'num_cpus': 2},
hash_fixed_precluster='map_groups',
+ static_partition=partition) is None
+ module.write_paimon_dataset.assert_called_once_with(
+ dataset, table, overwrite=True, concurrency=3,
ray_remote_args={'num_cpus': 2},
+ hash_fixed_precluster='map_groups', static_partition={'pt': 'builder'}
if partition is None else partition)
+ assert writer._python_writer is None
+ underlying.close.assert_not_called()
+
+
[email protected]('plan', [None, PostponeBucketPlan({('a',): 4})])
[email protected]('overwrite', [False, True])
+def test_native_postpone_ray_preserves_fixed_bucket_planner(tmp_path, plan,
overwrite):
+ table = _table(tmp_path, 'postpone')
+ writer = NativePostponeFixedBucketTableWrite(table, 'job', {} if overwrite
else None, plan, Mock())
+ module = ModuleType('pypaimon.write.ray_datasink')
+ module.write_paimon_dataset = Mock()
+ with patch.dict('sys.modules', {'pypaimon.write.ray_datasink': module}):
+ writer.write_ray(object(), hash_fixed_precluster='off')
+ options = module.write_paimon_dataset.call_args[1]
+ planner = options['postpone_bucket_planner']
+ assert planner.current_plan().as_dict() == ({} if plan is None else
{('a',): 4})
+ assert options['static_partition'] == ({} if overwrite else None)
+ assert writer._python_writer is None
+
+
[email protected]('native_writer', [False, True])
[email protected]('mode', ['append', 'builder-overwrite',
'call-overwrite', 'call-partition', 'call-empty'])
[email protected]('explicit_plan', [False, True])
+def test_ray_postpone_plans_use_effective_overwrite(tmp_path, native_writer,
mode, explicit_plan):
+ from pypaimon.write.postpone_batch_table_write import
PostponeFixedBucketBatchTableWrite
+ from pypaimon.write.postpone_bucket import PostponeBucketPlanner
+
+ table = _table(tmp_path, 'postpone')
+ plan = PostponeBucketPlan({('a',): 8}) if explicit_plan else None
+ partition = {} if mode == 'builder-overwrite' else None
+ module = ModuleType('pypaimon.write.ray_datasink')
+ module.write_paimon_dataset = Mock()
+ with patch.object(PostponeBucketPlanner, '_load_bucket_metadata',
return_value={('a',): 4}), \
+ patch.dict('sys.modules', {'pypaimon.write.ray_datasink': module}):
+ writer = (NativePostponeFixedBucketTableWrite(table, 'job', partition,
plan, Mock()) if native_writer
+ else PostponeFixedBucketBatchTableWrite(table, 'job',
partition, plan))
+ try:
+ writer.write_ray(object(), overwrite=mode == 'call-overwrite',
static_partition=(
+ {'pt': 'a'} if mode == 'call-partition' else {} if mode ==
'call-empty' else None))
+ finally:
+ writer.close()
+ actual =
module.write_paimon_dataset.call_args[1]['postpone_bucket_planner'].current_plan().as_dict()
+ assert actual == ({('a',): 8} if explicit_plan else {('a',): 4} if mode ==
'append' else {})
+
+
[email protected]('mode', ['append', 'fixed', 'data-evolution'])
+def test_non_video_group_boundaries_validate_without_switching(tmp_path, mode):
+ underlying = Mock()
+ writer = _writer(_table(tmp_path, mode), underlying)
+ for staged in (False, True):
+ if staged:
+ writer.write_arrow_batch(pa.RecordBatch.from_pylist(
+ [{'id': 1, 'pt': 'a', 'value': 'x'}], schema=SCHEMA))
+ for count in (1, 1000):
+ assert writer.roll_before_group_if_needed(count) is None
+ for count in (True, False, 0, -1, 1.0, '1', None):
+ with pytest.raises(ValueError, match='positive integer'):
+ writer.roll_before_group_if_needed(count)
+ assert writer._python_writer is None
+ underlying.close.assert_not_called()
+
+
[email protected]('names', [SCHEMA.names, list(reversed(SCHEMA.names))])
+def test_pk_complete_write_type_keeps_table_order_and_native_writer(tmp_path,
names):
+ underlying = Mock()
+ writer = _writer(_table(tmp_path, 'fixed'), underlying)
+ assert writer.with_write_type(iter(names)) is writer
+ writer.write_arrow_batch(pa.RecordBatch.from_pylist(
+ [{'id': 1, 'pt': 'a', 'value': 'x'}], schema=SCHEMA))
+ assert underlying.write_arrow.call_args[0][0].schema.names == SCHEMA.names
+ underlying.with_write_type.assert_not_called()
+ assert writer._python_writer is None
+
+
+def
test_standard_rest_query_auth_write_table_keeps_native_publication(rest_catalog):
+ catalog, _ = rest_catalog
+ table = _table(None, catalog=catalog, guarded=True)
+ module = ModuleType('pypaimon_rust.datafusion')
+ module.Table = Mock()
+ module.PaimonCatalog = Mock()
+ resolved = Mock(spec=['location', 'rest_table_uuid', 'branch',
'copy_with_resolved_schema'])
+ module.PaimonCatalog.return_value.get_table.return_value = resolved
+ resolved.location.return_value = table.table_path
+ resolved.rest_table_uuid.return_value = table.catalog_environment.uuid
+ resolved.branch.return_value = 'main'
+ resolved.copy_with_resolved_schema.return_value = resolved
+ with patch.dict('sys.modules', {'pypaimon_rust.datafusion': module}):
+ assert create_native_write_table(table) is resolved
+ resolved.copy_with_resolved_schema.assert_called_once()
+ schema = json.loads(resolved.copy_with_resolved_schema.call_args[0][0])
+ assert schema['options']['query-auth.enabled'] == 'true'
+
+
+def test_read_based_updates_keep_existing_guarded_selection(rest_catalog):
+ from pypaimon.write.native_update import _native_row_id_table
+
+ catalog, _ = rest_catalog
+ table = _table(None, 'data-evolution', catalog, use_native=True,
guarded=True)
+ with patch('pypaimon.write.native_update.native_runtime_available',
return_value=True), \
+ patch('pypaimon.write.native_update.create_native_write_table') as
create:
+ assert _native_row_id_table(table) is None
+ create.assert_not_called()
+
+
+def _rows(table):
+ builder = table.new_read_builder()
+ return
builder.new_read().to_arrow(builder.new_scan().plan().splits()).sort_by(
+ [('id', 'ascending'), ('value', 'ascending')]).to_pylist()
+
+
[email protected]('use_native,mode', [
+ (False, 'append'), (False, 'fixed'), (False, 'dynamic'), (False,
'postpone'),
+ pytest.param(True, 'append', marks=native), pytest.param(True, 'fixed',
marks=native),
+ pytest.param(True, 'dynamic', marks=native), pytest.param(True,
'data-evolution', marks=native),
+ pytest.param(True, 'postpone', marks=native),
+])
+def
test_query_auth_writes_do_not_require_select_and_reads_still_authorize(rest_catalog,
use_native, mode):
+ catalog, server = rest_catalog
+ table = _table(None, mode, catalog, use_native, guarded=True)
+ denied = server._mock_response(ErrorResponse('TABLE', 't', 'SELECT
denied', 403), 403)
+ with patch.object(server, '_table_query_auth_handle', return_value=denied)
as query:
+ for rows in ([{'id': 1, 'pt': 'a', 'value': 'before'}, {'id': 2, 'pt':
'b', 'value': 'hidden'}],
+ [{'id': 1, 'pt': 'a', 'value': 'after'}]):
+ builder = (table.new_postpone_fixed_bucket_write_builder()
+ if mode == 'postpone' else
table.new_batch_write_builder())
+ writer, commit = builder.new_write(), builder.new_commit()
+ with ExitStack() as stack:
+ if use_native:
+ assert isinstance(writer, NativeTableWrite)
+ stack.enter_context(patch.object(commit.file_store_commit,
'commit',
+
side_effect=AssertionError('Python commit')))
+ try:
+ writer.write_arrow(pa.Table.from_pylist(rows,
schema=SCHEMA))
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+ query.assert_not_called()
+ with pytest.raises(TableNoPermissionException):
+ _rows(table)
+
+ # Row policies apply only when the newly stored rows are read, including
+ # masking stored values; hidden rows were still written normally.
+ field = {'name': 'id', 'type': 'BIGINT', 'index': 0}
+ server.table_query_auth_store[table.identifier.get_full_name()] =
AuthTableQueryResponse(
+ filter=[json.dumps({'kind': 'LEAF', 'transform': {'name': 'FIELD_REF',
'fieldRef': field},
+ 'function': 'EQUAL', 'literals': [1]})],
+ column_masking={'value': json.dumps({'name': 'CONCAT', 'inputs':
['masked']})})
+ visible = _rows(table)
+ count = 2 if mode in ('append', 'data-evolution') else 1
+ assert visible == [{'id': 1, 'pt': 'a', 'value': 'masked'}] * count
+
+
[email protected]('restore_id', [0, 1])
[email protected]('use_native', [False, pytest.param(True,
marks=native)])
+def
test_guarded_dynamic_restore_keeps_explicit_snapshot_baseline(rest_catalog,
restore_id, use_native):
+ catalog, server = rest_catalog
+ table = _table(None, 'dynamic', catalog,
guarded=True).copy({'dynamic-bucket.target-row-num': '1'})
+ denied = server._mock_response(ErrorResponse('TABLE', 't', 'SELECT
denied', 403), 403)
+ with patch.object(server, '_table_query_auth_handle', return_value=denied)
as query:
+ for rows in ([{'id': 1, 'pt': 'a', 'value': 'base'}], [
+ {'id': 1, 'pt': 'a', 'value': 'future-1'}, {'id': 1, 'pt':
'a', 'value': 'future-2'},
+ {'id': 2, 'pt': 'a', 'value': 'new-bucket'}]):
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ writer.write_arrow(pa.Table.from_pylist(rows, schema=SCHEMA))
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+ assert table.snapshot_manager().get_latest_snapshot().id == 2
+ restored = table.copy({'write.native.enabled':
str(use_native).lower()})
+ builder =
restored.new_batch_write_builder().with_restore_snapshot(restore_id)
+ writer = builder.new_write()
+ assert isinstance(writer, NativeTableWrite) == use_native
+ try:
+ writer.write_arrow(pa.Table.from_pylist([
+ {'id': 1, 'pt': 'a', 'value': 'restored'}, {'id': 3, 'pt':
'a', 'value': 'another'}], schema=SCHEMA))
+ messages = writer.prepare_commit()
+ assert {message.bucket for message in messages} == {0, 1}
+ files = {message.bucket: message.new_files[0] for message in
messages}
+ assert files[0].min_sequence_number == restore_id
+ assert files[1].min_sequence_number == 0
+ # These messages are explicitly abandoned, never submitted for
+ # publication. The old snapshots and their HASH files stay intact.
+ commit = builder.new_commit()
+ try:
+ commit.abort(messages)
+ finally:
+ commit.close()
+ finally:
+ writer.close()
+ query.assert_not_called()
+ assert table.snapshot_manager().get_latest_snapshot().id == 2
+
+
[email protected]('use_native', [False, pytest.param(True,
marks=native)])
+def test_guarded_overwrite_and_truncate_keep_rest_publication(rest_catalog,
use_native):
+ catalog, server = rest_catalog
+ table = _table(None, catalog=catalog, use_native=use_native, guarded=True)
+ denied = server._mock_response(ErrorResponse('TABLE', 't', 'SELECT
denied', 403), 403)
+ with patch.object(server, '_table_query_auth_handle', return_value=denied)
as query:
+ for partition, rows in [(None, [
+ {'id': 1, 'pt': 'a', 'value': 'old'}, {'id': 2, 'pt': 'b',
'value': 'keep'}]),
+ ({'pt': 'a'}, [{'id': 3, 'pt': 'a', 'value': 'replace'}])]:
+ builder = table.new_batch_write_builder()
+ if partition is not None:
+ builder.overwrite(partition)
+ writer, commit = builder.new_write(), builder.new_commit()
+ with ExitStack() as stack:
+ if use_native:
+ assert isinstance(writer, NativeTableWrite)
+ stack.enter_context(patch.object(commit.file_store_commit,
'commit',
+
side_effect=AssertionError('Python commit')))
+ stack.enter_context(patch.object(commit.file_store_commit,
'overwrite',
+
side_effect=AssertionError('Python overwrite')))
+ try:
+ writer.write_arrow(pa.Table.from_pylist(rows,
schema=SCHEMA))
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+ assert
table.snapshot_manager().get_latest_snapshot().total_record_count == 2
+ commit = table.new_batch_write_builder().new_commit()
+ with ExitStack() as stack:
+ if use_native:
+ stack.enter_context(patch.object(commit.file_store_commit,
'drop_partitions',
+
side_effect=AssertionError('Python drop')))
+ stack.enter_context(patch.object(commit.file_store_commit,
'truncate_table',
+
side_effect=AssertionError('Python truncate')))
+ try:
+ commit.truncate_partitions([{'pt': 'b'}])
+ assert
table.snapshot_manager().get_latest_snapshot().total_record_count == 1
+ commit.truncate_table()
+ assert
table.snapshot_manager().get_latest_snapshot().total_record_count == 0
+ finally:
+ commit.close()
+ query.assert_not_called()
diff --git a/paimon-python/pypaimon/tests/native_write_test.py
b/paimon-python/pypaimon/tests/native_write_test.py
index 5816909a14..f5d0e95923 100644
--- a/paimon-python/pypaimon/tests/native_write_test.py
+++ b/paimon-python/pypaimon/tests/native_write_test.py
@@ -492,12 +492,13 @@ def
test_blob_only_partial_write_uses_native_and_updates_existing_range(tmp_path
@requires_native
@pytest.mark.parametrize('streaming', [False, True])
-def test_primary_key_full_write_type_selects_writer_before_staging(tmp_path,
streaming):
+def test_primary_key_full_write_type_keeps_native_writer(tmp_path, streaming):
table = _table(tmp_path, primary_key=True)
builder = table.new_stream_write_builder() if streaming else
table.new_batch_write_builder()
writer = builder.new_write()
try:
- assert writer.with_write_type(table.field_names) is
writer._python_writer
+ assert writer.with_write_type(table.field_names) is writer
+ assert writer._python_writer is None
writer.write_arrow_batch(_batch([1], ['a']))
messages = writer.prepare_commit(7) if streaming else
writer.prepare_commit()
commit = builder.new_commit()
diff --git a/paimon-python/pypaimon/tests/query_auth_validation_test.py
b/paimon-python/pypaimon/tests/query_auth_validation_test.py
index 6aaf1a9c6c..66a99b47c3 100644
--- a/paimon-python/pypaimon/tests/query_auth_validation_test.py
+++ b/paimon-python/pypaimon/tests/query_auth_validation_test.py
@@ -792,6 +792,7 @@ def _exploding_schema_manager():
return SimpleNamespace(latest=boom)
[email protected]_read
class TestSchemaRefreshAsksTheCatalog(unittest.TestCase):
def _table(self, cached, latest):
@@ -865,6 +866,7 @@ class TestSchemaRefreshAsksTheCatalog(unittest.TestCase):
pickle.dumps(read._auth_schema)
[email protected]_read
class TestReaderAppliesTheRules(unittest.TestCase):
def _read(self, fields, data, latest=None, projected=None):
diff --git a/paimon-python/pypaimon/tests/ray_vector_filter_exactness_test.py
b/paimon-python/pypaimon/tests/ray_vector_filter_exactness_test.py
index 983ba96ef8..91761c2bd3 100644
--- a/paimon-python/pypaimon/tests/ray_vector_filter_exactness_test.py
+++ b/paimon-python/pypaimon/tests/ray_vector_filter_exactness_test.py
@@ -141,6 +141,7 @@ def
test_worker_filters_overlapping_scalar_shards_at_planned_snapshot(tmp_path,
@pytest.mark.parametrize("batch", [False, True])
@pytest.mark.parametrize("mode", ["exact", "candidate", "unavailable"])
[email protected]_plan
def test_shared_scalar_index_is_evaluated_once_on_ray(table, tmp_path,
ray_cluster, batch, mode):
import os
from pathlib import Path
diff --git
a/paimon-python/pypaimon/tests/scanner/file_scanner_schema_fields_test.py
b/paimon-python/pypaimon/tests/scanner/file_scanner_schema_fields_test.py
index b75e7505d0..67b807b2c8 100644
--- a/paimon-python/pypaimon/tests/scanner/file_scanner_schema_fields_test.py
+++ b/paimon-python/pypaimon/tests/scanner/file_scanner_schema_fields_test.py
@@ -18,6 +18,11 @@
import unittest
from unittest.mock import MagicMock
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.manifest.manifest_list_manager import ManifestListManager
from pypaimon.read.scanner.file_scanner import FileScanner
from pypaimon.schema.data_types import AtomicType, DataField
@@ -90,5 +95,40 @@ class
FileScannerSchemaFieldsShortCircuitTest(unittest.TestCase):
scanner.table.schema_manager.get_schema.assert_not_called()
[email protected]_plan
[email protected]_read
[email protected]_write
[email protected]_commit
[email protected]('guarded', [False, True])
+def test_transported_query_auth_files_retain_snapshot_statistics(tmp_path,
guarded):
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+ catalog.create_database('default', True)
+ schema = pa.schema([('id', pa.int32()), ('value', pa.string())])
+ catalog.create_table('default.t', Schema.from_pyarrow_schema(schema,
options={
+ 'metadata.stats-mode': 'full',
+ 'write.native.enabled': 'false', 'commit.native.enabled': 'false',
+ 'scan.native-plan.enabled': 'false', 'read.native.enabled': 'false',
+ }), False)
+ table = catalog.get_table('default.t')
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ writer.write_arrow(pa.table({'id': [1, 2], 'value': ['a', 'z']},
schema=schema))
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+ table = table.copy({'query-auth.enabled': str(guarded).lower()})
+ snapshot = table.snapshot_manager().get_latest_snapshot()
+ manifests = ManifestListManager(table).read_all(snapshot)
+ scanner = table.new_read_builder().new_scan().file_scanner
+ entries = scanner.read_manifest_entries(manifests)
+ assert len(entries) == 1
+ # Core validates the complete transported metadata against the snapshot.
+ # Ordinary scans retain their existing reduced-statistics behavior.
+ assert entries[0].file.value_stats.null_counts == ([0, 0] if guarded else
[])
+ assert entries[0].file.value_stats_cols == (None if guarded else [])
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/paimon-python/pypaimon/tests/table_update_test.py
b/paimon-python/pypaimon/tests/table_update_test.py
index 83f971c2e2..552d3d0d29 100644
--- a/paimon-python/pypaimon/tests/table_update_test.py
+++ b/paimon-python/pypaimon/tests/table_update_test.py
@@ -366,6 +366,7 @@ class _TableUpdateTestBase(DataEvolutionTestBase):
result['name'].to_pylist(),
)
+ @pytest.mark.python_plan
@pytest.mark.python_write
def test_update_by_predicate_processes_one_file_group_at_a_time(self):
from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
diff --git a/paimon-python/pypaimon/tests/write/table_write_test.py
b/paimon-python/pypaimon/tests/write/table_write_test.py
index 13a94e9c68..072d050489 100644
--- a/paimon-python/pypaimon/tests/write/table_write_test.py
+++ b/paimon-python/pypaimon/tests/write/table_write_test.py
@@ -803,10 +803,11 @@ class TableWriteTest(unittest.TestCase):
PostponeFixedBucketBatchTableWrite,
PostponeFixedBucketWriteBuilder,
)
+ from pypaimon.write.native_write import
NativePostponeFixedBucketTableWrite
self.assertIsInstance(
write_builder, PostponeFixedBucketWriteBuilder)
self.assertIsInstance(
- table_write, PostponeFixedBucketBatchTableWrite)
+ table_write, (PostponeFixedBucketBatchTableWrite,
NativePostponeFixedBucketTableWrite))
table_commit = write_builder.new_commit()
table_write.write_arrow(expect)
commit_messages = table_write.prepare_commit()
@@ -1128,6 +1129,7 @@ class TableWriteTest(unittest.TestCase):
self.assertEqual(1, result.num_rows)
@parameterized.expand([('mod',), ('hive',)])
+ @pytest.mark.python_write
def test_postpone_rejects_unsupported_bucket_function(
self, bucket_function):
pa_schema = pa.schema([
diff --git a/paimon-python/pypaimon/write/file_store_write.py
b/paimon-python/pypaimon/write/file_store_write.py
index 7a75291334..151c038154 100644
--- a/paimon-python/pypaimon/write/file_store_write.py
+++ b/paimon-python/pypaimon/write/file_store_write.py
@@ -386,7 +386,7 @@ class FileStoreWrite:
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_all_files still validates row filters and column
masks.
+ # view for the writer's internal manifest lookup.
return self.table._copy_with_snapshot(snapshot)
return self.table
@@ -401,15 +401,18 @@ class FileStoreWrite:
else predicate_builder.equal(key, value))
partition_filter = predicate_builder.and_predicates(sub_predicates)
- scan = read_builder.with_filter(partition_filter).new_scan()
- splits = scan.plan_all_files().splits()
+ scanner =
read_builder.with_filter(partition_filter).new_scan().file_scanner
+ # Sequence restoration reads file metadata, not query results. As in
+ # Java's writer scan, include L0 and do not require SELECT or apply row
+ # filters/masks to internal state. Public plan_all_files stays guarded.
+ scanner.skip_level0 = False
max_seq_numbers = {}
- for split in splits:
- current_seq_num = max([file.max_sequence_number for file in
split.files])
- existing_max = max_seq_numbers.get(split.bucket, -1)
+ for entry in scanner.plan_files():
+ current_seq_num = entry.file.max_sequence_number
+ existing_max = max_seq_numbers.get(entry.bucket, -1)
if current_seq_num > existing_max:
- max_seq_numbers[split.bucket] = current_seq_num
+ max_seq_numbers[entry.bucket] = current_seq_num
return max_seq_numbers
diff --git a/paimon-python/pypaimon/write/native_commit.py
b/paimon-python/pypaimon/write/native_commit.py
index 56d6d3db64..d3cb5235dd 100644
--- a/paimon-python/pypaimon/write/native_commit.py
+++ b/paimon-python/pypaimon/write/native_commit.py
@@ -144,8 +144,7 @@ def create_native_write_table(table):
environment = table.catalog_environment
if (type(table) is not FileStoreTable
- or type(environment) is not CatalogEnvironment
- or table.options.query_auth_enabled):
+ or type(environment) is not CatalogEnvironment):
return None
from pypaimon_rust.datafusion import Table as NativeTable
diff --git a/paimon-python/pypaimon/write/native_update.py
b/paimon-python/pypaimon/write/native_update.py
index da7fb72c90..610eaedd54 100644
--- a/paimon-python/pypaimon/write/native_update.py
+++ b/paimon-python/pypaimon/write/native_update.py
@@ -45,6 +45,9 @@ def _native_update_table(table):
"""Resolve common native update prerequisites before preparing changes."""
if (type(table) is not FileStoreTable
or not table.options.native_write_enabled()
+ # These operations read target rows before staging changes. Pure
+ # write/commit support must not select an unsupported guarded read.
+ or table.options.query_auth_enabled
or not _native_update_formats_supported(table)
or table.options.changelog_producer() != ChangelogProducer.NONE
or any(table.options.options.contains_key(key) for key in
SCAN_KEYS)
diff --git a/paimon-python/pypaimon/write/native_write.py
b/paimon-python/pypaimon/write/native_write.py
index e26a4d87c3..d7aef5ebfd 100644
--- a/paimon-python/pypaimon/write/native_write.py
+++ b/paimon-python/pypaimon/write/native_write.py
@@ -150,11 +150,15 @@ class NativeTableWrite:
return self._python_writer.with_write_type(write_cols)
if self._written:
raise RuntimeError('with_write_type must be called before writing;
after native data it cannot change')
+ names = list(write_cols)
if self.table.is_primary_key_table:
+ if len(names) == len(self._table_schema.names) and set(names) ==
set(self._table_schema.names):
+ # Python treats a complete PK write type as the unchanged table
+ # schema. It does not configure a PK column-subset writer.
+ return self
# Core's write type is an append-table operation. Keep the existing
# Python writer contract for PK setters before any data is staged.
- return self._switch_to_python().with_write_type(write_cols)
- names = list(write_cols)
+ return self._switch_to_python().with_write_type(names)
self._native_writer.with_write_type(names)
self._schema = pa.schema([self._table_schema.field(name) for name in
names])
self._write_cols = names
@@ -241,6 +245,26 @@ class NativeTableWrite:
return self._python_writer.write_pandas(dataframe)
self.write_arrow_batch(pa.RecordBatch.from_pandas(dataframe,
schema=self._schema))
+ def write_ray(self, dataset, overwrite=False, concurrency=None,
ray_remote_args=None,
+ hash_fixed_precluster='auto', static_partition=None):
+ if self._python_writer is not None:
+ return self._python_writer.write_ray(
+ dataset, overwrite, concurrency, ray_remote_args,
hash_fixed_precluster, static_partition)
+ from pypaimon.write.table_write import _write_ray
+ _write_ray(self, dataset, overwrite, concurrency, ray_remote_args,
+ hash_fixed_precluster, static_partition)
+
+ def _distributed_write_options(self, *, overwrite=False):
+ return {}
+
+ def roll_before_group_if_needed(self, row_count):
+ if self._python_writer is not None:
+ return self._python_writer.roll_before_group_if_needed(row_count)
+ if isinstance(row_count, bool) or not isinstance(row_count, int) or
row_count <= 0:
+ raise ValueError('Group row count must be a positive integer.')
+ # Python's group boundary only affects packed video writers. Native
+ # selection excludes video, so ordinary Parquet/BLOB writes do not
roll.
+
def write_row(self, row):
if self._python_writer is not None:
return self._python_writer.write_row(row)
@@ -321,3 +345,10 @@ class
NativePostponeFixedBucketTableWrite(NativeTableWrite):
from pypaimon.write.postpone_batch_table_write import
PostponeFixedBucketBatchTableWrite
return
self._configure_python_writer(PostponeFixedBucketBatchTableWrite(
self.table, self.commit_user, self.static_partition,
self._bucket_plan))
+
+ def _distributed_write_options(self, *, overwrite=False):
+ from pypaimon.write.postpone_bucket import PostponeBucketPlanner
+ return {'postpone_bucket_planner': PostponeBucketPlanner(
+ self.table, known_num_buckets=(
+ self._bucket_plan.as_dict() if self._bucket_plan is not None
+ else {} if overwrite else None))}
diff --git a/paimon-python/pypaimon/write/postpone_batch_table_write.py
b/paimon-python/pypaimon/write/postpone_batch_table_write.py
index e1171f76ea..4486d697d3 100644
--- a/paimon-python/pypaimon/write/postpone_batch_table_write.py
+++ b/paimon-python/pypaimon/write/postpone_batch_table_write.py
@@ -204,7 +204,9 @@ class PostponeFixedBucketBatchTableWrite(BatchTableWrite):
self._flush_pending_inputs()
return self._prepare_commit(BATCH_COMMIT_IDENTIFIER)
- def _distributed_write_options(self) -> Dict[str, Any]:
+ def _distributed_write_options(self, *, overwrite=False) -> Dict[str, Any]:
+ if overwrite and not self._plan_provided:
+ return {"postpone_bucket_planner":
PostponeBucketPlanner(self.table, known_num_buckets={})}
return {"postpone_bucket_planner": self._planner}
def close(self):
diff --git a/paimon-python/pypaimon/write/table_write.py
b/paimon-python/pypaimon/write/table_write.py
index c78ea60801..63b175e168 100644
--- a/paimon-python/pypaimon/write/table_write.py
+++ b/paimon-python/pypaimon/write/table_write.py
@@ -36,6 +36,19 @@ if TYPE_CHECKING:
from ray.data import Dataset
+def _write_ray(writer, dataset, overwrite=False, concurrency=None,
ray_remote_args=None,
+ hash_fixed_precluster='auto', static_partition=None):
+ """Share Ray coordination without constructing another local data
writer."""
+ from pypaimon.write.ray_datasink import write_paimon_dataset
+
+ overwrite_partition = writer.static_partition if static_partition is None
else static_partition
+ return write_paimon_dataset(
+ dataset, writer.table, overwrite=overwrite,
static_partition=overwrite_partition,
+ concurrency=concurrency, ray_remote_args=ray_remote_args,
+ hash_fixed_precluster=hash_fixed_precluster,
+ **writer._distributed_write_options(overwrite=overwrite or
overwrite_partition is not None))
+
+
class TableWrite:
def __init__(self, table, commit_user, static_partition: Optional[dict] =
None,
*, restore_snapshot_id=None):
@@ -242,24 +255,10 @@ class TableWrite:
the Ray write runs in overwrite mode for this partition and
overrides any builder-level partition spec.
"""
- from pypaimon.write.ray_datasink import write_paimon_dataset
-
- overwrite_partition = self.static_partition
- if static_partition is not None:
- overwrite_partition = static_partition
-
- write_paimon_dataset(
- dataset,
- self.table,
- overwrite=overwrite,
- static_partition=overwrite_partition,
- concurrency=concurrency,
- ray_remote_args=ray_remote_args,
- hash_fixed_precluster=hash_fixed_precluster,
- **self._distributed_write_options(),
- )
+ _write_ray(self, dataset, overwrite, concurrency, ray_remote_args,
+ hash_fixed_precluster, static_partition)
- def _distributed_write_options(self) -> Dict[str, Any]:
+ def _distributed_write_options(self, *, overwrite=False) -> Dict[str, Any]:
"""Return options forwarded by ``write_ray`` to the Ray writer."""
return {}