This is an automated email from the ASF dual-hosted git repository.
amoghj pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg.git
The following commit(s) were added to refs/heads/main by this push:
new 56d81e72ff Spark 3.4: Backport UPDATE/MERGE logic for row lineage
(#13344)
56d81e72ff is described below
commit 56d81e72ff138d026fc6c79575d280a6a9b0bf48
Author: Drew Gallardo <[email protected]>
AuthorDate: Fri Jun 20 08:51:38 2025 +0700
Spark 3.4: Backport UPDATE/MERGE logic for row lineage (#13344)
---
.../extensions/IcebergSparkSessionExtensions.scala | 6 +
.../RewriteMergeIntoTableForRowLineage.scala | 71 +++
.../analysis/RewriteOperationForRowLineage.scala | 87 ++++
.../analysis/RewriteUpdateTableForRowLineage.scala | 53 +++
.../RemoveRowLineageOutputFromOriginalTable.scala | 54 +++
.../SparkRowLevelOperationsTestBase.java | 14 +-
.../extensions/TestCopyOnWriteWithLineage.java | 35 ++
.../extensions/TestMergeOnReadWithLineage.java | 35 ++
.../TestRowLevelOperationsWithLineage.java | 510 +++++++++++++++++++++
.../spark/source/SparkCopyOnWriteOperation.java | 20 +-
.../iceberg/spark/source/SparkCopyOnWriteScan.java | 40 ++
.../spark/source/SparkPositionDeltaOperation.java | 17 +-
.../source/SparkPositionDeltaWriteBuilder.java | 13 +-
.../apache/iceberg/spark/source/SparkTable.java | 5 +-
.../iceberg/spark/source/SparkWriteBuilder.java | 24 +-
15 files changed, 967 insertions(+), 17 deletions(-)
diff --git
a/spark/v3.4/spark-extensions/src/main/scala/org/apache/iceberg/spark/extensions/IcebergSparkSessionExtensions.scala
b/spark/v3.4/spark-extensions/src/main/scala/org/apache/iceberg/spark/extensions/IcebergSparkSessionExtensions.scala
index af6a64911e..350ed6988f 100644
---
a/spark/v3.4/spark-extensions/src/main/scala/org/apache/iceberg/spark/extensions/IcebergSparkSessionExtensions.scala
+++
b/spark/v3.4/spark-extensions/src/main/scala/org/apache/iceberg/spark/extensions/IcebergSparkSessionExtensions.scala
@@ -30,9 +30,12 @@ import
org.apache.spark.sql.catalyst.analysis.ResolveMergeIntoTableReferences
import org.apache.spark.sql.catalyst.analysis.ResolveProcedures
import org.apache.spark.sql.catalyst.analysis.ResolveViews
import org.apache.spark.sql.catalyst.analysis.RewriteMergeIntoTable
+import
org.apache.spark.sql.catalyst.analysis.RewriteMergeIntoTableForRowLineage
import org.apache.spark.sql.catalyst.analysis.RewriteUpdateTable
+import org.apache.spark.sql.catalyst.analysis.RewriteUpdateTableForRowLineage
import
org.apache.spark.sql.catalyst.optimizer.ExtendedReplaceNullWithFalseInPredicate
import
org.apache.spark.sql.catalyst.optimizer.ExtendedSimplifyConditionalsInPredicate
+import
org.apache.spark.sql.catalyst.optimizer.RemoveRowLineageOutputFromOriginalTable
import org.apache.spark.sql.catalyst.optimizer.ReplaceStaticInvoke
import
org.apache.spark.sql.catalyst.parser.extensions.IcebergSparkSqlExtensionsParser
import
org.apache.spark.sql.execution.datasources.v2.ExtendedDataSourceV2Strategy
@@ -54,6 +57,8 @@ class IcebergSparkSessionExtensions extends
(SparkSessionExtensions => Unit) {
extensions.injectResolutionRule { _ => CheckMergeIntoTableConditions }
extensions.injectResolutionRule { _ => ProcedureArgumentCoercion }
extensions.injectResolutionRule { _ => AlignRowLevelCommandAssignments }
+ extensions.injectResolutionRule { _ => RewriteUpdateTableForRowLineage}
+ extensions.injectResolutionRule { _ => RewriteMergeIntoTableForRowLineage}
extensions.injectResolutionRule { _ => RewriteUpdateTable }
extensions.injectResolutionRule { _ => RewriteMergeIntoTable }
extensions.injectCheckRule { _ => CheckViews }
@@ -64,6 +69,7 @@ class IcebergSparkSessionExtensions extends
(SparkSessionExtensions => Unit) {
extensions.injectOptimizerRule { _ =>
ExtendedSimplifyConditionalsInPredicate }
extensions.injectOptimizerRule { _ =>
ExtendedReplaceNullWithFalseInPredicate }
extensions.injectOptimizerRule { _ => ReplaceStaticInvoke }
+ extensions.injectOptimizerRule { _ =>
RemoveRowLineageOutputFromOriginalTable}
// pre-CBO rules run only once and the order of the rules is important
// - dynamic filters should be added before replacing commands with
rewrite plans
// - scans must be planned before building writes
diff --git
a/spark/v3.4/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteMergeIntoTableForRowLineage.scala
b/spark/v3.4/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteMergeIntoTableForRowLineage.scala
new file mode 100644
index 0000000000..cebb360a29
--- /dev/null
+++
b/spark/v3.4/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteMergeIntoTableForRowLineage.scala
@@ -0,0 +1,71 @@
+/*
+ * 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.spark.sql.catalyst.analysis
+
+import org.apache.spark.sql.catalyst.expressions.Literal
+import org.apache.spark.sql.catalyst.plans.logical.Assignment
+import org.apache.spark.sql.catalyst.plans.logical.InsertAction
+import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
+import org.apache.spark.sql.catalyst.plans.logical.MergeIntoIcebergTable
+import org.apache.spark.sql.catalyst.plans.logical.UpdateAction
+import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation
+
+object RewriteMergeIntoTableForRowLineage extends
RewriteOperationForRowLineage {
+
+ override def apply(plan: LogicalPlan): LogicalPlan = {
+ plan.resolveOperators {
+ case m @ MergeIntoIcebergTable(_, _, _, matchedActions, _, _)
+ if m.resolved && m.aligned &&
+ matchedActions.nonEmpty &&
+ shouldUpdatePlan(m.targetTable) =>
+ updateMergeIntoForRowLineage(m)
+ }
+ }
+
+ protected def updateMergeIntoForRowLineage(mergeIntoTable:
MergeIntoIcebergTable): LogicalPlan = {
+ EliminateSubqueryAliases(mergeIntoTable.targetTable) match {
+ case r: DataSourceV2Relation =>
+ val matchedActions = mergeIntoTable.matchedActions
+ val notMatchedActions = mergeIntoTable.notMatchedActions
+ val (rowId, lastUpdatedSequenceNumber) =
findRowLineageAttributes(r.metadataOutput).get
+
+ val matchedAssignmentsForLineage = matchedActions.map {
+ case UpdateAction(cond, assignments) =>
+ UpdateAction(cond, assignments ++ Seq(Assignment(rowId, rowId),
+ Assignment(lastUpdatedSequenceNumber, Literal(null))))
+
+ case deleteAction => deleteAction
+ }
+
+ val notMatchedActionsForLineage = notMatchedActions.map {
+ case InsertAction(cond, assignments) =>
+ InsertAction(cond, assignments ++ Seq(
+ Assignment(rowId, Literal(null)),
+ Assignment(lastUpdatedSequenceNumber, Literal(null))))
+ }
+
+ val tableWithLineage = r.copy(output = r.output ++ Seq(rowId,
lastUpdatedSequenceNumber))
+ mergeIntoTable.copy(
+ targetTable = tableWithLineage,
+ matchedActions = matchedAssignmentsForLineage,
+ notMatchedActions = notMatchedActionsForLineage)
+ }
+ }
+}
diff --git
a/spark/v3.4/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteOperationForRowLineage.scala
b/spark/v3.4/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteOperationForRowLineage.scala
new file mode 100644
index 0000000000..611e62b75e
--- /dev/null
+++
b/spark/v3.4/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteOperationForRowLineage.scala
@@ -0,0 +1,87 @@
+/*
+ * 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.spark.sql.catalyst.analysis
+
+import org.apache.iceberg.MetadataColumns
+import org.apache.iceberg.TableUtil
+import org.apache.iceberg.spark.source.SparkTable
+import org.apache.spark.sql.catalyst.expressions.AttributeReference
+import org.apache.spark.sql.catalyst.expressions.Expression
+import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
+import org.apache.spark.sql.catalyst.util.METADATA_COL_ATTR_KEY
+import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation
+import org.apache.spark.sql.types.MetadataBuilder
+
+trait RewriteOperationForRowLineage extends RewriteRowLevelIcebergCommand {
+
+ protected val ROW_ID_ATTRIBUTE_NAME = MetadataColumns.ROW_ID.name()
+ protected val LAST_UPDATED_SEQUENCE_NUMBER_ATTRIBUTE_NAME =
MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name()
+
+ // The plan should only be updated if the underlying Iceberg table supports
row lineage AND
+ // lineage attributes are not already on the output of operation which
indicates the rule already ran
+ protected def shouldUpdatePlan(table: LogicalPlan): Boolean = {
+ val supportsRowLineage = EliminateSubqueryAliases(table) match {
+ case r: DataSourceV2Relation =>
+ r.table match {
+ case sparkTable: SparkTable =>
+ TableUtil.supportsRowLineage(sparkTable.table())
+ }
+ case _ => false
+ }
+
+ val rowIdAbsentFromOutput = !table.output.exists(_.name ==
ROW_ID_ATTRIBUTE_NAME)
+
+ supportsRowLineage && rowIdAbsentFromOutput
+ }
+
+ protected def findRowLineageAttributes(
+ expressions: Seq[Expression]
+ ): Option[(AttributeReference, AttributeReference)] = {
+ val rowIdAttr = expressions.collectFirst {
+ case attr: AttributeReference
+ if isMetadataColumn(attr) && attr.name == ROW_ID_ATTRIBUTE_NAME =>
attr
+ }
+
+ val lastUpdatedAttr = expressions.collectFirst {
+ case attr: AttributeReference
+ if isMetadataColumn(attr) && attr.name ==
LAST_UPDATED_SEQUENCE_NUMBER_ATTRIBUTE_NAME => attr
+ }
+
+ // Treat row lineage columns as data columns by removing the metadata
attribute
+ // This works around the logic in ExposesMetadataColumns,
+ // which prevents surfacing other metadata columns when a single metadata
column is in the output
+ (rowIdAttr, lastUpdatedAttr) match {
+ case (Some(rowId), Some(lastUpdated)) =>
+ Some((removeMetadataColumnAttribute(rowId),
removeMetadataColumnAttribute(lastUpdated)))
+ case _ => None
+ }
+ }
+
+ protected def removeMetadataColumnAttribute(attr: AttributeReference):
AttributeReference = {
+ attr.withMetadata(
+ new MetadataBuilder()
+ .withMetadata(attr.metadata)
+ .remove(METADATA_COL_ATTR_KEY).build())
+ }
+
+ private def isMetadataColumn(attributeReference: AttributeReference):
Boolean = {
+ attributeReference.metadata.contains(METADATA_COL_ATTR_KEY)
+ }
+}
diff --git
a/spark/v3.4/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteUpdateTableForRowLineage.scala
b/spark/v3.4/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteUpdateTableForRowLineage.scala
new file mode 100644
index 0000000000..00513d89fc
--- /dev/null
+++
b/spark/v3.4/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteUpdateTableForRowLineage.scala
@@ -0,0 +1,53 @@
+/*
+ * 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.spark.sql.catalyst.analysis
+
+import org.apache.spark.sql.catalyst.expressions.Literal
+import org.apache.spark.sql.catalyst.plans.logical.Assignment
+import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
+import org.apache.spark.sql.catalyst.plans.logical.UpdateIcebergTable
+import org.apache.spark.sql.connector.catalog.SupportsRowLevelOperations
+import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation
+
+object RewriteUpdateTableForRowLineage extends RewriteOperationForRowLineage {
+
+ override def apply(plan: LogicalPlan): LogicalPlan = {
+ plan resolveOperators {
+ case updateTable@UpdateIcebergTable(_, _, _, _) if
shouldUpdatePlan(updateTable.table) =>
+ updatePlanWithRowLineage(updateTable)
+ }
+ }
+
+
+ private def updatePlanWithRowLineage(updateTable: UpdateIcebergTable):
LogicalPlan = {
+ EliminateSubqueryAliases(updateTable.table) match {
+ case r @ DataSourceV2Relation(_: SupportsRowLevelOperations, _, _, _, _)
=>
+ val lineageAttributes = findRowLineageAttributes(r.metadataOutput).get
+ val (rowId, lastUpdatedSequence) =
(removeMetadataColumnAttribute(lineageAttributes._1),
+ removeMetadataColumnAttribute(lineageAttributes._2))
+
+ val lineageAssignments = updateTable.assignments ++
+ Seq(Assignment(lastUpdatedSequence, Literal(null)),
Assignment(rowId, rowId))
+
+ val tableWithLineage = r.copy(output = r.output ++ Seq(rowId,
lastUpdatedSequence))
+ updateTable.copy(table = tableWithLineage, assignments =
lineageAssignments)
+ }
+ }
+}
diff --git
a/spark/v3.4/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RemoveRowLineageOutputFromOriginalTable.scala
b/spark/v3.4/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RemoveRowLineageOutputFromOriginalTable.scala
new file mode 100644
index 0000000000..a3e9ad372b
--- /dev/null
+++
b/spark/v3.4/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RemoveRowLineageOutputFromOriginalTable.scala
@@ -0,0 +1,54 @@
+/*
+ * 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.spark.sql.catalyst.optimizer
+
+import org.apache.iceberg.MetadataColumns
+import org.apache.spark.sql.catalyst.analysis.NamedRelation
+import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
+import org.apache.spark.sql.catalyst.plans.logical.ReplaceIcebergData
+import org.apache.spark.sql.catalyst.plans.logical.WriteIcebergDelta
+import org.apache.spark.sql.catalyst.rules.Rule
+import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation
+
+/**
+ * RemoveRowLineageOutputFromOriginalTable removes row lineage outputs from
Dsv2 write's
+ * originalTable so that downstream behaviors like relation caching just work,
without having to
+ * modify physical planning strategies.
+ */
+object RemoveRowLineageOutputFromOriginalTable extends Rule[LogicalPlan] {
+ override def apply(plan: LogicalPlan): LogicalPlan = {
+ plan.transform {
+ case writeDelta@WriteIcebergDelta(_, _, originalTable, _, _) =>
+ writeDelta.copy(originalTable = removeRowLineageOutput(originalTable))
+ case replaceData@ReplaceIcebergData(_, _, originalTable, _) =>
+ replaceData.copy(originalTable = removeRowLineageOutput(originalTable))
+ }
+ }
+
+ private def removeRowLineageOutput(table: NamedRelation):
DataSourceV2Relation = {
+ table match {
+ case dsv2Relation@DataSourceV2Relation(_, _, _, _, _) =>
+ dsv2Relation.copy(output = dsv2Relation.output.filterNot(
+ attr => attr.name == MetadataColumns.ROW_ID.name() ||
+ attr.name == MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name()))
+ }
+ }
+}
+
diff --git
a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/SparkRowLevelOperationsTestBase.java
b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/SparkRowLevelOperationsTestBase.java
index b5d6415763..893f9931cf 100644
---
a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/SparkRowLevelOperationsTestBase.java
+++
b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/SparkRowLevelOperationsTestBase.java
@@ -177,7 +177,19 @@ public abstract class SparkRowLevelOperationsTestBase
extends ExtensionsTestBase
SparkCatalog.class.getName(),
ImmutableMap.of("type", "hadoop"),
FileFormat.PARQUET,
- RANDOM.nextBoolean(),
+ true,
+ WRITE_DISTRIBUTION_MODE_HASH,
+ true,
+ null,
+ LOCAL,
+ 3
+ },
+ {
+ "testhadoop",
+ SparkCatalog.class.getName(),
+ ImmutableMap.of("type", "hadoop"),
+ FileFormat.PARQUET,
+ false,
WRITE_DISTRIBUTION_MODE_HASH,
true,
null,
diff --git
a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteWithLineage.java
b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteWithLineage.java
new file mode 100644
index 0000000000..5b82f55ead
--- /dev/null
+++
b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteWithLineage.java
@@ -0,0 +1,35 @@
+/*
+ * 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.iceberg.spark.extensions;
+
+import java.util.Map;
+import org.apache.iceberg.RowLevelOperationMode;
+import org.apache.iceberg.TableProperties;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
+
+public class TestCopyOnWriteWithLineage extends
TestRowLevelOperationsWithLineage {
+
+ @Override
+ protected Map<String, String> extraTableProperties() {
+ return ImmutableMap.of(
+ TableProperties.MERGE_MODE,
RowLevelOperationMode.COPY_ON_WRITE.modeName(),
+ TableProperties.UPDATE_MODE,
RowLevelOperationMode.COPY_ON_WRITE.modeName(),
+ TableProperties.DELETE_MODE,
RowLevelOperationMode.COPY_ON_WRITE.modeName());
+ }
+}
diff --git
a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadWithLineage.java
b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadWithLineage.java
new file mode 100644
index 0000000000..3f038276af
--- /dev/null
+++
b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadWithLineage.java
@@ -0,0 +1,35 @@
+/*
+ * 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.iceberg.spark.extensions;
+
+import java.util.Map;
+import org.apache.iceberg.RowLevelOperationMode;
+import org.apache.iceberg.TableProperties;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
+
+public class TestMergeOnReadWithLineage extends
TestRowLevelOperationsWithLineage {
+
+ @Override
+ protected Map<String, String> extraTableProperties() {
+ return ImmutableMap.of(
+ TableProperties.MERGE_MODE,
RowLevelOperationMode.MERGE_ON_READ.modeName(),
+ TableProperties.UPDATE_MODE,
RowLevelOperationMode.MERGE_ON_READ.modeName(),
+ TableProperties.DELETE_MODE,
RowLevelOperationMode.MERGE_ON_READ.modeName());
+ }
+}
diff --git
a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRowLevelOperationsWithLineage.java
b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRowLevelOperationsWithLineage.java
new file mode 100644
index 0000000000..008e97480e
--- /dev/null
+++
b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRowLevelOperationsWithLineage.java
@@ -0,0 +1,510 @@
+/*
+ * 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.iceberg.spark.extensions;
+
+import static org.apache.iceberg.MetadataColumns.schemaWithRowLineage;
+import static org.apache.iceberg.spark.Spark3Util.loadIcebergTable;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assumptions.assumeThat;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import org.apache.iceberg.AppendFiles;
+import org.apache.iceberg.FileFormat;
+import org.apache.iceberg.Files;
+import org.apache.iceberg.MetadataColumns;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.Snapshot;
+import org.apache.iceberg.SnapshotRef;
+import org.apache.iceberg.StructLike;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TestHelpers;
+import org.apache.iceberg.data.GenericAppenderFactory;
+import org.apache.iceberg.data.GenericRecord;
+import org.apache.iceberg.data.Record;
+import org.apache.iceberg.encryption.EncryptionUtil;
+import org.apache.iceberg.io.DataWriter;
+import org.apache.iceberg.io.OutputFile;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
+import org.apache.iceberg.relocated.com.google.common.collect.Iterables;
+import org.apache.iceberg.relocated.com.google.common.collect.Lists;
+import org.apache.iceberg.spark.functions.BucketFunction;
+import org.apache.iceberg.types.Types;
+import org.apache.iceberg.util.Pair;
+import org.apache.iceberg.util.PartitionMap;
+import org.apache.spark.sql.catalyst.analysis.NoSuchTableException;
+import org.apache.spark.sql.catalyst.parser.ParseException;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.TestTemplate;
+
+public abstract class TestRowLevelOperationsWithLineage extends
SparkRowLevelOperationsTestBase {
+ static final Function<StructLike, StructLike> BUCKET_PARTITION_GENERATOR =
+ record ->
+ TestHelpers.Row.of(BucketFunction.BucketInt.invoke(2, record.get(0,
Integer.class)));
+
+ static final Schema SCHEMA =
+ new Schema(
+ ImmutableList.of(
+ Types.NestedField.required(1, "id", Types.IntegerType.get()),
+ Types.NestedField.required(2, "data", Types.StringType.get()),
+ MetadataColumns.ROW_ID,
+ MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER));
+
+ static final List<Record> INITIAL_RECORDS =
+ ImmutableList.of(
+ createRecord(SCHEMA, 100, "a", 0L, 1L),
+ createRecord(SCHEMA, 101, "b", 1L, 1L),
+ createRecord(SCHEMA, 102, "c", 2L, 1L),
+ createRecord(SCHEMA, 103, "d", 3L, 1L),
+ createRecord(SCHEMA, 104, "e", 4L, 1L));
+
+ @BeforeAll
+ public static void setupSparkConf() {
+ spark.conf().set("spark.sql.shuffle.partitions", "4");
+ }
+
+ @BeforeEach
+ public void beforeEach() {
+ assumeThat(formatVersion).isGreaterThanOrEqualTo(3);
+ // ToDo: Remove these as row lineage inheritance gets implemented in the
other readers
+ assumeThat(fileFormat).isEqualTo(FileFormat.PARQUET);
+ }
+
+ @AfterEach
+ public void removeTables() {
+ sql("DROP TABLE IF EXISTS %s", tableName);
+ sql("DROP TABLE IF EXISTS source");
+ }
+
+ @TestTemplate
+ public void testMergeIntoWithBothMatchedAndNonMatched()
+ throws NoSuchTableException, ParseException, IOException {
+ createAndInitTable("id INT, data STRING", null);
+ createBranchIfNeeded();
+ Table table = loadIcebergTable(spark, tableName);
+ appendUnpartitionedRecords(table, INITIAL_RECORDS);
+ createOrReplaceView(
+ "source",
+ "id int, data string",
+ "{ \"id\": 101, \"data\": \"updated_b\" }\n " + "{ \"id\": 200,
\"data\": \"f\" }\n");
+ sql(
+ "MERGE INTO %s AS t USING source AS s "
+ + "ON t.id == s.id "
+ + "WHEN MATCHED THEN "
+ + " UPDATE SET t.data = s.data "
+ + "WHEN NOT MATCHED THEN "
+ + " INSERT *",
+ commitTarget());
+
+ Snapshot updateSnapshot = latestSnapshot(table);
+ long updateSnapshotFirstRowId = updateSnapshot.firstRowId();
+ List<Object[]> allRows = rowsWithLineageAndFilePos();
+ List<Object[]> carriedOverAndUpdatedRows =
+ allRows.stream()
+ .filter(row -> (long) row[3] < updateSnapshotFirstRowId)
+ .collect(Collectors.toList());
+
+ // Project sequence numbers first for easier comparison on the added row
+ assertEquals(
+ "Rows which are carried over or updated should have expected lineage",
+ ImmutableList.of(
+ row(1L, 100, "a", 0L, ANY, ANY),
+ row(updateSnapshot.sequenceNumber(), 101, "updated_b", 1L, ANY,
ANY),
+ row(1L, 102, "c", 2L, ANY, ANY),
+ row(1L, 103, "d", 3L, ANY, ANY),
+ row(1L, 104, "e", 4L, ANY, ANY)),
+ carriedOverAndUpdatedRows);
+
+ Object[] newRow =
+ Iterables.getOnlyElement(
+ allRows.stream()
+ .filter(row -> (long) row[3] >= updateSnapshotFirstRowId)
+ .collect(Collectors.toList()));
+ assertAddedRowLineage(row(updateSnapshot.sequenceNumber(), 200, "f"),
newRow);
+ }
+
+ @TestTemplate
+ public void testMergeIntoWithBothMatchedAndNonMatchedPartitioned()
+ throws NoSuchTableException, ParseException, IOException {
+ createAndInitTable("id INT, data STRING", "PARTITIONED BY (bucket(2,
id))", null);
+ createBranchIfNeeded();
+ Table table = loadIcebergTable(spark, tableName);
+ appendRecords(
+ table, partitionRecords(INITIAL_RECORDS, table.spec(),
BUCKET_PARTITION_GENERATOR));
+ createOrReplaceView(
+ "source",
+ "id int, data string",
+ "{ \"id\": 101, \"data\": \"updated_b\" }\n " + "{ \"id\": 200,
\"data\": \"f\" }\n");
+ sql(
+ "MERGE INTO %s AS t USING source AS s "
+ + "ON t.id == s.id "
+ + "WHEN MATCHED THEN "
+ + " UPDATE SET t.data = s.data "
+ + "WHEN NOT MATCHED THEN "
+ + " INSERT *",
+ commitTarget());
+
+ Snapshot updateSnapshot = latestSnapshot(table);
+ long updateSnapshotFirstRowId = updateSnapshot.firstRowId();
+ List<Object[]> allRows = rowsWithLineageAndFilePos();
+
+ List<Object[]> carriedOverAndUpdatedRows =
+ allRows.stream()
+ .filter(row -> (long) row[3] < updateSnapshotFirstRowId)
+ .collect(Collectors.toList());
+
+ // Project sequence numbers first for easier comparison on the added row
+ assertEquals(
+ "Rows which are carried over or updated should have expected lineage",
+ ImmutableList.of(
+ row(1L, 100, "a", 0L, ANY, ANY),
+ row(updateSnapshot.sequenceNumber(), 101, "updated_b", 1L, ANY,
ANY),
+ row(1L, 102, "c", 2L, ANY, ANY),
+ row(1L, 103, "d", 3L, ANY, ANY),
+ row(1L, 104, "e", 4L, ANY, ANY)),
+ carriedOverAndUpdatedRows);
+
+ Object[] newRow =
+ Iterables.getOnlyElement(
+ allRows.stream()
+ .filter(row -> (long) row[3] >= updateSnapshotFirstRowId)
+ .collect(Collectors.toList()));
+ assertAddedRowLineage(row(updateSnapshot.sequenceNumber(), 200, "f"),
newRow);
+ }
+
+ @TestTemplate
+ public void testMergeIntoWithOnlyNonMatched()
+ throws NoSuchTableException, ParseException, IOException {
+ createAndInitTable("id INT, data string", null);
+ createBranchIfNeeded();
+ Table table = loadIcebergTable(spark, tableName);
+ appendUnpartitionedRecords(table, INITIAL_RECORDS);
+ createOrReplaceView(
+ "source",
+ "id INT, data STRING",
+ "{ \"id\": 101, \"data\": \"updated_b\" }\n " + "{ \"id\": 200,
\"data\": \"f\" }\n");
+
+ sql(
+ "MERGE INTO %s AS t USING source AS s "
+ + "ON t.id == s.id "
+ + "WHEN NOT MATCHED THEN "
+ + "INSERT *",
+ commitTarget());
+
+ Snapshot updateSnapshot = latestSnapshot(table);
+ long updateSnapshotFirstRowId = updateSnapshot.firstRowId();
+
+ List<Object[]> allRows = rowsWithLineageAndFilePos();
+ List<Object[]> carriedOverAndUpdatedRows =
+ allRows.stream()
+ .filter(row -> (long) row[3] < updateSnapshotFirstRowId)
+ .collect(Collectors.toList());
+
+ // Project sequence numbers first for easier comparison on the added row
+ assertEquals(
+ "Rows which are carried over or updated should have expected lineage",
+ ImmutableList.of(
+ row(1L, 100, "a", 0L, ANY, ANY),
+ row(1L, 101, "b", 1L, ANY, ANY),
+ row(1L, 102, "c", 2L, ANY, ANY),
+ row(1L, 103, "d", 3L, ANY, ANY),
+ row(1L, 104, "e", 4L, ANY, ANY)),
+ carriedOverAndUpdatedRows);
+
+ Object[] newRow =
+ Iterables.getOnlyElement(
+ allRows.stream()
+ .filter(row -> (long) row[3] >= updateSnapshotFirstRowId)
+ .collect(Collectors.toList()));
+ assertAddedRowLineage(row(updateSnapshot.sequenceNumber(), 200, "f"),
newRow);
+ }
+
+ @TestTemplate
+ public void testMergeIntoWithOnlyMatched()
+ throws IOException, NoSuchTableException, ParseException {
+ createAndInitTable("id INT, data STRING", null);
+ createBranchIfNeeded();
+ Table table = loadIcebergTable(spark, tableName);
+ appendUnpartitionedRecords(table, INITIAL_RECORDS);
+ createOrReplaceView(
+ "source",
+ "id INT, data string",
+ "{ \"id\": 101, \"data\": \"updated_b\" }\n "
+ + "{ \"id\": 102, \"data\": \"updated_c\" }\n");
+
+ sql(
+ "MERGE INTO %s AS t USING source AS s "
+ + "ON t.id == s.id "
+ + "WHEN MATCHED THEN "
+ + " UPDATE SET t.data = s.data ",
+ commitTarget());
+
+ long updateSequenceNumber = latestSnapshot(table).sequenceNumber();
+ assertEquals(
+ "Rows which are carried over or updated should have expected lineage",
+ ImmutableList.of(
+ row(100, "a", 0L, 1L),
+ row(101, "updated_b", 1L, updateSequenceNumber),
+ row(102, "updated_c", 2L, updateSequenceNumber),
+ row(103, "d", 3L, 1L),
+ row(104, "e", 4L, 1L)),
+ rowsWithLineage());
+ }
+
+ @TestTemplate
+ public void testMergeMatchedDelete() throws NoSuchTableException,
ParseException, IOException {
+ createAndInitTable("id INT, data STRING", null);
+ createBranchIfNeeded();
+ Table table = loadIcebergTable(spark, tableName);
+ appendUnpartitionedRecords(table, INITIAL_RECORDS);
+ createOrReplaceView(
+ "source",
+ "id INT, data string",
+ "{ \"id\": 101, \"data\": \"delete_101\" }\n "
+ + "{ \"id\": 102, \"data\": \"delete_102\" }\n");
+ sql(
+ "MERGE INTO %s AS t USING source AS s " + "ON t.id == s.id " + "WHEN
MATCHED THEN DELETE",
+ commitTarget());
+
+ assertEquals(
+ "Rows which are carried over or updated should have expected lineage",
+ ImmutableList.of(row(100, "a", 0L, 1L), row(103, "d", 3L, 1L),
row(104, "e", 4L, 1L)),
+ rowsWithLineage());
+ }
+
+ @TestTemplate
+ public void testUpdate() throws NoSuchTableException, ParseException,
IOException {
+ createAndInitTable("id INT, data STRING", null);
+ createBranchIfNeeded();
+ Table table = loadIcebergTable(spark, tableName);
+ appendUnpartitionedRecords(table, INITIAL_RECORDS);
+
+ sql("UPDATE %s AS t set data = 'updated_b' WHERE id = 101",
commitTarget());
+ long updateSequenceNumber = latestSnapshot(table).sequenceNumber();
+
+ assertEquals(
+ "Rows which are carried over or updated should have expected lineage",
+ ImmutableList.of(
+ row(100, "a", 0L, 1L),
+ row(101, "updated_b", 1L, updateSequenceNumber),
+ row(102, "c", 2L, 1L),
+ row(103, "d", 3L, 1L),
+ row(104, "e", 4L, 1L)),
+ rowsWithLineage());
+ }
+
+ @TestTemplate
+ public void testDelete() throws NoSuchTableException, ParseException,
IOException {
+ assumeThat(formatVersion).isGreaterThanOrEqualTo(3);
+ createAndInitTable("id int, data STRING", null);
+ createBranchIfNeeded();
+ Table table = loadIcebergTable(spark, tableName);
+ appendUnpartitionedRecords(table, INITIAL_RECORDS);
+
+ sql("DELETE FROM %s WHERE id = 101", commitTarget());
+
+ assertEquals(
+ "Rows which are carried over or updated should have expected lineage",
+ ImmutableList.of(
+ row(100, "a", 0L, 1L),
+ row(102, "c", 2L, 1L),
+ row(103, "d", 3L, 1L),
+ row(104, "e", 4L, 1L)),
+ rowsWithLineage());
+ }
+
+ @TestTemplate
+ public void testMergeWithManyRecords() throws NoSuchTableException,
ParseException, IOException {
+ createAndInitTable("id INT, data STRING", null);
+ createBranchIfNeeded();
+ Table table = loadIcebergTable(spark, tableName);
+
+ int numRecords = 25000;
+ int startingId = 100;
+
+ List<Record> initialRecords = Lists.newArrayList();
+ int rowId = 0;
+ for (int id = 100; id < startingId + numRecords; id++) {
+ initialRecords.add(createRecord(SCHEMA, id, "data_" + id, rowId++, 1L));
+ }
+
+ appendUnpartitionedRecords(table, initialRecords);
+ createOrReplaceView(
+ "source",
+ "id int, data string",
+ "{ \"id\": 101, \"data\": \"updated_data_101\" }\n "
+ + "{ \"id\": 26000, \"data\": \"data_26000\" }\n");
+ sql(
+ "MERGE INTO %s AS t USING source AS s "
+ + "ON t.id == s.id "
+ + "WHEN MATCHED THEN "
+ + " UPDATE SET t.data = s.data "
+ + "WHEN NOT MATCHED THEN "
+ + " INSERT *",
+ commitTarget());
+
+ Snapshot updateSnapshot = latestSnapshot(table);
+ long updateSnapshotFirstRowId = updateSnapshot.firstRowId();
+ List<Object[]> allRows = rowsWithLineageAndFilePos();
+ List<Object[]> carriedOverAndUpdatedRows =
+ allRows.stream()
+ .filter(row -> (long) row[3] < updateSnapshotFirstRowId)
+ .collect(Collectors.toList());
+
+ int newlyInsertedId = 26000;
+ int updatedId = 101;
+ List<Object[]> expectedCarriedOverAndUpdatedRows =
+ ImmutableList.<Object[]>builder()
+ .add(row(1L, 100, "data_100", 0L, ANY, ANY))
+ .add(row(updateSnapshot.sequenceNumber(), updatedId,
"updated_data_101", 1L, ANY, ANY))
+ .addAll(
+ // Every record with higher ids than the updated excluding the
new row should be a
+ // carry over
+ initialRecords.stream()
+ .filter(
+ initialRecord -> {
+ int id = initialRecord.get(0, Integer.class);
+ return id > updatedId && id != newlyInsertedId;
+ })
+ .map(this::recordToExpectedRow)
+ .collect(Collectors.toList()))
+ .build();
+
+ assertEquals(
+ "Rows which are carried over or updated should have expected lineage",
+ expectedCarriedOverAndUpdatedRows,
+ carriedOverAndUpdatedRows);
+
+ Object[] newRow =
+ Iterables.getOnlyElement(
+ allRows.stream()
+ .filter(row -> (long) row[3] >= updateSnapshotFirstRowId)
+ .collect(Collectors.toList()));
+ assertAddedRowLineage(row(updateSnapshot.sequenceNumber(), 26000,
"data_26000"), newRow);
+ }
+
+ private Object[] recordToExpectedRow(Record record) {
+ int id = record.get(0, Integer.class);
+ String data = record.get(1, String.class);
+ long rowId = record.get(2, Long.class);
+ long lastUpdated = record.get(3, Long.class);
+ return row(lastUpdated, id, data, rowId, ANY, ANY);
+ }
+
+ private List<Object[]> rowsWithLineageAndFilePos() {
+ return sql(
+ "SELECT s._last_updated_sequence_number, s.id, s.data, s._row_id,
files.first_row_id, s._pos FROM %s"
+ + " AS s JOIN %s.files AS files ON files.file_path = s._file ORDER
BY s._row_id",
+ selectTarget(), selectTarget());
+ }
+
+ private List<Object[]> rowsWithLineage() {
+ return sql(
+ "SELECT id, data, _row_id, _last_updated_sequence_number FROM %s ORDER
BY _row_id",
+ selectTarget());
+ }
+
+ /**
+ * Partitions the provided records based on the spec and partition function
+ *
+ * @return a partitioned map
+ */
+ protected PartitionMap<List<Record>> partitionRecords(
+ List<Record> records,
+ PartitionSpec spec,
+ Function<StructLike, StructLike> partitionGenerator) {
+ PartitionMap<List<Record>> recordsByPartition =
+ PartitionMap.create(Map.of(spec.specId(), spec));
+ for (Record record : records) {
+ StructLike partition = partitionGenerator != null ?
partitionGenerator.apply(record) : null;
+ List<Record> recordsForPartition = recordsByPartition.get(spec.specId(),
partition);
+ if (recordsForPartition == null) {
+ recordsForPartition = Lists.newArrayList();
+ }
+
+ recordsForPartition.add(record);
+ recordsByPartition.put(spec.specId(), partition, recordsForPartition);
+ }
+
+ return recordsByPartition;
+ }
+
+ protected void appendUnpartitionedRecords(Table table, List<Record> records)
throws IOException {
+ appendRecords(table, partitionRecords(records, table.spec(), record ->
null));
+ }
+
+ // Append unpartitioned records?
+ protected void appendRecords(Table table, PartitionMap<List<Record>>
partitionedRecords)
+ throws IOException {
+ AppendFiles append = table.newAppend();
+
+ for (Map.Entry<Pair<Integer, StructLike>, List<Record>> entry :
partitionedRecords.entrySet()) {
+ OutputFile file =
Files.localOutput(temp.resolve(UUID.randomUUID().toString()).toFile());
+ DataWriter<Record> writer =
+ new GenericAppenderFactory(schemaWithRowLineage(table.schema()),
table.spec())
+ .newDataWriter(
+ EncryptionUtil.plainAsEncryptedOutput(file), fileFormat,
entry.getKey().second());
+ List<Record> recordsForPartition = entry.getValue();
+ writer.write(recordsForPartition);
+ writer.close();
+ append =
+ append
+ .appendFile(writer.toDataFile())
+ .toBranch(branch != null ? branch : SnapshotRef.MAIN_BRANCH);
+ }
+
+ append.commit();
+ }
+
+ protected static Record createRecord(
+ Schema schema, int id, String data, long rowId, long
lastUpdatedSequenceNumber) {
+ Record record = GenericRecord.create(schema);
+ record.set(0, id);
+ record.set(1, data);
+ record.set(2, rowId);
+ record.set(3, lastUpdatedSequenceNumber);
+ return record;
+ }
+
+ private Snapshot latestSnapshot(Table table) {
+ return branch != null ? table.snapshot(branch) : table.currentSnapshot();
+ }
+
+ // Expected should have last updated sequence number followed by data columns
+ // Actual should have the contents of expected followed by the file first
row ID and position
+ private void assertAddedRowLineage(Object[] expected, Object[] actual) {
+ // validate the sequence number and all the data columns
+ for (int pos = 0; pos < expected.length; pos++) {
+ assertThat(actual[pos]).isEqualTo(expected[pos]);
+ }
+
+ int rowIdPos = expected.length;
+ int firstRowIdPos = rowIdPos + 1;
+ int positionPos = firstRowIdPos + 1;
+ long expectedRowId = (Long) actual[firstRowIdPos] + (Long)
actual[positionPos];
+ assertThat(actual[rowIdPos]).isEqualTo(expectedRowId);
+ }
+}
diff --git
a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteOperation.java
b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteOperation.java
index 4fca05345a..dd3d785b3b 100644
---
a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteOperation.java
+++
b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteOperation.java
@@ -21,9 +21,12 @@ package org.apache.iceberg.spark.source;
import static
org.apache.spark.sql.connector.write.RowLevelOperation.Command.DELETE;
import static
org.apache.spark.sql.connector.write.RowLevelOperation.Command.UPDATE;
+import java.util.List;
import org.apache.iceberg.IsolationLevel;
import org.apache.iceberg.MetadataColumns;
import org.apache.iceberg.Table;
+import org.apache.iceberg.TableUtil;
+import org.apache.iceberg.relocated.com.google.common.collect.Lists;
import org.apache.spark.sql.SparkSession;
import org.apache.spark.sql.connector.expressions.Expressions;
import org.apache.spark.sql.connector.expressions.NamedReference;
@@ -95,13 +98,18 @@ class SparkCopyOnWriteOperation implements
RowLevelOperation {
@Override
public NamedReference[] requiredMetadataAttributes() {
- NamedReference file = Expressions.column(MetadataColumns.FILE_PATH.name());
- NamedReference pos =
Expressions.column(MetadataColumns.ROW_POSITION.name());
-
+ List<NamedReference> metadataAttributes = Lists.newArrayList();
+
metadataAttributes.add(Expressions.column(MetadataColumns.FILE_PATH.name()));
if (command == DELETE || command == UPDATE) {
- return new NamedReference[] {file, pos};
- } else {
- return new NamedReference[] {file};
+
metadataAttributes.add(Expressions.column(MetadataColumns.ROW_POSITION.name()));
+ }
+
+ if (TableUtil.supportsRowLineage(table)) {
+
metadataAttributes.add(Expressions.column(MetadataColumns.ROW_ID.name()));
+ metadataAttributes.add(
+
Expressions.column(MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name()));
}
+
+ return metadataAttributes.toArray(NamedReference[]::new);
}
}
diff --git
a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java
b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java
index 7a6025b073..733f35fb2a 100644
---
a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java
+++
b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java
@@ -35,6 +35,7 @@ import org.apache.iceberg.metrics.ScanReport;
import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
import org.apache.iceberg.relocated.com.google.common.collect.Sets;
import org.apache.iceberg.spark.SparkReadConf;
+import org.apache.iceberg.spark.SparkSchemaUtil;
import org.apache.iceberg.util.SnapshotUtil;
import org.apache.spark.sql.SparkSession;
import org.apache.spark.sql.connector.expressions.Expressions;
@@ -43,6 +44,10 @@ import org.apache.spark.sql.connector.read.Statistics;
import org.apache.spark.sql.connector.read.SupportsRuntimeFiltering;
import org.apache.spark.sql.sources.Filter;
import org.apache.spark.sql.sources.In;
+import org.apache.spark.sql.types.Metadata;
+import org.apache.spark.sql.types.MetadataBuilder;
+import org.apache.spark.sql.types.StructField;
+import org.apache.spark.sql.types.StructType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -53,6 +58,7 @@ class SparkCopyOnWriteScan extends
SparkPartitioningAwareScan<FileScanTask>
private final Snapshot snapshot;
private Set<String> filteredLocations = null;
+ private StructType readSchema;
SparkCopyOnWriteScan(
SparkSession spark,
@@ -82,6 +88,15 @@ class SparkCopyOnWriteScan extends
SparkPartitioningAwareScan<FileScanTask>
}
}
+ @Override
+ public StructType readSchema() {
+ if (readSchema == null) {
+ this.readSchema =
rowLineageAsDataCols(SparkSchemaUtil.convert(expectedSchema()));
+ }
+
+ return readSchema;
+ }
+
Long snapshotId() {
return snapshot != null ? snapshot.snapshotId() : null;
}
@@ -187,4 +202,29 @@ class SparkCopyOnWriteScan extends
SparkPartitioningAwareScan<FileScanTask>
Snapshot currentSnapshot = SnapshotUtil.latestSnapshot(table(), branch());
return currentSnapshot != null ? currentSnapshot.snapshotId() : null;
}
+
+ // Indicate to Spark to treat the row id and sequence number as data columns
since some optimizer
+ // rules for DELETE will not output the row lineage columns otherwise
+ private StructType rowLineageAsDataCols(StructType schema) {
+ StructField[] fields = new StructField[schema.fields().length];
+ for (int i = 0; i < schema.fields().length; i++) {
+ StructField field = schema.fields()[i];
+ if (isRowLineageField(field)) {
+ Metadata updatedMetadata =
+ new
MetadataBuilder().withMetadata(field.metadata()).remove("__metadata_col").build();
+ fields[i] = field.copy(field.name(), field.dataType(),
field.nullable(), updatedMetadata);
+ } else {
+ fields[i] = field;
+ }
+ }
+
+ return new StructType(fields);
+ }
+
+ private boolean isRowLineageField(StructField field) {
+ boolean hasLineageFieldName =
+ field.name().equals(MetadataColumns.ROW_ID.name())
+ ||
field.name().equals(MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name());
+ return hasLineageFieldName && field.metadata().contains("__metadata_col");
+ }
}
diff --git
a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeltaOperation.java
b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeltaOperation.java
index 8acd87d3cb..7d71142364 100644
---
a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeltaOperation.java
+++
b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeltaOperation.java
@@ -18,9 +18,12 @@
*/
package org.apache.iceberg.spark.source;
+import java.util.List;
import org.apache.iceberg.IsolationLevel;
import org.apache.iceberg.MetadataColumns;
import org.apache.iceberg.Table;
+import org.apache.iceberg.TableUtil;
+import org.apache.iceberg.relocated.com.google.common.collect.Lists;
import org.apache.spark.sql.SparkSession;
import org.apache.spark.sql.connector.expressions.Expressions;
import org.apache.spark.sql.connector.expressions.NamedReference;
@@ -96,9 +99,17 @@ class SparkPositionDeltaOperation implements
RowLevelOperation, SupportsDelta {
@Override
public NamedReference[] requiredMetadataAttributes() {
- NamedReference specId = Expressions.column(MetadataColumns.SPEC_ID.name());
- NamedReference partition =
Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME);
- return new NamedReference[] {specId, partition};
+ List<NamedReference> metadataAttributes = Lists.newArrayList();
+ metadataAttributes.add(Expressions.column(MetadataColumns.SPEC_ID.name()));
+
metadataAttributes.add(Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME));
+
+ if (TableUtil.supportsRowLineage(table)) {
+
metadataAttributes.add(Expressions.column(MetadataColumns.ROW_ID.name()));
+ metadataAttributes.add(
+
Expressions.column(MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name()));
+ }
+
+ return metadataAttributes.toArray(new NamedReference[0]);
}
@Override
diff --git
a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeltaWriteBuilder.java
b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeltaWriteBuilder.java
index c58935206b..70cd735e1d 100644
---
a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeltaWriteBuilder.java
+++
b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeltaWriteBuilder.java
@@ -22,6 +22,7 @@ import org.apache.iceberg.IsolationLevel;
import org.apache.iceberg.MetadataColumns;
import org.apache.iceberg.Schema;
import org.apache.iceberg.Table;
+import org.apache.iceberg.TableUtil;
import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
import org.apache.iceberg.spark.SparkSchemaUtil;
import org.apache.iceberg.spark.SparkUtil;
@@ -85,8 +86,12 @@ class SparkPositionDeltaWriteBuilder implements
DeltaWriteBuilder {
if (info.schema() == null || info.schema().isEmpty()) {
return null;
} else {
- Schema dataSchema = SparkSchemaUtil.convert(table.schema(),
info.schema());
- validateSchema("data", table.schema(), dataSchema);
+ Schema writeSchema =
+ TableUtil.supportsRowLineage(table)
+ ? MetadataColumns.schemaWithRowLineage(table.schema())
+ : table.schema();
+ Schema dataSchema = SparkSchemaUtil.convert(writeSchema, info.schema());
+ validateSchema("data", writeSchema, dataSchema);
return dataSchema;
}
}
@@ -104,6 +109,10 @@ class SparkPositionDeltaWriteBuilder implements
DeltaWriteBuilder {
new Schema(
MetadataColumns.SPEC_ID,
MetadataColumns.metadataColumn(table,
MetadataColumns.PARTITION_COLUMN_NAME));
+ if (TableUtil.supportsRowLineage(table)) {
+ expectedMetadataSchema =
MetadataColumns.schemaWithRowLineage(expectedMetadataSchema);
+ }
+
StructType metadataSparkType = info.metadataSchema().get();
Schema metadataSchema = SparkSchemaUtil.convert(expectedMetadataSchema,
metadataSparkType);
validateSchema("metadata", expectedMetadataSchema, metadataSchema);
diff --git
a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkTable.java
b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkTable.java
index bbc7434138..e4c0789971 100644
---
a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkTable.java
+++
b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkTable.java
@@ -261,7 +261,10 @@ public class SparkTable
new SparkMetadataColumn(MetadataColumns.PARTITION_COLUMN_NAME,
sparkPartitionType, true),
new SparkMetadataColumn(MetadataColumns.FILE_PATH.name(),
DataTypes.StringType, false),
new SparkMetadataColumn(MetadataColumns.ROW_POSITION.name(),
DataTypes.LongType, false),
- new SparkMetadataColumn(MetadataColumns.IS_DELETED.name(),
DataTypes.BooleanType, false)
+ new SparkMetadataColumn(MetadataColumns.IS_DELETED.name(),
DataTypes.BooleanType, false),
+ new SparkMetadataColumn(MetadataColumns.ROW_ID.name(),
DataTypes.LongType, true),
+ new SparkMetadataColumn(
+ MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name(),
DataTypes.LongType, true)
};
}
diff --git
a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkWriteBuilder.java
b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkWriteBuilder.java
index 602b692d73..ece0a5e6ff 100644
---
a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkWriteBuilder.java
+++
b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkWriteBuilder.java
@@ -19,8 +19,10 @@
package org.apache.iceberg.spark.source;
import org.apache.iceberg.IsolationLevel;
+import org.apache.iceberg.MetadataColumns;
import org.apache.iceberg.Schema;
import org.apache.iceberg.Table;
+import org.apache.iceberg.TableUtil;
import org.apache.iceberg.UpdateSchema;
import org.apache.iceberg.expressions.Expression;
import org.apache.iceberg.expressions.Expressions;
@@ -116,8 +118,14 @@ class SparkWriteBuilder implements WriteBuilder,
SupportsDynamicOverwrite, Suppo
@Override
public Write build() {
- // Validate
- Schema writeSchema = validateOrMergeWriteSchema(table, dsSchema,
writeConf);
+ // The write schema should only include row lineage in the output if it's
an overwrite
+ // operation.
+ // In any other case, only null row IDs and sequence numbers would be
produced which
+ // means the row lineage columns can be excluded from the output files
+ boolean writeIncludesRowLineage = TableUtil.supportsRowLineage(table) &&
overwriteFiles;
+ Schema writeSchema =
+ validateOrMergeWriteSchema(table, dsSchema, writeConf,
writeIncludesRowLineage);
+
SparkUtil.validatePartitionTransforms(table.spec());
// Get application id
@@ -170,7 +178,7 @@ class SparkWriteBuilder implements WriteBuilder,
SupportsDynamicOverwrite, Suppo
}
private static Schema validateOrMergeWriteSchema(
- Table table, StructType dsSchema, SparkWriteConf writeConf) {
+ Table table, StructType dsSchema, SparkWriteConf writeConf, boolean
writeIncludesRowLineage) {
Schema writeSchema;
boolean caseSensitive = writeConf.caseSensitive();
if (writeConf.mergeSchema()) {
@@ -182,6 +190,10 @@ class SparkWriteBuilder implements WriteBuilder,
SupportsDynamicOverwrite, Suppo
UpdateSchema update =
table.updateSchema().caseSensitive(caseSensitive).unionByNameWith(newSchema);
Schema mergedSchema = update.apply();
+ if (writeIncludesRowLineage) {
+ mergedSchema =
+ TypeUtil.join(mergedSchema,
MetadataColumns.schemaWithRowLineage(table.schema()));
+ }
// reconvert the dsSchema without assignment to use the ids assigned by
UpdateSchema
writeSchema = SparkSchemaUtil.convert(mergedSchema, dsSchema,
caseSensitive);
@@ -192,7 +204,11 @@ class SparkWriteBuilder implements WriteBuilder,
SupportsDynamicOverwrite, Suppo
// if the validation passed, update the table schema
update.commit();
} else {
- writeSchema = SparkSchemaUtil.convert(table.schema(), dsSchema,
caseSensitive);
+ Schema schema =
+ writeIncludesRowLineage
+ ? MetadataColumns.schemaWithRowLineage(table.schema())
+ : table.schema();
+ writeSchema = SparkSchemaUtil.convert(schema, dsSchema, caseSensitive);
TypeUtil.validateWriteSchema(
table.schema(), writeSchema, writeConf.checkNullability(),
writeConf.checkOrdering());
}