Skip to content

[Tracking] Physical DAG → deployment boundary: InstallablePlan, input slots, TableProvider, BindingDataSources #968

Closed
7 of 7 issues completed

Description

@zzylol

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:

  1. read InstallablePlan.stored_outputs and query_time_raw_windows. These are fields the Planner derives from the DAG, and InstallablePlan::validate recomputes them and compares them;
  2. call RawDataSampleSelector::new(dag, window) and window_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 the TimeRange node;
  3. build an InputContract for every boundary from the node's output_schema, and bind stored state as a Source at 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:

  1. reads InstallablePlan { dag, query_texts }, and calls materialized_views() to learn which views to maintain;
  2. compiles each view's refresh and the query run once (compile(dag, views: &[MaterializedView], roots)), with no readers;
  3. implements one DataStore over its raw data and its summary store: read_raw_data(source, RawDataRequest) for the http_requests_total time series or a SQL table, and read_materialized_view(view, MaterializedViewRequest) for the panes;
  4. calls instantiate(Arc<dyn DataStore>) with one store handle per run. The DAG's own Scan and TimeRange apply 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 Scan leaf. It keeps the Scan's Source, schema, predicates and window (the union of its readers' windows, scan_window), and is read by Source.
  • materialized view slot: a materialized view (a node whose output is kept across evaluations) that a DAG reads. It keeps the MaterializedView and the window its state summarizes (PhysicalASAPDAG::view_window: the union of the windows of every TimeRange below it), and is read by the MaterializedView. 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 readers

CompiledPhysicalDAG 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 Scan over the store. The Scan computes time_range_ms from its window and the run's Scope, 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 TimeRange is a native operator. A range window filters ts. 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 Scan shared by several windows requests the union of their ranges, and each TimeRange filters its own.

Decisions (2026-10-27)

# Question Decision
Q1 Is a materialized view bound through TableProvider too? Yes: one data interface Revised 2026-10-28 (#995): no. A view is read with 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? Yes Revised 2026-10-28 (#995): the request carries the slot's schema, and the store returns exactly those columns; the executor checks each batch
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:

  • RawSource is renamed TableProvider; TableProvider, ScanRequest, DataSources, BindingDataSources and MemorySource are then replaced by DataStore and InMemoryDataStore ([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 (MatAtIngestion or MatAtQueryKeptAcrossEvaluations) is a materialized view. StoredOutput becomes MaterializedView, StoredOutputBuild becomes its refresh mode, and a stored slot is a materialized view slot. A MatAtQuery node 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 a TableProvider that also implements Materialized { query(), rewrite_readiness() }, queries read it as a table, and mv_dependencies gives its build graph. Ours map as follows: query() is the sub-DAG under the node, and coverage.definition_id identifies it; readiness is the deployment's (the summary store's status); the build graph is MaterializedView.inputs.
  • InstallablePlan keeps its name. DataFusion's ExecutionPlan corresponds to our PhysicalOperator, not to InstallablePlan.
  • 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.

  1. [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.
  2. [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; CompiledPhysicalDAG drops serde.
  3. [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 uncalled query_time module. Independent of the others.
  4. [Refactor] TableProvider (RawSource renamed) with a ScanRequest, and one BindingDataSources for table slots and materialized-view slots #966: TableProvider (renamed RawSource) with ScanRequest; table and materialized view slots; BindingDataSources; materialized views through TableProvider. After [Refactor] Input slots take their schema from the physical DAG node; remove InputContract #965.
  5. [Refactor] Time-series TimeRange as a native operator; bind raw data only at Scan; remove the raw-sample selectors #969: time-series TimeRange as a native operator; raw data bound only at Scan; remove the raw-sample selectors. After [Refactor] TableProvider (RawSource renamed) with a ScanRequest, and one BindingDataSources for table slots and materialized-view slots #966.
  6. [Refactor] InstallablePlan holds only a versioned DAG and query texts; remove PhysicalASAPDAGDocument; StoredOutput becomes MaterializedView #970: InstallablePlan holds only a versioned DAG and the query texts (PhysicalASAPDAGDocument removed); StoredOutput is renamed MaterializedView; 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.
  7. [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) replaces TableProvider/BindingDataSources; compile takes MaterializedViews; Scope drops revision; 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:

Out of scope

  • How ASAPQuery-backend implements its providers and summary store.
  • Filter pushdown beyond hints (Q4), and limit pushdown.

Activity

  1. changed the title [-][Tracking] Physical DAG → deployment boundary: InstallablePlan, input slots, TableProvider, Bindings[/-] [+][Tracking] Physical DAG → deployment boundary: InstallablePlan, input slots, TableProvider, BindingDataSources[/+] on Oct 27, 2026
  2. zzylol commented on Oct 28, 2026

    @zzylol
    ContributorAuthor

    All code sub-issues are merged:

    Sub-issue PR Squash
    #965 #978 8e13aaa
    #967 #976 ffbce8f
    #966 #982 387de3c
    #969 #988 f06a555
    #970 #991 b318df5

    The design doc #971 (PR #972) is updated to match the merged code.

    Names changed during review:

    • the bound data sources: BindingDataSources { tables, materialized_views };
    • the slot kind: "materialized view slot";
    • the plan file: InstallablePlan { wire_version, dag, query_texts } with materialized_views(), raw_data_retention() and from_json;
    • retention: retention_ms on materialized-view nodes and on query-time Scan nodes.

    Follow-ups found in review (not blocking):

  3. zzylol commented on Oct 28, 2026

    @zzylol
    ContributorAuthor

    All sub-issues are done:

    Sub-issue PR Squash
    #965 #978 8e13aaa7
    #967 #976 ffbce8f9
    #966 #982 387de3cf
    #969 #988 f06a5556
    #970 #991 b318df5b
    #995 #1001 0f7465c9
    #971 (design doc) #972 9a48d9df

    A deployment now reads InstallablePlan { dag, query_texts }, compiles each view's refresh and the query run with compile(dag, views, roots), implements one DataStore (read_raw_data, read_materialized_view), and passes one store handle per run to instantiate. The design doc (§4 of physical-planning-and-deployment.md) describes this interface.

    Follow-ups:

    The system architecture doc (#1000) is reviewed separately.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions