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 b07f7ffbb2 [python] Add Java-compatible CommitMessage serialization
and native commit (#10081)
b07f7ffbb2 is described below
commit b07f7ffbb21ffdc2853bec6757545e8d2b9ea092
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Sep 22 17:36:31 2026 +0800
[python] Add Java-compatible CommitMessage serialization and native commit
(#10081)
---
paimon-python/README.md | 37 +++
.../pypaimon/common/options/core_options.py | 11 +
.../tests/commit_message_serializer_test.py | 113 +++++++
.../pypaimon/tests/file_store_commit_test.py | 47 +++
paimon-python/pypaimon/tests/native_commit_test.py | 335 +++++++++++++++++++++
paimon-python/pypaimon/tests/table_commit_test.py | 3 +
paimon-python/pypaimon/write/commit_message.py | 13 +-
.../pypaimon/write/commit_message_serializer.py | 265 ++++++++++++++++
paimon-python/pypaimon/write/file_store_commit.py | 56 +++-
paimon-python/pypaimon/write/native_commit.py | 102 +++++++
paimon-python/pypaimon/write/table_commit.py | 42 ++-
11 files changed, 1014 insertions(+), 10 deletions(-)
diff --git a/paimon-python/README.md b/paimon-python/README.md
index a711f71345..8e45ba7153 100644
--- a/paimon-python/README.md
+++ b/paimon-python/README.md
@@ -119,6 +119,43 @@ table = table.copy({"parquet.filter.columnindex.enabled":
"false"})
Unsupported reads use the normal path. Reading fewer bytes may require more
object-store requests.
+# Native commit
+
+PyPaimon can submit append commits through the optional `pypaimon-rust`
+runtime. Enable it independently of native planning and reading:
+
+```python
+native_table = table.copy({"commit.native.enabled": "true"})
+builder = native_table.new_batch_write_builder()
+writer, commit = builder.new_write(), builder.new_commit()
+try:
+ writer.write_arrow(data)
+ commit.commit(writer.prepare_commit())
+finally:
+ writer.close()
+ commit.close()
+```
+
+The Python writer still produces files. Its commit messages cross the Java v14
+wire format into `CommitMessage.deserialize()` and are committed by Rust. Batch
+and stream append commits retain the Python builder's commit user, identifier,
+empty-commit option, and batch one-shot lifecycle. Explicit abort also supports
+native cleanup of uncommitted files.
+
+This requires a runtime containing the commit bindings merged in
+[paimon-rust #912](https://github.com/apache/paimon-rust/pull/912). Older or
missing
+runtimes automatically use Python. The current native route supports
main-branch
+tables using filesystem/JDBC catalogs or `FileStoreTable.from_path()` with
+standard FileIO. Overwrite, truncate, REST/catalog-managed publication, custom
+FileIO/environments, commit callbacks, and snapshot properties use Python.
+Data-evolution updates that need Python's row-id conflict rewriting also retain
+the Python path. Compact increments remain unsupported by both committers.
+
+Fallback is limited to capability checks, table construction and message
+conversion before a native mutation starts. A native commit error propagates;
+the adapter neither retries it through Python nor aborts files, since the
+snapshot may already have been published. The option is disabled by default.
+
# Native scan planning
PyPaimon can plan splits with the optional `pypaimon-rust` package. Planning
and
diff --git a/paimon-python/pypaimon/common/options/core_options.py
b/paimon-python/pypaimon/common/options/core_options.py
index 54bd771310..b329f62d19 100644
--- a/paimon-python/pypaimon/common/options/core_options.py
+++ b/paimon-python/pypaimon/common/options/core_options.py
@@ -677,6 +677,14 @@ class CoreOptions:
"routes fall back to pypaimon.")
)
+ COMMIT_NATIVE_ENABLED: ConfigOption[bool] = (
+ ConfigOptions.key("commit.native.enabled")
+ .boolean_type()
+ .default_value(False)
+ .with_description("Commit append messages via pypaimon_rust.
Unsupported "
+ "operations use Python before any native commit is
attempted.")
+ )
+
CHANGELOG_PRODUCER: ConfigOption[ChangelogProducer] = (
ConfigOptions.key("changelog-producer")
.enum_type(ChangelogProducer)
@@ -1592,6 +1600,9 @@ class CoreOptions:
def native_read_enabled(self, default=None):
return self.options.get(CoreOptions.READ_NATIVE_ENABLED, default)
+ def native_commit_enabled(self, default=None):
+ return self.options.get(CoreOptions.COMMIT_NATIVE_ENABLED, default)
+
def changelog_producer(self, default=None):
return self.options.get(CoreOptions.CHANGELOG_PRODUCER, default)
diff --git a/paimon-python/pypaimon/tests/commit_message_serializer_test.py
b/paimon-python/pypaimon/tests/commit_message_serializer_test.py
new file mode 100644
index 0000000000..45ef8e3bef
--- /dev/null
+++ b/paimon-python/pypaimon/tests/commit_message_serializer_test.py
@@ -0,0 +1,113 @@
+# 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.
+
+import base64
+import unittest
+
+from pypaimon.data.timestamp import Timestamp
+from pypaimon.globalindex.global_index_meta import GlobalIndexMeta
+from pypaimon.index.deletion_vector_meta import DeletionVectorMeta
+from pypaimon.index.index_file_meta import IndexFileMeta
+from pypaimon.manifest.index_manifest_entry import IndexManifestEntry
+from pypaimon.manifest.schema.data_file_meta import DataFileMeta
+from pypaimon.manifest.schema.simple_stats import SimpleStats
+from pypaimon.schema.data_types import AtomicType, DataField
+from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.write.commit_message import CommitMessage
+from pypaimon.write.commit_message_serializer import (
+ deserialize_commit_message, serialize_commit_message)
+
+
+# Captured from Java CommitMessageSerializer v14 with an empty partition,
+# bucket 3 and checkFromSnapshot 7. The second body has totalBuckets 5 and a
+# data-increment IndexFileMeta("I", "index", 9, 2).
+_JAVA_EMPTY = base64.b64decode(
+
'AAAADAAAAAAAAAAAAAAAAAAAAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAABw==')
+_JAVA_INDEXED = base64.b64decode(
+
'AAAADAAAAAAAAAAAAAAAAAAAAAMBAAAABQAAAAAAAAAAAAAAAAAAAAEAAABAAHAAAAAAAABJAAAAAAAAgWluZGV4AACFCQAA'
+
'AAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAABw==')
+_JAVA_RICH_INDEXED = base64.b64decode(
+
'AAAADAAAAAAAAAAAAAAAAAAAAAMBAAAABQAAAAAAAAAAAAAAAAAAAAEAAADgAAAAAAAAAABEVgAAAAAAgmluZGV4AACFCQAA'
+
'AAAAAAACAAAAAAAAAEgAAABAAAAACwAAAIgAAABIAAAAmAAAAAEAAAAAAAAAOAAAABAAAAAAAAAAAAAAAAwAAAAoAAAAAwAA'
+
'AAAAAAAMAAAAAAAAAAIAAAAAAAAAZGF0YS5wYXJxdWV0AAAAAGZpbGU6L2luZGV4AAAAAAAAAAAAAAAAAAoAAAAAAAAAFAAA'
+
'AAAAAAADAAAAAAAAABAAAAA4AAAAAQIAAAAAAIIDAAAAAAAAgQIAAAAAAAAAAQAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
+ 'AAAAAAABAAAAAAAAAAc=')
+
+
+class CommitMessageSerializerTest(unittest.TestCase):
+
+ def test_java_v14_golden(self):
+ for payload in (_JAVA_EMPTY, _JAVA_INDEXED, _JAVA_RICH_INDEXED):
+ message = deserialize_commit_message(payload, [])
+ self.assertEqual(serialize_commit_message(message, []), payload)
+ self.assertEqual(message.check_from_snapshot, 7)
+ indexed = deserialize_commit_message(_JAVA_INDEXED, [])
+ self.assertEqual(indexed.total_buckets, 5)
+ self.assertEqual(indexed.index_adds[0].index_file.file_name, 'index')
+ self.assertEqual(serialize_commit_message(
+ CommitMessage((), 3, [], check_from_snapshot=7), []), _JAVA_EMPTY)
+
+ def test_rich_index_java_golden(self):
+ meta = IndexFileMeta(
+ 'DV', 'index', 9, 2,
+ {'data.parquet': DeletionVectorMeta('data.parquet', 3, 12, 2)},
+ 'file:/index', GlobalIndexMeta(10, 20, 3, [1, 4], b'\x01\x02',
b'\x03'))
+ entry = IndexManifestEntry(0, GenericRow([], []), 3, meta)
+ message = CommitMessage(
+ (), 3, [], check_from_snapshot=7, index_adds=[entry],
total_buckets=5)
+ self.assertEqual(serialize_commit_message(message, []),
_JAVA_RICH_INDEXED)
+
+ def test_files_and_compaction_stay_separate(self):
+ stats = SimpleStats.empty_stats()
+ file = DataFileMeta(
+ 'data.parquet', 100, 4, GenericRow([], []), GenericRow([], []),
+ stats, stats, 1, 4, 2, 0, [], Timestamp(0), first_row_id=10,
+ write_cols_sequences=[4])
+ index = IndexManifestEntry(
+ 0, GenericRow([], []), 3, IndexFileMeta('I', 'index', 9, 2))
+ message = CommitMessage(
+ (), 3, [file], check_from_snapshot=7,
+ compact_before=[file], compact_after=[file],
+ compact_index_adds=[index])
+ decoded = deserialize_commit_message(serialize_commit_message(message,
[]), [])
+ self.assertEqual(decoded.new_files[0].file_name, 'data.parquet')
+ self.assertEqual(decoded.new_files[0].first_row_id, 10)
+ self.assertEqual(decoded.new_files[0].write_cols_sequences, [4])
+ self.assertEqual(len(decoded.compact_before), 1)
+ self.assertEqual(len(decoded.compact_after), 1)
+ self.assertEqual(decoded.compact_index_adds[0].index_file.file_name,
'index')
+
+ def test_bad_version_and_truncation(self):
+ with self.assertRaises(ValueError):
+ deserialize_commit_message(_JAVA_EMPTY, [], version=13)
+ with self.assertRaises(ValueError):
+ deserialize_commit_message(_JAVA_EMPTY[:-1], [])
+ with self.assertRaises(ValueError):
+ deserialize_commit_message(_JAVA_EMPTY + b'\x00', [])
+
+ def test_primary_key_files_require_key_fields_for_decode(self):
+ key_fields = [DataField(0, 'k', AtomicType('INT'))]
+ stats = SimpleStats.empty_stats()
+ file = DataFileMeta(
+ 'pk.parquet', 10, 1, GenericRow([1], key_fields),
+ GenericRow([1], key_fields), stats, stats, 0, 0, 1, 0, [])
+ payload = serialize_commit_message(CommitMessage((), 0, [file]), [])
+ with self.assertRaisesRegex(ValueError, 'key_fields are required'):
+ deserialize_commit_message(payload, [])
+ decoded = deserialize_commit_message(payload, [], key_fields)
+ self.assertEqual(decoded.new_files[0].min_key.values, [1])
+ self.assertEqual(serialize_commit_message(decoded, []), payload)
diff --git a/paimon-python/pypaimon/tests/file_store_commit_test.py
b/paimon-python/pypaimon/tests/file_store_commit_test.py
index 649812d7fd..4f8778a23a 100644
--- a/paimon-python/pypaimon/tests/file_store_commit_test.py
+++ b/paimon-python/pypaimon/tests/file_store_commit_test.py
@@ -38,11 +38,55 @@ from pypaimon.write.file_store_commit import (
RollbackRetryResult,
RewriteResult,
_abort_commit_messages,
+ _reject_compact_increment,
+ _row_id_check_from_messages,
)
+class TestRowIdCheckFromMessages(unittest.TestCase):
+
+ def test_minimum_baseline_and_invalid_messages(self):
+ tagged = CommitMessage((), 0, [], check_from_snapshot=7)
+ self.assertEqual(_row_id_check_from_messages([tagged, tagged]), 7)
+ self.assertIsNone(_row_id_check_from_messages([CommitMessage((), 0,
[])]))
+
+ newer = CommitMessage((), 0, [], check_from_snapshot=8)
+ for messages in ([tagged, newer], [newer, tagged]):
+ self.assertEqual(_row_id_check_from_messages(messages), 7)
+ with self.assertRaisesRegex(ValueError, 'Invalid row-id check
snapshot'):
+ _row_id_check_from_messages([
+ CommitMessage((), 0, [], check_from_snapshot=-1)])
+ with self.assertRaisesRegex(ValueError, 'missing its check-from
snapshot'):
+ _row_id_check_from_messages([
+ tagged, CommitMessage((), 0, [Mock(first_row_id=1)])])
+
+ def test_compaction_lists_are_not_flattened_into_append(self):
+ message = CommitMessage(
+ (), 0, [], compact_before=[Mock(file_name='before')],
+ compact_after=[Mock(file_name='after')],
+ compact_changelog_files=[Mock(file_name='changelog')])
+ with self.assertRaisesRegex(NotImplementedError, 'separate COMPACT
snapshot'):
+ _reject_compact_increment([message])
+ _reject_compact_increment([CommitMessage((), 0, [])])
+
+ def test_overwrite_validates_message_baseline(self):
+ commit = FileStoreCommit.__new__(FileStoreCommit)
+ with self.assertRaisesRegex(ValueError, 'Invalid row-id check
snapshot'):
+ commit.overwrite(
+ None, [CommitMessage((), 0, [], check_from_snapshot=-1)], 1)
+
+
class TestAbortCommitMessages(unittest.TestCase):
+ def test_reconstructs_local_path_after_wire_decode(self):
+ table = Mock()
+ table.path_factory.return_value.bucket_path.return_value =
'/table/p=1/bucket-0'
+ file = Mock(file_name='data.parquet', external_path=None,
file_path=None)
+ message = CommitMessage((1,), 0, [file])
+ _abort_commit_messages(table, [message])
+ table.file_io.delete_quietly.assert_called_once_with(
+ '/table/p=1/bucket-0/data.parquet')
+
def test_index_path_failure_does_not_escape_abort(self):
table = Mock()
table.path_factory.side_effect = RuntimeError("path lookup failed")
@@ -51,6 +95,9 @@ class TestAbortCommitMessages(unittest.TestCase):
new_files=[],
changelog_files=[],
index_adds=[Mock(index_file=index_file)],
+ compact_after=[],
+ compact_changelog_files=[],
+ compact_index_adds=[],
)
with self.assertLogs(
diff --git a/paimon-python/pypaimon/tests/native_commit_test.py
b/paimon-python/pypaimon/tests/native_commit_test.py
new file mode 100644
index 0000000000..fde7def37c
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_commit_test.py
@@ -0,0 +1,335 @@
+# 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.
+
+from unittest.mock import Mock, patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.catalog.catalog_environment import CatalogEnvironment
+from pypaimon.common.identifier import Identifier
+from pypaimon.common.options.core_options import CoreOptions
+from pypaimon.common.options.options import Options
+from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
+from pypaimon.table.file_store_table import FileStoreTable
+from pypaimon.write.commit_message import CommitMessage
+from pypaimon.write.native_commit import (
+ create_native_commit, native_commit_available, native_messages_supported)
+
+
+requires_native = pytest.mark.skipif(
+ not native_commit_available(), reason='pypaimon-rust CommitMessage/commit
API required')
+
+
+def _table(tmp_path, mode='append', backend='filesystem'):
+ options = {'warehouse': str(tmp_path / 'warehouse')}
+ if backend == 'jdbc':
+ options.update({'metastore': 'jdbc', 'uri': 'jdbc:sqlite:' +
str(tmp_path / 'catalog.db')})
+ catalog = CatalogFactory.create(options)
+ catalog.create_database('default', True)
+ options = {'file.format': 'parquet', 'commit.native.enabled': 'true'}
+ if mode == 'pk':
+ options['bucket'] = '1'
+ elif mode == 'de':
+ options.update({'data-evolution.enabled': 'true',
'row-tracking.enabled': 'true'})
+ catalog.create_table('default.t', Schema.from_pyarrow_schema(
+ pa.schema([('id', pa.int64()), ('pt', pa.string())]),
+ options=options, primary_keys=['id'] if mode == 'pk' else [],
+ partition_keys=[] if mode == 'pk' else ['pt']), False)
+ table = catalog.get_table('default.t')
+ if backend == 'path':
+ table = FileStoreTable.from_path(table.table_path)
+ if backend == 'jdbc':
+ catalog.close()
+ return table
+
+
+def _prepare(builder, rows, identifier=None):
+ writer = builder.new_write()
+ try:
+ writer.write_arrow(pa.Table.from_pylist(
+ rows, schema=pa.schema([('id', pa.int64()), ('pt', pa.string())])))
+ return writer.prepare_commit() if identifier is None else
writer.prepare_commit(identifier)
+ finally:
+ writer.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'])
+
+
+def _must_not_fallback(commit):
+ return patch.object(commit.file_store_commit, 'commit',
side_effect=AssertionError('Python fallback'))
+
+
+def test_native_commit_is_opt_in():
+ assert not CoreOptions(Options({})).native_commit_enabled()
+ assert CoreOptions(Options({'commit.native.enabled':
'true'})).native_commit_enabled()
+
+
+@requires_native
[email protected]('backend', ['filesystem', 'path', 'jdbc'])
[email protected]('mode', ['append', 'pk', 'de'])
+def test_native_batch_roundtrip_preserves_identity(tmp_path, backend, mode):
+ table = _table(tmp_path, mode, backend)
+ builder = table.new_batch_write_builder()
+ rows = [{'id': 1, 'pt': 'a'}, {'id': 2, 'pt': None}]
+ messages = _prepare(builder, rows)
+ commit = builder.new_commit()
+ try:
+ with _must_not_fallback(commit):
+ commit.commit(messages)
+ snapshot = table.snapshot_manager().get_latest_snapshot()
+ assert snapshot.commit_user == builder.commit_user
+ assert snapshot.commit_identifier == BATCH_COMMIT_IDENTIFIER
+ assert _rows(table) == rows
+ with pytest.raises(RuntimeError, match='one-time'):
+ commit.commit(messages)
+ finally:
+ commit.close()
+
+
+@requires_native
+def test_native_stream_reuses_commit_user_and_identifiers(tmp_path):
+ table = _table(tmp_path)
+ builder = table.new_stream_write_builder()
+ commit = builder.new_commit()
+ try:
+ with _must_not_fallback(commit):
+ for identifier in (7, 8):
+ commit.commit(_prepare(builder, [{'id': identifier, 'pt':
None}], identifier), identifier)
+ snapshot = table.snapshot_manager().get_latest_snapshot()
+ assert snapshot.commit_user == builder.commit_user
+ assert snapshot.commit_identifier == identifier
+ assert _rows(table) == [{'id': 7, 'pt': None}, {'id': 8, 'pt': None}]
+ finally:
+ commit.close()
+
+
+@requires_native
[email protected]('mode', ['batch', 'stream'])
[email protected]('ignore', [True, False])
+def test_native_empty_commit_preserves_python_option(tmp_path, mode, ignore):
+ table = _table(tmp_path).copy({'snapshot.ignore-empty-commit':
str(ignore).lower()})
+ commit = getattr(table, 'new_' + mode + '_write_builder')().new_commit()
+ try:
+ with _must_not_fallback(commit):
+ if mode == 'batch':
+ commit.commit([])
+ else:
+ commit.commit([], 7)
+ snapshot = table.snapshot_manager().get_latest_snapshot()
+ if ignore:
+ assert snapshot is None
+ else:
+ assert snapshot.commit_identifier == (BATCH_COMMIT_IDENTIFIER if
mode == 'batch' else 7)
+ assert snapshot.total_record_count == 0
+ finally:
+ commit.close()
+
+
+@requires_native
+def test_native_abort_removes_uncommitted_files(tmp_path):
+ table = _table(tmp_path)
+ builder = table.new_batch_write_builder()
+ messages = _prepare(builder, [{'id': 1, 'pt': None}])
+ files = list(tmp_path.rglob('data-*.parquet'))
+ assert files
+ commit = builder.new_commit()
+ try:
+ with patch.object(commit.file_store_commit, 'abort',
side_effect=AssertionError('Python fallback')):
+ commit.abort(messages)
+ assert not any(path.exists() for path in files)
+ assert table.snapshot_manager().get_latest_snapshot() is None
+ finally:
+ commit.close()
+
+
[email protected]('failure', ['missing', 'construction', 'conversion'])
+def test_preflight_failure_uses_python(tmp_path, failure):
+ table = _table(tmp_path)
+ builder = table.new_batch_write_builder()
+ messages = _prepare(builder, [{'id': 1, 'pt': 'a'}])
+ commit = builder.new_commit()
+ native = Mock()
+ with patch('pypaimon.write.native_commit.create_native_commit',
return_value=native) as create:
+ if failure == 'missing':
+ create.return_value = None
+ elif failure == 'construction':
+ create.side_effect = RuntimeError('unsupported FileIO')
+ with patch('pypaimon.write.native_commit.to_native_commit_messages',
+ side_effect=ValueError('unsupported payload')):
+ commit.commit(messages)
+ native.commit.assert_not_called()
+ assert _rows(table) == [{'id': 1, 'pt': 'a'}]
+ commit.close()
+
+
[email protected]('method', ['commit', 'abort'])
+def test_native_mutation_failure_never_falls_back_or_aborts(tmp_path, method):
+ table = _table(tmp_path)
+ builder = table.new_batch_write_builder()
+ messages = _prepare(builder, [{'id': 1, 'pt': 'a'}])
+ commit = builder.new_commit()
+ native = Mock()
+ getattr(native, method).side_effect = OSError('outcome unknown')
+ with patch('pypaimon.write.native_commit.create_native_commit',
return_value=native), \
+ patch('pypaimon.write.native_commit.to_native_commit_messages',
return_value=['wire']), \
+ patch.object(commit.file_store_commit, 'commit') as fallback, \
+ patch.object(commit.file_store_commit, 'abort') as abort:
+ with pytest.raises(OSError, match='outcome unknown'):
+ getattr(commit, method)(messages)
+ fallback.assert_not_called()
+ abort.assert_not_called()
+ if method == 'commit':
+ native.abort.assert_not_called()
+ with pytest.raises(RuntimeError, match='one-time'):
+ commit.commit(messages)
+ assert list(tmp_path.rglob('data-*.parquet'))
+ commit.close()
+
+
+@requires_native
+def
test_publication_response_loss_does_not_duplicate_or_delete_files(tmp_path):
+ table = _table(tmp_path)
+ builder = table.new_batch_write_builder()
+ messages = _prepare(builder, [{'id': 1, 'pt': None}])
+ native = create_native_commit(table, builder.commit_user)
+ proxy = Mock(wraps=native)
+
+ def publish_then_fail(identifier, messages):
+ native.commit(identifier, messages)
+ raise OSError('response lost')
+
+ proxy.commit.side_effect = publish_then_fail
+ commit = builder.new_commit()
+ try:
+ with patch('pypaimon.write.native_commit.create_native_commit',
return_value=proxy), \
+ _must_not_fallback(commit), \
+ patch.object(commit.file_store_commit, 'abort',
side_effect=AssertionError('abort')):
+ with pytest.raises(OSError, match='response lost'):
+ commit.commit(messages)
+ assert table.snapshot_manager().get_latest_snapshot().id == 1
+ assert _rows(table) == [{'id': 1, 'pt': None}]
+ proxy.abort.assert_not_called()
+ finally:
+ commit.close()
+
+
[email protected]('properties', [{}, {'source': 'python'}])
+def test_snapshot_properties_select_python_before_native(tmp_path, properties):
+ table = _table(tmp_path)
+ builder = table.new_batch_write_builder()
+ commit = builder.new_commit()
+ with patch('pypaimon.write.native_commit.create_native_commit',
+ side_effect=AssertionError('must not initialize native')) as
create:
+ commit.commit(_prepare(builder, [{'id': 1, 'pt': None}]),
snapshot_properties=properties)
+ create.assert_not_called()
+ assert table.snapshot_manager().get_latest_snapshot().properties ==
(properties or None)
+ commit.close()
+
+
[email protected]('warmup', [False, True])
+def test_callbacks_added_after_construction_select_python(tmp_path, warmup):
+ if warmup and not native_commit_available():
+ pytest.skip('native warmup requires the commit bindings')
+ table = _table(tmp_path)
+ builder = table.new_stream_write_builder()
+ commit = builder.new_commit()
+ if warmup:
+ with _must_not_fallback(commit):
+ commit.commit(_prepare(builder, [{'id': 6, 'pt': None}], 6), 6)
+ callback = Mock()
+ commit.add_commit_callback(callback)
+ with patch('pypaimon.write.native_commit.create_native_commit') as create:
+ commit.commit(_prepare(builder, [{'id': 1, 'pt': None}], 7), 7)
+ create.assert_not_called()
+ callback.call.assert_called_once()
+ commit.close()
+ callback.close.assert_called_once()
+
+
+def test_overwrite_and_truncate_use_python(tmp_path):
+ table = _table(tmp_path)
+ builder = table.new_batch_write_builder().overwrite({'pt': 'a'})
+ commit = builder.new_commit()
+ with patch('pypaimon.write.native_commit.create_native_commit') as create:
+ commit.commit(_prepare(builder, [{'id': 1, 'pt': 'a'}]))
+
table.new_batch_write_builder().new_commit().truncate_partitions([{'pt': 'a'}])
+ table.new_batch_write_builder().new_commit().truncate_table()
+ create.assert_not_called()
+ assert _rows(table) == []
+ commit.close()
+
+
+def test_disabled_option_never_initializes_native(tmp_path):
+ table = _table(tmp_path).copy({'commit.native.enabled': 'false'})
+ builder = table.new_batch_write_builder()
+ commit = builder.new_commit()
+ with patch('pypaimon.write.native_commit.create_native_commit') as create:
+ commit.commit(_prepare(builder, [{'id': 1, 'pt': None}]))
+ create.assert_not_called()
+ assert _rows(table) == [{'id': 1, 'pt': None}]
+ commit.close()
+
+
[email protected]('kind', ['version-managed', 'custom-env', 'branch',
'custom-io'])
+def test_incompatible_publication_environment_is_not_reconstructed(tmp_path,
kind):
+ table = _table(tmp_path)
+ if kind == 'version-managed':
+ table.catalog_environment.supports_version_management = True
+ elif kind == 'custom-env':
+ class CustomEnvironment(CatalogEnvironment):
+ pass
+ table.catalog_environment = CustomEnvironment()
+ elif kind == 'branch':
+ table.identifier = Identifier('default', 't', branch='dev')
+ else:
+ table.file_io = Mock()
+ with patch('pypaimon.write.native_commit.native_commit_available',
return_value=True), \
+
patch('pypaimon.write.native_commit._resolved_schema_file_io_options') as
resolve:
+ assert create_native_commit(table, 'job') is None
+ resolve.assert_not_called()
+
+
+def test_missing_capability_falls_back_without_reconstructing_table(tmp_path):
+ table = _table(tmp_path)
+ with patch('pypaimon.write.native_commit.native_method_available',
+ side_effect=lambda cls, method: (cls, method) !=
('CommitMessage', 'deserialize')), \
+
patch('pypaimon.write.native_commit._resolved_schema_file_io_options') as
resolve:
+ assert create_native_commit(table, 'job') is None
+ resolve.assert_not_called()
+
+
+def
test_partial_row_id_and_compact_messages_preserve_python_recovery(tmp_path):
+ table = _table(tmp_path, 'de')
+ assert not native_messages_supported(table, [CommitMessage((), 0, [],
check_from_snapshot=7)])
+ assert not native_messages_supported(table, [CommitMessage((), 0,
[Mock(first_row_id=1)])])
+ assert not native_messages_supported(table, [CommitMessage((), 0, [],
compact_after=[Mock()])])
+
+
+def test_close_releases_python_resources_even_if_native_close_fails(tmp_path):
+ commit = _table(tmp_path).new_batch_write_builder().new_commit()
+ commit._native_commit = Mock()
+ commit._native_commit.close.side_effect = OSError('close failure')
+ with patch.object(commit.file_store_commit, 'close') as close:
+ with pytest.raises(OSError, match='close failure'):
+ commit.close()
+ close.assert_called_once()
diff --git a/paimon-python/pypaimon/tests/table_commit_test.py
b/paimon-python/pypaimon/tests/table_commit_test.py
index 2c7079bcc7..5b71403b19 100644
--- a/paimon-python/pypaimon/tests/table_commit_test.py
+++ b/paimon-python/pypaimon/tests/table_commit_test.py
@@ -78,10 +78,13 @@ class TestTableCommit(unittest.TestCase):
commit = cls.__new__(cls)
commit.table = Mock()
commit.table.identifier = 'default.test_table'
+ commit.table.options.native_commit_enabled.return_value = False
commit.commit_user = 'test_user'
commit.overwrite_partition = overwrite_partition
commit.file_store_commit = Mock()
commit.batch_committed = False
+ commit._commit_callbacks = []
+ commit._native_commit = None
return commit, commit.file_store_commit
# -- Overwrite mode: should always call overwrite(), even with empty
messages --
diff --git a/paimon-python/pypaimon/write/commit_message.py
b/paimon-python/pypaimon/write/commit_message.py
index e3c6e15287..1325260cd2 100644
--- a/paimon-python/pypaimon/write/commit_message.py
+++ b/paimon-python/pypaimon/write/commit_message.py
@@ -29,12 +29,18 @@ class CommitMessage:
partition: Tuple
bucket: int
new_files: List[DataFileMeta]
- check_from_snapshot: Optional[int] = -1
+ check_from_snapshot: Optional[int] = None
deleted_files: List[DataFileMeta] = field(default_factory=list)
index_adds: List['IndexManifestEntry'] = field(default_factory=list)
index_deletes: List['IndexManifestEntry'] = field(default_factory=list)
changelog_files: List[DataFileMeta] = field(default_factory=list)
total_buckets: Optional[int] = None
+ # Java CommitMessageImpl keeps compaction changes separate from data
changes.
+ compact_before: List[DataFileMeta] = field(default_factory=list)
+ compact_after: List[DataFileMeta] = field(default_factory=list)
+ compact_changelog_files: List[DataFileMeta] = field(default_factory=list)
+ compact_index_adds: List['IndexManifestEntry'] =
field(default_factory=list)
+ compact_index_deletes: List['IndexManifestEntry'] =
field(default_factory=list)
def is_empty(self):
return (
@@ -43,4 +49,9 @@ class CommitMessage:
and not self.index_adds
and not self.index_deletes
and not self.changelog_files
+ and not self.compact_before
+ and not self.compact_after
+ and not self.compact_changelog_files
+ and not self.compact_index_adds
+ and not self.compact_index_deletes
)
diff --git a/paimon-python/pypaimon/write/commit_message_serializer.py
b/paimon-python/pypaimon/write/commit_message_serializer.py
new file mode 100644
index 0000000000..6b2b2ba2c7
--- /dev/null
+++ b/paimon-python/pypaimon/write/commit_message_serializer.py
@@ -0,0 +1,265 @@
+# 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.
+
+"""Java ``CommitMessageSerializer`` v14 body, without an outer version header.
+
+The caller must carry version 14 alongside these bytes, as Java's
+``ManifestCommittableSerializer`` does. The data and compaction increments are
+kept separate on the wire even when only the data increment is populated.
+"""
+
+from dataclasses import replace
+import struct
+from typing import List, Optional
+
+from pypaimon.globalindex.global_index_meta import GlobalIndexMeta
+from pypaimon.index.deletion_vector_meta import DeletionVectorMeta
+from pypaimon.index.index_file_meta import IndexFileMeta
+from pypaimon.manifest.index_manifest_entry import IndexManifestEntry
+from pypaimon.read.split_serializer import (
+ _DFM_FIELDS, _Reader, _Writer, _binary_array_header,
+ _datafilemeta_from_row, _round_to_word,
+ _serialize_data_file_meta,
+)
+from pypaimon.schema.data_types import AtomicType, DataField
+from pypaimon.table.row.binary_row import BinaryRow
+from pypaimon.table.row.generic_row import (
+ GenericRow, GenericRowDeserializer, GenericRowSerializer,
+)
+from pypaimon.write.commit_message import CommitMessage
+
+VERSION = 14
+
+
+def _fields(types):
+ return [DataField(i, str(i), AtomicType(t)) for i, t in enumerate(types)]
+
+
+_INDEX_FIELDS = _fields(['STRING', 'STRING', 'BIGINT', 'BIGINT', 'BYTES',
+ 'STRING', 'BYTES'])
+_DV_FIELDS = _fields(['STRING', 'INT', 'INT', 'BIGINT'])
+_GLOBAL_FIELDS = _fields(['BIGINT', 'BIGINT', 'INT', 'BYTES', 'BYTES',
'BYTES'])
+
+
+def _row(values, fields):
+ return GenericRowSerializer.to_bytes(GenericRow(values, fields))[4:]
+
+
+def _array_rows(rows: List[bytes]) -> bytes:
+ count = len(rows)
+ header = _binary_array_header(count)
+ data = bytearray(_round_to_word(header + count * 8))
+ struct.pack_into('<i', data, 0, count)
+ for i, row in enumerate(rows):
+ offset = len(data)
+ data.extend(row)
+ data.extend(b'\x00' * (_round_to_word(len(row)) - len(row)))
+ struct.pack_into('<Q', data, header + i * 8,
+ (offset << 32) | len(row))
+ return bytes(data)
+
+
+def _read_array_rows(data: bytes) -> List[bytes]:
+ if len(data) < 4:
+ raise ValueError('truncated BinaryArray<row>')
+ count = struct.unpack_from('<i', data)[0]
+ if count < 0:
+ raise ValueError('negative BinaryArray<row> count')
+ header = _binary_array_header(count)
+ fixed = _round_to_word(header + count * 8)
+ if fixed > len(data):
+ raise ValueError('truncated BinaryArray<row> fixed part')
+ result = []
+ for i in range(count):
+ if data[4 + i // 8] & (1 << (i % 8)):
+ raise ValueError('null BinaryArray<row> element')
+ slot = struct.unpack_from('<Q', data, header + i * 8)[0]
+ offset, length = slot >> 32, slot & 0xFFFFFFFF
+ if offset < fixed or offset + length > len(data):
+ raise ValueError('BinaryArray<row> element outside buffer')
+ result.append(data[offset:offset + length])
+ return result
+
+
+def _int_array(values: List[int]) -> bytes:
+ header = _binary_array_header(len(values))
+ data = bytearray(_round_to_word(header + len(values) * 4))
+ struct.pack_into('<i', data, 0, len(values))
+ for i, value in enumerate(values):
+ struct.pack_into('<i', data, header + i * 4, value)
+ return bytes(data)
+
+
+def _read_int_array(data: bytes) -> List[int]:
+ if len(data) < 4:
+ raise ValueError('truncated BinaryArray<int>')
+ count = struct.unpack_from('<i', data)[0]
+ header = _binary_array_header(count)
+ if count < 0 or header + count * 4 > len(data):
+ raise ValueError('invalid BinaryArray<int> size')
+ return [struct.unpack_from('<i', data, header + i * 4)[0]
+ for i in range(count)]
+
+
+def _index_row(meta: IndexFileMeta) -> bytes:
+ dv = None
+ if meta.dv_ranges is not None:
+ dv = _array_rows([
+ _row([value.data_file_name, value.offset, value.length,
+ value.cardinality], _DV_FIELDS)
+ for value in meta.dv_ranges.values()
+ ])
+ global_meta = None
+ if meta.global_index_meta is not None:
+ value = meta.global_index_meta
+ global_meta = _row([
+ value.row_range_start, value.row_range_end, value.index_field_id,
+ _int_array(value.extra_field_ids)
+ if value.extra_field_ids is not None else None,
+ value.index_meta, value.source_meta,
+ ], _GLOBAL_FIELDS)
+ return _row([meta.index_type, meta.file_name, meta.file_size,
+ meta.row_count, dv, meta.external_path, global_meta],
+ _INDEX_FIELDS)
+
+
+def _index_from_row(data: bytes) -> IndexFileMeta:
+ row = BinaryRow(struct.pack('>i', 7) + data, _INDEX_FIELDS)
+ dv = None
+ if row.get_field(4) is not None:
+ dv = {}
+ for raw in _read_array_rows(row.get_field(4)):
+ entry = BinaryRow(struct.pack('>i', 4) + raw, _DV_FIELDS)
+ value = DeletionVectorMeta(entry.get_field(0), entry.get_field(1),
+ entry.get_field(2), entry.get_field(3))
+ dv[value.data_file_name] = value
+ global_meta = None
+ if row.get_field(6) is not None:
+ value = BinaryRow(struct.pack('>i', 6) + row.get_field(6),
_GLOBAL_FIELDS)
+ extra = value.get_field(3)
+ global_meta = GlobalIndexMeta(
+ value.get_field(0), value.get_field(1), value.get_field(2),
+ _read_int_array(extra) if extra is not None else None,
+ value.get_field(4), value.get_field(5))
+ return IndexFileMeta(row.get_field(0), row.get_field(1), row.get_field(2),
+ row.get_field(3), dv, row.get_field(5), global_meta)
+
+
+def _write_list(writer: _Writer, values, encode):
+ writer.i32(len(values))
+ for value in values:
+ row = encode(value)
+ writer.i32(len(row))
+ writer.take(row)
+
+
+def _read_list(reader: _Reader, decode):
+ count = reader.i32()
+ if count < 0:
+ raise ValueError('negative CommitMessage list count')
+ return [decode(reader.take(reader.i32())) for _ in range(count)]
+
+
+def _file_row(meta):
+ # A local file_path is not Java's externalPath field.
+ return _serialize_data_file_meta(replace(meta, file_path=None), '')
+
+
+def _file_from_row(raw, key_fields):
+ if not key_fields:
+ row = BinaryRow(struct.pack('>i', len(_DFM_FIELDS)) + raw, _DFM_FIELDS)
+ for pos in (3, 4):
+ key = row.get_field(pos)
+ if key is not None and (len(key) < 4 or struct.unpack_from('>i',
key)[0] != 0):
+ raise ValueError('key_fields are required to decode
primary-key files')
+ meta = _datafilemeta_from_row(raw, '', len(_DFM_FIELDS), key_fields)
+ meta.file_path = None
+ return meta
+
+
+def serialize_commit_message(message: CommitMessage,
+ partition_fields: List[DataField]) -> bytes:
+ """Return the unframed Java v14 ``CommitMessageSerializer.serialize``
bytes."""
+ if len(message.partition) != len(partition_fields):
+ raise ValueError('partition arity does not match partition fields')
+ writer = _Writer()
+ partition = GenericRowSerializer.to_bytes(
+ GenericRow(list(message.partition), partition_fields))
+ writer.i32(len(partition))
+ writer.take(partition)
+ writer.i32(message.bucket)
+ writer.u8(1 if message.total_buckets is not None else 0)
+ if message.total_buckets is not None:
+ writer.i32(message.total_buckets)
+ for values in (message.new_files, message.deleted_files,
+ message.changelog_files):
+ _write_list(writer, values, _file_row)
+ for values in (message.index_adds, message.index_deletes):
+ _write_list(writer, values, lambda entry: _index_row(entry.index_file))
+ for values in (message.compact_before, message.compact_after,
+ message.compact_changelog_files):
+ _write_list(writer, values, _file_row)
+ for values in (message.compact_index_adds, message.compact_index_deletes):
+ _write_list(writer, values, lambda entry: _index_row(entry.index_file))
+ check = message.check_from_snapshot
+ writer.u8(0 if check is None else 1)
+ if check is not None:
+ writer.i64(check)
+ return writer.finish()
+
+
+def deserialize_commit_message(data: bytes, partition_fields: List[DataField],
+ key_fields: Optional[List[DataField]] = None,
+ version: int = VERSION) -> CommitMessage:
+ """Read a Java v14 body; ``version`` is supplied by the enclosing
format."""
+ if version != VERSION:
+ raise ValueError('unsupported CommitMessage version %d' % version)
+ reader = _Reader(data)
+ partition_bytes = reader.take(reader.i32())
+ if (len(partition_bytes) < 4 or
+ struct.unpack_from('>i', partition_bytes)[0] !=
len(partition_fields)):
+ raise ValueError('partition arity does not match partition fields')
+ partition = tuple(GenericRowDeserializer.from_bytes(
+ partition_bytes, partition_fields).values)
+ bucket = reader.i32()
+ total_buckets = reader.i32() if reader.u8() else None
+ file_reader = lambda raw: _file_from_row(raw, key_fields)
+ new_files = _read_list(reader, file_reader)
+ deleted_files = _read_list(reader, file_reader)
+ changelog_files = _read_list(reader, file_reader)
+
+ def index_list(kind):
+ return [IndexManifestEntry(kind, GenericRow(list(partition),
partition_fields),
+ bucket, meta)
+ for meta in _read_list(reader, _index_from_row)]
+
+ index_adds, index_deletes = index_list(0), index_list(1)
+ compact_before = _read_list(reader, file_reader)
+ compact_after = _read_list(reader, file_reader)
+ compact_changelog_files = _read_list(reader, file_reader)
+ compact_index_adds, compact_index_deletes = index_list(0), index_list(1)
+ check = reader.i64() if reader.u8() else None
+ reader.finish()
+ return CommitMessage(
+ partition=partition, bucket=bucket, new_files=new_files,
+ check_from_snapshot=check, deleted_files=deleted_files,
+ index_adds=index_adds, index_deletes=index_deletes,
+ changelog_files=changelog_files, total_buckets=total_buckets,
+ compact_before=compact_before, compact_after=compact_after,
+ compact_changelog_files=compact_changelog_files,
+ compact_index_adds=compact_index_adds,
+ compact_index_deletes=compact_index_deletes)
diff --git a/paimon-python/pypaimon/write/file_store_commit.py
b/paimon-python/pypaimon/write/file_store_commit.py
index 3fc49b1243..afe7476620 100644
--- a/paimon-python/pypaimon/write/file_store_commit.py
+++ b/paimon-python/pypaimon/write/file_store_commit.py
@@ -53,13 +53,51 @@ from pypaimon.write.commit_message import CommitMessage
logger = logging.getLogger(__name__)
+def _row_id_check_from_messages(messages: List[CommitMessage]) ->
Optional[int]:
+ """Check conflicts from the earliest baseline, matching the native
committer."""
+ check_from_snapshot = None
+ for message in messages:
+ snapshot = message.check_from_snapshot
+ if snapshot is None:
+ continue
+ if snapshot < 0:
+ raise ValueError('Invalid row-id check snapshot: %s' % snapshot)
+ check_from_snapshot = (snapshot if check_from_snapshot is None
+ else min(check_from_snapshot, snapshot))
+ if check_from_snapshot is not None:
+ for message in messages:
+ if message.check_from_snapshot is not None:
+ continue
+ if any(file.first_row_id is not None
+ for file in message.new_files + message.deleted_files):
+ raise ValueError(
+ 'A row-id commit message is missing its check-from
snapshot.')
+ return check_from_snapshot
+
+
+def _reject_compact_increment(messages: List[CommitMessage]):
+ # Java commits this increment as a separate COMPACT snapshot.
+ for message in messages:
+ if (message.compact_before or message.compact_after or
+ message.compact_changelog_files or
+ message.compact_index_adds or message.compact_index_deletes):
+ raise NotImplementedError(
+ 'Committing a compact increment requires a separate COMPACT
snapshot.')
+
+
def _abort_commit_messages(table, commit_messages: List[CommitMessage]):
"""Delete files created by messages known to be uncommitted."""
for message in commit_messages:
- for file in list(message.new_files) + list(message.changelog_files):
+ for file in (list(message.new_files) + list(message.changelog_files)
+ + list(message.compact_after)
+ + list(message.compact_changelog_files)):
path = None
try:
path = file.external_path or file.file_path
+ if not path:
+ bucket_path = table.path_factory().bucket_path(
+ tuple(message.partition), message.bucket)
+ path = '%s/%s' % (bucket_path.rstrip('/'), file.file_name)
if path:
table.file_io.delete_quietly(str(path))
except Exception as error:
@@ -68,7 +106,7 @@ def _abort_commit_messages(table, commit_messages:
List[CommitMessage]):
path,
error,
)
- for entry in message.index_adds:
+ for entry in message.index_adds + message.compact_index_adds:
file_name = None
try:
index_file = entry.index_file
@@ -197,11 +235,10 @@ class FileStoreCommit:
if not commit_messages and ignore_empty_commit:
return
- # Extract the minimum check_from_snapshot from commit messages
- valid_snapshots = [msg.check_from_snapshot for msg in commit_messages
- if msg.check_from_snapshot != -1]
- if valid_snapshots:
- self.conflict_detection._row_id_check_from_snapshot =
min(valid_snapshots)
+ _reject_compact_increment(commit_messages)
+ check_from_snapshot = _row_id_check_from_messages(commit_messages)
+ # A committer can be reused; an untagged commit clears the prior
baseline.
+ self.conflict_detection._row_id_check_from_snapshot =
check_from_snapshot
logger.info(
"Ready to commit to table %s, number of commit messages: %d",
@@ -226,7 +263,7 @@ class FileStoreCommit:
updated_cols = set()
written_partitions = set()
for msg in commit_messages:
- if msg.check_from_snapshot == -1:
+ if msg.check_from_snapshot is None:
continue
for f in msg.new_files:
write_cols =
self.table.table_schema.partial_file_write_cols(
@@ -282,6 +319,9 @@ class FileStoreCommit:
commit_identifier: int,
snapshot_properties: Optional[Dict[str, str]] = None):
"""Commit the given commit messages in overwrite mode."""
+ _reject_compact_increment(commit_messages)
+ self.conflict_detection._row_id_check_from_snapshot = (
+ _row_id_check_from_messages(commit_messages))
logger.info(
"Ready to overwrite to table %s, number of commit messages: %d",
self.table.identifier,
diff --git a/paimon-python/pypaimon/write/native_commit.py
b/paimon-python/pypaimon/write/native_commit.py
new file mode 100644
index 0000000000..decba9dcdc
--- /dev/null
+++ b/paimon-python/pypaimon/write/native_commit.py
@@ -0,0 +1,102 @@
+# 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.
+
+"""Optional native append commits using the Java CommitMessage v14 bridge."""
+
+from pypaimon.common.json_util import JSON
+from pypaimon.read.native_plan import (
+ _option_value_to_string, _resolved_schema_file_io_options,
+ native_method_available)
+from pypaimon.write.commit_message_serializer import serialize_commit_message
+
+
+def native_commit_available() -> bool:
+ """Probe capabilities instead of assuming every 0.4 development wheel has
them."""
+ return all(native_method_available(type_name, method) for type_name,
method in (
+ ('Table', 'from_resolved_schema'),
+ ('Table', 'new_stream_write_builder'),
+ ('StreamWriteBuilder', 'with_commit_user'),
+ ('StreamWriteBuilder', 'new_commit'),
+ ('StreamTableCommit', 'commit'),
+ ('StreamTableCommit', 'abort'),
+ ('StreamTableCommit', 'close'),
+ ('CommitMessage', 'deserialize'),
+ ))
+
+
+def native_messages_supported(table, messages) -> bool:
+ for message in messages:
+ if (message.compact_before or message.compact_after
+ or message.compact_changelog_files
+ or message.compact_index_adds or
message.compact_index_deletes):
+ return False
+ # Python can rewrite stale row-id files before retrying. The native
+ # committer does not yet implement that recovery path.
+ if table.options.data_evolution_enabled() and (
+ message.check_from_snapshot is not None
+ or any(file.first_row_id is not None
+ for file in message.new_files + message.deleted_files)):
+ return False
+ return True
+
+
+def create_native_commit(table, commit_user):
+ """Return a native committer only when its publication protocol matches
Python."""
+ from pypaimon.catalog.catalog_environment import CatalogEnvironment
+ from pypaimon.filesystem.local_file_io import LocalFileIO
+ from pypaimon.filesystem.pyarrow_file_io import PyArrowFileIO
+ from pypaimon.filesystem.resolving_file_io import ResolvingFileIO
+ from pypaimon.table.file_store_table import FileStoreTable
+
+ if not native_commit_available():
+ return None
+ # Native branch writes are not supported. Catalog-backed publication (REST
+ # or custom version management) must continue through Python's environment.
+ environment = table.catalog_environment
+ if (type(table) is not FileStoreTable
+ or type(environment) is not CatalogEnvironment
+ or environment.supports_version_management
+ or table.current_branch() != 'main'
+ or table.options.query_auth_enabled
+ or type(table.file_io) not in (LocalFileIO, PyArrowFileIO,
ResolvingFileIO)):
+ return None
+ file_io_options = _resolved_schema_file_io_options(table)
+ if file_io_options is None:
+ return None
+
+ from pypaimon_rust.datafusion import Table as NativeTable
+ # Preserve the resolved schema and all effective table options, including
+ # copy() overrides. Do not inject scan options or reload catalog schemas.
+ options = {str(key): _option_value_to_string(value)
+ for key, value in table.table_schema.options.items() if value
is not None}
+ native_table = NativeTable.from_resolved_schema(
+ table.table_path,
JSON.to_json(table.table_schema.copy(new_options=options)),
+ database=table.identifier.get_database_name(),
+ table=table.identifier.get_table_name(),
+ options=file_io_options)
+ # Both Python modes already enforce their public lifecycle/empty-commit
+ # contract. The stream builder preserves their existing writer identity;
+ # a native batch builder would mint a different commit user.
+ return
native_table.new_stream_write_builder().with_commit_user(commit_user).new_commit()
+
+
+def to_native_commit_messages(table, messages):
+ from pypaimon_rust.datafusion import CommitMessage as NativeCommitMessage
+ # Convert the whole batch before any native mutation can begin.
+ return [NativeCommitMessage.deserialize(
+ serialize_commit_message(message, table.partition_keys_fields),
version=14)
+ for message in messages]
diff --git a/paimon-python/pypaimon/write/table_commit.py
b/paimon-python/pypaimon/write/table_commit.py
index 35ee83f2fc..c6ba7bc3fa 100644
--- a/paimon-python/pypaimon/write/table_commit.py
+++ b/paimon-python/pypaimon/write/table_commit.py
@@ -52,6 +52,7 @@ class TableCommit:
raise RuntimeError("Table does not provide a SnapshotCommit
instance")
self._commit_callbacks: List[CommitCallback] = []
+ self._native_commit = None
self.file_store_commit = FileStoreCommit(
snapshot_commit, table, commit_user,
commit_callbacks=self._commit_callbacks)
@@ -95,13 +96,52 @@ class TableCommit:
"Committing table %s, %d non-empty messages",
self.table.identifier, len(non_empty_messages)
)
+ if snapshot_properties is None:
+ prepared = self._prepare_native_commit(non_empty_messages)
+ if prepared is not None:
+ native, messages = prepared
+ # Mutation is deliberately outside the fallback boundary:
+ # an exception can mean the snapshot was already published.
+ native.commit(commit_identifier, messages)
+ return
self.file_store_commit.commit(**commit_kwargs)
+ def _prepare_native_commit(self, messages):
+ if (not self.table.options.native_commit_enabled()
+ or self.overwrite_partition is not None
+ or self._commit_callbacks):
+ return None
+ try:
+ from pypaimon.write.native_commit import (
+ create_native_commit, native_messages_supported,
+ 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)
+ if self._native_commit is None:
+ return None
+ return self._native_commit, to_native_commit_messages(self.table,
messages)
+ except Exception as error:
+ # No native mutation has started. Preserve the normal Python path
+ # when the optional runtime, FileIO or wire bridge is unavailable.
+ logger.debug("Native commit preparation failed; using Python: %s",
error)
+ return None
+
def abort(self, commit_messages: List[CommitMessage]):
+ prepared = self._prepare_native_commit(commit_messages)
+ if prepared is not None:
+ native, messages = prepared
+ native.abort(messages)
+ return
self.file_store_commit.abort(commit_messages)
def close(self):
- self.file_store_commit.close()
+ try:
+ if self._native_commit is not None:
+ self._native_commit.close()
+ finally:
+ self.file_store_commit.close()
class BatchTableCommit(TableCommit):