Skip to content

Compose exact operators with summary plans across explicit update/readout boundaries #171

Description

@zzylol

Context

Same migration as issues #150/#151 (ASAPQuery-backend's control_plane adopting asap_plan::bind::implement_tree_in_with for L3→L4 binding, replacing its own locally-defined L4 IR).

We hit a real coverage gap that shows up in both nesting orders of "mix an exact computation with a summary/sketch computation in the same query": an outer exact op wrapping an inner realized summary, and an outer summary op wrapping an inner exact computation. Neither direction has an L4 representation today.

Direction 1 — outer exact fold over an inner realized summary

max by (zone) (quantile_over_time(0.99, http_latency_ms[5m]))
avg by (zone) (quantile_over_time(0.99, http_latency_ms[5m]))

Structurally this is two nested Aggregate nodes: an outer AggIntent::Max/Avg whose child is itself an Aggregate carrying an inner, independently-realizable AggIntent::Quantile. Semantically, the query wants: read the inner quantile's per-zone answer (from whatever summary/accumulator actually answers it), then fold same-zone rows by max/avg. It is not asking for a real, independently-materialized "max" or "avg" accumulator over raw samples — in our deployment this shape is typically the identity fold (the inner summary already emits one row per zone), but it can be a real multi-row fold too (e.g. the inner summary is keyed more granularly than the outer by).

crates/plan/src/boundary.rs's implementation_for_with has no representation for this composition:

AggIntent::Min { .. } | AggIntent::Max { .. } => {
    accumulator(intent, SummaryKind::MinMax, SummaryParams::MinMax)
}
// ...
AggIntent::Avg { .. } | AggIntent::StdDev { .. } | AggIntent::Variance { .. } => {
    Implementation::PassThrough
}
  • Max/Min unconditionally commit to SummaryKind::MinMax — this requires a real, independently-registered MinMax-family sid to exist for the metric. One never does for this shape (there's no reason to plan/register a MinMax accumulator alongside a Quantile sketch just so an outer max by (...) can wrap it) — so find_candidates (our SummaryExecutor impl) always returns no candidates for the outer node, and the whole tree fails to execute even though the inner Quantile would answer fine on its own.
  • Avg/StdDev/Variance go straight to PassThrough — per implement_tree_in_with's conservative-fallback behavior (a logical parent above a bindable aggregate subsumes it unbound), this wraps the entire tree, including the otherwise-realizable inner Quantile, as one opaque Logical blob.

Either way, the outer wrapper poisons an inner sub-tree that would otherwise realize correctly on its own.

Direction 2 — outer summary op over an inner exact computation

The mirror image: rate(...)/irate(...) are themselves exact, per-sample computations over raw counter data (counter-reset-aware delta ÷ time). A query can wrap one in an operator that would otherwise be summary-realizable, e.g.

sum_over_time(rate(metric[5m])[10m:])

or stack a top-k summary on top of an exact rate, e.g. our own already-excluded

topk(5, sum by (zone) (rate(metric[5m])))

Here the inner rate(...) is the exact computation, and the outer sum_over_time/topk is the one that could in principle be answered from a summary (CMS-with-heap for the top-k case) — but there's no way to express "realize the outer as a summary over the OUTPUT of an inner exact per-sample transform" either. This deployment doesn't currently attempt this composition at all: any query containing rate(...)/irate(...) anywhere in its tree is unconditionally excluded from our SummaryExecutor binding before we ever try (see "What we did instead" below) — direction 2 is why.

Both directions are the same underlying gap, just which side of the composition is exact vs. summary-realized.

The composition should consider when a query DAG, part of the DAG computes from raw data, part of the DAG computes from summaries as well.

Request

Is there room in the L4 model for a third answer shape, alongside "realized as a summary/accumulator" and "opaque Logical, compute from raw data": an L4 node that composes an exact computation with a nested realized summary, in either nesting order — outer-exact-over-inner-summary (direction 1) or outer-summary-over-inner-exact (direction 2)? Concretely, something like the L4Node DAG carrying an explicit "apply this function to the child's per-group output, independent of whether the child bound as a summary or stayed exact" node, so implement_tree_in_with can commit whichever sub-tree can realize independently, and let the caller (or a CostModel-style hook) apply the outer/inner composition at readout — instead of one side either requiring its own unrelated accumulator/summary sid or poisoning the other side's binding.

We don't have a strong opinion on the right shape for this (a new L4Node/SummaryExpr variant vs. a CostModel hook similar to realize_extension/readout_extension vs. something else) — flagging the gap for discussion rather than proposing a specific API.

What we did instead (for now)

Nothing routed around locally for direction 1 yet. We're treating it the same way we treat #151's TopK { accuracy: Exact } gap: a known, accepted "falls over to the exact/archive tier" limitation for now, rather than forking implement_tree_in_with's tree walk to special-case it in control_plane.

For direction 2, we already have a standing local exclusion: any candidate whose PromQL contains rate(...)/irate(...) anywhere in its tree is excluded from our SummaryExecutor binding before it's ever attempted (a RateShape skip in our serving-time lowering), and topk(K, sum by (...) (rate(...))) is excluded the same way. We hadn't connected these to direction 1 until writing this up — they're the same gap.

If a CostModel-reachable hook (or a new IR shape) lands upstream for either direction, we'd rather adopt that than build a parallel local mechanism for either one.

References

  • crates/plan/src/boundary.rsimplementation_for_with's AggIntent::{Min,Max} / AggIntent::{Avg,StdDev,Variance} arms (direction 1), and the Rate/Increase accumulator arms (direction 2)
  • ASAPController#151 — same "deployment wants a different answer than core's default realization for one intent shape" pattern, one layer over (ranking within an already-offered candidate set, vs. no candidate set being offered at all here)
  • ASAPController#150 — the CostModel-style extension-point precedent (realize_extension/readout_extension) this request is loosely modeled after

Metadata

Metadata

Assignees

No one assigned

    Labels

    cost-modelCost formulas, statistics, ranking, and selectionenhancementNew feature or requestirPre-ASAP or post-ASAP intermediate representationnested queriesreplacement-strategyASAP replacement candidates and physical strategies

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions