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 db5b308f77 [spark] Fix inconsistent snapshots for data evolution V1 
UPDATE (#9597)
db5b308f77 is described below

commit db5b308f77a5b4468740ca6a2ab706ea10f55bc8
Author: zhoulii <[email protected]>
AuthorDate: Mon Sep 7 22:45:09 2026 +0800

    [spark] Fix inconsistent snapshots for data evolution V1 UPDATE (#9597)
---
 .../cdc/mysql/MySqlSyncDatabaseActionITCase.java   | 18 +++++
 .../MergeIntoPaimonDataEvolutionTable.scala        | 14 ++--
 .../MergeIntoPaimonDataEvolutionTable.scala        | 14 ++--
 .../UpdatePaimonDataEvolutionTableCommand.scala    |  3 +-
 .../commands/DataEvolutionUpdateSnapshotTest.scala | 81 ++++++++++++++++++++++
 .../paimon/spark/sql/RowTrackingTestBase.scala     | 45 +++++++++++-
 6 files changed, 162 insertions(+), 13 deletions(-)

diff --git 
a/paimon-flink/paimon-flink-cdc/src/test/java/org/apache/paimon/flink/action/cdc/mysql/MySqlSyncDatabaseActionITCase.java
 
b/paimon-flink/paimon-flink-cdc/src/test/java/org/apache/paimon/flink/action/cdc/mysql/MySqlSyncDatabaseActionITCase.java
index 52464368b3..e0b2344cf7 100644
--- 
a/paimon-flink/paimon-flink-cdc/src/test/java/org/apache/paimon/flink/action/cdc/mysql/MySqlSyncDatabaseActionITCase.java
+++ 
b/paimon-flink/paimon-flink-cdc/src/test/java/org/apache/paimon/flink/action/cdc/mysql/MySqlSyncDatabaseActionITCase.java
@@ -1028,6 +1028,22 @@ public class MySqlSyncDatabaseActionITCase extends 
MySqlActionITCaseBase {
                     rowType,
                     Collections.singletonList("k"));
 
+            // Use records from both shards as a barrier before altering t2. 
Waiting for t1 alone
+            // does not guarantee that the t2 snapshot readers have loaded 
their schemas.
+            statement.executeUpdate("INSERT INTO database_shard_1.t2 VALUES 
(-1, -1.1)");
+            statement.executeUpdate("INSERT INTO database_shard_2.t2 VALUES 
(-2, -2.2)");
+            table = getFileStoreTable("t2");
+            rowType =
+                    RowType.of(
+                            new DataType[] {DataTypes.BIGINT().notNull(), 
DataTypes.DOUBLE()},
+                            new String[] {"k", "v1"});
+            waitForResult(
+                    client,
+                    Arrays.asList("+I[-1, -1.1]", "+I[-2, -2.2]"),
+                    table,
+                    rowType,
+                    Collections.singletonList("k"));
+
             // test schema evolution of t2
             statement.executeUpdate("ALTER TABLE database_shard_1.t2 ADD 
COLUMN v2 INT");
             statement.executeUpdate("ALTER TABLE database_shard_2.t2 ADD 
COLUMN v3 VARCHAR(10)");
@@ -1048,6 +1064,8 @@ public class MySqlSyncDatabaseActionITCase extends 
MySqlActionITCaseBase {
             waitForResult(
                     client,
                     Arrays.asList(
+                            "+I[-1, -1.1, NULL, NULL]",
+                            "+I[-2, -2.2, NULL, NULL]",
                             "+I[1, 1.1, 1, NULL]",
                             "+I[2, 2.2, 2, NULL]",
                             "+I[3, 3.3, NULL, db2_3]",
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 88deb9817c..c6db56419b 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
@@ -73,7 +73,8 @@ case class MergeIntoPaimonDataEvolutionTable(
     matchedCondition: Expression,
     matchedActions: Seq[MergeAction],
     notMatchedActions: Seq[MergeAction],
-    notMatchedBySourceActions: Seq[MergeAction])
+    notMatchedBySourceActions: Seq[MergeAction],
+    allowPinnedSelfMergeShortcut: Boolean = false)
   extends PaimonRowLevelCommand
   with Logging {
 
@@ -157,7 +158,7 @@ case class MergeIntoPaimonDataEvolutionTable(
 
   private def passthroughSourceRelation(plan: LogicalPlan): 
Option[DataSourceV2Relation] = {
     EliminateSubqueryAliases(plan) match {
-      case relation: DataSourceV2Relation if 
isPaimonRelationWithoutTimeTravel(relation) =>
+      case relation: DataSourceV2Relation if 
isEligibleSelfMergeSource(relation) =>
         Some(relation)
       case Project(projectList, child) if isPassthroughProject(projectList, 
child) =>
         passthroughSourceRelation(child)
@@ -166,10 +167,13 @@ case class MergeIntoPaimonDataEvolutionTable(
     }
   }
 
-  private def isPaimonRelationWithoutTimeTravel(relation: 
DataSourceV2Relation): Boolean =
+  private def isEligibleSelfMergeSource(relation: DataSourceV2Relation): 
Boolean =
     relation.table match {
       case sparkTable: SparkTable =>
-        
!TimeTravelUtil.hasTimeTravelOptions(Options.fromMap(sparkTable.getTable.options()))
+        
!TimeTravelUtil.hasTimeTravelOptions(Options.fromMap(sparkTable.getTable.options()))
 ||
+        // Reference equality proves that both sides use the exact same pinned 
table and snapshot.
+        // Keep this exception scoped to callers that explicitly construct 
such a self-merge.
+        (allowPinnedSelfMergeShortcut && (sparkTable eq targetSparkTable))
       case _ => false
     }
 
@@ -286,7 +290,7 @@ case class MergeIntoPaimonDataEvolutionTable(
   }
 
   private def invokeMergeInto(sparkSession: SparkSession): Unit = {
-    val readSnapshot = table.snapshotManager().latestSnapshot()
+    val readSnapshot = TimeTravelUtil.tryTravelOrLatest(table)
     val snapshotReader = table.newSnapshotReader()
     if (readSnapshot != null) {
       snapshotReader.withSnapshot(readSnapshot)
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 7bbc1487b6..f40db70fc1 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
@@ -73,7 +73,8 @@ case class MergeIntoPaimonDataEvolutionTable(
     matchedCondition: Expression,
     matchedActions: Seq[MergeAction],
     notMatchedActions: Seq[MergeAction],
-    notMatchedBySourceActions: Seq[MergeAction])
+    notMatchedBySourceActions: Seq[MergeAction],
+    allowPinnedSelfMergeShortcut: Boolean = false)
   extends PaimonRowLevelCommand
   with Logging {
 
@@ -157,7 +158,7 @@ case class MergeIntoPaimonDataEvolutionTable(
 
   private def passthroughSourceRelation(plan: LogicalPlan): 
Option[DataSourceV2Relation] = {
     EliminateSubqueryAliases(plan) match {
-      case relation: DataSourceV2Relation if 
isPaimonRelationWithoutTimeTravel(relation) =>
+      case relation: DataSourceV2Relation if 
isEligibleSelfMergeSource(relation) =>
         Some(relation)
       case Project(projectList, child) if isPassthroughProject(projectList, 
child) =>
         passthroughSourceRelation(child)
@@ -166,10 +167,13 @@ case class MergeIntoPaimonDataEvolutionTable(
     }
   }
 
-  private def isPaimonRelationWithoutTimeTravel(relation: 
DataSourceV2Relation): Boolean =
+  private def isEligibleSelfMergeSource(relation: DataSourceV2Relation): 
Boolean =
     relation.table match {
       case sparkTable: SparkTable =>
-        
!TimeTravelUtil.hasTimeTravelOptions(Options.fromMap(sparkTable.getTable.options()))
+        
!TimeTravelUtil.hasTimeTravelOptions(Options.fromMap(sparkTable.getTable.options()))
 ||
+        // Reference equality proves that both sides use the exact same pinned 
table and snapshot.
+        // Keep this exception scoped to callers that explicitly construct 
such a self-merge.
+        (allowPinnedSelfMergeShortcut && (sparkTable eq targetSparkTable))
       case _ => false
     }
 
@@ -286,7 +290,7 @@ case class MergeIntoPaimonDataEvolutionTable(
   }
 
   private def invokeMergeInto(sparkSession: SparkSession): Unit = {
-    val readSnapshot = table.snapshotManager().latestSnapshot()
+    val readSnapshot = TimeTravelUtil.tryTravelOrLatest(table)
     val snapshotReader = table.newSnapshotReader()
     if (readSnapshot != null) {
       snapshotReader.withSnapshot(readSnapshot)
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/UpdatePaimonDataEvolutionTableCommand.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/UpdatePaimonDataEvolutionTableCommand.scala
index daaef27a85..d3506ecec2 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/UpdatePaimonDataEvolutionTableCommand.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/UpdatePaimonDataEvolutionTableCommand.scala
@@ -84,7 +84,8 @@ case class UpdatePaimonDataEvolutionTableCommand(
       matchedCondition,
       Seq(updateAction),
       Nil,
-      Nil).run(sparkSession)
+      Nil,
+      allowPinnedSelfMergeShortcut = true).run(sparkSession)
   }
 
   private def deterministicUpdate: Boolean = {
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/commands/DataEvolutionUpdateSnapshotTest.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/commands/DataEvolutionUpdateSnapshotTest.scala
new file mode 100644
index 0000000000..7615c94c31
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/commands/DataEvolutionUpdateSnapshotTest.scala
@@ -0,0 +1,81 @@
+/*
+ * 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.errors.ErrorMessages
+import org.apache.paimon.spark.PaimonSparkTestBase
+
+import org.apache.spark.sql.catalyst.QueryPlanningTracker
+
+import scala.util.Try
+
+class DataEvolutionUpdateSnapshotTest extends PaimonSparkTestBase {
+
+  test("V1 update detects a concurrent update after its snapshot is pinned") {
+    withSparkSQLConf(
+      "spark.paimon.write.use-v2-write" -> "false",
+      "spark.paimon.write.data-evolution.update-conflict-retry.max-attempts" 
-> "1"
+    ) {
+      withTable("t") {
+        sql(
+          "CREATE TABLE t (id INT, status STRING) TBLPROPERTIES " +
+            "('row-tracking.enabled' = 'true', 'data-evolution.enabled' = 
'true')")
+        sql("INSERT INTO t VALUES (1, 'pending')")
+
+        val parsed = spark.sessionState.sqlParser.parsePlan(
+          "UPDATE t SET status = 'done' WHERE status = 'pending'")
+        val updateCommand = spark.sessionState.analyzer
+          .executeAndCheck(parsed, new QueryPlanningTracker)
+          .asInstanceOf[UpdatePaimonDataEvolutionTableCommand]
+
+        // Materialize the same pinned table produced at the beginning of 
runOnce, then commit a
+        // conflicting update before MergeIntoPaimonDataEvolutionTable 
resolves its target snapshot.
+        val (pinnedTable, pinnedRelation) =
+          MergeIntoPaimonDataEvolutionTable.withMatchedUpdateScanOptions(
+            updateCommand.v2Table,
+            updateCommand.relation)
+        val pinnedUpdate = updateCommand.copy(v2Table = pinnedTable, relation 
= pinnedRelation)
+
+        sql("UPDATE t SET status = 'cancelled' WHERE id = 1")
+
+        val result = Try(pinnedUpdate.run(spark))
+        val finalStatus = sql("SELECT status FROM t WHERE id = 
1").head().getString(0)
+        val detectedConflict = result.failed.toOption.exists(
+          hasMessage(_, ErrorMessages.DATA_EVOLUTION_ROW_ID_CONFLICT_MESSAGE))
+
+        assert(
+          detectedConflict && finalStatus == "cancelled",
+          s"Expected a row-id conflict and final status 'cancelled', but got " 
+
+            s"failure=${result.failed.toOption.map(_.toString)}, 
finalStatus=$finalStatus"
+        )
+      }
+    }
+  }
+
+  private def hasMessage(throwable: Throwable, expected: String): Boolean = {
+    var current = throwable
+    while (current != null) {
+      if (Option(current.getMessage).exists(_.contains(expected))) {
+        return true
+      }
+      current = current.getCause
+    }
+    false
+  }
+}
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 834eb9c372..a0a552baf4 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
@@ -1691,7 +1691,13 @@ abstract class RowTrackingTestBase extends 
PaimonSparkTestBase with AdaptiveSpar
           "CREATE TABLE t (id INT, b INT, c INT) TBLPROPERTIES 
('row-tracking.enabled' = 'true', 'data-evolution.enabled' = 'true')")
         sql("INSERT INTO t SELECT /*+ REPARTITION(1) */ id, id AS b, id AS c 
FROM range(2, 4)")
 
-        sql("UPDATE t SET b = 22 WHERE id = 2")
+        val (mergeRowsPlans, _) =
+          executeMergeIntoAndCollectPlans("UPDATE t SET b = 22 WHERE id = 2")
+        assert(
+          mergeRowsPlans.exists(_.collectFirst { case _: Join => true 
}.nonEmpty),
+          s"Expected conditional UPDATE to use the general MERGE plan, but 
got: " +
+            mergeRowsPlans.mkString("\n")
+        )
         checkAnswer(
           sql("SELECT *, _ROW_ID, _SEQUENCE_NUMBER FROM t ORDER BY id"),
           Seq(Row(2, 22, 2, 0, 2), Row(3, 3, 3, 1, 2))
@@ -1794,7 +1800,8 @@ abstract class RowTrackingTestBase extends 
PaimonSparkTestBase with AdaptiveSpar
           "CREATE TABLE t (id INT, b INT, c INT) TBLPROPERTIES 
('row-tracking.enabled' = 'true', 'data-evolution.enabled' = 'true')")
         sql("INSERT INTO t SELECT /*+ REPARTITION(1) */ id, id AS b, id AS c 
FROM range(2, 4)")
 
-        sql("UPDATE t SET b = 22")
+        val (mergeRowsPlans, _) = executeMergeIntoAndCollectPlans("UPDATE t 
SET b = 22")
+        assertSelfMergeShortcut(mergeRowsPlans)
         checkAnswer(
           sql("SELECT *, _ROW_ID, _SEQUENCE_NUMBER FROM t ORDER BY id"),
           Seq(Row(2, 22, 2, 0, 2), Row(3, 22, 3, 1, 2))
@@ -1803,6 +1810,40 @@ abstract class RowTrackingTestBase extends 
PaimonSparkTestBase with AdaptiveSpar
     }
   }
 
+  test("Data Evolution: V1 update with user-specified snapshot uses self-merge 
shortcut") {
+    withSparkSQLConf("spark.paimon.write.use-v2-write" -> "false") {
+      withTable("t") {
+        sql(
+          "CREATE TABLE t (id INT, b INT) TBLPROPERTIES 
('row-tracking.enabled' = 'true', 'data-evolution.enabled' = 'true')")
+        sql("INSERT INTO t VALUES (1, 10), (2, 20)")
+        val snapshotId = loadTable("t").snapshotManager().latestSnapshotId()
+        sql("INSERT INTO t VALUES (3, 30)")
+
+        var mergeRowsPlans = Seq.empty[LogicalPlan]
+        withSparkSQLConf("spark.paimon.scan.snapshot-id" -> 
snapshotId.toString) {
+          mergeRowsPlans = executeMergeIntoAndCollectPlans("UPDATE t SET b = 
100")._1
+        }
+        assertSelfMergeShortcut(mergeRowsPlans)
+
+        checkAnswer(
+          sql("SELECT id, b FROM t ORDER BY id"),
+          Seq(Row(1, 100), Row(2, 100), Row(3, 30)))
+      }
+    }
+  }
+
+  private def assertSelfMergeShortcut(mergeRowsPlans: Seq[LogicalPlan]): Unit 
= {
+    assert(mergeRowsPlans.nonEmpty, "Expected a MergeRows plan for V1 UPDATE.")
+    assert(
+      mergeRowsPlans.forall(_.collectFirst {
+        case p: Join => p
+        case p: Sort => p
+        case p: RepartitionByExpression => p
+      }.isEmpty),
+      s"Found unexpected Join/Sort/Exchange in plans: 
${mergeRowsPlans.mkString("\n")}"
+    )
+  }
+
   test("Data Evolution: V1 update retries concurrent update conflicts") {
     withSparkSQLConf(
       "spark.paimon.write.use-v2-write" -> "false",

Reply via email to