Skip to main content

stuffr_core/governor/
mod.rs

1//! Central resource governance. Nothing in the codebase spawns a thread
2//! outside the governor.
3
4pub mod budget;
5pub mod cgroup;
6
7pub use budget::{BudgetInputs, DEFAULT_CAP, detect_cpu_budget, resolve_workers};
8
9use std::sync::{Arc, Condvar, Mutex};
10
11/// Floor for the memory budget: below this, even single-threaded work struggles.
12pub const MEMORY_FLOOR: u64 = 256 * 1024 * 1024;
13
14/// Combines the readings into the effective budget: 25% of the smallest
15/// applicable figure, never below [`MEMORY_FLOOR`].
16///
17/// `available` is the host or cgroup-visible free memory; `cgroup_v2` and
18/// `cgroup_v1` are container limits when present. Taking the minimum is the
19/// fix: `/proc/meminfo` inside a container reports the host, so on its own it
20/// computes a budget the container cannot honour.
21pub fn effective_memory_limit(
22    available: u64,
23    cgroup_v2: Option<u64>,
24    cgroup_v1: Option<u64>,
25) -> u64 {
26    let mut smallest = available;
27    if let Some(v) = cgroup_v2 {
28        smallest = smallest.min(v);
29    }
30    if let Some(v) = cgroup_v1 {
31        smallest = smallest.min(v);
32    }
33    (smallest / 4).max(MEMORY_FLOOR)
34}
35
36/// 25% of *available* RAM. Available rather than total, because the distinction
37/// is exactly what matters on a host that is already loaded.
38///
39/// Reads `MemAvailable` from `/proc/meminfo` on Linux, and checks for cgroup
40/// limits (v2 and v1). Elsewhere it falls back to the floor, which is
41/// conservative by design.
42pub fn default_memory_limit() -> u64 {
43    #[cfg(target_os = "linux")]
44    {
45        let available = if let Ok(s) = std::fs::read_to_string("/proc/meminfo") {
46            cgroup::parse_meminfo_available(&s)
47        } else {
48            None
49        };
50
51        if let Some(avail) = available {
52            // Try v2 at the process's own cgroup first (for nested slices), then the root.
53            let v2 = {
54                let mut limit = None;
55                if let Ok(cgroup_contents) = std::fs::read_to_string("/proc/self/cgroup")
56                    && let Some(path) = cgroup::parse_self_cgroup_v2_path(&cgroup_contents)
57                {
58                    let nested_path = format!("/sys/fs/cgroup{}/memory.max", path);
59                    if let Ok(s) = std::fs::read_to_string(&nested_path) {
60                        limit = cgroup::parse_cgroup_v2_memory_max(&s);
61                    }
62                }
63                limit.or_else(|| {
64                    std::fs::read_to_string("/sys/fs/cgroup/memory.max")
65                        .ok()
66                        .and_then(|s| cgroup::parse_cgroup_v2_memory_max(&s))
67                })
68            };
69
70            // v1's hierarchy differs; only check the root. Nested v1 resolution was not
71            // required by the brief and might not have a per-cgroup memory limit set.
72            let v1 = std::fs::read_to_string("/sys/fs/cgroup/memory/memory.limit_in_bytes")
73                .ok()
74                .and_then(|s| cgroup::parse_cgroup_v1_memory_limit(&s));
75
76            return effective_memory_limit(avail, v2, v1);
77        }
78    }
79    MEMORY_FLOOR
80}
81
82#[derive(Default)]
83struct GovState {
84    workers_out: usize,
85    bytes_out: u64,
86}
87
88/// The single source of parallelism in the process.
89///
90/// Work units acquire [`LeaseSet`]s rather than being handed a thread count,
91/// which is what makes nested parallelism safe: an outer "8 entries at once"
92/// and an inner "multi-threaded zstd" both draw from this one pool, so their
93/// product can never exceed the budget.
94pub struct Governor {
95    workers: usize,
96    memory_limit: u64,
97    state: Mutex<GovState>,
98    cv: Condvar,
99}
100
101impl Governor {
102    pub fn new(workers: usize, memory_limit: u64) -> Arc<Self> {
103        Arc::new(Self {
104            workers: workers.max(1),
105            memory_limit,
106            state: Mutex::new(GovState::default()),
107            cv: Condvar::new(),
108        })
109    }
110
111    pub fn from_inputs(inputs: &BudgetInputs, memory_limit: u64) -> Arc<Self> {
112        Self::new(resolve_workers(inputs), memory_limit)
113    }
114
115    pub fn workers(&self) -> usize {
116        self.workers
117    }
118
119    pub fn memory_limit(&self) -> u64 {
120        self.memory_limit
121    }
122
123    pub fn outstanding(&self) -> usize {
124        self.state
125            .lock()
126            .expect("governor mutex poisoned")
127            .workers_out
128    }
129
130    pub fn reserved_bytes(&self) -> u64 {
131        self.state
132            .lock()
133            .expect("governor mutex poisoned")
134            .bytes_out
135    }
136
137    /// Acquires up to `want` workers, and the memory they need, in one call.
138    ///
139    /// Grants `min(want, workers_free, memory_free / per_worker_bytes)`,
140    /// blocking only while no worker slot is free at all, and never granting
141    /// fewer than one — a single over-budget worker beats no progress.
142    ///
143    /// Granting the floor worker can push the reserved total past
144    /// `memory_limit` — by less than one worker's demand *per grant*, but that
145    /// bound does not sum across grants. Eight concurrent callers each asking
146    /// for one worker will each see `cpu_free > 0`, each floor to 1, and each
147    /// charge `per_worker_bytes`, so the reserved total can reach
148    /// `workers × per_worker_bytes` regardless of `memory_limit`. The memory
149    /// budget therefore bounds *voluntary* parallelism, not a hard ceiling on
150    /// reserved bytes; the hard ceiling is the worker count. That
151    /// over-commitment is the price of the progress guarantee — a caller that
152    /// would otherwise wait forever runs over budget instead of never running.
153    ///
154    /// **Acquire once.** A unit takes its whole allocation in one call and does
155    /// not acquire again while holding. Hold-and-wait is what deadlocks: four
156    /// units each holding one lease and each blocking for a second would never
157    /// progress. Getting fewer workers than asked is fine, the work runs
158    /// slower; blocking for the full request would trade a deadlock for a stall.
159    pub fn acquire_many(self: &Arc<Self>, want: usize, per_worker_bytes: u64) -> LeaseSet {
160        let want = want.max(1);
161        let mut st = self.state.lock().expect("governor mutex poisoned");
162        loop {
163            let cpu_free = self.workers.saturating_sub(st.workers_out);
164            if cpu_free > 0 {
165                let mem_free = self.memory_limit.saturating_sub(st.bytes_out);
166                // `checked_div` rather than a manual zero-check-then-divide:
167                // `per_worker_bytes == 0` means "no memory constraint", i.e. `want`.
168                let by_mem = match mem_free.checked_div(per_worker_bytes) {
169                    Some(n) => n as usize,
170                    None => want,
171                };
172                // `.max(1)` cannot exceed cpu_free, which is at least 1 here.
173                let grant = want.min(cpu_free).min(by_mem).max(1);
174                let bytes = per_worker_bytes.saturating_mul(grant as u64);
175                st.workers_out += grant;
176                st.bytes_out = st.bytes_out.saturating_add(bytes);
177                return LeaseSet {
178                    gov: Arc::clone(self),
179                    workers: grant,
180                    bytes,
181                };
182            }
183            st = self.cv.wait(st).expect("governor mutex poisoned");
184        }
185    }
186
187    /// Blocks until at least one worker is available.
188    pub fn acquire(self: &Arc<Self>) -> LeaseSet {
189        self.acquire_many(1, 0)
190    }
191
192    /// Returns `None` immediately if the pool is full.
193    pub fn try_acquire(self: &Arc<Self>) -> Option<LeaseSet> {
194        let mut st = self.state.lock().expect("governor mutex poisoned");
195        if st.workers_out >= self.workers {
196            return None;
197        }
198        st.workers_out += 1;
199        Some(LeaseSet {
200            gov: Arc::clone(self),
201            workers: 1,
202            bytes: 0,
203        })
204    }
205
206    /// How many workers a codec needing `per_worker_bytes` *could* use.
207    ///
208    /// A hint for sizing work before committing. It reserves nothing — two
209    /// concurrent callers both get the same answer. Call [`Self::acquire_many`]
210    /// to actually reserve.
211    pub fn workers_for(&self, per_worker_bytes: u64) -> usize {
212        if per_worker_bytes == 0 {
213            return self.workers;
214        }
215        let by_memory = (self.memory_limit / per_worker_bytes) as usize;
216        by_memory.clamp(1, self.workers)
217    }
218}
219
220/// A granted allocation of workers and memory. Releases both on drop.
221pub struct LeaseSet {
222    gov: Arc<Governor>,
223    workers: usize,
224    bytes: u64,
225}
226
227impl LeaseSet {
228    pub fn workers(&self) -> usize {
229        self.workers
230    }
231
232    pub fn bytes(&self) -> u64 {
233        self.bytes
234    }
235}
236
237impl Drop for LeaseSet {
238    fn drop(&mut self) {
239        let mut st = self.gov.state.lock().expect("governor mutex poisoned");
240        st.workers_out = st.workers_out.saturating_sub(self.workers);
241        st.bytes_out = st.bytes_out.saturating_sub(self.bytes);
242        // notify_all rather than notify_one: releasing several workers can
243        // satisfy several waiters.
244        self.gov.cv.notify_all();
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use std::sync::atomic::{AtomicUsize, Ordering};
252
253    const GIB: u64 = 1024 * 1024 * 1024;
254
255    #[test]
256    fn leases_are_bounded_by_the_worker_count() {
257        let g = Governor::new(2, GIB);
258        let _a = g.try_acquire().expect("first lease");
259        let _b = g.try_acquire().expect("second lease");
260        assert!(g.try_acquire().is_none(), "third lease must not be issued");
261        assert_eq!(g.outstanding(), 2);
262    }
263
264    #[test]
265    fn dropping_a_lease_returns_it_to_the_pool() {
266        let g = Governor::new(1, GIB);
267        {
268            let _a = g.try_acquire().expect("lease");
269            assert!(g.try_acquire().is_none());
270        }
271        assert_eq!(g.outstanding(), 0);
272        assert!(
273            g.try_acquire().is_some(),
274            "lease must be reusable after drop"
275        );
276    }
277
278    #[test]
279    fn nested_parallelism_cannot_exceed_the_budget() {
280        // The N-squared guard. Outer "entries" and inner "codec threads" both
281        // draw from one pool, so the product can never exceed `workers`.
282        let g = Governor::new(4, GIB);
283        let peak = Arc::new(AtomicUsize::new(0));
284        let live = Arc::new(AtomicUsize::new(0));
285
286        std::thread::scope(|s| {
287            for _outer in 0..8 {
288                let (g, peak, live) = (Arc::clone(&g), Arc::clone(&peak), Arc::clone(&live));
289                s.spawn(move || {
290                    for _inner in 0..8 {
291                        let _lease = g.acquire();
292                        let now = live.fetch_add(1, Ordering::SeqCst) + 1;
293                        peak.fetch_max(now, Ordering::SeqCst);
294                        std::thread::yield_now();
295                        live.fetch_sub(1, Ordering::SeqCst);
296                    }
297                });
298            }
299        });
300
301        assert!(
302            peak.load(Ordering::SeqCst) <= 4,
303            "peak was {}",
304            peak.load(Ordering::SeqCst)
305        );
306        assert!(peak.load(Ordering::SeqCst) >= 1);
307        assert_eq!(g.outstanding(), 0, "every lease must be returned");
308    }
309
310    #[test]
311    fn acquire_blocks_until_a_lease_frees_rather_than_failing() {
312        let g = Governor::new(1, GIB);
313        let held = g.try_acquire().expect("lease");
314        let g2 = Arc::clone(&g);
315        let h = std::thread::spawn(move || {
316            let _l = g2.acquire();
317            true
318        });
319        std::thread::sleep(std::time::Duration::from_millis(250));
320        assert!(
321            !h.is_finished(),
322            "acquire must block while the pool is full"
323        );
324        drop(held);
325        assert!(h.join().unwrap());
326    }
327
328    #[test]
329    fn memory_clamp_reduces_workers_rather_than_oom_ing() {
330        // xz -9 at ~700 MiB per worker against a 2 GiB limit: 2 workers, not 8.
331        let g = Governor::new(8, 2 * GIB);
332        assert_eq!(g.workers_for(700 * 1024 * 1024), 2);
333    }
334
335    #[test]
336    fn memory_clamp_never_reports_fewer_than_one_worker() {
337        // Better to run one over-budget worker than to make no progress.
338        let g = Governor::new(8, 100);
339        assert_eq!(g.workers_for(GIB), 1);
340    }
341
342    #[test]
343    fn zero_demand_codecs_get_the_full_budget() {
344        let g = Governor::new(8, GIB);
345        assert_eq!(g.workers_for(0), 8);
346    }
347
348    #[test]
349    fn memory_clamp_never_exceeds_the_cpu_budget() {
350        let g = Governor::new(2, 1024 * GIB);
351        assert_eq!(
352            g.workers_for(1024),
353            2,
354            "plenty of RAM does not buy more CPUs"
355        );
356    }
357
358    #[test]
359    fn from_inputs_applies_the_conservative_default() {
360        let i = BudgetInputs {
361            detected: 16,
362            ..Default::default()
363        };
364        assert_eq!(Governor::from_inputs(&i, GIB).workers(), 8);
365    }
366
367    #[test]
368    fn a_small_container_limit_is_clamped_up_to_the_floor() {
369        // 512 MiB / 4 = 128 MiB, below MEMORY_FLOOR, so the floor wins.
370        let host = 64 * 1024 * 1024 * 1024;
371        assert_eq!(
372            effective_memory_limit(host, Some(512 * 1024 * 1024), None),
373            MEMORY_FLOOR
374        );
375    }
376
377    #[test]
378    fn a_container_limit_beats_a_larger_host_reading() {
379        // The behaviour that matters: inside a container, /proc/meminfo reports
380        // the HOST, so the host reading alone would give 16 GiB. The container
381        // limit must win. Chosen large enough to clear MEMORY_FLOOR, so the
382        // floor cannot mask the result the way it does at 512 MiB.
383        let host = 64 * 1024 * 1024 * 1024;
384        let container = 8 * 1024 * 1024 * 1024;
385        assert_eq!(
386            effective_memory_limit(host, Some(container), None),
387            2 * 1024 * 1024 * 1024,
388            "8 GiB container -> 2 GiB budget, not the 16 GiB the host would give"
389        );
390    }
391
392    #[test]
393    fn with_no_cgroup_limit_the_host_reading_stands() {
394        let host = 64 * 1024 * 1024 * 1024;
395        assert_eq!(
396            effective_memory_limit(host, None, None),
397            16 * 1024 * 1024 * 1024
398        );
399    }
400
401    #[test]
402    fn the_floor_applies_after_every_other_clamp() {
403        assert_eq!(effective_memory_limit(1024, Some(2048), None), MEMORY_FLOOR);
404    }
405
406    #[test]
407    fn default_memory_limit_is_sane() {
408        let m = default_memory_limit();
409        assert!(m >= 256 * 1024 * 1024, "floor keeps small hosts usable");
410    }
411
412    #[test]
413    fn acquire_many_grants_what_is_available_rather_than_blocking_for_all() {
414        let g = Governor::new(4, GIB);
415        let a = g.acquire_many(3, 0);
416        assert_eq!(a.workers(), 3);
417        // Only one slot left, so a request for three yields one — not a wait.
418        let b = g.acquire_many(3, 0);
419        assert_eq!(b.workers(), 1);
420        assert_eq!(g.outstanding(), 4);
421    }
422
423    #[test]
424    fn acquire_many_reserves_memory_so_two_callers_cannot_both_take_the_budget() {
425        // The bug this fixes: workers_for is pure arithmetic reserving nothing,
426        // so two callers against 2 GiB were each told "you may use 2" and
427        // together allocated 2.8 GiB.
428        let g = Governor::new(8, 2 * GIB);
429        let a = g.acquire_many(2, 700 * 1024 * 1024);
430        assert_eq!(a.workers(), 2);
431
432        // Memory pressure reduces the second caller's grant. THIS is the
433        // composition property — without reservation it would also have got 2.
434        let b = g.acquire_many(2, 700 * 1024 * 1024);
435        assert_eq!(b.workers(), 1, "only one worker's memory remains");
436
437        // Reservation is exact: three granted workers, three charges. Note the
438        // total EXCEEDS memory_limit, and that is deliberate — b's worker was
439        // granted by the floor, which trades over-commitment for a progress
440        // guarantee.
441        assert_eq!(g.reserved_bytes(), 3 * 700 * 1024 * 1024);
442
443        // Over-commitment is real and deliberate, but bounded: the worker
444        // count, not memory_limit, is the hard ceiling.
445        assert!(g.reserved_bytes() <= g.memory_limit() + (g.workers() as u64) * 700 * 1024 * 1024);
446    }
447
448    #[test]
449    fn a_lease_set_releases_both_resources_on_drop() {
450        let g = Governor::new(4, GIB);
451        {
452            let _a = g.acquire_many(4, 100);
453            assert_eq!(g.outstanding(), 4);
454            assert_eq!(g.reserved_bytes(), 400);
455        }
456        assert_eq!(g.outstanding(), 0);
457        assert_eq!(g.reserved_bytes(), 0);
458    }
459
460    #[test]
461    fn one_worker_is_granted_even_when_memory_alone_would_allow_none() {
462        // A single over-budget worker beats no progress, matching workers_for's
463        // existing clamp.
464        let g = Governor::new(8, 100);
465        let a = g.acquire_many(4, GIB);
466        assert_eq!(a.workers(), 1);
467    }
468
469    #[test]
470    fn acquire_many_blocks_only_while_no_worker_slot_is_free() {
471        let g = Governor::new(1, GIB);
472        let held = g.acquire_many(1, 0);
473        let g2 = Arc::clone(&g);
474        let h = std::thread::spawn(move || g2.acquire_many(4, 0).workers());
475        std::thread::sleep(std::time::Duration::from_millis(250));
476        assert!(!h.is_finished(), "must wait while the pool is empty");
477        drop(held);
478        assert_eq!(h.join().unwrap(), 1);
479    }
480
481    #[test]
482    fn single_shot_acquisition_does_not_deadlock_under_nested_demand() {
483        // Eight units each wanting three workers from a pool of four. Under
484        // repeated single acquire() with hold-and-wait this deadlocks; under
485        // single-shot acquisition it completes.
486        let g = Governor::new(4, GIB);
487        std::thread::scope(|s| {
488            for _ in 0..8 {
489                let g = Arc::clone(&g);
490                s.spawn(move || {
491                    let set = g.acquire_many(3, 0);
492                    assert!(set.workers() >= 1 && set.workers() <= 4);
493                    std::thread::yield_now();
494                });
495            }
496        });
497        assert_eq!(g.outstanding(), 0);
498    }
499}