Error handling
A pipeline chewing through ten million records will hit failures of very different weight. One malformed row in a CSV is data: annoying, expected, and no reason to throw away the other 9,999,999 records. A disk filling mid-write is infrastructure. And an invariant the compiler proved at plan time being violated at run time is a bug in clinker itself, which must stop everything, loudly. A good error type doesn’t just say that something failed; it encodes which kind, because the kind decides the fate of the run.
What you’ll be able to do
Section titled “What you’ll be able to do”- Read the
PipelineErrorenum and name the subsystem each variant aggregates. - Explain how a
From<E>impl lets the?operator lift a foreign subsystem error intoPipelineError. - Distinguish a recoverable per-record error from a fatal one, and say what each does under
ErrorStrategy::Continue. - Trace a per-record eval error through the dispatch router to the dead-letter queue, and say why
Internalnever reaches that router.
New terms in this lesson (each is also expanded inline at the point you first need it):
- error strategy: the policy (
FailFastvsContinue) that decides a recoverable error’s fate. - recoverable: a per-record data error (a cell that won’t cast, one bad row) that a non-fail-fast policy may quarantine and keep going.
- fatal: an error that aborts the run regardless of policy.
Internal,MemoryBudgetExceeded, schema/sort-order violations. - DLQ (dead-letter queue): where quarantined records go so one bad row doesn’t sink the job.
- the
?operator: one-character early-return-on-error that converts the error viaFrom; you met it in Missing cells & fallible parses, re-used here.
Predict first
Section titled “Predict first”One enum that aggregates every failure
Section titled “One enum that aggregates every failure”PipelineError is the engine’s top-level runtime error. It’s a sum type with one variant per
subsystem failure, plus a set of specific “this went wrong” variants:
clinker-plan ·error.rs ·PipelineError type @19acdcb4
#[derive(Debug)]pub enum PipelineError { Config(crate::config::ConfigError), Format(clinker_format::FormatError), Eval(cxl::eval::EvalError), Io(std::io::Error), /// Plan-time invariant violated at runtime — Clinker bug, not a data /// error. ALWAYS aborts the run regardless of ErrorStrategy::Continue. Internal { op: &'static str, node: String, detail: String }, MemoryBudgetExceeded { node: String, used: u64, limit: u64, /* … */ }, // ... ~25 variants; many documented "Always aborts the run"}Notice this enum is hand-written, with no thiserror derive. error.rs writes its own
Display, its own impl std::error::Error, and its own From conversions. That’s a
deliberate choice for a type this central: the Display strings are diagnostics shown to
users, worth hand-tuning. (You’ll meet thiserror elsewhere in the codebase; it’s a fine tool,
merely not used for this type.)
From impls are what make ? work
Section titled “From impls are what make ? work”The central piece of idiomatic Rust error handling is ?. Writing let r = next_record()?;
means “if this is Err, return it from the enclosing function, converting it to my error
type on the way out.” That conversion is powered by the
From
trait. Each subsystem error gets a hand-written From into PipelineError:
impl From<std::io::Error> for PipelineError { fn from(e: std::io::Error) -> Self { Self::Io(e) }}impl From<cxl::eval::EvalError> for PipelineError { fn from(e: cxl::eval::EvalError) -> Self { Self::Eval(e) }}// ... ConfigError, FormatError, SchemaError, SpillErrorWith those in place, a function returning Result<_, PipelineError> can call into the format
layer, the IO layer, and the CXL evaluator and write ? after each. Every foreign error is
auto-lifted into the right PipelineError variant. No match, no manual map_err. The six
From impls are the seams that let one error type absorb six others.
The taxonomy: abort versus quarantine
Section titled “The taxonomy: abort versus quarantine”Here’s where the type earns its keep. The variants split into two fates:
- Recoverable (per-record data errors). A
Valuethat won’t cast, acxl::eval::EvalErroron one row. Under the right policy these are sent to a dead-letter queue (DLQ): the bad record is set aside and the run continues. - Fatal (always abort).
Internal,MemoryBudgetExceeded, schema mismatches, sort-order violations. These stop the run regardless of policy. The variant docs say so in capital letters: “ALWAYS aborts the run regardless ofErrorStrategy::Continue.”
Which policy governs the recoverable ones is a config setting, the error strategy:
clinker-plan ·pipeline.rs ·ErrorStrategy type @19acdcb4
pub enum ErrorStrategy { FailFast, // any error stops the run (the default) Continue, // recoverable errors go to the DLQ; keep going BestEffort,}And the actual routing, the place a per-record eval error meets the policy, is one function in the executor:
clinker-exec ·dispatch.rs ·dispatch_transform_eval_error fn @19acdcb4
fn dispatch_transform_eval_error(/* … */) -> Result<…, PipelineError> { if ctx.strategy == ErrorStrategy::FailFast { return Err(eval_err.into()); // propagate — the ? at the call site aborts } // otherwise: classify and route the bad record to the DLQ, run continues}The crucial asymmetry: only recoverable errors flow through this router. The fatal variants
are never offered to the DLQ at all. Internal and its kin are constructed and ?-propagated
directly, bypassing this function entirely. So ErrorStrategy::Continue can keep a job alive
through a million bad rows, but it cannot suppress a clinker bug. That’s by design: a
Continue policy is a statement about your data, never a license to limp on through a broken
engine.
Why Internal is its own fate
Section titled “Why Internal is its own fate”Pause on Internal { op, node, detail }. Its doc calls it “a Clinker bug, not a data error.”
It exists for the cases that the plan/runtime boundary (last lesson) was supposed to make
impossible: an invariant that compilation proved, tripped anyway at run time. Routing such a
thing to the DLQ would be exactly wrong: it would hide a bug behind a “bad record” label and
let a broken run produce plausible-looking output. Making Internal always-fatal means the
engine fails loudly at the first sign it has violated its own contract. The run dies, the
operator sees it, nobody trusts corrupt output. The error taxonomy is how the engine refuses to
paper over its own bugs.
Worked → completion → faded
Section titled “Worked → completion → faded”Now you build the taxonomy in miniature (?, From, and the abort/quarantine split), scaffolded
down to your own code. Each rung adds exactly one new idea: first read the worked machine, then
complete the routing decision, then write the From-powered propagation from scratch.
Worked: the whole taxonomy, annotated
Section titled “Worked: the whole taxonomy, annotated”Here is a self-contained model of the engine’s split, all in std. Run it and watch the two
classes diverge: Internal aborts under every policy, Data only aborts under fail-fast.
> output appears here — press Run
Under Continue, "oops" is quarantined and "37" still processes; under FailFast, the
first bad record stops everything. Either way, an Internal would abort, but in this version
nothing triggers one yet. The next rung makes you supply that decision.
Completion: route the error to its fate
Section titled “Completion: route the error to its fate”Below, the run loop is written except for the recoverable arm. The Internal arm is done for
you; it always aborts. Complete the Data arm: under Continue (not fail-fast) a per-record
data error is quarantined to the DLQ and the loop keeps going; under FailFast it aborts. One
if decides which.
> output appears here — press Run
💡 Hint 1
if fail_fast { … return; } aborts; the else/fall-through path prints a “quarantine to DLQ” line and lets the loop continue to the next record. The Internal arm above does NOT branch on fail_fast; that’s the whole point.Show solution
Err(PipelineError::Data(e)) => { if fail_fast { println!("ABORT (fail-fast): {e:?}"); return; } println!("quarantine to DLQ, keep going: {e:?}");}The asymmetry is now visible in the code shape: the Data arm reads fail_fast and may
continue; the Internal arm never looks at it and always returns. Fate follows kind, and the
policy only ever governs the recoverable class. This mirrors dispatch_transform_eval_error,
which is the only place that consults ErrorStrategy, and which only ever sees recoverable
eval errors.
Faded: write the From-powered propagation
Section titled “Faded: write the From-powered propagation”Your turn with much less scaffolding. Two leaf errors come from two subsystems. Write the two
From impls and the validate function so that ? lifts each leaf into PipelineError
automatically, with no match and no map_err. The rules: a parse failure is a recoverable Data
error; a negative count is the impossible case, so construct Internal directly (don’t route
it through a fallible call).
> output appears here — press Run
Show solution
impl From<ParseError> for PipelineError { fn from(e: ParseError) -> Self { PipelineError::Data(e) }}impl From<RangeError> for PipelineError { fn from(e: RangeError) -> Self { PipelineError::Coercion(e) }}
fn validate(s: &str) -> Result<u64, PipelineError> { let raw = parse_count(s)?; // From<ParseError> fires on Err let count = nonneg(raw)?; // From<RangeError> fires on Err if count > 1_000_000 { // an invariant validation was supposed to enforce — a bug, not bad data return Err(PipelineError::Internal("count over 1_000_000 slipped past validation")); } Ok(count)}Two From impls, and now both ? calls lift their foreign error into PipelineError with no
ceremony, exactly how the real error.rs absorbs six subsystem errors. The Internal case is
constructed directly and returned, never produced by a fallible subsystem call, so it can
never be mistaken for a recoverable error or routed to the DLQ. That’s the propagation seam and
the taxonomy split, both produced by your own code.
Retrieval checkpoint
Section titled “Retrieval checkpoint”Connections
Section titled “Connections”You can now read the engine’s failure taxonomy and say why some errors quarantine a record while
others kill the run. You also saw that PipelineError is hand-written. The
next lesson shows exactly what that takes, and the one after it
meets thiserror, the derive that generates the same impls. CXL, the
expression language that has shown up from the outside all module, gets the deep lesson that closes
Planning & Expressions.
Go deeper (optional, one-directional):
Glossary terms used: error strategy, DLQ, Result / From / ?.