Skip to content

feat(query): answer planned range queries one evaluation per step - #825

Draft
zzylol wants to merge 2 commits into
feat/kept-query-time-resultsfrom
feat/range-queries
Draft

zzylol wants to merge 2 commits into
feat/kept-query-time-resultsfrom
feat/range-queries

Conversation

@zzylol

@zzylol zzylol commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

Stack 7/7. Base: #824.

Why

The data plane forwarded every /api/v1/query_range request, so a dashboard panel was never answered by the selected plan, even when the plan would answer each of its steps as an instant query. The design's query engine section defines range queries as one evaluation per step that shares raw samples, stored panes and kept results across steps.

What

  • Range queries: a planned query_range runs one evaluation per step, T = start + k·step through end (inclusive), and returns a Prometheus matrix with x-asap-execution: plan.
  • Raw samples: each query-time raw range is fetched once for the whole request, (start − offset − range, end − offset], and sliced per step.
  • Stored panes: each step reads its stored panes (MatAtIngestion) as an instant evaluation would.
  • Kept results: for each kept definition (MatAtQueryKeptAcrossEvaluations), the panes no evaluation has kept are fetched in one request covering all steps. A pane built at one step is reused by the later steps. feat(query): keep query-time panes across evaluations #824's rules still apply with one watermark for the whole request: a pane is kept only once it is closed, so an open pane is recomputed at each step that reads it, and eviction is measured from the newest evaluation.
  • Forwarding: if any step can't be answered (a missing or evicted pane, a step off the pane grid, an unsupported shape, a failed fetch), the whole request is forwarded unchanged and the reason goes in x-asap-forward-reason. A range query that partly reads evicted panes is forwarded whole, as the design says. Unplanned range queries are forwarded unchanged with no reason.
  • Parameters, as Prometheus parses them: start/end take Unix seconds or RFC 3339. step takes float seconds or a Prometheus duration (30s, 1h30m; units y w d h m s ms, in order). A request Prometheus would reject is forwarded unchanged so Prometheus returns its own error: a missing parameter, end < start, step ≤ 0, or more than 11,000 steps. A nonzero limit is also forwarded unchanged, for both endpoints. Instant queries now accept RFC 3339 time too.
  • State: memory only, as before.

How

QueryEngine::raw_ranges_for_steps checks every step's stored panes. It returns one RawRange per query-time raw input, spanning all steps, and one per kept definition, spanning its missing panes (keyed by the definition's raw_input). The HTTP side fetches each range once. QueryEngine::evaluate_steps splits each fetched vector into per-series runs once. It then walks the steps in order, slices each step's samples by binary search within each run, and calls evaluate for that step. Series order matches the instant path, so every step's input matches the input of the same instant query. The engine thread runs the request as two commands, RawRangesForSteps and EvaluateSteps, with the precompute watermark as of the request.

Key code interfaces added / modified

// data_plane/src/query.rs
impl QueryEngine {
    /// The root answering `query` at each of `steps` (ascending) and the
    /// raw samples they need, each fetched once for all steps: per
    /// query-time raw range, `(first step - offset - range, last step -
    /// offset]`; per kept definition, the span of its panes no evaluation
    /// has kept. Fails if the query is not planned or a stored pane any
    /// step reads is missing.
    pub fn raw_ranges_for_steps(&self, query: &str, steps: &[i64], store: &SummaryStore<Batch>)
        -> Result<(usize, Vec<RawRange>), Unanswered>; // new
    /// Runs `root` at each of `steps`, in order, as `evaluate` does, each
    /// over its slice of `raw`. A kept pane built at one step is reused by
    /// the later ones; panes open at `watermark_ms` are not kept.
    /// `results[k]` answers `steps[k]`; any failing step fails the call.
    pub fn evaluate_steps(&mut self, root: usize, steps: &[i64], store: &SummaryStore<Batch>,
        watermark_ms: Option<i64>, raw: &BTreeMap<u64, Vec<RawSample>>)
        -> Result<Vec<Vec<ResultSample>>, Unanswered>; // new
}

// data_plane/src/prometheus.rs
/// Parameters of a range query (`/api/v1/query_range`). // new
pub struct RangeQuery {
    /// The PromQL text.
    pub query: String,
    /// First evaluation time, inclusive.
    pub start_ms: i64,
    /// Last possible evaluation time, inclusive.
    pub end_ms: i64,
    /// Time between consecutive evaluations, at least 1 ms.
    pub step_ms: i64,
}
impl RangeQuery {
    /// Reads `query`, `start`, `end` and `step` as Prometheus does; `None`
    /// if one is missing or invalid, or `limit` is nonzero.
    pub fn parse(url_query: Option<&str>, form: &[u8]) -> Option<Self>;
    /// The evaluation times `start + k * step` up to `end`, ascending.
    pub fn steps(&self) -> Vec<i64>;
}
/// A Prometheus range-query response: `results[k]` at `steps[k]`, series
/// sorted by labels, each with its values in time order. // new
pub fn matrix_response(steps: &[i64], results: &[Vec<ResultSample>]) -> Value;

impl InstantQuery {
    /// Reads `query` and `time` (Unix seconds or, now, RFC 3339).
    pub fn parse(url_query: Option<&str>, form: &[u8]) -> Option<Self>;
}

// data_plane/src/engine.rs
impl EngineHandle {
    /// See `QueryEngine::raw_ranges_for_steps`. // new
    pub async fn raw_ranges_for_steps(&self, query: String, steps: Vec<i64>)
        -> Result<(usize, Vec<RawRange>), Unanswered>;
    /// See `QueryEngine::evaluate_steps`; one watermark for every step. // new
    pub async fn evaluate_steps(&self, root: usize, steps: Vec<i64>,
        raw: BTreeMap<u64, Vec<RawSample>>) -> Result<Vec<Vec<ResultSample>>, Unanswered>;
}

Cost, for a request with S steps

this PR S instant queries
Prometheus requests R + K, independent of S S·R + (kept panes missing at each evaluation)
root evaluations S S
kept pane builds distinct missing pane ends read by the steps (B3, step = pane: 4 + S) the same, kept panes are shared either way
stored-pane fetches 0 0

R is the number of query-time raw ranges the root reads. K is the number of kept definitions with a pane not yet kept at request time (0 or 1 in B3). Each step slices its samples in O(series · log n + slice). A pane that is still open is rebuilt at every step that reads it. Samples fetched per raw input cover end − start + range, against S · range for instant queries. So when step > range the single fetch also reads samples that no step uses.

Before this PR

$ curl -sG .../api/v1/query_range --data-urlencode 'query=sum by (job) (rate(http_requests_total[1m]))' ...
x-asap-execution: forwarded

Every range query went to Prometheus, including planned ones.

After this PR

The same request is answered by the plan. Its 7 steps read one raw range from Prometheus, and each step equals the instant query at that time.

Usage

Run on this PR's tree. A Prometheus 2.53.2 is backfilled (promtool tsdb create-blocks-from openmetrics) with Example 1's three http_requests_total series, one sample every 15 s for 10 minutes from T0 = 1792332000, each a quadratic counter (i+1)·(t−T0)².

# Plan #509 Example 1 with this backend's capabilities, then serve it.
cargo run -p control_plane -- --workload examples/planner-layering-example1.json --out plan.json
# wrote plan.json (2 queries, 13 nodes)
cargo run -p data_plane -- --plan plan.json --prometheus-url http://127.0.0.1:$PROM --listen 127.0.0.1:$DP

# A planned range query: 7 steps, 30 s apart, end included.
curl -sG -D - http://127.0.0.1:$DP/api/v1/query_range \
  --data-urlencode 'query=sum by (job) (rate(http_requests_total[1m]))' \
  --data-urlencode start=$((T0+180)) --data-urlencode end=$((T0+360)) --data-urlencode step=30s
