Skip to main content

mz_clusterd_test_driver/
dataflow.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Assembly of compute [`DataflowDescription`]s for the headless test driver.
11//!
12//! [`DataflowBuilder`] is the generic boundary between tests and the dataflow
13//! assembly mechanism. A test describes its dataflow in terms of persist imports,
14//! MIR objects to compute, and index exports; the builder owns the parts that are
15//! hard and reusable — the MIR-to-LIR lowering, the [`RenderPlan`] conversion, the
16//! [`CollectionMetadata`] attachment, and the `SqlRelationType`-versus-
17//! `ReprRelationType` bookkeeping — and produces a
18//! `RenderDataflowDescription<CollectionMetadata>` ready to ship as
19//! [`ComputeCommand::CreateDataflow`].
20//!
21//! [`index_dataflow`] is thin sugar over the builder for the common single-index
22//! shape.
23//!
24//! [`ComputeCommand::CreateDataflow`]: mz_compute_client::protocol::command::ComputeCommand::CreateDataflow
25
26use std::collections::BTreeMap;
27use std::time::Duration;
28
29use mz_compute_types::dataflows::{
30    BuildDesc, DataflowDescription, IndexDesc, IndexImport, LirDataflowDescription,
31    RenderDataflowDescription, SourceImport,
32};
33use mz_compute_types::plan::LirRelationExpr;
34use mz_compute_types::plan::render_plan::RenderPlan;
35use mz_compute_types::sinks::{
36    ComputeSinkConnection, ComputeSinkDesc, MaterializedViewSinkConnection, MetricSinkConnection,
37    SubscribeSinkConnection,
38};
39use mz_compute_types::sources::SourceInstanceDesc;
40use mz_expr::explain::ExplainContext;
41use mz_expr::{
42    AggregateExpr, AggregateFunc, MirRelationExpr, MirScalarExpr, OptimizedMirRelationExpr,
43};
44use mz_persist_types::{PersistLocation, ShardId};
45use mz_repr::explain::{DummyHumanizer, Explain, ExplainConfig, ExplainFormat, UsedIndexes};
46use mz_repr::optimize::OptimizerFeatures;
47use mz_repr::{GlobalId, RelationDesc, ReprRelationType, Timestamp};
48use mz_storage_types::controller::CollectionMetadata;
49use mz_transform::dataflow::DataflowMetainfo;
50use mz_transform::typecheck::empty_typechecking_context;
51use mz_transform::{EmptyStatisticsOracle, IndexOracle, TransformCtx, optimize_dataflow};
52use timely::progress::Antichain;
53
54/// A persist-backed storage collection to import into a dataflow.
55///
56/// `upper` is the exclusive upper bound of the shard's written data (the next
57/// timestamp after the last written one): for data written at a single timestamp
58/// `t`, pass `t + 1`; for data spread across `0..n_ts`, pass `n_ts`. The compute
59/// instance uses it to know when the source's data is fully available.
60#[derive(Clone, Debug)]
61pub struct PersistSource {
62    /// The data shard backing the collection.
63    pub shard: ShardId,
64    /// The persist location (blob + consensus) the shard lives in.
65    pub location: PersistLocation,
66    /// The relation schema of the collection.
67    pub desc: RelationDesc,
68    /// The exclusive upper bound of the shard's written data.
69    pub upper: Timestamp,
70}
71
72/// A persist-backed target shard for a materialized-view sink to write to.
73#[derive(Clone, Debug)]
74pub struct PersistSink {
75    /// The data shard the sink writes its output to.
76    pub shard: ShardId,
77    /// The persist location (blob + consensus) the shard lives in.
78    pub location: PersistLocation,
79}
80
81/// An [`IndexOracle`] over a dataflow's own `index_imports`, exposing exactly the
82/// arrangements this dataflow may read.
83///
84/// The real `environmentd` optimizer is handed a catalog-backed oracle that knows
85/// every index on the cluster; the test driver has no catalog, but a dataflow's
86/// `index_imports` already name exactly the arrangements available to it, so they
87/// are the correct — and only — index information to expose. Without this, the
88/// optimizer would not recognize an imported index and would re-plan a `Get` over
89/// the indexed collection as a (non-existent) persist read.
90#[derive(Debug)]
91struct ImportedIndexOracle {
92    /// `on_id` -> the `(index_id, key)` arrangements imported on it.
93    by_on_id: BTreeMap<GlobalId, Vec<(GlobalId, Vec<MirScalarExpr>)>>,
94}
95
96impl ImportedIndexOracle {
97    /// Build the oracle from a dataflow's `index_imports`, grouping by arranged id.
98    fn new(index_imports: &BTreeMap<GlobalId, IndexImport>) -> Self {
99        let mut by_on_id: BTreeMap<GlobalId, Vec<(GlobalId, Vec<MirScalarExpr>)>> = BTreeMap::new();
100        for (index_id, import) in index_imports {
101            by_on_id
102                .entry(import.desc.on_id)
103                .or_default()
104                .push((*index_id, import.desc.key.clone()));
105        }
106        ImportedIndexOracle { by_on_id }
107    }
108}
109
110impl IndexOracle for ImportedIndexOracle {
111    fn indexes_on(
112        &self,
113        id: GlobalId,
114    ) -> Box<dyn Iterator<Item = (GlobalId, &[MirScalarExpr])> + '_> {
115        match self.by_on_id.get(&id) {
116            Some(indexes) => Box::new(indexes.iter().map(|(id, key)| (*id, key.as_slice()))),
117            None => Box::new(std::iter::empty()),
118        }
119    }
120}
121
122/// A handle to an imported collection or built object, used to reference it when
123/// constructing MIR for further objects.
124#[derive(Clone, Debug)]
125pub struct Input {
126    id: GlobalId,
127    typ: ReprRelationType,
128}
129
130impl Input {
131    /// The id this input is bound to in the dataflow.
132    pub fn id(&self) -> GlobalId {
133        self.id
134    }
135
136    /// A MIR `Get` of this input, carrying its relation type, for use as a leaf
137    /// when building a computation over it.
138    pub fn get(&self) -> MirRelationExpr {
139        MirRelationExpr::global_get(self.id, self.typ.clone())
140    }
141}
142
143/// Builds a compute dataflow from generic parts, hiding the lowering and persist
144/// wiring mechanism.
145///
146/// # Contract
147///
148/// By default the caller supplies MIR and the builder lowers it *faithfully*,
149/// attaching the persist wiring without optimizing — so a hand-built minimal plan
150/// lowers exactly as written. Optimization — fusion, predicate pushdown, and notably
151/// join-implementation selection — is opt-in via [`Self::optimize`], paid for only
152/// by callers that need it. A `Join` whose `implementation` is left `Unimplemented`
153/// is rejected by the LIR lowering, so a plan containing one requires `optimize`,
154/// which runs [`mz_transform::optimize_dataflow`] to fill the implementation first.
155/// When optimizing, the builder hands the optimizer an index oracle built from its
156/// own `index_imports` (`ImportedIndexOracle`), so imported arrangements are
157/// recognized — the same index information `environmentd`'s catalog oracle would
158/// supply for these imports.
159///
160/// # Construction strategy
161///
162/// The builder deliberately does *not* hand-roll the [`RenderPlan`]: the [`LirId`]s
163/// used to stitch nodes together have no public constructor, and the [`LetFreePlan`]
164/// invariants (notably a valid `topological_order`) are easy to get wrong. Instead
165/// it mirrors exactly what the real compute controller does:
166///
167///  1. Accumulate a MIR-level [`DataflowDescription<OptimizedMirRelationExpr, ()>`]
168///     using the same [`import_source`] / [`insert_plan`] / [`export_index`] helpers
169///     the optimizer uses.
170///  2. Lower it to LIR via [`LirRelationExpr::finalize_dataflow`], yielding
171///     [`LirDataflowDescription`].
172///  3. Augment it into [`RenderDataflowDescription<CollectionMetadata>`] by
173///     converting each object's [`LirRelationExpr`] via [`RenderPlan::try_from`] and attaching
174///     the storage [`CollectionMetadata`] to each source import — the same step
175///     performed in `compute-client`'s `Instance::create_dataflow`.
176///
177/// This guarantees the emitted plan is structurally identical to one produced by a
178/// live `environmentd`, at the cost of running the (cheap, deterministic) lowering
179/// in-process.
180///
181/// [`LirId`]: mz_compute_types::plan::LirId
182/// [`LetFreePlan`]: mz_compute_types::plan::render_plan::LetFreePlan
183/// [`import_source`]: DataflowDescription::import_source
184/// [`insert_plan`]: DataflowDescription::insert_plan
185/// [`export_index`]: DataflowDescription::export_index
186/// [`DataflowDescription<OptimizedMirRelationExpr, ()>`]: DataflowDescription
187/// [`RenderDataflowDescription<CollectionMetadata>`]: RenderDataflowDescription
188pub struct DataflowBuilder {
189    /// The MIR-level description being accumulated.
190    mir: DataflowDescription<OptimizedMirRelationExpr, ()>,
191    /// Persist metadata per imported source id, consumed by the augment step.
192    sources: BTreeMap<GlobalId, PersistSource>,
193    /// Target storage metadata per materialized-view sink id, consumed by the
194    /// augment step to fill the sink connection's `storage_metadata`.
195    sinks: BTreeMap<GlobalId, CollectionMetadata>,
196    /// Relation type per referenceable id (imports and built objects), so
197    /// `export_index` can derive the `on_type` instead of taking it as an argument.
198    types: BTreeMap<GlobalId, ReprRelationType>,
199    /// Whether `finish` runs the MIR dataflow optimizer before lowering. Off by
200    /// default (faithful lowering of the caller's MIR); see [`Self::optimize`].
201    optimize: bool,
202}
203
204impl DataflowBuilder {
205    /// Start an empty builder. `name` becomes the dataflow's debug name.
206    pub fn new(name: impl Into<String>) -> Self {
207        DataflowBuilder {
208            mir: DataflowDescription::new(name.into()),
209            sources: BTreeMap::new(),
210            sinks: BTreeMap::new(),
211            types: BTreeMap::new(),
212            optimize: false,
213        }
214    }
215
216    /// Import a persist-backed storage collection as `id`.
217    ///
218    /// Registers the source on the MIR description and records the persist metadata
219    /// for the augment step. Returns an [`Input`] handle whose [`Input::get`] yields
220    /// a correctly typed `Get` node, so callers never construct a [`ReprRelationType`]
221    /// by hand.
222    pub fn import_persist(&mut self, id: GlobalId, source: PersistSource) -> Input {
223        // `import_source` takes the `SqlRelationType`; the `Get`/export path wants the
224        // `ReprRelationType`. Both are derived from the single `desc`.
225        let sql_typ = source.desc.typ().clone();
226        let repr_typ = ReprRelationType::from(source.desc.typ());
227        // `monotonic: false` matches the verified-structure requirement.
228        self.mir.import_source(id, sql_typ, false);
229        self.sources.insert(id, source);
230        self.types.insert(id, repr_typ.clone());
231        Input { id, typ: repr_typ }
232    }
233
234    /// Import a previously-exported index, making the collection it arranges
235    /// (`on_id`) available to this dataflow as an in-memory arrangement.
236    ///
237    /// Unlike [`Self::import_persist`], this imports no storage collection: the
238    /// arrangement is served from the replica's existing, hydrated index, so the
239    /// dataflow needs no [`CollectionMetadata`] and the augment step leaves the
240    /// index import untouched. The MIR-to-LIR lowering registers the imported
241    /// arrangement under `Get(on_id)` automatically, so a faithful (unoptimized)
242    /// `Get(on_id)` picks it up. Returns an [`Input`] referencing `on_id` — the
243    /// id a computation `Get`s, not the index id itself.
244    pub fn import_index(
245        &mut self,
246        index_id: GlobalId,
247        on_id: GlobalId,
248        key_cols: Vec<usize>,
249        on_type: ReprRelationType,
250        monotonic: bool,
251    ) -> Input {
252        let key: Vec<MirScalarExpr> = key_cols.into_iter().map(MirScalarExpr::column).collect();
253        self.mir.import_index(
254            index_id,
255            IndexDesc { on_id, key },
256            on_type.clone(),
257            monotonic,
258        );
259        self.types.insert(on_id, on_type.clone());
260        Input {
261            id: on_id,
262            typ: on_type,
263        }
264    }
265
266    /// A typed `Get` of an already-imported or built id, for callers that
267    /// assemble MIR by id rather than threading [`Input`] handles — notably the
268    /// JSON MIR translator. Errors if `id` was never imported or built, so a bad
269    /// reference surfaces cleanly instead of constructing an ill-typed `Get`.
270    pub fn get(&self, id: GlobalId) -> anyhow::Result<MirRelationExpr> {
271        let typ = self
272            .types
273            .get(&id)
274            .ok_or_else(|| anyhow::anyhow!("get of unknown id {id}; import or build it first"))?
275            .clone();
276        Ok(MirRelationExpr::global_get(id, typ))
277    }
278
279    /// Insert a MIR object to compute, bound to `id`.
280    ///
281    /// `expr` is wrapped via [`OptimizedMirRelationExpr::declare_optimized`]; the
282    /// caller is responsible for any optimization (see the type-level contract). The
283    /// object's relation type is recorded so a later [`Self::export_index`] over `id`
284    /// can derive its `on_type`.
285    pub fn build(&mut self, id: GlobalId, expr: MirRelationExpr) -> &mut Self {
286        self.types.insert(id, expr.typ());
287        self.mir
288            .insert_plan(id, OptimizedMirRelationExpr::declare_optimized(expr));
289        self
290    }
291
292    /// Export an index `index_id` arranging `on_id` by `key_cols`.
293    ///
294    /// `on_id` may be an imported source or a built object; either way the lowering
295    /// synthesizes the `ArrangeBy`. The `on_type` is derived from the referenced id,
296    /// which must have been imported or built first.
297    pub fn export_index(
298        &mut self,
299        index_id: GlobalId,
300        on_id: GlobalId,
301        key_cols: Vec<usize>,
302    ) -> &mut Self {
303        let on_type = self
304            .types
305            .get(&on_id)
306            .unwrap_or_else(|| panic!("export_index on unknown id {on_id}"))
307            .clone();
308        let key: Vec<MirScalarExpr> = key_cols.into_iter().map(MirScalarExpr::column).collect();
309        self.mir
310            .export_index(index_id, IndexDesc { on_id, key }, on_type);
311        self
312    }
313
314    /// Export a materialized-view persist sink `sink_id` writing the collection
315    /// `from_id` to a target persist shard (a materialized view).
316    ///
317    /// `value_desc` is the output relation schema; it must match `from_id`'s type
318    /// (validated by the caller). The target shard is identified by `target`, whose
319    /// `CollectionMetadata` the augment step splices into the sink connection — the
320    /// compute persist sink opens it as `SourceData/()/Timestamp/StorageDiff`, the
321    /// same codec a storage collection uses, so the shard reads back like any other.
322    ///
323    /// `up_to` is always the empty antichain: the persist sink does not implement
324    /// `UP TO` (it panics during rendering otherwise), and the real optimizer
325    /// likewise leaves a materialized view's `up_to` empty — it is a subscribe-only
326    /// concept.
327    pub fn export_materialized_view(
328        &mut self,
329        sink_id: GlobalId,
330        from_id: GlobalId,
331        value_desc: RelationDesc,
332        target: PersistSink,
333    ) -> &mut Self {
334        let metadata = CollectionMetadata {
335            persist_location: target.location,
336            data_shard: target.shard,
337            relation_desc: value_desc.clone(),
338            txns_shard: None,
339        };
340        self.sinks.insert(sink_id, metadata);
341        // The MIR-level description carries the unit storage metadata; the augment
342        // step replaces it with the `CollectionMetadata` recorded above.
343        let desc = ComputeSinkDesc {
344            from: from_id,
345            from_desc: value_desc.clone(),
346            connection: ComputeSinkConnection::MaterializedView(MaterializedViewSinkConnection {
347                value_desc,
348                storage_metadata: (),
349            }),
350            with_snapshot: true,
351            up_to: Antichain::new(),
352            non_null_assertions: vec![],
353            refresh_schedule: None,
354            from_arrangement: None,
355        };
356        self.mir.export_sink(sink_id, desc);
357        self
358    }
359
360    /// Export a subscribe sink `sink_id` streaming changes of the collection
361    /// `from_id` back as `ComputeResponse::SubscribeResponse` batches.
362    ///
363    /// Unlike a materialized view, a subscribe writes no shard, so it needs no
364    /// storage metadata. `value_desc` is the output schema (must match `from_id`'s
365    /// type); `up_to` is the exclusive upper at which the subscribe completes. The
366    /// empty `output` ordering leaves intra-timestamp order unconstrained — the
367    /// driver consolidates and sorts the updates for a deterministic golden.
368    pub fn export_subscribe(
369        &mut self,
370        sink_id: GlobalId,
371        from_id: GlobalId,
372        value_desc: RelationDesc,
373        up_to: Antichain<Timestamp>,
374    ) -> &mut Self {
375        let desc = ComputeSinkDesc {
376            from: from_id,
377            from_desc: value_desc,
378            connection: ComputeSinkConnection::Subscribe(SubscribeSinkConnection {
379                output: vec![],
380            }),
381            with_snapshot: true,
382            up_to,
383            non_null_assertions: vec![],
384            refresh_schedule: None,
385            from_arrangement: None,
386        };
387        self.mir.export_sink(sink_id, desc);
388        self
389    }
390
391    /// Export a metric sink `sink_id` publishing the collection `from_id` into the replica's
392    /// in-process Prometheus registry.
393    ///
394    /// Like a subscribe, a metric sink writes no shard, so it needs no storage metadata.
395    /// `from_desc` must be the shaped canonical row shape the operator reads: `metric_name`,
396    /// `metric_type`, `labels`, `value`, `help`, plus the planner-computed `metric_kind` and
397    /// `name_valid` columns (see `mz_adapter::optimize::metric_sink::shape_metric_sink_source`).
398    /// The sink has no upper bound, matching a maintained (non-`UP TO`) export.
399    pub fn export_metric_sink(
400        &mut self,
401        sink_id: GlobalId,
402        from_id: GlobalId,
403        from_desc: RelationDesc,
404    ) -> &mut Self {
405        let desc = ComputeSinkDesc {
406            from: from_id,
407            from_desc,
408            connection: ComputeSinkConnection::MetricSink(MetricSinkConnection {
409                label: sink_id.to_string(),
410            }),
411            with_snapshot: true,
412            up_to: Antichain::new(),
413            non_null_assertions: vec![],
414            refresh_schedule: None,
415            from_arrangement: None,
416        };
417        self.mir.export_sink(sink_id, desc);
418        self
419    }
420
421    /// Set the dataflow's `as_of` (the read frontier hydration starts from).
422    pub fn as_of(&mut self, t: Timestamp) -> &mut Self {
423        self.mir.as_of = Some(Antichain::from_elem(t));
424        self
425    }
426
427    /// Set the dataflow's `until` (the exclusive upper bound past which output is
428    /// dropped). Defaults to the empty antichain (no bound).
429    pub fn until(&mut self, t: Timestamp) -> &mut Self {
430        self.mir.until = Antichain::from_elem(t);
431        self
432    }
433
434    /// Run the MIR dataflow optimizer in [`Self::finish`] before lowering.
435    ///
436    /// Off by default: the builder otherwise lowers the caller's MIR faithfully (the
437    /// contract above). Enable it for plans that don't lower from raw MIR — notably a
438    /// `Join`, whose `implementation` defaults to `Unimplemented` and is rejected by
439    /// the LIR lowering until [`mz_transform::optimize_dataflow`]'s `JoinImplementation`
440    /// fills it in — or to reproduce the plan `environmentd` would ship for a logical
441    /// expression rather than the literal one written.
442    pub fn optimize(&mut self) -> &mut Self {
443        self.optimize = true;
444        self
445    }
446
447    /// Lower the accumulated MIR and attach persist wiring, producing the
448    /// `DataflowDescription` the compute protocol expects.
449    ///
450    /// Returns an error rather than panicking on a malformed plan (e.g. a key
451    /// column out of range, or an unbalanced object graph), so a caller driving
452    /// this from external input — notably the script reader — can surface a clean
453    /// error instead of crashing the process.
454    pub fn finish(self) -> anyhow::Result<RenderDataflowDescription<CollectionMetadata>> {
455        let features = OptimizerFeatures::default();
456        let lowered = Self::lower(self.mir, self.optimize, &features)?;
457        augment(lowered, &self.sources, &self.sinks)
458    }
459
460    /// Render the lowered dataflow as `EXPLAIN PHYSICAL PLAN`-style text — the LIR
461    /// the dataflow ships — so a script can golden-assert the optimized
462    /// plan shape and catch optimizer (or lowering) drift, which a result-only
463    /// assertion misses.
464    ///
465    /// Honors [`Self::optimize`] exactly like [`Self::finish`], so the explained
466    /// plan is the one that would be shipped. A no-catalog [`DummyHumanizer`]
467    /// renders ids as `u123` and columns as `#n` — stable and matching the `.spec`
468    /// MIR vocabulary, with no catalog to thread in. Literals render verbatim,
469    /// independent of the build profile.
470    pub fn explain(self) -> anyhow::Result<String> {
471        let features = OptimizerFeatures::default();
472        let mut lowered = Self::lower(self.mir, self.optimize, &features)?;
473        // `redacted` is pinned rather than taken from `ExplainConfig::default`, which
474        // derives it from the build's soft-assertion setting: a default-configured
475        // render would anonymize literals in the release-profile driver image and
476        // print them verbatim under `cargo test` or a `PROFILE=dev` local run. A
477        // golden must not depend on how the binary was built.
478        let config = ExplainConfig {
479            redacted: false,
480            ..ExplainConfig::default()
481        };
482        let context = ExplainContext {
483            config: &config,
484            features: &features,
485            humanizer: &DummyHumanizer,
486            cardinality_stats: BTreeMap::new(),
487            used_indexes: UsedIndexes::default(),
488            finishing: None,
489            duration: Duration::default(),
490            target_cluster: None,
491            optimizer_notices: Vec::new(),
492        };
493        lowered
494            .explain(&ExplainFormat::Text, &context)
495            .map_err(|e| anyhow::anyhow!("explaining dataflow failed: {e}"))
496    }
497
498    /// Optionally run the MIR dataflow optimizer, then lower MIR to LIR.
499    /// Shared by [`Self::finish`] (which augments the result with persist metadata)
500    /// and [`Self::explain`] (which renders it). Deterministic and self-contained.
501    fn lower(
502        mut mir: DataflowDescription<OptimizedMirRelationExpr, ()>,
503        optimize: bool,
504        features: &OptimizerFeatures,
505    ) -> anyhow::Result<LirDataflowDescription> {
506        // Optionally run the MIR dataflow optimizer first (e.g. to fill a `Join`'s
507        // implementation). The index oracle is built from this dataflow's own
508        // `index_imports`, so the optimizer recognizes imported arrangements and
509        // plans `Get`s over them as arrangement reads (not persist reads); the
510        // statistics oracle is empty — no catalog stats — so join planning falls
511        // back to a differential join, which lowers.
512        if optimize {
513            let indexes = ImportedIndexOracle::new(&mir.index_imports);
514            let typecheck_ctx = empty_typechecking_context();
515            let mut df_meta = DataflowMetainfo::default();
516            let mut ctx = TransformCtx::global(
517                &indexes,
518                &EmptyStatisticsOracle,
519                features,
520                &typecheck_ctx,
521                &mut df_meta,
522                None,
523            );
524            optimize_dataflow(&mut mir, &mut ctx, false)
525                .map_err(|e| anyhow::anyhow!("optimizing dataflow failed: {e}"))?;
526        }
527        // Lower MIR -> LIR. Deterministic and self-contained.
528        LirRelationExpr::finalize_dataflow(mir, features, None)
529            .map_err(|e| anyhow::anyhow!("lowering dataflow failed: {e}"))
530    }
531}
532
533/// Build a single-index dataflow over a persist shard.
534///
535/// Thin sugar over [`DataflowBuilder`] for the common shape: import the collection
536/// backed by `shard` as `source_id`, set `as_of`, and export an index `index_id`
537/// arranging the collection by `key_cols`.
538///
539/// `shard_upper` is the exclusive upper bound of the shard's written data; see
540/// [`PersistSource::upper`].
541pub fn index_dataflow(
542    source_id: GlobalId,
543    index_id: GlobalId,
544    shard: ShardId,
545    location: PersistLocation,
546    desc: RelationDesc,
547    key_cols: Vec<usize>,
548    as_of: Timestamp,
549    shard_upper: Timestamp,
550) -> anyhow::Result<RenderDataflowDescription<CollectionMetadata>> {
551    let mut builder = DataflowBuilder::new("headless-index");
552    builder.import_persist(
553        source_id,
554        PersistSource {
555            shard,
556            location,
557            desc,
558            upper: shard_upper,
559        },
560    );
561    builder.as_of(as_of);
562    builder.export_index(index_id, source_id, key_cols);
563    builder.finish()
564}
565
566/// Build a dataflow that counts the rows of an existing index and exports the
567/// count as a new, peekable index.
568///
569/// Imports index `index_id` (arranging `on_id`, schema `on_type`, key `key_cols`),
570/// computes `Reduce` with a single `count(*)` aggregate and an empty group key over
571/// `Get(on_id)`, and exports `out_index_id` arranging the one-column count by `[0]`.
572/// This is the compute-side realization of a row-count assertion: the count runs
573/// through a real reduce operator rather than being tallied in the driver.
574///
575/// The result collection has one `bigint` column. Over an empty input the reduce
576/// emits no rows (SQL's default-zero is added higher up), so a peek of the output
577/// yields `[]`, which callers read as a count of `0`.
578pub fn count_over_index(
579    index_id: GlobalId,
580    on_id: GlobalId,
581    on_type: ReprRelationType,
582    key_cols: Vec<usize>,
583    reduce_id: GlobalId,
584    out_index_id: GlobalId,
585    as_of: Timestamp,
586) -> anyhow::Result<RenderDataflowDescription<CollectionMetadata>> {
587    let mut builder = DataflowBuilder::new("headless-count");
588    // `monotonic: false` keeps the import faithful to a general (non-append-only)
589    // index; the count reduce does not require monotonicity.
590    let input = builder.import_index(index_id, on_id, key_cols, on_type, false);
591    // `count(*)`: count over a non-null literal, so every row contributes.
592    let count = AggregateExpr {
593        func: AggregateFunc::Count,
594        expr: MirScalarExpr::literal_true(),
595        distinct: false,
596    };
597    let reduce = MirRelationExpr::Reduce {
598        input: Box::new(input.get()),
599        group_key: vec![],
600        aggregates: vec![count],
601        monotonic: false,
602        expected_group_size: None,
603    };
604    builder.build(reduce_id, reduce);
605    builder.as_of(as_of);
606    // The reduce output is a single column; arrange it by that column so the
607    // exported index is peekable.
608    builder.export_index(out_index_id, reduce_id, vec![0]);
609    builder.finish()
610}
611
612/// Convert a lowered `DataflowDescription<Plan, ()>` into the
613/// `<RenderPlan, CollectionMetadata>` form expected by the compute protocol.
614///
615/// Mirrors `compute-client`'s `Instance::create_dataflow`: each object's [`LirRelationExpr`]
616/// is flattened into a [`RenderPlan`], and every source import is augmented with the
617/// storage [`CollectionMetadata`] needed by the compute instance to read it. The
618/// per-id [`PersistSource`] supplies the metadata and the exclusive `upper` telling
619/// the compute instance up to which timestamp the shard's data is available.
620fn augment(
621    lowered: LirDataflowDescription,
622    sources: &BTreeMap<GlobalId, PersistSource>,
623    sinks: &BTreeMap<GlobalId, CollectionMetadata>,
624) -> anyhow::Result<RenderDataflowDescription<CollectionMetadata>> {
625    // Attach the storage metadata to each source import, looked up by id. In a live
626    // controller the `upper` is the storage collection's real write frontier; the
627    // caller provides it via `PersistSource::upper` to reflect the written data.
628    let mut source_imports = BTreeMap::new();
629    for (id, import) in lowered.source_imports {
630        let source = sources
631            .get(&id)
632            .ok_or_else(|| anyhow::anyhow!("no persist metadata registered for source {id}"))?;
633        let metadata = CollectionMetadata {
634            persist_location: source.location.clone(),
635            data_shard: source.shard,
636            relation_desc: source.desc.clone(),
637            txns_shard: None,
638        };
639        let desc = SourceInstanceDesc {
640            storage_metadata: metadata,
641            arguments: import.desc.arguments,
642            typ: import.desc.typ,
643        };
644        source_imports.insert(
645            id,
646            SourceImport {
647                desc,
648                monotonic: import.monotonic,
649                with_snapshot: import.with_snapshot,
650                upper: Antichain::from_elem(source.upper),
651            },
652        );
653    }
654
655    let objects_to_build = lowered
656        .objects_to_build
657        .into_iter()
658        .map(|object| {
659            // `RenderPlan::try_from` fails (with `()`) on a structurally invalid
660            // lowered plan; surface it as an error rather than panicking.
661            let plan = RenderPlan::try_from(object.plan)
662                .map_err(|()| anyhow::anyhow!("RenderPlan conversion failed for {}", object.id))?;
663            Ok::<_, anyhow::Error>(BuildDesc {
664                id: object.id,
665                plan,
666            })
667        })
668        .collect::<anyhow::Result<Vec<_>>>()?;
669
670    // Splice the storage metadata into each sink export, mirroring how
671    // `compute-client`'s `Instance::create_dataflow` fills the materialized-view
672    // sink's `storage_metadata` from the storage controller. A subscribe carries no
673    // metadata; copy-to is not built by this driver.
674    let mut sink_exports = BTreeMap::new();
675    for (id, sink) in lowered.sink_exports {
676        let connection = match sink.connection {
677            ComputeSinkConnection::MaterializedView(conn) => {
678                let metadata = sinks.get(&id).ok_or_else(|| {
679                    anyhow::anyhow!("no target metadata registered for materialized-view sink {id}")
680                })?;
681                ComputeSinkConnection::MaterializedView(MaterializedViewSinkConnection {
682                    value_desc: conn.value_desc,
683                    storage_metadata: metadata.clone(),
684                })
685            }
686            ComputeSinkConnection::Subscribe(conn) => ComputeSinkConnection::Subscribe(conn),
687            // A metric sink writes into the process-local metrics registry, not persist, so it
688            // carries no storage metadata to splice.
689            ComputeSinkConnection::MetricSink(conn) => ComputeSinkConnection::MetricSink(conn),
690            ComputeSinkConnection::CopyToS3Oneshot(_) => {
691                anyhow::bail!("copy-to-s3 sink {id} is not implemented")
692            }
693        };
694        sink_exports.insert(
695            id,
696            ComputeSinkDesc {
697                from: sink.from,
698                from_desc: sink.from_desc,
699                connection,
700                with_snapshot: sink.with_snapshot,
701                up_to: sink.up_to,
702                non_null_assertions: sink.non_null_assertions,
703                refresh_schedule: sink.refresh_schedule,
704                from_arrangement: sink.from_arrangement,
705            },
706        );
707    }
708
709    Ok(DataflowDescription {
710        source_imports,
711        objects_to_build,
712        // The remaining fields carry over unchanged from the lowered dataflow.
713        index_imports: lowered.index_imports,
714        index_exports: lowered.index_exports,
715        sink_exports,
716        as_of: lowered.as_of,
717        until: lowered.until,
718        initial_storage_as_of: lowered.initial_storage_as_of,
719        refresh_schedule: lowered.refresh_schedule,
720        debug_name: lowered.debug_name,
721        time_dependence: lowered.time_dependence,
722    })
723}
724
725#[cfg(test)]
726mod tests {
727    use super::*;
728
729    use mz_compute_types::plan::GetPlan;
730    use mz_compute_types::plan::render_plan::Expr;
731    use mz_compute_types::plan::scalar::LirScalarExpr;
732    use mz_expr::Id;
733
734    /// Assert the assembled dataflow matches the verified structure: a single
735    /// source import, a single object building `Get(source) -> ArrangeBy(key)`,
736    /// and a single index export over the source.
737    #[mz_ore::test]
738    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
739    fn index_dataflow_structure() {
740        let desc = crate::data::sample_desc();
741        let loc = PersistLocation {
742            blob_uri: "mem://".parse().unwrap(),
743            consensus_uri: "mem://".parse().unwrap(),
744        };
745        let df = index_dataflow(
746            GlobalId::User(1000),
747            GlobalId::User(1001),
748            ShardId::new(),
749            loc,
750            desc,
751            vec![0],
752            Timestamp::from(0),
753            Timestamp::from(1),
754        )
755        .unwrap();
756        // Structural assertions mirroring the spec.
757        assert_eq!(df.source_imports.len(), 1);
758        assert_eq!(df.objects_to_build.len(), 1);
759        assert_eq!(df.index_exports.len(), 1);
760        assert!(df.sink_exports.is_empty());
761        assert!(df.index_imports.is_empty());
762        assert_eq!(df.as_of, Some(Antichain::from_elem(Timestamp::from(0))));
763        assert_eq!(df.debug_name, "headless-index");
764
765        let (sid, si) = df.source_imports.iter().next().unwrap();
766        assert_eq!(*sid, GlobalId::User(1000));
767        assert!(si.with_snapshot);
768        assert!(!si.monotonic);
769        assert_eq!(si.upper, Antichain::from_elem(Timestamp::from(1)));
770        assert!(si.desc.arguments.operators.is_none());
771
772        let (iid, (idesc, _typ)) = df.index_exports.iter().next().unwrap();
773        assert_eq!(*iid, GlobalId::User(1001));
774        assert_eq!(idesc.on_id, GlobalId::User(1000));
775        assert_eq!(idesc.key, vec![MirScalarExpr::column(0)]);
776
777        // The built object is `Get(source) -> ArrangeBy(key)`. Destructure the
778        // `RenderPlan` and verify the root arranges, keyed by `Column(0)`, over a
779        // `Get` of the source collection.
780        let plan = &df.objects_to_build[0].plan;
781        assert!(plan.binds.is_empty());
782        let (nodes, root, _order) = plan.body.clone().destruct();
783        let root_node = &nodes[&root];
784        let Expr::ArrangeBy {
785            input,
786            forms,
787            strategy,
788            ..
789        } = &root_node.expr
790        else {
791            panic!("expected root ArrangeBy, got {:?}", root_node.expr);
792        };
793        assert_eq!(forms.arranged.len(), 1);
794        assert_eq!(forms.arranged[0].0, vec![LirScalarExpr::column(0)]);
795        assert_eq!(
796            *strategy,
797            mz_compute_types::plan::ArrangementStrategy::Direct
798        );
799        let input_node = &nodes[input];
800        let Expr::Get { id, plan, .. } = &input_node.expr else {
801            panic!("expected ArrangeBy input Get, got {:?}", input_node.expr);
802        };
803        assert_eq!(*id, Id::Global(GlobalId::User(1000)));
804        assert!(matches!(plan, GetPlan::PassArrangements));
805    }
806
807    /// Exercise the general `build` path: import a source, compute a `Project` over
808    /// it, and export an index on the computed object. The computation and the
809    /// arrange must lower to two distinct objects, and the index export must
810    /// reference the built object rather than the source.
811    #[mz_ore::test]
812    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
813    fn build_computed_object_lowers() {
814        let desc = crate::data::sample_desc();
815        let loc = PersistLocation {
816            blob_uri: "mem://".parse().unwrap(),
817            consensus_uri: "mem://".parse().unwrap(),
818        };
819        let (source_id, comp_id, index_id) = (
820            GlobalId::User(1000),
821            GlobalId::User(1001),
822            GlobalId::User(1002),
823        );
824
825        let mut builder = DataflowBuilder::new("headless-build");
826        let src = builder.import_persist(
827            source_id,
828            PersistSource {
829                shard: ShardId::new(),
830                location: loc,
831                desc,
832                upper: Timestamp::from(1),
833            },
834        );
835        // Project away the payload column, keeping only `id` (column 0).
836        builder.build(comp_id, src.get().project(vec![0]));
837        builder.as_of(Timestamp::from(0));
838        builder.export_index(index_id, comp_id, vec![0]);
839        let df = builder.finish().unwrap();
840
841        // One source import; the index export references the computed object.
842        assert_eq!(df.source_imports.len(), 1);
843        assert!(df.source_imports.contains_key(&source_id));
844        let (iid, (idesc, _typ)) = df.index_exports.iter().next().unwrap();
845        assert_eq!(*iid, index_id);
846        assert_eq!(idesc.on_id, comp_id);
847
848        // The computation and the arrange lower to two distinct build objects.
849        assert_eq!(df.objects_to_build.len(), 2);
850        let ids: Vec<_> = df.objects_to_build.iter().map(|o| o.id).collect();
851        assert!(ids.contains(&comp_id));
852        assert!(ids.contains(&index_id));
853    }
854
855    /// A `Join` does not lower from raw MIR — its `implementation` defaults to
856    /// `Unimplemented` and the LIR lowering rejects it — but `optimize()` runs the
857    /// MIR optimizer first, which fills the implementation, so the same dataflow
858    /// then lowers. This is exactly what the `optimize` flag buys.
859    #[mz_ore::test]
860    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
861    fn join_lowers_only_with_optimize() {
862        let loc = PersistLocation {
863            blob_uri: "mem://".parse().unwrap(),
864            consensus_uri: "mem://".parse().unwrap(),
865        };
866        // Build a two-source equi-join (`#0 = #2` across the concatenated columns)
867        // and export an index over it. `optimize` selects whether the MIR optimizer
868        // runs in `finish`.
869        let assemble = |optimize: bool| {
870            let mut builder = DataflowBuilder::new("headless-join-test");
871            let left = builder.import_persist(
872                GlobalId::User(1000),
873                PersistSource {
874                    shard: ShardId::new(),
875                    location: loc.clone(),
876                    desc: crate::data::sample_desc(),
877                    upper: Timestamp::from(1),
878                },
879            );
880            let right = builder.import_persist(
881                GlobalId::User(1001),
882                PersistSource {
883                    shard: ShardId::new(),
884                    location: loc.clone(),
885                    desc: crate::data::sample_desc(),
886                    upper: Timestamp::from(1),
887                },
888            );
889            let join = MirRelationExpr::join_scalars(
890                vec![left.get(), right.get()],
891                vec![vec![MirScalarExpr::column(0), MirScalarExpr::column(2)]],
892            );
893            builder.build(GlobalId::User(2000), join);
894            if optimize {
895                builder.optimize();
896            }
897            builder.as_of(Timestamp::from(0));
898            builder.export_index(GlobalId::User(2001), GlobalId::User(2000), vec![0]);
899            builder.finish()
900        };
901
902        // Without the optimizer the `Unimplemented` join is rejected by the lowering.
903        assert!(assemble(false).is_err());
904        // With it, the optimizer fills the join implementation and the dataflow lowers.
905        assert!(assemble(true).is_ok());
906    }
907
908    /// `explain` renders the lowered LIR plan as text, so a script can assert the
909    /// optimized plan shape. Build the optimized two-source join and confirm the
910    /// rendered plan mentions a `Join` (the operator the optimizer selected).
911    #[mz_ore::test]
912    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
913    fn explain_join_renders_plan() {
914        let loc = PersistLocation {
915            blob_uri: "mem://".parse().unwrap(),
916            consensus_uri: "mem://".parse().unwrap(),
917        };
918        let mut builder = DataflowBuilder::new("headless-explain-test");
919        let left = builder.import_persist(
920            GlobalId::User(1000),
921            PersistSource {
922                shard: ShardId::new(),
923                location: loc.clone(),
924                desc: crate::data::sample_desc(),
925                upper: Timestamp::from(1),
926            },
927        );
928        let right = builder.import_persist(
929            GlobalId::User(1001),
930            PersistSource {
931                shard: ShardId::new(),
932                location: loc.clone(),
933                desc: crate::data::sample_desc(),
934                upper: Timestamp::from(1),
935            },
936        );
937        let join = MirRelationExpr::join_scalars(
938            vec![left.get(), right.get()],
939            vec![vec![MirScalarExpr::column(0), MirScalarExpr::column(2)]],
940        );
941        builder.build(GlobalId::User(2000), join);
942        builder.optimize();
943        builder.as_of(Timestamp::from(0));
944        builder.export_index(GlobalId::User(2001), GlobalId::User(2000), vec![0]);
945        let text = builder.explain().unwrap();
946        // Print so the rendered shape is visible under `--nocapture`.
947        println!("{text}");
948        assert!(
949            text.contains("Join"),
950            "explain output missing Join:\n{text}"
951        );
952    }
953
954    /// A single dataflow can export both an index and a materialized view over the
955    /// same built object (binding). Both exports reference that object; the index
956    /// arranges it and the MV sink writes it to a target shard.
957    #[mz_ore::test]
958    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
959    fn index_and_mv_same_binding() {
960        let desc = crate::data::sample_desc();
961        let loc = PersistLocation {
962            blob_uri: "mem://".parse().unwrap(),
963            consensus_uri: "mem://".parse().unwrap(),
964        };
965        let (source_id, view_id, index_id, sink_id) = (
966            GlobalId::User(1000),
967            GlobalId::User(1001),
968            GlobalId::User(1002),
969            GlobalId::User(1003),
970        );
971
972        let mut builder = DataflowBuilder::new("headless-index-and-mv");
973        let src = builder.import_persist(
974            source_id,
975            PersistSource {
976                shard: ShardId::new(),
977                location: loc.clone(),
978                desc: desc.clone(),
979                upper: Timestamp::from(1),
980            },
981        );
982        // A view over the source is the shared binding both exports reference.
983        builder.build(
984            view_id,
985            src.get().filter(vec![MirScalarExpr::literal_true()]),
986        );
987        builder.as_of(Timestamp::from(0));
988        builder.export_index(index_id, view_id, vec![0]);
989        builder.export_materialized_view(
990            sink_id,
991            view_id,
992            desc,
993            PersistSink {
994                shard: ShardId::new(),
995                location: loc,
996            },
997        );
998        let df = builder.finish().unwrap();
999
1000        // Both exports are present and reference the same view binding.
1001        assert_eq!(df.index_exports.len(), 1);
1002        assert_eq!(df.sink_exports.len(), 1);
1003        let (_iid, (idesc, _typ)) = df.index_exports.iter().next().unwrap();
1004        assert_eq!(idesc.on_id, view_id);
1005        let (sid, sink) = df.sink_exports.iter().next().unwrap();
1006        assert_eq!(*sid, sink_id);
1007        assert_eq!(sink.from, view_id);
1008        // The MV sink carries the target shard's storage metadata after augment.
1009        assert!(matches!(
1010            sink.connection,
1011            ComputeSinkConnection::MaterializedView(_)
1012        ));
1013    }
1014
1015    /// A metric sink assembles like any other export: a source import, a view binding built over
1016    /// it, and one sink export whose connection is a payload-free `MetricSink`. Unlike a
1017    /// materialized view, the augment step splices no storage metadata into it.
1018    #[mz_ore::test]
1019    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
1020    fn metric_sink_dataflow_structure() {
1021        let desc = crate::data::sample_desc();
1022        let loc = PersistLocation {
1023            blob_uri: "mem://".parse().unwrap(),
1024            consensus_uri: "mem://".parse().unwrap(),
1025        };
1026        let (source_id, view_id, sink_id) = (
1027            GlobalId::User(1000),
1028            GlobalId::User(1001),
1029            GlobalId::User(1002),
1030        );
1031
1032        let mut builder = DataflowBuilder::new("headless-metric-sink");
1033        let src = builder.import_persist(
1034            source_id,
1035            PersistSource {
1036                shard: ShardId::new(),
1037                location: loc,
1038                desc: desc.clone(),
1039                upper: Timestamp::from(1),
1040            },
1041        );
1042        builder.build(
1043            view_id,
1044            src.get().filter(vec![MirScalarExpr::literal_true()]),
1045        );
1046        builder.as_of(Timestamp::from(0));
1047        builder.export_metric_sink(sink_id, view_id, desc);
1048        let df = builder.finish().unwrap();
1049
1050        assert_eq!(df.sink_exports.len(), 1);
1051        let (sid, sink) = df.sink_exports.iter().next().unwrap();
1052        assert_eq!(*sid, sink_id);
1053        assert_eq!(sink.from, view_id);
1054        // The metric sink carries a payload-free connection and no storage metadata.
1055        assert!(matches!(
1056            sink.connection,
1057            ComputeSinkConnection::MetricSink(MetricSinkConnection { .. })
1058        ));
1059    }
1060
1061    /// With `optimize` on, the optimizer is handed an index oracle built from the
1062    /// dataflow's `index_imports`, so a `Get` over an imported (but not persisted)
1063    /// collection is recognized as an arrangement read. Were the oracle empty, the
1064    /// optimizer would re-plan that `Get` as a persist read of a collection that has
1065    /// no source import, and `finish` would fail — so success here, with one index
1066    /// import and no source imports, is the proof the index information reached the
1067    /// optimizer.
1068    #[mz_ore::test]
1069    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
1070    fn optimize_uses_imported_index() {
1071        let desc = crate::data::sample_desc();
1072        let on_type = ReprRelationType::from(desc.typ());
1073        let (index_id, on_id, view_id, out_index_id) = (
1074            GlobalId::User(1001),
1075            GlobalId::User(1000),
1076            GlobalId::User(2000),
1077            GlobalId::User(2001),
1078        );
1079
1080        let mut builder = DataflowBuilder::new("headless-optimize-imported-index");
1081        let input = builder.import_index(index_id, on_id, vec![0], on_type, false);
1082        // A view over the imported arrangement; with `optimize` the optimizer must
1083        // recognize the import to plan the `Get` as an arrangement read.
1084        builder.build(view_id, input.get().project(vec![0]));
1085        builder.optimize();
1086        builder.as_of(Timestamp::from(0));
1087        builder.export_index(out_index_id, view_id, vec![0]);
1088        let df = builder.finish().unwrap();
1089
1090        // The collection is read from the imported arrangement, not from persist:
1091        // exactly one index import, no source imports.
1092        assert_eq!(df.index_imports.len(), 1);
1093        assert!(df.source_imports.is_empty());
1094        let (iid, import) = df.index_imports.iter().next().unwrap();
1095        assert_eq!(*iid, index_id);
1096        assert_eq!(import.desc.on_id, on_id);
1097    }
1098
1099    /// A count-over-index dataflow imports the index (no storage source), builds
1100    /// the reduce and its arrange as two objects, and exports the count index.
1101    #[mz_ore::test]
1102    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
1103    fn count_over_index_structure() {
1104        let desc = crate::data::sample_desc();
1105        let on_type = ReprRelationType::from(desc.typ());
1106        let df = count_over_index(
1107            GlobalId::User(1001), // existing index to import
1108            GlobalId::User(1000), // collection it arranges
1109            on_type,
1110            vec![0],              // its key
1111            GlobalId::User(2000), // reduce build object
1112            GlobalId::User(2001), // exported count index
1113            Timestamp::from(0),
1114        )
1115        .unwrap();
1116
1117        // Imports the arrangement, not a storage collection.
1118        assert_eq!(df.index_imports.len(), 1);
1119        assert!(df.source_imports.is_empty());
1120        let (iid, import) = df.index_imports.iter().next().unwrap();
1121        assert_eq!(*iid, GlobalId::User(1001));
1122        assert_eq!(import.desc.on_id, GlobalId::User(1000));
1123        assert_eq!(import.desc.key, vec![MirScalarExpr::column(0)]);
1124
1125        // Reduce + arrange lower to two build objects; the count index exports.
1126        assert_eq!(df.objects_to_build.len(), 2);
1127        assert_eq!(df.index_exports.len(), 1);
1128        let (eid, (edesc, _typ)) = df.index_exports.iter().next().unwrap();
1129        assert_eq!(*eid, GlobalId::User(2001));
1130        assert_eq!(edesc.on_id, GlobalId::User(2000));
1131        assert_eq!(edesc.key, vec![MirScalarExpr::column(0)]);
1132    }
1133}