fix(tesseract): render FILTER_PARAMS on the measure side of a join back - #11814
waralexrom wants to merge 2 commits into
Conversation
A measure that needs the full-key plan is aggregated over a keys subquery joined back to a second copy of the fact source by primary key. The keys-side copy renders the cube's FILTER_PARAMS bindings as the query's predicates; the measure-side copy renders them as always-true, so the database builds the join against the whole unfiltered fact table. Both sources of the join back are covered: a bare cube, and the measure subquery a measure reaching another cube is aggregated over. Each test pins both copies, since the pushdown is only result-neutral while the two render the same predicate. A plain count, rewritten to a distinct count over a single filtered copy, anchors them. The Postgres tests state the equivalence in numbers: the same model with and without the bindings answers the same, so restricting the measure side can only shrink the build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A multiplied measure is aggregated over a keys subquery joined back to a second copy of the fact source by primary key. The keys subquery carries the query's filters, so the cube's FILTER_PARAMS bindings render as real predicates there. The measure side was built with no filter context at all, so every binding collapsed to always-true and the join was built against the whole unfiltered fact table - all tenants, all time. Results stayed correct, since the keys side restricts the output, but the hash build could outgrow the database's memory. Both copies read the same fact rows over the same columns and the join back is by primary key, so any row it can join already satisfies the predicates: rendering them on the measure side can only shrink the build. A select now takes the filters its sources' FILTER_PARAMS and FILTER_GROUP bindings resolve against separately from its own WHERE clause, since these selects carry no WHERE. Both sources of the join back are covered - a bare cube and a measure subquery - and both read the keys subquery's own filter, so the two copies cannot drift apart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @waralexrom's task in 3m 49s —— View job Review: sound, narrowly scoped fix — no blocking issues (1 medium, 3 low)Full review
VerdictThe diagnosis and the fix both hold up. Separating "the filters bindings resolve against" from "the WHERE clause" is the right seam: I checked the two claims the "cannot change results" argument rests on, since both are load-bearing:
Also verified: The I could not run Findings
Test coverageGood shape overall: two positive cases (one per Two gaps, neither blocking:
Minor: |
| query_tools, | ||
| &nodes_factory, | ||
| self.filter, | ||
| self.filter_params_filters.or(self.filter), |
There was a problem hiding this comment.
.or(self.filter) makes filter_params_filters a replacement, not an addition. Both current callers set it only on selects that have no WHERE of their own, so this is correct today — but the next caller that sets both will silently stop resolving bindings against its own WHERE predicates, which is exactly the class of bug this PR fixes (a binding quietly falling back to always_true).
Worth making the invariant explicit rather than relying on the doc comment, e.g.:
debug_assert!(
self.filter_params_filters.is_none() || self.filter.is_none(),
"filter_params_filters replaces the WHERE filter for binding resolution"
);or merging the two item lists instead of choosing one.
| // The enclosing keys subquery already restricts the rows, so this | ||
| // select carries no WHERE of its own - but its source cubes must still | ||
| // see the query's filters, or a `FILTER_PARAMS` binding in a cube's | ||
| // `sql` falls back to always-true and the join is built against the | ||
| // whole unfiltered fact table. | ||
| select_builder.set_filter_params_filters(measure_subquery.filter.all_filters()); |
There was a problem hiding this comment.
The same five-line rationale now appears three times — here, on MeasureSubquery::filter in logical_plan/measure_subquery.rs:10-14, and in aggregate_multiplied_subquery.rs:190-195. One load-bearing sentence at the field declaration (the one place a later edit would need it: "not a WHERE clause — the keys subquery restricts the rows; both fact copies must render the same predicate") is enough; the call sites read fine with a one-liner or nothing.
| // The enclosing keys subquery already restricts the rows, so this | |
| // select carries no WHERE of its own - but its source cubes must still | |
| // see the query's filters, or a `FILTER_PARAMS` binding in a cube's | |
| // `sql` falls back to always-true and the join is built against the | |
| // whole unfiltered fact table. | |
| select_builder.set_filter_params_filters(measure_subquery.filter.all_filters()); | |
| // Not a WHERE of its own - see `MeasureSubquery::filter`. | |
| select_builder.set_filter_params_filters(measure_subquery.filter.all_filters()); |
| assert_eq!( | ||
| params[6..9], | ||
| params[0..3], | ||
| "both copies must be bound to the same values\nparams: {:?}", | ||
| params | ||
| ); |
There was a problem hiding this comment.
params[6..9] vs params[0..3] hard-codes the parameter layout of the whole statement (three params per copy, second copy starting at index 6 — i.e. three params emitted in between). Any unrelated change to parameter emission order turns this into either a panic on slice bounds or a comparison of the wrong pair, with a message that still says "both copies must be bound to the same values".
Since the assertions above already prove each copy renders the same predicate shape, consider deriving the slices instead of pinning them, e.g. asserting params.len() and comparing the last three against the first three, or asserting the multiset of values contains t1 twice and each timestamp twice.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #11814 +/- ##
=======================================
Coverage 84.67% 84.67%
=======================================
Files 261 261
Lines 86648 86648
=======================================
Hits 73366 73366
Misses 13282 13282
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Fixes #11740.
Problem
When a measure that needs the full-key plan is requested together with a dimension of a
one_to_manyjoined cube, the planner builds a keys subquery joined back to a second copy of the fact source by primary key.The keys-side copy is planned with the query's filters, so the cube's
FILTER_PARAMSbindings render as real predicates. The measure side was planned with no filter context at all, so every binding collapsed to always-true:Results stayed correct — the keys side restricts the output — but the database built the join against the entire unfiltered fact table: all tenants, all time. On a large fact table the hash build outgrows the memory limit and the query fails.
The legacy planner is unaffected: its
FILTER_PARAMSproxy reads the query-levelallFiltersregardless of which sub-select is being rendered, so both copies come out restricted.CUBEJS_TESSERACT_SQL_PLANNER=falseis therefore a workaround, but the legacy planner is scheduled for removal.Cause
AggregateMultipliedSubquery's outer select andMeasureSubquery's select carry no WHERE clause of their own — the keys subquery already restricts the rows. In Tesseract the filter set thatFILTER_PARAMS/FILTER_GROUPbindings resolve against is taken from the select's WHERE filter, so for these two selects it was empty and every binding fell back toalways_true.What changed
SelectBuildernow takes the filters its sources'FILTER_PARAMS/FILTER_GROUPbindings resolve against separately from its WHERE clause (set_filter_params_filters), defaulting to the WHERE filter as before.AggregateMultipliedSubquerySource::Cube— the bare cube;AggregateMultipliedSubquerySource::MeasureSubquery— carried on the logical node, which the planner fills from the keys subquery it just built. This branch had the same gap; it is reached by a measure whosefilters:reach another cube.SelectBuilder::new_from_select, which had no callers and would have carried the two filter sets inconsistently.No WHERE clause is added anywhere, and no other plan shape changes.
On the reporter's proposal
@icoolguy1995 suggested pushing the subset of the query's filters whose members belong to the key cube into the bare cube source. That is the right diagnosis, and this change is the narrower form of it: rather than synthesising a predicate over the key cube's members, it lets the cube's existing
FILTER_PARAMSbindings resolve, which is what the legacy planner does and what the issue's SQL actually shows missing. It needs no member-ownership filtering (a binding renders the column stated at the binding site, never the member's own SQL, so a filter on another cube simply matches no binding) and cannot produce a reference to a cube that is not joined on the measure side.Why this cannot change results
Both copies read the same fact rows over the same columns, and the join back is by primary key. The keys side applies pushdown ∩ WHERE, the measure side applies pushdown only, so the measure side is always a superset of the key set the join looks up: no matched row can disappear and no
LEFT JOINcan turn into a NULL. Cumulative and rolling measures are planned outside this branch, so no window-widening semantics are involved.How it was verified
count, rewritten toCOUNT(DISTINCT id)over a single filtered copy, anchors them.1 = 1 AND 1 = 1) and pass after.cargo fmt,cargo clippy --all-targetsclean.Risks
Low. The only behaviour change is that
FILTER_PARAMS/FILTER_GROUPbindings reached from these two selects now resolve instead of rendering1 = 1— that filter set feeds nothing but those bindings. A model whose cubesqluses a binding negated (WHERE NOT (...)) would see its measure side change from empty to filtered, which brings it in line with the keys side and with the legacy planner.🤖 Generated with Claude Code