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 f9476867de [python] Notify callbacks for successful retried commits 
(#9589)
f9476867de is described below

commit f9476867dee338db8be9d7a583c00c882d2ab999
Author: XiaoHongbo <[email protected]>
AuthorDate: Thu Sep 3 21:05:29 2026 +0800

    [python] Notify callbacks for successful retried commits (#9589)
---
 .../pypaimon/tests/write/commit_callback_test.py   | 88 ++++++++++++++++++++++
 paimon-python/pypaimon/write/file_store_commit.py  | 56 +++++++++++---
 2 files changed, 134 insertions(+), 10 deletions(-)

diff --git a/paimon-python/pypaimon/tests/write/commit_callback_test.py 
b/paimon-python/pypaimon/tests/write/commit_callback_test.py
index 036031edef..55ba65dd4d 100644
--- a/paimon-python/pypaimon/tests/write/commit_callback_test.py
+++ b/paimon-python/pypaimon/tests/write/commit_callback_test.py
@@ -23,6 +23,7 @@ import unittest
 import pyarrow as pa
 
 from pypaimon import CatalogFactory, Schema
+from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
 from pypaimon.write.commit_callback import CommitCallback, 
CommitCallbackContext
 
 
@@ -65,6 +66,22 @@ class CommitCallbackTest(unittest.TestCase):
         self.catalog.create_table(f'default.{table_name}', schema, False)
         return self.catalog.get_table(f'default.{table_name}')
 
+    def _lose_commit_response_once(self, table_commit):
+        real_commit = table_commit.file_store_commit.snapshot_commit.commit
+        attempts = []
+
+        def commit_then_lose_response(
+                base_snapshot_uuid, snapshot, statistics):
+            attempts.append(snapshot.id)
+            self.assertTrue(real_commit(
+                base_snapshot_uuid, snapshot, statistics))
+            raise TimeoutError('lost snapshot commit response')
+
+        table_commit.file_store_commit.snapshot_commit.commit = (
+            commit_then_lose_response)
+        table_commit.file_store_commit._commit_retry_wait = lambda _: None
+        return attempts
+
     def test_callback_invoked_on_commit(self):
         table = self._create_table('test_callback_invoked')
         write_builder = table.new_batch_write_builder()
@@ -115,6 +132,77 @@ class CommitCallbackTest(unittest.TestCase):
         table_write.close()
         table_commit.close()
 
+    def test_callback_invoked_after_lost_commit_response(self):
+        table = self._create_table(
+            'test_callback_response_loss',
+            options={
+                'row-tracking.enabled': 'true',
+                'data-evolution.enabled': 'true',
+            },
+        )
+        write_builder = table.new_batch_write_builder()
+        table_write = write_builder.new_write()
+        table_commit = write_builder.new_commit()
+        callback = RecordingCallback()
+        table_commit.add_commit_callback(callback)
+        attempts = self._lose_commit_response_once(table_commit)
+
+        table_write.write_arrow(pa.Table.from_pydict({
+            'id': [1, 2],
+            'name': ['a', 'b'],
+            'dt': ['p1', 'p1'],
+        }, schema=self.pa_schema))
+        messages = table_write.prepare_commit()
+        expected_paths = sorted(
+            file.file_path
+            for message in messages
+            for file in message.new_files
+        )
+        table_commit.commit(messages)
+
+        self.assertEqual([1], attempts)
+        self.assertEqual(1, len(callback.contexts))
+        self.assertEqual(1, callback.contexts[0].snapshot.id)
+        self.assertGreater(len(callback.contexts[0].commit_entries), 0)
+        for entry in callback.contexts[0].commit_entries:
+            self.assertIsNotNone(entry.file.first_row_id)
+        self.assertEqual(expected_paths, sorted(
+            entry.file.file_path
+            for entry in callback.contexts[0].commit_entries
+            if entry.kind == 0
+        ))
+        table_write.close()
+        table_commit.close()
+
+    def test_empty_overwrite_callback_after_lost_commit_response(self):
+        table = self._create_table('test_empty_overwrite_response_loss')
+        builder = table.new_batch_write_builder()
+        table_write = builder.new_write()
+        initial_commit = builder.new_commit()
+        table_write.write_arrow(pa.Table.from_pydict({
+            'id': [1],
+            'name': ['a'],
+            'dt': ['p1'],
+        }, schema=self.pa_schema))
+        initial_commit.commit(table_write.prepare_commit())
+        table_write.close()
+        initial_commit.close()
+
+        table_commit = table.new_batch_write_builder().new_commit()
+        callback = RecordingCallback()
+        table_commit.add_commit_callback(callback)
+        attempts = self._lose_commit_response_once(table_commit)
+        table_commit.file_store_commit.truncate_table(
+            BATCH_COMMIT_IDENTIFIER)
+
+        self.assertEqual([2], attempts)
+        self.assertEqual(1, len(callback.contexts))
+        self.assertEqual(2, callback.contexts[0].snapshot.id)
+        read_builder = table.new_read_builder()
+        splits = read_builder.new_scan().plan().splits()
+        self.assertEqual(0, read_builder.new_read().to_arrow(splits).num_rows)
+        table_commit.close()
+
     def test_multiple_callbacks(self):
         table = self._create_table('test_multi_callbacks')
         write_builder = table.new_batch_write_builder()
diff --git a/paimon-python/pypaimon/write/file_store_commit.py 
b/paimon-python/pypaimon/write/file_store_commit.py
index 73ecc8e950..fbcda7fffe 100644
--- a/paimon-python/pypaimon/write/file_store_commit.py
+++ b/paimon-python/pypaimon/write/file_store_commit.py
@@ -497,6 +497,13 @@ class FileStoreCommit:
         start_time_ms = int(time.time() * 1000)
         while True:
             latest_snapshot = self.snapshot_manager.get_latest_snapshot()
+            if retry_result is not None and self._is_duplicate_commit(
+                    retry_result,
+                    latest_snapshot,
+                    commit_identifier,
+                    commit_kind,
+                    notify_callbacks=True):
+                break
             commit_entries = (
                 rewritten_commit_entries
                 if rewritten_commit_entries is not None
@@ -602,7 +609,11 @@ class FileStoreCommit:
                          commit_result_may_be_uncertain: bool = False) -> 
CommitResult:
         start_millis = int(time.time() * 1000)
         if self._is_duplicate_commit(
-                retry_result, latest_snapshot, commit_identifier, commit_kind):
+                retry_result,
+                latest_snapshot,
+                commit_identifier,
+                commit_kind,
+                notify_callbacks=True):
             return SuccessResult()
 
         latest_snapshot_id = latest_snapshot.id if latest_snapshot else 0
@@ -865,14 +876,8 @@ class FileStoreCommit:
             commit_kind,
         )
 
