Commit Graph
5502 Commits
Author SHA1 Message Date
Dennis Zhuang 7217bf43fb refactor(promql): run date functions only through map_math and refine the review points
Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-22 03:14:53 +08:00
Dennis Zhuang e06f5b3f58 feat(promql): compile and run date functions over a vector in the native tree
Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-22 03:01:48 +08:00
Dennis Zhuang 7c139a2ccc feat(promql): skip native histogram samples in math functions and cover review gaps
Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-22 02:31:40 +08:00
Dennis Zhuang e4d1b79b76 feat(promql): evaluate round and clamp with time-varying scalar parameters
Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-22 02:12:48 +08:00
dennis zhuang 6f94a869a9 feat(promql): add the info() combine kernel to native operators (#9829)
I hereby agree to the terms of the [GreptimeDB
CLA](https://github.com/GreptimeTeam/.github/blob/main/CLA.md).

## Refer to a related PR or issue link (optional)

Narthex operator task NEW-MATCH-INFO (kernel part).

## What's changed and what's your intention?

Adds the combine step of Prometheus v3.15.0's experimental `info(v,
{selector})` (`combineWithInfoSeries` / `combineWithInfoVector`) as a
native kernel, `info::info`.

The caller selects the info series by info metric name and data
matchers, with lookback, `@` and offset. It passes them as single-field
series whose value at each point is the original timestamp of the
selected sample, as Prometheus' `evalSeries(..., recordOrigT=true)`
produces them. The kernel then does everything else `evalInfo` does:

- **Info metric names.** Name matchers are completed as Prometheus does:
none means `target_info`, and only-negative matchers are prefixed with
`.+_info`. A base series that is itself an info metric passes through
unchanged.
- **Which info series count.** The kernel keeps only those Prometheus
would fetch (`infoIdentifyingMatcherSets`), so the caller may select by
name and data labels alone. Each identifying-label presence pattern
among the base series admits the values its series have; a base series
with neither `instance` nor `job` matches nothing.
- **Joining.** A base series is matched per info metric on `instance`
and `job`. When several info series with that signature are present at a
point, they are compared in input order, like Prometheus compares them
in its storage order: a newer one replaces the kept one, and an equally
new one is an error, but only at points where the base has samples.
- **Labels.** Only the data labels named by the selector are copied, or
all of them when it names none. Labels already on the base series are
kept. Two info metrics setting one label to different values is an
error. A base series with no matching info series is kept only when
every data matcher matches the empty string.
- **Results.** Results sharing a label set merge field by field; one
field held twice at a point is an error. Errors follow Prometheus'
order: the first failing step, and within a step info duplicates, then
label conflicts, then label set collisions.

Name regexes use `^(?s:...)$` with the `regex` crate, as `map_label`
does. Its dialect differs from Go's RE2 in the same ways (Unicode-aware
`\w`, `\d`, `\s`, `\b`).

Out of scope: planning and reading the info series.

### Tests

`info/prometheus_cases.txt` is generated by the Prometheus engine
(promql-compat `narthex-golden-info`, lookback 25s on a 10s step). The
base is the engine's own evaluation of the base expression; the info
input is the series matching the name and data matchers, with original
sample timestamps.

- 400 random cases, plus fixed cases for:
  - the input-order rule (timestamps 5, 5, 6 fail; 6, 5, 5 succeed);
  - equal timestamps where no base sample is present;
  - a label set collision reported before a later conflict;
  - a base without a metric name;
  - `=~` / `!~` name matchers.
- Unit tests: merging by field for multi-field bases, and histogram base
samples.

## PR Checklist
Please convert it to a draft if some of the following conditions are not
met.

- [x] I have written the necessary rustdoc comments.
- [x] I have added the necessary unit tests and integration tests.
- [ ] This PR requires documentation updates.
- [x] API changes are backward compatible.
- [x] Schema or data changes are backward compatible.
- [ ] This PR needs to be backported to release branches, and I have
added the `backport-<target>` labels (e.g. `backport-v1.3` targets
`release/v1.3`).
2026-10-22 01:56:17 +08:00
dennis zhuang a5c91db63a feat(promql): add stddev, stdvar, quantile and count_values native aggregates (#9799)
I hereby agree to the terms of the [GreptimeDB
CLA](https://github.com/GreptimeTeam/.github/blob/main/CLA.md).

## Refer to a related PR or issue link (optional)

Narthex operator task AGG-STAT + AGG-CV.

## What's changed and what's your intention?

Adds the native float kernels for `stddev`, `stdvar`, `quantile` and
`count_values` to `src/promql/src/aggregate.rs`, on the existing
`Grouping` and validation. Semantics follow Prometheus v3.15.0
`aggregation`:

- `stddev` / `stdvar` extend `aggregate_float` with the same
uncompensated Welford update Prometheus uses. The running sum of squared
deviations starts at NaN when the first sample is NaN or ±Inf, as in
Prometheus.
- `quantile` collects each group's values in input order and reuses the
`quantile` kernel from `range_eval`. q is taken per point from a scalar
series; a point without q has no result.
- `count_values` sets the value label to the sample formatted like Go's
`strconv.FormatFloat(f, 'f', -1, 64)`. Rust's shortest `Display` rounds
ties up while Go rounds them half-even, so the digits are recomputed
with exact formatting. With `by`, the value label is added to the
grouping; with `without`, it is not, so listing it there drops it. An
empty label name is an error.

`FloatAggregator` exposes `aggregate_float` as construct /
`update(&Series)` / `finish`, so the native Aggregate runtime can fold
series as they arrive instead of buffering all input. `aggregate_float`
now runs on it, with unchanged semantics. The aggregator owns its
grouping labels and borrows nothing. A series that fails validation
leaves its state unchanged.

With several fields, each field is aggregated on its own. For
`count_values`, a group row has a count only in the fields whose value
produced its label.

Out of scope: partial/final aggregation state (AGG-STATE) and native
tree compilation and runtime wiring.

### Tests

- `stat_cases.txt`: golden cases generated by the Prometheus engine
(promql-compat `narthex-golden-aggstat`), 12 per operation × {none, by,
without, by(i)}, including NaN/±Inf/±0, ties, time-varying and
out-of-range q, and `without (…, <value label>)`.
- `format_float_cases.txt`: 1810 values with Go `FormatFloat` output,
including rounding ties and exponent boundaries.
- Multi-field, missing-q and empty-label cases in the existing kernel
tests.

## PR Checklist
Please convert it to a draft if some of the following conditions are not
met.

- [x] I have written the necessary rustdoc comments.
- [x] I have added the necessary unit tests and integration tests.
- [ ] This PR requires documentation updates.
- [x] API changes are backward compatible.
- [x] Schema or data changes are backward compatible.
- [ ] This PR needs to be backported to release branches, and I have
added the `backport-<target>` labels (e.g. `backport-v1.3` targets
`release/v1.3`).
2026-10-22 01:54:43 +08:00
discord9 fa9838da65 feat(promql): execute vector-vector binary nodes natively (#9813)
* feat(promql): execute a bound native tree node by node, over sparse evaluated series

Ports the native runtime onto the sparse evaluated-series API: a chunk is
addressed by a `TimeAxis` that may hold several runs, so a consumer reads
positions through the axis instead of assuming `start + row`.

The driver and both node kinds move to that API:

- `chunk.range().len()` becomes `chunk.series.time.len()`, the compact row
  count, in every metric and byte estimate.
- `occupied_runs` walks `time.positions()` with one field cursor, so a chunk
  of several runs records each run where it belongs. A run open across a hole
  now ends where the hole begins; leaving it open to the last row made two
  runs look like one and refused a series that occupies the hole.
- A window keeps the pieces its evaluator decided in a queue: one push can
  decide several runs, and reporting only what the push returned stranded the
  rest until the next push. Queued pieces are charged by `retained_bytes`, and
  the charge is recomputed when one leaves, so a released piece is not held.
- `map_chunk` answers `Option`: an operation that empties every position
  (`clamp` with `max < min`) yields no chunk at all, unless the chunk closes
  its series -- a closing chunk holding no row is handed over so a consumer
  can tell "nothing more here" from "no more input".

`RangeEvaluator::retained_bytes` is restored with the three byte helpers it
reads: a field's buffers, a series' identity and the pending batch's vectors,
all by capacity, and still counted as storage rather than as a peak over a
push.

Tests read absolute positions from the axis (`decided` no longer adds a row
number to a start), and two sparse cases cover what the old reading got wrong:
a `Window -> Map -> Window` chain over a chunk of two runs, and a series that
occupies the hole between another's runs.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* refactor(promql): drop the window queue the sparse evaluator never needed

`RangeEvaluator::push` and `push_series` each return `Result<Option<EvaluatedChunk>>`: the
evaluator gathers every run it decided into one chunk and hands back at most one. So
`pending.extend(decided)` inserted at most one chunk and `take_pending` removed it on the spot,
leaving the queue empty after every successful push.

That makes the queue dead weight: a `VecDeque` allocation per productive window, an accounting
term that is always zero, a second drain site in `pull`, and doc comments claiming a problem the
API does not have. The driver's "ask the input again when a window decides nothing" loop was
already the whole story.

Back to returning the evaluator's own answer, and drop `pending`, `take_pending` and
`take_window_output`.

Covers the empty-map protocol this port introduces while it is here: an operation that empties
every position hands on nothing while its series is open, and one empty chunk when it closes. A
non-closing empty chunk would be rejected downstream and a dropped closing one would leave the
series unclosed.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* test(promql): pin the empty-map output count at one

The test checked that every output was empty and that exactly one closed, which a stray extra
non-closing empty output would also satisfy.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): charge the memory the native evaluator actually retains

Four omissions made the reservation blind to most of what a window over native
histograms keeps, and the existing incremental-equals-recomputed test could not see
any of them because both sides used the same formula.

- `FloatHistogram` owns four heap allocations -- both bucket lists, the custom
  bounds, and the span layout -- that `FieldSamples::bytes` never looked at. A
  retained 4096-bucket sample is tens of kilobytes against a ledger that saw a
  struct header. `heap_bytes` counts them, and both the retained sample slice and
  the histograms a row boxes are charged for what they own.
- Queued chunks were estimated as one float per field per row, which is not what a
  histogram field holds: it holds indices into a shared `Arc<HistogramBatch>` and
  the buffers behind the Arrow arrays. The figure now walks the samples, and counts
  a shared table or a shared buffer slice once per pass rather than once per chunk
  that references it.
- `history_bytes` included `size_of::<History>()` while the map slot term charged a
  `History` per entry as well, so every entry paid two headers. The per-entry figure
  now covers only the heap the entry owns.
- The primary key is charged for the allocation its `Buffer` keeps alive rather than
  the bytes it shows: a scan slices the key out of a decoded dictionary-values
  buffer, so a short key can pin a batch-sized allocation.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): keep the source queue's charge incremental

The queue's reservation was recounted by walking every queued chunk on each
enqueue and each pop. `push_raw` and `push_evaluated` are public and put no bound
on how deep the queue gets, so a caller that queues a whole series before pulling it
paid `N(N+1)/2` chunk visits to hold `N` chunks -- the same shape of defect this
branch fixes in `absorb`.

`QueuedCharge` keeps a reference count and the bytes behind each shared allocation:
a table or buffer is charged when the first queued chunk reaches it and given back
when the last one leaves, so an operation walks the one chunk it names. The count is
per chunk rather than per field, because a pop re-walks the same chunk with the same
dedupe and must remove exactly the references that were added.

A chunk's own struct bytes are counted per chunk (`own_bytes`); only the allocations
behind its fields are counted by address, since a struct has no address to dedupe on.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* perf(promql): append a forward-moving chunk to the emitted history

`absorb` rebuilt the whole stored run array on every non-empty chunk. Legal input
made that quadratic across chunks with no label collision: one series in `K` chunks,
chunk `i` occupying only grid position `2*i`, stores one more run per chunk and moves
`K(K+1)/2` of them -- about five billion for a hundred thousand rows.

A chunk that starts at or past the end of the last stored run only ever adds to the
end, so its runs are appended and the boundary run is joined where it touches. One
that can interleave or reach backwards still takes the full one-pass merge, which can
split a run. Both leave the runs sorted, disjoint and joined, which is what
`Emitted::occupy` looks a position up by.

The test records the chunks through `Emitted::occupy` itself and reads the history
back from it, covering the forward case, the touching boundary, a hole that has to
join both neighbours, and a position before everything stored.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): report a truncated input from a tree with no window

`close` only asked windows whether a series was still open, so a tree of an
input alone, or of a map over one, accepted a final chunk without `is_last` and
handed the consumer a series that never closed. The input node now follows the
series of the chunk it last handed over, and closing such a tree reports the
truncation against it, which is the same refusal a window makes for the input it
reads.

`occupied_runs` read a row the axis names with `expect`, which panics on a
`Series` whose fields are shorter than its axis; the fields are public, so that
chunk is constructible by a caller. It now returns the misalignment as an error
naming the node, the field and both lengths.

The chunk protocol says one series closes once, which reads as one close per
label set. State that it is per input series: a node that rewrites labels can
hand two series over under one label set and both of them close.

Also say in `Emitted`'s doc that the table is one node's own, so a label set
costs its bytes again in every node that keeps one.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* feat(promql): execute an instant selection as a tree node

The tree could not run `NativeExpr::Instant`: the state builder recorded
`not_implemented` and refused the whole tree before reading a chunk, while the
compiler already produced the node for a bare selector. A query the plan and the
compiler both accept therefore had no runtime path at all.

`InstantRuntime` buffers one series to its `is_last` chunk -- the selection kernel
reads a complete axis -- then calls the shared `select_instants` once and gathers the
rows it picked onto the binding's own grid. Positions come from the grid lattice the
kernel was asked for, and a timestamp off that lattice is an error rather than a
skipped row. A series that selects nothing still closes, with an empty chunk on the
grid; an input that ends inside a series is reported on `finish()`.

The node does not record label-set occupancy: it keeps the metric verbatim, so it
cannot map two series onto one label set, and the occupancy key drops empty-valued
tags -- two genuinely distinct series would be refused as a collision.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): refuse an interleaved input, and close a label set only for its owner

Two series-level identity holes, both reported against `42ea525`.

The truncation flag followed the last chunk handed over, which is the same as
"is a series open" only while at most one series can be open. A tree with no
window still accepted `Interleaved`, and `a` open, `b` closed, end of input then
passed while `a` never closed. Tracking the open set would need an identity that
a raw chunk in bound-label mode does not have (`metric` is `None`, the key holds
it), so the ordering is refused when the state is built instead, for every tree
shape and not only under a window. The flag's doc now says what it means and why
that is the whole answer.

`History` closed on any series' `is_last`, so with `abs` dropping the metric name
a series that contributed nothing could close a label set another series was
still filling: `a{job="x"}` value, `b{job="x"}` empty close, `a{job="x"}`
continuation was refused with "already closed the label set". Contiguous input
hands over exactly that sequence, so it was not an interleaving artefact. A
history now remembers the series that filled it and closes only for that series'
closing chunk; a series that produced nothing under an open set is passed over,
as it already was under a closed one. The owner is charged as part of the entry.

The ordering check also reads every input node rather than only the first: a tree
with two inputs would otherwise have its second declaration read and dropped.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): reserve the instant gather's workspace and keep the histogram start

Two findings from the review of the instant node.

The gather allocates after the last chunk arrives: the concatenated axis, the
descriptors that read it, the two arrays the selection returns, and the rows the
builder keeps. Nothing asked the pool for any of it, so with one sample selected
by every point of a million-point grid the node took tens of megabytes while its
reservation fell to the buffered chunks' size -- nothing at all, on the chunk
that ends the series. `gather_bytes` now derives a bound from the values in hand
(rows, chunks, segments, fields, points, and the widest histogram row per field,
since one row can be selected by every point), and the reservation grows by it
before the gather runs. It shrinks back on both the success and the error path,
so the workspace never outlives the call.

A selection reads an existing sample, so it has to carry that sample's payload.
`FloatHistogram` had no `start_timestamp` and `push_to` wrote `None`, which lost
it for any histogram that had one: `detect_start_timestamp_reset` reads that
field to spot a counter reset, so a window over this output would mis-detect
resets. The field is now carried through a view and written back; the kernels
that compute a histogram instead of reading one leave it `None`, which is what
they produced before.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* docs(promql): describe the gather test by what this branch does

The doc on `an_oversized_selection_is_refused_by_the_pool` said the kernel has
no point limit any more. It does on this branch: the limit is removed on its own
branch, which this one does not carry, so the comment described a state that has
not arrived.

What is true here is the order: the estimate now applies to every gather, so the
pool is asked before the kernel is, and the pool's refusal is the one the caller
sees. That is worth saying on its own -- a bound in bytes on the path that
decides the points, rather than a count that says nothing about what they cost --
and it stays true after the limit goes.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* feat(promql): execute vector binary nodes in the native runtime

Checkpoint the vector-vector runtime and single shared matching kernel. Keep addressed input delivery, whole-input buffering, nested composition and error cleanup together. The existing arithmetic/comparison implementation remains the only computation path.

Local review checkpoint only, not ready for publication: all-targets check passes; the current run has three new fixture failures. Independent review requires current-live workspace resizing, field-local matched storage and kept-value merge budgeting before approval.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* test(promql): infer the binary result fixture element type

Drop a redundant complex type annotation flagged by the actual strict clippy run. The collection and literal assertions are unchanged.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): reserve binary workspace along the shared kernel

Remove the separate matching prepass and reserve current live workspace at the existing kernel allocation boundaries. Release per-sample and per-field scratch after ownership ends; charge actual retained rows, values, histograms and errors. Unbudgeted public and set callers bypass accounting without another computation path.

Validate with cargo fmt -p promql -- --check; cargo check -p promql --all-targets; cargo nextest run -p promql --retries 0 --no-fail-fast; cargo clippy -p promql --all-targets -- -D warnings. The public nested Binary test also fails on swapped left/right operands and passes after byte-identical restoration.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): scope binary reservations to live scans and output rows

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): charge binary buffers at actual allocation boundaries

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): skip accounting ledger for unbudgeted vector merges

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* refactor(promql): simplify native execution memory estimates

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): match binary operands by logical series

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): cover flattened operand descriptor capacity

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): merge rewritten logical series before window evaluation

Accept disjoint closed-source fragments under one output label set. Retain occupancy checks and merge affected window inputs once at EOF using shared typed sample segments. Keep direct input windows and root maps streaming, and release collected fragments on finish or error.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* docs(promql): clarify approximate merge workspace estimate

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): validate series boundaries before collected input merges

Keep each collecting window's open metric and last position so EOF grouping cannot hide reversed runs or identity changes before closing. Preserve source and root-map piece semantics, and test malformed input release, empty closing chunks, and EOF truncation.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* test(promql): supply the default binary fill in the unimplemented-node fixture

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): end a subtree before the node above it computes

A window that collects its input answers only once its own input is finished, so ending the input now flushes the deepest ready window and lets the following pull carry its answer up; a binary node computes only when both operands are settled. Previously a binary node computed on an empty operand and marked itself computed, dropping what the window produced afterwards. Also stream the answer instead of collecting it: end_of_input() ends the input, and each pull runs the next stage, so the caller pulls the result and the last None closes the execution.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): reserve an instant selection for the points it decides

The gather reserved the whole planned grid, while select_instants clips the range to the series' first and last sample and its lookback: a week-long query at a one-minute step reserved ~504 KB for a series that produces five points, and a small pool refused it before gathering. Reserve for the clipped candidate count, derived with the same alignment the kernel uses.

Also pin the start-timestamp semantics the histogram change spread to: last_over_time and first_over_time hand back the sample they selected with its start timestamp, and limitk and limit_ratio keep it on the sample they keep, so a reader downstream can still place a counter reset.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* refactor(promql): reserve binary memory per phase, not per value

The binary kernel adjusted the pool and the metrics once per matched point, per filed row and per merged row, so a million plain float points meant roughly three million reserve calls inside a numeric loop. Reserve at three boundaries instead: the operands as chunks are retained, one figure per phase (pairing, sort, merge) with an estimate over rows/fields/segments/payload, and each result buffer when a block of it is allocated.

Delete the code that modelled another component's allocator: index_column_growth and its shadow index_capacity, BinaryOperand::ends_growth, and the flatten-capacity model in assembly_bytes. Nothing pre-reserves the left-times-right product and no large allocation happens before its reservation.

Tests that pinned the callback sequence or exact small-object arithmetic go; the resource properties stay: a genuinely large input or output is refused, a sparse small result is admitted, and reservations are released on finish, error, truncation and drop.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* test(promql): move the histogram_quantile doc back to its own test

The start-timestamp test was inserted into the middle of another test's doc comment, leaving the earlier test without one.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* test(promql): refuse an interleaved input under a selection when the tree is bound

The merged plan validates an input ordering in the arm that reads it, so a
selection over an interleaved input no longer binds at all. Assert that refusal
where it happens instead of asserting a runtime rejection the tree cannot reach,
and keep the map-over-a-selection case: a map above a selection does not shield
the check, because the check looks at the input the selection itself reads.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* docs(promql): say what the start timestamp pins, and cover every path that carries it

The field is GreptimeDB sample metadata, so the comments now say that instead of
arguing from what Prometheus omits: a FloatHistogram there has no such field, while
its rate and reset evaluation takes start timestamps through a separate channel, and
this crate's native range kernel does not read the field at all.

Comparison filtering and set selection were the two paths that carry the timestamp
without a test. Both keep the payload of the operand they hand back, including the
right side of an or at a point the left side does not cover, and a bool comparison
answers with a float so no histogram reaches the output.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* style(promql): drop the blank line the appended test block left behind

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): drop the start timestamp a computed histogram never had

A window holding one histogram became the result as it arrived, while two or more
went through an add that clears the timestamp, so whether a summed histogram carried
the payload said how many samples the window held rather than what the function did.
Clear it where the fold returns, leaving the bucket layout to the caller.

Also correct two comments that described behaviour this branch does not have: the
selection kernel carries no point limit of its own, and the interleaved map sits
above the selection rather than between it and the input.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): bill a binary operand the histogram tables it keeps alive

A chunk figure leaves out the tables its segments index into, because a table is one
allocation however many chunks share it. The operand then billed only the indices, so
tables it kept alive cost nothing: a pool holding room for one table admitted a second
and a third, each arriving from a source whose own reservation was released as it
handed the chunk over. Bill each table once, to the chunk that first references it,
and charge the one slot that remembers it.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

---------

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-22 00:11:11 +08:00
discord9 2e7ed9cbcd feat(promql): execute an instant selection as a tree node (#9798)
* feat(promql): execute a bound native tree node by node, over sparse evaluated series

Ports the native runtime onto the sparse evaluated-series API: a chunk is
addressed by a `TimeAxis` that may hold several runs, so a consumer reads
positions through the axis instead of assuming `start + row`.

The driver and both node kinds move to that API:

- `chunk.range().len()` becomes `chunk.series.time.len()`, the compact row
  count, in every metric and byte estimate.
- `occupied_runs` walks `time.positions()` with one field cursor, so a chunk
  of several runs records each run where it belongs. A run open across a hole
  now ends where the hole begins; leaving it open to the last row made two
  runs look like one and refused a series that occupies the hole.
- A window keeps the pieces its evaluator decided in a queue: one push can
  decide several runs, and reporting only what the push returned stranded the
  rest until the next push. Queued pieces are charged by `retained_bytes`, and
  the charge is recomputed when one leaves, so a released piece is not held.
- `map_chunk` answers `Option`: an operation that empties every position
  (`clamp` with `max < min`) yields no chunk at all, unless the chunk closes
  its series -- a closing chunk holding no row is handed over so a consumer
  can tell "nothing more here" from "no more input".

`RangeEvaluator::retained_bytes` is restored with the three byte helpers it
reads: a field's buffers, a series' identity and the pending batch's vectors,
all by capacity, and still counted as storage rather than as a peak over a
push.

Tests read absolute positions from the axis (`decided` no longer adds a row
number to a start), and two sparse cases cover what the old reading got wrong:
a `Window -> Map -> Window` chain over a chunk of two runs, and a series that
occupies the hole between another's runs.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* refactor(promql): drop the window queue the sparse evaluator never needed

`RangeEvaluator::push` and `push_series` each return `Result<Option<EvaluatedChunk>>`: the
evaluator gathers every run it decided into one chunk and hands back at most one. So
`pending.extend(decided)` inserted at most one chunk and `take_pending` removed it on the spot,
leaving the queue empty after every successful push.

That makes the queue dead weight: a `VecDeque` allocation per productive window, an accounting
term that is always zero, a second drain site in `pull`, and doc comments claiming a problem the
API does not have. The driver's "ask the input again when a window decides nothing" loop was
already the whole story.

Back to returning the evaluator's own answer, and drop `pending`, `take_pending` and
`take_window_output`.

Covers the empty-map protocol this port introduces while it is here: an operation that empties
every position hands on nothing while its series is open, and one empty chunk when it closes. A
non-closing empty chunk would be rejected downstream and a dropped closing one would leave the
series unclosed.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* test(promql): pin the empty-map output count at one

The test checked that every output was empty and that exactly one closed, which a stray extra
non-closing empty output would also satisfy.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): charge the memory the native evaluator actually retains

Four omissions made the reservation blind to most of what a window over native
histograms keeps, and the existing incremental-equals-recomputed test could not see
any of them because both sides used the same formula.

- `FloatHistogram` owns four heap allocations -- both bucket lists, the custom
  bounds, and the span layout -- that `FieldSamples::bytes` never looked at. A
  retained 4096-bucket sample is tens of kilobytes against a ledger that saw a
  struct header. `heap_bytes` counts them, and both the retained sample slice and
  the histograms a row boxes are charged for what they own.
- Queued chunks were estimated as one float per field per row, which is not what a
  histogram field holds: it holds indices into a shared `Arc<HistogramBatch>` and
  the buffers behind the Arrow arrays. The figure now walks the samples, and counts
  a shared table or a shared buffer slice once per pass rather than once per chunk
  that references it.
- `history_bytes` included `size_of::<History>()` while the map slot term charged a
  `History` per entry as well, so every entry paid two headers. The per-entry figure
  now covers only the heap the entry owns.
- The primary key is charged for the allocation its `Buffer` keeps alive rather than
  the bytes it shows: a scan slices the key out of a decoded dictionary-values
  buffer, so a short key can pin a batch-sized allocation.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): keep the source queue's charge incremental

The queue's reservation was recounted by walking every queued chunk on each
enqueue and each pop. `push_raw` and `push_evaluated` are public and put no bound
on how deep the queue gets, so a caller that queues a whole series before pulling it
paid `N(N+1)/2` chunk visits to hold `N` chunks -- the same shape of defect this
branch fixes in `absorb`.

`QueuedCharge` keeps a reference count and the bytes behind each shared allocation:
a table or buffer is charged when the first queued chunk reaches it and given back
when the last one leaves, so an operation walks the one chunk it names. The count is
per chunk rather than per field, because a pop re-walks the same chunk with the same
dedupe and must remove exactly the references that were added.

A chunk's own struct bytes are counted per chunk (`own_bytes`); only the allocations
behind its fields are counted by address, since a struct has no address to dedupe on.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* perf(promql): append a forward-moving chunk to the emitted history

`absorb` rebuilt the whole stored run array on every non-empty chunk. Legal input
made that quadratic across chunks with no label collision: one series in `K` chunks,
chunk `i` occupying only grid position `2*i`, stores one more run per chunk and moves
`K(K+1)/2` of them -- about five billion for a hundred thousand rows.

A chunk that starts at or past the end of the last stored run only ever adds to the
end, so its runs are appended and the boundary run is joined where it touches. One
that can interleave or reach backwards still takes the full one-pass merge, which can
split a run. Both leave the runs sorted, disjoint and joined, which is what
`Emitted::occupy` looks a position up by.

The test records the chunks through `Emitted::occupy` itself and reads the history
back from it, covering the forward case, the touching boundary, a hole that has to
join both neighbours, and a position before everything stored.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): report a truncated input from a tree with no window

`close` only asked windows whether a series was still open, so a tree of an
input alone, or of a map over one, accepted a final chunk without `is_last` and
handed the consumer a series that never closed. The input node now follows the
series of the chunk it last handed over, and closing such a tree reports the
truncation against it, which is the same refusal a window makes for the input it
reads.

`occupied_runs` read a row the axis names with `expect`, which panics on a
`Series` whose fields are shorter than its axis; the fields are public, so that
chunk is constructible by a caller. It now returns the misalignment as an error
naming the node, the field and both lengths.

The chunk protocol says one series closes once, which reads as one close per
label set. State that it is per input series: a node that rewrites labels can
hand two series over under one label set and both of them close.

Also say in `Emitted`'s doc that the table is one node's own, so a label set
costs its bytes again in every node that keeps one.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* feat(promql): execute an instant selection as a tree node

The tree could not run `NativeExpr::Instant`: the state builder recorded
`not_implemented` and refused the whole tree before reading a chunk, while the
compiler already produced the node for a bare selector. A query the plan and the
compiler both accept therefore had no runtime path at all.

`InstantRuntime` buffers one series to its `is_last` chunk -- the selection kernel
reads a complete axis -- then calls the shared `select_instants` once and gathers the
rows it picked onto the binding's own grid. Positions come from the grid lattice the
kernel was asked for, and a timestamp off that lattice is an error rather than a
skipped row. A series that selects nothing still closes, with an empty chunk on the
grid; an input that ends inside a series is reported on `finish()`.

The node does not record label-set occupancy: it keeps the metric verbatim, so it
cannot map two series onto one label set, and the occupancy key drops empty-valued
tags -- two genuinely distinct series would be refused as a collision.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): refuse an interleaved input, and close a label set only for its owner

Two series-level identity holes, both reported against `42ea525`.

The truncation flag followed the last chunk handed over, which is the same as
"is a series open" only while at most one series can be open. A tree with no
window still accepted `Interleaved`, and `a` open, `b` closed, end of input then
passed while `a` never closed. Tracking the open set would need an identity that
a raw chunk in bound-label mode does not have (`metric` is `None`, the key holds
it), so the ordering is refused when the state is built instead, for every tree
shape and not only under a window. The flag's doc now says what it means and why
that is the whole answer.

`History` closed on any series' `is_last`, so with `abs` dropping the metric name
a series that contributed nothing could close a label set another series was
still filling: `a{job="x"}` value, `b{job="x"}` empty close, `a{job="x"}`
continuation was refused with "already closed the label set". Contiguous input
hands over exactly that sequence, so it was not an interleaving artefact. A
history now remembers the series that filled it and closes only for that series'
closing chunk; a series that produced nothing under an open set is passed over,
as it already was under a closed one. The owner is charged as part of the entry.

The ordering check also reads every input node rather than only the first: a tree
with two inputs would otherwise have its second declaration read and dropped.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): reserve the instant gather's workspace and keep the histogram start

Two findings from the review of the instant node.

The gather allocates after the last chunk arrives: the concatenated axis, the
descriptors that read it, the two arrays the selection returns, and the rows the
builder keeps. Nothing asked the pool for any of it, so with one sample selected
by every point of a million-point grid the node took tens of megabytes while its
reservation fell to the buffered chunks' size -- nothing at all, on the chunk
that ends the series. `gather_bytes` now derives a bound from the values in hand
(rows, chunks, segments, fields, points, and the widest histogram row per field,
since one row can be selected by every point), and the reservation grows by it
before the gather runs. It shrinks back on both the success and the error path,
so the workspace never outlives the call.

A selection reads an existing sample, so it has to carry that sample's payload.
`FloatHistogram` had no `start_timestamp` and `push_to` wrote `None`, which lost
it for any histogram that had one: `detect_start_timestamp_reset` reads that
field to spot a counter reset, so a window over this output would mis-detect
resets. The field is now carried through a view and written back; the kernels
that compute a histogram instead of reading one leave it `None`, which is what
they produced before.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* docs(promql): describe the gather test by what this branch does

The doc on `an_oversized_selection_is_refused_by_the_pool` said the kernel has
no point limit any more. It does on this branch: the limit is removed on its own
branch, which this one does not carry, so the comment described a state that has
not arrived.

What is true here is the order: the estimate now applies to every gather, so the
pool is asked before the kernel is, and the pool's refusal is the one the caller
sees. That is worth saying on its own -- a bound in bytes on the path that
decides the points, rather than a count that says nothing about what they cost --
and it stays true after the limit goes.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* refactor(promql): simplify native execution memory estimates

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): merge rewritten logical series before window evaluation

Accept disjoint closed-source fragments under one output label set. Retain occupancy checks and merge affected window inputs once at EOF using shared typed sample segments. Keep direct input windows and root maps streaming, and release collected fragments on finish or error.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* docs(promql): clarify approximate merge workspace estimate

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): validate series boundaries before collected input merges

Keep each collecting window's open metric and last position so EOF grouping cannot hide reversed runs or identity changes before closing. Preserve source and root-map piece semantics, and test malformed input release, empty closing chunks, and EOF truncation.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* test(promql): supply the default binary fill in the unimplemented-node fixture

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): reserve an instant selection for the points it decides

The gather reserved the whole planned grid, while select_instants clips the range to the series' first and last sample and its lookback: a week-long query at a one-minute step reserved ~504 KB for a series that produces five points, and a small pool refused it before gathering. Reserve for the clipped candidate count, derived with the same alignment the kernel uses.

Also pin the start-timestamp semantics the histogram change spread to: last_over_time and first_over_time hand back the sample they selected with its start timestamp, and limitk and limit_ratio keep it on the sample they keep, so a reader downstream can still place a counter reset.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* test(promql): move the histogram_quantile doc back to its own test

The start-timestamp test was inserted into the middle of another test's doc comment, leaving the earlier test without one.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* test(promql): refuse an interleaved input under a selection when the tree is bound

The merged plan validates an input ordering in the arm that reads it, so a
selection over an interleaved input no longer binds at all. Assert that refusal
where it happens instead of asserting a runtime rejection the tree cannot reach,
and keep the map-over-a-selection case: a map above a selection does not shield
the check, because the check looks at the input the selection itself reads.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* docs(promql): say what the start timestamp pins, and cover every path that carries it

The field is GreptimeDB sample metadata, so the comments now say that instead of
arguing from what Prometheus omits: a FloatHistogram there has no such field, while
its rate and reset evaluation takes start timestamps through a separate channel, and
this crate's native range kernel does not read the field at all.

Comparison filtering and set selection were the two paths that carry the timestamp
without a test. Both keep the payload of the operand they hand back, including the
right side of an or at a point the left side does not cover, and a bool comparison
answers with a float so no histogram reaches the output.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): drop the start timestamp a computed histogram never had

A window holding one histogram became the result as it arrived, while two or more
went through an add that clears the timestamp, so whether a summed histogram carried
the payload said how many samples the window held rather than what the function did.
Clear it where the fold returns, leaving the bucket layout to the caller.

Also correct two comments that described behaviour this branch does not have: the
selection kernel carries no point limit of its own, and the interleaved map sits
above the selection rather than between it and the input.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

---------

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-21 22:44:38 +08:00
Dennis Zhuang 84d0dde4cb chore: merge narthex into feat/narthex-agg-stat
Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-21 19:55:53 +08:00
discord9 4a4afeef07 feat(promql): execute a bound native tree over sparse evaluated series (#9777)
* feat(promql): execute a bound native tree node by node, over sparse evaluated series

Ports the native runtime onto the sparse evaluated-series API: a chunk is
addressed by a `TimeAxis` that may hold several runs, so a consumer reads
positions through the axis instead of assuming `start + row`.

The driver and both node kinds move to that API:

- `chunk.range().len()` becomes `chunk.series.time.len()`, the compact row
  count, in every metric and byte estimate.
- `occupied_runs` walks `time.positions()` with one field cursor, so a chunk
  of several runs records each run where it belongs. A run open across a hole
  now ends where the hole begins; leaving it open to the last row made two
  runs look like one and refused a series that occupies the hole.
- A window keeps the pieces its evaluator decided in a queue: one push can
  decide several runs, and reporting only what the push returned stranded the
  rest until the next push. Queued pieces are charged by `retained_bytes`, and
  the charge is recomputed when one leaves, so a released piece is not held.
- `map_chunk` answers `Option`: an operation that empties every position
  (`clamp` with `max < min`) yields no chunk at all, unless the chunk closes
  its series -- a closing chunk holding no row is handed over so a consumer
  can tell "nothing more here" from "no more input".

`RangeEvaluator::retained_bytes` is restored with the three byte helpers it
reads: a field's buffers, a series' identity and the pending batch's vectors,
all by capacity, and still counted as storage rather than as a peak over a
push.

Tests read absolute positions from the axis (`decided` no longer adds a row
number to a start), and two sparse cases cover what the old reading got wrong:
a `Window -> Map -> Window` chain over a chunk of two runs, and a series that
occupies the hole between another's runs.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* refactor(promql): drop the window queue the sparse evaluator never needed

`RangeEvaluator::push` and `push_series` each return `Result<Option<EvaluatedChunk>>`: the
evaluator gathers every run it decided into one chunk and hands back at most one. So
`pending.extend(decided)` inserted at most one chunk and `take_pending` removed it on the spot,
leaving the queue empty after every successful push.

That makes the queue dead weight: a `VecDeque` allocation per productive window, an accounting
term that is always zero, a second drain site in `pull`, and doc comments claiming a problem the
API does not have. The driver's "ask the input again when a window decides nothing" loop was
already the whole story.

Back to returning the evaluator's own answer, and drop `pending`, `take_pending` and
`take_window_output`.

Covers the empty-map protocol this port introduces while it is here: an operation that empties
every position hands on nothing while its series is open, and one empty chunk when it closes. A
non-closing empty chunk would be rejected downstream and a dropped closing one would leave the
series unclosed.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* test(promql): pin the empty-map output count at one

The test checked that every output was empty and that exactly one closed, which a stray extra
non-closing empty output would also satisfy.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): charge the memory the native evaluator actually retains

Four omissions made the reservation blind to most of what a window over native
histograms keeps, and the existing incremental-equals-recomputed test could not see
any of them because both sides used the same formula.

- `FloatHistogram` owns four heap allocations -- both bucket lists, the custom
  bounds, and the span layout -- that `FieldSamples::bytes` never looked at. A
  retained 4096-bucket sample is tens of kilobytes against a ledger that saw a
  struct header. `heap_bytes` counts them, and both the retained sample slice and
  the histograms a row boxes are charged for what they own.
- Queued chunks were estimated as one float per field per row, which is not what a
  histogram field holds: it holds indices into a shared `Arc<HistogramBatch>` and
  the buffers behind the Arrow arrays. The figure now walks the samples, and counts
  a shared table or a shared buffer slice once per pass rather than once per chunk
  that references it.
- `history_bytes` included `size_of::<History>()` while the map slot term charged a
  `History` per entry as well, so every entry paid two headers. The per-entry figure
  now covers only the heap the entry owns.
- The primary key is charged for the allocation its `Buffer` keeps alive rather than
  the bytes it shows: a scan slices the key out of a decoded dictionary-values
  buffer, so a short key can pin a batch-sized allocation.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): keep the source queue's charge incremental

The queue's reservation was recounted by walking every queued chunk on each
enqueue and each pop. `push_raw` and `push_evaluated` are public and put no bound
on how deep the queue gets, so a caller that queues a whole series before pulling it
paid `N(N+1)/2` chunk visits to hold `N` chunks -- the same shape of defect this
branch fixes in `absorb`.

`QueuedCharge` keeps a reference count and the bytes behind each shared allocation:
a table or buffer is charged when the first queued chunk reaches it and given back
when the last one leaves, so an operation walks the one chunk it names. The count is
per chunk rather than per field, because a pop re-walks the same chunk with the same
dedupe and must remove exactly the references that were added.

A chunk's own struct bytes are counted per chunk (`own_bytes`); only the allocations
behind its fields are counted by address, since a struct has no address to dedupe on.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* perf(promql): append a forward-moving chunk to the emitted history

`absorb` rebuilt the whole stored run array on every non-empty chunk. Legal input
made that quadratic across chunks with no label collision: one series in `K` chunks,
chunk `i` occupying only grid position `2*i`, stores one more run per chunk and moves
`K(K+1)/2` of them -- about five billion for a hundred thousand rows.

A chunk that starts at or past the end of the last stored run only ever adds to the
end, so its runs are appended and the boundary run is joined where it touches. One
that can interleave or reach backwards still takes the full one-pass merge, which can
split a run. Both leave the runs sorted, disjoint and joined, which is what
`Emitted::occupy` looks a position up by.

The test records the chunks through `Emitted::occupy` itself and reads the history
back from it, covering the forward case, the touching boundary, a hole that has to
join both neighbours, and a position before everything stored.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): report a truncated input from a tree with no window

`close` only asked windows whether a series was still open, so a tree of an
input alone, or of a map over one, accepted a final chunk without `is_last` and
handed the consumer a series that never closed. The input node now follows the
series of the chunk it last handed over, and closing such a tree reports the
truncation against it, which is the same refusal a window makes for the input it
reads.

`occupied_runs` read a row the axis names with `expect`, which panics on a
`Series` whose fields are shorter than its axis; the fields are public, so that
chunk is constructible by a caller. It now returns the misalignment as an error
naming the node, the field and both lengths.

The chunk protocol says one series closes once, which reads as one close per
label set. State that it is per input series: a node that rewrites labels can
hand two series over under one label set and both of them close.

Also say in `Emitted`'s doc that the table is one node's own, so a label set
costs its bytes again in every node that keeps one.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): refuse an interleaved input, and close a label set only for its owner

Two series-level identity holes, both reported against `42ea525`.

The truncation flag followed the last chunk handed over, which is the same as
"is a series open" only while at most one series can be open. A tree with no
window still accepted `Interleaved`, and `a` open, `b` closed, end of input then
passed while `a` never closed. Tracking the open set would need an identity that
a raw chunk in bound-label mode does not have (`metric` is `None`, the key holds
it), so the ordering is refused when the state is built instead, for every tree
shape and not only under a window. The flag's doc now says what it means and why
that is the whole answer.

`History` closed on any series' `is_last`, so with `abs` dropping the metric name
a series that contributed nothing could close a label set another series was
still filling: `a{job="x"}` value, `b{job="x"}` empty close, `a{job="x"}`
continuation was refused with "already closed the label set". Contiguous input
hands over exactly that sequence, so it was not an interleaving artefact. A
history now remembers the series that filled it and closes only for that series'
closing chunk; a series that produced nothing under an open set is passed over,
as it already was under a closed one. The owner is charged as part of the entry.

The ordering check also reads every input node rather than only the first: a tree
with two inputs would otherwise have its second declaration read and dropped.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* refactor(promql): simplify native execution memory estimates

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): merge rewritten logical series before window evaluation

Accept disjoint closed-source fragments under one output label set. Retain occupancy checks and merge affected window inputs once at EOF using shared typed sample segments. Keep direct input windows and root maps streaming, and release collected fragments on finish or error.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* docs(promql): clarify approximate merge workspace estimate

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): validate series boundaries before collected input merges

Keep each collecting window's open metric and last position so EOF grouping cannot hide reversed runs or identity changes before closing. Preserve source and root-map piece semantics, and test malformed input release, empty closing chunks, and EOF truncation.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* test(promql): supply the default binary fill in the unimplemented-node fixture

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): merge label groups in first-member order and drop unused accessors

A HashMap iteration order made the merged series order, and the first collision reported, differ between runs; the root chunk stream exposes that order. Also drop len/is_empty, which had no callers and is_empty was always false.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

---------

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-21 19:44:43 +08:00
discord9 7309800aef feat(datatypes): describe label decoding in the raw series frame schema (#9786)
* feat(datatypes): describe label decoding in the raw series frame schema

A raw series frame written with key-encoded labels carries the primary key
as opaque bytes; only the binding held by the scanner says how to turn
those bytes back into a metric name and labels. A consumer that receives
the frame without that binding cannot decode it, so the schema now
describes its own label layout.

The description is compact JSON under `greptime.series.label_decode`,
carrying a version, the metric name, the primary-key encoding, the ordered
key columns with their physical types, and the logical tags to expose
-- exactly what decoding reads. It is derived from the binding, so there
is one writer, and a reader that does not implement the version it finds
rejects the frame instead of guessing.

`unpack_raw_series` keeps its binding argument and now requires it to
agree with the description the schema declares, so the two cannot drift.
`PrimaryKeyLabelDecoder` reduces both of its inputs to one internal key
layout, so the metadata-backed and description-backed paths share their
validation instead of growing a second decoder.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* refactor(datatypes): compare label descriptions by what their order means

The frame's declared label-decode description was compared to the binding's with
plain equality, but the description has one part whose order carries no meaning:
the decoder sorts logical tags by name when it binds them, so the same tags listed
in another order denote the same labels and were being refused.

`same_label_layout` canonicalises exactly that part and leaves `primary_key` in
place, because for a dense key the array position is the encoding position: the same
columns in another order are a different key, not the same key reordered. Both
fields now say which order they mean.

Three checks go with it. The binding stays for a stated reason -- it establishes
identity, the physical region key layout a decoded identity came from -- rather than
because a count of call sites kept it. The post-pack schema comparison in the series
scan could not fire: the leaf already re-derives that schema and compares it, and
every `SeriesSchema` field is covered by the checks between them. And two test
groups re-asserted what the tracked v1_metadata fixture pins case by case.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* feat(promql): decode a raw frame from the frame alone

`unpack_raw_series` took the label mode, the ordering and the caller's binding
along with the stream, so a consumer that received a frame from a peer still
needed a local copy of the binding to read it — the thing the frame's own
description exists to avoid, and the reason the description was freezing a wire
format for nobody.

The stream is now the only input: the field schema, the label mode, the label
decode description and the producer ordering all come from the frame's schema,
and every frame is validated whether or not a caller asked for it. `expected`
stays as an optional check on the schema that is declared — field names and their
order, and no evaluation grid — and supplies nothing to the decoder, so a caller
cannot smuggle in a layout the frame does not declare.

Three call sites compared the caller's binding against the frame and all three
were load-bearing: a peer's response is not trusted to be the region that was
planned, and a native plan's child is an arbitrary execution plan. Those became
`ensure_frame_matches_label_source`, which states what it compares: the label
layout, not the region. Two regions with the same tag ids and names compare equal,
and the doc now says so instead of claiming to establish identity.

A stream with no scan-bound identity reports `NO_BOUND_LABEL_SOURCE` and answers
`label_decode_spec()` instead; every consumer that decides whether to decode reads
the description, so the placeholder never stands in for "these labels are
complete".

The merge contract is the description, not the binding: inputs are compared by
the layout they declare, and a binding that could not be expressed on the wire — a
`RegionMetadata` snapshot — no longer makes the merge stricter than the frames it
merges. `logical_tags` gets one rule rather than two: the writer canonicalizes a
copy, because the caller's binding keeps its own order and the table scan compares
producer tag lists positionally, and readers compare by equality.

The text embeds `ConcreteDataType`'s serde, which `LABEL_DECODE_VERSION` has to
cover. Rather than mirror all 25 datatype variants into a type this module owns, a
frozen fixture records the text of every variant and an exhaustive match makes a
new variant fail to compile until it is recorded. The version policy is written
next to the constant: exact-text comparison across nodes, unknown versions
rejected, any same-version change breaking — with the planning-time fallback
stated as intended and marked not implemented.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* refactor(store-api): make every key-encoded raw stream declare its layout

A raw series stream whose chunks carry key-encoded labels had to be decoded
against a scan-bound `RawLabelSource`, and two consumers compensated for
producers that stayed silent by deriving a description from that binding:
`pack_raw_series` and the merge's `declared_layout`. `materialize_raw_labels`
did not compensate, so an unframed producer stream reached it with no
description and a chunk carrying no labels, and failed.

The stream itself is the authority for its chunks. A key-encoded producer
knows its own region metadata and can state the layout it writes, exactly as a
frame states the layout it carries, so the split contract — "a producer
declares its identity, a frame declares its layout" — is replaced by one rule:
a stream whose chunks carry key-encoded labels declares the description, and a
stream whose chunks carry complete labels answers `None`.

mito2's `RawStream` caches the derivation at construction, so the declaration
exists before the first poll and for an empty stream. The trait documentation
states the rule and no longer exempts unframed producers. Both fallbacks are
gone: `pack_raw_series` reads the stream's declaration only, and rejects a
key-encoded stream that declares none rather than packing it as an
explicit-label frame, which would describe chunks carrying no labels as if they
carried all of them. The merge reads the declaration only.

Test fixtures that model key-encoded producers now declare their layout too;
fixtures that carry complete labels keep answering `None`. The new mito2 test
decodes a real produced key through the declared description for both dense and
sparse encodings, and checks the declaration is present before polling even for
an empty stream.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* refactor(common-query): let the declaration decide the frame mode alone

`pack_raw_series` rejected a key-encoded stream that declared no description
outright, so a stream that stays silent could not be packed at all. That check
was redundant: the declaration already decides the frame's label mode, and the
first chunk whose labels disagree with it is refused by `RawSeriesChunkCursor`
with "raw chunk label mode does not match frame mode", so no such stream can
pack data either way.

What the check added was an early error for a producer that already breaks the
contract, in exchange for reading the stream's scan binding here — in the one
function whose rule is that the declaration is the only decode input. Drop it,
so pack keeps deciding the mode from the declaration and nothing else.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* test(store-api): assert the fixture's declared label decode description

The mock prepared source derives a `LabelDecodeSpec` from its label source and
reports it through `RawSeriesStream::label_decode_spec`, but no assertion read
it, so the derivation could drift from the chunks the fixture actually builds.
Check it against the description the fixture's own key columns imply: an
explicit source declares nothing, a primary-key source declares the metric, the
dense encoding, the one key column and its tag.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* refactor(promql): let the frame's declaration be the only label authority

Two things still treated a caller-supplied binding as authoritative after the
declaration became the decode input:

- A merged stream forwarded its first input's scan-bound identity, though a
  merge is not a region scan and decodes from the declaration alone. It now
  reports no bound identity.
- `KeyColumn.tag` repeated an invariant every key column is a tag. The check
  moves to the one place a decoder is bound, so a snapshot built without
  validation is still rejected instead of silently decoded.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

---------

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-21 19:11:24 +08:00
discord9 f235434b7f feat(promql): compile nested expressions recursively (#9782)
* feat(promql): compile nested expressions instead of fixed query shapes

The native compiler matched three shapes: a bare selector, `range_fn(x[r])`,
and `range_fn(n, x[r])`. Every composition of those kernels was refused, so
`abs(rate(x[5m]))` and `clamp_min(rate(x[5m]), 0)` had no path through it even
though the kernels, the `Map` node and the `Sum` node already existed.

`compile` is now a thin entry over one recursive rule, `compile_expr`: a
parenthesis compiles its inner expression, a vector selector becomes the
instant selection it already was, a call dispatches on its signature, and
anything else is refused naming what was found. Composition follows from that
rule rather than from a list of accepted query shapes -- `abs(rate(x[5m]))` is
a map over whatever its argument compiles to, and
`clamp_min(abs(rate(x[5m])), 0)` is two maps over one window because the inner
call compiles once.

Math functions lower onto the node that already computes them: one `Map`
carrying one `MathOp`, over the node its vector argument compiles to. The
28 names and their parameters are one explicit table, so supporting another is
a decision made in one place; a name outside it is refused as having no native
kernel rather than silently treated as math. `round` without an argument binds
the kernel's documented default.

A scalar parameter is folded from the statement: number literals,
parentheses, unary negation, and arithmetic whose two sides both fold. The
operator goes through `ArithmeticOp::calculate`, the same function the kernels
use, so `%`, `^` and `atan2` keep Prometheus' semantics instead of being
redefined in the compiler -- `(-7) % 3` is `-1`, signed as Go's `math.Mod` is.
A parameter the statement cannot decide, `time()` being the real case, is
refused rather than bound to a value that would be wrong at every point but
one.

`ArithmeticOp::calculate` widens from `pub(crate)` to `pub` for the fold; its
body is unchanged. Nothing else is added: the plan nodes, the kernels and the
tree validation are the ones that were there, and validation still happens
once per statement, at the root.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): match the arithmetic token by its id, not by its text

`arithmetic_op` read the operator through the token's `Display` output. That
is a second source of truth for what an operator is: the id is stable and is
what the rest of the planner matches on (`prom_token_to_binary_expr_builder`),
while the rendered text is a presentation detail. A formatting change would
silently stop `%`, `^` and `atan2` from folding -- not wrong, but quietly
less than it says it does.

Also corrects the doc comment that claimed the parser keeps its token ids to
itself.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): say what the validator compares, and make the fold test bite

The map's doc claimed `NativeTree::validate` compares the map's output grid with
its input's by identity. It compares them by `EvalGrid::spec`, and only an input
node's output is compared by identity, so a map that allocated an equal grid of
its own would validate the same way. The comment says that now, and the two
assertions in the nested-map test compare spec and field names instead of
`Arc::ptr_eq`, which failed a map that allocated an equal grid for no behavioural
reason.

The constant-fold test could not catch what its doc said it guarded: `go_math`'s
modulo is `x % y` and its power agrees with `f64::powf` on `2 ^ 10`, so replacing
the kernel arithmetic with a hand-written match still passed. The power case is
now `1.0000006850274261 ^ 9.80019037e8`, where Go's repeated squaring accumulates
error the standard library's `pow` does not, and where the two disagree above the
last bit. Replacing the fold with `%`/`powf` fails it.

`pi()` and `time()` have no vector to be pointwise over, so the "no arguments"
branch explained them wrongly; without it they fall to "has no native kernel",
which is accurate, and a listed math name called with no argument at all is
covered by the signature's arity check. `math_op` now reads the name first and
returns the vector it found, so both rejections come from one place.
`argument_position` indexes a fixed array: its three callers pass literals, so
there is no fourth position to default to.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): refuse an interleaved scan at the compiler entry

Every accepted shape reads one series to its last chunk before the next, so the ordering
has to be refused before the statement's shape is dispatched on; the check sat in the range
function branch, which let the bare selector and a math function over it compile under
Interleaved. The window branch of NativeTree::validate stays as the backstop for a tree built
another way.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

---------

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-21 19:09:00 +08:00
discord9 c879564671 chore(common-query): drop the raw-series request extension transport (#9815)
* chore(query): drop the prepared raw-series RPC skeleton

The raw-series preparation RPC was merged into `narthex` for a plan that never
landed: no server or frontend dispatches `greptime.prepare_raw_series.v1`, and the
three `Datanode` methods, `bind_raw_series_query` and the `QueryEngine` trio have no
call sites and no implementors. `PreparedNamedRawSeries` had no user outside them.

The wire envelope never shipped -- no tag contains the commit that added it -- so the
compatibility fixtures go with it. The harness drives `wire/` fixtures from the Rust
tests that own their context conversion rather than from SQLness discovery, and has
no retirement mechanism to record instead.

Left in place: `RawSeriesRequest` and its extension key, `raw_series_frame_ordering`,
`unpack_raw_series` and `RawLabelSource`, all of which the surviving series-frame
path still uses.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* chore(common-query): drop the raw-series request extension transport

The prepared raw-series RPC carried a `RawSeriesRequest` through a
query-context extension, and with that RPC gone (`607de914464`) nothing in
`src/` writes or reads the extension any more. What remains is the binding
the table scan actually uses: `RawSeriesRequest`, `RawSeriesTag`, `bind`,
`validate` and `same_encoding_identity`, unchanged.

Deleted: the extension key, `take_raw_series_request`, and the two
serialization methods that only existed to encode and decode that
transport, plus the wire fixture whose only reader was the test for them.
The fixture never shipped — the commit that introduced it is in no tag.

The tests that covered `validate` are kept, with their rejections now
asserted directly instead of through a JSON round-trip: an unsupported
version, an empty field list, a duplicate field, and a non-field column
id are all still refused. The binding test is untouched, including its
order, identity and `Arc` assertions.

`pub` items in a library crate never trip `dead_code`, so the compiler
could not have found this; the greps above are the evidence.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

---------

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-21 18:53:57 +08:00
discord9 e31b4884ad fix(promql): bound an instant selection by the memory pool, not a point count (#9801)
* fix(promql): bound an instant selection by the memory pool, not a point count

`MAX_INSTANT_MANIPULATE_OUTPUT_POINTS` capped an instant selection at a million
grid points per series. It arrived in a commit whose message was only "cap max
points": no doc, no derivation, no test of the bound itself. It bounded neither
the output (series count is unbounded) nor the payload (a histogram row is
kilobytes), and because the comparison was `>`, exactly a million points passed.

`InstantManipulateExec` — the operator this path actually runs — had no memory
accounting at all, so deleting the count alone would have turned a refusal into
an unbounded allocation. It now registers one reservation per partition and grows
it by the workspace the selection is about to allocate, before it allocates:
the two vectors `select_instants` returns, the copy of the indices `take` reads
with, the output time column, and one row per point per column — where a
histogram column is charged the widest row it holds, because one source row can
be selected by every point of the grid and is copied for each of them. The
reservation is released once the batch is built, since from then on it belongs to
whoever reads it.

The native operators keep their own reservations; `NativeInstantManipulateExec`'s
existing per-point term already covered the same three arrays, and this only says
so where it is computed.

`select_instants` no longer refuses anything: it documents that it allocates 16
bytes per selected point and reserves none of that, which is now every caller's
job. A query over a longer range therefore succeeds when the pool can hold it and
is refused with `ResourcesExhausted` when it cannot, instead of being refused at
a count that says nothing about either.

One bound is gone with the count and is not replaced here: a resolved
`query.memory_pool_size` of zero leaves DataFusion's unbounded pool attached, so
an operator that leaves that option unset has nothing refusing on this path. The
operator doc says so.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): reserve an instant selection for the points it clips to

The workspace estimate charged a series for the whole planned grid, while select_instants clips the range to the series' first and last sample and its lookback. A week-long query at a one-minute step therefore reserved ~413 KB for a series that produces five points, and a small pool refused it before selecting anything. Reserve for the clipped candidate count instead, derived with the same alignment select_instants uses; a series that does span the grid is still charged for it.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* refactor(promql): keep one clipping, shared by the selection and its reservation

The reservation and `manipulate` carried the same clipping arithmetic twice,
with a comment asking the two copies to stay in line. They cannot drift now:
`clipped_range` is the only copy, and both call it. No behaviour change.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

---------

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-21 18:47:18 +08:00
Dennis Zhuang f2673ce10b perf(promql): resolve info signatures per series and cover the review gaps
Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-21 17:46:56 +08:00
Dennis Zhuang d9a1b14314 feat(promql): add the info() combine kernel to native operators
Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-21 17:34:29 +08:00
discord9 ee54ded60f docs(promql): state the real reason an instant selection needs no ordering check (#9785)
* docs(promql): state the real reason an instant selection needs no ordering check

`select_instants` reads one series' complete axis, so the executor must
collect a series to its last chunk before evaluating it: a single buffer
closed by `is_last` and keyed by nothing else. That needs each series'
chunks contiguous, which `Contiguous` and `KeyThenTime` both guarantee,
and `Interleaved` never reaches it because `unpack_raw_series` rejects
that ordering at the raw-frame boundary.

The comment said the opposite: that interleaved input was enough because
`select_instants` holds no cross-series state. The conclusion stood, but
the stated reason named a property the executor does not have.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* docs(promql): stop deriving the instant arm's ordering check from another path

The comment justified skipping an ordering check here by pointing at
`unpack_raw_series`, which rejects `Interleaved` at the raw-frame boundary. This
validator never runs through that call, so the rejection was never a premise it
could use, and the conclusion did not follow.

The requirement it was reaching for does exist, one layer down:
`NativeInstantManipulateExec` collects a series to its `is_last` chunk into one
buffer keyed by nothing else, so it needs each series' chunks contiguous. This
validator does not check that. `Instant` has no implementation in the native tree
yet and `try_to_native_execution_plan` has no callers, so the requirement is
unenforced rather than met in advance; it is settled when `Instant` lands.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* feat(promql): refuse an interleaved scan at the compiler entry, and in the validator

The `Instant` validation arm replaced an ordering check with a comment saying no
check was needed. The argument was that `unpack_raw_series` rejects `Interleaved`,
which holds only for the legacy `InstantManipulate` path and says nothing about
this tree. Meanwhile the refusal in `compile` sat after the bare-selector early
return, so `compile(eval_stmt("x"), schema, Interleaved)` returned `Ok` against
`compile`'s own rustdoc, and the same hole was open to every nested shape.

The requirement is real for every shape: a native evaluation reads one series to
its last chunk before the next. The refusal now runs before `compile` dispatches
on the shape, so the bare selector, the range function and its parameterised form
all inherit it, and a shape added later inherits it too.

`NativeNode::validate` keeps a check of its own, for a tree built some other way:
`Instant` and `Window` now share `ensure_not_interleaved` instead of one branch
having the check and the other a comment about it.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* docs(promql): link the validator by the node that has it

`NativeTree` has no `validate`; the method lives on `NativeNode`. Both doc
comments linked the tree, so rustdoc reports an unresolved link and the
published API docs lose the reference.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* test(promql): assert the control records the scan's ordering

The contiguous control in the interleaved-refusal test only checked that the
bare selector compiled, while its comment said the ordering is recorded. Assert
it on the tree's input instead. Drop the plain range case from the loop, which
the ordering table already compiles under `Interleaved`.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

---------

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-21 17:24:07 +08:00
discord9 1156cfbe0c chore(query): drop the prepared raw-series RPC skeleton (#9797)
The raw-series preparation RPC was merged into `narthex` for a plan that never
landed: no server or frontend dispatches `greptime.prepare_raw_series.v1`, and the
three `Datanode` methods, `bind_raw_series_query` and the `QueryEngine` trio have no
call sites and no implementors. `PreparedNamedRawSeries` had no user outside them.

The wire envelope never shipped -- no tag contains the commit that added it -- so the
compatibility fixtures go with it. The harness drives `wire/` fixtures from the Rust
tests that own their context conversion rather than from SQLness discovery, and has
no retirement mechanism to record instead.

Left in place: `RawSeriesRequest` and its extension key, `raw_series_frame_ordering`,
`unpack_raw_series` and `RawLabelSource`, all of which the surviving series-frame
path still uses.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-21 17:02:37 +08:00
discord9 77a7d099e6 docs(promql): add a map for src/promql (#9796)
* docs(promql): add a map for src/promql

`src/promql` has no guide of its own, and the two things that cost the most to
learn the hard way are not visible from any one file: which of the two
evaluation paths is live, and what has to line up for a new extension node to
reach a datanode at all.

The map records the module layout, that coupling (substrait payload branch,
physical planner branch, `Commutativity` arm), the native model's reachability,
and the gotchas that fail silently: a missing commutativity arm, a label set
that closes twice under one label, a raw frame decoded with a binding other than
the one it was written with.

It describes mechanisms and where they live, not which branch or PR adds what,
so it stays true as the surrounding work lands.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* docs(promql): correct what the frame schema does and does not say

Two claims in the map were wrong about the code it describes.

The guide said a raw frame's schema metadata explains how to decode the primary
key. It does not: the metadata carries the format keys only — layout, content,
labels, time — and the decoder compares those and the field list, never a label
identity. `unpack_raw_series` still requires the caller's `RawLabelSource`, so
the guide now says to decode with the binding the stream was produced under,
which is the mistake it exists to prevent. A future decode descriptor can be
described here once it is in the base and this text can describe the API that
actually exists.

The raw-scan gap named one way past the partition problem as though it were the
only one. `RegionScanExec::try_to_series_scan` documents the other — finish
partition preparation before converting — so both are stated now, with the
scanner-side override kept as the actual requirement.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* docs(promql): say the storage producer exists and name what is left

The "cannot be planned" bullet listed the scanner-side override as missing,
but mito2 implements `RegionScanner::raw_series_schema` — #9669 landed it, so
the trait's `Ok(None)` default only refuses engines that have not implemented
it. Listing an implemented override as a gap misreads how far the raw path
actually reaches.

Separate the two things: the storage producer is available, and what is missing
is planner wiring plus the partition-preparation order. Merge current narthex in
so the paragraph describes the branch it will land on rather than a stale base.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

---------

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-21 17:02:18 +08:00
discord9 ee4c06d498 test(promql): hold the batch path to the per-window one on random input (#9739)
* test(promql): hold the batch path to the per-window one on random input

Two comments claimed the opposite of what the code relies on: a batch's spans were
described as absolute indices that survive a release, but a span is recorded against
the retained arrays as they stand, and `advance` drains and shifts them. Nothing
enforces the difference -- the batch is correct only because `evaluate` flushes it
before advancing, so a later change that let a batch outlive one call would return
wrong counter values with no error. Both now say that, and `PendingBatch::spans`
carries the invariant where a reader looks for it.

The batch path had no randomized comparison at all: the differential tests drive a
probe kernel and the function tests cover no counter function, so only fixed cases
exercised it. A new differential test runs rate and increase over 200 seeds of one
to three fields, three series through one evaluator, random chunk splits and mixed
histogram samples, comparing the batch path against the same evaluator with its
batch form removed. Two assertions that could only have failed on a broken helper
are gone, and the counter-reset flag is documented as governing both readers of the
index, not just the per-window one.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* test(promql): assert the batch is flushed before the sample drain

`PendingBatch.spans` index the retained sample arrays, which `FieldSamples::advance`
drains and shifts. The batch is correct only because `evaluate` flushes it just above
that drain, and nothing enforced the ordering: a future change that let a batch
outlive the call would compute counter values against shifted samples with no error.

The assertion holds on every tested batch path. It is skipped when no batch kernel
is installed, since `pending` is only ever filled on the kernel branch.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* docs(promql): state the flush-before-advance rule once, and assert it plainly

The batch assert could not fail on its `batch.is_none()` disjunct: `accumulate`
is reached only from inside `if let Some(kernel) = self.batch.as_mut()`, and
`batch` is never cleared, so a non-empty `pending` implies a kernel. Drop the
disjunct and name who guarantees the order in the message instead.

The rule that a batch must flush before `advance` was restated in six places.
Keep it on `PendingBatch::spans`, where a reader asks why a span dies with the
batch, and in the assertion message. The other four now state only the
site-specific fact each needs.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* docs(promql): drop the last restatement of flush-before-advance

The rule now lives only on PendingBatch::spans and in the debug_assert message, as the review asked; the comment above the final flush restated it a third time.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

---------

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-21 16:57:22 +08:00
Dennis Zhuang 0db7241f9b test(promql): drop the explicit type clippy rejects
Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-21 16:50:14 +08:00
dennis zhuang b8a3a52989 feat(promql): add max_of, min_of and query context functions to native kernels (#9822)
I hereby agree to the terms of the [GreptimeDB
CLA](https://github.com/GreptimeTeam/.github/blob/main/CLA.md).

## Refer to a related PR or issue link (optional)

Narthex operator task NEW-MAP-FN, first part. `histogram_quantiles`
follows separately: its quantile label uses Go's
`FormatOpenMetricsFloat`, which needs the shortest-digit formatting that
#9799 adds for `count_values`.

## What's changed and what's your intention?

Native kernels for four experimental Prometheus v3.15.0 functions.

- `max_of(a, b)` / `min_of(a, b)`: `ScalarFunction::apply` follows Go's
`math.Max` / `math.Min`, which differ from `f64::max` / `f64::min`:
  - an infinity of the function's own sign beats NaN;
  - NaN beats every other value;
  - `+0` is greater than `-0`.

`map_scalar_function` applies it to two time-varying scalars, paired by
absolute grid position. Constant arguments can fold through `apply`.
- `start()` / `end()` / `range()` / `step()`: Prometheus folds these
into number literals before evaluation (`foldQueryContextFunctions`), so
the kernel is `QueryContextFunction::value(start_ms, end_ms, step_ms)`.
- `range()` and `step()` use Go's `Duration.Seconds`, which rounds twice
(whole seconds plus remainder / 1e9) and differs from `ms / 1000` in the
last bit for most values.
  - `step()` is 0 when start equals end.

## Tests

Reference values come from promql-compat `narthex-golden-scalarfn`,
built for amd64:

- `math.Max` / `math.Min` over all pairs of ±0, ±1, 2.5, ±Inf and NaN;
- `Duration.Seconds` for 210 millisecond counts, including the
saturation boundary.

A unit test checks that time-varying scalars pair by position and that a
point missing on either side has no result.

## PR Checklist
Please convert it to a draft if some of the following conditions are not
met.

- [x] I have written the necessary rustdoc comments.
- [x] I have added the necessary unit tests and integration tests.
- [ ] This PR requires documentation updates.
- [x] API changes are backward compatible.
- [x] Schema or data changes are backward compatible.
- [ ] This PR needs to be backported to release branches, and I have
added the `backport-<target>` labels (e.g. `backport-v1.3` targets
`release/v1.3`).
2026-10-21 16:49:12 +08:00
Dennis Zhuang fb7703ea4b fix(promql): keep count_values labels round-tripping at power-of-two boundaries
Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-21 16:48:48 +08:00
dennis zhuang f50f3e5b1a feat(promql): merge native histogram series that share a label set (#9819)
I hereby agree to the terms of the [GreptimeDB
CLA](https://github.com/GreptimeTeam/.github/blob/main/CLA.md).

## Refer to a related PR or issue link (optional)

Narthex operator task MAP-LABEL (histogram / mixed same-label-set
merge).

## What's changed and what's your intention?

`grid_series::merge_same_labelset` returned `NotImplemented` as soon as
a member held a native histogram sample. That limited `label_replace` /
`label_join`, the date and native histogram functions, and
`evaluate_histograms` to float output whenever two results shared a
label set.

The merge now handles every sample type. Members of one label set are
still combined by absolute grid position and field. Floats and
histograms keep their type. A field held by two members at one point
fails with the same "vector cannot contain metrics with the same
labelset" error, whatever the sample types, as in Prometheus. Sparse
axes and multi-field series work as before.

### Tests

`merge_keeps_histograms_and_rejects_any_doubled_field` covers:
- two members whose histograms and floats interleave across points and
fields;
- histograms compared field by field after the merge;
- the collision error for float/histogram and histogram/histogram at one
point.

## PR Checklist
Please convert it to a draft if some of the following conditions are not
met.

- [x] I have written the necessary rustdoc comments.
- [x] I have added the necessary unit tests and integration tests.
- [ ] This PR requires documentation updates.
- [x] API changes are backward compatible.
- [x] Schema or data changes are backward compatible.
- [ ] This PR needs to be backported to release branches, and I have
added the `backport-<target>` labels (e.g. `backport-v1.3` targets
`release/v1.3`).
2026-10-21 16:46:59 +08:00
Yingwen 8543cbf401 feat(mito): scaffold series format writers and artifact lifecycle (#9788)
* feat(mito): add pluggable series artifact reconciliation

Signed-off-by: evenyag <realevenyag@gmail.com>

* feat(mito): prepare independent series reads from SST artifacts

Signed-off-by: evenyag <realevenyag@gmail.com>

* feat(mito): prepare mixed sidecar SST and memtable series reads

Signed-off-by: evenyag <realevenyag@gmail.com>

* refactor(mito): make prepared series sources replaceable

Signed-off-by: evenyag <realevenyag@gmail.com>

* fix(mito): narrow series formats to artifact lifecycle integration

Signed-off-by: evenyag <realevenyag@gmail.com>

* test(mito): populate artifact regression buckets above build threshold

Signed-off-by: evenyag <realevenyag@gmail.com>

---------

Signed-off-by: evenyag <realevenyag@gmail.com>
2026-10-21 16:38:37 +08:00
Dennis Zhuang 6106fb0bc8 refactor(promql): index quantile cursors by field
Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-21 15:47:19 +08:00
Dennis Zhuang e467538e90 perf(promql): walk quantile groups by a position heap
Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-21 15:46:01 +08:00
Dennis Zhuang 9158f6ccba fix(promql): saturate range() when the endpoint difference leaves i64
Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-21 15:43:20 +08:00
Dennis Zhuang b16cffd5c6 feat(promql): add max_of, min_of and query context functions to native kernels
Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-21 12:08:42 +08:00
Dennis Zhuang 66f055e186 feat(promql): merge native histogram series that share a label set
Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-21 11:39:40 +08:00
Dennis Zhuang 62f61f437a chore: merge narthex into feat/narthex-agg-stat
Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-21 11:31:43 +08:00
Dennis Zhuang 77f2b32c01 refactor(promql): expose aggregate_float as a per-series FloatAggregator
Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-21 11:30:39 +08:00
Dennis Zhuang 686d3f5b8a fix(promql): let count_values group by its own value label
Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-21 11:25:01 +08:00
dennis zhuang 420255ed39 feat(promql): add limitk, limit_ratio and binary fill modifiers to native kernels (#9805)
Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-21 11:21:36 +08:00
Dennis Zhuang 51d7987d73 fix(promql): format count_values labels like Go and cover review gaps
Round value labels half-even among the shortest digits as strconv.FormatFloat
does, add without(<value label>) golden cases, and pin that quantile has no
result at points where q is absent.

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-20 23:12:50 +08:00
Dennis Zhuang cc6aa640fd feat(promql): add stddev, stdvar, quantile and count_values aggregates
Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-20 22:57:41 +08:00
discord9 e1dba11eea test(mito2): check the ordering the producer declares, and drop the promql dev-dep
The three end-to-end sites unpacked every frame as `Contiguous` while the
producer declared `KeyThenTime`, and `SequenceValidator` only checks key order
under the ordering it is given. A producer that declared key-then-time and
emitted keys out of order passed all three. Each site now reads the ordering the
frame declares, asserts it is `KeyThenTime` — so a future producer that quietly
downgrades its declaration fails instead of skipping the check — and unpacks
under it. Reversing two series' key order in the fixture now fails the test;
passing `Contiguous` for the same frames still passes, which is what the old
code did.

The two-phase test asserted key order for an ordering that declares only
`Contiguous`: a reader that stayed contiguous and stopped sorting keys is within
contract and failed the test for it. It now asserts one run per key and that the
two assignments hold the first and last key, which is what `Contiguous` plus the
assignment composition actually promise.

`promql` leaves mito2's dev-dependencies. The storage contract needs only
`pack`/`unpack_raw_series` from `common-query`, which mito2 already depends on;
the cross-region merge composition moves to promql, where it is a stream-level
test rather than a two-region fixture: `merges_two_unpacked_frames_through_the_frame_codec`.
The merge rejection that the two-phase test used to exercise is already covered
by `rejects_unsupported_ordering_and_empty_input_without_polling`, which the
remaining test now cites instead of duplicating.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-20 22:46:23 +08:00
discord9 38f728ee3f test(mito2): cover a second assignment reaching the same partition
The cross-assignment test wrote four candidate series, gave each of two output
partitions one assignment, and concatenated the two streams. It proved that two
disjoint non-empty assignments stay contiguous once joined, but production only
sends a second round to the *same* receiver when one collection round holds
`CANDIDATE_SERIES_ASSIGNMENT_THRESHOLD` (1,000,000) candidates, so that boundary was
never crossed.

The new test lowers the threshold through a `#[cfg(test)]` override instead of
writing a million series, then writes 501 series -- one candidate batch plus a
trailing remainder -- into a single output partition and asserts on that partition's
one stream: two or more assignment rounds were sent, every series appears exactly
once in key order with no recurrence, timestamps increase within a key, `is_last` is
correct at the boundary, and every value matches. The stream then goes through
`pack_raw_series`/`unpack_raw_series` so `SequenceValidator` runs on it as well.
Dropping a later round leaves a validator-clean stream, so the data assertions are
what catch it.

The layout assertions now check the composition rather than non-emptiness: the scan
input reports two SST sources whose row counts are the two flushed write rounds and
one memtable whose rows are the unflushed round. Compaction is disabled in both tests
so a background merge cannot move that composition.

The older test is renamed to say what it covers: two disjoint partitions, one
assignment each, concatenated.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-20 21:42:34 +08:00
discord9 8e90db600d test(mito2): cover raw partition bounds and contiguity across assignments
The raw-series path had two untested behaviours.

An out-of-range partition must fail without consuming the scanner's
partition state: the range check runs before the receiver is taken, so a
later in-bounds scan on the same scanner still reads its complete data,
and a repeat scan of that partition then reports the one-shot violation.

A two-phase stream must stay contiguous in the sense the raw contract
advertises, including across candidate assignments: chunks are checked per
partition for a non-empty, disjoint key set, the whole stream is routed
through the frame codec so its own sequence validator runs, and a series
may appear in only one run so a non-adjacent recurrence would fail.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-20 20:27:55 +08:00
discord9 5b649daaea feat(mito2): produce typed raw series from RegionScanner with truthful ordering
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-20 20:27:55 +08:00
discord9 3ad5f96328 feat(promql): compile a parameterised range function (#9743)
* feat(promql): compile a parameterised range function

`quantile_over_time(q, x[r])` now compiles: the quantile is bound as a
number literal and the range selector is bound by the same path a function
without parameters takes. The two shapes share one binder, so the range,
the offset, the anchor and the grid cannot drift between them.

The quantile is not range-checked. Prometheus binds any value and the
kernel answers `-Infinity` below zero and `+Infinity` above one, so
refusing it here would be this compiler inventing a rule the engine does
not have.

A parameter in parentheses binds the same quantile: the planner already
looks through the parentheses when it reads a literal, so a query written
`quantile_over_time((0.9), x[5m])` keeps working on this path too.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* test(promql): cover a parenthesised selector of a parameterised function

The unparen rule applies to the selector a parameterised function binds, not
only to the one a plain range function binds: a query that writes its range
selector in parentheses asks for the same tree.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* docs(promql): name the real run-time parameter, and reuse `unparen`

The doc said `1 + 1` is computed at run time. It is not: `try_build_literal_expr`
folds `Paren`, `Unary` and literal-only `Binary` in the existing planner, so
`quantile_over_time(1 - 0.1, x[5m])` and `quantile_over_time(-(0.9), x[5m])`
plan today and reach this compiler unfolded. Refusing them is the agreed
behaviour for this stage, not a run-time-only case, and the doc now says so;
`time()` is the real run-time example.

A test pins both folded forms as refused, so implementing folding later is a
deliberate change rather than a silent one.

`scalar_parameter` open-coded the parenthesis loop `unparen` already implements,
so the rule for transparent wrappers now lives in one place.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

---------

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-20 19:26:29 +08:00
discord9 44df7a2a1f feat(promql): compile a bare selector and an anchored range window (#9736)
* feat(promql): compile a bare selector and an anchored range window

The compiler refused a bare instant selector and any `@` anchor, so the two
shapes an ordinary query starts from could not reach the tree. It now binds a
selector's lookback, offset and output grid into an instant node, and resolves
`@ 1234`, `@ start()` and `@ end()` into the millisecond anchor a range window
already carries, bound apart from the offset so a shift and an anchor are not
folded into one number.

A bare selector's own anchor stays refused: an instant node binds a lookback, an
offset and a grid and has no field to anchor one.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* test(promql): tell `@ end()` apart from the last grid point

The anchor test used a statement ending at 100s on a 5s step, so its last grid
point was also 100s: binding `end()` to the last grid point instead of to the
statement's own end would have passed. The end is now a parameter of the
statement helper, and a statement ending at 103s pins the two apart, asserting
both the anchor and that the grid still stops at 100s.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* docs(promql): write down what an anchored window reads, and cover a pre-epoch anchor

An `@`-anchored window reads `(anchor - offset - range, anchor - offset]`, which
can lie entirely outside the statement's own range, so whoever binds the scan
has to derive it from the window rather than from `stmt.start` and `stmt.end`.
A scan bound to the statement alone leaves such a window with no samples, and
the query returns empty without an error. That contract was nowhere in the code.

The anchor cases only covered non-negative anchors, so a helper that defaulted a
pre-epoch anchor to zero would have kept the suite green. `rate(x[5m] @ -1.5)`
now pins -1500, which is the `Err(earlier)` branch of the conversion.

The `assert_ne!` at the end of the `end()` test was implied by the two
equalities above it and could not fail on its own.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

---------

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-20 19:22:19 +08:00
discord9 4c7cc8b10b feat(promql): compile a statement into a native tree and display it (#9732)
* feat(promql): compile a PromQL statement into a native tree

Compile a range selector and its surrounding shape into the native plan
nodes introduced earlier, and render the resulting tree through
`Display` so an `EXPLAIN` can show it.

The display carries each node's operation, its bound window and the
fields it reports. Field layout is validated here rather than at
execution: a node that declares a different number of fields than it
consumes is a planning error, which is what the fixture tests pin.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): strip parentheses the native compiler does not read through

Parentheses group; they say nothing about the expression inside them, so
`(rate(x[5m]))` asks for exactly what `rate(x[5m])` asks for. The compiler
matched the bare shape only and refused the parenthesised one as
NotImplemented, rejecting a statement on how it was typed rather than on what
it means.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): refuse an ordering a window cannot consume, and stop claiming an executor that does not exist

`compile` recorded the scan's ordering without checking it, so
`compile(rate(x[5m]), schema, Interleaved)` built a tree that `validate` accepted
and the range evaluator was guaranteed to reject at run time: it reads one
series to its last chunk before the next. The ordering is refused where it is
declared now, and the tree's own window check is the backstop.

The tree carries no metric name or matchers, so the contract on `input` decides
correctness and was nowhere written: matchers, `__field__` among them, have to be
applied before `input` is bound, and `input.fields` is the post-`__field__` set.

The display marked a hand-written list of nodes "(no executor)". No executor
exists at this point in the stack, so `Window` and `Map` read as runnable while
`Instant` was marked as not, although `select_instants` runs in production. The
marker is gone; every line reads as the node it is and claims nothing about
whether it could run.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): render a grouping as the shape it was bound as

The tree now binds an aggregate's grouping as `Grouping::{By, Without}`
(#9723), so `label_list(&op.group)` no longer type-checks: the argument is a
`Grouping`, and the function takes `&[Arc<str>]`. This is production code, not
a test helper, so the merged stack would not build.

Rendering on the `By`/ `Without` shape fixes more than the type error: both were
printed as `by [... ]`, which would show `by(job)` and `without(job)` — two
different queries over the same label list — as the same query. A `without`
aggregate now reads `sum without [job] complete`, and
`a_without_grouping_does_not_read_as_by` pins it.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

---------

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-20 19:17:56 +08:00
discord9 f2f8043fdd feat(promql): add the bound native tree and the metric surface it reports through (#9723)
* feat(promql): add the bound tree and batch protocol for native execution

A native PromQL evaluation needs a compiled form whose operations are
already bound to kernels, fields and grids. Recognising an opportunity from
a finished physical plan cannot provide that: the opportunity then depends
on how the optimizer happened to arrange a subtree, and every new shape
widens a fixed template.

`NativeExpr` instead covers input, scalar, instant, window, map, aggregate
and binary nodes, each carrying a bound operation and a declared output
type. Legality becomes a question about types and data dependencies, which
`NativeNode::validate` answers, so a new combination of supported
operations is an ordinary tree rather than another matcher. Two properties
are checked there rather than assumed: a window's declared input grid must
be the grid its input produces, so a subquery's inner grid is consumed as it
is instead of being resampled, and an aggregate's output type must match its
stage, so intermediate state cannot pass for a query value.

`EvaluatedChunk` is the batch a node hands to the next one: an ordinary Rust
value, addressed on absolute grid positions, closing a series exactly once.
An empty final chunk is still meaningful — it is how a closed prefix is
reported, which a consumer needs in order to tell "nothing more here" from
"no more input".

Nodes are identified by their position in the tree rather than by an id.
The position is derived by the one traversal both `DisplayAs` and run-time
construction use, so what is shown and what is registered cannot disagree.
It stays in the metric name because the collector aggregates by name first,
which would otherwise merge two nodes of the same operation.

This adds the tree and the batch protocol only. The evaluator's chunked
interface, memory reservation and execution are not part of it.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): make the bound tree's type contracts match what each stage consumes

Four review findings on the bound tree, all about checks that passed the wrong
things.

An aggregate's stage decided its output but not its input. Every stage first
required its input to be evaluated, which aggregate state is not, so
`Final(Partial(...))` was rejected and `Final(Window(...))` was accepted. Each
stage now states its pair: `Complete` takes a vector and produces one on the
same grid, `Partial` takes a vector and produces state on that grid with the
bound grouping, `Final` takes state with the bound grouping and produces a
vector on the state's grid.

Window and instant selections accepted aggregate state. The cause was asking
`is_evaluated()`, which is false both for raw samples and for state, since
state has no grid: `Instant(Partial(...))` and a window bound to
`input_grid: None` over `Partial(...)` both passed and would have read state as
raw samples. Both now match on `NativeType::Samples` first and only then on the
grid, so state is rejected by what it is rather than by lacking a grid.

Scalars were modelled as a grid-less evaluated sample, and `BoundScalar`'s doc
claimed a value varying per grid point is not a scalar. That is wrong for
PromQL: `time()` and `scalar(v)` produce a scalar per evaluation time. A scalar
is now its own `NativeType::Scalar(grid)`, so a binary operation can tell a
broadcast from label matching -- `sum by(job)(rate(x[5m])) + time()` broadcasts
rather than matching on the empty label set -- and `Map`, `Aggregate`, `Instant`
and `Window` reject it by type. The `time()` kernel itself is not in scope.

An input could bind only samples, so an exchange boundary could not bind the
state a `Final` aggregate consumes. `BoundInput` holds a `NativeType`, and input
nodes check that their output is the same binding rather than the same schema.

A peak is the one figure the collector must not add up: it groups metrics by
name, clears the partition and sums gauges, so partition peaks of 1024 and 2048
reported 3072, which is neither peak. `PEAK_RESERVED_BYTES` now carries the
partition in its name (`native.p0.root.peak_reserved_bytes`), while counts and
the other gauges still sum across partitions, where a sum is meaningful. The
test that claimed the peak kept its partition only checked a name prefix; it now
registers two partitions, runs the real `aggregate_by_name`, and asserts both
peaks survive with their own values.

The walkthrough's `+1` test was wrong twice over: it took a `Final` aggregate
directly over a `Window` -- the case the first finding now rejects -- and
applied `MathOp::Abs`, which is not `+1`. It now builds a real binary `+` with a
scalar operand.

Signed-off-by: discord9 <discord9@users.noreply.github.com>
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* refactor(promql): use the evaluator's chunk type for native batches

`native/batch.rs` defined its own `EvaluatedChunk` with the same two fields as
`promql::evaluated::EvaluatedChunk`. Two definitions of one protocol drift: the
evaluator's chunk could not be handed to a native node without translation, and a
change to either would have to be mirrored.

Use `crate::evaluated::EvaluatedChunk` instead. The type moves here with the
evaluator's documentation, which states the same two rules this module did: a grid
position is absolute, and `is_last` is the only thing that closes a series. The
module keeps its own doc explaining the rules native nodes rely on and re-exports
the type, so `native::batch::EvaluatedChunk` still names it.

Signed-off-by: discord9 <discord9@users.noreply.github.com>
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): reject a node that declares a different field count than it consumes

A window and a map both report one value per input field, so they keep the
layout of their input. Validation checked that both sides were samples and
that the grids matched, but not that the field counts did, so a tree could
declare two output fields over a one-field input and still pass
`NativeTree::try_new`. The mismatch then surfaced at run time, against a
schema the tree was supposed to have validated.

Signed-off-by: discord9 <discord9@users.noreply.github.com>
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): validate the field layout of every node that declares one

