This is an automated email from the ASF dual-hosted git repository.
andygrove pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-comet.git
The following commit(s) were added to refs/heads/main by this push:
new 3598d55951 feat: Optionally split the Iceberg V2 write operator into
distinct writer and committer operations (#4658)
3598d55951 is described below
commit 3598d55951948e4d35227a6dce6562eb46751d14
Author: Jordan Epstein <[email protected]>
AuthorDate: Thu Aug 6 21:53:00 2026 -0500
feat: Optionally split the Iceberg V2 write operator into distinct writer
and committer operations (#4658)
* docs: add Iceberg V2 writes user guide
* feat: add split-operator plan for Iceberg V2 appends and overwrites
Iceberg's V2 writes run as a single `V2ExistingTableWriteExec` command that
both
writes files and commits. Spark's `InsertAdaptiveSparkPlan` treats it as a
leaf,
so AQE never sees the data sub-query inside it and Comet's columnar rules
can't
convert the scans / shuffles / sorts that feed the write.
This injects `IcebergWriteStrategy` ahead of Spark's `DataSourceV2Strategy`
(via
`experimentalMethods.extraStrategies`, which is prepended to the planner's
strategy list). For Iceberg `AppendData`, `OverwriteByExpression`, and
`OverwritePartitionsDynamic` it rewrites the single command into two
operators:
IcebergCommitExec(batchWrite, refreshCache) <- committer (driver-side)
└── AdaptiveSparkPlanExec <- AQE bubble
└── IcebergWriteExec(batchWrite) <- writer (UnaryExecNode)
└── <data sub-query> <- now visible to AQE /
Comet
The writer emits one row per Spark task carrying the Java-serialised
`WriterCommitMessage`; the committer collects them and calls
`BatchWrite.commit(messages)` -- the same call Iceberg-Java makes
internally --
then runs Spark's post-write cache refresh.
Two design points keep this stable under AQE, which re-runs the planner on
each
materialised stage's `logicalLink`:
- The writer's child is wrapped in `IcebergWriteLogical`, a stable logical
anchor, so each re-plan re-emits only the writer rather than vanishing it
or
re-firing the surrounding logical write and duplicating the commit.
- The `BatchWrite` is materialised once (`Write.toBatch()` mints a fresh
instance per call) and shared between committer and writer, so commit-time
validation sees the same scan / emitted-file state the writer used.
`WriteDelta` (merge-on-read) is intentionally not intercepted: its per-task
`DeltaWriter` is row-dispatched and the native writer can't emit
position-delete
files, so the split plan would add planning complexity for no acceleration.
Copy-on-write DELETE / UPDATE / MERGE (`ReplaceData`) is handled in a
follow-up
commit.
Guarded by `spark.comet.write.iceberg.splitOperator.enabled` (off by
default);
when off, writes go straight through Iceberg-Java unchanged. File writing
still
runs through Iceberg's JVM writer -- native Parquet write lands in a later
commit.
* feat: extend Iceberg split plan to copy-on-write DELETE/UPDATE/MERGE
Adds copy-on-write row-level DML to the split-operator plan. Spark lowers a
CoW
`DELETE` / `UPDATE` / `MERGE` on a V2 table into a `ReplaceData` logical
node;
`IcebergWriteStrategy` now matches it (refreshing `originalTable`'s cache)
and
routes it through the same committer + writer pair as appends/overwrites.
The writer gains a per-row dispatch path. On Spark 4.x a `ReplaceData`
rewrite
prefixes each row with an operation code and carries a
`ReplaceDataProjections`;
`IcebergWriteExec` applies the row / metadata projections per code (WRITE /
WRITE_WITH_METADATA) before handing rows to the `DataWriter`. The two-arg
`DataWriter.write(metadata, row)` is looked up reflectively since it is
4.x-only.
On Spark 3.4 / 3.5 the rewritten stream is already post-projection, so the
shim
returns `None` and the writer keeps its plain `write(row)` loop.
`ReplaceDataProjections` does not exist on 3.4 / 3.5, so
`ReplaceDataDispatchInfo`
mirrors it as a version-neutral carrier, populated by the per-version
`IcebergReplaceDataShim`.
Iceberg 1.5.2 (Spark 3.4) lacks native row-level operations and instead
routes
UPDATE / MERGE through its own `ReplaceIcebergData` logical node. It has
the same
field shape as Spark's `ReplaceData` and is matched by FQCN via reflection,
so
the main module takes no compile dependency on iceberg-spark-extensions.
* test: cover Iceberg V2 split-operator appends and overwrites
* test: cover Iceberg copy-on-write DELETE/UPDATE/MERGE writes
* fix: correct Iceberg split write metrics, abort guard, and dispatch
* test: cover review-requested Iceberg split write scenarios
* docs: note CTAS and RTAS planning in Iceberg split writes
---------
Co-authored-by: Jordan Epstein <[email protected]>
Co-authored-by: Andy Grove <[email protected]>
---
.github/workflows/pr_build_linux.yml | 1 +
.github/workflows/pr_build_macos.yml | 1 +
dev/diffs/iceberg/1.10.0.diff | 56 ++
dev/diffs/iceberg/1.11.0.diff | 28 +
dev/diffs/iceberg/1.8.1.diff | 56 ++
dev/diffs/iceberg/1.9.1.diff | 56 ++
docs/source/user-guide/latest/iceberg-writes.md | 97 ++
docs/source/user-guide/latest/index.rst | 1 +
.../main/scala/org/apache/comet/CometConf.scala | 10 +
.../apache/comet/CometSparkSessionExtensions.scala | 2 +
.../apache/comet/iceberg/IcebergReflection.scala | 43 +
.../apache/comet/iceberg/IcebergWriteLogical.scala | 43 +
.../comet/iceberg/IcebergWriteStrategy.scala | 122 +++
.../comet/iceberg/ReplaceDataDispatchInfo.scala | 31 +
.../apache/spark/sql/comet/IcebergCommitExec.scala | 125 +++
.../apache/spark/sql/comet/IcebergWriteExec.scala | 179 ++++
.../comet/iceberg/IcebergDriverMetricsShim.scala | 28 +
.../comet/iceberg/IcebergRefreshCacheShim.scala | 29 +
.../comet/iceberg/IcebergReplaceDataShim.scala | 26 +
.../spark/sql/comet/IcebergDataWriterShim.scala | 35 +
.../spark/sql/comet/IcebergWriteSummaryShim.scala | 33 +
.../comet/iceberg/IcebergRefreshCacheShim.scala | 33 +
.../spark/sql/comet/IcebergWriteSummaryShim.scala | 33 +
.../comet/iceberg/IcebergRefreshCacheShim.scala | 43 +
.../spark/sql/comet/IcebergWriteSummaryShim.scala | 55 ++
.../comet/iceberg/IcebergDriverMetricsShim.scala | 27 +
.../comet/iceberg/IcebergReplaceDataShim.scala | 34 +
.../spark/sql/comet/IcebergDataWriterShim.scala | 32 +
.../comet/CometIcebergRewriteActionSuite.scala | 13 +-
.../comet/CometIcebergWriteActionSuite.scala | 979 +++++++++++++++++++++
30 files changed, 2247 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/pr_build_linux.yml
b/.github/workflows/pr_build_linux.yml
index 8fbf82d3f9..def2816607 100644
--- a/.github/workflows/pr_build_linux.yml
+++ b/.github/workflows/pr_build_linux.yml
@@ -314,6 +314,7 @@ jobs:
org.apache.comet.CometIcebergNativeSuite
org.apache.comet.CometIcebergEncryptionSuite
org.apache.comet.CometIcebergRewriteActionSuite
+ org.apache.comet.CometIcebergWriteActionSuite
org.apache.comet.iceberg.IcebergReflectionSuite
org.apache.comet.csv.CometCsvNativeReadSuite
org.apache.comet.CometFuzzTestSuite
diff --git a/.github/workflows/pr_build_macos.yml
b/.github/workflows/pr_build_macos.yml
index 5b92c53c4a..bac1dcd15b 100644
--- a/.github/workflows/pr_build_macos.yml
+++ b/.github/workflows/pr_build_macos.yml
@@ -130,6 +130,7 @@ jobs:
org.apache.comet.CometIcebergNativeSuite
org.apache.comet.CometIcebergEncryptionSuite
org.apache.comet.CometIcebergRewriteActionSuite
+ org.apache.comet.CometIcebergWriteActionSuite
org.apache.comet.iceberg.IcebergReflectionSuite
org.apache.comet.csv.CometCsvNativeReadSuite
org.apache.comet.CometFuzzTestSuite
diff --git a/dev/diffs/iceberg/1.10.0.diff b/dev/diffs/iceberg/1.10.0.diff
index fd5aad2a32..310b541499 100644
--- a/dev/diffs/iceberg/1.10.0.diff
+++ b/dev/diffs/iceberg/1.10.0.diff
@@ -3025,3 +3025,59 @@ index 9d2ce2b388..5e23368848 100644
} else {
assertThat(planAsString).as("Should be no post scan
filter").doesNotContain("Filter (");
}
+diff --git
a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownInRowLevelOperations.java
b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownInRowLevelOperations.java
+index 5a0a04edb..afe92a8b7 100644
+---
a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownInRowLevelOperations.java
++++
b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownInRowLevelOperations.java
+@@ -38,6 +38,7 @@ import
org.apache.spark.sql.catalyst.expressions.ApplyFunctionExpression;
+ import org.apache.spark.sql.catalyst.expressions.Expression;
+ import org.apache.spark.sql.catalyst.expressions.objects.StaticInvoke;
+ import org.apache.spark.sql.execution.CommandResultExec;
++import org.apache.spark.sql.execution.SparkPlan;
+ import org.apache.spark.sql.execution.datasources.v2.V2TableWriteExec;
+ import org.junit.jupiter.api.AfterEach;
+ import org.junit.jupiter.api.BeforeEach;
+@@ -313,9 +314,13 @@ public class
TestSystemFunctionPushDownInRowLevelOperations extends ExtensionsTe
+
+ private List<Expression> executeAndCollectFunctionCalls(String query,
Object... args) {
+ CommandResultExec command = (CommandResultExec) executeAndKeepPlan(query,
args);
+- V2TableWriteExec write = (V2TableWriteExec) command.commandPhysicalPlan();
++ // Comet's split-operator Iceberg write plans the command as
IcebergCommitExec, which is not
++ // a V2TableWriteExec; collect over the whole command plan in that case.
++ SparkPlan plan = command.commandPhysicalPlan();
++ SparkPlan queryPlan =
++ plan instanceof V2TableWriteExec ? ((V2TableWriteExec) plan).query()
: plan;
+ return SparkPlanUtil.collectExprs(
+- write.query(),
++ queryPlan,
+ expr -> expr instanceof StaticInvoke || expr instanceof
ApplyFunctionExpression);
+ }
+
+diff --git
a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownInRowLevelOperations.java
b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownInRowLevelOperations.java
+index 8cb92224f..777b9bb1d 100644
+---
a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownInRowLevelOperations.java
++++
b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownInRowLevelOperations.java
+@@ -38,6 +38,7 @@ import
org.apache.spark.sql.catalyst.expressions.ApplyFunctionExpression;
+ import org.apache.spark.sql.catalyst.expressions.Expression;
+ import org.apache.spark.sql.catalyst.expressions.objects.StaticInvoke;
+ import org.apache.spark.sql.execution.CommandResultExec;
++import org.apache.spark.sql.execution.SparkPlan;
+ import org.apache.spark.sql.execution.datasources.v2.V2TableWriteExec;
+ import org.junit.jupiter.api.AfterEach;
+ import org.junit.jupiter.api.BeforeEach;
+@@ -313,9 +314,13 @@ public class
TestSystemFunctionPushDownInRowLevelOperations extends ExtensionsTe
+
+ private List<Expression> executeAndCollectFunctionCalls(String query,
Object... args) {
+ CommandResultExec command = (CommandResultExec) executeAndKeepPlan(query,
args);
+- V2TableWriteExec write = (V2TableWriteExec) command.commandPhysicalPlan();
++ // Comet's split-operator Iceberg write plans the command as
IcebergCommitExec, which is not
++ // a V2TableWriteExec; collect over the whole command plan in that case.
++ SparkPlan plan = command.commandPhysicalPlan();
++ SparkPlan queryPlan =
++ plan instanceof V2TableWriteExec ? ((V2TableWriteExec) plan).query()
: plan;
+ return SparkPlanUtil.collectExprs(
+- write.query(),
++ queryPlan,
+ expr -> expr instanceof StaticInvoke || expr instanceof
ApplyFunctionExpression);
+ }
+
diff --git a/dev/diffs/iceberg/1.11.0.diff b/dev/diffs/iceberg/1.11.0.diff
index bddfe96160..050e2107d3 100644
--- a/dev/diffs/iceberg/1.11.0.diff
+++ b/dev/diffs/iceberg/1.11.0.diff
@@ -784,3 +784,31 @@ index 8cdcf22e58..820f9fc03b 100644
.enableHiveSupport()
.getOrCreate();
+diff --git
a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownInRowLevelOperations.java
b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownInRowLevelOperations.java
+index 934220e5d..92132d4b8 100644
+---
a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownInRowLevelOperations.java
++++
b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownInRowLevelOperations.java
+@@ -40,6 +40,7 @@ import
org.apache.spark.sql.catalyst.expressions.ApplyFunctionExpression;
+ import org.apache.spark.sql.catalyst.expressions.Expression;
+ import org.apache.spark.sql.catalyst.expressions.objects.StaticInvoke;
+ import org.apache.spark.sql.execution.CommandResultExec;
++import org.apache.spark.sql.execution.SparkPlan;
+ import org.apache.spark.sql.execution.datasources.v2.V2TableWriteExec;
+ import org.junit.jupiter.api.AfterEach;
+ import org.junit.jupiter.api.BeforeEach;
+@@ -330,9 +331,13 @@ public class
TestSystemFunctionPushDownInRowLevelOperations extends ExtensionsTe
+
+ private List<Expression> executeAndCollectFunctionCalls(String query,
Object... args) {
+ CommandResultExec command = (CommandResultExec) executeAndKeepPlan(query,
args);
+- V2TableWriteExec write = (V2TableWriteExec) command.commandPhysicalPlan();
++ // Comet's split-operator Iceberg write plans the command as
IcebergCommitExec, which is not
++ // a V2TableWriteExec; collect over the whole command plan in that case.
++ SparkPlan plan = command.commandPhysicalPlan();
++ SparkPlan queryPlan =
++ plan instanceof V2TableWriteExec ? ((V2TableWriteExec) plan).query()
: plan;
+ return SparkPlanUtil.collectExprs(
+- write.query(),
++ queryPlan,
+ expr -> expr instanceof StaticInvoke || expr instanceof
ApplyFunctionExpression);
+ }
+
diff --git a/dev/diffs/iceberg/1.8.1.diff b/dev/diffs/iceberg/1.8.1.diff
index aa89d3e83b..b674f43244 100644
--- a/dev/diffs/iceberg/1.8.1.diff
+++ b/dev/diffs/iceberg/1.8.1.diff
@@ -3261,3 +3261,59 @@ index 6719c45ca9..2515454401 100644
sourceColumnName,
tableName,
tableName(OTHER_TABLE_NAME),
+diff --git
a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownInRowLevelOperations.java
b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownInRowLevelOperations.java
+index 49119d319..04fd3d485 100644
+---
a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownInRowLevelOperations.java
++++
b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownInRowLevelOperations.java
+@@ -37,6 +37,7 @@ import
org.apache.spark.sql.catalyst.expressions.ApplyFunctionExpression;
+ import org.apache.spark.sql.catalyst.expressions.Expression;
+ import org.apache.spark.sql.catalyst.expressions.objects.StaticInvoke;
+ import org.apache.spark.sql.execution.CommandResultExec;
++import org.apache.spark.sql.execution.SparkPlan;
+ import org.apache.spark.sql.execution.datasources.v2.V2TableWriteExec;
+ import org.junit.After;
+ import org.junit.Before;
+@@ -316,9 +317,13 @@ public class
TestSystemFunctionPushDownInRowLevelOperations extends SparkExtensi
+
+ private List<Expression> executeAndCollectFunctionCalls(String query,
Object... args) {
+ CommandResultExec command = (CommandResultExec) executeAndKeepPlan(query,
args);
+- V2TableWriteExec write = (V2TableWriteExec) command.commandPhysicalPlan();
++ // Comet's split-operator Iceberg write plans the command as
IcebergCommitExec, which is not
++ // a V2TableWriteExec; collect over the whole command plan in that case.
++ SparkPlan plan = command.commandPhysicalPlan();
++ SparkPlan queryPlan =
++ plan instanceof V2TableWriteExec ? ((V2TableWriteExec) plan).query()
: plan;
+ return SparkPlanUtil.collectExprs(
+- write.query(),
++ queryPlan,
+ expr -> expr instanceof StaticInvoke || expr instanceof
ApplyFunctionExpression);
+ }
+
+diff --git
a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownInRowLevelOperations.java
b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownInRowLevelOperations.java
+index 8cb92224f..777b9bb1d 100644
+---
a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownInRowLevelOperations.java
++++
b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownInRowLevelOperations.java
+@@ -38,6 +38,7 @@ import
org.apache.spark.sql.catalyst.expressions.ApplyFunctionExpression;
+ import org.apache.spark.sql.catalyst.expressions.Expression;
+ import org.apache.spark.sql.catalyst.expressions.objects.StaticInvoke;
+ import org.apache.spark.sql.execution.CommandResultExec;
++import org.apache.spark.sql.execution.SparkPlan;
+ import org.apache.spark.sql.execution.datasources.v2.V2TableWriteExec;
+ import org.junit.jupiter.api.AfterEach;
+ import org.junit.jupiter.api.BeforeEach;
+@@ -313,9 +314,13 @@ public class
TestSystemFunctionPushDownInRowLevelOperations extends ExtensionsTe
+
+ private List<Expression> executeAndCollectFunctionCalls(String query,
Object... args) {
+ CommandResultExec command = (CommandResultExec) executeAndKeepPlan(query,
args);
+- V2TableWriteExec write = (V2TableWriteExec) command.commandPhysicalPlan();
++ // Comet's split-operator Iceberg write plans the command as
IcebergCommitExec, which is not
++ // a V2TableWriteExec; collect over the whole command plan in that case.
++ SparkPlan plan = command.commandPhysicalPlan();
++ SparkPlan queryPlan =
++ plan instanceof V2TableWriteExec ? ((V2TableWriteExec) plan).query()
: plan;
+ return SparkPlanUtil.collectExprs(
+- write.query(),
++ queryPlan,
+ expr -> expr instanceof StaticInvoke || expr instanceof
ApplyFunctionExpression);
+ }
+
diff --git a/dev/diffs/iceberg/1.9.1.diff b/dev/diffs/iceberg/1.9.1.diff
index b9153abb3e..636f7e958f 100644
--- a/dev/diffs/iceberg/1.9.1.diff
+++ b/dev/diffs/iceberg/1.9.1.diff
@@ -3219,3 +3219,59 @@ index 6719c45ca9..2515454401 100644
sourceColumnName,
tableName,
tableName(OTHER_TABLE_NAME),
+diff --git
a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownInRowLevelOperations.java
b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownInRowLevelOperations.java
+index 5a0a04edb..afe92a8b7 100644
+---
a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownInRowLevelOperations.java
++++
b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownInRowLevelOperations.java
+@@ -38,6 +38,7 @@ import
org.apache.spark.sql.catalyst.expressions.ApplyFunctionExpression;
+ import org.apache.spark.sql.catalyst.expressions.Expression;
+ import org.apache.spark.sql.catalyst.expressions.objects.StaticInvoke;
+ import org.apache.spark.sql.execution.CommandResultExec;
++import org.apache.spark.sql.execution.SparkPlan;
+ import org.apache.spark.sql.execution.datasources.v2.V2TableWriteExec;
+ import org.junit.jupiter.api.AfterEach;
+ import org.junit.jupiter.api.BeforeEach;
+@@ -313,9 +314,13 @@ public class
TestSystemFunctionPushDownInRowLevelOperations extends ExtensionsTe
+
+ private List<Expression> executeAndCollectFunctionCalls(String query,
Object... args) {
+ CommandResultExec command = (CommandResultExec) executeAndKeepPlan(query,
args);
+- V2TableWriteExec write = (V2TableWriteExec) command.commandPhysicalPlan();
++ // Comet's split-operator Iceberg write plans the command as
IcebergCommitExec, which is not
++ // a V2TableWriteExec; collect over the whole command plan in that case.
++ SparkPlan plan = command.commandPhysicalPlan();
++ SparkPlan queryPlan =
++ plan instanceof V2TableWriteExec ? ((V2TableWriteExec) plan).query()
: plan;
+ return SparkPlanUtil.collectExprs(
+- write.query(),
++ queryPlan,
+ expr -> expr instanceof StaticInvoke || expr instanceof
ApplyFunctionExpression);
+ }
+
+diff --git
a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownInRowLevelOperations.java
b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownInRowLevelOperations.java
+index 8cb92224f..777b9bb1d 100644
+---
a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownInRowLevelOperations.java
++++
b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownInRowLevelOperations.java
+@@ -38,6 +38,7 @@ import
org.apache.spark.sql.catalyst.expressions.ApplyFunctionExpression;
+ import org.apache.spark.sql.catalyst.expressions.Expression;
+ import org.apache.spark.sql.catalyst.expressions.objects.StaticInvoke;
+ import org.apache.spark.sql.execution.CommandResultExec;
++import org.apache.spark.sql.execution.SparkPlan;
+ import org.apache.spark.sql.execution.datasources.v2.V2TableWriteExec;
+ import org.junit.jupiter.api.AfterEach;
+ import org.junit.jupiter.api.BeforeEach;
+@@ -313,9 +314,13 @@ public class
TestSystemFunctionPushDownInRowLevelOperations extends ExtensionsTe
+
+ private List<Expression> executeAndCollectFunctionCalls(String query,
Object... args) {
+ CommandResultExec command = (CommandResultExec) executeAndKeepPlan(query,
args);
+- V2TableWriteExec write = (V2TableWriteExec) command.commandPhysicalPlan();
++ // Comet's split-operator Iceberg write plans the command as
IcebergCommitExec, which is not
++ // a V2TableWriteExec; collect over the whole command plan in that case.
++ SparkPlan plan = command.commandPhysicalPlan();
++ SparkPlan queryPlan =
++ plan instanceof V2TableWriteExec ? ((V2TableWriteExec) plan).query()
: plan;
+ return SparkPlanUtil.collectExprs(
+- write.query(),
++ queryPlan,
+ expr -> expr instanceof StaticInvoke || expr instanceof
ApplyFunctionExpression);
+ }
+
diff --git a/docs/source/user-guide/latest/iceberg-writes.md
b/docs/source/user-guide/latest/iceberg-writes.md
new file mode 100644
index 0000000000..8ef6b4cc16
--- /dev/null
+++ b/docs/source/user-guide/latest/iceberg-writes.md
@@ -0,0 +1,97 @@
+<!---
+ 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.
+-->
+
+# Iceberg Writes: Comet's Split-Operator Plan (Experimental)
+
+**This feature is experimental and disabled by default.** Enable it only after
validating it
+against your own workloads.
+
+## Overview
+
+Spark writes an Iceberg table through a single physical operator that combines
data-file
+writing with metadata writing, committing, and catalog validation. Because
that operator sits
+outside Spark's Adaptive Query Execution (AQE), the sub-query feeding the
write — the scans,
+projects, sorts, and exchanges producing the rows — cannot be re-planned at
runtime.
+
+When `spark.comet.write.iceberg.splitOperator.enabled=true`, Comet rewrites
eligible Iceberg
+writes into two operators:
+
+1. **`IcebergWrite`** — writes the data files on the executors, exactly as
iceberg-java does
+ today, and returns each task's serialized commit message. This operator and
the sub-query
+ feeding it run inside AQE.
+2. **`IcebergCommit`** — collects the commit messages on the driver and
performs the normal
+ Iceberg commit (including commit-time validation), outside AQE, exactly
once.
+
+Data files are still written by iceberg-java; only the plan shape changes. The
split makes the
+write's input visible to AQE and to Comet's columnar rules, and it is the
groundwork for a
+planned follow-up in which Comet writes the data files natively via
+[iceberg-rust](https://github.com/apache/iceberg-rust).
+
+## Configuration
+
+Standard Comet + Iceberg setup (see [`iceberg.md`](iceberg.md)) plus the
write-side toggle:
+
+```
+# Standard Comet / Iceberg wiring
+spark.plugins=org.apache.spark.CometPlugin
+spark.sql.extensions=org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions
+spark.sql.catalog.<name>=org.apache.iceberg.spark.SparkCatalog
+spark.sql.catalog.<name>.type=hadoop # or hive / glue
/ rest / ...
+spark.sql.catalog.<name>.warehouse=...
+
+# Split-operator plan (experimental, off by default)
+spark.comet.write.iceberg.splitOperator.enabled=true
+```
+
+## Supported operations
+
+The split-operator plan supports the following operations on every Spark
version Comet
+supports:
+
+- `INSERT INTO` / DataFrame `append` (`AppendData`)
+- `INSERT OVERWRITE`, static and dynamic (`OverwriteByExpression`,
+ `OverwritePartitionsDynamic`)
+- Copy-on-write `DELETE` / `UPDATE` / `MERGE` (`ReplaceData`)
+
+The mechanism behind row-level DML differs by Spark version: on Spark 4.0+ the
analyzer emits
+operation-coded rows that Comet's writer dispatches through `ReplaceData`'s
projections, while
+on Spark 3.4/3.5 the rewritten rows are written as a plain row stream. The
supported set of
+operations is the same either way.
+
+On Spark 4.1+ the split plan matches two further stock-Spark behaviours: MERGE
metrics are
+forwarded to the writer's commit (Iceberg 1.11+ records them in the snapshot
summary), and
+cached catalog tables are recached by name after a write so cache entries
survive schema
+changes.
+
+## When Comet falls back to Spark's write operator
+
+The rewrite is skipped — and the write runs through Spark's stock combined
operator — when:
+
+- `spark.comet.write.iceberg.splitOperator.enabled` is `false` (the default);
+- the write is not an Iceberg `SparkWrite` (any other V2 data source);
+- the table uses merge-on-read: delta writes (Iceberg `WriteDelta`) are not
intercepted;
+- the statement is CTAS / RTAS on Spark 3.4, where the staged exec writes
inline; on Spark
+ 3.5+ those statements re-plan their inner append, which is intercepted
normally;
+- the write requires Spark's commit coordinator, which Comet's per-task commit
protocol does
+ not use;
+- Comet cannot reflect the Iceberg internals needed to build the two-operator
plan (for
+ example an unrecognised write class or a `ReplaceData` projection it cannot
map).
+
+In every fallback case the write is planned as if Comet were absent; there is
no correctness
+trade-off, only no plan change.
diff --git a/docs/source/user-guide/latest/index.rst
b/docs/source/user-guide/latest/index.rst
index 2cd1c7e16f..815e12289c 100644
--- a/docs/source/user-guide/latest/index.rst
+++ b/docs/source/user-guide/latest/index.rst
@@ -82,6 +82,7 @@ to read more.
:hidden:
Iceberg Guide <iceberg>
+ Iceberg Writes <iceberg-writes>
S3 Credential Providers <s3-credential-providers>
Kubernetes Guide <kubernetes>
diff --git a/spark/src/main/scala/org/apache/comet/CometConf.scala
b/spark/src/main/scala/org/apache/comet/CometConf.scala
index 1bb524349d..6e7bb4271b 100644
--- a/spark/src/main/scala/org/apache/comet/CometConf.scala
+++ b/spark/src/main/scala/org/apache/comet/CometConf.scala
@@ -121,6 +121,16 @@ object CometConf extends ShimCometConf {
.booleanConf
.createWithDefault(true)
+ val COMET_ICEBERG_WRITE_SPLIT_OPERATOR_ENABLED: ConfigEntry[Boolean] =
+ conf("spark.comet.write.iceberg.splitOperator.enabled")
+ .category(CATEGORY_TESTING)
+ .doc(
+ "Whether to rewrite Iceberg V2 writes from Spark's combined V2
write/commit operator " +
+ "into Comet's two-operator shape: a file writer exec (inside AQE)
and a committer " +
+ "(outside AQE).")
+ .booleanConf
+ .createWithDefault(false)
+
val COMET_ICEBERG_DATA_FILE_CONCURRENCY_LIMIT: ConfigEntry[Int] =
conf("spark.comet.scan.icebergNative.dataFileConcurrencyLimit")
.category(CATEGORY_SCAN)
diff --git
a/spark/src/main/scala/org/apache/comet/CometSparkSessionExtensions.scala
b/spark/src/main/scala/org/apache/comet/CometSparkSessionExtensions.scala
index 3bcb3f98e1..4dc3619433 100644
--- a/spark/src/main/scala/org/apache/comet/CometSparkSessionExtensions.scala
+++ b/spark/src/main/scala/org/apache/comet/CometSparkSessionExtensions.scala
@@ -32,6 +32,7 @@ import org.apache.spark.sql.execution._
import org.apache.spark.sql.internal.SQLConf
import org.apache.comet.CometConf._
+import org.apache.comet.iceberg.IcebergWriteStrategy
import org.apache.comet.rules.{CometExecRule,
CometPlanAdaptiveDynamicPruningFilters, CometReuseSubquery, CometScanRule,
CometSpark34AqeDppFallbackRule, EliminateRedundantTransitions,
RevertNativeForTransitionHeavyStages}
import org.apache.comet.shims.ShimCometSparkSessionExtensions
@@ -99,6 +100,7 @@ class CometSparkSessionExtensions
extensions.injectQueryStagePrepRule { session => CometExecRule(session) }
injectQueryStageOptimizerRuleShim(extensions,
CometPlanAdaptiveDynamicPruningFilters)
injectQueryStageOptimizerRuleShim(extensions, CometReuseSubquery)
+ extensions.injectPlannerStrategy { session =>
IcebergWriteStrategy(session) }
}
case class CometScanColumnar(session: SparkSession) extends ColumnarRule {
diff --git
a/spark/src/main/scala/org/apache/comet/iceberg/IcebergReflection.scala
b/spark/src/main/scala/org/apache/comet/iceberg/IcebergReflection.scala
index c90d697bf7..edb321e8fc 100644
--- a/spark/src/main/scala/org/apache/comet/iceberg/IcebergReflection.scala
+++ b/spark/src/main/scala/org/apache/comet/iceberg/IcebergReflection.scala
@@ -57,6 +57,10 @@ object IcebergReflection extends Logging {
val SPARK_SCHEMA_UTIL = "org.apache.iceberg.spark.SparkSchemaUtil"
val TABLE = "org.apache.iceberg.Table"
val PARTITIONING = "org.apache.iceberg.Partitioning"
+ val SPARK_WRITE = "org.apache.iceberg.spark.source.SparkWrite"
+
+ // Iceberg 1.5.2 uses its own `ReplaceIcebergData` due to lack of
`ReplaceData` in Spark 3.4.
+ val REPLACE_ICEBERG_DATA =
"org.apache.spark.sql.catalyst.plans.logical.ReplaceIcebergData"
}
/**
@@ -131,6 +135,45 @@ object IcebergReflection extends Logging {
val UNKNOWN = "unknown"
}
+ /** Loads a class, returning `None` when it's absent (e.g. Iceberg not on
the classpath). */
+ private def tryLoadClass(name: String): Option[Class[_]] =
+ try Some(loadClass(name))
+ catch { case _: ClassNotFoundException => None }
+
+ private lazy val sparkWriteClassOpt: Option[Class[_]] =
tryLoadClass(ClassNames.SPARK_WRITE)
+
+ /** Whether `write` is an Iceberg `SparkWrite` (false if Iceberg isn't on
the classpath). */
+ def isIcebergSparkWrite(write: Any): Boolean =
+ sparkWriteClassOpt.exists(_.isInstance(write))
+
+ def isReplaceIcebergData(plan: Any): Boolean =
+ plan != null && plan.getClass.getName == ClassNames.REPLACE_ICEBERG_DATA
+
+ private def reflectField(plan: Any, fieldName: String): Option[AnyRef] =
+ try {
+ val field = plan.getClass.getDeclaredField(fieldName)
+ field.setAccessible(true)
+ Option(field.get(plan))
+ } catch {
+ case e: Exception =>
+ logError(
+ s"Iceberg reflection failure: $fieldName on
${plan.getClass.getName}: ${e.getMessage}")
+ None
+ }
+
+ def extractReplaceIcebergDataFields(plan: Any): Option[(AnyRef, AnyRef,
AnyRef, AnyRef)] = {
+ if (!isReplaceIcebergData(plan)) return None
+ for {
+ table <- reflectField(plan, "table")
+ query <- reflectField(plan, "query")
+ originalTable <- reflectField(plan, "originalTable")
+ write <- reflectField(
+ plan,
+ "write"
+ ) // Option[Write]; field can be Some(null) so kept AnyRef
+ } yield (table, query, originalTable, write)
+ }
+
/**
* Loads a class using the thread context classloader first, then falls back
to the system
* classloader.
diff --git
a/spark/src/main/scala/org/apache/comet/iceberg/IcebergWriteLogical.scala
b/spark/src/main/scala/org/apache/comet/iceberg/IcebergWriteLogical.scala
new file mode 100644
index 0000000000..6ceb526d4c
--- /dev/null
+++ b/spark/src/main/scala/org/apache/comet/iceberg/IcebergWriteLogical.scala
@@ -0,0 +1,43 @@
+/*
+ * 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.comet.iceberg
+
+import org.apache.spark.sql.catalyst.expressions.{Attribute,
AttributeReference}
+import org.apache.spark.sql.catalyst.plans.logical.{LogicalPlan, UnaryNode}
+import org.apache.spark.sql.comet.IcebergWriteExec
+import org.apache.spark.sql.connector.write.BatchWrite
+import org.apache.spark.sql.types.BinaryType
+
+/** Logical anchor for the writer. See `IcebergWriteStrategy` for the
rationale. */
+case class IcebergWriteLogical(
+ child: LogicalPlan,
+ // Driver-side only: AQE re-planning is driver-local and write commands
aren't cached.
+ @transient batchWrite: BatchWrite,
+ replaceDataDispatch: Option[ReplaceDataDispatchInfo] = None)
+ extends UnaryNode {
+
+ // Owns the commit-message attribute so the physical writer keeps the same
exprId across
+ // AQE re-plans.
+ override val output: Seq[Attribute] = Seq(
+ AttributeReference(IcebergWriteExec.CommitMessageColumn, BinaryType,
nullable = false)())
+
+ override protected def withNewChildInternal(newChild: LogicalPlan):
IcebergWriteLogical =
+ copy(child = newChild)
+}
diff --git
a/spark/src/main/scala/org/apache/comet/iceberg/IcebergWriteStrategy.scala
b/spark/src/main/scala/org/apache/comet/iceberg/IcebergWriteStrategy.scala
new file mode 100644
index 0000000000..c501c90097
--- /dev/null
+++ b/spark/src/main/scala/org/apache/comet/iceberg/IcebergWriteStrategy.scala
@@ -0,0 +1,122 @@
+/*
+ * 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.comet.iceberg
+
+import org.apache.spark.sql.SparkSession
+import org.apache.spark.sql.catalyst.plans.logical.{AppendData, LogicalPlan,
OverwriteByExpression, OverwritePartitionsDynamic, ReplaceData}
+import org.apache.spark.sql.comet.{IcebergCommitExec, IcebergWriteExec}
+import org.apache.spark.sql.connector.write.Write
+import org.apache.spark.sql.execution.{SparkPlan, SparkStrategy}
+import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation
+
+import org.apache.comet.CometConf
+
+/**
+ * Spark Strategy that intercepts Iceberg V2 copy-on-write logical writes and
emits Comet's
+ * two-operator physical tree.
+ */
+case class IcebergWriteStrategy(session: SparkSession) extends SparkStrategy {
+
+ override def apply(plan: LogicalPlan): Seq[SparkPlan] = {
+ if
(!CometConf.COMET_ICEBERG_WRITE_SPLIT_OPERATOR_ENABLED.get(session.sessionState.conf))
{
+ return Nil
+ }
+
+ plan match {
+ case ad: AppendData =>
+ matchedSparkWrite(ad.table, ad.write, ad.query, replaceDataDispatch =
None).toList
+ case obe: OverwriteByExpression =>
+ matchedSparkWrite(obe.table, obe.write, obe.query, replaceDataDispatch
= None).toList
+ case opd: OverwritePartitionsDynamic =>
+ matchedSparkWrite(opd.table, opd.write, opd.query, replaceDataDispatch
= None).toList
+ case rd: ReplaceData =>
+ matchedSparkWrite(
+ rd.originalTable,
+ rd.write,
+ rd.query,
+ replaceDataDispatch =
IcebergReplaceDataShim.extractProjections(rd)).toList
+ case plan if IcebergReflection.isReplaceIcebergData(plan) =>
+ IcebergReflection
+ .extractReplaceIcebergDataFields(plan)
+ .flatMap { case (_, query, originalTable, write) =>
+ matchedSparkWrite(
+
originalTable.asInstanceOf[org.apache.spark.sql.catalyst.analysis.NamedRelation],
+ write.asInstanceOf[Option[Write]],
+ query.asInstanceOf[LogicalPlan],
+ replaceDataDispatch = None)
+ }
+ .toList
+ // Hit by AQE.
+ case l @ IcebergWriteLogical(child, batchWrite, replaceDataDispatch) =>
+ Seq(IcebergWriteExec(batchWrite, l.output, planLater(child),
replaceDataDispatch))
+ case _ => Nil
+ }
+ }
+
+ private def matchedSparkWrite(
+ table: org.apache.spark.sql.catalyst.analysis.NamedRelation,
+ write: Option[Write],
+ query: LogicalPlan,
+ replaceDataDispatch: Option[ReplaceDataDispatchInfo]): Option[SparkPlan]
= {
+ table match {
+ case rel: DataSourceV2Relation =>
+ write.flatMap { w =>
+ if (IcebergReflection.isIcebergSparkWrite(w)) {
+ buildTwoOp(w, rel, query, replaceDataDispatch)
+ } else {
+ None
+ }
+ }
+ case _ => None
+ }
+ }
+
+ /**
+ * Builds the two-op tree. The committer and writer share one `BatchWrite`
(also reused across
+ * AQE re-plans): `toBatch()` returns a fresh instance per call, but the
committer's commit-time
+ * validation must see the same instance the writer wrote through, hence we
store it. The
+ * writer's child is wrapped in [[IcebergWriteLogical]] so AQE re-emits only
the data-writing
+ * operator on each re-plan as opposed to multiple new commit operators.
+ *
+ * Iceberg's `SparkWrite` never asks for Spark's commit coordinator, so the
+ * `useCommitCoordinator` fallback below is defensive coverage in case a
future Iceberg version
+ * changes that; the split writer's per-task commit protocol does not use it.
+ */
+ private def buildTwoOp(
+ write: Write,
+ rel: DataSourceV2Relation,
+ query: LogicalPlan,
+ replaceDataDispatch: Option[ReplaceDataDispatchInfo]): Option[SparkPlan]
= {
+ val batchWrite = write.toBatch
+ if (batchWrite.useCommitCoordinator()) {
+ return None
+ }
+ // To mirror Spark ReplaceData semantics we invalidate our cache of the
state of
+ // `originalTable`.
+ val refresh: () => Unit = () =>
IcebergRefreshCacheShim.refreshCache(session, rel)
+ Some(
+ IcebergCommitExec(
+ batchWrite,
+ write,
+ refresh,
+ // `replaceDataDispatch` may project the data into the format the
writer expects.
+ planLater(IcebergWriteLogical(query, batchWrite,
replaceDataDispatch))))
+ }
+}
diff --git
a/spark/src/main/scala/org/apache/comet/iceberg/ReplaceDataDispatchInfo.scala
b/spark/src/main/scala/org/apache/comet/iceberg/ReplaceDataDispatchInfo.scala
new file mode 100644
index 0000000000..8f9274a9e2
--- /dev/null
+++
b/spark/src/main/scala/org/apache/comet/iceberg/ReplaceDataDispatchInfo.scala
@@ -0,0 +1,31 @@
+/*
+ * 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.comet.iceberg
+
+import org.apache.spark.sql.catalyst.ProjectingInternalRow
+
+/**
+ * Mirror of Spark 4.x's `ReplaceDataProjections`, defined here so we can
compile against Spark
+ * 3.4 / 3.5 source trees (where the class doesn't exist). Populated by
`IcebergReplaceDataShim`
+ * on 4.x and left `None` on 3.x; consumed by `IcebergWriteExec`.
+ */
+case class ReplaceDataDispatchInfo(
+ rowProjection: ProjectingInternalRow,
+ metadataProjection: Option[ProjectingInternalRow])
diff --git
a/spark/src/main/scala/org/apache/spark/sql/comet/IcebergCommitExec.scala
b/spark/src/main/scala/org/apache/spark/sql/comet/IcebergCommitExec.scala
new file mode 100644
index 0000000000..5041c9fc7f
--- /dev/null
+++ b/spark/src/main/scala/org/apache/spark/sql/comet/IcebergCommitExec.scala
@@ -0,0 +1,125 @@
+/*
+ * 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.comet
+
+import org.apache.spark.internal.Logging
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions.Attribute
+import org.apache.spark.sql.connector.write.{BatchWrite, Write,
WriterCommitMessage}
+import org.apache.spark.sql.execution.{SparkPlan, SQLExecution, UnaryExecNode}
+import org.apache.spark.sql.execution.datasources.v2.V2CommandExec
+import org.apache.spark.sql.execution.metric.{SQLMetric, SQLMetrics}
+
+import org.apache.comet.iceberg.IcebergDriverMetricsShim
+
+/**
+ * Driver-side committer for Comet's split-operator Iceberg V2 write.
+ */
+case class IcebergCommitExec(
+ // None of these fields are serialized, this is all run on the driver.
+ @transient batchWrite: BatchWrite,
+ @transient write: Write,
+ @transient refreshCache: IcebergCommitExec.RefreshCache,
+ child: SparkPlan)
+ extends V2CommandExec
+ with UnaryExecNode
+ with Logging {
+
+ override def output: Seq[Attribute] = Nil
+
+ override lazy val metrics: Map[String, SQLMetric] =
+ Map(
+ "numCommittedMessages" -> SQLMetrics
+ .createMetric(sparkContext, "number of task commit messages")) ++
+ write
+ .supportedCustomMetrics()
+ .map(m => m.name -> SQLMetrics.createV2CustomMetric(sparkContext, m))
+
+ // Exactly-once relies on V2CommandExec memoizing run() via its `result`
lazy val and on
+ // the writer executing once inside the AQE bubble anchored by
IcebergWriteLogical; pinned
+ // by the AQE re-plan test in CometIcebergWriteActionSuite.
+ override protected def run(): Seq[InternalRow] = {
+ try {
+ collectAndCommit()
+ } finally {
+ postDriverMetrics()
+ }
+ }
+
+ private def collectAndCommit(): Seq[InternalRow] = {
+ val messages: Array[WriterCommitMessage] =
+ try {
+ child.executeCollect().map { row =>
+ IcebergWriteExec.deserializeMessage(row.getBinary(0))
+ }
+ } catch {
+ case cause: Throwable =>
+ // The write job failed; the BatchWrite contract still expects a
job-level abort.
+ try batchWrite.abort(Array.empty[WriterCommitMessage])
+ catch {
+ case abortFailure: Throwable =>
+ cause.addSuppressed(abortFailure)
+ }
+ throw cause
+ }
+ longMetric("numCommittedMessages").add(messages.length)
+
+ try {
+ messages.foreach(batchWrite.onDataWriterCommit)
+ IcebergWriteSummaryShim.commit(batchWrite, messages, child)
+ logInfo(s"Iceberg commit succeeded with ${messages.length} task
message(s)")
+ } catch {
+ case cause: Throwable =>
+ logError(s"Iceberg commit failed; aborting ${messages.length} task
message(s)", cause)
+ try batchWrite.abort(messages)
+ catch {
+ case abortFailure: Throwable =>
+ cause.addSuppressed(abortFailure)
+ }
+ throw cause
+ }
+
+ refreshCache()
+ Nil
+ }
+
+ private def postDriverMetrics(): Unit = {
+ val driverMetrics = IcebergDriverMetricsShim.reportDriverMetrics(write)
+ if (driverMetrics.nonEmpty) {
+ val updated = driverMetrics.flatMap { taskMetric =>
+ metrics.get(taskMetric.name).map { sqlMetric =>
+ sqlMetric.set(taskMetric.value)
+ sqlMetric
+ }
+ }
+ val executionId =
sparkContext.getLocalProperty(SQLExecution.EXECUTION_ID_KEY)
+ SQLMetrics.postDriverMetricUpdates(sparkContext, executionId,
updated.toIndexedSeq)
+ }
+ }
+
+ override protected def withNewChildInternal(newChild: SparkPlan):
IcebergCommitExec =
+ copy(child = newChild)
+
+ override def nodeName: String = "IcebergCommit"
+}
+
+object IcebergCommitExec {
+ type RefreshCache = () => Unit
+}
diff --git
a/spark/src/main/scala/org/apache/spark/sql/comet/IcebergWriteExec.scala
b/spark/src/main/scala/org/apache/spark/sql/comet/IcebergWriteExec.scala
new file mode 100644
index 0000000000..90bfdaab04
--- /dev/null
+++ b/spark/src/main/scala/org/apache/spark/sql/comet/IcebergWriteExec.scala
@@ -0,0 +1,179 @@
+/*
+ * 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.comet
+
+import org.apache.spark.TaskContext
+import org.apache.spark.rdd.RDD
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions.Attribute
+import org.apache.spark.sql.catalyst.expressions.UnsafeProjection
+import org.apache.spark.sql.catalyst.plans.physical.{Distribution,
UnspecifiedDistribution}
+import org.apache.spark.sql.connector.write.{BatchWrite, DataWriter,
PhysicalWriteInfoImpl, WriterCommitMessage}
+import org.apache.spark.sql.execution.{SparkPlan, UnaryExecNode}
+import org.apache.spark.sql.execution.metric.{SQLMetric, SQLMetrics}
+import org.apache.spark.sql.types.{BinaryType, StructField, StructType}
+import org.apache.spark.util.Utils
+
+import org.apache.comet.iceberg.ReplaceDataDispatchInfo
+
+/**
+ * Executor-side file writer for Comet's split-operator Iceberg V2 write.
+ */
+case class IcebergWriteExec(
+ // `batchWrite` only stored driver side, only the writer factory is
shipped to executors.
+ @transient batchWrite: BatchWrite,
+ override val output: Seq[Attribute],
+ child: SparkPlan,
+ replaceDataDispatch: Option[ReplaceDataDispatchInfo] = None)
+ extends UnaryExecNode {
+
+ // Spark already adds a distribution for the V2 write; adding another here
is redundant.
+ override def requiredChildDistribution: Seq[Distribution] =
Seq(UnspecifiedDistribution)
+
+ // The write's custom metrics are registered on IcebergCommitExec only:
Iceberg reports them
+ // through the driver-side commit report, and its DataWriters expose no
task-level values.
+ // Registering them here too would double-count them in the SQL UI.
+ override lazy val metrics: Map[String, SQLMetric] =
+ Map("numOutputRows" -> SQLMetrics.createMetric(sparkContext, "number of
output rows"))
+
+ override protected def doExecute(): RDD[InternalRow] = {
+ val rdd = {
+ val tempRdd = child.execute()
+ // SPARK-23271: run one write task even when the query RDD has zero
partitions.
+ if (tempRdd.getNumPartitions == 0) {
+ sparkContext.parallelize(Seq.empty[InternalRow], 1)
+ } else {
+ tempRdd
+ }
+ }
+ val factory =
batchWrite.createBatchWriterFactory(PhysicalWriteInfoImpl(rdd.getNumPartitions))
+ // Backstop only; IcebergWriteStrategy already falls back at planning time.
+ require(
+ !batchWrite.useCommitCoordinator(),
+ "Comet's Iceberg write path does not currently support BatchWrite
implementations that " +
+ "require Spark's commit coordinator; received: " +
batchWrite.getClass.getName)
+
+ val rowsMetric = longMetric("numOutputRows")
+ val schemaTypes = output.map(_.dataType).toArray
+ val capturedReplaceDataDispatch = replaceDataDispatch
+ rdd.mapPartitionsInternal { iter =>
+ val partId = TaskContext.getPartitionId()
+ val taskId = TaskContext.get().taskAttemptId()
+ val writer = factory.createWriter(partId, taskId)
+ val projection = UnsafeProjection.create(schemaTypes)
+ IcebergWriteExec.runWriter(
+ writer,
+ iter,
+ rowsMetric,
+ projection,
+ capturedReplaceDataDispatch)
+ }
+ }
+
+ override protected def withNewChildInternal(newChild: SparkPlan):
IcebergWriteExec =
+ copy(child = newChild)
+
+ override def nodeName: String = "IcebergWrite"
+}
+
+object IcebergWriteExec {
+
+ val CommitMessageColumn: String = "iceberg_commit_message"
+
+ val OutputSchema: StructType = StructType(
+ Seq(StructField(CommitMessageColumn, BinaryType, nullable = false)))
+
+ /** Writes data files and returns the serialised Iceberg commit message. */
+ def runWriter(
+ writer: DataWriter[InternalRow],
+ iter: Iterator[InternalRow],
+ rowsMetric: SQLMetric,
+ projection: UnsafeProjection,
+ replaceDataDispatch: Option[ReplaceDataDispatchInfo]):
Iterator[InternalRow] = {
+ val iterWithMetrics = new IteratorWithMetrics(iter, rowsMetric)
+ // Serialization happens inside the guarded block: if the commit message
cannot be
+ // serialised the task must abort so the already-finalised data files get
deleted.
+ val messageBytes = Utils.tryWithSafeFinallyAndFailureCallbacks(block = {
+ if (replaceDataDispatch.isDefined) {
+ runReplaceDataWriter(writer, iterWithMetrics, replaceDataDispatch.get)
+ } else {
+ while (iterWithMetrics.hasNext) {
+ writer.write(iterWithMetrics.next())
+ }
+ }
+ serializeMessage(writer.commit())
+ })(
+ catchBlock = {
+ writer.abort()
+ },
+ finallyBlock = {
+ writer.close()
+ })
+
+ Iterator.single(projection(InternalRow(messageBytes)).copy())
+ }
+
+ private class IteratorWithMetrics(iter: Iterator[InternalRow], rowsMetric:
SQLMetric)
+ extends Iterator[InternalRow] {
+
+ override def hasNext: Boolean = iter.hasNext
+
+ override def next(): InternalRow = {
+ rowsMetric.add(1L)
+ iter.next()
+ }
+ }
+
+ // Mirrors Spark RowDeltaUtils, which is private and changes location across
versions.
+ // These codes only exist on Spark 4.0+; on 3.x IcebergReplaceDataShim
returns None and rows
+ // take the plain write(row) loop instead, so this dispatch is never reached
there.
+ private val WRITE_OPERATION = 5
+ private val WRITE_WITH_METADATA_OPERATION = 6
+
+ def serializeMessage(message: WriterCommitMessage): Array[Byte] =
+ Utils.serialize(message)
+
+ def deserializeMessage(bytes: Array[Byte]): WriterCommitMessage =
+ Utils.deserialize[WriterCommitMessage](bytes,
Utils.getContextOrSparkClassLoader)
+
+ private def runReplaceDataWriter(
+ writer: DataWriter[InternalRow],
+ iter: Iterator[InternalRow],
+ dispatch: ReplaceDataDispatchInfo): Unit = {
+ val rowProjection = dispatch.rowProjection
+ val metadataProjection = dispatch.metadataProjection.orNull
+ while (iter.hasNext) {
+ val row = iter.next()
+ row.getInt(0) match {
+ case WRITE_OPERATION =>
+ rowProjection.project(row)
+ writer.write(rowProjection)
+ case WRITE_WITH_METADATA_OPERATION =>
+ rowProjection.project(row)
+ if (metadataProjection != null) metadataProjection.project(row)
+ IcebergDataWriterShim.writeWithMetadata(writer, metadataProjection,
rowProjection)
+ case other =>
+ throw new IllegalArgumentException(
+ s"Unexpected ReplaceData operation code $other; supported: " +
+ s"$WRITE_OPERATION (WRITE), $WRITE_WITH_METADATA_OPERATION
(WRITE_WITH_METADATA)")
+ }
+ }
+ }
+}
diff --git
a/spark/src/main/spark-3.x/org/apache/comet/iceberg/IcebergDriverMetricsShim.scala
b/spark/src/main/spark-3.x/org/apache/comet/iceberg/IcebergDriverMetricsShim.scala
new file mode 100644
index 0000000000..4f45f18cef
--- /dev/null
+++
b/spark/src/main/spark-3.x/org/apache/comet/iceberg/IcebergDriverMetricsShim.scala
@@ -0,0 +1,28 @@
+/*
+ * 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.comet.iceberg
+
+import org.apache.spark.sql.connector.metric.CustomTaskMetric
+import org.apache.spark.sql.connector.write.Write
+
+/** `Write.reportDriverMetrics` (SPARK-50049) does not exist before Spark 4.0.
*/
+object IcebergDriverMetricsShim {
+ def reportDriverMetrics(write: Write): Array[CustomTaskMetric] = Array.empty
+}
diff --git
a/spark/src/main/spark-3.x/org/apache/comet/iceberg/IcebergRefreshCacheShim.scala
b/spark/src/main/spark-3.x/org/apache/comet/iceberg/IcebergRefreshCacheShim.scala
new file mode 100644
index 0000000000..a3dc459231
--- /dev/null
+++
b/spark/src/main/spark-3.x/org/apache/comet/iceberg/IcebergRefreshCacheShim.scala
@@ -0,0 +1,29 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.comet.iceberg
+
+import org.apache.spark.sql.SparkSession
+import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation
+
+private[iceberg] object IcebergRefreshCacheShim {
+ def refreshCache(session: SparkSession, rel: DataSourceV2Relation): Unit = {
+ session.sharedState.cacheManager.recacheByPlan(session, rel)
+ }
+}
diff --git
a/spark/src/main/spark-3.x/org/apache/comet/iceberg/IcebergReplaceDataShim.scala
b/spark/src/main/spark-3.x/org/apache/comet/iceberg/IcebergReplaceDataShim.scala
new file mode 100644
index 0000000000..5168e35012
--- /dev/null
+++
b/spark/src/main/spark-3.x/org/apache/comet/iceberg/IcebergReplaceDataShim.scala
@@ -0,0 +1,26 @@
+/*
+ * 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.comet.iceberg
+
+import org.apache.spark.sql.catalyst.plans.logical.ReplaceData
+
+private[iceberg] object IcebergReplaceDataShim {
+ def extractProjections(rd: ReplaceData): Option[ReplaceDataDispatchInfo] =
None
+}
diff --git
a/spark/src/main/spark-3.x/org/apache/spark/sql/comet/IcebergDataWriterShim.scala
b/spark/src/main/spark-3.x/org/apache/spark/sql/comet/IcebergDataWriterShim.scala
new file mode 100644
index 0000000000..a2ab14eacb
--- /dev/null
+++
b/spark/src/main/spark-3.x/org/apache/spark/sql/comet/IcebergDataWriterShim.scala
@@ -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.spark.sql.comet
+
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.connector.write.DataWriter
+
+// Never reached on Spark 3.x: operation-coded ReplaceData rows only exist on
4.0+ and
+// IcebergReplaceDataShim returns no dispatch info there.
+private[comet] object IcebergDataWriterShim {
+
+ def writeWithMetadata(
+ writer: DataWriter[InternalRow],
+ metadata: InternalRow,
+ row: InternalRow): Unit =
+ throw new UnsupportedOperationException(
+ "DataWriter.write(metadata, row) requires Spark 4.0 or later")
+}
diff --git
a/spark/src/main/spark-3.x/org/apache/spark/sql/comet/IcebergWriteSummaryShim.scala
b/spark/src/main/spark-3.x/org/apache/spark/sql/comet/IcebergWriteSummaryShim.scala
new file mode 100644
index 0000000000..d6a78ca2ea
--- /dev/null
+++
b/spark/src/main/spark-3.x/org/apache/spark/sql/comet/IcebergWriteSummaryShim.scala
@@ -0,0 +1,33 @@
+/*
+ * 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.comet
+
+import org.apache.spark.sql.connector.write.{BatchWrite, WriterCommitMessage}
+import org.apache.spark.sql.execution.SparkPlan
+
+/** The `BatchWrite.commit(messages, summary)` overload only exists on Spark
4.1+. */
+private[comet] object IcebergWriteSummaryShim {
+ def commit(
+ batchWrite: BatchWrite,
+ messages: Array[WriterCommitMessage],
+ query: SparkPlan): Unit = {
+ batchWrite.commit(messages)
+ }
+}
diff --git
a/spark/src/main/spark-4.0/org/apache/comet/iceberg/IcebergRefreshCacheShim.scala
b/spark/src/main/spark-4.0/org/apache/comet/iceberg/IcebergRefreshCacheShim.scala
new file mode 100644
index 0000000000..2fa3172dd8
--- /dev/null
+++
b/spark/src/main/spark-4.0/org/apache/comet/iceberg/IcebergRefreshCacheShim.scala
@@ -0,0 +1,33 @@
+/*
+ * 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.comet.iceberg
+
+import org.apache.spark.sql.classic.SparkSession
+import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation
+
+/** Spark 4.x needs the classic `SparkSession`; the api-module one has no
`sharedState`. */
+private[iceberg] object IcebergRefreshCacheShim {
+ def refreshCache(
+ session: org.apache.spark.sql.SparkSession,
+ rel: DataSourceV2Relation): Unit = {
+ val classic = session.asInstanceOf[SparkSession]
+ classic.sharedState.cacheManager.recacheByPlan(classic, rel)
+ }
+}
diff --git
a/spark/src/main/spark-4.0/org/apache/spark/sql/comet/IcebergWriteSummaryShim.scala
b/spark/src/main/spark-4.0/org/apache/spark/sql/comet/IcebergWriteSummaryShim.scala
new file mode 100644
index 0000000000..d6a78ca2ea
--- /dev/null
+++
b/spark/src/main/spark-4.0/org/apache/spark/sql/comet/IcebergWriteSummaryShim.scala
@@ -0,0 +1,33 @@
+/*
+ * 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.comet
+
+import org.apache.spark.sql.connector.write.{BatchWrite, WriterCommitMessage}
+import org.apache.spark.sql.execution.SparkPlan
+
+/** The `BatchWrite.commit(messages, summary)` overload only exists on Spark
4.1+. */
+private[comet] object IcebergWriteSummaryShim {
+ def commit(
+ batchWrite: BatchWrite,
+ messages: Array[WriterCommitMessage],
+ query: SparkPlan): Unit = {
+ batchWrite.commit(messages)
+ }
+}
diff --git
a/spark/src/main/spark-4.1+/org/apache/comet/iceberg/IcebergRefreshCacheShim.scala
b/spark/src/main/spark-4.1+/org/apache/comet/iceberg/IcebergRefreshCacheShim.scala
new file mode 100644
index 0000000000..bd14131b8a
--- /dev/null
+++
b/spark/src/main/spark-4.1+/org/apache/comet/iceberg/IcebergRefreshCacheShim.scala
@@ -0,0 +1,43 @@
+/*
+ * 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.comet.iceberg
+
+import org.apache.spark.sql.classic.SparkSession
+import org.apache.spark.sql.execution.datasources.v2.{DataSourceV2Relation,
ExtractV2CatalogAndIdentifier}
+
+/** Spark 4.x needs the classic `SparkSession`; the api-module one has no
`sharedState`. */
+private[iceberg] object IcebergRefreshCacheShim {
+
+ // Spark 4.1 recaches catalog tables by name rather than by plan so that
cache entries built
+ // before a schema change are still refreshed.
+ def refreshCache(
+ session: org.apache.spark.sql.SparkSession,
+ rel: DataSourceV2Relation): Unit = {
+ val classic = session.asInstanceOf[SparkSession]
+ val cacheManager = classic.sharedState.cacheManager
+ rel match {
+ case ExtractV2CatalogAndIdentifier(catalog, ident) =>
+ val nameParts = catalog.name +: ident.namespace.toSeq :+ ident.name
+ cacheManager.recacheTableOrView(classic, nameParts, includeTimeTravel
= false)
+ case _ =>
+ cacheManager.recacheByPlan(classic, rel)
+ }
+ }
+}
diff --git
a/spark/src/main/spark-4.1+/org/apache/spark/sql/comet/IcebergWriteSummaryShim.scala
b/spark/src/main/spark-4.1+/org/apache/spark/sql/comet/IcebergWriteSummaryShim.scala
new file mode 100644
index 0000000000..59aaad3f8c
--- /dev/null
+++
b/spark/src/main/spark-4.1+/org/apache/spark/sql/comet/IcebergWriteSummaryShim.scala
@@ -0,0 +1,55 @@
+/*
+ * 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.comet
+
+import org.apache.spark.sql.connector.write.{BatchWrite, MergeSummaryImpl,
WriterCommitMessage}
+import org.apache.spark.sql.execution.SparkPlan
+import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
+import org.apache.spark.sql.execution.datasources.v2.MergeRowsExec
+
+/**
+ * Spark 4.1 collects MERGE metrics from the executed plan and hands them to
+ * `BatchWrite.commit(messages, summary)`; Iceberg 1.11+ records them in the
snapshot summary.
+ */
+private[comet] object IcebergWriteSummaryShim extends AdaptiveSparkPlanHelper {
+ def commit(
+ batchWrite: BatchWrite,
+ messages: Array[WriterCommitMessage],
+ query: SparkPlan): Unit = {
+ collectFirst(query) { case m: MergeRowsExec => m } match {
+ case Some(mergeRows) =>
+ val metrics = mergeRows.metrics
+ def metricValue(name: String): Long =
metrics.get(name).map(_.value).getOrElse(-1L)
+ batchWrite.commit(
+ messages,
+ MergeSummaryImpl(
+ metricValue("numTargetRowsCopied"),
+ metricValue("numTargetRowsDeleted"),
+ metricValue("numTargetRowsUpdated"),
+ metricValue("numTargetRowsInserted"),
+ metricValue("numTargetRowsMatchedUpdated"),
+ metricValue("numTargetRowsMatchedDeleted"),
+ metricValue("numTargetRowsNotMatchedBySourceUpdated"),
+ metricValue("numTargetRowsNotMatchedBySourceDeleted")))
+ case None =>
+ batchWrite.commit(messages)
+ }
+ }
+}
diff --git
a/spark/src/main/spark-4.x/org/apache/comet/iceberg/IcebergDriverMetricsShim.scala
b/spark/src/main/spark-4.x/org/apache/comet/iceberg/IcebergDriverMetricsShim.scala
new file mode 100644
index 0000000000..46f9abf35e
--- /dev/null
+++
b/spark/src/main/spark-4.x/org/apache/comet/iceberg/IcebergDriverMetricsShim.scala
@@ -0,0 +1,27 @@
+/*
+ * 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.comet.iceberg
+
+import org.apache.spark.sql.connector.metric.CustomTaskMetric
+import org.apache.spark.sql.connector.write.Write
+
+object IcebergDriverMetricsShim {
+ def reportDriverMetrics(write: Write): Array[CustomTaskMetric] =
write.reportDriverMetrics()
+}
diff --git
a/spark/src/main/spark-4.x/org/apache/comet/iceberg/IcebergReplaceDataShim.scala
b/spark/src/main/spark-4.x/org/apache/comet/iceberg/IcebergReplaceDataShim.scala
new file mode 100644
index 0000000000..c860580005
--- /dev/null
+++
b/spark/src/main/spark-4.x/org/apache/comet/iceberg/IcebergReplaceDataShim.scala
@@ -0,0 +1,34 @@
+/*
+ * 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.comet.iceberg
+
+import org.apache.spark.sql.catalyst.plans.logical.ReplaceData
+
+/**
+ * Spark 4.x: `ReplaceData` carries `projections: ReplaceDataProjections` --
the rewritten row
+ * stream is prefixed with an operation column (5=WRITE,
6=WRITE_WITH_METADATA), and we have to
+ * apply `dataProj` / `metadataProj` before handing rows to the underlying
`DataWriter`.
+ */
+private[iceberg] object IcebergReplaceDataShim {
+ def extractProjections(rd: ReplaceData): Option[ReplaceDataDispatchInfo] = {
+ val p = rd.projections
+ Some(ReplaceDataDispatchInfo(p.rowProjection, p.metadataProjection))
+ }
+}
diff --git
a/spark/src/main/spark-4.x/org/apache/spark/sql/comet/IcebergDataWriterShim.scala
b/spark/src/main/spark-4.x/org/apache/spark/sql/comet/IcebergDataWriterShim.scala
new file mode 100644
index 0000000000..e30bdff4f4
--- /dev/null
+++
b/spark/src/main/spark-4.x/org/apache/spark/sql/comet/IcebergDataWriterShim.scala
@@ -0,0 +1,32 @@
+/*
+ * 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.comet
+
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.connector.write.DataWriter
+
+private[comet] object IcebergDataWriterShim {
+
+ def writeWithMetadata(
+ writer: DataWriter[InternalRow],
+ metadata: InternalRow,
+ row: InternalRow): Unit =
+ writer.write(metadata, row)
+}
diff --git
a/spark/src/test/scala/org/apache/comet/CometIcebergRewriteActionSuite.scala
b/spark/src/test/scala/org/apache/comet/CometIcebergRewriteActionSuite.scala
index af2508aedc..19a4d6a3ce 100644
--- a/spark/src/test/scala/org/apache/comet/CometIcebergRewriteActionSuite.scala
+++ b/spark/src/test/scala/org/apache/comet/CometIcebergRewriteActionSuite.scala
@@ -204,7 +204,7 @@ class CometIcebergRewriteActionSuite extends CometTestBase
with CometIcebergTest
s"Expected >= 1 input file rewritten, got $rewrittenCount")
}
- val rewritePlans = plans.filter(_.hasNode("AppendData"))
+ val rewritePlans = plans.filter(isRewriteWrite)
assert(rewritePlans.nonEmpty, "Expected at least one rewrite plan")
assertReadsAreComet(rewritePlans)
@@ -266,11 +266,11 @@ class CometIcebergRewriteActionSuite extends
CometTestBase with CometIcebergTest
assertDataPreserved(rowsBefore, rowsAfterById, filesBefore,
filesAfter)
rc.verifyDataAfter(rowsAfterFileOrder)
- val rewritePlans = plans.filter(_.hasNode("AppendData"))
+ val rewritePlans = plans.filter(isRewriteWrite)
assert(
rewritePlans.nonEmpty,
- "Expected at least one captured plan with AppendData but got
none.\n" +
- dumpPlans(plans))
+ "Expected at least one captured plan with AppendData or
IcebergCommit but got " +
+ "none.\n" + dumpPlans(plans))
rc.verifyPlans(rewritePlans)
} catch {
case _: ClassNotFoundException =>
@@ -329,6 +329,11 @@ class CometIcebergRewriteActionSuite extends CometTestBase
with CometIcebergTest
// -- Plan assertions -------------------------------------------------------
+ // The per-group rewrite write plans as Spark's AppendData, or as Comet's
IcebergCommit when
+ // COMET_ICEBERG_WRITE_SPLIT_OPERATOR_ENABLED is on.
+ private def isRewriteWrite(plan: CapturedPlan): Boolean =
+ plan.hasNode("AppendData") || plan.hasNode("IcebergCommit")
+
private def assertReadsAreComet(plans: Seq[CapturedPlan]): Unit = {
assertOperator(plans, "CometIcebergNativeScan")
val withFallback =
diff --git
a/spark/src/test/scala/org/apache/comet/CometIcebergWriteActionSuite.scala
b/spark/src/test/scala/org/apache/comet/CometIcebergWriteActionSuite.scala
new file mode 100644
index 0000000000..41f596e5ea
--- /dev/null
+++ b/spark/src/test/scala/org/apache/comet/CometIcebergWriteActionSuite.scala
@@ -0,0 +1,979 @@
+/*
+ * 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.comet
+
+import java.io.File
+import java.util.concurrent.{CountDownLatch, TimeUnit}
+
+import scala.collection.mutable
+import scala.concurrent.{Await, Future}
+import scala.concurrent.ExecutionContext.Implicits.global
+import scala.concurrent.duration.DurationInt
+
+import org.apache.spark.{CometListenerBusUtils, SparkConf}
+import org.apache.spark.sql.CometTestBase
+import org.apache.spark.sql.Row
+import org.apache.spark.sql.comet.{IcebergCommitExec, IcebergWriteExec}
+import org.apache.spark.sql.connector.catalog.InMemoryTableCatalog
+import org.apache.spark.sql.execution.{QueryExecution, SparkPlan}
+import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
+import org.apache.spark.sql.types.{DoubleType, IntegerType, StringType,
StructField, StructType}
+import org.apache.spark.sql.util.QueryExecutionListener
+
+import org.apache.comet.CometSparkSessionExtensions.{isSpark35Plus,
isSpark41Plus}
+
+private case class WriteSnapshot(snapshotDelta: Long, plans: Seq[SparkPlan])
+
+class CometIcebergWriteActionSuite
+ extends CometTestBase
+ with AdaptiveSparkPlanHelper
+ with CometIcebergTestBase {
+
+ override protected def sparkConf: SparkConf = {
+ super.sparkConf
+ .set(CometConf.COMET_ICEBERG_WRITE_SPLIT_OPERATOR_ENABLED.key, "true")
+ .set(
+ "spark.sql.extensions",
+ "org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions")
+ }
+
+ test("AppendData unpartitioned INSERT INTO routes through two-op") {
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ withIcebergCatalog { warehouseDir =>
+ createTable(warehouseDir, "append_unpart", partitionSpec = "")
+ val snapshot = captureWrite("append_unpart") {
+ spark.sql(
+ "INSERT INTO cat.db.append_unpart VALUES " +
+ "(1, 'us-east', 10.5), (2, 'us-west', 20.3), (3, 'eu', 30.7)")
+ }
+ assertExactlyOneCommit(snapshot)
+ assertRows("append_unpart", expectedIds = Seq(1, 2, 3))
+ }
+ }
+
+ test("AppendData partitioned INSERT INTO routes through two-op") {
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ withIcebergCatalog { warehouseDir =>
+ createTable(warehouseDir, "append_part", partitionSpec = "PARTITIONED BY
(region)")
+ val snapshot = captureWrite("append_part") {
+ spark.sql(
+ "INSERT INTO cat.db.append_part VALUES " +
+ "(1, 'us-east', 10.5), (2, 'us-east', 20.3), (3, 'eu', 30.7)")
+ }
+ assertExactlyOneCommit(snapshot)
+ assertRows("append_part", expectedIds = Seq(1, 2, 3))
+ }
+ }
+
+ test("AppendData INSERT FROM SELECT survives the intervening exchange/sort")
{
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ withIcebergCatalog { warehouseDir =>
+ createTable(warehouseDir, "src", partitionSpec = "")
+ createTable(warehouseDir, "append_from_select", partitionSpec =
"PARTITIONED BY (region)")
+ spark.sql(
+ "INSERT INTO cat.db.src VALUES " +
+ "(1, 'us-east', 10.5), (2, 'us-west', 20.3), (3, 'eu', 30.7)")
+
+ val snapshot = captureWrite("append_from_select") {
+ spark.sql(
+ "INSERT INTO cat.db.append_from_select " +
+ "SELECT id, region, amount FROM cat.db.src ORDER BY id")
+ }
+ assertExactlyOneCommit(snapshot)
+ assertRows("append_from_select", expectedIds = Seq(1, 2, 3))
+ }
+ }
+
+ test("AppendData on an empty source still emits a single commit") {
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ withIcebergCatalog { warehouseDir =>
+ createTable(warehouseDir, "empty_target", partitionSpec = "")
+ val snapshot = captureWrite("empty_target") {
+ spark.sql(
+ "INSERT INTO cat.db.empty_target SELECT id, region, amount " +
+ "FROM (SELECT 1 AS id, 'r' AS region, 1.0 AS amount) WHERE id < 0")
+ }
+ assertExactlyOneCommit(snapshot)
+ assertRows("empty_target", expectedIds = Seq.empty)
+ }
+ }
+
+ test("AppendData from a zero-partition RDD still runs one write task and
commits") {
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ withIcebergCatalog { warehouseDir =>
+ createTable(warehouseDir, "zero_part", partitionSpec = "")
+ val schema = StructType(
+ Seq(
+ StructField("id", IntegerType),
+ StructField("region", StringType),
+ StructField("amount", DoubleType)))
+ val emptyDf = spark.createDataFrame(spark.sparkContext.emptyRDD[Row],
schema)
+ assert(emptyDf.rdd.getNumPartitions == 0, "test requires a genuinely
zero-partition input")
+
+ val snapshot = captureWrite("zero_part") {
+ emptyDf.writeTo(s"$catalog.$ns.zero_part").append()
+ }
+ assertExactlyOneCommit(snapshot)
+ val commitNodes = snapshot.plans.flatMap { plan =>
+ collectWithSubqueries(plan) { case c: IcebergCommitExec => c }
+ }
+ assert(
+ commitNodes.exists(_.metrics("numCommittedMessages").value == 1),
+ "expected the dummy single-partition write task to produce one commit
message")
+ assertRows("zero_part", expectedIds = Seq.empty)
+ }
+ }
+
+ test("AQE re-plan of the writer subtree writes and commits exactly once") {
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ withIcebergCatalog { warehouseDir =>
+ createTable(warehouseDir, "aqe_replan", partitionSpec = "")
+ val session = spark
+ import session.implicits._
+ (1 to 100)
+ .map(i => (i, s"r${i % 4}", i.toDouble))
+ .toDF("id", "region", "amount")
+ .createOrReplaceTempView("aqe_replan_left")
+ (1 to 100)
+ .map(i => (i, i * 10.0))
+ .toDF("id", "bonus")
+ .createOrReplaceTempView("aqe_replan_right")
+
+ // Broadcast is disabled at static planning time, so the initial plan
under the writer
+ // joins with a shuffle. AQE's runtime stats then re-plan it to a
broadcast join, which
+ // re-emits the writer subtree via IcebergWriteLogical mid-execution.
+ val snapshot = captureWrite("aqe_replan") {
+ withSQLConf(
+ "spark.sql.adaptive.enabled" -> "true",
+ "spark.sql.autoBroadcastJoinThreshold" -> "-1",
+ "spark.sql.adaptive.autoBroadcastJoinThreshold" -> "10m") {
+ spark.sql(
+ "INSERT INTO cat.db.aqe_replan " +
+ "SELECT l.id, l.region, l.amount + r.bonus " +
+ "FROM aqe_replan_left l JOIN aqe_replan_right r ON l.id = r.id")
+ }
+ }
+ assertExactlyOneCommit(snapshot)
+ val broadcastJoins = snapshot.plans.flatMap { plan =>
+ collectWithSubqueries(plan) {
+ case j if j.nodeName.contains("BroadcastHashJoin") => j
+ }
+ }
+ assert(
+ broadcastJoins.nonEmpty,
+ "expected AQE to re-plan the static shuffle join to a broadcast join.
Plans:\n" +
+ snapshot.plans.mkString("\n--\n"))
+ assertRows("aqe_replan", expectedIds = 1 to 100)
+ }
+ }
+
+ test("partitioned write under AQE keeps the clustered writer working across
the shuffle") {
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ withIcebergCatalog { warehouseDir =>
+ createTable(warehouseDir, "aqe_part", partitionSpec = "PARTITIONED BY
(region)")
+ val session = spark
+ import session.implicits._
+ (1 to 500)
+ .map(i => (i, s"r${i % 8}", i.toDouble))
+ .toDF("id", "region", "amount")
+ .createOrReplaceTempView("aqe_part_src")
+
+ val snapshot = captureWrite("aqe_part") {
+ withSQLConf(
+ "spark.sql.adaptive.enabled" -> "true",
+ "spark.sql.shuffle.partitions" -> "8") {
+ spark.sql(s"INSERT INTO $catalog.$ns.aqe_part SELECT * FROM
aqe_part_src")
+ }
+ }
+ assertExactlyOneCommit(snapshot)
+ val exchanges = snapshot.plans.flatMap { plan =>
+ collectWithSubqueries(plan) { case e if
e.nodeName.contains("Exchange") => e }
+ }
+ assert(
+ exchanges.nonEmpty,
+ "expected the clustered partitioned write to shuffle its input.
Plans:\n" +
+ snapshot.plans.mkString("\n--\n"))
+ assertRows("aqe_part", expectedIds = 1 to 500)
+ }
+ }
+
+ test("multi-partition partitioned write collects one commit message per
task") {
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ withIcebergCatalog { warehouseDir =>
+ createTable(warehouseDir, "multi_task", partitionSpec = "PARTITIONED BY
(region)")
+ val session = spark
+ import session.implicits._
+ (1 to 100)
+ .map(i => (i, s"r${i % 4}", i.toDouble))
+ .toDF("id", "region", "amount")
+ .createOrReplaceTempView("multi_task_src")
+
+ val snapshot = captureWrite("multi_task") {
+ withSQLConf(
+ "spark.sql.adaptive.coalescePartitions.enabled" -> "false",
+ "spark.sql.shuffle.partitions" -> "4") {
+ spark.sql(s"INSERT INTO $catalog.$ns.multi_task SELECT * FROM
multi_task_src")
+ }
+ }
+ assertExactlyOneCommit(snapshot)
+ val commitNodes = snapshot.plans.flatMap { plan =>
+ collectWithSubqueries(plan) { case c: IcebergCommitExec => c }
+ }
+ assert(
+ commitNodes.exists(_.metrics("numCommittedMessages").value >= 2),
+ "expected multiple task commit messages, got " +
+ s"${commitNodes.map(_.metrics("numCommittedMessages").value)}")
+ assertRows("multi_task", expectedIds = 1 to 100)
+ }
+ }
+
+ // Spark 4.1 recaches v2 catalog tables by name, so a cached table must pick
up an append
+ // that follows a schema-changing DDL; plan-based recaching on older Sparks
cannot match the
+ // pre-DDL cache entry, matching stock behaviour there.
+ test("cached table reflects an append that follows a schema change on Spark
4.1+") {
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ assume(isSpark41Plus, "name-based cache refresh needs Spark 4.1+")
+ withIcebergCatalog { warehouseDir =>
+ createTable(warehouseDir, "cache_refresh", partitionSpec = "")
+ spark.sql(s"INSERT INTO $catalog.$ns.cache_refresh VALUES (1, 'us-east',
10.0)")
+ spark.sql(s"CACHE TABLE $catalog.$ns.cache_refresh")
+ assert(spark.sql(s"SELECT * FROM $catalog.$ns.cache_refresh").count() ==
1)
+
+ try {
+ spark.sql(s"ALTER TABLE $catalog.$ns.cache_refresh ADD COLUMN extra
INT")
+ spark.sql(s"INSERT INTO $catalog.$ns.cache_refresh VALUES (2, 'eu',
20.0, -1)")
+ // Sorted client-side: ORDER BY over an InMemoryTableScan trips an
unrelated,
+ // pre-existing Comet shuffle-planning bug regardless of this config.
+ val ids = spark
+ .sql(s"SELECT id FROM $catalog.$ns.cache_refresh")
+ .collect()
+ .map(_.getInt(0))
+ .sorted
+ .toSeq
+ assert(ids == Seq(1, 2), s"cached table missed the post-DDL append,
got $ids")
+ } finally {
+ spark.sql(s"UNCACHE TABLE IF EXISTS $catalog.$ns.cache_refresh")
+ }
+ }
+ }
+
+ test("OverwriteByExpression replaces existing rows via two-op") {
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ withIcebergCatalog { warehouseDir =>
+ createTable(warehouseDir, "overwrite_static", partitionSpec = "")
+ spark.sql(
+ "INSERT INTO cat.db.overwrite_static VALUES " +
+ "(1, 'old', 1.0), (2, 'old', 2.0), (3, 'old', 3.0)")
+
+ val snapshot = captureWrite("overwrite_static") {
+ withSQLConf("spark.sql.sources.partitionOverwriteMode" -> "STATIC") {
+ spark.sql(
+ "INSERT OVERWRITE cat.db.overwrite_static VALUES " +
+ "(10, 'new', 100.0), (11, 'new', 110.0)")
+ }
+ }
+ assertExactlyOneCommit(snapshot)
+ assertRows("overwrite_static", expectedIds = Seq(10, 11))
+ }
+ }
+
+ test("OverwritePartitionsDynamic replaces only touched partitions") {
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ withIcebergCatalog { warehouseDir =>
+ createTable(warehouseDir, "overwrite_dynamic", partitionSpec =
"PARTITIONED BY (region)")
+ spark.sql(
+ "INSERT INTO cat.db.overwrite_dynamic VALUES " +
+ "(1, 'us-east', 1.0), (2, 'us-west', 2.0), (3, 'eu', 3.0)")
+
+ val snapshot = captureWrite("overwrite_dynamic") {
+ withSQLConf("spark.sql.sources.partitionOverwriteMode" -> "DYNAMIC") {
+ spark.sql("INSERT OVERWRITE cat.db.overwrite_dynamic VALUES (10,
'us-east', 100.0)")
+ }
+ }
+ assertExactlyOneCommit(snapshot)
+ val ids = spark
+ .sql("SELECT id FROM cat.db.overwrite_dynamic ORDER BY id")
+ .collect()
+ .map(_.getInt(0))
+ .toSeq
+ assert(ids == Seq(2, 3, 10), s"expected (2,3,10), got $ids")
+ }
+ }
+
+ test("ReplaceData (CoW DELETE) on a row predicate goes through two-op") {
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ withIcebergCatalog { warehouseDir =>
+ createTable(
+ warehouseDir,
+ "cow_delete",
+ partitionSpec = "",
+ properties = Some("'write.delete.mode'='copy-on-write'"))
+ withSQLConf(CometConf.COMET_ICEBERG_WRITE_SPLIT_OPERATOR_ENABLED.key ->
"false") {
+ coalesceInsert(
+ "cow_delete",
+ Seq((1, "us-east", 10.0), (2, "us-west", 20.0), (3, "eu", 30.0), (4,
"us-east", 40.0)))
+ }
+
+ val snapshot = captureWrite("cow_delete") {
+ spark.sql("DELETE FROM cat.db.cow_delete WHERE id = 2")
+ }
+ assertExactlyOneCommit(snapshot)
+ assertRows("cow_delete", expectedIds = Seq(1, 3, 4))
+ }
+ }
+
+ test("ReplaceData (CoW UPDATE) routes through two-op") {
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ withIcebergCatalog { warehouseDir =>
+ createTable(
+ warehouseDir,
+ "cow_update",
+ partitionSpec = "",
+ properties = Some("'write.update.mode'='copy-on-write'"))
+ coalesceInsert(
+ "cow_update",
+ Seq((1, "us-east", 10.0), (2, "us-west", 20.0), (3, "eu", 30.0)))
+
+ val snapshot = captureWrite("cow_update") {
+ spark.sql("UPDATE cat.db.cow_update SET amount = amount * 2 WHERE id =
2")
+ }
+ assertExactlyOneCommit(snapshot)
+ val r = spark
+ .sql("SELECT id, amount FROM cat.db.cow_update WHERE id = 2")
+ .collect()
+ assert(r.length == 1 && r(0).getDouble(1) == 40.0, s"got ${r.toSeq}")
+ }
+ }
+
+ test("ReplaceData (CoW MERGE) with matched and unmatched legs routes through
two-op") {
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ withIcebergCatalog { warehouseDir =>
+ createTable(
+ warehouseDir,
+ "cow_merge",
+ partitionSpec = "",
+ properties = Some("'write.merge.mode'='copy-on-write'"))
+ coalesceInsert("cow_merge", Seq((1, "us-east", 10.0), (2, "us-west",
20.0)))
+
+ val snapshot = captureWrite("cow_merge") {
+ spark.sql("""
+ |MERGE INTO cat.db.cow_merge t
+ |USING (SELECT 2 AS id, 'us-west' AS region, 200.0 AS amount UNION
ALL
+ | SELECT 3 AS id, 'eu' AS region, 30.0 AS amount) s
+ |ON t.id = s.id
+ |WHEN MATCHED THEN UPDATE SET t.amount = s.amount
+ |WHEN NOT MATCHED THEN INSERT (id, region, amount) VALUES (s.id,
s.region, s.amount)
+ |""".stripMargin)
+ }
+ assertExactlyOneCommit(snapshot)
+ assertRows("cow_merge", expectedIds = Seq(1, 2, 3))
+ }
+ }
+
+ // Spark 4.1 hands MERGE metrics to `BatchWrite.commit(messages, summary)`
and Iceberg 1.11+
+ // records them in the snapshot summary.
+ test("CoW MERGE records merge metrics in the snapshot summary on Spark
4.1+") {
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ assume(isSpark41Plus, "MERGE write summaries need Spark 4.1+")
+ withIcebergCatalog { warehouseDir =>
+ createTable(
+ warehouseDir,
+ "merge_summary",
+ partitionSpec = "",
+ properties = Some("'write.merge.mode'='copy-on-write'"))
+ coalesceInsert("merge_summary", Seq((1, "us-east", 10.0), (2, "us-west",
20.0)))
+
+ spark.sql(s"""
+ |MERGE INTO $catalog.$ns.merge_summary t
+ |USING (SELECT 2 AS id, 'us-west' AS region, 200.0 AS amount UNION ALL
+ | SELECT 3 AS id, 'eu' AS region, 30.0 AS amount) s
+ |ON t.id = s.id
+ |WHEN MATCHED THEN UPDATE SET t.amount = s.amount
+ |WHEN NOT MATCHED THEN INSERT (id, region, amount) VALUES (s.id,
s.region, s.amount)
+ |""".stripMargin)
+
+ val summary = spark
+ .sql(s"SELECT summary FROM $catalog.$ns.merge_summary.snapshots " +
+ "ORDER BY committed_at DESC LIMIT 1")
+ .collect()(0)
+ .getMap[String, String](0)
+ val expected = Map(
+ "spark.merge-into.num-target-rows-copied" -> "1",
+ "spark.merge-into.num-target-rows-updated" -> "1",
+ "spark.merge-into.num-target-rows-inserted" -> "1")
+ expected.foreach { case (key, value) =>
+ assert(
+ summary.get(key).contains(value),
+ s"expected $key=$value in snapshot summary, got $summary")
+ }
+ }
+ }
+
+ test("failed write job aborts and leaves the table unchanged") {
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ withIcebergCatalog { warehouseDir =>
+ createTable(warehouseDir, "task_fail", partitionSpec = "")
+ coalesceInsert("task_fail", Seq((1, "us-east", 10.0)))
+ val session = spark
+ import session.implicits._
+ (1 to 10)
+ .map(i => (i, s"r$i", i.toDouble))
+ .toDF("id", "region", "amount")
+ .createOrReplaceTempView("task_fail_src")
+ spark.udf.register(
+ "boom_on_seven",
+ (id: Int) => {
+ if (id == 7) throw new RuntimeException("boom")
+ id
+ })
+
+ val before = countSnapshots("task_fail")
+ val e = intercept[Exception] {
+ spark.sql(
+ s"INSERT INTO $catalog.$ns.task_fail " +
+ "SELECT boom_on_seven(id), region, amount FROM task_fail_src")
+ }
+ assert(
+ exceptionChain(e).exists(_.getMessage != null) &&
+ exceptionChain(e).exists(t =>
Option(t.getMessage).exists(_.contains("boom"))),
+ s"expected the injected task failure to surface, got $e")
+ assert(countSnapshots("task_fail") == before, "failed write must not
create a snapshot")
+ assertRows("task_fail", expectedIds = Seq(1))
+ }
+ }
+
+ test("commit-time validation still sees a conflicting concurrent append") {
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ withIcebergCatalog { warehouseDir =>
+ createTable(
+ warehouseDir,
+ "conflict",
+ partitionSpec = "",
+ properties = Some(
+
"'write.delete.mode'='copy-on-write','write.delete.isolation-level'='serializable'"))
+ coalesceInsert("conflict", Seq((1, "us-east", 10.0), (2, "us-west",
20.0), (3, "eu", 30.0)))
+
+ ConflictGate.reset()
+ spark.udf.register(
+ "conflict_gate",
+ (id: Int) => {
+ ConflictGate.enter()
+ id
+ })
+
+ val before = countSnapshots("conflict")
+ // The gate UDF blocks the DELETE's tasks after its scan snapshot is
pinned, so the
+ // append below is guaranteed to land between the scan and the commit.
Runtime group
+ // filtering is disabled so the conflict is detected by Iceberg's
commit-time
+ // validation rather than aborted earlier by the runtime file filter.
+
withSQLConf("spark.sql.optimizer.runtime.rowLevelOperationGroupFilter.enabled"
-> "false") {
+ val delete = Future {
+ spark.sql(s"DELETE FROM $catalog.$ns.conflict WHERE
conflict_gate(id) = 2")
+ }
+ assert(
+ ConflictGate.awaitScanStarted(2, TimeUnit.MINUTES),
+ "DELETE never started scanning; gate UDF was not invoked")
+ spark.sql(s"INSERT INTO $catalog.$ns.conflict VALUES (2, 'us-west',
99.0)")
+ ConflictGate.releaseWrite()
+
+ val e = intercept[Exception] {
+ Await.result(delete, 2.minutes)
+ }
+ assert(
+ exceptionChain(e).exists(t =>
+ Option(t.getMessage).exists(_.toLowerCase.contains("conflict"))),
+ s"expected Iceberg commit-time validation to fail the DELETE, got
$e")
+ }
+ assert(
+ countSnapshots("conflict") == before + 1,
+ "only the concurrent append may commit; the DELETE must not")
+ val ids = spark
+ .sql(s"SELECT id FROM $catalog.$ns.conflict ORDER BY id")
+ .collect()
+ .map(_.getInt(0))
+ .toSeq
+ assert(ids == Seq(1, 2, 2, 3), s"expected (1,2,2,3), got $ids")
+ }
+ }
+
+ test("non-Iceberg V2 write plans through Spark unchanged with the config
on") {
+ withSQLConf(
+ "spark.sql.catalog.testcat" -> classOf[InMemoryTableCatalog].getName,
+ CometConf.COMET_ENABLED.key -> "true",
+ CometConf.COMET_EXEC_ENABLED.key -> "true") {
+ spark.sql("CREATE TABLE testcat.tbl (id INT, region STRING, amount
DOUBLE)")
+ try {
+ val plans = capturePlans {
+ spark.sql("INSERT INTO testcat.tbl VALUES (1, 'us-east', 10.5)")
+ }
+ val (commits, writes) = collectIcebergWriteOps(plans)
+ assert(commits.isEmpty, s"unexpected IcebergCommitExec on a
non-Iceberg write: $commits")
+ assert(writes.isEmpty, s"unexpected IcebergWriteExec on a non-Iceberg
write: $writes")
+ val ids = spark.sql("SELECT id FROM
testcat.tbl").collect().map(_.getInt(0)).toSeq
+ assert(ids == Seq(1), s"expected (1), got $ids")
+ } finally {
+ spark.sql("DROP TABLE IF EXISTS testcat.tbl")
+ }
+ }
+ }
+
+ test("sanity check: Spark's default DELETE path works against a Hadoop
catalog") {
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ withIcebergCatalog { warehouseDir =>
+ withSQLConf(CometConf.COMET_ICEBERG_WRITE_SPLIT_OPERATOR_ENABLED.key ->
"false") {
+ createTable(
+ warehouseDir,
+ "spark_cow_delete",
+ partitionSpec = "",
+ properties = Some("'write.delete.mode'='copy-on-write'"))
+ coalesceInsert(
+ "spark_cow_delete",
+ Seq((1, "us-east", 10.0), (2, "us-west", 20.0), (3, "eu", 30.0), (4,
"us-east", 40.0)))
+ spark.sql("DELETE FROM cat.db.spark_cow_delete WHERE id = 2")
+ assertRows("spark_cow_delete", expectedIds = Seq(1, 3, 4))
+ }
+ }
+ }
+
+ test("disabled config falls through to Spark's V2ExistingTableWriteExec") {
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ withIcebergCatalog { warehouseDir =>
+ createTable(warehouseDir, "disabled_conf", partitionSpec = "")
+
+ val snapshot = captureWrite("disabled_conf") {
+ withSQLConf(CometConf.COMET_ICEBERG_WRITE_SPLIT_OPERATOR_ENABLED.key
-> "false") {
+ spark.sql("INSERT INTO cat.db.disabled_conf VALUES (1, 'us-east',
10.5)")
+ }
+ }
+ val (commits, writes) = collectIcebergWriteOps(snapshot.plans)
+ assert(commits.isEmpty, s"unexpected IcebergCommitExec: $commits")
+ assert(writes.isEmpty, s"unexpected IcebergWriteExec: $writes")
+ assertRows("disabled_conf", expectedIds = Seq(1))
+ }
+ }
+
+ test("Comet-written rows round-trip through Spark's reader unchanged") {
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ withIcebergCatalog { warehouseDir =>
+ createTable(warehouseDir, "parity_comet", partitionSpec = "PARTITIONED
BY (region)")
+ createTable(warehouseDir, "parity_spark", partitionSpec = "PARTITIONED
BY (region)")
+
+ spark.sql(
+ "INSERT INTO cat.db.parity_comet VALUES " +
+ "(1, 'us', 1.5), (2, 'eu', 2.5), (3, 'ap', 3.5), (4, 'us', 4.5)")
+
+ withSQLConf(CometConf.COMET_ICEBERG_WRITE_SPLIT_OPERATOR_ENABLED.key ->
"false") {
+ spark.sql(
+ "INSERT INTO cat.db.parity_spark VALUES " +
+ "(1, 'us', 1.5), (2, 'eu', 2.5), (3, 'ap', 3.5), (4, 'us', 4.5)")
+ }
+
+ val cometRows: Array[Row] = spark
+ .sql("SELECT id, region, amount FROM cat.db.parity_comet ORDER BY id")
+ .collect()
+ val sparkRows: Array[Row] = spark
+ .sql("SELECT id, region, amount FROM cat.db.parity_spark ORDER BY id")
+ .collect()
+ assert(cometRows.toSeq == sparkRows.toSeq, s"$cometRows vs $sparkRows")
+ }
+ }
+
+ test("write custom metrics are registered on the committer only") {
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ withIcebergCatalog { warehouseDir =>
+ createTable(warehouseDir, "metrics_once", partitionSpec = "")
+ val snapshot = captureWrite("metrics_once") {
+ spark.sql(s"INSERT INTO $catalog.$ns.metrics_once VALUES (1,
'us-east', 10.5)")
+ }
+ assertExactlyOneCommit(snapshot)
+ val (commits, writes) = collectIcebergWriteOps(snapshot.plans)
+ writes.foreach { w =>
+ assert(
+ w.metrics.keySet == Set("numOutputRows"),
+ s"IcebergWriteExec must not re-register the write's custom metrics,
got ${w.metrics.keySet}")
+ }
+ // Iceberg publishes write custom metrics from 1.9 on; older versions
expose none.
+ commits.head.metrics.get("addedDataFiles").foreach { m =>
+ assert(m.value == 1L, s"expected addedDataFiles=1 on the committer,
got ${m.value}")
+ }
+ }
+ }
+
+ test("AppendData round-trips all primitive types unchanged") {
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ withIcebergCatalog { _ =>
+ spark.sql(s"""
+ CREATE TABLE $catalog.$ns.prim_types (
+ b BOOLEAN, ti TINYINT, si SMALLINT, i INT, bi BIGINT,
+ f FLOAT, d DOUBLE, dec DECIMAL(18,4),
+ s STRING, bin BINARY, dt DATE, ts TIMESTAMP
+ ) USING iceberg""")
+ val literals =
+ "(true, CAST(1 AS TINYINT), CAST(2 AS SMALLINT), 3, CAST(4 AS BIGINT),
" +
+ "CAST(1.5 AS FLOAT), 2.5D, CAST(12.3456 AS DECIMAL(18,4)), " +
+ "'hello', X'0102', DATE'2024-01-15', TIMESTAMP'2024-01-15
10:00:00'), " +
+ "(false, CAST(NULL AS TINYINT), CAST(NULL AS SMALLINT), CAST(NULL AS
INT), " +
+ "CAST(NULL AS BIGINT), CAST(NULL AS FLOAT), CAST(NULL AS DOUBLE), " +
+ "CAST(NULL AS DECIMAL(18,4)), CAST(NULL AS STRING), CAST(NULL AS
BINARY), " +
+ "CAST(NULL AS DATE), CAST(NULL AS TIMESTAMP))"
+ val snapshot = captureWrite("prim_types") {
+ spark.sql(s"INSERT INTO $catalog.$ns.prim_types VALUES $literals")
+ }
+ assertExactlyOneCommit(snapshot)
+ checkAnswer(
+ spark.sql(s"SELECT * FROM $catalog.$ns.prim_types"),
+ spark.sql(s"SELECT * FROM VALUES $literals"))
+ }
+ }
+
+ test("AppendData writes NULLs in every column") {
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ withIcebergCatalog { warehouseDir =>
+ createTable(warehouseDir, "nulls_all", partitionSpec = "")
+ val snapshot = captureWrite("nulls_all") {
+ spark.sql(s"""INSERT INTO $catalog.$ns.nulls_all VALUES
+ (CAST(NULL AS INT), CAST(NULL AS STRING), CAST(NULL AS DOUBLE)),
+ (1, NULL, 1.0),
+ (2, 'r', NULL)""")
+ }
+ assertExactlyOneCommit(snapshot)
+ checkAnswer(
+ spark.sql(s"SELECT id, region, amount FROM $catalog.$ns.nulls_all"),
+ Seq(Row(null, null, null), Row(1, null, 1.0), Row(2, "r", null)))
+ }
+ }
+
+ test("AppendData round-trips STRUCT, ARRAY, and MAP columns") {
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ withIcebergCatalog { _ =>
+ spark.sql(s"""
+ CREATE TABLE $catalog.$ns.nested_types (
+ id INT,
+ s STRUCT<a: INT, b: STRING>,
+ arr ARRAY<INT>,
+ m MAP<STRING, INT>
+ ) USING iceberg""")
+ val snapshot = captureWrite("nested_types") {
+ spark.sql(s"""INSERT INTO $catalog.$ns.nested_types VALUES
+ (1, named_struct('a', 10, 'b', 'x'), array(1, 2, 3), map('k1', 1,
'k2', 2)),
+ (2, named_struct('a', CAST(NULL AS INT), 'b', CAST(NULL AS STRING)),
+ CAST(NULL AS ARRAY<INT>), CAST(NULL AS MAP<STRING, INT>))""")
+ }
+ assertExactlyOneCommit(snapshot)
+ checkAnswer(
+ spark.sql(s"SELECT id, s.a, s.b, arr, m FROM
$catalog.$ns.nested_types"),
+ Seq(
+ Row(1, 10, "x", Seq(1, 2, 3), Map("k1" -> 1, "k2" -> 2)),
+ Row(2, null, null, null, null)))
+ }
+ }
+
+ test("CoW MERGE with only a matched DELETE leg routes through two-op") {
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ withIcebergCatalog { warehouseDir =>
+ createTable(
+ warehouseDir,
+ "merge_del",
+ partitionSpec = "",
+ properties = Some("'write.merge.mode'='copy-on-write'"))
+ coalesceInsert("merge_del", Seq((1, "a", 1.0), (2, "b", 2.0), (3, "c",
3.0)))
+
+ val snapshot = captureWrite("merge_del") {
+ spark.sql(s"""
+ |MERGE INTO $catalog.$ns.merge_del t
+ |USING (SELECT 2 AS id UNION ALL SELECT 3 AS id) s
+ |ON t.id = s.id
+ |WHEN MATCHED THEN DELETE
+ |""".stripMargin)
+ }
+ assertExactlyOneCommit(snapshot)
+ assertRows("merge_del", expectedIds = Seq(1))
+ }
+ }
+
+ test("CoW MERGE with a NOT MATCHED BY SOURCE leg routes through two-op") {
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ // Iceberg 1.5.x (the Spark 3.4 pairing) rejects the clause in its
extensions parser.
+ assume(isSpark35Plus, "NOT MATCHED BY SOURCE needs Iceberg 1.8+")
+ withIcebergCatalog { warehouseDir =>
+ createTable(
+ warehouseDir,
+ "merge_nmbs",
+ partitionSpec = "",
+ properties = Some("'write.merge.mode'='copy-on-write'"))
+ coalesceInsert("merge_nmbs", Seq((1, "a", 1.0), (2, "b", 2.0), (3, "c",
3.0)))
+
+ val snapshot = captureWrite("merge_nmbs") {
+ spark.sql(s"""
+ |MERGE INTO $catalog.$ns.merge_nmbs t
+ |USING (SELECT 2 AS id) s
+ |ON t.id = s.id
+ |WHEN MATCHED THEN UPDATE SET t.amount = t.amount + 100
+ |WHEN NOT MATCHED BY SOURCE THEN DELETE
+ |""".stripMargin)
+ }
+ assertExactlyOneCommit(snapshot)
+ assertRows("merge_nmbs", expectedIds = Seq(2))
+ checkAnswer(spark.sql(s"SELECT amount FROM $catalog.$ns.merge_nmbs"),
Seq(Row(102.0)))
+ }
+ }
+
+ test("AppendData to a bucket- and truncate-partitioned table routes through
two-op") {
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ withIcebergCatalog { _ =>
+ spark.sql(s"""
+ CREATE TABLE $catalog.$ns.bucketed (
+ id INT, region STRING, amount DOUBLE
+ ) USING iceberg PARTITIONED BY (bucket(4, id), truncate(2, region))""")
+ val snapshot = captureWrite("bucketed") {
+ spark.sql(
+ s"INSERT INTO $catalog.$ns.bucketed VALUES " +
+ "(1, 'aa', 1.0), (2, 'ab', 2.0), (3, 'bb', 3.0), (4, 'bc', 4.0),
(5, 'cc', 5.0)")
+ }
+ assertExactlyOneCommit(snapshot)
+ assertRows("bucketed", expectedIds = Seq(1, 2, 3, 4, 5))
+ }
+ }
+
+ test("AppendData to a days()-partitioned table routes each day to its own
partition") {
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ withIcebergCatalog { _ =>
+ spark.sql(s"""
+ CREATE TABLE $catalog.$ns.by_day (
+ id INT, ts TIMESTAMP, amount DOUBLE
+ ) USING iceberg PARTITIONED BY (days(ts))""")
+ val snapshot = captureWrite("by_day") {
+ spark.sql(
+ s"INSERT INTO $catalog.$ns.by_day VALUES " +
+ "(1, TIMESTAMP'2024-01-15 10:00:00', 1.0), " +
+ "(2, TIMESTAMP'2024-01-15 10:00:00', 2.0), " +
+ "(3, TIMESTAMP'2024-02-15 10:00:00', 3.0)")
+ }
+ assertExactlyOneCommit(snapshot)
+ val partitions = spark
+ .sql(s"SELECT count(*) FROM $catalog.$ns.by_day.partitions")
+ .collect()
+ .head
+ .getLong(0)
+ assert(partitions == 2, s"expected 2 day partitions, got $partitions")
+ val ids = spark
+ .sql(s"SELECT id FROM $catalog.$ns.by_day ORDER BY id")
+ .collect()
+ .map(_.getInt(0))
+ .toSeq
+ assert(ids == Seq(1, 2, 3), s"expected Seq(1, 2, 3), got $ids")
+ }
+ }
+
+ // On Spark 3.5+ the staged CTAS/RTAS operators run their inner append as
its own
+ // `AppendData` QueryExecution, which IcebergWriteStrategy intercepts like
any other append.
+ // Spark 3.4 writes inline inside the exec (no re-planning), so nothing is
intercepted there.
+ test("CTAS and RTAS write through the split operators on Spark 3.5+") {
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ withIcebergCatalog { _ =>
+ val session = spark
+ import session.implicits._
+ (1 to 5)
+ .map(i => (i, s"r${i % 2}", i.toDouble))
+ .toDF("id", "region", "amount")
+ .createOrReplaceTempView("ctas_src")
+
+ def assertSplitUsage(plans: Seq[SparkPlan], statement: String): Unit = {
+ val (commits, writes) = collectIcebergWriteOps(plans)
+ if (isSpark35Plus) {
+ assert(
+ commits.nonEmpty && writes.nonEmpty,
+ s"expected the $statement inner append to plan through the split
operators: $plans")
+ } else {
+ assert(
+ commits.isEmpty && writes.isEmpty,
+ s"expected the $statement write to stay inside Spark's staged
exec: $plans")
+ }
+ }
+
+ val ctasPlans = capturePlans {
+ spark.sql(s"CREATE TABLE $catalog.$ns.ctas_tgt USING iceberg AS SELECT
* FROM ctas_src")
+ }
+ assertSplitUsage(ctasPlans, "CTAS")
+ assert(countSnapshots("ctas_tgt") == 1L, "CTAS must land exactly one
snapshot")
+ assertRows("ctas_tgt", expectedIds = Seq(1, 2, 3, 4, 5))
+
+ val rtasPlans = capturePlans {
+ (1 to 2)
+ .map(i => (i, s"r$i", i.toDouble))
+ .toDF("id", "region", "amount")
+ .writeTo(s"$catalog.$ns.ctas_tgt")
+ .using("iceberg")
+ .createOrReplace()
+ }
+ assertSplitUsage(rtasPlans, "RTAS")
+ assertRows("ctas_tgt", expectedIds = Seq(1, 2))
+ }
+ }
+
+ private val catalog = "cat"
+ private val ns = "db"
+
+ private def withIcebergCatalog(f: File => Unit): Unit = withTempIcebergDir {
warehouseDir =>
+ withSQLConf(
+ s"spark.sql.catalog.$catalog" -> "org.apache.iceberg.spark.SparkCatalog",
+ s"spark.sql.catalog.$catalog.type" -> "hadoop",
+ s"spark.sql.catalog.$catalog.warehouse" -> warehouseDir.getAbsolutePath,
+ CometConf.COMET_ENABLED.key -> "true",
+ CometConf.COMET_EXEC_ENABLED.key -> "true") {
+ f(warehouseDir)
+ }
+ }
+
+ private def createTable(
+ warehouseDir: File,
+ tableName: String,
+ partitionSpec: String,
+ properties: Option[String] = None): Unit = {
+ val props = properties.map(s => s" TBLPROPERTIES ($s)").getOrElse("")
+ spark.sql(s"""
+ CREATE TABLE $catalog.$ns.$tableName (
+ id INT,
+ region STRING,
+ amount DOUBLE
+ ) USING iceberg
+ $partitionSpec
+ $props
+ """)
+ }
+
+ private def coalesceInsert(tableName: String, rows: Seq[(Int, String,
Double)]): Unit = {
+ val session = spark
+ import session.implicits._
+ rows
+ .toDF("id", "region", "amount")
+ .coalesce(1)
+ .writeTo(s"$catalog.$ns.$tableName")
+ .append()
+ }
+
+ private def capturePlans(action: => Unit): Seq[SparkPlan] = {
+ val captured = mutable.Buffer.empty[SparkPlan]
+ val listener = new QueryExecutionListener {
+ override def onSuccess(funcName: String, qe: QueryExecution, durationNs:
Long): Unit = {
+ captured += qe.executedPlan
+ }
+ override def onFailure(funcName: String, qe: QueryExecution, exception:
Exception): Unit =
+ ()
+ }
+ spark.listenerManager.register(listener)
+ try {
+ action
+ CometListenerBusUtils.waitUntilEmpty(spark.sparkContext)
+ } finally {
+ spark.listenerManager.unregister(listener)
+ }
+ captured.toSeq
+ }
+
+ private def captureWrite(tableName: String)(action: => Unit): WriteSnapshot
= {
+ val before = countSnapshots(tableName)
+ val plans = capturePlans(action)
+ WriteSnapshot(countSnapshots(tableName) - before, plans)
+ }
+
+ private def countSnapshots(tableName: String): Long =
+ try {
+ spark
+ .sql(s"SELECT count(*) FROM $catalog.$ns.$tableName.snapshots")
+ .collect()
+ .head
+ .getLong(0)
+ } catch {
+ case _: Throwable => 0L
+ }
+
+ private def collectIcebergWriteOps(
+ plans: Seq[SparkPlan]): (Seq[IcebergCommitExec], Seq[IcebergWriteExec])
= {
+ val commits = plans.flatMap { plan =>
+ collectWithSubqueries(plan) { case c: IcebergCommitExec => c }
+ }
+ val writes = plans.flatMap { plan =>
+ collectWithSubqueries(plan) { case w: IcebergWriteExec => w }
+ }
+ (commits, writes)
+ }
+
+ private def assertExactlyOneCommit(snapshot: WriteSnapshot): Unit = {
+ assert(
+ snapshot.snapshotDelta == 1L,
+ s"expected exactly 1 new Iceberg snapshot, got
${snapshot.snapshotDelta}. Plans:\n" +
+ snapshot.plans.mkString("\n--\n"))
+ val (commits, writes) = collectIcebergWriteOps(snapshot.plans)
+ assert(
+ commits.nonEmpty,
+ s"expected >= 1 IcebergCommitExec in captured plans, got
${commits.size}. Plans:\n" +
+ snapshot.plans.mkString("\n--\n"))
+ assert(
+ writes.nonEmpty,
+ s"expected >= 1 IcebergWriteExec in captured plans, got ${writes.size}.
Plans:\n" +
+ snapshot.plans.mkString("\n--\n"))
+ }
+
+ private def assertRows(tableName: String, expectedIds: Seq[Int]): Unit = {
+ val ids = spark
+ .sql(s"SELECT id FROM $catalog.$ns.$tableName ORDER BY id")
+ .collect()
+ .map(_.getInt(0))
+ .toSeq
+ assert(ids == expectedIds, s"expected $expectedIds, got $ids")
+ }
+
+ private def exceptionChain(t: Throwable): Seq[Throwable] = {
+ val chain = mutable.Buffer.empty[Throwable]
+ var current = t
+ while (current != null && !chain.contains(current)) {
+ chain += current
+ current = current.getCause
+ }
+ chain.toSeq
+ }
+
+}
+
+/**
+ * Blocks the DELETE's write job between its scan-snapshot pin and its commit
so the test can
+ * inject a conflicting commit. Top-level so the UDF closure doesn't capture
the suite.
+ */
+private object ConflictGate {
+ @volatile private var scanStarted = new CountDownLatch(1)
+ @volatile private var writeReleased = new CountDownLatch(1)
+
+ def reset(): Unit = {
+ scanStarted = new CountDownLatch(1)
+ writeReleased = new CountDownLatch(1)
+ }
+
+ def enter(): Unit = {
+ scanStarted.countDown()
+ if (!writeReleased.await(2, TimeUnit.MINUTES)) {
+ throw new IllegalStateException("ConflictGate was never released")
+ }
+ }
+
+ def awaitScanStarted(timeout: Long, unit: TimeUnit): Boolean =
scanStarted.await(timeout, unit)
+
+ def releaseWrite(): Unit = writeReleased.countDown()
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]