Commit Graph
6 Commits
Author SHA1 Message Date
discord9 6157479d9a chore: integrate series-label naming with the recursive native POC
Apply only the type rename to the surviving recursive runtime and tests; preserve the removal of global final-label occupancy and speculative split-series admission guards.

Signed-off-by: discord9 <discord9@163.com>
2026-10-25 03:54:27 +08:00
discord9 8388c09d5c chore: merge narthex into refactor/promql-series-labels
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-24 18:55:47 +08:00
discord9 da9c63dc0e refactor(promql): remove NodePath and own the runtime tree recursively
A node was identified by the path that reached it, so every execution and
every metric export walked that path again, and a node's own state lived in
tables keyed by it. This replaces that with one recursive runtime tree: each
node owns its children, its operator state, its metrics and, once settled,
nothing it no longer needs.

An `InputId` is assigned only to the leaves that actually receive a feed, by a
depth-first walk of the built tree, so a scalar leaf takes no id and no node is
addressed by position. Pulling, settling, flushing after a subtree closes,
EOF, errors and drop all recurse through the same tree.


Signed-off-by: discord9 <discord9@163.com>
2026-10-24 18:52:05 +08:00
discord9 5ff4f5b911 refactor(datatypes): name the PromQL series label set by what it is
`MetricName` held the whole PromQL-visible label set of a series -- the
`__name__` value plus its ordinary labels -- not one metric name. Rename it to
`SeriesLabels` so the type says what it holds, and record in its doc comment
that it is neither a canonical matching/grouping identity nor a storage
identity.

Signed-off-by: discord9 <discord9@163.com>
(cherry picked from commit c69827ae89)
2026-10-23 14:52:25 +08:00
Dennis Zhuang 0efeae0f1d feat(promql): compile and run label_replace and label_join as map operations
Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-22 03:47:42 +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