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 1dbe05f9d0 [python] Include partition and bucket context in file
deletion conflicts (#10810)
1dbe05f9d0 is described below
commit 1dbe05f9d0ce553f4d467409d61664f28393e379
Author: Ran Tao <[email protected]>
AuthorDate: Tue Oct 27 14:13:39 2026 +0800
[python] Include partition and bucket context in file deletion conflicts
(#10810)
---
.../python/write/commit/conflict_detection.py | 21 +++++++-
.../tests/overwrite_commit_conflict_test.py | 35 +++++++++++++
.../tests/write/conflict_detection_test.py | 60 ++++++++++++++++++++++
3 files changed, 114 insertions(+), 2 deletions(-)
diff --git
a/paimon-python/pypaimon/execution/python/write/commit/conflict_detection.py
b/paimon-python/pypaimon/execution/python/write/commit/conflict_detection.py
index 3f6c3e5553..bd0d9df352 100644
--- a/paimon-python/pypaimon/execution/python/write/commit/conflict_detection.py
+++ b/paimon-python/pypaimon/execution/python/write/commit/conflict_detection.py
@@ -26,6 +26,7 @@ from pypaimon.manifest.index_manifest_file import
IndexManifestFile
from pypaimon.manifest.schema.data_file_meta import DataFileMeta
from pypaimon.manifest.schema.file_entry import FileEntry
from pypaimon.table.special_fields import SpecialFields
+from pypaimon.utils.file_store_path_factory import _escape_partition_component
from pypaimon.utils.range import Range
from pypaimon.utils.range_helper import RangeHelper
from pypaimon.write.row_id_conflict import RowIdExistenceConflict
@@ -226,8 +227,9 @@ class ConflictDetection:
if entry.kind == 1:
return RuntimeError(
"File deletion conflicts detected! Give up committing. "
- "Trying to delete file {} which is not previously
added.".format(
- entry.file.file_name))
+ "Trying to delete file {} which is not previously added "
+ "(partition={}, bucket={}).".format(
+ entry.file.file_name,
self._partition_to_string(entry.partition), entry.bucket))
conflict = self.check_bucket_num_conflicts(merged_entries)
if conflict is not None:
@@ -279,6 +281,21 @@ class ConflictDetection:
return self.check_row_id_from_snapshot(latest_snapshot, delta_entries)
+ def _partition_to_string(self, partition):
+ values = tuple(partition.values)
+ if not values:
+ return "<non-partitioned>"
+ try:
+ path_factory = self.table.path_factory()
+ partition_values = path_factory._canonical_partition(values)
+ return "/".join(
+ "{}={}".format(_escape_partition_component(key),
_escape_partition_component(partition_values[i]))
+ for i, key in enumerate(path_factory.partition_keys)
+ )
+ except Exception:
+ # Formatting diagnostics must not hide the original commit
conflict.
+ return str(values)
+
@staticmethod
def check_bucket_num_conflicts(entries):
total_buckets = {}
diff --git a/paimon-python/pypaimon/tests/overwrite_commit_conflict_test.py
b/paimon-python/pypaimon/tests/overwrite_commit_conflict_test.py
index 3a5ea143ca..dfb57badcb 100644
--- a/paimon-python/pypaimon/tests/overwrite_commit_conflict_test.py
+++ b/paimon-python/pypaimon/tests/overwrite_commit_conflict_test.py
@@ -29,8 +29,10 @@ import unittest
import pandas as pd
import pyarrow as pa
+import pytest
from pypaimon import CatalogFactory, Schema
+from pypaimon.write.commit_message import CommitMessage
class OverwriteCommitConflictTest(unittest.TestCase):
@@ -87,6 +89,39 @@ class OverwriteCommitConflictTest(unittest.TestCase):
w.close()
tc.close()
+ @pytest.mark.python_commit
+ def test_repeated_file_delete_reports_partition_and_bucket(self):
+ builder = self.table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ writer.write_pandas(pd.DataFrame({'f0': [3], 'f1': ['delete-me']}))
+ messages = writer.prepare_commit()
+ commit.commit(messages)
+ finally:
+ writer.close()
+ commit.close()
+
+ self.assertEqual(len(messages), 1)
+ original = messages[0]
+ deletion = CommitMessage(
+ original.partition, original.bucket, [],
deleted_files=original.new_files)
+ first = builder.new_commit()
+ try:
+ first.commit([deletion])
+ finally:
+ first.close()
+
+ second = builder.new_commit()
+ try:
+ with self.assertRaises(RuntimeError) as raised:
+ second.commit([deletion])
+ message = str(raised.exception)
+ self.assertIn("File deletion conflicts detected", message)
+ self.assertIn(original.new_files[0].file_name, message)
+ self.assertIn("partition=f0=3, bucket={}".format(original.bucket),
message)
+ finally:
+ second.close()
+
def test_conflict_scan_runs_once_not_once_per_retry(self):
K = 3
diff --git a/paimon-python/pypaimon/tests/write/conflict_detection_test.py
b/paimon-python/pypaimon/tests/write/conflict_detection_test.py
index a36b80ce2e..95b6b17de1 100644
--- a/paimon-python/pypaimon/tests/write/conflict_detection_test.py
+++ b/paimon-python/pypaimon/tests/write/conflict_detection_test.py
@@ -17,7 +17,11 @@
import unittest
from dataclasses import dataclass
+from datetime import date
from typing import List
+from unittest.mock import Mock
+
+from parameterized import parameterized
from pypaimon.index.index_file_meta import IndexFileMeta
from pypaimon.manifest.index_manifest_entry import IndexManifestEntry
@@ -27,6 +31,7 @@ from pypaimon.manifest.schema.manifest_entry import
ManifestEntry
from pypaimon.schema.data_types import AtomicType, DataField
from pypaimon.schema.table_schema import TableSchema
from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.utils.file_store_path_factory import FileStorePathFactory
from pypaimon.execution.python.write.commit.conflict_detection import (
ConflictDetection,
RowIdColumnConflictChecker,
@@ -329,6 +334,61 @@ class TestOverwriteConflictDetection(unittest.TestCase):
self.assertIn("Total buckets", str(result))
+class TestFileDeletionConflictDiagnostics(unittest.TestCase):
+
+ @parameterized.expand([
+ ("string", [("dt", "STRING")], ["2026-10-26"], False, "dt=2026-10-26"),
+ ("multiple", [("dt", "STRING"), ("hr", "INT")], ["2026-10-26", 12],
False,
+ "dt=2026-10-26/hr=12"),
+ ("escaped", [("a/b", "STRING"), ("hr", "STRING")], ["x/y=z", None],
False,
+ "a%2Fb=x%2Fy%3Dz/hr=MISSING"),
+ ("null", [("dt", "STRING")], [None], False, "dt=MISSING"),
+ ("empty", [("dt", "STRING")], [""], False, "dt=MISSING"),
+ ("date", [("dt", "DATE")], [date(2026, 10, 26)], False,
"dt=2026-10-26"),
+ ("legacy_date", [("dt", "DATE")], [date(1970, 1, 2)], True, "dt=1"),
+ ("non_partitioned", [], [], False, "<non-partitioned>"),
+ ])
+ def test_missing_file_reports_partition_and_bucket(
+ self, _, field_types, values, legacy, expected):
+ fields = [DataField(i, name, AtomicType(type_name))
+ for i, (name, type_name) in enumerate(field_types)]
+ table = Mock()
+ table.path_factory.return_value = FileStorePathFactory(
+ root="/table", partition_keys=[f.name for f in fields],
+ partition_types=[f.type for f in fields],
default_part_value="MISSING",
+ format_identifier="parquet", data_file_prefix="data-",
+ changelog_file_prefix="changelog-", legacy_partition_name=legacy,
+ file_suffix_include_compression=False, file_compression="zstd",
+ data_file_path_directory="data")
+
+ result = self._missing_file_conflict(table, fields, values)
+
+ self.assertIsInstance(result, RuntimeError)
+ self.assertIn("Trying to delete file missing.parquet which is not
previously added", str(result))
+ self.assertIn("partition={}, bucket=3".format(expected), str(result))
+ if not fields:
+ table.path_factory.assert_not_called()
+
+ def test_partition_formatting_failure_preserves_conflict(self):
+ table = Mock()
+ table.path_factory.return_value._canonical_partition.side_effect =
ValueError("invalid partition")
+
+ result = self._missing_file_conflict(
+ table, [DataField(0, "dt", AtomicType("STRING"))], ["2026-10-26"])
+
+ self.assertIsInstance(result, RuntimeError)
+ self.assertIn("Trying to delete file missing.parquet which is not
previously added", str(result))
+ self.assertIn("partition=('2026-10-26',), bucket=3", str(result))
+ self.assertNotIn("invalid partition", str(result))
+
+ @staticmethod
+ def _missing_file_conflict(table, fields, values):
+ entry = _make_entry("missing.parquet", kind=1, bucket=3)
+ entry.partition = GenericRow(values, fields)
+ detection = ConflictDetection(False, None, None, table, None)
+ return detection.check_conflicts(None, [], [entry], "OVERWRITE")
+
+
class _FakeSnapshot:
def __init__(self, snapshot_id, commit_kind, next_row_id=None):