The IO seam — traits
Clinker reads and writes eight wildly different file formats (CSV, JSON, XML, fixed-width,
EDIFACT, X12, HL7, SWIFT), yet the executor that pushes records through the DAG contains
zero format-specific code. The mechanism that makes that possible is the engine’s most
important seam: a single contract every format satisfies, so a new format plugs in without
the execution loop changing by a character. You spent Data & Representation
taking the data layer apart cell by cell; now we move up a layer to where new behaviour plugs
in. The Rust tool underneath the seam is the trait; we use just enough of it to read the
real FormatReader, and The Rust Book, ch. 10
is the canonical treatment if you want traits from first principles.
What you’ll be able to do
Section titled “What you’ll be able to do”- Read the
FormatReadertrait and name its two required trait methods and what a full drain looks like. - Explain how every format reaches the rest of the engine through that one contract, with zero format-specific code in the executor.
- Write a trait, two implementors, and a function that drives any implementor through the trait: runnable Rust, the engine’s seam in miniature.
- Distinguish a trait object (
Box<dyn FormatReader>) from a concrete type, and say why the engine boxes readers and what dynamic dispatch costs here.
New terms in this lesson (each is also expanded inline at the point you first need it):
- trait: a contract of methods, not a type.
- trait method: a method the contract requires (or supplies by default).
impl: the block where a concrete type satisfies the trait.- seam: the plug-in boundary a trait creates.
- trait object (
Box<dyn FormatReader>): pre-taught where it appears below.
Predict first
Section titled “Predict first”The motivating problem: one engine, eight formats
Section titled “The motivating problem: one engine, eight formats”Clinker ships readers and writers for CSV, JSON, XML, fixed-width, EDIFACT, X12, HL7, and SWIFT. That’s eight wildly different ways to lay bytes on disk. Yet the executor, the part that pushes records through the DAG, contains zero format-specific code. It never asks “is this a CSV?” How can the same execution loop drive all eight?
The answer is a trait: a contract that says what a reader can do without fixing which reader it is. Every format implements the same two methods, and the engine talks only to the contract.
clinker-format ·traits.rs ·FormatReader trait @19acdcb4
/// Streaming record reader. Yields records one at a time.pub trait FormatReader: Send { fn schema(&mut self) -> Result<Arc<Schema>, FormatError>; fn next_record(&mut self) -> Result<Option<Record>, FormatError>; // ... plus default-bodied methods for multi-file / envelope handling}Two required
trait methods
carry the whole seam: ask for the schema, then pull records until next_record returns
Ok(None). A FormatReader is exactly “a thing the engine can ask for a schema and then
drain, one Record at a time.” Notice the return type of next_record,
Result<Option<Record>, FormatError>: it’s the composed three-outcome type you read in
Data & Representation, and the trait just promises every format produces it. The write side is its mirror
image, consuming records one at a time and flushing:
clinker-format ·traits.rs ·FormatWriter trait @19acdcb4
/// Streaming record writer. Consumes records one at a time.pub trait FormatWriter: Send { fn write_record(&mut self, record: &Record) -> Result<(), FormatError>; fn flush(&mut self) -> Result<(), FormatError>; // ... plus default-bodied document-framing methods}Notice the supertrait bound : Send. The doc comment is explicit about why: a reader is
moved onto the executor’s per-source ingest thread, so it must be Send. It is
deliberately not Sync: a single reader is driven by one thread, streaming, never
shared. That bound is a small architectural decision encoded in the type.
A minimal model: the trait is the seam
Section titled “A minimal model: the trait is the seam”Strip the engine away and the pattern is small. A trait with one method, two implementors,
and a function that works on any implementor. This is the same shape as FormatReader,
shrunk to something you can run:
> output appears here — press Run
drain is the executor in miniature: it takes &mut dyn FormatReader and never learns
whether it’s draining CSV or JSON, exactly what you predicted. That, and nothing more, is
the seam
doing its job: one function written against the contract, indifferent to which implementor it
drives.
Worked → completion → faded
Section titled “Worked → completion → faded”You just read the seam. Now you build one, scaffolded down to writing the whole thing
yourself. Each rung adds one idea: first see a full trait + two impls + a driver; then supply
one missing impl body; then write a fresh trait and its driver from a skeleton.
Worked: a trait, two impls, and a driver that drives both
Section titled “Worked: a trait, two impls, and a driver that drives both”Here is a complete, runnable seam. A trait Counter with one required method, two unrelated
structs that each impl it, and a total function that drives any Counter through the
contract. Run it and watch one function tally two different structs.
> output appears here — press Run
total is drain again: one driver, written once against Counter, driving two structs it
never names.
Completion: supply the missing impl body
Section titled “Completion: supply the missing impl body”Below, the trait, the driver, and one implementor are done. The second implementor,
EvenStream, which should yield 0, 2, 4, … up to a limit, is missing its
next_count body. Fill in only that one method so the program drains cleanly to None.
> output appears here — press Run
💡 Hint 1
None when the source is exhausted, otherwise Some(value). Here “exhausted” means current has reached limit. Don’t forget to advance current so the next call makes progress.Show solution
fn next_count(&mut self) -> Option<u32> { if self.current >= self.limit { return None; } let n = self.current; self.current += 2; Some(n)}total never changed. You taught a brand-new type to satisfy the same contract, and the
driver drove it untouched; the seam absorbed the new implementor exactly the way the engine
absorbs a new format.
Faded: write the whole seam yourself
Section titled “Faded: write the whole seam yourself”Now with no method bodies given. Define a trait Source with one required method
next_line(&mut self) -> Option<String>; give it two implementors, Fixed (yields lines
from a Vec<String>) and Repeat (yields the same string times times), and a driver
collect_all(&mut dyn Source) -> Vec<String> that drains any source into a Vec. The
main is written for you and asserts the expected output.
> output appears here — press Run
💡 Hint 1
&mut dyn Source, looping while let Some(line) = src.next_line(). Each impl Source for … block supplies a next_line that returns None when its own source is exhausted.Show solution
trait Source { fn next_line(&mut self) -> Option<String>;}
struct Fixed { lines: Vec<String> }impl Source for Fixed { fn next_line(&mut self) -> Option<String> { self.lines.pop() }}
struct Repeat { text: String, times: u32 }impl Source for Repeat { fn next_line(&mut self) -> Option<String> { if self.times == 0 { return None; } self.times -= 1; Some(self.text.clone()) }}
fn collect_all(src: &mut dyn Source) -> Vec<String> { let mut out = Vec::new(); while let Some(line) = src.next_line() { out.push(line); } out}You wrote the seam end to end: one contract, two unrelated implementors, one driver that
drives either. Scale next_line up to next_record, swap your two structs for eight format
readers, and you have FormatReader and the executor’s drain loop. The shape does not change.
Why a trait object, and what dyn costs
Section titled “Why a trait object, and what dyn costs”The format a job uses isn’t known when the engine is compiled; it’s chosen at run time,
from the plan. So the executor needs a single variable that can hold any reader. That’s a
trait object:
Box<dyn FormatReader>, a heap-allocated value plus a hidden pointer to a vtable (the
table of “which next_record does this actual reader use?”). Calling through it is dynamic
dispatch: the concrete method is looked up at run time.
In real clinker, the concrete readers are themselves generic structs: CsvReader<R>,
FixedWidthReader<R>, and so on, generic over the byte source R. They’re built
type-specifically, then immediately boxed into a trait object at one factory function so
that everything downstream is uniform:
clinker-exec ·mod.rs ·RecordSource trait @19acdcb4
// crates/clinker-exec/src/executor/ingest.rs — the dispatch boundaryfn build_format_reader(/* … */) -> Box<dyn FormatReader> { match &input.format { InputFormat::Csv(opts) => Box::new(CsvReader::new(/* … */)), InputFormat::Json(opts) => Box::new(JsonReader::new(/* … */)), // ... one arm per format, each boxed into the same trait-object type }}This is worth pausing on, because it’s the shape of every plug-in seam in clinker: a typed
enum (InputFormat) is matched once at the boundary, each arm constructs the right concrete
reader, and all of them collapse into one Box<dyn FormatReader>. There is no string-keyed
“format registry”: dispatch is over the closed enum you met in Data & Representation,
so an unknown format is a plan-time error, not a runtime lookup miss.
What does dyn cost? One pointer-indirection per call. Here that’s invisible: a vtable call
is dwarfed by the file IO and parsing that produce each record. Dynamic dispatch is the
right trade exactly where the set of types is open-ended and chosen at run time, and the
per-call cost is noise against the work being dispatched. The next lesson,
One reader, every format, shows the opposite choice,
generics, for the field-read hot path where that same pointer-indirection would not be noise.
One step further: the transport generalization
Section titled “One step further: the transport generalization”FormatReader assumes bytes; it decodes a stream. But a SQL SELECT cursor yields rows with
no byte body at all. So one crate up, the executor defines a broader contract,
RecordSource, and bridges the file case to it with a single blanket impl:
impl RecordSource for Box<dyn FormatReader> { // a file transport reaches the row-oriented seam by wrapping its byte reader}You don’t need the details yet; Execution & Memory returns to
it. The point is the layering: a narrow, byte-oriented trait (FormatReader) nested inside a
broader, transport-agnostic one (RecordSource), each a clean seam. Traits compose into layers
the same way types do.
Retrieval checkpoint
Section titled “Retrieval checkpoint”Connections
Section titled “Connections”You’ve seen the engine’s open seam: a trait, implemented per format, boxed into a trait object at one boundary so the rest of the engine stays format-blind. You also built one from scratch (a contract, two implementors, one driver) which is the same shape, scaled down. Next, One reader, every format: the other dispatch strategy, generics, and why the field-read hot path makes the opposite choice.
Go deeper on the Rust (optional, one-directional, for traits taught from first principles in The Rust Book):
Glossary terms used: trait, trait method, impl, seam, trait object.