Parquet: Fix initial-default rows dropped when filtering on the defaulted column#16692
Parquet: Fix initial-default rows dropped when filtering on the defaulted column#16692cbb330 wants to merge 12 commits into
Conversation
dbc0dff to
bbafc90
Compare
…lted column A column added by schema evolution with an initial-default is backfilled with the default at read time, but ParquetMetricsRowGroupFilter evaluates predicates against a column that is absent from a data file as if it were all-null. For a file written before the column existed, a predicate referencing the column -- including the IsNotNull engines infer for null-intolerant predicates -- skipped the row group, silently dropping exactly the rows the default backfills. The full scan was correct, so the case was untested and unobserved. Make the row-group metrics filter default-aware: when a predicate references a column that is missing from the file but carries an initialDefault, evaluate it against the default value instead of assuming null. The filter already receives the table schema carrying the default, so this is a single check at the predicate dispatch point. Columns present in the file, and missing columns without a default, are unchanged. Closes apache#16690 Signed-off-by: Christian Bush <chbush@linkedin.com>
bbafc90 to
8326eb7
Compare
|
I've gone ahead and added this to the 1.10.3 milestone since it's a correctness fix. I do think though this needs to be fixed differently by using the parquet schema as a source of truth rather than checking the stats. |
…e in row-group filter Determine whether a column is missing from a file from the Parquet file schema rather than the column statistics. A column can be present in a file yet lack stats, so the stats maps cannot decide presence. A predicate on a column absent from the file with a non-null initial-default is evaluated against the default; a column present in the file is filtered on its real values. Adds a test that a present-but-no-stats column is filtered on stats rather than evaluated against its default.
…alue-fix Parquet pruning default value fix
| } | ||
|
|
||
| @TestTemplate | ||
| public void testColumnNotInFileWithInitialDefault() { |
There was a problem hiding this comment.
Since the unit test only asserts eq/notEq/isNull/notNull against the default, it might be worth adding one range and one set case for the absent-default column, e.g. in("country", "US", "MX") -> read and in("country", "CA", "MX") -> skip, plus lessThan("country", "AA") -> skip.
There was a problem hiding this comment.
Would be great to land this in prior to merge since it's a small change to the test case. It looks good to me.
…ult column The unit test only asserted eq/notEq/isNull/notNull against the default. Range (lt/gtEq) and set (in/notIn) predicates dispatch through the same predicate() fold, so cover them explicitly to guard the dispatch path. Co-authored-by: Isaac
amogh-jahagirdar
left a comment
There was a problem hiding this comment.
I'll go ahead and approve this. I'll give it a day or so for any others to review cc @pvary @RussellSpitzer @stevenzwu @nssalian . If we're able to merge the test assertions by EOD today that's great, otherwise i think it's best to do a fast follow and in the backport we can include both. I'm largely trying to move the 1.10.3 release along as it's been some time.
…ter-tests Parquet: Add range and set predicate coverage for absent initial-default column
nssalian
left a comment
There was a problem hiding this comment.
thanks for fixing the tests @cbb330 and @amogh-jahagirdar.
| StructType struct = schema.asStruct(); | ||
| this.expr = Binder.bind(struct, Expressions.rewriteNot(unbound), caseSensitive); | ||
| this.initialDefaults = Maps.newHashMap(); | ||
| for (Types.NestedField field : schema.columns()) { |
There was a problem hiding this comment.
schema.columns() walks only top-level fields. nested field will still have the same problem. Consider iterating all fields via TypeUtil.indexById(struct).
I am also wondering if we should just call schema.findField(id) inside predicate(). Since findField(id) internally uses lazyIdToField() with a cached map, it is unnecessary to keep another <id, initialDefault> here.
There was a problem hiding this comment.
Yeah we should just use the existing map to lookup by ID, no need for the extra mapping as it doesn't even save anything when we already need to keep all the field IDs in memory anyway. That addresses the nested field issue as well
| } | ||
|
|
||
| @TestTemplate | ||
| public void testColumnNotInFileWithInitialDefault() { |
There was a problem hiding this comment.
A few paths that flow through the new predicate() override aren't exercised here. All three can reuse this test's 'field id not in the file' pattern with fresh ids — no writer setup, no new fixture:
startsWith/notStartsWithoncountry:startsWith("U")should match,startsWith("X")should skip; symmetric fornotStartsWith.- One non-string default.
pred.test((T) initialDefault)receives the internal Iceberg representation —Integerfor date,Longfor timestamp,BigDecimalfor decimal,ByteBufferfor binary. String hides that contract because Java value and internal repr coincide; e.g. anIntegerTypefield (id 100) withLiteral.of(42)againstlt/gt/eqwould guard the unchecked cast against a future representation change. - A
DoubleTypefield (id 101) with a non-NaN default — enablesisNaN(skip) andnotNaN(match), which the current coverage skips entirely.
There was a problem hiding this comment.
Thanks for the suggestion, Steven. Added all three:
- startsWith/notStartsWith on country ("U" matches and "X" skips, with symmetric notStartsWith cases).
- A DateType field using its raw Integer day-ordinal representation for lt/gt/eq, guarding the unchecked cast.
- A DoubleType field with a non-NaN default covering isNaN (skip) and notNaN (match).
All follow the existing “field ID absent from the file schema” pattern with no new fixtures. Happy to add decimal or binary coverage if useful.
| table.updateSchema().addColumn("c", Types.StringType.get(), Expressions.lit("US")).commit(); | ||
| sql("REFRESH TABLE %s", tableName); | ||
|
|
||
| sql("INSERT INTO %s VALUES (2, 'Bob', 'US')", tableName); |
There was a problem hiding this comment.
I would suggest also include a row with non-US values to cover the case that actual rows are read from the file
There was a problem hiding this comment.
Added (3, 'Eve', 'CA') alongside the existing US row in the same insert, so filtering exercises physically stored matching and non-matching values in addition to the initial-default path.
…ter-tests fix nested field case
Cover additional predicate types and physical non-default values to guard row-group pruning behavior. Co-authored-by: Cursor <cursoragent@cursor.com>
Use a date field to verify predicate evaluation handles its internal integer representation. Co-authored-by: Cursor <cursoragent@cursor.com>
stevenzwu
left a comment
There was a problem hiding this comment.
LGTM. thanks for expanding the test coverage.
|
@cbb330 can you fix the conflict? |
|
@stevenzwu GitHub currently shows the PR as cleanly mergeable, and I can merge current main locally without conflicts. Are you still seeing a conflict? If so, could you point me to it? |
| Types.NestedField field = schema.findField(id); | ||
| if (field != null && field.initialDefault() != null && !valueCounts.containsKey(id)) { | ||
| return pred.test((T) field.initialDefault()) ? ROWS_MIGHT_MATCH : ROWS_CANNOT_MATCH; | ||
| } |
There was a problem hiding this comment.
How is this behaves with structs and defaults for structs?
What
A column added by schema evolution with an
initial-defaultis backfilled with the default at readtime, but
ParquetMetricsRowGroupFilterevaluates predicates against a column that is absent froma data file as if it were all-null. For a file written before the column existed, a predicate
referencing the column — including the
IsNotNullengines infer for null-intolerant predicates —skipped the row group, silently dropping exactly the rows the default backfills. The full scan
was correct, so the case was untested and unobserved.
Fixes #16690.
Repro (Spark 3.5, Parquet, format-version 3)
SELECT id, c(1,US),(2,US)✅(1,US),(2,US)WHERE c = 'US'(2)❌(1),(2)✅WHERE upper(c) = 'US'(2)❌(1),(2)✅WHERE c IS NOT NULL(2)❌(1),(2)✅WHERE c = 'CA'()()✅ (absent file still excluded)WHERE c IS NULL()()✅Fix
The drop happens in
ParquetMetricsRowGroupFilter, whose per-predicate handlers treat a columnmissing from the file as all-null (
// the column is not present and is all nulls). The filter isalready constructed with the table read schema, which carries
initialDefault, so it has everythingit needs to be correct.
Override the single predicate dispatch point: when a predicate references a column that is missing
from the file but has an
initialDefault, evaluate it against the default value(
pred.test(default)) instead of falling through to the null-assuming handlers.c = 'US'→ROWS_MIGHT_MATCH,c = 'CA'→ROWS_CANNOT_MATCH,IsNotNull(c)→ match,IsNull(c)→ skip.Columns present in the file, and missing columns without a default, are unchanged — so present-column
files still prune on real stats, and existing missing-column behavior is preserved.
This mirrors how the filter already gets the schema; it's a fix at the source rather than a rewrite
of the predicate at the call site.
Testing
TestMetricsRowGroupFilter#testColumnNotInFileWithInitialDefault(Parquet) — unit coverage for thefold: match/non-match/
IsNull/IsNotNull/notEqualagainst the default.TestDefaultValuesFilteredRead(Spark 3.5) — end-to-end; fails onmainwithout the fix.:iceberg-parquet:test,:iceberg-data:test TestMetricsRowGroupFilter(Parquet + ORC), and:iceberg-arrow:testpass.Scope / notes
ParquetReaderandVectorizedParquetReaderboth share this filter viaReadConf, so the row andvectorized read paths are both fixed.
no change (verified by the end-to-end test).
injected), so it is already correct. ORC throws
UnsupportedOperationExceptionon reading aninitial-default column, so there is no silent drop there.
ParquetFilters.convert) shares the same missing-column assumptionbut is only reached via the
@DeprecatedreadSupportreader, not by any engine read; it can getthe same treatment as a follow-up if desired.