Dispatching a node — enum vs trait
Every node in a compiled DAG has to be routed to the operator that runs it, and the executor
faces a sharp design question: how do you call into one of many kinds? You met one answer
already, in Planning & Expressions. The format layer reached its
readers through Box<dyn FormatReader>, dynamic dispatch, because formats are an open-ended,
runtime-chosen plug-in seam. The DAG executor faces the same shape of problem (many kinds of
node) and deliberately answers it the opposite way: a closed enum and one exhaustive
match. This is the first lesson of the deepest pass, where we stop reading plans and watch
records actually move. The Rust tools in tension are the enum and the trait; we use just
enough of each to read the real dispatch code, and
The Rust Book, ch. 10 is the canonical
treatment of traits and trait objects if you want them from first principles.
What you’ll be able to do
Section titled “What you’ll be able to do”- Distinguish static dispatch from dynamic dispatch, and name what each costs: a vtable indirection per call versus none.
- Read the executor’s central
matchand explain why it has no_ =>catch-all and nodyn Operatoranywhere. - Explain when a closed
enum+ exhaustivematchbeatsBox<dyn Trait>, and why the IO seam made the opposite call. - Write both strategies in runnable Rust and predict which one the compiler turns into a checklist when a new kind is added.
New terms in this lesson (each is also expanded inline at the point you first need it):
- static dispatch: the call target is fixed at compile time.
- dynamic dispatch: the call target is found at run time, through a table.
- vtable: the per-type method table a trait object follows.
dyn: pre-taught where it reappears below, the marker of a trait object.- enum dispatch: one
matchover a closed enum, the executor’s strategy.
Predict first
Section titled “Predict first”The closed set of node kinds
Section titled “The closed set of node kinds”A compiled plan is a DAG of nodes, and a node is one of a fixed, engine-known set of kinds:
a source, a transform, a route, a merge, a sort, an aggregation, an output, and a handful more.
That set is a closed enum, the same PlanNode you glimpsed in Planning & Expressions:
clinker-plan ·mod.rs ·PlanNode type @19acdcb4
pub enum PlanNode { Source { /* ... */ }, Transform { /* ... */ }, Route { /* ... */ }, Merge { /* ... */ }, Sort { /* ... */ }, Aggregation { /* ... */ }, Output { /* ... */ }, // ... 13 variants in all — the complete vocabulary of pipeline nodes}The key word is closed. The kinds of node a pipeline can contain are decided by the engine, not by users, not at run time. Contrast that with formats: anyone can add a new wire format, and which one a job uses is read from the plan at run time. Formats are open; node kinds are closed. That single difference drives the whole dispatch decision.
Two ways to dispatch over many kinds
Section titled “Two ways to dispatch over many kinds”“Call into one of many kinds” has two standard answers in Rust, and this lesson is about choosing between them.
- A trait object,
Box<dyn Operator>. Each kind is its own type implementing a shared trait; you store them all behind one boxeddynand call through a vtable. The call target is found at run time, which is dynamic dispatch. The set can be open: outside code can add a new implementor the central code never named. - A closed enum, one
matchover a fixed set of variants. Each arm is resolved at compile time, so the call is static dispatch: no vtable, no indirection. The set is closed: every kind is named in one place.
You met the first strategy in the IO seam and the static-dispatch idea again in one reader, every format (generics). This lesson is the deliberate contrast: same problem shape, opposite choice, because the constraints flipped.
One exhaustive match, no dyn
Section titled “One exhaustive match, no dyn”Because the node set is closed, the executor dispatches with a single match over the enum,
enum dispatch.
Each arm hands the node to its operator module:
clinker-exec ·dispatch.rs ·dispatch_plan_node fn @19acdcb4
pub(crate) fn dispatch_plan_node( ctx: &mut ExecutorContext<'_>, current_dag: &ExecutionPlanDag, node_idx: NodeIndex,) -> Result<(), PipelineError> { let node = current_dag.graph[node_idx].clone(); match node { PlanNode::Source { .. } => dispatch_source(ctx, current_dag, node_idx, &node)?, PlanNode::Transform { .. } => dispatch_transform(ctx, current_dag, node_idx, &node)?, PlanNode::Route { .. } => dispatch_route(ctx, current_dag, node_idx, &node)?, PlanNode::Merge { .. } => dispatch_merge(ctx, current_dag, node_idx, &node)?, // ... one arm per variant — and crucially, NO `_ =>` catch-all } Ok(())}There is no trait object here and none anywhere in the engine: searching the whole codebase
for dyn Operator or trait Operator finds nothing. Operators are not boxed behind a vtable;
they’re arms of a match. And there is no _ => wildcard: the match spells out every
variant. That second detail is doing real architectural work, as the next section shows.
Why closed-enum dispatch wins here
Section titled “Why closed-enum dispatch wins here”The IO seam argued dynamic dispatch was right for formats. Here the trade flips, for three concrete reasons:
- Exhaustiveness is a feature. Because the
matchhas no catch-all, adding a newPlanNodevariant makes every non-exhaustive match a compile error. The compiler hands you the exact list of sites to update (exactly the guarantee from reading a value). Withdyn Operator, a forgotten case would be a run-time surprise, not a build failure. - The set is closed and known at compile time. A
dynseam exists to let outside code plug in types the engine never named. Node kinds are all named by the engine itself, so the open-endednessdynbuys is worthless here, and you’d pay a vtable indirection per node for it. - Operators want specialized data. Each arm can pull the variant’s own payload (a sort’s keys, a transform’s compiled program) by pattern-matching, with no downcasting.
The mental model: dyn is for open sets chosen by others at run time; a closed enum is for
sets the engine owns and wants the compiler to police. The IO seam is the former, the operator
set the latter. Same language feature family (traits and enums), opposite tool for opposite
jobs.
Worked → completion → faded
Section titled “Worked → completion → faded”Now you build both strategies in runnable Rust, scaffolded down to your own. One new idea per rung: first read the enum strategy and feel the exhaustiveness; then complete a missing arm; then write the closed-enum dispatcher from a skeleton, and see the trait-object version stay silent where the enum version shouts.
Worked: feel the exhaustiveness
Section titled “Worked: feel the exhaustiveness”Here are both strategies side by side. The enum version makes the compiler your checklist; the trait-object version quietly accepts a new kind with no nudge. Run it, then read the comments.
> output appears here — press Run
Add a Sort variant to Node and the build breaks at dispatch, pointing at the exact code
that doesn’t yet handle it. That is the compiler acting as a complete, always-current checklist
of “every place a new operator must be wired in.” The dyn Operator side has no such site: a
new Sort struct compiles fine and simply never appears in ops if you forget, and the failure
waits for run time.
Completion: write the missing arm
Section titled “Completion: write the missing arm”Below, the closed-enum dispatcher is written for you except one arm. A Merge variant
exists but its arm was dropped, so the match is non-exhaustive and won’t build. Fill in the
arm so every variant is handled, and notice you can’t paper over it with a _ => and keep
the lesson’s guarantee.
> output appears here — press Run
💡 Hint 1
"combine" (or similar). Add Node::Merge => "combine",, a real arm, not a _ => wildcard, so the match stays exhaustive.Show solution
Node::Merge => "combine",A named arm, not a _ => catch-all. The difference is the whole point: with the explicit
arm, the next variant someone adds breaks the build here and demands attention. A _ =>
wildcard would have compiled today but silently swallowed every future kind, throwing away the
compiler-as-checklist guarantee the executor relies on.
Faded: write the dispatcher from a skeleton
Section titled “Faded: write the dispatcher from a skeleton”Your turn with much less scaffolding. Given the closed Stage enum, write dispatch so it
maps each variant to its action word, with no _ => arm. Then confirm the claim for
yourself: after it compiles, mentally add a Validate variant and predict exactly what the
compiler says.
> output appears here — press Run
Show solution
fn dispatch(s: &Stage) -> &'static str { match s { Stage::Parse => "parse", Stage::Typecheck => "typecheck", Stage::Eval => "eval", }}This is dispatch_plan_node in miniature: one match, one arm per variant, no wildcard. Add a
Validate variant and the compiler reports non-exhaustive patterns: Stage::Validate not covered, pointing straight at this function. That error is not an annoyance; it is the engine’s
guarantee that no node kind can ever silently fall through dispatch.
Retrieval checkpoint
Section titled “Retrieval checkpoint”Connections
Section titled “Connections”You can now distinguish static from dynamic dispatch, read the executor’s wildcard-free match,
and say why the engine has no dyn Operator anywhere, reserving trait objects for the open
IO seam and closed enums for the node set it owns. Next: how the executor runs operators at
once, the threads that drive each source and the bounded channels that connect them.
Go deeper on the Rust (optional, one-directional, for the same concepts taught from first principles in The Rust Book):
Glossary terms used: static dispatch, dynamic dispatch, vtable, dyn, enum dispatch.