Field counts were checked for a window and a map and nothing else, so an instant
selection, either aggregate stage and a binary operation could each declare a layout
their kernel does not produce and still pass validation, surfacing at run time
against a schema the tree was supposed to have validated. Each node is now checked
against the count its own kernel produces, which for a binary over two vectors is
the shorter side.

Grids are compared by their specification, as the kernels compare them, instead of
by allocation. A compiler that allocates one grid per window -- `rate(a[5m]) +
rate(b[5m])` -- built a tree the runtime would run and validation rejected. Binding
identity is still compared by allocation: an input has to report the one binding it
was given, which is a different question from what two equal grids mean.

A rejected tree is a compiler defect, so it is reported as an internal error rather
than as a mistake in the client's statement, which `Plan` would have implied.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): check a window's input ordering and range, and drop the tests that only repeat another module

`validate` accepted an interleaved input for a window, although the range
evaluator reads one series to its last chunk before it starts the next, so the
tree this layer builds was guaranteed to fail at run time. A window now rejects
both an interleaved input and a range that is not positive. An instant selection
reads one series at a time, so it keeps accepting an interleaved input and now
says why.

The grid comment claimed the input grid is checked to cover the window's read
range. It is not: only a subquery feeds an evaluated input to a window, and this
compiler does not accept subqueries yet.

