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 47a220fed7 [spark][python] Preserve rows when rebasing stale updates
(#9096)
47a220fed7 is described below
commit 47a220fed78461ae2cb7a71e06a521f4520026de
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Aug 7 17:47:31 2026 +0800
[spark][python] Preserve rows when rebasing stale updates (#9096)
---
.../pypaimon/tests/table_upsert_by_key_test.py | 60 ++++++++++++++++++++
paimon-python/pypaimon/write/file_store_commit.py | 4 +-
.../DataEvolutionRowIdConflictRewriter.scala | 64 ++++++++++++----------
.../paimon/spark/sql/RowTrackingTestBase.scala | 61 ++++++++++++++++++++-
4 files changed, 158 insertions(+), 31 deletions(-)
diff --git a/paimon-python/pypaimon/tests/table_upsert_by_key_test.py
b/paimon-python/pypaimon/tests/table_upsert_by_key_test.py
index feb98ae3c6..7f06fcab42 100644
--- a/paimon-python/pypaimon/tests/table_upsert_by_key_test.py
+++ b/paimon-python/pypaimon/tests/table_upsert_by_key_test.py
@@ -620,6 +620,66 @@ class _TableUpsertByKeyTestBase(DataEvolutionTestBase):
}
self.assertEqual(99, rows[2])
+ def test_compaction_rewrite_rejects_update_after_compaction(self):
+ table = self._create_table()
+ self._write_arrow(table, pa.Table.from_pydict({
+ 'id': [1, 2],
+ 'name': ['Alice', 'Bob'],
+ 'age': [25, 30],
+ 'city': ['NYC', 'LA'],
+ }, schema=self.pa_schema))
+ self._write_arrow(table, pa.Table.from_pydict({
+ 'id': [3, 4],
+ 'name': ['Carol', 'Dave'],
+ 'age': [35, 40],
+ 'city': ['Chicago', 'Houston'],
+ }, schema=self.pa_schema))
+
+ wb = self._make_write_builder(table)
+ update = wb.new_update().with_update_type(['age'])
+ commit_identifier = self._next_commit_id()
+ stale_messages = self._apply_upsert(
+ update,
+ pa.Table.from_pydict({
+ 'id': [2],
+ 'name': ['ignored'],
+ 'age': [31],
+ 'city': ['ignored'],
+ }, schema=self.pa_schema),
+ ['id'],
+ commit_identifier,
+ )
+
+ self._compact_all_data_files(table)
+ self._upsert(
+ table,
+ pa.Table.from_pydict({
+ 'id': [2],
+ 'name': ['ignored'],
+ 'age': [99],
+ 'city': ['ignored'],
+ }, schema=self.pa_schema),
+ ['id'],
+ update_cols=['age'],
+ )
+
+ commit = wb.new_commit()
+ with self.assertRaisesRegex(
+ RuntimeError,
+ "multiple 'MERGE INTO' operations have encountered conflicts"):
+ self._apply_commit(
+ commit,
+ stale_messages,
+ commit_identifier,
+ )
+ commit.close()
+
+ rows = {
+ row['id']: row['age']
+ for row in self._read_all(table).to_pylist()
+ }
+ self.assertEqual(99, rows[2])
+
def test_large_table_upsert(self):
"""Upsert that touches a wide selection of rows in a 200-row table."""
table = self._create_table()
diff --git a/paimon-python/pypaimon/write/file_store_commit.py
b/paimon-python/pypaimon/write/file_store_commit.py
index 28fbd441a9..b1f212283d 100644
--- a/paimon-python/pypaimon/write/file_store_commit.py
+++ b/paimon-python/pypaimon/write/file_store_commit.py
@@ -851,7 +851,9 @@ class FileStoreCommit:
)
)
if non_compaction_conflict is not None:
- return None
+ raise CommitConflictError(
+ str(non_compaction_conflict)
+ ) from non_compaction_conflict
try:
return RowIdConflictRewriter(
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
index 5512c735fb..ebb9402b1a 100644
---
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
@@ -106,30 +106,12 @@ private[spark] class DataEvolutionRowIdConflictRewriter(
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)
+ rewriteFiles(sparkSession, columnNames, files, currentSplits)
}
val candidateKeys = candidates.map(staged => fileKey(staged.message,
staged.file)).toSet
@@ -142,8 +124,7 @@ private[spark] class DataEvolutionRowIdConflictRewriter(
sparkSession: SparkSession,
columnNames: Seq[String],
stagedFiles: Seq[StagedFile],
- affectedSplits: Seq[DataSplit],
- firstRowIds: immutable.IndexedSeq[Long]): Seq[CommitMessage] = {
+ currentSplits: Seq[DataSplit]): Seq[CommitMessage] = {
val stagedSplits = stagedFiles.map(
staged =>
DataSplit
@@ -160,6 +141,23 @@ private[spark] class DataEvolutionRowIdConflictRewriter(
.withDataFiles(java.util.Collections.singletonList(staged.file))
.rawConvertible(true)
.build())
+ val affectedSplits = currentSplits.flatMap(
+ split => {
+ val filtered = split.filterDataFile(
+ file =>
+ isNormalRowIdFile(file) && stagedFiles.exists(
+ staged =>
+ sameBucket(split, staged.message) &&
+
file.nonNullRowIdRange().hasIntersection(staged.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 relationAttributes = (targetRelation.output ++
targetRelation.metadataOutput).collect {
case attribute: AttributeReference => attribute
@@ -172,14 +170,24 @@ private[spark] class DataEvolutionRowIdConflictRewriter(
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)
+ def readRows(splits: Seq[DataSplit]) = {
+ val relation = createNewScanPlan(splits, targetRelation)
+ val readPlan =
+ SparkShimLoader.shim.copyDataSourceV2Relation(relation,
relation.table, readOutput)
+ createDataset(sparkSession, readPlan)
+ .select((columnNames.map(quotedColumn) :+ quotedColumn(ROW_ID_NAME)):
_*)
+ }
+
+ val stagedRows = readRows(stagedSplits)
+ val currentRows = readRows(affectedSplits)
+ // A new compacted row-id range may contain rows outside the staged file.
Preserve their
+ // latest values and only replace rows for which the staged update has a
value.
+ val mergedRows = currentRows
+ .join(stagedRows.select(quotedColumn(ROW_ID_NAME)), Seq(ROW_ID_NAME),
"left_anti")
+ .unionByName(stagedRows)
.select((columnNames.map(quotedColumn) :+ quotedColumn(ROW_ID_NAME)): _*)
+ val firstRowIdUdf = udf((rowId: Long) => floorBinarySearch(firstRowIds,
rowId))
+ val rewrittenRows = mergedRows
.withColumn(FIRST_ROW_ID_NAME, firstRowIdUdf(quotedColumn(ROW_ID_NAME)))
.repartition(col(FIRST_ROW_ID_NAME))
.sortWithinPartitions(FIRST_ROW_ID_NAME, ROW_ID_NAME)
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 7c40937434..60527e913b 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
@@ -163,7 +163,7 @@ abstract class RowTrackingTestBase extends
PaimonSparkTestBase with AdaptiveSpar
.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")
+ val stagedRows = sql("SELECT b + 1 AS b, _ROW_ID FROM t WHERE id = 1")
.withColumn("_FIRST_ROW_ID", firstRowId(col("_ROW_ID")))
.select("b", "_FIRST_ROW_ID", "_ROW_ID")
val stagedUpdates =
@@ -185,7 +185,64 @@ abstract class RowTrackingTestBase extends
PaimonSparkTestBase with AdaptiveSpar
readSnapshot.id(),
Operation.MERGE)
- checkAnswer(sql("SELECT id, b FROM t ORDER BY id"), Seq(Row(1, 11),
Row(2, 21)))
+ checkAnswer(sql("SELECT id, b FROM t ORDER BY id"), Seq(Row(1, 11),
Row(2, 20)))
+ }
+ }
+
+ test("Data Evolution: rebase rejects same-column update 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.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 WHERE id = 1")
+ .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()
+ sql("UPDATE t SET b = 99 WHERE id = 1").collect()
+
+ val writer = PaimonSparkWriter(table)
+ writer.rowIdCheckConflict(readSnapshot.id())
+ val targetRelation =
+
PaimonRelation.getPaimonRelation(spark.table("t").queryExecution.analyzed)
+ val exception = intercept[RuntimeException] {
+ DataEvolutionRowIdConflictCommitter.commit(
+ spark,
+ table,
+ targetRelation,
+ writer,
+ stagedUpdates,
+ Nil,
+ readSnapshot.id(),
+ Operation.MERGE)
+ }
+
+ assert(hasMessage(exception,
ErrorMessages.DATA_EVOLUTION_ROW_ID_CONFLICT_MESSAGE))
+ checkAnswer(sql("SELECT id, b FROM t ORDER BY id"), Seq(Row(1, 99),
Row(2, 20)))
}
}