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 b21f36cc56 [Python] Complete native FUSE writes, truncate, postpone
planning and MERGE callbacks (#10642)
b21f36cc56 is described below
commit b21f36cc56ee6bd11f43b7a2d7f43695445cd446
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Oct 19 08:56:17 2026 +0800
[Python] Complete native FUSE writes, truncate, postpone planning and MERGE
callbacks (#10642)
---
.../pypaimon/ray/data_evolution_merge_into.py | 9 +-
.../pypaimon/ray/data_evolution_merge_join.py | 3 +
.../pypaimon/ray/data_evolution_merge_transform.py | 26 +-
paimon-python/pypaimon/read/native_plan.py | 54 ++-
.../pypaimon/table/data_evolution_merge_into.py | 8 +
paimon-python/pypaimon/tests/native_commit_test.py | 6 +-
.../tests/native_read_path_completion_test.py | 70 ++-
paimon-python/pypaimon/tests/native_read_test.py | 1 +
.../tests/native_write_path_completion_test.py | 493 +++++++++++++++++++++
.../tests/primary_key_index_definitions_test.py | 14 +-
.../tests/ray_data_evolution_merge_into_test.py | 60 ++-
.../pypaimon/tests/write/table_write_test.py | 30 +-
paimon-python/pypaimon/write/native_blob_rows.py | 12 +
paimon-python/pypaimon/write/native_commit.py | 46 +-
paimon-python/pypaimon/write/native_merge_into.py | 14 +-
paimon-python/pypaimon/write/native_update.py | 2 +-
paimon-python/pypaimon/write/native_write.py | 16 +-
.../pypaimon/write/postpone_batch_table_write.py | 13 +-
paimon-python/pypaimon/write/postpone_bucket.py | 30 +-
paimon-python/pypaimon/write/ray_datasink.py | 3 -
paimon-python/pypaimon/write/table_commit.py | 31 +-
21 files changed, 792 insertions(+), 149 deletions(-)
diff --git a/paimon-python/pypaimon/ray/data_evolution_merge_into.py
b/paimon-python/pypaimon/ray/data_evolution_merge_into.py
index 6fb9e34b99..ae32cecc3f 100644
--- a/paimon-python/pypaimon/ray/data_evolution_merge_into.py
+++ b/paimon-python/pypaimon/ray/data_evolution_merge_into.py
@@ -213,7 +213,7 @@ def _prepare(
c.update,
settable_field_names,
on_map,
- allow_callables=is_self_merge,
+ allow_callables=True,
)
matched_specs.append(
_NormalizedClause(
@@ -254,6 +254,7 @@ def _prepare(
spec = _normalize_set_spec(
c.insert, settable_field_names, on_map,
allow_target_refs=False,
+ allow_callables=True,
)
for tk, sk in on_map.items():
if tk in settable_field_names and tk not in spec:
@@ -277,8 +278,6 @@ def _prepare(
if read_columns and not has_callable:
raise ValueError("read_columns requires a callable SET value.")
if has_callable:
- if not read_columns:
- raise ValueError("Callable SET values require read_columns.")
for col in read_columns:
if col not in full_target_field_names:
raise ValueError(
@@ -714,10 +713,14 @@ def _resolve_target_projection(
target_on: Sequence[str],
update_cols: Sequence[str],
target_field_names: Sequence[str],
+ callable_columns: Optional[Sequence[str]] = None,
) -> list:
needed = set(_needed_target_cols(
clauses, target_on, update_cols, target_field_names,
))
+ if any(callable(value) and not isinstance(value, type)
+ for clause in clauses for value in clause.spec.values()):
+ needed.update(target_field_names if callable_columns is None else
callable_columns)
if any(c.condition is not None for c in clauses):
from pypaimon.ray.merge_condition import extract_target_columns
target_set = set(target_field_names)
diff --git a/paimon-python/pypaimon/ray/data_evolution_merge_join.py
b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
index 6225e59e6f..fb56710fa9 100644
--- a/paimon-python/pypaimon/ray/data_evolution_merge_join.py
+++ b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
@@ -113,6 +113,8 @@ def _resolve_source_projection(
for clause in clauses:
for value in clause.spec.values():
+ if callable(value) and not isinstance(value, type):
+ needed.update(source_field_names)
if isinstance(value, SourceColumnRef):
needed.add(value.column)
if clause.condition is not None:
@@ -269,6 +271,7 @@ def build_self_merge_update_plan(
row_id_name = SpecialFields.ROW_ID.name
needed_cols = set(resolve_target_projection(
clauses, [row_id_name], update_cols, target_field_names,
+ callable_columns=read_columns or None,
))
needed_cols.update(read_columns)
for clause in clauses:
diff --git a/paimon-python/pypaimon/ray/data_evolution_merge_transform.py
b/paimon-python/pypaimon/ray/data_evolution_merge_transform.py
index ad29368e51..adaf5fa3cc 100644
--- a/paimon-python/pypaimon/ray/data_evolution_merge_transform.py
+++ b/paimon-python/pypaimon/ray/data_evolution_merge_transform.py
@@ -53,6 +53,8 @@ def lit(value: Any) -> LiteralValue:
class WhenMatched:
+ """Matched UPDATE/DELETE clause; callable SET values consume t./s. Arrow
columns."""
+
def __init__(
self,
action: str,
@@ -83,6 +85,8 @@ class WhenMatched:
@dataclass
class WhenNotMatched:
+ """INSERT clause; callable values consume s. Arrow columns and return
Arrow arrays."""
+
insert: SetSpec
condition: Optional[str] = None
@@ -169,6 +173,20 @@ def build_delete_schema(row_id_name: str) -> pa.Schema:
return pa.schema([pa.field(row_id_name, pa.int64(), nullable=False)])
+def _merge_callable_input(batch: pa.Table, on_pairs):
+ # Arrow joins coalesce ON keys; callbacks still expose both SQL aliases.
+ for source, target in on_pairs:
+ source_name, target_name = "s." + source, "t." + target
+ if target_name in batch.column_names:
+ if source_name in batch.column_names:
+ batch =
batch.set_column(batch.schema.get_field_index(source_name),
+ source_name,
batch.column(target_name))
+ else:
+ batch = batch.append_column(source_name,
batch.column(target_name))
+ return batch.select(sorted(name for name in batch.column_names
+ if name.startswith(("t.", "s."))))
+
+
def _resolve_spec_array(
val: Any,
batch: pa.Table,
@@ -178,11 +196,9 @@ def _resolve_spec_array(
callable_input: Optional[pa.Table] = None,
):
if callable(val) and not isinstance(val, type):
- if callable_input is None:
- raise TypeError(
- "Callable SET values are only supported for self-merge."
- )
- result = val(callable_input)
+ # Public MERGE callbacks see the joined t./s. columns. The internal
+ # predicate-update adapter supplies its existing unqualified read view.
+ result = val(_merge_callable_input(batch, on_pairs) if callable_input
is None else callable_input)
if not isinstance(result, (pa.Array, pa.ChunkedArray)):
raise ValueError(
"Callable SET values must return a pyarrow.Array or "
diff --git a/paimon-python/pypaimon/read/native_plan.py
b/paimon-python/pypaimon/read/native_plan.py
index 062fe799ca..6fedad6302 100644
--- a/paimon-python/pypaimon/read/native_plan.py
+++ b/paimon-python/pypaimon/read/native_plan.py
@@ -218,12 +218,13 @@ def _predicate_to_native(predicate: Predicate) -> dict:
return result
-def _resolved_rest_table_response(table):
+def _resolved_rest_table_response(table, identifier=None):
"""Reuse REST metadata only when the standard loader can be reproduced."""
from pypaimon.catalog.catalog_environment import CatalogEnvironment
from pypaimon.catalog.rest.rest_catalog_loader import RESTCatalogLoader
environment = table.catalog_environment
+ identifier = table.identifier if identifier is None else identifier
response = getattr(environment, 'rest_table_response', None)
if (type(environment) is not CatalogEnvironment
or type(environment.catalog_loader) is not RESTCatalogLoader
@@ -231,9 +232,9 @@ def _resolved_rest_table_response(table):
return None
metadata = json.loads(response)
if (metadata.get('path') != table.table_path
- or metadata.get('name') != table.identifier.get_object_name()
+ or metadata.get('name') != identifier.get_object_name()
or ('database' in metadata
- and metadata['database'] !=
table.identifier.get_database_name())):
+ and metadata['database'] != identifier.get_database_name())):
return None
return response
@@ -296,8 +297,9 @@ def _rest_data_token(table):
def _validate_native_fuse_binding(table):
- """Catalog options must reconstruct the FileIO already bound to the
view."""
+ """Find the catalog table whose construction reproduces the bound
FileIO."""
from pypaimon.catalog.rest.fuse_support import FusePathResolver
+ from pypaimon.common.identifier import Identifier
from pypaimon.filesystem.caching_file_io import CachingFileIO
from pypaimon.filesystem.local_file_io import FuseLocalFileIO, LocalFileIO
@@ -312,13 +314,30 @@ def _validate_native_fuse_binding(table):
options = loader.context().options
if not options.get(FuseOptions.FUSE_ENABLED):
raise RuntimeError('Native catalog would disable the bound FUSE mount')
- expected = FusePathResolver(options,
None).resolve_local_path(table.table_path, table.identifier)
+ if file_io.path.rstrip('/') != table.table_path.rstrip('/'):
+ raise RuntimeError('The bound FUSE FileIO belongs to a different table
path')
+ candidates = []
+ response = table.catalog_environment.rest_table_response
+ if isinstance(response, str):
+ metadata = json.loads(response)
+ if (metadata.get('path') == table.table_path
+ and metadata.get('database',
table.identifier.get_database_name())
+ == table.identifier.get_database_name() and
isinstance(metadata.get('name'), str)):
+ original = Identifier.create(table.identifier.get_database_name(),
metadata['name'])
+ if original.get_table_name() == table.identifier.get_table_name():
+ candidates.append(original)
+ candidates.append(table.identifier)
+ # A manually resolved view can omit the original REST response. Its usual
+ # main-table binding can still be reconstructed by the same catalog.
+ candidates.append(Identifier.create(table.identifier.get_database_name(),
table.identifier.get_table_name()))
+ resolver = FusePathResolver(options, None)
local = LocalFileIO()
- if (file_io.path.rstrip('/') != table.table_path.rstrip('/')
- or local._to_file(file_io.fuse_path).absolute() !=
local._to_file(expected).absolute()):
- # In pvfs mode, copy(branch=...) retains Python's original FileIO;
- # loading the decorated identifier would bind a different directory.
- raise RuntimeError('Native catalog would resolve a different FUSE
mount')
+ mounted = local._to_file(file_io.fuse_path).absolute()
+ for identifier in candidates:
+ expected = resolver.resolve_local_path(table.table_path, identifier)
+ if mounted == local._to_file(expected).absolute():
+ return identifier
+ raise RuntimeError('Native catalog cannot reconstruct the bound FUSE
FileIO')
def _native_table(table):
@@ -341,18 +360,18 @@ def _native_table(table):
def _load_native_table(table):
- rest_response = _resolved_rest_table_response(table)
+ identifier = _validate_native_fuse_binding(table) or table.identifier
+ rest_response = _resolved_rest_table_response(table, identifier)
file_io_options = _resolved_schema_file_io_options(table)
if rest_response is not None:
cache = table.catalog_environment.__dict__.setdefault(
'_native_rest_table_cache', _NativeRestTableCache())
rt = cache.get(
rest_response,
- database=table.identifier.get_database_name(),
- table=table.identifier.get_object_name(),
+ database=identifier.get_database_name(),
+ table=identifier.get_object_name(),
options=_catalog_options(table),
token_loader=lambda: _rest_data_token(table))
- rt = _with_native_resolved_schema(table, rt)
elif file_io_options is not None:
from pypaimon_rust.datafusion import Table
rt = Table.from_resolved_schema(
@@ -368,9 +387,14 @@ def _load_native_table(table):
# REST may keep branch schemas in the catalog only. Load the base
# environment, then attach the schema/branch already resolved here.
rt = catalog.get_table((
- table.identifier.get_database_name(),
table.identifier.get_object_name()))
+ identifier.get_database_name(), identifier.get_object_name()))
if rt.location() != table.table_path:
raise RuntimeError('Native catalog resolved a different table
location')
+ if identifier != table.identifier:
+ # Construct the originally bound table, then use the existing copy API.
+ # Core retains its FileIO while resolving the branch's REST identity.
+ rt = rt.copy({'branch': table.current_branch()})
+ if file_io_options is None:
rt = _with_native_resolved_schema(table, rt)
if table.current_branch() != 'main':
if rt.branch() != table.current_branch():
diff --git a/paimon-python/pypaimon/table/data_evolution_merge_into.py
b/paimon-python/pypaimon/table/data_evolution_merge_into.py
index b12a766a49..d64294af60 100644
--- a/paimon-python/pypaimon/table/data_evolution_merge_into.py
+++ b/paimon-python/pypaimon/table/data_evolution_merge_into.py
@@ -91,6 +91,12 @@ def merge_into(
The returned messages must be committed by the caller's matching
``TableCommit``.
+
+ SET and INSERT mappings may contain callables accepting an Arrow table
+ with qualified ``t.`` target and ``s.`` source columns (INSERT has only
+ source columns). Each callable evaluates the rows selected by its clause
+ and returns an Arrow Array or ChunkedArray of the same length. Empty
+ clauses do not invoke callbacks; callback errors propagate to the caller.
"""
if target_table.options.native_write_enabled():
from pypaimon.write.native_merge_into import create_native_merge_into
@@ -182,6 +188,7 @@ def _prepare(
c.update,
settable_field_names,
on_map,
+ allow_callables=True,
)
matched_specs.append(
_NormalizedClause(
@@ -230,6 +237,7 @@ def _prepare(
settable_field_names,
on_map,
allow_target_refs=False,
+ allow_callables=True,
)
for tk, sk in on_map.items():
if tk in settable_field_names and tk not in spec:
diff --git a/paimon-python/pypaimon/tests/native_commit_test.py
b/paimon-python/pypaimon/tests/native_commit_test.py
index 804a00db08..ff369e8d65 100644
--- a/paimon-python/pypaimon/tests/native_commit_test.py
+++ b/paimon-python/pypaimon/tests/native_commit_test.py
@@ -478,17 +478,17 @@ def test_callbacks_added_after_construction_use_native(
callback.close.assert_called_once()
-def test_truncate_uses_python(tmp_path):
+def
test_truncate_keeps_python_when_native_publication_is_unavailable(tmp_path):
table = _table(tmp_path)
_seed(table)
- with patch('pypaimon.write.native_commit.create_native_commit') as create:
+ with patch('pypaimon.write.native_commit.create_native_commit',
return_value=None) as create:
for method, args in [('truncate_partitions', ([{'pt': 'a'}],)),
('truncate_table', ())]:
commit = table.new_batch_write_builder().new_commit()
try:
getattr(commit, method)(*args)
finally:
commit.close()
- create.assert_not_called()
+ assert create.call_count == 2
assert _rows(table) == []
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 b415380daa..851d1b7d0b 100644
--- a/paimon-python/pypaimon/tests/native_read_path_completion_test.py
+++ b/paimon-python/pypaimon/tests/native_read_path_completion_test.py
@@ -85,14 +85,76 @@ def
test_copied_branch_does_not_rebind_an_existing_fuse_mount(tmp_path):
assert branch.identifier.get_object_name() == 't$branch_dev'
module = ModuleType('pypaimon_rust.datafusion')
module.PaimonCatalog = Mock()
- native_table = module.PaimonCatalog.return_value.get_table.return_value
+ native_table = Mock(spec=['location', 'branch', 'copy'])
+ module.PaimonCatalog.return_value.get_table.return_value = native_table
native_table.location.return_value = location
native_table.branch.return_value = 'dev'
+ native_table.copy.return_value = native_table
+ with patch.dict('sys.modules', {'pypaimon_rust.datafusion': module}), \
+ patch('pypaimon.read.native_plan._with_native_resolved_schema',
return_value=native_table):
+ assert _native_table(branch) is native_table
+ native_table.copy.assert_called_once_with({'branch': 'dev'})
+
module.PaimonCatalog.return_value.get_table.assert_called_once_with(('default',
't'))
+
+
[email protected]('target_branch', ['main', 'audit'])
[email protected]('mode', ['pvfs', 'raw'])
+def test_copied_fuse_view_reconstructs_original_non_main_binding(tmp_path,
target_branch, mode):
+ from pypaimon.catalog.catalog_context import CatalogContext
+ from pypaimon.catalog.rest.fuse_support import FusePathResolver
+ from pypaimon.catalog.rest.rest_catalog_loader import RESTCatalogLoader
+ from pypaimon.common.identifier import Identifier
+ from pypaimon.common.options.options import Options
+ from pypaimon.filesystem.local_file_io import FuseLocalFileIO
+ from pypaimon.read.native_plan import _native_table
+ from pypaimon.schema.table_schema import TableSchema
+ from pypaimon.table.file_store_table import FileStoreTable
+
+ identifier = Identifier.create('default', 't', branch='dev')
+ options = Options({'fuse.enabled': 'true', 'fuse.root': tmp_path.as_uri(),
+ 'fuse.mode': mode, 'fuse.validation-mode': 'none'})
+ loader = RESTCatalogLoader(CatalogContext.create_from_options(options))
+ location = 'oss://bucket/warehouse/default/t'
+ response = json.dumps({'database': 'default', 'name': 't$branch_dev',
+ 'path': location, 'id': 'original-uuid'})
+ environment = CatalogEnvironment(identifier=identifier,
catalog_loader=loader,
+ uuid='original-uuid',
rest_table_response=response)
+ mount = FusePathResolver(options, None).resolve_local_path(location,
identifier)
+ table = FileStoreTable(FuseLocalFileIO(location, mount), identifier,
location, TableSchema(), environment)
+ table = table.copy_without_time_travel({'branch': target_branch})
+ module = ModuleType('pypaimon_rust.datafusion')
+ module.Table = Mock()
+ native_table = Mock(spec=['copy', 'branch'])
+ native_table.copy.return_value = native_table
+ native_table.branch.return_value = target_branch
+ module.Table.from_rest_response.return_value = native_table
with patch.dict('sys.modules', {'pypaimon_rust.datafusion': module}), \
patch('pypaimon.read.native_plan._with_native_resolved_schema',
return_value=native_table):
- with pytest.raises(RuntimeError, match='different FUSE mount'):
- _native_table(branch)
- module.PaimonCatalog.assert_not_called()
+ assert _native_table(table) is native_table
+ assert module.Table.from_rest_response.call_args[1]['table'] ==
't$branch_dev'
+ native_table.copy.assert_called_once_with({'branch': target_branch})
+
+
+def
test_native_fuse_binding_rejects_unreconstructible_mount_before_catalog_io(tmp_path):
+ from pypaimon.catalog.catalog_context import CatalogContext
+ from pypaimon.catalog.rest.rest_catalog_loader import RESTCatalogLoader
+ from pypaimon.common.identifier import Identifier
+ from pypaimon.filesystem.local_file_io import FuseLocalFileIO
+ from pypaimon.read.native_plan import _native_table
+ from pypaimon.schema.table_schema import TableSchema
+ from pypaimon.table.file_store_table import FileStoreTable
+
+ identifier = Identifier.create('default', 't')
+ loader = RESTCatalogLoader(CatalogContext.create_from_options({
+ 'fuse.enabled': 'true', 'fuse.root': str(tmp_path / 'configured')}))
+ location = 'oss://bucket/warehouse/default/t'
+ environment = CatalogEnvironment(identifier=identifier,
catalog_loader=loader)
+ table = FileStoreTable(FuseLocalFileIO(location, str(tmp_path /
'custom')), identifier,
+ location, TableSchema(), environment)
+ with patch('pypaimon.read.native_plan._load_native_table') as load:
+ with pytest.raises(RuntimeError, match='cannot reconstruct'):
+ _native_table(table)
+ load.assert_not_called()
@pytest.mark.parametrize('mode,branch', [('pvfs', 'main'), ('pvfs', 'dev'),
('raw', 'dev')])
diff --git a/paimon-python/pypaimon/tests/native_read_test.py
b/paimon-python/pypaimon/tests/native_read_test.py
index 41a908465f..17e4d06ebd 100644
--- a/paimon-python/pypaimon/tests/native_read_test.py
+++ b/paimon-python/pypaimon/tests/native_read_test.py
@@ -233,6 +233,7 @@ def test_native_read_propagates_native_fork_safety_error():
split = _Split()
split._native_split = object()
with patch.dict(sys.modules, {'pypaimon_rust': module}), patch(
+ 'pypaimon.read.native_plan.native_runtime_available',
return_value=True), patch(
'pypaimon.read.native_plan.native_read',
side_effect=ForkSafetyError('cannot reuse Jindo after fork')):
with pytest.raises(ForkSafetyError):
diff --git a/paimon-python/pypaimon/tests/native_write_path_completion_test.py
b/paimon-python/pypaimon/tests/native_write_path_completion_test.py
new file mode 100644
index 0000000000..939a7b3183
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_write_path_completion_test.py
@@ -0,0 +1,493 @@
+# 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 write/commit routing and failure ownership contracts."""
+
+import json
+from types import ModuleType
+from unittest.mock import Mock, patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.read.native_plan import native_runtime_available
+from pypaimon.write.native_commit import _rest_catalog_supported
+from pypaimon.write.native_write import NativeTableWrite
+
+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())])
+
+
+def _table(tmp_path, catalog=None, options=None):
+ catalog = catalog or CatalogFactory.create({'warehouse': str(tmp_path /
'warehouse')})
+ catalog.create_database('default', True)
+ catalog.create_table('default.t', Schema.from_pyarrow_schema(
+ _SCHEMA, partition_keys=['pt'], options=dict({
+ 'commit.native.enabled': 'true', 'write.native.enabled': 'false'
+ }, **(options or {}))), False)
+ return catalog.get_table('default.t')
+
+
+def _write(table, rows):
+ 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()
+
+
+def _rows(table):
+ builder = table.new_read_builder()
+ return
sorted(builder.new_read().to_arrow(builder.new_scan().plan().splits()).to_pylist(),
+ key=lambda row: row['id'])
+
+
[email protected]('operation,args', [
+ ('truncate_table', ()), ('truncate_partitions', ([{'pt': 'a'}],)),
+])
+def test_native_truncate_publication_error_never_retries_or_aborts(tmp_path,
operation, args):
+ table = _table(tmp_path)
+ commit = table.new_batch_write_builder().new_commit()
+ native_commit = Mock()
+ cause = RuntimeError('snapshot may have published before response failed')
+ getattr(native_commit, operation).side_effect = cause
+ try:
+ with patch.object(commit, '_prepare_native_commit',
return_value=(native_commit, [])), \
+ patch.object(commit.file_store_commit, 'truncate_table') as
truncate, \
+ patch.object(commit.file_store_commit, 'drop_partitions') as
drop, \
+ patch.object(commit.file_store_commit, 'abort') as abort:
+ with pytest.raises(RuntimeError) as error:
+ getattr(commit, operation)(*args)
+ assert error.value is cause
+ truncate.assert_not_called()
+ drop.assert_not_called()
+ abort.assert_not_called()
+ native_commit.abort.assert_not_called()
+ finally:
+ commit.close()
+
+
+def
test_abort_then_truncate_partitions_then_commit_keeps_batch_lifecycle(tmp_path):
+ table = _table(tmp_path)
+ commit = table.new_batch_write_builder().new_commit()
+ stream, batch = Mock(), Mock()
+ try:
+ with patch('pypaimon.write.native_commit.create_native_commit',
side_effect=[stream, batch]) as create, \
+
patch('pypaimon.write.native_commit.to_native_commit_messages',
return_value=[]), \
+
patch('pypaimon.write.native_commit.native_messages_supported',
return_value=True), \
+ patch.object(commit.file_store_commit, 'commit') as fallback:
+ commit.abort([])
+ commit.truncate_partitions([{'pt': 'a'}])
+ # Java truncatePartitions does not consume the one-shot batch
commit.
+ commit.table = table.copy({'snapshot.ignore-empty-commit':
'false'})
+ commit.commit([])
+ stream.abort.assert_called_once_with([])
+ stream.close.assert_called_once_with()
+ batch.truncate_partitions.assert_called_once_with([{'pt': 'a'}])
+ batch.commit.assert_called_once_with([])
+ assert create.call_args_list[1][1] == {'batch': True}
+ fallback.assert_not_called()
+ with pytest.raises(RuntimeError, match='one-time'):
+ commit.commit([])
+ finally:
+ commit.close()
+
+
+def _bind_fuse(table, root, cached=False):
+ from pypaimon.catalog.rest.fuse_support import FusePathResolver
+ from pypaimon.filesystem.caching_file_io import CachingFileIO
+ from pypaimon.filesystem.local_file_io import FuseLocalFileIO, LocalFileIO
+
+ options = table.catalog_environment.catalog_loader.context().options
+ options.data.update({'fuse.enabled': 'true', 'fuse.root': root.as_uri(),
+ 'fuse.mode': 'pvfs', 'fuse.validation-mode': 'none'})
+ mount = LocalFileIO()._to_file(FusePathResolver(options,
None).resolve_local_path(
+ table.table_path, table.identifier)).absolute()
+ mount.parent.mkdir(parents=True, exist_ok=True)
+ mount.symlink_to(LocalFileIO()._to_file(table.table_path),
target_is_directory=True)
+ io = FuseLocalFileIO(table.table_path, str(mount), options)
+ table.file_io = CachingFileIO(io, Mock()) if cached else io
+ return mount
+
+
[email protected]('cached', [False, True])
+def test_builtin_fuse_is_accepted_without_relaxing_custom_file_io(tmp_path,
native_rest_catalog, cached):
+ from pypaimon.filesystem.local_file_io import FuseLocalFileIO
+
+ table = _table(tmp_path, native_rest_catalog)
+ mount = _bind_fuse(table, tmp_path / 'fuse', cached)
+ assert _rest_catalog_supported(table)
+
+ class CustomFuse(FuseLocalFileIO):
+ pass
+
+ table.file_io = CustomFuse(table.table_path, str(mount))
+ assert not _rest_catalog_supported(table)
+
+
[email protected]('cached', [False, True])
[email protected]('invalid', [None, 'source-uuid', 'source-path',
'target-path', 'target-branch'])
+def test_copied_fuse_write_uses_existing_copy_and_checks_identity(
+ tmp_path, native_rest_catalog, cached, invalid):
+ from pypaimon.write.native_commit import create_native_write_table
+
+ original = _table(tmp_path, native_rest_catalog)
+ _bind_fuse(original, tmp_path / 'fuse', cached)
+ table = original.copy_without_time_travel({'branch': 'dev'})
+ assert table.catalog_environment.uuid is None
+ assert _rest_catalog_supported(table)
+
+ module = ModuleType('pypaimon_rust.datafusion')
+ module.Table = Mock()
+ module.PaimonCatalog = Mock()
+ source = Mock(spec=['location', 'rest_table_uuid', 'copy'])
+ target = Mock(spec=['location', 'branch', 'copy_with_resolved_schema'])
+ source.location.return_value = original.table_path if invalid !=
'source-path' else '/different'
+ source.rest_table_uuid.return_value = (
+ original.catalog_environment.uuid if invalid != 'source-uuid' else
'replacement-uuid')
+ source.copy.return_value = target
+ target.location.return_value = table.table_path if invalid !=
'target-path' else '/different'
+ target.branch.return_value = 'dev' if invalid != 'target-branch' else
'main'
+ target.copy_with_resolved_schema.return_value = target
+ module.PaimonCatalog.return_value.get_table.return_value = source
+ with patch.dict('sys.modules', {'pypaimon_rust.datafusion': module}):
+ result = create_native_write_table(table)
+
module.PaimonCatalog.return_value.get_table.assert_called_once_with(('default',
't'))
+ if invalid is None:
+ assert result is target
+ source.copy.assert_called_once_with({'branch': 'dev'})
+ target.copy_with_resolved_schema.assert_called_once()
+ schema = json.loads(target.copy_with_resolved_schema.call_args[0][0])
+ assert schema['options']['branch'] == 'dev'
+ else:
+ assert result is None
+ target.copy_with_resolved_schema.assert_not_called()
+ if invalid.startswith('source-'):
+ source.copy.assert_not_called()
+
+
[email protected]('identity', [None, '', 123])
+def test_copied_fuse_write_requires_original_rest_uuid(tmp_path,
native_rest_catalog, identity):
+ table = _table(tmp_path, native_rest_catalog)
+ _bind_fuse(table, tmp_path / 'fuse')
+ table = table.copy_without_time_travel({'branch': 'dev'})
+ response = json.loads(table.catalog_environment.rest_table_response)
+ response['id'] = identity
+ table.catalog_environment.rest_table_response = json.dumps(response)
+ assert not _rest_catalog_supported(table)
+
+
+@native
[email protected]('copied_branch', [False, True])
+def test_fuse_native_write_commit_and_truncate_preserve_bound_mount(
+ tmp_path, native_rest_catalog, copied_branch):
+ table = _table(tmp_path, native_rest_catalog, {'write.native.enabled':
'true'})
+ _bind_fuse(table, tmp_path / 'fuse')
+ if copied_branch:
+ table.branch_manager().create_branch('dev')
+ table = table.copy({'branch': 'dev'})
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ assert isinstance(writer, NativeTableWrite)
+ try:
+ writer.write_arrow(pa.Table.from_pylist([{'id': 1, 'pt': 'a'}, {'id':
2, 'pt': 'b'}], schema=_SCHEMA))
+ with patch.object(commit.file_store_commit, 'commit',
side_effect=AssertionError('Python commit')):
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+
+ assert _rows(table) == [{'id': 1, 'pt': 'a'}, {'id': 2, 'pt': 'b'}]
+ commit = table.new_batch_write_builder().new_commit()
+ try:
+ with patch.object(commit.file_store_commit, 'drop_partitions',
side_effect=AssertionError('Python truncate')):
+ commit.truncate_partitions([{'pt': 'a'}])
+ assert _rows(table) == [{'id': 2, 'pt': 'b'}]
+ with patch.object(commit.file_store_commit, 'truncate_table',
side_effect=AssertionError('Python truncate')):
+ commit.truncate_table()
+ assert _rows(table) == []
+ finally:
+ commit.close()
+
+
+def _merge_table(tmp_path, use_native):
+ schema = pa.schema([('id', pa.int32()), ('value', pa.int32()), ('payload',
pa.string())])
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path / 'merge')})
+ catalog.create_database('default', True)
+ catalog.create_table('default.t', Schema.from_pyarrow_schema(schema,
options={
+ 'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+ 'write.native.enabled': str(use_native).lower()}), 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.from_pylist([
+ {'id': 1, 'value': 10, 'payload': 'old'}, {'id': 2, 'value': 20,
'payload': 'keep'}
+ ], schema=schema))
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+ return table
+
+
[email protected]('use_native', [False, pytest.param(True,
marks=native)])
[email protected]('source_key', ['id', 'source_id'])
[email protected]('dictionary_key', [False, True])
+def test_merge_callable_reads_target_source_and_coalesced_keys(tmp_path,
use_native, source_key, dictionary_key):
+ import pyarrow.compute as pc
+ import pypaimon.table.data_evolution_merge_into as merge_impl
+ from pypaimon.table.data_evolution_merge_into import WhenMatched,
WhenNotMatched
+ table = _merge_table(tmp_path, use_native)
+ keys = pa.array([1, 3], pa.int32())
+ source = pa.table({source_key: keys.dictionary_encode() if dictionary_key
else keys,
+ 'delta': pa.array([4, 5], pa.int32()), 'label':
['unused', 'new']})
+ calls = []
+
+ def value(batch):
+ calls.append(batch.column_names)
+ assert batch['s.' + source_key].type == pa.int32()
+ return pc.add(pc.add(batch['t.value'], batch['s.delta']), batch['s.' +
source_key])
+
+ update = table.new_batch_write_builder().new_update()
+ with patch('pypaimon.table.data_evolution_merge_into._build_tables',
+ wraps=merge_impl._build_tables) as python_merge:
+ messages = update.merge_into(
+ source, on={'id': source_key},
+ when_matched=[WhenMatched.update({
+ 'value': value, 'payload': lambda batch:
pc.utf8_upper(batch['t.payload'])})],
+ when_not_matched=[WhenNotMatched({
+ 'value': lambda batch: pc.multiply(batch['s.delta'], 2),
+ 'payload': lambda batch: batch['s.label']})])
+ if use_native:
+ python_merge.assert_not_called()
+ table.new_batch_write_builder().new_commit().commit(messages)
+ assert _rows(table) == [{'id': 1, 'value': 15, 'payload': 'OLD'},
+ {'id': 2, 'value': 20, 'payload': 'keep'},
+ {'id': 3, 'value': 10, 'payload': 'new'}]
+ assert len(calls) == 1
+ assert calls[0] == sorted(['t._ROW_ID', 't.id', 't.value', 't.payload',
+ 's.' + source_key, 's.delta', 's.label'])
+
+
[email protected]('use_native', [False, pytest.param(True,
marks=native)])
+def test_merge_callable_is_lazy_and_failure_preserves_committed_rows(tmp_path,
use_native):
+ from pypaimon.table.data_evolution_merge_into import WhenMatched
+ table = _merge_table(tmp_path, use_native)
+ cause = RuntimeError('callable failure')
+ calls = []
+
+ def fail(batch):
+ calls.append(batch.num_rows)
+ raise cause
+
+ update = table.new_batch_write_builder().new_update()
+ clause = [WhenMatched.update({'value': fail})]
+ assert update.merge_into(pa.table({'id': pa.array([99], pa.int32())}),
on=['id'],
+ when_matched=clause, when_not_matched=[]) == []
+ assert calls == []
+ with pytest.raises(RuntimeError) as error:
+ update.merge_into(pa.table({'id': pa.array([1], pa.int32())}),
on=['id'],
+ when_matched=clause, when_not_matched=[])
+ assert error.value is cause
+ assert calls == [1]
+ assert _rows(table) == [{'id': 1, 'value': 10, 'payload': 'old'},
+ {'id': 2, 'value': 20, 'payload': 'keep'}]
+
+
[email protected]('use_native', [False, pytest.param(True,
marks=native)])
[email protected]('result_kind', ['chunked', 'wrong_length',
'not_arrow'])
+def test_merge_callable_validates_complete_clause_result(tmp_path, use_native,
result_kind):
+ from pypaimon.table.data_evolution_merge_into import WhenMatched
+ table = _merge_table(tmp_path, use_native)
+ source = pa.table({'id': pa.array([1, 2], pa.int32())})
+ calls = []
+
+ def values(batch):
+ calls.append(batch.num_rows)
+ if result_kind == 'chunked':
+ return pa.chunked_array([pa.array([101]), pa.array([202])])
+ if result_kind == 'wrong_length':
+ return pa.array([1])
+ return [101, 202]
+
+ update = table.new_batch_write_builder().new_update()
+ if result_kind == 'chunked':
+ messages = update.merge_into(source, on=['id'],
when_matched=[WhenMatched.update({'value': values})])
+ table.new_batch_write_builder().new_commit().commit(messages)
+ assert [row['value'] for row in _rows(table)] == [101, 202]
+ else:
+ with pytest.raises((ValueError, TypeError)):
+ update.merge_into(source, on=['id'],
when_matched=[WhenMatched.update({'value': values})])
+ assert [row['value'] for row in _rows(table)] == [10, 20]
+ assert calls == [2]
+
+
+def test_merge_callback_aliases_use_coalesced_key_type():
+ from pypaimon.ray.data_evolution_merge_transform import
_merge_callable_input
+ dictionary = pa.array([1, 2], pa.int32()).dictionary_encode()
+ batch = pa.table({'t.id': pa.array([1, 2], pa.int32()), 's.source_id':
dictionary,
+ 's.value': [10, 20], '_scratch': [0, 0]})
+ actual = _merge_callable_input(batch, [('source_id', 'id')])
+ assert actual.column_names == ['s.source_id', 's.value', 't.id']
+ assert actual['s.source_id'].equals(actual['t.id'])
+ # INSERT callbacks retain source layout because no target key exists.
+ unmatched = _merge_callable_input(batch.drop(['t.id']), [('source_id',
'id')])
+ assert unmatched['s.source_id'].type == dictionary.type
+
+
+def test_explicit_callable_read_columns_keep_self_merge_projection_narrow():
+ from pypaimon.ray.data_evolution_merge_into import
_resolve_target_projection
+ from pypaimon.ray.data_evolution_merge_transform import _NormalizedClause
+ clauses = [_NormalizedClause({'value': lambda rows: rows['value']})]
+ fields = ['id', 'value', 'large_blob']
+ assert _resolve_target_projection(clauses, ['_ROW_ID'], ['value'], fields,
+ callable_columns=['value']) == ['value']
+ assert _resolve_target_projection(clauses, ['id'], ['value'], fields) ==
fields
+
+
+def test_consumed_blob_rows_are_released_without_losing_unplanned_inputs():
+ from pypaimon.schema.data_types import AtomicType
+ from pypaimon.table.row.blob import Blob, BlobDescriptor
+ from pypaimon.write.native_blob_rows import NativeBlobRows
+ rows = NativeBlobRows()
+ blob_type = AtomicType('BLOB')
+ known = BlobDescriptor.deserialize(rows.encode(Blob.from_data(b'known'),
blob_type)).uri
+ unknown =
BlobDescriptor.deserialize(rows.encode(Blob.from_data(b'unknown'),
blob_type)).uri
+ reader = rows.create(known)
+ assert rows.create(known) is reader
+ rows.release_consumed()
+ assert not rows._supports_uri(known)
+ assert rows._supports_uri(unknown)
+ with rows.create(unknown).new_input_stream(unknown) as stream:
+ assert stream.read() == b'unknown'
+ rows.clear()
+ assert not rows.readers
+ assert not rows._consumed
+
+
[email protected]('use_native', [False, pytest.param(True,
marks=native)])
+def test_automatic_postpone_plan_counts_all_batches_per_partition(tmp_path,
use_native):
+ from pypaimon.write.native_write import NativePostponeFixedBucketTableWrite
+ schema = pa.schema([('id', pa.int64()), ('pt', pa.string())])
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path / 'postpone')})
+ catalog.create_database('default', True)
+ catalog.create_table('default.t', Schema.from_pyarrow_schema(
+ schema, primary_keys=['id', 'pt'], partition_keys=['pt'], options={
+ 'bucket': '-2', 'write.native.enabled': str(use_native).lower(),
+ 'postpone.target-row-num-per-bucket': '2',
+ 'postpone.batch-write-fixed-bucket.max-parallelism': '3'}), False)
+ table = catalog.get_table('default.t')
+ builder = table.new_postpone_fixed_bucket_write_builder()
+ writer = builder.new_write()
+ if use_native:
+ assert isinstance(writer, NativePostponeFixedBucketTableWrite)
+ try:
+ writer.write_arrow(pa.Table.from_pylist([{'id': 1, 'pt': 'a'}, {'id':
2, 'pt': 'b'}], schema=schema))
+ writer.write_arrow(pa.Table.from_pylist([{'id': n, 'pt': 'a'} for n in
[3, 4, 5, 6]], schema=schema))
+ messages = writer.prepare_commit()
+ assert {m.partition: m.total_buckets for m in messages} == {('a',): 3,
('b',): 1}
+ builder.new_commit().commit(messages)
+ finally:
+ writer.close()
+ assert _rows(table) == [{'id': 1, 'pt': 'a'}, {'id': 2, 'pt': 'b'}] + [
+ {'id': n, 'pt': 'a'} for n in [3, 4, 5, 6]]
+
+
+@native
[email protected]('kind', ['boolean', 'date', 'time', 'timestamp',
'decimal', 'float',
+ 'unicode_int', 'decimal_overflow'])
+def test_native_truncate_partition_specs_use_java_typed_strings(tmp_path,
native_rest_catalog, kind):
+ from datetime import date, datetime, time
+ from decimal import Decimal
+ cases = {
+ 'boolean': (pa.bool_(), [True, False], 'TRUE'),
+ 'date': (pa.date32(), [date(2024, 1, 2), date(2024, 1, 3)],
'2024-01-02'),
+ 'time': (pa.time32('ms'), [time(12, 34, 56, 789000), time(12, 35)],
'12:34:56.789'),
+ 'timestamp': (pa.timestamp('us'), [datetime(2024, 1, 2, 12, 34),
datetime(2024, 1, 3)],
+ '2024-01-02 12:34:00'),
+ 'decimal': (pa.decimal128(6, 2), [Decimal('12.35'), Decimal('13.00')],
'12.345'),
+ 'float': (pa.float32(), [1.2, 2.0], '1.2'),
+ 'unicode_int': (pa.int32(), [12, 13], '12'),
+ 'decimal_overflow': (pa.decimal128(3, 0), [None, Decimal('12')],
'1000'),
+ }
+ data_type, values, text = cases[kind]
+ schema = pa.schema([('id', pa.int64()), ('pt', data_type)])
+ native_rest_catalog.create_table('default.typed',
Schema.from_pyarrow_schema(
+ schema, partition_keys=['pt'], options={
+ 'write.native.enabled': 'false', 'commit.native.enabled':
'true'}), False)
+ table = native_rest_catalog.get_table('default.typed')
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ writer.write_arrow(pa.table({'id': [1, 2], 'pt': values},
schema=schema))
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+ commit = table.new_batch_write_builder().new_commit()
+ try:
+ with patch.object(commit.file_store_commit, 'drop_partitions',
side_effect=AssertionError('Python truncate')):
+ commit.truncate_partitions([{'pt': text}])
+ assert [row['id'] for row in _rows(table)] == [2]
+ finally:
+ commit.close()
+
+
+@native
+def
test_automatic_postpone_blob_rows_keep_unknown_sources_until_prepare(tmp_path):
+ 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)
+ 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)
diff --git a/paimon-python/pypaimon/tests/primary_key_index_definitions_test.py
b/paimon-python/pypaimon/tests/primary_key_index_definitions_test.py
index 7907651a71..eaf7b229e3 100644
--- a/paimon-python/pypaimon/tests/primary_key_index_definitions_test.py
+++ b/paimon-python/pypaimon/tests/primary_key_index_definitions_test.py
@@ -111,15 +111,11 @@ class PrimaryKeyIndexDefinitionsTest(unittest.TestCase):
.with_query("content", "paimon")
.with_limit(2))
- # Exercise native qualification even without an installed binding. A
- # schema-only table cannot delegate, but still selects the PK readers.
- with mock.patch(
-
"pypaimon.table.source.native_search_plan.native_runtime_available",
- return_value=True):
- self.assertIsInstance(vector.new_vector_search_scan(),
PrimaryKeyVectorScan)
- self.assertIsInstance(vector.new_vector_search_read(),
PrimaryKeyVectorRead)
- self.assertIsInstance(full_text.new_full_text_scan(),
PrimaryKeyFullTextScan)
- self.assertIsInstance(full_text.new_full_text_read(),
PrimaryKeyFullTextRead)
+ # A schema-only table selects the PK readers without a native table.
+ self.assertIsInstance(vector.new_vector_search_scan(),
PrimaryKeyVectorScan)
+ self.assertIsInstance(vector.new_vector_search_read(),
PrimaryKeyVectorRead)
+ self.assertIsInstance(full_text.new_full_text_scan(),
PrimaryKeyFullTextScan)
+ self.assertIsInstance(full_text.new_full_text_read(),
PrimaryKeyFullTextRead)
def test_primary_key_full_text_only_supports_fast_mode(self):
fields = [DataField(0, "id", AtomicType("INT")),
diff --git a/paimon-python/pypaimon/tests/ray_data_evolution_merge_into_test.py
b/paimon-python/pypaimon/tests/ray_data_evolution_merge_into_test.py
index 9fa88a19e4..e915fd8a9d 100644
--- a/paimon-python/pypaimon/tests/ray_data_evolution_merge_into_test.py
+++ b/paimon-python/pypaimon/tests/ray_data_evolution_merge_into_test.py
@@ -1789,17 +1789,24 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
self.assertEqual(out['name'], ['old'])
self.assertEqual(out['age'], [99])
- def test_callable_value_rejected(self):
+ def test_callable_values_read_qualified_target_and_source(self):
target = self._create_table()
- with self.assertRaises(TypeError):
- merge_into(
- target=target,
- source=self._source(),
- catalog_options=self.catalog_options,
- on=['id'],
- when_matched=[WhenMatched.update({'name': lambda r: r})],
- num_partitions=_TEST_NUM_PARTITIONS,
- )
+ self._write(target, self._source())
+ merge_into(
+ target=target,
+ source=self._source(),
+ catalog_options=self.catalog_options,
+ on=['id'],
+ when_matched=[WhenMatched.update({
+ 'age': lambda rows: pc.add(rows['t.age'], rows['s.age']),
+ 'name': lambda rows: pc.utf8_upper(rows['t.name']),
+ })],
+ num_partitions=_TEST_NUM_PARTITIONS,
+ )
+ expected = self._source().sort_by('id').to_pydict()
+ actual = self._read_sorted(target)
+ self.assertEqual(actual['age'], [age * 2 for age in expected['age']])
+ self.assertEqual(actual['name'], [name.upper() for name in
expected['name']])
def test_source_missing_referenced_col(self):
target = self._create_table()
@@ -2530,6 +2537,7 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
self.assertEqual(result['num_matched'], 4)
self.assertEqual(self._read_sorted(target)['age'], [99, 99, 99, 99])
+ @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
def test_self_merge_rebases_staged_updates_after_compaction(self):
from pypaimon.data.generic_variant import GenericVariant
from pypaimon.data.variant_path import variant_get, variant_replace
@@ -3507,17 +3515,13 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
def test_self_merge_callable_validation(self):
target = self._create_table()
- with self.assertRaisesRegex(
- ValueError, 'Callable SET values require read_columns'):
- merge_into(
- target=target,
- source=target,
- catalog_options=self.catalog_options,
- on=['_ROW_ID'],
- when_matched=[WhenMatched.update({
- 'age': lambda rows: rows['age'],
- })],
- )
+ from pypaimon.ray.data_evolution_merge_into import _prepare
+ _, _, _, _, ctx = _prepare(
+ target, target, self.catalog_options,
+ [WhenMatched.update({'age': lambda rows: rows['t.age']})],
+ [], ['_ROW_ID'],
+ )
+ self.assertEqual(ctx.read_columns, ())
with self.assertRaisesRegex(
ValueError, 'read_columns requires a callable SET value'):
@@ -3543,20 +3547,6 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
})],
)
- source = self._create_table()
- with self.assertRaisesRegex(
- TypeError, 'callables are only supported for self-merge'):
- merge_into(
- target=target,
- source=source,
- catalog_options=self.catalog_options,
- on=['id'],
- read_columns=['age'],
- when_matched=[WhenMatched.update({
- 'age': lambda rows: rows['age'],
- })],
- )
-
def test_self_merge_callable_rejects_invalid_result(self):
from pypaimon.ray.data_evolution_merge_transform import (
_resolve_spec_array,
diff --git a/paimon-python/pypaimon/tests/write/table_write_test.py
b/paimon-python/pypaimon/tests/write/table_write_test.py
index 576947c526..13a94e9c68 100644
--- a/paimon-python/pypaimon/tests/write/table_write_test.py
+++ b/paimon-python/pypaimon/tests/write/table_write_test.py
@@ -935,7 +935,7 @@ class TableWriteTest(unittest.TestCase):
}, schema=pa_schema)
planner = PostponeBucketPlanner(
- table, known_num_buckets={}, postpone_row_counts={})
+ table, known_num_buckets={})
stats = planner.input_partition_stats(data)
self.assertEqual((1000, 32000), stats[()])
self.assertEqual(2, planner.plan(stats).num_buckets(()))
@@ -956,8 +956,7 @@ class TableWriteTest(unittest.TestCase):
}, schema=self.postpone_pa_schema)
planner = PostponeBucketPlanner(
table,
- known_num_buckets={('known',): 2},
- postpone_row_counts={},
+ known_num_buckets={('known',): 2}
)
self.assertEqual(
@@ -997,7 +996,7 @@ class TableWriteTest(unittest.TestCase):
}, schema=pa_schema)
planner = PostponeBucketPlanner(
- table, known_num_buckets={}, postpone_row_counts={})
+ table, known_num_buckets={})
self.assertEqual((1, 176), planner.input_partition_stats(data)[()])
def test_postpone_size_inference_supports_nested_vector(self):
@@ -1018,7 +1017,7 @@ class TableWriteTest(unittest.TestCase):
}, schema=pa_schema)
planner = PostponeBucketPlanner(
- table, known_num_buckets={}, postpone_row_counts={})
+ table, known_num_buckets={})
self.assertEqual((1, 80), planner.input_partition_stats(data)[()])
def test_postpone_default_bucket_function_matches_java(self):
@@ -1243,7 +1242,7 @@ class TableWriteTest(unittest.TestCase):
self.assertEqual(16, self._read_sorted(table, 'id').num_rows)
- def test_postpone_target_row_num_counts_existing_postpone_rows(self):
+ def test_postpone_target_row_num_uses_only_new_input(self):
from pypaimon.write.postpone_bucket import (
PostponeBucketPlanner,
)
@@ -1257,21 +1256,22 @@ class TableWriteTest(unittest.TestCase):
'postpone.batch-write-fixed-bucket.max-parallelism': 8,
},
)
- append_planner = PostponeBucketPlanner(
- table,
- known_num_buckets={},
- postpone_row_counts={('p',): 3},
- )
+ # Old staged rows must not influence the current batch's bucket count.
+ staged = self.pk_expected.set_column(
+ self.pk_expected.schema.get_field_index('dt'),
self.pk_expected.schema.field('dt'),
+ pa.array(['p'] * self.pk_expected.num_rows))
+ pending = self._commit_arrow(table, staged)
+ self.assertEqual({-2}, {message.bucket for message in pending})
+ append_planner = PostponeBucketPlanner(table)
append_plan = append_planner.plan({('p',): (1, 10)})
- self.assertEqual(2, append_plan.num_buckets(('p',)))
+ self.assertEqual(1, append_plan.num_buckets(('p',)))
overwrite_planner = PostponeBucketPlanner(
table,
- known_num_buckets={},
- postpone_row_counts={('p',): 3},
+ known_num_buckets={}
)
overwrite_plan = overwrite_planner.plan(
- {('p',): (1, 10)}, include_postpone_rows=False
+ {('p',): (1, 10)}
)
self.assertEqual(1, overwrite_plan.num_buckets(('p',)))
diff --git a/paimon-python/pypaimon/write/native_blob_rows.py
b/paimon-python/pypaimon/write/native_blob_rows.py
index 7505e82272..7feb12c08e 100644
--- a/paimon-python/pypaimon/write/native_blob_rows.py
+++ b/paimon-python/pypaimon/write/native_blob_rows.py
@@ -47,6 +47,7 @@ class NativeBlobRows:
def __init__(self, fallback=None):
self.readers = {}
+ self._consumed = set()
self.fallback = fallback
def _supports_uri(self, uri):
@@ -62,9 +63,20 @@ class NativeBlobRows:
def create(self, uri):
if uri in self.readers:
+ self._consumed.add(uri)
return self.readers[uri]
return self.fallback.create(uri)
+ def release_consumed(self):
+ """Release inputs consumed by core, retaining unplanned partition
rows."""
+ for uri in self._consumed:
+ self.readers.pop(uri, None)
+ self._consumed.clear()
+
+ def clear(self):
+ self.readers.clear()
+ self._consumed.clear()
+
def to_batch(self, table, rows, names):
"""Transport existing row values; core owns physical input
validation."""
descriptor_fields = table.options.blob_descriptor_fields()
diff --git a/paimon-python/pypaimon/write/native_commit.py
b/paimon-python/pypaimon/write/native_commit.py
index 623d067daf..56d6d3db64 100644
--- a/paimon-python/pypaimon/write/native_commit.py
+++ b/paimon-python/pypaimon/write/native_commit.py
@@ -17,6 +17,8 @@
"""Optional native commits using the Java CommitMessage v14 bridge."""
+import json
+
from pypaimon.common.json_util import JSON
from pypaimon.read.native_plan import (
native_runtime_available, _catalog_context_options, _catalog_metastore,
_option_value_to_string,
@@ -45,7 +47,7 @@ def native_messages_supported(table, messages) -> bool:
return True
-def create_native_commit(table, commit_user, overwrite_partition=None):
+def create_native_commit(table, commit_user, overwrite_partition=None, *,
batch=False):
"""Return a native committer only when its publication protocol matches
Python."""
if not _rest_catalog_supported(table) or not native_runtime_available():
return None
@@ -59,10 +61,11 @@ def create_native_commit(table, commit_user,
overwrite_partition=None):
native_table = create_native_write_table(table)
if native_table is None:
return None
- if overwrite_partition is not None:
- return (native_table.new_batch_write_builder()
- ._with_commit_user(commit_user)
- .with_overwrite(overwrite_partition).new_commit())
+ if batch or overwrite_partition is not None:
+ builder =
native_table.new_batch_write_builder()._with_commit_user(commit_user)
+ if overwrite_partition is not None:
+ builder = builder.with_overwrite(overwrite_partition)
+ return builder.new_commit()
# Append commits use the stream committer with the Python writer's
identity;
# Python enforces each mode's lifecycle and empty-commit rules.
return
native_table.new_stream_write_builder().with_commit_user(commit_user).new_commit()
@@ -72,7 +75,7 @@ def _rest_catalog_supported(table):
from pypaimon.catalog.catalog_environment import CatalogEnvironment
from pypaimon.catalog.rest.rest_token_file_io import RESTTokenFileIO
from pypaimon.filesystem.caching_file_io import CachingFileIO
- from pypaimon.filesystem.local_file_io import LocalFileIO
+ from pypaimon.filesystem.local_file_io import FuseLocalFileIO, LocalFileIO
from pypaimon.filesystem.oss_file_io import OssFileIO
from pypaimon.filesystem.pyarrow_file_io import PyArrowFileIO
from pypaimon.filesystem.resolving_file_io import ResolvingFileIO
@@ -86,26 +89,47 @@ def _rest_catalog_supported(table):
file_io = file_io._delegate
return (type(table) is FileStoreTable
and type(environment) is CatalogEnvironment
- and type(file_io) in (LocalFileIO, PyArrowFileIO, OssFileIO,
+ and type(file_io) in (LocalFileIO, FuseLocalFileIO, PyArrowFileIO,
OssFileIO,
ResolvingFileIO, RESTTokenFileIO)
and environment.supports_version_management
- and environment.uuid is not None
+ and _rest_source_uuid(table) is not None
and context is not None
and context.options is not None
and all(getattr(context, attr, None) is None for attr in (
'hadoop_conf', 'prefer_io_loader', 'fallback_io_loader')))
+def _rest_source_uuid(table):
+ """Identity of the catalog table used to reconstruct a copied FUSE view."""
+ if table.catalog_environment.uuid is not None:
+ return table.catalog_environment.uuid
+ from pypaimon.read.native_plan import _resolved_rest_table_response,
_validate_native_fuse_binding
+ try:
+ original = _validate_native_fuse_binding(table)
+ if original is None or original == table.identifier:
+ return None
+ response = _resolved_rest_table_response(table, original)
+ uuid = json.loads(response).get('id') if response is not None else None
+ return uuid if isinstance(uuid, str) and uuid else None
+ except (ValueError, RuntimeError):
+ return None
+
+
def _native_rest_table(table, schema_json):
from pypaimon_rust.datafusion import PaimonCatalog as NativeCatalog
+ from pypaimon.read.native_plan import _validate_native_fuse_binding
+ identifier = _validate_native_fuse_binding(table) or table.identifier
catalog_options = _catalog_context_options(table)
catalog_options['metastore'] = 'rest'
native_table = NativeCatalog(catalog_options,
config_required=False).get_table((
- table.identifier.get_database_name(),
table.identifier.get_object_name()))
+ identifier.get_database_name(), identifier.get_object_name()))
if (native_table.location() != table.table_path
- or native_table.rest_table_uuid() != table.catalog_environment.uuid
- or native_table.branch() != table.current_branch()):
+ or native_table.rest_table_uuid() != _rest_source_uuid(table)):
+ return None
+ if identifier != table.identifier:
+ native_table = native_table.copy({'branch': table.current_branch()})
+ if native_table.location() != table.table_path or native_table.branch() !=
table.current_branch():
return None
return native_table.copy_with_resolved_schema(schema_json)
diff --git a/paimon-python/pypaimon/write/native_merge_into.py
b/paimon-python/pypaimon/write/native_merge_into.py
index b5f7c5236d..3338ccefd3 100644
--- a/paimon-python/pypaimon/write/native_merge_into.py
+++ b/paimon-python/pypaimon/write/native_merge_into.py
@@ -30,6 +30,15 @@ from pypaimon.write.native_update import (
from pypaimon.write.native_write import _native_partition_types_supported
+def _callable_assignment(function, on_pairs):
+ from pypaimon.ray.data_evolution_merge_transform import
_merge_callable_input
+
+ def evaluate(batch):
+ return function(_merge_callable_input(batch, on_pairs))
+
+ return evaluate
+
+
def create_native_merge_into(table, source, on, matched, not_matched,
commit_user, commit_identifier):
from pypaimon.table.data_evolution_merge_into import _is_self_merge,
_is_table_like, _prepare
from pypaimon.ray.data_evolution_merge_into import _normalize_on,
_union_update_cols
@@ -90,8 +99,6 @@ def create_native_merge_into(table, source, on, matched,
not_matched, commit_use
native_source = _with_native_read_snapshot(source, native_source)
source, matched, not_matched, context = _prepare(
table, source, list(matched), list(not_matched), on,
source_schema=source_schema)
- if any(callable(value) for clause in matched + not_matched for value in
clause.spec.values()):
- return None
if not _native_update_columns_supported(table,
_union_update_cols(matched)):
return None
for target, origin in zip(target_keys, source_keys):
@@ -109,6 +116,9 @@ def create_native_merge_into(table, source, on, matched,
not_matched, commit_use
values.append((name, 'target', value.column))
elif isinstance(value, LiteralValue):
values.append((name, 'literal', value.value))
+ elif callable(value) and not isinstance(value, type):
+ values.append((name, 'callable', _callable_assignment(
+ value, list(zip(source_keys, target_keys)))))
else:
return None
condition = None if clause.condition is None else
dict(sql=rewrite_condition(clause.condition))
diff --git a/paimon-python/pypaimon/write/native_update.py
b/paimon-python/pypaimon/write/native_update.py
index 99c3aa49c8..da7fb72c90 100644
--- a/paimon-python/pypaimon/write/native_update.py
+++ b/paimon-python/pypaimon/write/native_update.py
@@ -437,7 +437,7 @@ class NativeTableUpsert:
self.writer.upsert_by_arrow_with_key(self.data, self.keys))
finally:
if self.blob_rows is not None:
- self.blob_rows.readers.clear()
+ self.blob_rows.clear()
class NativePredicateTableUpdate:
diff --git a/paimon-python/pypaimon/write/native_write.py
b/paimon-python/pypaimon/write/native_write.py
index 57dce53372..e26a4d87c3 100644
--- a/paimon-python/pypaimon/write/native_write.py
+++ b/paimon-python/pypaimon/write/native_write.py
@@ -104,6 +104,7 @@ class NativeTableWrite:
self._blob_uri_reader_factory = None
self._restore_snapshot_id = restore_snapshot_id
self._blob_rows = None
+ self._defer_blob_input = False
if any(is_blob_file_field(field) for field in
table.table_schema.fields):
from pypaimon.write.native_blob_rows import NativeBlobRows
self._blob_rows = NativeBlobRows()
@@ -260,7 +261,9 @@ class NativeTableWrite:
# Core externalizes Blob values during write, before buffering PK
# records. Their live Python objects need not survive the call.
if self._blob_rows is not None:
- self._blob_rows.readers.clear()
+ self._blob_rows.release_consumed()
+ if not self._defer_blob_input:
+ self._blob_rows.clear()
def prepare_commit(self, commit_identifier=None):
if self._python_writer is not None:
@@ -274,7 +277,11 @@ class NativeTableWrite:
else:
if commit_identifier is not None:
raise TypeError('BatchTableWrite.prepare_commit accepts no
identifier')
- messages = self._native_writer.prepare_commit()
+ try:
+ messages = self._native_writer.prepare_commit()
+ finally:
+ if self._blob_rows is not None and self._defer_blob_input:
+ self._blob_rows.clear()
return from_native_commit_messages(self.table, messages)
def close(self):
@@ -284,7 +291,7 @@ class NativeTableWrite:
self._native_writer.close()
self._native_writer = None
if self._blob_rows is not None:
- self._blob_rows.readers.clear()
+ self._blob_rows.clear()
def abort(self):
if self._python_writer is not None:
@@ -306,6 +313,9 @@ class NativePostponeFixedBucketTableWrite(NativeTableWrite):
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
+ # Automatic planning stages input until all partition sizes are known.
+ self._defer_blob_input = (bucket_plan is None
+ and
table.options.postpone_default_bucket_num() is None)
def _new_python_writer(self):
from pypaimon.write.postpone_batch_table_write import
PostponeFixedBucketBatchTableWrite
diff --git a/paimon-python/pypaimon/write/postpone_batch_table_write.py
b/paimon-python/pypaimon/write/postpone_batch_table_write.py
index f15f0bafb5..e1171f76ea 100644
--- a/paimon-python/pypaimon/write/postpone_batch_table_write.py
+++ b/paimon-python/pypaimon/write/postpone_batch_table_write.py
@@ -50,14 +50,10 @@ 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
+ 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,
@@ -190,7 +186,6 @@ class PostponeFixedBucketBatchTableWrite(BatchTableWrite):
self._bucket_plan = self._planner.plan(
partition_stats,
- include_postpone_rows=self.static_partition is None,
)
self.row_key_extractor.with_bucket_plan(self._bucket_plan)
inputs = self._pending_inputs
diff --git a/paimon-python/pypaimon/write/postpone_bucket.py
b/paimon-python/pypaimon/write/postpone_bucket.py
index ca6c9a8100..d2dac9a6e9 100644
--- a/paimon-python/pypaimon/write/postpone_bucket.py
+++ b/paimon-python/pypaimon/write/postpone_bucket.py
@@ -220,7 +220,6 @@ class PostponeBucketPlanner:
self,
table,
known_num_buckets=None,
- postpone_row_counts=None,
):
options = table.options
if options.bucket() != BucketMode.POSTPONE_BUCKET.value:
@@ -273,13 +272,8 @@ class PostponeBucketPlanner:
self._partition_keys = list(table.partition_keys)
self._field_dict = dict(table.field_dict)
if known_num_buckets is None:
- known_num_buckets, loaded_postpone_counts = (
- self._load_bucket_metadata(table)
- )
- if postpone_row_counts is None:
- postpone_row_counts = loaded_postpone_counts
+ known_num_buckets = self._load_bucket_metadata(table)
self._known_num_buckets = dict(known_num_buckets)
- self._postpone_row_counts = dict(postpone_row_counts or {})
@staticmethod
def _load_bucket_metadata(table):
@@ -291,14 +285,9 @@ class PostponeBucketPlanner:
)
known = {}
- postpone_counts = {}
for entry in entries:
partition = tuple(entry.partition.values)
- if entry.bucket == BucketMode.POSTPONE_BUCKET.value:
- postpone_counts[partition] = (
- postpone_counts.get(partition, 0) + entry.file.row_count
- )
- elif entry.bucket >= 0 and entry.total_buckets > 0:
+ if entry.bucket >= 0 and entry.total_buckets > 0:
previous = known.get(partition)
if previous is not None and previous != entry.total_buckets:
raise RuntimeError(
@@ -306,7 +295,7 @@ class PostponeBucketPlanner:
.format(partition, previous, entry.total_buckets)
)
known[partition] = entry.total_buckets
- return known, postpone_counts
+ return known
def current_plan(self) -> PostponeBucketPlan:
return PostponeBucketPlan(self._known_num_buckets)
@@ -340,7 +329,6 @@ class PostponeBucketPlanner:
def plan(
self,
partition_stats: Dict[Tuple, Tuple[int, int]],
- include_postpone_rows: bool = True,
) -> PostponeBucketPlan:
for partition, (row_count, data_size) in partition_stats.items():
partition = tuple(partition)
@@ -349,13 +337,8 @@ class PostponeBucketPlanner:
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
- else 0
- )
if self.target_row_num_per_bucket is not None:
- total_rows = row_count + postpone_rows
+ total_rows = row_count
num_buckets = max(
1,
_ceil_div(
@@ -363,15 +346,12 @@ class PostponeBucketPlanner:
)
else:
estimated_size = data_size
- if postpone_rows and row_count:
- estimated_size = _ceil_div(
- data_size * (row_count + postpone_rows), row_count)
num_buckets = max(
1,
_ceil_div(
estimated_size, self.target_size_per_bucket),
)
self._known_num_buckets[partition] = min(
- num_buckets, self.max_num_buckets
+ 1 << (num_buckets - 1).bit_length(), self.max_num_buckets
)
return self.current_plan()
diff --git a/paimon-python/pypaimon/write/ray_datasink.py
b/paimon-python/pypaimon/write/ray_datasink.py
index edfdcb90ba..af8322cb40 100644
--- a/paimon-python/pypaimon/write/ray_datasink.py
+++ b/paimon-python/pypaimon/write/ray_datasink.py
@@ -346,9 +346,6 @@ def write_paimon_dataset(
)
plan = planner.plan(
partition_stats,
- include_postpone_rows=not (
- overwrite or static_partition is not None
- ),
)
return _write_primary_key_groups(
dataset,
diff --git a/paimon-python/pypaimon/write/table_commit.py
b/paimon-python/pypaimon/write/table_commit.py
index 9819141476..8ae28d33a9 100644
--- a/paimon-python/pypaimon/write/table_commit.py
+++ b/paimon-python/pypaimon/write/table_commit.py
@@ -53,6 +53,7 @@ class TableCommit:
self._commit_callbacks: List[CommitCallback] = []
self._native_commit = None
+ self._native_commit_is_batch = False
self._native_callbacks_registered = False
self.file_store_commit = FileStoreCommit(
snapshot_commit, table, commit_user,
@@ -111,11 +112,14 @@ class TableCommit:
native, messages = prepared
# Mutation is deliberately outside the fallback boundary:
# an exception can mean the snapshot was already published.
- native.commit(commit_identifier, messages, **native_kwargs)
+ if self._native_commit_is_batch:
+ native.commit(messages, **native_kwargs)
+ else:
+ native.commit(commit_identifier, messages, **native_kwargs)
return
self.file_store_commit.commit(**commit_kwargs)
- def _prepare_native_commit(self, messages):
+ def _prepare_native_commit(self, messages, *, batch=False):
if not self.table.options.native_commit_enabled():
return None
try:
@@ -124,9 +128,17 @@ class TableCommit:
to_native_commit_messages)
if not native_messages_supported(self.table, messages):
return None
- if self._native_commit is None:
- self._native_commit = create_native_commit(
- self.table, self.commit_user, self.overwrite_partition)
+ if self._native_commit is None or (batch and not
self._native_commit_is_batch):
+ native = create_native_commit(
+ self.table, self.commit_user, self.overwrite_partition,
+ **({'batch': True} if batch else {}))
+ if native is None:
+ return None
+ if self._native_commit is not None:
+ self._native_commit.close()
+ self._native_commit = native
+ self._native_commit_is_batch = batch or
self.overwrite_partition is not None
+ self._native_callbacks_registered = False
if self._native_commit is None:
return None
native_messages = to_native_commit_messages(self.table, messages)
@@ -185,10 +197,17 @@ class BatchTableCommit(TableCommit):
def truncate_table(self) -> None:
"""Truncate the entire table, deleting all data."""
self._check_committed()
+ prepared = self._prepare_native_commit([], batch=True)
+ if prepared is not None:
+ prepared[0].truncate_table()
+ return
self.file_store_commit.truncate_table(BATCH_COMMIT_IDENTIFIER)
def truncate_partitions(self, partitions: List[Dict[str, str]]) -> None:
- self._check_committed()
+ prepared = self._prepare_native_commit([], batch=True)
+ if prepared is not None:
+ prepared[0].truncate_partitions(partitions)
+ return
self.file_store_commit.drop_partitions(partitions,
BATCH_COMMIT_IDENTIFIER)
def _check_committed(self):