szehon-ho commented on code in PR #58145:
URL: https://github.com/apache/spark/pull/58145#discussion_r4140133789
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/planmerging/PlanMerger.scala:
##########
@@ -500,31 +509,43 @@ class PlanMerger(
// Note: the new Project always uses mergedChild as its
child (rather than
// flattening into an existing Project below) because
mergedChild.output may
// contain previously-propagated filter attributes that
cp.condition references.
+ val advisoryFilters =
deferred.toSeq.flatMap(_.advisoryFilters)
+ val residualNPCondition =
+ removeAdvisoryConjuncts(mappedNPCondition, advisoryFilters)
+ val residualCPCondition =
+ removeAdvisoryConjuncts(cp.condition, advisoryFilters)
val newNPCondition =
- npFilter.fold(mappedNPCondition) { case (f, _) => And(f,
mappedNPCondition) }
- val newCPCondition = cpFilter.fold(cp.condition)(And(_,
cp.condition))
+ npFilter.fold(residualNPCondition) {
+ case (f, _) => And(f, residualNPCondition)
+ }
+ val newCPCondition = cpFilter.fold(residualCPCondition) {
+ And(_, residualCPCondition)
+ }
// The OR-widen moves both conditions into a boolean Project
above the merged
// scan, so the scan itself would read the full table. Build
the deferred scan
// here with OR(np condition, cp condition) as the
best-effort filter (the Filter
// above still enforces exactness). The best-effort filter
is derived from the
// scan-level conditions, not the propagated filter
attributes in newNP/newCP.
tryBuildFilterDSv2ScanChild(
mergedChild, deferred, Some(Or(mappedNPCondition,
cp.condition)))
- .map { prunedChild =>
- val newNPFilterAlias =
- Alias(newNPCondition,
s"propagatedFilter_${PlanMerger.newId}")()
- val newCPFilterAlias =
- Alias(newCPCondition,
s"propagatedFilter_${PlanMerger.newId}")()
- val newNPFilter = newNPFilterAlias.toAttribute
- val newCPFilter = newCPFilterAlias.toAttribute
- val project = Project(
- prunedChild.output.toList ++ Seq(newNPFilterAlias,
newCPFilterAlias),
- prunedChild)
- val newFilter = Filter(Or(newNPFilter, newCPFilter),
project)
- newFilter.copyTagsFrom(cp)
- newFilter.setTagValue(PlanMerger.MERGED_FILTER_TAG, ())
- TryMergeResult(newFilter, npMapping, Some((newNPFilter,
true)),
- Some(newCPFilter), dsv2Merged = dsv2Merged)
+ .flatMap { prunedChild =>
+ addAdvisoryFilter(prunedChild, advisoryFilters).map {
filteredChild =>
Review Comment:
The opt-in path now keeps inferred predicates only in
`DataSourceV2ScanRelation.inferredFilters`, so they are not passed through
executable filter propagation. The earlier advisory-specific `PlanMerger`
changes were removed. Constraint integration is deferred; the requested
three-input scan-merge regression under both symmetric modes is still
outstanding.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DataSourceV2Strategy.scala:
##########
@@ -126,8 +126,22 @@ class DataSourceV2Strategy(session: SparkSession) extends
Strategy with Predicat
tableSpec.withNewLocation(newLoc)
}
+ private def removeNotEvaluatedFilters(
+ filters: Seq[Expression],
+ relation: DataSourceV2ScanRelation,
+ otherNotEvaluatedFilters: Seq[Expression] = Nil): Seq[Expression] = {
+ // Fast path: with nothing to exclude, avoid canonicalizing every filter
via ExpressionSet.
+ if (otherNotEvaluatedFilters.isEmpty && relation.advisoryFilters.isEmpty) {
+ filters
+ } else {
+ val notEvaluatedFilterSet =
+ ExpressionSet(otherNotEvaluatedFilters ++ relation.advisoryFilters)
+ filters.filterNot(notEvaluatedFilterSet.contains)
Review Comment:
Implemented the direct statistics path in 3088868b60c. `FilterEstimation`
now accepts a condition and base statistics. Scans opting in through
`SupportsReportStatistics.useInferredFilterEstimation()` keep inferred
predicates as scan metadata, with no inferred logical Filter or physical
expression-subtraction step in this mode.
The `A OR C` / `A OR B` factoring regression checks that inferred-only `B`
stays out of both logical filters and `FilterExec`. Exposing these predicates
through constraints remains deferred. The opt-in defaults to `false`,
preserving the ordinary-filter path for other scans.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/V2ScanRelationPushDown.scala:
##########
@@ -1261,6 +1289,37 @@ object V2ScanRelationPushDown extends Rule[LogicalPlan]
with PredicateHelper {
}
}
+ private def getAdvisoryFilters(sHolder: ScanBuilderHolder): Seq[Expression]
= {
+ sHolder.builder match {
+ case r: SupportsPushDownCatalystFilters =>
+ val advisoryFilters = r.advisoryFilters.filter { filter =>
+ filter.deterministic &&
Review Comment:
Covered by `inferred filters exclude non-deterministic and subquery
expressions` in `DataSourceV2StrategySuite`. It reports predicates containing
`Rand` and `ScalarSubquery`, then checks that the scan's inferred metadata is
empty and neither rejected expression appears in a logical Filter.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/planmerging/PlanMerger.scala:
##########
@@ -778,54 +849,59 @@ class PlanMerger(
relation: DataSourceV2Relation,
unionAttrs: Seq[Attribute],
strictFilters: Seq[Expression],
+ advisoryFilters: Seq[Expression],
bestEffortFilter: Option[Expression]): Option[DataSourceV2ScanRelation]
= {
val relationOut = relation.outputSet
- // Defensive: strict filters come from `pushedFilters`, which reference
only relation columns,
- // so this holds today. If a future caller offers a filter over
non-relation attributes, decline
- // the merge rather than build an unsound scan (there is no fallback above
the leaf).
- if (!strictFilters.forall(_.references.subsetOf(relationOut))) {
+ // Defensive: both filter groups reference the relation's full output
today. If a future caller
+ // offers one over non-relation attributes, decline rather than build an
unsound scan.
+ if (!(strictFilters ++
advisoryFilters).forall(_.references.subsetOf(relationOut))) {
return None
}
// `unionAttrs` are the relation's own attributes (the caller builds the
union in the relation's
- // space), so they are the projection directly.
- // strictFilters are enforced; the bestEffortFilter is offered too, but
only when it is
+ // space), so they are the projection directly. Strict and advisory
filters must both come back
+ // fully pushed by the rebuilt scan (checked below); advisory filters are
safe to offer because
+ // both inputs guarantee them. The best-effort filter is offered too, but
only when it is
// expressible over the relation -- a condition referencing propagated
boolean filter aliases
// rather than relation columns is dropped. A non-deterministic predicate
needs no handling
- // here: SPARK-58207 keeps non-deterministic filters from being pushed to
a V2 source, so an
- // offered one is simply not pushed (and dropped when the scan is
extracted).
- val conds = strictFilters ++
bestEffortFilter.filter(_.references.subsetOf(relationOut))
+ // here: SPARK-58207 keeps non-deterministic filters from being pushed to
a V2 source.
+ val conds = strictFilters ++ advisoryFilters ++
+ bestEffortFilter.filter(_.references.subsetOf(relationOut))
V2ScanRelationPushDown.rebuildScan(relation, unionAttrs, conds).filter {
scan =>
// The rebuilt scan must itself be mergeable. rebuildScan re-runs the
full pushdown, so any
// non-reproducible pushdown it introduces would make the merged scan
unsound. Today's
// Project-over-Filter input only triggers the plain path (always
mergeable), so this is
// defensive: it re-validates the rebuild's output against the same gate
applied to its
// inputs, rather than trusting the rebuild if its input plan ever
broadens.
+ val freshPushed = ExpressionSet(scan.pushedFilters)
scan.mergeableScan &&
- // Every intended-strict filter must be fully enforced by the rebuilt
scan (nothing above
- // re-checks it), and the scan must produce exactly the requested
union of columns.
- strictFilters.forall(ExpressionSet(scan.pushedFilters).contains) &&
+ // Every intended-strict and advisory filter must be fully enforced by
the rebuilt scan.
+ // Nothing above the leaf re-checks the strict filters, while full
pushdown is what allows
+ // the advisory guarantees to transfer from both input scans to the
fresh merged scan.
+ (strictFilters ++ advisoryFilters).forall(freshPushed.contains) &&
scan.outputSet == AttributeSet(unionAttrs)
}.map { scan =>
+ val confirmedScan = if (advisoryFilters.nonEmpty) {
Review Comment:
The advisory-specific reset/subtraction implementation from this version has
been removed. `DataSourceV2Strategy` no longer subtracts inferred predicates
from post-scan filters, so freshly reported inferred metadata cannot remove a
user residual. Opted-in scans consume that metadata directly for statistics.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/V2ScanRelationPushDown.scala:
##########
@@ -1261,6 +1289,37 @@ object V2ScanRelationPushDown extends Rule[LogicalPlan]
with PredicateHelper {
}
}
+ private def getAdvisoryFilters(sHolder: ScanBuilderHolder): Seq[Expression]
= {
+ sHolder.builder match {
+ case r: SupportsPushDownCatalystFilters =>
+ val advisoryFilters = r.advisoryFilters.filter { filter =>
+ filter.deterministic &&
+ !SubqueryExpression.hasSubquery(filter) &&
+ !filter.containsPattern(EXTERNAL_UDF) &&
+ !filter.exists(_.isInstanceOf[UserDefinedExpression])
+ }
+ rebindFilters(advisoryFilters,
sHolder.output).flatMap(splitConjunctivePredicates)
+ case _ =>
+ Nil
+ }
+ }
+
+ private def rebindFilters(
+ filters: Seq[Expression],
+ output: Seq[AttributeReference]): Seq[Expression] = {
+ val outputPlan = LocalRelation(output)
+ filters.map(_.transformUp {
+ case attr: AttributeReference =>
+ outputPlan.resolveQuoted(attr.name, conf.resolver) match {
+ case Some(Alias(child, _)) => child
+ case Some(resolved) => resolved.toAttribute
+ case None =>
+ throw SparkException.internalError(
+ s"Cannot resolve Catalyst filter attribute '${attr.name}'")
Review Comment:
The current code drops unknown and ambiguous names with warnings, then
validates the rebound expression for resolution, Boolean type, and input types.
`inferred filters ignore unresolvable and ill-typed expressions` covers those
cases, including a non-Boolean expression. The additional field-ordinal and
`Unevaluable` cases from your later review are still open.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/planmerging/PlanMerger.scala:
##########
@@ -746,25 +774,68 @@ class PlanMerger(
// np.output to cp's relation attributes is consistent with the eventual
built scan.
Some(TryMergeResult(cp.relation, npMapping,
dsv2DeferredScan = Some(DSv2DeferredScan(unionAttrs, cp.pushedFilters,
- expectedKeyGroupedPartitioning, expectedOrdering)), dsv2Merged =
true))
+ expectedAdvisoryFilters, expectedKeyGroupedPartitioning,
expectedOrdering)),
+ dsv2Merged = true))
} else {
// No enclosing Filter: build the merged scan here enforcing the (equal)
strict filters over
// the union of columns, with no best-effort filter (no post-scan Filter
to prune on).
- tryBuildMergedDSv2Scan(cp.relation, unionAttrs, cp.pushedFilters,
bestEffortFilter = None)
+ tryBuildMergedDSv2Scan(
+ cp.relation,
+ unionAttrs,
+ cp.pushedFilters,
+ expectedAdvisoryFilters,
+ bestEffortFilter = None)
.filterNot(mergeDegradesReporting(_, expectedKeyGroupedPartitioning,
expectedOrdering))
.map(TryMergeResult(_, npMapping, dsv2Merged = true))
}
}
+ /** Removes scan advisory predicates from a condition before it becomes
executable projection. */
+ private def removeAdvisoryConjuncts(
+ condition: Expression,
+ advisoryFilters: Seq[Expression]): Expression = {
+ val advisorySet = ExpressionSet(advisoryFilters)
Review Comment:
`removeAdvisoryConjuncts` was removed along with the advisory-specific
`PlanMerger` changes. This PR now has no diff in `PlanMerger`, so this
empty-advisory path no longer rewrites propagated conjunctions.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/V2ScanRelationPushDown.scala:
##########
@@ -1019,11 +1049,18 @@ object V2ScanRelationPushDown extends Rule[LogicalPlan]
with PredicateHelper {
// merge unsound. See DataSourceV2ScanRelation.pushedFilters.
val scanRelation = DataSourceV2ScanRelation(sHolder.relation,
wrappedScan, output,
pushedFilters = sHolder.pushedFilterExpressions,
+ inferredFilters = remappedInferredFilters,
// The one site that grants mergeability: a plain scan carrying only
reproducible pushdowns
// (column pruning + deterministic filters) may be fused. See
hasBlockingPushdown.
mergeableScan = !hasBlockingPushdown(sHolder))
- val finalFilters = normalizedFilters.map(projectionFunc)
+ val inferredAdjustmentFilters =
Review Comment:
Implemented the minimum-estimate path in 3088868b60c. For opted-in scans,
Spark estimates the original group (fully pushed predicates plus residuals,
including those above deterministic projections) and the inferred group
independently from the same unadjusted scan statistics. It selects the smaller
row-count estimate together with that estimate's size and column statistics.
The correlated-column regression asserts 101 rows for this example, and
separate coverage checks the benefit when the original predicate is opaque to
the estimator. Inferred predicates stay on scan metadata in this mode.
This is controlled by
`SupportsReportStatistics.useInferredFilterEstimation()`, defaulting to `false`
for compatibility. The new estimation requires CBO and
`reflectsFullyPushedDownFilters() == false`; scans that do not opt in retain
the ordinary logical-filter path.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/V2ScanRelationPushDown.scala:
##########
@@ -1261,6 +1292,78 @@ object V2ScanRelationPushDown extends Rule[LogicalPlan]
with PredicateHelper {
}
}
+ private def shouldAddSparkPostPushdownAdjustmentFilters(scan: Scan): Boolean
= scan match {
+ case s: SupportsReportStatistics => !s.reflectsFullyPushedDownFilters()
+ case _ => false
+ }
+
+ private def getInferredFilters(sHolder: ScanBuilderHolder): Seq[Expression]
= {
+ sHolder.builder match {
+ case r: SupportsPushDownCatalystFilters =>
+ val validFilters = r.inferredFilters
+ .flatMap(rebindInferredFilter(_, sHolder.output))
+ .filter(validateInferredFilter(_, sHolder.output))
+ .flatMap(splitConjunctivePredicates)
+ ExpressionSet(validFilters).toSeq
+ case _ =>
+ Nil
+ }
+ }
+
+ private def validateInferredFilter(
+ filter: Expression,
+ output: Seq[AttributeReference]): Boolean = {
+ val hasInvalidColumnReference = filter.exists {
+ case _: AttributeReference => false
+ case _: Attribute | _: BoundReference | _: LateralColumnAliasReference |
Review Comment:
Agreed. The reported expression still needs a check for `GetStructField` and
`GetArrayStructFields` before rebinding, while allowing the extractors Spark
creates when resolving dotted names. The estimation refactoring does not fix
this validation gap; this remains open.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]