Skip to main content

agentd/runtime/
pressure.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! Resource pressure, and what a healthy daemon does about it: **shed new work,
3//! drain what is in flight.**
4//!
5//! The failure this exists for is disk. The file store writes until `ENOSPC`,
6//! and a checkpoint failure is a halting condition, so a full disk would
7//! otherwise stop the agent *after* the fact with nothing between "fine" and
8//! "dead". Two thresholds give it a middle: below `warn` the operator is told;
9//! below `shed` the daemon stops **admitting** work — no new runs fired,
10//! webhooks answered `429 Retry-After`, no new turns dispatched — while
11//! everything already running drains normally. An agent that finishes its
12//! current job but takes no more degrades; one that dies mid-checkpoint
13//! corrupts the next restart's starting point.
14//!
15//! Memory pressure (the cgroup's `memory.high`, when armed) sheds through the
16//! same gate. Every admission point must consult the same verdict: a daemon
17//! that refuses to spawn a child at its soft limit while still accepting a
18//! webhook, firing a schedule and dispatching a turn has not shed anything.
19//!
20//! Assessment is cached (~2s) and lock-free to read: the gates sit on hot
21//! paths and a `statvfs` per webhook would be its own kind of pressure.
22
23use std::path::PathBuf;
24use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
25
26/// How pressed the daemon is. Ordering matters: higher is worse.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
28pub enum Level {
29    Ok = 0,
30    /// Running low — logged, exported, nothing refused yet.
31    Warn = 1,
32    /// Admission stops; in-flight work drains.
33    Shed = 2,
34}
35
36impl Level {
37    pub fn as_str(self) -> &'static str {
38        match self {
39            Level::Ok => "ok",
40            Level::Warn => "warn",
41            Level::Shed => "shed",
42        }
43    }
44    fn from_u8(v: u8) -> Level {
45        match v {
46            2 => Level::Shed,
47            1 => Level::Warn,
48            _ => Level::Ok,
49        }
50    }
51}
52
53pub struct Pressure {
54    /// The file store's root — the filesystem whose headroom decides. `None`
55    /// (a memory/mcp/http store) disables the disk checks: their durability
56    /// does not live on this disk, and refusing work for a full local disk the
57    /// store never touches would be shedding for the wrong reason.
58    disk_path: Option<PathBuf>,
59    /// Below this many free bytes: shed. Warn at twice this.
60    shed_below: u64,
61    level: AtomicU8,
62    /// What drove the level ("disk" / "memory"), packed as u8.
63    cause: AtomicU8,
64    last_check_ms: AtomicU64,
65    /// The last measured free-bytes reading (for gauges and logs).
66    pub disk_free: AtomicU64,
67}
68
69const RECHECK_MS: u64 = 2_000;
70
71impl Pressure {
72    /// The admission verdict for work at a given priority: `Shed` refuses
73    /// everything, `Warn` already refuses **low**-priority work — which is what
74    /// gives `priority: low` teeth beyond a niceness delta: it is the work an
75    /// operator pre-agreed to sacrifice first.
76    pub fn refusal(&self, low_priority: bool) -> Option<String> {
77        match self.level() {
78            Level::Shed => Some(format!(
79                "{} pressure (shedding new work; in-flight work drains)",
80                self.cause()
81            )),
82            Level::Warn if low_priority => Some(format!(
83                "{} pressure (low-priority work sheds at warn)",
84                self.cause()
85            )),
86            _ => None,
87        }
88    }
89}
90
91impl Pressure {
92    pub fn new(disk_path: Option<PathBuf>, shed_below: u64) -> Pressure {
93        Pressure {
94            disk_path,
95            shed_below,
96            level: AtomicU8::new(0),
97            cause: AtomicU8::new(0),
98            last_check_ms: AtomicU64::new(0),
99            disk_free: AtomicU64::new(u64::MAX),
100        }
101    }
102
103    /// The current level, re-measuring at most every couple of seconds.
104    pub fn level(&self) -> Level {
105        let now = crate::state::now_ms();
106        let last = self.last_check_ms.load(Ordering::Relaxed);
107        if now.saturating_sub(last) >= RECHECK_MS
108            && self
109                .last_check_ms
110                .compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed)
111                .is_ok()
112        {
113            let (level, cause) = self.assess();
114            self.level.store(level as u8, Ordering::Relaxed);
115            self.cause.store(cause, Ordering::Relaxed);
116        }
117        Level::from_u8(self.level.load(Ordering::Relaxed))
118    }
119
120    /// Whether new work should be REFUSED right now.
121    pub fn shedding(&self) -> bool {
122        self.level() == Level::Shed
123    }
124
125    /// What drove the current level.
126    pub fn cause(&self) -> &'static str {
127        match self.cause.load(Ordering::Relaxed) {
128            1 => "disk",
129            2 => "memory",
130            _ => "none",
131        }
132    }
133
134    fn assess(&self) -> (Level, u8) {
135        if let Some(p) = &self.disk_path
136            && self.shed_below > 0
137            && let Some(free) = free_bytes(p)
138        {
139            self.disk_free.store(free, Ordering::Relaxed);
140            if free < self.shed_below {
141                return (Level::Shed, 1);
142            }
143            if free < self.shed_below.saturating_mul(2) {
144                return (Level::Warn, 1);
145            }
146        }
147        if crate::supervisor::cgroup::under_memory_pressure() {
148            return (Level::Shed, 2);
149        }
150        (Level::Ok, 0)
151    }
152}
153
154/// Free bytes available to unprivileged writes on `path`'s filesystem.
155///
156/// `f_bavail` (available to non-root), not `f_bfree`: the reserved root blocks
157/// are headroom the store cannot actually use, and counting them reports a
158/// disk as fine right up until every write fails.
159pub fn free_bytes(path: &std::path::Path) -> Option<u64> {
160    use std::os::unix::ffi::OsStrExt;
161    let c = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
162    let mut sv: libc::statvfs = unsafe { std::mem::zeroed() };
163    if unsafe { libc::statvfs(c.as_ptr(), &mut sv) } != 0 {
164        return None;
165    }
166    Some((sv.f_bavail as u64).saturating_mul(sv.f_frsize as u64))
167}
168
169/// Parse a human size: `256MB`, `1.5GiB`, `524288000`. Decimal and binary
170/// prefixes both mean binary here — an operator writing `256MB` for a
171/// threshold wants "about a quarter gig", and the 4.8% difference is noise
172/// against a knob whose purpose is "not zero".
173pub fn parse_bytes(s: &str) -> Result<u64, String> {
174    let t = s.trim();
175    if t.is_empty() {
176        return Err("empty size".into());
177    }
178    let lower = t.to_ascii_lowercase();
179    let (num, mult) = if let Some(n) = lower
180        .strip_suffix("gib")
181        .or_else(|| lower.strip_suffix("gb"))
182        .or_else(|| lower.strip_suffix('g'))
183    {
184        (n, 1u64 << 30)
185    } else if let Some(n) = lower
186        .strip_suffix("mib")
187        .or_else(|| lower.strip_suffix("mb"))
188        .or_else(|| lower.strip_suffix('m'))
189    {
190        (n, 1u64 << 20)
191    } else if let Some(n) = lower
192        .strip_suffix("kib")
193        .or_else(|| lower.strip_suffix("kb"))
194        .or_else(|| lower.strip_suffix('k'))
195    {
196        (n, 1u64 << 10)
197    } else {
198        (lower.as_str(), 1u64)
199    };
200    let v: f64 = num
201        .trim()
202        .parse()
203        .map_err(|_| format!("invalid size {s:?} (want e.g. 256MB, 1.5GiB, or bytes)"))?;
204    if !(v.is_finite() && v >= 0.0) {
205        return Err(format!("invalid size {s:?}"));
206    }
207    Ok((v * mult as f64) as u64)
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn refusal_gives_low_priority_its_teeth_one_level_early() {
216        // Real statvfs, engineered thresholds: with shed_below at 2/3 of the
217        // actual free space, free sits between shed (free×2/3) and warn
218        // (free×4/3) — the WARN band — deterministically, whatever the disk.
219        let free = free_bytes(std::path::Path::new("/")).expect("statvfs /");
220        let warn_band = Pressure::new(Some("/".into()), free * 2 / 3);
221        assert_eq!(warn_band.level(), Level::Warn);
222        assert!(
223            warn_band.refusal(false).is_none(),
224            "normal work admits at warn"
225        );
226        let msg = warn_band.refusal(true).expect("low sheds at warn");
227        assert!(msg.contains("low-priority"), "{msg}");
228
229        // Below shed everything refuses (shed_below > free → Shed).
230        let shedding = Pressure::new(Some("/".into()), u64::MAX);
231        assert_eq!(shedding.level(), Level::Shed);
232        assert!(shedding.refusal(false).is_some());
233        assert!(shedding.refusal(true).is_some());
234
235        // No file store → no disk opinion at all.
236        let none = Pressure::new(None, 0);
237        assert_eq!(none.level(), Level::Ok);
238        assert!(none.refusal(true).is_none());
239    }
240
241    #[test]
242    fn sizes_parse_and_the_root_filesystem_reports_headroom() {
243        assert_eq!(parse_bytes("256MB").unwrap(), 256 << 20);
244        assert_eq!(parse_bytes("256MiB").unwrap(), 256 << 20);
245        assert_eq!(
246            parse_bytes("1.5GiB").unwrap(),
247            (1.5 * (1u64 << 30) as f64) as u64
248        );
249        assert_eq!(parse_bytes("1024").unwrap(), 1024);
250        assert_eq!(parse_bytes("0").unwrap(), 0);
251        assert!(parse_bytes("lots").is_err());
252        // statvfs works on a path that certainly exists.
253        assert!(free_bytes(std::path::Path::new("/")).unwrap() > 0);
254    }
255
256    /// The thresholds, driven with a fake filesystem via the raw pieces.
257    #[test]
258    fn levels_change_at_the_declared_thresholds() {
259        let p = Pressure::new(None, 256 << 20);
260        // No disk path: only memory can shed, and without a cgroup the level
261        // is Ok — the checks disable rather than guess.
262        assert_eq!(p.assess().0, Level::Ok);
263    }
264}