Skip to content

Commit f5f60d4

Browse files
committed
Allow file filtering with partial stats over AND predicates
1 parent 10d71ab commit f5f60d4

3 files changed

Lines changed: 78 additions & 31 deletions

File tree

spark/src/main/scala/org/apache/spark/sql/delta/stats/DataFiltersBuilder.scala

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ class DataFiltersBuilder(
102102
filters.reduceOption { (a, b) =>
103103
(a, b) match {
104104
case (Some(a), Some(b)) =>
105-
Some(DataSkippingPredicate(a.expr || b.expr, a.referencedStats ++ b.referencedStats))
105+
Some(DataSkippingPredicate.or(a, b))
106106
case _ => None
107107
}
108108
}.flatten
@@ -263,9 +263,7 @@ class DataFiltersBuilder(
263263
val e1Filter = constructDataFilters(e1, isNullExpansionDepth)
264264
val e2Filter = constructDataFilters(e2, isNullExpansionDepth)
265265
if (e1Filter.isDefined && e2Filter.isDefined) {
266-
Some(DataSkippingPredicate(
267-
e1Filter.get.expr && e2Filter.get.expr,
268-
e1Filter.get.referencedStats ++ e2Filter.get.referencedStats))
266+
Some(DataSkippingPredicate.and(e1Filter.get, e2Filter.get))
269267
} else if (e1Filter.isDefined) {
270268
e1Filter
271269
} else {
@@ -312,9 +310,7 @@ class DataFiltersBuilder(
312310
val e1Filter = constructDataFilters(e1, isNullExpansionDepth)
313311
val e2Filter = constructDataFilters(e2, isNullExpansionDepth)
314312
if (e1Filter.isDefined && e2Filter.isDefined) {
315-
Some(DataSkippingPredicate(
316-
e1Filter.get.expr || e2Filter.get.expr,
317-
e1Filter.get.referencedStats ++ e2Filter.get.referencedStats))
313+
Some(DataSkippingPredicate.or(e1Filter.get, e2Filter.get))
318314
} else {
319315
None
320316
}
@@ -760,4 +756,3 @@ class DataFiltersBuilder(
760756
true
761757
}
762758
}
763-

spark/src/main/scala/org/apache/spark/sql/delta/stats/DataSkippingReader.scala

Lines changed: 58 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -96,27 +96,63 @@ object StatsColumn {
9696
* NOTE: It would be more accurate to call these "file keeping" predicates, because they specify the
9797
* set of files a query must examine, not the set of rows a query can safely skip.
9898
*/
99-
private [sql] case class DataSkippingPredicate(
100-
expr: Column,
101-
referencedStats: Set[StatsColumn]
102-
)
99+
private [sql] class DataSkippingPredicate private(
100+
val expr: Column,
101+
val referencedStats: Set[StatsColumn],
102+
validatedExpr: (Set[StatsColumn] => Column) => Column) {
103+
/**
104+
* Returns a file-level predicate that is safe to evaluate against stats.
105+
*
106+
* `expr` can only be trusted when all referenced stats are present for a file. This method adds
107+
* the required missing-stats checks while preserving the predicate's boolean shape, so one side
108+
* of an AND can still skip a file when the other side's stats are missing.
109+
*/
110+
def withStatsValidation(verifyStatsForFilter: Set[StatsColumn] => Column): Column = {
111+
validatedExpr(verifyStatsForFilter)
112+
}
113+
}
103114

104-
/**
105-
* Overloads the constructor for `DataSkippingPredicate`, allowing callers to pass referenced stats
106-
* as individual arguments, rather than wrapped up as a Set.
107-
*
108-
* For example, instead of this:
109-
*
110-
* DataSkippingPredicate(pred, Set(stat1, stat2))
111-
*
112-
* We can just do:
113-
*
114-
* DataSkippingPredicate(pred, stat1, stat2)
115-
*/
116115
private [sql] object DataSkippingPredicate {
116+
def apply(filters: Column, referencedStats: Set[StatsColumn]): DataSkippingPredicate = {
117+
new DataSkippingPredicate(
118+
filters,
119+
referencedStats,
120+
verifyStatsForFilter => filters || !verifyStatsForFilter(referencedStats))
121+
}
122+
123+
/**
124+
* Overloads the constructor for `DataSkippingPredicate`, allowing callers to pass referenced
125+
* stats as individual arguments, rather than wrapped up as a Set.
126+
*
127+
* For example, instead of this:
128+
*
129+
* DataSkippingPredicate(pred, Set(stat1, stat2))
130+
*
131+
* We can just do:
132+
*
133+
* DataSkippingPredicate(pred, stat1, stat2)
134+
*/
117135
def apply(filters: Column, referencedStats: StatsColumn*): DataSkippingPredicate = {
118136
DataSkippingPredicate(filters, referencedStats.toSet)
119137
}
138+
139+
def and(left: DataSkippingPredicate, right: DataSkippingPredicate): DataSkippingPredicate = {
140+
new DataSkippingPredicate(
141+
left.expr && right.expr,
142+
left.referencedStats ++ right.referencedStats,
143+
verifyStatsForFilter =>
144+
left.withStatsValidation(verifyStatsForFilter) &&
145+
right.withStatsValidation(verifyStatsForFilter))
146+
}
147+
148+
def or(left: DataSkippingPredicate, right: DataSkippingPredicate): DataSkippingPredicate = {
149+
new DataSkippingPredicate(
150+
left.expr || right.expr,
151+
left.referencedStats ++ right.referencedStats,
152+
verifyStatsForFilter =>
153+
left.withStatsValidation(verifyStatsForFilter) ||
154+
right.withStatsValidation(verifyStatsForFilter))
155+
}
120156
}
121157

122158
/**
@@ -619,13 +655,14 @@ trait DataSkippingReaderBase
619655
val (partitionSize, partitionFilter) = buildSizeCollectorFilter()
620656
val (scanSize, scanFilter) = buildSizeCollectorFilter()
621657

622-
// NOTE: If any stats are missing, the value of `dataFilters` is untrustworthy -- it could be
623-
// NULL or even just plain incorrect. We rely on `verifyStatsForFilter` to be FALSE in that
624-
// case, forcing the overall OR to evaluate as TRUE no matter what value `dataFilters` takes.
658+
// NOTE: If a filter's required stats are missing, that filter's value is untrustworthy -- it
659+
// could be NULL or even just plain incorrect. Missing stats force that filter's file-keeping
660+
// predicate to TRUE, but compound filters preserve their boolean shape. This lets one side of
661+
// an AND still prune a file even if the other side's stats are missing.
625662
val filteredFiles = withStats.where(
626663
totalFilter(trueLiteral) &&
627664
partitionFilter(partitionFilters) &&
628-
scanFilter(dataFilters.expr || !verifyStatsForFilter(dataFilters.referencedStats))
665+
scanFilter(dataFilters.withStatsValidation(verifyStatsForFilter))
629666
)
630667

631668
val statsColumn = if (keepNumRecords) {
@@ -796,9 +833,7 @@ trait DataSkippingReaderBase
796833

797834
val finalSkippingFilters = skippingFilters
798835
.map(_._2.get)
799-
.reduceOption((skip1, skip2) => DataSkippingPredicate(
800-
// Fold the filters into a conjunction, while unioning their referencedStats.
801-
skip1.expr && skip2.expr, skip1.referencedStats ++ skip2.referencedStats))
836+
.reduceOption(DataSkippingPredicate.and)
802837
.getOrElse(DataSkippingPredicate(trueLiteral))
803838

804839
val (files, sizes) = {

spark/src/test/scala/org/apache/spark/sql/delta/stats/DataSkippingDeltaTests.scala

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -888,6 +888,23 @@ trait DataSkippingDeltaTestsBase extends QueryTest
888888
checkAnswer(df.where("value > 0"), Seq(Row(1), Row(2), Row(3)))
889889
}
890890

891+
test("data skipping with partially missing stats in AND") {
892+
val tempDir = Utils.createTempDir()
893+
894+
withSQLConf(getDataSkippingConfs(indexedCols = 1, deltaStatsColNamesOpt = None).toSeq: _*) {
895+
Seq((1, 10)).toDF("a", "b").coalesce(1).write.format("delta").save(tempDir.toString)
896+
}
897+
898+
setNumIndexedColumns(tempDir.toString, numIndexedCols = 2)
899+
Seq((2, 20)).toDF("a", "b").coalesce(1).write.format("delta").mode("append")
900+
.save(tempDir.toString)
901+
902+
val log = DeltaLog.forTable(spark, new Path(tempDir.toString))
903+
904+
assert(filesRead(log, "a < 0 AND b < 100") == 0)
905+
assert(filesRead(log, "a > 0 AND b < 100") == 2)
906+
}
907+
891908
test("data skipping stats before and after optimize") {
892909
assume(!catalogOwnedDefaultCreationEnabledInTests,
893910
"OPTIMIZE is blocked on catalog-managed tables")

0 commit comments

Comments
 (0)