Node buffers & spill
Most operators stream: a record comes in, a record goes out. But a blocking operator, like a sort or a large aggregation, has to hold records while it works: you can’t emit the smallest row until you’ve seen them all. If the held set is bigger than memory allows, it must spill to disk. Clinker models that held set as a three-state enum (in memory, spilled, or mixed), and the enum is the whole state machine. This is one of the cleanest “an enum is a state machine” examples in the engine, and it’s how the engine keeps memory bounded even when a buffer outgrows RAM.
What you’ll be able to do
Section titled “What you’ll be able to do”- Name the three
NodeBufferstates and say what each one means for where the records physically live. - Trace the
Spilled → Mixedtransition and explain why it usesstd::mem::replacerather than mutating in place. - Write the
pushandspillmethods of a three-state buffer enum, with the move on the spilled edge. - Explain how a spilled buffer drains back in a fixed order, and why a
tempfile-backedSpillFileleaves no garbage behind.
New terms in this lesson (each is also expanded inline at the point you first need it):
- buffer: the records a blocking operator holds while it works.
- spill: push held rows to disk to free RAM.
- bounded memory: keeping working memory under a budget regardless of input size.
- backpressure: the channel-bound pacing from the previous lesson (re-used here).
Predict first
Section titled “Predict first”A buffer with three states
Section titled “A buffer with three states”A node’s held records are a NodeBuffer, and its variants are the three places those records can
live:
clinker-exec ·node_buffer.rs ·NodeBuffer type @19acdcb4
pub(crate) enum NodeBuffer { /// All events live in memory — records and punctuations in arrival order. Memory(Vec<StreamEvent>), /// Every record lives on disk, as spill files paired with row counts. /// Punctuations never spill — they wait in the `pending_puncts` sidecar. Spilled { chunks: Vec<(SpillFile<u64>, u64)>, pending_puncts: Vec<Punctuation>, }, /// A memory tail accumulated after a partial spill. Mixed { mem: Vec<StreamEvent>, spills: Vec<(SpillFile<u64>, u64)>, pending_puncts: Vec<Punctuation>, },}The enum makes the illegal states unrepresentable: a buffer is in exactly one of these shapes, and
every reader matches all three. Memory is the cheap default; Spilled means the records have
been pushed to disk so they no longer count against the memory budget; Mixed is what you get when
new records arrive after a spill: a fresh in-memory tail riding on top of the on-disk chunks.
Memory Spilled Mixed ┌──────────┐ ┌───────────────┐ ┌─────────────────────────────┐ │ Vec in │ │ spill files │ │ mem tail │ spill files │ │ RAM │ │ on disk │ │ in RAM │ on disk │ └──────────┘ └───────────────┘ └─────────────────────────────┘ │ spill (under memory pressure) ▲ ▲ └─────────────────────────────────┘ │ push after a spill │ (new mem tail)This is the engine’s answer to bounded memory:
a Memory buffer can grow only until the budget says spill; after that its bulk lives on disk and
only the new tail counts against RAM. In Threads & channels, a bounded
channel paced a fast reader so it couldn’t pile records into memory: that’s backpressure.
Spill is the matching trick one layer in: when a holding operator can’t be paced (it genuinely
needs every row), the engine moves the bulk to disk instead of letting it grow without bound.
Transitions: std::mem::replace moves a state forward
Section titled “Transitions: std::mem::replace moves a state forward”Pushing a record onto a Spilled buffer promotes it to Mixed, carrying the existing spill
chunks across. You can’t merely mutate in place; you need to move the old variant’s owned fields
into the new variant, and std::mem::replace is the standard Rust move-out-then-replace trick:
pub(crate) fn push_event(&mut self, event: StreamEvent) { match self { Self::Memory(v) => v.push(event), Self::Mixed { mem, .. } => mem.push(event), Self::Spilled { .. } => { // move the chunks + puncts out of the old `Spilled`, leaving a temporary let (chunks, puncts) = match std::mem::replace(self, Self::Memory(Vec::new())) { Self::Spilled { chunks, pending_puncts } => (chunks, pending_puncts), _ => unreachable!(), }; *self = Self::Mixed { mem: vec![event], spills: chunks, pending_puncts: puncts }; } }}Why std::mem::replace? self is &mut, so you only have a borrow: you can’t move chunks out
of *self and leave a hole, because a borrowed value must always stay valid. std::mem::replace
swaps in a placeholder (Self::Memory(Vec::new())) and hands you the old value by ownership, so
you can take its chunks apart and build Mixed. The Memory → Spilled transition happens
elsewhere, at the admission boundary, when the memory arbitrator
(the next lesson) says to spill. The
point for now is that the type enforces the rules: each transition consumes the old state and
produces a valid new one.
Spilling to disk, and draining back
Section titled “Spilling to disk, and draining back”When a buffer spills, its rows are written through a SpillWriter:
clinker-exec ·node_buffer_spill.rs ·spill_node_buffer fn @19acdcb4
pub(crate) fn spill_node_buffer( rows: Vec<(Record, u64)>, spill_dir: Option<&Path>, compress: bool,) -> Result<Option<(SpillFile<u64>, u64)>, PipelineError> { // writes each (record, row_number) pair through a SpillWriter, returns the file + count} clinker-exec ·spill.rs ·SpillWriter type @19acdcb4
The on-disk format is a leading tag byte (so the reader knows whether the rest is LZ4-compressed),
a JSON schema header, then length-prefixed postcard record frames.
A SpillFile is backed by a tempfile::TempPath, so it auto-deletes when dropped. The RAII
cleanup pattern (Data & Representation’s Drop) means a spilled buffer
leaves no garbage behind even if the run aborts.
Draining unifies all three states into one iterator, in a fixed order: memory events first, then
each spill file streamed back via a SpillReader, then the trailing punctuations. A downstream
operator consumes a Mixed buffer exactly as it would a Memory one, so the spill is invisible at
the drain interface. That uniformity is what lets the rest of the engine ignore whether a buffer
fit in RAM.
Worked → completion → faded
Section titled “Worked → completion → faded”Now you read the state machine, complete it, and finally write the whole thing; each rung adds exactly one idea over the last.
Worked: walk all three states
Section titled “Worked: walk all three states”Here is a self-contained buffer that mirrors the real NodeBuffer shape, minus the disk IO. Run it
and watch the buffer walk Memory → Spilled → Mixed. The std::mem::replace move on the
Spilled → Mixed edge is the same trick the real push_event uses.
> output appears here — press Run
The compiler guarantees you handled every state in push; std::mem::replace lets you move owned
data out of the old variant safely. That’s the whole spill state machine, minus the disk IO.
Completion: finish the spilled edge
Section titled “Completion: finish the spilled edge”Same buffer, but the Spilled arm of push has a gap. The replace-and-rebuild is the one move on
the whole edge: fill in the line that constructs the new Mixed variant, carrying the disk count
across and starting a fresh memory tail with the new row.
> output appears here — press Run
💡 Hint 1
mem is vec![row] and on_disk is the count you moved out.Show solution
*self = Buffer::Mixed { mem: vec![row], on_disk };The new row opens a fresh in-memory tail (vec![row]), and on_disk rides across unchanged: the
spilled rows are still on disk; nothing moved them. This is the Spilled → Mixed promotion exactly
as the real push_event does it.
Faded: write the buffer yourself
Section titled “Faded: write the buffer yourself”Now with much less scaffolding. The Buffer enum and main are given; write both methods.
spill turns a Memory buffer into Spilled (recording the row count) and leaves the other states
alone. push appends in place for Memory/Mixed, and promotes Spilled → Mixed with the
std::mem::replace move. Make main’s expected output match.
> output appears here — press Run
Show solution
fn spill(&mut self) { if let Buffer::Memory(v) = self { *self = Buffer::Spilled { on_disk: v.len() }; }}
fn push(&mut self, row: u32) { match self { Buffer::Memory(v) => v.push(row), Buffer::Mixed { mem, .. } => mem.push(row), Buffer::Spilled { .. } => { let on_disk = match std::mem::replace(self, Buffer::Memory(Vec::new())) { Buffer::Spilled { on_disk } => on_disk, _ => unreachable!(), }; *self = Buffer::Mixed { mem: vec![row], on_disk }; } }}spill only fires from Memory (the if let leaves Spilled/Mixed untouched). push is the
exhaustive match you completed above: append for the two in-memory cases, replace-and-rebuild for
Spilled. Drop any arm of that match and the compiler refuses to build; exhaustiveness is doing
the work of guaranteeing you handled every state.
Real-source grounding
Section titled “Real-source grounding”The buffer you just wrote is the shape of the engine’s real one. Read these in order:
clinker-exec ·node_buffer.rs ·NodeBuffer type @19acdcb4
What the compiler enforces: a NodeBuffer is in exactly one of Memory / Spilled / Mixed,
and every consumer matches all three. There is no way to read records out without first deciding
which state you’re in; the on-disk case can’t be silently skipped.
What a junior might misread: assuming a push onto Spilled stays Spilled (it promotes to
Mixed), or assuming Mixed means the rows are duplicated in RAM and on disk. They aren’t:
Mixed partitions the rows (old ones on disk, new tail in memory) with no overlap.
clinker-exec ·node_buffer_spill.rs ·spill_node_buffer fn @19acdcb4
The real spill writes through a SpillWriter into a tempfile-backed SpillFile. Because the file
is owned by an RAII guard, dropping the buffer unlinks the file, the same Drop-based cleanup
you met in Data & Representation, now doing the work of “no leftover spill
garbage even if the run aborts.”
Why-bridge: spill is how a holding operator stays within budget
Section titled “Why-bridge: spill is how a holding operator stays within budget”Why model the buffer as a state machine at all, instead of just letting a Vec grow? Because a
blocking operator can’t apply backpressure to its own input the way the bounded channel does: a
sort genuinely needs every row before it can emit the first one. If the held set outgrows RAM, the
only options are abort or spill. The three-state enum is what makes spill invisible to the
rest of the engine: a downstream operator drains a Mixed buffer exactly as it drains a Memory
one, so nothing above the buffer has to know whether the run fit in memory.
Retrieval checkpoint
Section titled “Retrieval checkpoint”Connections
Section titled “Connections”You can now name the three NodeBuffer states, trace the Spilled → Mixed move, write the buffer
yourself, and explain how it drains and cleans up. A buffer spills when memory gets tight. But
who decides it’s tight, across all the operators running at once, and how do they coordinate
without stepping on each other? That’s the memory arbitrator, and it’s
the engine’s sharpest lesson in interior mutability.
Go deeper on the Rust (optional, one-directional, the enum-as-state-machine idea from first principles, with no engine framing):
Glossary terms used: buffer, spill, bounded memory, backpressure.