Skip to content

fix(tesseract): share a rolling window's base scan across measures - #11852

Merged
waralexrom merged 4 commits into
masterfrom
tesseract-rolling-window-cte-merge
Sep 14, 2026
Merged

waralexrom merged 4 commits into
masterfrom
tesseract-rolling-window-cte-merge

Conversation

@waralexrom

Copy link
Copy Markdown
Member

Part of #11770.

Problem

A rolling window's base CTE aggregates one measure over the rows the window can
reach, and the rows it reads are decided by the window's frame and the query's
filters — not by the measure. Every window still got its own CTE, so a query
over several rolling measures scanned the fact table once per measure even
where two windows were byte-identical apart from the column they summed.

Grouped by a high-cardinality dimension that repeats the whole
(entities × window × anchors) cost per measure. The reported query is three
calculated measures over five rolling sums covering two distinct windows: five
scans where two would do.

What changed

Base scans that read the same rows now ride on one CTE, and each window reads
its own column off it.

The merge is a pass over the logical plan rather than a decision taken while
planning, and it runs after pre-aggregations have been matched. That
ordering is what makes it safe to take unconditionally: a pre-aggregation
answers only for a query whose every measure it carries, so a scan carrying two
measures could not be served by a rollup holding one of them — a model storing
one rollup per rolling measure (the partitionedRolling shape) would silently
fall back to the fact table the moment two were queried together. By the time
this pass runs, each base scan either already reads a rollup — a source the
pass declines to merge — or was left on the fact table, where nothing is lost
by sharing it. No measure-list bookkeeping is needed to arbitrate between the
two.

Two scans read the same rows when their grain, filters, modifiers, join and
evaluation context all agree. The last one is not visible in the query a leaf
holds: a time shift moves the period the leaf covers and a row-grain evaluation
stops it aggregating, and both live beside the query rather than in it. A shape
the comparison cannot read answers "not the same", which costs a shared scan
and never merges two scans that read differently. A measure two scans have in
common — a switch dispatching several stages onto one — becomes a single
column of the merged scan rather than two under the same alias.

Sharing is confined to a CTE whose only reader is one rolling window, taking it
as the measure side of its frame. Only such a reader is repointed at the scan
it was folded into, so a reference of any other kind would survive into the SQL
naming a CTE that is no longer in the WITH list. It also keeps a window
without a granularity out: that has no stage on top and its base CTE is the
requested measure's own result, which a CTE shared between measures cannot
answer for.

Testing

  • cargo test -p cubesqlplanner --features integration-postgres — 1382 pass,
    including the existing rolling-window, multi-stage and pre-aggregation suites
    against a real Postgres.
  • New tests pin sharing on three shapes (plain, joined, two measures at row
    grain) and five boundaries it must not cross. Each sharing test was confirmed
    to fail with the pass reverted.
  • The two new snapshots are the values the same queries answer with the pass
    disabled
    , i.e. while each measure still scans on its own. Re-running them
    against the merged plan reproduces them byte for byte, so the merge is
    value-preserving on both the grouped and the row-grain path.
  • Two defects found this way and fixed before commit: a measure shared by two
    switch-dispatched stages was emitted as two columns under one alias, and
    omitting the evaluation context from the comparison merged a time-shifted
    scan with its unshifted twin, collapsing a growth ratio to zero.

Risks

  • The comparison reads the join condition by identity. It holds because the
    join tree is built once per query and handed to every leaf, and
    test_measures_over_a_joined_query_share_a_base_scan pins that; were it to
    change, the pass would stop firing for joined models rather than merge
    wrongly.
  • Reference counting relies on referenced_cte_names, which every node linking
    a CTE by name has to declare. A member joined in by name through the builder
    context would not be counted; today that channel carries
    dimension-calculation CTEs only, and those are never candidates here.
  • A base scan already reading a pre-aggregation is left alone rather than
    merged with another such scan. That is a shared scan not taken, never a wrong
    one.

Note for review order

#11824 currently also carries a plan-time version of this merge. That half is
superseded by this PR and will be removed from it, leaving #11824 to the
literal base-scan bounds, which are derived while planning and cannot move into
an optimizer pass.

🤖 Generated with Claude Code

@github-actions github-actions Bot added the rust Pull requests that update Rust code label Sep 11, 2026
@claude

claude Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

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


Review: sound design, no correctness defect found — 0 high, 2 medium, 3 low (5 inline comments)

