Bounded memory in action
The arbitrator built the shared, interior-mutable state. This one is about the decisions it makes with that state: when memory gets tight, does it spill a buffer, pause a source, or abort the run? And, the part that saves you from a wedged job, how does it refuse a budget it can never satisfy, before a single record moves? The answer threads back through two earlier ideas: a swappable strategy (where a trait object is finally the right call) and the fail-fast error taxonomy from Planning & Expressions. By the end you’ll have written the gate-and-policy machinery yourself and watched it pick a victim under pressure.
What you’ll be able to do
Section titled “What you’ll be able to do”- Explain the spill/pause/abort decision as a set of boolean gates the arbitrator exposes, not one verdict enum.
- Trace what
should_spillchecks (peak RSS or summed charged bytes against the soft limit) and what it triggers when it trips. - Write a runtime-chosen
ArbitrationPolicybehind a trait object and observe two policies pick different victims under the same pressure. - Distinguish why operators are a closed enum while the memory policy is a trait object: closed-and-owned vs open-and-configured.
- Explain the unsatisfiable-budget rejection: why a limit below baseline RSS deadlocks a pausing policy, and why it must fail before the run starts.
New terms in this lesson (each is also expanded inline at the point you first need it):
- bounded memory: the run-stays-under-a-cap guarantee this whole machine exists to enforce.
- spill: move records to disk so they stop costing RAM (you met the mechanism in Node buffers & spill).
- soft / hard limit: the two thresholds (a soft cap that triggers spill/pause, a hard cap that triggers abort).
- streaming: the default flow where a record comes in and a record goes out, holding nothing.
Predict first
Section titled “Predict first”The decision is a set of gates, not one verdict
Section titled “The decision is a set of gates, not one verdict”There’s no single enum { Spill, Pause, Abort }. The arbitrator exposes a few boolean gates that
operators poll. The central one is should_spill, checked at every bulk admission:
clinker-exec ·memory.rs ·should_spill fn @19acdcb4
pub fn should_spill(&self) -> bool { self.observe(); // refresh peak RSS let soft = self.soft_limit(); let tripped = self.peak_rss.load(Ordering::Relaxed) > soft || self.sum_consumer_usage() > soft; // RSS OR summed charged bytes if tripped { self.poll_arbitration(); // run ONE arbitration round } tripped}Two things worth noting. It trips on either real process RSS or the summed charged bytes,
so it still works on a platform where RSS can’t be read (the charged-byte sum is the backstop). And
when it trips, it runs one arbitration round (poll_arbitration) that actually does something
about the pressure. Its sibling should_abort checks the hard limit and is the line that turns
runaway memory into a fatal error. The soft and hard limits are the two
limits
the whole machine watches.
The policy is a trait object, and that’s correct here
Section titled “The policy is a trait object, and that’s correct here”Inside the arbitration round, which operator gets acted on is a strategy, and clinker makes it swappable through a trait:
clinker-exec ·memory.rs ·ArbitrationPolicy trait @19acdcb4
pub trait ArbitrationPolicy: Send + Sync { fn select_victim(&self, consumers: &[(ConsumerId, &dyn MemoryConsumer)], pressure_bytes: u64) -> Option<ConsumerId>;}// concrete policies: LargestFirst, Priority, BackPressurePreferred, NoOpPolicyThe arbitrator holds its policy as Box<dyn ArbitrationPolicy> and the round asks it for a victim,
then pauses or spills that operator:
let victim = self.policy.select_victim(&snapshot, pressure);if let Some(id) = victim { if consumer.can_back_pressure() { consumer.pause(); } // a source: park it else { consumer.try_spill(pressure); } // an operator: spill it}Stop and compare with Dispatching a node. There, operators were a
closed enum with no dyn, because the set is engine-owned and fixed. Here the memory policy is
a trait object, because it’s a strategy chosen at run time from config: Spill, Pause, or
Both map to different policy implementations, and a deployment picks one. That’s exactly the open,
runtime-chosen seam where dyn pays off (like the IO seam). Same engine,
same author, opposite tools, chosen by
whether the set is closed-and-owned or open-and-configured. Recognising which situation you’re in
is the whole skill.
memory pressure ──▶ should_spill() trips ──▶ poll_arbitration() │ policy.select_victim() │ ┌───────────────┴───────────────┐ can_back_pressure? otherwise │ │ pause the source spill the operatorReject the impossible budget up front
Section titled “Reject the impossible budget up front”Here’s the failure the design most wants to prevent. Suppose a config sets a memory limit below the process’s baseline RSS, the memory the engine occupies before reading any data. Under a producer-pausing policy, the arbitrator would pause everything to get under budget and then never be able to resume: a deadlock. So clinker checks for it before the run starts and refuses:
clinker-exec ·memory.rs ·reject_unsatisfiable_budget fn @19acdcb4
pub fn reject_unsatisfiable_budget(limit: u64, knob: BackpressureKnob) -> Result<(), PipelineError> { if !knob.pauses_producers() { return Ok(()); } // only pausing policies can deadlock let Some(baseline_rss) = rss_bytes() else { return Ok(()); }; if limit < baseline_rss { return Err(PipelineError::UnsatisfiableMemoryBudget { limit, baseline_rss }); } Ok(())}That PipelineError::UnsatisfiableMemoryBudget (error code E312) is one of the always-abort
variants from Error handling: there’s no DLQ for “your budget is
impossible,” it stops the run before any thread spawns. This is the
bounded-memory
contract’s front door: fail loudly at startup, never wedge mid-run. A guarantee is most valuable
when it’s checked before you’ve spent an hour on the job.
Worked → completion → faded
Section titled “Worked → completion → faded”You’ve read the three pieces: the gate, the policy seam, the startup check. Now build them, one new idea per rung, and observe the bounded-memory machine react under pressure.
Worked: the spill gate, fully annotated
Section titled “Worked: the spill gate, fully annotated”Here is a self-contained arbitrator with the should_spill gate. It tracks summed charged bytes
against a soft limit and counts how many arbitration rounds it runs. Run it and watch the gate trip
only once charged bytes cross the limit.
> output appears here — press Run
The gate is the heartbeat of bounded memory: every bulk admission polls it, and only a trip pays for an arbitration round. Notice nothing here decides spill vs pause; that’s the policy’s job, which is the next rung.
Completion: select a victim under one policy
Section titled “Completion: select a victim under one policy”Below, the LargestFirst policy is written for you; the arbitrate function that drives it has one
gap. An arbitration round asks the policy for a victim only when pressure is positive: when
pressure_bytes == 0 there’s nothing to relieve, so it should return None without consulting the
policy. Fill in that guard.
> output appears here — press Run
💡 Hint 1
should_spill tripped, i.e. when there’s real pressure. With pressure_bytes == 0 there’s nothing to act on, so guard it with an early return None; before the policy call.Show solution
fn arbitrate(policy: &dyn Policy, usage: &[Usage], pressure_bytes: u64) -> Option<u32> { if pressure_bytes == 0 { return None; // nothing to relieve — don't pick a victim } policy.select_victim(usage)}With the guard, the no-pressure call returns None without naming a victim, matching the engine,
where a round only runs because should_spill already tripped. The policy is consulted only when
there’s pressure to relieve.
Faded: a second policy, and observe the swap
Section titled “Faded: a second policy, and observe the swap”Your turn with much less scaffolding. Add a LowestIdFirst policy that selects the operator with
the smallest id (a crude stand-in for “the highest-priority operator gets protected last”).
Then box each policy as Box<dyn Policy> and run the same pressure through both: the value of a
runtime-chosen strategy is that the victim changes with no other code touched.
> output appears here — press Run
Show solution
struct LowestIdFirst;impl Policy for LowestIdFirst { fn select_victim(&self, usage: &[Usage]) -> Option<u32> { usage.iter().min_by_key(|(id, _)| *id).map(|(id, _)| *id) }}
fn main() { let usage = [(0u32, 2_000), (1, 9_000), (2, 5_000)];
let policy: Box<dyn Policy> = Box::new(LargestFirst); println!("largest-first victim: {:?}", arbitrate(&*policy, &usage)); // Some(1)
let policy: Box<dyn Policy> = Box::new(LowestIdFirst); println!("lowest-id victim: {:?}", arbitrate(&*policy, &usage)); // Some(0)}Swap the boxed policy and the victim changes with no other code touched; that’s the value of a
runtime-chosen strategy behind a trait. The real ArbitrationPolicy is this exact shape, just with
LargestFirst / Priority / BackPressurePreferred and real operators, and the round then pauses
or spills the chosen victim depending on whether it can back-pressure.
Why-bridge: a guarantee is only as good as its front door
Section titled “Why-bridge: a guarantee is only as good as its front door”Why split the work into polled gates plus a swappable policy plus a startup check, instead of
one tidy decide() function? Because each piece answers a different pressure that the others can’t:
- Gates (
should_spill/should_abort) are polled independently by many operators across many threads; there’s no single chokepoint to serialize on, which matters when the whole point is to not stall the pipeline. - The policy is the one part a deployment legitimately wants to vary, so it’s the one part
behind
dyn: config choosesLargestFirstvsPrioritywithout the engine naming each inline. - The startup check turns the worst failure mode (a silent mid-run deadlock under a pausing
policy) into a loud, immediate
UnsatisfiableMemoryBudgetabort. The bounded-memory contract is worth most when it’s verified before you’ve spent an hour on the job.
Retrieval checkpoint
Section titled “Retrieval checkpoint”Connections
Section titled “Connections”You’ve now seen the whole bounded-memory machine: buffers that spill, an arbitrator that decides
through polled gates, a policy that picks the victim, and a startup check that refuses an impossible
budget. The next lesson, unsafe & FieldStr, drops to the lowest level the data
layer reaches: the unsafe code behind the string type every record field uses, and the invariants
that keep it sound.
Go deeper on the Rust (optional, one-directional, the policy seam from first principles):
Glossary terms used: bounded memory, spill, soft / hard limit, streaming.