# x-asap-execution: plan
# {"data":{"result":[
#   {"metric":{"job":"api"},"values":[[1792332180.0,"944.9999999999998"],[1792332210.0,"1124.9999999999998"],
#     [1792332240.0,"1304.9999999999998"],[1792332270.0,"1484.9999999999998"],[1792332300.0,"1664.9999999999995"],
#     [1792332330.0,"1844.9999999999995"],[1792332360.0,"2024.9999999999995"]]},
#   {"metric":{"job":"web"},"values":[[1792332180.0,"944.9999999999999"], ... ,[1792332360.0,"2024.9999999999998"]]}],
#  "resultType":"matrix"},"status":"success"}
# Prometheus' prometheus_http_requests_total{handler="/api/v1/query"}: 1 -> 2 (one raw fetch for 7 steps).

# The same 7 times as instant queries: identical values, 7 Prometheus requests.
for k in 0 1 2 3 4 5 6; do curl -sG http://127.0.0.1:$DP/api/v1/query \
  --data-urlencode 'query=sum by (job) (rate(http_requests_total[1m]))' --data-urlencode time=$((T0+180+30*k)); done
# {"data":{"result":[{"metric":{"job":"api"},"value":[1792332180.0,"944.9999999999998"]}, ...

# RFC 3339 times and a float step give the same first three steps.
curl -sG http://127.0.0.1:$DP/api/v1/query_range --data-urlencode 'query=sum by (job) (rate(http_requests_total[1m]))' \
  --data-urlencode start=2026-10-18T14:03:00Z --data-urlencode end=2026-10-18T14:04:00Z --data-urlencode step=30
# x-asap-execution: plan   values [[1792332180.0,"944.9..."],[1792332210.0,"1124.9..."],[1792332240.0,"1304.9..."]]

# Unplanned, and invalid (end before start): forwarded unchanged; Prometheus answers.
curl -sG http://127.0.0.1:$DP/api/v1/query_range --data-urlencode 'query=count(http_requests_total)' \
  --data-urlencode start=$((T0+180)) --data-urlencode end=$((T0+240)) --data-urlencode step=30
# x-asap-execution: forwarded
# {"status":"success","data":{"resultType":"matrix","result":[{"metric":{},"values":[[1792332180,"3"],[1792332210,"3"],[1792332240,"3"]]}]}}
curl -sG http://127.0.0.1:$DP/api/v1/query_range --data-urlencode 'query=sum by (job) (rate(http_requests_total[1m]))' \
  --data-urlencode start=$((T0+240)) --data-urlencode end=$((T0+180)) --data-urlencode step=30
# x-asap-execution: forwarded
# {"status":"error","errorType":"bad_data","error":"invalid parameter \"end\": end timestamp must not be before start time"}

Prometheus 2.53 answers the same rate query with 900, 1080, …, not 945, 1125, …. That gap comes from #821, not from this PR. Prometheus 2 includes the sample at T − 1m in the range, while the plan's ranges are left-open (T − 1m, T], the PromQL range convention the Planner executor uses.

Forwarding with a reason needs a plan with stored panes, so it is covered over HTTP in answers.rs. For Example 4b at 90 to 100 min, step=5m is forwarded with … is not on the 600000 ms pane grid, and start=80m with pane ending at … is not stored.

End-to-end numbers

cargo test -p data_plane --test e2e_examples -- --nocapture runs the data_plane binary over HTTP against a stand-in Prometheus that counts raw range reads. Each step is compared with the Planner executor's reference (the selected plan run entirely at query time) and with the same instant query.

example range steps plan vs executor range vs instant raw ranges read (S instants)
1, sum by (job) (rate(...[1m])) 3 to 6 min, 30s 7 difference 0 equal 1 (7)
1, topk by (job) (10, sum_over_time(...[1m])) 3 to 6 min, 30s 7 difference 0 equal 1 (7)
4b without raw data, p99 [60m] 90 to 100 min, 10m 2 0 (a: 358, 398; b: 1358, 1398) equal 0 (0)
4 B3, p99 [5m] 10 to 14 min, 60 5 0 (a: 95, 94, 93, 92, 91; b: 195 … 191) equal 1 (9: 5, then 1 per step)
4 B3, same request again 10 to 14 min 5 0 equal 1 (the evicted panes, 6 to 8 min)

