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 18d9d74bcc [spark] Rebase data evolution merge updates after
compaction (#9091)
18d9d74bcc is described below
commit 18d9d74bcc82f0375758fb919932aa3ffb84f754
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Aug 7 15:22:06 2026 +0800
[spark] Rebase data evolution merge updates after compaction (#9091)
---
docs/docs/multimodal-table/data-evolution.mdx | 9 +
.../paimon/operation/commit/ConflictDetection.java | 12 +-
.../commit/RowIdExistenceConflictException.java | 34 ++
.../operation/commit/ConflictDetectionTest.java | 8 +-
.../MergeIntoPaimonDataEvolutionTable.scala | 13 +-
.../DataEvolutionRowIdConflictRewriter.scala | 400 +++++++++++++++++++++
.../MergeIntoPaimonDataEvolutionTable.scala | 13 +-
.../paimon/spark/sql/RowTrackingTestBase.scala | 59 ++-
8 files changed, 530 insertions(+), 18 deletions(-)
diff --git a/docs/docs/multimodal-table/data-evolution.mdx
b/docs/docs/multimodal-table/data-evolution.mdx
index f630c7dc3e..e40b976783 100644
--- a/docs/docs/multimodal-table/data-evolution.mdx
+++ b/docs/docs/multimodal-table/data-evolution.mdx
@@ -211,6 +211,15 @@ Notes:
`spark.paimon.write.data-evolution.update-conflict-retry.max-attempts` and
retry wait time with
`spark.paimon.write.data-evolution.update-conflict-retry.wait-ms`.
+- If concurrent compaction changes row-ID file boundaries after Spark SQL
+ `MERGE INTO` stages regular partial-column files, Spark rebases those staged
+ files onto the latest boundaries before committing instead of rerunning the
+ MERGE source and join. Spark performs this rewrite with distributed DataFrame
+ processing, so the PyPaimon-only
+ `data-evolution.row-id-conflict-rewrite.max-size` limit does not apply.
+ Recovery does not apply when deletion vectors are enabled or when
+ existing-row BLOB or VECTOR files are staged, and it does not hide logical
+ concurrent-update conflicts.
- In Spark SQL, `MERGE INTO` supports `WHEN NOT MATCHED BY SOURCE` for delete
actions on Data Evolution tables.
- The Flink `data_evolution_merge_into` procedure currently supports updating
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 99bae20226..ca7e57224e 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
@@ -811,15 +811,9 @@ public class ConflictDetection {
return Optional.empty();
}
- private RuntimeException rowIdExistenceConflict(SimpleFileEntry entry) {
- return new RuntimeException(
- String.format(
- "Row ID existence conflict: file '%s' references "
- + "firstRowId=%d, rowCount=%d in bucket %d, "
- + "but no matching file exists in the current
snapshot. "
- + "The referenced file may have been rewritten
by a "
- + "concurrent compaction or removed by an
overwrite.",
- entry.fileName(), entry.firstRowId(),
entry.rowCount(), entry.bucket()));
+ private RowIdExistenceConflictException
rowIdExistenceConflict(SimpleFileEntry entry) {
+ return new RowIdExistenceConflictException(
+ entry.fileName(), entry.firstRowId(), entry.rowCount(),
entry.bucket());
}
private static boolean dedicatedStorageFile(String fileName) {
diff --git
a/paimon-core/src/main/java/org/apache/paimon/operation/commit/RowIdExistenceConflictException.java
b/paimon-core/src/main/java/org/apache/paimon/operation/commit/RowIdExistenceConflictException.java
new file mode 100644
index 0000000000..4b14f928c2
--- /dev/null
+++
b/paimon-core/src/main/java/org/apache/paimon/operation/commit/RowIdExistenceConflictException.java
@@ -0,0 +1,34 @@
+/*
+ * 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.
+ */
+
+package org.apache.paimon.operation.commit;
+
+/** Conflict caused by a staged file referencing a row-id range absent from
the latest snapshot. */
+public final class RowIdExistenceConflictException extends RuntimeException {
+
+ RowIdExistenceConflictException(String fileName, long firstRowId, long
rowCount, int bucket) {
+ super(
+ String.format(
+ "Row ID existence conflict: file '%s' references "
+ + "firstRowId=%d, rowCount=%d in bucket %d, "
+ + "but no matching file exists in the current
snapshot. "
+ + "The referenced file may have been rewritten
by a "
+ + "concurrent compaction or removed by an
overwrite.",
+ fileName, firstRowId, rowCount, bucket));
+ }
+}
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 d73cfac0e0..71757cb49d 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
@@ -461,7 +461,9 @@ class ConflictDetectionTest {
detection.checkRowIdExistence(
baseEntries, deltaEntries, 100L,
Snapshot.CommitKind.APPEND);
assertThat(result).isPresent();
- assertThat(result.get().getMessage()).contains("Row ID existence
conflict");
+ assertThat(result.get())
+ .isInstanceOf(RowIdExistenceConflictException.class)
+ .hasMessageContaining("Row ID existence conflict");
}
@Test
@@ -478,7 +480,9 @@ class ConflictDetectionTest {
detection.checkRowIdExistence(
baseEntries, deltaEntries, 200L,
Snapshot.CommitKind.APPEND);
assertThat(result).isPresent();
- assertThat(result.get().getMessage()).contains("Row ID existence
conflict");
+ assertThat(result.get())
+ .isInstanceOf(RowIdExistenceConflictException.class)
+ .hasMessageContaining("Row ID existence conflict");
}
@Test
diff --git
a/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonDataEvolutionTable.scala
b/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonDataEvolutionTable.scala
index 79e09bc380..4585f75885 100644
---
a/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonDataEvolutionTable.scala
+++
b/paimon-spark/paimon-spark-4.0/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonDataEvolutionTable.scala
@@ -287,9 +287,16 @@ case class MergeIntoPaimonDataEvolutionTable(
if (readSnapshot != null) {
writer.rowIdCheckConflict(readSnapshot.id())
}
- writer.commit(
- matchedResult.commitMessages ++ deleteCommit ++ insertCommit,
- Snapshot.Operation.MERGE)
+ DataEvolutionRowIdConflictCommitter.commit(
+ sparkSession,
+ table,
+ targetRelation,
+ writer,
+ matchedResult.commitMessages,
+ deleteCommit ++ insertCommit,
+ if (readSnapshot == null) -1L else readSnapshot.id(),
+ Snapshot.Operation.MERGE
+ )
} finally {
targetActionCleanup()
if (persistSourceDss.isDefined) {
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/DataEvolutionRowIdConflictRewriter.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/DataEvolutionRowIdConflictRewriter.scala
new file mode 100644
index 0000000000..5512c735fb
--- /dev/null
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/DataEvolutionRowIdConflictRewriter.scala
@@ -0,0 +1,400 @@
+/*
+ * 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.
+ */
+
+package org.apache.paimon.spark.commands
+
+import org.apache.paimon.Snapshot
+import org.apache.paimon.data.BinaryRow
+import org.apache.paimon.format.blob.BlobFileFormat.isBlobFile
+import org.apache.paimon.io.{DataFileMeta, DataIncrement}
+import org.apache.paimon.operation.commit.RowIdExistenceConflictException
+import org.apache.paimon.spark.util.ScanPlanHelper
+import org.apache.paimon.table.{FileStoreTable, SpecialFields}
+import org.apache.paimon.table.sink.{CommitMessage, CommitMessageImpl}
+import org.apache.paimon.table.source.DataSplit
+import org.apache.paimon.types.VectorType.isVectorStoreFile
+import org.apache.paimon.utils.{ExceptionUtils, Range, RetryWaiter}
+
+import org.apache.spark.sql.{Row, SparkSession}
+import org.apache.spark.sql.PaimonUtils.createDataset
+import org.apache.spark.sql.catalyst.analysis.SimpleAnalyzer.resolver
+import org.apache.spark.sql.catalyst.expressions.AttributeReference
+import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation
+import org.apache.spark.sql.functions.{col, udf}
+import org.apache.spark.sql.paimon.shims.SparkShimLoader
+import org.slf4j.LoggerFactory
+
+import scala.collection.JavaConverters._
+import scala.collection.immutable
+
+/** Rebase staged partial-column files onto current row-id file boundaries. */
+private[spark] class DataEvolutionRowIdConflictRewriter(
+ table: FileStoreTable,
+ targetRelation: DataSourceV2Relation)
+ extends ScanPlanHelper {
+
+ import DataEvolutionRowIdConflictRewriter._
+
+ def rewrite(
+ sparkSession: SparkSession,
+ latestSnapshot: Snapshot,
+ commitMessages: Seq[CommitMessage]): Option[RewriteResult] = {
+ if (table.coreOptions().deletionVectorsEnabled()) {
+ return None
+ }
+
+ val messageImpls = commitMessages.collect { case message:
CommitMessageImpl => message }
+ if (messageImpls.size != commitMessages.size) {
+ return None
+ }
+
+ val stagedFiles = messageImpls.flatMap(
+ message =>
+ message
+ .newFilesIncrement()
+ .newFiles()
+ .asScala
+ .map(file => StagedFile(message, file)))
+ val nextRowId =
Option(latestSnapshot.nextRowId()).map(_.longValue()).getOrElse(return None)
+
+ if (
+ stagedFiles.exists(
+ staged =>
+ isDedicatedFile(staged.file) && staged.file.firstRowId() != null &&
+ staged.file.firstRowId() < nextRowId)
+ ) {
+ return None
+ }
+
+ val currentSplits = table
+ .newSnapshotReader()
+ .withSnapshot(latestSnapshot)
+ .read()
+ .splits()
+ .asScala
+ .collect { case split: DataSplit => split }
+ .toSeq
+ val currentFiles = currentSplits.flatMap(
+ split =>
+ split
+ .dataFiles()
+ .asScala
+ .filter(isNormalRowIdFile)
+ .map(file => CurrentFile(split, file)))
+ val currentExactRanges = currentFiles.map(file => rangeKey(file.split,
file.file)).toSet
+ val candidates = stagedFiles.filter(
+ staged =>
+ isRewriteCandidate(staged.file, nextRowId) &&
+ !currentExactRanges.contains(rangeKey(staged.message, staged.file)))
+
+ if (candidates.isEmpty || !rangesAreStillCovered(currentFiles,
candidates)) {
+ return None
+ }
+
+ val affectedSplits = currentSplits.flatMap(
+ split => {
+ val filtered = split.filterDataFile(
+ file =>
+ isNormalRowIdFile(file) && candidates.exists(
+ candidate =>
+ sameBucket(split, candidate.message) &&
+
file.nonNullRowIdRange().hasIntersection(candidate.file.nonNullRowIdRange())))
+ if (filtered.isPresent) Some(filtered.get()) else None
+ })
+ val firstRowIds: immutable.IndexedSeq[Long] = affectedSplits
+ .flatMap(_.dataFiles().asScala)
+ .filter(isNormalRowIdFile)
+ .map(_.firstRowId().longValue())
+ .distinct
+ .sorted
+ .toIndexedSeq
+
+ val rewrittenMessages = candidates
+ .groupBy(staged => staged.file.writeCols().asScala.toSeq)
+ .toSeq
+ .flatMap {
+ case (columnNames, files) =>
+ rewriteFiles(sparkSession, columnNames, files, affectedSplits,
firstRowIds)
+ }
+
+ val candidateKeys = candidates.map(staged => fileKey(staged.message,
staged.file)).toSet
+ val remainingMessages =
+ messageImpls.flatMap(message => withoutCandidates(message,
candidateKeys))
+ Some(RewriteResult(remainingMessages ++ rewrittenMessages,
candidates.size))
+ }
+
+ private def rewriteFiles(
+ sparkSession: SparkSession,
+ columnNames: Seq[String],
+ stagedFiles: Seq[StagedFile],
+ affectedSplits: Seq[DataSplit],
+ firstRowIds: immutable.IndexedSeq[Long]): Seq[CommitMessage] = {
+ val stagedSplits = stagedFiles.map(
+ staged =>
+ DataSplit
+ .builder()
+ .withPartition(staged.message.partition())
+ .withBucket(staged.message.bucket())
+ .withTotalBuckets(staged.message.totalBuckets())
+ .withBucketPath(
+ table
+ .store()
+ .pathFactory()
+ .bucketPath(staged.message.partition(), staged.message.bucket())
+ .toString)
+ .withDataFiles(java.util.Collections.singletonList(staged.file))
+ .rawConvertible(true)
+ .build())
+
+ val relationAttributes = (targetRelation.output ++
targetRelation.metadataOutput).collect {
+ case attribute: AttributeReference => attribute
+ }
+ def attribute(name: String): AttributeReference = {
+ relationAttributes
+ .find(attr => resolver(attr.name, name))
+ .getOrElse(throw new RuntimeException(s"Cannot find column $name for
row-id rewrite."))
+ }
+
+ val rowIdAttribute = attribute(ROW_ID_NAME)
+ val readOutput = columnNames.map(attribute) :+ rowIdAttribute
+ val stagedRelation = createNewScanPlan(stagedSplits, targetRelation)
+ val readPlan = SparkShimLoader.shim.copyDataSourceV2Relation(
+ stagedRelation,
+ stagedRelation.table,
+ readOutput)
+ val firstRowIdUdf = udf((rowId: Long) => floorBinarySearch(firstRowIds,
rowId))
+ val rewrittenRows = createDataset(sparkSession, readPlan)
+ .select((columnNames.map(quotedColumn) :+ quotedColumn(ROW_ID_NAME)): _*)
+ .withColumn(FIRST_ROW_ID_NAME, firstRowIdUdf(quotedColumn(ROW_ID_NAME)))
+ .repartition(col(FIRST_ROW_ID_NAME))
+ .sortWithinPartitions(FIRST_ROW_ID_NAME, ROW_ID_NAME)
+
+ DataEvolutionPaimonWriter(table, affectedSplits)
+ .writePartialFields(rewrittenRows, columnNames)
+ }
+
+ private def rangesAreStillCovered(
+ currentFiles: Seq[CurrentFile],
+ candidates: Seq[StagedFile]): Boolean = {
+ val currentRanges = currentFiles
+ .groupBy(current => bucketKey(current.split))
+ .map {
+ case (key, files) =>
+ key ->
Range.sortAndMergeOverlap(files.map(_.file.nonNullRowIdRange()).asJava, true)
+ }
+ candidates.forall(
+ candidate => {
+ val ranges = currentRanges.getOrElse(
+ bucketKey(candidate.message),
+ java.util.Collections.emptyList[Range]())
+ candidate.file.nonNullRowIdRange().exclude(ranges).isEmpty
+ })
+ }
+
+ private def withoutCandidates(
+ message: CommitMessageImpl,
+ candidates: Set[FileKey]): Option[CommitMessage] = {
+ val increment = message.newFilesIncrement()
+ val newFiles = increment
+ .newFiles()
+ .asScala
+ .filterNot(file => candidates.contains(fileKey(message, file)))
+ .asJava
+ val remaining = new CommitMessageImpl(
+ message.partition(),
+ message.bucket(),
+ message.totalBuckets(),
+ new DataIncrement(
+ newFiles,
+ increment.deletedFiles(),
+ increment.changelogFiles(),
+ increment.newIndexFiles(),
+ increment.deletedIndexFiles()),
+ message.compactIncrement()
+ )
+ if (remaining.isEmpty) None else Some(remaining)
+ }
+}
+
+private[spark] object DataEvolutionRowIdConflictRewriter {
+
+ private val ROW_ID_NAME = "_ROW_ID"
+ private val FIRST_ROW_ID_NAME = "_FIRST_ROW_ID"
+
+ private case class StagedFile(message: CommitMessageImpl, file: DataFileMeta)
+
+ private case class CurrentFile(split: DataSplit, file: DataFileMeta)
+
+ private case class BucketKey(partition: BinaryRow, bucket: Int)
+
+ private case class FileKey(partition: BinaryRow, bucket: Int, fileName:
String)
+
+ private case class RangeKey(partition: BinaryRow, bucket: Int, firstRowId:
Long, rowCount: Long)
+
+ case class RewriteResult(commitMessages: Seq[CommitMessage],
rewrittenFileCount: Int)
+
+ private def isRewriteCandidate(file: DataFileMeta, nextRowId: Long): Boolean
= {
+ isNormalRowIdFile(file) &&
+ file.firstRowId() < nextRowId &&
+ Option(file.writeCols()).exists(
+ columns =>
+ !columns.isEmpty && columns.asScala.forall(column =>
!SpecialFields.isSystemField(column)))
+ }
+
+ private def isNormalRowIdFile(file: DataFileMeta): Boolean = {
+ file.firstRowId() != null && !isDedicatedFile(file)
+ }
+
+ private def isDedicatedFile(file: DataFileMeta): Boolean = {
+ isBlobFile(file.fileName()) || isVectorStoreFile(file.fileName())
+ }
+
+ private def bucketKey(split: DataSplit): BucketKey = {
+ BucketKey(split.partition(), split.bucket())
+ }
+
+ private def bucketKey(message: CommitMessage): BucketKey = {
+ BucketKey(message.partition(), message.bucket())
+ }
+
+ private def sameBucket(split: DataSplit, message: CommitMessage): Boolean = {
+ bucketKey(split) == bucketKey(message)
+ }
+
+ private def fileKey(message: CommitMessage, file: DataFileMeta): FileKey = {
+ FileKey(message.partition(), message.bucket(), file.fileName())
+ }
+
+ private def rangeKey(split: DataSplit, file: DataFileMeta): RangeKey = {
+ RangeKey(split.partition(), split.bucket(), file.firstRowId(),
file.rowCount())
+ }
+
+ private def rangeKey(message: CommitMessage, file: DataFileMeta): RangeKey =
{
+ RangeKey(message.partition(), message.bucket(), file.firstRowId(),
file.rowCount())
+ }
+
+ private def quotedColumn(name: String) = {
+ col("`" + name.replace("`", "``") + "`")
+ }
+
+ private def floorBinarySearch(firstRowIds: immutable.IndexedSeq[Long],
rowId: Long): Long = {
+ val index =
+ java.util.Collections.binarySearch(firstRowIds.map(Long.box).asJava,
Long.box(rowId))
+ if (index >= 0) {
+ firstRowIds(index)
+ } else {
+ val insertionPoint = -index - 1
+ if (insertionPoint == 0) {
+ throw new IllegalArgumentException(
+ s"Row ID $rowId is less than the first current row ID boundary.")
+ }
+ firstRowIds(insertionPoint - 1)
+ }
+ }
+}
+
+private[spark] object DataEvolutionRowIdConflictCommitter {
+
+ private val LOG = LoggerFactory.getLogger(getClass)
+
+ def commit(
+ sparkSession: SparkSession,
+ table: FileStoreTable,
+ targetRelation: DataSourceV2Relation,
+ writer: PaimonSparkWriter,
+ updateMessages: Seq[CommitMessage],
+ otherMessages: Seq[CommitMessage],
+ readSnapshotId: Long,
+ operation: Snapshot.Operation): Unit = {
+ var currentUpdateMessages = updateMessages
+ var retryCount = 0
+ val startMillis = System.currentTimeMillis()
+ val options = table.coreOptions()
+ val retryWaiter = new RetryWaiter(options.commitMinRetryWait(),
options.commitMaxRetryWait())
+ val rewriter = new DataEvolutionRowIdConflictRewriter(table,
targetRelation)
+
+ val latestBeforeCommit = table.snapshotManager().latestSnapshot()
+ if (latestBeforeCommit != null && latestBeforeCommit.id() !=
readSnapshotId) {
+ rewriter.rewrite(sparkSession, latestBeforeCommit,
currentUpdateMessages).foreach {
+ result =>
+ currentUpdateMessages = result.commitMessages
+ logRewrite(table, latestBeforeCommit, result)
+ }
+ }
+
+ while (true) {
+ try {
+ writer.commit(currentUpdateMessages ++ otherMessages, operation)
+ return
+ } catch {
+ case conflict: RuntimeException if isRowIdExistenceConflict(conflict)
=>
+ val elapsedBeforeRewrite = System.currentTimeMillis() - startMillis
+ if (
+ elapsedBeforeRewrite > options.commitTimeout() ||
+ retryCount >= options.commitMaxRetries()
+ ) {
+ throw conflict
+ }
+
+ val latestSnapshot = table.snapshotManager().latestSnapshot()
+ if (latestSnapshot == null) {
+ throw conflict
+ }
+
+ val rewriteResult =
+ try {
+ rewriter.rewrite(sparkSession, latestSnapshot,
currentUpdateMessages)
+ } catch {
+ case rewriteError: RuntimeException =>
+ throw new RuntimeException(
+ s"${conflict.getMessage} ${rewriteError.getMessage}",
+ conflict)
+ }
+ if (rewriteResult.isEmpty) {
+ throw conflict
+ }
+
+ currentUpdateMessages = rewriteResult.get.commitMessages
+ val elapsedMillis = System.currentTimeMillis() - startMillis
+ if (elapsedMillis > options.commitTimeout()) {
+ throw conflict
+ }
+
+ logRewrite(table, latestSnapshot, rewriteResult.get)
+ retryWaiter.retryWait(retryCount)
+ retryCount += 1
+ }
+ }
+ }
+
+ private def isRowIdExistenceConflict(error: Throwable): Boolean = {
+ ExceptionUtils.findThrowable(error,
classOf[RowIdExistenceConflictException]).isPresent
+ }
+
+ private def logRewrite(
+ table: FileStoreTable,
+ snapshot: Snapshot,
+ result: DataEvolutionRowIdConflictRewriter.RewriteResult): Unit = {
+ LOG.info(
+ "Rewrote {} stale row-id file(s) against snapshot {} before committing
to table {}.",
+ Int.box(result.rewrittenFileCount),
+ Long.box(snapshot.id()),
+ table.name()
+ )
+ }
+}
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonDataEvolutionTable.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonDataEvolutionTable.scala
index 479515ca46..e739036303 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonDataEvolutionTable.scala
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonDataEvolutionTable.scala
@@ -287,9 +287,16 @@ case class MergeIntoPaimonDataEvolutionTable(
if (readSnapshot != null) {
writer.rowIdCheckConflict(readSnapshot.id())
}
- writer.commit(
- matchedResult.commitMessages ++ deleteCommit ++ insertCommit,
- Snapshot.Operation.MERGE)
+ DataEvolutionRowIdConflictCommitter.commit(
+ sparkSession,
+ table,
+ targetRelation,
+ writer,
+ matchedResult.commitMessages,
+ deleteCommit ++ insertCommit,
+ if (readSnapshot == null) -1L else readSnapshot.id(),
+ Snapshot.Operation.MERGE
+ )
} finally {
targetActionCleanup()
if (persistSourceDss.isDefined) {
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/RowTrackingTestBase.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/RowTrackingTestBase.scala
index ba66e09697..7c40937434 100644
---
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/RowTrackingTestBase.scala
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/RowTrackingTestBase.scala
@@ -18,11 +18,13 @@
package org.apache.paimon.spark.sql
-import org.apache.paimon.Snapshot.CommitKind
+import org.apache.paimon.Snapshot.{CommitKind, Operation}
import org.apache.paimon.errors.ErrorMessages
import org.apache.paimon.globalindex.IndexedSplit
import org.apache.paimon.spark.PaimonMetrics.RESULTED_TABLE_FILES
import org.apache.paimon.spark.PaimonSparkTestBase
+import org.apache.paimon.spark.catalyst.analysis.PaimonRelation
+import org.apache.paimon.spark.commands.{DataEvolutionPaimonWriter,
DataEvolutionRowIdConflictCommitter, PaimonSparkWriter}
import org.apache.paimon.spark.read.PaimonSplitScan
import org.apache.paimon.table.source.DataSplit
@@ -32,6 +34,7 @@ import org.apache.spark.sql.connector.metric.CustomTaskMetric
import org.apache.spark.sql.execution.QueryExecution
import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
import org.apache.spark.sql.execution.datasources.v2.BatchScanExec
+import org.apache.spark.sql.functions.{col, udf}
import org.apache.spark.sql.paimon.Utils
import org.apache.spark.sql.util.QueryExecutionListener
@@ -132,6 +135,60 @@ abstract class RowTrackingTestBase extends
PaimonSparkTestBase with AdaptiveSpar
}
}
+ test("Data Evolution: rebase staged merge updates after concurrent compact")
{
+ withTable("t") {
+ sql(s"""
+ |CREATE TABLE t (id INT, b INT) TBLPROPERTIES (
+ | 'row-tracking.enabled' = 'true',
+ | 'compaction.min.file-num' = '2',
+ | 'commit.max-retries' = '0',
+ | 'data-evolution.row-id-conflict-rewrite.max-size' = '0 B',
+ | 'data-evolution.enabled' = 'true')
+ |""".stripMargin)
+ sql("INSERT INTO t VALUES (1, 10)")
+ sql("INSERT INTO t VALUES (2, 20)")
+
+ val table = loadTable("t")
+ val readSnapshot = table.latestSnapshot().get()
+ val dataSplits = table
+ .newSnapshotReader()
+ .withSnapshot(readSnapshot)
+ .read()
+ .splits()
+ .asScala
+ .collect { case split: DataSplit => split }
+ .toSeq
+ val firstRowIds = dataSplits
+ .flatMap(_.dataFiles().asScala)
+ .map(_.firstRowId().longValue())
+ .sorted
+ val firstRowId = udf((rowId: Long) => firstRowIds.takeWhile(_ <=
rowId).last)
+ val stagedRows = sql("SELECT b + 1 AS b, _ROW_ID FROM t")
+ .withColumn("_FIRST_ROW_ID", firstRowId(col("_ROW_ID")))
+ .select("b", "_FIRST_ROW_ID", "_ROW_ID")
+ val stagedUpdates =
+ DataEvolutionPaimonWriter(table,
dataSplits).writePartialFields(stagedRows, Seq("b"))
+
+ sql("CALL sys.compact(table => 't')").collect()
+
+ val writer = PaimonSparkWriter(table)
+ writer.rowIdCheckConflict(readSnapshot.id())
+ val targetRelation =
+
PaimonRelation.getPaimonRelation(spark.table("t").queryExecution.analyzed)
+ DataEvolutionRowIdConflictCommitter.commit(
+ spark,
+ table,
+ targetRelation,
+ writer,
+ stagedUpdates,
+ Nil,
+ readSnapshot.id(),
+ Operation.MERGE)
+
+ checkAnswer(sql("SELECT id, b FROM t ORDER BY id"), Seq(Row(1, 11),
Row(2, 21)))
+ }
+ }
+
test("Data Evolution: concurrent merge and merge") {
withTable("s", "t") {
sql(s"""