The binary operation's catch-all arm is unreachable at run time -- state has no
grid and the grid checks return before the match -- but the match still has to
cover the state pairs, so they are spelled out and say so instead of claiming a
value arrived.

Rejection branches had no tests, so deleting any of them kept the suite green.
Each now has one. The four tests in `native/batch.rs` repeated the ones in
`evaluated.rs` through the re-export, so they could not fail on their own; they
are gone, and the coverage stays in `evaluated.rs`.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): bind a grouping as `by` or `without`, not as a label list

A label list can only spell `by`: `sum without (instance) (rate(x[5m]))` has no
list of group labels, because its key is every label the series has except the
ones named. Spelling that as a list would group the query by labels it meant to
remove, and the kernel takes both shapes already
(`aggregate::Grouping::{By, Without}`), so the tree had no way to say what the
engine can already do.

State carries its grouping in the same terms now, so a `Final` also refuses state
built for the other shape, not only state built from a different label list.

The `groups` gauge counts per partition: a `Partial` aggregate sees one group in
every partition it reaches, so the summed gauge is the total of per-partition
entries rather than distinct groups. Both the metric's doc and the comment that
claimed otherwise now say so.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

---------

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
Signed-off-by: discord9 <discord9@users.noreply.github.com>
2026-10-20 18:58:07 +08:00
discord9 872753e4cd refactor(promql): read and write sparse evaluated series (#9750)
* refactor(promql): read and write sparse evaluated series

The axis can now say a series holds values at only some grid positions. This
makes PromQL use that: the builder stops padding its output, every operator
reads positions and rows from the axis instead of deriving one from the
other, and the streaming protocol stops treating a run boundary as a delivery
boundary.

`GridSeriesBuilder::finish` returns one `Series`, or `None` when no retained
row holds a value, with the axis built from the runs the retained positions
make up. It keeps one buffer per field and slices runs out of it by span, so a
series with values at 10 and 900000 on a million-point grid costs two rows,
not the distance between them. The queue that cut a series into one piece per
run is gone: `take_buffered` hands out everything buffered so far as one
`Series` and ends nothing, so a caller that wants a prefix still gets one
while the later rows stay on the same logical series. The position cursor
resets only in `begin_series`, which is what marks the next logical series; a
flush must not rewind it, and a test pins that.

Every reader follows. A place that took one start offset and computed a row
as `position - start` now asks the axis for the row, or walks positions with a
forward cursor per field, or intersects two axes with `intersect_runs`
carrying one compact offset per side -- broadcast and vector matching used to
compare envelope bounds and subtract starts, which is only correct while every
axis is contiguous. Matches stay exhaustive: no wildcard arm was added.

The six functions that had grown a `Vec` purely because a result could be
segmented return `Option<Series>` again: `absent`, `map_native_histogram`,
`map_date`, `map_compare`, `map_arithmetic_series` and `map_grid_scalar`. Each
has one output identity per input -- one metric name, one field set, one axis
-- and a hole in the result is a property of that axis, not a second identity.
`None` means no row survived. Operators that genuinely fan one input into
several identities keep returning a `Vec`: aggregation groups, topk, binary
and set matching, `merge_fields`, `merge_same_labelset` and the label
operators; those reuse one builder per output series and now call
`begin_series` explicitly, since `finish` no longer resets.

An evaluated series may now be closed by an empty delivery. `push_series`
refused any empty input while the evaluator itself produces an empty closing
chunk after a valued prefix and for an identity that never produced a value,
so a pipeline of two evaluators rejected its own output. An empty chunk that
is not the last still decides nothing and stays an error; the last one is a
close, whatever it carries. `SeriesState::last_ts` becomes an `Option` so an
identity with no position at all has no timestamp to invent.

Three walks that were correct but quadratic are fixed here because the shapes
changed under them: binary matching rebuilt its two sample cursors for every
shared run, so each run re-read every row before it; set matching asked the
filter for presence one point at a time and topk asked one member at a time,
both through `Samples::sample`, which scans chunk descriptors from the start;
and topk ranked every grid point instead of the union of the members'
positions. The cost argument is from the code, not measured -- how many samples
a walk reads is not observable through the public API -- while the tests pin
that the results are unchanged under fragmentation.

The per-operator suites asserted the old shapes, so the expectations that said
"one piece per run" now say "one series, several runs". Two of them could not
have failed before: histogram aggregation was compared through a helper that
dropped every non-histogram row, so a padded range and a sparse one compared
equal, and the sparse `map_date` case claimed to exercise the cutting path
while holding only present samples. Both now assert the exact runs, row counts
and alignment, so a return to padded output fails.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* perf(promql): pick a merged row's cursor by slot, not by search

`merge_same_labelset` collects the rows of every member of a label group and
then merges them position by position. Collecting stores which member each row
came from, and the merge read that member's value through a per-member cursor,
because a member of several runs must not re-scan its field chunks per row.

Between the two it looked the member up again for every row:

    let member_index = members
        .iter()
        .position(|candidate| *candidate == member)
        .expect("the row names one of the members");

That is a linear search over the group per row, so a group of N members with
one disjoint value each pays N(N+1)/2 comparisons just to find its cursors --
on the path a label function takes when it drops the label that kept the
series apart, which is the ordinary way several identities become one. The
previous shape indexed `series[member]` directly and had no such term.

The rows already hold the member; make it hold the member's slot instead.
`members` is built in ascending input order, so sorting by slot orders the
merge exactly as sorting by member did, and the cursors stay forward-only.
No lookup, no map, no change to the merge itself.

The regression case is the one that exposes it: many members whose single
values sit at distinct positions share a label set once the distinguishing
label is dropped, so the collision check never short-circuits and the whole
merge runs. It asserts the merged axis carries one row per member with each
member's own value, which a wrong slot fails -- checked by forcing the slot to
zero, where both this case and the existing disjoint-fields case fail.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): say what a chunk does and does not promise, and compare without folding

