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>
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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
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`).
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`).
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>
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>
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>
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>
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.
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.
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>
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>
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>
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`).
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>
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>
The set kernel checked every filter series of a kept series' match group
at every kept point, so a coarse `on()` / `on(...)` cost
O(kept series x points x group size). Prometheus decides a step from
the set of signatures present on the filter side.
Build one bitmap per filter match group, one bit per grid point, from
the filter side's rows, and look each kept sample up in O(1). Each
bitmap is charged to the walk phase when its group is first met and is
released with the group table before the sort phase. Results are
unchanged.
Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
The legacy planner refuses `and`, `or` and `unless` over several fields
(MultiFieldsNotSupported), and Prometheus has one value per sample, so
per-field presence has no reference semantics. The compiler now declines
a set operation unless each side has exactly one field, and the query
keeps the legacy plan and its error.
Errors raised by the shared two-operand runtime now name the set node
instead of calling every such node "the binary node".
Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
A two-scan FusedSeries over a table split into several regions has no
placement: DistPlannerAnalyzer rejects it with "only accept plan with at
most one child", so the query failed instead of falling back. This hit
the existing arithmetic pair and, since set operators now compile, set
operators too. Decline the native route for two selectors when the table
has partition columns.
Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
Bind `and`, `or` and `unless` (with `on`/`ignoring`) to a new
`NativeExpr::Set` node instead of refusing them. The node runs the
existing MATCH-SET kernel through the binary runtime, which retains both
operands until the input ends, so a set operation reads every series of
both sides in one execution.
The set kernel now reports its workspace like the binary kernel does:
kept rows, the filter side's match groups, the sort and the merge are
charged to the node's reservation.
The compiler refuses a fill value on a set operator, which Prometheus
rejects and the parser accepts, and sides with different field counts.
Same-table set operations over two scans take the existing two-scan
native route.
Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
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)
- **Stacked on #9942** (PR A, the unified SeriesFrame codec).
- GitHub cannot target a fork branch, so this PR's base reads `narthex`.
**Review #9942 first.**
- The commits unique to this PR are `b32573f9cbf`, `16b3e428951`,
`d5547c55bdf` and
`f2041d8d9de` (test-only lint cleanup). The chain is
`narthex (771347df5d) → #9942 (8bc53c4871) → this PR (CLEANUP_SHA)`.
- Merge order remains **#9942 → #9943**. Once #9942 lands, rebase this
PR onto the resulting
`narthex`; do not merge the codec changes twice.
- Spec: Narthex Unified SeriesFrame refactor, PR B of two.
## What's changed and what's your intention?
The fused native node could only answer one way: a row table with the
grid timestamps, one column
per series field and one per declared tag. That is what a query returns,
but it is not what a
consumer of the native path needs when it has to tell series and
positions apart — the labels of a
series, and which grid positions it actually holds, are both flattened
away by the row shape.
This adds a second output format, **fixed when the plan is built**
rather than chosen at run time:
```rust
pub enum FusedOutputFormat { PlainRows, SeriesFrame }
```
| | `PlainRows` (default) | `SeriesFrame` |
| --- | --- | --- |
| output schema | the existing row table (time / fields / tags) | the
codec's declared evaluated frame |
| encoding | `series_to_record_batch` | straight from `EvaluatedChunk`
via `SeriesFrameCursor` |
| reachable from a query | yes | no |
| `EXPLAIN` | byte-identical | appends ` (output: series_frame)` |
The format is part of the plan because it decides the node's output
schema, so it is part of the
node's identity (`PartialEq`/`Hash`/`PartialOrd`), its `EXPLAIN` text,
and its rebuild over a new
child. A format error is refused while the plan is built, not when the
first poll happens.
The `SeriesFrame` schema is derived from the
**`NativeTree.root().output` evaluated `SeriesSchema`**,
never from the original scan schema or the input tags: after a label
rewrite or an aggregate, the
result's labels and fields are what count.
### Independent chains remain independent
`is_last` closes the current chain, not the entire label set. Separate
chains may therefore carry
identical complete labels and each close independently. This PR adds no
plan-owned global label
identity management; combining contributions across partitions remains
the consumer's responsibility.
Tests check exact positions, payload bits and separate finality for both
consecutive same-label
chains in one encoder and chains in separate partitions.
### Parallelism is unchanged
For the supported single-source Frame path, output partition count
equals the input scan's
partition count. No `CoalescePartitionsExec`, `RepartitionExec` or
round-robin is introduced in
either format.
`series_frame_preserves_four_input_partitions` checks the in-memory
boundary. The standalone
integration test additionally captures a real single-region
`SeriesScanExec` at `parallelism=4`
and executes every partition in each output format.
### Implementation notes worth knowing
1. **Stream and batch schemas agree.** The codec-derived output schema
includes the explicit
`contiguous` ordering declaration. Published batches are checked against
that declared schema.
2. **The end of the execution must flush.** The frame builder buffers up
to a batch across chunks,
so missing the final flush loses the tail rows.
3. **`Poll::Pending` also flushes.** Otherwise a closing row stays in
the builder behind an input
that may never become ready — the same convention PR A's `PackStream`
already follows.
### Key code locations
- `src/promql/src/extension_plan.rs`: exports `FusedOutputFormat`.
- `src/promql/src/extension_plan/fused_series.rs`: default/explicit
format constructors, root-output
schema derivation, plan identity/rebuild/EXPLAIN, direct chunk encoding
and stream flushing.
- `src/promql/src/extension_plan/fused_series/tests.rs` and
`tests/frame_output.rs`: internal
logical/physical execution and lifecycle/semantic regressions.
- `tests-integration/tests/http.rs`: real standalone scan replay in both
output formats.
### Review fixes and acceptance
- **Batch-level backpressure:** the stream retains the existing
`SeriesFrameCursor` and advances
only until one batch is ready, then returns control to the consumer. It
no longer encodes an
entire evaluated chunk into a `ready` queue before returning the first
batch. Pending native
output is drained before more upstream input; a pre-EOF stall does not
close the executor.
PlainRows and partial-state conversion keep their original timing.
- **Real scan acceptance:** the standalone File integration fixture has
one physical region and
`parallelism=4`. The test captures its actual `SeriesScanExec`, replays
the internal fused plan
in both formats, executes all four nonempty partitions, and checks the
exact 24 expected rows,
eight Frame chain closings and per-partition native output metrics.
Frame is not exposed as a
user query's final result to perform this test.
- Replaced the vacuous sparse fixture with exact nonempty positions
`0,2,5`, two fields with
partial NULLs and preserved ordinary-NaN bits, and a PlainRows
comparison. Added fixed-value
`abs(rate(...))` and label-rewrite comparisons, including a new label
outside the input tag
projection and two independent same-label chains in one encoder.
Cross-partition chains also
check exact positions and value bits.
- Added retained-cursor/one-batch checks, buffered upstream-error
termination, and early-drop
ownership checks. Reintroducing eager encoding compiles but makes the
backpressure regression
fail at its queue assertion; restoring the reviewed source makes it
pass.
- Removed the redundant physical scalar/Frame guard; the shared
root-output validator remains.
- Independent correctness and anti-over-design review: **APPROVE** for
`8bc53c48714..d5547c55bdf`. Review approval is separate from new-head
CI.
### Limitations
- `SeriesFrame` is **not reachable from a user query** yet (production
defaults to `PlainRows`).
Per spec §8.7 the acceptance executes the internal physical plan and
decodes its output, instead
of exposing the frame as a query's final result format.
- No sqlness result changes: the production planner still constructs
`PlainRows`. The existing
standalone integration case is extended to execute and decode the
internal Frame plan.
- Rebased onto `narthex` at `771347df5d9`, including #9936, #9882 and
#9886. The native zero-source
scalar, `timestamp()`/instant-`@`, multi-input and partial-state paths
are preserved. A Frame over
a scalar root is rejected because this format requires evaluated
samples.
- The multi-source case is refused for frames rather than implemented:
this node still answers only
partition 0 when it has more than one raw source, so a frame over
several sources would have to
change that first.
- Consumer contract: a sparse chunk decodes into **several** `Series`
(one frame row per grid run),
so a consumer must concatenate by position and cannot assume "one chunk
→ one decoded result".
Nothing consumes frames in this PR, but Complete Aggregate's SeriesFrame
input must respect it.
- Not verified: Windows; the results below are Linux.
### Does this break API or data compatibility?
`PlainRows` remains the default and the only format a user query
reaches. This increment leaves
its conversion and plan display paths in place. The added tests compare
exact Frame semantics
without changing the user-facing result format. No persisted format is
touched.
### Tests
Coverage includes codec-derived schema, logical rebuild and EXPLAIN,
dense and genuinely sparse
output, multi-field NULL/NaN values, composed math and label rewrites,
four in-memory and four real
scan partitions, independent same-label chains, partial-state schema,
constructor refusals,
zero-sample closing, Pending/EOF flush, buffered error, cancellation and
incremental encoding.
<!-- CURRENT_VERIFICATION_START -->
Main verification on `d5547c55bdf` (Linux; default features):
- `make fmt-check`: passed.
- `cargo nextest run -p datatypes -p common-query -p table -p promql -p
query --retries 0`:
**2698 passed, 2 skipped**.
- `cargo nextest run -p tests-integration --test main -E
'test(test_native_promql_route_plans_the_query) & test(file)' --retries
0 --success-output immediate`:
**1 passed, 771 skipped**. Both formats produced per-partition decoded
row counts
**[15, 3, 3, 3]**; exact 24-row results and eight Frame chain closings
were asserted.
- `cargo check --workspace --all-targets`: passed.
- The final strict Clippy pass found two test-only hygiene issues in the
new tests: an
unnecessary `f64` cast and two `println!` calls that the workspace's
`print_stdout` lint
rejects. The follow-up types the existing float literal explicitly,
removes the cast, and drops
the diagnostics, whose values were already asserted by the surrounding
exact checks. It changes
no production code or assertions.
- Final test-only cleanup `f2041d8d9de` on top of the reviewed
`d5547c55bdf`:
`make fmt-check` passed; `cargo clippy -p promql -p query -p
tests-integration --all-targets -- -D warnings`
passed; the affected
`abs_rate_frame_matches_plain_rows_with_exact_values_and_labels`
(1 passed) and real File scan replay (1 passed, 771 skipped) passed
again. The full five-crate
suite above was run at `d5547c55bdf`; this last commit touches tests
only and was not followed by
another full-suite run.
Other focused evidence: the eager-encoding mutation compiled and then
failed the queue-boundary
assertion; restored production source passed. License/TOML/diff checks
passed. These are local
Linux results, not a claim of Windows or new-head CI success; full
all-feature workspace Clippy
and the full workspace test suite were not run.
<!-- CURRENT_VERIFICATION_END -->
## 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`).
The workspace denies the default Clippy warnings, and the new tests tripped
three of them without changing what any of them proves:
- an expected value was built through `scale as f64` even though the tuple
already carries an `f64`, so the conversion is spelled in the literal and the
cast is gone;
- the standalone replay printed the executed plan and the per-partition counts
even though the surrounding assertions already require four nonempty
partitions, 24 rows in total and eight closing frames, so the diagnostics
are gone rather than exempted from the workspace lint.
No production code, assertion or fixture changes.
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
The frame output was covered for its happy path and for the plan-level decisions, but three things
the spec asks about had no test of their own at this boundary, and one test did not prove what it
claimed.
The pending test is the one that mattered. It awaited the stream, and `StagedStream` wakes itself
right after reporting pending, so the await ran straight through the boundary: every later assertion
held whether or not the buffered rows were handed over at the pending poll. It now polls that single
poll by hand and requires it to answer the rows it already encoded. Deleting the `flush_frames` call
from the pending branch makes it fail, which it did not before.
The three boundaries now have their own tests: a chain that closes with no position survives as one
row where the row table answers nothing at all; a sparse series carries exactly the positions the
row table answers and no filled hole; and two chains that close on one label set are both kept, with
values that differ so a lost chain would show.
Also drops the `as_str` helper, which only asked whether to show a format and then spelled the same
string twice, and corrects two comments: `convert` produces three outputs rather than two, and a
built plan is not a promise that execution cannot fail.
Signed-off-by: discord9 <discord9@users.noreply.github.com>
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>