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 e36ed8406c [Python] Keep REST commits with callbacks on the native 
path (#10509)
e36ed8406c is described below

commit e36ed8406c22e285f1e8a61fbea1824201b529a5
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Oct 12 16:54:35 2026 +0800

    [Python] Keep REST commits with callbacks on the native path (#10509)
---
 paimon-python/pypaimon/tests/native_commit_test.py | 221 ++++++++++++++++++++-
 paimon-python/pypaimon/write/native_commit.py      |  22 ++
 paimon-python/pypaimon/write/table_commit.py       |  14 +-
 3 files changed, 247 insertions(+), 10 deletions(-)

diff --git a/paimon-python/pypaimon/tests/native_commit_test.py 
b/paimon-python/pypaimon/tests/native_commit_test.py
index 31d9b63a19..232e126fd4 100644
--- a/paimon-python/pypaimon/tests/native_commit_test.py
+++ b/paimon-python/pypaimon/tests/native_commit_test.py
@@ -456,8 +456,9 @@ def 
test_non_rest_snapshot_properties_keep_python_commit(tmp_path, properties, o
     commit.close()
 
 
[email protected]('warmup', [False, pytest.param(True, 
marks=pytest.mark.native_plan)])
-def test_callbacks_added_after_construction_select_python(
+@requires_native
[email protected]('warmup', [False, True])
+def test_callbacks_added_after_construction_use_native(
         tmp_path, native_rest_catalog, warmup):
     if warmup and not native_commit_available():
         pytest.skip('native warmup requires the commit bindings')
@@ -469,10 +470,13 @@ def test_callbacks_added_after_construction_select_python(
             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:
+    with _must_not_fallback(commit):
         commit.commit(_prepare(builder, [{'id': 1, 'pt': None}], 7), 7)
-        create.assert_not_called()
     callback.call.assert_called_once()
+    context = callback.call.call_args[0][0]
+    assert context.identifier == 7
+    assert context.snapshot.id == (2 if warmup else 1)
+    assert sum(entry.file.row_count for entry in context.commit_entries) == 1
     commit.close()
     callback.close.assert_called_once()
 
@@ -517,9 +521,10 @@ def test_overwrite_callbacks_select_python(tmp_path):
     commit = builder.new_commit()
     callback = Mock()
     commit.add_commit_callback(callback)
-    with patch('pypaimon.write.native_commit.create_native_commit') as create:
+    with patch('pypaimon.write.native_commit.create_native_commit', 
wraps=create_native_commit) as create:
         commit.commit(_prepare(builder, [{'id': 1, 'pt': 'a'}]))
-        create.assert_not_called()
+        create.assert_called_once()
+        assert commit._native_commit is None
     assert _rows(table) == [{'id': 1, 'pt': 'a'}]
     callback.call.assert_called_once()
     commit.close()
@@ -604,3 +609,207 @@ def 
test_close_releases_python_resources_even_if_native_close_fails(tmp_path):
         with pytest.raises(OSError, match='close failure'):
             commit.close()
         close.assert_called_once()
+
+
+@requires_native
[email protected]('mode', ['append', 'pk', 'de'])
[email protected]('overwrite', [False, True])
+def test_native_callbacks_keep_python_context_and_allow_native_read(
+        tmp_path, native_rest_catalog, mode, overwrite):
+    from pypaimon.manifest.schema.manifest_entry import ManifestEntry
+    from pypaimon.table.row.generic_row import GenericRow
+    from pypaimon.write.commit_callback import CommitCallback, 
CommitCallbackContext
+
+    table = _table(tmp_path, mode, catalog=native_rest_catalog)
+    table = table.copy({
+        'scan.native-plan.enabled': 'true', 'read.native.enabled': 'true',
+        'dynamic-partition-overwrite': 'false'})
+    builder = table.new_batch_write_builder()
+    if overwrite:
+        _seed(table)
+        builder.overwrite()
+    calls = []
+
+    class ReadingCallback(CommitCallback):
+        def call(self, context):
+            assert isinstance(context, CommitCallbackContext)
+            assert all(isinstance(entry, ManifestEntry) for entry in 
context.commit_entries)
+            assert all(isinstance(entry.partition, GenericRow) for entry in 
context.commit_entries)
+            # Re-enter Rust's runtime from the Python callback after 
publication.
+            with patch('pypaimon.read.table_read.TableRead._create_split_read',
+                       side_effect=AssertionError('callback read fell back')):
+                calls.append((context, _rows(table)))
+
+    commit = builder.new_commit()
+    callback = ReadingCallback()
+    follower = Mock()
+    commit.add_commit_callback(callback)
+    commit.add_commit_callback(follower)
+    rows = [{'id': 9, 'pt': 'a/b%'}]
+    messages = _prepare(builder, rows)
+    paths = [file.file_path for message in messages for file in 
message.new_files]
+    try:
+        with _must_not_fallback(commit, 'overwrite' if overwrite else 
'commit'):
+            commit.commit(messages, snapshot_properties={'source': 
'callback-test'})
+        assert len(calls) == 1
+        context, actual_rows = calls[0]
+        assert actual_rows == rows
+        assert context.identifier == BATCH_COMMIT_IDENTIFIER
+        assert context.snapshot.properties == {'source': 'callback-test'}
+        assert context.snapshot == 
table.snapshot_manager().get_latest_snapshot()
+        adds = [entry for entry in context.commit_entries if entry.kind == 0]
+        assert [entry.file.file_path for entry in adds] == paths
+        assert all(table.file_io.exists(file_path) for file_path in paths)
+        if mode == 'de':
+            assert all(entry.file.first_row_id is not None for entry in adds)
+            assert all(entry.file.min_sequence_number == context.snapshot.id 
for entry in adds)
+        if overwrite:
+            deletes = [entry for entry in context.commit_entries if entry.kind 
== 1]
+            assert deletes
+            assert all(entry.file.file_path for entry in deletes)
+        follower.call.assert_called_once_with(context)
+    finally:
+        commit.close()
+    follower.close.assert_called_once()
+
+
+@requires_native
[email protected]('lose_response', [False, True])
[email protected]('callback_fails', [False, True])
+def test_native_callback_recovery_and_error_preserve_files(
+        tmp_path, native_rest_catalog, lose_response, callback_fails):
+    from pypaimon.tests.rest.rest_server import RESTCatalogServer
+
+    table = _table(tmp_path, 'de', catalog=native_rest_catalog)
+    table = table.copy({'commit.min-retry-wait': '0 ms', 
'commit.max-retry-wait': '0 ms'})
+    builder = table.new_batch_write_builder()
+    messages = _prepare(builder, [{'id': 1, 'pt': 'a/b%'}])
+    paths = [file.file_path for message in messages for file in 
message.new_files]
+    commit = builder.new_commit()
+    callback = Mock()
+    if callback_fails:
+        callback.call.side_effect = LookupError('notification failed')
+    commit.add_commit_callback(callback)
+    follower = Mock()
+    commit.add_commit_callback(follower)
+    publish = RESTCatalogServer._table_commit_handle
+    attempts = []
+
+    def publish_then_lose_response(server, *args, **kwargs):
+        result = publish(server, *args, **kwargs)
+        attempts.append(result[1])
+        assert result[1] == 200
+        # A malformed successful response leaves publication outcome uncertain.
+        return ('not-json', 200) if lose_response else result
+
+    try:
+        with patch.object(RESTCatalogServer, '_table_commit_handle', 
publish_then_lose_response), \
+                _must_not_fallback(commit), \
+                patch.object(commit.file_store_commit, 'abort', 
side_effect=AssertionError('abort')):
+            if callback_fails:
+                with pytest.raises(LookupError, match='notification failed'):
+                    commit.commit(messages)
+            else:
+                commit.commit(messages)
+        assert attempts == [200], 'notification failure must not republish the 
snapshot'
+        context = callback.call.call_args[0][0]
+        assert context.snapshot.id == 1
+        assert context.snapshot.next_row_id == 1
+        assert context.commit_entries[0].file.first_row_id == 0
+        assert context.commit_entries[0].file.file_path == paths[0]
+        assert tuple(context.commit_entries[0].partition.values) == ('a/b%',)
+        assert _rows(table) == [{'id': 1, 'pt': 'a/b%'}]
+        assert all(table.file_io.exists(file_path) for file_path in paths)
+        callback.call.assert_called_once()
+        if callback_fails:
+            follower.call.assert_not_called()
+        else:
+            follower.call.assert_called_once_with(context)
+    finally:
+        commit.close()
+    callback.close.assert_called_once()
+    follower.close.assert_called_once()
+
+
+@requires_native
+def test_native_callback_empty_commit_and_prepublication_failure(tmp_path, 
native_rest_catalog):
+    table = _table(tmp_path, 
catalog=native_rest_catalog).copy({'snapshot.ignore-empty-commit': 'false'})
+    builder = table.new_stream_write_builder()
+    commit = builder.new_commit()
+    callback = Mock()
+    commit.add_commit_callback(callback)
+    try:
+        with _must_not_fallback(commit):
+            commit.commit([], 1)
+        context = callback.call.call_args[0][0]
+        assert context.snapshot.id == 1
+        assert context.identifier == 1
+        assert context.commit_entries == []
+        bad = _prepare(builder, [{'id': 1, 'pt': None}], 2)
+        # A repeated file entry must fail before publication and notification.
+        with _must_not_fallback(commit), pytest.raises(ValueError, 
match='Duplicate'):
+            commit.commit(bad + bad, 2)
+        callback.call.assert_called_once()
+        assert table.snapshot_manager().get_latest_snapshot().id == 1
+    finally:
+        commit.close()
+
+
+@requires_native
+def test_native_callbacks_added_between_commits_share_context_and_close_once(
+        tmp_path, native_rest_catalog):
+    table = _table(tmp_path, catalog=native_rest_catalog)
+    builder = table.new_stream_write_builder()
+    commit = builder.new_commit()
+    first, second = Mock(), Mock()
+    events = []
+
+    def first_call(context):
+        events.append(('first', context.identifier, context))
+        # Preserve Python's shared Context contract between registered 
callbacks.
+        context.observed = True
+
+    def second_call(context):
+        assert context.observed
+        events.append(('second', context.identifier, context))
+
+    first.call.side_effect = first_call
+    second.call.side_effect = second_call
+    first.close.side_effect = OSError('close failed')
+    commit.add_commit_callback(first)
+    try:
+        with _must_not_fallback(commit):
+            commit.commit(_prepare(builder, [{'id': 1, 'pt': None}], 1), 1)
+            commit.add_commit_callback(second)
+            commit.commit(_prepare(builder, [{'id': 2, 'pt': None}], 2), 2)
+            commit.commit(_prepare(builder, [{'id': 3, 'pt': None}], 3), 3)
+        assert [(name, identifier) for name, identifier, _ in events] == [
+            ('first', 1), ('first', 2), ('second', 2), ('first', 3), 
('second', 3)]
+        assert events[1][2] is events[2][2]
+        assert events[3][2] is events[4][2]
+    finally:
+        commit.close()
+    first.close.assert_called_once()
+    second.close.assert_called_once()
+
+
+@requires_native
[email protected]('close', [False, True])
+def test_native_commit_callback_cycle_is_collected(tmp_path, 
native_rest_catalog, close):
+    import gc
+    import weakref
+
+    table = _table(tmp_path, catalog=native_rest_catalog)
+    builder = table.new_stream_write_builder()
+    commit = builder.new_commit()
+    callback = Mock()
+    callback.owner = commit
+    reference = weakref.ref(callback)
+    commit.add_commit_callback(callback)
+    with _must_not_fallback(commit):
+        commit.commit(_prepare(builder, [{'id': 1, 'pt': None}], 1), 1)
+    if close:
+        commit.close()
+    del callback, commit
+    gc.collect()
+    assert reference() is None
diff --git a/paimon-python/pypaimon/write/native_commit.py 
b/paimon-python/pypaimon/write/native_commit.py
index 2abcdd7f4e..30cd838230 100644
--- a/paimon-python/pypaimon/write/native_commit.py
+++ b/paimon-python/pypaimon/write/native_commit.py
@@ -184,3 +184,25 @@ def from_native_commit_messages(table, messages):
             file.file_path = file.physical_path() if file.external_path else 
canonical_data_file_path(
                 table, message.partition, message.bucket, file.file_name)
     return decoded
+
+
+def native_commit_callback(table, callbacks):
+    """Translate the native notification into the existing Python context."""
+    from pypaimon.manifest.schema.manifest_entry import ManifestEntry
+    from pypaimon.snapshot.snapshot import Snapshot
+    from pypaimon.table.row.generic_row import GenericRow
+    from pypaimon.write.commit_callback import CommitCallbackContext
+
+    def notify(native_context):
+        entries = []
+        for entry in native_context.commit_entries:
+            message = from_native_commit_messages(table, 
[entry._to_commit_message()])[0]
+            entries.append(ManifestEntry(
+                entry.kind, GenericRow(list(message.partition), 
table.partition_keys_fields),
+                entry.bucket, entry.total_buckets, message.new_files[0]))
+        context = CommitCallbackContext(
+            JSON.from_json(native_context._snapshot_json(), Snapshot), 
entries, native_context.identifier)
+        for callback in callbacks:
+            callback.call(context)
+
+    return notify
diff --git a/paimon-python/pypaimon/write/table_commit.py 
b/paimon-python/pypaimon/write/table_commit.py
index fb0ac3db6d..9819141476 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_callbacks_registered = False
         self.file_store_commit = FileStoreCommit(
             snapshot_commit, table, commit_user,
             commit_callbacks=self._commit_callbacks)
@@ -115,12 +116,11 @@ class TableCommit:
             self.file_store_commit.commit(**commit_kwargs)
 
     def _prepare_native_commit(self, messages):
-        if (not self.table.options.native_commit_enabled()
-                or self._commit_callbacks):
+        if not self.table.options.native_commit_enabled():
             return None
         try:
             from pypaimon.write.native_commit import (
-                create_native_commit, native_messages_supported,
+                create_native_commit, native_commit_callback, 
native_messages_supported,
                 to_native_commit_messages)
             if not native_messages_supported(self.table, messages):
                 return None
@@ -129,7 +129,13 @@ class TableCommit:
                     self.table, self.commit_user, self.overwrite_partition)
             if self._native_commit is None:
                 return None
-            return self._native_commit, to_native_commit_messages(self.table, 
messages)
+            native_messages = to_native_commit_messages(self.table, messages)
+            # Callbacks may be registered after the native committer is 
created.
+            # FileStoreCommit owns their close lifecycle in either execution 
path.
+            if self._commit_callbacks and not 
self._native_callbacks_registered:
+                
self._native_commit.add_commit_callback(native_commit_callback(self.table, 
self._commit_callbacks))
+                self._native_callbacks_registered = True
+            return self._native_commit, native_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.

Reply via email to