`EvaluatedChunk` claimed the positions after the last row handed out had not
been decided yet, and `range()` claimed it reported how far a series had been
evaluated. Neither holds: a push can settle a trailing stretch as empty and hand
out nothing for it. What holds is that a position no chunk carries either holds
no value or has not been decided, and a consumer cannot tell the two apart, so
only `is_last` says a series is complete.

`assert_result` folded the series it was given by label set before comparing, so
a producer that wrongly cut one label set into two series was folded back
together and passed. It now refuses that outright; `fold_pieces` stays for the
callers that genuinely fold a stream.

`RangeEvaluator::pull` and the `finish` branch that checked it were dead: every
push ends in an `emit` that drains the builder, so nothing is ever left to pull
and seven assertions said a condition that could not be false.

`skipped_and_all_missing_positions_are_the_same_series` pushed `&[None]` where
it meant to skip, so both builders received the same calls and the test compared
a builder with itself.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

---------

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-20 17:57:52 +08:00
discord9 82c7d145b7 feat(datatypes): support sparse evaluation-grid axes (#9762)
* feat(datatypes): support sparse evaluation-grid axes

A series that holds a value at only some grid positions had no way to say so:
its time axis was one contiguous range, and an explicit timestamp list is not
the same thing -- it loses the evaluation grid the series is validated
against, and cannot answer which grid position a row belongs to.

`TimeAxis` already owns positions, length, timestamp access and slicing, and
already has a chunked form that keeps several Arrow buffers unflattened. This
gives grids the same treatment. `GridSpan` is one run of absolute positions;
`GridRanges` is an ordered, non-overlapping list of spans on one shared
`EvalGrid`; `TimeAxis::GridRanges` carries it beside the contiguous
`TimeAxis::Grid(GridRange)` fast path, which keeps its contract including the
zero-copy `timestamps()` slice.

The coordinates are fixed by the axis and documented on it. `len()` counts
carried rows, not the distance from the first position to the last;
`timestamp`, `slice` and `row_of` speak compact rows; `positions()` walks
`(absolute position, compact row)` pairs in one pass with one cursor per span;
`intersect_runs` merges two run lists without forming their product;
`union_positions` merges several axes. Spans are validated at construction --
ordered, non-overlapping, inside the grid, arithmetic checked -- and an axis
with no rows still owns its grid, so nothing unwraps a first span to find it.
`row_of` is a binary search and `slice` rebuilds through the checked
constructor. `Samples` gains a forward `RowCursor` so a walk reads rows in
order without scanning chunk descriptors per row.

`Series::try_new` accepts the new form under the same rule as the contiguous
one: its grid must match `SeriesSchema::eval_grid`, and an explicit axis still
may not carry an evaluation grid.

Nothing produces a fragmented axis yet. The type and its invariants land
first, and every existing reader keeps its behaviour: the places that require
a contiguous axis already return an error for anything else, so they reject
the new form rather than misreading it. Wiring the readers changes what those
places must do with their results, which is why it follows separately.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(datatypes): check the shared grid, keep one constructor, and stop a cursor from poisoning itself

`intersect_runs` and `union_positions` walk two axes together but never said
they must be addressed on one grid; on an explicit axis every position is 0, so
the pairs were silent nonsense. Both now state the precondition and assert it in
debug builds.

`GridRanges::try_new` accepted a single span, which is not normalized into the
one run a `TimeAxis` fast path expects, so a public caller could build a range
that reports `is_contiguous()` and still has no grid range. It is now
`pub(crate)`; `TimeAxis::from_grid_spans` stays the public way in and keeps
normalizing a single span.

`Samples::RowCursor::at` drained its iterator on an out-of-range row, so one bad
call made every later call answer `None`. It now bound-checks first and leaves
the cursor where it was.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

---------

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-20 15:53:33 +08:00
discord9 05659b1094 perf(promql): hand out evaluated series as chunks instead of one buffered series (#9719)
* perf(promql): hand out evaluated series as chunks instead of one buffered series

`RangeEvaluator::push` evaluated every grid point a chunk completed, wrote the
rows into its `GridSeriesBuilder`, and then returned `None` for a non-final
chunk. A series therefore became visible only on its last chunk: the rows of
every earlier chunk stayed buffered in the builder, and a consumer could not
release or forward anything until the series ended. That is also why the fused
node that evaluates raw samples could emit one batch per series and not per
input chunk.

`push` now returns the positions it finished as an `EvaluatedChunk`: the
evaluated rows plus an `is_last` flag, addressed on the grid by an absolute
`GridRange`. A consumer can forward a prefix while the series continues, and
`is_last` is the only thing that closes the series. `evaluated.rs` holds that
type and the rule it enforces, that a chunk is addressed on a grid.

Two protocol points make streaming safe. Ranges of one series only increase, so
concatenating the chunks of a series never reorders them. The closing chunk is
handed out even when it holds no position, so a series whose remaining grid
points have no value still reports that it ended rather than leaving a consumer
waiting for input that will not come; previously those series returned `None`
and ended silently. `push_series` likewise returns its one closing chunk.

Tests fold the chunks of a series back into one series and compare that against
the same brute-force reference and Prometheus UDF references as before, so the
chunking is checked against the values, not against itself. The fold helper
fails on a chunk after the closing chunk, and the golden range-function and
mixed-histogram suites, which push one sample per chunk, go through it.

Signed-off-by: discord9 <discord9@users.noreply.github.com>
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* test(promql): check the label and position of the chunk that closes a series

The fold helper checked the ordering of a chunk only through the rows holding a
value, which the grid builder sees. A closing chunk has no rows, so an empty
chunk at a wrong position passed. It now compares every chunk's range against
the end of the previous one directly, and compares every chunk's metric, since
a consumer keys series by their labels and a later chunk with different ones
would look like a different series.

`an_empty_closing_chunk_is_labelled_like_the_chunks_before_it` covers the label
half for `rate`, which drops `__name__`: this also fails if the empty closing
chunk stops applying the metadata removal.

Also corrects the docs: the range spans the first and last position holding a
value, so it is an envelope that may contain missing values, not a report of
every position evaluated. `is_last`, not the empty endpoint, says whether the
positions after the range are missing or not evaluated yet.

Signed-off-by: discord9 <discord9@users.noreply.github.com>
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): hand out positions decided to be empty, not only positions holding a value

`emit` finished the builder and returned `None` for a non-final chunk when the
builder held no valid row. A grid point that was already decided to have no
result therefore produced no chunk, and a consumer never learned it was settled:
it could not advance its own windows past those points and stayed waiting for
input that had already been shown to contribute nothing.

The example that fails: inner `rate(x[1s])` on the grid 0..4s. A chunk with
samples at -0.5s and 0s decides the point at 0s. A chunk with a sample at 2s
decides that 1s has an empty window and 2s a single-sample window -- both
missing -- and nothing later can change them, since a sample at 4s lies past
both windows. The old code returned `None` there, so an outer
`sum_over_time(...[3s:1s])` could not emit its own result for 1s and 2s until
the series ended.

To report progress, the evaluator now tracks how far it has decided, separately
from how far it has values, and the chunk span covers that whole range:

- `SeriesState::completed` is the grid index past the last decided position. A
  point counts as decided when it has been evaluated, when no retained sample
  reaches its window any more (the loop stops there instead of walking the grid,
  so the points behind it are reported too), or when the series ends.
- `GridSeriesBuilder::cover_from` / `cover_through` widen the builder's span
  around positions that hold no value, so the finished series reports them as
  missing instead of trimming them away. The builder's own `finish` still trims
  to the outermost value for callers that have not decided anything.
- `emit` covers `[emitted_end, completed)`, which is empty only when nothing was
  decided since the last chunk. A series that ends without deciding anything new
  still closes, on an empty chunk.

Tests that pinned the old trimming are updated, and
`a_chunk_of_decided_missing_positions_is_handed_out_before_the_series_ends` is
the case above: with the sample at 4s not yet arrived, the second push must
yield a non-final, all-missing chunk covering points 1 and 2. Folding the three
chunks still reproduces the whole grid, so reporting the missing spans costs no
values.

Signed-off-by: discord9 <discord9@users.noreply.github.com>
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): hand out no chunk when a push completes no position

