Skip to main content

mz_clusterd_test_driver/
script.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//! Executes a text command script against `clusterd`.
11//!
12//! Instead of recompiling a Rust scenario, a test (or an agent) writes a
13//! [`crate::text`] script: a sequence of commands, each with an expected output
14//! block (`----`) that is the assertion. The coarse orchestration verbs map almost
15//! directly to [`Driver`] calls; `define` carries arbitrary MIR (as pretty-form
16//! specs parsed by `mz-expr-parser`, the `.spec` test syntax) over the full
17//! [`DataflowBuilder`] surface, including index imports, while `define_index`
18//! stays as sugar for the common single-index shape. Explicit `write_rows`
19//! payloads are typed against the schema token-by-token via `cell_from_token`
20//! (reusing `mz_repr::strconv`) rather than `Row`'s opaque serde.
21//!
22//! # Execution
23//!
24//! [`run`] parses the script, executes each command, and compares its golden
25//! output to the expected block — failing the run on a mismatch, or rewriting the
26//! file when `REWRITE` is set. A command that fails renders as `error: <message>`,
27//! so an expected failure is asserted by its golden block. Assertions are
28//! level-triggered waits on monotonic frontiers, so a single sequential script is
29//! deterministic regardless of how the dataflows interleave.
30//!
31//! Shards are referenced by a string alias; the first command naming an alias
32//! allocates a fresh [`ShardId`] for it. Object ids follow [`GlobalId`]'s own
33//! text form: a bare number (`1000`) is the user namespace, or an explicit
34//! `s`/`si`/`u`/`t` prefix (`t7`, `u1000`, `s42`) selects the namespace
35//! directly.
36
37use std::collections::BTreeMap;
38use std::path::Path;
39use std::time::Duration;
40
41use anyhow::Context;
42use mz_compute_client::protocol::command::{ComputeCommand, PeekTarget};
43use mz_dyncfg::{ConfigType, ConfigUpdates, ConfigVal};
44use mz_expr::visit::Visit;
45use mz_expr::{Id, MirRelationExpr};
46use mz_expr_parser::{TestCatalog, try_parse_mir};
47use mz_persist_client::PersistClient;
48use mz_persist_types::{PersistLocation, ShardId};
49use mz_repr::{
50    GlobalId, RelationDesc, ReprRelationType, Row, SqlColumnType, SqlRelationType, SqlScalarType,
51    Timestamp, strconv,
52};
53use mz_storage_types::controller::CollectionMetadata;
54use serde::{Deserialize, Serialize};
55use timely::progress::Antichain;
56
57use crate::data::{
58    Cell, pack_cells, sample_desc, synth_rows, write_rows_single_ts, write_rows_spread,
59};
60use crate::dataflow::{
61    DataflowBuilder, PersistSink, PersistSource, count_over_index, index_dataflow,
62};
63use crate::driver::Driver;
64
65/// The default payload padding (bytes) for synthetic rows when a command omits it.
66const DEFAULT_ROW_BYTES: usize = 64;
67/// The default timeout (seconds) for `await_frontier` when a command omits it.
68const DEFAULT_TIMEOUT_SECS: u64 = 600;
69
70/// A column declaration in a `define_schema` command.
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct ColumnSpec {
73    /// Column name.
74    pub name: String,
75    /// Scalar type name; see `scalar_type_from_str`.
76    #[serde(rename = "type")]
77    pub ty: String,
78    /// Whether the column admits `NULL`.
79    #[serde(default)]
80    pub nullable: bool,
81}
82
83/// A single dyncfg update in an `update-configuration` command: a config name, a
84/// type tag selecting how `value` is parsed (`bool`/`u32`/`usize`/`f64`/`string`/
85/// `duration`), and the value. Typed against [`mz_dyncfg`] at execution.
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct ConfigSetting {
88    /// The dyncfg name (sent to the replica by name; unknown names are ignored).
89    pub name: String,
90    /// The type tag selecting the [`ConfigVal`] variant.
91    #[serde(rename = "type")]
92    pub ty: String,
93    /// The value, parsed against `ty`.
94    pub value: String,
95}
96
97/// A collection to import in a `define` command: a persist source or an existing
98/// index. Externally tagged: `{"source": {…}}` or `{"index": {…}}`.
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(rename_all = "snake_case")]
101pub enum ImportSpec {
102    /// Import a persist-backed storage collection, as `define_index` does.
103    Source {
104        /// The imported source's global id.
105        id: GlobalId,
106        /// Shard alias to import; allocated on first use.
107        shard: String,
108        /// Schema name; defaults to the built-in sample schema.
109        #[serde(default)]
110        schema: Option<String>,
111        /// The shard's exclusive write upper (see `PersistSource::upper`).
112        upper: u64,
113    },
114    /// Import an existing index by its global id; its arranged collection, key,
115    /// and type are taken from the registry, so it must have been defined first.
116    Index {
117        /// The index's global id.
118        index_id: GlobalId,
119    },
120}
121
122/// A MIR object to build in a `define` command.
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124pub struct BuildSpec {
125    /// The built object's global id.
126    pub id: GlobalId,
127    /// The computation, as a pretty-form MIR spec parsed by `mz-expr-parser`
128    /// (e.g. `Reduce aggregates=[count(*)]` over `Get u1000`). It references
129    /// imported or previously-built objects by their global-id name (`u<n>`); the
130    /// leaf `Get`'s type is resolved from the import, not authored.
131    pub expr: String,
132}
133
134/// An export in a `create-dataflow` command, mirroring the export kinds a real
135/// dataflow produces (see [`mz_compute_types::sinks::ComputeSinkConnection`]).
136/// `copy-to` is intentionally absent: the parser rejects it as unimplemented.
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(rename_all = "kebab-case")]
139pub enum ExportSpec {
140    /// An arrangement, peekable as an index and importable by later dataflows.
141    Index {
142        /// The exported index's global id.
143        index_id: GlobalId,
144        /// The imported or built id the index arranges.
145        on_id: GlobalId,
146        /// Columns to arrange by.
147        key: Vec<usize>,
148    },
149    /// A persist sink writing the collection to a shard (a materialized view),
150    /// verified by reading the shard back with a persist `peek` of the sink id.
151    MaterializedView {
152        /// The sink's global id (scheduled and frontier-tracked under this id).
153        sink_id: GlobalId,
154        /// The imported or built id the sink writes.
155        on_id: GlobalId,
156        /// Target shard alias; allocated on first use.
157        shard: String,
158        /// Output schema; defaults to the sample schema. Must match `on_id`'s type.
159        schema: Option<String>,
160    },
161    /// A subscribe sink streaming changes back as responses, collected by
162    /// `await-subscribe`.
163    Subscribe {
164        /// The sink's global id.
165        sink_id: GlobalId,
166        /// The imported or built id the sink streams.
167        on_id: GlobalId,
168        /// Output schema; defaults to the sample schema. Must match `on_id`'s type.
169        schema: Option<String>,
170        /// Exclusive upper at which the subscribe completes; unbounded if absent.
171        up_to: Option<u64>,
172    },
173}
174
175/// What an `explain` command renders: a dataflow given inline, or a reference to one
176/// a prior `create-dataflow` declared by name. The reference form avoids repeating a
177/// dataflow's body just to assert its plan.
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(rename_all = "snake_case")]
180pub enum ExplainTarget {
181    /// A dataflow declared inline, with the same body as `create-dataflow`.
182    Inline {
183        /// Debug name for the dataflow; defaults to `headless-create-dataflow`.
184        #[serde(default)]
185        name: Option<String>,
186        /// Collections to import (persist sources and/or existing indexes).
187        #[serde(default)]
188        imports: Vec<ImportSpec>,
189        /// MIR objects to compute, each bound to an id.
190        #[serde(default)]
191        builds: Vec<BuildSpec>,
192        /// Exports over imported or built ids.
193        #[serde(default)]
194        exports: Vec<ExportSpec>,
195        /// The dataflow's `as_of`.
196        as_of: u64,
197        /// Run the MIR optimizer before lowering. Off by default.
198        #[serde(default)]
199        optimize: bool,
200    },
201    /// A dataflow a prior `create-dataflow name=<name>` declared, rendered without
202    /// repeating its body. Reuses the recorded spec, so the plan matches what was
203    /// submitted.
204    Reference {
205        /// The `create-dataflow` name to render.
206        name: String,
207    },
208}
209
210/// Map a JSON type name to a [`SqlScalarType`]. The supported set is intentionally
211/// small and matches [`crate::data::Cell`]; extend both together.
212fn scalar_type_from_str(s: &str) -> anyhow::Result<SqlScalarType> {
213    Ok(match s.to_ascii_lowercase().as_str() {
214        "int16" | "smallint" => SqlScalarType::Int16,
215        "int32" | "int" | "integer" => SqlScalarType::Int32,
216        "int64" | "bigint" => SqlScalarType::Int64,
217        "bool" | "boolean" => SqlScalarType::Bool,
218        "string" | "text" => SqlScalarType::String,
219        "bytes" | "bytea" => SqlScalarType::Bytes,
220        other => anyhow::bail!("unsupported column type {other:?}"),
221    })
222}
223
224/// Parse a configuration value string into a [`ConfigVal`], selecting the variant
225/// by a type tag. Each numeric/bool/duration type reuses [`mz_dyncfg`]'s own
226/// `ConfigType::parse` (so the accepted syntax — e.g. `on`/`off` for bool, humantime
227/// for duration — matches the rest of the codebase); `string` is taken verbatim.
228fn parse_config_val(ty: &str, value: &str) -> anyhow::Result<ConfigVal> {
229    let err = |e: String| anyhow::anyhow!("config value {value:?} is not a valid {ty}: {e}");
230    Ok(match ty {
231        "bool" => <bool as ConfigType>::parse(value).map_err(err)?.into(),
232        "u32" => <u32 as ConfigType>::parse(value).map_err(err)?.into(),
233        "usize" => <usize as ConfigType>::parse(value).map_err(err)?.into(),
234        "f64" => <f64 as ConfigType>::parse(value).map_err(err)?.into(),
235        "duration" => <Duration as ConfigType>::parse(value).map_err(err)?.into(),
236        "string" => ConfigVal::String(value.to_string()),
237        other => anyhow::bail!(
238            "unsupported config type {other:?}; use bool/u32/usize/f64/duration/string"
239        ),
240    })
241}
242
243/// Build a [`RelationDesc`] from column specs.
244fn relation_desc(columns: &[ColumnSpec]) -> anyhow::Result<RelationDesc> {
245    let mut builder = RelationDesc::builder();
246    for col in columns {
247        builder = builder.with_column(
248            col.name.as_str(),
249            SqlColumnType {
250                scalar_type: scalar_type_from_str(&col.ty)?,
251                nullable: col.nullable,
252            },
253        );
254    }
255    Ok(builder.finish())
256}
257
258/// Strip surrounding double quotes from a token, if present.
259fn unquote(s: &str) -> &str {
260    s.strip_prefix('"')
261        .and_then(|s| s.strip_suffix('"'))
262        .unwrap_or(s)
263}
264
265/// Type a raw row-value token against its column into an owned [`Cell`].
266///
267/// The bare token `null` is SQL `NULL` (only in a nullable column); quote it
268/// (`"null"`) for the literal string. Numeric and boolean tokens go through
269/// [`mz_repr::strconv`] — the canonical PostgreSQL-compatible text parser the rest
270/// of the codebase uses — so the accepted syntax matches `mz_pgrepr`'s text decode.
271/// `string`/`bytes` columns take the (unquoted) token verbatim; `bytes` is its
272/// UTF-8 encoding.
273fn cell_from_token(token: &str, col: &SqlColumnType) -> anyhow::Result<Cell> {
274    if token == "null" {
275        anyhow::ensure!(col.nullable, "null value in non-nullable column");
276        return Ok(Cell::Null);
277    }
278    let parse =
279        |kind: &str, e: strconv::ParseError| anyhow::anyhow!("parsing {token:?} as {kind}: {e}");
280    let cell = match col.scalar_type {
281        SqlScalarType::Int16 => {
282            Cell::Int16(strconv::parse_int16(token).map_err(|e| parse("int16", e))?)
283        }
284        SqlScalarType::Int32 => {
285            Cell::Int32(strconv::parse_int32(token).map_err(|e| parse("int32", e))?)
286        }
287        SqlScalarType::Int64 => {
288            Cell::Int64(strconv::parse_int64(token).map_err(|e| parse("int64", e))?)
289        }
290        SqlScalarType::Bool => {
291            Cell::Bool(strconv::parse_bool(token).map_err(|e| parse("bool", e))?)
292        }
293        SqlScalarType::String => Cell::Str(unquote(token).to_string()),
294        SqlScalarType::Bytes => Cell::Bytes(unquote(token).as_bytes().to_vec()),
295        ref other => anyhow::bail!("unsupported column type {other:?}"),
296    };
297    Ok(cell)
298}
299
300/// Pack explicit row tokens against `desc`, validating arity per row.
301fn rows_from_tokens(desc: &RelationDesc, rows: &[Vec<String>]) -> anyhow::Result<Vec<Row>> {
302    let cols: Vec<&SqlColumnType> = desc.iter_types().collect();
303    let mut out = Vec::with_capacity(rows.len());
304    for (r, row) in rows.iter().enumerate() {
305        anyhow::ensure!(
306            row.len() == cols.len(),
307            "row {r} has {} values but schema has {} columns",
308            row.len(),
309            cols.len()
310        );
311        // Arity validated above, so indexing `cols` by position is in bounds.
312        let cells = row
313            .iter()
314            .enumerate()
315            .map(|(c, v)| cell_from_token(v, cols[c]))
316            .collect::<anyhow::Result<Vec<Cell>>>()?;
317        out.push(pack_cells(&cells));
318    }
319    Ok(out)
320}
321
322/// Register a referenceable object in the parser `catalog` under its global-id
323/// name (e.g. `u1000`), recording the name-to-id mapping so the parsed `Get`s —
324/// which carry the catalog's own assigned ids — can be remapped back to the
325/// script's ids.
326fn register_catalog_object(
327    catalog: &mut TestCatalog,
328    name_to_id: &mut BTreeMap<String, GlobalId>,
329    id: GlobalId,
330    sql_typ: SqlRelationType,
331) -> anyhow::Result<()> {
332    let name = id.to_string();
333    // Column names are only used for display; `Get` references columns by `#n`,
334    // so synthetic `c0..cN` names suffice.
335    let cols = (0..sql_typ.column_types.len())
336        .map(|i| format!("c{i}"))
337        .collect();
338    catalog
339        .insert(&name, cols, sql_typ, false)
340        .map_err(|e| anyhow::anyhow!("registering {name} in catalog: {e}"))?;
341    name_to_id.insert(name, id);
342    Ok(())
343}
344
345/// Rewrite every global `Get` in `expr` from the catalog's assigned id back to
346/// the script's id, looked up by the object's name.
347fn remap_gets(
348    expr: &mut MirRelationExpr,
349    catalog: &TestCatalog,
350    name_to_id: &BTreeMap<String, GlobalId>,
351) -> anyhow::Result<()> {
352    expr.try_visit_mut_post::<_, anyhow::Error>(&mut |e| {
353        if let MirRelationExpr::Get {
354            id: Id::Global(g), ..
355        } = e
356        {
357            let name = catalog
358                .get_source_name(g)
359                .ok_or_else(|| anyhow::anyhow!("get of unknown catalog object {g}"))?;
360            let id = name_to_id
361                .get(name)
362                .ok_or_else(|| anyhow::anyhow!("get of unregistered object {name}"))?;
363            *g = *id;
364        }
365        Ok(())
366    })
367}
368
369/// A command read from the script stream.
370///
371/// Tagged on `"cmd"`, snake_case, e.g.
372/// `{"cmd":"write_single_ts","shard":"s1","ts":0,"rows":1000}`.
373#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
374#[serde(tag = "cmd", rename_all = "snake_case")]
375pub enum Command {
376    /// Declare a named relation schema for later `schema` references.
377    DefineSchema {
378        /// Schema name, referenced by `schema` fields on other commands.
379        name: String,
380        /// Ordered column declarations.
381        columns: Vec<ColumnSpec>,
382    },
383    /// Write `count` synthetic rows to `shard` at a single timestamp `ts`.
384    WriteSingleTs {
385        /// Shard alias; allocated on first use.
386        shard: String,
387        /// Schema name; defaults to the built-in `(bigint, text)` sample schema.
388        #[serde(default)]
389        schema: Option<String>,
390        /// The timestamp to write at.
391        ts: u64,
392        /// Number of synthetic rows to write.
393        count: u64,
394        /// First synthetic row index, so successive batches can use disjoint id
395        /// ranges (`start..start + count`) that never consolidate. Defaults to 0.
396        #[serde(default)]
397        start: u64,
398        /// Payload padding per row; defaults to `DEFAULT_ROW_BYTES`.
399        #[serde(default)]
400        row_bytes: Option<usize>,
401    },
402    /// Write `count` synthetic rows to `shard`, spread across `n_ts` timestamps in a
403    /// single append.
404    WriteSpread {
405        /// Shard alias; allocated on first use.
406        shard: String,
407        /// Schema name; defaults to the built-in sample schema.
408        #[serde(default)]
409        schema: Option<String>,
410        /// Number of synthetic rows to write.
411        count: u64,
412        /// Number of distinct timestamps to spread the rows across.
413        n_ts: u64,
414        /// First synthetic row index (see [`Command::WriteSingleTs`]). Defaults to 0.
415        #[serde(default)]
416        start: u64,
417        /// Payload padding per row; defaults to `DEFAULT_ROW_BYTES`.
418        #[serde(default)]
419        row_bytes: Option<usize>,
420    },
421    /// Write explicit rows to `shard` at a single timestamp `ts`. Each row is an
422    /// array of JSON values matching the schema's columns in order.
423    WriteRows {
424        /// Shard alias; allocated on first use.
425        shard: String,
426        /// Schema name; defaults to the built-in sample schema.
427        #[serde(default)]
428        schema: Option<String>,
429        /// The timestamp to write at.
430        ts: u64,
431        /// Rows as arrays of raw value tokens, typed against the schema by
432        /// `cell_from_token`.
433        rows: Vec<Vec<String>>,
434    },
435    /// Submit (without scheduling) an index dataflow over `shard`.
436    DefineIndex {
437        /// The imported source's global id.
438        source_id: GlobalId,
439        /// The exported index's global id.
440        index_id: GlobalId,
441        /// Shard alias to import; must already exist.
442        shard: String,
443        /// Schema name; defaults to the built-in sample schema. Must match what was
444        /// written to `shard`.
445        #[serde(default)]
446        schema: Option<String>,
447        /// Columns to arrange by.
448        key: Vec<usize>,
449        /// The dataflow's `as_of`.
450        as_of: u64,
451        /// The shard's exclusive write upper (see `PersistSource::upper`).
452        upper: u64,
453    },
454    /// Schedule a previously-submitted collection so it makes progress.
455    Schedule {
456        /// The collection's global id.
457        id: GlobalId,
458    },
459    /// Advance an index's read frontier (`since`) via `AllowCompaction`.
460    AllowCompaction {
461        /// The index's global id.
462        id: GlobalId,
463        /// The new read frontier.
464        frontier: u64,
465    },
466    /// Take a collection out of read-only mode via `AllowWrites`, letting its
467    /// persist sink begin writing. Every dataflow starts read-only; indexes,
468    /// subscribes, and peeks work regardless, but a materialized-view sink withholds
469    /// all persist writes until this is sent for its sink id.
470    AllowWrites {
471        /// The sink's global id.
472        id: GlobalId,
473    },
474    /// Wait until `id`'s output frontier reaches `ts`, or fail after the timeout.
475    AwaitFrontier {
476        /// The collection's global id.
477        id: GlobalId,
478        /// The target output-frontier timestamp.
479        ts: u64,
480        /// Timeout in seconds; defaults to `DEFAULT_TIMEOUT_SECS`.
481        #[serde(default)]
482        timeout_secs: Option<u64>,
483        /// If true, a timeout is reported (`status: timeout`) without failing the
484        /// run. Used by reproductions where not reaching the frontier is an
485        /// expected outcome, not an assertion failure.
486        #[serde(default)]
487        allow_timeout: bool,
488    },
489    /// Count `id`'s rows at `ts` and emit the count.
490    ///
491    /// Sugar over a `Reduce`: builds an ephemeral dataflow that index-imports `id`,
492    /// computes `count(*)` over it, and peeks the single-row result — so the count
493    /// runs through a real reduce operator rather than being tallied in the driver.
494    /// `id` must have been registered by a prior `define_index` (or `define`
495    /// export). The golden output is the count; the script's `----` block asserts it.
496    Count {
497        /// The index's global id.
498        id: GlobalId,
499        /// The timestamp to count at.
500        ts: u64,
501    },
502    /// Submit (without scheduling) a dataflow built from generic MIR — the
503    /// abstraction behind index / materialized-view / subscribe / copy-to.
504    ///
505    /// A projection of [`DataflowBuilder`]: import sources and/or existing indexes,
506    /// build MIR objects (each a pretty-form MIR spec; see [`BuildSpec`]), and
507    /// export over them. Exports are index, materialized-view, or subscribe (see
508    /// [`ExportSpec`]); copy-to is not implemented. Exported indexes are registered
509    /// for later import or count assertion; subscribe sinks register a response
510    /// buffer for `await-subscribe`. `define-index` is sugar over this. The optional
511    /// `optimize` flag runs the MIR optimizer before lowering (needed for joins).
512    CreateDataflow {
513        /// Debug name for the dataflow; defaults to `headless-create-dataflow`.
514        #[serde(default)]
515        name: Option<String>,
516        /// Collections to import (persist sources and/or existing indexes).
517        #[serde(default)]
518        imports: Vec<ImportSpec>,
519        /// MIR objects to compute, each bound to an id.
520        #[serde(default)]
521        builds: Vec<BuildSpec>,
522        /// Exports over imported or built ids.
523        #[serde(default)]
524        exports: Vec<ExportSpec>,
525        /// The dataflow's `as_of`.
526        as_of: u64,
527        /// Run the MIR optimizer before lowering (needed for e.g. joins). Off by
528        /// default, so the caller's MIR is lowered faithfully.
529        #[serde(default)]
530        optimize: bool,
531    },
532    /// Render a dataflow's lowered LIR plan as text, the output assertion being the
533    /// plan shape itself. It submits nothing and records no index, subscribe, or
534    /// materialized-view output. With `optimize`, this asserts the optimizer's plan
535    /// so subtle optimizer (or lowering) drift is caught, which a result-only assertion
536    /// misses. The dataflow is given either inline (the `create-dataflow` body) or by
537    /// reference to one a prior `create-dataflow` declared (see [`ExplainTarget`]).
538    Explain {
539        /// What to explain: an inline dataflow or a reference to a declared one.
540        target: ExplainTarget,
541    },
542    /// Peek `id` at `ts` and emit the returned rows (sorted, one per line). The
543    /// generic output assertion: the script's `----` block holds the expected rows.
544    Peek {
545        /// The index's global id.
546        id: GlobalId,
547        /// Schema name describing the peek's output; defaults to the sample schema.
548        #[serde(default)]
549        schema: Option<String>,
550        /// The timestamp to peek at.
551        ts: u64,
552    },
553    /// Wait for subscribe sink `id`'s upper to reach `up_to`, then emit its
554    /// accumulated updates as `<ts> <diff> <datums>` lines (consolidated, sorted).
555    /// The output assertion for a subscribe sink.
556    AwaitSubscribe {
557        /// The subscribe sink's global id.
558        id: GlobalId,
559        /// The exclusive upper to wait for (typically the sink's `up_to`).
560        up_to: u64,
561        /// Timeout in seconds; defaults to `DEFAULT_TIMEOUT_SECS`.
562        timeout_secs: Option<u64>,
563    },
564    /// Send `CreateInstance`, opening the compute instance (and the reconciliation
565    /// window). The settable [`InstanceConfig`] knobs default to the values a plain
566    /// `create-instance` supplies; the peek-stash location is always the host's, and
567    /// the peek-response stash is force-disabled (see `Driver::create_instance`).
568    ///
569    /// [`InstanceConfig`]: mz_compute_client::protocol::command::InstanceConfig
570    CreateInstance {
571        /// Replica expiration offset (a duration like `30s`); none if absent.
572        #[serde(default)]
573        expiration_offset: Option<String>,
574        /// Whether arrangements use dictionary compression.
575        #[serde(default)]
576        arrangement_dictionary_compression: bool,
577        /// The create-time dyncfg snapshot the controller would supply (`name type value` rows).
578        /// Applied to the replica's worker config before create-time setup, so a scenario can
579        /// assert that create-time work observes synced values rather than dyncfg defaults.
580        #[serde(default)]
581        initial_config: Vec<ConfigSetting>,
582    },
583    /// Send `UpdateConfiguration` with a table of dyncfg updates (`name type value`
584    /// rows). Generic over any configuration; the peek-response stash is not settable
585    /// here (it is force-disabled at instance creation).
586    UpdateConfiguration {
587        /// The dyncfg updates to apply.
588        #[serde(default)]
589        updates: Vec<ConfigSetting>,
590    },
591    /// Drop the current connection and reconnect, sending only `Hello`. Re-issue
592    /// `create_instance`, replay the dataflows the replica should keep, then
593    /// `initialization_complete` to close the reconciliation window.
594    Reconnect,
595    /// Send `InitializationComplete`, closing the reconciliation window.
596    InitializationComplete,
597}
598
599/// What the registry remembers about an exported index, so a later `define`
600/// import or count assertion can reconstruct the import without re-declaring it.
601struct IndexEntry {
602    /// The id of the collection the index arranges.
603    on_id: GlobalId,
604    /// The columns the index is arranged by.
605    key: Vec<usize>,
606    /// The arranged collection's relation type (for `import_index`).
607    on_type: ReprRelationType,
608}
609
610/// Side-effect registrations a successful `create-dataflow` submit must apply:
611/// exported indexes (for later import / count), subscribe sinks (response buffers),
612/// and materialized-view outputs (for persist peeks). `explain` builds the same
613/// dataflow but discards these, since it submits nothing.
614#[derive(Default)]
615struct PendingRegistrations {
616    /// Exported indexes, by global id.
617    indexes: Vec<(GlobalId, IndexEntry)>,
618    /// Subscribe sink ids needing a response buffer.
619    subscribes: Vec<GlobalId>,
620    /// Materialized-view outputs: sink id and its target shard metadata.
621    mv_outputs: Vec<(GlobalId, CollectionMetadata)>,
622}
623
624/// The `create-dataflow` body recorded under a dataflow's name, so a later
625/// `explain ref=<name>` can re-render its plan without repeating the body. Holds the
626/// parsed spec rather than a built plan: lowering is deterministic, so re-running it
627/// yields the same plan that was submitted.
628#[derive(Clone)]
629struct DataflowSpec {
630    imports: Vec<ImportSpec>,
631    builds: Vec<BuildSpec>,
632    exports: Vec<ExportSpec>,
633    as_of: u64,
634    optimize: bool,
635}
636
637/// The base for ephemeral global ids the count sugar allocates. Far above any
638/// id a script would use, so its dataflows never collide with user objects.
639const INTERNAL_ID_BASE: u64 = u64::MAX / 2;
640
641/// Mutable state threaded through a script run.
642pub struct ScriptState {
643    driver: Driver,
644    client: PersistClient,
645    loc: PersistLocation,
646    /// Named schemas declared via `define_schema`.
647    schemas: BTreeMap<String, RelationDesc>,
648    /// Alias-to-shard map; aliases are allocated lazily on first use.
649    shards: BTreeMap<String, ShardId>,
650    /// Exported indexes, by global id, for later import / count assertions.
651    indexes: BTreeMap<GlobalId, IndexEntry>,
652    /// Materialized-view sink outputs, by sink global id: the target shard's
653    /// metadata, so a `peek` of the sink id reads its shard via a persist peek.
654    mv_outputs: BTreeMap<GlobalId, CollectionMetadata>,
655    /// `create-dataflow` specs by name, so `explain ref=<name>` can render a declared
656    /// dataflow's plan without repeating its body.
657    dataflows: BTreeMap<String, DataflowSpec>,
658    /// Next ephemeral id for the count sugar's dataflows.
659    next_internal: u64,
660}
661
662impl ScriptState {
663    /// Build the state from a connected driver and its persist location, opening a
664    /// persist client.
665    pub async fn new(driver: Driver, loc: PersistLocation) -> anyhow::Result<Self> {
666        let client = driver.host.client().await?;
667        Ok(ScriptState {
668            driver,
669            client,
670            loc,
671            schemas: BTreeMap::new(),
672            shards: BTreeMap::new(),
673            indexes: BTreeMap::new(),
674            mv_outputs: BTreeMap::new(),
675            dataflows: BTreeMap::new(),
676            next_internal: INTERNAL_ID_BASE,
677        })
678    }
679
680    /// Resolve a shard alias, allocating a fresh [`ShardId`] on first use.
681    fn shard_id(&mut self, alias: &str) -> ShardId {
682        *self
683            .shards
684            .entry(alias.to_string())
685            .or_insert_with(ShardId::new)
686    }
687
688    /// Allocate a fresh ephemeral global id for an internally-built dataflow.
689    fn alloc_internal(&mut self) -> GlobalId {
690        let id = self.next_internal;
691        self.next_internal += 1;
692        GlobalId::User(id)
693    }
694
695    /// Count the rows of a registered index at `ts` by running a `count(*)`
696    /// `Reduce` over it: build an ephemeral dataflow that index-imports `index_id`,
697    /// schedule and hydrate it, then peek its single-row output. An empty result
698    /// (the reduce emits no row over empty input) reads as a count of `0`.
699    async fn count_via_reduce(&mut self, index_id: GlobalId, ts: u64) -> anyhow::Result<u64> {
700        let entry = self.indexes.get(&index_id).ok_or_else(|| {
701            anyhow::anyhow!("unknown index {index_id}; define it with define_index first")
702        })?;
703        let on_id = entry.on_id;
704        let key = entry.key.clone();
705        let on_type = entry.on_type.clone();
706
707        let reduce_id = self.alloc_internal();
708        let out_index_id = self.alloc_internal();
709        let df = count_over_index(
710            index_id,
711            on_id,
712            on_type,
713            key,
714            reduce_id,
715            out_index_id,
716            Timestamp::from(ts),
717        )?;
718        self.driver.submit_dataflow(df)?;
719        self.driver.schedule(out_index_id)?;
720        // The count is final once the output frontier passes `ts`.
721        self.driver
722            .expect_frontier(
723                out_index_id,
724                Timestamp::from(ts).step_forward(),
725                Duration::from_secs(DEFAULT_TIMEOUT_SECS),
726            )
727            .await?;
728
729        // The reduce output is a single non-null bigint column.
730        let count_desc = RelationDesc::builder()
731            .with_column(
732                "count",
733                SqlColumnType {
734                    scalar_type: SqlScalarType::Int64,
735                    nullable: false,
736                },
737            )
738            .finish();
739        let rows = self
740            .driver
741            .peek(
742                PeekTarget::Index { id: out_index_id },
743                count_desc,
744                Timestamp::from(ts),
745            )
746            .await?;
747        match rows.as_slice() {
748            // No row over empty input: count is zero.
749            [] => Ok(0),
750            [row] => {
751                let count = row.unpack_first().unwrap_int64();
752                Ok(u64::try_from(count)?)
753            }
754            other => anyhow::bail!(
755                "count reduce returned {} rows, expected 0 or 1",
756                other.len()
757            ),
758        }
759    }
760
761    /// Resolve a schema name, defaulting to the built-in sample schema when absent.
762    fn resolve_schema(&self, name: &Option<String>) -> anyhow::Result<RelationDesc> {
763        match name {
764            None => Ok(sample_desc()),
765            Some(name) => self.schemas.get(name).cloned().ok_or_else(|| {
766                anyhow::anyhow!("unknown schema {name:?}; declare it with define_schema first")
767            }),
768        }
769    }
770
771    /// Validate that a sink's declared output schema `desc` matches the column types
772    /// of the object `on_id` it exports. Compares column types (not inferred keys),
773    /// so a mismatched arity or type fails before the dataflow is submitted.
774    fn check_sink_schema(
775        &self,
776        builder: &DataflowBuilder,
777        on_id: GlobalId,
778        desc: &RelationDesc,
779    ) -> anyhow::Result<()> {
780        let on_type = builder.get(on_id)?.typ();
781        let want = ReprRelationType::from(desc.typ());
782        anyhow::ensure!(
783            on_type.column_types == want.column_types,
784            "sink output schema does not match object {on_id}: \
785             declared {:?}, object is {:?}",
786            want.column_types,
787            on_type.column_types
788        );
789        Ok(())
790    }
791
792    /// Build (but do not submit) a [`DataflowBuilder`] from a `create-dataflow` /
793    /// `explain` body: import sources and existing indexes, build the MIR objects, and
794    /// wire the exports, setting `as_of`. Returns the configured builder plus the
795    /// [`PendingRegistrations`] a successful submit must apply — `create-dataflow`
796    /// applies them, `explain` discards them (it submits nothing).
797    fn configure_dataflow(
798        &mut self,
799        name: Option<String>,
800        imports: Vec<ImportSpec>,
801        builds: Vec<BuildSpec>,
802        exports: Vec<ExportSpec>,
803        as_of: u64,
804        optimize: bool,
805    ) -> anyhow::Result<(DataflowBuilder, PendingRegistrations)> {
806        let mut builder =
807            DataflowBuilder::new(name.unwrap_or_else(|| "headless-create-dataflow".to_string()));
808        if optimize {
809            builder.optimize();
810        }
811        // The parser's catalog resolves `Get u<n>` leaves by name; it assigns its own
812        // global ids, so we keep a name->our-id map and remap the parsed `Get`s back to
813        // the script's ids afterwards.
814        let mut catalog = TestCatalog::default();
815        let mut name_to_id: BTreeMap<String, GlobalId> = BTreeMap::new();
816        for import in imports {
817            match import {
818                ImportSpec::Source {
819                    id,
820                    shard,
821                    schema,
822                    upper,
823                } => {
824                    let desc = self.resolve_schema(&schema)?;
825                    register_catalog_object(&mut catalog, &mut name_to_id, id, desc.typ().clone())?;
826                    let shard = self.shard_id(&shard);
827                    builder.import_persist(
828                        id,
829                        PersistSource {
830                            shard,
831                            location: self.loc.clone(),
832                            desc,
833                            upper: Timestamp::from(upper),
834                        },
835                    );
836                }
837                ImportSpec::Index { index_id } => {
838                    let entry = self.indexes.get(&index_id).ok_or_else(|| {
839                        anyhow::anyhow!("unknown index {index_id}; define it before importing it")
840                    })?;
841                    let on_id = entry.on_id;
842                    let key = entry.key.clone();
843                    let on_type = entry.on_type.clone();
844                    register_catalog_object(
845                        &mut catalog,
846                        &mut name_to_id,
847                        on_id,
848                        SqlRelationType::from_repr(&on_type),
849                    )?;
850                    builder.import_index(index_id, on_id, key, on_type, false);
851                }
852            }
853        }
854        for build in builds {
855            // Parse the pretty MIR spec against the catalog, then remap its
856            // catalog-assigned `Get` ids to the script's ids.
857            let mut expr = try_parse_mir(&catalog, &build.expr)
858                .map_err(|e| anyhow::anyhow!("parsing MIR for object {}: {e}", build.id))?;
859            remap_gets(&mut expr, &catalog, &name_to_id)?;
860            let id = build.id;
861            // Register the built object so later builds can `get` it.
862            register_catalog_object(
863                &mut catalog,
864                &mut name_to_id,
865                id,
866                SqlRelationType::from_repr(&expr.typ()),
867            )?;
868            builder.build(id, expr);
869        }
870        // Wire each export onto the builder. Index exports are captured for later
871        // import / count assertions; sink exports route their output either to a target
872        // shard (materialized view) or back as responses (subscribe). Sink output
873        // schemas must match the exported object's type, validated here so a mismatch
874        // fails before submission.
875        let mut registrations = PendingRegistrations::default();
876        for export in exports {
877            match export {
878                ExportSpec::Index {
879                    index_id,
880                    on_id,
881                    key,
882                } => {
883                    let on_type = builder.get(on_id)?.typ();
884                    builder.export_index(index_id, on_id, key.clone());
885                    registrations.indexes.push((
886                        index_id,
887                        IndexEntry {
888                            on_id,
889                            key,
890                            on_type,
891                        },
892                    ));
893                }
894                ExportSpec::MaterializedView {
895                    sink_id,
896                    on_id,
897                    shard,
898                    schema,
899                } => {
900                    let desc = self.resolve_schema(&schema)?;
901                    self.check_sink_schema(&builder, on_id, &desc)?;
902                    let shard = self.shard_id(&shard);
903                    let location = self.loc.clone();
904                    builder.export_materialized_view(
905                        sink_id,
906                        on_id,
907                        desc.clone(),
908                        PersistSink {
909                            shard,
910                            location: location.clone(),
911                        },
912                    );
913                    // Record the target shard so a later `peek` of the sink id reads it
914                    // via a persist peek (the `SELECT * FROM mv` path), with no separate
915                    // read-back command.
916                    registrations.mv_outputs.push((
917                        sink_id,
918                        CollectionMetadata {
919                            persist_location: location,
920                            data_shard: shard,
921                            relation_desc: desc,
922                            txns_shard: None,
923                        },
924                    ));
925                }
926                ExportSpec::Subscribe {
927                    sink_id,
928                    on_id,
929                    schema,
930                    up_to,
931                } => {
932                    let desc = self.resolve_schema(&schema)?;
933                    self.check_sink_schema(&builder, on_id, &desc)?;
934                    builder.export_subscribe(sink_id, on_id, desc, up_to_antichain(up_to));
935                    registrations.subscribes.push(sink_id);
936                }
937            }
938        }
939        builder.as_of(Timestamp::from(as_of));
940        Ok((builder, registrations))
941    }
942
943    /// Execute a single command, returning its golden output text.
944    pub async fn execute(&mut self, cmd: Command) -> anyhow::Result<String> {
945        match cmd {
946            Command::DefineSchema { name, columns } => {
947                let desc = relation_desc(&columns)?;
948                self.schemas.insert(name, desc);
949                Ok("ok".to_string())
950            }
951            Command::WriteSingleTs {
952                shard,
953                schema,
954                ts,
955                count,
956                start,
957                row_bytes,
958            } => {
959                let desc = self.resolve_schema(&schema)?;
960                let shard = self.shard_id(&shard);
961                let pad = row_bytes.unwrap_or(DEFAULT_ROW_BYTES);
962                let batch = synth_rows(&desc, start, count, pad);
963                write_rows_single_ts(&self.client, shard, &desc, &batch, Timestamp::from(ts))
964                    .await?;
965                Ok(format!("wrote {count}"))
966            }
967            Command::WriteSpread {
968                shard,
969                schema,
970                count,
971                n_ts,
972                start,
973                row_bytes,
974            } => {
975                let desc = self.resolve_schema(&schema)?;
976                let shard = self.shard_id(&shard);
977                let pad = row_bytes.unwrap_or(DEFAULT_ROW_BYTES);
978                let batch = synth_rows(&desc, start, count, pad);
979                write_rows_spread(&self.client, shard, &desc, &batch, n_ts).await?;
980                Ok(format!("wrote {count}"))
981            }
982            Command::WriteRows {
983                shard,
984                schema,
985                ts,
986                rows,
987            } => {
988                let desc = self.resolve_schema(&schema)?;
989                let batch = rows_from_tokens(&desc, &rows)?;
990                let written = batch.len();
991                let shard = self.shard_id(&shard);
992                write_rows_single_ts(&self.client, shard, &desc, &batch, Timestamp::from(ts))
993                    .await?;
994                Ok(format!("wrote {written}"))
995            }
996            Command::DefineIndex {
997                source_id,
998                index_id,
999                shard,
1000                schema,
1001                key,
1002                as_of,
1003                upper,
1004            } => {
1005                let desc = self.resolve_schema(&schema)?;
1006                // Validate the key columns against the schema up front, so a bad
1007                // index (e.g. key past the last column) yields a clean error rather
1008                // than reaching the lowering with an out-of-range column reference.
1009                let arity = desc.arity();
1010                for &col in &key {
1011                    anyhow::ensure!(
1012                        col < arity,
1013                        "key column {col} out of range for a {arity}-column schema"
1014                    );
1015                }
1016                let shard = self.shard_id(&shard);
1017                let on_type = ReprRelationType::from(desc.typ());
1018                let df = index_dataflow(
1019                    source_id,
1020                    index_id,
1021                    shard,
1022                    self.loc.clone(),
1023                    desc,
1024                    key.clone(),
1025                    Timestamp::from(as_of),
1026                    Timestamp::from(upper),
1027                )?;
1028                self.driver.submit_dataflow(df)?;
1029                // Register only after a successful submit, so a rejected index is
1030                // not later importable or countable.
1031                self.indexes.insert(
1032                    index_id,
1033                    IndexEntry {
1034                        on_id: source_id,
1035                        key,
1036                        on_type,
1037                    },
1038                );
1039                Ok("ok".to_string())
1040            }
1041            Command::Schedule { id } => {
1042                self.driver.schedule(id)?;
1043                Ok("ok".to_string())
1044            }
1045            Command::AllowCompaction { id, frontier } => {
1046                self.driver.send(ComputeCommand::AllowCompaction {
1047                    id,
1048                    frontier: Antichain::from_elem(Timestamp::from(frontier)),
1049                })?;
1050                Ok("ok".to_string())
1051            }
1052            Command::AllowWrites { id } => {
1053                self.driver.send(ComputeCommand::AllowWrites(id))?;
1054                Ok("ok".to_string())
1055            }
1056            Command::AwaitFrontier {
1057                id,
1058                ts,
1059                timeout_secs,
1060                allow_timeout,
1061            } => {
1062                let timeout = Duration::from_secs(timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS));
1063                let result = self
1064                    .driver
1065                    .expect_frontier(id, Timestamp::from(ts), timeout)
1066                    .await;
1067                if allow_timeout {
1068                    // The outcome is intentionally unobserved: emit a fixed token so
1069                    // the golden output stays deterministic whether or not the
1070                    // frontier was reached (see `multi_dataflow`, whose hydration is
1071                    // nondeterministic).
1072                    Ok("awaited".to_string())
1073                } else {
1074                    result?;
1075                    Ok("ok".to_string())
1076                }
1077            }
1078            Command::Count { id, ts } => {
1079                let count = self.count_via_reduce(id, ts).await?;
1080                Ok(count.to_string())
1081            }
1082            Command::CreateDataflow {
1083                name,
1084                imports,
1085                builds,
1086                exports,
1087                as_of,
1088                optimize,
1089            } => {
1090                // Record the spec under its name so `explain ref=<name>` can render
1091                // this dataflow's plan later without repeating the body.
1092                if let Some(name) = &name {
1093                    self.dataflows.insert(
1094                        name.clone(),
1095                        DataflowSpec {
1096                            imports: imports.clone(),
1097                            builds: builds.clone(),
1098                            exports: exports.clone(),
1099                            as_of,
1100                            optimize,
1101                        },
1102                    );
1103                }
1104                let (builder, registrations) =
1105                    self.configure_dataflow(name, imports, builds, exports, as_of, optimize)?;
1106                let df = builder.finish()?;
1107                self.driver.submit_dataflow(df)?;
1108                // Register only after a successful submit, so a rejected dataflow
1109                // leaves no dangling index entry or subscribe buffer.
1110                for (index_id, entry) in registrations.indexes {
1111                    self.indexes.insert(index_id, entry);
1112                }
1113                for sink_id in registrations.subscribes {
1114                    self.driver.register_subscribe(sink_id);
1115                }
1116                for (sink_id, metadata) in registrations.mv_outputs {
1117                    self.mv_outputs.insert(sink_id, metadata);
1118                }
1119                Ok("ok".to_string())
1120            }
1121            Command::Explain { target } => {
1122                // Resolve the target to a dataflow body: either given inline, or the
1123                // spec a prior `create-dataflow name=<name>` recorded.
1124                let (name, imports, builds, exports, as_of, optimize) = match target {
1125                    ExplainTarget::Inline {
1126                        name,
1127                        imports,
1128                        builds,
1129                        exports,
1130                        as_of,
1131                        optimize,
1132                    } => (name, imports, builds, exports, as_of, optimize),
1133                    ExplainTarget::Reference { name } => {
1134                        let spec = self.dataflows.get(&name).ok_or_else(|| {
1135                            anyhow::anyhow!(
1136                                "unknown dataflow {name:?}; declare it with \
1137                                 create-dataflow name={name} first"
1138                            )
1139                        })?;
1140                        (
1141                            Some(name.clone()),
1142                            spec.imports.clone(),
1143                            spec.builds.clone(),
1144                            spec.exports.clone(),
1145                            spec.as_of,
1146                            spec.optimize,
1147                        )
1148                    }
1149                };
1150                // Build the same dataflow as `create-dataflow`, but render its lowered
1151                // LIR plan instead of submitting it. The registrations are discarded:
1152                // explain has no side effects, so it neither installs a dataflow nor
1153                // records an index / subscribe / materialized-view output.
1154                let (builder, _registrations) =
1155                    self.configure_dataflow(name, imports, builds, exports, as_of, optimize)?;
1156                // The LIR render separates objects with blank lines; the `----` block
1157                // preserves them via the doubled-separator form (see `crate::text`).
1158                // Trim the trailing newline so the golden matches like every other
1159                // command's (none emit a trailing newline).
1160                let plan = builder.explain()?;
1161                Ok(plan.trim_end().to_string())
1162            }
1163            Command::Peek { id, schema, ts } => {
1164                let desc = self.resolve_schema(&schema)?;
1165                // A materialized-view sink id resolves to a persist peek of its
1166                // output shard; any other id is an index peek. The persist peek
1167                // blocks until the shard seals through `ts`, so it doubles as a wait
1168                // for the writing sink to catch up.
1169                let target = match self.mv_outputs.get(&id) {
1170                    Some(metadata) => PeekTarget::Persist {
1171                        id,
1172                        metadata: metadata.clone(),
1173                    },
1174                    None => PeekTarget::Index { id },
1175                };
1176                let rows = self.driver.peek(target, desc, Timestamp::from(ts)).await?;
1177                Ok(render_rows(&rows))
1178            }
1179            Command::AwaitSubscribe {
1180                id,
1181                up_to,
1182                timeout_secs,
1183            } => {
1184                let timeout = Duration::from_secs(timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS));
1185                let updates = self
1186                    .driver
1187                    .await_subscribe(id, Timestamp::from(up_to), timeout)
1188                    .await?;
1189                Ok(render_updates(&updates))
1190            }
1191            Command::CreateInstance {
1192                expiration_offset,
1193                arrangement_dictionary_compression,
1194                initial_config,
1195            } => {
1196                let expiration_offset = expiration_offset
1197                    .as_deref()
1198                    .map(|s| {
1199                        <Duration as ConfigType>::parse(s).map_err(|e| {
1200                            anyhow::anyhow!("expiration-offset {s:?} is not a duration: {e}")
1201                        })
1202                    })
1203                    .transpose()?;
1204                let mut initial = ConfigUpdates::default();
1205                for setting in &initial_config {
1206                    initial.add_dynamic(
1207                        &setting.name,
1208                        parse_config_val(&setting.ty, &setting.value)?,
1209                    );
1210                }
1211                self.driver.create_instance(
1212                    expiration_offset,
1213                    arrangement_dictionary_compression,
1214                    initial,
1215                )?;
1216                Ok("ok".to_string())
1217            }
1218            Command::UpdateConfiguration { updates } => {
1219                let mut dyncfg_updates = ConfigUpdates::default();
1220                for setting in &updates {
1221                    dyncfg_updates.add_dynamic(
1222                        &setting.name,
1223                        parse_config_val(&setting.ty, &setting.value)?,
1224                    );
1225                }
1226                self.driver.update_configuration(dyncfg_updates)?;
1227                Ok("ok".to_string())
1228            }
1229            Command::Reconnect => {
1230                self.driver.reconnect().await?;
1231                Ok("ok".to_string())
1232            }
1233            Command::InitializationComplete => {
1234                self.driver.send(ComputeCommand::InitializationComplete)?;
1235                Ok("ok".to_string())
1236            }
1237        }
1238    }
1239}
1240
1241/// Run a script: parse `content` into stanzas, execute each command, and either
1242/// compare its output to the stanza's expected block or — when `REWRITE` is set
1243/// and `path` is given — rewrite the file in place with the actual outputs.
1244///
1245/// Returns `Err` if any stanza's output differs from its expected block, so a
1246/// scripted run exits non-zero on a mismatch (and CI fails). A command that fails
1247/// renders as `error: <message>`, so an expected failure is asserted by its
1248/// golden block rather than a special command.
1249pub async fn run(
1250    driver: Driver,
1251    loc: PersistLocation,
1252    content: &str,
1253    path: Option<&Path>,
1254) -> anyhow::Result<()> {
1255    let items = crate::text::parse_file(content)?;
1256    let mut state = ScriptState::new(driver, loc).await?;
1257    let rewrite = std::env::var_os("REWRITE").is_some();
1258
1259    let mut actuals = Vec::new();
1260    let mut mismatches = 0usize;
1261    for item in &items {
1262        let crate::text::Item::Stanza(stanza) = item else {
1263            continue;
1264        };
1265        let actual = match state.execute(stanza.command.clone()).await {
1266            Ok(output) => output,
1267            Err(e) => format!("error: {e}"),
1268        };
1269        let directive = stanza.input.lines().next().unwrap_or_default();
1270        if rewrite {
1271            println!("{directive} => {actual}");
1272        } else if actual == stanza.expected {
1273            println!("ok: {directive}");
1274        } else {
1275            mismatches += 1;
1276            println!(
1277                "MISMATCH: {directive}\n  expected: {:?}\n  actual:   {:?}",
1278                stanza.expected, actual
1279            );
1280        }
1281        actuals.push(actual);
1282    }
1283
1284    if rewrite {
1285        let path = path.context("REWRITE is set but the script came from stdin")?;
1286        std::fs::write(path, crate::text::rewrite(&items, &actuals))
1287            .with_context(|| format!("rewriting {}", path.display()))?;
1288        return Ok(());
1289    }
1290    if mismatches > 0 {
1291        anyhow::bail!("{mismatches} stanza(s) did not match their expected output");
1292    }
1293    Ok(())
1294}
1295
1296/// Render peeked rows as deterministic golden text: each row's datums joined by
1297/// spaces, with the rows sorted so the output is independent of arrangement order.
1298fn render_rows(rows: &[Row]) -> String {
1299    let mut lines: Vec<String> = rows
1300        .iter()
1301        .map(|row| {
1302            row.unpack()
1303                .iter()
1304                .map(|datum| datum.to_string())
1305                .collect::<Vec<_>>()
1306                .join(" ")
1307        })
1308        .collect();
1309    lines.sort();
1310    lines.join("\n")
1311}
1312
1313/// Convert an optional `up_to` timestamp into a sink's exclusive upper antichain;
1314/// `None` is the empty antichain (no bound — the sink runs indefinitely).
1315fn up_to_antichain(up_to: Option<u64>) -> Antichain<Timestamp> {
1316    match up_to {
1317        Some(t) => Antichain::from_elem(Timestamp::from(t)),
1318        None => Antichain::new(),
1319    }
1320}
1321
1322/// Render a subscribe's updates as golden text: `<ts> <diff> <datums>` per line.
1323/// Updates are consolidated by `(time, row)` — so split batches and retractions
1324/// collapse — net-zero rows dropped, and the lines sorted for determinism.
1325fn render_updates(updates: &[(Row, Timestamp, i64)]) -> String {
1326    let mut by_key: BTreeMap<(Timestamp, Row), i64> = BTreeMap::new();
1327    for (row, ts, diff) in updates {
1328        *by_key.entry((*ts, row.clone())).or_default() += diff;
1329    }
1330    let mut lines: Vec<String> = by_key
1331        .into_iter()
1332        .filter(|(_, diff)| *diff != 0)
1333        .map(|((ts, row), diff)| {
1334            let datums = row
1335                .unpack()
1336                .iter()
1337                .map(|datum| datum.to_string())
1338                .collect::<Vec<_>>()
1339                .join(" ");
1340            format!("{ts} {diff} {datums}")
1341        })
1342        .collect();
1343    lines.sort();
1344    lines.join("\n")
1345}
1346
1347#[cfg(test)]
1348mod tests {
1349    use super::*;
1350
1351    /// `define_schema` types map to a `RelationDesc` with matching arity and
1352    /// nullability, and `synth_rows` fills it.
1353    #[mz_ore::test]
1354    fn schema_parse_and_synth() {
1355        let columns = vec![
1356            ColumnSpec {
1357                name: "k".to_string(),
1358                ty: "bigint".to_string(),
1359                nullable: false,
1360            },
1361            ColumnSpec {
1362                name: "flag".to_string(),
1363                ty: "boolean".to_string(),
1364                nullable: false,
1365            },
1366            ColumnSpec {
1367                name: "v".to_string(),
1368                ty: "text".to_string(),
1369                nullable: true,
1370            },
1371        ];
1372        let desc = relation_desc(&columns).unwrap();
1373        assert_eq!(desc.arity(), 3);
1374        let types: Vec<_> = desc.iter_types().collect();
1375        assert_eq!(types[0].scalar_type, SqlScalarType::Int64);
1376        assert_eq!(types[1].scalar_type, SqlScalarType::Bool);
1377        assert!(types[2].nullable);
1378
1379        let rows = synth_rows(&desc, 0, 4, 8);
1380        assert_eq!(rows.len(), 4);
1381
1382        assert!(scalar_type_from_str("nope").is_err());
1383    }
1384
1385    /// Tokens type into the right `Cell`s against their column, bare `null` is SQL
1386    /// null (rejected for a non-nullable column), and a bad numeric token errors.
1387    #[mz_ore::test]
1388    fn cell_from_token_maps_values() {
1389        let int_col = SqlColumnType {
1390            scalar_type: SqlScalarType::Int64,
1391            nullable: false,
1392        };
1393        let str_col = SqlColumnType {
1394            scalar_type: SqlScalarType::String,
1395            nullable: true,
1396        };
1397        assert_eq!(cell_from_token("7", &int_col).unwrap(), Cell::Int64(7));
1398        assert_eq!(
1399            cell_from_token("hi", &str_col).unwrap(),
1400            Cell::Str("hi".to_string())
1401        );
1402        // A quoted string keeps its contents; the quotes are stripped.
1403        assert_eq!(
1404            cell_from_token("\"a b\"", &str_col).unwrap(),
1405            Cell::Str("a b".to_string())
1406        );
1407        assert_eq!(cell_from_token("null", &str_col).unwrap(), Cell::Null);
1408        // null into a non-nullable column is an error.
1409        assert!(cell_from_token("null", &int_col).is_err());
1410        // a non-numeric token in an int column is an error.
1411        assert!(cell_from_token("x", &int_col).is_err());
1412    }
1413}