Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 199 additions & 2 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -833,10 +833,103 @@ pub enum QueryWorkload {
QueryLog(QueryLogReplay),
}

pub struct QuerySpec { /* metric_name, aggregations, time_window, SLAs, ... */ }
pub struct QuerySpec {
/// Stable identifier — preserved across `replan` cycles so the runtime
/// can correlate plan outputs with the originating spec, and L4 reuse
/// rules can name shared producers across consumers in the same workload.
pub id: QueryId,

/// Source-language query string + which language it's written in.
/// L1 parses `query` against `language`; no schema lookup yet.
pub query: String,
pub language: QueryLanguage, // PromQL | Sql | DataFusion | ElasticDsl

/// Evaluation time range. Some languages (PromQL) carry their own ranges
/// inside `query`; this field wins when both are set.
pub time_range: TimeRange,

/// Per-target SLA. Drives L4 binding (which sketch / which params) and
/// L5 stage placement (push down or not). The three are independent —
/// `accuracy: Exact` disables all `Bind*` rules; an unset latency /
/// dollars target lets the cost model pick freely.
pub accuracy: AccuracyTarget, // Exact | Epsilon(f64) | EpsilonDelta { ε, δ }
pub latency: Option<Duration>, // p99 evaluation latency target
pub dollars: Option<Dollars>, // per-evaluation budget

/// Routing hint: which deployment model should plan this query. When
/// unset, the runtime defaults to the deployment model bound to the
/// inbound HTTP route (`POST /plan/lifecycle` vs `POST /plan/query`).
pub deployment_model: Option<DeploymentModelId>,

/// How the query is *evaluated*: one-shot, continuous, or scheduled.
/// Distinct from `data` below — a one-shot query against a streaming
/// source is "evaluate now over the latest window"; a streaming query
/// against a batch source doesn't make sense and is rejected at L1.
/// Drives L4 binding (mergeable vs one-shot sketch family) and the
/// L5 wire format (`OneShot` emits config + result; `Streaming` and
/// `Periodic` emit a config that keeps running).
pub shape: QueryShape,

/// Shape of the *data* feeding the query. Workload-level summary —
/// per-leaf detail rides on `Source::data_shape` (§6 L3). For a join
/// over streaming + batch this is `Mixed`, and the planner reads the
/// per-leaf shape during L4. Drives binding choices: an
/// `AppendOnlyStream` unlocks incremental, mergeable sketches and
/// retraction-free aggregation; `Batch` lets the planner pick a
/// non-mergeable estimator (e.g. exact percentile over a sort) that
/// wouldn't survive a distributed streaming setting; `Mutable`
/// requires retraction-aware operators (out of scope today — the
/// planner refuses sketch binding and falls back to re-scan).
pub data: DataShape,
}

pub enum QueryLanguage { PromQL, Sql, DataFusion, ElasticDsl }
pub enum AccuracyTarget { Exact, Epsilon(f64), EpsilonDelta { eps: f64, delta: f64 } }

pub enum QueryShape {
/// Evaluate once. Plan, execute, return result, discard state.
/// SQL ad-hoc queries; one-off PromQL via `POST /plan`.
OneShot,
/// Continuous query — output stream that the executor keeps emitting
/// as new data arrives. No fixed cadence; the runtime emits whenever
/// the underlying state changes. Streaming dashboards, alerting
/// expressions evaluated by the agent rather than by a poller.
Streaming,
/// Re-evaluated at a fixed cadence — Prometheus recording rules,
/// scheduled dashboard panels, alerting evaluation cycles. The
/// planner amortises sketch / aggregate build cost across evaluations
/// within the cadence and reuses state between adjacent windows.
Periodic { every: Duration },
}

