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

zhztheplayer 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 ddca36e75a [VL] Delta: Support Delta CDF scan offload (#12218)
ddca36e75a is described below

commit ddca36e75aeeba347b9ef9e66e2688f277dc574c
Author: Mohammad Linjawi <[email protected]>
AuthorDate: Thu Aug 13 15:32:19 2026 +0300

    [VL] Delta: Support Delta CDF scan offload (#12218)
---
 .../util/delta-spark-ut/known-failures.txt         |  13 ++
 .../gluten/component/VeloxDeltaComponent.scala     |  14 +-
 .../apache/gluten/config/VeloxDeltaConfig.scala    |   9 +
 .../apache/gluten/execution/VeloxDeltaSuite.scala  |  35 +++-
 docs/get-started/VeloxDelta.md                     |   6 +
 .../gluten/extension/DeltaCDFRelationHelper.scala  |  44 +++++
 .../gluten/extension/DeltaCDFRelationHelper.scala  |  44 +++++
 .../gluten/extension/DeltaCDFRelationHelper.scala  |  44 +++++
 .../gluten/extension/DeltaCDFRelationHelper.scala  |  47 +++++
 .../gluten/execution/DeltaScanTransformer.scala    |  25 +++
 .../apache/gluten/extension/DeltaCDFScanRule.scala | 157 ++++++++++++++++
 .../apache/gluten/extension/OffloadDeltaScan.scala |   8 +-
 .../org/apache/gluten/execution/DeltaSuite.scala   | 203 ++++++++++++++++++++-
 13 files changed, 644 insertions(+), 5 deletions(-)

diff --git a/.github/workflows/util/delta-spark-ut/known-failures.txt 
b/.github/workflows/util/delta-spark-ut/known-failures.txt
index bc55624d49..810c5bc58f 100644
--- a/.github/workflows/util/delta-spark-ut/known-failures.txt
+++ b/.github/workflows/util/delta-spark-ut/known-failures.txt
@@ -73,6 +73,19 @@ 
org.apache.spark.sql.delta.DeltaAlterTableByNameIdColumnMappingSuite#CHANGE COLU
 org.apache.spark.sql.delta.DeltaAlterTableByNameNameColumnMappingSuite#CHANGE 
COLUMN - case insensitive - column mapping name mode
 org.apache.spark.sql.delta.DeltaAlterTableByNameNameColumnMappingSuite#CHANGE 
COLUMN - move to first (nested) - column mapping name mode
 org.apache.spark.sql.delta.DeltaArbitraryColumnNameSuite#create table
+org.apache.spark.sql.delta.DeltaCDCIdColumnMappingSuite#filters with special 
characters in name should be pushed down - column mapping id mode
+org.apache.spark.sql.delta.DeltaCDCNameColumnMappingSuite#filters with special 
characters in name should be pushed down - column mapping name mode
+org.apache.spark.sql.delta.DeltaCDCSQLIdColumnMappingSuite#filters with 
special characters in name should be pushed down - column mapping id mode
+org.apache.spark.sql.delta.DeltaCDCSQLNameColumnMappingSuite#filters with 
special characters in name should be pushed down - column mapping name mode
+org.apache.spark.sql.delta.DeltaCDCSQLSuite#select individual column should 
push down filters
+org.apache.spark.sql.delta.DeltaCDCSQLWithCatalogOwnedBatch100Suite#select 
individual column should push down filters
+org.apache.spark.sql.delta.DeltaCDCSQLWithCatalogOwnedBatch1Suite#select 
individual column should push down filters
+org.apache.spark.sql.delta.DeltaCDCSQLWithCatalogOwnedBatch2Suite#select 
individual column should push down filters
+org.apache.spark.sql.delta.DeltaCDCScalaSuite#filters should be pushed down
+org.apache.spark.sql.delta.DeltaCDCScalaWithCatalogOwnedBatch100Suite#filters 
should be pushed down
+org.apache.spark.sql.delta.DeltaCDCScalaWithCatalogOwnedBatch1Suite#filters 
should be pushed down
+org.apache.spark.sql.delta.DeltaCDCScalaWithCatalogOwnedBatch2Suite#filters 
should be pushed down
+org.apache.spark.sql.delta.DeltaCDCScalaWithDeletionVectorsSuite#filters 
should be pushed down
 org.apache.spark.sql.delta.DeltaCDCStreamDeletionVectorSuite#cdc streams with 
noop merge
 org.apache.spark.sql.delta.DeltaCDCStreamSuite#cdc streams with noop merge
 org.apache.spark.sql.delta.DeltaCDCStreamWithCatalogManagedBatch100Suite#cdc 
streams with noop merge
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 164cf52886..d9bdc4e392 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
@@ -17,8 +17,8 @@
 package org.apache.gluten.component
 
 import org.apache.gluten.backendsapi.velox.VeloxBackend
-import org.apache.gluten.config.GlutenConfig
-import org.apache.gluten.extension.{DeltaPostTransformRules, 
OffloadDeltaFilter, OffloadDeltaProject, OffloadDeltaScan}
+import org.apache.gluten.config.{GlutenConfig, VeloxDeltaConfig}
+import org.apache.gluten.extension.{DeltaCDFScanRule, DeltaPostTransformRules, 
OffloadDeltaFilter, OffloadDeltaProject, OffloadDeltaScan}
 import org.apache.gluten.extension.columnar.heuristic.HeuristicTransform
 import org.apache.gluten.extension.columnar.validator.Validators
 import org.apache.gluten.extension.injector.Injector
@@ -35,6 +35,16 @@ class VeloxDeltaComponent extends Component {
   }
 
   override def injectRules(injector: Injector): Unit = {
+    // Expands Delta CDF relations while the plan is still logical, so the 
Delta file scans they
+    // read reach the offload rules below and Spark's optimizer handles their 
predicate pushdown.
+    // Must not run earlier than the optimizer: analysis is eager at Dataset 
creation, and an
+    // open-ended CDF range has to resolve to the latest version at execution 
time.
+    injector.spark.injectOptimizerRule(
+      spark =>
+        DeltaCDFScanRule(
+          spark,
+          () => new 
VeloxDeltaConfig(spark.sessionState.conf).enableChangeDataFeedScan))
+
     val legacy = injector.gluten.legacy
     // Deletion-vector scans need no Gluten-side logical preprocessing: 
Delta's own
     // PreprocessTableWithDVsStrategy injects the skip-row column and filter 
during physical
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 99c2d2c26a..566a17aab7 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
@@ -22,6 +22,8 @@ class VeloxDeltaConfig(conf: SQLConf) extends 
GlutenCoreConfig(conf) {
   import VeloxDeltaConfig._
 
   def enableNativeWrite: Boolean = getConf(ENABLE_NATIVE_WRITE)
+
+  def enableChangeDataFeedScan: Boolean = getConf(ENABLE_CHANGE_DATA_FEED_SCAN)
 }
 
 object VeloxDeltaConfig extends ConfigRegistry {
@@ -40,4 +42,11 @@ object VeloxDeltaConfig extends ConfigRegistry {
       .doc("Enable native Delta Lake write for Velox backend.")
       .booleanConf
       .createWithDefault(false)
+
+  val ENABLE_CHANGE_DATA_FEED_SCAN: ConfigEntry[Boolean] =
+    
buildConf("spark.gluten.sql.columnar.backend.velox.delta.enableChangeDataFeedScan")
+      .experimental()
+      .doc("Enable Delta Lake change data feed scan offload for Velox 
backend.")
+      .booleanConf
+      .createWithDefault(true)
 }
diff --git 
a/backends-velox/src-delta/test/scala/org/apache/gluten/execution/VeloxDeltaSuite.scala
 
b/backends-velox/src-delta/test/scala/org/apache/gluten/execution/VeloxDeltaSuite.scala
index d7a12d1fc5..851cf1dcbc 100644
--- 
a/backends-velox/src-delta/test/scala/org/apache/gluten/execution/VeloxDeltaSuite.scala
+++ 
b/backends-velox/src-delta/test/scala/org/apache/gluten/execution/VeloxDeltaSuite.scala
@@ -16,4 +16,37 @@
  */
 package org.apache.gluten.execution
 
-class VeloxDeltaSuite extends DeltaSuite
+import org.apache.gluten.config.VeloxDeltaConfig
+
+import org.apache.spark.sql.Row
+
+class VeloxDeltaSuite extends DeltaSuite {
+  test("delta: change data feed scan offload can be disabled") {
+    withTable("delta_cdf_disabled") {
+      spark.sql("""
+                  |create table delta_cdf_disabled (id int, name string) using 
delta
+                  |tblproperties ("delta.enableChangeDataFeed" = "true")
+                  |""".stripMargin)
+      spark.sql("""
+                  |insert into delta_cdf_disabled values (1, "v1"), (2, "v2")
+                  |""".stripMargin)
+
+      withSQLConf(VeloxDeltaConfig.ENABLE_CHANGE_DATA_FEED_SCAN.key -> 
"false") {
+        val df = spark.sql("""
+                             |select id, name, _change_type, _commit_version
+                             |from table_changes('delta_cdf_disabled', 1)
+                             |""".stripMargin)
+        checkAnswer(
+          df,
+          Seq(
+            Row(1, "v1", "insert", 1L),
+            Row(2, "v2", "insert", 1L)))
+        assert(
+          df.queryExecution.executedPlan.collect {
+            case scan: DeltaScanTransformer => scan
+          }.isEmpty,
+          df.queryExecution.executedPlan)
+      }
+    }
+  }
+}
diff --git a/docs/get-started/VeloxDelta.md b/docs/get-started/VeloxDelta.md
index 69964e7890..3c594b9b97 100644
--- a/docs/get-started/VeloxDelta.md
+++ b/docs/get-started/VeloxDelta.md
@@ -28,6 +28,12 @@ Native Delta write is controlled by:
   - Default: `false`
   - Type: experimental
 
+Native change data feed scan offload is controlled by:
+
+- `spark.gluten.sql.columnar.backend.velox.delta.enableChangeDataFeedScan`
+  - Default: `true`
+  - Type: experimental
+
 | 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-delta23/main/scala/org/apache/gluten/extension/DeltaCDFRelationHelper.scala
 
b/gluten-delta/src-delta23/main/scala/org/apache/gluten/extension/DeltaCDFRelationHelper.scala
new file mode 100644
index 0000000000..30bdb75e99
--- /dev/null
+++ 
b/gluten-delta/src-delta23/main/scala/org/apache/gluten/extension/DeltaCDFRelationHelper.scala
@@ -0,0 +1,44 @@
+/*
+ * 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.{DataFrame, SparkSession}
+import org.apache.spark.sql.delta.BatchCDFSchemaEndVersion
+import org.apache.spark.sql.delta.commands.cdc.CDCReader
+
+object DeltaCDFRelationHelper {
+  def changesToBatchDF(
+      relation: CDCReader.DeltaCDFRelation,
+      spark: SparkSession): DataFrame = {
+    val deltaLog = relation.snapshotWithSchemaMode.snapshot.deltaLog
+    val latestVersion = deltaLog.update().version
+    val endingVersionForBatchSchema =
+      relation.endingVersion.map(v => 
latestVersion.min(v)).getOrElse(latestVersion)
+    val snapshotForBatchSchema = relation.snapshotWithSchemaMode.schemaMode 
match {
+      case BatchCDFSchemaEndVersion => 
deltaLog.getSnapshotAt(endingVersionForBatchSchema)
+      case _ => relation.snapshotWithSchemaMode.snapshot
+    }
+    val endVersion = relation.endingVersion.getOrElse(latestVersion)
+
+    CDCReader.changesToBatchDF(
+      deltaLog,
+      relation.startingVersion.get,
+      endVersion,
+      spark,
+      readSchemaSnapshot = Some(snapshotForBatchSchema))
+  }
+}
diff --git 
a/gluten-delta/src-delta24/main/scala/org/apache/gluten/extension/DeltaCDFRelationHelper.scala
 
b/gluten-delta/src-delta24/main/scala/org/apache/gluten/extension/DeltaCDFRelationHelper.scala
new file mode 100644
index 0000000000..30bdb75e99
--- /dev/null
+++ 
b/gluten-delta/src-delta24/main/scala/org/apache/gluten/extension/DeltaCDFRelationHelper.scala
@@ -0,0 +1,44 @@
+/*
+ * 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.{DataFrame, SparkSession}
+import org.apache.spark.sql.delta.BatchCDFSchemaEndVersion
+import org.apache.spark.sql.delta.commands.cdc.CDCReader
+
+object DeltaCDFRelationHelper {
+  def changesToBatchDF(
+      relation: CDCReader.DeltaCDFRelation,
+      spark: SparkSession): DataFrame = {
+    val deltaLog = relation.snapshotWithSchemaMode.snapshot.deltaLog
+    val latestVersion = deltaLog.update().version
+    val endingVersionForBatchSchema =
+      relation.endingVersion.map(v => 
latestVersion.min(v)).getOrElse(latestVersion)
+    val snapshotForBatchSchema = relation.snapshotWithSchemaMode.schemaMode 
match {
+      case BatchCDFSchemaEndVersion => 
deltaLog.getSnapshotAt(endingVersionForBatchSchema)
+      case _ => relation.snapshotWithSchemaMode.snapshot
+    }
+    val endVersion = relation.endingVersion.getOrElse(latestVersion)
+
+    CDCReader.changesToBatchDF(
+      deltaLog,
+      relation.startingVersion.get,
+      endVersion,
+      spark,
+      readSchemaSnapshot = Some(snapshotForBatchSchema))
+  }
+}
diff --git 
a/gluten-delta/src-delta33/main/scala/org/apache/gluten/extension/DeltaCDFRelationHelper.scala
 
b/gluten-delta/src-delta33/main/scala/org/apache/gluten/extension/DeltaCDFRelationHelper.scala
new file mode 100644
index 0000000000..30bdb75e99
--- /dev/null
+++ 
b/gluten-delta/src-delta33/main/scala/org/apache/gluten/extension/DeltaCDFRelationHelper.scala
@@ -0,0 +1,44 @@
+/*
+ * 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.{DataFrame, SparkSession}
+import org.apache.spark.sql.delta.BatchCDFSchemaEndVersion
+import org.apache.spark.sql.delta.commands.cdc.CDCReader
+
+object DeltaCDFRelationHelper {
+  def changesToBatchDF(
+      relation: CDCReader.DeltaCDFRelation,
+      spark: SparkSession): DataFrame = {
+    val deltaLog = relation.snapshotWithSchemaMode.snapshot.deltaLog
+    val latestVersion = deltaLog.update().version
+    val endingVersionForBatchSchema =
+      relation.endingVersion.map(v => 
latestVersion.min(v)).getOrElse(latestVersion)
+    val snapshotForBatchSchema = relation.snapshotWithSchemaMode.schemaMode 
match {
+      case BatchCDFSchemaEndVersion => 
deltaLog.getSnapshotAt(endingVersionForBatchSchema)
+      case _ => relation.snapshotWithSchemaMode.snapshot
+    }
+    val endVersion = relation.endingVersion.getOrElse(latestVersion)
+
+    CDCReader.changesToBatchDF(
+      deltaLog,
+      relation.startingVersion.get,
+      endVersion,
+      spark,
+      readSchemaSnapshot = Some(snapshotForBatchSchema))
+  }
+}
diff --git 
a/gluten-delta/src-delta40/main/scala/org/apache/gluten/extension/DeltaCDFRelationHelper.scala
 
b/gluten-delta/src-delta40/main/scala/org/apache/gluten/extension/DeltaCDFRelationHelper.scala
new file mode 100644
index 0000000000..f1f84cd153
--- /dev/null
+++ 
b/gluten-delta/src-delta40/main/scala/org/apache/gluten/extension/DeltaCDFRelationHelper.scala
@@ -0,0 +1,47 @@
+/*
+ * 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.{DataFrame, SparkSession}
+import org.apache.spark.sql.delta.BatchCDFSchemaEndVersion
+import org.apache.spark.sql.delta.commands.cdc.CDCReader
+
+object DeltaCDFRelationHelper {
+  def changesToBatchDF(
+      relation: CDCReader.DeltaCDFRelation,
+      spark: SparkSession): DataFrame = {
+    val deltaLog = relation.snapshotWithSchemaMode.snapshot.deltaLog
+    val latestVersion = deltaLog.update(catalogTableOpt = 
relation.catalogTableOpt).version
+    val endingVersionForBatchSchema =
+      relation.endingVersion.map(v => 
latestVersion.min(v)).getOrElse(latestVersion)
+    val snapshotForBatchSchema = relation.snapshotWithSchemaMode.schemaMode 
match {
+      case BatchCDFSchemaEndVersion =>
+        deltaLog.getSnapshotAt(
+          endingVersionForBatchSchema,
+          catalogTableOpt = relation.catalogTableOpt)
+      case _ => relation.snapshotWithSchemaMode.snapshot
+    }
+    val endVersion = relation.endingVersion.getOrElse(latestVersion)
+
+    CDCReader.changesToBatchDF(
+      deltaLog,
+      relation.startingVersion.get,
+      endVersion,
+      spark,
+      readSchemaSnapshot = Some(snapshotForBatchSchema))
+  }
+}
diff --git 
a/gluten-delta/src/main/scala/org/apache/gluten/execution/DeltaScanTransformer.scala
 
b/gluten-delta/src/main/scala/org/apache/gluten/execution/DeltaScanTransformer.scala
index 86667d988f..c2cb6604db 100644
--- 
a/gluten-delta/src/main/scala/org/apache/gluten/execution/DeltaScanTransformer.scala
+++ 
b/gluten-delta/src/main/scala/org/apache/gluten/execution/DeltaScanTransformer.scala
@@ -27,6 +27,7 @@ import org.apache.spark.sql.catalyst.expressions.{Attribute, 
AttributeReference,
 import org.apache.spark.sql.catalyst.plans.QueryPlan
 import org.apache.spark.sql.connector.read.streaming.SparkDataStream
 import org.apache.spark.sql.delta.{DeltaParquetFileFormat, NoMapping}
+import org.apache.spark.sql.delta.files.{CdcAddFileIndex, TahoeRemoveFileIndex}
 import org.apache.spark.sql.execution.FileSourceScanExec
 import org.apache.spark.sql.execution.datasources.{FilePartition, 
HadoopFsRelation}
 import org.apache.spark.sql.types.StructType
@@ -61,6 +62,27 @@ case class DeltaScanTransformer(
 
   override lazy val fileFormat: ReadFileFormat = 
ReadFileFormat.ParquetReadFormat
 
+  // Delta CDF over a deletion-vector-enabled table needs DV-aware, row-level 
reconciliation that
+  // the native scan path does not do yet: it would surface rows that are 
still live (not covered
+  // by the DV) as CDF `delete` change rows. Fall back to Spark for both CDF 
scan sides -- the add
+  // side (`CdcAddFileIndex`) and the remove side (`TahoeRemoveFileIndex`) -- 
whenever the touched
+  // files carry DVs. Normal (non-CDF) DV scans are unaffected: those apply 
the DV natively through
+  // the per-file split-info handoff and never reach this guard.
+  override protected def doValidateInternal(): ValidationResult = {
+    if (cdfFilesHaveDeletionVectors) {
+      return 
ValidationResult.failed(DeltaScanTransformer.DELETION_VECTOR_UNSUPPORTED)
+    }
+    super.doValidateInternal()
+  }
+
+  private def cdfFilesHaveDeletionVectors: Boolean = relation.location match {
+    case index: TahoeRemoveFileIndex =>
+      index.filesByVersion.exists(_.actions.exists(_.deletionVector != null))
+    case index: CdcAddFileIndex =>
+      index.addFiles.exists(_.deletionVector != null)
+    case _ => false
+  }
+
   // For Delta column-mapping tables, `dataFilters` on the scan node are 
LOGICAL-named so Delta's
   // file index (`PreparedDeltaFileIndex.matchingFiles`, 
`Snapshot.filesForScan`) can do partition
   // pruning and stats-based file skipping -- both resolve filter attrs 
against logical schemas.
@@ -138,6 +160,9 @@ case class DeltaScanTransformer(
 }
 
 object DeltaScanTransformer {
+
+  val DELETION_VECTOR_UNSUPPORTED = "Deletion vector is not supported in 
native."
+
   def apply(scanExec: FileSourceScanExec): DeltaScanTransformer = {
     new DeltaScanTransformer(
       scanExec.relation,
diff --git 
a/gluten-delta/src/main/scala/org/apache/gluten/extension/DeltaCDFScanRule.scala
 
b/gluten-delta/src/main/scala/org/apache/gluten/extension/DeltaCDFScanRule.scala
new file mode 100644
index 0000000000..f4b993cece
--- /dev/null
+++ 
b/gluten-delta/src/main/scala/org/apache/gluten/extension/DeltaCDFScanRule.scala
@@ -0,0 +1,157 @@
+/*
+ * 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.SparkSession
+import org.apache.spark.sql.catalyst.expressions.{Alias, Attribute, 
NamedExpression}
+import org.apache.spark.sql.catalyst.plans.logical.{LocalRelation, 
LogicalPlan, Project}
+import org.apache.spark.sql.catalyst.rules.Rule
+import org.apache.spark.sql.delta.actions.{AddFile, RemoveFile}
+import org.apache.spark.sql.delta.commands.cdc.CDCReader
+import org.apache.spark.sql.execution.datasources.LogicalRelation
+
+import scala.util.control.NonFatal
+
+/**
+ * Expands Delta's `DeltaCDFRelation` into the underlying change-data plan so 
the Delta file scans
+ * it reads become visible to Gluten's normal scan offload rules. Without 
this, Spark plans the CDF
+ * relation as a `RowDataSourceScanExec` whose file scans are hidden inside an 
RDD, and no columnar
+ * rule can reach them.
+ *
+ * This runs as a logical optimizer rule. Only an output-restoring projection 
is needed: the
+ * replacement keeps the original relation's expression IDs, and because the 
rule participates in
+ * the operator optimization batch, Spark's own optimizer then pushes the 
surrounding filters into
+ * the expanded file scans. Expanding later -- during physical planning -- 
would require re-running
+ * the optimizer out of band and hand-preserving expression IDs against 
operators that were already
+ * planned, which is fragile (a collapsed alias there surfaces as `Couldn't 
find <attr>` at shuffle
+ * binding time).
+ *
+ * The phase also has to be no earlier than the optimizer. Analysis runs 
eagerly when a Dataset is
+ * created, while optimization is deferred to the first action, and Delta 
resolves an open-ended CDF
+ * range (no ending version) to the latest version at execution time. 
Expanding during analysis
+ * would snapshot that range at Dataset creation and silently drop commits 
made before the action --
+ * see `DeltaCDCSQLSuite."ending version not specified resolves to latest at 
execution time"`.
+ */
+case class DeltaCDFScanRule(spark: SparkSession, offloadEnabled: () => Boolean)
+  extends Rule[LogicalPlan] {
+
+  override def apply(plan: LogicalPlan): LogicalPlan = {
+    if (!offloadEnabled()) {
+      return plan
+    }
+
+    // transformUp, not resolveOperatorsUp: by the optimizer the plan is 
already marked analyzed,
+    // and resolveOperatorsUp skips analyzed subtrees, so it would never reach 
the relation. The
+    // analyzer's assertion against transformUp does not apply outside the 
analyzer. Expansion is
+    // idempotent -- the replacement holds no DeltaCDFRelation -- so 
re-running the operator
+    // optimization batch to fixed point cannot expand twice.
+    plan.transformUp {
+      case relation: LogicalRelation =>
+        relation.relation match {
+          case cdfRelation: CDCReader.DeltaCDFRelation =>
+            expandOrDecline(relation, cdfRelation)
+          case _ => relation
+        }
+    }
+  }
+
+  // Leave the relation untouched on any failure so Delta's own CDF path runs. 
This rule executes
+  // during analysis, so an escaping exception would fail the query outright 
-- including for
+  // metadata-only calls such as `explain()` -- where vanilla Spark would have 
succeeded. Offload is
+  // an optimization; it must never be the reason a CDF query stops working.
+  private def expandOrDecline(
+      relation: LogicalRelation,
+      cdfRelation: CDCReader.DeltaCDFRelation): LogicalPlan = {
+    try {
+      if (changesContainDeletionVectors(cdfRelation)) {
+        relation
+      } else {
+        expand(relation, cdfRelation)
+      }
+    } catch {
+      case NonFatal(e) =>
+        logWarning("Failed to expand Delta CDF relation, falling back to 
Delta's CDF scan.", e)
+        relation
+    }
+  }
+
+  // Delta CDF over a commit that uses deletion vectors needs Delta's 
DV-aware, row-level
+  // reconciliation: a deleted row stays physically in its data file and is 
masked by a DV, so the
+  // CDF `delete` rows for a commit are only the rows the DV newly marks. 
Expanding the analyzed
+  // CDF batch plan here loses that reconciliation on some Delta versions 
(e.g. Delta 2.4 / Spark
+  // 3.4), where the remove side would then surface every row of a 
logically-removed file as a
+  // `delete` change row instead of just the DV-marked ones.
+  //
+  // The table property is not a reliable guard: it controls whether future 
writes may create DVs,
+  // while existing DVs can remain after the property is disabled. Inspect the 
AddFile/RemoveFile
+  // actions in the requested CDF interval instead. Decline to expand only 
ranges that actually
+  // contain DVs and let Delta's DeltaCDFRelation apply them correctly. The 
matching physical-scan
+  // guard in DeltaScanTransformer remains as a backstop.
+  private def changesContainDeletionVectors(
+      cdfRelation: CDCReader.DeltaCDFRelation): Boolean = {
+    cdfRelation.startingVersion.exists {
+      startVersion =>
+        val changes =
+          
cdfRelation.snapshotWithSchemaMode.snapshot.deltaLog.getChanges(startVersion)
+        val changesInRange = cdfRelation.endingVersion match {
+          case Some(endVersion) => changes.takeWhile { case (version, _) => 
version <= endVersion }
+          case None => changes
+        }
+        changesInRange.exists {
+          case (_, actions) =>
+            actions.exists {
+              case file: AddFile => file.deletionVector != null
+              case file: RemoveFile => file.deletionVector != null
+              case _ => false
+            }
+        }
+    }
+  }
+
+  private def expand(
+      relation: LogicalRelation,
+      cdfRelation: CDCReader.DeltaCDFRelation): LogicalPlan = {
+    // Delta represents an empty CDF range with `emptyCDFRelation`, whose 
`startingVersion` is unset
+    // and whose scan is an empty RDD. There is nothing to expand, and asking 
Delta for the changes
+    // would fail on `startingVersion.get`.
+    if (cdfRelation.startingVersion.isEmpty) {
+      return LocalRelation(relation.output)
+    }
+
+    val cdfPlan =
+      DeltaCDFRelationHelper.changesToBatchDF(cdfRelation, 
spark).queryExecution.analyzed
+    val cdfOutput = cdfPlan.output
+    // Bind the expanded plan back to the expression IDs the rest of the query 
already references.
+    val projects: Seq[NamedExpression] = relation.output.map {
+      attr =>
+        Alias(resolveCDFAttribute(attr, cdfOutput), attr.name)(
+          exprId = attr.exprId,
+          qualifier = attr.qualifier,
+          explicitMetadata = Some(attr.metadata))
+    }
+    Project(projects, cdfPlan)
+  }
+
+  private def resolveCDFAttribute(attr: Attribute, cdfOutput: Seq[Attribute]): 
Attribute = {
+    cdfOutput
+      .find(output => spark.sessionState.conf.resolver(output.name, attr.name))
+      .getOrElse(
+        throw new IllegalArgumentException(
+          s"Unable to resolve CDF attribute ${attr.name} from " +
+            s"${cdfOutput.map(_.name).mkString("[", ", ", "]")}"))
+  }
+}
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 ebafb0c08c..4664da8769 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
@@ -23,7 +23,7 @@ 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.commands.DeletionVectorUtils.deletionVectorsReadable
-import org.apache.spark.sql.delta.files.TahoeFileIndex
+import org.apache.spark.sql.delta.files.{CdcAddFileIndex, TahoeFileIndex, 
TahoeRemoveFileIndex}
 import org.apache.spark.sql.delta.stats.PreparedDeltaFileIndex
 import org.apache.spark.sql.execution.{FileSourceScanExec, SparkPlan}
 import org.apache.spark.util.SparkVersionUtil
@@ -97,6 +97,12 @@ case class OffloadDeltaScan() extends OffloadSingleNode {
 
   private def containsDeletionVector(scan: FileSourceScanExec): Boolean = {
     scan.relation.location match {
+      // CDF indexes expose the exact actions in the requested range. Use 
those instead of the
+      // table-level protocol capability so a DV-capable table can still 
offload DV-free ranges.
+      case index: TahoeRemoveFileIndex =>
+        index.filesByVersion.exists(_.actions.exists(_.deletionVector != null))
+      case index: CdcAddFileIndex =>
+        index.addFiles.exists(_.deletionVector != null)
       case preparedIndex: PreparedDeltaFileIndex =>
         preparedIndex.preparedScan.files.exists(_.deletionVector != null)
       case index: TahoeFileIndex =>
diff --git 
a/gluten-delta/src/test/scala/org/apache/gluten/execution/DeltaSuite.scala 
b/gluten-delta/src/test/scala/org/apache/gluten/execution/DeltaSuite.scala
index b2f4122039..138d318cff 100644
--- a/gluten-delta/src/test/scala/org/apache/gluten/execution/DeltaSuite.scala
+++ b/gluten-delta/src/test/scala/org/apache/gluten/execution/DeltaSuite.scala
@@ -19,7 +19,7 @@ package org.apache.gluten.execution
 import org.apache.gluten.extension.DeltaPostTransformRules
 
 import org.apache.spark.SparkConf
-import org.apache.spark.sql.Row
+import org.apache.spark.sql.{DataFrame, Row}
 import org.apache.spark.sql.types._
 import org.apache.spark.util.SparkVersionUtil
 
@@ -350,6 +350,207 @@ abstract class DeltaSuite extends 
WholeStageTransformerSuite {
     }
   }
 
+  test("delta: change data feed read") {
+    withTable("delta_cdf") {
+      spark.sql(s"""
+                   |create table delta_cdf (id int, name string) using delta
+                   |tblproperties ("delta.enableChangeDataFeed" = "true")
+                   |""".stripMargin)
+      spark.sql(s"""
+                   |insert into delta_cdf values (1, "v1"), (2, "v2")
+                   |""".stripMargin)
+      spark.sql(s"""
+                   |update delta_cdf set name = "v2_updated" where id = 2
+                   |""".stripMargin)
+      spark.sql(s"""
+                   |delete from delta_cdf where id = 1
+                   |""".stripMargin)
+
+      val tableChangesFromZeroDF = runAndCompare(
+        s"""
+           |select id, name, _change_type, _commit_version
+           |from table_changes('delta_cdf', 0)
+           |order by _commit_version, id, name, _change_type
+           |""".stripMargin)
+      checkCDFRead(tableChangesFromZeroDF)
+
+      val tableChangesDF = runAndCompare(
+        s"""
+           |select id, name, _change_type, _commit_version
+           |from table_changes('delta_cdf', 1)
+           |order by _commit_version, id, name, _change_type
+           |""".stripMargin)
+      checkCDFRead(tableChangesDF)
+
+      val filteredCDF = runAndCompare(
+        s"""
+           |select id, name, _change_type, _commit_version
+           |from table_changes('delta_cdf', 1)
+           |where _commit_version = 2 and id = 2
+           |order by name, _change_type
+           |""".stripMargin)
+      checkCDFRead(
+        filteredCDF,
+        Seq(
+          Row(2, "v2", "update_preimage", 2L),
+          Row(2, "v2_updated", "update_postimage", 2L)))
+      assert(
+        collect(filteredCDF.queryExecution.executedPlan) {
+          case scan: DeltaScanTransformer =>
+            scan.dataFilters.exists(_.references.exists(_.name == "id"))
+        }.contains(true),
+        filteredCDF.queryExecution.executedPlan
+      )
+
+      val boundedCDF = runAndCompare(
+        s"""
+           |select id, name, _change_type, _commit_version
+           |from table_changes('delta_cdf', 1, 2)
+           |order by _commit_version, id, name, _change_type
+           |""".stripMargin)
+      checkCDFRead(
+        boundedCDF,
+        Seq(
+          Row(1, "v1", "insert", 1L),
+          Row(2, "v2", "insert", 1L),
+          Row(2, "v2", "update_preimage", 2L),
+          Row(2, "v2_updated", "update_postimage", 2L)))
+
+      val readChangeFeedDF = compareCDFDataFrame(
+        () =>
+          spark.read
+            .format("delta")
+            .option("readChangeFeed", "true")
+            .option("startingVersion", "1")
+            .table("delta_cdf")
+            .selectExpr("id", "name", "_change_type", "_commit_version")
+            .orderBy("_commit_version", "id", "name", "_change_type"))
+      checkCDFRead(readChangeFeedDF)
+    }
+  }
+
+  test("delta: change data feed read with column mapping") {
+    withTable("delta_cdf_cm") {
+      spark.sql(s"""
+                   |create table delta_cdf_cm (id int, name string) using delta
+                   |tblproperties (
+                   |  "delta.enableChangeDataFeed" = "true",
+                   |  "delta.columnMapping.mode" = "name")
+                   |""".stripMargin)
+      spark.sql(s"""
+                   |insert into delta_cdf_cm values (1, "v1"), (2, "v2")
+                   |""".stripMargin)
+      spark.sql(s"""
+                   |update delta_cdf_cm set name = "v2_updated" where id = 2
+                   |""".stripMargin)
+      spark.sql(s"""
+                   |delete from delta_cdf_cm where id = 1
+                   |""".stripMargin)
+
+      val df = runAndCompare(
+        s"""
+           |select id, name, _change_type, _commit_version
+           |from table_changes('delta_cdf_cm', 1)
+           |order by _commit_version, id, name, _change_type
+           |""".stripMargin)
+      checkCDFRead(df)
+    }
+  }
+
+  testWithMinSparkVersion("delta: change data feed read with deletion 
vectors", "3.4") {
+    withTable("delta_cdf_dv") {
+      spark.sql(s"""
+                   |create table delta_cdf_dv (id int, name string) using delta
+                   |tblproperties (
+                   |  "delta.enableChangeDataFeed" = "true",
+                   |  "delta.enableDeletionVectors" = "true")
+                   |""".stripMargin)
+      spark.sql(s"""
+                   |insert into delta_cdf_dv values (1, "v1"), (2, "v2"), (3, 
"v3")
+                   |""".stripMargin)
+
+      // Enabling DV writes does not mean this CDF range contains a DV. The 
insert-only range must
+      // still be eligible for native scan offload.
+      val insertOnlyDF = runAndCompare(
+        s"""
+           |select id, name, _change_type
+           |from table_changes('delta_cdf_dv', 0, 1)
+           |order by id, name, _change_type
+           |""".stripMargin)
+      assert(
+        collect(insertOnlyDF.queryExecution.executedPlan) {
+          case _: DeltaScanTransformer => true
+        }.nonEmpty,
+        insertOnlyDF.queryExecution.executedPlan)
+      checkAnswer(
+        insertOnlyDF,
+        Seq(
+          Row(1, "v1", "insert"),
+          Row(2, "v2", "insert"),
+          Row(3, "v3", "insert")))
+
+      spark.sql(s"""
+                   |delete from delta_cdf_dv where id = 2
+                   |""".stripMargin)
+      spark.sql(s"""
+                   |alter table delta_cdf_dv set tblproperties (
+                   |  "delta.enableDeletionVectors" = "false")
+                   |""".stripMargin)
+
+      // Disabling future DV writes does not remove existing DVs. This range 
contains the DV-backed
+      // delete, so Gluten keeps the whole CDF read on Spark and lets Delta 
perform row-level
+      // reconciliation. Still-live rows must not be surfaced as `delete` 
change rows.
+      val df = runAndCompare(
+        s"""
+           |select id, name, _change_type
+           |from table_changes('delta_cdf_dv', 0)
+           |order by id, name, _change_type
+           |""".stripMargin)
+      assert(
+        collect(df.queryExecution.executedPlan) { case d: DeltaScanTransformer 
=> d }.isEmpty,
+        df.queryExecution.executedPlan)
+      checkAnswer(
+        df,
+        Seq(
+          Row(1, "v1", "insert"),
+          Row(2, "v2", "delete"),
+          Row(2, "v2", "insert"),
+          Row(3, "v3", "insert")))
+    }
+  }
+
+  private def compareCDFDataFrame(dataframe: () => DataFrame): DataFrame = {
+    var expected: Seq[Row] = null
+    withSQLConf(vanillaSparkConfs(): _*) {
+      expected = dataframe().collect()
+    }
+    val df = dataframe()
+    checkAnswer(df, expected)
+    df
+  }
+
+  private def checkCDFRead(
+      df: DataFrame,
+      expectedRows: Seq[Row] = allCDFRows): Unit = {
+    // Delta CDF expansion can keep a Spark-side branch for synthesized change 
rows; this PR
+    // verifies the Delta file scans in the expanded plan are transformed.
+    checkLengthAndPlan(df, expectedRows.length)
+    checkAnswer(
+      df,
+      expectedRows)
+    assert(
+      collect(df.queryExecution.executedPlan) { case _: DeltaScanTransformer 
=> true }.nonEmpty,
+      df.queryExecution.executedPlan)
+  }
+
+  private def allCDFRows: Seq[Row] =
+    Seq(
+      Row(1, "v1", "insert", 1L),
+      Row(2, "v2", "insert", 1L),
+      Row(2, "v2", "update_preimage", 2L),
+      Row(2, "v2_updated", "update_postimage", 2L),
+      Row(1, "v1", "delete", 3L))
+
   test("column mapping with complex type") {
     withTable("t1") {
       val simpleNestedSchema = new StructType()


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

Reply via email to