Repository navigation
Flatten ASAP DAG Operators for Both Pre-ASAP and Post-ASAP IR #468
Description
Problem
The post-ASAP DAG and the pre-ASAP plan are two separate operator sets. They meet at exactly one point: KeepPreAsap. It is an opaque leaf holding an entire pre-ASAP subtree, and no post-ASAP node can reference anything inside it. So that relational operators can sit above a summary, post-ASAP duplicates part of the pre-ASAP operator set:
pre-ASAP (QueryExpr) |
post-ASAP duplicate |
|---|---|
Project / Filter / Sort / Limit |
ValueOperation::Project / Filter / Sort / Limit |
Join |
RelationalJoin |
BinaryOp |
BinaryOp |
Aggregate |
ValueOperation::Exact(Aggregate) |
SetOp, Dedup, Concat, window functions, PromQL-only operators |
none |
This design causes three problems. Every example below was run on main (8acb472).
1. One operator, two spellings
WITH metric AS (SELECT avg(CASE WHEN l_quantity BETWEEN 1 AND 50 THEN 1.0 ELSE 0.0 END) AS in_range FROM lineitem)
SELECT in_range, in_range = 1.0 AS ok FROM metricNo summary is used at all, yet the three semantically identical Projects end up in two different types:
ValueOperation(Project in_range, ok) ← post-ASAP
ValueOperation(Project in_range) ← post-ASAP
KeepPreAsap(Aggregate(avg(expr)) ← Project(CASE WHEN …) ← Scan lineitem)
↑ pre-ASAP
Which spelling a node gets depends on where assemble_residual stopped peeling, not on semantics. Every downstream consumer handles each operator twice, and every new operator has to be duplicated again.
2. The opaque leaf blocks sharing
SELECT avg(l_extendedprice), approx_percentile_cont(l_discount, 0.99) FROM lineitemOn its own, the percentile gets a KLL sketch. Next to avg, the whole aggregate becomes KeepPreAsap(Aggregate). The immediate cause is that the rule only replaces single-measure aggregates.
But even if the rule could replace just the percentile and share one scan with avg, there is no way to express that today: Scan is inside the opaque leaf, and SummaryAgg cannot reference it.
The only option is to move Scan out of the leaf and rewrite Aggregate as its duplicate:
ValueOperation::Exact(Aggregate[avg]) SummaryAgg(Kll)
╲ ╱
KeepPreAsap(Scan lineitem)
In other words, sharing forces you onto the duplicates from problem 1.
3. An operator with no duplicate blocks every replacement below it
SELECT approx_distinct(l_partkey) FROM lineitem
UNION ALL
SELECT approx_distinct(l_suppkey) FROM lineitemEach approx_distinct on its own gets a cardinality sketch (Theta on main). Joined by UNION ALL, the output is a single KeepPreAsap(SetOp) and neither side uses a sketch.
SetOp has no duplicate, so it can only live inside the opaque leaf, and the leaf cannot hold a summary. Window functions, Dedup, Concat and the PromQL-only operators behave the same way. There is no workaround short of adding another duplicate, which makes problem 1 worse.
Proposal
Keep a single set of relational operators, make summary operators an extension variant of it, and give every operator the same child type.
Concretely: one Operator type with two variants. Basic holds today's QueryExpr operators; Ext holds the summary operators. Every operator's children are Operator, so a summary node can sit anywhere a relational node can:
enum Operator {
Basic(Relational), // Scan / Filter / Project / Aggregate / Join / SetOp / Sort / Limit / BinaryOp / … — every operator QueryExpr has today
Ext(SummaryOp), // SummaryAgg / SummaryEstimate / SummaryMerge / SummarySubtract / SummaryDelete / SummaryJoin
}Pre-ASAP and post-ASAP are the same type. The frontend produces only Basic nodes by convention; binding rules are the only place that creates Ext.
The three problems after the change:
Problem 1 Problem 2 Problem 3
Project(in_range, ok) Aggregate[avg] Ext(SummaryAgg) SetOp(UnionAll)
Project(in_range) ╲ ╱ Ext(SummaryEstimate) ← Ext(SummaryAgg) ← Scan
Aggregate(avg(expr)) Scan lineitem Ext(SummaryEstimate) ← Ext(SummaryAgg) ← Scan
Project(CASE WHEN …)
Scan lineitem
(Basic(...) wrappers omitted; only Ext nodes are marked.)
KeepPreAsapand the opaque boundary are gone. When the executor needs "a subtree that can be handed to the native engine as one unit", it takes the largest subtree containing noExt.ValueOperationkeeps only the summary-specific variants (FinalizeExactAccumulator,MaintainPopulation/ReadPopulation).assemble_residualis no longer needed: replace only the selected nodes and leave everything else in place.
Open issues
- Two type guarantees are lost.
Project(Ext(SummaryAgg))becomes expressible, and so does anExtnode in frontend output. Validation can catch both: a relational operator checks that all of its inputs arePlaincolumns, and pre-ASAP consumers rejectExt. The rule "a read-out value must not be fed back intoSummaryAgg" is already enforced byvalidate_execution_data_statestoday, not by the type system. - Node wrapper:
schema,guaranteeandtimingcurrently hang offSummaryNode. After unification they need a common node wrapper. - Column references: pre-ASAP refers to columns by position (
ColumnId); summary nodes useSummaryField. The two schemas have to merge into one. - Extra fields on the duplicates: for example
RelationalJoin.pruning(the completeness proof for candidate pruning). It either moves into the extension operator or becomes an optional annotation on the relational operator. - Large blast radius: the frontend, search, assembly, cost model, DAG export,
ExecutableDagand ASAPQuery-backend all depend on the current two type sets.
For discussion
- Is one type enough, or should pre / post be distinguished with a type parameter?
Operator<Ext>withPreAsap = Operator<Never>would make the frontend unable to produceExtat compile time, and pre-only code (SQL / PromQL lowering, CSE, validation, DAG export) could drop itsExtarm. The cost is that every function touching both sides becomes generic. - The interim option "let
ValueOperationwrap a pre-ASAP operator directly" reduces the duplication in problem 1, but the opaque boundary stays, so it does not solve problems 2 and 3. Is it worth doing first?
Activity
milindsrivastava1997 commentedon Sep 27, 2026
This is useful, thanks!
Regarding open issue 1 -- we might be able to solve this by introducing types to describe the input and output of each
BasicandExtnode. @zzylol may have thoughts on this.Discussion point 1 -- didn't understand @Selvomega
zzylol commentedon Sep 27, 2026
Thanks! I completely agree with this. This is an issue indeed and I didn't solve it yet. We should follow what is being suggested in the issue.
zzylol commentedon Sep 27, 2026
@milindsrivastava1997 Let me unpack discussion point 1, since it's closely related to the typing idea you raised for open issue 1.
What it's reacting to. Today the frontend cannot emit a summary operator — not by convention, but because
QueryExprsimply has no such variant. Merging pre- and post-ASAP into oneOperatortype gives that away:Ext(SummaryAgg)becomes constructible anywhere, including in frontend output, and we're left catching it with runtime validation. Discussion point 1 asks whether we can get that guarantee back at compile time instead.The mechanism. Parameterize
Operatorover which extension operators are permitted:The trick is
Never: no value of it can ever be constructed, so inOperator<Never>theExtarm is statically dead. Two consequences:PreAsap, and it is a compile error for it to produce anExtnode. No validation pass needed for that half of open issue 1.Extarm entirely (match e {}on an uninhabited value) rather than carrying anunreachable!()it has to keep justifying.The cost, which is the part that makes it a real trade-off: every function that has to work on both sides becomes generic (
fn f<E>(op: &Operator<E>) -> ...), and we pay for that in trait bounds, inference, error messages, and general readability. Binding rules, the cost model, and anything walking a plan that may or may not contain summaries all fall in that bucket.So the question is really: single concrete type (simpler code, guarantee enforced at runtime) vs. type parameter (guarantee enforced by the compiler, but the shared code goes generic).
Worth noting these are two different routes, not competing versions of the same one. Your suggestion types the inputs and outputs of nodes, which is what we need for the other half of open issue 1 — ruling out
Project(Ext(SummaryAgg)), i.e. a relational operator consuming a summary value. That can't be expressed by theEparameter, sincePostAsaplegitimately contains both kinds of node; it needs something on the schema/column side (theColumnIdvsSummaryFieldmerge in open issue 3 is the same question from a different angle). Discussion point 1 only addresses whereExtmay appear at all. We could adopt either, both, or neither.Selvomega commentedon Sep 27, 2026
Basically the discussion point 1 is about: After flattening the operators (both pre and post) into one type, we might lose the operator type guarantee in pre-ASAP-DAG (no post-ASAP-nodes involved). Before it is guaranteed by a separate
QueryExprtype, now we may also need a mechanism to check that.What is proposed in the PR and by Zeying is a compile-time type check method. That's doable, yet I personally don't see any reason why we cannot do run-time check using a validate function traversing the pre-ASAP-DAG.
milindsrivastava1997 commentedon Sep 27, 2026
Okay understood. I don't have a strong opinion on this. If no one else does either, we can just go for the easy solution i.e. runtime type checking.
Meta -- the AI text in the issue is nowhere as clear as what you wrote above. Let's please try to have more readable communication, thanks
docs: propose operator flattening (#468) (#469)
docs: define unified operators and SQL/PromQL scalar boundaries (#511)