Skip to content

feat: range evaluations (step, span) in the workload; pane grid, per-step pricing and pane retention - #759

Merged
zzylol merged 4 commits into
mainfrom
feat/range-evaluation-757
Oct 18, 2026
Merged

zzylol merged 4 commits into
mainfrom
feat/range-evaluation-757

Conversation

@zzylol

@zzylol zzylol commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Why

A dashboard panel refreshes on a cadence and sends one query_range request per refresh, for example the last hour at 1-minute steps. Each request evaluates the query at 61 steps. A workload entry could only say how often a request runs (QueryRecurrence), so:

  • Stage 3 priced one evaluation per refresh, about 61 times too little.
  • Stage 3 could not see that panes kept from earlier steps and refreshes are reused.
  • Stage 2 could offer panes that some steps cannot read: a step that ends inside a pane is forwarded by the runtime (ASAPQuery-backend #825).
  • Kept panes were retained only for the window. Each refresh then rebuilt the panes its earliest steps read (#825's repeated B3 request rebuilt 3).

What

  • Workload. QueryWorkloadEntry, RepeatingEntry and BatchEntry take an optional range_evaluation: {"step_ms", "span_ms"}. recurrence still says how often a request runs.
    • Steps are placed as Prometheus does: forward from start = end − span_ms through end.
    • Validation rejects step_ms: 0, a span_ms that is not a multiple of step_ms (the last step would fall off the end's grid), and more than 11,000 steps per request (Prometheus' limit).
    • A one-step range (span_ms: 0) is treated as one evaluation per request in every stage.
    • When it is omitted, nothing changes, and entries serialize exactly as before.
  • Stage 1 and Stage 2 (pane grid).
    • Stage 1 now chooses pane widths as gcd(window, interval, step) instead of gcd(window, interval). Every step therefore ends on a pane boundary.
    • Stage 2 rejects any pane chain whose width does not divide a reader's step, with a clear reason.
    • Stage 2 also keeps a range-evaluated whole-window build at query time.
  • Stage 3 (price). A request costs S = span_ms / step_ms + 1 evaluations, and sharing across steps and refreshes is credited. Formula below.
  • Retention for the runtime. Selection::pane_retention_ms gives, for each pane chain the selected plan keeps beyond one evaluation, the window plus the readers' span. stage_pipeline shows it as stage3_plan_selection.pane_retention_ms. feat(planner): plan_installable, the one call a deployment runtime makes (#749) #755 (the installable plan) has not merged yet. When it does, it will carry this value in its stored outputs.
  • Devtool. stage_pipeline --workload <file> plans a PlanningWorkload JSON file. The document's workload section shows range_evaluation.

How

Stage 3's formula, for a query that runs R requests per second, each with S = span_ms / step_ms + 1 steps:

node computed per second kept
NotMaterialized, MatAtQuery (every query-time computation not kept) R · S per consumer unchanged
MatAtIngestion state once per second of arriving rows, as before; the merge and estimate over it run R · S N + span_ms/w completed panes, plus the one being built
MatAtQueryKeptAcrossEvaluations chain R: in the steady state each request builds only its newest pane (Stage 2 offers it only when requests repeat at the pane width), and every other pane its steps read was built earlier N − 1 + span_ms/w panes between requests, all of them at the peak

The retention reported to the runtime is N·w + span_ms: the window plus the span. For a chain read by several queries, the longest span counts.

Within evaluation_rate, queries on one interval are evaluated together, and every request ends at the same instant. A node they share is computed once per distinct instant end − k·step_ms (k·step_ms ≤ span_ms) of any of them:

  • With one step, that is span_ms/step_ms + 1 for the longest span. A 61-step and an 11-step reader at 1-min steps count 61.
  • With different steps, the offsets are unioned. 1-min steps over 1 h and 30 s steps over 10 min share 11 instants, so they count 71, not 82.
  • A reader with no range, or a one-step range, counts as step = interval, span 0, i.e. only the request's end, which every range already evaluates at.

Queries on different intervals add. Latency stays per evaluation, i.e. per step. With no range, S = 1 and the retention is the window, so every formula reduces to today's.

Sharing across refreshes works through the pane grid. Kept panes are offered only when w == interval and w | step, so the earliest step of every request falls on the grid even when the interval does not divide the span.

Key code interfaces added / modified

Workload: crates/types/src/workload/mod.rs

/// The most evaluation steps one range request may have, as Prometheus
/// allows for `/api/v1/query_range`.
pub const MAX_RANGE_EVALUATION_STEPS: u64 = 11_000; // new

/// How one request is answered as a range request, as Prometheus' query_range does: it evaluates
/// the query at `start = end − span_ms` and every `step_ms` after, through the request's `end`.
/// JSON `{"step_ms": 60000, "span_ms": 3600000}`; unknown fields rejected. // new type
pub struct RangeEvaluation {
    /// Time between consecutive steps of one request, ms; must be positive.
    pub step_ms: u64, // new
    /// How far before the request's end its first step is, ms: a multiple of `step_ms`; 0 for one step.
    pub span_ms: u64, // new
}
impl RangeEvaluation {
    /// Steps per request, `span_ms / step_ms + 1`: 61 for 1 h at 1 min.
    pub fn evaluations_per_request(&self) -> u64; // new
}

/// The greatest common divisor of two cadences in ms (`a` when `b` is 0); Stage 1's pane widths use it.
pub fn greatest_common_divisor(a: u64, b: u64) -> u64; // new (was a private `gcd` in asap_aware_cse.rs)

/// One query of a finite batch. The type stays.
pub struct BatchEntry {
    /// The query text.
    pub query: Query, // unchanged
    /// Accuracy target and latency bound.
    pub requirements: QueryRequirements, // unchanged
    /// How far ahead it is known.
    pub predictability: Predictability, // unchanged
    /// How many times it runs.
    pub invocations: u64, // unchanged
    /// When it runs, if known.
    pub execute_at: Option<TimestampMs>, // unchanged
    /// Each invocation as a range request; default `None` (one evaluation). Omitted from JSON when `None`.
    pub range_evaluation: Option<RangeEvaluation>, // new
}

/// One query with repeated demand. The type stays.
pub struct RepeatingEntry {
    /// The query text.
    pub query: Query, // unchanged
    /// How it recurs.
    pub demand: RepeatedDemand, // unchanged
    /// Accuracy target and latency bound.
    pub requirements: QueryRequirements, // unchanged
    /// How far ahead it is known.
    pub predictability: Predictability, // unchanged
    /// Each request as a range request; default `None` (one evaluation). Omitted from JSON when `None`.
    pub range_evaluation: Option<RangeEvaluation>, // new
}

/// Planner-facing normalized entry. The type stays.
pub struct QueryWorkloadEntry {
    /// The query text.
    pub query: Query, // unchanged
    /// Accuracy target and latency bound (default exact, no bound).
    pub requirements: QueryRequirements, // unchanged
    /// How far ahead it is known (default unknown).
    pub predictability: Predictability, // unchanged
    /// How often a request runs (default unknown).
    pub recurrence: QueryRecurrence, // unchanged
    /// Each run as a range request; default `None`. Stages 1–2 keep panes on its step grid, Stage 3 prices its steps.
    pub range_evaluation: Option<RangeEvaluation>, // new
}
impl QueryWorkloadEntry {
    /// The grid every evaluation falls on: the fixed interval, or `gcd(interval, step)` with a range of more than one step; `None` without a positive fixed interval.
    pub fn evaluation_grid_ms(&self) -> Option<u64>; // new
}

/// Why a planning workload is rejected; every variant is bad input. The type stays.
pub enum WorkloadError {
    /// A bare PromQL instant selector without its metric's sample interval.
    MissingSampleInterval, // unchanged
    /// A sample interval of zero.
    ZeroSampleInterval, // unchanged
    /// A one-time query with zero invocations.
    ZeroInvocations, // unchanged
    /// A fixed repetition interval of zero.
    ZeroRepetitionInterval, // unchanged
    /// A range evaluation with `step_ms: 0`.
    ZeroRangeEvaluationStep, // new
    /// A range evaluation whose `span_ms` is not a multiple of `step_ms`.
    RangeEvaluationSpanNotMultipleOfStep {
        /// The declared span.
        span_ms: u64,
        /// The declared step.
        step_ms: u64 }, // new
    /// A range evaluation with more than `MAX_RANGE_EVALUATION_STEPS` steps per request.
    TooManyRangeEvaluationSteps { /// The steps one request would evaluate.
        steps: u64 }, // new
    /// A scheduled query with no timestamps.
    EmptySchedule, // unchanged
    /// A negative or non-finite rate.
    InvalidRate(f64), // unchanged
    /// A negative or non-finite `max_latency_ms`.
    InvalidLatency(f64), // unchanged
    /// A negative or non-finite stream norm bound.
    InvalidStreamNorm(f64), // unchanged
    /// `max_distinct_per_evaluation` of zero.
    ZeroMaxDistinct, // unchanged
    /// `series_count` of zero.
    ZeroSeriesCount, // unchanged
}

Stage 1: crates/logical-asap-optimization/src/workload_candidates.rs, asap_aware_cse.rs

/// Stage 1 over the Pass 1 inventory; `entries[q]` is query `q`'s workload entry, whose
/// evaluation grid sets the pane widths the window-composition rule offers.
pub fn stage1_logical_asap_optimization<'a, Id: Clone>(
    per_computation: &'a PerComputationCandidates<Id>,
    entries: &[QueryWorkloadEntry], // was recurrence: &[QueryRecurrence]
) -> impl Iterator<Item = Stage1Candidate> + 'a;

/// The pane candidates of `roots`: widths `gcd(window, every reader's evaluation grid)`.
pub fn pane_candidates(
    roots: &[QueryRoot],
    evaluation_grid_ms: &[Option<u64>], // was interval_ms (same type)
) -> Vec<PaneCandidate>;

Stage 2: crates/physical-asap-optimization/src/lib.rs

/// Why one candidate cannot be implemented; the caller prunes that candidate with it. The type stays.
pub enum PhysicalASAPOptimizationError {
    /// Internal bug: a schema derivation failed.
    Structure(SchemaDerivationError), // unchanged
    /// Internal bug: timings contradict.
    Timing(ExecutionDataStateError), // unchanged
    /// Unsupported: exact top-k with no Sort + Limit implementation.
    ExactTopK(String), // unchanged
    /// Candidate unfit for the workload: a pane width that does not divide the step of a reader's range of more than one step.
    PaneWidthOffRangeStep {
        /// The chain's pane width.
        pane_width_ms: u64,
        /// The reader's step.
        step_ms: u64 }, // new
}

(The crate-private IngestionPruneReason gains RangeEvaluatedWholeWindow.)

Stage 3: crates/plan-selection/src/lib.rs

/// Stage 3's result. The type stays.
pub struct Selection {
    /// The selected candidate's id.
    pub selected: String, // unchanged
    /// The selected roots with confirmed weights: the plan to run.
    pub plan: Vec<QueryRoot>, // unchanged
    /// Each valid candidate's cost.
    pub costs: BTreeMap<String, CandidateCost>, // unchanged
    /// Per valid candidate and query, the accuracy accepted.
    pub accepted: BTreeMap<String, Vec<AcceptedAccuracy>>, // unchanged
    /// Every candidate not selected, with its cause.
    pub rejected: Vec<Rejection>, // unchanged
    /// The Pareto front under cost model 3's pareto-front; else empty.
    pub front: Vec<String>, // unchanged
    /// Per pane chain the selected plan keeps beyond one evaluation (ingestion time or kept
    /// across evaluations), by node id: how far back from the newest evaluation the runtime must
    /// keep its panes, ms (window + longest reader span). Empty when it keeps none.
    pub pane_retention_ms: BTreeMap<PhysicalASAPNodeId, u64>, // new
}

Devtool: crates/devtools/src/bin/stage_pipeline.rs

  • --workload <file> (new): plans a PlanningWorkload JSON file. It cannot be combined with --example or --promql/--sql. --table declares the tables an SQL workload reads.
  • Document: stage3_plan_selection.pane_retention_ms (new, {node_id: ms}), and workload.queries[i].requirements.range_evaluation (new, when declared).

Usage (end to end, run at this head)

# What the user writes: a dashboard panel, max over the last 5 min per series, over 1M series
# sampled every 15 s. It refreshes every minute ("fixed_interval": 60000), and each refresh is a
# query_range over the last hour at 1-minute steps ("range_evaluation"): 61 steps per request.
cat > dashboard.json <<'JSON'
{"query_workload": {"language": "promql", "query_batch": null, "repeating_queries": [{
   "query": "max_over_time(m[5m])", "demand": {"fixed_interval": 60000}, "requirements": {},
   "predictability": {"predictable": {"known_at": null}},
   "range_evaluation": {"step_ms": 60000, "span_ms": 3600000}}]},
 "data_workload": {"arrival": {"ingesting": {"rows_per_second": 66666.67}},
   "sources": {"m": {"series_count": 1000000, "sample_interval": 15000}}}}
JSON
# Plan it for a runtime that keeps query-time results (B3 is offered).
cargo run -p asap-devtools --bin stage_pipeline -- --workload dashboard.json \
  --deployment-capabilities '{"query_time_results_retained": true}' --out run.json

Inside the run:

  • Stage 1: evaluation_grid_ms = gcd(60 s, 60 s) = 60 s, so the 5-min window becomes five 1-min panes.
  • Stage 2: 60 s divides the 60 s step, so the chain is offered as NotMaterialized, MatAtIngestion and kept across evaluations.
  • Stage 3: each request is priced as 61 evaluations.

What comes back in run.json:

workload.queries[0].requirements.range_evaluation = {"span_ms": 3600000, "step_ms": 60000}
stage3_plan_selection.selected = "P2-m2"                       # 1-min panes built at query time, kept
stage3_plan_selection.pane_retention_ms = {"1": 3900000}       # keep panes 1 h + 5 min back
costs: P1 127.0833 (rebuild the window every step)  P2 132.1667 (panes rebuilt every step)
       P2-m1 72.5133 (panes at ingestion)           P2-m2 70.5133 (selected)
P2-m2 per node:
  0 scan 4000000 samples; x 0.0167/s                       # one new 1-min pane per request
  1 build exact Max accumulator in 5 panes ...; x 0.0167/s; keeps 512000000 bytes across evaluations
  2 merge 5000000 states into 1000000; x 1.0167/s          # 61 steps per minute
  3 finalize 1000000 accumulators; x 1.0167/s
rejected: P1 costlier 127.083 vs 70.513; P2 costlier; P2-m1 costlier 72.513 vs 70.513

Without range_evaluation, the same workload selects P1 (2.0833 cost/s), ahead of P2-m2 (4.5133) and P2-m1 (6.5133).

Before this PR

  • A dashboard's query_range was priced as one evaluation per refresh.
  • Panes were chosen from the refresh interval alone, so a 30 s step under a 1-min refresh got 1-min panes that half the steps cannot read.
  • Kept panes were priced and retained for the window only.
  • No output told the runtime how long to keep panes.

After this PR

Pattern B, quantile_over_time(0.99, latency_ms[5m]) every 1 min, 1M series, kept results allowed (cost/s, cost model 1) no range {60000, 3600000} (61 steps)
P1 exact 2.4 146.4
P2 whole-window KLL (selected in both) 2.1 128.1
P3 KLL panes rebuilt (B2) 2.18333 133.18333
P3-m1 panes at ingestion (B1), memory 13,326.53, 106.6 GB 146,593.53, 1,172.7 GB (66 panes instead of 6)
P3-m2 panes kept (B3), memory 8,884.53, 71.1 GB 142,151.53, 1,137.2 GB (64 panes instead of 4)

For Pattern B, the kept and ingestion-time panes are still not selected. Each pane holds a KLL for each of the 1M series, so the memory of 1 h of panes outweighs rebuilding at every step under the illustrative prices. This matches #754.

With per-series exact state (8 bytes a pane), declaring the range flips the choice, as in the Usage above:

max_over_time(m[5m]) every 1 min, 1M series rebuild (P1) kept panes (P2-m2) ingestion panes (P2-m1) selected
no range 2.0833 4.5133 6.5133 P1
last hour at 1-min steps 127.0833 70.5133 72.5133 P2-m2 (P2-m1 without query-time retention)

Verification

All tests below were written to fail first, the review fixes' tests included (span not a multiple of the step, one-step ranges, shared steps). With range handling disabled (one step per request, step ignored in the grid, Stage 2 check removed), the four new range tests and the two Stage 2 tests failed. They pass at this head.

  • cargo nextest run -p asap-integration-tests --test range_evaluation: 5 passed.
    • (a) stage3_plan_selection_prices_61_evaluations_per_dashboard_request: the Pattern B numbers in the table above, pinned.
    • (b) stage2_physical_asap_optimization_offers_only_step_aligned_pane_widths: 1-min refresh with 30 s steps gives only 30 s panes, and none is pruned. Without the range, the panes are 1 min.
    • (c) stage3_plan_selection_reports_the_retention_of_kept_panes: kept panes report 3,900,000 ms. B1 without a range reports 300,000 ms. A plan with no panes reports none.
    • (d) no_range_evaluation_leaves_every_price_unchanged: Pattern B's pinned 2.1, 2.18333, 8,884.53 and 106.6 GB are unchanged, and span_ms: 0 prices identically to no range.
    • Sharing example: stage3_plan_selection_prefers_kept_panes_once_the_range_is_declared.
  • asap-physical-asap-optimization: 16 passed, including a_pane_chain_off_the_range_step_grid_is_rejected (a one-step range with a 30 s step accepts 1-min panes) and a_range_evaluated_whole_window_build_stays_at_query_time.
  • asap-types: range_evaluation_is_optional_carried_and_validated (JSON, normalization, grid, validation bounds, a 90 s span at 60 s steps rejected, a one-step range with a 45 s step keeping the 60 s grid) and the doc tests.
  • asap-plan-selection: readers_at_one_interval_and_step_share_their_steps (61-step, 11-step, no-range and one-step readers on one interval: 61 a minute; 1-min steps over 1 h with 30 s steps over 10 min: 71, the distinct instants).
  • asap-devtools: the stage_pipeline test binary, 26 passed (the whole crate, 40, at the first head), including workload_file_with_a_range_evaluation_is_planned. The committed Example 1 fixture changed only by the new empty "pane_retention_ms": {}; all its costs are identical.
  • No-change evidence: the existing pinned tests pass unchanged, in planner_layering_example3/example4/example1, tumbling_pane_merge, deployment_capabilities, plan-selection (all), logical-asap-optimization (all) and devtools cost_model_flag… / deployment_capabilities_flag… (8,884.53).
  • cargo fmt --all -- --check passes. Clippy (--all-targets --all-features --locked -D warnings) passes on types, logical, physical, plan-selection, devtools and integration-tests. The workspace cargo check --all-targets passes after rebasing onto feat(plan-selection): AWS Fargate us-east-1 price preset for cost model 1 #756 and refactor!: name summary builds by shape; one RowWeight for keyed builds #758.

Limitations and follow-ups

  • Raw samples are not credited as shared across steps. ASAPQuery-backend #825 fetches raw samples once per request, but query-time scans are still priced once per step. Raw data kept on disk also still covers the per-step look-back only, not the span (disk is unpriced by default).
  • Kept panes need the refresh to equal the pane width, as before. With a range, the width must also divide the step. Generalizing to "the width divides the refresh" (E/w new panes per request) would also widen what is offered without a range, so it is left out.
  • No reuse of step results across refreshes. 59 of 61 step results repeat between refreshes, but no materialization keeps a step's final result. Only panes are shared.
  • Latency bounds stay per evaluation (one step), not per request.
  • feat(planner): plan_installable, the one call a deployment runtime makes (#749) #755 will carry pane_retention_ms in its installable plan's stored outputs.

Closes #757

A dashboard panel sends one query_range request per refresh, which
evaluates the query at every step over the last span. QueryWorkloadEntry
(and BatchEntry/RepeatingEntry, which normalize into it) now carry an
optional RangeEvaluation { step_ms, span_ms } next to the recurrence,
which still says how often a request runs. Validation rejects a zero
step and more steps per request than Prometheus allows (11,000). Omitted,
nothing changes and entries serialize as before.
Stage 1 splits windows into panes on every reader's evaluation grid,
gcd(interval, step) with a range evaluation, and Stage 2 rejects a pane
chain off a reader's step grid and keeps range-evaluated whole-window
builds at query time. Stage 3 prices span/step + 1 evaluations per
request, builds a chain kept across evaluations once per request, and
retains kept and ingestion-time panes for the window plus the span; the
selected plan reports that retention (Selection::pane_retention_ms).
stage_pipeline plans a PlanningWorkload JSON file (--workload) and shows
both. Without a range evaluation every price is unchanged.
@zzylol zzylol added cost-model Cost formulas, statistics, ranking, and selection enhancement New feature or request window Window functions, frames, panes, or temporal semantics workload Batch, repeating, or multi-query workload modeling labels Oct 18, 2026
…iced once

Review fixes for range evaluations:
- Prometheus places query_range steps forward from start, so a span that
  is not a whole number of steps puts the last step off the end's pane
  grid. Reject it (RangeEvaluationSpanNotMultipleOfStep) and drop
  evaluated_span_ms, which then always equals span_ms.
- A one-step range (span 0) no longer narrows the evaluation grid or
  triggers the pane-on-step check.
- evaluation_rate counts readers with one interval and step at their most
  steps, not the sum; a once-per-request reader adds nothing beside a
  range on its interval.
- One gcd (asap_types::workload::greatest_common_divisor).
- Pane retention is computed for the selected candidate only.
…e interval

Readers on one interval end their requests at the same instant, so a node
they share runs once per distinct instant end - k*step. Readers at
different steps (60 s over 1 h and 30 s over 10 min) shared 11 instants
that were counted twice (82 a minute instead of 71). evaluation_rate now
keeps the longest span per step and, when steps differ, counts the union
of their offsets; once per request is the interval with span 0.
@zzylol
zzylol merged commit 5a0318d into main Oct 18, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cost-model Cost formulas, statistics, ranking, and selection enhancement New feature or request window Window functions, frames, panes, or temporal semantics workload Batch, repeating, or multi-query workload modeling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Workload: declare range-query step and span so Stage 2/3 plan pane sharing across steps and refreshes

1 participant