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 f7981ce93c [core] Show readable partitions in commit conflict entries
(#10566)
f7981ce93c is described below
commit f7981ce93c26363efc9d4a845c126aee9e90c442
Author: Ran Tao <[email protected]>
AuthorDate: Fri Oct 16 08:50:52 2026 +0800
[core] Show readable partitions in commit conflict entries (#10566)
---
.../apache/paimon/manifest/SimpleFileEntry.java | 10 +-
.../paimon/manifest/SimpleFileEntryWithDV.java | 4 +-
.../paimon/operation/commit/ConflictDetection.java | 28 ++-
.../commit/DataEvolutionConflictDetection.java | 10 +-
.../operation/commit/ConflictDetectionTest.java | 231 ++++++++++++++++++++-
5 files changed, 272 insertions(+), 11 deletions(-)
diff --git
a/paimon-core/src/main/java/org/apache/paimon/manifest/SimpleFileEntry.java
b/paimon-core/src/main/java/org/apache/paimon/manifest/SimpleFileEntry.java
index 77ae12b580..297e406414 100644
--- a/paimon-core/src/main/java/org/apache/paimon/manifest/SimpleFileEntry.java
+++ b/paimon-core/src/main/java/org/apache/paimon/manifest/SimpleFileEntry.java
@@ -238,11 +238,19 @@ public class SimpleFileEntry implements FileEntry {
@Override
public String toString() {
+ return toStringWithPartition(String.valueOf(partition));
+ }
+
+ /**
+ * Formats the entry with the supplied partition representation while
retaining its other
+ * diagnostic fields.
+ */
+ public String toStringWithPartition(String partitionString) {
return "{"
+ "kind="
+ kind
+ ", partition="
- + partition
+ + partitionString
+ ", bucket="
+ bucket
+ ", totalBuckets="
diff --git
a/paimon-core/src/main/java/org/apache/paimon/manifest/SimpleFileEntryWithDV.java
b/paimon-core/src/main/java/org/apache/paimon/manifest/SimpleFileEntryWithDV.java
index 6b4c479157..ed03c9fcda 100644
---
a/paimon-core/src/main/java/org/apache/paimon/manifest/SimpleFileEntryWithDV.java
+++
b/paimon-core/src/main/java/org/apache/paimon/manifest/SimpleFileEntryWithDV.java
@@ -79,8 +79,8 @@ public class SimpleFileEntryWithDV extends SimpleFileEntry {
}
@Override
- public String toString() {
- return super.toString() + ", {dvFileName=" + dvFileName + '}';
+ public String toStringWithPartition(String partitionString) {
+ return super.toStringWithPartition(partitionString) + ", {dvFileName="
+ dvFileName + '}';
}
/**
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 212ce0962a..3685d96311 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
@@ -653,11 +653,13 @@ public abstract class ConflictDetection {
String baseEntriesString =
"Base entries are:\n"
+ baseEntries.stream()
- .map(Object::toString)
+ .map(this::fileEntryToString)
.collect(Collectors.joining("\n"));
String changesString =
"Changes are:\n"
- +
changes.stream().map(Object::toString).collect(Collectors.joining("\n"));
+ + changes.stream()
+ .map(this::fileEntryToString)
+ .collect(Collectors.joining("\n"));
RuntimeException fullException =
new RuntimeException(
@@ -679,12 +681,12 @@ public abstract class ConflictDetection {
"Base entries are:\n"
+ baseEntries.subList(0,
Math.min(baseEntries.size(), maxEntry))
.stream()
- .map(Object::toString)
+ .map(this::fileEntryToString)
.collect(Collectors.joining("\n"));
changesString =
"Changes are:\n"
+ changes.subList(0, Math.min(changes.size(),
maxEntry)).stream()
- .map(Object::toString)
+ .map(this::fileEntryToString)
.collect(Collectors.joining("\n"));
simplifiedException =
new RuntimeException(
@@ -706,6 +708,24 @@ public abstract class ConflictDetection {
}
}
+ /**
+ * Converts a file entry to a string with a readable partition, falling
back to its original
+ * representation if partition formatting fails.
+ */
+ protected final String fileEntryToString(SimpleFileEntry entry) {
+ if (partitionType.getFieldCount() == 0) {
+ return entry.toStringWithPartition("<non-partitioned>");
+ }
+ String partitionString;
+ try {
+ partitionString =
pathFactory.getPartitionString(entry.partition());
+ } catch (RuntimeException e) {
+ // fallback to original representation
+ return entry.toString();
+ }
+ return entry.toStringWithPartition(partitionString);
+ }
+
/** Factory to create {@link ConflictDetection}. */
public interface Factory {
ConflictDetection create(CommitScanner scanner);
diff --git
a/paimon-core/src/main/java/org/apache/paimon/operation/commit/DataEvolutionConflictDetection.java
b/paimon-core/src/main/java/org/apache/paimon/operation/commit/DataEvolutionConflictDetection.java
index c1a31e1de3..b3a7c82fae 100644
---
a/paimon-core/src/main/java/org/apache/paimon/operation/commit/DataEvolutionConflictDetection.java
+++
b/paimon-core/src/main/java/org/apache/paimon/operation/commit/DataEvolutionConflictDetection.java
@@ -410,7 +410,9 @@ public class DataEvolutionConflictDetection extends
ConflictDetection {
"For Data Evolution table, multiple 'MERGE
INTO' and 'COMPACT' "
+ "operations "
+ "have encountered conflicts, data
files: "
- + dataFileGroup));
+ + dataFileGroup.stream()
+ .map(this::fileEntryToString)
+
.collect(Collectors.toList())));
}
}
return Optional.empty();
@@ -448,10 +450,12 @@ public class DataEvolutionConflictDetection extends
ConflictDetection {
"For Data Evolution table, multiple 'MERGE
INTO' and 'COMPACT' "
+ "operations have encountered
conflicts, dedicated "
+ "file %s %s %s: %s",
- dedicatedFile,
+ fileEntryToString(dedicatedFile),
dedicatedRange,
conflictReason,
- intersectingDataFiles)));
+ intersectingDataFiles.stream()
+ .map(this::fileEntryToString)
+ .collect(Collectors.toList()))));
}
return Optional.empty();
}
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 41022f77b4..2a22fd58b3 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
@@ -18,8 +18,12 @@
package org.apache.paimon.operation.commit;
+import org.apache.paimon.CoreOptions;
import org.apache.paimon.Snapshot;
import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.data.BinaryRowWriter;
+import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.fs.Path;
import org.apache.paimon.index.DeletionVectorMeta;
import org.apache.paimon.index.GlobalIndexMeta;
import org.apache.paimon.index.IndexFileHandler;
@@ -34,11 +38,19 @@ import org.apache.paimon.manifest.SimpleFileEntryWithDV;
import
org.apache.paimon.operation.commit.RetryCommitResult.CommitFailRetryResult;
import org.apache.paimon.schema.SchemaManager;
import org.apache.paimon.table.BucketMode;
+import org.apache.paimon.types.DataType;
+import org.apache.paimon.types.IntType;
import org.apache.paimon.types.RowType;
+import org.apache.paimon.types.VarCharType;
+import org.apache.paimon.utils.FileStorePathFactory;
+import org.apache.paimon.utils.Pair;
import org.apache.paimon.utils.Range;
import org.apache.paimon.utils.SnapshotManager;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
import javax.annotation.Nullable;
@@ -68,6 +80,177 @@ import static org.mockito.Mockito.when;
class ConflictDetectionTest {
+ @Test
+ void testFileEntryWithDVToStringPreservesOriginalOutput() {
+ // Guard the inherited toString() path after moving DV formatting to
toStringWithPartition.
+ BinaryRow partition = BinaryRow.singleColumn(12);
+ SimpleFileEntry entry = createFileEntryWithRowId("base", ADD,
partition, 2, 10L, 3L);
+ SimpleFileEntryWithDV entryWithDV = new SimpleFileEntryWithDV(entry,
"dv-1");
+
+ assertThat(entryWithDV.toString())
+ .isEqualTo(entry + ", {dvFileName=dv-1}")
+ .contains("partition=" + partition);
+ }
+
+ @Test
+ void testConflictEntriesShowPartitionInFullAndSimplifiedExceptions() {
+ // Exceed the 50-entry limit in both lists while preserving DV
metadata and the cause.
+ FileStorePathFactory pathFactory = mock(FileStorePathFactory.class);
+ BinaryRow partition = BinaryRow.singleColumn(12);
+ BinaryRow changedPartition = BinaryRow.singleColumn(13);
+ when(pathFactory.getPartitionString(partition)).thenReturn("hr=12/");
+
when(pathFactory.getPartitionString(changedPartition)).thenReturn("hr=13/");
+ ConflictDetection detection =
+ new AppendConflictDetection(
+ "test-table",
+ "test-user",
+ RowType.of(new IntType()),
+ pathFactory,
+ BucketMode.HASH_FIXED,
+ false,
+ null,
+ null);
+ SimpleFileEntry entry = createFileEntryWithRowId("base", ADD,
partition, 2, 10L, 3L);
+ SimpleFileEntry change =
+ createFileEntryWithRowId("changed", DELETE, changedPartition,
2, 13L, 1L);
+ List<SimpleFileEntry> baseEntries = new
ArrayList<>(Collections.nCopies(49, entry));
+ baseEntries.add(createFileEntryWithRowId("base-50", ADD, partition, 2,
13L, 1L));
+ baseEntries.add(createFileEntryWithRowId("base-51", ADD, partition, 2,
14L, 1L));
+ List<SimpleFileEntry> changes =
+ new ArrayList<>(Collections.nCopies(49, new
SimpleFileEntryWithDV(change, "dv-1")));
+ changes.add(createFileEntryWithRowId("changed-50", DELETE,
changedPartition, 2, 14L, 1L));
+ changes.add(createFileEntryWithRowId("changed-51", DELETE,
changedPartition, 2, 15L, 1L));
+ RuntimeException cause = new RuntimeException("original conflict");
+
+ Pair<RuntimeException, RuntimeException> exceptions =
+ detection.createConflictException(
+ "commit conflict", "base-user", baseEntries, changes,
cause);
+
+ assertThat(exceptions.getLeft())
+ .hasMessageContaining("partition=hr=12/")
+ .hasMessageContaining("Changes are:\n{kind=DELETE,
partition=hr=13/")
+ .hasMessageContaining("totalBuckets=1")
+ .hasMessageContaining("rowCount=3")
+ .hasMessageContaining("fileName=base-51")
+ .hasMessageContaining("fileName=changed-51")
+ .hasCause(cause);
+ assertThat(exceptions.getRight())
+ .hasMessageContaining("Base entries are:")
+ .hasMessageContaining("Changes are:\n{kind=DELETE,
partition=hr=13/")
+ .hasMessageContaining("partition=hr=12/")
+ .hasMessageContaining("dvFileName=dv-1")
+ .hasMessageContaining("fileName=base-50")
+ .hasMessageContaining("fileName=changed-50")
+ .hasMessageNotContaining("fileName=base-51")
+ .hasMessageNotContaining("fileName=changed-51")
+ .hasCause(cause);
+ }
+
+ @Test
+ void testConflictEntriesUseEscapedPartitionPath(@TempDir
java.nio.file.Path tempDir) {
+ // Use the real formatter to cover escaping and null defaults in a
multi-column partition.
+ RowType partitionType =
+ RowType.of(
+ new DataType[] {new VarCharType(10), new IntType()},
+ new String[] {"dt", "hr"});
+ FileStorePathFactory pathFactory =
+ new FileStorePathFactory(
+ new Path(tempDir.toString()),
+ partitionType,
+ "default",
+ CoreOptions.FILE_FORMAT.defaultValue().toString(),
+ CoreOptions.DATA_FILE_PREFIX.defaultValue(),
+ CoreOptions.CHANGELOG_FILE_PREFIX.defaultValue(),
+
CoreOptions.PARTITION_GENERATE_LEGACY_NAME.defaultValue(),
+
CoreOptions.FILE_SUFFIX_INCLUDE_COMPRESSION.defaultValue(),
+ CoreOptions.FILE_COMPRESSION.defaultValue(),
+ null,
+ null,
+ CoreOptions.ExternalPathStrategy.NONE,
+ null,
+ false,
+ null);
+ BinaryRow partition = new BinaryRow(2);
+ BinaryRowWriter writer = new BinaryRowWriter(partition);
+ writer.writeString(0, BinaryString.fromString("a/b=c"));
+ writer.setNullAt(1);
+ writer.complete();
+ ConflictDetection detection =
+ new AppendConflictDetection(
+ "test-table",
+ "test-user",
+ partitionType,
+ pathFactory,
+ BucketMode.HASH_FIXED,
+ false,
+ null,
+ null);
+
+ Pair<RuntimeException, RuntimeException> exceptions =
+ detection.createConflictException(
+ "commit conflict",
+ "base-user",
+ Collections.singletonList(
+ createFileEntryWithRowId("base", ADD,
partition, 2, 10L, 3L)),
+ Collections.emptyList(),
+ null);
+
+
assertThat(exceptions.getLeft()).hasMessageContaining("partition=dt=a%2Fb%3Dc/hr=default/");
+ }
+
+ @Test
+ void testConflictEntriesForNonPartitionedTable() {
+ // An empty partition schema should produce an explicit label without
a path factory.
+ ConflictDetection detection = createConflictDetection(null, false,
false);
+ SimpleFileEntry entry = createFileEntry("base", ADD);
+
+ Pair<RuntimeException, RuntimeException> exceptions =
+ detection.createConflictException(
+ "commit conflict",
+ "base-user",
+ Collections.singletonList(entry),
+ Collections.emptyList(),
+ null);
+
+ assertThat(exceptions.getLeft())
+ .isSameAs(exceptions.getRight())
+ .hasMessageContaining("partition=<non-partitioned>");
+ }
+
+ @Test
+ void testConflictEntryFormattingFailurePreservesOriginalOutputAndCause() {
+ // A formatting error must not hide the original conflict or discard
DV metadata.
+ FileStorePathFactory pathFactory = mock(FileStorePathFactory.class);
+ BinaryRow partition = BinaryRow.singleColumn(12);
+ when(pathFactory.getPartitionString(partition))
+ .thenThrow(new IllegalArgumentException("invalid partition"));
+ ConflictDetection detection =
+ new AppendConflictDetection(
+ "test-table",
+ "test-user",
+ RowType.of(new IntType()),
+ pathFactory,
+ BucketMode.HASH_FIXED,
+ false,
+ null,
+ null);
+ RuntimeException cause = new RuntimeException("original conflict");
+ SimpleFileEntry entry = createFileEntryWithRowId("base", ADD,
partition, 2, 10L, 3L);
+ SimpleFileEntryWithDV entryWithDV = new SimpleFileEntryWithDV(entry,
"dv-1");
+
+ Pair<RuntimeException, RuntimeException> exceptions =
+ detection.createConflictException(
+ "commit conflict",
+ "base-user",
+ Arrays.asList(entry, entryWithDV),
+ Collections.emptyList(),
+ cause);
+
+ assertThat(exceptions.getLeft())
+ .hasMessageContaining("Base entries are:\n" + entry + "\n" +
entryWithDV)
+ .hasCause(cause);
+ }
+
@Test
void testCreateConflictDetectionByTableType() {
ConflictDetection append = createConflictDetection(null, false, false);
@@ -1356,7 +1539,9 @@ class ConflictDetectionTest {
Snapshot.CommitKind.COMPACT);
assertThat(exception).isPresent();
-
assertThat(exception.get()).isInstanceOf(DataEvolutionRowRangeConflictException.class);
+ assertThat(exception.get())
+ .isInstanceOf(DataEvolutionRowRangeConflictException.class)
+ .hasMessageContaining("partition=<non-partitioned>");
}
@Test
@@ -1377,6 +1562,7 @@ class ConflictDetectionTest {
assertThat(exception).isPresent();
assertThat(exception.get())
.isNotInstanceOf(DataEvolutionRowRangeConflictException.class)
+ .hasMessageContaining("partition=<non-partitioned>")
.hasMessageContaining("dedicated file")
.hasMessageContaining("p1.blob")
.hasMessageContaining("spans multiple data file ranges")
@@ -1384,6 +1570,49 @@ class ConflictDetectionTest {
.hasMessageContaining("f2");
}
+ @ParameterizedTest
+ @ValueSource(strings = {"compacted", "p1.blob", "p1.vector.data"})
+ void testRowRangeConflictEntriesShowPartitions(String fileName) {
+ // Cover overlapping data files and dedicated blob/vector files
spanning adjacent ranges.
+ BinaryRow partition = BinaryRow.singleColumn(12);
+ FileStorePathFactory pathFactory = mock(FileStorePathFactory.class);
+ when(pathFactory.getPartitionString(partition)).thenReturn("hr=12/");
+ DataEvolutionConflictDetection detection =
+ new DataEvolutionConflictDetection(
+ "test-table",
+ "test-user",
+ RowType.of(new DataType[] {new IntType()}, new
String[] {"hr"}),
+ pathFactory,
+ BucketMode.HASH_FIXED,
+ false,
+ false,
+ null,
+ null,
+ null);
+
+ Optional<RuntimeException> exception =
+ detection.checkConflicts(
+ snapshot(1),
+ Arrays.asList(
+ createFileEntryWithRowId("f1", ADD, partition,
0, 0L, 2L),
+ createFileEntryWithRowId("f2", ADD, partition,
0, 2L, 2L)),
+ Collections.singletonList(
+ createFileEntryWithRowId(fileName, ADD,
partition, 0, 0L, 4L)),
+ Collections.emptyList(),
+ null,
+ Snapshot.CommitKind.COMPACT);
+
+ assertThat(exception).isPresent();
+ assertThat(exception.get())
+ .hasMessageContaining("partition=hr=12/")
+
.hasMessageNotContaining("partition=org.apache.paimon.data.BinaryRow@")
+ .hasMessageContaining("fileName=f1")
+ .hasMessageContaining("fileName=f2")
+ .hasMessageContaining("fileName=" + fileName)
+ .hasMessageContaining("rowCount=4")
+ .hasMessageContaining("firstRowId=0");
+ }
+
@Test
void testCheckRowIdRangeConflictsRejectsOverlappingNormalFiles() {
DataEvolutionConflictDetection detection = createConflictDetection();