Skip to content

fix(tesseract): retry pre-aggregation matching within one external type - #11812

Open
waralexrom wants to merge 2 commits into
masterfrom
tesseract-preagg-external-split-fallback
Open

fix(tesseract): retry pre-aggregation matching within one external type#11812
waralexrom wants to merge 2 commits into
masterfrom
tesseract-preagg-external-split-fallback

Conversation

@waralexrom

Copy link
Copy Markdown
Member

Problem

A multi-stage measure whose stages match different pre-aggregations gets no
pre-aggregation at all when those matches land in different external types:
/sql reports preAggregations: [] and the query reads the raw source. With
both matches in the same external type the query is served normally.

Reported shape: a brand_share measure whose denominator lifts the brand
filter through filter.exclude. The numerator matches a brand-grained rollup
(external: true, CubeStore), the denominator a day-grained one
(external: false, source DB) — and the query falls through to the fact table,
the worst available outcome on a large table.

Cause

Stages are matched independently, each taking the first candidate that fits
in declaration order. Once every stage has matched, a set spanning both
external types is refused — correctly, since no single query can read CubeStore
and the source database at once.

The defect is that the refusal was terminal. There is no retry, so a set
that could have been covered by one wider pre-aggregation in a single engine
was discarded along with everything else. Whether the query was served came
down to the order the pre-aggregations happen to be declared in: declaring the
wider one first makes every stage reach it and the query is served, at the cost
of plain queries matching the wide rollup instead of the precise one.

What changed

A matching pass now reports why it failed instead of a bare None:

  • UnmatchedStage — a stage matched no candidate. Returns immediately, since
    matching is monotone in the candidate set and no subset can cover it.
  • ExternalTypesSplit — every stage matched, but across both external types.
    Matching is retried against candidates of a single external type, CubeStore
    first as the default and faster store.

This is the fallback order the reported case needs: most precise per stage,
then one engine, then the fact table. Per-stage precision is given up only in
the retry, and only where the alternative is reading the fact table.

Two properties keep this contained:

  • Plain queries are untouched. A query that matches as a whole is rewritten
    before the multi-stage path is reached, so the retry cannot move it onto the
    wider pre-aggregation. The retry only ever fires on a set that today yields
    zero pre-aggregations.
  • No new correctness surface. The retry re-runs the same matching predicate
    over a subset of candidates, so it can only produce plans the matcher already
    sanctioned — the same plans that reordering the model would have produced.

How it was verified

  • Five tests on a fixture reproducing the reported shape. Two fail without the
    fix (preAggregations: [], left: 0 / right: 2); three guard what the retry
    must not disturb — the wider pre-aggregation covering every stage alone, the
    narrow one covering nothing, and a whole-query match still choosing by_day.
  • Row-level parity against the fact table on Postgres: the denominator now
    re-aggregates across the brands stored in the brand-grained rollup, and the
    shares come back 0.30 / 0.40, matching the fact-table plan exactly. A
    brand predicate leaking into that read would show as a share of 1.0.
  • Full cubesqlplanner suite: 1377 passed, 0 failed. cargo fmt and
    cargo clippy clean.

Risks

  • The retry adds up to two extra matching passes, but only on a set split
    across external types — a case that currently returns nothing. A stage that
    matched nothing still fails on the first pass, so queries with no usable
    pre-aggregation do not pay for it.
  • When both external groups can cover the query, CubeStore wins. That is a
    policy choice rather than a cost estimate; there is no ranking between
    candidates anywhere in the matcher today.
  • Queries in the reported shape start hitting a pre-aggregation where they
    previously read the source, so their results now depend on rollup freshness
    like any other pre-aggregated query.
  • disable_external_pre_aggregations is unaffected: external candidates are
    filtered out before matching, so a pass can never be split and the retry
    never runs.

🤖 Generated with Claude Code

waralexrom and others added 2 commits September 9, 2026 12:30
…t external types

A share measure whose numerator filters by brand and whose denominator lifts
that filter through `filter.exclude` matches two different pre-aggregations.
When those land in different external types the whole query drops to the fact
table, because a set of per-stage matches spanning CubeStore and the source
database cannot be read by one SQL query and the matcher gives up instead of
retrying inside a single external type.

Adds the fixture, a seed, and five tests: the split case, its rows compared
against the fact table, the wider pre-aggregation covering every stage on its
own, the narrow one covering nothing, and a whole-query match left untouched.
The first two fail until the retry exists; the other three guard what the
retry must not disturb.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each stage of a multi-stage query takes its own most precise pre-aggregation,
which can leave the resulting set spanning both external types. Such a set is
correctly refused, since no single query can read CubeStore and the source
database at once — but the refusal was terminal, so the query dropped all the
way to the fact table even when one wider pre-aggregation could have covered
every stage on its own. Which pre-aggregation each stage reached, and therefore
whether the query was served at all, came down to their declaration order.

