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 26da929908 [spark] Fix data evolution matched updates with global
index (#8411)
26da929908 is described below
commit 26da929908649c46b8c24d4c35b9d636c7d4679a
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Jul 1 17:59:57 2026 +0800
[spark] Fix data evolution matched updates with global index (#8411)
Fixes Data Evolution matched-update scans with global index by forcing
row-id discovery to use full global-index search on a pinned current
snapshot. This prevents rows inserted after global index creation from
being skipped during UPDATE or MERGE matched updates.
---
.../MergeIntoPaimonDataEvolutionTable.scala | 68 ++++++++++++++++++++--
.../MergeIntoPaimonDataEvolutionTable.scala | 68 ++++++++++++++++++++--
.../UpdatePaimonDataEvolutionTableCommand.scala | 17 ++++--
.../paimon/spark/sql/RowTrackingTestBase.scala | 56 ++++++++++++++++++
4 files changed, 193 insertions(+), 16 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 c14ee04648..e4e2d9b1e1 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
@@ -18,6 +18,7 @@
package org.apache.paimon.spark.commands
+import org.apache.paimon.CoreOptions
import org.apache.paimon.CoreOptions.GlobalIndexColumnUpdateAction
import org.apache.paimon.Snapshot
import org.apache.paimon.data.BinaryRow
@@ -36,6 +37,7 @@ import org.apache.paimon.table.FileStoreTable
import org.apache.paimon.table.sink.{CommitMessage, CommitMessageImpl}
import org.apache.paimon.table.source.DataSplit
import org.apache.paimon.table.source.snapshot.SnapshotReader
+import org.apache.paimon.table.source.snapshot.TimeTravelUtil
import org.apache.paimon.types.DataTypeRoot.BLOB
import org.apache.paimon.types.RowType
import org.apache.paimon.types.VectorType.isVectorStoreFile
@@ -53,6 +55,8 @@ import org.apache.spark.sql.functions.{col, udf}
import org.apache.spark.sql.paimon.shims.SparkShimLoader
import org.apache.spark.sql.types.{BooleanType, StructType}
+import java.util.{HashMap => JHashMap}
+
import scala.collection.{immutable, mutable}
import scala.collection.JavaConverters._
import scala.collection.Searching.{search, Found, InsertionPoint}
@@ -87,7 +91,19 @@ case class MergeIntoPaimonDataEvolutionTable(
import MergeIntoPaimonDataEvolutionTable._
- override val table: FileStoreTable =
v2Table.getTable.asInstanceOf[FileStoreTable]
+ private lazy val originalTargetRelation: DataSourceV2Relation =
+ PaimonRelation.getPaimonRelation(targetTable)
+
+ private lazy val matchedUpdateScanTarget: (SparkTable, DataSourceV2Relation)
=
+ if (matchedActions.nonEmpty) {
+ withMatchedUpdateScanOptions(v2Table, originalTargetRelation)
+ } else {
+ (v2Table, originalTargetRelation)
+ }
+
+ private lazy val targetSparkTable: SparkTable = matchedUpdateScanTarget._1
+
+ override lazy val table: FileStoreTable =
targetSparkTable.getTable.asInstanceOf[FileStoreTable]
private val updateColumns: Set[AttributeReference] = {
val columns = mutable.Set[AttributeReference]()
@@ -121,7 +137,9 @@ case class MergeIntoPaimonDataEvolutionTable(
private lazy val isSelfMergeOnRowId: Boolean = {
if (!isPaimonTable(sourceTable)) {
false
- } else if
(!targetRelation.name.equals(PaimonRelation.getPaimonRelation(sourceTable).name))
{
+ } else if (
+
!originalTargetRelation.name.equals(PaimonRelation.getPaimonRelation(sourceTable).name)
+ ) {
false
} else {
matchedCondition match {
@@ -139,10 +157,9 @@ case class MergeIntoPaimonDataEvolutionTable(
"NOT MATCHED BY SOURCE are not supported."
)
- private lazy val targetRelation: DataSourceV2Relation =
- PaimonRelation.getPaimonRelation(targetTable)
+ private lazy val targetRelation: DataSourceV2Relation =
matchedUpdateScanTarget._2
- lazy val tableSchema: StructType = v2Table.schema
+ lazy val tableSchema: StructType = targetSparkTable.schema
override def run(sparkSession: SparkSession): Seq[Row] = {
// Persist the schema that the analyzer evolved in memory (commit deferred
to execution).
@@ -821,6 +838,47 @@ object MergeIntoPaimonDataEvolutionTable {
final private val FIRST_ROW_ID_NAME = "_FIRST_ROW_ID";
final private val RAW_BLOB_PLACEHOLDER_MARKER_PREFIX =
"__paimon_raw_blob_placeholder_"
+ private[commands] def withMatchedUpdateScanOptions(
+ v2Table: SparkTable,
+ relation: DataSourceV2Relation): (SparkTable, DataSourceV2Relation) = {
+ val table = v2Table.getTable.asInstanceOf[FileStoreTable]
+ val fullSearchMode = CoreOptions.GlobalIndexSearchMode.FULL.toString
+ Option(TimeTravelUtil.tryTravelOrLatest(table)) match {
+ case None =>
+ (v2Table, relation)
+ case Some(snapshot) =>
+ val configuredSnapshotId =
+ Option(table.options().get(CoreOptions.SCAN_SNAPSHOT_ID.key()))
+ val snapshotId = snapshot.id().toString
+ if (
+ configuredSnapshotId.contains(snapshotId) &&
+ fullSearchMode.equalsIgnoreCase(
+ table.options().get(CoreOptions.GLOBAL_INDEX_SEARCH_MODE.key()))
+ ) {
+ (v2Table, relation)
+ } else {
+ val dynamicOptions = new JHashMap[String, String]()
+ timeTravelOptionKeys.foreach(dynamicOptions.put(_, null))
+ dynamicOptions.put(CoreOptions.GLOBAL_INDEX_SEARCH_MODE.key(),
fullSearchMode)
+ dynamicOptions.put(CoreOptions.SCAN_SNAPSHOT_ID.key(), snapshotId)
+
+ val scanTable = SparkTable.of(table.copy(dynamicOptions))
+ val scanRelation =
+ SparkShimLoader.shim.copyDataSourceV2Relation(relation, scanTable,
relation.output)
+ (scanTable, scanRelation)
+ }
+ }
+ }
+
+ private def timeTravelOptionKeys: Seq[String] = Seq(
+ CoreOptions.SCAN_SNAPSHOT_ID.key(),
+ CoreOptions.SCAN_TAG_NAME.key(),
+ CoreOptions.SCAN_WATERMARK.key(),
+ CoreOptions.SCAN_TIMESTAMP.key(),
+ CoreOptions.SCAN_TIMESTAMP_MILLIS.key(),
+ CoreOptions.SCAN_VERSION.key()
+ )
+
private[commands] def isModifiedAssignment(assignment: Assignment): Boolean
= {
!sameAttributeReference(assignment.key, assignment.value)
}
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 7464176c87..15379a1147 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
@@ -18,6 +18,7 @@
package org.apache.paimon.spark.commands
+import org.apache.paimon.CoreOptions
import org.apache.paimon.CoreOptions.GlobalIndexColumnUpdateAction
import org.apache.paimon.Snapshot
import org.apache.paimon.data.BinaryRow
@@ -36,6 +37,7 @@ import org.apache.paimon.table.FileStoreTable
import org.apache.paimon.table.sink.{CommitMessage, CommitMessageImpl}
import org.apache.paimon.table.source.DataSplit
import org.apache.paimon.table.source.snapshot.SnapshotReader
+import org.apache.paimon.table.source.snapshot.TimeTravelUtil
import org.apache.paimon.types.DataTypeRoot.BLOB
import org.apache.paimon.types.RowType
import org.apache.paimon.types.VectorType.isVectorStoreFile
@@ -53,6 +55,8 @@ import org.apache.spark.sql.functions.{col, udf}
import org.apache.spark.sql.paimon.shims.SparkShimLoader
import org.apache.spark.sql.types.{BooleanType, StructType}
+import java.util.{HashMap => JHashMap}
+
import scala.collection.{immutable, mutable}
import scala.collection.JavaConverters._
import scala.collection.Searching.{search, Found, InsertionPoint}
@@ -87,7 +91,19 @@ case class MergeIntoPaimonDataEvolutionTable(
import MergeIntoPaimonDataEvolutionTable._
- override val table: FileStoreTable =
v2Table.getTable.asInstanceOf[FileStoreTable]
+ private lazy val originalTargetRelation: DataSourceV2Relation =
+ PaimonRelation.getPaimonRelation(targetTable)
+
+ private lazy val matchedUpdateScanTarget: (SparkTable, DataSourceV2Relation)
=
+ if (matchedActions.nonEmpty) {
+ withMatchedUpdateScanOptions(v2Table, originalTargetRelation)
+ } else {
+ (v2Table, originalTargetRelation)
+ }
+
+ private lazy val targetSparkTable: SparkTable = matchedUpdateScanTarget._1
+
+ override lazy val table: FileStoreTable =
targetSparkTable.getTable.asInstanceOf[FileStoreTable]
private val updateColumns: Set[AttributeReference] = {
val columns = mutable.Set[AttributeReference]()
@@ -121,7 +137,9 @@ case class MergeIntoPaimonDataEvolutionTable(
private lazy val isSelfMergeOnRowId: Boolean = {
if (!isPaimonTable(sourceTable)) {
false
- } else if
(!targetRelation.name.equals(PaimonRelation.getPaimonRelation(sourceTable).name))
{
+ } else if (
+
!originalTargetRelation.name.equals(PaimonRelation.getPaimonRelation(sourceTable).name)
+ ) {
false
} else {
matchedCondition match {
@@ -139,10 +157,9 @@ case class MergeIntoPaimonDataEvolutionTable(
"NOT MATCHED BY SOURCE are not supported."
)
- private lazy val targetRelation: DataSourceV2Relation =
- PaimonRelation.getPaimonRelation(targetTable)
+ private lazy val targetRelation: DataSourceV2Relation =
matchedUpdateScanTarget._2
- lazy val tableSchema: StructType = v2Table.schema
+ lazy val tableSchema: StructType = targetSparkTable.schema
override def run(sparkSession: SparkSession): Seq[Row] = {
// Persist the schema that the analyzer evolved in memory (commit deferred
to execution).
@@ -820,6 +837,47 @@ object MergeIntoPaimonDataEvolutionTable {
final private val FIRST_ROW_ID_NAME = "_FIRST_ROW_ID";
final private val RAW_BLOB_PLACEHOLDER_MARKER_PREFIX =
"__paimon_raw_blob_placeholder_"
+ private[commands] def withMatchedUpdateScanOptions(
+ v2Table: SparkTable,
+ relation: DataSourceV2Relation): (SparkTable, DataSourceV2Relation) = {
+ val table = v2Table.getTable.asInstanceOf[FileStoreTable]
+ val fullSearchMode = CoreOptions.GlobalIndexSearchMode.FULL.toString
+ Option(TimeTravelUtil.tryTravelOrLatest(table)) match {
+ case None =>
+ (v2Table, relation)
+ case Some(snapshot) =>
+ val configuredSnapshotId =
+ Option(table.options().get(CoreOptions.SCAN_SNAPSHOT_ID.key()))
+ val snapshotId = snapshot.id().toString
+ if (
+ configuredSnapshotId.contains(snapshotId) &&
+ fullSearchMode.equalsIgnoreCase(
+ table.options().get(CoreOptions.GLOBAL_INDEX_SEARCH_MODE.key()))
+ ) {
+ (v2Table, relation)
+ } else {
+ val dynamicOptions = new JHashMap[String, String]()
+ timeTravelOptionKeys.foreach(dynamicOptions.put(_, null))
+ dynamicOptions.put(CoreOptions.GLOBAL_INDEX_SEARCH_MODE.key(),
fullSearchMode)
+ dynamicOptions.put(CoreOptions.SCAN_SNAPSHOT_ID.key(), snapshotId)
+
+ val scanTable = SparkTable.of(table.copy(dynamicOptions))
+ val scanRelation =
+ SparkShimLoader.shim.copyDataSourceV2Relation(relation, scanTable,
relation.output)
+ (scanTable, scanRelation)
+ }
+ }
+ }
+
+ private def timeTravelOptionKeys: Seq[String] = Seq(
+ CoreOptions.SCAN_SNAPSHOT_ID.key(),
+ CoreOptions.SCAN_TAG_NAME.key(),
+ CoreOptions.SCAN_WATERMARK.key(),
+ CoreOptions.SCAN_TIMESTAMP.key(),
+ CoreOptions.SCAN_TIMESTAMP_MILLIS.key(),
+ CoreOptions.SCAN_VERSION.key()
+ )
+
private[commands] def isModifiedAssignment(assignment: Assignment): Boolean
= {
!sameAttributeReference(assignment.key, assignment.value)
}
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 9e2c35e3e4..0a614f8f12 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
@@ -39,8 +39,10 @@ case class UpdatePaimonDataEvolutionTableCommand(
with SupportsSubquery {
override def run(sparkSession: SparkSession): Seq[Row] = {
- val targetRowId = rowIdAttribute(relation)
- val sourceTable = updatedRowIdSource(targetRowId)
+ val (updateTable, updateRelation) =
+ MergeIntoPaimonDataEvolutionTable.withMatchedUpdateScanOptions(v2Table,
relation)
+ val targetRowId = rowIdAttribute(updateRelation)
+ val sourceTable = updatedRowIdSource(updateTable, updateRelation,
targetRowId)
val sourceRowId = sourceTable.output.head.asInstanceOf[AttributeReference]
val matchedCondition = EqualTo(targetRowId, sourceRowId)
@@ -49,8 +51,8 @@ case class UpdatePaimonDataEvolutionTableCommand(
alignedExpressions.map { case (expression, attribute) =>
Assignment(attribute, expression) })
MergeIntoPaimonDataEvolutionTable(
- v2Table,
- relation,
+ updateTable,
+ updateRelation,
sourceTable,
matchedCondition,
Seq(updateAction),
@@ -58,13 +60,16 @@ case class UpdatePaimonDataEvolutionTableCommand(
Nil).run(sparkSession)
}
- private def updatedRowIdSource(targetRowId: AttributeReference): Project = {
+ private def updatedRowIdSource(
+ updateTable: SparkTable,
+ updateRelation: DataSourceV2Relation,
+ targetRowId: AttributeReference): Project = {
val conditionReferences = condition.references.toSeq.collect {
case attr: AttributeReference => attr
}
val readOutput = deduplicateByExprId(conditionReferences :+ targetRowId)
val sourceScan =
- SparkShimLoader.shim.copyDataSourceV2Relation(relation, v2Table,
readOutput)
+ SparkShimLoader.shim.copyDataSourceV2Relation(updateRelation,
updateTable, readOutput)
// Keep the Filter visible for conditional UPDATEs. The data-evolution
MERGE command uses a
// self-merge shortcut for Project(PaimonRelation); if a WHERE update were
shaped that way, the
// shortcut would bypass the source join path and update every row.
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 c7f6ea86e3..f960b59d9b 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
@@ -1027,6 +1027,62 @@ abstract class RowTrackingTestBase extends
PaimonSparkTestBase with AdaptiveSpar
}
}
+ test("Data Evolution: V1 update with global index updates unindexed rows") {
+ withSparkSQLConf("spark.paimon.write.use-v2-write" -> "false") {
+ withTable("t") {
+ sql("""
+ |CREATE TABLE t (id INT, name STRING, b INT) TBLPROPERTIES (
+ | 'row-tracking.enabled' = 'true',
+ | 'data-evolution.enabled' = 'true',
+ | 'btree-index.records-per-range' = '1000')
+ |""".stripMargin)
+ sql("INSERT INTO t VALUES (1, 'old', 10)")
+ sql(
+ "CALL sys.create_global_index(table => 'test.t', index_column =>
'name', " +
+ "index_type => 'btree')")
+ sql("INSERT INTO t VALUES (2, 'new', 20)")
+
+ sql("UPDATE t SET b = 21 WHERE name = 'new'")
+
+ checkAnswer(
+ sql("SELECT id, name, b FROM t ORDER BY id"),
+ Seq(Row(1, "old", 10), Row(2, "new", 21))
+ )
+ }
+ }
+ }
+
+ test("Data Evolution: merge with global index updates unindexed rows") {
+ withTable("s", "t") {
+ sql("CREATE TABLE s (dummy INT, b INT)")
+ sql("INSERT INTO s VALUES (1, 21)")
+
+ sql("""
+ |CREATE TABLE t (id INT, name STRING, b INT) TBLPROPERTIES (
+ | 'row-tracking.enabled' = 'true',
+ | 'data-evolution.enabled' = 'true',
+ | 'btree-index.records-per-range' = '1000')
+ |""".stripMargin)
+ sql("INSERT INTO t VALUES (1, 'old', 10)")
+ sql(
+ "CALL sys.create_global_index(table => 'test.t', index_column =>
'name', " +
+ "index_type => 'btree')")
+ sql("INSERT INTO t VALUES (2, 'new', 20)")
+
+ sql("""
+ |MERGE INTO t
+ |USING s
+ |ON t.name = 'new' AND s.dummy = 1
+ |WHEN MATCHED THEN UPDATE SET t.b = s.b
+ |""".stripMargin)
+
+ checkAnswer(
+ sql("SELECT id, name, b FROM t ORDER BY id"),
+ Seq(Row(1, "old", 10), Row(2, "new", 21))
+ )
+ }
+ }
+
test("Data Evolution: V1 update table with data-evolution without
condition") {
withSparkSQLConf("spark.paimon.write.use-v2-write" -> "false") {
withTable("t") {