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}