In data_plane/tests/range.rs, the B3 range over 10 to 14 min gives KeptResultStats { built: 9, reused: 16 }, against 25 builds for five unshared evaluations. Run again, it gives { built: 15, reused: 35 }. With the watermark at 12 min it gives { built: 10, reused: 15 }: the open panes ending at 13 and 14 min are rebuilt.

Planner notes (no Planner change)

  • The workload describes a recurring query by its evaluation frequency, not by the steps of a range request. The Planner therefore can't see that a range query with step equal to a pane width shares panes across steps, and can't price a range query's span. Kept retention also covers only the newest evaluation's window (largest offset plus two widths), so a dashboard refreshing [now − 1h, now] rebuilds the panes of its early steps on every refresh.
  • A step must land on the pane grid of every stored definition the root reads. The Planner doesn't know which steps a dashboard will use, so it can't pick a pane width that matches them.

Limitations

  • The engine thread runs all steps of a request as one command, so a long range (up to 11,001 steps) delays ingestion while it runs.
  • One fetch per raw input covers the whole range. When step > range, that fetch includes samples no step reads.
  • The timeout parameter is ignored, as for instant queries.

Verification

Tests were written first. Each failed against interface stubs (Unsupported("range queries"), or a forwarded response) before the implementation. The two forwarding-unchanged HTTP tests already passed, because forwarding was the old behavior; they guard it. The implementation author also designed these tests, so they are not independent.

  • data_plane/src/prometheus.rs unit tests (4 new): parsing of start/end/step (float, RFC 3339, durations), rejected requests (missing parameters, end < start, step ≤ 0, bad duration, more than 11,000 steps, nonzero limit), end-inclusive steps, and the matrix format.
  • data_plane/tests/range.rs (4 tests): (b) Example 1 fetches one raw range (2 min, 6 min] for 7 steps, and each step equals its instant query. (c) Example 4b steps read stored panes only, with no raw ranges. A step off the grid, or a step reading an evicted pane, is MissingPane. (d) B3 reuses kept panes, with kept_stats as above, and an open pane is recomputed at each step.
  • data_plane/tests/answers.rs (3 new, HTTP): a planned range query returns a matrix whose values equal the instant queries, for step given as 10m, 600 or 600.0. (e) An off-grid step, or a step reading an evicted pane, forwards the whole request with a reason. (f) Unplanned or invalid range queries are forwarded unchanged with no reason.
  • data_plane/tests/e2e_examples.rs (3 new): (a) example1_range, example4b_range and example4_b3_range compare against the executor reference and the instant queries, with read counts as in the table.
  • cargo test -p control_plane -p data_plane: all pass (e2e: 9 tests in 376 s, debug).
  • cargo fmt --all -- --check and cargo clippy --workspace --all-targets --locked -- -D warnings pass.
  • The Usage commands were run on this PR's tree against Prometheus 2.53.2; the outputs above are copied from that run.

A planned /api/v1/query_range runs one evaluation per step, start + k*step
through end, and returns a Prometheus matrix. Each query-time raw range is
fetched from Prometheus once for all steps and sliced per step; each kept
definition's missing panes are fetched once, so a kept pane built at one
step is reused by the later ones. Stored panes are read per step. If any
step cannot be answered, the whole request is forwarded with the reason.

start/end accept Unix seconds or RFC 3339 (now also for instant queries),
step float seconds or a Prometheus duration; requests Prometheus would
reject, and requests with a nonzero limit, are forwarded unchanged.
@zzylol zzylol added enhancement New feature or request area: data plane Runtime ingestion, storage, and query-serving data paths area: query engine PromQL lowering, execution, result shaping, and fallback behavior feature: PromQL PromQL compatibility and execution semantics feature: range evaluation Range-query steps, windows, timestamps, and temporal evaluation test: end-to-end Process-boundary, differential, conformance, and full-stack tests labels Oct 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: data plane Runtime ingestion, storage, and query-serving data paths area: query engine PromQL lowering, execution, result shaping, and fallback behavior enhancement New feature or request feature: PromQL PromQL compatibility and execution semantics feature: range evaluation Range-query steps, windows, timestamps, and temporal evaluation test: end-to-end Process-boundary, differential, conformance, and full-stack tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant