Skip to content

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.

  • Explain the spill/pause/abort decision as a set of boolean gates the arbitrator exposes, not one verdict enum.
  • Trace what should_spill checks (peak RSS or summed charged bytes against the soft limit) and what it triggers when it trips.
  • Write a runtime-chosen ArbitrationPolicy behind 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.

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, NoOpPolicy

The 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 operator

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.

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.

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.

rust // editable

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.

rust // editable
💡 Hint 1
An arbitration round only runs when 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.

rust // editable
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 chooses LargestFirst vs Priority without 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 UnsatisfiableMemoryBudget abort. The bounded-memory contract is worth most when it’s verified before you’ve spent an hour on the job.

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.