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 metric
No 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 lineitem
On 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 lineitem
Each 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.)
KeepPreAsap and 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 no Ext.
ValueOperation keeps only the summary-specific variants (FinalizeExactAccumulator, MaintainPopulation / ReadPopulation).
assemble_residual is 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 an Ext node in frontend output. Validation can catch both: a relational operator checks that all of its inputs are Plain columns, and pre-ASAP consumers reject Ext. The rule "a read-out value must not be fed back into SummaryAgg" is already enforced by validate_execution_data_states today, not by the type system.
- Node wrapper:
schema, guarantee and timing currently hang off SummaryNode. After unification they need a common node wrapper.
- Column references: pre-ASAP refers to columns by position (
ColumnId); summary nodes use SummaryField. 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,
ExecutableDag and 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> with PreAsap = Operator<Never> would make the frontend unable to produce Ext at compile time, and pre-only code (SQL / PromQL lowering, CSE, validation, DAG export) could drop its Ext arm. The cost is that every function touching both sides becomes generic.
- The interim option "let
ValueOperation wrap 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?
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:QueryExpr)Project/Filter/Sort/LimitValueOperation::Project/Filter/Sort/LimitJoinRelationalJoinBinaryOpBinaryOpAggregateValueOperation::Exact(Aggregate)SetOp,Dedup,Concat, window functions, PromQL-only operatorsThis design causes three problems. Every example below was run on
main(8acb472).1. One operator, two spellings
No summary is used at all, yet the three semantically identical
Projects end up in two different types:Which spelling a node gets depends on where
assemble_residualstopped 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
On its own, the percentile gets a KLL sketch. Next to
avg, the whole aggregate becomesKeepPreAsap(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:Scanis inside the opaque leaf, andSummaryAggcannot reference it.The only option is to move
Scanout of the leaf and rewriteAggregateas its duplicate:In other words, sharing forces you onto the duplicates from problem 1.
3. An operator with no duplicate blocks every replacement below it
Each
approx_distincton its own gets a cardinality sketch (Theta onmain). Joined byUNION ALL, the output is a singleKeepPreAsap(SetOp)and neither side uses a sketch.SetOphas no duplicate, so it can only live inside the opaque leaf, and the leaf cannot hold a summary. Window functions,Dedup,Concatand 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
Operatortype with two variants.Basicholds today'sQueryExproperators;Extholds the summary operators. Every operator's children areOperator, so a summary node can sit anywhere a relational node can:Pre-ASAP and post-ASAP are the same type. The frontend produces only
Basicnodes by convention; binding rules are the only place that createsExt.The three problems after the change:
(
Basic(...)wrappers omitted; onlyExtnodes 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
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.schema,guaranteeandtimingcurrently hang offSummaryNode. After unification they need a common node wrapper.ColumnId); summary nodes useSummaryField. The two schemas have to merge into one.RelationalJoin.pruning(the completeness proof for candidate pruning). It either moves into the extension operator or becomes an optional annotation on the relational operator.ExecutableDagand ASAPQuery-backend all depend on the current two type sets.For discussion
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.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?