Skip to content

fix(promql): rate/irate/increase/delta/deriv/resets return no series when step >= range - #981

Merged
rita7lopes merged 3 commits into
metrico:masterfrom
bzed:fix/promql-step-ge-range-empty-result
Sep 17, 2026
Merged

rita7lopes merged 3 commits into
metrico:masterfrom
bzed:fix/promql-step-ge-range-empty-result

Conversation

@bzed

@bzed bzed commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Fixes #980

Summary

rate(), irate(), increase(), delta(), deriv() (and, by
construction, resets()/changes()/idelta()) returned an empty
query_range result — HTTP 200, "status":"success", no error — for
any series whenever the query's step reached or exceeded the
function's own range-vector duration, e.g. rate(x[300s]) queried
with step=300s. That's an ordinary, standard PromQL shape (step == range, back-to-back tiling), not an edge case, so any client whose
step happens to match its rate window silently got nothing back. See
#980 for the full reproduction and a binary-searched boundary.

Root cause (two independent code paths, same underlying flaw)

  1. The ClickHouse pushdown (VectorRange optimizer →
    CounterPlanner/CounterFlagsPlanner, used for rate, increase,
    delta, resets, changes): real samples were bucketed once per
    ctx.Step before being scanned for two distinct samples inside
    (t-range, t]. Once step >= range, every bucket landed exactly on
    the step grid, so that window could hold at most one of them —
    "two distinct samples" was never satisfied, and the result was
    always empty.

  2. The older per-step resampling path (DownsampleHintsPlanner,
    used for irate, deriv, idelta, which have no ClickHouse-side
    acceleration and rely on the Prometheus engine's own function
    implementation over whatever the storage layer hands back): same
    idea, but worse — the resample bucket grid is anchored to epoch,
    not to the query's own evaluation timestamps, so whether a given
    (t-range, t] window catches enough buckets to compute anything
    depends on an alignment the caller never controls. Verified live
    against ClickHouse: irate(x[300s]) failed unpredictably well
    before step >= range too (empty at step 210s, 240s, 260s... while
    working at 220s, 230s, 250s in the same sweep), which is why the
    step >= range boundary in the report is necessary but not
    sufficient to describe it.

Fix

Both paths get the same fix: cap the resample/bucket width to at most
range/2 for functions that measure a change across samples (rate,
irate, deriv, delta, idelta, resets; increase/changes
included defensively). Two buckets of that width always fit inside any
(t-range, t] window, regardless of where its boundary falls — a
pigeonhole guarantee, not something that depends on step or on
alignment. ctx.Step is used unchanged whenever it already leaves
room for two buckets, so the common case (step finer than range) is
untouched.

OverTimePlanner (sum_over_time, avg_over_time, min_over_time,
max_over_time, count_over_time, last_over_time) deliberately does
not get this cap: those functions only need one sample in the
window, not two, so there's no correctness floor forcing anything
finer than ctx.Step, and doing so anyway would raise their query cost
for no reported benefit. See the last commit and
TestOverTimeKeepsQueryStepEvenWhenCoarserThanRange.

Performance

The cap only changes behavior once step >= range/2 for the affected
functions, and even then the cost is bounded by, and identical to, an
already-supported query shape: a query with step = range/2 explicitly
(never broken, already accepted). A caller with step = 1000×range
does not pay 1000× anything — they pay exactly what a step = range/2
caller already pays. No new class of expensive query is introduced;
correctness for a previously-silently-empty shape now costs the same
as an already-working neighboring one.

Verification

  • Unit tests added per fix (fail against the pre-fix code, pass with
    it): TestCounterAcceleratesWhenStepIsNotFinerThanRange,
    TestDownsampleHintsCapsChangeFunctionBucket /
    TestDownsampleHintsLeavesPlainAggregatesAtTheQueryStep,
    TestOverTimeKeepsQueryStepEvenWhenCoarserThanRange.
  • Verified end-to-end against a real, local ClickHouse: pushed a
    synthetic counter via remote_write, swept step from 30s–500s across
    rate/irate/deriv/delta/idelta/increase/resets, confirmed
    every function returns data with correct values at every step
    (previously empty at/around step >= range).
  • Cross-checked against a real Prometheus 3.14 fed the same data:
    rate/delta/increase/resets match Prometheus byte-for-byte
    across a 75-step sweep. irate/deriv match closely but not always
    exactly, and idelta numerically differs at most resolutions — both
    are a pre-existing, separate limitation of
    DownsampleHintsPlanner (it hands the engine a resampled proxy
    series tagged with the bucket boundary rather than the real sample
    timestamp, which these two-point functions are sensitive to),
    present identically whether or not this fix's cap is even engaged,
    so out of scope for this PR.

🤖 Generated with Claude Code

bzed and others added 3 commits September 14, 2026 22:23
rate(), increase(), delta(), resets() and changes() accelerated via the
ClickHouse pushdown (VectorRange -> CounterPlanner/CounterFlagsPlanner)
came back empty whenever the query's step was greater than or equal to
the function's own range-vector duration -- e.g. rate(x[300s]) queried
with step=300s, or any step above it. This is a very ordinary shape
(step == rate window, back-to-back tiling with no gaps or overlap), so
it silently broke a standard client pattern: HTTP 200, empty result,
no error, while the underlying data was present and healthy the whole
time.

Root cause: prevValues() (shared by CounterPlanner and
CounterFlagsPlanner) bucketed real samples once per ctx.Step via
BucketProducer, then looked, per row, at every bucket inside
(t-range, t] to find two distinct samples (the first/last for a
counter function, or the current/previous for a transition-counting
one). Once ctx.Step reached or exceeded the range, every bucket landed
exactly on the step grid, so a whole (t-range, t] window could hold at
most one of them -- first and last (or current and previous) always
collapsed onto the same single row, "two distinct samples" was never
satisfied, and the query returned no series for any series.

Fix: bucket real samples at min(ctx.Step, range/2) instead of always
ctx.Step (bucketResolution in planner/shared.go), so at least two
buckets are always in reach inside any (t-range, t] window regardless
of how coarse the query's step is. ctx.Step is used unchanged whenever
it already leaves room for two buckets, so the common case (step finer
than range) is untouched. BucketProducer and FillGapsPlanner now take
an explicit Resolution instead of always reading ctx.Step, so the real
buckets and the gap-fill grid stay in lockstep with whatever
resolution the caller picked; OverTimePlanner picks up the same
guard as a byproduct, which only makes its own bucketing more precise
in the same step >= range case (a single step-wide bucket previously
spanned more than the requested range).

Adds TestCounterAcceleratesWhenStepIsNotFinerThanRange, which fails
against the old bucketing (asserts it does not fall back to bucketing
at the full range/step width) and passes with the fix, across rate,
increase, delta, resets and changes at step == range and step > range.

Bug report: metrico#980

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…step

Verified against a live ClickHouse: pushed a synthetic counter over
remote_write and swept irate(x[300s]) across query steps 30s-400s. On
the pre-fix binary this failed unpredictably -- not only once step
reached the 300s range, but well before that (confirmed empty at step
210, 240, 260, 270, 280, 290, 298, 300 while working at 220, 230, 250 in
the same sweep) -- because the affected code path resamples the raw
series once per query step, anchored to epoch, and then hands that
resampled series to the engine's own irate()/deriv()/etc. as if it were
the raw data. Whether a given (t-range, t] window catches enough of
those buckets to compute anything depends on an alignment between the
epoch-anchored grid and the query's own evaluation timestamps that the
caller never controls -- so the same step can work one second and fail
the next as "now" ticks forward, which is why the boundary in the
original report (step >= range) is necessary but not sufficient: it
reproduces reliably there, but the underlying flaw already misfires
below it.

This is a second, independent instance of the step-vs-range bug fixed
for rate/increase/delta/resets/changes in the prior commit: that one
covers the ClickHouse-pushdown path (CounterPlanner /
CounterFlagsPlanner, used when a MatrixSelector is a direct Call
argument); this covers the older per-step resampling path
(DownsampleHintsPlanner) that irate, deriv and idelta still go through,
since they have no ClickHouse-side acceleration of their own and rely
on the engine's own implementation over whatever samples storage hands
back.

Fix: cap the resample bucket to at most range/2 for every function that
measures a change across samples (rate, irate, deriv, delta, idelta,
resets; increase and changes included defensively, though they are
normally intercepted by the pushdown before reaching this planner) --
the same technique as the prior commit, and for the same reason: two
buckets of that width always fit inside any (t-range, t] window
regardless of where its boundary falls, which a resample at the query's
own step only achieves by accident. Plain reducing aggregates
(sum_over_time, last_over_time, ...) need only one point in the window
and are left resampling at the query's own step, unchanged.

