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 3403989f38 [spark] Ignore source row ids outside the table in the data 
evolution MERGE row-id shortcut (#10055)
3403989f38 is described below

commit 3403989f386efce21be358eee69e38f02eb823d4
Author: Xiangyi Zhu <[email protected]>
AuthorDate: Tue Sep 22 10:45:18 2026 +0800

    [spark] Ignore source row ids outside the table in the data evolution MERGE 
row-id shortcut (#10055)
---
 .../MergeIntoPaimonDataEvolutionTable.scala        | 42 +++++++++++++--
 .../MergeIntoPaimonDataEvolutionTable.scala        | 42 +++++++++++++--
 .../paimon/spark/sql/RowTrackingTestBase.scala     | 63 ++++++++++++++++++++++
 3 files changed, 139 insertions(+), 8 deletions(-)

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 5cf8f812d0..15a8f89429 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
@@ -25,7 +25,7 @@ import org.apache.paimon.data.BinaryRow
 import org.apache.paimon.deletionvectors.DeletionVector
 import org.apache.paimon.format.blob.BlobFileFormat.isBlobFile
 import org.apache.paimon.index.GlobalIndexMeta
-import org.apache.paimon.io.{CompactIncrement, DataIncrement}
+import org.apache.paimon.io.{CompactIncrement, DataFileMeta, DataIncrement}
 import org.apache.paimon.manifest.IndexManifestEntry
 import org.apache.paimon.options.Options
 import org.apache.paimon.predicate.{Predicate, PredicateBuilder}
@@ -327,7 +327,7 @@ case class MergeIntoPaimonDataEvolutionTable(
       .map(_.asInstanceOf[DataSplit])
       .toSeq
 
-    val firstRowIds: immutable.IndexedSeq[Long] = tableSplits
+    val normalDataFiles: Seq[DataFileMeta] = tableSplits
       .flatMap(_.dataFiles().asScala)
       .filter {
         file =>
@@ -335,10 +335,19 @@ case class MergeIntoPaimonDataEvolutionTable(
           !isBlobFile(file.fileName()) &&
           !isVectorStoreFile(file.fileName())
       }
+    val firstRowIds: immutable.IndexedSeq[Long] = normalDataFiles
       .map(file => file.firstRowId().asInstanceOf[Long])
       .distinct
       .sorted
       .toIndexedSeq
+    // Exclusive end of the row-id range starting at each entry of 
`firstRowIds`. Files sharing a
+    // first row id are column groups of one range and end at the same row id.
+    val rowIdRangeEnds: immutable.IndexedSeq[Long] = {
+      val ends = normalDataFiles
+        .groupBy(_.firstRowId().longValue())
+        .map { case (first, files) => first -> files.map(file => first + 
file.rowCount()).max }
+      firstRowIds.map(ends)
+    }
 
     val firstRowIdToBlobFirstRowIds: Map[Long, List[Long]] = {
       val map = new mutable.HashMap[Long, List[Long]]()
@@ -377,6 +386,7 @@ case class MergeIntoPaimonDataEvolutionTable(
         sparkSession,
         tableSplits,
         firstRowIds,
+        rowIdRangeEnds,
         firstRowIdToBlobFirstRowIds,
         persistSourceDss)
       lazy val touchedFileTargetRelation =
@@ -522,6 +532,7 @@ case class MergeIntoPaimonDataEvolutionTable(
       sparkSession: SparkSession,
       tableSplits: Seq[DataSplit],
       firstRowIds: immutable.IndexedSeq[Long],
+      rowIdRangeEnds: immutable.IndexedSeq[Long],
       firstRowIdToBlobFirstRowIds: Map[Long, List[Long]],
       persistSourceDss: Option[Dataset[Row]]): Seq[DataSplit] = {
     // Self-Merge shortcut:
@@ -547,6 +558,7 @@ case class MergeIntoPaimonDataEvolutionTable(
           sourceDss,
           sparkSession,
           firstRowIds,
+          rowIdRangeEnds,
           firstRowIdToBlobFirstRowIds,
           sourceRowIdAttr.name).toSet
 
@@ -557,6 +569,7 @@ case class MergeIntoPaimonDataEvolutionTable(
           targetDss.alias("_left").join(sourceDss, toColumn(matchedCondition), 
"inner"),
           sparkSession,
           firstRowIds,
+          rowIdRangeEnds,
           firstRowIdToBlobFirstRowIds,
           "_left." + ROW_ID_NAME
         ).toSet
@@ -1261,12 +1274,18 @@ case class MergeIntoPaimonDataEvolutionTable(
       dataset: Dataset[Row],
       sparkSession: SparkSession,
       firstRowIds: immutable.IndexedSeq[Long],
+      rowIdRangeEnds: immutable.IndexedSeq[Long],
       firstRowIdToBlobFirstRowIds: Map[Long, List[Long]],
       identifier: String): Array[Long] = {
     import sparkSession.implicits._
-    val firstRowIdUdf = udf((rowId: Long) => floorBinarySearch(firstRowIds, 
rowId))
+    // The column may come straight from the source table, so it can hold row 
ids that no longer
+    // exist (rows overwritten or dropped since the source was built) or 
arbitrary values. Those
+    // match no target row: they map to no file at all rather than to the 
nearest one.
+    val firstRowIdUdf =
+      udf((rowId: Long) => rowIdRangeStart(firstRowIds, rowIdRangeEnds, rowId))
     dataset
-      .select(firstRowIdUdf(col(identifier)))
+      .select(firstRowIdUdf(col(identifier)).as(FIRST_ROW_ID_NAME))
+      .filter(col(FIRST_ROW_ID_NAME).isNotNull)
       .distinct()
       .as[Long]
       .flatMap(
@@ -1533,6 +1552,21 @@ object MergeIntoPaimonDataEvolutionTable {
     }
   }
 
+  /**
+   * The start of the row-id range `[firstRowIds(i), rowIdRangeEnds(i))` 
holding `rowId`, or `None`
+   * when no range does.
+   */
+  private def rowIdRangeStart(
+      firstRowIds: immutable.IndexedSeq[Long],
+      rowIdRangeEnds: immutable.IndexedSeq[Long],
+      rowId: Long): Option[Long] = {
+    val index = firstRowIds.search(rowId) match {
+      case Found(foundIndex) => foundIndex
+      case InsertionPoint(insertionIndex) => insertionIndex - 1
+    }
+    if (index >= 0 && rowId < rowIdRangeEnds(index)) Some(firstRowIds(index)) 
else None
+  }
+
   private def floorBinarySearch(indexed: immutable.IndexedSeq[Long], value: 
Long): Long = {
     if (indexed.isEmpty) {
       throw new IllegalArgumentException("The input sorted sequence is empty.")
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 5cf8f812d0..15a8f89429 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
@@ -25,7 +25,7 @@ import org.apache.paimon.data.BinaryRow
 import org.apache.paimon.deletionvectors.DeletionVector
 import org.apache.paimon.format.blob.BlobFileFormat.isBlobFile
 import org.apache.paimon.index.GlobalIndexMeta
-import org.apache.paimon.io.{CompactIncrement, DataIncrement}
+import org.apache.paimon.io.{CompactIncrement, DataFileMeta, DataIncrement}
 import org.apache.paimon.manifest.IndexManifestEntry
 import org.apache.paimon.options.Options
 import org.apache.paimon.predicate.{Predicate, PredicateBuilder}
@@ -327,7 +327,7 @@ case class MergeIntoPaimonDataEvolutionTable(
       .map(_.asInstanceOf[DataSplit])
       .toSeq
 
-    val firstRowIds: immutable.IndexedSeq[Long] = tableSplits
+    val normalDataFiles: Seq[DataFileMeta] = tableSplits
       .flatMap(_.dataFiles().asScala)
       .filter {
         file =>
@@ -335,10 +335,19 @@ case class MergeIntoPaimonDataEvolutionTable(
           !isBlobFile(file.fileName()) &&
           !isVectorStoreFile(file.fileName())
       }
+    val firstRowIds: immutable.IndexedSeq[Long] = normalDataFiles
       .map(file => file.firstRowId().asInstanceOf[Long])
       .distinct
       .sorted
       .toIndexedSeq
+    // Exclusive end of the row-id range starting at each entry of 
`firstRowIds`. Files sharing a
+    // first row id are column groups of one range and end at the same row id.
+    val rowIdRangeEnds: immutable.IndexedSeq[Long] = {
+      val ends = normalDataFiles
+        .groupBy(_.firstRowId().longValue())
+        .map { case (first, files) => first -> files.map(file => first + 
file.rowCount()).max }
+      firstRowIds.map(ends)
+    }
 
     val firstRowIdToBlobFirstRowIds: Map[Long, List[Long]] = {
       val map = new mutable.HashMap[Long, List[Long]]()
@@ -377,6 +386,7 @@ case class MergeIntoPaimonDataEvolutionTable(
         sparkSession,
         tableSplits,
         firstRowIds,
+        rowIdRangeEnds,
         firstRowIdToBlobFirstRowIds,
         persistSourceDss)
       lazy val touchedFileTargetRelation =
@@ -522,6 +532,7 @@ case class MergeIntoPaimonDataEvolutionTable(
       sparkSession: SparkSession,
       tableSplits: Seq[DataSplit],
       firstRowIds: immutable.IndexedSeq[Long],
+      rowIdRangeEnds: immutable.IndexedSeq[Long],
       firstRowIdToBlobFirstRowIds: Map[Long, List[Long]],
       persistSourceDss: Option[Dataset[Row]]): Seq[DataSplit] = {
     // Self-Merge shortcut:
@@ -547,6 +558,7 @@ case class MergeIntoPaimonDataEvolutionTable(
           sourceDss,
           sparkSession,
           firstRowIds,
+          rowIdRangeEnds,
           firstRowIdToBlobFirstRowIds,
           sourceRowIdAttr.name).toSet
 
@@ -557,6 +569,7 @@ case class MergeIntoPaimonDataEvolutionTable(
           targetDss.alias("_left").join(sourceDss, toColumn(matchedCondition), 
"inner"),
           sparkSession,
           firstRowIds,
+          rowIdRangeEnds,
           firstRowIdToBlobFirstRowIds,
           "_left." + ROW_ID_NAME
         ).toSet
@@ -1261,12 +1274,18 @@ case class MergeIntoPaimonDataEvolutionTable(
       dataset: Dataset[Row],
       sparkSession: SparkSession,
       firstRowIds: immutable.IndexedSeq[Long],
+      rowIdRangeEnds: immutable.IndexedSeq[Long],
       firstRowIdToBlobFirstRowIds: Map[Long, List[Long]],
       identifier: String): Array[Long] = {
     import sparkSession.implicits._
-    val firstRowIdUdf = udf((rowId: Long) => floorBinarySearch(firstRowIds, 
rowId))
+    // The column may come straight from the source table, so it can hold row 
ids that no longer
+    // exist (rows overwritten or dropped since the source was built) or 
arbitrary values. Those
+    // match no target row: they map to no file at all rather than to the 
nearest one.
+    val firstRowIdUdf =
+      udf((rowId: Long) => rowIdRangeStart(firstRowIds, rowIdRangeEnds, rowId))
     dataset
-      .select(firstRowIdUdf(col(identifier)))
+      .select(firstRowIdUdf(col(identifier)).as(FIRST_ROW_ID_NAME))
+      .filter(col(FIRST_ROW_ID_NAME).isNotNull)
       .distinct()
       .as[Long]
       .flatMap(
@@ -1533,6 +1552,21 @@ object MergeIntoPaimonDataEvolutionTable {
     }
   }
 
+  /**
+   * The start of the row-id range `[firstRowIds(i), rowIdRangeEnds(i))` 
holding `rowId`, or `None`
+   * when no range does.
+   */
+  private def rowIdRangeStart(
+      firstRowIds: immutable.IndexedSeq[Long],
+      rowIdRangeEnds: immutable.IndexedSeq[Long],
+      rowId: Long): Option[Long] = {
+    val index = firstRowIds.search(rowId) match {
+      case Found(foundIndex) => foundIndex
+      case InsertionPoint(insertionIndex) => insertionIndex - 1
+    }
+    if (index >= 0 && rowId < rowIdRangeEnds(index)) Some(firstRowIds(index)) 
else None
+  }
+
   private def floorBinarySearch(indexed: immutable.IndexedSeq[Long], value: 
Long): Long = {
     if (indexed.isEmpty) {
       throw new IllegalArgumentException("The input sorted sequence is empty.")
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 f3ba6808be..fef629d1cf 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
@@ -1252,6 +1252,69 @@ abstract class RowTrackingTestBase extends 
PaimonSparkTestBase with AdaptiveSpar
     }
   }
 
+  test("Data Evolution: merge into with _ROW_ID shortcut ignores source row 
ids outside the table") {
+    withTable("source", "target") {
+      sql("""
+            |CREATE TABLE target (id INT, b INT, dt STRING) TBLPROPERTIES (
+            |  'row-tracking.enabled' = 'true',
+            |  'data-evolution.enabled' = 'true')
+            |PARTITIONED BY (dt)
+            |""".stripMargin)
+      sql("INSERT INTO target VALUES (1, 10, 'p1'), (2, 20, 'p1')")
+      sql("INSERT INTO target VALUES (3, 30, 'p2'), (4, 40, 'p2')")
+
+      // The source is built from this snapshot and keeps the row ids 0..3, 
then partition p1 is
+      // overwritten: its rows get fresh row ids and 0 and 1 no longer exist 
anywhere. Add a value
+      // below and above every live range as well.
+      sql("CREATE TABLE source (rid BIGINT, b INT)")
+      sql("INSERT INTO source SELECT _ROW_ID, b + 100 FROM target")
+      sql("INSERT INTO source VALUES (-1L, 999), (1000L, 999)")
+      sql("INSERT OVERWRITE target PARTITION (dt = 'p1') VALUES (5, 50), (6, 
60)")
+      checkAnswer(
+        sql("SELECT id, _ROW_ID FROM target ORDER BY id"),
+        Seq(Row(3, 2), Row(4, 3), Row(5, 4), Row(6, 5)))
+
+      var findSplitsPlan: LogicalPlan = null
+      val listener = new QueryExecutionListener {
+        override def onSuccess(funcName: String, qe: QueryExecution, 
durationNs: Long): Unit = {
+          if (qe.analyzed.collectFirst { case _: Deduplicate => true 
}.nonEmpty) {
+            findSplitsPlan = qe.analyzed
+          }
+        }
+        override def onFailure(funcName: String, qe: QueryExecution, 
exception: Exception): Unit =
+          onSuccess(funcName, qe, 0L)
+      }
+      spark.listenerManager.register(listener)
+      try {
+        sql("""
+              |MERGE INTO target
+              |USING source
+              |ON target._ROW_ID = source.rid
+              |WHEN MATCHED THEN UPDATE SET b = source.b
+              |WHEN NOT MATCHED THEN INSERT (id, b, dt) VALUES 
(CAST(source.rid AS INT), source.b, 'p3')
+              |""".stripMargin)
+        Utils.waitUntilEventEmpty(spark)
+      } finally {
+        spark.listenerManager.unregister(listener)
+      }
+      // The row-id shortcut was taken: touched files were found without a 
join.
+      assert(findSplitsPlan != null && findSplitsPlan.collect { case plan: 
Join => plan }.isEmpty)
+
+      checkAnswer(
+        sql("SELECT id, b, dt FROM target ORDER BY dt, id"),
+        Seq(
+          Row(5, 50, "p1"),
+          Row(6, 60, "p1"),
+          Row(3, 130, "p2"),
+          Row(4, 140, "p2"),
+          Row(-1, 999, "p3"),
+          Row(0, 110, "p3"),
+          Row(1, 120, "p3"),
+          Row(1000, 999, "p3"))
+      )
+    }
+  }
+
   test("Data Evolution: merge into table with data-evolution for Self-Merge 
with _ROW_ID shortcut") {
     withTable("target") {
       sql(

Reply via email to