`evaluate` opens the builder's span at the first position it has not handed out
yet, even when the push settled nothing. `emit` then called `cover_from` and
`cover_through` for the decided range, and `finish` built a zero-length series
instead of returning `None`, so a consumer received a chunk whose span claimed
progress the push had not made. With a grid of 10/20/30ms and a 10ms window, a
first chunk holding only `(5ms, 1.0)` reported a zero-length chunk while the
first grid point was still undecided.

Return `None` when the decided range is empty and this is not the last chunk. The
closing chunk is still handed out, empty when nothing is left to report, because
`is_last` is what ends a series.

`a_push_that_completes_nothing_hands_out_no_chunk` pins that single push, and
then checks the closing push still reports every position of the grid.

Signed-off-by: discord9 <discord9@users.noreply.github.com>
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* test(promql): fold the batch-path chunk before comparing against the per-window series

`a_batch_spanning_fields_and_the_flush_threshold_matches_the_per_window_path`
compared the two paths as `Series`, but `push` hands out an `EvaluatedChunk`. Taking
`.series` is not enough: a chunk's grid range reports the positions its push
decided, which can include leading and trailing positions holding no value, while
the reference assertion requires the grid range to be trimmed to the valid rows.
The batch path reported `(0, 600)` against the expected `(1, 599)`.

