This is the detailed node reference. Start with the Pre-ASAP IR concept for purpose and the compact catalog.
ASAPPlanner has one operator IR before and after ASAP optimization, defined in
crates/types/src/ir/. "Pre-ASAP" is not a separate type: it is this IR as a front end
emits it, before any ASAP operator has been introduced. This document covers what every
plan shares — the node, the schema, scalar expressions, how front ends produce the DAG, and
the catalog of ordinary (NonASAPOp) operators. The ASAP operators, execution timing and
the exported wire form are described in the Post-ASAP IR
document; the two do not repeat each other.
The goal of the pre-ASAP form is to represent operations from different query languages in a single representation, and make it easier to analyze how/where ASAP primitives can be used. Only operations that are semantically relevant to answering the query and selecting an ASAP primitive need to become first-class nodes here.
- Expose the query semantics that affect summary applicability, correctness, and cost.
- If an operation changes presentation but does not change the semantic summary intent, it does not need to be represented.
- Equivalent SQL, PromQL, and future-language queries should produce the same intent shape.
Notes: SQL and PromQL use different schema models. SQL typically uses a closed schema, where tables, columns, and types are predefined, while PromQL uses an open (schemaless) schema, where metrics and labels can evolve without a fixed table schema. Closed schemas provide stronger structure and validation; open schemas provide greater flexibility and makes it easier to evolve or ingest diverse data, but can require more care around naming conventions, label cardinality, and query consistency.
A plan is a DAG of Rc<OperatorNode> (crates/types/src/ir/node.rs). Nodes are immutable
and shared through Rc: a structurally identical sub-DAG referenced from several parents is
one node, and that pointer identity is what CSE, target discovery and plan assembly key on.
pub struct OperatorNode {
pub operator: Operator, // NonASAP(NonASAPOp) | ASAP(ASAPOp)
pub result_kind: OperatorResultKind, // Relation | InstantVector | RangeVector | State | Scalar
pub schema: Schema, // output schema, derived at construction
pub guarantee: Option<ResultGuarantee>, // None until accuracy assessment establishes one
pub timing: Option<ExecutionTiming>, // None until a materialization assignment is applied
}operatoris the operation. A front-end DAG contains onlyOperator::NonASAPnodes;OperatorNode::expect_non_asap()relies on that.result_kindis the output category, derived from the operator and its inputs. Matching column schemas do not make categories interchangeable (a range vector is not an instant vector).schemais derived byOperatorNode::new(operator); it fails when the schema cannot be derived (a column reference out of range, a reserved ASAP operator). ASAP planning may retain a more specific schema throughOperatorNode::with_schema.guaranteeisNoneuntil accuracy assessment establishes one;Nonenever means exact.timingisNonein every front-end DAG and every candidate. It is written byir::timing::apply_materialization_timings(see the Post-ASAP IR document); export rejects an untimed node.
OperatorNode::children() returns the operator's inputs in field order followed by the
operator nodes its scalar expressions read (see "Scalar expressions"). Every DAG traversal —
map_children, reachable, contains_asap, CSE, export — follows that same list.
OperatorNode::validate_structure() checks every operator's input contract, scalar typing
against the owning operator's input schema, and that each retained schema agrees with the
derived one.
One Schema type (crates/types/src/pre_asap/schema.rs) describes every edge, whether it
carries rows or summary state:
pub struct Schema {
pub fields: Vec<Field>, // positional; every ColumnId indexes into this
pub time_index: Option<ColumnId>, // the time axis, if any (PromQL leaves always have one)
pub unique_keys: Vec<Vec<ColumnId>>,
pub closed: bool, // true: these are all the columns; false: open (schemaless) superset
}
pub struct Field {
pub name: String,
pub dtype: FieldDataType, // Plain(DataType) | ExactAggregate(..) | Sketch(..) | Sample(..) | Wavelet(..) | StatModel(..)
pub nullable: bool,
pub table: Option<String>, // SQL table/alias qualifier; None for PromQL labels
}A pre-ASAP field is always FieldDataType::Plain(DataType). The other variants carry summary
state and only appear below an ASAP operator; a scalar expression that reads such a field is a
typing error (ScalarExpr::scalar_type), because state must be read out before a value can use
it. Column references are positional ColumnIds (indexes into the input schema), never names.
Schema::has_unique_key() is the legality gate CSE uses: a non-ASAP producer is only shared
across consumers when its row identity is provable.
Value computation lives in ScalarExpr (crates/types/src/ir/scalar.rs), owned by value
by an operator field: Scan.predicates, Filter.pred, Join.pred, Project.cols[i].expr,
Aggregate.having, Sort.keys[i].expr, SQLWindowFunc.args/order_by, PromqlRelabel.value,
Values.rows, and QueryRoot::Scalar and PromqlVectorFromScalar. A scalar expression never
produces a table and is never a node of the DAG; it is evaluated against the input schema of
the operator that owns it.
Variants: Column(ColumnId), Literal(ScalarValue), Negative (unary minus), Compare,
BoolAnd / BoolOr (flat conjunction/disjunction), Not, IsNull / IsNotNull, Cast
(with try_cast), InList, FunctionCall { name, args }, Arithmetic, Case,
CurrentTimestamp (SQL NOW()), EvalTimestamp (PromQL time()), and four
plan-reading variants that reference an operator node:
| Variant | Meaning |
|---|---|
PromqlScalarFromVector(Rc<OperatorNode>) |
PromQL scalar(v): the single sample of an instant vector, NaN otherwise |
ScalarSubquery(Rc<OperatorNode>) |
Uncorrelated SQL scalar subquery: one column; zero rows is NULL, more than one row is an error |
Exists { subquery, negated } |
SQL [NOT] EXISTS (subquery) |
InSubquery { expr, subquery, negated } |
SQL expr [NOT] IN (subquery) over a one-column relation |
These are the only operator references inside a scalar tree. ScalarExpr::operator_refs()
lists them, NonASAPOp::children() appends them after the operator's own inputs, and
canonicalization lowers the three SQL subquery forms to joins (see below), so a canonical SQL
DAG contains none of them. PromqlScalarFromVector survives canonicalization: its referenced
vector is a real plan dependency, exported as a ScalarRef edge.
Compare, Arithmetic and Negative carry an ExprSemantics (Sql or Promql): both
languages use Float64, so the result type alone does not preserve NaN, ordering or error
rules, and the executing engine needs to know which language's rules apply.
Wrapper types: Predicate(ScalarExpr), ProjectItem { alias, expr },
SortKey { expr, ascending, nulls_first }.
A front end never constructs OperatorNodes directly. It builds a name-based tree in
crates/frontend-common — UnresolvedOp / UnresolvedScalar, a mirror of NonASAPOp /
ScalarExpr in which every column reference is a ColumnRef and a PromQL Scan has no schema
yet — and calls asap_frontend_common::resolve_root, which does three things in order:
- Resolution — a bottom-up walk that binds every
ColumnRefto a positionalColumnIdagainst the derived schema of the already-resolved child. A schemaless (PromQL) leaf gets its binding schema fromSchemaResolver, built from the names the query references.Join/SetOpsides and the operators referenced from scalar positions are each bound as a root in their own scope; aBinaryOpside additionally inherits the label names its enclosing scope references. - Schema derivation — each
OperatorNode::newderives the node's output schema and result kind from the operator and its children. - Canonicalization —
asap_types::ir::canonicalize::canonicalizeerases structural differences between semantically identical queries: it promotes an additiveLimit { Sort { Aggregate } }ranking to theAggIntent::TopKheavy-hitter shape, and lowersEXISTS/NOT EXISTS/IN (subquery)predicates toJoin { Semi | Anti }and a scalar subquery to aJoin { Cross }plus column reference. The pass is idempotent and keeps the pointer identity of every untouched sub-DAG.
The result is Rc<OperatorNode>. lower_promql_workload, lower_sql / lower_sql_dialect /
lower_sql_batch and lower_metricsql all return it.
Workload search then runs structural CSE (asap_types::ir::cse::share_common_sub_dags) once
across every root: bottom-up hash-consing where the structural hash is only a filter and the
typed PartialEq decides sharing, following scalar references like any other input, and
gated by Schema::has_unique_key() for non-ASAP producers.
Schema holds Field metadata (name, type, nullability, qualifier) and no
values; an unresolved ColumnRef resolves to a positional ColumnId within one
schema. The design, including how the same position selects a runtime value, is
in Schema and physical data for ASAP primitives.
Grouped to match the sections below — common relational nodes first, then the nodes specific to one source language.
Aggregate— collapses input rows into fewer output rows via a reduction and aggregate intents.
TimeRange— temporal selection over a time-series input (PromQL instant lookback or[5m]range selector).TimeShift— shifts when a selector is evaluated (PromQLoffset/@).PromqlSubquery— re-evaluates an instant-vector expression over a range at a given step.
Relational nodes — common to both SQL and PromQL
Scan— identifies the logical data source.Values— SQLVALUESrows, or the one empty row of aSELECTwithoutFROM.Filter— restricts rows using a predicate.Project— column projection (SQLSELECTlist).BinaryOp— arithmetic / comparison / set composition of two inputs.Sort— generic (non-heavy-hitter) order-by, optionally per-group.Limit— caps the row count, with an offset, optionally per-group.Dedup— row-level deduplication.Join— logical join of two inputs.SetOp— SQL's typed set operations (UNION/INTERSECT/EXCEPT).Concat— exact, untypedUNION ALLof union-compatible branches.
QueryRoot::Scalar— a standalone scalar expression, without an operator node.PromqlVectorFromScalar— promotes a scalar to a label-less instant vector.
PromqlRelabel— per-series label rewrite (PromQLlabel_replace/label_join).PromqlInfoEnrich— left-join label enrichment from an info metric.PromqlSeriesSample— keeps a subset of whole series, not a reduction.
SQLWindowFunc— SQL analytic window function (OVER (...)).
PromQL time() and scalar(v) are scalar expressions (ScalarExpr::EvalTimestamp,
ScalarExpr::PromqlScalarFromVector), not nodes.
Collapses input rows into fewer output rows via a reduction plus a
list of aggregate intents (measures).
Aggregate.reduction is a Reduction — richer than a plain SQL GROUP BY — one of:
-
Reduce(GroupKeys)— a cross-row reduction: group by some columns, or group by every column except some listed ones.GroupKeysholds positional column references (ColumnIds — indexes into the input schema, not column names) and carries aby/withoutflag, not just a plain list:by(keys)— group by exactly these columns (SQLGROUP BY, PromQLby(...)).without(keys)— group by every column except these (PromQLwithout(...)); the excluded columns are stored but the full set of all columns stay open (because PromQL is schemaless), resolved at runtime against the actual input schema.none()— an empty GroupKeys list, i.e. a global (ungrouped) reduction. This is different fromPerEntitybelow.
Aggregate( reduction = Reduce(by = [service, region]), // GROUP BY service, region measures = [Count], output_names = [], having = None, child = ... ) -
PerEntity— no collapsing multiple input rows/entities into fewer output rows: each input entity keeps its own output row (the value is still recomputed by the agg intent, e.g.Rate), for a computation with noby(...)clause to attach to.PerEntityis different frombyfor all columns, because in PromQL, it is schemaless and you don't know all columns beforehand. E.g. PromQLrate(http_requests_total[5m]), which has one rate value per input series:Aggregate( reduction = PerEntity, measures = [Rate], output_names = [], having = None, child = TimeRange(range = 5m, kind = Range, child = Scan("http_requests_total")) )
Each entry in measures names one statistic to compute. The vocabulary is wider than a minimal
aggregate algebra needs, because it also covers PromQL's range-vector functions and
native-histogram accessors — this list is representative, not exhaustive:
Count, Sum(col), Min(col), Max(col), Avg(col), StdDev(col), Variance(col),
Quantile(col, q), TopK(k), Cardinality(cols), PearsonCorr(left, right) // data-model-agnostic
Rate, Increase // counter derivatives
Changes, Delta, IDelta, Deriv, Resets,
PredictLinear(seconds), DoubleExpSmoothing(sf, tf) // range-vector functions
HistogramCount, HistogramSum, HistogramAvg, HistogramStdDev,
HistogramStdVar, HistogramFraction(lo, hi) // native-histogram accessors
HistogramQuantile(q, le) // classic-bucket quantile
Math(func) // element-wise transform
HistogramQuantile { q, le } names its bucket-bound column le. PromQL's
Aggregate groups it without([le]), so one histogram is the set of series
that differ only in le. The SQL asap_histogram_quantile bridge reads one
histogram over all rows.
PearsonCorr { left, right } has two value inputs. Both
references resolve to positional column IDs, and input_cols() exposes both
dependencies. SQL lowering projects both arguments, preserving
expressions, casts, and qualified join columns. The result is nullable Float64,
with pairwise null handling owned by the executing engine. It remains exact:
finalized correlation coefficients cannot be combined as scalar rollups, and no
sketch or maintained correlation accumulator is selected. Physical costing accepts
it as a hash aggregate with provider-supplied accumulator size.
Cardinality { cols, accuracy } carries a list, not one column. One entry is
SQL COUNT(DISTINCT col); several count distinct tuples
(COUNT(DISTINCT a, b)), which is not the distinct count of any one of them.
Empty is the PromQL convention "the sample value" (count_values,
distinct_over_time). Serialized intents reject unknown fields: legacy
Cardinality payloads containing col must be migrated to cols before loading
(col: n becomes cols: [n], and col: null becomes cols: []). Omitting both
fields still selects the implicit sample input.
input_cols() is the only column accessor on AggIntent — an intent's arity is
its own business, so no consumer can ask for "the" input column of an aggregate
that reads two and silently receive one leg of it. That mattered concretely:
before Cardinality took a list, SQL lowering dropped every argument after the
first, reporting single-column cardinality as tuple cardinality.
Realization is the single-column one with a wider item: a tuple becomes a
SummaryInputExpr::Tuple, which the distinct-count sketches (HLL, KMV)
hash as one value. UnivMon is withheld from a tuple — it estimates frequency
moments over a single value stream. At AccuracyTarget::Exact the node stays a
logical pass-through at any arity. Each SQL argument must be a bare column,
qualifier preserved so a tuple over a join resolves to the correct side; an
expression argument is rejected rather than reduced over a probe column.
SQL corr currently rejects DISTINCT, aggregate ORDER BY,
explicit null treatment, and window usage (OVER); a FILTER clause becomes the
measure's own predicate (see filters below). Further two-input statistics
(covar, the regr_* family) would each add their own variant, following the
StdDev / Variance precedent, once they have explicit output and realization
semantics.
Additional measures can be added when there is a stable semantic distinction and a meaningful summary implementation.
Fields:
reduction— how rows are grouped/collapsed; see "Reduction" above.measures— the aggregate intents to compute; see "Measures" above.output_names— output column name per entry inmeasures; a non-empty entry overrides the synthetic default — SQL threads DataFusion's generated name (e.g."sum(metrics.bytes)") here so an enclosingProjectcan resolve the aggregate output by the name it references.filters— one optional row predicate per entry inmeasures, with SQLFILTER (WHERE …)semantics (#466): only rows wherefilters[i]isTRUEupdatemeasures[i]; groups are still formed from every row. It is positional againstchild's output (likeFilter.pred), not against the aggregate's output likehaving. Empty means no measure is filtered; that is the only spelling of "unfiltered" a resolved DAG carries, so[None, None]is normalized to[].having— an optional post-aggregation filter predicate (SQLHAVING).child— the input being aggregated.
Example for filters:
SELECT l_shipmode, count(CASE WHEN l_returnflag = 'R' THEN 1 END), sum(l_quantity)
FROM lineitem GROUP BY l_shipmodeis one scan and one grouping, so it is one Aggregate. The conditional count is a plain
Count whose filter is the CASE condition; the sum is unfiltered:
Aggregate(
reduction = Reduce(by = [l_shipmode]),
measures = [Count, Sum(l_quantity)],
filters = [Some(l_returnflag = 'R'), None],
child = Scan("lineitem"),
)
The SQL front end fills filters from an explicit FILTER (WHERE p), from
count(CASE WHEN p THEN x END) (p, plus x IS NOT NULL when x is nullable), and from
count(expr) over any other nullable expr (expr IS NOT NULL), because canonical Count
counts rows and never consults its argument. A filtered measure has no summary binding yet:
asap-aware-mapping retains such an Aggregate as an ordinary exact sub-DAG, and canonicalization does
not promote a filtered count ranking to a heavy-hitter TopK.
Example for having:
SELECT srcip, COUNT(*) AS cnt FROM packets GROUP BY srcip HAVING COUNT(*) > 10What the field is for is putting the cnt > 10 predicate directly on the Aggregate that
produces cnt, instead of behind a separate Filter:
Aggregate(
reduction = Reduce(by = [srcip]),
measures = [Count],
output_names = ["cnt"],
having = Some(cnt > 10),
child = Scan("packets"),
)
Rules/Invariants: A filtering predicate will be passed to at the lowest node (closer to the leaves) in the AST/DAG that can express it — Scan.predicates,
then Aggregate.having, then Filter as the fallback — so its constraint is visible at
the node it actually applies to, not behind an opaque wrapper, once summary binding reads it.
The upper nodes (closer to the root) in the AST/DAG can still have a Filter node with the same condition. This intentional duplication is for Summary related translation and optimizations.
For example, SELECT srcip, COUNT(*) AS cnt FROM packets GROUP BY srcip HAVING COUNT(*) > 10
pins cnt > 10 to the lowest node that can express it, Aggregate.having:
Aggregate(
reduction = Reduce(by = [srcip]),
measures = [Count],
output_names = ["cnt"],
having = Some(cnt > 10),
child = Scan("packets"),
)
but the DAG can still carry the same condition as a wrapping Filter near the root:
Filter(
pred = cnt > 10,
child = Aggregate(
reduction = Reduce(by = [srcip]),
measures = [Count],
output_names = ["cnt"],
having = Some(cnt > 10),
child = Scan("packets"),
),
)
Both are valid at once, and neither is derived from the other: having is the canonical
spot a summary-aware pass reads to decide whether Aggregate can bind to a summary, while
the outer Filter is what a plain logical evaluator runs without knowing having exists. The duplication is forward-looking groundwork for
once HAVING-aware summary binding lands.
Neither direction of that push-down is enforced yet: the SQL front end doesn't populate
having from a real HAVING clause (#201), and canonicalization doesn't fold an existing
Filter { child: Aggregate { having: None, .. } } into Aggregate { having: Some(..), .. }
either (#204).
Temporal selection over a time-series input. Kept different from Filter to treat time as an
explicit concern. kind records which samples a PromQL selector reads:
TimeRangeKind::Instant— an instant selector:rangeis the lookback horizon and the latest eligible sample per series is selected (the planner injects the declaredsample_intervalof the selector's metric around a bare selector).TimeRangeKind::Range— a range selector (m[5m]): every sample in the window.
rate(http_requests_total[5m])
Fields:
range— how far back to look (the PromQL[5m]duration, or the instant lookback).kind—InstantorRange.child— the input the range applies to.
PromQL offset / @ time shift on a selector — a pass-through wrapper that moves when
the child is evaluated but leaves its schema unchanged.
up offset 5m
Fields:
shift— the offset/@anchor to apply (moves whenchildis evaluated).child— the selector being shifted.
PromQL sub-query syntax <expr>[range:resolution] — a logical pass-through that lets a
range function apply to the result of an already-evaluated instant-vector expression at a
given step resolution.
avg_over_time(up[5m:1m])
Fields:
range— how far back the sub-query evaluates.resolution— the step between evaluated points;Nonedefers to the default step.child— the instant-vector expression being re-evaluated over the range.
Identifies the logical data source.
Scan(
source = "metrics"
)
PromQL metric selection and SQL FROM clauses can both map into Scan when they denote
the same logical data domain.
Fields:
source— the logical data source (a table name or PromQL metric selector).predicates— row-level filters pushed all the way down to this scan (Rules/Invariants rule 1): PromQL label matchers and pushed-downWHEREconjuncts.schema— the binding schema every positional column reference in the tree resolves against. A catalog-backed SQL leaf carries its catalog schema; a PromQL leaf carries the usage-derived schemaSchemaResolverbuilt from the labels the query references.
SQL VALUES rows, or the one empty row of a SELECT without FROM
(SELECT 1 + 1). Row expressions have no input-column scope.
Fields:
rows— oneVec<ScalarExpr>per row.schema— the output schema of the rows.
Restricts the logical input using predicates.
Filter(
input = Scan("metrics"),
predicate = region = "us-east"
)
Filters are first-class because they can change which summaries are applicable. A summary for an entire dataset is not necessarily sufficient to answer the same intent under an arbitrary predicate.
Note (Rules/Invariants rule 1): a predicate pushes down to the lowest node that can express it. A
WHERE/label-matcher predicate directly on a base table scan lands in Scan.predicates, not
a Filter node. A predicate directly over an enclosing Aggregate's own output — SQL
HAVING, or an equivalent derived-table WHERE — belongs in that Aggregate's having
field instead (see Aggregate's having field above), not a Filter:
SELECT * FROM (SELECT srcip, COUNT(*) AS cnt FROM packets GROUP BY srcip) t WHERE cnt > 10Filter genuinely survives once neither applies — e.g. a predicate over a computed column
that's neither a base scan column nor an aggregate output:
SELECT * FROM (SELECT srcip, bytes_in + bytes_out AS total FROM packets) t WHERE total > 500A Filter whose predicate contains EXISTS / NOT EXISTS / IN (subquery) does not
survive canonicalization: the conjunct becomes a Join { Semi | Anti } under the remaining
predicate.
Fields:
pred— the row-level predicate to apply.child— the input being filtered.
π — column projection (SQL SELECT list). No PromQL equivalent: PromQL never subsets
columns, so this node is SQL-only in practice today.
SELECT srcip, dstip FROM packetsFields:
cols— the output column list (expression + optional alias per column).qualifier— a table alias re-qualifying every output column, for a derived table;Nonefor an ordinarySELECTlist.child— the input being projected.
Arithmetic / comparison / set composition of two operands. PromQL binary operators between two vectors,
two vectors. Mixed vector/scalar arithmetic uses Project; non-bool comparison uses Filter. Standalone scalar expressions are QueryRoot::Scalar.
up > 1
Fields:
operator— aBinaryOperator { kind, vector_match, checked_relative_division, checked_finite_division }:kind—BinaryOpKind::Arithmetic(..),Compare(..)orSet(..)(PromQLand/or/unless).vector_match— PromQL vector-matching modifiers (on/ignoring,group_left/group_right);Noneoutside PromQL and the only supported value today.checked_relative_division/checked_finite_division— typed division guards set by summary planning, never by a front end (see physical-plan integration).
return_bool— the PromQLboolmodifier: a comparison returns0/1instead of filtering. Valid only for comparison operators.lhs— the left operand.rhs— the right operand.
Generic order-by for non-heavy-hitter cases. partition_by makes the ordering per-group —
the home for PromQL topk by (...)/SQL ... OVER (PARTITION BY ...)-style grouped ranking.
sort_desc(up)
Fields:
keys— the ordering expressions and direction (SortKey).partition_by— grouping keys that make the ordering per-group instead of global; empty = a single global order.child— the input being ordered.
Caps the row count, with an offset. SQL LIMIT n [OFFSET o]; also paired with Sort for
PromQL's generic (non-heavy-hitter) topk/bottomk.
topk(3, up)
Fields:
n— the maximum number of rows to keep;Noneis offset-only.offset— how many leading rows to skip first.partition_by— applies the limit per group (PromQLtopk by (..)); empty = global.child— the input being capped.
δ — row-level deduplication (SQL SELECT DISTINCT). Distinct from
AggIntent::Cardinality (COUNT(DISTINCT col), COUNT(DISTINCT a, b)), which
collapses to a single number — Dedup still returns multiple rows.
SELECT DISTINCT srcip, dstip FROM packetsFields:
cols— the columns to dedup on; empty = dedup on every column.child— the input being deduplicated.
Logical join; the physical strategy (hash/merge/broadcast) is picked downstream of the planner. SQL JOIN,
and the shape canonicalization lowers subqueries to.
SELECT u.prefix FROM bgp_updates u JOIN bgp_rib_state r ON u.prefix = r.prefixFields:
kind— the join type (Inner/Left/Right/Full/Cross/Semi/Anti). A semi/anti join outputs the left input's columns alone, but its predicate resolves againstleft ++ right.pred— the join condition.left— the left input.right— the right input.
SQL's typed set operations — UNION/INTERSECT/EXCEPT — as opposed to Concat's untyped
concatenation. UNION (dedup) further wraps a SetOp in a Dedup; UNION ALL does not.
SELECT srcip FROM packets UNION ALL SELECT dstip FROM packetsFields:
kind— which set operation (union/intersect/except).all— whether duplicates are kept (ALL) or removed.left— the left branch.right— the right branch.
⊕ — exact, n-ary UNION ALL of independent, union-compatible branches; rows concatenate,
never dedup. Used when a single Aggregate can't express the shape — the canonical case is
PromQL histogram_quantiles (one branch per φ, each its own HistogramQuantile reduction
relabeled with its le value) — and SQL ROLLUP/CUBE/GROUPING SETS (one branch per
grouping level). The output schema is the first child's.
histogram_quantiles(rate(http_request_duration_seconds_bucket[5m]), "le", 0.5, 0.9)
Fields:
children— the union-compatible branches to concatenate; must be non-empty.discriminator_unique_key— an optional caller-proven compound unique key(discriminator, inner_key)over the output; nothing verifies the claim.
QueryRoot distinguishes an operator result from an owned ScalarExpr. It is
an API root discriminator, not an operator. 2, time(), and
scalar(sum(up)) + 1 therefore introduce no constant-wrapper nodes.
Use lower_promql_query_workload for mixed scalar/vector workloads. The
operator-only convenience API rejects standalone scalar roots. ParsedWorkload
retains each scalar's workload index; PlanOutput::roots() returns all results
in workload order. Scalar plan reads remain exact and retain their operator
references; summary selection currently operates on operator roots.
up * 2 projects the sample expression while retaining time and full series
identity, removing the metric name. up > 0 and 0 < up filter the vector and
retain its sample and name. up > bool 0 projects a zero-or-one Case.
Open label schemas acquire a full runtime series-identity field before this
lowering. The runtime must populate that field with all labels.
The scalar→instant-vector bridge — PromQL vector(s). Promotes a scalar expression to a
single label-less series carrying that value at every step, e.g. for dead-man's-switch
patterns (up or vector(0)).
vector(1)
Fields: a single unnamed ScalarExpr — the scalar being promoted to a vector.
ρ — a per-series label rewrite. PromQL label_replace/label_join; every row passes
through unchanged except for the destination label, whose new value is computed from the
child's label columns.
label_replace(up, "foo", "$1", "bar", "(.*)")
Fields:
dst— the label being written.value— the scalar expression computing the new label value from the child's labels.child— the input series being relabeled.
PromQL info(v, [selector]) — left-join label enrichment. Each series in the child is
enriched with labels from the matching info metric(s) (target_info by default).
info(up)
Fields:
selector— matchers picking which info metric(s) to enrich from; empty = the defaulttarget_info.child— the input series being enriched.
Series-sampling selection — PromQL limitk/limit_ratio. Keeps a subset of whole series
per group (or globally); not a ranking (TopK) and not a reduction, since the output schema
equals the child's.
limitk(3, up)
Fields:
by— grouping keys the sample is taken within; empty = a global sample.kind— the sampling strategy (LimitK/LimitRatio) and its parameter.child— the input series being sampled.
SQL analytic window function: func(args) OVER (PARTITION BY ... ORDER BY ...). Output
schema is the child schema plus one new column for the window expression's result.
SELECT srcip, LAG(time) OVER (PARTITION BY srcip ORDER BY time) FROM packetsFields:
func— the window function (e.g.LAG,RANK).args— the function's operand expressions; empty for rank-only functions.partition_by— grouping keys the window is computed within.order_by— the ordering the window function reads.frame— the optional window frame.output_name— the name of the new output column.child— the input the window function is computed over.