The refusal now reports why a matching pass failed, so a set split across
external types is retried against candidates of a single external type,
CubeStore first. Per-stage precision is given up only in that retry, and only
where the alternative is reading the fact table. A stage that matched nothing
still fails immediately, as no subset of the candidates can cover it, and
queries matched as a whole never enter this path — so a plain query keeps the
pre-aggregation it already matched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the rust Pull requests that update Rust code label Sep 9, 2026
@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Claude finished @waralexrom's task in 3m 56s —— View job


Review complete — no blocking issues; 4 inline comments (0 high, 2 medium, 2 low)

Full review
  • Gather context (diff, optimizer.rs, tests, fixtures)
  • Verify the retry's state handling and monotonicity argument
  • Check test coverage and fixture realism
  • Check existing review threads for duplicates (none existed)
  • Post review

Verdict

The change is well scoped and the reasoning in the description holds up against the code. I traced the two properties it rests on:

  • State is correctly rolled back between passes. Both failure branches in match_multistages truncate self.usages to saved_usages_len and restore usage_counter (optimizer.rs:398-403, 416-426), and every other piece of per-pass state (needed, rewritten_multistages_rev, the LogicalPlanRewriter) is a fresh local, so a retry cannot inherit anything from the rejected pass. The only unrolled-back path is an early ?, which is pre-existing.
  • The UnmatchedStage early return is sound. try_rewrite_query takes the first fitting candidate and its predicate depends only on (query, candidate, time_shifts) — usages carry no matching state — so a stage that matched nothing over the full candidate set matches nothing over a subset. The early break on has_unrewritten_leaf doesn't weaken this: the stage that failed genuinely failed.
  • The group filter pa.external.unwrap_or(false) and the usage's external() (from .external(pre_aggregation.external.unwrap_or_default()), line 486) are the same predicate, so a single-external pass really cannot report a split, and both groups are non-empty whenever the retry runs — no wasted empty pass.
  • No security or input-handling surface: this is planner-internal candidate selection, no new SQL construction, no user-controlled string flowing anywhere new.

Inline comments

Sev Where Point
Medium optimizer.rs:280-291 The [true, false] preference is never exercised — only one group can cover in the fixture, so the test passes with the order reversed. Also, a non-Matched outcome from a single-external pass is swallowed rather than asserted impossible.
Medium external_split.rs:125 Missing coverage for disable_external_pre_aggregations (the configuration the PR claims is a strict no-op) and for the exhausted-retry path where neither group covers.
Low optimizer.rs:276-279 Four-line comment; the load-bearing part is the fallback ordering, the rest restates the enum comment. Suggested a 3-line version.
Low optimizer.rs:51-60 // rather than /// on the new enum, unlike RowGrain right above it.

Things I liked

  • Naming the failure mode instead of returning a bare None is the right shape for this fix — it makes the "why" available at the call site rather than re-deriving it, and it keeps the retry from having to guess.
  • test_fallback_rows_agree_with_the_fact_table earns its keep: the plan-shape assertions in the other tests would all still pass if a brand predicate leaked into the denominator read, and the 0.30 / 0.40 fixture data is chosen so that failure shows up as 1.0 rather than as a subtle number. The #[cfg(feature = "integration-postgres")] assert that the comparison isn't vacuous is a nice touch — without it, assert_eq!(None, None) would pass silently.
  • test_whole_query_match_is_left_alone_by_the_retry guards the one property that keeps this contained (plain queries keep matching the narrower by_day).

Out of scope, noted for context

Two pre-existing gaps in the same area that this change doesn't introduce but does interact with, in case they're worth separate issues:

  • external is the only engine-identity check on a usage set. Two external: false rollups on cubes with different data_source are equally unreadable in one query, but nothing in the pre-aggregation optimizer looks at data source (no data_source reference anywhere under optimizers/pre_aggregation/). The retry can pick candidate combinations the first pass didn't, so it widens exposure to this — though every such combination is one that reordering declarations could already produce today.
  • A rollupLambda with mixed member rollups falls back to static_data.external (pre_aggregations_compiler.rs:345-352) while its union source still spans engines, so its external() flag doesn't fully describe what the read touches.

What I could not verify

cargo is not in my allowed tools in this environment, so I could not build, run cubesqlplanner's suite, or check fmt/clippy myself — I'm taking the description's "1377 passed, 0 failed, fmt and clippy clean" at face value and reviewing by reading. The formatting of the new block does look rustfmt-shaped. If you'd like me to run these, add Bash(cargo *) to --allowedTools.

