Skip to content

Add a transform/operator

A format plugs into an open dyn seam (adding a reader/writer format). An operator is the opposite: node kinds are a closed, engine-owned set (the wildcard-free PlanNode enum from dispatching a node), so adding one means editing the closed set itself rather than plugging into a seam. That makes this the change that touches the most places. The good news is that the engine’s two wildcard-free matches turn most of those places into compile errors that hand you the to-do list.

  • Trace the path from an authored YAML step (type:) through lowering and dispatch to a running operator body.
  • Name the four edit sites adding an operator touches, and say which two the compiler forces on you.
  • Predict what cargo check reports after you add both enum variants but neither match arm.
  • Explain why the engine keeps two mirrored node enums (PipelineNode and PlanNode) instead of one shared enum.

New terms in this lesson (each is also expanded inline at the point you first need it):

  • operator: one node kind in the pipeline DAG (source, transform, route, …). “Operator” and “node kind” name the same thing here, and adding one is this lesson’s task.
  • transform: the specific operator that runs a CXL program on each record (type: transform in YAML). Used as the worked example of an operator that round-trips from YAML.
  • PipelineNode: the authored node enum: one variant per YAML node type:, span-carrying, strictly deserialized. Lives on the config side (clinker-plan).
  • PlanNode: the lowered node enum, the closed set the executor runs on (the wildcard-free enum from dispatching a node). A superset of PipelineNode, it also holds planner-synthesized kinds like Sort.
  • lowering: the one-way match that turns each PipelineNode into a PlanNode at compile time (lower_node_to_plan_node), building the DAG. The bridge across the plan/runtime boundary.

The engine represents a pipeline node twice, on two sides of the plan/runtime boundary you met in Planning & Expressions:

  • PipelineNode: the authored node, as it appears in YAML (type: transform, type: route, …). It lives in the config layer (clinker-plan), carries spans, and is deserialized with strict per-variant checks.
  • PlanNode: the lowered node, the executor’s vocabulary (the closed enum from dispatching a node). It carries resolved schemas, parallelism class, and other compile-time enrichment.
clinker-plan ·pipeline_node.rs ·PipelineNode type @19acdcb4
// The authored side — one variant per YAML node `type:`.
pub enum PipelineNode {
Source { header: NodeHeader, config: SourceBody },
Transform { header: NodeHeader, config: TransformBody },
Aggregate { header: NodeHeader, config: AggregateBody },
Route { header: NodeHeader, config: RouteBody },
Merge { /* ... */ },
// ...11 authored node kinds
}
clinker-plan ·mod.rs ·PlanNode type @19acdcb4
// The executor side — the closed set the DAG runs on.
pub enum PlanNode {
Source { name: String, /* ... */ },
Transform { name: String, /* resolved payload, parallelism, schema */ },
Sort { name: String, /* ... */ sort_fields: Vec<SortField> },
Aggregation { /* ... */ },
// ...13 variants — a superset of PipelineNode
}

They are deliberately not identical. PipelineNode::Aggregate lowers to PlanNode::Aggregation (the names differ), and PlanNode has variants like Sort and CorrelationCommit that are planner-synthesized: injected during compilation, never authored in YAML, so they have no PipelineNode counterpart. Teaching tip: build your first operator as one that does round-trip from YAML (Transform, Route, Reshape, Cull) so you exercise the whole path.

YAML ──serde/strict──▶ PipelineNode::Route (authored, in clinker-plan/config)
│ lower_node_to_plan_node (the lowering match)
PlanNode::Route (lowered, goes into the DAG)
│ graph.add_node(...) → ExecutionPlanDag → CompiledPlan
dispatch_plan_node (the executor's exhaustive match)
dispatch_route → the operator body (clinker-exec)

The lowering match is one free function (config variant in, plan variant out) with no intermediate IR:

clinker-plan ·pipeline.rs ·lower_node_to_plan_node fn @19acdcb4
pub(crate) fn lower_node_to_plan_node(
node: &PipelineNode,
// ...plus the node's identity, span, and compile-time context
) -> Option<PlanNode> {
match node {
PipelineNode::Route { config, .. } => Some(PlanNode::Route {
// the lowered payload — the route's branch config, resolved:
mode: config.mode,
branches: config.conditions.keys().cloned().collect(),
default: config.default.clone(),
// ...plus the per-node boilerplate (name, span, ...)
}),
PipelineNode::Aggregate { config, .. } => Some(PlanNode::Aggregation { /* ... */ }),
// ...one arm per authored variant, no `_ =>`. Returns None when an
// earlier stage already errored on this node.
}
}

This runs inside the compile step (Stage 5), once per authored node, building the DAG:

// PipelineConfig::compile_with_diagnostics, Phase 1: one PlanNode per node.
for spanned in &self.nodes {
let plan_node = lower_node_to_plan_node(node, /* node identity, span, compile ctx */);
if let Some(pn) = plan_node {
let idx = graph.add_node(pn); // into DiGraph<PlanNode, PlanEdge>
name_to_idx.insert(name, idx);
}
}

The resulting DiGraph<PlanNode, _> becomes the ExecutionPlanDag inside CompiledPlan, the typed handle from the plan/runtime boundary. At run time the executor walks it and dispatches each node through the wildcard-free match you saw in dispatching a node:

clinker-exec ·dispatch.rs ·dispatch_plan_node fn @19acdcb4
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)?,
// ...one arm per PlanNode variant, no `_ =>`
}

