fix(tesseract): keep time_shift when a pre-aggregation serves the query - #11599
fix(tesseract): keep time_shift when a pre-aggregation serves the query#11599waralexrom wants to merge 6 commits into
Conversation
A multi_stage measure with time_shift returned NULL for every row when queried through a view while a pre-aggregation was matched. The shifted leaf scanned a partition set that could not contain its rows. Time shifts are keyed by the fully resolved cube member: QueryProperties builds them from all_time_members(), which peels the TimeDimension wrapper and follows the reference chain. extract_date_range probed that map with BaseFilter::member_name(), which resolves neither, so a view-qualified filter never found its shift and the range was left un-widened. Add TimeShiftState::get_for_symbol, which normalizes the probe the same way the keys are built, and route the lookup sites through it. Preferred over a fallback second lookup so the key-normalization rule lives in one place instead of being re-derived per call site; member_name() is left alone because its other callers compare against query-level names, where view qualification is consistent. TimeShiftSqlNode keeps its own probe: it is guarded on a non-reference symbol, and resolving there would apply the shift twice. Covered by a view-level test sitting next to the existing cube-level one, asserting the shifted and unshifted usages carry different date ranges. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…m a rollup A time_shift declared on a time dimension that wraps another cube's time dimension was lost entirely once a pre-aggregation served the query: the shifted leaf read the same rows as the unshifted one, so the shifted measure silently repeated the current period instead of the previous one. The same query without a pre-aggregation was correct. Dimension-specific shifts are keyed by the owned member the declared dimension wraps, because that is where the interval lands when the member's SQL is expanded. Two things then went wrong when the rollup materialized the derived dimension instead: - extract_date_range probed only the chain-resolved name, so the range was never widened. get_for_symbol now probes the owned child too, covering both ways a key is built. - The rollup column is substituted for the dimension, so its SQL is never expanded and the recursion that normally carries the shift down to the owned member never happens. TimeShiftSqlNode now applies the shift to the column itself, but only for dimensions it knows are substituted — an evaluated dimension must still wait for the recursion, or the interval would be added twice. Covered by a test asserting the widened range, a single shift on the rollup column, and — on a seed holding a period before the queried range — the executed values. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…review Fall through instead of unwrapping a shift with no interval, so TimeShiftSqlNode treats it as "no shift" like the other two consumers of the same lookup rather than panicking. Assert the single-application invariant in the derived-dimension test by requiring every rendered interval to sit directly on the rollup column, instead of matching one exact textual form of a doubled shift. Record what the view test's executed rows do and do not cover: the widened range only selects rollup partitions, which the harness does not emulate — it loads each rollup whole — so only the assertions on the usages guard the widening. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @waralexrom's task in 5m 19s —— View job Review complete — looks correct and well-tested; 0 blocking, 1 medium, 3 low (posted as 3 inline comments)Full review
VerdictThe diagnosis holds up under reading. Both lookups (
Findings
Nothing on security (no new input handling, no user-controlled strings reaching SQL that weren't already there) and nothing on docs (internal planner fix, no user-facing surface). TestsGood shape: cube-level and view-level cases sit side by side, the derived-dimension test asserts the widened range and that every interval lands on the rollup column, and the seed deliberately holds a period before the queried range. Two small notes, neither worth blocking:
What I could not verify
|
| let input = if !self.time_shifts.is_empty() { | ||
| TimeShiftSqlNode::new(self.time_shifts.clone(), input) | ||
| TimeShiftSqlNode::new( | ||
| self.time_shifts.clone(), | ||
| self.pre_aggregation_dimensions_references.clone(), | ||
| input, | ||
| ) | ||
| } else { | ||
| input | ||
| }; |
There was a problem hiding this comment.
TimeShiftSqlNode only ever calls contains_key on this map, but it takes a full RenderReferences clone (a HashMap<String, RenderReferencesType>) — the third clone of the same map in this function (lines 204, 269, 292). Cheap in absolute terms, but it also couples the shift node to a rendering-substitution type it doesn't render from. A HashSet<String> of substituted names (or Rc<RenderReferences>) would express "these names are columns, not expressions" more directly and drop the copy.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #11599 +/- ##
========================================
Coverage 79.58% 79.58%
========================================
Files 480 485 +5
Lines 99544 100011 +467
Branches 3636 3675 +39
========================================
+ Hits 79224 79596 +372
- Misses 19801 19868 +67
- Partials 519 547 +28
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:
|
A shift entry reaching the renderer without an interval was rendered unshifted, turning a state the map calls shifted into silently wrong numbers. Return an error instead. Also record why the first probe is by exact name: a dimension that gets evaluated picks its shift up when the recursion reaches the owned member it wraps, so matching it at the outer level too would add the interval twice. Only a substituted dimension, never expanded, resolves through the chain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A time dimension built from several members, only some of which the shift covers, has no valid offset of its stored column: moving the column would carry along the rows the shift must leave in place. The rollup was matched anyway and the shift was dropped, so the shifted measure silently repeated the unshifted one. Reject such a pre-aggregation during matching. The unrewritten leaf then triggers the existing rollback of the whole multi-stage rewrite and the query falls back to base SQL, which computes the shift correctly. The gate is tied to the shift lookup rather than re-deriving reachability: reject exactly when a shift is involved but cannot be attributed to the stored column. Re-deriving the rule would add a second place obliged to stay in step with the lookup. The test's expected values were captured from the same query with pre-aggregations disabled, before the gate existed. They differ from what offsetting the stored column would produce, which is what rules that approach out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @waralexrom's task in 3m 15s —— View job Re-review of the 2 new commits — both prior blocking-ish concerns addressed; 1 new medium (posted inline), 2 low notesFull review
What changed since my last pass
Both now degrade to "correct but slower" instead of "silently wrong". Resolved that thread too. The reasoning comment on The new test asserts New finding
Low notes (no inline comment)
Re-affirmed prior thread
What I could not verify
|
… shift The gate scanned only a pre-aggregation's time dimensions, but dimensions and segments are substituted by column just the same. A dimension built from a partially shifted member slipped through, and the shifted leaf then read it computed from unshifted values: both leaves rendered identically, so the shifted measure repeated the unshifted one. Check every member the pre-aggregation stores. The type is not what matters — any stored column computed from a shifted member is wrong when read unshifted — so dimensions and segments are checked whatever they hold. Tests cover all three ways such a member reaches a rollup: as its time dimension, under dimensions, and through a segment. Each was confirmed to fail with its own part of the gate removed. Their snapshots pin what makes the stored column unusable: the row the shift leaves in place lands on the same key in both stages, which no offset of a single column reproduces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @waralexrom's task in 2m 14s —— View job Re-review of
|
| Sev | Where | What |
|---|---|---|
| Low-Medium | optimizer.rs:501-506 |
The comment now claims "every member the pre-aggregation stores is checked", but measures is still outside the chain. A measure whose SQL references the shifted time dimension (sql: "CASE WHEN {CUBE.happened_at} >= … THEN val END") re-enters the dimension branch of RootSqlNode when expanded, so base SQL shifts it while the rollup returns a column aggregated from unshifted values. has_shift_under/get_for_symbol already answer correctly for a measure symbol; only the iteration misses it. Posted inline with the one-line patch and two caveats (it over-rejects relative to the dimension case, and pre_aggregation.measures is the unfiltered list). Excluding measures is a defensible scope call — but then the comment should say so. |
Notes carried forward (not re-posted)
factory.rs:297—TimeShiftSqlNodestill clones the wholeRenderReferencesmap to callcontains_key. Thread left open, unchanged.has_shift_underhas no visited set. Now called up to 3× more members per candidate, so the re-walk of shared sub-dependencies got proportionally wider. Still tiny in practice (depths are small, once per candidate), still a note rather than a request — but the constant factor moved in the wrong direction, so if member graphs ever get deep this is where it shows.- Shift interval vs. stored granularity. Unchanged from my last pass and predates this PR: a sub-granularity interval against a coarser rollup gives
month_start + 7 dayswhere base SQL givesdate_trunc('month', day + 7 days). Worth confirming separately that matching rejects a shift that isn't a multiple of the stored granularity.
What I could not verify
cargo is not in this job's allowlist, so cargo test -p cubesqlplanner and clippy were not run here. Two things I'd specifically want from a local run:
- That the widened guard doesn't cost existing rollup matches.
dimensionsandsegmentsare far more commonly populated than a rollup'stime_dimension, so this commit has more blast radius than the previous one — any rollup carrying a derived dimension whose graph touches a shifted member now falls back to base SQL.has_shift_underreturningfalsefor the overwhelming majority should keep that at zero, but the suite is the confirmation. - That both new tests are red without the commit. The
batch_ataddition strongly suggests they were written that way, but I can't observe it.
• branch tesseract-time-shift-view-preagg-date-range •
| .time_dimensions | ||
| .iter() | ||
| .chain(pre_aggregation.dimensions.iter()) | ||
| .chain(pre_aggregation.segments.iter()) |
There was a problem hiding this comment.
The comment now says "every member the pre-aggregation stores is checked", but measures is still outside the chain — and a measure column is substituted the same way (pre_aggregation_measures_references, wired at factory.rs:227 / final_measure_node_processor).
The dimension branch of RootSqlNode is where TimeShiftSqlNode lives, and a measure's SQL that references a time dimension re-enters that branch when evaluate_sql expands it. So for
measures:
- name: total_recent
type: sum
sql: "CASE WHEN {CUBE.happened_at} >= '2024-02-01' THEN val END"base SQL computes the case over happened_at + interval '1 month', while the rollup returns a column aggregated from the unshifted happened_at — the same read-unshifted-under-a-shift inconsistency the guard exists to reject, one member kind over. has_shift_under(total_recent) is true (recursion reaches happened_at) and get_for_symbol(total_recent) is None (a measure is neither the chain end nor an owned time child), so the existing predicate already answers correctly; only the iteration misses it:
.chain(pre_aggregation.segments.iter())
.chain(pre_aggregation.measures.iter())Two caveats worth weighing before taking it:
- This is strictly more conservative than the dimension/segment case. A measure whose dependency graph merely touches the shifted dimension without its value depending on it (e.g. a
filters:clause the rollup already applied at build time) would now also be rejected — correct, but it costs rollup hits. pre_aggregation.measureshere is the unfiltered list;make_pre_aggregation_sourcenarrows tomatched_measuresafterwards, so gating on the full list rejects on measures the query never reads. Gating on the matched set would be tighter, butcan_carry_time_shiftsruns before matching, so that's a larger change — the unfiltered list is the conservative-but-simple option.
Given the other two kinds are now covered, leaving measures out is a defensible scope call; if you do, a line in the comment saying measures are knowingly excluded (and why) would keep the "every member" claim honest.
Summary
A
multi_stagemeasure withtime_shiftsilently returned wrong values whenever a pre-aggregation served the query. Time shifts are keyed by the fully resolved cube member, but the lookups that decide the shifted leaf's date range and rendering probed with an unresolved name, so the shift was dropped. Fixes CORE-767 / #11536.Two distinct shapes were affected:
NULL. The identical query against the cube was correct.Changes
TimeShiftStateand route the shift lookups through it, so the key-resolution rule lives in one place instead of being re-derived per call site. It probes both ways a key is built: the chain-resolved dimension, and the owned member a declared dimension wraps.member_name()is deliberately left alone: its other callers compare against query-level names, where view qualification is consistent.Only Tesseract is fixed. The legacy planner has the same defect and is untouched.
Testing
cargo test -p cubesqlplanner: 1225 passed.--features integration-cubestore): 1216 passed. The 9switch_rollingfailures are pre-existing on this branch's base — verified against a baseline run with the fixes reverted — and come from the older releasedcubestoredbinary used locally.One limitation worth recording: the view fix is pinned only by assertions on the plan. The widened range decides which rollup partitions get loaded, and the test harness builds each rollup as one whole table, so no seed can make the executed rows discriminate. Filed as CORE-805.