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 e194867bcc [core][python] Improve data evolution row-id conflict
diagnostics (#8491)
e194867bcc is described below
commit e194867bcc0597653ffcf79979a0f90e2eb9c99b
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Jul 7 18:19:35 2026 +0800
[core][python] Improve data evolution row-id conflict diagnostics (#8491)
This PR improves Data Evolution row-id range conflict detection so
normal data files and dedicated storage files are checked separately. It
prevents adjacent normal data files from being reported without the
blob/vector file that actually connects their row-id ranges.
---
.../paimon/operation/commit/ConflictDetection.java | 96 ++++++++++++++++---
.../operation/commit/ConflictDetectionTest.java | 58 ++++++++++++
.../tests/write/conflict_detection_test.py | 58 ++++++++++++
.../pypaimon/write/commit/conflict_detection.py | 102 ++++++++++++++++++---
4 files changed, 285 insertions(+), 29 deletions(-)
diff --git
a/paimon-core/src/main/java/org/apache/paimon/operation/commit/ConflictDetection.java
b/paimon-core/src/main/java/org/apache/paimon/operation/commit/ConflictDetection.java
index fcf00c792b..a679b9d091 100644
---
a/paimon-core/src/main/java/org/apache/paimon/operation/commit/ConflictDetection.java
+++
b/paimon-core/src/main/java/org/apache/paimon/operation/commit/ConflictDetection.java
@@ -488,25 +488,94 @@ public class ConflictDetection {
RangeHelper<SimpleFileEntry> rangeHelper =
new RangeHelper<>(SimpleFileEntry::nonNullRowIdRange);
- List<List<SimpleFileEntry>> merged =
rangeHelper.mergeOverlappingRanges(entries);
- for (List<SimpleFileEntry> group : merged) {
- List<SimpleFileEntry> dataFiles = new ArrayList<>();
- for (SimpleFileEntry f : group) {
- if (!dedicatedStorageFile(f.fileName())) {
- dataFiles.add(f);
- }
- }
- if (!rangeHelper.areAllRangesSame(dataFiles)) {
+ List<SimpleFileEntry> dataFiles =
+ entries.stream()
+ .filter(file -> !dedicatedStorageFile(file.fileName()))
+ .collect(Collectors.toList());
+
+ Optional<RuntimeException> exception =
+ checkDataFileRowIdRangeConflicts(rangeHelper, dataFiles);
+ if (exception.isPresent()) {
+ return exception;
+ }
+
+ List<SimpleFileEntry> dedicatedFiles =
+ entries.stream()
+ .filter(file -> dedicatedStorageFile(file.fileName()))
+ .collect(Collectors.toList());
+ exception = checkDedicatedFileRowIdRangeConflicts(dataFiles,
dedicatedFiles);
+ if (exception.isPresent()) {
+ return exception;
+ }
+
+ return Optional.empty();
+ }
+
+ private Optional<RuntimeException> checkDataFileRowIdRangeConflicts(
+ RangeHelper<SimpleFileEntry> rangeHelper, List<SimpleFileEntry>
dataFiles) {
+ for (List<SimpleFileEntry> dataFileGroup :
rangeHelper.mergeOverlappingRanges(dataFiles)) {
+ if (!rangeHelper.areAllRangesSame(dataFileGroup)) {
return Optional.of(
new RuntimeException(
- "For Data Evolution table, multiple 'MERGE
INTO' and 'COMPACT' operations "
+ "For Data Evolution table, multiple 'MERGE
INTO' and 'COMPACT' "
+ + "operations "
+ "have encountered conflicts, data
files: "
- + dataFiles));
+ + dataFileGroup));
}
}
+
+ return Optional.empty();
+ }
+
+ private Optional<RuntimeException> checkDedicatedFileRowIdRangeConflicts(
+ List<SimpleFileEntry> dataFiles, List<SimpleFileEntry>
dedicatedFiles) {
+ if (dedicatedFiles.isEmpty()) {
+ return Optional.empty();
+ }
+
+ RowRangeIndex dataFileRowRangeIndex = rowRangeIndex(dataFiles, false);
+
+ for (SimpleFileEntry dedicatedFile : dedicatedFiles) {
+ Range dedicatedRange = dedicatedFile.nonNullRowIdRange();
+ if (dataFileRowRangeIndex.contains(dedicatedRange)) {
+ continue;
+ }
+
+ List<Range> intersectingRanges =
+
dataFileRowRangeIndex.intersectedRanges(dedicatedRange.from, dedicatedRange.to);
+ List<SimpleFileEntry> intersectingDataFiles =
+ dataFiles.stream()
+ .filter(
+ dataFile ->
+ dataFile.nonNullRowIdRange()
+
.hasIntersection(dedicatedRange))
+ .collect(Collectors.toList());
+ String conflictReason =
+ intersectingRanges.size() > 1
+ ? "spans multiple data file ranges"
+ : "is not covered by one data file range";
+ return Optional.of(
+ new RuntimeException(
+ String.format(
+ "For Data Evolution table, multiple 'MERGE
INTO' and 'COMPACT' "
+ + "operations have encountered
conflicts, dedicated "
+ + "file %s %s %s: %s",
+ dedicatedFile,
+ dedicatedRange,
+ conflictReason,
+ intersectingDataFiles)));
+ }
+
return Optional.empty();
}
+ private static RowRangeIndex rowRangeIndex(
+ Collection<SimpleFileEntry> files, boolean mergeAdjacent) {
+ return RowRangeIndex.create(
+
files.stream().map(SimpleFileEntry::nonNullRowIdRange).collect(Collectors.toList()),
+ mergeAdjacent);
+ }
+
private Optional<RuntimeException> checkForRowIdFromSnapshot(
Snapshot latestSnapshot,
List<SimpleFileEntry> deltaEntries,
@@ -640,15 +709,14 @@ public class ConflictDetection {
return Optional.empty();
}
- List<Range> existingRanges =
+ List<SimpleFileEntry> existingDataFiles =
baseEntries.stream()
.filter(
base ->
base.firstRowId() != null
&&
!dedicatedStorageFile(base.fileName()))
- .map(SimpleFileEntry::nonNullRowIdRange)
.collect(Collectors.toList());
- RowRangeIndex existingIndex = RowRangeIndex.create(existingRanges,
false);
+ RowRangeIndex existingIndex = rowRangeIndex(existingDataFiles, false);
for (SimpleFileEntry entry : filesToCheck) {
Range rowRange = entry.nonNullRowIdRange();
diff --git
a/paimon-core/src/test/java/org/apache/paimon/operation/commit/ConflictDetectionTest.java
b/paimon-core/src/test/java/org/apache/paimon/operation/commit/ConflictDetectionTest.java
index 0695406bde..5be15f0dcc 100644
---
a/paimon-core/src/test/java/org/apache/paimon/operation/commit/ConflictDetectionTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/operation/commit/ConflictDetectionTest.java
@@ -585,6 +585,64 @@ class ConflictDetectionTest {
assertThat(detection.checkRowIdExistence(baseEntries, deltaEntries,
null)).isEmpty();
}
+ @Test
+ void testCheckRowIdRangeConflictsReportsDedicatedFileSpanningDataFiles() {
+ ConflictDetection detection = createConflictDetection();
+
+ Optional<RuntimeException> exception =
+ detection.checkConflicts(
+ snapshot(1),
+ Arrays.asList(
+ createFileEntryWithRowId("f1", ADD, 0L, 2L),
+ createFileEntryWithRowId("f2", ADD, 2L, 2L)),
+
Collections.singletonList(createFileEntryWithRowId("p1.blob", ADD, 0L, 4L)),
+ Collections.emptyList(),
+ null,
+ Snapshot.CommitKind.COMPACT);
+
+ assertThat(exception).isPresent();
+ assertThat(exception.get())
+ .hasMessageContaining("dedicated file")
+ .hasMessageContaining("p1.blob")
+ .hasMessageContaining("spans multiple data file ranges")
+ .hasMessageContaining("f1")
+ .hasMessageContaining("f2");
+ }
+
+ @Test
+ void testCheckRowIdRangeConflictsAllowsAdjacentDataFiles() {
+ ConflictDetection detection = createConflictDetection();
+
+ Optional<RuntimeException> exception =
+ detection.checkConflicts(
+ snapshot(1),
+ Arrays.asList(
+ createFileEntryWithRowId("f1", ADD, 0L, 2L),
+ createFileEntryWithRowId("f2", ADD, 2L, 2L)),
+ Collections.emptyList(),
+ Collections.emptyList(),
+ null,
+ Snapshot.CommitKind.COMPACT);
+
+ assertThat(exception).isEmpty();
+ }
+
+ @Test
+ void testCheckRowIdRangeConflictsAllowsDedicatedFileCoveredByOneDataFile()
{
+ ConflictDetection detection = createConflictDetection();
+
+ Optional<RuntimeException> exception =
+ detection.checkConflicts(
+ snapshot(1),
+
Collections.singletonList(createFileEntryWithRowId("f1", ADD, 0L, 4L)),
+
Collections.singletonList(createFileEntryWithRowId("p1.blob", ADD, 1L, 2L)),
+ Collections.emptyList(),
+ null,
+ Snapshot.CommitKind.COMPACT);
+
+ assertThat(exception).isEmpty();
+ }
+
private SimpleFileEntry createFileEntryWithRowId(
String fileName, FileKind kind, long firstRowId, long rowCount) {
return createFileEntryWithRowId(fileName, kind, EMPTY_ROW, 0,
firstRowId, rowCount);
diff --git a/paimon-python/pypaimon/tests/write/conflict_detection_test.py
b/paimon-python/pypaimon/tests/write/conflict_detection_test.py
index 6d06762b3f..62889370f5 100644
--- a/paimon-python/pypaimon/tests/write/conflict_detection_test.py
+++ b/paimon-python/pypaimon/tests/write/conflict_detection_test.py
@@ -137,6 +137,13 @@ class TestCheckRowIdExistence(unittest.TestCase):
self.assertIsNone(
detection.check_row_id_existence(base, delta, next_row_id=200))
+ def test_no_conflict_when_vector_file_range_is_covered(self):
+ detection = self._make_detection()
+ base = [_make_entry("f1", kind=0, first_row_id=0, row_count=100)]
+ delta = [_make_entry("p1.vector.0", kind=0, first_row_id=20,
row_count=10)]
+ self.assertIsNone(
+ detection.check_row_id_existence(base, delta, next_row_id=200))
+
def test_conflict_when_blob_file_range_is_not_covered(self):
detection = self._make_detection()
base = [_make_entry("f1", kind=0, first_row_id=0, row_count=100)]
@@ -208,6 +215,57 @@ class TestCheckRowIdExistence(unittest.TestCase):
detection.check_row_id_existence(base, delta, next_row_id=None))
+class TestCheckRowIdRangeConflicts(unittest.TestCase):
+
+ def _make_detection(self):
+ return ConflictDetection(
+ data_evolution_enabled=True,
+ snapshot_manager=None,
+ manifest_list_manager=None,
+ table=None,
+ commit_scanner=None,
+ )
+
+ def test_reports_dedicated_file_spanning_data_files(self):
+ detection = self._make_detection()
+ entries = [
+ _make_entry("f1", kind=0, first_row_id=0, row_count=2),
+ _make_entry("f2", kind=0, first_row_id=2, row_count=2),
+ _make_entry("p1.blob", kind=0, first_row_id=0, row_count=4),
+ ]
+
+ result = detection.check_row_id_range_conflicts("COMPACT", entries)
+
+ self.assertIsNotNone(result)
+ self.assertIn("dedicated file", str(result))
+ self.assertIn("p1.blob", str(result))
+ self.assertIn("spans multiple data file ranges", str(result))
+ self.assertIn("f1", str(result))
+ self.assertIn("f2", str(result))
+
+ def test_allows_adjacent_data_files(self):
+ detection = self._make_detection()
+ entries = [
+ _make_entry("f1", kind=0, first_row_id=0, row_count=2),
+ _make_entry("f2", kind=0, first_row_id=2, row_count=2),
+ ]
+
+ result = detection.check_row_id_range_conflicts("COMPACT", entries)
+
+ self.assertIsNone(result)
+
+ def test_allows_dedicated_file_covered_by_one_data_file(self):
+ detection = self._make_detection()
+ entries = [
+ _make_entry("f1", kind=0, first_row_id=0, row_count=4),
+ _make_entry("p1.blob", kind=0, first_row_id=1, row_count=2),
+ ]
+
+ result = detection.check_row_id_range_conflicts("COMPACT", entries)
+
+ self.assertIsNone(result)
+
+
class TestOverwriteConflictDetection(unittest.TestCase):
def _make_detection(self):
diff --git a/paimon-python/pypaimon/write/commit/conflict_detection.py
b/paimon-python/pypaimon/write/commit/conflict_detection.py
index 14b6c8a0e2..30615ec893 100644
--- a/paimon-python/pypaimon/write/commit/conflict_detection.py
+++ b/paimon-python/pypaimon/write/commit/conflict_detection.py
@@ -406,7 +406,7 @@ class ConflictDetection:
existing_index.add((
base.partition, base.bucket,
base.file.first_row_id, base.file.row_count))
- if not DataFileMeta.is_blob_file(base.file.file_name):
+ if not self._is_dedicated_file(base.file.file_name):
existing_ranges.setdefault((base.partition, base.bucket),
[]).append(
base.file.row_id_range())
@@ -416,7 +416,7 @@ class ConflictDetection:
}
for entry in files_to_check:
- if DataFileMeta.is_blob_file(entry.file.file_name):
+ if self._is_dedicated_file(entry.file.file_name):
base_ranges = existing_ranges.get((entry.partition,
entry.bucket), [])
if not entry.file.row_id_range().exclude(base_ranges):
continue
@@ -452,29 +452,101 @@ class ConflictDetection:
return None
range_helper = RangeHelper(lambda entry: entry.file.row_id_range())
- merged_groups =
range_helper.merge_overlapping_ranges(entries_with_row_id)
+ data_files = [
+ entry for entry in entries_with_row_id
+ if not self._is_dedicated_file(entry.file.file_name)
+ ]
- for group in merged_groups:
- data_files = [
- entry for entry in group
- if not DataFileMeta.is_blob_file(entry.file.file_name)
- ]
- if not range_helper.are_all_ranges_same(data_files):
+ conflict = self._check_data_file_row_id_range_conflicts(
+ range_helper, data_files)
+ if conflict is not None:
+ return conflict
+
+ dedicated_files = [
+ entry for entry in entries_with_row_id
+ if self._is_dedicated_file(entry.file.file_name)
+ ]
+ conflict = self._check_dedicated_file_row_id_range_conflicts(
+ data_files, dedicated_files)
+ if conflict is not None:
+ return conflict
+
+ return None
+
+ def _check_data_file_row_id_range_conflicts(self, range_helper,
data_files):
+ for data_file_group in
range_helper.merge_overlapping_ranges(data_files):
+ if not range_helper.are_all_ranges_same(data_file_group):
file_descriptions = [
- "{name}(rowId={row_id}, count={count})".format(
- name=entry.file.file_name,
- row_id=entry.file.first_row_id,
- count=entry.file.row_count,
- )
- for entry in data_files
+ self._file_description(entry) for entry in data_file_group
]
return RuntimeError(
"For Data Evolution table, multiple 'MERGE INTO' and
'COMPACT' "
"operations have encountered conflicts, data files: "
+ str(file_descriptions))
+ return None
+
+ def _check_dedicated_file_row_id_range_conflicts(
+ self, data_files, dedicated_files):
+ if not dedicated_files:
+ return None
+
+ data_ranges = self._data_file_row_ranges(data_files)
+
+ for dedicated_file in dedicated_files:
+ dedicated_range = dedicated_file.file.row_id_range()
+ if any(self._contains(row_range, dedicated_range) for row_range in
data_ranges):
+ continue
+
+ intersecting_ranges = [
+ row_range for row_range in data_ranges
+ if row_range.overlaps(dedicated_range)
+ ]
+ intersecting_files = [
+ self._file_description(entry)
+ for entry in data_files
+ if entry.file.row_id_range().overlaps(dedicated_range)
+ ]
+ conflict_reason = (
+ "spans multiple data file ranges"
+ if len(intersecting_ranges) > 1
+ else "is not covered by one data file range"
+ )
+ return RuntimeError(
+ "For Data Evolution table, multiple 'MERGE INTO' and 'COMPACT'
"
+ "operations have encountered conflicts, dedicated file "
+ "{file} {row_range} {reason}: {groups}".format(
+ file=self._file_description(dedicated_file),
+ row_range=dedicated_range,
+ reason=conflict_reason,
+ groups=intersecting_files))
return None
+ @staticmethod
+ def _data_file_row_ranges(data_files):
+ return Range.sort_and_merge_overlap(
+ [entry.file.row_id_range() for entry in data_files],
+ True,
+ False,
+ )
+
+ @staticmethod
+ def _contains(container, row_range):
+ return container.from_ <= row_range.from_ and container.to >=
row_range.to
+
+ @staticmethod
+ def _is_dedicated_file(file_name):
+ return (DataFileMeta.is_blob_file(file_name)
+ or DataFileMeta.is_vector_file(file_name))
+
+ @staticmethod
+ def _file_description(entry):
+ return "{name}(rowId={row_id}, count={count})".format(
+ name=entry.file.file_name,
+ row_id=entry.file.first_row_id,
+ count=entry.file.row_count,
+ )
+
def check_row_id_from_snapshot(self, latest_snapshot, commit_entries):
if not self.data_evolution_enabled:
return None