Repository navigation
[Tracking] Physical DAG → deployment boundary: InstallablePlan, input slots, TableProvider, BindingDataSources #968
Description
Example
rate(http_requests_total{job="api"}[5m]), where Stage 3 stores 1-minute panes at ingestion time.
Today a deployment runtime that installs this plan has to:
- read
InstallablePlan.stored_outputsandquery_time_raw_windows. These are fields the Planner derives from the DAG, andInstallablePlan::validaterecomputes them and compares them; - call
RawDataSampleSelector::new(dag, window)andwindow_batch(end, &[RawDataSample]). These apply the window, the label matchers, instant lookback and stale markers outside the DAG, and the runtime binds the result at theTimeRangenode; - build an
InputContractfor every boundary from the node'soutput_schema, and bind stored state as aSourceat the pane node.
A SQL table with the same window goes through a different path (RawSource + Scan, or TableRowSelector). The executor's public API also carries legacy types (Statistic, KeyByLabelValues, Measurement).
After this tracking issue, the runtime:
- reads
InstallablePlan { dag, query_texts }, and callsmaterialized_views()to learn which views to maintain; - compiles each view's refresh and the query run once (
compile(dag, views: &[MaterializedView], roots)), with no readers; - implements one
DataStoreover its raw data and its summary store:read_raw_data(source, RawDataRequest)for thehttp_requests_totaltime series or a SQL table, andread_materialized_view(view, MaterializedViewRequest)for the panes; - calls
instantiate(Arc<dyn DataStore>)with one store handle per run. The DAG's ownScanandTimeRangeapply the window and predicates for PromQL and SQL alike.
Layers and ownership
| Layer | Holds | Owner | Analogue |
|---|---|---|---|
| Planner | InstallablePlan: the selected physical ASAP DAG (each node's materialization and retention) and the query texts |
ASAPPlanner (deployment-agnostic) | Flink's JobGraph |
| Executor: operator library | PhysicalOperator and the native operators |
asap_executor |
DataFusion's ExecutionPlan nodes |
| Executor: compiled plan | compile(dag, views, roots) → CompiledPhysicalDAG with raw data and materialized view slots |
asap_executor |
DataFusion's physical planner, except that DataFusion resolves tables while planning |
| Executor: deployment interface | DataStore (read_raw_data, read_materialized_view), RawDataRequest, MaterializedViewRequest, instantiate |
asap_executor defines; the deployment implements |
DataFusion's TableProvider and catalog |
| Deployment | raw-data storage, the summary store (what a stored state is, where it lives, readiness), run scheduling, retention, transport | ASAPQuery-backend | catalog and runtime |
Concepts
InstallablePlan holds only what the DAG cannot derive:
pub struct InstallablePlan {
pub wire_version: u32, // versions the whole plan file
pub dag: PhysicalASAPDAG, // a materialized node carries its retention_ms
pub query_texts: Vec<String>, // query i is answered by dag.roots[i]
}
impl InstallablePlan {
pub fn materialized_views(&self) -> Vec<MaterializedView>; // node, refresh, coverage, inputs, retention
}Input slot. A position in a compiled DAG where data is read at run time, and that position's schema, which is the DAG node's output_schema. There are two kinds, and no other: the executor cannot cut a DAG at an arbitrary node.
- raw data slot: a
Scanleaf. It keeps theScan'sSource, schema, predicates and window (the union of its readers' windows,scan_window), and is read bySource. - materialized view slot: a materialized view (a node whose output is kept across evaluations) that a DAG reads. It keeps the
MaterializedViewand the window its state summarizes (PhysicalASAPDAG::view_window: the union of the windows of everyTimeRangebelow it), and is read by theMaterializedView. The same node is the root of the DAG that refreshes it.
pub fn compile(dag: &PhysicalASAPDAG, views: &[MaterializedView], roots: &[NodeId])
-> Result<CompiledPhysicalDAG, Error>; // opens no readersCompiledPhysicalDAG is derived inside the executor. It is not persisted and has no serde.
DataStore is the only data interface between the executor and a deployment. Its method names say what is read, because the two contracts differ:
pub trait DataStore {
fn read_raw_data(&self, source: &Source, request: &RawDataRequest, context: RunContext)
-> Result<OutputStream<'_, Batch>, Error>;
fn read_materialized_view(&self, view: &MaterializedView, request: &MaterializedViewRequest, context: RunContext)
-> Result<OutputStream<'_, Batch>, Error>;
}
pub struct RawDataRequest<'a> {
pub time_range_ms: Option<(i64, i64)>, // (start, end], from the slot's window and the run's time
pub schema: &'a SchemaRef, // the slot's columns; the store returns exactly these
pub predicates: &'a [ScalarExpr<()>], // a hint: the executor re-applies every predicate
}
pub struct MaterializedViewRequest<'a> {
pub time_range_ms: (i64, i64), // (start, end], the interval the view's state summarizes in this run
pub schema: &'a SchemaRef, // group columns and one state column
}read_raw_data |
read_materialized_view |
|
|---|---|---|
| Keyed by | Source |
MaterializedView (coverage: definition_id and selection) |
| Time range | optional, a hint | always given, exact |
| Predicates | a hint | none |
| Executor re-filters | yes | no |
| Charged to the run's memory | yes | no, held by the store |
A time series is a table (ts, value, *labels), as Source::TimeSeries declares. A view read returns exactly the states whose intervals tile the requested range: never an observation twice, never another definition's state, never missing state as empty (an error instead). Only SDS definitions cross the interface (the view's coverage and schema); instances stay inside the store.
Instantiation and runs.
impl CompiledPhysicalDAG {
pub fn instantiate(&self, store: Arc<dyn DataStore>) -> Result<PhysicalDAG<'static, Batch, SchemaRef>, Error>;
}A deployment passes one store handle per run, which presents one consistent state to every read of that run; how it keeps the snapshot is the deployment's choice. RunContext/Scope carry only what the executor uses (scope, limits, cancellation, query range).
Operators own semantics; the store only reads.
- A raw data slot becomes a
Scanover the store. TheScancomputestime_range_msfrom its window and the run'sScope, passes the request, checks each batch against the slot's schema, and re-applies the time range and the predicates. - A materialized view slot asks for the view's window:
(T - o - w, T - o]in a query run,(window_end - w, window_end]in an ingestion run. - A time-series
TimeRangeis a native operator. A range window filtersts. An instant window keeps each series' latest row in the lookback, and a series whose latest sample is a stale marker has no row. - A
Scanshared by several windows requests the union of their ranges, and eachTimeRangefilters its own.
Decisions (2026-10-27)
| # | Question | Decision |
|---|---|---|
| Q1 | Is a materialized view bound through TableProvider too? |
read_materialized_view, keyed by the view. Its read is exact and never re-filtered, and its states are not charged to the run, so a table contract does not fit it |
| Q2 | Are table slots bound by Source or by node id? |
By Source. Revised 2026-10-28 (#995): no catalog (DataSources); Source is an argument of read_raw_data and the store dispatches |
| Q3 | Does ScanRequest carry a projection? |
|
| Q4 | Three-state filter pushdown (DataFusion's Unsupported/Inexact/Exact)? |
No; every pushed predicate is treated as inexact and re-applied |
| Q5 | Where does retention live? | On the materialized node of the exported physical DAG, so InstallablePlan is the DAG plus query texts |
| Q6 (2026-10-28) | One interface or two for raw data and views? | One trait, DataStore, so one handle per run keeps one snapshot; its method names (read_raw_data, read_materialized_view) say what is read. Each takes a key and a request (RawDataRequest, MaterializedViewRequest) |
| Q7 (2026-10-28) | Where does the store revision live? | Not in the executor: Scope drops revision (it was never read). The contract is one consistent snapshot per run handle; the mechanism is the deployment's |
| Q8 (2026-10-28) | May the executor cut a DAG at any node and bind it by id? | No. Input enters only at Scans and materialized views. precompute::compile, the candidate API (compile_candidate(s), cut_candidate, enumerate_frontiers, select_candidate, frontier_from_timing, CompiledPhysicalPlan), compose/cut and the external inputs of from_operators are removed; none had a non-test caller |
| Q9 (2026-10-28) | Which window does a view built at ingestion read from the views it reads? | The window ending at the run's end; so installation refuses an ingestion-time view whose raw windows or read views' windows end at different offsets (IngestionBuildReadsWindowsOfDifferentOffsets) |
Also decided:
RawSourceis renamedTableProvider;TableProvider,ScanRequest,DataSources,BindingDataSourcesandMemorySourceare then replaced byDataStoreandInMemoryDataStore([Refactor] Read raw data and materialized views through one DataStore (read_raw_data, read_materialized_view), not per-run Source/NodeId → TableProvider maps #995).- A node whose output is kept across evaluations (
MatAtIngestionorMatAtQueryKeptAcrossEvaluations) is a materialized view.StoredOutputbecomesMaterializedView,StoredOutputBuildbecomes its refresh mode, and a stored slot is a materialized view slot. AMatAtQuerynode is not a view, because its output is discarded after one evaluation. The names follow DataFusion's materialized-views extension (datafusion-contrib/datafusion-materialized-views). There, a view is aTableProviderthat also implementsMaterialized { query(), rewrite_readiness() }, queries read it as a table, andmv_dependenciesgives its build graph. Ours map as follows:query()is the sub-DAG under the node, andcoverage.definition_ididentifies it; readiness is the deployment's (the summary store's status); the build graph isMaterializedView.inputs. InstallablePlankeeps its name. DataFusion'sExecutionPlancorresponds to ourPhysicalOperator, not toInstallablePlan.- The summary store (SDS) stays in ASAPQuery-backend. The Planner and the executor never see storage identity.
PRs, in order
Each sub-issue is one PR.
- [Docs] Physical DAG → deployment interface in the physical planning design doc #971: design doc:
docs/design_docs/physical-planning-and-deployment.md§4–§5 describe these concepts. - [Refactor] Input slots take their schema from the physical DAG node; remove InputContract #965: an input slot is a position and the node's schema; remove
InputContract;CompiledPhysicalDAGdrops serde. - [Refactor] Remove legacy public types from asap_executor (Statistic, KeyByLabelValues, Measurement) and the uncalled query_time module #967: remove legacy public types (
Statistic,KeyByLabelValues,Measurement) and the uncalledquery_timemodule. Independent of the others. - [Refactor] TableProvider (RawSource renamed) with a ScanRequest, and one BindingDataSources for table slots and materialized-view slots #966:
TableProvider(renamedRawSource) withScanRequest; table and materialized view slots;BindingDataSources; materialized views throughTableProvider. After [Refactor] Input slots take their schema from the physical DAG node; remove InputContract #965. - [Refactor] Time-series TimeRange as a native operator; bind raw data only at Scan; remove the raw-sample selectors #969: time-series
TimeRangeas a native operator; raw data bound only atScan; remove the raw-sample selectors. After [Refactor] TableProvider (RawSource renamed) with a ScanRequest, and one BindingDataSources for table slots and materialized-view slots #966. - [Refactor] InstallablePlan holds only a versioned DAG and query texts; remove PhysicalASAPDAGDocument; StoredOutput becomes MaterializedView #970:
InstallablePlanholds only a versioned DAG and the query texts (PhysicalASAPDAGDocumentremoved);StoredOutputis renamedMaterializedView; retention on the node. After [Refactor] Time-series TimeRange as a native operator; bind raw data only at Scan; remove the raw-sample selectors #969. - [Refactor] Read raw data and materialized views through one DataStore (read_raw_data, read_materialized_view), not per-run Source/NodeId → TableProvider maps #995: one
DataStore(read_raw_data,read_materialized_view) replacesTableProvider/BindingDataSources;compiletakesMaterializedViews;Scopedropsrevision; node-id cut paths removed. After [Refactor] InstallablePlan holds only a versioned DAG and query texts; remove PhysicalASAPDAGDocument; StoredOutput becomes MaterializedView #970.
Status (2026-10-28)
| Sub-issue | PR | State |
|---|---|---|
| #965 | #978 | merged 8e13aaa7 |
| #967 | #976 | merged ffbce8f9 |
| #966 | #982 | merged 387de3cf |
| #969 | #988 | merged f06a5556 |
| #970 | #991 | merged b318df5b |
| #995 | #1001 | merged 0f7465c9 |
| #971 (design doc) | #972 | merged 9a48d9df |
All sub-issues are done; this issue is closed. The system architecture doc (#1000) is reviewed separately. Follow-ups, tracked outside it:
- per-root query-time
Scanretention (today's value can over-report); - Stage 3 does not cost range spans, and costs a kept pane chain at its newest pane only (Retained raw data as concrete state groups per lookback: offsets, @ and a retention limit #955);
- memo costing (
direct_profile,proposals,lower_bounds) does not seeTimeShiftoffsets; - open point: group-column predicates as a hint on a view read, once decode cost is measured.
Out of scope
- How ASAPQuery-backend implements its providers and summary store.
- Filter pushdown beyond hints (Q4), and limit pushdown.
Activity
[-][Tracking] Physical DAG → deployment boundary: InstallablePlan, input slots, TableProvider, Bindings[/-][+][Tracking] Physical DAG → deployment boundary: InstallablePlan, input slots, TableProvider, BindingDataSources[/+]zzylol commentedon Oct 28, 2026
All code sub-issues are merged:
The design doc #971 (PR #972) is updated to match the merged code.
Names changed during review:
BindingDataSources { tables, materialized_views };InstallablePlan { wire_version, dag, query_texts }withmaterialized_views(),raw_data_retention()andfrom_json;retention_mson materialized-view nodes and on query-timeScannodes.Follow-ups found in review (not blocking):
Scan's retention is the longest window of all its readers plus the longest span of any root reaching it, even when those come from different readers. It can over-report (keep too much, never too little) when one scan feeds an instant[1h]reader and a range-evaluated[5m]reader. A tighter value takes, for each root, the window in that root's subtree plus that root's span (materialized_view.rs,set_retention).is_query_time_scanwas inserted betweenreach_ms_of's doc comment and its function.direct_profile,proposalsandlower_boundsdon't account for offsets; this is part of Retained raw data as concrete state groups per lookback: offsets, @ and a retention limit #955.zzylol commentedon Oct 28, 2026
All sub-issues are done:
8e13aaa7ffbce8f9387de3cff06a5556b318df5b0f7465c99a48d9dfA deployment now reads
InstallablePlan { dag, query_texts }, compiles each view's refresh and the query run withcompile(dag, views, roots), implements oneDataStore(read_raw_data,read_materialized_view), and passes one store handle per run toinstantiate. The design doc (§4 ofphysical-planning-and-deployment.md) describes this interface.Follow-ups:
Scanretention (today's value can over-report), memo costing that does not seeTimeShiftoffsets, and group predicates as a hint on view reads (open point, after measuring decode cost).The system architecture doc (#1000) is reviewed separately.