Commit Graph
1152 Commits
Author SHA1 Message Date
discord9 58a67a47c3 feat(query): execute native raw promql over real datanodes (#10150)
* feat(promql): serialize raw series source bindings through Substrait

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

* feat(query): transport interleaved raw series fragments

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

* feat(promql): reconstruct interleaved raw series at repartition

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

* feat(query): plan distributed native raw series

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

* chore: satisfy clippy for the raw series transport

The workspace clippy run rejects a redundant rebinding, a constant-size
`chunks_exact`, and an implicit `String` clone in the tests these
commits carry. No behavior changes.

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

* feat(query): execute native raw promql over real datanodes

Plan a whole native statement as one fused node over raw series
sources when the frontend reaches regions over the wire, including
single-selector statements, while keeping the in-process split and
segment shapes unchanged. Validate the whole tree with the runtime
constructor before committing to that plan, so unsupported shapes
still fall back to the legacy route.

Bound the region reads of that topology with a query-wide permit
budget of target_partitions, held until each region body is drained;
other plan shapes keep unbounded reads rather than risk a body budget
they could deadlock.

Acceptance on a real two-datanode cluster with TCP frontend clients:
time-partitioned series are reconciled before the fused node and match
the unpartitioned control, with the DoGet peak at the target bound;
series that no region shares keep contiguous per-series order and
reach the fused node without a reconciling router.

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

* chore(deps): pin greptime-proto to the raw series transport PR

The raw series Substrait message lives in greptime-proto PR #349, which
is not merged, so pin its head commit instead of carrying a local path
patch. Move the rev to the merge commit once #349 lands.

    https://github.com/GreptimeTeam/greptime-proto/pull/349

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

* fix(promql): reconcile a discovered union of raw series

A nameless selector reads its matches as one `Union`, and `UnionExec`
reports the frame metadata of its last child. A time-partitioned metric
sorted before a host-partitioned one therefore looked contiguous, and
the fused node skipped the reconciliation of the fragmented member's
series, silently folding region fragments as separate series.

Lower the declaration on the fused edge to the weakest ordering any
member declares, before the fused node reads it. The union itself is
unchanged; the fused node's existing interleaved-input path inserts the
router. A projection carries the lowered declaration because the
pinned physical planner ignores a union's own schema.

Cover it with a planner regression for both member orders and an
all-complete control, and with a real two-datanode acceptance case that
mixes a time-partitioned and a host-partitioned metric and checks the
extrapolated increase per series.

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

* test(promql): cover aggregates over discovered metrics

A nameless selector's metric comes from its own frames, so a folding
aggregate is a multi-input tree even with one selector: apply the same
preflight and order-dependent refusals on the remote route. Cover
`sum(increase(...))` and grouping by the metric name over the mixed
discovery fixture.

A grouping that names only `__name__` stays declined, since the output
schema cannot name that group: here the metrics also differ by `site`,
so grouping by the name and `site` exercises the two groups.

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

* fix(query): keep remote multi-source refusals the split made

Skipping the aggregate split and `segment_tree` over the wire must not
drop their admission refusals. Two sources that fold groups by metric
name would report groups that differ only by name under one label set,
and an order-dependent operation over a discovered selector's union
would answer in arrival order.

Scope both refusals to the actual multi-source inputs rather than to
the logical-table-id heuristic: a discovered selector reads several
metrics through one selector, and a Mito table beside a metric one
carries no logical id.

Cover the Mito-plus-metric grouping refusal on a real two-datanode
cluster, and cover a discovered `sum by (__name__)` over a raw selector
(the name survives it) and a folding `sum(increase(...))`.

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

* docs: drop the local proto patch prerequisite

The dependency is pinned to greptime-proto PR #349's commit, so the wire
fixture no longer needs a local path patch.

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

* chore: finish the narthex rebase resolution

Keep the upstream aggregate split (`FusedSeries::split_aggregate`) and
pass the engine state through the native planner's callers, while the
remote route still declines the split it cannot carry.

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

* test: pass the engine state to the native operand test

The native operand planner now takes the engine state, which decides
whether a statement is planned for remote regions. Keep the histogram
operand contract test on that signature.

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

* fix(query): keep the split and segment plans over the wire

Remote planning disabled the aggregate split and segmentation because
the split's producer read a bare scan. The producer reads whatever the
statement prepared, and over the wire that is the raw series source:
the partial half runs per partition on the frontend, the final half
folds the exported states, and no aggregate state crosses the wire.

Keep the split and let segmentation build evaluated segments from the
raw sources instead of bare scans, so per-series window work keeps the
scan width. Admit the resulting fused chain to the query-wide read
budget, since each fused node polls its input while it is drained.

Remove the recovery-projection wrapper: the analyzer no longer inserts
that projection, and the physical planner consumes the frame boundary
as it is. Serialization is now only the node's encoding: the codec no
longer decides placement, and the metric-name binding is checked
against the declared frames rather than the node types below.

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

* refactor(query): drop the in-process encodability exception

A split aggregate encodes now, so the analyzer no longer needs a
special case that skipped the encoding gate for it. The gate itself
stays: every plan a region receives still has to encode.

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

* test: cover a repeated selector over one partitioned table

Two occurrences of one time-partitioned metric keep their own raw
source, time bounds and identity over the wire, so a ratio of two
windows and a binary expression over two identical ones stay native and
reconcile each occurrence's fragments before the fused node reads them.
Compare both against the unpartitioned control.

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

* fix(table): refuse a scan that plans interleaved ordering

A scan's planned ordering is a guarantee its own partitions keep.
Interleaved is the transport annotation for fragments several producers
contribute, so a scanner that planned it has no raw-series read whose
frame would honour that declaration. Reject it in the scan conversion,
while the frame codec keeps accepting interleaved raw frames from the
router that reconciles them.

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

* test(promql): assert the metric name beside the value

`sum by (__name__)` over a discovered selector was asserted as a bare sorted
list of values, so a plan that grouped by the wrong metric name - or dropped
the name and folded everything into one series - could produce those same
numbers. Read `__name__` and `val` by column name (the result puts `__name__`
last, and a fold that drops the name drops the column rather than emptying it)
and compare the `(name, value)` pairs against the fixture's own metric names.

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

* fix(query): keep the hybrid route for a partitioned pair

A multi-selector tree over a partitioned table was admitted as one fused node
for any pair of tables, not only for scans of one table. That preempted the
frontend hybrid route, which copies the matcher's labels into each operand's
scan and checks uniqueness above the join. `native_hybrid_binary_05_06_08`
depends on exactly that plan: its partitioned RHS holds a duplicated
`job="other"` group, so the whole-native binary reports the duplicate instead
of the value the hybrid route returns. Refuse the whole-fused plan for that
shape in process again; the remote raw-source admission is unchanged, since a
remote multi-selector tree over a partitioned table is the point of this
branch and the distributed acceptance tests read it.

The hybrid route then has to behave the same way it does without native
operands: `matching_filters::propagate` now runs for them too, and a rewritten
operand is re-planned through `try_plan_native_operand` with its original
`selector_matcher` preserved, falling back to the ordinary plan when the
native route declines. The unpartitioned case, whose duplicate group the
whole-native binary is expected to report, is unchanged.

The two new re-plan call sites put the native route's large future inside the
recursive planner frame at every nesting level (~178 KB became ~209 KB), which
overflowed the 2 MiB test-thread stack in `test_parse_or_operator` even where
the new branch never ran. Box the future in a non-async helper and move the
re-plan into `replan_rewritten_operand`, which brings the frame back to the
old size without changing the behaviour.

The partitioned-pair unit test asserted the removed shape. It now asks for the
remote engine state, where a cross-table pair really is one fused node over
two raw sources, and asserts that the same pair in process keeps the hybrid
route.

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

* chore(deps): pin greptime-proto to the merged raw series transport

GreptimeTeam/greptime-proto#349 has been merged into `narthex-proto` as
`f51cb5312bf7`. Move the rev from that PR's head commit to its merge commit,
in `Cargo.toml` and `Cargo.lock` together, so the pin no longer points at an
unmerged ref.

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

---------

Signed-off-by: discord9 <discord9@163.com>
2026-11-06 10:31:52 +08:00
discord9 a995ad0951 test(compat): exercise the bloom-token case only below v1.3.0 (#10170)
`fulltext_bloom_tokens` reads a fulltext bloom SST in the first format and
asserts that rows written by the older binary stay visible next to a current
one. Its `from_range` started at v1.0.0, but the released v1.3.0 binary
already writes `bloom_version: 2`, so the setup produces two current-format
SSTs and the expectation ("one old, one new") can never hold:

  expected: | false | 1 |   actual: | true | 2 |
  expected: | true  | 1 |

Narrow the range to the releases that still write the first format. The
constraint language has `>=`, `<=`, `==` and `=` only, and several entries
are alternatives rather than a conjunction, so `<=v1.2.99` is the bound
that excludes v1.3.0 while keeping v1.2.x and older - including the
nightly window.

Verified with the compat runner: `--from-version v1.2.1 --dry-run` selects
the case, `--from-version v1.3.0 --dry-run` skips it with
`from_range ["<=v1.2.99"] does not match version 'v1.3.0'`, and
`update-compat-versions.py --check --published-only --check-anchors`
passes.

Signed-off-by: discord9 <discord9@163.com>
2026-11-05 12:52:23 +08:00
discord9 b30557960b feat(promql): preserve scan parallelism through native frame boundaries (#10055)
* fix(promql): let a closed chain keep its charge until the packer is done

Four places released the frame reservation: the early-publish path handed it to
the packer, but the queue drain and the end-of-input path freed it in place. Those
two run while the packer still holds rows it has not turned into batches, so a
series spanning several batches went unbilled once its last piece was handed over.

Every emit path now hands the charge over, and the packed stream frees what it
holds: what is left when it is polled and pending is the one identity it is
turning into batches, and nothing survives its end or its drop.

The identity guard is also widened: within one source every series shares its
metric name, so a node that drops `__name__`/`__type__`/`__unit__` -- which is
what a range function, a math map and the timestamp shape do -- still maps
distinct series to distinct labels. Only rewriting an ordinary label can make two
equal, and that is what the guard refuses. Ordinary `rate` and arithmetic frame
output therefore publish as their chains close instead of buffering a partition.

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

* fix(promql): keep a cross-source tree's partitions, and gather a raw one only when it must

Three defects in how this node decided a tree's execution layout.

A match whose two operands derive from the same source still needs its match keys
co-located, but only the two-source case was recognized. `x - 2*x` with `on(host)`
therefore fell through to single-partition execution and lost the parallelism the
scan gave it.

A raw input never received a matching router: the normalization branch returned it
unchanged unless the whole tree was global, while the builder requires a direct
matching router for any multi-partition matching-local input. A two-source raw
query was rejected at planning.

Nothing reconciled the partition counts of different sources either, though the
builder requires one common positive count. A single count is now computed for the
matching-local case and every router under it uses it, gathering when that count is
one.

The window restriction also ran before normalization decided the layout, so a tree
about to be gathered could be refused for a limit that only applies to partitioned
windows.

Two tests changed with the behaviour rather than around it: hash routing does not
promise which partition holds a given series, so that test compares results
instead of placement while keeping its partition count, per-partition execution
and operand-order assertions; and a test that pinned the old refusal of a
multi-source frame now pins its acceptance.

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

* refactor(promql): keep a raw source's scan partitions instead of gathering them

`RawSeriesSourceExec` declared one partition and read every child partition
inside `execute(0)`, so a cross-table node lost the scan's parallelism before
its inputs reached it. It now declares the child's own count, executes exactly
the partition it is asked for, and re-labels that partition's frames.

That leaves `SeriesGather` with no consumer: it existed to interleave several
partitions into one stream. The module is gone rather than kept as a hidden
N-to-one path a later plan could quietly fall back on.

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

* fix(promql): publish a raw source's frames under the ordering it declared

`pack_raw_series` derives the frame schema from the stream's own ordering, and
`RawSeriesSourceExec` fixes that schema at plan time from a constant. Passing
the scan's stream through unchanged broke that agreement: a columnar scan
promises key-then-time, so the region published key-then-time frames while the
frontend still expected the schema it had built, and `MergeScan` refused them.

The forwarding adapter restates the ordering this node declares. It is the
weaker promise, so a scan that keeps a finer one still satisfies it, and every
other stream property is forwarded unchanged.

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

* fix(promql): route a raw frame that names its labels by its key

A region cannot hand out complete labels: it has no metric-name binding, so
`prepare_raw_scan` refuses an explicit binding outright. What it hands out is
the stored key plus the description a decoder needs, which is the normal input
to this node rather than a case to refuse — every single-table selector is
planned that way. The node's constructor refused exactly those frames, so any
plan that inserted a router under a single-table selector failed before it ran:
the matching policies this branch adds, and the one-partition normalization of
an evaluated tree.

The node publishes labels, because that is what it groups and closes identities
by: `finish_raw_group` reads `chunk.metric` and the merge needs it too. So the
input is decoded once, at the unpack boundary this node already has, and an
already-labelled stream passes through it untouched. The frame it declares is
rebuilt from the input's own declaration instead of being compared against it,
which keeps the check that a frame this node cannot re-declare is refused while
letting input and output differ in how they name the same series. `frame_schema_with`
becomes public for that rebuild, since it is the one place that decides what a
declaration is.

`FusedSeries` now describes the router's output rather than the scan's when it
wraps a raw input, because the binding it hands `build` has to match the frame
the router actually publishes.

Tested through the node directly: a key-labelled input routes at all; matching
puts both pieces of one label set in one owner and closes it once; and
`RawPrimaryKey` still hashes the key rather than the decoded labels.

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

* test(promql): measure a routed pair by the width it routes to

Four assertions still described the layout this branch replaced: a node with two
inputs used to read every partition of both into one and answer one partition,
and that gather is gone. The pair is now matched in the partitions it was
scanned in, so the fused node answers as many partitions as its inputs route to.

Each of these fixtures scans with four partitions, and on the base of this branch
all five tests passed, so this is a behaviour change this branch made and did not
re-verify rather than a defect: the value assertions behind these shape
assertions pass unchanged, in both the native and the legacy comparison.

The width is read from the routed children instead of being written as four, and
the scan is found by walking down through the nodes that only pass its frames
along, since a routed input is no longer a bare `SeriesScanExec`.

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

* fix(query): normalize a multi-partition series-frame root

A query engine hands its plan to one stream, and for a plan with several
partitions that means `CoalescePartitionsExec` - ordinary batch coalescing.
A series frame cannot survive it: one stored series may be split across
partitions, and a chunk's `is_last` closes that producer rather than the
series, so interleaving two partitions' batches produces a stream whose
decoder refuses it (`raw series key changed before final chunk`). Evaluated
frames have the same shape of problem: their validator keeps one open chain,
and a batch from another partition interrupts it.

The engine's root is also a region's root, which is how a cross-table native
statement reaches this: its per-region sub-plan is rooted at a partitioned
`RawSeriesSourceExec`. So the rule belongs at the physical optimizer's end,
before the final sanity check, and it normalizes the root and nothing else -
`FusedSeries` already inserts its own owner wherever a consumer needs one.

A single-partition root is left alone: one producer is the whole answer. A
key-labelled raw root is refused rather than coalesced, because the router
checks that one stored key decodes to one label set and it needs the labels to
do that. No production plan reaches that rejection today - the only producer of
a key-labelled frame is the region's series scan, and every plan that executes
it puts `RawSeriesSourceExec` or `FusedSeries` above it, both of which publish
explicit labels - and the rejection is what keeps it that way.

`schema_check` stays enabled: for the roots this rule accepts, the router
re-declares its input's frame with the same content, label mode, decode
description and ordering, so the schema it publishes is the one it was given.

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

* feat(promql): admit an evaluated frame as a fused input

The extension planner hands a child straight to the fused executor when that
child already publishes a series frame, and until now it only recognised a raw
one. A segment's output is an evaluated frame, so a statement planned as two
segments could not be bound above the first one.

The check it replaces asked for a raw frame; the property that matters is that
the frame names its own series, which is what `labels() == Explicit` says -
evaluated frames always declare it and carry no decode description. Everything
else stays with `FusedSeriesExec::build`, which already refuses a mixed-content
node, an evaluated input that is not contiguous, and a frame whose declaration
does not match the binding it was given.

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

* feat(query): plan a global topk under an aggregate as two segments

`sum(topk(1, m))` needs the whole vector for its ranking, which is a property of
the tree rather than of the statement, and the routes that existed could only
answer a whole-vector tree in one partition: the statement fell back to the
existing planner. Splitting it lets the per-series prefix keep the scan's
partitions while the ranking sees every candidate.

The cut goes below the `topk`, not below the `sum`. A segment holding a `topk`
reads across series, so it would gather its own input before evaluating the
prefix - correct numbers, no parallelism, which is the thing the split is for.
Below the `topk`, the prefix runs on the scan's partitions and the ranking and
the fold above it run once, on the child's evaluated frame.

Both segments are built from one compiled tree: the child is the prefix subtree,
the parent is the original tree with that subtree replaced by an input bound to
the child's frame. Nothing is re-parsed, so the grid, the matching rules and the
bound scans are the ones the compiler produced.

The parent needs its output columns without asking the existing planner to name
a computed column after its expression, so `fused_output_tags` now derives the
grouping labels of an `Aggregate` and passes a selecting `Vector`'s input labels
through. It derives output *columns*, not a complete runtime label set: the
metric name is bound separately from the tag columns, and a grouping by
`__name__` keeps that name in the labels while contributing no column.
`fused_root_schema` is that derivation, shared with the ordinary route.

The segmenter is its own method, boxed at the call site. Inlined, its future
made every frame of the planner's recursion large enough to overflow the stack
on a deeply nested statement - `test_parse_or_operator` aborted before this.

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

* test(promql): pin the two-segment topk plan and what it keeps

The statement's two segments do not land in one physical tree: the prefix has
one scan for a child, so the categorizer classifies it commutative and pushes it
into the region, while the aggregate above it stays at the frontend. The
observer therefore records every plan a query engine wraps, not only the first,
so the test can look at the region's own root.

What the assertions pin, and why each one is worth its brittleness:

- the answer is the global maximum, not a sum of per-partition winners;
- the frontend segment holds the complete sum and the global topk over an
  evaluated input, with no instant evaluation left in it;
- the region's segment holds the prefix and its output is an evaluated,
  explicitly labelled, contiguous frame;
- that segment keeps more than one output partition, which is the whole point of
  cutting below the topk;
- the region's root is the series-aware router, one output, and it reads that
  segment.

Column names are not compared: this route names a computed column after its
compiled root while the existing planner names it after the expression, which is
older than this split and not what this test is about.

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

* feat(promql): share the input-locality classification

A tree's distribution requirement is one property, and two layers now ask it.
The physical planner already decided with `input_locality` what a tree needs
from its inputs; the logical planner has to ask the same question when it splits
a statement, or the two can disagree about what a subtree requires.

`InputLocality` and `input_locality` are therefore public within the workspace.
The classification describes the external inputs a tree needs - not its output
distribution, and not which neighbouring operators may share a node - so a
caller that reads it as either of those will cut where it should not.

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

* feat(query): segment a statement where a gather would swallow its prefixes

The previous commit planned `sum(topk(k, prefix))` by matching that shape. A
planner that grows one matcher per query shape grows one per benchmark entry,
and the shape is not what decides where a statement divides: the tree already
says what each subtree needs from its inputs, and `input_locality` answers it.

So a segment is now a maximal subtree with one distribution requirement. Inside
a tree that must gather, any child that (a) can cross a boundary - it produces
evaluated samples rather than raw samples, aggregate state or a scalar - and
(b) does not itself need a gather, becomes a segment of its own and keeps the
partitions it was scanned with. Nothing else is cut, and a tree with nothing to
cut keeps the plan it had.

Every segment rebinds its own inputs: the scans it reads directly first, in the
order it first names them, then the segments cut out of it, because
`FusedSeries` requires dense source indices over its children. `fused_output_tags`
derives the parent's columns from those inputs rather than from the statement's
first selector.

Three gates keep this from answering what the routes before it would not. Each
one is a failure this change produced before it was added:

- the existing planner must still be able to plan the statement. Deriving tags
  locally had cut the route off from that refusal, so `sum(clamp(m, 2, 1))` -
  an empty range, which the existing planner rejects - was answered instead of
  failing;
- every segment's tree must construct a `NativeExecutionState`, because nothing
  below can fall back once physical planning starts, and a grouping by a value
  field is a shape the runtime refuses;
- the tree must contain a prefix that a boundary could carry, so a whole-vector
  tree with no per-series work inside it is not split for nothing.

The scope is one selector. A statement over two tables binds each scan as a
`RawSeriesSource`, and a segment whose child is that wrapper cannot be placed in
a region, so its prefix would not gain the partitions the cut is for; those
statements keep the plan they had, which the acceptance test pins.

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

* test(promql): pin the segment boundaries and their scope

One statement per shape the cut can meet, asserting what the rule says rather
than what one query happens to produce: a per-series tree stays one node, a
matching tree routes instead of gathering, a global fold over per-series work
cuts that work out, a constant stays embedded because a boundary cannot carry
one, and a statement over two tables keeps the plan it had because a segment
over `RawSeriesSource` cannot be placed in a region.

Answers are compared as sets of rows with the value column renamed. Three
things differ between the routes and none of them is the answer: this one names
a value column after its compiled root while the existing planner names it after
the expression, the two emit columns in a different order, and this one is
partitioned so its rows arrive by partition. Comparing them as what they are
leaves the values as the only thing an assertion can fail on.

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

* fix(query): keep a selected segment whole

A segment is chosen because its subtree needs one distribution; cutting inside
it again contradicts the choice. The inner builder was still applying the cut
predicate, and that predicate is "exportable and not `SinglePartition`" - which
every node inside a per-series subtree satisfies, since a per-series tree has no
`SinglePartition` node in it to begin with.

`sum(topk(1, abs(m)))` showed it: the prefix was cut out of the `topk` as one
segment, then `abs` was cut out of that segment, leaving a middle segment whose
child is a node rather than a scan. That middle segment cannot be placed in a
region, so it answered one partition while the scan had several - the prefix
lost exactly the parallelism the cut is for.

The inner builder now collects the segment's `Input` leaves and nothing else.

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

* refactor(query): bind a segment to its own scan

A cut segment carries one source's identity: its scan, its metric name, its
time index, and its labels. Building it from the statement's first selector
only happens to be the same thing while a statement has one selector.

`build_inner_segment` takes the two lists the planner already has - each
occurrence's filtered scan, and each occurrence's metric, time index and tags -
and binds the segment's single source from them. The parent keeps the wrapped
children and the statement-level binding it has now, because a source that
reaches the parent directly still has to cross a region as a raw frame.

A segment that collects more than one source is declined. `MatchingLocal`
satisfies the cut predicate while reading two sources, and one segment cannot
carry both sources' identity.

No behaviour changes: the entry is still one selector, so no scan is wrapped
and the statement-level values are already that selector's.

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

* feat(query): segment a statement that reads more than one table

A whole-vector statement over two sources is now cut into one per-series
prefix segment per source, with the fold above them in the frontend. Each
segment binds its own source's scan, metric, time index and labels, so it can
be placed in the region that holds that source, and each prefix keeps the scan
partitions it was read with.

Three things had to change for the existing segmenter to reach that shape:

The cut sat inside `!per_partition && !multi_input`, so a statement over two
metric tables - which is `multi_input` - never reached it. The cut now sits
under `!per_partition` alone. The aggregate split keeps its own conditions,
including `!multi_input`, so a multi-input tree is still not split that way.

The single-selector gate was doing two jobs. A statement naming several
selectors declines unless those selectors read more than one table: the count
alone says nothing about whether the tree has more than one input to place, and
the shapes it would otherwise admit - one metric named twice under a fold, such
as `sum(m) / m` or `m + sum(m)` - are a plan the existing path already answers.
`cross_table` rather than `multi_input` decides this, because two Mito tables
carry no metric logical id and are still two sources.

A segment that collects more than one source is declined. A matching subtree
over two sources is a cut candidate, and one segment cannot carry both sources'
identity, so `sum(topk(1, m / n))` keeps the plan it had.

The segmented parent reports the value column the compiled tree names, not the
one the existing planner names; `sum(rate(a[5m])) / sum(b)` reports `v` where
the existing plan reports an expression-shaped name. The values are identical
and both names are pinned in the test, so a change to either rule shows up
there. Restoring the existing name needs a rename above the parent - the node's
own schema must keep the compiled names its output columns are classified by -
and is left to a follow-up.

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

* test(query): pin that two sources keep their own scan bounds

A segmented statement binds each prefix to its own source's scan, and A1 made
that binding: `bare_scans` is derived from the prepared scans after each
selector's time filter has been pushed into its own `TableScan`. Nothing tested
that the two segments actually read `their` bounds rather than one window applied
twice - the existing two-source case has no offset, so both sides read the same
range and a crossed binding would answer the same numbers.

`sum(off_a) / sum(off_b offset 60s)` differs from `sum(off_a) / sum(off_b)` only
in the right side's window, so the two queries must disagree: 20/1 and 30/2
against 20/2 and 30/3. The test asserts they do, which is what fails if both
segments were given the left scan's bounds.

The shape is read from `MergeScanExec`'s `Debug`, because its `DisplayAs` prints
only the peers: the debug form carries the table each segment reads and the
embedded `FusedSeries` segment. An in-process region hands the plan observer no
separately wrapped region tree, so the per-partition evidence for segments is
measured in `native_aggregate_test.rs::check_two_source_segregation` instead.

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

* fix(query): decline a multi-source tree that groups by the metric name

The gate that lets a multi-source statement reach the segmenter is
`selectors.len() != 1 && !cross_table`, but the metric-name grouping guard above
it is keyed on `multi_input`, which is `selectors.len() > 1 && logical_ids_differ`.
Two ordinary Mito tables differ in table name and carry no metric logical id, so
they are `cross_table && !multi_input`: the guard did not apply and the statement
reached the segmenter.

`fused_output_tags` keeps a grouping by `__name__` out of the declared columns on
purpose - the name travels in the chunk's labels, not as a tag column - so
`sum by (__name__)` over two sources derives no tag column while the runtime
holds two groups that differ only by metric name. Both are then reported under
one label set, as two rows a reader cannot tell apart. That is the failure the
metric-logical-table test already documents; it could not catch this path because
its tables make `multi_input` true.

The check is scoped to a tree that reads more than one source. One source folds
one metric, so its groups carry the same name and naming that name or dropping it
cannot merge two of them.

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

* test(query): cut a range function on each side of a division

The two-source cases so far cut an `Instant` prefix: one sample per grid point.
A window function's prefix computes its window inside the region over that
source's own samples instead, so the segment carries a `Window` rather than an
`Instant` and nothing tested that such a prefix is still a cut candidate.

`sum(increase(a[2m])) / sum(increase(b[2m]))` over two tables: at the first step
the increases are 60 and 30, at the second 120 and 90, so the ratio is 2 and then
4/3. Two different step values are what let a shared window bound change an
answer, which is asserted before the result is compared.

The shape is read from `MergeScanExec`'s `Debug` for the same reason as the offset
case: an in-process region hands the plan observer no separately wrapped region
tree, so the per-partition evidence lives in `native_aggregate_test.rs`.

This is the shape of the throttling query in the benchmark corpus: a window under
a fold, on each side of a division.

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

* test(query): plan the benchmark's throttling and namespace-ratio shapes

Every two-source case so far divides two ungrouped scalars. The corpus queries
that motivate this work group by a label on each side, so each segment reports
several groups and the parent matches the two sides by that label. Nothing
tested that path.

`container_cpu_cfs_throttled_periods_total` over `container_cpu_cfs_periods_total`
is the corpus query verbatim: two metric logical tables on one physical table,
`sum(increase(...)) by (container)` on each side. It plans as one frontend
`FusedSeries` over two `MergeScan` children, each holding a segment bound to its
own metric, and reports the same labelled rows as the existing plan.

The namespace-CPU-usage-over-requests shape differs from the corpus text only in
the metric names, because the corpus names carry recording-rule colons that a
table name cannot: it exercises the nested `sum(max by (...) (...)) by (...)` form
and is asserted as that shape rather than as the query verbatim.

Both tests assert the native route answered, so a decline fails here instead of
passing on the legacy rows. The value column still differs from the existing
plan's - the same known gap recorded for the ungrouped case - and both names are
pinned.

The fixture helper records the three setup traps the hard way: `INSERT` needs
explicit column lists because the physical table's column order differs from the
logical table's, SQL keywords used as tags need quoting, and no sample may sit at
timestamp zero, which both the scan's lower bound and PromQL staleness drop.

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

* test(query): record which native route every corpus query takes

The corpus is 29 queries and nothing said which of them the native route
answers, or by which of its plans. One parameterised test now runs all of them
over generated fixtures and records, per query, the route and the numbers.

Four routes, and the frontend tree alone cannot tell them apart:

- `segmented` - the frontend runs a `FusedSeries` over `MergeScan` children, one
  per source; the prefix work happens in the regions.
- `aggregate-split` - the frontend runs `AggregateFinalizeExec` over a region
  `FusedSeries` that reports `partial state:`, so what crosses the boundary is
  aggregate state.
- `region-local` - the frontend holds no native node at all: a per-series tree is
  commutative, so the whole `FusedSeries` is pushed into the region. A classifier
  that only looks at the frontend calls these legacy, which is what happened
  before this test existed.
- `legacy` - no native node on either side.

All 29 take a native route: sixteen `aggregate-split`, nine `segmented`, two
`region-local`, none legacy. The two `region-local` ones are the corpus queries
that fold nothing - a bare selector and a `rate` - which is also why pushing the
whole tree into the region costs nothing.

The test asserts only that the existing plan answers each query with rows, and
that a native route's labelled rows agree to within `1e-10` relative. It records
the route rather than asserting it, because which route a shape takes is a
property of the planner that this test exists to report, not to legislate.

Ten queries differ from the existing plan in the last bits; the largest relative
difference is 2.9e-16, which is summation order, not arithmetic. The table prints
that figure per query so a future equality failure can be told apart from
rounding without re-measuring.

Every fixture here is generated: explicit `INSERT` column lists, quoted SQL
keywords, and no sample at timestamp zero. They establish the plans and the
values; they say nothing about speed, memory, or how many partitions actually
produced rows.

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

* test(query): record how many partitions each corpus query's nodes used

The route matrix said which plan answers each query. It did not say whether that
plan produced anything, so a node that ran and emitted nothing looked the same as
one that worked.

`output_values_per_partition` reads `native.root.output_values` off a
`FusedSeriesExec` and returns the counts per partition, and the matrix prints them
as `partitions used`. `assert_four_native_partitions` keeps its own assertions and
now builds on the same helper.

What the column does not establish, and says so where it is printed: these
fixtures hold a handful of series each, so a scan puts them all in one partition -
every vector here is `[n,0,0,0]`. That is a property of the fixture, not of the
plan. The one place a fixture is built to fill the scan's partitions is
`native_aggregate_test.rs::check_two_source_segregation`, which asserts all four
produce values for both segments of a two-source statement. This matrix records a
single assertion instead: at least one partition produced values, so a node that
ran and emitted nothing fails.

Four cases report `no metric` - L05, L06, L08 and R03, the same-table pointwise
divisions. Their region side has no `FusedSeriesExec` at all, because both scans
are matched inside one region: that is the route, not a missing measurement, and
the table says `no metric` rather than implying zero.

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

* test(query): record the new rule in the explain stages

`TQL EXPLAIN` lists the plan after every physical optimizer rule, so registering
`SeriesFrameOutput` adds one line to that listing whether or not the rule changes
the plan. The three `TQL EXPLAIN VERBOSE` statements in this case use ordinary
tables, whose schemas carry no series frame declaration, so the rule returns the
plan untouched - the new line says `SAME TEXT AS ABOVE`, which is what the
existing lines say for the same reason.

Regenerated by the sqlness runner; the diff is those three lines and nothing else.

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

* test(query): make the corpus matrix prove what it claims

Three gaps, all in the test the "every corpus query answers natively" claim rests
on.

The partition helper returned early on a plan that reported no
`native.root.output_values`, so a producer that registered nothing passed on the
declared partition count alone. It now expects the metric: the point of the
assertion is that every partition executed, and an absent counter says nothing
about that.

`segmented` was decided by the frontend tree containing a `MergeScan`, which also
matches a frontend native node consuming raw frames from its regions - a
different placement that says nothing about a prefix running in a region. The
merge scan's `Debug` carries the plan its region runs, so the check now reads
that for a segment; the raw case gets its own `frontend-raw` route. Four queries
move there: L05, L06, L08 and R03.

The matrix held 23 of the 29 queries: Q05, Q08, Q09 and Q10 had only ever been
probed in a throwaway run. They are entries now, with the join labels Q08 and Q09
match on and real `le` buckets for Q10's histogram. A fallback is no longer a
route to record: a case that reaches the existing plan fails, because that is the
regression this table exists to catch.

Twenty-seven cases here plus the separate Q04 and Q07 tests cover all 29.

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

* refactor(query): let the compiler decide which calls this route answers

A second list of function names stood beside the compiler's own capability
check, and the two disagreed. `admitted_selector` named five calls; the compiler
folds `ceil`, `floor`, `sqrt`, `irate`, `avg_over_time`, `sort`, `absent` and
more. So `ceil(m)` was refused while `sum(ceil(m))` was admitted, and the same
call meant two different things depending on what wrapped it.

The compiler already answers this question: `compile_call` refuses a name it has
no kernel for with `NotImplemented`, and the planner turns that into a decline.
With the list gone there is one place that decides, and a call the compiler folds
reaches the route whether or not it is wrapped in an aggregate.

`sort` and `absent` read across a vector rather than one series at a time, which
is why the old list named them out. They compile, so they are planned; the tests
that asserted the refusal now assert the admission. Only the shapes the compiler
genuinely cannot express keep the existing plan - a subquery evaluates its body on
a grid of its own, which this route does not offer.

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

* refactor(query): admit an operand by its shape, not by call name

An operand of a hybrid binary was admitted only if it was a selector under one of
five named calls. The compiler folds many more than those five, so `ceil(m)`,
`irate(m[5m])`, `sort(m)` and `absent(m)` were refused while the same call under
an aggregate was admitted - the name list disagreed with the compiler about what
this route can answer.

What the list was really protecting is a layout assumption, and the shape states
it directly: `try_plan_native_operand` hands back a selector's planner context,
and the hybrid caller reads field columns and tags off that context, so an
operand whose output layout is not its selector's cannot be one. `sum(A) / B` is
refused for that reason, not because `sum` is a name.

`single_selector_spine` states it on the expression: a selector, parentheses,
unary, a call carrying exactly one vector-or-matrix argument, or a binary whose
other side is a compile-time constant. Calls are followed by the type of their
arguments rather than by `constant_value`, because a call's string parameters are
constants that `constant_value` does not fold and would otherwise look like data.

Which calls this route answers is now the compiler's answer alone: a name it has
no kernel for comes back as `NotImplemented`, which the planner turns into a
decline.

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

* feat(promql): mix raw and evaluated inputs in one fused tree

`build` refused a tree whose children were not all raw or all evaluated,
but nothing below that point assumed uniformity: `execute` picks the
decoder from each source's own schema, `FusedFeed` carries the source
index, and `collect_source_schemas` already validates a repeated source
against its own grid. A frame stream has one format per stream, not one
per consumer, so one segment can decode the raw samples of a rate and
compare the result against a vector a previous stage already evaluated.

Remove the refusal and make the remaining per-input decisions per source:
`evaluated_global` now asks about the source it orders, and
`window_sources` keeps only the evaluated sources, since a raw source is
answered by the raw path it always used.

Cover it by executing `rate(x[5m]) + y` where `x` arrives raw and `y`
arrives evaluated on the statement grid. The test asserts all ten
(host, timestamp) rows and fails on the removed refusal if the refusal is
restored.

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

* feat(promql): bind a segment to every source its subtree reads

`cut_as_segment` accepts any exportable subtree that is not
`SinglePartition`, so a matching subtree over two sources - `m / n` - is
a legal cut point. `build_inner_segment` then required exactly one source
and returned `None` for it, and `segment_tree` propagates that `None` as
"the statement keeps the plan it had", so `sum(topk(1, m / n))` fell back
to the legacy path even though its cut was legal.

Build the segment from every source the subtree reads. Each child gets the
`RawSeriesSource` boundary already used for separate metric tables, which
carries that source's own metric, tags and fields across the boundary and
makes the child placeable; a segment over two tables cannot sit inside
either region, so it runs above them. The single-source case keeps its
existing direct-scan path unchanged, which is what lets a one-table
segment still be pushed into its region.

Field names come from `tree_source_fields` on the rewritten tree, whose
source indexes are already dense in the order the children are built. That
helper also validates a repeated source against its own schema and refuses
a non-dense index set, which a second traversal would have to re-derive.
It becomes `pub` and is re-exported for the query crate to call.

Take the segment's time column from its own first source rather than from
the statement's first: a cut whose sources are `[1, 2]` would otherwise
name the time column of a table it does not read.

Cover the construction with a planner test and with a real-Mito
integration test that asserts both the exact values and the route: one
segment over the two tables, each child reading its own table through its
own raw series source. Without the route assertion a silent fallback would
pass on equal values.

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

* feat(promql): run a matching tree at the configured width

A matching tree's width came from its inputs: `matching_partitions` took
the selected source's count, or the maximum of every input's. In
standalone a cross-table statement arrives as one `MergeScanExec` per
table with one partition each, so `m / n` matched on a single worker while
the scans under it ran in parallel. The engine's
`execution.target_partitions` was reachable - `plan_fused_series` already
uses it for `series_scan` - but never reached this decision.

Add `to_execution_plan_with_parallelism(inputs, target_partitions)`, which
resolves a matching tree's width as `target_partitions.max(1)` for both
the same-source and the multi-input case. `to_execution_plan` keeps its
signature and its input-derived width, so a caller that has no configured
parallelism is unchanged. `PartitionLocal` and `SinglePartition` trees do
not consult the value at all: a per-series tree runs on whatever
partitions it was scanned with, and asking for a router there would only
add a shuffle.

Forward the value through `FusedSeriesExec` because `with_new_children`
rebuilds from the node's own children, which are the ones below the
router; rebuilding without it would resolve the width again from the
pre-router count and silently fall back to one.

Verified on a real standalone: the two-table segment now reports four
partitions with a `SeriesRepartitionExec` under each one-partition region
scan, where it reported one before, and the rows are identical.

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

* test(promql): declare a keyed test frame's ordering, and order its keys

`plan_keyed` built its schema with `datatypes::series::frame::series_frame_schema`,
which hardcodes the frame metadata `ORDERING` to `contiguous`. The stream it
returns packs what `RawScript::ordering` reports, which is now the key-then-time
order this helper models. The node reads the ordering off the *plan* schema, so the
two disagreeing meant the router scheduled these fixtures as `Contiguous` while
unpacking validated them under the stricter ordered contract.

Build the schema through `frame_schema_with` with the ordering the stream actually
produces, so the plan and the runtime frames state the same contract.

That mismatch was invisible until `RawScript` stopped hardcoding `Contiguous`: with
both sides claiming `contiguous`, the ordered validation never ran. Running it
showed the matching fixture feeding key `b` before `a` within one partition, which
its declared ordering forbids. Reorder the partition; the two pieces of `host="a"`
still arrive in different partitions, which is what the test is about.

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

* fix(mito2): declare the key order a two-phase raw scan actually keeps

`SeriesScan::raw_ordering` reported `SeriesOrdering::Contiguous` for a
`TwoPhase` scan that is not reading columnar artifacts. The data that path
produces is ordered by primary key:

- candidates are merged and sorted ascending by the encoded primary-key
  column, then decoded to ids by walking that ordered stream without
  reordering;
- partition assignment is a deterministic TSID-to-partition map that
  appends in arrival order, so one series never spans two partitions;
- each assignment is drained completely before the next is taken, rather
  than emitted in completion order;
- the raw adapter's predicate buffering preserves input order and cannot
  let a later key overtake a held one, and two-phase records always report
  `ends_package: false`, so it never engages the package-tail machinery
  that legacy relies on.

Declaring the weaker order costs two things that are available: the frame
codec's `SequenceValidator` never ran its increasing-key check, and a
repartition owner could not use a key watermark to release an identity
before another input ended. It also contradicts the scanner's own
documented contract, which states the output is always ordered by
(primary key, time index) within a partition.

Report the order the path actually keeps, and reword the two ordering
errors that said "legacy" now that both modes can reach them.

Cover it with a test that forces two-phase mode and the non-prepared
fallback, asserts which of the two it ran, and then checks per-partition
key monotonicity, per-series timestamp monotonicity, single-partition
ownership, and the ordering the scan reports. The fixture writes three
rounds, uses two output partitions and a residual predicate, and keeps
one series above a batch so the multi-batch path is exercised.

The three existing two-phase raw tests asserted the old declaration. They
check that a series stays in one run and never recurs non-adjacently,
which the stronger order still guarantees; only their expected declaration
and their names said "contiguous", so those change and the substantive
assertions stay.

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

* fix(promql): route a raw match only by a key the computation keeps

`same_source` answers "this subtree reads one source", and a `MatchingLocal`
classification was taking that answer as licence to insert the router below
the fused computations, hashing the labels as they are at the raw stage.
Those are different facts. A math map drops the metric name and the
`__type__`/`__unit__` metadata, `timestamp` does the same, and every range
function drops them except `last_over_time` and `first_over_time` — so
`on(__name__)` over `abs(...)` routes by a key the very next node deletes.

Concretely, `abs(x) + on(__name__) abs(y)` with one series on each side
routes `x` and `y` to different owners; both become the empty key after
`abs`, nothing re-partitions between the map and the match, and the pair is
silently lost.

Decide preservation separately from source identity. A node keeps the key
when it cannot remove one of the labels the key is built from; a `Window`
consults `RangeFunction::drops_metric_name` rather than assuming, so the
two functions that keep the name still route early. Anything not proven to
keep the key — a label rewrite, a histogram map, an unmodelled node —
does not establish preservation, and the tree is classified `SinglePartition`.

That is not a fallback that trades parallelism for correctness. When the
computation removes the labels the key is built from, every series on one
side reduces to the same empty key, so every left series matches every
right one: the match is whole-vector by construction and has no partitioned
form that preserves its result. One partition is the only placement that
answers it. Where the key does survive, the match stays partitioned.

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

* fix(query): describe an admitted operand by what it produces

`try_plan_native_operand` handed the binary matcher the context saved
during scanning, which describes the operand's *input* layout. An admitted
call can change that layout: `histogram_quantile` folds a classic
histogram and drops `le`, so the scan context still names a tag column the
operand no longer produces, and default matching then builds a join key
over a column that is not there.

Concretely, `histogram_quantile(0.9, m) / histogram_quantile(0.5, m)`
declines whole-tree planning (two selectors over one table) and is then
admitted operand by operand; each operand drops `le`, and planning fails
building the join with `FieldNotFound { field: "le" }` against a schema of
`timestamp, field_0, host`.

Publish only the tag and field columns that the operand's own plan schema
actually contains, and drop `use_tsid`, which the produced output does not
carry. The check is against the schema the node returns rather than
against a list of functions that happen to shrink it, so a call added
later cannot reintroduce the gap by not being on a list.

Covered by a classic-histogram fixture with `host` and `le` and a float
bucket field, asserting each operand's context reports `["host"]` and that
the planned join key omits `le`. Reverting the change fails that test with
the `FieldNotFound` above.

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

* test(promql): pin the partitioned duplicate-group answer, with the gap named

`native_hybrid_binary_05_06_08` asserted that the right side's two
`job="other"` series are refused as a duplicate match group. On the
partitioned route they are not: the check is gated on `lhs.presence &&
rhs.presence` computed from the partition's own slices, so the partition
holding `other` sees no left-hand data and filters every point out.

Pin what the route actually answers, and state the three things that
constrain the fix, because each has already been tried and rejected here:

- the gate cannot be dropped. `prometheus_cases.txt:1065-1075` is a
  Prometheus-generated case where the left is absent at one point and the
  right carries three samples of one group there, and the answer is a
  value with no error;
- the scan cannot run unconditionally, for the same reason;
- the match cannot be collapsed to one partition to make both operands
  whole, because that gives up the parallelism this route exists for.

What remains is to let the verdict see whole-operand presence while the
partitions keep answering — a deferred check rather than a different
placement. This test now makes any change to the current answer visible
instead of silent.

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

* feat(query): segment a nested match instead of gathering it

A nested one-to-one match — `x + y + z`, or `(x - y) / on(host) z` —
passes the logical admission check, because `independent_scan_pair`
recurses through `scan_operand`. The physical locality classification does
not go that far: it recognizes a match only when both operands read one
source each, so `same_source` fails on an operand that is itself a match
and the whole tree is classified `SinglePartition`. The fused computation
then ran on one partition while the scans under it stayed parallel, which
the existing planner does not do: it builds ordinary equijoins that
DataFusion can execute as partitioned hash joins.

Admission and placement are different questions, so they now get different
predicates. The tree is segmented when it is cross-table, not multi-input,
classified `SinglePartition`, and its root is a vector match with a nested
vector match directly under it. The inner match becomes a segment and the
outer one repartitions the evaluated operands by its own key, which is
what the segmenter already does for any exportable evaluated prefix; a
nested match whose two levels use different keys is routed twice, once per
key, rather than pretending one routing serves both.

The predicate stays narrow on purpose. Three-selector trees over distinct
metric logical ids are `multi_input` and keep their existing construction,
including the parent binding that construction produces. A same-table
nested match does not enter this branch either, so its prior admission is
unchanged.

The plan test's collector walks each fused node's native tree in
expression-child order and resolves each input's `source_index` against
that node's inputs, so a segmented plan is checked for the same property
as before — every selector occurrence bound exactly once, in expression
order — rather than being checked more weakly. It panics on a shape it
does not model instead of returning an empty list.

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

* fix(promql): prove a raw match key survives the computation

`preserves_matching_key` decides whether a subtree keeps the labels a
vector match routes by. Two arms answered yes without looking at the
field that decides it.

A binary with a scalar operand recursed into the vector side alone, but
arithmetic and a `bool` comparison drop the metric name while a filtering
comparison keeps it — the distinction execution already makes. And an
`Instant` recursed unconditionally, while `timestamp(x)` compiles to
`InstantValue::Timestamp`, which drops the name too; the check written for
the separate `Timestamp` node never covered that shape.

Both now read the field that decides: the operator together with
`return_bool`, and `InstantValue`. The labels checked are the whole
computed set — name, type and unit — because `drop_metric_name` removes
all three and default matching does not ignore the last two by itself.

Without this, `(x * 2) + on(__name__) (y * 2)` was classified
matching-local, the router hashed the raw names before the maps dropped
them, and a valid pair could land in different partitions with nothing
re-partitioning afterwards. The regression asserts the value is 10 and
that the tree is `SinglePartition`, computing the destinations the router
would have used (`x0` to partition 0, `y0` to partition 1 of two). With
the old arm restored the value assertion fails with `left: [10.0],
right: []`.

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

* feat(query): find a nested match through the wrappers around it

Segmentation was gated on the root itself being a vector match with a
nested match beneath it, so `abs(x + y + z)` and `2 * (x + y + z)` never
entered: the wrapper or the scalar broadcast sat where the match was
expected. Meanwhile the segmenter already walks children and leaves a
wrapper in the parent while cutting what it can carry, so no per-wrapper
case is needed — the search just has to look through them.

Replacing the root check with `carries_prefix` was too wide: almost any
multi-source tree contains something a boundary could carry, so ordinary
shapes like `some_metric unless on(tag_0) other_metric` were cut for no
gain and their inputs stopped being raw sources.

So keep requiring a nested vector match, and search for it anywhere in the
tree rather than at the root. Wrappers and scalar broadcasts are
transparent because the walk visits every child; the nested match itself
is still required, which is what keeps the plain multi-source shapes on
the plan they had.

`cross_table`, `!multi_input` and the `SinglePartition` locality
requirement stay, as before: they are what keeps distinct metric logical
ids and the same-table shapes out of this branch.

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

* refactor(promql): let a router publish an identity whose chain is complete

`SeriesRepartitionExec` published an identity only once every input had
contributed it or ended, because an identity may be split across inputs. A
per-series region scan assigns each identity to one partition, so on that
shape the rule holds every identity until the other partitions reach EOF: a
region's whole raw selection is retained before the first series leaves.

The node now carries an `IdentityCompletion`, defaulting to the existing
merge. `CompleteDisjoint` publishes an identity as soon as its chain closes
and keeps the published identities charged in the pool, so a second chain
for the same identity fails the execution instead of emitting a partial
series as final. The declaration is the producer's, not something a consumer
infers from a node's type: publishing early against a producer that does not
own its identities would be wrong, so the conservative mode stays the
default and every existing constructor keeps it.

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

* fix(query): choose a series-frame root's router from declared provenance

`SeriesFrameOutput` normalized every multi-partition frame root through a
router that merges identities, which is wrong for a root whose identities are
already disjoint: it waits for every scan partition to end before the first
series leaves.

The rule now selects `CompleteDisjoint` only for a root whose producer
declared that property. A raw root qualifies when `RawSeriesSourceExec` was
built through `to_per_series_execution_plan` - which also checks the child is
a `SeriesScanExec`, so the type alone is not taken as proof - and a direct
`SeriesRepartitionExec` root qualifies because it merged the identities
itself. Everything else, including a fused frame root, keeps the merging
router: a native label transformation can map two source series onto one
output label set, so its output identities are not exclusive.

The rejection of a key-only raw frame is unchanged and its reason is
restated: coalescing cannot produce such a frame, and a router needs explicit
labels to check that one key has one binding.

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

* fix(promql): report the labels `absent` names, not the table's columns

`fused_output_tags` answered `None` for `absent`, so the caller fell back to
the existing planner's column names. Those are the table's tag columns, which
is not what `absent` reports: `absent(m{host="x"})` carries the labels its
matchers name, and the compiled tree already knows them in
`BoundVectorOp::Absent { labels }`.

The label set is now read from that field: the named labels are kept in the
input's column order, so `absent(native_shapes{host="missing"})` over a table
with `host` and `le` reports `host` rather than `ts, val, host, le`. A label
the matchers name that is not an input column still answers `None`, because a
frame has no column to carry it; `count_values` and `histogram` are unchanged.

Caught by the native-versus-legacy value comparison: the extra column made
`absent` differ from the existing plan while every value matched.

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

* fix(promql): describe a frame root's emission from what it does

`FusedSeriesExec` declared `EmissionType::Final` for a frame output, but
`poll_output` hands a frame's evaluated chunk over while its input is still
arriving, so the declaration overstated the node to the optimizer. Only a
partial aggregate's state is emitted after EOF; every other output keeps its
input's emission type.

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

* test(promql): compare the shapes the native route now admits with the existing plan

Removing the shapes a name list used to refuse put single-selector `sort` and
`sort_desc`, `absent`, and a bare `histogram_quantile` on the segmented route.
Nothing compared their answers with the existing plan, so the widening was
unverified.

The new case runs each shape both ways over one metric table. `sort`,
`sort_desc` and both `absent` cases compare the whole printed result, which
covers column names and values together. `histogram_quantile` compares values
row by row and asserts the one known difference instead of hiding it: a
segmented statement names its value column from the compiled tree (`val`)
where the existing plan names it after the expression that produced it. That
case is given a window long enough to produce a row, so it cannot pass by
comparing two empty results.

The `absent` cases caught a real difference: the native route reported the
table's tag columns rather than the labels the matchers name, which is fixed
in the preceding commit.

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

* fix(query): publish a fused root's identities early when the tree keeps them

The root rule publishes an identity as soon as its chain closes only for a
root whose producer declared that each identity lives in one partition, and
it knew two such producers: a per-series raw scan, and a router that had
already merged its identities. A fused root was always treated as needing a
merge, so a region whose segment is a plain instant selection over one
per-series scan still held every identity until the other scan partitions
reached EOF - `sum(topk(1, m))` is that shape, and its segment is already
multi-partition with no label rewrite.

`FusedSeriesExec` now answers whether its output identities are disjoint, and
the rule asks it. The answer is narrow on purpose: one input, and a tree of
nothing but inputs and sample instant selections, over a per-series scan or
another fused node that already answered yes. Anything that computes labels
is excluded, because a timestamp or a pointwise map can map two source series
onto one output identity, and publishing early would then hand over one
identity's pieces as if they were different ones. A vector-to-vector binary
reads across series and is excluded too.

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

* fix(query): segment a computed matching key's operands instead of gathering them

A vector match whose operands compute away one of the matched labels was
classified `SinglePartition` and executed on one partition. That is correct
for the bare tree - the raw names cannot route a match whose operands drop
them - but it also gave up the parallelism the operands still have:
`(x * 2) + on(__name__, host) (y * 2)` loses `__name__` and keeps `host`, so
the computed sides can be routed by what they actually carry.

The planner now cuts those operands into segments of their own and lets the
parent route their evaluated outputs by the unchanged match key. The key
itself is not rewritten: `group_key` reads the labels present at that point,
so `On(__name__, host)` degrades to the `host` a computed operand still has.

Which shapes take this route is decided by one predicate shared with
`input_locality`, so the admission rule cannot drift from the locality
judgement: a matching site whose two operands are each proven single-source
computations, at least one of which does not preserve the key. A failed
preservation proof on its own is not enough - it can also mean the proof gave
up, as with a parameter that is a compound expression or an operand that is
itself a vector match - and the shared doc says so.

The same table read twice is admitted for this shape only. The rule that
declines several selectors on one table exists to keep folds such as
`sum(m) / m` off this route, not because one table cannot be read twice; the
compiler already gives each occurrence its own source index, and each cut
gets its own input. Distinct partitions are what the two operands need, and
they get them.

The regression executes both the same-table and the cross-table shape over a
four-host fixture and asserts that at least two output partitions produce
rows, so the result is not silently produced by one of them.

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

* fix(query): take the early-publication guarantee from the scan's binding

The root rule publishes an identity as soon as its chain closes only when the
producer can promise that each identity lives in one partition, and the fused
answer to that question asked whether its input was a `SeriesScanExec`. The
node's type is not that promise: a scan binds a set of logical tags and decodes
those into the series' labels, and `try_to_named_series_scan` requires the
caller to pass complete tag names but does not check it. A binding that names
only some primary-key columns maps two stored series onto one output identity,
and early publication would hand one identity's pieces over as different ones.

The answer now reads the binding: the label source has to be `PrimaryKey`, and
every primary-key column has to be among the bound tags. A source that carries
its labels explicitly is not this shape and answers no, as before.

Covered by a new `bound_tags_cover_primary_key` unit test - partial coverage is
false, full coverage is true, an explicit source is false - and by tests for the
predicate's two other parts: a tree that computes labels and a tree that is not
one instant selection over one input both answer no, and a gated execution
shows a completed identity leaving a fused node while the other input is still
held.

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

* refactor(promql): drop the checks the types and decoders already guarantee

Two re-checks ran inside the repartition node over facts its inputs had
already established, and both cost real work on every query.

A raw merge scanned each chunk's timestamps for strict increase, but
`RawTimestamps` cannot hold an unordered buffer: its field is private, its only
constructor validates, and the accessors are read-only or order-preserving, so
no safe caller can build the value this check defends against. The check is
gone; the identity, label and shape checks around it stay, because those
compare different producers' pieces rather than one chunk's own timestamps.

An owner rebuilt each chunk's identity to prove a chain does not change it, but
the decoder that hands the chain over already refuses that: `SequenceValidator`
holds one open key and rejects a key or metric change before the chain closes,
`EvaluatedSequenceValidator` does the same for labels, and the input task that
assembles chains only reads from those decoders. What stays is the identity the
owner needs anyway to pick a destination, now built by the named functions
rather than inline.

Where a schema was needed, the node built an empty stream of its own, wrapped it
in a packer and read the packer's schema back. The frame module already answers
that question from the declaration, so the fake stream is deleted along with its
half of the roundtrip.

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

* fix(promql): recursively segment native matching plans

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

* fix(promql): derive native schemas without legacy probes

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

* fix(promql): recognize complete metric scan identities

Signed-off-by: discord9 <discord9@163.com>
(cherry picked from commit 8f752a02dbb9dc7858618c1d5cc0c41e2d4efb23)

* fix(promql): release completed disjoint identity history

Signed-off-by: discord9 <discord9@163.com>
(cherry picked from commit 9975fa6c5c68786e18af230f62041d2aa5e71f6c)

* test(promql): assert native aggregate schemas independently

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

* fix(promql): identify internal metric keys by reserved id

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

* fix(promql): keep parallel sources and derive group match labels

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

* test(promql): observe the frontend fused root in scan-parallelism checks

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

* fix(promql): release duplicate evidence immediately on abort

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

* test(promql): preserve operand identity across matching repartition

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

* fix(promql): charge the final duplicate verdict until it is reported

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

* test(promql): observe native scans across frontend and region plans

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

* chore(promql): drop dead test helper and clippy lints

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

* fix(promql): select limitk by canonical label order

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

* fix(promql): keep data-free subtrees in the parent segment

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

* test(promql): prove the duplicate error crosses matching partitions

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

* test(promql): drop debug prints from the segmentation test

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

* revert(promql): keep limitk's Prometheus input-order semantics

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

* chore(promql): satisfy clippy in fused root schema

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

---------

Signed-off-by: discord9 <discord9@163.com>
2026-11-04 14:12:56 +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
discord9 2b67d0d60c test(series): pin literal ordering declarations and decoder validation
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-26 13:57:51 +08:00
discord9 62652ec62e fix(series): validate v2 ordering and repair scan binding fixtures
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-26 13:38:23 +08:00
discord9 b20e22b426 refactor(series): unify raw and evaluated SeriesFrame codec
SeriesFrame is the internal transport of the Narthex native PromQL path, and
it could only carry raw samples: an evaluated result had to fall back to a
plain row table. This makes one self-describing Arrow format carry both, so a
receiver decodes a frame from the frame alone, without the scanner, region, or
planning context its producer had.

What is unified is the physical representation and its codec, not the runtime
data structures. RawSeriesChunk, Series, and EvaluatedChunk stay as they are;
only the adapter boundaries branch on the sample shape.

Three combinations are legal and every other one is refused in both
directions:

  raw + key + timestamps          a storage raw-series scan
  raw + explicit + timestamps     a raw input whose labels are decoded
  evaluated + explicit + grid_spans   a compute node's output

An evaluated frame cannot claim a storage key, because the computation may
rewrite its labels; raw samples have no grid to be addressed on. The schema
builder and the declaration parser both enforce that, so a frame cannot be
written or read in a shape no producer could mean.

An evaluated frame declares its GridSpec once in schema metadata and carries
absolute grid positions per row as spans, so a sparse grid costs its runs and
not its holes: nothing is expanded, and no position a chunk does not hold is
filled in. A chunk with no position is legal exactly when it closes its chain,
and it is kept as one frame row, because dropping it would drop the finality
it exists to carry.

Layout is v2 and the writer and reader of the reserved greptime.series.*
metadata live in one place each, so a producer and a consumer cannot spell one
frame two ways. The unreleased v1 format is not read: the in-tree producers,
consumers, tests, and the two frozen wire fixtures all move to v2 together.

Signed-off-by: discord9 <discord9@users.noreply.github.com>
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
(cherry picked from commit 176f6f6479)
2026-10-26 12:57:11 +08:00
discord9 6eff9f5804 test(promql): verify scalar routes and four-partition broadcasts
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-26 00:13:13 +08:00
discord9 78be1b1138 test(promql): regenerate independent matcher and range results
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-25 20:27:32 +08:00
discord9 8dbf08d252 feat(promql): execute independent selector scans in fused binary trees
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-25 20:10:32 +08:00
discord9 c3dd11bf48 test(promql): record shared-selector SQL regression results
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-25 14:31:27 +08:00
discord9 2c3dace6b2 test(promql): align frame ordering and SQL result sorting
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-25 13:47:42 +08:00
discord9 4748081579 feat(promql): fan out a shared raw scan to bound binary inputs
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-25 13:47:42 +08:00
jeremyhi a0a7ec02b5 ci: compare several images in one object store WAL S3 benchmark run (#9691)
* ci: compare several images in one object store WAL S3 benchmark run

A new `images` input takes a JSON list of 2..4 {label, image}. Every
cell then runs once per image, back to back, and the image order
reverses with the configuration order every other round. The cell id
gains the label. The single-image inputs keep their meaning and cell ids.

The validate job pulls every image before an instance is allocated. It
pins each image to its digest and records the version and commit that
the image reports. Each cell starts the image of its label. An optional
`warmup` runs the first load cell once per image as round 0, and no
statistic uses it.

The plan step estimates the benchmark job's minutes on c6id.2xlarge
from the cell times of earlier runs. It refuses a plan that does not
fit the job timeout.

The summary keeps the Raft Engine ratios within each image. With
several images it also reports, per group, concurrency and config:

- every round's rows/s and client p50/p99;
- each image's ratio to the first, with the geometric mean, minimum
  and maximum over the rounds.

Every cell records the host load averages at its start and end.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* ci: scale the S3 benchmark time estimate with the batch size

The per-cell rates were measured with 1000-row batches. A smaller batch
keeps about the request rate, not the row rate, so the estimate now
shrinks rows/s with the batch. A one-row durable plan is refused instead
of passing a timeout it cannot fit.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* ci: pin the S3 benchmark runtime image without pulling it in validate

An anonymous pull from ECR Public on a hosted runner can exceed the
registry's data limit, and it failed the validate job of several
dispatches with "toomanyrequests: Data limit exceeded". Validate now
reads only the registry manifest to pin the runtime image's digest. A
reference that names a digest must be served under that digest.

The check that the image holds the loader and the generator moves to
the benchmark host. It runs right after the runtime pull and before any
GreptimeDB image is pulled or any cell starts.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* test: run the S3 benchmark runtime content check against both files

The fake docker now runs the pull step's own check in a directory that stands in for the runtime image. It covers both files present, the loader missing and the generator missing, so dropping either file check fails the test.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* ci: pin a digest-named S3 benchmark runtime image without the registry

ECR Public's anonymous data limit now refuses hosted runners even the
manifest read, and validate failed in "Pin the runtime image digest".
A runtime reference that names its sha256 digest, like the default one,
is already pinned, so validate takes that digest with no network call.
The format checks stay. Only a tag-only reference is resolved through
the registry. The content check on the benchmark host is unchanged.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* ci: check the full S3 benchmark runtime reference before pinning it offline

A named digest no longer reaches the registry, so nothing parsed the rest of the reference. Validate now checks the whole reference against Docker's reference grammar first. It refuses an empty repository, a doubled '@' or a bad tag before any pin is emitted or any instance is allocated.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

---------

Signed-off-by: jeremyhi <fengjiachun@gmail.com>
(cherry picked from commit a436213bf3)
2026-10-23 14:43:10 +08:00
jeremyhi b2b606115b ci: add crash, restart, multi-region and drop cells to the S3 WAL benchmark (#9632)
* ci: add crash, restart, multi-region and drop cells to the S3 WAL benchmark

Extend the object store WAL S3 benchmark beyond one load per configuration:

- Inputs for a full image reference, the configurations to run (R0 adds Raft
  Engine with the standalone default sync_write = false), extra environment
  for every server container, a restart after each load, and a JSON list of
  named cell groups that override those inputs.
- Restart cells SIGKILL the loaded server, restart it on the same prefix and
  data home, and time /health, the first correct count and the log store
  recovery; min_objects fails a cell that left too few objects indexed.
- Crash groups write striped 1000-row batches from several writers, kill the
  server at a random point, restart it and check every acknowledged batch
  against the table (durable: present exactly once; enqueued: no duplicate,
  no partial batch, losses counted). A pinned variant keeps one row of a
  second table unflushed and samples the indexed objects while it runs.
- Idle windows scrape the server before and after a quiet period.
- A load group may split the loaded table into equal event_time ranges, so
  every batch writes to every region, and checks that each region took rows.
- Drop groups write a table, drop it, kill and restart the server, then
  write and flush another table and wait for the dropped table's objects to
  be collected.
- The summary adds WAL traffic, objects per second, mean object size,
  entries and appends per object, S3 requests per operation, server and host
  CPU, the loader's own request latency, rows per region, crash cycles, and
  each restart's replay per region from the server log.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* ci: report the object sequences a replay spans instead of objects replayed

The width of a region's replayed entry range counts every object sequence in it, including objects that hold no entry of the region and are skipped, so the summary labels it as a span.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* ci: fail a crash cycle that loses a batch an earlier restart recovered

The oracle accepted every missing acknowledged batch in the enqueued mode,
so a batch that one restart recovered and a later restart no longer found
passed. Every cycle keeps what its restart found, and a cycle now fails in
either mode unless every batch an earlier restart recovered is still
present, so only the current cycle's backlog that was not durable at its
kill may be missing.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* ci: keep the pinned WAL pinned and label OpenDAL operations as such

The pin table inherited the engine's 10-minute auto flush, so a longer
pinned cycle could flush its row and release the WAL before the kill. It now
sets its own auto_flush_interval of one day, longer than any crash group can
run, and a pinned cycle fails unless its indexed objects never fall while
it runs.

The summary's request counts come from opendal_operation_duration_seconds,
which counts OpenDAL calls rather than HTTP requests (a paginated list or a
multipart write is one operation), so the fields and columns now say S3
operations.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* ci: check a pinned cycle's retention by the collected-object counter

Net growth of the indexed objects can hide collection, and short cycles take no periodic sample, so a pinned cycle now fails unless deleted_objects_total is the same in the scrape before its writers start and in the one before the kill, both of the same server process. The operation columns name OpenDAL.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* ci: let a pinned cell collect only the objects below its pin

The first write after a start lets the store collect the empty start object asynchronously, so a deletion after a cycle's baseline does not mean the pin let go. A pinned cell now scrapes the server before it writes the pin row and fails only once its servers together have collected more objects than were indexed then, counting the first server from that scrape and every restarted one from its start.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* ci: test that a pinned cell's allowance spans its server restarts

Two servers that each collect fewer objects than lie below the pin but more together now fail the cell in the second cycle.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

---------

Signed-off-by: jeremyhi <fengjiachun@gmail.com>
(cherry picked from commit 0e82b29b23)
2026-10-23 14:43:10 +08:00
Yingwen 6a40c0b811 feat(mito): implement columnar samples format (#9887)
* docs(mito): specify columnar samples format

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

* feat(mito): implement columnar samples format

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

* fix(mito): address columnar samples review feedback

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

* style(mito): use crate-rooted samples imports

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

---------

Signed-off-by: evenyag <realevenyag@gmail.com>
2026-10-22 19:41:56 +08:00
discord9 7309800aef feat(datatypes): describe label decoding in the raw series frame schema (#9786)
* feat(datatypes): describe label decoding in the raw series frame schema

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-21 17:02:37 +08:00
Yingwen 8543cbf401 feat(mito): scaffold series format writers and artifact lifecycle (#9788)
* feat(mito): add pluggable series artifact reconciliation

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

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

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

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

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

* refactor(mito): make prepared series sources replaceable

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

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

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

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

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

---------

Signed-off-by: evenyag <realevenyag@gmail.com>
2026-10-21 16:38:37 +08:00
discord9 3ef71b5702 feat(common-query): add raw series request binding and preflight types
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-18 11:33:20 +08:00
discord9 88a1e684a0 feat(common-recordbatch): recognize SeriesScanExec in metrics and preserve typed errors (#9656)
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)

Independent PR. No dependencies.

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

Recognize `SeriesScanExec` in query metrics and preserve typed errors
through the record batch adapter:

- `PlanMetrics`: recognizes `SeriesScanExec` nodes so region-scoped
stats attribution works.
- `RecordBatchStreamAdapter`: preserves the original typed error via
`BoxedError` instead of wrapping into a generic execution error. This
lets downstream recover the specific status code (e.g.,
`InvalidArguments` instead of `EngineExecuteQuery`).

**2 files, +203/-9 production + 1 sqlness expected result.**

### Error code change

The typed error preservation changes the error code for the
`semantic_graph` upper-bound-only query from `3001(EngineExecuteQuery)`
to `1004(InvalidArguments)`. The error message text is unchanged. The
new code is correct: the original error was an argument validation
error, not a generic execution failure. The sqlness expected result has
been updated.

### Verification

- `cargo check --all-targets` ✅, `cargo nextest run -p
common-recordbatch` → **65/65 passed** ✅
- sqlness expected result updated for the typed error code change

## 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.
2026-10-18 09:55:54 +08:00
discord9 55e59f6c91 fix: update semantic_graph expected error code for typed error preservation
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-17 20:26:18 +08:00
discord9 997cbf87f7 feat(common-query): declare raw series frame ordering in schema metadata
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-17 11:03:29 +08:00
dennis zhuang 5c4d9eb4f8 fix: align fulltext bloom tokens with matches_term and use them for LIKE (#9470)
* fix: align fulltext bloom tokens with matches_term and use them for LIKE

The bloom fulltext index stored tokens that did not follow
matches_term's word boundaries (`_`, ASCII next to Han, Han substrings),
so pruning dropped rows matches_term accepts.

Tokens and query probes now share the character classes of
matches_term, Han runs are indexed as bigrams, and LIKE patterns use the
same probes. The bloom blob is written under a new key; blobs from older
versions are no longer probed. Tantivy no longer serves matches_term.

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

* chore: drop unused lazy_static from index and avoid typo-flagged test tokens

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

* docs: link like_probes to tokenize instead of the downstream ScriptTokenizer

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

* refactor: name probe extraction with verbs and document CharClass

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

* feat: rebuild legacy fulltext bloom indexes and probe lower(col)

The manifest records the fulltext bloom version per column, so ADMIN
BUILD_INDEX now rebuilds SSTs whose bloom index predates the current
tokens, and ssts_index_meta shows the version.

Case-insensitive bloom indexes also store the tokens of the whole
lowercased text for rows where it can differ (U+0130, U+212A, sigma),
so matches_term(lower(col), ..) and lower(col) LIKE .. use the index.

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

* test: cover legacy bloom index rewrite by compaction in the compat case

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

* chore: log outdated fulltext bloom indexes in the consistency check

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

* docs: explain the unrecorded fulltext bloom version

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

---------

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-14 08:07:24 +00:00
Yingwen 00aa65de7a fix(query): guard deep expressions during query planning (#9564)
* fix(query): avoid speculative clones in constant normalization

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

* fix(query): reject deeply nested expressions before optimization

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

---------

Signed-off-by: evenyag <realevenyag@gmail.com>
2026-10-14 06:49:35 +00:00
NightG1owZandNightG1owZ caefcfd012 test: add inverted index compatibility case (#9519)
* test: add inverted index compatibility case (#5188)

Signed-off-by: NightG1owZ <316247938+NightG1owZ@users.noreply.github.com>

* test(compat): assert the newly indexed column stays queryable (#9535)

Signed-off-by: NightG1owZ <316247938+NightG1owZ@users.noreply.github.com>

* test(compat): narrow to_range to versions carrying #9545

The filtered read added in the previous commit only returns rows once #9545 is present. That fix is on main only (no backport-* labels), so it ships in the v1.4.0 line. Narrow to_range to >=v1.4.0-alpha.0 so the documented downgrade invocation (--to-version v1.1.4) is not broken.

Also add the missing trailing newline to the three case files.

Signed-off-by: NightG1owZ <316247938+NightG1owZ@users.noreply.github.com>

---------

Signed-off-by: NightG1owZ <316247938+NightG1owZ@users.noreply.github.com>
Co-authored-by: NightG1owZ <316247938+NightG1owZ@users.noreply.github.com>
2026-10-14 02:00:39 +00:00
dennis zhuang 2a70194930 feat: add per-column COMPRESSION option for SST columns (#9426)
* feat: add per-column COMPRESSION option for SST columns

CREATE TABLE accepts COMPRESSION WITH (type = 'zstd', level = N,
page_size = '<size>') on a column. The options are stored in the column
metadata, carried through gRPC column options, shown by SHOW CREATE
TABLE, and applied by the mito SST writer and bulk part encoder as
parquet column properties. Unset fields keep the SST defaults. Nested
types are rejected.

A page size below the dictionary page limit also lowers the column's
dictionary page limit: parquet sizes write batches by the dictionary
budget and keeps that size after falling back to plain encoding, which
would otherwise ignore the smaller data page limit.

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

* fix: validate column compression options before persistence

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

* fix: reject compression overrides on metric logical tables

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

---------

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-13 08:03:49 +00:00
Weny Xu 287ced2de2 fix(mito2): ignore missing inverted indexes in old SSTs (#9545)
Signed-off-by: WenyXu <wenymedia@gmail.com>
2026-10-13 03:18:37 +00:00
jeremyhi c1a0ca9595 ci: benchmark the object store WAL against S3 from an EC2 runner (#9536)
* ci: benchmark the object store WAL against S3 from an EC2 runner

Add a workflow_dispatch workflow that loads the o11ybench agent observability
dataset into a fresh GreptimeDB container per cell and compares three WAL
configurations that differ only in [wal]: Raft Engine with sync_write on the
instance store, and the object store WAL in durable and enqueued mode, with
the data files on S3 in the runner's region for all three. Rounds alternate
the configuration order. A summary reports rows per second, D/R and E/R as
the geometric mean over rounds, append acknowledgement p50 and p99, and the
object store WAL counters. A smoke mode runs the same chain on ubuntu-latest
against the MinIO fixture.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* ci: scrape WAL metrics on failed loads and keep logic out of the benchmark YAML

Scrape the object store WAL metrics before stopping a cell's server whether
the load succeeded or failed, so failed cells keep their stall, poison and
create failure counters. Build the summary from the plan so that cells which
never ran are listed and leave their ratios undefined. Move budget, region,
runtime image, manifest and credential scan logic from the workflow into the
helper scripts, and drive the cell lifecycle in the tests through fake docker
and curl binaries.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* ci: drop redundant required flags from benchmark inputs

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* ci: fail a benchmark cell whose metrics scrape fails and zero untouched WAL counters

A load that succeeds without its metrics scrape now fails the cell with exit
code 4 and records metrics_scraped in cell.json. The summary reports an
event counter that never registered as zero when the cell is an object store
WAL cell whose scrape succeeded and created objects, and as unknown otherwise.
Planned cells that never ran are read through the same path as recorded ones.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* ci: register the S3 benchmark workflow for dispatch from its branch

A workflow_dispatch workflow can only be dispatched once GitHub has
registered it, which happens on the default branch or when one of its
triggers fires. A push trigger limited to this branch registers it; validate
runs only for workflow_dispatch, so a push run skips every job.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* ci: delete the benchmark S3 prefix one object at a time

boto3 now sends a CRC checksum instead of Content-MD5 with DeleteObjects,
which the MinIO fixture of the smoke run rejects. DeleteObject needs neither
and the prefix of one run holds at most a few thousand objects.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* ci: default the S3 benchmark to c6id.2xlarge

c7id is not offered in the runner region ap-southeast-1, where DescribeInstanceTypes
rejects it. c6id is the compute optimized x86 family with a local NVMe instance
store that the region offers, with the same 8 vCPU and 16 GiB at 2xlarge.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* ci: drop the registration trigger of the S3 benchmark workflow

The workflow is registered now, so dispatches no longer need a push trigger
on its branch.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* ci: fail an S3 benchmark run on EC2 without a local instance store

Without an instance store the Raft Engine WAL of the R cells silently stayed
on the root EBS volume, so the run measured a different baseline. Only a smoke
run may keep it on the root volume now.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* ci: let the instance store check fail its workflow step

The step piped wal-disk into tee without pipefail, so a rejected host still
passed. wal-disk now writes its record itself and the step runs it alone.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

---------

Signed-off-by: jeremyhi <fengjiachun@gmail.com>
2026-10-12 13:36:55 +00:00
shuiyisong 2df33380a7 feat(catalog): declare information-schema table scopes (#9504)
* feat(catalog): declare information-schema table scopes

Signed-off-by: shuiyisong <xixing.sys@gmail.com>

* chore: rename cluster to global

Signed-off-by: shuiyisong <xixing.sys@gmail.com>

* fix: update sqlness result

Signed-off-by: shuiyisong <xixing.sys@gmail.com>

* refactor(catalog): require information-schema table scopes

Signed-off-by: shuiyisong <xixing.sys@gmail.com>

---------

Signed-off-by: shuiyisong <xixing.sys@gmail.com>
2026-10-12 08:49:33 +00:00
dennis zhuang f3a0841f4f fix(promql): follow IEEE 754 float semantics in comparisons and aggregations (#9479)
* fix(promql): follow IEEE 754 float semantics in operators and aggregations

- Comparisons: Arrow orders floats totally (NaN == NaN, NaN > +Inf); PromQL
  comparisons with NaN are false except !=.
- min/max: skip NaN unless the whole group is NaN. DataFusion's grouped
  min/max let NaN replace and be replaced by neighbours, so the result
  depended on input order.
- topk/bottomk rank NaN last.
- round() rounds ties up and returns NaN for a zero step, like Prometheus.
- sqrt() of a negative number is NaN instead of an error.

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

* fix(promql): round() without a step rounds to integers

The planner passed a zero step for single-argument round(), which now
yields NaN like Prometheus; pass 1 instead. Found by the compatibility harness.

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

* fix(promql): apply the sqrt guard to builtin functions and register min/max for plan decoding

sqrt resolves through the session's builtin functions, not the UDF branch.
Distributed plans are decoded with the serializer's session, which did not
know prom_min/prom_max.

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

* test(promql): cover NaN and rounding semantics

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

* fix(promql): keep the nested count rewrite for PromQL min/max

count(min(x) by (l)) was rewritten by count_nest_aggr only for the builtin
min/max names. prom_min/prom_max return NULL for the same groups, so the
rewrite stays valid. Update plan snapshots for the IEEE comparison, sqrt,
topk and min/max changes.

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

* fix(promql): register prom_min/prom_max in the function registry

Distributed plans split an aggregation into state and merge steps only for
functions whose state function exists in FUNCTION_REGISTRY; prom_min and
prom_max lived in the promql crate, so min/max stopped being pushed down to
datanodes. Move them to common-function and register them before the state
functions are derived.

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

* test: update sqlness results for PromQL float semantics

Plans and column names now show prom_min/prom_max, the NaN guards of
comparisons and sqrt, and the NaN sort key of topk. round() results follow
Prometheus' formula. Distributed min/max keep the state/merge split.

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

* fix(promql): keep column names and NULLs in IEEE comparisons and sqrt

Rewriting comparisons as `a op b AND NOT (isnan(a) OR isnan(b))` changed
TQL output column names and turned NULL operands into true for `!=` and
false for the bool forms. Implement comparisons and sqrt as PromQL UDFs
that propagate NULL and report the column name of the expression they
replace; prom_min/prom_max report min/max column names the same way.

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

* test: restore column names in sqlness results for PromQL float semantics

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

* test(promql): share the prom_min/prom_max groups accumulator with tests

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

* fix(promql): keep distinct names for prom_min/prom_max

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

---------

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-12 05:10:18 +00:00
dennis zhuang 845fc0af9c fix(promql): default a stepless subquery to a one-minute step (#9481)
* fix(promql): default a stepless subquery to a one-minute step

`x[5m:]` evaluated the inner expression at the query step, which is 1s
for instant queries. Prometheus uses the evaluation interval, one minute
by default.

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

* test(promql): cover the default subquery step

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

* test(promql): cover the default subquery step in a range query

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

---------

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-12 05:10:07 +00:00
discord9 28ec3ff5aa fix(table): require complete primary keys for hash partitioning (#9507)
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-10 12:28:47 +00:00
discord9 32e8467cda fix(query): stop reporting region scans as hash partitioned (#9491)
* fix(query): stop reporting region scans as hash partitioned

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

* test(query): use current DataFusion plan downcasts

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

* test(query): match null rendering in join regression

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

* test(query): refresh union plan for unknown scan partitioning

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

* test(query): refresh shared plans after distribution rule removal

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

* test(query): cover semi joins with remote dynamic filtering

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

* test(query): strengthen partitioned merge scan regressions

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

* test(query): remove unused merge scan test helpers

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

* test(perf): measure merge scan hash repartition cost

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

* fix(query): adapt merge scan deserialization to two-arg constructor

Adapt the production and test serializer callers added by #9206 and remove the obsolete partition_cols assertion.

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

* test(query): adapt nested merge scan integration constructor

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

---------

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-10 11:18:46 +00:00
fys 9a57e1195d feat(json2): json_get respect type hints (#9349)
Signed-off-by: fys <fengys1996@gmail.com>
2026-10-10 08:31:18 +00:00
dennis zhuang b913be2af7 fix(promql): return range-vector results for instant queries (#9477)
* fix(promql): return range-vector results for instant queries

An instant query of a range selector or subquery (e.g. `up[5m]`) returned
the internal range arrays, which panicked when the frontend decoded them as
dictionaries. Plan such queries as the selected samples with their own
timestamps, and reject range-vector expressions in range queries like
Prometheus does.

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

* test(promql): keep encode_substrait on a valid range query

A range query of a range-vector expression now fails like in Prometheus.
Wrap the subquery in max_over_time so the case still encodes both
RangeManipulate nodes through substrait.

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

* fix(promql): plan wrapped range-vector roots and reject a zero subquery step

TQL EXPLAIN, ANALYZE and AS alias wrap the root in an extension, which the
range-vector path did not unwrap. A zero subquery step divided by zero.

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

* test(promql): cover wrapped range-vector roots and a zero subquery step

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

* fix(promql): default subquery step and reject range vectors in range queries

A root subquery without a step uses the one-minute default instead of
the query step. The HTTP range query endpoint rejects range-vector
expressions even when start equals end.

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

* test: cover root subquery default step

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

* refactor(promql): align root subqueries with subquery_child_window

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

* fix(promql): return millisecond timestamps for range-vector results

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

* refactor(promql): keep the time index timezone and name its table in errors

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

---------

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-10 07:04:02 +00:00
discord9 28697c9e55 feat(datanode): execute nested MergeScan plans on the datanode (#9206)
* feat(datanode): execute nested MergeScan plans on the datanode

Give a datanode the ability to decode and execute a plan whose MergeScan
carries a nested plan (e.g. an inner MergeScan over a build table), so a
distributed sub-plan pushed to a datanode can itself fan out to other
datanodes. This is the execution/codec capability only; it is not wired
to any user-facing switch and produces no plan by itself.

Codec (src/query/src/query_engine/default_serializer.rs):
- MergeScanAwareSerializer keeps the request's SessionState and the
  engine catalog manager, and decodes a MergeScan payload's tables by
  (catalog, schema, table) name through the engine catalog rather than
  the region-aware request catalog list (which binds every table to the
  request's region and breaks on mismatched columns).

Datanode (src/datanode):
- New DatanodeRegionQueryHandler (region_query.rs) serves region queries
  for the MergeScan nodes of a received plan, resolving region leaders
  via PartitionRuleManager and dispatching to peer datanodes.
- Wire KvBackendCatalogManager + PartitionRuleManager + NodeClients into
  the datanode's catalog/cache registry (with_dist_planner stays false:
  a datanode never rewrites its own plan).

Bug fixes uncovered while wiring this up:
- cache registry: register the table/table_info/table_name/table_route/
  partition_info caches the datanode's catalog manager and partition
  rule manager require (previously missing -> panic on lookup).
- default_serializer: decode_sub_plan bridge no longer drives a future
  on a current-thread runtime from within another executor (spawn a
  dedicated thread + runtime when not on a multi-thread runtime).

Tested by query_engine::default_serializer unit tests: nested MergeScan
round-trip and engine-catalog resolution with mismatched column names.
No user-facing behavior change.

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

* fix(query): keep GreptimeDB function bindings after rebuilding decode state

SessionStateBuilder::new_from_existing collects the function registry
(HashMap) into a Vec and build() re-registers each entry; register_udf
registers aliases before the function's own name and the Vec order is
unstable. Rebuilding the session state to install MergeScanAwareSerializer
after the GreptimeDB functions were registered could therefore rebind a
name/alias (e.g. date_format, an alias of DataFusion's to_char) back to
the built-in function. This path runs for every decoded plan, not only
nested MergeScan ones.

Extract the GreptimeDB function registration into register_greptime_functions
and re-apply it after the final build of both the top-level decode state and
the MergeScan payload state. Add regression tests asserting date_format stays
bound to the GreptimeDB implementation on both paths.

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

* fix(datanode): address review on nested MergeScan capability

Three follow-ups from review of the nested MergeScan execution capability:

1. Layer the datanode caches (P2b). table_cache derives from
   table_info_cache + table_name_cache, and partition_info_cache derives
   from table_route_cache, but they were all registered in a single flat
   CacheRegistry whose invalidate() runs concurrently with no ordering.
   A derived cache could be invalidated and refilled from a base cache
   that had not been invalidated yet, leaving stale metadata. Build the
   base caches and the derived caches as separate layers of a
   LayeredCacheRegistry (reusing the existing layering mechanism) so base
   caches finish invalidating before derived ones. Both the production
   path (cmd) and the test scaffolding now build the same layered
   registry; the flat build_datanode_cache_registry is removed.

2. Don't let a nested MergeScan's internal region query compete for the
   concurrency permit (P2a). An outer query holds its permit until its
   stream is fully consumed, and a nested MergeScan makes the datanode
   issue region queries to peer datanodes as execution stages of that
   same query. If such an inner stage also took a permit, the outer query
   waiting on it would deadlock against its own held permit (or time out
   when max_concurrent_queries > 0). Mark inner DN->DN region queries as
   internal stages: QueryRequest gains an  flag carried over
   the wire in the request header's query_context extensions (reserved
   key, not injectable via hints), and the region server skips acquiring
   a concurrency permit for internal-stage requests on both acquire
   sites. Outer queries still acquire normally.

3. Add an end-to-end capability test
   (tests-integration/.../nested_merge_scan_capability_test.rs): hand-build
   Join(<local probe region>, MergeScan(build regions on both datanodes))
   with mismatched column names and duplicate build keys, ship it to a
   datanode over a real TCP Flight endpoint, consume the stream fully and
   compare the row multiset against the frontend's reference join result,
   assert a real cross-datanode DoGet via an RPC counter, and assert an
   inner region failure fails the whole query. Also migrate the
   with_real_datanode_grpc_addr + DatanodeRpcStats test scaffolding.

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

* fix(query): adapt date_format regression test to the updated DataFusion fork

ScalarUDFImpl's trait bounds changed so its impl can no longer be
downcast via as_any(); assert the function equals the GreptimeDB
implementation instead, which to_char can never satisfy.

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

* test(datanode): cover the internal stage under the concurrency limit

Add an end-to-end regression test for the nested MergeScan capability
under max_concurrent_queries = 1: both datanodes' only permit is held by
the outer queries' streams while their inner MergeScans fetch the build
regions from the same datanodes over real Flight connections. The result
consumption is bounded by a timeout, so a broken link anywhere in the
chain (marking the inner request internal, carrying the marker in the
request header, honoring it at the receiving acquire sites) fails the
test instead of deadlocking.

Also add a minimal with_datanode_options_override hook to the cluster
builder, and fix the test module doc: the outer request is handed to
RegionServer::handle_remote_read in process; only the inner region
queries go over the wire.

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

* test(datanode): cover cold route loads under unrelated invalidations

The table route cache uses InitStrategy::VersionChecked, whose version
counter belongs to the whole CacheContainer rather than to a single
table: any TableId invalidation bumps the shared version, so a cold
load of one table's route retries when an unrelated table is
invalidated concurrently. Document this as a known limitation on the
region query handler, and pin the behavior with an integration test:
run the nested plans while a background task invalidates an unrelated
table id on every datanode, assert the results still match the expected
multiset (retries only add work, they never return a stale route), and
report the observed load amplification via the cache miss counter
(2 loads without invalidations, 8 over three runs with 24 unrelated
invalidations in this run). Also expose the datanode cache registries
on the test cluster so a test can drive the local invalidation path
that the heartbeat handler drives after a metasrv broadcast.

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

* docs(query): trim nested plan decoder commentary

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

* refactor: remove redundant nested scan support plumbing

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

* fix(query): decode nested MergeScan payloads asynchronously

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

* test(query): avoid unnecessary extension name allocation

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

* fix(query): retain extension decoding for manual session states

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

* refactor(datanode): keep nested scan query admission unchanged

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

* fix(datanode): honor query handler factories and trim nested scan fixtures

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

* fix(datanode): retain required cache error import

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

* refactor(query): reuse registered state for nested scan decoding

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

* test(query): verify payload information schema table functions

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

* test(query): retain nested scan runtime and cold-route guards

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

* refactor(query): address nested scan review feedback

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

* fix(session): preserve read preference across region query hops

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

* test(session): verify strict context rejection at Flow ingress

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

* test(query): remove redundant serializer error case

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

---------

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-10 06:49:38 +00:00
discord9 178a0021b8 fix: unify unknown flow option rejection across OSS and enterprise builds (#9319)
* fix(operator): unify unknown flow option rejection across OSS and enterprise builds

Remove the enterprise-gated passthrough that let unknown flow options
through SQL-layer validation. Both OSS and enterprise binaries now reject
unknown flow options with a single 1004(InvalidArguments) error at the
SQL layer, producing identical sqlness output.

The metasrv-side validate_flow_options remains as a defensive check.

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

* fix(flow): use DISTINCT output columns as sink key for streaming flows

When a streaming flow's source table has no primary key, sink key
inference produced an empty key. For SELECT DISTINCT v, the DISTINCT
establishes v as the output key, but the inference only looked at
source primary keys. Combined with the zero-valued placeholder
timestamp, all output rows shared the same storage identity and were
deduplicated into a single row.

Use all non-time-index output columns as the sink key when the plan
is a DISTINCT and no source primary key was inherited.

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

* fix(flow): preserve extension options and complete DISTINCT sink identity

Keep enterprise CREATE FLOW options available to the existing handler and report unknown ordinary options as InvalidArguments with the OSS SQL error text. Infer DISTINCT sink keys from every selected non-time-index output column, including when source key columns survive. Wait for asynchronous mirrors in show_create_flow and exercise 1000 distinct sink rows in the SQL regression.

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

* test(flow): align meta unknown option status assertions

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

* test(flow): use valid aliases in DISTINCT sink regression SQL

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

* test(flow): record complete DISTINCT sink SQL results

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

---------

Signed-off-by: discord9 <discord9@163.com>
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-10 06:45:11 +00:00
dennis zhuang a8e293fd79 fix(promql): accept evaluation times before the Unix epoch (#9480)
* fix(promql): accept evaluation times before the Unix epoch

Prometheus accepts negative timestamps in query parameters; GreptimeDB
rejected them while parsing and would panic converting them in the planner.

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

* test: update flow_tql error for a negative TQL end time

A negative end now parses and fails the end >= start check instead of
the timestamp conversion.

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

* fix(promql): reject evaluation times outside the millisecond range

The signed millisecond conversion cast u128 to i64 unchecked, so a far-past
time wrapped into the future. Reuse the checked conversion from
label_values and return an error instead.

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

* chore: drop an unused import

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

* fix(promql): round query times to milliseconds like Prometheus

Numeric times round the fraction of a second to milliseconds, and
pre-epoch times floor sub-millisecond parts, matching Prometheus'
parseTime and timestamp.FromTime.

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

* fix(promql): round the float fraction before converting to nanoseconds

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

* test: skip the overflow case where SystemTime cannot hold it

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

---------

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-10 05:26:21 +00:00
Weny Xu 6e1a137411 ci: enhance benchmark workflows with version comparisons and diagnostics (#9383)
* ci: update Agent and Traces benchmark runtime

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

* ci: use unrestricted benchmark runtimes by default

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

* ci: retry benchmark image pulls before generating datasets

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

* ci: increase tracesbench defaults to 8 CPU and 32 GiB

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

* ci: configure tracesbench query timeout and update runtime

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

* ci: update tracesbench runtime and configure import scan workers

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

* ci: use shared parallel tracesbench import preparation

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

* ci: update tracesbench runtime to preserve Tempo trace requests

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

* ci: summarize tracesbench query results and lifecycle

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

* ci: show benchmark resource usage and update parallel loader runtime

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

* fix(ci): resolve shared summary module in existing test harness

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

* ci: select tracesbench engines and GreptimeDB image version

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

* ci: update benchmark runtime with parallel Agent preparation

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

* ci: select benchmark runtime registry by cloud provider

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

* ci: retry long-range benchmark image pulls

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

* ci: compare baseline and candidate GreptimeDB benchmarks

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

* ci: pin published dual-version benchmark runtime

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

* ci: prepare agent benchmark indexes with updated runtime

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

* ci: validate agent admission and render traces version comparison

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

* ci: use runtime with traces index preparation

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

* ci: use consolidated runtime for benchmark version reports

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

* test: add license header to benchmark comparison tests

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

* fix(ci): read legacy Tempo ingestion metrics in traces summaries

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

* ci: require tracesbench query plan artifacts

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

* ci: use optimized tracesbench report runtime

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

* ci: update tracesbench runtime to merged report validation

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

---------

Signed-off-by: WenyXu <wenymedia@gmail.com>
2026-10-09 12:39:51 +00:00
discord9 b3c756a7dd perf(promql): reduce timestamp payload allocations and use narrow cursor scans (#9417)
* perf(promql): zero-copy timestamp payload for millisecond zero-offset ranges

RangeManipulate rebuilt the timestamp range payload per sample through
wide-precision i128 conversion even for millisecond input with zero
offset, where the input is already millisecond and nothing changes.
That turned a previously zero-copy path into a per-sample loop plus a
Vec allocation, regressing range-function queries.

Reuse the input timestamp array directly when the time index is already
millisecond and the offset is zero. Other units and non-zero offsets
keep the wide-precision conversion (required so ns/us data near the
native type limits stays correct under large offsets).

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

* perf(promql): vectorize timestamp payload with plain i64

RangeManipulate rebuilt the timestamp range payload per sample through
wide-precision i128 conversion, turning a hot path into a scalar loop
with a Vec allocation. Realistic timestamps and offsets stay far inside
i64, so the wide path is unnecessary.

- millisecond + zero offset reuses the input array with zero copy;
- other units / offsets shift in one vectorized i64 pass (no i128, no
  per-sample Option, no overflow branch).

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

* perf(promql): check timestamp payload overflow via arrow try_unary

Replace the silent wrapping shift with try_unary + checked_mul/checked_add.
checked arithmetic lowers to a per-lane overflow flag so the single pass
still vectorizes over the dense i64 buffer; any overflow short-circuits to
an error instead of silently wrapping to a wrong timestamp. No i128, no
per-sample Option.

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

* perf(promql): vectorize cursor scans with i64 fast path

RangeManipulate::calculate_range and InstantManipulate::manipulate
compared every sample through i128 wide-precision arithmetic. For
realistic timestamps and offsets the shifted values stay far inside
i64, so the wide path was pure overhead.

Split both scans into an i64 fast path (checked arithmetic precomputed
once per batch) and an i128 exact fallback. Any input that would
overflow i64 falls back to the i128 scan, keeping window membership
semantics identical.

Verified: 47/47 range_manipulate + instant_manipulate tests, 275/275
promql crate tests, 1612 randomized cases byte-identical to i128 scan.

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

* refactor(promql): generalize cursor scans and share endpoint precheck

Address review comments on PR #9417:
- Generalize scan_ranges/scan_instants over Nanoseconds (i64/i128),
  eliminating the duplicated scan bodies.
- Extract shared narrow_scan precheck used by both RangeManipulate and
  InstantManipulate, with identical checked endpoint logic.
- Trim doc comments to one line; fix factual errors about fallback
  trigger (2262 for ms/s, not native type limits).

Semantics unchanged: same cursor algorithm, same overflow fallback,
same i64-vs-i128 selection, same stale/null/tie handling.

Verified: 47/47 range_manipulate + instant_manipulate tests, clippy clean.

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

* perf(promql): vectorize payload with try_unary and add scan carrier bench

Replace the per-sample i128 payload loop with a single arrow try_unary
pass: i128 intermediate holds any native timestamp shift, only the final
millisecond result must fit i64. This is semantically equivalent to the
original i128 implementation (no silent wrap, no per-sample branching).

Add cursor_scan_carriers A/B benchmark: i64 fast path vs i128 exact
fallback on four workloads. Add five deterministic differential tests
pinning scan_ranges::<i64> and scan_ranges::<i128> to be pointwise
equivalent on boundary shapes (sub-ms samples, fallback triggers,
nanosecond extremes, seeded precision matrix).

Bench (N=4096, 15s cadence):
- payload wall_time 20x15s: 74.94us -> 48.98us (-35%)
- payload wall_time 240x15s: 102.44us
- scan i64 fast path: 10.97us vs i128 19.84us (1.81x)

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

* perf(promql): restore zero-copy payload for ms+offset=0 and fix comment

Restore the zero-copy fast path for millisecond timestamps with zero
offset, guarded by the input column's exact data type (Timestamp
(Millisecond, None)) rather than self.time_unit. The old time_unit guard
would accept a tz-carrying millisecond column and produce a dictionary
value type disagreeing with the output schema; the datatype guard is the
correct check.

Also fix the comment: arrow's try_unary is a fallible kernel that LLVM
cannot auto-vectorize; the win over the hand-written loop comes from
eliminating the per-sample Option/Vec and branches, not SIMD.

Add a regression test with a timezone-carrying millisecond index to
prove it falls back to the try_unary path.

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

* perf(promql): replace try_unary with unary + Cell overflow flag

Arrow's try_unary is a fallible kernel that LLVM cannot auto-vectorize.
Replace it with the infallible unary kernel: the closure is a plain
i64 -> i64 mapping that sets a Cell<bool> on overflow instead of
returning Err per sample. The flag is checked once after the loop.

This removes the per-lane Result construction and lets LLVM vectorize
the shift+truncate sequence. Bench (N=4096, 15s cadence):
- window4x15s: 42.12us -> 28.77us (-32%)
- window20x15s: 48.98us -> 27.68us (-43%)
- window240x15s: 102.44us -> 27.61us (-73%)
- regular_sparse: 90.04us -> 29.20us (-68%)

All cases now run at ~27-29us regardless of window size; the zero-copy
path dominates for ms+offset=0.

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

* perf(promql): dispatch payload conversion on null_count; drop wall-time bench

Null-free batches (dominant PromQL case) take arrow's vectorizable unary
kernel with a Cell<bool> overflow flag; nullable batches take try_unary so
a NULL slot's arbitrary underlying value cannot raise a false batch-wide
overflow error. Add a regression test exercising both paths.

Remove bench_range_manipulate_wall_time (all-ms zero-offset cases only
exercised the zero-copy path, measuring nothing about the conversion) and
its orphaned helpers; restore the cursor-scan bench helpers it shared.

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

* fix(promql): keep nullable timestamp cursor scans on i128

NULL slots may contain unordered arbitrary values, so the endpoint-only
narrow-scan proof cannot bound their interior timestamp arithmetic. Both
range and instant scans now attempt i64 only for null-free timestamp arrays
and otherwise retain the existing exact i128 path.

Add focused interior-NULL regressions through both active entrypoints,
checking concrete selections and output against the existing wide scan.
The null-free fast path and timestamp payload conversion are unchanged.

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-09 10:45:15 +00:00
Niladri Adhikary 0dde3a7b70 fix(promql): return no point for zero-interval irate/idelta (#9455)
fix(promql): irate/idelta return no point for a zero-ms sample gap

Signed-off-by: Niladri Adhikary <niladrix719@gmail.com>
2026-10-09 10:03:36 +00:00
fengyue-xve 764ab8bc1a test(promql): cover label_replace normalization with distinct series (#9485)
The duplicate-label-set rejection must not fire when a label rewrite keeps
every series distinct through another label. Normalizing mixed histogram
boundary representations (`10.0` -> `10`) while `instance` still differs is
the false positive reported in #9444. The check already accounts for it
since it became a per-timestamp row count instead of a planning-time
rejection, but no fixture covered the positive case. Add one, together
with the aggregation variant that produces a genuine duplicate and must
still fail.

Signed-off-by: fengyue-xve <1759519137@qq.com>
2026-10-09 09:59:20 +00:00
dennis zhuang 6da5352c53 fix(promql): keep vector labels when a computed scalar is on the right (#9478)
* fix(promql): keep vector labels when a computed scalar is on the right

`metric + time()` or `metric * scalar(other)` planned the join with the
scalar side's context, which has no tags, so every series collapsed into
one unlabeled series. Use the vector side's context when the right operand
is scalar-typed.

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

* test(promql): update results for labels kept with a computed scalar on the right

v * pi() and v + time() now keep the vector's labels and time index name,
as in Prometheus.

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

* fix(promql): qualify columns of an unnamed operand in vector joins

A comparison filter's projection has no table name, so after the join its
time index resolved unqualified and was ambiguous when the other operand had
the same column. Also keep the empty-metric branch unchanged for scalar
left operands.

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

* test(promql): cover comparison filters joined with a scalar

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

---------

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-09 07:16:42 +00:00
discord9 0d2cb1aa7e fix(promql): align subqueries to the absolute evaluation grid (#9400)
* fix(promql): align subqueries to the absolute evaluation grid

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

* docs(promql): streamline subquery comments

Remove redundant arithmetic narration and restated test comments. Keep grid, fallback, validation and overflow contracts, and preserve all regression tests and assertions. Regenerate SQLness output only for comment echoes.

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

---------

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-09 03:50:54 +00:00
fys fe8a647bc8 fix(json2): support settings in ALTER TABLE ADD COLUMN (#9472)
* fix(json2): support settings in ALTER TABLE ADD COLUMN

Signed-off-by: fys <fengys1996@gmail.com>

* refactor(sql): reuse Column for ADD COLUMN definitions

Signed-off-by: fys <fengys1996@gmail.com>

* chore: better method name

* refactor(sql): share column parsing between CREATE TABLE and ADD COLUMN

Signed-off-by: fys <fengys1996@gmail.com>

* chore: simplify tests

Signed-off-by: fys <fengys1996@gmail.com>

* fix(sql): accept trailing commas in ALTER TABLE ADD COLUMN

Signed-off-by: fys <fengys1996@gmail.com>

* fix(sql): preserve ALTER TABLE ADD COLUMN compatibility

Signed-off-by: fys <fengys1996@gmail.com>

---------

Signed-off-by: fys <fengys1996@gmail.com>
2026-10-09 03:17:22 +00:00
fys c46fbde9ac fix(json2): preserve metadata when adding columns via alter table (#8696)
* fix(json2): support adding columns via alter table

Signed-off-by: fys <fengys1996@gmail.com>

* fix(json2): scope missing-column null handling to JSON2

---------

Signed-off-by: fys <fengys1996@gmail.com>
2026-10-08 09:23:25 +00:00
dennis zhuang 526dd48bda fix(promql): restore exact filtering for nullable label matchers (#9418)
* fix(promql): restore exact filtering for nullable label matchers

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

* test: verify compound filter semantics and last-row selection

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

* test: consolidate label filter regression coverage

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

* refactor: separate filter evaluation and timestamp adaptation

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

* refactor: inline predicate and timestamp tests

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

* refactor: keep direct IN predicates outside simple filters

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

* fix: coerce partition bounds before simple filtering

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

* test: cover partition bound overflow after timestamp widening

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

---------

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-08 07:43:25 +00:00
dennis zhuang b78dc4b33b test(sqlness): wait for mirrored inserts before flushing streaming flows (#9424)
Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-10-08 02:30:37 +00:00