Skip to content

Commit bca92ba

Browse files
committed
[VL][Delta] Support Delta CDF scan offload
Delta change-data-feed reads enter Spark as a CDCReader.DeltaCDFRelation, which does not have the FileSourceScanExec + DeltaParquetFileFormat shape that Gluten's Delta scan offload recognizes. Add a Gluten Delta planner strategy (wired from VeloxDeltaComponent) that recognizes DeltaCDFRelation, expands it through Delta's own CDF batch planning, and rewrites the projection/filter attributes onto the expanded plan, so the existing Delta scan offload path can plan the underlying CDF file scans as DeltaScanTransformer. CDF over a deletion-vector-enabled table is kept on Spark: native CDF lacks the DV-aware row-level reconciliation, so an offloaded CDF scan would emit still-live rows as `delete` change rows. DeltaScanTransformer falls back for both CDF scan sides -- the add side (CdcAddFileIndex) and the remove side (TahoeRemoveFileIndex) -- when the touched files carry DVs. Normal (non-CDF) DV scans are unaffected and continue to apply the DV natively. Addresses #12195.
1 parent 2dcee79 commit bca92ba

8 files changed

Lines changed: 475 additions & 2 deletions

File tree

backends-velox/src-delta/main/scala/org/apache/gluten/component/VeloxDeltaComponent.scala

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ package org.apache.gluten.component
1818

1919
import org.apache.gluten.backendsapi.velox.VeloxBackend
2020
import org.apache.gluten.config.GlutenConfig
21-
import org.apache.gluten.extension.{DeltaPostTransformRules, OffloadDeltaFilter, OffloadDeltaProject, OffloadDeltaScan}
21+
import org.apache.gluten.extension.{DeltaCDFScanStrategy, DeltaPostTransformRules, OffloadDeltaFilter, OffloadDeltaProject, OffloadDeltaScan}
2222
import org.apache.gluten.extension.columnar.heuristic.HeuristicTransform
2323
import org.apache.gluten.extension.columnar.validator.Validators
2424
import org.apache.gluten.extension.injector.Injector
@@ -35,6 +35,8 @@ class VeloxDeltaComponent extends Component {
3535
}
3636

