Skip to content

fix(stage2): a window equal to its interval is kept at ingestion time; whole-window builds end on the epoch grid - #899

Merged
zzylol merged 3 commits into
mainfrom
fix/window-equals-interval-tumbling
Oct 22, 2026
Merged

zzylol merged 3 commits into
mainfrom
fix/window-equals-interval-tumbling

Conversation

@zzylol

@zzylol zzylol commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Why

A query whose window equals its repeat interval had no ingestion-time plan, in PromQL and SQL: quantile_over_time(0.99, m[5m]) every 300 s, or SELECT region, approx_percentile_cont(latency, 0.99) ... WHERE ts >= now() - INTERVAL '5 minutes' GROUP BY region every 300 s. A deployment that keeps no raw data therefore got no plan at all. In the 2026-10-22 W-RollupHeavy 8-user run without raw data, 26 of the 30 final components had no plan for this reason (#893).

The same rule also accepted a whole-window build at a phase off the epoch grid (m[20s] every 20 s at phase 5 s), which a runtime builds on the epoch grid and so reads 5 s early (#828).

What

Stage 2 keeps a whole-window build at ingestion time when every evaluation of its readers ends on the grid of the window's width anchored at the epoch:

  • readers with a fixed interval and no declared phase are now accepted (taken to run on the grid, as a pane chain's readers already are);
  • the interval, and a known phase, must be multiples of the window (before: window no longer than the interval, any phase).

So m[5m] every 5 minutes is one tumbling 5-minute window maintained at ingestion time and read at each evaluation, as a one-pane chain would be. m[20s] every 20 s at phase 5 s, or every 30 s, is not kept whole; Pass 2's 5 s or 10 s panes serve it. Pass 2 is unchanged.

How

ingestion_eligibility (physical-asap-optimization) collects the readers' interval and known phase (FixedInterval or FixedIntervalAt; others stay WindowNotFixed), requires one interval and agreeing known phases, and then checks interval % window == 0 and phase % window == 0. Off the grid it returns the new prune reason WindowOffEpochGrid.

Key code interfaces added / modified

crates/physical-asap-optimization/src/lib.rs (crate-private)

/// Why a summary build cannot be materialized at ingestion time (S4). Stays.
pub(crate) enum IngestionPruneReason {
    /// The data workload is at rest, or its arrival is unknown.
    NotIngesting, // unchanged
    /// A query reading it is not repeated.
    NotRepeated, // unchanged
    /// A query reading it is not predictable.
    NotPredictable, // unchanged
    /// It has a filter, which only a query-time build can apply.
    Filtered, // unchanged
    /// A counter state over data not taken to arrive in time order.
    CounterNeedsTimeOrder, // unchanged
    /// A whole-window build whose readers do not all run at one fixed
    /// interval with agreeing declared phases, or whose window is shifted,
    /// not a range, or zero wide.
    WindowNotFixed, // was: also "no known phase" and "window longer than the interval"
    /// A whole-window build whose evaluations do not all end on the epoch grid
    /// of its window's width: the interval or the declared phase is not a
    /// multiple of the window (e.g. m[20s] every 20 s at phase 5 s, or
    /// m[15m] every 5 minutes, which panes serve). Not an input error.
    WindowOffEpochGrid, // new
    /// A whole-window build read by a range evaluation of more than one step.
    RangeEvaluatedWholeWindow, // unchanged
    /// A tumbling-pane chain read at a known phase off its pane boundaries.
    PanesOffPhase, // unchanged
    /// Something below it can only run at query time.
    UpstreamAtQueryTime(String), // unchanged
    /// It is not a summary build.
    NotASummaryBuild, // unchanged
}

/// Whether a summary build may be maintained at ingestion time for its readers.
fn ingestion_eligibility(
    node: &OperatorNode,
    readers: &[usize],
    entries: &[QueryWorkloadEntry],
    data: &DataWorkload,
) -> Result<(), IngestionPruneReason>; // signature unchanged; whole-window rule changed

No public interface, wire or file format changes.

Usage

# One W-RollupHeavy 8-user component: the p99 of latency per region over 5 and
# 15 minutes, each every 60 s and every 300 s, for a deployment without raw data.
stage_pipeline --workload L4/http-p99-region.json \
  --table "$(jq -c '.[0]' tables.json)" \
  --deployment-capabilities '{"raw_data_retained":false,"query_time_results_retained":false}' \
  --cost-model aws-fargate-us-east-1 --enforce-accuracy-guarantees --out out.stages.json

Stage 1 lowers each WHERE ts >= now() - INTERVAL 'w' to TimeRange(w) and picks KLL; Pass 2 builds 1-minute panes for the queries whose window exceeds their interval. Stage 2 calls ingestion_eligibility on the whole-window KLL of Q3 (5 minutes every 300 s, no phase): interval 300000 ms % window 300000 ms = 0, so it is offered MatAtIngestion(Memory) (before: WindowNotFixed). Stage 3 selects:

planned, 192 candidates, cost 5.312e-07/s
plan: ... one Kll for Q1, Q2, Q4 in 15 × 1m panes (Q1 newest 5) · ingestion time: Kll ×15 panes, Kll
  n1  build Kll in 15 panes of 60000 ms for 4 groups; keeps 347648 bytes
  n12 build Kll into 4 states (Q3's 5-minute window); keeps 43456 bytes

Before this PR the same run ended with NoValidCandidate: "453 Stage 1 candidates scan raw data at query time in every materialization, but the deployment does not retain raw data".

Major algorithm changes

Stage 2 ingestion eligibility of a whole-window build

  • Problem: quantile_over_time(0.99, m[5m]) every 300 s (fixed_interval, no phase): Pass 2 offers no pane chain (w = gcd(W, I) = W gives one pane, and chains need pane_count >= 2), and Stage 2 pruned the whole-window build as WindowNotFixed because no phase was declared. Without raw data, no plan. Conversely m[20s] every 20 s at phase 5 s was accepted and read (t-25s, t-5s] on a runtime that builds on the epoch grid.
  • Before (ingestion_eligibility): 1. every reader is FixedIntervalAt with the same (interval, phase), else WindowNotFixed; 2. the build's child is an unshifted range TimeRange with W <= I, else WindowNotFixed.
  • After: 1. every reader is FixedInterval(I) or FixedIntervalAt(I, p), else WindowNotFixed; all share I > 0, and declared phases p mod I agree, else WindowNotFixed; 2. the child is an unshifted range TimeRange(W) with W > 0, else WindowNotFixed; 3. I % W == 0 and (no declared phase or p % W == 0), else WindowOffEpochGrid.
  • Correctness: every evaluation t = p + kI is then a multiple of W, so the window (t-W, t] is exactly one tumbling window of the epoch grid the runtime maintains; an unknown phase is taken to be on the grid, the assumption pane chains already make (builds_one_pane_per_evaluation, the epoch check in the chain branch). Tests: a_whole_window_build_needs_evaluations_on_the_epoch_grid_of_its_window (unit: no phase, 2× interval and phase 3 min accepted; phase 30 s, every 30 s, every 90 s rejected; with two readers, a declared on-grid phase plus an undeclared one accepted, two disagreeing phases or two intervals rejected), a_window_equal_to_its_interval_is_kept_at_ingestion_time (PromQL KLL 5m every 300 s over 3 evaluations, sum over 20 s every 20 s over 4, on the epoch-anchored stand-in runtime, equal to the query-time reference), a_window_equal_to_its_interval_at_an_offset_phase_answers_as_reference (phase 5 s, 4 evaluations; before this PR it answered 150105 instead of 152535), a_window_equal_to_its_interval_is_built_at_ingestion_time_from_table_rows (SQL distinct count and exact row count, 5 minutes every 300 s, from table rows).
  • Complexity: O(readers) per build, as before. Each newly eligible build adds one MatAtIngestion option, so the materialization assignments of a component can grow by up to 2^n for n such builds. Measured on W-RollupHeavy 8 users: without raw data, the 26 formerly unplannable components check 192 to 84,240 candidates in 0.27 s to 71 s; with raw data retained, the 28 components that planned before check 23 to 65 % more candidates (52,014 to 67,902; 19,205 to 23,741; 54,792 to 90,267; the 15-minute L5 components unchanged at 264,612) and take 20 to 70 % longer (at most 139 s), with no new timeout and the same selected cost in every one.
  • Assumptions and limits: a reader of unknown phase runs on the epoch grid (same as chains). A whole-window build whose window does not divide the interval (m[20s] every 30 s, accepted before) is now pruned; Pass 2's gcd panes serve it. Not changed: Pass 2 still offers no one-pane chain, so a 5m@300s query does not join a shared 1-minute pane chain of a 15-minute query; it gets its own whole-window build (see the plan above).

Before this PR

A window equal to its interval with no declared phase was kept only at query time, so without raw data the workload had no plan; a whole-window build at a phase off its window's grid was accepted and read the wrong window at runtime.

After this PR

Such a query is one tumbling window maintained at ingestion time and plans without raw data, in PromQL and SQL; a phase off the grid falls back to panes on the grid.

Verification

  • New tests fail on main (527d93f4) and pass here: the 3 planner integration tests above (NoValidCandidate x2 and the wrong offset-phase answer on main) and the updated unit test.
  • cargo test -p asap-planner -p asap-integration-tests -p asap-devtools -p asap-plan-selection: all pass (the new PromQL quantile test first ran without an accuracy target, where an exact quantile has no summary to keep; it now asks for rank error 0.01); cargo nextest run -p asap-physical-asap-optimization: 18 passed. The committed dag-viewer examples are unchanged (stage_pipeline tests pass).
  • cargo fmt --all -- --check; cargo clippy -p asap-physical-asap-optimization -p asap-planner --all-targets --all-features --locked -- -D warnings: clean.
  • End to end, W-RollupHeavy 8 users, no raw data, default search, cost model 1, accuracy guarantees enforced, 180 s timeout, release stage_pipeline on a 56-core server, the 26 components that had no plan:
planned no plan wall s (max) candidates checked
main 527d93f4 0 26 8.1 –
this PR 26 0 71.3 192 – 84,240

Costs of the new plans: 5.3e-07 to 9.6e-06 per second.

With raw data retained, all 34 L4 and L5 components, both binaries on the same server, 12 at a time:

planned timeout (180 s) selected cost changed candidates checked wall
main 527d93f4 28 6 – 19,205 – 264,612 6.0 – 137 s
this PR 28 6 0 of 28 23,741 – 264,612 7.5 – 139 s

The 6 timeouts (four L4 and two L5 distinct-count components) are limited by search size, before and after.

Closes #893
Closes #828

@zzylol zzylol added bug Something isn't working optimizer Workload optimization and plan selection promql PromQL front-end lowering (L1→L2) sql SQL front-end lowering (DataFusion → L2) window Window functions, frames, panes, or temporal semantics labels Oct 22, 2026
…hole-window builds end on the epoch grid

A whole-window build (one window per evaluation) may now be maintained at
ingestion time for readers declared with a fixed interval and no phase, as a
pane chain's readers already are: a reader of unknown phase is taken to run
on the epoch grid. The build must end on the grid of its window's width
anchored at the epoch, where the runtime builds it: the interval and any
known phase are multiples of the window. So m[5m] every 5 minutes (PromQL or
SQL) is one tumbling window kept at ingestion time and plans without raw
data, and m[20s] every 20 s at phase 5 s no longer reads a window off by 5 s
(it uses 5 s panes).

Closes #893
Closes #828
@zzylol
zzylol force-pushed the fix/window-equals-interval-tumbling branch from 5fe118a to 940ad49 Compare October 22, 2026 19:34
@zzylol
zzylol merged commit c7f7091 into main Oct 22, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working optimizer Workload optimization and plan selection promql PromQL front-end lowering (L1→L2) sql SQL front-end lowering (DataFusion → L2) window Window functions, frames, panes, or temporal semantics

Projects

None yet

1 participant