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