Skip to content

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.

  • Distinguish static dispatch from dynamic dispatch, and name what each costs: a vtable indirection per call versus none.
  • Read the executor’s central match and explain why it has no _ => catch-all and no dyn Operator anywhere.
  • Explain when a closed enum + exhaustive match beats Box<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 match over a closed enum, the executor’s strategy.

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.

“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 boxed dyn and 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 match over 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.

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.

The IO seam argued dynamic dispatch was right for formats. Here the trade flips, for three concrete reasons:

  • Exhaustiveness is a feature. Because the match has no catch-all, adding a new PlanNode variant 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). With dyn Operator, a forgotten case would be a run-time surprise, not a build failure.
  • The set is closed and known at compile time. A dyn seam exists to let outside code plug in types the engine never named. Node kinds are all named by the engine itself, so the open-endedness dyn buys 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.

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.

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.

rust // editable

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.

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.

rust // editable
💡 Hint 1
The other three arms each return a one-word action for that kind. A merge node combines several inputs into one stream, so its action word is "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.

rust // editable
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.

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.