3737
override def injectRules(injector: Injector): Unit = {
38+
injector.spark.injectPlannerStrategy(DeltaCDFScanStrategy(_))
39+
3840
val legacy = injector.gluten.legacy
3941
// Deletion-vector scans need no Gluten-side logical preprocessing: Delta's own
4042
// PreprocessTableWithDVsStrategy injects the skip-row column and filter during physical
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.gluten.extension
18+
19+
import org.apache.spark.sql.{DataFrame, SparkSession}
20+
import org.apache.spark.sql.delta.BatchCDFSchemaEndVersion
21+
import org.apache.spark.sql.delta.commands.cdc.CDCReader
22+
23+
object DeltaCDFRelationHelper {
24+
def changesToBatchDF(
25+
relation: CDCReader.DeltaCDFRelation,
26+
spark: SparkSession): DataFrame = {
27+
val deltaLog = relation.snapshotWithSchemaMode.snapshot.deltaLog
28+
val latestVersion = deltaLog.update().version
29+
val endingVersionForBatchSchema =
30+
relation.endingVersion.map(v => latestVersion.min(v)).getOrElse(latestVersion)
31+
val snapshotForBatchSchema = relation.snapshotWithSchemaMode.schemaMode match {
32+
case BatchCDFSchemaEndVersion => deltaLog.getSnapshotAt(endingVersionForBatchSchema)
33+
case _ => relation.snapshotWithSchemaMode.snapshot
34+
}
35+
val endVersion = relation.endingVersion.getOrElse(latestVersion)
36+
37+
CDCReader.changesToBatchDF(
38+
deltaLog,
39+
relation.startingVersion.get,
40+
endVersion,
41+
spark,
42+
readSchemaSnapshot = Some(snapshotForBatchSchema))
43+
}
44+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.gluten.extension
18+
19+
import org.apache.spark.sql.{DataFrame, SparkSession}
20+
import org.apache.spark.sql.delta.BatchCDFSchemaEndVersion
21+
import org.apache.spark.sql.delta.commands.cdc.CDCReader
22+
23+
object DeltaCDFRelationHelper {
24+
def changesToBatchDF(
25+
relation: CDCReader.DeltaCDFRelation,
26+
spark: SparkSession): DataFrame = {
27+
val deltaLog = relation.snapshotWithSchemaMode.snapshot.deltaLog
28+
val latestVersion = deltaLog.update().version
29+
val endingVersionForBatchSchema =
30+
relation.endingVersion.map(v => latestVersion.min(v)).getOrElse(latestVersion)
31+
val snapshotForBatchSchema = relation.snapshotWithSchemaMode.schemaMode match {
32+
case BatchCDFSchemaEndVersion => deltaLog.getSnapshotAt(endingVersionForBatchSchema)
33+
case _ => relation.snapshotWithSchemaMode.snapshot
34+
}
35+
val endVersion = relation.endingVersion.getOrElse(latestVersion)
36+
37+
CDCReader.changesToBatchDF(
38+
deltaLog,
39+
relation.startingVersion.get,
40+
endVersion,
41+
spark,
42+
readSchemaSnapshot = Some(snapshotForBatchSchema))
43+
}
44+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.gluten.extension
18+
19+
import org.apache.spark.sql.{DataFrame, SparkSession}
20+
import org.apache.spark.sql.delta.BatchCDFSchemaEndVersion
21+
import org.apache.spark.sql.delta.commands.cdc.CDCReader
22+
23+
object DeltaCDFRelationHelper {
24+
def changesToBatchDF(
25+
relation: CDCReader.DeltaCDFRelation,
26+
spark: SparkSession): DataFrame = {
27+
val deltaLog = relation.snapshotWithSchemaMode.snapshot.deltaLog
28+
val latestVersion = deltaLog.update().version
29+
val endingVersionForBatchSchema =
30+
relation.endingVersion.map(v => latestVersion.min(v)).getOrElse(latestVersion)
31+
val snapshotForBatchSchema = relation.snapshotWithSchemaMode.schemaMode match {
32+
case BatchCDFSchemaEndVersion => deltaLog.getSnapshotAt(endingVersionForBatchSchema)
33+
case _ => relation.snapshotWithSchemaMode.snapshot
34+
}
35+
val endVersion = relation.endingVersion.getOrElse(latestVersion)
36+
37+
CDCReader.changesToBatchDF(
38+
deltaLog,
39+
relation.startingVersion.get,
40+
endVersion,
41+
spark,
42+
readSchemaSnapshot = Some(snapshotForBatchSchema))
43+
}
44+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.gluten.extension
18+
19+
import org.apache.spark.sql.{DataFrame, SparkSession}
20+
import org.apache.spark.sql.delta.BatchCDFSchemaEndVersion
21+
import org.apache.spark.sql.delta.commands.cdc.CDCReader
22+
23+
object DeltaCDFRelationHelper {
24+
def changesToBatchDF(
25+
relation: CDCReader.DeltaCDFRelation,
26+
spark: SparkSession): DataFrame = {
27+
val deltaLog = relation.snapshotWithSchemaMode.snapshot.deltaLog
28+
val latestVersion = deltaLog.update(catalogTableOpt = relation.catalogTableOpt).version
29+
val endingVersionForBatchSchema =
30+
relation.endingVersion.map(v => latestVersion.min(v)).getOrElse(latestVersion)
31+
val snapshotForBatchSchema = relation.snapshotWithSchemaMode.schemaMode match {
32+
case BatchCDFSchemaEndVersion =>
33+
deltaLog.getSnapshotAt(
34+
endingVersionForBatchSchema,
35+
catalogTableOpt = relation.catalogTableOpt)
36+
case _ => relation.snapshotWithSchemaMode.snapshot
37+
}
38+
val endVersion = relation.endingVersion.getOrElse(latestVersion)
39+
40+
CDCReader.changesToBatchDF(
41+
deltaLog,
42+
relation.startingVersion.get,
43+
endVersion,
44+
spark,
45+
readSchemaSnapshot = Some(snapshotForBatchSchema))
46+
}
47+
}

gluten-delta/src/main/scala/org/apache/gluten/execution/DeltaScanTransformer.scala

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeReference,
2727
import org.apache.spark.sql.catalyst.plans.QueryPlan
2828
import org.apache.spark.sql.connector.read.streaming.SparkDataStream
2929
import org.apache.spark.sql.delta.{DeltaParquetFileFormat, NoMapping}
30+
import org.apache.spark.sql.delta.files.{CdcAddFileIndex, TahoeRemoveFileIndex}
3031
import org.apache.spark.sql.execution.FileSourceScanExec
3132
import org.apache.spark.sql.execution.datasources.{FilePartition, HadoopFsRelation}
3233
import org.apache.spark.sql.types.StructType
@@ -61,6 +62,27 @@ case class DeltaScanTransformer(
6162

6263
override lazy val fileFormat: ReadFileFormat = ReadFileFormat.ParquetReadFormat
6364

65+
// Delta CDF over a deletion-vector-enabled table needs DV-aware, row-level reconciliation that
66+
// the native scan path does not do yet: it would surface rows that are still live (not covered
67+
// by the DV) as CDF `delete` change rows. Fall back to Spark for both CDF scan sides -- the add
68+
// side (`CdcAddFileIndex`) and the remove side (`TahoeRemoveFileIndex`) -- whenever the touched
69+
// files carry DVs. Normal (non-CDF) DV scans are unaffected: those apply the DV natively through
70+
// the per-file split-info handoff and never reach this guard.
71+
override protected def doValidateInternal(): ValidationResult = {
72+
if (cdfFilesHaveDeletionVectors) {
73+
return ValidationResult.failed(DeltaScanTransformer.DELETION_VECTOR_UNSUPPORTED)
74+
}
75+
super.doValidateInternal()
76+
}
77+
78+
private def cdfFilesHaveDeletionVectors: Boolean = relation.location match {
79+
case index: TahoeRemoveFileIndex =>
80+
index.filesByVersion.exists(_.actions.exists(_.deletionVector != null))
81+
case index: CdcAddFileIndex =>
82+
index.addFiles.exists(_.deletionVector != null)
83+
case _ => false
84+
}
85+
6486
// For Delta column-mapping tables, `dataFilters` on the scan node are LOGICAL-named so Delta's
6587
// file index (`PreparedDeltaFileIndex.matchingFiles`, `Snapshot.filesForScan`) can do partition
6688
// pruning and stats-based file skipping -- both resolve filter attrs against logical schemas.
@@ -138,6 +160,9 @@ case class DeltaScanTransformer(
138160
}
139161

140162
object DeltaScanTransformer {
163+
164+
val DELETION_VECTOR_UNSUPPORTED = "Deletion vector is not supported in native."
165+
141166
def apply(scanExec: FileSourceScanExec): DeltaScanTransformer = {
142167
new DeltaScanTransformer(
143168
scanExec.relation,
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.gluten.extension
18+
19+
import org.apache.spark.sql.SparkSession
20+
import org.apache.spark.sql.catalyst.expressions.{Alias, Attribute, AttributeReference, Expression, NamedExpression}
21+
import org.apache.spark.sql.catalyst.planning.PhysicalOperation
22+
import org.apache.spark.sql.catalyst.plans.logical.{Filter, LocalRelation, LogicalPlan, Project}
23+
import org.apache.spark.sql.delta.commands.cdc.CDCReader
24+
import org.apache.spark.sql.execution.{SparkPlan, SparkStrategy}
25+
import org.apache.spark.sql.execution.datasources.LogicalRelation
26+
27+
case class DeltaCDFScanStrategy(spark: SparkSession) extends SparkStrategy {
28+
override def apply(plan: LogicalPlan): Seq[SparkPlan] = plan match {
29+
case PhysicalOperation(projects, filters, relation: LogicalRelation) =>
30+
relation.relation match {
31+
case cdfRelation: CDCReader.DeltaCDFRelation =>
32+
planCDFRelation(relation, cdfRelation, projects, filters).map(planLater).toSeq
33+
case _ => Nil
34+
}
35+
case _ => Nil
36+
}
37+
38+
private def planCDFRelation(
39+
relation: LogicalRelation,
40+
cdfRelation: CDCReader.DeltaCDFRelation,
41+
projects: Seq[NamedExpression],
42+
filters: Seq[Expression]): Option[LogicalPlan] = {
43+
if (cdfRelation.startingVersion.isEmpty) {
44+
return Some(projectAndFilter(LocalRelation(relation.output), projects, filters))
45+
}
46+
47+
val cdfPlan =
48+
DeltaCDFRelationHelper.changesToBatchDF(cdfRelation, spark).queryExecution.analyzed
49+
val cdfOutput = cdfPlan.output
50+
val rewrittenFilters = filters.map(rewriteExpression(_, cdfOutput))
51+
val rewrittenProjects = projects.map(rewriteProject(_, cdfOutput))
52+
Some(projectAndFilter(cdfPlan, rewrittenProjects, rewrittenFilters))
53+
}
54+
55+
private def projectAndFilter(
56+
child: LogicalPlan,
57+
projects: Seq[NamedExpression],
58+
filters: Seq[Expression]): LogicalPlan = {
59+
val filtered = filters.reduceOption(org.apache.spark.sql.catalyst.expressions.And) match {
60+
case Some(condition) => Filter(condition, child)
61+
case None => child
62+
}
63+
Project(projects, filtered)
64+
}
65+
66+
private def rewriteProject(
67+
project: NamedExpression,
68+
cdfOutput: Seq[Attribute]): NamedExpression = {
69+
project match {
70+
case attr: AttributeReference =>
71+
Alias(
72+
resolveCDFAttribute(attr, cdfOutput),
73+
attr.name)(
74+
exprId = attr.exprId,
75+
qualifier = attr.qualifier,
76+
explicitMetadata = Some(attr.metadata))
77+
case other =>
78+
rewriteExpression(other, cdfOutput).asInstanceOf[NamedExpression]
79+
}
80+
}
81+
82+
private def rewriteExpression(expr: Expression, cdfOutput: Seq[Attribute]): Expression = {
83+
expr.transform {
84+
case attr: AttributeReference => resolveCDFAttribute(attr, cdfOutput)
85+
}
86+
}
87+
88+
private def resolveCDFAttribute(
89+
attr: AttributeReference,
90+
cdfOutput: Seq[Attribute]): Attribute = {
91+
cdfOutput
92+
.find(output => spark.sessionState.conf.resolver(output.name, attr.name))
93+
.getOrElse(
94+
throw new IllegalArgumentException(
95+
s"Unable to resolve CDF attribute ${attr.name} from " +
96+
s"${cdfOutput.map(_.name).mkString("[", ", ", "]")}"))
97+
}
98+
}

0 commit comments

Comments
 (0)