fix(tesseract): keep query-level join hints while matching pre-aggregations - #11858
waralexrom wants to merge 2 commits into
Conversation
…hing A query whose members alone do not determine a join root carries explicit query-level join hints. Matching re-derives the query's join groups to compare them against a candidate rollup, and dropping the hints there turns a fully specified query into "Can't find join path to join 'users', 'line_items'". Adds a Rust fixture with a cross-cube rollup plus tests for the three cases: the rollup can serve the query, it cannot serve the query (the plan must stay identical to the one built without any pre-aggregation), and no pre-aggregation is declared at all. A view query, which resolves the same ambiguity through its own join_path and therefore carries no query-level hints, is covered too. The schema-compiler test runs the same shape on both planners: the legacy one already plans it, the native one does not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ations PreAggregationOptimizer re-derives the query's join groups to compare join paths against a candidate rollup, and seeded them from an empty hint set. The query's own join hints were therefore lost: a query that only resolves through them failed with "Can't find join path to join ..." as soon as any cross-cube rollup existed, even one that could not serve it. Pass the request's join hints into the optimizer and seed both query-side hint builders with them, so matching plans the query the same way the query planner does. The hints of a pre-aggregation itself keep coming from its own members. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @waralexrom's task in 3m 48s —— View job Review of #11858 — the fix is right and correctly scoped; 1 medium, 2 lowFull review
No prior review threads on this PR, nothing to resolve or dedupe against. VerdictThe diagnosis holds up on reading the code. Worth noting explicitly, because the PR body only frames it as a risk: the old behaviour also produced false accepts, not just spurious errors. A rollup declared on The pre-aggregation side is untouched ( Verification note: Findings
Test coverageThe Rust fixture is well chosen: Gap: nothing covers a query carrying hints whose member-derived tree would have differed — i.e. the behaviour change called out under "Risks", including the false-accept case above. That is the one shape where this patch changes an answer rather than turning an error into an answer, and it is currently unpinned in either suite. A rollup on Nothing security-relevant here — no new input paths, hints are already-parsed request data. |
| measures: &[Rc<MemberSymbol>], | ||
| ) -> Result<MultiFactJoinGroups, CubeError> { | ||
| let hints = MeasuresJoinHints::builder(&JoinHints::new()) | ||
| let hints = MeasuresJoinHints::builder(&self.query_join_hints) |
There was a problem hiding this comment.
Seeding the query's own hints is the right fix for the reported shape, but the failure mode itself is still live: query_join_groups is called with ? from is_schema_and_filters_match (line 658), so any hint set the join graph refuses while probing a candidate still aborts the whole query instead of rejecting that candidate.
With this patch the query-side seed can no longer be the cause, but the same Can't find join path… can still come out of a rollup whose own members resolve to an unbuildable tree — the case the PR body lists under "Not covered here". That is the same user-visible bug from the same call chain, one seed over.
MultiFactJoinGroups::merge_nested_groups already draws the distinction explicitly:
// Resolving is a probe: a hint set the join graph refuses
// means there is no merge to make here, not that the query
// the groups came from is unplannable.
let Ok((key, tree)) = resolve(&hints) else { continue; };Candidate matching is a probe in exactly the same sense. Swallowing a resolve failure here is safe: if the query's own hints genuinely don't resolve, the query planner raises it anyway on the non-pre-aggregated path — so the error is never lost, only stopped from being raised by an irrelevant rollup. Worth applying the same discipline to the candidate scan (is_schema_and_filters_match returning Ok(None) on a resolve error) so no rollup can take down a query it cannot serve.
| expect(() => query.buildSqlAndParams()).not.toThrow(); | ||
| expect(query.buildSqlAndParams()[0]).toMatch(/line_items/); |
There was a problem hiding this comment.
This test can't fail the way its describe title promises ("a rollup that cannot serve the query does not change how it is planned"). The rollup here is base_orders.orders_and_line_items_of_users, so its table name contains line_items — toMatch(/line_items/) passes whether the query reads the raw tables or (wrongly) reads the rollup. The only thing actually pinned is "does not throw".
Since the query is the uncovered shape (base_orders.count is multiplied over the line_items fan-out), assert that no pre-aggregation was picked, which is what makes it a plan-unchanged test:
| expect(() => query.buildSqlAndParams()).not.toThrow(); | |
| expect(query.buildSqlAndParams()[0]).toMatch(/line_items/); | |
| expect(() => query.buildSqlAndParams()).not.toThrow(); | |
| expect(query.preAggregations?.preAggregationForQuery).toBeFalsy(); | |
| expect(query.buildSqlAndParams()[0]).toMatch(/FROM\s+line_items/); |
Also, buildSqlAndParams() is called twice here; the second call alone surfaces a throw, so the first expect is redundant once a real assertion follows.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #11858 +/- ##
==========================================
- Coverage 80.35% 80.34% -0.01%
==========================================
Files 500 500
Lines 106555 106555
Branches 3881 3881
==========================================
- Hits 85619 85615 -4
- Misses 20386 20390 +4
Partials 550 550
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:
|
Problem
A raw multi-cube query that carries explicit
joinHints— the shape the SQL API sends wheneverthe queried members alone don't determine a join root — fails as soon as any cube declares a
cross-cube pre-aggregation:
The query is fully specified: its hints name
base_ordersas the root, andbase_ordersis theonly cube that joins both
usersandline_items. Removing the pre-aggregation makes the samequery plan fine, so the failure comes from a rollup that cannot even serve the query.
The legacy planner does not reproduce this — matching simply finds no rollup and moves on.
Cause
PreAggregationOptimizerre-derives the query's join groups so it can compare join paths againsta candidate rollup (
query_join_groups, and the multiplied-measure check next to it). Both seededMeasuresJoinHints::builderwith an emptyJoinHints, so the hints the query was planned withwere dropped and the hint set was rebuilt from members only. For a query whose members don't
determine a root, that set has no resolvable root and
JoinGraph.buildJointhrows instead of thecandidate simply being rejected.
What was done
PreAggregationOptimizernow takes the request's join hints and seeds both query-side hintbuilders with them, so matching derives the query's join groups exactly the way the query
planner does.
The change is a no-op for queries that carry no
joinHints— the seed is empty and every hint setcomes out byte-identical, so view queries (which resolve the same ambiguity through their own
join_path) are unaffected.How it was verified
tests/integration/pre_aggregations/query_join_hints.rs: the rollupserves the query; the rollup cannot serve the query and the plan stays identical to the one built
with no pre-aggregation at all; no pre-aggregation declared; a view query still matches.
Reverting the fix fails the first two with the reported error and leaves the controls green.
packages/cubejs-schema-compiler/test/unit/pre-aggregations.test.tsruns the same shape on bothplanners. Before the fix: legacy passes, native throws
Can't find join path to join 'users', 'line_items'. After: both pass.cargo test -p cubesqlplanner --lib— 1378 passed, 0 failed.jest dist/test/unitincubejs-schema-compiler— 954 passed; the 2 failures areerror-reporterANSI-colour snapshots that also fail on a clean tree without a TTY.Risks
Matching now compares against the join tree the query actually uses rather than one re-derived from
members alone. For a query with explicit
joinHintswhose hint-derived tree differed from themember-derived one, a rollup that used to be accepted may now be rejected (or the reverse) — that is
the intended correction, but it is a behaviour change for such queries. Queries without
joinHintsare untouched.
Not covered here: a rollup whose members are written without a join path still throws during its
own compilation, from a hint set seeded only by its dimensions. That is a separate defect on the
pre-aggregation side and is out of scope for this change.
🤖 Generated with Claude Code