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 f660ba4dfb [python] Advance overwrite changes cache on retry (#8368)
f660ba4dfb is described below
commit f660ba4dfb117b18ce9c26a001addd13847ec210
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Jun 29 14:06:30 2026 +0800
[python] Advance overwrite changes cache on retry (#8368)
Follow up #8358 by extending the overwrite-specific retry cache.
`OverwriteChangesProvider` now advances its cached target-partition
state with intervening raw delta entries, so overwrite retries do not
rebuild the DELETE list with another full scan when those deltas can be
applied safely.
Relates to #8359.
---
.../pypaimon/tests/overwrite_changes_cache_test.py | 58 ++++++++++-------
.../write/commit/overwrite_changes_provider.py | 73 ++++++++++++----------
2 files changed, 74 insertions(+), 57 deletions(-)
diff --git a/paimon-python/pypaimon/tests/overwrite_changes_cache_test.py
b/paimon-python/pypaimon/tests/overwrite_changes_cache_test.py
index c7fb16e628..2bbef578e6 100644
--- a/paimon-python/pypaimon/tests/overwrite_changes_cache_test.py
+++ b/paimon-python/pypaimon/tests/overwrite_changes_cache_test.py
@@ -21,8 +21,8 @@ On every OVERWRITE retry, pypaimon re-scanned the full target
partitions to
recompute the files to delete. Under concurrent writers this made each retry as
expensive as the first attempt. OverwriteChangesProvider caches the existing
files of the target partitions and, on retry, reuses them when the snapshots in
-between are all APPEND and have not touched the target partitions (verified by
a
-cheap DELTA probe), instead of a full re-scan.
+between can be applied from target-partition DELTA manifests, instead of a full
+re-scan.
The test deterministically forces ``K`` conflicts, each advancing the latest
snapshot with an append to an unrelated partition, and asserts the full scan
@@ -87,7 +87,7 @@ class OverwriteChangesCacheTest(unittest.TestCase):
# --- count provider full scans and delta probes (class-level spies)
---
counts = {'full_scan': 0, 'probe': 0}
orig_full = OverwriteChangesProvider._full_scan
- orig_probe = OverwriteChangesProvider._delta_touches_target
+ orig_probe = OverwriteChangesProvider._read_delta_entries
def spy_full(self, *a, **k):
counts['full_scan'] += 1
@@ -112,13 +112,13 @@ class OverwriteChangesCacheTest(unittest.TestCase):
fsc.snapshot_commit.commit = patched_cas
OverwriteChangesProvider._full_scan = spy_full
- OverwriteChangesProvider._delta_touches_target = spy_probe
+ OverwriteChangesProvider._read_delta_entries = spy_probe
try:
c.commit(messages)
c.close()
finally:
OverwriteChangesProvider._full_scan = orig_full
- OverwriteChangesProvider._delta_touches_target = orig_probe
+ OverwriteChangesProvider._read_delta_entries = orig_probe
# Harness sanity: we really did force K conflicts and then converged.
self.assertEqual(cas['fails'], K, "expected exactly K forced
conflicts")
@@ -146,8 +146,9 @@ class OverwriteChangesCacheTest(unittest.TestCase):
self.assertEqual(sorted(actual[actual['f0'] == 2]['f1'].tolist()),
['c'])
self.assertEqual(len(actual[actual['f0'] == 99]), K)
- def test_cache_rebuilt_when_concurrent_append_hits_target_partition(self):
- # Concurrent appends hit the overwrite target; probe sees it touched
-> rebuild.
+ def
test_cache_applies_delta_when_concurrent_append_hits_target_partition(self):
+ # Concurrent appends hit the overwrite target; retry advances cached
+ # target-partition state from APPEND deltas instead of rebuilding.
K = 3
wb = self.table.new_batch_write_builder().overwrite({'f0': 1})
@@ -160,7 +161,7 @@ class OverwriteChangesCacheTest(unittest.TestCase):
counts = {'full_scan': 0, 'probe': 0}
orig_full = OverwriteChangesProvider._full_scan
- orig_probe = OverwriteChangesProvider._delta_touches_target
+ orig_probe = OverwriteChangesProvider._read_delta_entries
def spy_full(self, *a, **k):
counts['full_scan'] += 1
@@ -182,20 +183,20 @@ class OverwriteChangesCacheTest(unittest.TestCase):
fsc.snapshot_commit.commit = patched_cas
OverwriteChangesProvider._full_scan = spy_full
- OverwriteChangesProvider._delta_touches_target = spy_probe
+ OverwriteChangesProvider._read_delta_entries = spy_probe
try:
c.commit(messages)
c.close()
finally:
OverwriteChangesProvider._full_scan = orig_full
- OverwriteChangesProvider._delta_touches_target = orig_probe
+ OverwriteChangesProvider._read_delta_entries = orig_probe
self.assertEqual(cas['fails'], K, "expected exactly K forced
conflicts")
- # Target touched each retry => cache rebuilds; full scan runs every
attempt.
- self.assertEqual(counts['full_scan'], K + 1,
- f"full scan ran {counts['full_scan']}x; cache must
rebuild "
- f"when the target partition is touched")
+ # Target APPEND deltas can be applied to cached state; no full rebuild.
+ self.assertEqual(counts['full_scan'], 1,
+ f"full scan ran {counts['full_scan']}x; APPEND deltas
"
+ f"should advance cached target state")
self.assertEqual(counts['probe'], K,
f"delta probe ran {counts['probe']}x; once per retry
(= K)")
@@ -248,9 +249,9 @@ class OverwriteChangesCacheTest(unittest.TestCase):
self.assertEqual(cas['fails'], K, "expected exactly K forced
conflicts")
return captured['provider']
- def test_cache_rebuilt_on_non_append_snapshot(self):
- # A non-APPEND (OVERWRITE) snapshot between retries forces a rebuild
even
- # though it only touches an unrelated partition.
+ def
test_cache_applies_delta_when_concurrent_overwrite_hits_target_partition(self):
+ # Concurrent overwrites produce DELETE+ADD deltas. They can be applied
+ # to the cached target state just like APPEND deltas.
K = 2
wb = self.table.new_batch_write_builder().overwrite({'f0': 1})
w = wb.new_write()
@@ -258,13 +259,21 @@ class OverwriteChangesCacheTest(unittest.TestCase):
w.write_pandas(pd.DataFrame({'f0': [1], 'f1': ['new']}))
provider = self._run_with_conflicts(
c, w.prepare_commit(), K,
- lambda i: self._overwrite_partition(99, f'z{i}'))
+ lambda i: self._overwrite_partition(1, f'z{i}'))
- self.assertEqual(provider.full_scan_count, K + 1) # rebuilt every
retry
- self.assertEqual(provider.delta_probe_count, K) # probed, bailed
at kind check
+ self.assertEqual(provider.full_scan_count, 1)
+ self.assertEqual(provider.delta_probe_count, K)
+ self.assertEqual(provider.delta_apply_count, 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'])
- def test_whole_table_overwrite_always_full_scans(self):
- # Whole-table overwrite (no partition filter) can never reuse the
cache.
+ def test_whole_table_overwrite_advances_by_append_delta(self):
+ # Whole-table overwrite (no partition filter) can still advance through
+ # APPEND deltas because every appended file belongs to the target
state.
K = 2
wb = self.table.new_batch_write_builder().overwrite()
w = wb.new_write()
@@ -274,8 +283,9 @@ class OverwriteChangesCacheTest(unittest.TestCase):
c, w.prepare_commit(), K,
lambda i: self._append(pd.DataFrame({'f0': [99], 'f1':
[f'x{i}']})))
- self.assertEqual(provider.full_scan_count, K + 1) # null filter ->
always full scan
- self.assertEqual(provider.delta_probe_count, 0) # never enters the
probe loop
+ self.assertEqual(provider.full_scan_count, 1)
+ self.assertEqual(provider.delta_probe_count, K)
+ self.assertEqual(provider.delta_apply_count, K)
read_builder = self.table.new_read_builder()
actual = read_builder.new_read().to_pandas(
diff --git a/paimon-python/pypaimon/write/commit/overwrite_changes_provider.py
b/paimon-python/pypaimon/write/commit/overwrite_changes_provider.py
index a76d5936f1..9a20dc008b 100644
--- a/paimon-python/pypaimon/write/commit/overwrite_changes_provider.py
+++ b/paimon-python/pypaimon/write/commit/overwrite_changes_provider.py
@@ -17,6 +17,8 @@
from typing import List, Optional
+from pypaimon.manifest.manifest_file_manager import ManifestFileManager
+from pypaimon.manifest.schema.file_entry import FileEntry
from pypaimon.manifest.schema.manifest_entry import ManifestEntry
from pypaimon.read.scanner.file_scanner import FileScanner
from pypaimon.snapshot.snapshot import Snapshot
@@ -28,11 +30,9 @@ class OverwriteChangesProvider:
caching the existing files of the target partitions across commit retries
to avoid repeated full scans.
- On retry, if the latest snapshot advanced, the cache is reused only when
the
- snapshots in between are all APPEND and have not touched the target
- partitions; otherwise it is rebuilt by a full scan. A whole-table overwrite
- (``partition_filter is None``) always rebuilds. Mirrors Java
- ``OverwriteChangesProvider`` (#7894).
+ On retry, if the latest snapshot advanced, the cached state is updated by
+ applying the target-partition delta manifests. Missing snapshots or
+ unreadable deltas fall back to a full scan.
"""
def __init__(self, table, manifest_list_manager, snapshot_manager,
@@ -46,9 +46,10 @@ class OverwriteChangesProvider:
self._cached_snapshot: Optional[Snapshot] = None
self._cached_entries: List[ManifestEntry] = []
- # Counters for tests / observability (mirrors Java @VisibleForTesting).
+ # Counters for tests / observability.
self.full_scan_count = 0
self.delta_probe_count = 0
+ self.delta_apply_count = 0
def provide(self, latest_snapshot: Optional[Snapshot]) ->
List[ManifestEntry]:
if latest_snapshot is None:
@@ -63,7 +64,7 @@ class OverwriteChangesProvider:
f"Cached snapshot id {self._cached_snapshot.id} is greater
than "
f"latest snapshot id {latest_snapshot.id}")
elif self._cached_snapshot.id < latest_snapshot.id:
- if not self._can_use_cache(latest_snapshot):
+ if not self._advance_cache(latest_snapshot):
self._cached_entries = self._full_scan(latest_snapshot)
self._cached_snapshot = latest_snapshot
# cached_snapshot.id == latest_snapshot.id -> reuse cache as-is
@@ -76,39 +77,45 @@ class OverwriteChangesProvider:
partition_predicate=self.partition_filter)
.read_manifest_entries(self.manifest_list_manager.read_all(latest_snapshot)))
- def _can_use_cache(self, latest_snapshot: Snapshot) -> bool:
- if self.partition_filter is None:
- # Whole-table overwrite: any concurrent commit touches the target,
- # so skip the delta probe and force a full scan.
- return False
- for snapshot_id in range(self._cached_snapshot.id + 1,
latest_snapshot.id + 1):
- self.delta_probe_count += 1
- try:
+ def _advance_cache(self, latest_snapshot: Snapshot) -> bool:
+ pending_entries = []
+ applied_count = 0
+ manifest_file_manager = ManifestFileManager(self.table)
+ try:
+ for snapshot_id in range(self._cached_snapshot.id + 1,
latest_snapshot.id + 1):
+ self.delta_probe_count += 1
snapshot =
self.snapshot_manager.get_snapshot_by_id(snapshot_id)
if snapshot is None:
return False
- if snapshot.commit_kind != "APPEND":
- # Only APPEND snapshots produce a reliable DELTA manifest
for
- # probing; other kinds may rewrite/reorganize manifests.
- return False
- if self._delta_touches_target(snapshot):
- return False
- except Exception:
- # e.g. the snapshot is being expired; a full scan is always
safe.
- return False
+ entries = self._read_delta_entries(snapshot,
manifest_file_manager)
+ if entries:
+ pending_entries.extend(entries)
+ applied_count += 1
+ if pending_entries:
+ self._cached_entries = list(
+ FileEntry.merge_entries(self._cached_entries +
pending_entries))
+ self.delta_apply_count += applied_count
+ except Exception:
+ # e.g. the snapshot is being expired; a full scan is always safe.
+ return False
return True
- def _delta_touches_target(self, snapshot: Snapshot) -> bool:
+ def _read_delta_entries(
+ self, snapshot: Snapshot,
+ manifest_file_manager: ManifestFileManager) -> List[ManifestEntry]:
delta_manifests = self.manifest_list_manager.read_delta(snapshot)
if not delta_manifests:
- return False
- # Only APPEND snapshots are probed (see _can_use_cache), so the delta
has
- # no standalone DELETEs; FileScanner's partition predicate prunes at
the
- # manifest-file level before reading entries.
- entries = (FileScanner(self.table, lambda: ([], None),
- partition_predicate=self.partition_filter)
- .read_manifest_entries(delta_manifests))
- return len(entries) > 0
+ return []
+ # Read raw delta entries so DELETE entries from OVERWRITE / COMPACT
+ # snapshots are applied instead of being discarded by FileScanner.
+ entries = []
+ for manifest_file in delta_manifests:
+ for entry in manifest_file_manager.read(manifest_file.file_name):
+ if (self.partition_filter is not None
+ and not self.partition_filter.test(entry.partition)):
+ continue
+ entries.append(entry)
+ return entries
def _build_result(self, existing_entries: List[ManifestEntry]) ->
List[ManifestEntry]:
entries = []