Adds TestDownsampleHintsCapsChangeFunctionBucket (fails against the old
bucketing, passes with the fix, across all six affected functions at
and past the range boundary) and
TestDownsampleHintsLeavesPlainAggregatesAtTheQueryStep (guards the
non-regression for functions that don't need the cap).

Re-verified end-to-end against ClickHouse after the fix: a dense sweep
of steps 30s-400s (5s increments) for rate, irate, deriv, delta,
idelta, increase and resets against the same live counter came back
with a series every time, with rate/irate/deriv reporting the correct
1/s and delta/increase the correct 300 over the 300s window.

Bug report: metrico#980

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The first commit's range/2 bucket cap is a real, if bounded, cost: for
any query whose step is coarser than its range, internal row count
goes from (span / step) to (span / (range/2)) -- capped at the cost of
an equivalent step=range/2 query, never worse, but still strictly more
work than the query's own step would otherwise need.

That cost buys a correctness floor rate/increase/delta/resets/changes
cannot do without: they measure a change between two samples, so
fewer than two landing inside (t-range, t] means an empty result,
which is the bug being fixed. OverTimePlanner's functions
(sum_over_time, avg_over_time, min_over_time, max_over_time,
count_over_time, last_over_time) have no such floor -- they reduce
over whatever the window holds, so a single bucket is already enough
for an answer. The commit applied the same cap to them anyway "as a
byproduct", trading that same cost increase for a correctness
improvement nobody asked for: at step >= range they were already
returning a value, just one computed over the full step-wide bucket
rather than strictly the trailing range -- a real but unreported
inaccuracy, not the reported empty-result bug.

Revert that one line: OverTimePlanner keeps bucketing at ctx.Step
however coarse that gets relative to its range, so its cost stays tied
to the query's own step, same as before either fix. Adds
TestOverTimeKeepsQueryStepEvenWhenCoarserThanRange to hold this scope
boundary in place.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@rita7lopes rita7lopes left a comment

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.

Thanks for the PR @bzed !
The test TestOverTimeKeepsQueryStepEvenWhenCoarserThanRange asserts a current behaviour that is wrong.
sum_over_time(x[1m]) at step=1h buckets at 1 hour, so it sums a whole hour of samples and calls it a 1-minute sum. Basically it gives wrong numbers and the test asserts it.

This bug is in master so I will not hold this PR which solves a real (different) bug because of something it doesn't introduce.
I will fix the sum bug in a coming PR.

Merging this one

@rita7lopes
rita7lopes merged commit 8cebc8a into metrico:master Sep 17, 2026
9 of 10 checks passed
rita7lopes added a commit that referenced this pull request Sep 21, 2026
A function that measures a change across samples (rate, irate, deriv,
delta, idelta, resets, increase, changes) cannot answer from a single
bucket, so its bucket has to be sized against its range rather than
against the query's step. After #981 that rule existed in three places,
with three different lists of function names and two different floors,
and they did not agree.

deriv is where the disagreement was reachable: it has no ClickHouse
pushdown, so it goes through DownsampleHintsPlanner, and it was also in
rateFunctions, so adjustHintsForRate had already capped its step by the
time it got there. Below a 30s range the request layer floored the cap at
15s -- metrics_15s rows are stamped on a 15s grid, so a finer bucket
cannot hold a second row -- and the planner then overrode that back down
to range/2, asking ClickHouse for buckets narrower than the table's own
resolution. The two also disagreed on the threshold: the request layer
capped once step passed range/2, the planner only once step reached the
full range, so a step between the two was capped in one place and not the
other.

Consolidate both decisions into planner.NeedsDistinctSamples and
planner.BucketResolution. BucketResolution is idempotent, so the request
layer and the planners can both apply it to the same query without
fighting over the answer; a test asserts the two layers agree on the
width they settle on.

Rename rateFunctions to prolongFunctions. That list is consulted by
isProlong, which decides whether the raw iterator carries a series
forward between steps -- a different question from which functions need a
capped bucket, and the two must not be folded back together.

Restructure DownsampleHintsPlanner into an explicit three-way switch on
what the function needs from its window. The step > range branch was
unreachable for change functions and read as if it still applied; it is
the shape a reducer takes, reading only the trailing range of each step
and snapping it forward, and it is now stated as such. That branch had no
test at all, so its output is captured verbatim first.

Behavior changes, all of them narrow:

  - irate and idelta are now capped at the request layer as well. Their
    SQL is unchanged -- the planner already capped them to the same width
    -- but hints.Step now reflects it.
  - The 15s floor is gone. A cap that drops the step under the 15s grid
    now trips the existing useRawData check, so a range too small for
    metrics_15s to serve is read from raw samples at real timestamps
    instead of from a bucket finer than the table's own resolution.
    Affects change functions with a range under 30s.
  - The counter path now caps at min(step, range/2) rather than leaving
    any step below the full range alone, matching what the request layer
    has always done and what #981 documents itself as doing. A step
    between range/2 and range previously kept only two grid slots inside
    (t-range, t], the outer one on its first millisecond, so a series
    that had not reported into that one bucket collapsed to a single
    sample and was dropped.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

query_range (and [range:resolution] subqueries) return zero series for range-vector functions when step >= range

2 participants