Skip to content

Flatten ASAP DAG Operators for Both Pre-ASAP and Post-ASAP IR #468

Description

@Selvomega

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

  1. 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.
  2. Node wrapper: schema, guarantee and timing currently hang off SummaryNode. After unification they need a common node wrapper.
  3. Column references: pre-ASAP refers to columns by position (ColumnId); summary nodes use SummaryField. The two schemas have to merge into one.
  4. 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.
  5. 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

  1. 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.
  2. 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?

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions