Skip to content

Commit 1dbe05f

Browse files
authored
[python] Include partition and bucket context in file deletion conflicts (#10810)
1 parent 4a7f072 commit 1dbe05f

3 files changed

Lines changed: 114 additions & 2 deletions

File tree

‎paimon-python/pypaimon/execution/python/write/commit/conflict_detection.py‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from pypaimon.manifest.schema.data_file_meta import DataFileMeta
2727
from pypaimon.manifest.schema.file_entry import FileEntry
2828
from pypaimon.table.special_fields import SpecialFields
29+
from pypaimon.utils.file_store_path_factory import _escape_partition_component
2930
from pypaimon.utils.range import Range
3031
from pypaimon.utils.range_helper import RangeHelper
3132
from pypaimon.write.row_id_conflict import RowIdExistenceConflict
@@ -226,8 +227,9 @@ def check_conflicts(
226227
if entry.kind == 1:
227228
return RuntimeError(
228229
"File deletion conflicts detected! Give up committing. "
229-
"Trying to delete file {} which is not previously added.".format(
230-
entry.file.file_name))
230+
"Trying to delete file {} which is not previously added "
231+
"(partition={}, bucket={}).".format(
232+
entry.file.file_name, self._partition_to_string(entry.partition), entry.bucket))
231233

232234
conflict = self.check_bucket_num_conflicts(merged_entries)
233235
if conflict is not None:
@@ -279,6 +281,21 @@ def check_conflicts(
279281

280282
return self.check_row_id_from_snapshot(latest_snapshot, delta_entries)
281283

284+
def _partition_to_string(self, partition):
285+
values = tuple(partition.values)
286+
if not values:
287+
return "<non-partitioned>"
288+
try:
289+
path_factory = self.table.path_factory()
290+
partition_values = path_factory._canonical_partition(values)
291+
return "/".join(
292+
"{}={}".format(_escape_partition_component(key), _escape_partition_component(partition_values[i]))
293+
for i, key in enumerate(path_factory.partition_keys)
294+
)
295+
except Exception:
296+
# Formatting diagnostics must not hide the original commit conflict.
297+
return str(values)
298+
282299
@staticmethod
283300
def check_bucket_num_conflicts(entries):
284301
total_buckets = {}

‎paimon-python/pypaimon/tests/overwrite_commit_conflict_test.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,10 @@
2929

3030
import pandas as pd
3131
import pyarrow as pa
32+
import pytest
3233

3334
from pypaimon import CatalogFactory, Schema
35+
from pypaimon.write.commit_message import CommitMessage
3436

3537

3638
class OverwriteCommitConflictTest(unittest.TestCase):
@@ -87,6 +89,39 @@ def _compact_target(self, f1_val):
8789
w.close()
8890
tc.close()
8991

92+
@pytest.mark.python_commit
93+
def test_repeated_file_delete_reports_partition_and_bucket(self):
94+
builder = self.table.new_batch_write_builder()
95+
writer, commit = builder.new_write(), builder.new_commit()
96+
try:
97+
writer.write_pandas(pd.DataFrame({'f0': [3], 'f1': ['delete-me']}))
98+
messages = writer.prepare_commit()
99+
commit.commit(messages)
100+
finally:
101+
writer.close()
102+
commit.close()
103+
104+
self.assertEqual(len(messages), 1)
105+
original = messages[0]
106+
deletion = CommitMessage(
107+
original.partition, original.bucket, [], deleted_files=original.new_files)
108+
first = builder.new_commit()
109+
try:
110+
first.commit([deletion])
111+
finally:
112+
first.close()
113+
114+
second = builder.new_commit()
115+
try:
116+
with self.assertRaises(RuntimeError) as raised:
117+
second.commit([deletion])
118+
message = str(raised.exception)
119+
self.assertIn("File deletion conflicts detected", message)
120+
self.assertIn(original.new_files[0].file_name, message)
121+
self.assertIn("partition=f0=3, bucket={}".format(original.bucket), message)
122+
finally:
123+
second.close()
124+
90125
def test_conflict_scan_runs_once_not_once_per_retry(self):
91126
K = 3
92127

‎paimon-python/pypaimon/tests/write/conflict_detection_test.py‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@
1717

1818
import unittest
1919
from dataclasses import dataclass
20+
from datetime import date
2021
from typing import List
22+
from unittest.mock import Mock
23+
24+
from parameterized import parameterized
2125

2226
from pypaimon.index.index_file_meta import IndexFileMeta
2327
from pypaimon.manifest.index_manifest_entry import IndexManifestEntry
@@ -27,6 +31,7 @@
2731
from pypaimon.schema.data_types import AtomicType, DataField
2832
from pypaimon.schema.table_schema import TableSchema
2933
from pypaimon.table.row.generic_row import GenericRow
34+
from pypaimon.utils.file_store_path_factory import FileStorePathFactory
3035
from pypaimon.execution.python.write.commit.conflict_detection import (
3136
ConflictDetection,
3237
RowIdColumnConflictChecker,
@@ -329,6 +334,61 @@ def test_bucket_num_mismatch_conflicts(self):
329334
self.assertIn("Total buckets", str(result))
330335

331336

337+
class TestFileDeletionConflictDiagnostics(unittest.TestCase):
338+
339+
@parameterized.expand([
340+
("string", [("dt", "STRING")], ["2026-10-26"], False, "dt=2026-10-26"),
341+
("multiple", [("dt", "STRING"), ("hr", "INT")], ["2026-10-26", 12], False,
342+
"dt=2026-10-26/hr=12"),
343+
("escaped", [("a/b", "STRING"), ("hr", "STRING")], ["x/y=z", None], False,
344+
"a%2Fb=x%2Fy%3Dz/hr=MISSING"),
345+
("null", [("dt", "STRING")], [None], False, "dt=MISSING"),
346+
("empty", [("dt", "STRING")], [""], False, "dt=MISSING"),
347+
("date", [("dt", "DATE")], [date(2026, 10, 26)], False, "dt=2026-10-26"),
348+
("legacy_date", [("dt", "DATE")], [date(1970, 1, 2)], True, "dt=1"),
349+
("non_partitioned", [], [], False, "<non-partitioned>"),
350+
])
351+
def test_missing_file_reports_partition_and_bucket(
352+
self, _, field_types, values, legacy, expected):
353+
fields = [DataField(i, name, AtomicType(type_name))
354+
for i, (name, type_name) in enumerate(field_types)]
355+
table = Mock()
356+
table.path_factory.return_value = FileStorePathFactory(
357+
root="/table", partition_keys=[f.name for f in fields],
358+
partition_types=[f.type for f in fields], default_part_value="MISSING",
359+
format_identifier="parquet", data_file_prefix="data-",
360+
changelog_file_prefix="changelog-", legacy_partition_name=legacy,
361+
file_suffix_include_compression=False, file_compression="zstd",
362+
data_file_path_directory="data")
363+
364+
result = self._missing_file_conflict(table, fields, values)
365+
366+
self.assertIsInstance(result, RuntimeError)
367+
self.assertIn("Trying to delete file missing.parquet which is not previously added", str(result))
368+
self.assertIn("partition={}, bucket=3".format(expected), str(result))
369+
if not fields:
370+
table.path_factory.assert_not_called()
371+
372+
def test_partition_formatting_failure_preserves_conflict(self):
373+
table = Mock()
374+
table.path_factory.return_value._canonical_partition.side_effect = ValueError("invalid partition")
375+
376+
result = self._missing_file_conflict(
377+
table, [DataField(0, "dt", AtomicType("STRING"))], ["2026-10-26"])
378+
379+
self.assertIsInstance(result, RuntimeError)
380+
self.assertIn("Trying to delete file missing.parquet which is not previously added", str(result))
381+
self.assertIn("partition=('2026-10-26',), bucket=3", str(result))
382+
self.assertNotIn("invalid partition", str(result))
383+
384+
@staticmethod
385+
def _missing_file_conflict(table, fields, values):
386+
entry = _make_entry("missing.parquet", kind=1, bucket=3)
387+
entry.partition = GenericRow(values, fields)
388+
detection = ConflictDetection(False, None, None, table, None)
389+
return detection.check_conflicts(None, [], [entry], "OVERWRITE")
390+
391+
332392
class _FakeSnapshot:
333393

334394
def __init__(self, snapshot_id, commit_kind, next_row_id=None):

0 commit comments

Comments
 (0)