A pushed-down WHERE no longer widens the parquet projection - #724
Conversation
`parquetDataSource.scan` dropped the engine's column projection whenever a
WHERE converted to a parquet filter:
const readColumns = filter ? undefined : hints.columns
`undefined` means "read every column", so any filtered scan decoded the whole
row, payload columns included. On a table where a few columns carry most of
the bytes that is the difference between reading a projection and reading the
file. Measured on a synthetic three-column file (two thin columns, one fat
text column, 2000 rows): 8,009,294 bytes read for `SELECT model WHERE role =
'user'` against 596 bytes for the same query honoring the projection, same
1000 rows out.
The original reason no longer holds. A filter can reference columns the SELECT
does not, and reading everything is the always-correct blunt answer, but two
layers now cover that case independently. squirreling folds WHERE identifiers
into the scan projection when it plans (`collectColumnsFromExpr(select.where,
identifiers)`), and hyparquet unions `columnsNeededForFilter` into its own read
plan and deletes the extras from the rows it returns before handing them back
(`requiresProjection`, hyparquet >= 1.28.1, already the pinned version). So
passing the narrow projection alongside a filter is safe, not merely cheaper.
Two adjacent worries checked rather than assumed. Additive schema drift is not
a hazard: hyparquet ignores a projected column the file lacks instead of
throwing, and `unionSources` already refuses to push a predicate to a partition
missing its columns. `filterStrict: false` governs `==` against `===` when
comparing values, not whether the filter is fully applied, so `matchFilter`
still runs per row and `appliedWhere: true` stays honest.
Scope is narrower than the shape of the bug suggests. Neither the local cache
nor the server archive reaches this code: both go through icebird's
`icebergDataSource`, which already keeps the projection and adds filter columns
on top. The only caller is the s3 plugin's `format: 'parquet'` datasets.
The existing tests pass either way, because they assert the rows are right and
they stay right when the scan reads everything and the engine projects
afterwards. The new test asserts the narrow read directly, in what the scan
emits and in the bytes it pulls off the file, and fails on the old line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…et test unionSources always reports appliedWhere: false, handing filters back to the engine to re-apply over the merged stream. Since the projection pushdown fix now emits exactly the requested columns, that re-apply only works because squirreling folds WHERE columns into the projection it hands to scan(). Add an end-to-end test over two real parquet partitions that pins this, and note the dependency in both parquet-source.js and union-source.js comments.
|
Review round 1 of The core change is correct. The reviewer could not produce a single row-level or value-level divergence between the pre-fix and post-fix scan across 35 queries covering NULLs, aggregates, 1. minor - the union's re-filter input set is now narrowed, and nothing pinned the invariant that keeps it safe. FIXED
Not a live bug: squirreling's Fixed by pinning the invariant where it is actually depended on: a new test unions two real parquet partitions and runs Also checked, clean
Gates at the new head: Separately: this review found a live bug on
|
…lure mode The new end-to-end test was annotated `@ref LLP 0098#union-flags`, but 0098's union flag merging covers the `scanColumn` path and settles that the union's `appliedWhere` is the AND across partitions. This test never reaches that path: `parquetDataSource` defines no `scanColumn`, so `unionSources` installs no hook, and the query is not an aggregate. The invariant it pins is stated verbatim in LLP 0015 `#multi-partition-union` (unconditional `appliedWhere: false`, engine re-applies over the merged stream), which is already the ref on the construct under test in `src/core/query/union-source.js`. Also correct the comment's stated failure mode. If squirreling stopped folding WHERE columns into the projection, squirreling's `asyncRow` raises `ColumnNotFoundError` when the engine re-filters on a column the rows no longer carry, so the break is loud at query time, not silent wrong rows. The overstated clause would skew a future squirreling upgrade calculus toward "silent data corruption" when the true exposure is caught by the first query. Comment and annotation text only; no assertions or code changed.
|
Review round 2 of 1. minor - the
|
|
Triage after the review budget (LLP 0017). Two review rounds ran (3 findings, all fixed). Both residuals were judged non-blocking, so this PR can ship. Deferred to #731. (A) The LLP 0015 doc-versus-behaviour gap - the triage reproduced it independently on a two-partition drift fixture, then reverted this PR's line and re-ran the identical fixture, getting byte-identical output. So the false invariant is neither introduced nor worsened here. It also traced why: (B) The inline Blocking a verified 170x latency and 1800x memory fix on either would be a poor trade. |
Keeps both intents in the union's `columns`-forwarding comment: this branch's corrected absent-column contract (undefined-or-throws, with the three `executeProject`/`collect()` conditions and the `resolveable` gate) and #724's clause on the narrowed projection determining what the engine re-filters on. Both test files converge on the shared `test/helpers/parquet_source_fixture.js` rather than keeping master's re-copied inline parquet fixtures. Re-verified the documented contract against the post-merge tree: with #724's `readColumns = hints.columns`, a partition asked for a column it lacks still emits only the columns it has, so a bare projection still reads `undefined` even under a pushed-down WHERE.
What
parquetDataSource.scanthrew away the engine's column projection whenever aWHEREconverted to a parquet filter:undefinedmeans "read every column" to hyparquet, so any filtered scan decoded the whole row, payload columns included. The fix is to pass the projection unconditionally.Measured on a synthetic three-column file (two thin columns, one fat text column, 2000 rows),
SELECT model ... WHERE role = 'user':Same 1000 rows out both times.
Why it is safe, not just cheaper
A filter can name columns the
SELECTdoes not, and "read everything" was the always-correct blunt answer to that. Two layers now cover it independently:WHEREidentifiers into the scan projection at plan time (collectColumnsFromExpr(select.where, identifiers)inplan/columns.js).columnsNeededForFilterinto its own read plan, then deletes the extras from the rows it returns (requiresProjectioninread.js). This needs hyparquet >= 1.28.1, which is already the pinned version.Two adjacent worries were checked rather than assumed:
unionSourcesalready declines to push a predicate to a partition missing its columns.filterStrict: falsegoverns==against===when comparing values, not whether the filter is fully applied.matchFilterstill runs per row, soappliedWhere: truestays honest.Scope
Narrower than the shape of the bug suggests, and worth stating plainly: neither the local cache nor the server archive reaches this code. Both go through icebird's
icebergDataSource, which already keeps the projection and adds filter columns on top. The only caller is the s3 plugin'sformat: 'parquet'datasets (plugins-workspace/s3/src/query-dataset.js). This is a real fix on a real path, not the fleet-wide query win the line's shape implies.Testing
The existing tests pass either way: they assert the rows are right, and the rows stay right when the scan reads everything and the engine projects afterwards. The new test asserts the narrow read directly, both in what the scan emits and in the bytes it pulls off the file.
test/core/parquet-source.test.js16/16, including the newa pushed-down filter does not widen the projection, verified to fail against the old linetest/core/union-source.test.js15/15,test/plugins/s3-query-dataset.test.js6/6npm run typecheckcleannpm test3942/3945 pass. The one failure (leave tears down a central layer whose active-slot pointer does not resolve (#623)) reproduces on clean master and is unrelated.Note for reviewers
No LLP was minted. The comment being replaced encoded the old decision, and the replacement comment plus this commit message carry the new one; a one-line change did not seem to warrant its own
decisiondoc. Happy to add one if you disagree.🤖 Generated with Claude Code
Fixes #729