pub enum DataShape {
/// Bounded relation, fully materialised at plan time. SQL tables,
/// Parquet / CSV files, DataFusion in-process tables.
Batch,
/// Append-only stream — events arrive over time, never updated or
/// deleted. Metrics, logs, event streams. The common case for
/// asaplifecycle and asapquery.
AppendOnlyStream,
/// Mutable relation — inserts + updates + deletes. Operational
/// databases, CRUD-style tables. Sketch binding is currently refused
/// for this shape (no retraction-aware sketches in the catalog yet).
Mutable,
/// Join across sources of differing shape. The planner consults
/// `Source::data_shape` per leaf during L4; this variant exists so
/// callers don't have to flatten a workload-level summary.
Mixed,
}
```

All three deployment models take `&QueryWorkload`. Same type, different planners.
All three deployment models take `&QueryWorkload`. Same type, different planners. Fields are deployment-model-agnostic — anything deployment-model-specific (DC's stage-budget overrides, fusion's `SessionContext` handle) rides on `DeploymentConstraints`, not on `QuerySpec`.

The `shape` × `data` cross-product isn't fully populated. Combinations the planner accepts and rejects:

| `shape` \ `data` | `Batch` | `AppendOnlyStream` | `Mutable` | `Mixed` |
|---|---|---|---|---|
| `OneShot` | ✓ ad-hoc SQL, fusion | ✓ "evaluate now over latest window" | ✓ via re-scan, no sketch binding | ✓ per-leaf shape decides |
| `Streaming` | ✗ rejected at L1 (no stream over a static dataset) | ✓ canonical streaming case | ✗ rejected (no retraction support) | ✓ if streaming leaves dominate |
| `Periodic { every }` | ✓ scheduled batch report | ✓ recording rules | ✓ via re-scan | ✓ |

### `core::cost`

Expand Down Expand Up @@ -1097,6 +1190,110 @@ Returned to the caller's `SessionContext` for execution by `asap-fusion`'s in-pr
- **L5 is where deployment models diverge.** Same `SketchExpr` → three topologies → three emitter outputs. The topology is a parameter, not an axis of code (§3).
- **The sketch path is selected, not mandated.** If the `QuerySpec` had `accuracy: AccuracyTarget::Exact`, no `Bind*` rule would fire; L4 would pass `SketchExpr::Logical(QueryExpr::Aggregate{…})` through, and L5 would target an exact `HashAgg` (§1 goal 6).

### End-to-end example — batched queries with shared sub-DAGs

The single-query trace above shows one `QueryExpr` flowing through all five layers. This example shows what changes when the `QueryWorkload` is an `AuthoredSet` of related queries — the case `CostModel::workload_cost` and §6 design rule 5 (DAG fan-in) are designed for. The point is that reuse is expressed as **shared nodes in a single DAG**, not as side-channel caching.

**Input.** A `QueryWorkload::AuthoredSet` arrives with three PromQL queries on the same metric:

```text
q1: quantile_over_time(0.99, http_request_duration_seconds{service="api"}[5m])
q2: quantile_over_time(0.95, http_request_duration_seconds{service="api"}[5m])
q3: max_over_time(http_request_duration_seconds{service="api"}[5m])
```

All three carry `accuracy: Epsilon(0.01)`. Planning them independently would scan + window the same metric three times. The DAG-shaped IR collapses the shared work.

#### L3 — common sub-expression elimination produces fan-in

After per-query L1→L2→L3 lowering, the three `QueryExpr` trees are identical below the `Aggregate` node. A workload-level CSE pass (`core::lower::workload::dedupe_subtrees`) hoists the shared sub-DAG behind a `LetBinding`, leaving three `Aggregate` nodes that fan in to one producer:

```text
Scan{http_request_duration_seconds, service="api"}
Window{Sliding, 5m} ◄── shared producer
╱ │ ╲
Aggregate Aggregate Aggregate
[Quantile{0.99,ε}] [Quantile{0.95,ε}] [Max]
│ │ │
q1 q2 q3
```

Multi-root DAGs live one level above `QueryExpr`. `QueryExpr` stays single-root (one query in, one root out) — the workload-level container holds N roots plus the hoisted bindings they share:

```rust
// core::workload — output of L1→L2→L3 lowering for a QueryWorkload
pub struct WorkloadPlan {
/// Named shared producers, hoisted out of individual queries by the CSE
/// pass. Each binding is referenced by ≥2 roots via `QueryExpr::Ref`.
pub bindings: Vec<(BindingName, QueryExpr)>,
/// One root per QuerySpec in the workload, in input order.
pub roots: Vec<(QueryId, QueryExpr)>,
}
```

For the example above:

```rust
WorkloadPlan {
bindings: vec![
("windowed_latency".into(), /* Scan → Window subtree */),
],
roots: vec![
(q1_id, QueryExpr::Aggregate {
by: vec![],
aggs: vec![AggIntent::Quantile { q: 0.99, accuracy: Epsilon(0.01) }],
child: Box::new(QueryExpr::Ref("windowed_latency".into())),
having: None,
}),
(q2_id, /* same shape, q=0.95, same Ref */),
(q3_id, /* Aggregate { aggs: [AggIntent::Max] } over the same Ref */),
],
}
```

Three `QueryExpr::Ref` nodes, one binding; the fan-in is explicit. `CostModel::workload_cost` credits the Scan + Window build cost once across {q1, q2, q3} via `WorkloadCost::reused`, which is what makes the bundled plan beat three independent plans on `total_dollars` (§6 `core::cost`). Within-query CTE fan-in (SQL `WITH`, PromQL recording rules) keeps using `QueryExpr::LetBinding`/`Ref` unchanged — `WorkloadPlan` only adds the cross-query layer.

CSE legality leans on `Schema::unique_keys` (§6 Schema flow): two `QueryExpr::Ref` consumers can share a producer only when its output schema is provably stable across reads — the unique-key metadata is what lets the deduper assert that without re-running the producer's logic.

#### L4 — sketch reuse across q1 and q2

`BindKllOnQuantile` fires three times (once per `Quantile` intent in the workload), but a follow-on rule `MergeKllSketches` recognises that q1 and q2 read from the same input at the same accuracy. KLL state is independent of `q` — one sketch serves any quantile readout — so the two `SketchAgg{KLL}` nodes collapse into one, with two `SketchEstimate` parents reading `q=0.99` and `q=0.95`. q3 (`Max`) takes a separate path: KLL doesn't expose max, so the rule emits an exact `Aggregate{Max}` over the shared `Window`.

```text
Scan
Window
╱ │ ╲
SketchAgg{KLL,k=200} Aggregate{Max}
╱ ╲ │
SketchEstimate SketchEstimate q3
q=0.99 q=0.95
│ │
q1 q2
```

`SketchExpr::LetBinding` carries the two-tier fan-in: the outer let names the `Window` output (read by all three branches), an inner let names the `SketchAgg{KLL}` output (read by both `SketchEstimate` parents). The L4 type system rejects mismatched merges before L5 sees them — both `SketchEstimate` parents must declare `Sketch(Kll, KllParams{k:200})` on their input edge, which is checked locally per the §6.4 sketch-state schema rules.

#### L5 — colored DAG, one config per executor

`StageAllocator` colors the bound DAG by `StageId` exactly as for the single query, but the shared nodes are colored once. Under `topology::ThreeStage`:

| Node | StageId |
|---|---|
| `Scan` + `Window` + `SketchAgg{KLL}` + `Aggregate{Max}` | `"edge"` |
| `SketchMerge` (over KLL streams), `Merge` (over Max streams) | `"gateway"` |
| `SketchEstimate{q=0.99}`, `SketchEstimate{q=0.95}`, root of q3 | `"backend"` |

The OpAMP config emitted to each edge agent describes one scrape, one window operator, one KLL builder, one max accumulator — feeding two output streams (KLL state, Max state) toward gateway. Per-edge memory drops from `3× scan + 3× window + 2× KLL + 1× Max` (independent plans) to `1× scan + 1× window + 1× KLL + 1× Max`; bandwidth across the edge→gateway cut drops correspondingly.

#### What this example demonstrates

- **Reuse is a DAG property, not a sketch property.** The Scan + Window collapse is L3 CSE; the KLL collapse is L4 sketch-aware; both expressed as fan-in in the same DAG. No new IR surface, no caching layer.
- **`Schema::unique_keys` becomes load-bearing.** The CSE pass uses it to prove two `Ref`s read from a stable producer. Without it, the deduper has to be conservative and reuse drops on the floor — which is why §6 keeps the field on the `Schema` struct even though single-query plans don't read it (§6 Schema flow).
- **`CostModel::workload_cost` decides whether to share.** Reuse isn't free — when q1 demands a tighter accuracy than q2, the smaller-budget sketch may not satisfy both, and keeping them separate can be cheaper. `MergeKllSketches` is gated by `workload_cost`, not unconditional.
- **L5 is unchanged.** Same allocator, same emitter, same wire format — the only difference is that the DAG has multiple roots and shared interior nodes. The single-query path is the degenerate case where `outputs.len() == 1` and no fan-in fires.

## 7. Runtime crate details

Lifted from DC controller with zero semantic change:
Expand Down