Commit Graph
6918 Commits
Author SHA1 Message Date
discord9 abc5868073 feat(promql): carry the native plan with serde instead of a hand-written codec
`native/wire.rs` encoded `NativeTree` by hand: fixed node tags, fixed field
layouts, both directions written out, and a `check_shape` that narrowed the
transport to `Input -> Instant|Window -> Map(Math*)`. The serialization layer
therefore decided which plans could exist. `abs(ceil(x))` compiled but could
not be transported, and every new node kind cost a second codec path plus a
new whitelist entry. Widening the whitelist to nested maps maintained that
interpreter rather than removing it.

The file is deleted. The plan types derive serde and the codec is postcard
1.1.3 -- bincode is unusable here because RUSTSEC-2025-0141 marks the whole
crate unmaintained with no patched version. postcard is maintained, has a
documented stable wire format since 1.0, and writes an f64 as its bit pattern
in eight little-endian bytes, so NaN payloads, signed zero, infinities and
subnormals survive exactly.

What remains hand-written is framing only: magic `NPRQ`, a version, a payload
length. The version is rejected before the payload is deserialized, so an
unknown version is a clean error instead of a silent misparse, and oversize,
trailing bytes and truncation are separate errors. Decoding ends at
`NativeTree::try_new`, so a tree the compiler would not build cannot be
decoded.

Three types keep written impls because they hold invariants. `EvalGrid`
serializes its `GridSpec` and rebuilds through `try_deferred` with an empty
cache -- the grid is never serialized and never materialized on decode.
`SeriesLabels` rebuilds through `try_new`, keeping the sorting and the
duplicate / `__name__`-as-a-tag rejection. `LabelRewrite` travels as its
parameters with the regex recompiled, and fails to decode if the pattern no
longer compiles; compiled internals never travel.

`FusedSeries` now carries its output schema and accepts `PlainRows` and
`partial_fold`, which it used to refuse. A carried schema is only honest if it
is checked, so decode validates every column role, the exact time type and
nullability, missing or extra value columns, and the scalar-root tag rule; a
`SeriesFrame` schema must equal the frame layout the tree derives, and a
partial export must match `state_schema(binding.tags)` including tag order.

Identity stops being display text. `FusedSeries` compared `tree.to_string()`
with a field patch, `AggregateFinalize` hashed the rendering, and
`LabelRewrite` compared its own output; all three are structural now, with
floats compared by `to_bits()` so that signed zero and NaN payloads stay
reflexive under `Eq`. `same_binding` compared `Arc::ptr_eq`, which put an
allocation address into a transportable plan; it compares the ordered fields
and the optional grid spec now, distinguishing raw from evaluated.

Tests assert the properties rather than the byte layout: a non-canonical NaN
payload round-trips by bits, an unknown version is rejected before decode even
when the payload is garbage, non-prefix trees (binary, set, aggregate, scalar,
evaluated input) round-trip, thirteen malformed schemas are each rejected with
their own message, and a multi-chunk chain over a real region is checked
against its exact sample count and contiguous timestamps.

The multi-partition integration test is split in two and labelled honestly. It
used to assert a failure, on the theory that coalescing interleaved multi-chunk
chains; its fixture's grid has one position, so no chain has more than one
chunk and that assertion was already failing at the base commit. One test now
covers the single-position shape and says it does not exercise interleaving;
the other covers a 8192-position grid that does.

Deserialization is bounded by depth, not only by bytes. The derived recursion
walks boxed nodes, and a payload under the 1 MiB limit can nest far deeper than
the stack allows, which aborts the process instead of returning an error. A
thread-local budget guards `NativeNode`'s deserializer, so a hostile payload is
rejected before a complete tree exists. `NativeTree::Deserialize` also routes
through `try_new`, so no path can materialize a tree the constructor would
refuse. Measured under postcard with no `RUST_MIN_STACK` override: 128 nodes
decode, 129 return a decode error. Postcard collapses custom serde errors to
its unit variant, so that rejection does not name the nesting limit; the other
envelope failures (version, size, trailing bytes, truncation) each keep their
own message.

Signed-off-by: discord9 <discord9@163.com>
2026-10-31 01:21:05 +08:00
discord9 e7044931fb feat(promql): serialize the native prefix and execute it on a datanode (#10029)
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)

This is the wire-format half of the closed #9993 line. The raw fan-in,
aggregate split and remote-source work that #10002 also carried moves to
a separate PR on top of the public execution framework.

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

### The problem

A native PromQL plan could not leave the frontend. `FusedSeries` carries
a `NativeTree`, and that tree had no wire representation, so a datanode
could only ever be asked for raw samples.

### The wire format

`src/promql/src/native/wire.rs` defines protocol version 1 for the
prefix shapes the remote route can actually produce — `Input →
Instant|Window → Map(Math*)+`, i.e. a selection under any number of math
maps — as an explicit byte layout documented in the module:

- every node kind and every operation has a **fixed tag**; the tags are
declared wire-stable and deliberately do not follow Rust discriminants,
so reordering an enum cannot change the protocol;
- floats travel as `f64::to_bits()`, so NaN payloads, signed zero,
infinities and subnormals round-trip exactly;
- the input ordering is carried as data (0 Interleaved, 1 Contiguous, 2
KeyThenTime) rather than filtered by the encoder: legality belongs to
`NativeTree::try_new` on decode, not to the transport;
- decoding rebuilds grids through their validating constructor and ends
with `NativeTree::try_new`, so a structurally invalid tree is refused at
decode; compatibility between a window modifier and its function, or a
negative `lookback_ms`, is refused later, when the datanode builds the
physical plan;
- the payload is bounded (1 MiB) and rejects unknown versions, unknown
tags, truncation and trailing bytes instead of guessing.

`Instant`'s value selector is carried as a tag (0 sample, 1 timestamp, 2
info timestamp) rather than a bool, because `InstantValue` has three
cases since `info()` became native. Anything outside the supported
shapes fails to encode *and* to decode with an error naming the
unsupported operation. `label_replace`/`label_join` are not encoded
because admission already declines them.

### Carrying it

`FusedSeries` gains `serialize`/`deserialize` (a small versioned
envelope around the tree plus its binding) and registers arms in the
existing Substrait extension serializer, so a datanode's
`DefaultPlanDecoder` already understands the node. No new transport, no
protocol change, no `greptime-proto` bump: extension payloads are opaque
bytes.

Serialization is restricted to the `SeriesFrame` output with no
`partial_fold`, because only that shape derives its output schema from
the tree and can therefore rebuild itself after decoding.

### Executing it on a datanode

The existing `PromExtensionPlanner::plan_fused_series` already knows how
to build a decoded `FusedSeries` over a `RegionScanExec`. The
integration test `native_prefix_executes_over_real_mito_region` uses a
real standalone Mito region to prove the whole path: a flushed table, an
encoded `Input → Window(rate)` prefix, `encode` → `handle_remote_read`
on the real region server, decode and execute, then unpack the returned
evaluated frame and check that the tag `host` survives, `__name__` is
dropped as `rate` requires, the grid timestamps are `3000`/`4000`, and
the rates are `20/3` and `10`. Those two numbers are not
interchangeable: a counter whose first sample is `0` cannot be
extrapolated backwards at all, while the second window's sample pattern
allows the full 1 s gap. The test also tampers with the embedded version
byte and asserts the datanode fails rather than returning a plausible
frame.

### Encode/decode agreement

A payload the encoder accepts must be one this version can decode. Three
places disagreed and are fixed here:

- **Field count.** The decoder limited an `Input`'s field count to 128
while the encoder wrote any count, so a 129-field `Input → Instant`
encoded to 983 bytes and failed to decode. Both sides now use
`MAX_FIELD_COUNT`.
- **Tag count.** `serialize` only checked that the tag count fit in a
`u32` while `deserialize` limited it to 1024. Both sides now use
`MAX_TAG_COUNT`.
- **Frame schema.** `deserialize` passed an empty schema to
`try_new_with_format`, which since #9968 *checks* a frame node's
declared schema against the layout derived from its tree instead of
overwriting it. Every serialized frame node was therefore impossible to
rebuild. `deserialize` now derives the schema from the decoded tree,
which is where it comes from in the first place.

Counts were left at their previously published values (depth 128,
operations 128, fields 128, tags 1024); only the missing encoder-side
checks and the wrong schema were added. `MAX_COUNT` is split into
`MAX_DEPTH` / `MAX_FIELD_COUNT` / `MAX_OPERATION_COUNT` so each limit
names what it bounds, and a field-count overflow no longer reports
"operation count exceeds maximum".

### Limitations

- **The frontend does not route queries through this yet.** It is a
plan-serialization and datanode-execution boundary, not a query-plan
change. Wiring the frontend to push the prefix and consume evaluated
frames is the next increment.
- Only one input source and the prefix shapes listed above are
transported; binary, vector-wide, aggregate-state and subquery trees are
not.
- No performance claim: a datanode that executes the prefix returns
evaluated frames instead of raw samples, but the frontend still gathers
every selected region into one partition.
- A selection may sit under any number of math maps, because a map is
transparent to shape: it keeps its input's samples type and grid, and
only the operation kinds are restricted. Non-math operations are still
refused at every level. The payload stays bounded by `MAX_DEPTH` (128
node levels, so a selection plus 126 maps), and both the encoder and the
decoder enforce it.

### Known limitation: the datanode boundary is not chain-atomic

The region-read merge point wraps a non-streaming plan in
`CoalescePartitionsExec`, which coalesces **per batch**. A series frame
declares `Contiguous` and one series chain spans several batches (the
packer bounds rows and flushes on `Pending`), so on a multi-partition
datanode the batches of one chain are interleaved with another's and
decoding fails with `evaluated series identity changed before its chain
closed`.

This is reproduced by `native_prefix_multi_partition_series_chains` (16
hosts × 20k samples, `rate[10s]`, 16 partitions). That test asserts the
failure, so it passes today and will turn red when the merge is fixed —
which is the signal to change it. The `parallelism: 1` path is covered
by `native_prefix_executes_over_real_mito_region`.

The fix does not belong here: the merge point should become chain-atomic
in the frontend-integration change, for example by reusing the public
`SeriesRepartitionExec`. Until then, **a multi-partition datanode read
of a series frame is not correct** — this PR is not evidence that the
datanode path is usable.

## 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-29 20:57:16 +08:00
discord9 f11173f193 fix(mito2): close a raw series when its package completes (#10023)
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)

## What's changed and your intention?

### The problem

A raw series stream reports when a series is complete by setting
`is_last` on a chunk. Its contract is "the final chunk for this series
in the input stream exposing it", but the producer only ever set it when
the next chunk of a **different** series arrived, or when the input
ended.

So a consumer could not tell a series that is finished from one that is
still being written. It had to buffer a whole series before it could act
on it, which is a cost paid whether or not the series is long.

### What this changes

`FlatSeriesBatchDivider` already emits a package only once it holds
every row of the series in it, so the record that ends such a package
also ends the last series in it. `raw::make_stream` flattened those
packages into a plain record sequence and dropped that fact. Records now
carry it:

- **The fact survives being held back.** A record held for the next
predicate group keeps its flag instead of losing it in the round trip.
- **A flagged record ends its group.** The group's end and the package's
end become the same record, so a record of the *next* package can never
be closed by the wrong boundary.
- **The tail key is taken before the residual predicates run.** A
predicate may drop the run that would have closed the series while the
package still holds all of its rows.
- **Closing a package clears `pending`, so the ordering check keeps its
own baseline.** Without it the next package's first chunk would lose the
key it must exceed and a cross-package regression would go unnoticed.
- **A series that continues past the package that completed it is
rejected**, not yielded: it would be split into two runs that both claim
to be final.

The prepared columnar source never carried the fact at all. Its
per-range readers cut complete series out, but the row-level merge
behind them re-cuts the stream by batch size, so a `Flat` package
boundary is not recoverable there. An earlier revision re-divided that
stream to restore packages; this revision drops that and yields ordinary
`RecordBatch` instead, which is why the closing below applies to the
legacy producer only. The one surviving change in `prepared.rs` skips
empty batches.

`SeriesBatch`'s doc said "batches of the same series", which contradicts
the divider that produces it (a package may hold several complete
series). Corrected.

### Limitations

- **No performance claim.** This changes *when* a consumer can observe a
complete series, not how much work the producer does. A consumer that
already buffers whole series gains nothing until it drops that
buffering.
- Package-completion information is used only under `KeyThenTime`, which
is what the one real flat producer declares: `Flat` comes from the
legacy distributor and its stream is globally ordered. A series that
reopens after the package that completed it is rejected by the
ordered-key comparison (`legacy series primary keys are not strictly
increasing`), covered by
`a_key_closed_by_a_package_must_not_continue_in_a_later_one`. Under
`Contiguous` nothing closes a flat series from package metadata, and
there is no `Contiguous` flat producer to need it.
- This does not change the `is_last` meaning or the wire representation;
it stops under-reporting it.

### Testing

Thirteen new tests. The central one,
`a_complete_package_closes_its_last_series_without_the_next_package`,
feeds one package and then never produces anything again (no next
package, no EOF, no waker). It asserts both chunks arrive with `is_last`
set. With the package fact removed the test hangs on the second chunk —
that is how the old behaviour was confirmed.

The others cover a package tail the predicate empties; a series
continued after its package, in both the same-key and after-another-key
shapes and under `KeyThenTime` (the only ordering a flat producer
declares); an ordinary `RecordBatch`, which says nothing about where a
series ends; and the batching case below.

`record_batches_separated_by_pending_share_one_predicate_pass` and the
corrected
`filtered_package_tails_do_not_close_an_incomplete_ordinary_tail` pin a
regression this revision also fixes: the fill loop awaited upstream only
when a package tail was held, so a brief `Pending` no longer splits one
predicate pass into many. Previously one hundred small record batches
separated by `Pending` cost one hundred predicate passes.

Existing tests that pin boundaries are unchanged and still pass:
`upstream_error_is_terminal_after_yielded_prefix_without_flushing_pending`,
`duplicate_or_descending_cross_batch_time_is_terminal`,
`empty_filtered_group_keeps_the_preceding_series_last_flag`.

Locally: `cargo nextest run -p mito2 -E 'test(series_scan)'` 45 passed,
`cargo clippy -p mito2 --all-targets -- -D warnings` clean, `cargo fmt
--all --check` clean. Also ran `-E 'test(series) or test(scan_test)'`
(284 passed) to cover consumers of the raw producer. No CI run claimed.

## 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-29 20:57:02 +08:00
discord9 c0fdb12b1d test(promql): pin cross-producer topk to fixed partitions
`native_aggregate_topk_over_global_partitions` also asserted that a selected
hostname had state from two distinct original producers. That follows from
the storage scheduler, not from the merge: a scan dispatches on a channel of
capacity one and independent flushes do not fix their output partition, so a
legal schedule can leave one partition holding every series but two. The
assertion failed intermittently - `[25, 1, 1, 1]` - while the answer itself
stayed correct.

Move the requirement to a fixture that fixes which partition each contribution
arrives on, so it holds on every run. `topk_above_the_cut_sees_every_producer_
contribution` folds `topk(1, sum by (hostname)(...))` over four producers whose
contributions make the winner differ between the two grid points and require
both major producers to be summed: at point 0 producer 0 holds A=6/B=10 and
producer 1 holds A=6/C=10, so A wins on 12; at point 1 the same producers give
B=12. Two further producers contribute a small hostname each so all four
partitions carry state.

Dropping a producer or taking topk inside a producer before the merge both
answer a different winner, which the fixture catches - checked by mutating the
fixture each way and watching it fail.

The integration test keeps everything that follows from this build: the
native/legacy comparison, the hand-computed twenty rows, the two points
selecting different topk members, the producer and finalize plan shapes, and
the four non-empty producer partitions. Its now-unused partial-state recorder
is removed with it.

Signed-off-by: discord9 <discord9@163.com>
2026-10-29 20:50:55 +08:00
discord9 5afa151d62 perf(mito2): compare raw series keys without copying them
The ordered-key guard built a Buffer for every record before checking
whether a previous package key existed to compare against. Read the
slices instead, so the common case allocates nothing.

Signed-off-by: discord9 <discord9@163.com>
2026-10-29 20:26:07 +08:00
discord9 1ade4551c7 fix(mito2): keep one predicate pass across a brief upstream pause
The fill loop awaited upstream only while nothing was buffered, so one
Pending split a predicate pass even when the group held no package tail
that could close anything. Gate the wait on the package tail instead:
without one, breaking out cannot settle a series, so waiting is free.
One hundred small record batches separated by Pending now cost one pass
instead of one hundred.

Package completion is also only usable under KeyThenTime, which is what
the one real flat producer declares. Drop the Contiguous package path
and its closed-key state: it has no producer, and reaching it would close
a series twice. A reopened key is still refused by the ordered-key
comparison, now covered under KeyThenTime.

Signed-off-by: discord9 <discord9@163.com>
2026-10-29 20:21:53 +08:00
discord9 6f99b36e44 fix(promql): bound the prefix depth and name what a refusal names
Lower MAX_DEPTH to 32. The old 128 was validated recursively, and a
debug build overflows a default test-thread stack well before reaching
it, so the depth guard itself could abort instead of refusing.

Report the refused node by its own name instead of "unsupported", which
is what every kind used to say.

Add the multi-partition datanode case. It asserts the failure the review
reproduced - a series chain cut by CoalescePartitionsExec no longer
decodes - so the limitation stays visible and turns red when the region
merge becomes chain-atomic.

Reconcile the retained-rate comment with the values the test asserts.

Signed-off-by: discord9 <discord9@163.com>
2026-10-29 20:17:00 +08:00
discord9 72bee4ae9c feat(promql): answer whole-vector and multi-metric statements natively (#10025)
* feat(promql): name a fused tree's tags from the existing plan

A fused node declares exactly the tags the planner gives it, and reads each
one back off the series, so a label set this route cannot derive is a label
set the answer would lose. The walk over the tree derives labels for a
match, a set operation and the point-wise wrappers over them, and names
none for an aggregate's groups or a whole-vector operation - which today
declines every such statement.

Take those columns from the existing planner instead, the way the aggregate
split already does: its tag columns are what it projects for the same
statement, and they are now shared through one helper rather than written
out twice. Only the tag columns are copied, because the node names its
field columns after its own tree while the existing planner can name a
computed column after its expression.

A label rewrite is a shape the walk cannot derive at all: label_replace
adds its destination label and an empty replacement removes it, so passing
the input's labels through is an inference the rewrite contradicts. Report
no label set there. The rewrite itself keeps the existing plan, and this
does not change that: the native runtime refuses two series a rewrite gives
one label set, where that plan answers them, so admitting the shape would
turn an answer into an error. The test that holds this now covers the
two-occurrence shape as well.

An aggregate or whole-vector root does not reach this branch today: both
read across series, so they are planned by the aggregate split or fall
back. It is the prerequisite for the placement that runs such a tree in one
execution, and reaches the branch when that lands.

Signed-off-by: discord9 <discord9@163.com>

* feat(promql): run a classic histogram function above the aggregate cut

The split accepts a suffix that runs once over the fully merged series,
which is why TopK/BottomK were already admitted: the merge has folded every
partition's contribution to each group, so the series the suffix reads are
the ones the statement's input would have produced in one execution.

A classic histogram function is that kind of operation. It reads each
histogram off the bucket series that share its other labels and drops le,
and the merge has already folded each (..., le) group into one complete
series on the evaluated grid, so the buckets it reads are exactly the ones
the input would have had. Reading le off those merged series is the same
computation, not an approximation. LimitK still declines: it depends on the
storage input order, which the merge does not preserve.

This is what the o11ybench query
`histogram_quantile(0.99, sum(rate(...[5m])) by (instance, le))` needs: it
was planned by the existing path only because the split refused a
whole-vector operation above the cut.

The acceptance test runs the shape over real data and compares the rows two
executions report: the fixture spreads six bucket series over several files
and a memtable range, so the partial sums genuinely have partitions to
merge, and the native answer must equal the complete input's for quantiles
at both ends of the interpolation and for histogram_fraction. It also
asserts the native run really merged state, so a silent fall back cannot
pass it.

Signed-off-by: discord9 <discord9@163.com>

* feat(promql): read two metric logical tables as two inputs

A two-selector statement whose two sides are aggregates kept the existing
plan, even where both sides read a metric-engine logical table. The
o11ybench query `sum(max by (...) (a)) by (namespace) / sum(b) by
(namespace)` is that shape, and it fell back for three separate reasons.

First, the engine gate refused every multi-selector metric-engine
statement. The clause after it already requires each such selector to be a
recognized logical rewrite, so the removed clause refused only the case
this change handles.

Second, a metric logical table is rewritten to a scan of the physical table
restricted by `__table_id = <id>`, so two logical tables over one physical
table prepare two scans of the same table. The check deciding whether the
node binds one metric or one input per occurrence compared table names
only, so the two metrics read as one and both inputs would carry the first
one's name - a wrong answer, not a fall back. The id the rewrite already
requires is now part of that identity.

Third, a tree over two aggregates reads across series, so it is not
answered per partition, and the branch for such trees was the aggregate
split, which takes one child. A node with more than one input needs no
split: it declares one partition and reads all of every input's partitions
in one execution, so every group is folded in one place. Such a tree now
falls through to the node.

The one-selector path is unchanged: it still requires `can_split` and still
declines a tree the split cannot cut. So is the multi-selector case whose
inputs stay direct scans of one table - such a node is not region-local, as
`Categorizer::check_extension_plan` refuses a cross-series plan over more
than one scan - which is why the condition is the metric-logical-table one.

The acceptance test runs both shapes over two logical tables of one
physical table, with values that make a swapped input visibly wrong, and
compares the rows the two routes report.

Signed-off-by: discord9 <discord9@163.com>

* test(promql): log the metric pair's rows through telemetry, not stdout

The acceptance test printed its evidence with println!, which the crate
lints as an error under -D warnings, and echoed a Display value through
to_string inside the format arguments. Every other test in the file logs
through common_telemetry::info!, so this one does too.

Signed-off-by: discord9 <discord9@163.com>

* fix(promql): keep the existing plan for a tree the runtime cannot execute

The multi-input route skipped the aggregate split, and the split was what
refused a tree the runtime cannot execute: `check_cut` declines a
`quantile` and a grouping by a value field. Skipping it turned queries the
existing plan answers into errors, because the node is chosen while
planning the logical plan and a refusal the physical layer raises later has
nowhere to fall back to.

`quantile(0.5, a) / sum(b)` over two metric logical tables reported
`NotImplemented: the native runtime does not execute the Quantile { .. }
aggregate yet` where the existing plan answers it, and
`count(count by (val) (a)) / sum(b)` failed on the grouping by a value
field, which the outer `count` had hidden from the column check.

Ask the runtime before taking the route, with the same construction the
physical layer validates by, and keep the existing plan for whatever it
refuses. Asking it rather than listing the unsupported kinds is what makes
the check cover refusals this planner does not know about.

Signed-off-by: discord9 <discord9@163.com>

* fix(promql): keep the existing plan for a grouping and a selection the node cannot answer

A multi-input node skips the aggregate split, so the refusals the split
makes for reasons that are not about what the runtime computes were skipped
with it. Two of them matter here.

A grouping by the metric name keeps a label no column of the existing plan
can name. The native aggregate folds its groups by `__name__` as by any
other label, but the label set the node declares comes from the existing
planner's columns, and `__name__` is the name a raw frame carries rather
than a column of any scan - so the existing planner's `by(__name__)` finds
nothing and reports no label. The schema would then report one label set for
two groups the tree keeps apart: `sum by (__name__) (a or b)` over two
metrics would answer two rows a reader cannot tell apart, which is a series
identity lost rather than an answer given.

A selection that depends on the order its input arrives in is the other.
`limitk` keeps the first `k` series of each group in input order, while a
node with more than one input reads its inputs' partitions through a gather
that hands them over in the order they became ready: the schedule would
decide which series a query keeps. Prometheus defines the selection over a
series set. The split declines `LimitK` for the same reason. Every other
vector operation orders its input itself - `topk` by label, `sort` by value,
`limit_ratio` by a label hash - which is why only this one is asked about.

Both shapes keep the existing plan. `limitk` is not answerable by either
route today, since the existing planner has no kernel for the operator; the
point is that the native route is not the first to answer a selection the
schedule would decide.

Each guard is checked with the guard removed, so both tests are known to
fail without it.

Signed-off-by: discord9 <discord9@163.com>

* fix(promql): decline unsupported count_values groupings before execution

Signed-off-by: discord9 <discord9@163.com>

* docs(promql): scope the native capability probe guarantee

Signed-off-by: discord9 <discord9@163.com>

* test(promql): pin the same-table pair the multi-selector exclusion used to keep out

State that a Mito input joins a multi-input tree only when a second
metric logical id makes the tree multi-input, and that only LimitK's
selection depends on arrival order.

Signed-off-by: discord9 <discord9@163.com>

* fix(promql): decline ranking grouped by a value field before native execution

topk, bottomk and limitk key their groups by series labels, while the
existing plan also accepts a value field there and forms one group per
sample value. Admitted multi-input trees could therefore keep one row
where the existing plan keeps one per group. Validate the grouping when
the vector runtime is built so the statement keeps the existing plan.

Signed-off-by: discord9 <discord9@163.com>

---------

Signed-off-by: discord9 <discord9@163.com>
2026-10-29 20:16:44 +08:00
discord9 dfd052605d feat(promql): carry nested math maps in the native prefix
The wire format accepted at most one Math map above the selection, so
abs(ceil(x)) - which compiles to one map over another - could not be
transported even though a map is transparent to shape: it keeps its
input samples type and grid, and only the operation kinds are
restricted. Walk every map before the selection check instead, and keep
rejecting non-math operations at any depth.

The tests cover a nested round trip that would notice flattening, a
non-math operation under a valid outer map, three distinct levels, and
the depth boundary in both directions.

Signed-off-by: discord9 <discord9@163.com>
2026-10-29 18:52:18 +08:00
discord9 1d4aae7371 fix(promql): make the native prefix codec agree with itself
A payload the encoder accepts has to be one this version can decode. Three
places disagreed.

An `Input`'s field count was bounded only on the way in, so a 129-field
`Input → Instant` encoded to 983 bytes and then failed to decode. The
decoder's limit was `MAX_COUNT`, a constant that also bounds nesting depth
and `Map` operation count, which is why the missing half was easy to miss;
each limit now has its own name and both sides check it.

`FusedSeries::serialize` only checked that the tag count fit in a `u32`
while `deserialize` bounded it at 1024. Both sides now share `MAX_TAG_COUNT`.

`FusedSeries::deserialize` passed an empty schema to `try_new_with_format`,
which checks a frame node's declared schema against the layout derived from
its tree rather than overwriting it. Every serialized frame node was
therefore impossible to rebuild. The schema is derived from the decoded tree
now, which is where it comes from in the first place, so nothing is added to
the wire.

Encoding refuses an over-limit count instead of writing a payload it cannot
read back, and a field-count overflow no longer reports "operation count
exceeds maximum".

`rejects_excess_operation_count` hand-built a payload whose operation count
was read from the wrong offset: it placed an `Instant` where the decoder
expects the count, so it exercised the limit with 258 while claiming 129.
It now starts from a tree the codec accepts, asserts the bytes it edits are
the count it means, and checks the error names both the operation limit and
the value. The same is added for field count, tag count and nesting.

The integration test moves to its own file: it was written against
`tests-integration/tests/raw_series.rs`, which the raw-fan-in line creates
and this one does not carry.

Signed-off-by: discord9 <discord9@163.com>
2026-10-29 18:32:44 +08:00
discord9 ecb76d7e30 feat(promql): serialize fused native prefixes through substrait
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-29 18:32:44 +08:00
discord9 7d113e2aa4 feat(promql): add a versioned wire format for the native prefix
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-29 18:32:44 +08:00
discord9 404ab63e69 fix(mito2): retain streaming and batching around raw package completion
Signed-off-by: discord9 <discord9@163.com>
2026-10-29 18:32:24 +08:00
discord9 cfb74b1b38 fix(mito2): close a raw series when its package completes
The raw producer only ever marked `is_last` when the next chunk of another
series arrived or the input ended, so a series that a scanned package held
completely stayed open until that later event. A consumer could not tell a
finished series from one still being written, which is why the frontend's
raw fan-in has to buffer a whole series before it can merge anything.

`FlatSeriesBatchDivider` already emits a package only once it holds every
row of the series in it, so the record that ends such a package also ends
the last series in it. `raw::make_stream` flattened those packages into a
plain record sequence and dropped that fact. Records now carry it, survive
being held back a round, and a flagged record ends its group: the group's
end and the package's end are the same record, so a record of the next
package can never be closed by the wrong boundary.

Two things had to follow. The tail primary key is taken before the residual
predicates run, because a predicate may drop the run that would have closed
the series while the package still holds all of its rows. And closing a
package clears `pending`, so the ordering check keeps its own baseline
instead of losing the key the next package must exceed.

A series that continues past the package that completed it would be split
into two runs that both claim to be final, so that is rejected rather than
yielded.

The prepared columnar source lost the same fact a second way: its per-range
readers cut complete series out, but the row-level merge behind them
re-cut the stream by batch size. Dividing that stream again restores
packages, reusing the divider the legacy producer already uses. Both flat
producers declare `KeyThenTime`, which is what lets the closed-key
comparison stay a single key; the code states that dependency.

Signed-off-by: discord9 <discord9@163.com>
2026-10-29 18:32:24 +08:00
discord9 bb20057985 feat(promql): route evaluated fragments to their canonical owner (#10038)
* feat(promql): route evaluated chains independently by canonical key

Signed-off-by: discord9 <discord9@163.com>

* test(promql): verify canonical routing key semantics

Signed-off-by: discord9 <discord9@163.com>

* fix(promql): release closed-output chains and yield routing producers

Signed-off-by: discord9 <discord9@163.com>

* test(promql): check closed-chain budget before releasing the child

Signed-off-by: discord9 <discord9@163.com>

* fix(promql): account histogram slices and verify routing completion

Signed-off-by: discord9 <discord9@163.com>

* fix(promql): finalize contiguous series at repartition owners

Wait for successful producer EOF before publishing exact complete metric-name/tag identities. Reuse the existing sample merge for position ordering, complementary fields, and conflicting-cell errors; recompute one final close per owner identity.

Keep N producer tasks and M outputs. Admit nonwaiting queue payloads through the query pool, reserve merge workspace, and retain cancellation, failure, memory-release and unread-owner coverage.

Signed-off-by: discord9 <discord9@163.com>

* feat(promql): publish completed repartition identities incrementally

Signed-off-by: discord9 <discord9@163.com>

* test(promql): expose histogram amplification in public repartition

Signed-off-by: discord9 <discord9@163.com>

* fix(datatypes): encode only referenced histogram table rows

Signed-off-by: discord9 <discord9@163.com>

* test(promql): wait for queued retention before draining a failed read

Signed-off-by: discord9 <discord9@163.com>

* perf(promql): bound identity rescans and share repeated histogram rows

Signed-off-by: discord9 <discord9@163.com>

* docs(query): scope the evaluated validator to one chain

Signed-off-by: discord9 <discord9@163.com>

---------

Signed-off-by: discord9 <discord9@163.com>
2026-10-29 17:38:44 +08:00
Yingwen daa903254d perf(mito): cache native reader metadata (#10015)
* perf(mito): cache native reader metadata in the decoded SST tier

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

* fix(mito): account for partially occupied metadata tree nodes

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

* style(mito): use absolute cache imports

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

* perf(mito): trim native metadata cache lookup overhead

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

* test(mito): bound wide metadata benchmark setup

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

* test(mito): allow manual benchmark measurement output

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

* docs: keep series specification focused on the file format

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

---------

Signed-off-by: evenyag <realevenyag@gmail.com>
2026-10-29 17:29:50 +08:00
discord9 fe8b64f0e4 chore(ci): slide the compatibility window to v1.3.0 (#10054)
Signed-off-by: discord9 <discord9@163.com>
2026-10-29 14:35:10 +08:00
Weny Xu eb8e4316ec ci: backport scoped heavy CI to narthex (#10053)
* ci: backport scoped heavy CI to narthex

Backport #9927 and the CI-only changes from #10043. Adapt script references to narthex, preserve manual platform checks, and document the default-branch schedule limitation.

Upstream-Commit: ebb7e22566
Upstream-Commit: e87766d7ef
Signed-off-by: WenyXu <wenymedia@gmail.com>

* ci: refresh narthex compatibility release window

Signed-off-by: WenyXu <wenymedia@gmail.com>

---------

Signed-off-by: WenyXu <wenymedia@gmail.com>
2026-10-29 13:06:14 +08:00
dennis zhuang 9f906da091 feat(promql): run time(), vector(s) and data-free scalar broadcasts natively (#9988)
* feat(promql): run time(), scalar(v), vector(s) and dynamic scalar broadcasts natively

Bind time() and zero-argument date functions as EvalTime, scalar(v) as ScalarOf and
vector(s) as VectorOf, and run binary operations whose scalar side varies per grid point:
the scalar is read whole, vector chunks that arrive before it are held and charged, and
both operand orders and bool comparisons follow Prometheus.

A tree holding scalar(v) runs as one execution over every scan partition, and the
planner admits it only over an unpartitioned Mito table. Data-free statements plan
without a scan.

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* fix(promql): hold the labels of chunks waiting for a varying scalar

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* feat(promql): read round and clamp parameters from varying scalars natively

Bind round, clamp, clamp_min and clamp_max whose parameters are not all constants as a
ParameterizedMap with scalar parameter nodes, run through map_math_with_parameters. The
parameters are read whole first; vector chunks that arrive before them are held and
charged, through the same hold the varying-scalar broadcast now shares. Constant
parameters keep the MathOp map.

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* test(promql): pin a parameterized map's empty closing chunk

Also correct the held-chunk docs, use same-table examples for varying map parameters,
and assert the native decline instead of the existing planner's failure.

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* fix(query): plan vector(s) natively whatever the table's field is named

The fused route declined a root field that is not a raw field, which is every root over
vector(s): it names its result `value`. Take the compiled root's fields as they are and
decline only a name the output already gives a tag or the time index.

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* fix(promql): reserve a constant map parameter before building it

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* refactor(promql): leave scalar(v) to the existing planner until it has its own execution boundary

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* fix(promql): leave data-free statements that generate labels to the existing plan

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* fix(promql): reserve the output point-wise kernels build before running them

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

---------

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-28 20:07:45 +08:00
Yingwen 7996bcb6fd perf(mito): batch samples reads and reuse part readers (#10016)
* perf(mito): batch samples reads and reuse part readers

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

* fix(mito): validate boundary reads and report scan memory exhaustion

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

* docs: keep samples specification focused on the file format

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

* style: remove trailing blank line in samples format doc

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

---------

Signed-off-by: evenyag <realevenyag@gmail.com>
2026-10-28 18:44:22 +08:00
Yingwen 3c21571387 perf(mito): push predicates into columnar series discovery (#10014)
* perf(mito): push predicates into columnar series discovery

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

* perf(mito): resolve series tags once per discovery batch

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

---------

Signed-off-by: evenyag <realevenyag@gmail.com>
2026-10-28 17:33:27 +08:00
discord9 c25fd3100e perf(mito2): coalesce raw series records before residual predicates (#10017)
* perf(mito2): coalesce raw series records before residual predicates

`raw::make_stream` evaluated every residual predicate against one record per
`ScanBatch`, and a `ScanBatch` carries the rows of a single series. A predicate
pass is dominated by per-call setup rather than by per-row comparison, so the
cost was paid once per series: profiling the lite-02 aggregate query put
`common_recordbatch::filter::batch_filter` at ~20% of CPU across 1280 calls for
640 series, and 70% of that was per-call overhead.

Buffer records until 4096 rows, concatenate them with `concat_batches`, and
evaluate the predicates once per group. The grouping loop, the `is_last`
marking, the strictly-increasing checks and the error messages are unchanged.

Three guards keep the behaviour identical:
- A record whose first primary key continues the series the buffer ends with is
  held back, because concatenation would fuse two chunk boundaries of one
  series. Two existing tests pin those boundaries.
- With no predicate there is nothing to amortize, so the threshold drops to one
  record and the stream keeps its per-record shape.
- Buffered records are reported one at a time when an upstream error arrives.
  Merging them would put rows into the chunk withheld as `pending`, dropping a
  prefix the per-record stream emitted before the error.

All 12 vmbench-lite queries return identical series, timestamps and values.
Paired A/B against the previous head: CPU is lower in 30 of 30 rounds, median
cut 13.8%-29.4% across five queries.

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

* perf(mito2): release coalesced source records before filtering

The concatenated arrays own their buffers through `Arc`, so the source
`RecordBatch`es are dead once `concat_batches` returns. Releasing them keeps the
original arrays from staying alive across the predicate pass and the per-chunk
yields of the same round.

Raised in review of #10017; no behaviour change.

Signed-off-by: discord9 <discord9@163.com>

* perf(mito2): start a group at a record that fills a batch, drop the error split

Two review comments on #10017.

`make_stream` processed the records buffered before an upstream error one at a
time, so the chunks of the earlier records came out before the error. Nothing
reads that prefix: the error fails the query, and the `RawSeriesStream` contract
promises per-series ordering and finality, not a prefix on error. The predicate
path already withholds its `pending` chunk on failure, so the split was also
inconsistent with the rest of the stream. Removed, along with the half of
`filtered_boundary_fuses_chunks_on_eof_but_an_upstream_error_still_withholds_one_record`
that pinned it; the remaining fusing assertion keeps its own test.

A record that reaches `coalesce_rows` rows by itself now heads its own group.
Concatenating it with whatever is buffered copies it, and its predicate pass is
paid once either way; on its own it takes the single-record path unchanged. The
threshold's doc comment no longer claims a record larger than the bound is
isolated, which was never true.

Neutral for the measured workload: a vmbench-lite series is 61 rows, so the
threshold is never met by one record. `cargo nextest -p mito2` series-scan
selection: 53 passed.

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

---------

Signed-off-by: discord9 <discord9@users.noreply.github.com>
Signed-off-by: discord9 <discord9@163.com>
2026-10-28 11:45:06 +08:00
discord9 487a398116 fix(promql): keep a group's points together when decoding aggregate state (#10008)
* fix(promql): keep a group's points together when decoding aggregate state

The state encoder writes a group's points in ascending order, one row per
(point, field). The decoder opened a group whenever the row's point differed
from the group's first point, so every point of every group became its own
group: each row re-allocated the label set's `Arc<str>` strings, and the merge
then re-hashed those labels and repeated a map lookup for each of them.

Open a group only when the labels change. A later point of the same group is
appended to it and a repeated point is that point's next field, which keeps
the arrival order of every (point, field) and therefore the floating-point
merge order unchanged. A point that goes backwards within a group is refused.
Appended points and their state vectors are admitted before they allocate.

Measured with paired alternating A/B on one prepared vmbench-lite database,
30 measured requests per run, opposite order each round, 6 rounds:

  Q02 avg without(cpu) 5m   p50 A/B 1.60, CPU A/B 1.25, 6/6 rounds faster
  Q11 sum by 4 tags 5m      p50 A/B 1.57, CPU A/B 1.49, 6/6
  Q01 100-(avg(...)*100)    p50 A/B 1.40, CPU A/B 1.25, 6/6
  Q10 count by              p50 A/B 1.04, CPU A/B 0.99, 4/6  (neutral)
  Q03 sum by region,az      p50 A/B 0.92, CPU A/B 0.87, 2/6  (slightly slower)

`cargo nextest run -p promql` 987 passed, 0 skipped.

Signed-off-by: discord9 <discord9@163.com>

* fix(promql): accept concatenated aggregate state instead of rejecting it

Review of #10008 found three problems.

`decode_state` refused a row whose point went backwards within its group, and
refused a repeated `(point, field)` pair. Both are real inputs once state
crosses Flight: `CoalescingBatcher` merges consecutive small batches
(`servers/src/grpc/flight/stream.rs:313`), so two partitions' state for one
group arrives back to back and the second restarts the group at its first
point. Accept them instead: the merge addresses points by index
(`aggregate/state.rs:396-423`) and folds groups by label key, so a second entry
for the same point lands in the same slot, exactly as merging the two batches
separately would. Out-of-grid point and field indices are still errors.

The doc comment on `decode_state` still described the old rule (a change of
label or point opens a group); only a label change does now.

`decode_admits_the_points_it_appends` did not test what it claimed: the fixed
layout charge already exceeded its `tallest / 2` threshold, so the first
admission was refused whether or not appended points were charged, and the test
passed with the append-time `budget(total)?` removed. Derive the threshold from
a one-point batch of the same group so only appended points can exceed it.

Both files' tests updated to pin the new behaviour: a concatenated batch of two
encoders' output decodes like the two batches merged, a repeated field sums, and
the budget test now fails if the append-time charge is dropped.

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

---------

Signed-off-by: discord9 <discord9@163.com>
Signed-off-by: discord9 <discord9@users.noreply.github.com>
2026-10-28 11:44:55 +08:00
dennis zhuang 5b17bbd584 feat(promql): run info() natively (#10005)
* feat(promql): run info() natively

info(v, {selector}) fell back to the existing plan, which has no info
and refuses the query ("Unsupported expr type: info", or "Cannot accept
multiple vector as function input" when a selector is given).

Compile it into the native tree and run it on the info kernel:

- Plan: NativeExpr::Info { base, info, selector }. The info side is an
  instant selection reporting each selected sample's own time in
  milliseconds (InstantValue::InfoTimestamp, which replaces the
  BoundInstant.timestamp flag); validation refuses any other info side.
- Compiler: info_input derives the info selector the way Prometheus
  does: target_info or one __name__="..." matcher, the data matchers,
  and the @ and offset every selector of the first argument shares
  (subquery offsets added until an @ anchors them, none when they
  differ). Regex or negative name matchers, which can select several
  tables, __field__/__schema__/__database__ matchers and multi-field
  info tables are refused. The planner's selector walk uses the same
  helper, so both bind the info table as the last input.
- Runtime: BinaryRuntime runs the kernel as PairKernel::Info, with the
  retention and pool accounting of binary and set nodes; the kernel
  reports its workspace through info_with_reservation.
- Planner: the info table is a second table, so it is read through its
  own RawSeriesSource and the node runs on the frontend. The output adds
  the info table's tags, or only the data labels the selector names.
  Partitioned or non-Mito tables and a missing info table keep the
  existing plan.

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* test(promql): pin region-local placement of info over its own table

info(rate(target_info[5m])) reads the info table twice. Check that the
planner binds both as direct scans under one FusedSeries node bound to
the table, that the node is placed in the region, and the columns it
reports.

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* fix(promql): keep sample-less base series out of info's identifying patterns

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* fix(promql): share identifying label strings in info() instead of copying them

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

---------

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-28 10:39:43 +08:00
discord9 42f69f2d82 perf(promql): resolve the vector match result row once per pairing (#10007)
* perf(promql): resolve the result row once per pairing in vector match

Signed-off-by: discord9 <discord9@163.com>

* docs(promql): state the native node metric naming contract

Signed-off-by: discord9 <discord9@163.com>

* bench(promql): make the vector match timing reproducible

Signed-off-by: discord9 <discord9@163.com>

---------

Signed-off-by: discord9 <discord9@163.com>
2026-10-28 10:36:48 +08:00
dennis zhuang 654a44aa9f feat(promql): run native statements over any number of selectors (#10004)
* feat(promql): run native trees with more than two selectors

Lift the two-selector cap of the native PromQL route. A tree of one-to-one
vector matches and set operators, nested under point-wise wrappers, is now
admitted for any number of selectors: on one table the node runs in the
region over every scan, across tables it runs on the frontend over one
RawSeriesSource per selector. Partitioned tables, aggregates, whole-vector
operations and many-to-one matches still keep the existing plan.

The dist analyzer and commutativity checks accept a same-table node with
more than two scans; without that such a plan fails in the analyzer.

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* test(promql): cover nested set operators and metric-engine multi-selector decline

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* test(promql): wait for aborted partition tasks to release their inputs

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
(cherry picked from commit 3f8699782c3116c4063ec70a3522d1f5f3bff897)

* docs(promql): say spawned partitions are released after an error, not before

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

---------

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-27 09:18:26 -07:00
dennis zhuang 87bd2c6e62 feat(promql): run cross-table selector pairs natively (#9990)
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)

Native tree operator wiring (#25): cross-table inputs ("wiring B").
Builds on the two-scan route of #9870 / #9871 / #9896.

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

A two-selector native statement can now read two different tables: `a /
b`, `rate(a[5m]) / rate(b[5m])`, and set operators across tables. A
scalar or point-wise wrapper around such a pair is admitted too, e.g.
`100 * (1 - a / b)`, `(a / b) * 100`, `abs(a - on(x) b)`, on one table
or across two. Before, the planner required every input to share one
metric, time index and tag list, and only admitted the pair as the root.
Both kinds of query fell back to the legacy plan.

- **Placement.** A cross-table `FusedSeries` runs on the frontend.
Region-side placement would read the wrong data:
`NameAwareDataSourceInjector` rebinds every `TableScan` in a region
request to that request's one region, so a two-table node sent to one
region would read it for both inputs.
- **`RawSeriesSource`.** New logical node and exec, one per table. It
wraps that table's scan and carries the table's metric name, tags and
fields. It is commutative, so the dist analyzer pushes each one into its
own `MergeScan`.
- In the region, it builds the `SeriesScanExec`, materializes labels,
and emits one explicit-label raw frame stream declared `Contiguous`,
reading the scan partitions in order. A series never spans scan
partitions, so each series stays contiguous.
- Its declared frame schema is built the same way as what the region
emits, which `MergeScan`'s exact `validate_remote_schema` requires.
- `plan_fused_series` accepts these frame children, and `ScanHintRule`
gives their scans the per-series distribution.
- **Planner.**
- Each input gets its own scan time filter on its own time index; the
output time index is the first input's.
- Output labels are worked out over the whole tree from each input's own
tags:
    - one-to-one `on` / `ignoring` narrows the left side's labels;
    - `and` / `unless` keep the left side's labels;
- `or` takes both sides' tags, and a series' missing tag is NULL, not
invented.

`FusedSeriesExec` takes its label columns from the output schema;
before, it narrowed them from the root binary alone, which was wrong for
wrapped trees.
- A tree whose labels cannot be named declines: an aggregate or vector
operation, or an `or` union whose columns would collide.
- **Comparisons.** Vector–vector comparisons (`a > on(host) b`, `a ==
bool b`, and wrappers around them) are admitted on one table and across
two, with Prometheus semantics: a filter keeps the left sample and
labels, `bool` returns 0/1 and drops the name.
- **Route precedence with #9973.** The whole statement is tried natively
first, so a pair over two unpartitioned Mito tables now takes this
cross-table route instead of #9973's hybrid route; the hybrid route
still covers what this route declines (e.g. partitioned or metric-engine
tables). #9973's hybrid fixtures use a partitioned right table to keep
exercising it. A right side with a duplicate match group now returns
Prometheus' "found duplicate series for the match group" error on this
route, where legacy returns a result.
- **Unchanged.**
  - Same-table pairs still run inside the region.
  - Partitioned tables with two selectors still fall back.
- `group_left` / `group_right`, aggregates on both sides, more than two
selectors and `info()` are not covered and keep the legacy plan.

In an o11ybench plan probe (standalone, Mito, single-region tables),
vmbench-lite-05, lite-06, lite-08 and long-range-03 now plan natively.
That makes 22 of the 30 queries native, all with results equal to
legacy.

Legacy itself fails `a / on(host) b` when the two tables' time indexes
have different names ("Error parsing timestamp from 'h0'"); native
answers it. That case is checked against explicit rows only.

- **Concurrent inputs.** The multi-input gather of `FusedSeriesExec` and
`RawSeriesSourceExec` used to drain scan partitions (and inputs) one
after another. That stalled mito2's legacy series distributor, which
sends into one `channel(1)` per partition. On regions with overlapping
SSTs, every scan took ~480ms, and a same-table pair took up to 12.8s. A
new `SeriesGather` polls every partition and input concurrently, each
partition on its own query task.
- It hands the executor series-contiguous input: a chunk is held while
another partition of the same input has a series open.
- What it holds is at most one series per partition, charged to the
memory pool.
  - Inputs do not constrain each other.
- An error frees everything; dropping the stream aborts the partition
tasks; a panicking task reports an error.

The distributor itself is fixed separately on main in #9991; this PR no
longer needs it.

Measured on standalone single-region Mito tables with overlapping SSTs
(o11ybench data, median of 10, ON vs legacy OFF):

| query | sequential gather (before) | this PR | legacy |
| --- | --- | --- | --- |
| vmbench-lite-05 | 479 ms | 3.03 ms | 7.4 ms |
| vmbench-lite-06 | 970 ms | 4.19 ms | 10.4 ms |
| vmbench-lite-08 | 971 ms | 3.30 ms | 8.0 ms |
| vmbench-long-range-03 | 98.6 ms | 39.6 ms | 36 ms |
| same-table cpu pair | 12.8 s | 15.3 ms | 7.6 ms |

Same-table pairs are still slower than legacy (0.5–0.7x), because native
scans the region twice while legacy plans `x / x` with one scan. Results
are equal to legacy in every compared query. One exception: legacy
returns no series for `x{region="r"} / x{region=~"r"}`, where both sides
select the same series; native returns them, as Prometheus does.

## 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-27 18:28:58 +08:00
Dennis Zhuang 02eb3aa60c chore: merge narthex into feat/narthex-cross-table
Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-27 18:20:21 +08:00
discord9 b29a471575 fix(promql): repair native execution contracts and remove obsolete paths (#9980)
* fix(promql): restore native scan statistics and remove unused raw merger

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

* fix(promql): repair native execution contracts and remove obsolete paths

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

* refactor(promql): leave fused frame output repairs to separate PR

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

* docs: restore native contracts and regenerate config reference

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

* fix: slice nullable sparse frame fields after span selection

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

* fix(promql): intersect anchored timestamp selection windows

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

* test(query): name anchored timestamp boundary case type

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

* test(promql): align partitioned pair checks with hybrid operands

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@163.com>
2026-10-27 17:41:38 +08:00
Dennis Zhuang ba8156be47 fix(promql): keep the right side's labels for a filled left side across tables
Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-27 16:21:31 +08:00
dennis zhuang dd79052c7f fix(mito2): stop the series distributor from stalling on unread partitions (#10003)
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)

Cherry-pick of #9991 (merged into `main`) onto `narthex`.

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

`FusedSeriesExec` with two inputs reads every scan partition one after
another. On `narthex` this hits the series distributor's round-robin
`send_timeout(100us)`. Tokio timers tick at 1 ms, so after a merge (one
batch per series) each series batch costs about 1 ms per unpolled
partition. With 16 partitions, two-selector native queries over regions
with several SSTs spent ~470 ms to 12.7 s in `SeriesScanExec`, while
legacy took ~10 ms.

#9991 waits on all senders at once and sends to the first free slot. The
cherry-pick applies cleanly and the diff is identical to #9991.

## 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-27 16:00:16 +08:00
Dennis Zhuang 3d5f99596c chore: merge narthex into feat/narthex-cross-table
Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-27 15:47:23 +08:00
discord9 70638a769f feat(promql): add explicit local canonical merge operator (#9969)
* feat(promql): add explicit local canonical merge operator

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

* fix(promql): tighten canonical merge accounting and validation

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

* refactor(promql): remove redundant merge tree whitelist

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

* fix(promql): retain canonical merge output label reservations

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

* refactor(promql): finish canonical merge review simplifications

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

* test(promql): pin the parent barrier over canonical merge

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

* test(promql): assert the two-selector binary stays unfused

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

---------

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-27 15:15:20 +08:00
dennis zhuang 1dbc2c15ce fix(mito2): stop the series distributor from stalling on unread partitions (#9991)
When every partition channel is full, the distributor walked the senders
round-robin with a 100us send timeout each. Tokio timers fire on a 1ms
tick, so a consumer that leaves some partitions unpolled costs about one
millisecond per unpolled partition for every series batch.
SeriesScan::build_stream and scan_all_partitions drain partitions one
after another, and SortPreservingMergeExec above a multi-partition
SeriesScan only pulls the partition holding the current minimum key.
With 16 partitions and one batch per series after a merge, a 40-series
scan read partition by partition took ~480ms (measured on the native
PromQL two-input route).

Wait on all senders at once and send to the first one that frees a slot.
The send-timeout metric goes away with the timeout.

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
(cherry picked from commit e6b60644b3)
Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-27 14:49:12 +08:00
Dennis Zhuang 7bcdec1a83 chore: merge narthex into feat/narthex-cross-table
Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

# Conflicts:
#	src/promql/src/extension_plan/fused_series.rs
#	src/promql/src/extension_plan/fused_series/tests.rs
2026-10-27 14:39:04 +08:00
Dennis Zhuang 04a5aab7c1 fix(promql): charge the chunks a spawned partition reads ahead
A spawned partition queues up to two chunks and its task holds a third
while the queue is full. None of them was charged to a reservation, and
since they carry materialized labels their size is unbounded even though
their count is not.

Each chunk is now charged to the query's memory pool when the task reads
it, and the charge travels with the chunk: it is released when the chunk
is handed to the gather, which charges what it holds back itself, or
when the chunk is dropped in the queue or in an aborted task. A pool
that cannot hold the next chunk ends the partition in an error.

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-27 12:36:20 +08:00
Dennis Zhuang ed562fc905 feat(promql): run vector-vector comparisons natively
A one-to-one comparison between two selectors, such as `a > on(host) b`
or `a == bool b`, kept the existing plan: the pair admission accepted
only arithmetic operators. The kernel already pairs comparisons by the
same matching rules and only differs in the value a pair keeps (the
left sample when the comparison holds, or 1/0 with `bool`), so the
operator check is dropped. This covers one table and two, and the
point-wise wrappers around a pair, such as `(a > b) * 100`.

Adds an end-to-end case over two tables comparing explicit rows, the
legacy answer and the native placement for a filter and a `bool`
comparison.

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-27 12:36:20 +08:00
discord9 48797ab132 fix(promql): delegate fused frame output to the shared packer (#9968)
Reuse pack_evaluated_series for fused Frame output, preserving the single native driver and PlainRows/partial timing. Reject mismatched logical frame schemas and use the reset scan plan in integration tests. Add evaluated-packer lifecycle and invalid-sequence regressions, and address the review follow-ups.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-27 11:27:04 +08:00
discord9 a9266b2da9 feat(promql): fuse independent operands of hybrid binary queries (#9973)
I hereby agree to the terms of the [GreptimeDB
CLA](https://github.com/GreptimeTeam/.github/blob/main/CLA.md).

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

Enable vmbench-lite 05/06/08 cross-metric arithmetic using two
independently planned native operands and the existing DataFusion binary
join, PromQL matching and arithmetic projection.

Physical shape: `SeriesScan A(N) -> Fused A(N)` and `SeriesScan B(M) ->
Fused B(M)` execute region-side; separate MergeScan branches feed the
existing frontend Join. No native Join, second matching kernel or
Exchange is added; same-table fused admission is preserved.

A bounded analyzer pass locates each direct Fused(TableScan) through
Alias/Projection/Filter/Window wrappers, rewrites only the fused
subtree, and leaves all wrappers frontend-side. This preserves global
uniqueness checks. Subquery bodies disable native admission and restore
the prior flag before returning either success or error, avoiding use of
outer evaluation bounds.

### Verification
- `make fmt-check`
- `cargo check --locked -p query -p tests-integration --all-targets`
- Focused query admission/analyzer tests: 2 passed
- `cargo nextest run --locked -p tests-integration --lib -E
'test(native_hybrid_binary_05_06_08)'`: passed, independently rerun on
final source
- `cargo clippy --locked -p query --all-targets --all-features -- -D
warnings`
- Independent source review approved.

Standalone integration asserts two independent region-local
Fused->SeriesScan branches, four nonempty partitions per input, frontend
join placement, native/legacy complete-row parity, unmatched-series
removal (05), outer scalar projection (06), and independent rate inputs
(08). Additional cases cover subquery legacy fallback, valid
reduced-label `on(job)` matching, frontend uniqueness Window plus
`prom_assert_unique_match_group` Filter above MergeScan, and meaningful
duplicate-match execution errors under both routes.

### Scope and limitations
Standalone single-region per table, multiple scan partitions. Arithmetic
and admitted one-to-one matching only. No distributed Join/Exchange,
cross-region routing, group_left/right, set operators, native subqueries
or multi-field expansion. No full workspace suite or performance
benchmark is claimed. Based directly on narthex, independent of the
global-TopK PR.

## PR Checklist
- [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 matching labels.
2026-10-27 11:26:20 +08:00
discord9 6ca3d32090 feat(promql): finalize topk over globally merged aggregates (#9972)
I hereby agree to the terms of the [GreptimeDB
CLA](https://github.com/GreptimeTeam/.github/blob/main/CLA.md).

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

Enable vmbench-lite 09, `topk(10, sum by(hostname)(rate(...[5m])))`,
through the existing native aggregate-finalize path. Split TopK/BottomK
into the global suffix above fully merged aggregate values. Other vector
operations retain their existing admission behavior. No new physical
operator or persisted/wire format is introduced.

Physical shape: `SeriesScan(N) -> FusedSeries(N, partial sum) ->
merge/coalesce -> AggregateFinalize(global TopK) -> rows`. TopK is never
partition-local.

The standalone regression uses one region, four nonempty scan
partitions, 28 series and two evaluation timestamps with different
winning hosts. It compares complete native/legacy results and
independently expected values, checks producer/finalizer plans, and
records original partial-state streams to prove the same hostname
contributes from distinct partitions at both timestamps. Partition
output counts were [14,14,14,14].

### Verification
- `make fmt-check`
- `cargo check --locked -p promql -p tests-integration --all-targets`
- `cargo nextest run --locked -p promql -E 'test(aggregate_finalize)'`:
28 passed
- `cargo nextest run --locked -p tests-integration --lib -E
'test(native_aggregate_test)'`: 2 passed
- `cargo clippy --locked -p promql --all-targets --all-features -- -D
warnings`
- Independent source review approved; main-agent targeted execution
checks passed.

### Scope and limitations
Standalone single-region, multiple scan partitions only. No distributed
Exchange, cross-region merge/routing, or new matching/subquery support.
No full workspace suite or performance benchmark is claimed. This PR is
independent of the hybrid-binary PR and is based directly on narthex.

## PR Checklist
- [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 matching labels.
2026-10-27 11:26:14 +08:00
Dennis Zhuang e7a9badd29 perf(promql): read native inputs and scan partitions concurrently
A multi-input FusedSeriesExec drained every partition of its first input
before it touched the second, and RawSeriesSourceExec drained its scan
partitions one after another. The series distributor feeding those
partitions then waited on the one partition being read, the two inputs'
scans never overlapped, and all decoding ran on the one consuming task.

Read every partition through SeriesGather instead. It polls all of them
and hands a chunk on unless another partition of the same input has a
series open; such a chunk is held until that series closes, so each
input still sees contiguous series. A partition holding a closed series
is not polled again until it is handed over, which bounds what is held
to one series per partition, and that is charged to a memory
reservation. Inputs do not constrain each other.

When there is more than one partition, each is driven on its own query
task with a two-chunk channel, so the scan conversion, frame codec and
label decode of different partitions run in parallel. Dropping the
stream aborts those tasks, and a task that panics ends its partition in
an error rather than a silent end.

On the o11ybench vmlite/vmlong data (standalone, native ON, median of 10
alternating runs; results byte-equal to the sequential reader):

                         sequential*   this change
  vmbench-lite-05            4.4ms         3.0ms
  vmbench-lite-06            7.3ms         4.2ms
  vmbench-lite-08            5.0ms         3.3ms
  vmbench-long-range-03     54.4ms        39.6ms
  cpu same-table pair       27.6ms        15.3ms
  long fs same-table pair  104.2ms        83.6ms

* with the series distributor fix; without it the sequential reader
  stalled for up to 12.8s on these queries, and this change does not
  need that fix.

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-27 10:22:59 +08:00
Dennis Zhuang c9423f0193 fix(promql): decline cross-table outputs the native node cannot name
Under `or` the right table's tags join the left side's columns, and one of
them can share a name with the left side's field or time index; building
the output schema then failed instead of leaving the query to the existing
plan. Such a union is now declined. The output-label walk also declines an
aggregate or whole-vector node instead of passing its input's labels
through, which would describe the wrong schema if one were ever reached.

Adds an end-to-end case over two tables whose tags, field and time index
are all named differently, partly flushed and partly in the memtable,
under both series scan modes.

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-27 02:38:21 +08:00
Dennis Zhuang 4ce757b611 feat(promql): run cross-table selector pairs natively
A binary or set operation between selectors of two tables, such as
`rate(a[5m]) / rate(b[5m])`, fell back to the existing plan: the native
route required every input to share one metric, time index and tag list,
and a fused node is placed inside the one region it reads.

Two tables are two regions, so such a pair now runs on the frontend. Each
input becomes a `RawSeriesSource` that rides in its own merge scan: the
region binds its scan to that table's metric, tags and fields, decodes the
labels and answers one explicit-label raw frame stream, reading its scan
partitions one after another so each series stays contiguous. The fused
node consumes those frames directly. Pairs on one table keep running in
the region; partitioned tables keep the existing plan.

A point-wise wrapper around an admitted pair, such as
`100 * (1 - a / b)`, is admitted too, on one table and across two. The
output labels are now derived from the whole tree with each input's own
tags, and the physical node takes them from the output schema instead of
narrowing by the root alone.

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-27 01:53:07 +08:00
discord9 6b7d1bca4c test(promql): strengthen hybrid binary review regressions
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-27 01:03:31 +08:00
discord9 1c6c288936 test(promql): address global topk review feedback
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-27 00:54:51 +08:00
dennis zhuang 45852dbf67 feat(promql): compile and run set operators natively (#9970)
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)

Native tree operator wiring (#25): set operators. The kernel came in
#9679; the two-input route comes from #9870 / #9871 / #9896.

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

`and`, `or` and `unless`, with `on(...)` / `ignoring(...)`, now compile
into the native tree and run natively. Before, the compiler refused them
and the query fell back to the legacy plan.

- **Plan.** New `NativeExpr::Set { left, right, op: BoundSetOp { op,
labels } }`. There is no cardinality, `bool` or fill field, because
Prometheus rejects those modifiers on set operators. Validation requires
two evaluated vectors on the same grid; the output takes the left side's
fields.
- **Runtime.** `BinaryRuntime` runs either kernel (`PairKernel::{Binary,
Set}`), so a set node gets the same operand retention, assembly,
reservation, queueing and contiguity checks as vector–vector binary.
Errors name the set node.
- **Kernel.** `set_vectors` charged no memory.
`set_vectors_with_reservation` now charges it the way the binary kernel
does: output schema, filter-side group table, kept rows, the sort phase,
error text and the merge. Membership is decided from one presence bitmap
per filter match group (one bit per grid point), so each kept sample is
an O(1) lookup. Before, a coarse `on()` scanned every filter candidate
at every kept point, O(K·P·F). Results are unchanged; the Prometheus
golden set cases still pass.
- **Compiler.** `set_operation` binds the operator and the matching
labels, and nests on either side and above. It refuses a fill value:
promql-parser accepts one, but Prometheus rejects "filling in missing
series not allowed for set operators". It also refuses any side that is
not a single field. Legacy refuses multi-field set operations (both
sides for `or`, the left side for `and` / `unless`), and a per-field
presence rule is not defined by Prometheus, so those queries keep the
legacy plan.
- **Planner.** Same-table set operations take the existing two-scan
route, where `FusedSeriesExec` reads every partition of both scans into
one execution, so a set operation sees every series of both sides.
- **Partitioned tables.** A two-selector native plan on a table with
partition columns failed in `DistPlannerAnalyzer` ("only accept plan
with at most one child") instead of falling back. This already happened
for arithmetic pairs such as `x{kind="a"} - on(host) x{kind="b"}`, and
set operators would have hit it too. The planner now declines
two-selector plans on such tables, so both keep the legacy plan.

Not covered yet: set operations across two tables (`rate(x[5m]) and
on(job) up`), and those with aggregate operands (`sum by (job)(a) or sum
by (job)(b)`). Both still use the legacy plan. The presence bitmaps take
`groups × grid points / 8` bytes. That can exceed the filter samples
only when groups are very sparse (under about 1/64 occupancy); such a
query is refused by the memory pool rather than running over budget.
Allocating each bitmap over its group's position span would tighten
this, and is left for later.

## 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-26 19:36:08 +08:00
discord9 f882c1182d refactor(promql): reuse prepared native operand context
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-26 19:02:38 +08:00
discord9 9270b1ef7c test(promql): narrow global topk partition recording
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-26 18:53:54 +08:00
Dennis Zhuang 78dadff86d test(promql): pin per-step or matching through the native entry
A range query `a or on(host) b` where one host's left series covers only
the middle steps: native must keep the left sample where it exists and
take the right sample with its own labels at the other steps, matching
the explicit rows and the legacy plan. The set test's row reader moves
to module level so both cases share it.

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-26 17:44:31 +08:00
Dennis Zhuang 7f545344c4 fix(promql): hold the labels of queued results until they are handed on
A node that reads its whole input (binary, set, vector) queues the
series its kernel produced and settles its reservation on the queue.
Each queued result was held for `evaluated_bytes`, which covers the
chunk and its fields but not the label set the chunk owns, so once the
operands were released the reservation dropped below what the queue
actually held. A set over many long-labelled one-point series made the
gap large.

Hold each result that does not share the input for `series_bytes`, the
complete owned estimate including labels. The figure stored with the
entry is still the one released when it is handed on.

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-26 17:32:49 +08:00