Adding an operator means a coordinated change across four places, and crucially, two of them are wildcard-free matches that will not compile until you add the arm:

#SiteFileCompiler-forced?
1PipelineNode variant (+ Body struct, strict deserialize)clinker-plan/src/config/pipeline_node.rs— (you author it)
2PlanNode variantclinker-plan/src/plan/execution/mod.rs— (you author it)
3lower_node_to_plan_node arm + Phase-2 edge wiringclinker-plan/src/config/pipeline.rsyes (exhaustive match)
4dispatch_plan_node arm → operator bodyclinker-exec/src/executor/dispatch.rs (+ a *_dispatch.rs module)yes (exhaustive match)

Sites 1–3 are all in clinker-plan (config, plan, lowering); site 4 is in clinker-exec (dispatch + the operator’s actual record logic). That split is the plan/runtime boundary again: planning lowers and validates; execution runs. The operator’s behavior, the code that actually transforms records, lives on the exec side, separate from the dispatch plumbing (for the Transform operator, evaluate_single_transform).

Once you add variants 1 and 2, the two exhaustive matches break the build and point you at sites 3 and 4. That’s the closed-enum payoff doing your change-management, the same guarantee that makes a dyn Operator design worse here (a forgotten case there would be a silent runtime no-op, not a compile error).

Add an operator: worked → completion → faded

Section titled “Add an operator: worked → completion → faded”

The real four-place change spans two crates and a lot of supporting types. The shape of it, though, fits in one file: two node enums, a lowering match, a dispatch match. You’ll work that miniature three times: first reading a complete one, then closing one gap, then adding a whole operator end to end. Each rung adds exactly one site of the change.

Here is the miniature with all four pieces in place and a Route operator already wired end to end. Run it and watch the lowering-then-dispatch path produce one line per node.

rust // editable

Notice the two matches have no _ => arm. That is the whole safety property in miniature: the set of node kinds is closed, so the compiler can check you handled every one.

A Dedup operator has been started: the variant is added to both enums (sites 1 and 2), and dispatch already has its arm (site 4). One site is left undone, the lowering arm (site 3). cargo check is reporting a non-exhaustive match at lower. Add the one arm that closes it.

rust // editable
💡 Hint 1
Lowering maps each authored kind to its executor kind. Dedup lowers to Dedup; the names happen to match here, the way Route does (unlike AggregateAggregation).
Show solution
ConfigNode::Dedup => PlanNode::Dedup,

That one arm makes the match exhaustive, and the build goes green. In the real engine this arm is lower_node_to_plan_node’s, and it carries the lowered payload (resolved fields, spans) rather than a bare variant. The move, though, is identical: config kind in, plan kind out, no catch-all to hide a missing case.

Faded: add a whole operator (all four sites)

Section titled “Faded: add a whole operator (all four sites)”

Now the unaided version. Add a brand-new Filter operator end to end. Both matches start exhaustive over { Transform, Route }, so the moment you add Filter to the enums both will stop compiling: exactly the two-error to-do list you predicted at the top. Add the variant to each enum, then the arm to each match.

rust // editable
Show solution
enum ConfigNode { Transform, Route, Filter } // site 1
enum PlanNode { Transform, Route, Filter } // site 2
fn lower(c: &ConfigNode) -> PlanNode {
match c {
ConfigNode::Transform => PlanNode::Transform,
ConfigNode::Route => PlanNode::Route,
ConfigNode::Filter => PlanNode::Filter, // site 3
}
}
fn dispatch(p: &PlanNode) -> &'static str {
match p {
PlanNode::Transform => "evaluate the CXL program",
PlanNode::Route => "send the record down a branch",
PlanNode::Filter => "keep only records that pass the predicate", // site 4
}
}

Four edits, two of them compiler-mandatory (the two match arms). That is the full change-management shape of adding an operator: the closed enums turn “did I wire it everywhere?” into a build error instead of a runtime surprise. In the real engine sites 1–3 land in clinker-plan and site 4 in clinker-exec, and the dispatch arm calls out to the operator’s actual record logic (evaluate_single_transform for Transform) rather than returning a string. The four sites and the two forced arms, though, are exactly these.

clinker-exec ·integration_tests.rs ·test_end_to_end_csv_transform test @19acdcb4
#[test]
fn test_end_to_end_csv_transform() {
// Inline YAML: source -> transform -> transform -> output, CSV in.
let (counters, dlq, output) = run_pipeline(yaml, csv).unwrap();
assert_eq!(counters.ok_count, 3);
assert_eq!(counters.dlq_count, 0);
// ...assert on the transformed output bytes
}

A new operator’s verify step is a test exactly like this: author a small YAML pipeline that uses your type:, feed input, and assert on the output bytes, testing to the boundary (testing strategy).

// quick check

After adding a PipelineNode::Dedup and a PlanNode::Dedup variant, you run cargo check. What does the compiler report, and why is that the design working as intended?

You can now extend all three of the engine’s extension points: expressions, formats, and operators. The last two lessons turn from making a change to landing one: the review gauntlet, then planning a change that respects the engine’s boundaries.

Go deeper on the Rust (optional, one-directional; the same machinery taught from first principles in The Rust Book, with no engine framing):

Glossary terms used: operator / node.