Fold the chunk back into one series first, which trims to the valid rows the same
way, and compare that. The rebase also removed the `sum` helper along with the
test `overlapping_window_spans_chunks`, so the now-unused `floats` helper goes
with it.

Signed-off-by: discord9 <discord9@users.noreply.github.com>
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* chore(promql): let an evaluated chunk be cloned

`EvaluatedChunk` holds a `Series` and a bool, both cheap to copy behind their
existing ownership, so deriving `Clone` costs nothing. A consumer that has to keep
the chunk it matched on, or hand the same chunk to two stages, would otherwise
have to destructure and rebuild it.

Needed by the native tree, whose bound nodes receive chunks by value and reuse
them.

Signed-off-by: discord9 <discord9@users.noreply.github.com>
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* test(promql): share the owned schema when folding a batch chunk

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

---------

Signed-off-by: discord9 <discord9@users.noreply.github.com>
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-19 21:51:38 +08:00
discord9anddiscord9 21e3e10b78 perf(promql): evaluate rate and increase over a batch of windows (#9710)
* perf(promql): evaluate rate and increase over a batch of windows

`RangeEvaluator` called a boxed `FnMut(FloatWindow) -> Option<f64>` once per
grid point per field, building a six-field window struct and appending a row
for each. A series of thousands of points therefore made thousands of boxed
calls and row appends.

The counter functions now take a batch: `BatchKernel` is
`FnMut(&FloatWindows, &mut Vec<Option<f64>>)`, selected once outside the loop,
and `extrapolated_rate_batch` runs concretely typed per `rate` and
`increase`. `FloatWindows` reuses the retained arrays and one sample range per
window rather than a copy per window, because a counter function reads only a
window's endpoints, sample count and resets. Other functions keep the
per-window form and the evaluator falls back to it.

This batches the computation only. The results still reach `GridSeriesBuilder`
one row at a time, and `flush_batch` reuses the evaluator's row buffer rather
than allocating per point. Writing a whole batch as columns needs a column
entry point on the builder, which is follow-up work; this commit cannot be read
as carrying the whole gain of the experimental branch.

A window holding a native histogram sample is decided by the mixed-window
rules of `histogram::evaluate`, which read both kinds of sample together and
report nothing for a counter over a mixed window. The batch kernel reads the
floats alone, so a point whose window holds a histogram flushes what is
pending and runs on the per-window path. Without that, a mixed window would
report a float rate instead of nothing, and a histogram-only window would lose
its histogram result.

Tests assert the batch path is the one that ran, since both dispatch shapes
produce the same output; a mutation that drops the histogram admission check
fails them. Window ranges stay absolute into the retained arrays across a
batch, and each accumulated point keeps its own grid index, so a batch spanning
a skipped grid point or a chunk boundary still writes the right rows.

Signed-off-by: discord9 <discord9@users.noreply.github.com>

* test(promql): cover several fields and a mid-evaluation flush in batch window evaluation

The batch path was checked only by single-field inputs of at most 20 points, so
two of its properties had no test: that each field's windows come from that
field's own samples, and that a batch flushes when it fills up before the series
ends. Mutating the first -- giving every field field 0's windows -- and
`panic!`ing in the second both passed the whole suite.

`a_batch_spanning_fields_and_the_flush_threshold_matches_the_per_window_path`
pushes 600 points over three fields, each field resetting at a different point,
growing at a different rate and missing at a different interval, and compares
the batch result against the per-window path on the same input. The gaps are
what make the property observable: with every field present at every point the
three fields' windows coincide and field 0's spans would be correct by
accident, which is how the first form of this test missed the mutation.

The histogram admission check also stops scanning every remaining histogram per
point and running for functions without a batch kernel: a field's histograms are
retained in time order, so the first one ahead of the window head decides, and
the check only matters when there is a batch kernel to fall back from.

Signed-off-by: discord9 <discord9@users.noreply.github.com>
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

---------

Signed-off-by: discord9 <discord9@users.noreply.github.com>
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
Co-authored-by: discord9 <discord9@users.noreply.github.com>
2026-10-19 20:03:28 +08:00
discord9anddiscord9 c500f4697f perf(promql): append ordinary raw chunks to the evaluator in bulk (#9709)
* perf(promql): append ordinary raw chunks to the evaluator in bulk

`ingest` copied every input sample into `FieldSamples` one at a time. A
`RawSeriesChunk` hands the evaluator Arrow buffers `Samples::from_float`
wrapped without copying, and `ingest` read them back through `Samples::iter`
only to perform, per sample, an enum match, a stale-marker comparison, an
overflow-checked offset addition and two `Vec::push` calls.

A chunk of null-free float columns read with no offset now appends with
`extend_from_slice`. Every other shape keeps the per-sample loop: a shifted
timestamp reads different timestamps, a null has no sample, a field mixing
native histograms reports `DenseSamplesRef::Mixed` and must keep its windows
on the histogram path where the mixed-window rules decide the result, and a
chunk whose values contain a stale marker drops its row and needs the per-row
decision.

The reset index stays per-sample inside the bulk append, so the recorded
resets are the same ones the per-sample path records. Tests assert both that
the values still match the brute-force reference and that the bulk path is the
one that ran, since both paths produce identical output by construction; a
mutation that lets a mixed field through the admission check fails them.

Signed-off-by: discord9 <discord9@users.noreply.github.com>

* refactor(promql): keep the ingest docs with ingest and drop untested guards

Three review findings on the bulk-ingest path, none changing behaviour.

The doc comment describing `ingest` sat on top of `ingest_in_bulk` after the
bulk path was inserted above it, so `ingest` had lost its docs and the bulk
function carried documentation for work it does not do. The comment moves back
to `ingest`.

`ingest_in_bulk` returned `Result<bool>` but never returned `Err`: the two
length checks inside it are already done by `check_chunk` for raw input and by
`push_series` for evaluated input. It now returns `bool`, and the length checks
go with the `Result`.

The stale-marker and offset "is not appended in bulk" tests guarded faults the
behaviour tests already catch -- the differential and reference comparisons
fail if either condition is admitted. The histogram one stays: it is the only
test covering that admission rule.

Signed-off-by: discord9 <discord9@users.noreply.github.com>
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

---------

Signed-off-by: discord9 <discord9@users.noreply.github.com>
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
Co-authored-by: discord9 <discord9@users.noreply.github.com>
2026-10-19 18:45:40 +08:00
discord9 3fca17791c perf(promql): index counter resets per field instead of rescanning the window (#9708)
* perf(promql): index counter resets per field instead of rescanning the window

The fused evaluator rescanned every window for counter resets, once per grid
point. Record the resets of each field as the samples are ingested instead,
keyed by an absolute sequence number, and read the ones a window covers with a
binary search. Only `rate` and `increase` correct counters, so only they
maintain the index.

The binary search runs over the field's retained reset index rather than over
the window's samples: its input is the remaining slice of resets, so its cost
follows the number of retained resets, not the window length.

A drop across an intervening native histogram sample is recorded as a reset of
two samples that are not neighbours. That is unobservable: a window holding
both floats also holds the histogram between them, so it is a mixed window,
whose counter functions report nothing; and a window starting after the earlier
float excludes a reset on its first sample.

Verified with `cargo nextest run -p promql` (453 passed) and `cargo clippy -p
promql --all-targets --all-features -D warnings`. Tests assert both that the
indexed results match the brute-force scan and that the index is what ran.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* refactor(promql): drop per-field clearing the evaluator never needs

Two review findings, both about code that does nothing.

`finish_series` cleared every field after `evaluate(None)` returned. That loop
is redundant: `evaluate` ends with either every point evaluated -- so `head`
moved to the end and `advance` dropped the samples -- or no field holding a
sample, and either way the fields are empty. It also did not clear
`histograms`, which made it read as though it should. Removing it keeps the
suite green, and `push` already documents that an evaluator is not reused after
an error. `FieldSamples::clear` existed only for that loop.

`overlapping_window_spans_chunks` used a `sum` kernel and never touched resets.
Windows spanning chunks are already covered by
`evaluator_matches_brute_force_reference`, which chunks randomly, and no
mutation was caught only by this test. Its `sum` helper went with it.

Signed-off-by: discord9 <discord9@users.noreply.github.com>
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

---------

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
Signed-off-by: discord9 <discord9@users.noreply.github.com>
2026-10-19 17:47:10 +08:00