-        if self.commit_callbacks:
-            context = CommitCallbackContext(
-                snapshot=snapshot_data,
-                commit_entries=commit_entries,
-                identifier=commit_identifier,
-            )
-            for callback in self.commit_callbacks:
-                callback.call(context)
+        self._notify_commit_callbacks(
+            snapshot_data, commit_entries, commit_identifier)
 
         return SuccessResult()
 
@@ -930,7 +935,8 @@ class FileStoreCommit:
             retry_result,
             latest_snapshot,
             commit_identifier,
-            commit_kind) -> bool:
+            commit_kind,
+            notify_callbacks=False) -> bool:
         if (isinstance(retry_result, CommitFailRetryResult)
                 and latest_snapshot is not None):
             start_check_snapshot_id = 1  # Snapshot.FIRST_SNAPSHOT_ID
@@ -956,9 +962,39 @@ class FileStoreCommit:
                         f"Commit already completed (snapshot {snapshot_id}), "
                         f"user: {self.commit_user}, identifier: 
{commit_identifier}"
                     )
+                    if notify_callbacks and self.commit_callbacks:
+                        entries = []
+                        for manifest in self.manifest_list_manager.read_delta(
+                                snapshot):
+                            entries.extend(self.manifest_file_manager.read(
+                                manifest.file_name, drop_stats=False))
+                        path_factory = self.table.path_factory()
+                        for entry in entries:
+                            file = entry.file
+                            file.file_path = file.external_path or "%s/%s" % (
+                                path_factory.bucket_path(
+                                    tuple(entry.partition.values),
+                                    entry.bucket,
+                                ).rstrip("/"),
+                                file.file_name,
+                            )
+                        self._notify_commit_callbacks(
+                            snapshot, entries, commit_identifier)
                     return True
         return False
 
+    def _notify_commit_callbacks(
+            self, snapshot, commit_entries, commit_identifier):
+        if not self.commit_callbacks:
+            return
+        context = CommitCallbackContext(
+            snapshot=snapshot,
+            commit_entries=commit_entries,
+            identifier=commit_identifier,
+        )
+        for callback in self.commit_callbacks:
+            callback.call(context)
+
     def _create_dynamic_partition_filter(self, commit_messages: 
List[CommitMessage]):
         """Build a partition filter from the unique partitions present in 
commit_messages."""
         predicate_builder = PredicateBuilder(self.table.partition_keys_fields)

Reply via email to