Summary
The ASAPQuery query engine should support nested query functions — applying a function on top of a subquery — for both PromQL and SQL. This issue tracks the testing and implementation of general nested-query support.
What the architecture already supports (grounding)
Nesting is structural at L3: intent_algebra::QueryExpr is a recursive, box-owned tree, and every operator already accepts an arbitrary child. The pieces that consume it are recursive too, so most of the "nesting" plumbing is already in place:
- L3 IR (
crates/core/src/intent_algebra/query_expr.rs) — Aggregate { child }, Sort { child }, Limit { child }, Subquery { child }, BinaryOp { lhs, rhs }, etc. all box an arbitrary subtree. Schema derivation (output_schema_in) recurses and already handles a cross-series aggregate freezing an open schema to closed, per-series (label-preserving) reductions under TimeRange, etc.
- L2→L3 converter (
crates/core/src/intent_algebra/lower.rs) — convert recurses through every node, including an outer Aggregate over an arbitrary child, Sort/Limit over arbitrary children, PromQLSubquery, and BinaryOp.
- Binder (
crates/core/src/intent_algebra/binder.rs) — walks the whole tree to seed the usage-derived schema, so nested group keys / matchers resolve positionally.
- SQL front end (
crates/lower/src/sql/mod.rs) — lower_plan recurses through the DataFusion LogicalPlan, so a nested aggregate / filter / projection chain already lowers. (Gaps below.)
So the binary form Q1 <op> Q2 was never the real ceiling — BinaryOp already nests both sides arbitrarily (see crates/e2e/tests/nested.rs::q25_div_over_complex_subtrees).
The actual gap (PromQL)
The ceiling was in the PromQL L1→L2 front end (crates/lower/src/promql.rs). walk_aggregate lowered an aggregate's argument through a flat two-level template (Inner + Outer enums): an outer aggregator over exactly one inner selector or range-vector function. Any composite argument was rejected:
max(sum by (job) (rate(m[5m]))) — outer aggregate over a nested aggregate
topk(3, sum by (instance) (rate(m[5m]))) — topk over a nested aggregate (was pinned as topk_over_aggregate_arg_is_rejected__GAP)
sum(rate(a[5m]) + rate(b[5m])) — aggregate over a binary op
sum(histogram_quantile(0.9, sum by (le) (rate(h_bucket[5m])))) — aggregate over a function lowered on a separate path
Done in this issue (PromQL) — PR linked below
walk_aggregate now tries the flat fast path first (keeping heavy-hitter topk(k, count_over_time(...)) recognition intact), then falls back to recursing through the same walk used at top level and wrapping the result in the outer aggregation (outer_kind + build_over_subtree). This lifts the two-level limit to arbitrary function nesting.
- Genuinely unsupported inner expressions (e.g. unary negation) still surface their error — nothing is silently mislowered.
ColumnRef::SampleValue resolution (crates/core/src/intent_algebra/column_resolution.rs) now also resolves to the sole numeric non-timestamp column, so an outer ranking over a cross-series aggregate (topk(k, sum by (job) (…)), value column renamed sum) finds its sort key.
- Tests: gap test flipped to a passing conformance test; added nested-aggregate / aggregate-over-binary-op / outer-over-nested-aggregate conformance tests, an exact-tree e2e test (
q27_max_over_sum_by_job_over_rate), and SampleValue resolution unit tests.
Remaining implementation plan (tasks)
PromQL
SQL
Cross-cutting
Open questions
- Full set of nested patterns query authors need (gather more concrete examples).
- For SQL, how far to go on correlated subqueries vs. rejecting them cleanly in v1.
Summary
The ASAPQuery query engine should support nested query functions — applying a function on top of a subquery — for both PromQL and SQL. This issue tracks the testing and implementation of general nested-query support.
What the architecture already supports (grounding)
Nesting is structural at L3:
intent_algebra::QueryExpris a recursive, box-owned tree, and every operator already accepts an arbitrary child. The pieces that consume it are recursive too, so most of the "nesting" plumbing is already in place:crates/core/src/intent_algebra/query_expr.rs) —Aggregate { child },Sort { child },Limit { child },Subquery { child },BinaryOp { lhs, rhs }, etc. all box an arbitrary subtree. Schema derivation (output_schema_in) recurses and already handles a cross-series aggregate freezing an open schema to closed, per-series (label-preserving) reductions underTimeRange, etc.crates/core/src/intent_algebra/lower.rs) —convertrecurses through every node, including an outerAggregateover an arbitrary child,Sort/Limitover arbitrary children,PromQLSubquery, andBinaryOp.crates/core/src/intent_algebra/binder.rs) —walks the whole tree to seed the usage-derived schema, so nested group keys / matchers resolve positionally.crates/lower/src/sql/mod.rs) —lower_planrecurses through the DataFusionLogicalPlan, so a nested aggregate / filter / projection chain already lowers. (Gaps below.)So the binary form
Q1 <op> Q2was never the real ceiling —BinaryOpalready nests both sides arbitrarily (seecrates/e2e/tests/nested.rs::q25_div_over_complex_subtrees).The actual gap (PromQL)
The ceiling was in the PromQL L1→L2 front end (
crates/lower/src/promql.rs).walk_aggregatelowered an aggregate's argument through a flat two-level template (Inner+Outerenums): an outer aggregator over exactly one inner selector or range-vector function. Any composite argument was rejected:max(sum by (job) (rate(m[5m])))— outer aggregate over a nested aggregatetopk(3, sum by (instance) (rate(m[5m])))—topkover a nested aggregate (was pinned astopk_over_aggregate_arg_is_rejected__GAP)sum(rate(a[5m]) + rate(b[5m]))— aggregate over a binary opsum(histogram_quantile(0.9, sum by (le) (rate(h_bucket[5m]))))— aggregate over a function lowered on a separate pathDone in this issue (PromQL) — PR linked below
walk_aggregatenow tries the flat fast path first (keeping heavy-hittertopk(k, count_over_time(...))recognition intact), then falls back to recursing through the samewalkused at top level and wrapping the result in the outer aggregation (outer_kind+build_over_subtree). This lifts the two-level limit to arbitrary function nesting.ColumnRef::SampleValueresolution (crates/core/src/intent_algebra/column_resolution.rs) now also resolves to the sole numeric non-timestamp column, so an outer ranking over a cross-series aggregate (topk(k, sum by (job) (…)), value column renamedsum) finds its sort key.q27_max_over_sum_by_job_over_rate), andSampleValueresolution unit tests.Remaining implementation plan (tasks)
PromQL
walk_aggregateto recurse over composite arguments (this PR).max_over_time(rate(m[5m])[1h:]).extract_matrixonly accepts a (parenthesised) matrix selector; teach theInnerFuncpath to accept aPromQLSubquerychild so*_over_time/ratecan wrap a sub-query. Pinned today asover_time_of_subquery_is_rejected__GAP(crates/lower/tests/promql_conformance.rs).topk(k, <bare instant selector>) by (labels)— e.g.topk(3, http_requests_total) by (job). The non-heavy-hitter path defaults a bare selector to an implicit cross-seriesSum, which destroys thebylabels beforeSort.partition_byresolves them (and is semantically wrong — PromQL ranks the raw samples). Rank the selector's own value, keep labels, routebytoSort.partition_by. (Surfaced reviewing PR feat(core)!: collapse L3 grouping — remove Partition node, unify on GroupKeys (#12, #13) #18.)sum(-m),v > 10*1024*1024. Needs a negate/scalar node in the L2 PromQL path (separate from this issue; tracked by the__GAPtests in section G).SQL
SELECT … FROM (SELECT … ) t.lower_planrejectsLogicalPlan::SubqueryAliasover a non-TableScaninput and bareLogicalPlan::Subquery(crates/lower/src/sql/mod.rs). Lower the inner plan recursively and re-qualify its output columns with the alias sot.colresolves. This is the SQL counterpart of PromQL function nesting.IN/EXISTSsubqueries in predicates — decide representation (correlated vs uncorrelated) or reject cleanly with a pinned gap test.Cross-cutting
docs/promql-lowering.md.Open questions