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