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 7ca1cd3603 [spark] Retry V1 UPDATE data-evolution conflicts (#8428)
7ca1cd3603 is described below

commit 7ca1cd3603f4df873188eb0ea8496e64039d9448
Author: Kerwin Zhang <[email protected]>
AuthorDate: Thu Jul 2 21:58:40 2026 +0800

    [spark] Retry V1 UPDATE data-evolution conflicts (#8428)
---
 docs/docs/multimodal-table/data-evolution.mdx      | 21 ++++---
 docs/generated/spark_connector_configuration.html  | 12 ++++
 .../org/apache/paimon/errors/ErrorMessages.java    | 29 ++++++++++
 .../paimon/operation/commit/ConflictDetection.java | 11 +++-
 .../apache/paimon/spark/SparkConnectorOptions.java | 17 ++++++
 .../UpdatePaimonDataEvolutionTableCommand.scala    | 65 +++++++++++++++++++++-
 .../org/apache/paimon/spark/util/OptionUtils.scala |  8 +++
 .../paimon/spark/sql/RowTrackingTestBase.scala     | 36 +++++++++++-
 8 files changed, 188 insertions(+), 11 deletions(-)

diff --git a/docs/docs/multimodal-table/data-evolution.mdx 
b/docs/docs/multimodal-table/data-evolution.mdx
index abafa67240..ad8c03064c 100644
--- a/docs/docs/multimodal-table/data-evolution.mdx
+++ b/docs/docs/multimodal-table/data-evolution.mdx
@@ -117,16 +117,18 @@ commit.close()
 
 ## Partial Updates
 
-You can update selected columns with Spark `MERGE INTO`, the Flink
-`data_evolution_merge_into` procedure, or the PyPaimon table update API. Only
-the updated column files are written; untouched columns remain in their 
original
-files.
+You can update selected columns with Spark SQL `UPDATE` or `MERGE INTO`, the
+Flink `data_evolution_merge_into` procedure, or the PyPaimon table update API.
+Only the updated column files are written; untouched columns remain in their
+original files.
 
 <Tabs groupId="data-evolution-partial-update">
 
 <TabItem value="spark-sql" label="Spark SQL">
 
 ```sql
+UPDATE target_table SET b = b + 10 WHERE id = 1;
+
 CREATE TABLE source_table (id INT, b INT);
 INSERT INTO source_table VALUES (1, 11), (2, 22);
 
@@ -201,9 +203,14 @@ commit.close()
 
 Notes:
 
-- SQL `DELETE` and standalone SQL `UPDATE` statements are not supported for
-  Data Evolution tables yet. Use Spark `MERGE INTO`, the Flink procedure, or
-  PyPaimon APIs.
+- Spark SQL supports standalone `UPDATE` statements for Data Evolution tables.
+  SQL `DELETE` statements are not supported for Data Evolution tables yet. Use
+  Spark `UPDATE` or `MERGE INTO`, the Flink procedure, or PyPaimon APIs.
+- Concurrent Spark SQL `UPDATE` statements that update the same data file and
+  columns may be retried automatically. Configure retry attempts with
+  `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`.
 - `MERGE INTO` for Data Evolution tables does not support the
   `WHEN NOT MATCHED BY SOURCE` clause.
 - The Flink `data_evolution_merge_into` procedure currently supports updating
diff --git a/docs/generated/spark_connector_configuration.html 
b/docs/generated/spark_connector_configuration.html
index 0dbc8b830a..c9b5cab70f 100644
--- a/docs/generated/spark_connector_configuration.html
+++ b/docs/generated/spark_connector_configuration.html
@@ -80,6 +80,18 @@ under the License.
             <td>Boolean</td>
             <td>Whether to adjust the target split size based on pruned 
(projected) columns. If enabled, split size estimation uses only the columns 
actually being read.</td>
         </tr>
+        <tr>
+            
<td><h5>write.data-evolution.update-conflict-retry.max-attempts</h5></td>
+            <td style="word-wrap: break-word;">20</td>
+            <td>Integer</td>
+            <td>Maximum attempts for Spark V1 UPDATE on data-evolution tables 
when concurrent partial-column updates conflict on the same row-id range and 
update columns. Values less than 2 disable retry.</td>
+        </tr>
+        <tr>
+            
<td><h5>write.data-evolution.update-conflict-retry.wait-ms</h5></td>
+            <td style="word-wrap: break-word;">10</td>
+            <td>Long</td>
+            <td>Wait time in milliseconds between retry attempts for Spark V1 
UPDATE on data-evolution tables after row-id range update conflicts.</td>
+        </tr>
         <tr>
             <td><h5>write.merge-schema</h5></td>
             <td style="word-wrap: break-word;">false</td>
diff --git 
a/paimon-api/src/main/java/org/apache/paimon/errors/ErrorMessages.java 
b/paimon-api/src/main/java/org/apache/paimon/errors/ErrorMessages.java
new file mode 100644
index 0000000000..8fd9012b1b
--- /dev/null
+++ b/paimon-api/src/main/java/org/apache/paimon/errors/ErrorMessages.java
@@ -0,0 +1,29 @@
+/*
+ * 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.errors;
+
+/** Shared error messages. */
+public class ErrorMessages {
+
+    public static final String DATA_EVOLUTION_ROW_ID_CONFLICT_MESSAGE =
+            "For Data Evolution table, multiple 'MERGE INTO' operations have 
encountered conflicts,"
+                    + " updating the same file, which can render some updates 
ineffective.";
+
+    private ErrorMessages() {}
+}
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 39bbca3338..fcf00c792b 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
@@ -22,6 +22,7 @@ import org.apache.paimon.Snapshot;
 import org.apache.paimon.Snapshot.CommitKind;
 import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.errors.ErrorMessages;
 import org.apache.paimon.index.DeletionVectorMeta;
 import org.apache.paimon.index.GlobalIndexMeta;
 import org.apache.paimon.index.IndexFileHandler;
@@ -541,10 +542,16 @@ public class ConflictDetection {
                 if (file.firstRowId() != null
                         && file.nonNullRowIdRange().from < checkNextRowId
                         && columnChecker.conflictsWith(file)) {
+                    LOG.debug(
+                            "Data evolution row id conflict detected for table 
{}, commit user {}, "
+                                    + "snapshot {}, file {}.",
+                            tableName,
+                            commitUser,
+                            snapshot.id(),
+                            file);
                     return Optional.of(
                             new RuntimeException(
-                                    "For Data Evolution table, multiple 'MERGE 
INTO' operations have encountered conflicts,"
-                                            + " updating the same file, which 
can render some updates ineffective."));
+                                    
ErrorMessages.DATA_EVOLUTION_ROW_ID_CONFLICT_MESSAGE));
                 }
             }
         }
diff --git 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkConnectorOptions.java
 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkConnectorOptions.java
index 5217ea0513..4138ae295a 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkConnectorOptions.java
+++ 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkConnectorOptions.java
@@ -67,6 +67,23 @@ public class SparkConnectorOptions {
                     .withDescription(
                             "If true, v2 write will be used. Currently, only 
HASH_FIXED and BUCKET_UNAWARE bucket modes are supported. Will fall back to v1 
write for other bucket modes. Currently, Spark V2 write does not support 
TableCapability.STREAMING_WRITE.");
 
+    public static final ConfigOption<Integer> 
DATA_EVOLUTION_UPDATE_CONFLICT_RETRY_MAX_ATTEMPTS =
+            key("write.data-evolution.update-conflict-retry.max-attempts")
+                    .intType()
+                    .defaultValue(20)
+                    .withDescription(
+                            "Maximum attempts for Spark V1 UPDATE on 
data-evolution tables when "
+                                    + "concurrent partial-column updates 
conflict on the same row-id "
+                                    + "range and update columns. Values less 
than 2 disable retry.");
+
+    public static final ConfigOption<Long> 
DATA_EVOLUTION_UPDATE_CONFLICT_RETRY_WAIT_MS =
+            key("write.data-evolution.update-conflict-retry.wait-ms")
+                    .longType()
+                    .defaultValue(10L)
+                    .withDescription(
+                            "Wait time in milliseconds between retry attempts 
for Spark V1 UPDATE "
+                                    + "on data-evolution tables after row-id 
range update conflicts.");
+
     public static final ConfigOption<Integer> MAX_FILES_PER_TRIGGER =
             key("read.stream.maxFilesPerTrigger")
                     .intType()
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 0a614f8f12..daaef27a85 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
@@ -18,10 +18,13 @@
 
 package org.apache.paimon.spark.commands
 
+import org.apache.paimon.errors.ErrorMessages
 import org.apache.paimon.spark.SparkTable
 import org.apache.paimon.spark.leafnode.PaimonLeafRunnableCommand
 import org.apache.paimon.spark.schema.PaimonMetadataColumn.ROW_ID_COLUMN
+import org.apache.paimon.spark.util.OptionUtils
 
+import org.apache.spark.internal.Logging
 import org.apache.spark.sql.{Row, SparkSession}
 import org.apache.spark.sql.catalyst.expressions.{Alias, Attribute, 
AttributeReference, EqualTo, Expression}
 import org.apache.spark.sql.catalyst.expressions.Literal.TrueLiteral
@@ -36,9 +39,33 @@ case class UpdatePaimonDataEvolutionTableCommand(
     condition: Expression,
     alignedExpressions: Seq[(Expression, Attribute)])
   extends PaimonLeafRunnableCommand
-  with SupportsSubquery {
+  with SupportsSubquery
+  with Logging {
 
   override def run(sparkSession: SparkSession): Seq[Row] = {
+    val maxAttempts = math.max(1, 
OptionUtils.dataEvolutionUpdateConflictRetryMaxAttempts())
+    val retryWaitMs = math.max(0L, 
OptionUtils.dataEvolutionUpdateConflictRetryWaitMs())
+    val canRetry = deterministicUpdate
+    var attempt = 1
+
+    while (attempt < maxAttempts) {
+      try {
+        return runOnce(sparkSession)
+      } catch {
+        case e: RuntimeException if canRetry && 
isDataEvolutionUpdateConflict(e) =>
+          val nextAttempt = attempt + 1
+          logInfo(
+            s"Retry Spark V1 UPDATE for data-evolution table after concurrent 
update conflict " +
+              s"(next attempt $nextAttempt/$maxAttempts).")
+          sleepBeforeRetry(retryWaitMs)
+          attempt += 1
+      }
+    }
+
+    runOnce(sparkSession)
+  }
+
+  private def runOnce(sparkSession: SparkSession): Seq[Row] = {
     val (updateTable, updateRelation) =
       MergeIntoPaimonDataEvolutionTable.withMatchedUpdateScanOptions(v2Table, 
relation)
     val targetRowId = rowIdAttribute(updateRelation)
@@ -60,6 +87,42 @@ case class UpdatePaimonDataEvolutionTableCommand(
       Nil).run(sparkSession)
   }
 
+  private def deterministicUpdate: Boolean = {
+    condition.deterministic && alignedExpressions.forall {
+      case (expression, _) =>
+        expression.deterministic
+    }
+  }
+
+  private def sleepBeforeRetry(retryWaitMs: Long): Unit = {
+    if (retryWaitMs > 0) {
+      try {
+        Thread.sleep(retryWaitMs)
+      } catch {
+        case e: InterruptedException =>
+          Thread.currentThread().interrupt()
+          throw new RuntimeException(
+            "Interrupted while retrying data-evolution UPDATE conflict.",
+            e)
+      }
+    }
+  }
+
+  private def isDataEvolutionUpdateConflict(e: Throwable): Boolean = {
+    var current = e
+    while (current != null) {
+      val message = current.getMessage
+      if (
+        message != null &&
+        message.contains(ErrorMessages.DATA_EVOLUTION_ROW_ID_CONFLICT_MESSAGE)
+      ) {
+        return true
+      }
+      current = current.getCause
+    }
+    false
+  }
+
   private def updatedRowIdSource(
       updateTable: SparkTable,
       updateRelation: DataSourceV2Relation,
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/util/OptionUtils.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/util/OptionUtils.scala
index b21b83de26..7aff2966e6 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/util/OptionUtils.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/util/OptionUtils.scala
@@ -118,6 +118,14 @@ object OptionUtils extends SQLConfHelper with Logging {
     getOptionString(SparkConnectorOptions.TYPE_WIDENING).toBoolean
   }
 
+  def dataEvolutionUpdateConflictRetryMaxAttempts(): Int = {
+    
getOptionString(SparkConnectorOptions.DATA_EVOLUTION_UPDATE_CONFLICT_RETRY_MAX_ATTEMPTS).toInt
+  }
+
+  def dataEvolutionUpdateConflictRetryWaitMs(): Long = {
+    
getOptionString(SparkConnectorOptions.DATA_EVOLUTION_UPDATE_CONFLICT_RETRY_WAIT_MS).toLong
+  }
+
   def v1FunctionEnabled(): Boolean = {
     getOptionString(SparkCatalogOptions.V1FUNCTION_ENABLED).toBoolean
   }
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 6c63e1919d..dc96e3ab1e 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
@@ -19,6 +19,7 @@
 package org.apache.paimon.spark.sql
 
 import org.apache.paimon.Snapshot.CommitKind
+import org.apache.paimon.errors.ErrorMessages
 import org.apache.paimon.spark.PaimonMetrics.RESULTED_TABLE_FILES
 import org.apache.paimon.spark.PaimonSparkTestBase
 import org.apache.paimon.spark.read.PaimonSplitScan
@@ -149,7 +150,7 @@ abstract class RowTrackingTestBase extends 
PaimonSparkTestBase with AdaptiveSpar
                        |WHEN MATCHED THEN
                        |UPDATE SET t.id = s.id, t.b = s.b + t.b, t.c = s.c + 
t.c
                        |""".stripMargin).collect(),
-          "multiple 'MERGE INTO' operations have encountered conflicts"
+          ErrorMessages.DATA_EVOLUTION_ROW_ID_CONFLICT_MESSAGE
         )
       }
 
@@ -1099,6 +1100,39 @@ abstract class RowTrackingTestBase extends 
PaimonSparkTestBase with AdaptiveSpar
     }
   }
 
+  test("Data Evolution: V1 update retries concurrent update conflicts") {
+    withSparkSQLConf(
+      "spark.paimon.write.use-v2-write" -> "false",
+      "spark.paimon.write.data-evolution.update-conflict-retry.max-attempts" 
-> "50",
+      "spark.paimon.write.data-evolution.update-conflict-retry.wait-ms" -> "10"
+    ) {
+      withTable("t") {
+        sql(
+          "CREATE TABLE t (id INT, b INT, c INT) TBLPROPERTIES 
('row-tracking.enabled' = 'true', 'data-evolution.enabled' = 'true')")
+        sql("INSERT INTO t VALUES (1, 0, 0)")
+
+        val ready = new CountDownLatch(4)
+        val start = new CountDownLatch(1)
+        val updates = (1 to 4).map {
+          _ =>
+            Future {
+              ready.countDown()
+              assert(start.await(30, TimeUnit.SECONDS))
+              for (_ <- 1 to 5) {
+                sql("UPDATE t SET b = b + 1 WHERE id = 1").collect()
+              }
+            }
+        }
+
+        assert(ready.await(30, TimeUnit.SECONDS))
+        start.countDown()
+        updates.foreach(Await.result(_, 120.seconds))
+
+        checkAnswer(sql("SELECT b FROM t"), Seq(Row(20)))
+      }
+    }
+  }
+
   test("Data Evolution: V1 update partition column throws exception") {
     withSparkSQLConf("spark.paimon.write.use-v2-write" -> "false") {
       withTable("t") {

Reply via email to