Full review — verdict, findings, what I verified and what I could not
  • Gather context (diff, changed files, surrounding planner/physical-builder code)
  • Review the optimizer pass (grouping, widening, repointing, CTE bookkeeping)
  • Review the reads_same_rows predicate against every struct it compares
  • Check the pass against the physical builder (alias resolution, merged schemas)
  • Review tests, fixtures and the two new snapshots
  • Post findings

Verdict

The shape of the change is the right one, and the two load-bearing arguments hold up under reading:

  • Running after pre-aggregation matching is genuinely what makes the merge unconditional, and test_a_rollup_per_rolling_measure_survives pins the case that would otherwise regress silently (the partitionedRolling shape falling back to the fact table). The early is_pre_aggregations_match_only() return sits above the new call, so the refresh path is untouched.
  • is_sole_input_of_a_window is the correct guard for dropping a CTE: rename_measure_input only ever repoints a RollingWindow::measure_input, so requiring reference_counts == 1 plus one naming window is exactly the condition under which no dangling WITH reference can survive. A leaf read by a FullKeyAggregate as well (count 2) is correctly skipped, as are hoisted multiplied-measure leaves.

Details I checked and found correct rather than merely plausible:

Claim Verified
enumerate() positions stay valid after the filter Yes — enumerate precedes filter, so merged_at keys are original indices
CTE order still has dependencies before dependents Yes — the kept CTE is the group's earliest position; absorbed windows come after it
Distinct frames don't merge Two ways: make_rolling_base_state widens the date-range filter by trailing/leading (caught by same_filter) and derives the leaf granularity from the window interval (caught by same_grain)
Row-grain (non-additive) leaves don't merge with aggregating ones Yes — ungrouped is in both same_modifiers and same_evaluation_context
Merged columns resolve to the right measure Yes — Schema::find_column_for_member keys on the resolved chain, which differs per measure (see the medium finding for the one case where it doesn't)
new() on a unit struct won't trip -D warnings Yes — new_without_default = "allow" in the crate's [lints.clippy]
Unchanged existing snapshots are real evidence Yes — multiple_rolling.rs and friends snapshot Postgres results, not SQL, so identical snapshots across the merge is a value-preservation check, not an artefact of nothing being asserted

Two windows differing only by offset (start vs end) will now merge, since make_rolling_base_state folds only trailing/leading into the date range. That is value-preserving today precisely because their base scans are already byte-identical — worth knowing that #11824, by making the bounds depend on the offset, will make same_filter separate them again on its own. No action needed.

Findings

Medium

  1. same_rows.rs:29-64 — the comparison is the pass's entire safety argument, and it is the one part not protected by exhaustiveness. widen_leaf and rename_measure_input use struct literals and break the build on a new field; every same_* helper reads through accessors and silently gets weaker instead. Destructuring the left-hand argument fixes it. This is the same class of defect as the same_evaluation_context omission the PR description says was caught pre-commit.
  2. optimizer.rs:99-108 — measure dedup uses full_name() while the physical side identifies a column by its resolved reference chain. Two symbols with different names resolving to the same chain get two columns, and both windows read whichever comes first. member_chain_eq is already next door.

Low

  1. base_scan_merge.rs:44-64 — both new snapshots record a within-date row order nothing pins (2024-01-03 is pending, completed; 2024-01-04 is completed, pending). Flake risk on a plan or PG-version change, for reasons unrelated to the merge. An order: on orders.status makes it total.
  2. optimizer.rs:129-134 — the widened schema mixes query.schema() dimensions with leaf.measures, two lists the leaf doesn't guarantee to agree on (is_without_member_leaf). Unreachable today, but guarded by a condition about who reads the CTE rather than about the lists matching.
  3. optimizer.rs:8-22 — the pre-aggregation-ordering rationale appears three times; only the copy at the call site can prevent the edit that breaks it. The second paragraph of is_sole_input_of_a_window's doc (L227-232) reads the same way — a risk note addressed to a reviewer, not to a future editor of that function.

Also minor, not worth an inline: widen_leaf returns Result but cannot fail, which costs optimize a collect::<Result<_,_>>; same_rows::same_members is pub inside a private module.

One thing to decide, not a defect

The pass is unconditional with no way to turn it off. Everything else in this area has an escape hatch (CUBEJS_TESSERACT_SQL_PLANNER), and a query-plan rewrite that changes which CTEs exist for every rolling-window query is the kind of thing where an env flag buys a cheap rollback if a shape nobody modelled misbehaves in production. Your call — I mention it because the merge itself is not individually observable to a user reporting a wrong number.

What I could not run

cargo is not permitted in this environment, so I did not compile or run the suite — the 1382 passing tests and the byte-identical snapshot re-runs are the author's report, not something I reproduced. Everything above is from reading the changed files together with the planner and physical-builder code they depend on. No Docker/psql either, so the value-preservation claim on the two new snapshots is likewise unverified here.

· branch [`tesseract-rolling-window-cte-merge`](https://github.com/cube-js/cube/tree/tesseract-rolling-window-cte-merge)

@codecov

codecov Bot commented Sep 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.35%. Comparing base (2762d76) to head (00259d3).
⚠️ Report is 2 commits behind head on master.

Additional details and impacted files
@@           Coverage Diff           @@
##           master   #11852   +/-   ##
=======================================
  Coverage   80.35%   80.35%           
=======================================
  Files         500      500           
  Lines      106555   106555           
  Branches     3881     3881           
=======================================
+ Hits        85618    85619    +1     
+ Misses      20387    20386    -1     
  Partials      550      550           
Flag Coverage Δ
cube-backend 60.23% <ø> (ø)
cubesql 84.76% <ø> (+<0.01%) ⬆️

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.

waralexrom and others added 3 commits September 14, 2026 15:09
A rolling window's base CTE aggregates one measure over the rows the
window can reach, and which rows those are is decided by the frame and
the query's filters rather than by the measure. Windows differing only
in the column they aggregate therefore scan the fact table once each
where one scan would do.

Pins the wanted behaviour on three shapes — a plain query, a joined one,
and two measures evaluated at row grain — and the boundaries it must not
cross: a differing frame, a differing fact, a time shift (which moves
the period a window covers without changing anything in the query its
leaf holds), a window without a granularity (whose base CTE is the
requested measure's own result and cannot be shared), and a model
storing one rollup per rolling measure, which a scan carrying both
measures could not be served by.

The two snapshots record values a shared scan must not change: they are
what the same queries answer while each measure still scans on its own.

Only the sharing tests fail today; the rest are guards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A rolling window's base CTE aggregates one measure over the rows the
window can reach, and the rows it reads are decided by the window's
frame and the query's filters — not by the measure. Every window still
got its own CTE, so a query over several rolling measures scanned the
fact table once per measure even where two windows were byte-identical
apart from the column they summed. Grouped by a high-cardinality
dimension, that repeats the whole (entities × window × anchors) cost per
measure.

Base scans that read the same rows now ride on one CTE, and each window
reads its own column off it. The merge is a pass over the logical plan
rather than a decision taken while planning, which is what lets it run
after pre-aggregations have been matched: a rollup answers only for a
query whose every measure it carries, so a scan carrying two measures
could not be served by a rollup holding one of them. By the time this
pass runs, each base scan either already reads a rollup — a source it
declines to merge — or was left on the fact table, where nothing is lost
by sharing it.

Two scans read the same rows when their grain, filters, modifiers, join
and evaluation context all agree. The last one is not visible in the
query a leaf holds: a time shift moves the period the leaf covers and a
row-grain evaluation stops it aggregating, and both live beside the
query rather than in it. A shape the comparison cannot read answers "not
the same", which costs a shared scan and never merges two scans that
read differently. A measure two scans have in common — a switch
dispatching several stages onto one — becomes a single column of the
merged scan rather than two under the same alias.

Sharing is confined to a CTE whose only reader is one rolling window,
taking it as the measure side of its frame. Only such a reader is
repointed at the scan it was folded into, so a reference of any other
kind would survive into the SQL naming a CTE that is no longer in the
`WITH` list. It also keeps a window without a granularity out: that has
no stage on top and its base CTE is the requested measure's own result,
which a CTE shared between measures cannot answer for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four of the five, plus the doc the fifth asked to stop repeating.

The comparison deciding that two scans read the same rows is the pass's
whole safety argument, and it was the one part not protected by
exhaustiveness: every helper read its fields through accessors, so a
field added to any of the compared types would silently widen "the same
rows" rather than break the build. They destructure their left-hand
argument now. `Query` and `LogicalJoin` keep their fields private and
cannot, so they say so.

Measures riding on one scan are deduplicated by the resolved reference
chain rather than by name, which is the identity the physical side
matches a column by. Two symbols resolving to the same chain render the
same column; projecting both left each window reading whichever came
first, and agreeing only for as long as the two rendered alike.

A leaf is a candidate only when the measures it carries are the measures
its query selects. The widened CTE takes one list from each, and a leaf
whose member is rendered by the stage above rather than by itself has
them disagree — unreachable through today's readers, but the guard that
keeps it so is about who reads the CTE, not about the two lists.

The two snapshots group by a dimension, so their queries now order by it
as well: the recorded row order was a tie the plan is free to break, and
would have failed for reasons unrelated to the merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@waralexrom
waralexrom force-pushed the tesseract-rolling-window-cte-merge branch from f387d55 to c1b358f Compare September 14, 2026 13:13
@claude

claude Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

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


Re-review of c1b358f: all five prior findings addressed, no new correctness defect — 0 high, 0 medium, 1 low (1 inline comment)

Full re-review — what the fix commit changed, what I re-verified, the one residual
  • Diff c1b358f against the state I reviewed last round
  • Check each of the five prior findings against the new code
  • Re-read the pass end to end for defects the fixes could have introduced
  • Resolve my five stale threads
  • Post findings

The five prior findings

# Finding State
1 same_* helpers read through accessors, so a new field silently widens "reads the same rows" Fixed — same_evaluation_context, same_grain, same_filter, same_modifiers, same_subquery_join, same_dimension_subquery all destructure their left-hand argument now. Query and LogicalJoin keep private fields and cannot, so L36-38 says so
2 Measure dedup by full_name() vs. the physical side's resolved chain Fixed — member_chain_eq at optimizer.rs:108, matching Schema::find_column_for_member
3 Snapshots recorded an unpinned within-date row order Fixed — both queries order by orders.created_at, orders.status, and both .snap files were re-recorded (34 and 38 lines changed)
4 Widened schema mixes query.schema() dimensions with leaf.measures Fixed, and fixed at the right place — optimizer.rs:89 makes list agreement a candidacy precondition, so the rewrite no longer assumes it. This also closes the is_without_member_leaf (Rank-shaped) hole independently of who reads the CTE
5 Pre-aggregation-ordering rationale written out three times Fixed — the module doc is down to two sentences pointing at the call site; the load-bearing copy now lives at top_level_planner.rs:56-60, where the ordering is what an editor would move

All five threads resolved.

Re-verified after the fixes

  • same_members as a candidacy guard is order- and length-sensitive, so it rejects rather than accepts on disagreement — the conservative direction. The is_without_member_leaf case has 1 leaf measure against 0 query measures and is now skipped on length alone.
  • Dedup by chain leaves the absorbed window pointing at a dropped symbol. That still resolves correctly: resolve_member_aliasfind_column_for_member keys on resolve_reference_chain(), the same relation member_chain_eq now uses, so the kept column is found rather than falling through to member.alias() (schema.rs:37-62, multi_stage_rolling_window.rs:147).
  • measure_for_ungrouped is a bool, not a per-measure symbol — so the row-grain pair genuinely shares one scan rather than accidentally comparing equal, which is what test_row_grain_measures_share_only_with_each_other asserts at fact_scans == 2 for three measures.
  • widen_leaf's Query::builder() sets all four fields of Query; typed_builder makes a new non-defaulted field a compile error there.
  • Call-site placement is still correct: the new call sits below the is_pre_aggregations_match_only() early return (so the refresh path is untouched) and below try_pre_aggregations, and optimized_plan is shadowed, so the merged plan is what OriginalSqlCollector and PhysicalPlanBuilder both receive.
  • The new fixture measure rolling_sum_trailing_7d_prior is a multi_stage time-shift over rolling_sum_trailing_7d, which is the only thing that makes test_a_shifted_window_keeps_its_own_scan a real boundary test rather than a tautology.

Residual (low)

The destructuring pass stopped one level short of reads_same_rows itself, which is the outermost claim and the one function that can be exhaustive — MultiStageLeafMeasure's fields are pub. Also, the "private fields, compared by hand" note names two types where four are in that position (LogicalJoinItem and Cube are read through accessors too). Both in the inline comment; neither is a defect today.

What I could not run

cargo is not permitted in this environment, so I did not compile, run the suite, or re-record the snapshots — the fix commit's claim that the re-recorded snapshots reproduce byte for byte is the author's, not reproduced here. No Docker/psql either. Everything above is from reading the changed files together with the planner and physical-builder code they depend on.

· branch [`tesseract-rolling-window-cte-merge`](https://github.com/cube-js/cube/tree/tesseract-rolling-window-cte-merge)

Comment on lines +16 to +19
pub fn reads_same_rows(a: &MultiStageLeafMeasure, b: &MultiStageLeafMeasure) -> bool {
same_evaluation_context(&a.evaluation_context, &b.evaluation_context)
&& same_query_rows(&a.query, &b.query)
}

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.

The destructuring pass stopped one level short of its own entry point. MultiStageLeafMeasure's three fields are all pub (multistage/leaf_measure.rs:11-15), so this is the one function in the module that can be made exhaustive and isn't — and it is the outermost claim, the one that says "these two leaves read the same rows". A fourth field added to the leaf is silently excluded from the comparison, which is precisely the shape of the same_evaluation_context omission that was caught pre-commit.

Suggested change
pub fn reads_same_rows(a: &MultiStageLeafMeasure, b: &MultiStageLeafMeasure) -> bool {
same_evaluation_context(&a.evaluation_context, &b.evaluation_context)
&& same_query_rows(&a.query, &b.query)
}
pub fn reads_same_rows(a: &MultiStageLeafMeasure, b: &MultiStageLeafMeasure) -> bool {
let MultiStageLeafMeasure {
evaluation_context,
query,
// What the merge widens, so deliberately not part of "the same rows".
measures: _,
} = a;
same_evaluation_context(evaluation_context, &b.evaluation_context)
&& same_query_rows(query, &b.query)
}

Related, and only a doc fix: the note at L36-38 names Query and LogicalJoin as the types whose fields are private and so compared by hand. LogicalJoinItem (cube, on_sql, splits_rows) and Cube (cube, original_sql_pre_aggregation) are in exactly the same position — same_join_item and same_cube read them through accessors too — but a reader checking the invariant against that list would conclude those two are covered by destructuring. Worth naming all four. Fix this →

Rewriting the plan is clones and builders throughout, so neither the pass
nor the leaf it widens can fail. Both said they could, which made the
caller handle an error that has no way of arising.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Claude finished @waralexrom's task in 1m 8s —— View job


Re-review of 00259d3: mechanical refactor, no behaviour change, no new finding — 0 high, 0 medium, 0 new low (1 prior low still open)

Full re-review — what the commit changed, what I re-verified, the one open thread
  • Diff 00259d3 against the state reviewed last round
  • Re-check the residual low finding from the c1b358f round
  • Re-read the pass end to end for defects the refactor could have introduced
  • Check review threads, post findings

What 00259d3 changed

RollingBaseScanOptimizer::optimize and widen_leaf drop their Result wrappers; the call site drops its ?. Nothing else moved.

The claim in the commit message holds on reading. optimize's body is clone, HashMap inserts, typed_builder build() calls and a Vec collect — no fallible call remains on either path, and widen_leaf is the same. The Ok(...)/collect::<Result<_,_>>()? scaffolding was wrapping code that could only ever produce Ok, so the caller was handling an error with no way of arising.

Things I checked rather than assumed:

Claim Verified
Single call site Yes — top_level_planner.rs:61 is the only one (grep over rust/); no ? left, and plan() still returns Result for everything around it, so no unused-Result or type mismatch
Both match arms of the CTE map now agree in type Yes — widen_leaf and rename_measure_input both return Rc<LogicalMultiStageMember>
Diverging from PreAggregationOptimizer, which does return Result, isn't an inconsistency That pass resolves symbols and genuinely fails; this one does not. Signature follows the code
Call-site ordering comment survives the edit Yes — top_level_planner.rs:55-60, still below the is_pre_aggregations_match_only() early return and below try_pre_aggregations, with the load-bearing "merging is only safe once rollups have been matched" sentence intact
Nothing else in the pass shifted Yes — the merge logic, same_rows.rs, the fixtures, the tests and both snapshots are byte-identical to c1b358f

Open thread from last round

same_rows.rs:19 still applies unchanged: reads_same_rows reads a.evaluation_context / a.query through fields rather than destructuring MultiStageLeafMeasure, and the "private fields, compared by hand" note at L36-38 still names two of the four types in that position (LogicalJoinItem and Cube are read through accessors too). Not reposting — the thread is unresolved and says it. Low, and not a defect today.

Also minor, not worth an inline: now that optimize is infallible and &self is unused, RollingBaseScanOptimizer is a unit struct wrapping a function — a free pub fn would say the same thing with less ceremony. Purely taste; the Optimizer shape matches its neighbour.

What I could not run

cargo is not permitted in this environment, so I did not compile or run the suite — that the refactor type-checks is from reading the single call site and both changed signatures, not from cargo check. No Docker/psql either, so the snapshots are unchanged-by-inspection rather than re-recorded here.

· branch [`tesseract-rolling-window-cte-merge`](https://github.com/cube-js/cube/tree/tesseract-rolling-window-cte-merge)

@waralexrom
waralexrom merged commit 03377e2 into master Sep 14, 2026
313 of 319 checks passed
@waralexrom
waralexrom deleted the tesseract-rolling-window-cte-merge branch September 14, 2026 17:47
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.

2 participants