· branch [`tesseract-preagg-external-split-fallback`](https://github.com/cube-js/cube/tree/tesseract-preagg-external-split-fallback)

Comment on lines +280 to +291
for external in [true, false] {
let candidates: Vec<_> = compiled_pre_aggregations
.iter()
.filter(|pa| pa.external.unwrap_or(false) == external)
.cloned()
.collect();
if let MultiStageMatch::Matched(rewritten) =
self.match_multistages(root, &candidates)?
{
return Ok(Some(rewritten));
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things about this loop worth pinning down.

The [true, false] preference is unexercised. In the fixture only by_brand_day (external) can cover both stages, so test_stages_split_across_external_types_serve_from_one_engine would pass with the order reversed too. Since "CubeStore wins" is called out as a deliberate policy in the PR description, a case where both groups can cover — asserting the external one is chosen — would be the test that actually holds the policy in place against a future reordering.

Non-Matched outcomes from the retry are swallowed. A single-external pass can only return Matched or UnmatchedStage today, because the group filter (pa.external.unwrap_or(false)) and the usage flag (PreAggregation::external(), set from pre_aggregation.external.unwrap_or_default() at line 486) are the same predicate. If those two ever drift — e.g. a rollupLambda whose external is recomputed differently in the compiler — an ExternalTypesSplit from inside a single group would be silently treated as "no match" and the invariant would go unnoticed. An explicit match makes the assumption checkable:

match self.match_multistages(root, &candidates)? {
    MultiStageMatch::Matched(rewritten) => return Ok(Some(rewritten)),
    // A single-external group cannot split; the filter here is the same
    // predicate the usage's `external()` reports.
    MultiStageMatch::ExternalTypesSplit => debug_assert!(false, "single-external pass split"),
    MultiStageMatch::UnmatchedStage => {}
}

Comment on lines +276 to +279
// Every stage is covered, just not by one engine. Retrying within a
// single external type gives up per-stage precision for a set that one
// query can actually read, and the alternative is reading the fact
// table. CubeStore goes first as the default and faster store.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Four lines where the load-bearing part is the fallback ordering — "we trade per-stage precision for a readable set, and external goes first". The rest restates the enum comment above. Suggest trimming to the sentence a later edit would need in order not to reverse the order:

Suggested change
// Every stage is covered, just not by one engine. Retrying within a
// single external type gives up per-stage precision for a set that one
// query can actually read, and the alternative is reading the fact
// table. CubeStore goes first as the default and faster store.
// The set is covered, just not by one engine. Retrying within a single
// external type trades per-stage precision for a set one query can read;
// CubeStore goes first as the default and faster store.

Comment on lines +51 to +60
// Outcome of one matching pass over a single set of candidate pre-aggregations.
enum MultiStageMatch {
Matched(Rc<RootQuery>),
// A stage matched no candidate, so no subset of the candidates covers it
// either.
UnmatchedStage,
// Every stage matched, but the matches span both external types, so no one
// query can read them all.
ExternalTypesSplit,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: RowGrain directly above uses /// doc comments; this enum uses //, so the variant semantics — which are the non-obvious part of the whole change — don't show up in rustdoc. Worth switching to /// for consistency within the file.

);

Ok(())
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two scenarios the PR description makes claims about but no test pins:

  1. disable_external_pre_aggregations — the description argues the retry can never fire because external candidates are filtered out in compile_all_pre_aggregations, so a pass cannot split. That is the one configuration where this change is asserted to be a strict no-op, and it's the cheapest thing to guard: same fixture, externals disabled, expect the pre-existing outcome (no usages).
  2. Neither group covers — a shape where the stages split and no single external type can serve them, expecting pre_aggrs.is_empty() after two failed retries. Right now the only "returns nothing" path covered is UnmatchedStage (via test_narrow_pre_aggregation_alone_cannot_serve_the_query), not the exhausted-retry path.

@codecov

codecov Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.22%. Comparing base (a497bb4) to head (1dbc3ae).
⚠️ Report is 3 commits behind head on master.

Additional details and impacted files
@@             Coverage Diff             @@
##           master   #11812       +/-   ##
===========================================
+ Coverage   60.16%   80.22%   +20.05%     
===========================================
  Files         239      500      +261     
  Lines       19203   105851    +86648     
  Branches     3886     3886               
===========================================
+ Hits        11554    84916    +73362     
- Misses       7099    20385    +13286     
  Partials      550      550               
Flag Coverage Δ
cube-backend 60.16% <ø> (ø)
cubesql 84.66% <ø> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

rust Pull requests that update Rust code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant