blob: e18439ea84a49ea0da5ee8bf963fd22ed1b70fc6 [file]
# 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(base_snapshot_uuid, 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(base_snapshot_uuid, 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(base_snapshot_uuid, 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(base_snapshot_uuid, 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(base_snapshot_uuid, 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(base_snapshot_uuid, 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(base_snapshot_uuid, 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(base_snapshot_uuid, 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()