jordepic commented on code in PR #4658:
URL: https://github.com/apache/datafusion-comet/pull/4658#discussion_r3650435689


##########
spark/src/main/scala/org/apache/comet/iceberg/IcebergWriteLogical.scala:
##########
@@ -0,0 +1,38 @@
+/*
+ * 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
+import org.apache.spark.sql.catalyst.plans.logical.{LogicalPlan, UnaryNode}
+import org.apache.spark.sql.connector.write.BatchWrite
+
+/** 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 {
+
+  override def output: Seq[Attribute] = Nil

Review Comment:
   Agreed this was fragile. `IcebergWriteLogical` now owns the commit-message 
attribute (`output = Seq(AttributeReference(...))`) and the strategy passes 
`l.output` to `IcebergWriteExec`, so logical and physical outputs agree -- and 
as a bonus the exprId is now stable across AQE re-plans instead of being 
regenerated on every `output` call.
   



##########
spark/src/main/scala/org/apache/comet/iceberg/IcebergWriteStrategy.scala:
##########
@@ -0,0 +1,120 @@
+/*
+ * 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 IcebergWriteLogical(child, batchWrite, replaceDataDispatch) =>
+        Seq(IcebergWriteExec(batchWrite, 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.
+   *
+   * Returns None, falling back to Spark's combined write operator, when the 
`BatchWrite` requires
+   * Spark's commit coordinator, which the split writer's per-task commit 
protocol does not use.
+   */
+  private def buildTwoOp(
+      write: Write,
+      rel: DataSourceV2Relation,
+      query: LogicalPlan,
+      replaceDataDispatch: Option[ReplaceDataDispatchInfo]): Option[SparkPlan] 
= {
+    val batchWrite = write.toBatch
+    if (batchWrite.useCommitCoordinator()) {
+      return None

Review Comment:
   Reworded: the scaladoc now states plainly that Iceberg's `SparkWrite` never 
asks for the coordinator and the check is defensive coverage in case a future 
Iceberg version changes that. (Non-Iceberg V2 sinks never reach this point 
either -- they fall out earlier at `isIcebergSparkWrite` -- so defensive 
coverage for future Iceberg is the honest framing.)
   



##########
spark/src/main/scala/org/apache/comet/iceberg/IcebergWriteStrategy.scala:
##########
@@ -0,0 +1,120 @@
+/*
+ * 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 IcebergWriteLogical(child, batchWrite, replaceDataDispatch) =>
+        Seq(IcebergWriteExec(batchWrite, 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.
+   *
+   * Returns None, falling back to Spark's combined write operator, when the 
`BatchWrite` requires
+   * Spark's commit coordinator, which the split writer's per-task commit 
protocol does not use.
+   */
+  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.recacheByPlan(rel)

Review Comment:
   Done -- the strategy now passes its captured `session` into 
`IcebergRefreshCacheShim.recacheByPlan(session, rel)`; the shim no longer 
touches `SparkSession.active`.
   



##########
spark/src/main/scala/org/apache/comet/CometSparkSessionExtensions.scala:
##########
@@ -99,6 +100,7 @@ class CometSparkSessionExtensions
     extensions.injectQueryStagePrepRule { session => CometExecRule(session) }
     injectQueryStageOptimizerRuleShim(extensions, 
CometPlanAdaptiveDynamicPruningFilters)
     injectQueryStageOptimizerRuleShim(extensions, CometReuseSubquery)
+    extensions.injectPlannerStrategy { session => 
IcebergWriteStrategy(session) }

Review Comment:
   Added -- a test writes to an `InMemoryTableCatalog` table with the config on 
and asserts no `IcebergWriteExec`/`IcebergCommitExec` appears in any captured 
plan and the rows land.
   



##########
spark/src/main/scala/org/apache/comet/iceberg/IcebergWriteStrategy.scala:
##########
@@ -0,0 +1,120 @@
+/*
+ * 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 IcebergWriteLogical(child, batchWrite, replaceDataDispatch) =>
+        Seq(IcebergWriteExec(batchWrite, 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.
+   *
+   * Returns None, falling back to Spark's combined write operator, when the 
`BatchWrite` requires
+   * Spark's commit coordinator, which the split writer's per-task commit 
protocol does not use.
+   */
+  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.recacheByPlan(rel)
+    Some(
+      IcebergCommitExec(
+        batchWrite,
+        refresh,
+        // `replaceDataDispatch` may project the data into the format the 
writer expects.
+        planLater(IcebergWriteLogical(query, batchWrite, 
replaceDataDispatch))))

Review Comment:
   Added a partitioned INSERT ... SELECT under AQE with an 8-partition shuffle 
(Iceberg's clustered distribution forces the exchange, AQE coalesces it), 
asserting the shuffle exists, exactly one commit, and all 500 rows land. 
Skew-split I couldn't force deterministically at unit-test scale, but the 
coalesce case covers the "AQE changed the shuffle output" class of risk for the 
clustered writer.
   



##########
spark/src/test/scala/org/apache/comet/CometIcebergWriteActionSuite.scala:
##########
@@ -0,0 +1,437 @@
+/*
+ * 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 scala.collection.mutable
+
+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.execution.{QueryExecution, SparkPlan}
+import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
+import org.apache.spark.sql.util.QueryExecutionListener
+
+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("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("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))
+    }
+  }
+
+  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")
+    }
+  }
+
+  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 captureWrite(tableName: String)(action: => Unit): WriteSnapshot 
= {
+    val before = countSnapshots(tableName)
+    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
+      try CometListenerBusUtils.waitUntilEmpty(spark.sparkContext)
+      catch { case _: java.util.concurrent.TimeoutException => () }

Review Comment:
   Done -- the helper no longer swallows `TimeoutException`; a dropped listener 
event now fails the test instead of passing silently. Worth noting the 
commit-count half of the assertion (`snapshotDelta`) was already reading 
Iceberg's snapshot metadata directly, not the listener -- only plan capture 
depends on listener delivery.
   



##########
docs/source/user-guide/latest/iceberg-writes.md:
##########
@@ -0,0 +1,85 @@
+<!---
+  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 is supported on every Spark version Comet supports, 
with identical
+coverage on each:
+

Review Comment:
   Reworded -- the docs now say the supported operations are the same on every 
version but the row-level DML mechanism differs (4.0+ operation-coded rows with 
projections, 3.4/3.5 a plain row stream).
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]


Reply via email to