Pin cross-backend query parity and icebird 0.8.22's pushdown contract (tests only) - #751
Pin cross-backend query parity and icebird 0.8.22's pushdown contract (tests only)#751philcunliffe wants to merge 4 commits into
Conversation
neutral review - round 1Head reviewed: Two minor findings, no blockers. The design's load-bearing claim, that Finding 1 is the one place the PR's own headline overreaches, and it is exactly at VERDICT: findings 1. minor — The LLP's Consequences assert cross-backend parity unconditionally: "A predicate over Judgement call 5 keeps forwarding the Why it matters: the direction is safe (pruning can only remove rows icebird's filter excludes, and that set contains no SQL-TRUE row, so the cache lands between SQL and the parquet path — exactly what judgement call 5 of the PR body says, correctly). No rows SQL selects are lost. But an Accepted LLP now records a parity claim that does not hold, and the corpus contains no Exact fix: (a) scope Consequences bullet 1 to "a predicate the kernel converter owns"; (b) amend bullet 3 to state that for a declined predicate the cache path may return fewer rows than the parquet path, because the pruning hint drops UNKNOWN rows the engine's two-valued WHERE would have returned, and that this is bounded between SQL's answer and the parquet path's; (c) add a typed-literal case to 2. minor — The s3 branch ships with zero test coverage; the author lists "no s3 round-trip" under "What I could NOT verify." I verified it. The change is correct and the s3 iceberg path is NULL-correct end to end ( Exact fix: add that round-trip to The superset argument, attackedThe design stands or falls on: icebird's converted filter never excludes a row SQL says TRUE, at row level or through any pruner. I did not accept LLP 0221 Randomised fuzz. 612 distinct predicates (grammar: comparisons in both operand orders, NULL literals at ~15%, Leaf-by-leaf, reading
Verdict on the argument: it holds. LLP 0221 Also checked, clean
LIMIT/OFFSET (priority 4). Verified over an 8-file / 80-row table with a 1-in-3 NULL pattern: Performance (priority 6). Reproduced the methodology on a 20-file / 100k-row table (
Files opened and bytes read are byte-identical in every shape, including wide rows and the cross-column Tests (priority 7). Re-derived the discrimination independently: with Sequencing hazard with #721 (priority 8). The code carries no guard and LLP 0221 only notes the ordering — but the tests do guard it, more strongly than the doc: if LLP hygiene (priority 9). 0221 is unclaimed (single file, no collision), header conventional ( Conventions (priority 10). No em dashes in any changed file. No trailing semicolons in code (the three Ran: fresh |
… s3 iceberg branch (#744) Review round 1 on PR #751 found the Consequences section overclaiming cross-backend parity unconditionally: for a predicate the kernel converter declines (a CAST/typed-literal operand), icebird's own converter still folds it, so the cache path can return fewer rows than the parquet path (safe direction, not the equality the doc claimed). Scope bullet 1 to predicates the kernel converter owns and rewrite bullet 3 to state the declined-predicate relationship is bounded SQL ⊆ cache ⊆ parquet, backed by a new CAST case in iceberg-source-parity.test.js that asserts the subset chain instead of strict equality. Same fix applied to the PR body's own overreaching #734 bullet. Also closes the s3 iceberg branch's test coverage gap: query-dataset.js wraps its icebergDataSource in withSqlCorrectWhere the same way the local cache does, but had no test proving the remote branch is NULL-correct. Added a round-trip test that writes a real table through a real BlobStore and exercises the same NULL-literal predicates. Co-Authored-By: Claude <noreply@anthropic.com>
|
All checks done, worktree clean. Here is the review record. VERDICT: findings Head reviewed: Three findings, all minor/nit. None is ship-blocking. The round-2 diff is doc + test + PR body only: 1. minor - The I verified every load-bearing step rather than trusting the case's name:
On the silencing question the answer is partly. The Exact fix: (a) add Not ship-blocking: the code is correct and unchanged, the doc now states the true bound, and the shipped case does discriminate against removing the wrapper. This is under-pinning, not a defect. 2. minor - The rewritten bullet 1 reads: "A predicate the kernel converter owns ( That gap is not symmetric across the two backends, and it is measurable. A predicate the kernel converter unambiguously owns, and the two backends return different rows at the source API. It does not surface through SQL - squirreling folds WHERE columns into the projection, so the engine never sends this shape, and judgement call 4 says so - which is why this is minor rather than major. But bullet 1 was rewritten precisely to make "owns it" a crisp checkable condition, and as written it names the wrong condition. I also checked the two edge cases the doc's new bound could have missed, and both are fine:
Exact fix: replace the parenthetical with the real condition, e.g. "( Not ship-blocking. 3. nit - PR body, Three stale or self-contradicting sentences, all introduced by the round-2 changes:
Exact fix: in Not ship-blocking. Round-1 findings, re-derived
Also checked, cleanRegressions in what round 1 cleared. PR body. The LLP hygiene. 0221 is still the only 022x document in a 207-file corpus, no collision. All anchors survived the Consequences rewrite: Conventions. No U+2014 anywhere in the repo ( Ran: fresh |
neutral triage - shipReview budget exhausted at 2 rounds with three residual findings open. Triage Weighed against the baseline, which is the thing that matters here: without this The one finding triage re-derived rather than inherited was the On the missing test tripwire (finding 1): the suite as shipped does catch the Verified at head Sequencing note for whoever merges. PR #721 is now unstuck and under review; it |
Hold before merging: this PR may be obsoleted by #721This PR is held ready-to-merge on a triage that said ship. That triage predates PR #721 ( So the bump closes #744 by itself. This PR's wrapper then converts with icebird's Two assertions here fail outright once #721 lands, rather than merely ceasing to assert.equal(sameColumn.appliedWhere, true) // becomes false
assert.deepEqual(sameColumn.values, []) // becomes [100, null, 300, null, 500]because icebird 0.8.22 declines a NULL-literal comparison rather than folding it Separately, the The choiceBoth PRs fix #744, by different mechanisms. On today's master (icebird 0.8.20) the
Either way, LLP 0221 and LLP 0222 both currently claim to settle #744 by different Neutral is not merging or closing anything here. Full evidence is in the round-1 |
neutral is holding this PR: #721 landed, and this one should not be merged as writtenWhat changed. PR #721 merged to Why this PR cannot proceed unchanged. Everything above the fold still says
What it needs from you. A disposition, and neutral will not pick for you because
Either way LLP 0221 needs a Also note #754, which tracks this PR's own deferred findings, is How to unstick. Reply on this thread, or close the PR. Neutral monitors the |
|
The sequencing hazard called out in "Interaction with #721" resolved the good way: #721 landed together with an icebird bump to 0.8.22, which has the NULL work converged upstream (De Morgan instead of $nor, null guards on $ne/$nin, never-match for NOT IN lists holding NULL, declines answered by the three-valued engine in squirreling 0.15.3, hyparquet 1.28.2 rejecting null cells in bare relational operators). I ran this PR's parity corpus (26 predicates plus the filtered aggregates) against master at 8c08185 through storage.dataSourceForTable and all of them now match SQL, so #744 is closed and the wrapper no longer has a bug to work around. Suggestion for salvaging this PR as a tests-only change, since the parity coverage is the part still worth landing:
Happy to push the reduction here if useful, or it can land as a fresh tests-only PR. |
unstuck, taking the reduction
Taking you up on the offer the other way round: neutral will push the reduction here
On the CAST/typed-literal subset chain: your instinct that it should tighten to full Once it lands, this PR re-enters the ladder at its new head: fresh review round, then |
…tics (#744) Wrap every icebergDataSource so the rows it yields are judged by this repo's WHERE converter instead of icebird's, which converts a NULL-literal comparison to IS NULL semantics, pushes unguarded inequalities, and complements a negated OR two-valued - all three claimed as appliedWhere, so the engine never re-filtered and the wrong answer was final. The predicate is still forwarded to icebird as a pruning-only hint (its filter is always a superset of SQL's answer), so no file or row-group pruning is lost. Co-Authored-By: Claude <noreply@anthropic.com>
… s3 iceberg branch (#744) Review round 1 on PR #751 found the Consequences section overclaiming cross-backend parity unconditionally: for a predicate the kernel converter declines (a CAST/typed-literal operand), icebird's own converter still folds it, so the cache path can return fewer rows than the parquet path (safe direction, not the equality the doc claimed). Scope bullet 1 to predicates the kernel converter owns and rewrite bullet 3 to state the declined-predicate relationship is bounded SQL ⊆ cache ⊆ parquet, backed by a new CAST case in iceberg-source-parity.test.js that asserts the subset chain instead of strict equality. Same fix applied to the PR body's own overreaching #734 bullet. Also closes the s3 iceberg branch's test coverage gap: query-dataset.js wraps its icebergDataSource in withSqlCorrectWhere the same way the local cache does, but had no test proving the remote branch is NULL-correct. Added a round-trip test that writes a real table through a real BlobStore and exercises the same NULL-literal predicates. Co-Authored-By: Claude <noreply@anthropic.com>
…LLP 0222) PR #721 bumped icebird 0.8.20 to 0.8.22, hyparquet 1.28.1 to 1.28.2 and squirreling 0.15.2 to 0.15.3, and replaced this repo's WHERE converter with icebird's. The NULL work converged upstream, so issue #744 is closed by that bump and `withSqlCorrectWhere` has no bug left to work around: it would only re-apply icebird's own filter to rows that already passed it, which is the per-row materialization LLP 0098 exists to prevent. Deleted: `src/core/query/iceberg-source.js`, its export in `src/core/query/index.js`, the `store.js` and s3 `query-dataset.js` wiring, LLP 0221, and the LLP 0098 forward-ref. LLP 0222 records where converter ownership now lives. Kept, as the durable value: the cross-backend parity corpus, rebuilt through `dataSourceForTable` directly. Every expected row set is SQL's three-valued answer written down by hand, so the suite fails on a shared regression as well as a divergent one. Against the pre-#721 stack (icebird 0.8.20, hyparquet 1.28.1, squirreling 0.15.2) five of its six tests fail. The wrapper-specific tests changed meaning rather than dying: `appliedWhere` honesty and LIMIT/OFFSET now pin icebird 0.8.22's own contract, which is what LLP 0222 makes the whole stack depend on, asserted shape by shape against the parquet tier so a converter change in a dependency cannot diverge silently. The CAST subset chain tightened to full equality, measured rather than assumed. `neg > CAST(-400 AS BIGINT)`, `NOT (neg > CAST(-400 AS BIGINT))`, `NOT (neg >= CAST(-300 AS BIGINT))` and `NOT (neg > CAST(-400 AS BIGINT) OR neg > CAST(-600 AS BIGINT))` all give SQL == cache == parquet, so the `bounded` option, the `isSubset` helper and the subset branch are gone rather than left as an assertion that cannot fail. Co-Authored-By: Claude <noreply@anthropic.com>
8ce8d29 to
6707ad5
Compare
neutral review - fresh round 1 at the reduced headHead reviewed: Why this is a fresh round 1 and not round 3. The two rounds already in this Finding 1 is mine. I told the reduction worker to preserve HTML-comment markers The change itself holds up, and the reviewer earned that verdict by injection
The reviewer also confirmed the reduction worker's correction to the human VERDICT: findings Head reviewed: Four findings, none ship-blocking: one minor (a stale marker) and three nits (two wrong sentences in the PR body, one comment that overstates what its assertion pins). The change itself holds up. The deletion is complete and exact, all six parity tests and the s3 round trip discriminate against at least one plausible regression that I injected and measured, item 4's equality claim is a real property rather than a fixture coincidence, and deleting the 1. minor - PR body, trailing marker The marker records a ship triage at Why it matters: it is the only machine-readable disposition on the PR, and it currently claims this head is triaged when nothing has triaged it. That is precisely the confusion that produced this review round. A human skimming the body sees a ship verdict attached to a SHA they cannot match to the diff. Exact fix: delete the marker line from the PR body. The fresh review and triage rounds on 2. nit - PR body,
I installed exactly that trio and replayed. Five of six do fail (correct), but the corpus reports 29 wrong answers, not 27. The 27 is round 1's number for the 41-case corpus; the corpus grew to 47 and the CAST section it gained contributes two more old-stack divergences ( Why it matters: it is a small number in the one paragraph whose entire job is to prove this tests-only PR discriminates. A carried-forward measurement in a verification claim is the same class of error the PR body elsewhere warns about. Exact fix: change "27 wrong answers" to "29 wrong answers". 3. nit - PR body,
Seven of the nine shapes in the body's own table push a bare, unguarded operator. Why it matters: it misattributes the mechanism that makes the equality claim true. A reader who believes the guard is doing the work would not treat the hyparquet floor as load-bearing, and the floor is the single dependency constraint the whole equality rests on. I confirmed by measurement: with root Exact fix: "pushes a bare bound (guarded only for 4. nit - // the typed-literal fold has to reach the remote tier too, or an
// s3-backed dataset scans unpruned where the local cache does not
['ts > CAST(300 AS BIGINT)', [5]],
['NOT (ts > CAST(300 AS BIGINT))', [1, 3]],The comment claims these cases pin the fold (a pruning property). They do not, and I proved it: with icebird's The cases are not decoration - Why it matters: the comment tells the next maintainer these cases guard pruning. If pruning coverage is later added elsewhere, they would be deleted as redundant, silently removing the s3 tier's only floor tripwire. Exact fix: replace the comment with what the assertion actually pins, e.g. The deletion, verifiedComplete and exact. Established by blob SHA rather than by reading:
Item 4, re-derivedI rebuilt the table myself through the real
The numbers reproduce exactly, including Verdict on the mechanism claim: correct, and the worker's correction to the human's guidance is right. This is a property, not a fixture coincidence, for two reasons I established independently of the numbers:
Deleting What each kept test would catchI did not accept the PR body's per-test claims. For each, I injected a regression and measured. All injections were to
Nothing here is decoration. Every test in both files fails under at least one regression I injected, and no two tests are covered by an identical set. Also checked, cleanRan, all at this head with a fresh Corpus expectations spot-checked against Kleene 3VL by hand, not against either engine: PR body, everything else. It describes what the PR now is. It states plainly "This PR closes nothing. #744 is already closed by #721", and Conventions. No U+2014 anywhere in either changed file ( Test hygiene. Every test wraps its temp directory in Worktree. All scratch files removed, all injected patches reverted and verified by re-running the suites green. |
…g, s3 comment - Body: pre-#721 corpus divergence is 29, not 27 (round 1's number for the 41-case corpus, carried forward after it grew to 47 with the CAST section) - Body: the CAST subset chain pushes a bare bound, guarded only for != / NOT IN; the rest are SQL-correct on null cells because of the hyparquet >= 1.28.2 floor, not because of a guard - test/plugins/s3-query-dataset.test.js: the two CAST cases pin row correctness and the hyparquet-floor tripwire on the remote tier, not the cast fold (verified: removing the fold still passes 7/0) Co-Authored-By: Claude <noreply@anthropic.com>
neutral review - round 2 (final)Head reviewed: One nit, nothing ship-blocking. Both re-measured numbers are correct as written, The measurement work is the substance of this round, and it found a second trap The payoff is a five-state table that separates what the previous rounds had All measurements done, worktree clean. Here is the review record. VERDICT: findings Head reviewed: One nit. Nothing is ship-blocking. Both re-measured numbers are correct as written, the bare-versus-guarded count settles at 8/1 and the body's wording genuinely does not depend on it, and both claims in the new s3 comment are true. The round-2 edit is exactly one test comment plus PR-body prose, and the stale 1. nit (not ship-blocking) - The new comment is true, but it now explains only one of the two rows it sits above, and the row it leaves unexplained is measurably inert. It is not decoration, and the reason is the mechanism the fixer traced but did not write down: hyparquet 1.28.1 evaluates Why it matters: only at nit level. Nothing is wrong today; the risk is that the next maintainer deletes the control and the comment stops making sense. Exact fix (optional): after "so this does not pin the fold ... 7/0)", add one clause, for example Round-1 nits, re-derived
The numbers, re-measuredHow I controlled for the nested-copy trap. At this head there is no I also hit a second trap worth recording.
The body's 29 is correct. Every one of the 29 is a cache-versus-SQL line, which is the point the paragraph is making: the expectations are hand-written Kleene truth, so a regression that moves both tiers together is still caught. The true floor drop fires 7 cache-versus-SQL divergences, on Round 1's 7-divergence measurement was a split-version artifact, for the record. Round 1 labelled it honestly ("root The s3 tripwire isolates to icebird's own copy, which is what "the remote tier's hyparquet" in the comment means. Swapping only Also checked, cleanDiff scope. PR body. No Conventions. No U+2014 in either changed file. No trailing semicolons. The new comment matches the file's idiom: lowercase continuation lines, wrapped under 80 columns, attached directly above the rows it describes with no intervening blank line, citing a Ran, all at this head with a fresh Worktree. Real dependency versions confirmed restored before the final runs (root |
neutral triage - shipOne residual nit, a comment in a test file on a tests-only PR. Preference. This PR The classification was close to foregone, so triage spent its effort on the question
Tally: all 6 parity tests and the s3 round trip failed under at least one The baseline argument holds up. These tests pin three dependencies this repo does not Verified at head |
Tests only. No production code changes; the diff against
masteris two testfiles.
What happened to this PR
It opened as a fix for #744:
withSqlCorrectWhere, a wrapper making theiceberg cache path SQL-correct on NULL predicates. Then #721 merged
(
8c08185), bumpingicebird 0.8.20 to 0.8.22,hyparquet 1.28.1 to 1.28.2and
squirreling 0.15.2 to 0.15.3, and replacing this repo's WHERE converterwith icebird's. The NULL work converged upstream, so #744 is closed by that
bump and the wrapper had no bug left to work around: it would only re-apply
icebird's own filter, with the same evaluator, to rows that had already passed
it, which is the per-row materialization LLP 0098 exists to prevent.
Two independent runs of this PR's own parity corpus against
masterwith nowrapper present came back fully correct. The reduction below follows the
disposition left on this thread:
#751 (comment)
This PR closes nothing. #744 is already closed by #721.
Removed
src/core/query/iceberg-source.js(withSqlCorrectWhere) and its export insrc/core/query/index.jsdataSourceForTablewiring insrc/core/cache/iceberg/store.jshypaware-core/plugins-workspace/s3/src/query-dataset.jsExtended-byforward-ref it added to LLP 0098LLP 0222, which landed with #721, records where converter ownership lives now.
Nothing in the tree references the deleted module.
Kept, and what each surviving test would catch
Both tiers run the same converter now, so "cache agrees with parquet" is a
weaker statement than it was in the first round of this PR. Stated honestly,
per test:
cache and parquet backends answer the corpus identically, and answer it the way SQL does(47 predicates). Every expected row set is SQL's three-valuedanswer written down by hand, not a recording of behaviour, so the corpus fails
on a shared regression as well as a divergent one. That is its main job: it
is a tripwire on three pinned dependencies this repo does not own. Verified:
against the pre-#721 stack (
icebird 0.8.20,hyparquet 1.28.1,squirreling 0.15.2) five of this file's six tests fail, the corpus test with29 wrong answers. The cache-versus-parquet half is still a real cross-backend
check, because the two sources are not the same code below the converter:
icebird prunes on manifests and data-file bounds before hyparquet sees a row
group, and only one tier is a dependency.
filtered aggregates take the same NULL semantics as the row scan.icebergDataSourceconverts the predicate a second time inscanColumn.Catches the two conversion sites drifting apart, which the row corpus alone
would not see.
the cache column stream reports appliedWhere honestly.appliedWhereisfinal on this path: the engine never re-judges a claimed predicate, and a
direct
scanColumncaller has nothing above it to re-filter. A claim for adeclined predicate is a wrong answer, not a lost optimisation. This is exactly
the #744 failure mode, pinned at the seam where it bit.
both backends agree on which predicates are converted and which are declined. The contract LLP 0222 makes the stack depend on, pinned from theconsumer's side: bare bounds and folded casts converted, NULL literals, LIKE,
function calls and column-versus-column declined, asserted shape by shape
against the parquet tier. An upstream converter change that started claiming a
shape it does not apply, or quietly stopped folding one it used to, fails here.
Note this also records that the projection asymmetry round 2 measured is gone:
scan({columns: ['id'], where: ts > 300})now reportsappliedWhere: trueonboth tiers.
LIMIT and OFFSET are held back under a WHERE. icebird would otherwise capthe scan at
offset + limitrows matching its own filter before the enginefinished judging the predicate. Catches both a flag regression
(
appliedLimitOffsetclaimed under a WHERE) and an ordering one, end to end:ts IS NOT NULL LIMIT 2returns[1, 3], which is wrong as[1]if the sliceruns before the filter.
a filtered cache scan still prunes whole data files. Two data files withdisjoint
tsranges; the filtered scan must open one. Catcheswhereceasingto reach icebird's manifest walk, for instance by a wrapper like the one this
PR just deleted. It now also asserts the cast form prunes
(
ts > CAST(8000 AS BIGINT)), which is the fold LLP 0222#context measured inproduction at 11.4s versus 7.3s. Verified sensitive: swap that for a shape
icebird declines and the assertion fails with "cast-bounded scan opened 2 data
files".
test/plugins/s3-query-dataset.test.js, the BlobStore round trip, kept inadapted form. The s3 iceberg branch is a lazy dynamic import reached only by
s3-configured deployments, so a regression there is silent; the test drives the
production
buildS3QueryDatasetfactory over a real table written through areal BlobStore.
The CAST subset chain: measured, then tightened to equality
Round 2 of this PR asserted
SQL ⊆ cache ⊆ parquetfor CAST/typed-literalpredicates, and round 2's own review found the
boundedflag was dead weighteven under the old code: the one case using it landed on equality, so the
subset branch never exercised a strict subset, and nothing distinguished a
legitimately bounded case from a regression relabelled
bounded.Measured on the fixture at this head, through
dataSourceForTableandparquetDataSource, including the case round 2 measured as a genuine strictsubset under the old stack:
neg > CAST(-400 AS BIGINT)[3,5][3,5][3,5]NOT (neg > CAST(-400 AS BIGINT))[1][1][1]NOT (neg >= CAST(-300 AS BIGINT))[1][1][1]NOT (neg > CAST(-400 AS BIGINT) OR neg > CAST(-600 AS BIGINT))[][][]neg != CAST(-300 AS BIGINT)[1,5][1,5][1,5]ts > CAST('300' AS BIGINT)[5][5][5]neg > CAST(-400.9 AS INTEGER)[3,5][3,5][3,5]neg > CAST(-400 AS DOUBLE)[3,5][3,5][3,5]label > CAST(3 AS TEXT)[1,3,5][1,3,5][1,3,5]Under the old stack,
NOT (neg > CAST(-400 AS BIGINT))wasSQL [1], cache[1], parquet[1,2,4]. The divergence is gone, and the mechanism is notluck: the predicate class is no longer declined at all. The kernel converter
used to refuse a cast operand while icebird folded it, so only the cache tier
got a pruning hint and only the parquet tier fell to a two-valued WHERE. Both
tiers now run icebird's converter, which folds the cast, pushes a bare bound
(guarded only for
!=/NOT IN; the rest are SQL-correct on null cellsbecause of the
hyparquet >= 1.28.2floor, LLP 0222#hyparquet-floor) andclaims
appliedWhereon both sides.So the chain is asserted as full equality, and the
boundedoption, theisSubsethelper and the subset branch are deleted rather than left behindas an assertion that cannot fail. The negated forms are in the corpus as plain
equality cases, which is strictly stronger than the subset assertion they
replace.
Checks
Rebased onto
masterat8c08185(clean, no conflicts). Freshnpm install.npm test: 4036 pass, 0 fail, 1 pre-existing skipnpm run typecheck: cleannode --test test/core/iceberg-source-parity.test.js test/plugins/s3-query-dataset.test.js test/core/llp-ref-hygiene.test.js: 24/24npm run smoke -- local_parquet_export: oknpm run smoke -- cache_lifecycle_maintenance: okpackage_bin_bootandwalkthrough_picker_to_first_queryare red onmasterfor unrelated reasons (#758, #750).
🤖 Generated with Claude Code