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 62e26b8f1a [python] Reuse base entries on commit retry instead of full
re-scan (#8358)
62e26b8f1a is described below
commit 62e26b8f1a63aeb50db3a6150ec425da94aa4d5a
Author: XiaoHongbo <[email protected]>
AuthorDate: Sun Jun 28 00:19:20 2026 +0800
[python] Reuse base entries on commit retry instead of full re-scan (#8358)
---
.../tests/overwrite_commit_conflict_test.py | 332 +++++++++++++++++++++
.../pypaimon/write/commit/commit_scanner.py | 31 +-
paimon-python/pypaimon/write/file_store_commit.py | 40 ++-
3 files changed, 390 insertions(+), 13 deletions(-)
diff --git a/paimon-python/pypaimon/tests/overwrite_commit_conflict_test.py
b/paimon-python/pypaimon/tests/overwrite_commit_conflict_test.py
new file mode 100644
index 0000000000..738d455b85
--- /dev/null
+++ b/paimon-python/pypaimon/tests/overwrite_commit_conflict_test.py
@@ -0,0 +1,332 @@
+# 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.
+
+"""Conflict-detection base scan is reused across commit retries.
+
+The first attempt full-scans the changed partitions; later retries reuse that
+base and read only the incremental changes since (read_incremental_changes).
+"""
+
+import os
+import shutil
+import tempfile
+import unittest
+
+import pandas as pd
+import pyarrow as pa
+
+from pypaimon import CatalogFactory, Schema
+
+
+class OverwriteCommitConflictTest(unittest.TestCase):
+
+ def setUp(self):
+ self.temp_dir = tempfile.mkdtemp(prefix="ow_conflict_")
+ self.warehouse = os.path.join(self.temp_dir, 'wh')
+ self.catalog = CatalogFactory.create({"warehouse": self.warehouse})
+ self.catalog.create_database("test_db", True)
+
+ pa_schema = pa.schema([('f0', pa.int32()), ('f1', pa.string())])
+ # Static overwrite, scoped to the explicit partition f0=1.
+ schema = Schema.from_pyarrow_schema(
+ pa_schema, partition_keys=['f0'],
+ options={'dynamic-partition-overwrite': 'false'})
+ self.catalog.create_table('test_db.t', schema, False)
+ self.table = self.catalog.get_table('test_db.t')
+
+ # f0=1 is the overwrite target, f0=2 untouched.
+ self._append(pd.DataFrame({'f0': [1, 1, 2], 'f1': ['a', 'b', 'c']}))
+
+ def tearDown(self):
+ shutil.rmtree(self.temp_dir, ignore_errors=True)
+
+ def _append(self, df):
+ wb = self.table.new_batch_write_builder()
+ w = wb.new_write()
+ c = wb.new_commit()
+ w.write_pandas(df)
+ c.commit(w.prepare_commit())
+ w.close()
+ c.close()
+
+ def _overwrite_target(self, f1_val):
+ wb = self.table.new_batch_write_builder().overwrite({'f0': 1})
+ w = wb.new_write()
+ c = wb.new_commit()
+ w.write_pandas(pd.DataFrame({'f0': [1], 'f1': [f1_val]}))
+ c.commit(w.prepare_commit())
+ w.close()
+ c.close()
+
+ def _compact_target(self, f1_val):
+ # pypaimon has no compact API; produce a COMPACT-kind snapshot by
running
+ # an overwrite of f0=1 but labelling the snapshot COMPACT.
+ wb = self.table.new_batch_write_builder().overwrite({'f0': 1})
+ w = wb.new_write()
+ tc = wb.new_commit()
+ w.write_pandas(pd.DataFrame({'f0': [1], 'f1': [f1_val]}))
+ cfsc = tc.file_store_commit
+ orig_try = cfsc._try_commit
+ cfsc._try_commit = lambda commit_kind, *a, **k: orig_try("COMPACT",
*a, **k)
+ tc.commit(w.prepare_commit())
+ w.close()
+ tc.close()
+
+ def test_conflict_scan_runs_once_not_once_per_retry(self):
+ K = 3
+
+ wb = self.table.new_batch_write_builder().overwrite({'f0': 1})
+ w = wb.new_write()
+ c = wb.new_commit()
+ w.write_pandas(pd.DataFrame({'f0': [1], 'f1': ['new']}))
+ messages = w.prepare_commit()
+
+ fsc = c.file_store_commit
+ counts = {'full_scan': 0, 'incremental': 0}
+ orig_full = fsc.commit_scanner.read_all_entries_from_changed_partitions
+ orig_incr = fsc.commit_scanner.read_incremental_changes
+
+ def spy_full(*a, **k):
+ counts['full_scan'] += 1
+ return orig_full(*a, **k)
+
+ def spy_incr(*a, **k):
+ counts['incremental'] += 1
+ return orig_incr(*a, **k)
+
+ fsc.commit_scanner.read_all_entries_from_changed_partitions = spy_full
+ fsc.commit_scanner.read_incremental_changes = spy_incr
+
+ orig_cas = fsc.snapshot_commit.commit
+ cas = {'fails': 0}
+
+ def patched_cas(snapshot, statistics):
+ # Each conflict appends to an unrelated partition (f0=99),
advancing
+ # latest, then fails our CAS.
+ if snapshot.commit_kind == "OVERWRITE" and cas['fails'] < K:
+ cas['fails'] += 1
+ self._append(pd.DataFrame({'f0': [99], 'f1':
[f'x{cas["fails"]}']}))
+ return False
+ return orig_cas(snapshot, statistics)
+
+ fsc.snapshot_commit.commit = patched_cas
+
+ c.commit(messages)
+ c.close()
+
+ self.assertEqual(cas['fails'], K, "expected exactly K forced
conflicts")
+ self.assertEqual(counts['full_scan'], 1) # once, then incremental on
retry
+ self.assertEqual(counts['incremental'], K)
+
+ read_builder = self.table.new_read_builder()
+ actual = read_builder.new_read().to_pandas(
+ read_builder.new_scan().plan().splits())
+ self.assertEqual(sorted(actual[actual['f0'] == 1]['f1'].tolist()),
['new'])
+ self.assertEqual(sorted(actual[actual['f0'] == 2]['f1'].tolist()),
['c'])
+ self.assertEqual(len(actual[actual['f0'] == 99]), K)
+
+ def
test_incremental_merge_when_concurrent_append_hits_target_partition(self):
+ # Concurrent appends hit the target partition, so the incremental read
is
+ # non-empty and must be merged into the reused base.
+ K = 3
+
+ wb = self.table.new_batch_write_builder().overwrite({'f0': 1})
+ w = wb.new_write()
+ c = wb.new_commit()
+ w.write_pandas(pd.DataFrame({'f0': [1], 'f1': ['new']}))
+ messages = w.prepare_commit()
+
+ fsc = c.file_store_commit
+ full_scans = {'n': 0}
+ incr_lengths = []
+ captured = []
+ orig_full = fsc.commit_scanner.read_all_entries_from_changed_partitions
+ orig_incr = fsc.commit_scanner.read_incremental_changes
+ orig_check = fsc.conflict_detection.check_conflicts
+
+ def spy_full(*a, **k):
+ full_scans['n'] += 1
+ return orig_full(*a, **k)
+
+ def spy_incr(*a, **k):
+ r = orig_incr(*a, **k)
+ incr_lengths.append(None if r is None else len(r))
+ return r
+
+ def spy_check(latest_snapshot, base_entries, delta_entries, *a, **k):
+ captured.append((latest_snapshot, list(base_entries),
list(delta_entries)))
+ return orig_check(latest_snapshot, base_entries, delta_entries,
*a, **k)
+
+ fsc.commit_scanner.read_all_entries_from_changed_partitions = spy_full
+ fsc.commit_scanner.read_incremental_changes = spy_incr
+ fsc.conflict_detection.check_conflicts = spy_check
+
+ orig_cas = fsc.snapshot_commit.commit
+ cas = {'fails': 0}
+
+ def patched_cas(snapshot, statistics):
+ if snapshot.commit_kind == "OVERWRITE" and cas['fails'] < K:
+ cas['fails'] += 1
+ self._append(pd.DataFrame({'f0': [1], 'f1':
[f'y{cas["fails"]}']}))
+ return False
+ return orig_cas(snapshot, statistics)
+
+ fsc.snapshot_commit.commit = patched_cas
+
+ c.commit(messages)
+ c.close()
+
+ self.assertEqual(cas['fails'], K, "expected exactly K forced
conflicts")
+ self.assertEqual(full_scans['n'], 1)
+ self.assertEqual(len(incr_lengths), K)
+ self.assertTrue(all(incr_lengths),
+ f"expected non-empty incremental on every retry, got
{incr_lengths}")
+
+ # The incremental-merged base must equal a fresh full scan.
+ last_snapshot, merged_base, last_delta = captured[-1]
+ full = orig_full(last_snapshot, last_delta)
+ self.assertEqual(
+ set(e.identifier() for e in merged_base),
+ set(e.identifier() for e in full))
+
+ read_builder = self.table.new_read_builder()
+ actual = read_builder.new_read().to_pandas(
+ read_builder.new_scan().plan().splits())
+ self.assertEqual(sorted(actual[actual['f0'] == 1]['f1'].tolist()),
['new'])
+ self.assertEqual(sorted(actual[actual['f0'] == 2]['f1'].tolist()),
['c'])
+
+ def test_falls_back_to_full_scan_when_intermediate_snapshot_missing(self):
+ # A missing intermediate snapshot -> read_incremental_changes returns
None
+ # and the retry falls back to a full scan.
+ K = 1
+ missing_id = self.table.snapshot_manager().get_latest_snapshot().id + 1
+
+ wb = self.table.new_batch_write_builder().overwrite({'f0': 1})
+ w = wb.new_write()
+ c = wb.new_commit()
+ w.write_pandas(pd.DataFrame({'f0': [1], 'f1': ['new']}))
+ messages = w.prepare_commit()
+
+ fsc = c.file_store_commit
+ full_scans = {'n': 0}
+ incr_results = []
+ orig_full = fsc.commit_scanner.read_all_entries_from_changed_partitions
+ orig_incr = fsc.commit_scanner.read_incremental_changes
+
+ def spy_full(*a, **k):
+ full_scans['n'] += 1
+ return orig_full(*a, **k)
+
+ def spy_incr(*a, **k):
+ r = orig_incr(*a, **k)
+ incr_results.append(r)
+ return r
+
+ fsc.commit_scanner.read_all_entries_from_changed_partitions = spy_full
+ fsc.commit_scanner.read_incremental_changes = spy_incr
+
+ # Only the scanner's lookups see missing_id as absent; the commit's own
+ # manager (bound earlier) is untouched.
+ real_mgr = fsc.commit_scanner.table.snapshot_manager()
+
+ class _Wrap:
+ def __getattr__(self, name):
+ return getattr(real_mgr, name)
+
+ def get_snapshot_by_id(self, i):
+ return None if i == missing_id else
real_mgr.get_snapshot_by_id(i)
+
+ fsc.commit_scanner.table.snapshot_manager = lambda: _Wrap()
+
+ orig_cas = fsc.snapshot_commit.commit
+ cas = {'fails': 0}
+
+ def patched_cas(snapshot, statistics):
+ if snapshot.commit_kind == "OVERWRITE" and cas['fails'] < K:
+ cas['fails'] += 1
+ self._append(pd.DataFrame({'f0': [99], 'f1': ['x']}))
+ return False
+ return orig_cas(snapshot, statistics)
+
+ fsc.snapshot_commit.commit = patched_cas
+
+ c.commit(messages)
+ c.close()
+
+ self.assertEqual(cas['fails'], K, "expected exactly K forced
conflicts")
+ self.assertIn(None, incr_results) # incremental bailed on
missing
+ self.assertEqual(full_scans['n'], 2) # first attempt + fallback
+
+ def test_incremental_merge_across_non_append_snapshot(self):
+ self._assert_merge_equals_full_scan(self._overwrite_target)
+
+ def test_incremental_merge_across_compact_snapshot(self):
+ self._assert_merge_equals_full_scan(self._compact_target)
+
+ def _assert_merge_equals_full_scan(self, concurrent_fn):
+ # A non-APPEND snapshot (OVERWRITE/COMPACT, delta = ADD+DELETE) lands
+ # between retries; the merged base must still equal a fresh full scan.
+ K = 2
+
+ wb = self.table.new_batch_write_builder().overwrite({'f0': 1})
+ w = wb.new_write()
+ c = wb.new_commit()
+ w.write_pandas(pd.DataFrame({'f0': [1], 'f1': ['new']}))
+ messages = w.prepare_commit()
+
+ fsc = c.file_store_commit
+ captured = []
+ orig_check = fsc.conflict_detection.check_conflicts
+ orig_full = fsc.commit_scanner.read_all_entries_from_changed_partitions
+
+ def spy_check(latest_snapshot, base_entries, delta_entries, *a, **k):
+ captured.append((latest_snapshot, list(base_entries),
list(delta_entries)))
+ return orig_check(latest_snapshot, base_entries, delta_entries,
*a, **k)
+
+ fsc.conflict_detection.check_conflicts = spy_check
+
+ orig_cas = fsc.snapshot_commit.commit
+ cas = {'fails': 0}
+
+ def patched_cas(snapshot, statistics):
+ if snapshot.commit_kind == "OVERWRITE" and cas['fails'] < K:
+ cas['fails'] += 1
+ concurrent_fn(f'z{cas["fails"]}')
+ return False
+ return orig_cas(snapshot, statistics)
+
+ fsc.snapshot_commit.commit = patched_cas
+
+ c.commit(messages)
+ c.close()
+
+ self.assertEqual(cas['fails'], K, "expected exactly K forced
conflicts")
+
+ last_snapshot, merged_base, last_delta = captured[-1]
+ full = orig_full(last_snapshot, last_delta)
+ self.assertEqual(
+ set(e.identifier() for e in merged_base),
+ set(e.identifier() for e in full))
+
+ read_builder = self.table.new_read_builder()
+ actual = read_builder.new_read().to_pandas(
+ read_builder.new_scan().plan().splits())
+ self.assertEqual(sorted(actual[actual['f0'] == 1]['f1'].tolist()),
['new'])
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/paimon-python/pypaimon/write/commit/commit_scanner.py
b/paimon-python/pypaimon/write/commit/commit_scanner.py
index 95702f1d02..eb39bc0ecf 100644
--- a/paimon-python/pypaimon/write/commit/commit_scanner.py
+++ b/paimon-python/pypaimon/write/commit/commit_scanner.py
@@ -97,16 +97,18 @@ class CommitScanner:
).read_manifest_entries(delta_manifests)
def read_incremental_raw_entries_from_changed_partitions(self, snapshot:
Snapshot,
- commit_entries:
List[ManifestEntry]):
- """Like ``read_incremental_entries_from_changed_partitions`` but
- preserves DELETE entries (kind=1). The regular method funnels through
- ``read_entries_parallel`` which discards standalone DELETEs.
+ commit_entries:
List[ManifestEntry],
+
partition_filter=None):
+ """Like ``read_incremental_entries_from_changed_partitions`` but
preserves
+ DELETE entries (kind=1). ``partition_filter`` may be passed to avoid
+ rebuilding it per call.
"""
delta_manifests = self.manifest_list_manager.read_delta(snapshot)
if not delta_manifests:
return []
- partition_filter =
self._build_partition_filter_from_entries(commit_entries)
+ if partition_filter is None:
+ partition_filter =
self._build_partition_filter_from_entries(commit_entries)
mfm = ManifestFileManager(self.table)
entries = []
for mf in delta_manifests:
@@ -116,6 +118,25 @@ class CommitScanner:
entries.append(entry)
return entries
+ def read_incremental_changes(self, from_snapshot: Snapshot, to_snapshot:
Snapshot,
+ commit_entries: List[ManifestEntry]) ->
Optional[List[ManifestEntry]]:
+ """Delta entries (incl. DELETEs) in ``(from_snapshot, to_snapshot]``,
+ changed-partition filtered, so a retry can reuse the prior base and
read
+ only the changes since. Returns None on a missing snapshot (caller then
+ full-scans). Mirrors Java ``CommitScanner#readIncrementalChanges``.
+ """
+ snapshot_manager = self.table.snapshot_manager()
+ partition_filter =
self._build_partition_filter_from_entries(commit_entries)
+ entries = []
+ for snapshot_id in range(from_snapshot.id + 1, to_snapshot.id + 1):
+ snapshot = snapshot_manager.get_snapshot_by_id(snapshot_id)
+ if snapshot is None:
+ return None
+ entries.extend(
+ self.read_incremental_raw_entries_from_changed_partitions(
+ snapshot, commit_entries, partition_filter))
+ return entries
+
def _build_partition_filter_from_entries(self, entries:
List[ManifestEntry]):
"""Build a partition predicate that matches all partitions present in
the given entries.
diff --git a/paimon-python/pypaimon/write/file_store_commit.py
b/paimon-python/pypaimon/write/file_store_commit.py
index 3149a3678d..304792b412 100644
--- a/paimon-python/pypaimon/write/file_store_commit.py
+++ b/paimon-python/pypaimon/write/file_store_commit.py
@@ -27,6 +27,7 @@ from pypaimon.manifest.manifest_file_manager import
ManifestFileManager
from pypaimon.manifest.manifest_file_merger import ManifestFileMerger
from pypaimon.manifest.manifest_list_manager import ManifestListManager
from pypaimon.manifest.schema.data_file_meta import DataFileMeta
+from pypaimon.manifest.schema.file_entry import FileEntry
from pypaimon.manifest.schema.manifest_entry import ManifestEntry
from pypaimon.manifest.schema.manifest_file_meta import ManifestFileMeta
@@ -63,9 +64,13 @@ class SuccessResult(CommitResult):
class RetryResult(CommitResult):
- def __init__(self, latest_snapshot, exception: Optional[Exception] = None):
+ def __init__(self, latest_snapshot, exception: Optional[Exception] = None,
+ base_data_files: Optional[List[ManifestEntry]] = None):
self.latest_snapshot = latest_snapshot
self.exception = exception
+ # Base entries as of latest_snapshot, carried so the next attempt
reuses
+ # them and reads only the incremental changes.
+ self.base_data_files = base_data_files
def is_success(self) -> bool:
return False
@@ -374,17 +379,36 @@ class FileStoreCommit:
# process snapshot
new_snapshot_id = latest_snapshot.id + 1 if latest_snapshot else 1
- # Conflict detection: read base entries from latest snapshot, then
check conflicts
+ # Base entries for conflict detection. On retry, reuse the previous
+ # attempt's base + read only the incremental changes (mirrors Java).
+ base_data_files = None
if detect_conflicts and latest_snapshot is not None:
- base_entries =
self.commit_scanner.read_all_entries_from_changed_partitions(
- latest_snapshot, commit_entries)
+ incremental = None
+ if (retry_result is not None
+ and retry_result.latest_snapshot is not None
+ and retry_result.base_data_files is not None):
+ incremental = self.commit_scanner.read_incremental_changes(
+ retry_result.latest_snapshot, latest_snapshot,
commit_entries)
+ if incremental is not None:
+ base_data_files = list(retry_result.base_data_files)
+ if incremental:
+ base_data_files.extend(incremental)
+ base_data_files = FileEntry.merge_entries(base_data_files)
+ else:
+ # First attempt, or incremental could not be built (missing
+ # snapshot): scan the changed partitions in full.
+ base_data_files =
self.commit_scanner.read_all_entries_from_changed_partitions(
+ latest_snapshot, commit_entries)
+
conflict_exception = self.conflict_detection.check_conflicts(
- latest_snapshot, base_entries, commit_entries, commit_kind)
+ latest_snapshot, base_data_files, commit_entries, commit_kind)
if conflict_exception is not None:
if allow_rollback and self.rollback is not None:
if self.rollback.try_to_rollback(latest_snapshot):
- return RetryResult(latest_snapshot, conflict_exception)
+ # Rolled back: base/snapshot no longer valid; next
attempt
+ # re-scans from scratch (matches Java
RollbackRetryResult).
+ return RetryResult(None, conflict_exception)
raise conflict_exception
# Apply row tracking logic after conflict detection (matches Java
ordering)
@@ -492,11 +516,11 @@ class FileStoreCommit:
commit_kind,
commit_time_s,
)
- return RetryResult(latest_snapshot, None)
+ return RetryResult(latest_snapshot, None,
base_data_files=base_data_files)
except Exception as e:
# Commit exception, not sure about the situation and should not
clean up the files
logger.warning("Retry commit for exception.", exc_info=True)
- return RetryResult(latest_snapshot, e)
+ return RetryResult(latest_snapshot, e,
base_data_files=base_data_files)
logger.info(
"Successfully commit snapshot %d to table %s by user %s "