This is an automated email from the ASF dual-hosted git repository.

zhouyuan pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gluten.git


The following commit(s) were added to refs/heads/main by this push:
     new 2fe84a1a00 [VL][Delta] Add an opt-out for the native Delta DV DML 
row-index scan (#12215)
2fe84a1a00 is described below

commit 2fe84a1a00842c519e1c3f12571114b10850c291
Author: Mohammad Linjawi <[email protected]>
AuthorDate: Thu Sep 3 18:11:15 2026 +0300

    [VL][Delta] Add an opt-out for the native Delta DV DML row-index scan 
(#12215)
---
 .../gluten/component/VeloxDeltaComponent.scala     |   8 +-
 .../apache/gluten/config/VeloxDeltaConfig.scala    |  12 +
 .../delta/DeltaDeletionVectorHandoffSuite.scala    | 237 +++++++++++++++++++-
 .../delta/DeltaDeletionVectorHandoffSuite.scala    | 241 +++++++++++++++++++--
 docs/get-started/VeloxDelta.md                     |   8 +
 .../gluten/extension/DeltaPostTransformRules.scala |   9 +-
 .../apache/gluten/extension/DeltaScanUtils.scala   |  40 ++++
 .../apache/gluten/extension/OffloadDeltaScan.scala |  65 ++++--
 8 files changed, 572 insertions(+), 48 deletions(-)

diff --git 
a/backends-velox/src-delta/main/scala/org/apache/gluten/component/VeloxDeltaComponent.scala
 
b/backends-velox/src-delta/main/scala/org/apache/gluten/component/VeloxDeltaComponent.scala
index d9bdc4e392..d10ef08e41 100644
--- 
a/backends-velox/src-delta/main/scala/org/apache/gluten/component/VeloxDeltaComponent.scala
+++ 
b/backends-velox/src-delta/main/scala/org/apache/gluten/component/VeloxDeltaComponent.scala
@@ -52,7 +52,13 @@ class VeloxDeltaComponent extends Component {
     // offloads, and DeltaScanTransformer materializes the per-file DV 
payloads for Velox.
     legacy.injectTransform {
       c =>
-        val offload = Seq(OffloadDeltaScan(), OffloadDeltaProject(), 
OffloadDeltaFilter())
+        val offload = Seq(
+          OffloadDeltaScan(
+            enableNativeDmlRowIndexScan =
+              new VeloxDeltaConfig(c.sqlConf).enableNativeDmlRowIndexScan),
+          OffloadDeltaProject(),
+          OffloadDeltaFilter()
+        )
           .map(_.toStrcitRule())
         HeuristicTransform.Simple(
           Validators.newValidator(new GlutenConfig(c.sqlConf), offload),
diff --git 
a/backends-velox/src-delta/main/scala/org/apache/gluten/config/VeloxDeltaConfig.scala
 
b/backends-velox/src-delta/main/scala/org/apache/gluten/config/VeloxDeltaConfig.scala
index 566a17aab7..948eec1f7f 100644
--- 
a/backends-velox/src-delta/main/scala/org/apache/gluten/config/VeloxDeltaConfig.scala
+++ 
b/backends-velox/src-delta/main/scala/org/apache/gluten/config/VeloxDeltaConfig.scala
@@ -23,6 +23,8 @@ class VeloxDeltaConfig(conf: SQLConf) extends 
GlutenCoreConfig(conf) {
 
   def enableNativeWrite: Boolean = getConf(ENABLE_NATIVE_WRITE)
 
+  def enableNativeDmlRowIndexScan: Boolean = 
getConf(ENABLE_NATIVE_DML_ROW_INDEX_SCAN)
+
   def enableChangeDataFeedScan: Boolean = getConf(ENABLE_CHANGE_DATA_FEED_SCAN)
 }
 
@@ -43,6 +45,16 @@ object VeloxDeltaConfig extends ConfigRegistry {
       .booleanConf
       .createWithDefault(false)
 
+  val ENABLE_NATIVE_DML_ROW_INDEX_SCAN: ConfigEntry[Boolean] =
+    
buildConf("spark.gluten.sql.columnar.backend.velox.delta.enableNativeDmlRowIndexScan")
+      .experimental()
+      .doc(
+        "Enable the native Delta DELETE/UPDATE/MERGE target row-index scan for 
Velox. When " +
+          "disabled, the DML target scan that produces file paths and row 
indexes for " +
+          "deletion-vector writes stays on Spark; other scans are unaffected.")
+      .booleanConf
+      .createWithDefault(true)
+
   val ENABLE_CHANGE_DATA_FEED_SCAN: ConfigEntry[Boolean] =
     
buildConf("spark.gluten.sql.columnar.backend.velox.delta.enableChangeDataFeedScan")
       .experimental()
diff --git 
a/backends-velox/src-delta33/test/scala/org/apache/spark/sql/delta/DeltaDeletionVectorHandoffSuite.scala
 
b/backends-velox/src-delta33/test/scala/org/apache/spark/sql/delta/DeltaDeletionVectorHandoffSuite.scala
index 6b44941b45..12f27c1e12 100644
--- 
a/backends-velox/src-delta33/test/scala/org/apache/spark/sql/delta/DeltaDeletionVectorHandoffSuite.scala
+++ 
b/backends-velox/src-delta33/test/scala/org/apache/spark/sql/delta/DeltaDeletionVectorHandoffSuite.scala
@@ -16,37 +16,65 @@
  */
 package org.apache.spark.sql.delta
 
+import org.apache.gluten.config.VeloxDeltaConfig
 import org.apache.gluten.execution.DeltaScanTransformer
 
 import org.apache.spark.sql.QueryTest
+import org.apache.spark.sql.delta.sources.DeltaSQLConf
 import org.apache.spark.sql.delta.test.{DeltaSQLCommandTest, DeltaSQLTestUtils}
+import org.apache.spark.sql.execution.SparkPlan
+import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
+import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.test.SharedSparkSession
 import org.apache.spark.tags.ExtendedSQLTest
+import org.apache.spark.util.SparkVersionUtil
 
 import org.apache.hadoop.fs.Path
 
+import java.io.File
+
 @ExtendedSQLTest
 class DeltaDeletionVectorHandoffSuite
   extends QueryTest
   with SharedSparkSession
   with DeltaSQLTestUtils
-  with DeltaSQLCommandTest {
+  with DeltaSQLCommandTest
+  with AdaptiveSparkPlanHelper {
 
   import testImplicits._
 
+  private def containsNativeDeltaScan(plan: SparkPlan): Boolean = {
+    collectWithSubqueries(plan) { case scan: DeltaScanTransformer => scan 
}.nonEmpty
+  }
+
+  private def captureDeletePlans(path: String, predicate: String): 
Seq[SparkPlan] = {
+    DeltaTestUtils.withAllPlansCaptured(spark) {
+      spark.sql(s"DELETE FROM delta.`$path` WHERE $predicate").collect()
+    }.map(_.executedPlan)
+  }
+
+  private def activeDvCardinality(path: String): Long = {
+    val log = DeltaLog.forTable(spark, new Path(path))
+    log.update().allFiles.collect().flatMap(
+      file => Option(file.deletionVector).map(_.cardinality)).sum
+  }
+
+  private def writeDvTable(path: String, rows: Seq[(Int, String)]): Unit = {
+    rows
+      .toDF("id", "value")
+      .coalesce(1)
+      .write
+      .format("delta")
+      .save(path)
+    spark.sql(
+      s"ALTER TABLE delta.`$path` SET TBLPROPERTIES 
('delta.enableDeletionVectors' = true)")
+  }
+
   test("Spark 3.5 Delta DV scan handoff should filter deleted rows") {
     withTempDir {
       tempDir =>
         val path = tempDir.getCanonicalPath
-        Seq((1, "a"), (2, "b"), (3, "c"), (4, "d"))
-          .toDF("id", "value")
-          .coalesce(1)
-          .write
-          .format("delta")
-          .save(path)
-
-        spark.sql(
-          s"ALTER TABLE delta.`$path` SET TBLPROPERTIES 
('delta.enableDeletionVectors' = true)")
+        writeDvTable(path, Seq((1, "a"), (2, "b"), (3, "c"), (4, "d")))
         spark.sql(s"DELETE FROM delta.`$path` WHERE id IN (3, 4)")
 
         val log = DeltaLog.forTable(spark, new Path(path))
@@ -58,7 +86,8 @@ class DeltaDeletionVectorHandoffSuite
 
         val df = spark.read.format("delta").load(path)
         val executedPlan = df.queryExecution.executedPlan
-        val nativeScans = executedPlan.collect { case scan: 
DeltaScanTransformer => scan }
+        val nativeScans =
+          collectWithSubqueries(executedPlan) { case scan: 
DeltaScanTransformer => scan }
         assert(nativeScans.nonEmpty)
         val planText = executedPlan.toString()
         assert(!planText.contains("__delta_internal_is_row_deleted"))
@@ -72,4 +101,190 @@ class DeltaDeletionVectorHandoffSuite
         assert(metrics("dvPayloadReadTime").value > 0L)
     }
   }
+
+  test("Delta metadata row-index predicate should not be stripped from a 
native scan") {
+    assume(SparkVersionUtil.gteSpark35, "metadata row index is available in 
Spark 3.5+")
+    withTempDir {
+      tempDir =>
+        val path = tempDir.getCanonicalPath
+        writeDvTable(path, Seq((1, "a"), (2, "b"), (3, "c"), (4, "d")))
+
+        val df = spark.sql(
+          s"SELECT id, _metadata.row_index AS row_index FROM delta.`$path` " +
+            "WHERE _metadata.row_index = 2")
+        val rows = df.collect()
+        val executedPlan = df.queryExecution.executedPlan
+        val planText = executedPlan.treeString
+        assert(containsNativeDeltaScan(executedPlan), planText)
+        assert(rows.length === 1, planText)
+        assert(rows.head.getLong(1) === 2L, planText)
+    }
+  }
+
+  Seq(true, false).foreach {
+    useMetadataRowIndex =>
+      test(
+        "Delta DV DELETE should write correct deletion vectors, " +
+          s"metadata row index=$useMetadataRowIndex") {
+        assume(SparkVersionUtil.gteSpark35, "DV DML coverage targets Spark 
3.5+")
+        withTempDir {
+          tempDir =>
+            val path = tempDir.getCanonicalPath
+            writeDvTable(path, Seq((1, "a"), (2, "b"), (3, "c"), (4, "d")))
+
+            withSQLConf(
+              DeltaSQLConf.DELETION_VECTORS_USE_METADATA_ROW_INDEX.key ->
+                useMetadataRowIndex.toString) {
+              val executedPlans = captureDeletePlans(path, "id IN (3, 4)")
+              val planText = executedPlans.map(_.treeString).mkString("\n\n")
+              // With the metadata row index, the DML target scan offloads 
like any other DV
+              // scan; without it, Delta relies on Spark's injected row-index 
filter column and
+              // the scan stays on Spark.
+              assert(
+                executedPlans.exists(containsNativeDeltaScan) === 
useMetadataRowIndex,
+                planText)
+
+              assert(activeDvCardinality(path) === 2L)
+              checkAnswer(spark.read.format("delta").load(path), Seq((1, "a"), 
(2, "b")).toDF())
+            }
+        }
+      }
+  }
+
+  test("Delta DV repeated DELETE over an existing DV should accumulate deleted 
rows") {
+    assume(SparkVersionUtil.gteSpark35, "DV DML coverage targets Spark 3.5+")
+    withTempDir {
+      tempDir =>
+        val path = new File(tempDir, "delta table with 
spaces").getCanonicalPath
+        writeDvTable(path, Seq((1, "a"), (2, "b"), (3, "c"), (4, "d"), (5, 
"e"), (6, "f")))
+
+        withSQLConf(DeltaSQLConf.DELETION_VECTORS_USE_METADATA_ROW_INDEX.key 
-> "true") {
+          // Delete the LEADING rows first: after DV {0, 1} masks them, every 
surviving row's
+          // absolute row index differs from its post-mask position. A scan 
that renumbered row
+          // indexes after applying the existing DV would emit {0, 1} for the 
second DELETE
+          // instead of {2, 3}, failing both the cardinality and the result 
checks below.
+          val firstDeletePlans = captureDeletePlans(path, "id IN (1, 2)")
+          assert(
+            firstDeletePlans.exists(containsNativeDeltaScan),
+            firstDeletePlans.map(_.treeString).mkString("\n\n"))
+          assert(activeDvCardinality(path) === 2L)
+
+          // The second DELETE scans files that already carry a DV and must 
merge into it.
+          val secondDeletePlans = captureDeletePlans(path, "id IN (3, 4)")
+          assert(
+            secondDeletePlans.exists(containsNativeDeltaScan),
+            secondDeletePlans.map(_.treeString).mkString("\n\n"))
+          assert(activeDvCardinality(path) === 4L)
+
+          checkAnswer(spark.read.format("delta").load(path), Seq((5, "e"), (6, 
"f")).toDF())
+        }
+    }
+  }
+
+  // MERGE puts joins between the target scan and Delta's BitmapAggregator. 
With a shuffle join
+  // the scan lands in its own AQE query stage, so this covers target-scan 
offload both with and
+  // without the rest of the DML plan visible in the same stage.
+  Seq(true, false).foreach {
+    broadcastJoin =>
+      test(s"Delta DV MERGE should write correct deletion vectors, broadcast 
join=$broadcastJoin") {
+        assume(SparkVersionUtil.gteSpark35, "DV DML coverage targets Spark 
3.5+")
+        withTempDir {
+          tempDir =>
+            val path = tempDir.getCanonicalPath
+            writeDvTable(path, Seq((1, "a"), (2, "b"), (3, "c"), (4, "d")))
+
+            withTempView("merge_source") {
+              Seq((3, "c2"), (4, "d2")).toDF("id", 
"value").createOrReplaceTempView("merge_source")
+
+              withSQLConf(
+                SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key ->
+                  (if (broadcastJoin) "10485760" else "-1"),
+                DeltaSQLConf.DELETION_VECTORS_USE_METADATA_ROW_INDEX.key -> 
"true"
+              ) {
+                val executedPlans = DeltaTestUtils.withAllPlansCaptured(spark) 
{
+                  spark
+                    .sql(s"""MERGE INTO delta.`$path` AS t
+                            |USING merge_source AS s
+                            |ON t.id = s.id
+                            |WHEN MATCHED THEN DELETE""".stripMargin)
+                    .collect()
+                }.map(_.executedPlan)
+                assert(
+                  executedPlans.exists(containsNativeDeltaScan),
+                  executedPlans.map(_.treeString).mkString("\n\n"))
+              }
+
+              val log = DeltaLog.forTable(spark, new Path(path))
+              assert(log.update().allFiles.collect().exists(_.deletionVector 
!= null))
+              checkAnswer(
+                spark.read.format("delta").load(path),
+                Seq((1, "a"), (2, "b")).toDF())
+            }
+        }
+      }
+  }
+
+  test("Delta DV DML row-index scan should stay on Spark when disabled") {
+    assume(SparkVersionUtil.gteSpark35, "DV DML coverage targets Spark 3.5+")
+    withTempDir {
+      tempDir =>
+        val path = tempDir.getCanonicalPath
+        writeDvTable(path, Seq((1, "a"), (2, "b"), (3, "c"), (4, "d")))
+
+        withSQLConf(
+          DeltaSQLConf.DELETION_VECTORS_USE_METADATA_ROW_INDEX.key -> "true",
+          VeloxDeltaConfig.ENABLE_NATIVE_DML_ROW_INDEX_SCAN.key -> "false") {
+          val executedPlans = captureDeletePlans(path, "id IN (3, 4)")
+          assert(
+            !executedPlans.exists(containsNativeDeltaScan),
+            executedPlans.map(_.treeString).mkString("\n\n"))
+          assert(activeDvCardinality(path) === 2L)
+
+          // The fallback is scoped to the DML target scan: a plain read of 
the same table keeps
+          // offloading even while the config is off.
+          val df = spark.read.format("delta").load(path)
+          assert(containsNativeDeltaScan(df.queryExecution.executedPlan))
+          checkAnswer(df, Seq((1, "a"), (2, "b")).toDF())
+        }
+
+        // The config is read per query, so re-enabling in the same session 
restores DML offload.
+        withSQLConf(DeltaSQLConf.DELETION_VECTORS_USE_METADATA_ROW_INDEX.key 
-> "true") {
+          val executedPlans = captureDeletePlans(path, "id = 2")
+          assert(
+            executedPlans.exists(containsNativeDeltaScan),
+            executedPlans.map(_.treeString).mkString("\n\n"))
+          assert(activeDvCardinality(path) === 3L)
+          checkAnswer(spark.read.format("delta").load(path), Seq((1, 
"a")).toDF())
+        }
+    }
+  }
+
+  test("Delta non-DV DML should offload with a user column named row_index 
when disabled") {
+    assume(SparkVersionUtil.gteSpark35, "DV DML coverage targets Spark 3.5+")
+    withTempDir {
+      tempDir =>
+        val path = tempDir.getCanonicalPath
+        // Deletion vectors are deliberately left off: this DELETE rewrites 
whole files and never
+        // reads a generated row index. The scoped fallback must not claim it 
merely because the
+        // table has a user column called row_index -- that name is only 
Delta's row index when it
+        // appears inside the file metadata struct.
+        Seq((1, "a", 10L), (2, "b", 20L), (3, "c", 30L))
+          .toDF("id", "value", "row_index")
+          .coalesce(1)
+          .write
+          .format("delta")
+          .save(path)
+
+        withSQLConf(VeloxDeltaConfig.ENABLE_NATIVE_DML_ROW_INDEX_SCAN.key -> 
"false") {
+          val executedPlans = captureDeletePlans(path, "id = 3")
+          assert(
+            executedPlans.exists(containsNativeDeltaScan),
+            executedPlans.map(_.treeString).mkString("\n\n"))
+        }
+
+        checkAnswer(
+          spark.read.format("delta").load(path),
+          Seq((1, "a", 10L), (2, "b", 20L)).toDF())
+    }
+  }
 }
diff --git 
a/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/DeltaDeletionVectorHandoffSuite.scala
 
b/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/DeltaDeletionVectorHandoffSuite.scala
index 159e848df0..c9355e7c40 100644
--- 
a/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/DeltaDeletionVectorHandoffSuite.scala
+++ 
b/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/DeltaDeletionVectorHandoffSuite.scala
@@ -16,38 +16,64 @@
  */
 package org.apache.spark.sql.delta
 
+import org.apache.gluten.config.VeloxDeltaConfig
 import org.apache.gluten.execution.DeltaScanTransformer
 
 import org.apache.spark.sql.QueryTest
 import org.apache.spark.sql.delta.sources.DeltaSQLConf
 import org.apache.spark.sql.delta.test.{DeltaSQLCommandTest, DeltaSQLTestUtils}
+import org.apache.spark.sql.execution.SparkPlan
+import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
+import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.test.SharedSparkSession
 import org.apache.spark.tags.ExtendedSQLTest
 
 import org.apache.hadoop.fs.Path
 
+import java.io.File
+
 @ExtendedSQLTest
 class DeltaDeletionVectorHandoffSuite
   extends QueryTest
   with SharedSparkSession
   with DeltaSQLTestUtils
-  with DeltaSQLCommandTest {
+  with DeltaSQLCommandTest
+  with AdaptiveSparkPlanHelper {
 
   import testImplicits._
 
+  private def containsNativeDeltaScan(plan: SparkPlan): Boolean = {
+    collectWithSubqueries(plan) { case scan: DeltaScanTransformer => scan 
}.nonEmpty
+  }
+
+  private def captureDeletePlans(path: String, predicate: String): 
Seq[SparkPlan] = {
+    DeltaTestUtils.withAllPlansCaptured(spark) {
+      spark.sql(s"DELETE FROM delta.`$path` WHERE $predicate").collect()
+    }.map(_.executedPlan)
+  }
+
+  private def activeDvCardinality(path: String): Long = {
+    val log = DeltaLog.forTable(spark, new Path(path))
+    log.update().allFiles.collect().flatMap(
+      file => Option(file.deletionVector).map(_.cardinality)).sum
+  }
+
+  private def writeDvTable(path: String, rows: Seq[(Int, String)]): Unit = {
+    rows
+      .toDF("id", "value")
+      .coalesce(1)
+      .write
+      .format("delta")
+      .save(path)
+    spark.sql(
+      s"ALTER TABLE delta.`$path` SET TBLPROPERTIES 
('delta.enableDeletionVectors' = true)")
+  }
+
   test("Spark 4 Delta DV scan should fall back when metadata row index is 
disabled") {
     withTempDir {
       tempDir =>
         val path = tempDir.getCanonicalPath
-        Seq((1, "a"), (2, "b"), (3, "c"), (4, "d"))
-          .toDF("id", "value")
-          .coalesce(1)
-          .write
-          .format("delta")
-          .save(path)
-
-        spark.sql(
-          s"ALTER TABLE delta.`$path` SET TBLPROPERTIES 
('delta.enableDeletionVectors' = true)")
+        writeDvTable(path, Seq((1, "a"), (2, "b"), (3, "c"), (4, "d")))
         spark.sql(s"DELETE FROM delta.`$path` WHERE id IN (3, 4)")
 
         val log = DeltaLog.forTable(spark, new Path(path))
@@ -58,7 +84,7 @@ class DeltaDeletionVectorHandoffSuite
         withSQLConf(DeltaSQLConf.DELETION_VECTORS_USE_METADATA_ROW_INDEX.key 
-> "false") {
           val df = spark.read.format("delta").load(path)
           val executedPlan = df.queryExecution.executedPlan
-          assert(executedPlan.collect { case _: DeltaScanTransformer => true 
}.isEmpty)
+          assert(!containsNativeDeltaScan(executedPlan))
           checkAnswer(df, Seq((1, "a"), (2, "b")).toDF())
         }
     }
@@ -68,15 +94,7 @@ class DeltaDeletionVectorHandoffSuite
     withTempDir {
       tempDir =>
         val path = tempDir.getCanonicalPath
-        Seq((1, "a"), (2, "b"), (3, "c"), (4, "d"))
-          .toDF("id", "value")
-          .coalesce(1)
-          .write
-          .format("delta")
-          .save(path)
-
-        spark.sql(
-          s"ALTER TABLE delta.`$path` SET TBLPROPERTIES 
('delta.enableDeletionVectors' = true)")
+        writeDvTable(path, Seq((1, "a"), (2, "b"), (3, "c"), (4, "d")))
         spark.sql(s"DELETE FROM delta.`$path` WHERE id IN (3, 4)")
 
         val log = DeltaLog.forTable(spark, new Path(path))
@@ -88,7 +106,8 @@ class DeltaDeletionVectorHandoffSuite
 
         val df = spark.read.format("delta").load(path)
         val executedPlan = df.queryExecution.executedPlan
-        val nativeScans = executedPlan.collect { case scan: 
DeltaScanTransformer => scan }
+        val nativeScans =
+          collectWithSubqueries(executedPlan) { case scan: 
DeltaScanTransformer => scan }
         assert(nativeScans.nonEmpty)
         val planText = executedPlan.toString()
         assert(!planText.contains("__delta_internal_is_row_deleted"))
@@ -102,4 +121,184 @@ class DeltaDeletionVectorHandoffSuite
         assert(metrics("dvPayloadReadTime").value > 0L)
     }
   }
+
+  test("Delta metadata row-index predicate should not be stripped from a 
native scan") {
+    withTempDir {
+      tempDir =>
+        val path = tempDir.getCanonicalPath
+        writeDvTable(path, Seq((1, "a"), (2, "b"), (3, "c"), (4, "d")))
+
+        val df = spark.sql(
+          s"SELECT id, _metadata.row_index AS row_index FROM delta.`$path` " +
+            "WHERE _metadata.row_index = 2")
+        val rows = df.collect()
+        val executedPlan = df.queryExecution.executedPlan
+        val planText = executedPlan.treeString
+        assert(containsNativeDeltaScan(executedPlan), planText)
+        assert(rows.length === 1, planText)
+        assert(rows.head.getLong(1) === 2L, planText)
+    }
+  }
+
+  Seq(true, false).foreach {
+    useMetadataRowIndex =>
+      test(
+        "Delta DV DELETE should write correct deletion vectors, " +
+          s"metadata row index=$useMetadataRowIndex") {
+        withTempDir {
+          tempDir =>
+            val path = tempDir.getCanonicalPath
+            writeDvTable(path, Seq((1, "a"), (2, "b"), (3, "c"), (4, "d")))
+
+            withSQLConf(
+              DeltaSQLConf.DELETION_VECTORS_USE_METADATA_ROW_INDEX.key ->
+                useMetadataRowIndex.toString) {
+              val executedPlans = captureDeletePlans(path, "id IN (3, 4)")
+              val planText = executedPlans.map(_.treeString).mkString("\n\n")
+              // With the metadata row index, the DML target scan offloads 
like any other DV
+              // scan; without it, Delta relies on Spark's injected row-index 
filter column and
+              // the scan stays on Spark.
+              assert(
+                executedPlans.exists(containsNativeDeltaScan) === 
useMetadataRowIndex,
+                planText)
+
+              assert(activeDvCardinality(path) === 2L)
+              checkAnswer(spark.read.format("delta").load(path), Seq((1, "a"), 
(2, "b")).toDF())
+            }
+        }
+      }
+  }
+
+  test("Delta DV repeated DELETE over an existing DV should accumulate deleted 
rows") {
+    withTempDir {
+      tempDir =>
+        val path = new File(tempDir, "delta table with 
spaces").getCanonicalPath
+        writeDvTable(path, Seq((1, "a"), (2, "b"), (3, "c"), (4, "d"), (5, 
"e"), (6, "f")))
+
+        withSQLConf(DeltaSQLConf.DELETION_VECTORS_USE_METADATA_ROW_INDEX.key 
-> "true") {
+          // Delete the LEADING rows first: after DV {0, 1} masks them, every 
surviving row's
+          // absolute row index differs from its post-mask position. A scan 
that renumbered row
+          // indexes after applying the existing DV would emit {0, 1} for the 
second DELETE
+          // instead of {2, 3}, failing both the cardinality and the result 
checks below.
+          val firstDeletePlans = captureDeletePlans(path, "id IN (1, 2)")
+          assert(
+            firstDeletePlans.exists(containsNativeDeltaScan),
+            firstDeletePlans.map(_.treeString).mkString("\n\n"))
+          assert(activeDvCardinality(path) === 2L)
+
+          // The second DELETE scans files that already carry a DV and must 
merge into it.
+          val secondDeletePlans = captureDeletePlans(path, "id IN (3, 4)")
+          assert(
+            secondDeletePlans.exists(containsNativeDeltaScan),
+            secondDeletePlans.map(_.treeString).mkString("\n\n"))
+          assert(activeDvCardinality(path) === 4L)
+
+          checkAnswer(spark.read.format("delta").load(path), Seq((5, "e"), (6, 
"f")).toDF())
+        }
+    }
+  }
+
+  // MERGE puts joins between the target scan and Delta's BitmapAggregator. 
With a shuffle join
+  // the scan lands in its own AQE query stage, so this covers target-scan 
offload both with and
+  // without the rest of the DML plan visible in the same stage.
+  Seq(true, false).foreach {
+    broadcastJoin =>
+      test(s"Delta DV MERGE should write correct deletion vectors, broadcast 
join=$broadcastJoin") {
+        withTempDir {
+          tempDir =>
+            val path = tempDir.getCanonicalPath
+            writeDvTable(path, Seq((1, "a"), (2, "b"), (3, "c"), (4, "d")))
+
+            withTempView("merge_source") {
+              Seq((3, "c2"), (4, "d2")).toDF("id", 
"value").createOrReplaceTempView("merge_source")
+
+              withSQLConf(
+                SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key ->
+                  (if (broadcastJoin) "10485760" else "-1"),
+                DeltaSQLConf.DELETION_VECTORS_USE_METADATA_ROW_INDEX.key -> 
"true"
+              ) {
+                val executedPlans = DeltaTestUtils.withAllPlansCaptured(spark) 
{
+                  spark
+                    .sql(s"""MERGE INTO delta.`$path` AS t
+                            |USING merge_source AS s
+                            |ON t.id = s.id
+                            |WHEN MATCHED THEN DELETE""".stripMargin)
+                    .collect()
+                }.map(_.executedPlan)
+                assert(
+                  executedPlans.exists(containsNativeDeltaScan),
+                  executedPlans.map(_.treeString).mkString("\n\n"))
+              }
+
+              val log = DeltaLog.forTable(spark, new Path(path))
+              assert(log.update().allFiles.collect().exists(_.deletionVector 
!= null))
+              checkAnswer(
+                spark.read.format("delta").load(path),
+                Seq((1, "a"), (2, "b")).toDF())
+            }
+        }
+      }
+  }
+
+  test("Delta DV DML row-index scan should stay on Spark when disabled") {
+    withTempDir {
+      tempDir =>
+        val path = tempDir.getCanonicalPath
+        writeDvTable(path, Seq((1, "a"), (2, "b"), (3, "c"), (4, "d")))
+
+        withSQLConf(
+          DeltaSQLConf.DELETION_VECTORS_USE_METADATA_ROW_INDEX.key -> "true",
+          VeloxDeltaConfig.ENABLE_NATIVE_DML_ROW_INDEX_SCAN.key -> "false") {
+          val executedPlans = captureDeletePlans(path, "id IN (3, 4)")
+          assert(
+            !executedPlans.exists(containsNativeDeltaScan),
+            executedPlans.map(_.treeString).mkString("\n\n"))
+          assert(activeDvCardinality(path) === 2L)
+
+          // The fallback is scoped to the DML target scan: a plain read of 
the same table keeps
+          // offloading even while the config is off.
+          val df = spark.read.format("delta").load(path)
+          assert(containsNativeDeltaScan(df.queryExecution.executedPlan))
+          checkAnswer(df, Seq((1, "a"), (2, "b")).toDF())
+        }
+
+        // The config is read per query, so re-enabling in the same session 
restores DML offload.
+        withSQLConf(DeltaSQLConf.DELETION_VECTORS_USE_METADATA_ROW_INDEX.key 
-> "true") {
+          val executedPlans = captureDeletePlans(path, "id = 2")
+          assert(
+            executedPlans.exists(containsNativeDeltaScan),
+            executedPlans.map(_.treeString).mkString("\n\n"))
+          assert(activeDvCardinality(path) === 3L)
+          checkAnswer(spark.read.format("delta").load(path), Seq((1, 
"a")).toDF())
+        }
+    }
+  }
+
+  test("Delta non-DV DML should offload with a user column named row_index 
when disabled") {
+    withTempDir {
+      tempDir =>
+        val path = tempDir.getCanonicalPath
+        // Deletion vectors are deliberately left off: this DELETE rewrites 
whole files and never
+        // reads a generated row index. The scoped fallback must not claim it 
merely because the
+        // table has a user column called row_index -- that name is only 
Delta's row index when it
+        // appears inside the file metadata struct.
+        Seq((1, "a", 10L), (2, "b", 20L), (3, "c", 30L))
+          .toDF("id", "value", "row_index")
+          .coalesce(1)
+          .write
+          .format("delta")
+          .save(path)
+
+        withSQLConf(VeloxDeltaConfig.ENABLE_NATIVE_DML_ROW_INDEX_SCAN.key -> 
"false") {
+          val executedPlans = captureDeletePlans(path, "id = 3")
+          assert(
+            executedPlans.exists(containsNativeDeltaScan),
+            executedPlans.map(_.treeString).mkString("\n\n"))
+        }
+
+        checkAnswer(
+          spark.read.format("delta").load(path),
+          Seq((1, "a", 10L), (2, "b", 20L)).toDF())
+    }
+  }
 }
diff --git a/docs/get-started/VeloxDelta.md b/docs/get-started/VeloxDelta.md
index 3c594b9b97..6a73a27403 100644
--- a/docs/get-started/VeloxDelta.md
+++ b/docs/get-started/VeloxDelta.md
@@ -34,6 +34,14 @@ Native change data feed scan offload is controlled by:
   - Default: `true`
   - Type: experimental
 
+The native DELETE/UPDATE/MERGE deletion-vector target scan is controlled by:
+
+- `spark.gluten.sql.columnar.backend.velox.delta.enableNativeDmlRowIndexScan`
+  - Default: `true`
+  - Type: experimental
+  - When disabled, the DML target scan that produces file paths and row 
indexes for
+    deletion-vector writes stays on Spark; other scans are unaffected.
+
 | Feature | Delta minWriterVersion | Delta minReaderVersion | Iceberg 
format-version | Feature type | Supported by Gluten (Velox) |
 |---|---:|---:|---:|---|---|
 | Basic functionality | 2 | 1 | 1 | Writer | Yes |
diff --git 
a/gluten-delta/src/main/scala/org/apache/gluten/extension/DeltaPostTransformRules.scala
 
b/gluten-delta/src/main/scala/org/apache/gluten/extension/DeltaPostTransformRules.scala
index fb694c70d9..a6cfc42916 100644
--- 
a/gluten-delta/src/main/scala/org/apache/gluten/extension/DeltaPostTransformRules.scala
+++ 
b/gluten-delta/src/main/scala/org/apache/gluten/extension/DeltaPostTransformRules.scala
@@ -56,6 +56,13 @@ object DeltaPostTransformRules {
 
   private val deletionVectorDeletedRowColumnName = 
"__delta_internal_is_row_deleted"
   private val deletionVectorRowIndexColumnName = "__delta_internal_row_index"
+  // Spark 3.5+ exposes this as 
ParquetFileFormat.ROW_INDEX_TEMPORARY_COLUMN_NAME.
+  private val parquetTemporaryRowIndexColumnName = "_tmp_metadata_row_index"
+  private val deletionVectorRowIndexColumnNames =
+    Set(
+      deletionVectorRowIndexColumnName,
+      DeltaParquetFileFormat.ROW_INDEX_COLUMN_NAME,
+      parquetTemporaryRowIndexColumnName)
   private val deletionVectorInternalColumnNames =
     Set(deletionVectorDeletedRowColumnName, deletionVectorRowIndexColumnName)
 
@@ -213,7 +220,7 @@ object DeltaPostTransformRules {
   }
 
   private def referencesDeletionVectorRowIndex(expr: Expression): Boolean = {
-    expr.references.exists(_.name == deletionVectorRowIndexColumnName)
+    expr.references.exists(attr => 
deletionVectorRowIndexColumnNames.contains(attr.name))
   }
 
   private def tagRowIndexRequiredSubtrees(plan: SparkPlan): Unit = {
diff --git 
a/gluten-delta/src/main/scala/org/apache/gluten/extension/DeltaScanUtils.scala 
b/gluten-delta/src/main/scala/org/apache/gluten/extension/DeltaScanUtils.scala
new file mode 100644
index 0000000000..d5e02ba3de
--- /dev/null
+++ 
b/gluten-delta/src/main/scala/org/apache/gluten/extension/DeltaScanUtils.scala
@@ -0,0 +1,40 @@
+/*
+ * 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.gluten.extension
+
+import org.apache.spark.sql.delta.DeltaParquetFileFormat
+import org.apache.spark.sql.delta.files.TahoeFileIndex
+import org.apache.spark.sql.delta.stats.PreparedDeltaFileIndex
+import org.apache.spark.sql.execution.FileSourceScanExec
+
+/** Structural checks shared by the Delta offload rules for recognizing Delta 
table scans. */
+object DeltaScanUtils {
+  def isDeltaScan(scan: FileSourceScanExec): Boolean = {
+    isDeltaFileIndex(scan) || isDeltaParquetScan(scan)
+  }
+
+  private def isDeltaParquetScan(scan: FileSourceScanExec): Boolean = {
+    val fileFormatClass = scan.relation.fileFormat.getClass
+    fileFormatClass == classOf[DeltaParquetFileFormat] ||
+    fileFormatClass.getSimpleName == "GlutenDeltaParquetFileFormat"
+  }
+
+  private def isDeltaFileIndex(scan: FileSourceScanExec): Boolean = {
+    scan.relation.location.isInstanceOf[TahoeFileIndex] ||
+    scan.relation.location.isInstanceOf[PreparedDeltaFileIndex]
+  }
+}
diff --git 
a/gluten-delta/src/main/scala/org/apache/gluten/extension/OffloadDeltaScan.scala
 
b/gluten-delta/src/main/scala/org/apache/gluten/extension/OffloadDeltaScan.scala
index 4664da8769..fd8ccf7842 100644
--- 
a/gluten-delta/src/main/scala/org/apache/gluten/extension/OffloadDeltaScan.scala
+++ 
b/gluten-delta/src/main/scala/org/apache/gluten/extension/OffloadDeltaScan.scala
@@ -20,22 +20,38 @@ import org.apache.gluten.execution.DeltaScanTransformer
 import org.apache.gluten.extension.columnar.FallbackTags
 import org.apache.gluten.extension.columnar.offload.OffloadSingleNode
 
-import org.apache.spark.sql.delta.DeltaParquetFileFormat
-import org.apache.spark.sql.delta.SnapshotDescriptor
+import org.apache.spark.sql.delta.{DeltaParquetFileFormat, SnapshotDescriptor}
 import 
org.apache.spark.sql.delta.commands.DeletionVectorUtils.deletionVectorsReadable
-import org.apache.spark.sql.delta.files.{CdcAddFileIndex, TahoeFileIndex, 
TahoeRemoveFileIndex}
+import org.apache.spark.sql.delta.files.{CdcAddFileIndex, TahoeBatchFileIndex, 
TahoeFileIndex, TahoeRemoveFileIndex}
 import org.apache.spark.sql.delta.stats.PreparedDeltaFileIndex
 import org.apache.spark.sql.execution.{FileSourceScanExec, SparkPlan}
+import org.apache.spark.sql.execution.datasources.FileFormat
+import org.apache.spark.sql.types.{DataType, StructType}
 import org.apache.spark.util.SparkVersionUtil
 
-case class OffloadDeltaScan() extends OffloadSingleNode {
+case class OffloadDeltaScan(enableNativeDmlRowIndexScan: Boolean) extends 
OffloadSingleNode {
   private val DeletionVectorsUseMetadataRowIndexKey =
     "spark.databricks.delta.deletionVectors.useMetadataRowIndex"
 
+  // Spark 3.5+ exposes this as 
ParquetFileFormat.ROW_INDEX_TEMPORARY_COLUMN_NAME.
+  private val parquetTemporaryRowIndexColumnName = "_tmp_metadata_row_index"
+  // Row-index columns Delta generates as top-level scan outputs.
+  private val generatedRowIndexColumnNames =
+    Set(DeltaParquetFileFormat.ROW_INDEX_COLUMN_NAME, 
parquetTemporaryRowIndexColumnName)
+  // ParquetFileFormat.ROW_INDEX, the generated field Delta adds to the file 
metadata struct in
+  // PreprocessTableWithDVs when deletionVectors.useMetadataRowIndex is on. 
Only meaningful nested
+  // under _metadata -- a user column may legitimately be called row_index.
+  private val metadataRowIndexFieldName = "row_index"
+  // TahoeBatchFileIndex.actionType as set by Delta's DELETE, UPDATE and MERGE 
commands.
+  private val dmlActionTypes = Set("delete", "update", "merge")
+
   override def offload(plan: SparkPlan): SparkPlan = plan match {
     case scan: FileSourceScanExec if isDeltaLogScan(scan) =>
       FallbackTags.add(scan, "fallback Delta _delta_log scan")
       scan
+    case scan: FileSourceScanExec if shouldFallbackDeletionVectorDmlScan(scan) 
=>
+      FallbackTags.add(scan, "fallback Delta DV DML row-index scan by 
configuration")
+      scan
     case scan: FileSourceScanExec if 
shouldFallbackSpark34DeletionVectorScan(scan) =>
       FallbackTags.add(scan, "fallback Spark 3.4 Delta DV scan")
       scan
@@ -43,24 +59,45 @@ case class OffloadDeltaScan() extends OffloadSingleNode {
         if shouldFallbackDeletionVectorScanWithoutMetadataRowIndex(scan) =>
       FallbackTags.add(scan, "fallback Delta DV scan without metadata row 
index")
       scan
-    case scan: FileSourceScanExec if isDeltaScan(scan) =>
+    case scan: FileSourceScanExec if DeltaScanUtils.isDeltaScan(scan) =>
       DeltaScanTransformer(scan)
     case other => other
   }
 
-  private def isDeltaScan(scan: FileSourceScanExec): Boolean = {
-    isDeltaFileIndex(scan) || isDeltaParquetScan(scan)
+  /**
+   * The scoped escape hatch: with the config off, the DELETE/UPDATE/MERGE 
target scan that produces
+   * file paths and row indexes for deletion-vector writes stays on Spark, 
while every other scan
+   * keeps offloading. The whole check lives here, on the scan alone: Delta 
builds every DML target
+   * relation over a [[TahoeBatchFileIndex]] carrying the command name, which 
survives AQE stage
+   * splits and arbitrary join placement, and only DV-writing DML reads a 
row-index column from that
+   * relation; DML that rewrites whole files does not, and remains eligible 
for native execution.
+   */
+  private def shouldFallbackDeletionVectorDmlScan(scan: FileSourceScanExec): 
Boolean = {
+    !enableNativeDmlRowIndexScan && isDmlTargetScan(scan) && 
scanReadsRowIndexColumn(scan)
   }
 
-  private def isDeltaParquetScan(scan: FileSourceScanExec): Boolean = {
-    val fileFormatClass = scan.relation.fileFormat.getClass
-    fileFormatClass == classOf[DeltaParquetFileFormat] ||
-    fileFormatClass.getSimpleName == "GlutenDeltaParquetFileFormat"
+  private def isDmlTargetScan(scan: FileSourceScanExec): Boolean = {
+    scan.relation.location match {
+      case index: TahoeBatchFileIndex => 
dmlActionTypes.contains(index.actionType)
+      case _ => false
+    }
   }
 
-  private def isDeltaFileIndex(scan: FileSourceScanExec): Boolean = {
-    scan.relation.location.isInstanceOf[TahoeFileIndex] ||
-    scan.relation.location.isInstanceOf[PreparedDeltaFileIndex]
+  private def isRowIndexColumn(name: String, dataType: DataType): Boolean = {
+    generatedRowIndexColumnNames.contains(name) ||
+    (name == FileFormat.METADATA_NAME && (dataType match {
+      case struct: StructType => 
struct.fieldNames.contains(metadataRowIndexFieldName)
+      case _ => false
+    }))
+  }
+
+  private def scanReadsRowIndexColumn(scan: FileSourceScanExec): Boolean = {
+    val outputFields = scan.output.iterator.map(attribute => (attribute.name, 
attribute.dataType))
+    val requiredFields =
+      scan.requiredSchema.fields.iterator.map(field => (field.name, 
field.dataType))
+    (outputFields ++ requiredFields).exists {
+      case (name, dataType) => isRowIndexColumn(name, dataType)
+    }
   }
 
   private def isDeltaLogScan(scan: FileSourceScanExec): Boolean = {


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

Reply via email to