Skip to content

Commit cb4e73f

Browse files
antiguruclaude
andauthored
clusterd-test-driver: add explain verb to assert optimized plan shape (#37141)
Follow-up to #37008 (now merged), addressing DAlperin's review on `join.spec`: a script using `optimize` asserts only the result, so optimizer or lowering drift could silently change the plan under test. `explain` renders the lowered LIR plan (the `EXPLAIN PHYSICAL PLAN` form, via a no-catalog `DummyHumanizer` so ids are `u<n>` and columns `#n`) as its golden, submitting nothing. It takes the dataflow either inline (the `create-dataflow` body) or by reference — `explain ref=<name>` renders a dataflow a prior `create-dataflow name=<name>` declared, without repeating its body. `join.spec` declares the join, then `explain ref=join` asserts the differential-join plan alongside the count. The multi-object render separates objects with blank lines, so the `.spec` format gains the `datadriven` doubled-`----` block form, emitted automatically by `REWRITE`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 03c8b04 commit cb4e73f

5 files changed

Lines changed: 603 additions & 172 deletions

File tree

doc/developer/design/20260612_headless_clusterd_test_driver.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,10 @@ The ephemeral dataflows take global ids from a high reserved range so they never
281281

282282
`create-dataflow` is the generic abstraction behind index / materialized-view / subscribe / copy-to: a command carrying `imports`, `builds`, `exports`, an `as_of`, and an optional `optimize` flag over the `DataflowBuilder` API, with `define-index` as sugar over it.
283283
The `optimize` flag runs the MIR optimizer before lowering; it is what lets a `Join` lower (`join.spec` joins two sources on a key), since a raw join's `implementation` is `Unimplemented` and the lowering rejects it otherwise.
284+
The `explain` verb renders a dataflow's lowered LIR plan (the `EXPLAIN PHYSICAL PLAN` form, via a no-catalog `DummyHumanizer` so ids render as `u<n>` and columns as `#n`) as its golden output, instead of submitting.
285+
It takes the dataflow either inline (the same body as `create-dataflow`) or by reference — `explain ref=<name>` renders a dataflow a prior `create-dataflow name=<name>` declared, without repeating its body (the recorded spec is re-lowered, so the plan matches what was submitted).
286+
With `optimize`, this asserts the optimizer's plan shape, so subtle optimizer or lowering drift that a result-only assertion would miss is caught (`join.spec` declares the join, then `explain ref=join` asserts the differential-join plan alongside the count).
287+
Because the render spans multiple objects separated by blank lines, an `explain` golden uses the `datadriven` doubled-`----` block form (see `text`), which `REWRITE` emits automatically.
284288
Each export selects a `kind`: `index` (an arrangement), `materialized-view` (a persist sink), and `subscribe` (a sink streaming changes back) are implemented; `copy-to` (matching the last `ComputeSinkConnection` variant) is rejected as unimplemented.
285289
A sink export declares its output `schema`, which the builder validates against the exported object's column types before submission. A subscribe also takes an optional `up-to` (the exclusive upper at which it completes); the materialized-view sink does not support `UP TO` (the real optimizer leaves it empty too), so the driver always passes an empty one.
286290
The augment step splices each materialized-view sink's target `CollectionMetadata` into its connection — the same fill-in `compute-client`'s `Instance::create_dataflow` does — while subscribe carries no storage metadata. A materialized-view sink begins read-only and writes nothing until `allow-writes`; indexes, subscribes, and peeks need no such permission.

src/clusterd-test-driver/src/dataflow.rs

Lines changed: 97 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
//! [`ComputeCommand::CreateDataflow`]: mz_compute_client::protocol::command::ComputeCommand::CreateDataflow
2525
2626
use std::collections::BTreeMap;
27+
use std::time::Duration;
2728

2829
use mz_compute_types::dataflows::{
2930
BuildDesc, DataflowDescription, IndexDesc, IndexImport, SourceImport,
@@ -35,10 +36,12 @@ use mz_compute_types::sinks::{
3536
SubscribeSinkConnection,
3637
};
3738
use mz_compute_types::sources::SourceInstanceDesc;
39+
use mz_expr::explain::ExplainContext;
3840
use mz_expr::{
3941
AggregateExpr, AggregateFunc, MirRelationExpr, MirScalarExpr, OptimizedMirRelationExpr,
4042
};
4143
use mz_persist_types::{PersistLocation, ShardId};
44+
use mz_repr::explain::{DummyHumanizer, Explain, ExplainConfig, ExplainFormat, UsedIndexes};
4245
use mz_repr::optimize::OptimizerFeatures;
4346
use mz_repr::{GlobalId, RelationDesc, ReprRelationType, Timestamp};
4447
use mz_storage_types::controller::CollectionMetadata;
@@ -443,34 +446,73 @@ impl DataflowBuilder {
443446
/// column out of range, or an unbalanced object graph), so a caller driving
444447
/// this from external input — notably the script reader — can surface a clean
445448
/// error instead of crashing the process.
446-
pub fn finish(mut self) -> anyhow::Result<DataflowDescription<RenderPlan, CollectionMetadata>> {
449+
pub fn finish(self) -> anyhow::Result<DataflowDescription<RenderPlan, CollectionMetadata>> {
447450
let features = OptimizerFeatures::default();
451+
let lowered = Self::lower(self.mir, self.optimize, &features)?;
452+
augment(lowered, &self.sources, &self.sinks)
453+
}
454+
455+
/// Render the lowered dataflow as `EXPLAIN PHYSICAL PLAN`-style text — the LIR
456+
/// the dataflow ships — so a script can golden-assert the optimized
457+
/// plan shape and catch optimizer (or lowering) drift, which a result-only
458+
/// assertion misses.
459+
///
460+
/// Honors [`Self::optimize`] exactly like [`Self::finish`], so the explained
461+
/// plan is the one that would be shipped. A no-catalog [`DummyHumanizer`]
462+
/// renders ids as `u123` and columns as `#n` — stable and matching the `.spec`
463+
/// MIR vocabulary, with no catalog to thread in.
464+
pub fn explain(self) -> anyhow::Result<String> {
465+
let features = OptimizerFeatures::default();
466+
let mut lowered = Self::lower(self.mir, self.optimize, &features)?;
467+
let config = ExplainConfig::default();
468+
let context = ExplainContext {
469+
config: &config,
470+
features: &features,
471+
humanizer: &DummyHumanizer,
472+
cardinality_stats: BTreeMap::new(),
473+
used_indexes: UsedIndexes::default(),
474+
finishing: None,
475+
duration: Duration::default(),
476+
target_cluster: None,
477+
optimizer_notices: Vec::new(),
478+
};
479+
lowered
480+
.explain(&ExplainFormat::Text, &context)
481+
.map_err(|e| anyhow::anyhow!("explaining dataflow failed: {e}"))
482+
}
483+
484+
/// Optionally run the MIR dataflow optimizer, then lower MIR to LIR.
485+
/// Shared by [`Self::finish`] (which augments the result with persist metadata)
486+
/// and [`Self::explain`] (which renders it). Deterministic and self-contained.
487+
fn lower(
488+
mut mir: DataflowDescription<OptimizedMirRelationExpr, ()>,
489+
optimize: bool,
490+
features: &OptimizerFeatures,
491+
) -> anyhow::Result<DataflowDescription<LirRelationExpr, ()>> {
448492
// Optionally run the MIR dataflow optimizer first (e.g. to fill a `Join`'s
449493
// implementation). The index oracle is built from this dataflow's own
450494
// `index_imports`, so the optimizer recognizes imported arrangements and
451495
// plans `Get`s over them as arrangement reads (not persist reads); the
452496
// statistics oracle is empty — no catalog stats — so join planning falls
453497
// back to a differential join, which lowers.
454-
if self.optimize {
455-
let indexes = ImportedIndexOracle::new(&self.mir.index_imports);
498+
if optimize {
499+
let indexes = ImportedIndexOracle::new(&mir.index_imports);
456500
let typecheck_ctx = empty_typechecking_context();
457501
let mut df_meta = DataflowMetainfo::default();
458502
let mut ctx = TransformCtx::global(
459503
&indexes,
460504
&EmptyStatisticsOracle,
461-
&features,
505+
features,
462506
&typecheck_ctx,
463507
&mut df_meta,
464508
None,
465509
);
466-
optimize_dataflow(&mut self.mir, &mut ctx, false)
510+
optimize_dataflow(&mut mir, &mut ctx, false)
467511
.map_err(|e| anyhow::anyhow!("optimizing dataflow failed: {e}"))?;
468512
}
469513
// Lower MIR -> LIR. Deterministic and self-contained.
470-
let lowered: DataflowDescription<LirRelationExpr, ()> =
471-
LirRelationExpr::finalize_dataflow(self.mir, &features, None)
472-
.map_err(|e| anyhow::anyhow!("lowering dataflow failed: {e}"))?;
473-
augment(lowered, &self.sources, &self.sinks)
514+
LirRelationExpr::finalize_dataflow(mir, features, None)
515+
.map_err(|e| anyhow::anyhow!("lowering dataflow failed: {e}"))
474516
}
475517
}
476518

@@ -848,6 +890,52 @@ mod tests {
848890
assert!(assemble(true).is_ok());
849891
}
850892

893+
/// `explain` renders the lowered LIR plan as text, so a script can assert the
894+
/// optimized plan shape. Build the optimized two-source join and confirm the
895+
/// rendered plan mentions a `Join` (the operator the optimizer selected).
896+
#[mz_ore::test]
897+
#[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
898+
fn explain_join_renders_plan() {
899+
let loc = PersistLocation {
900+
blob_uri: "mem://".parse().unwrap(),
901+
consensus_uri: "mem://".parse().unwrap(),
902+
};
903+
let mut builder = DataflowBuilder::new("headless-explain-test");
904+
let left = builder.import_persist(
905+
GlobalId::User(1000),
906+
PersistSource {
907+
shard: ShardId::new(),
908+
location: loc.clone(),
909+
desc: crate::data::sample_desc(),
910+
upper: Timestamp::from(1),
911+
},
912+
);
913+
let right = builder.import_persist(
914+
GlobalId::User(1001),
915+
PersistSource {
916+
shard: ShardId::new(),
917+
location: loc.clone(),
918+
desc: crate::data::sample_desc(),
919+
upper: Timestamp::from(1),
920+
},
921+
);
922+
let join = MirRelationExpr::join_scalars(
923+
vec![left.get(), right.get()],
924+
vec![vec![MirScalarExpr::column(0), MirScalarExpr::column(2)]],
925+
);
926+
builder.build(GlobalId::User(2000), join);
927+
builder.optimize();
928+
builder.as_of(Timestamp::from(0));
929+
builder.export_index(GlobalId::User(2001), GlobalId::User(2000), vec![0]);
930+
let text = builder.explain().unwrap();
931+
// Print so the rendered shape is visible under `--nocapture`.
932+
println!("{text}");
933+
assert!(
934+
text.contains("Join"),
935+
"explain output missing Join:\n{text}"
936+
);
937+
}
938+
851939
/// A single dataflow can export both an index and a materialized view over the
852940
/// same built object (binding). Both exports reference that object; the index
853941
/// arranges it and the MV sink writes it to a target shard.

0 commit comments

Comments
 (0)