Skip to main content

memory_budget/
reporter.rs

1//! Read-only attribution of non-cache memory to named subsystems.
2//!
3//! The budget tick's `non_cache_live_bytes` (jemalloc-allocated minus
4//! registered caches and leases) is the program's live heap outside
5//! the resizable caches — typically dominated by the reasoner working
6//! set. It is one opaque number. A [`NonCacheReporter`] lets a
7//! subsystem publish "how many bytes am I holding right now" so the
8//! tick can *name* slices of that bucket and leave the rest as a
9//! `non_cache_other` residual.
10//!
11//! Unlike [`Resizable`](crate::Resizable), reporters are **read-only**:
12//! the budget observes but cannot shrink them (the reasoner's working
13//! set isn't evictable mid-pass). The budget holds `Weak` handles, so
14//! a dropped reporter prunes itself from the next tick.
15
16use std::sync::Arc;
17use std::sync::atomic::{AtomicU64, Ordering};
18
19/// A subsystem that reports its current non-cache memory footprint.
20pub trait NonCacheReporter: Send + Sync {
21    /// Short label surfaced on the budget tick (e.g. `reasoner`).
22    fn name(&self) -> &str;
23
24    /// Bytes this subsystem is currently holding. Best-effort — an
25    /// estimate is fine; the budget uses it for attribution, not
26    /// eviction.
27    fn current_bytes(&self) -> u64;
28}
29
30/// A [`NonCacheReporter`] backed by a shared atomic gauge.
31///
32/// The budget registers the `AtomicReporter`; the producing subsystem
33/// holds a [`MemoryGauge`] clone (via [`AtomicReporter::gauge`]) and
34/// updates it as its footprint changes. Construct one per subsystem
35/// instance (e.g. a parser or reasoner), register it, and thread
36/// the gauge into the subsystem.
37pub struct AtomicReporter {
38    name: String,
39    gauge: Arc<AtomicU64>,
40}
41
42impl AtomicReporter {
43    /// Create a reporter with a zeroed gauge.
44    #[must_use]
45    pub fn new(name: impl Into<String>) -> Self {
46        Self {
47            name: name.into(),
48            gauge: Arc::new(AtomicU64::new(0)),
49        }
50    }
51
52    /// Producer-side handle used to update the reported byte count.
53    #[must_use]
54    pub fn gauge(&self) -> MemoryGauge {
55        MemoryGauge {
56            inner: Arc::clone(&self.gauge),
57        }
58    }
59}
60
61impl NonCacheReporter for AtomicReporter {
62    fn name(&self) -> &str {
63        &self.name
64    }
65
66    fn current_bytes(&self) -> u64 {
67        self.gauge.load(Ordering::Relaxed)
68    }
69}
70
71/// Producer-side handle to an [`AtomicReporter`]'s gauge.
72///
73/// Cheap to clone (one `Arc`). Threaded into a subsystem so it can
74/// publish its live footprint. A [`MemoryGauge::disconnected`] gauge
75/// writes to a private atomic nobody reads — handy so subsystem code
76/// always has a gauge to write to even when not running under a
77/// registered budget (tests or applications without a budget).
78#[derive(Clone, Debug)]
79pub struct MemoryGauge {
80    inner: Arc<AtomicU64>,
81}
82
83impl MemoryGauge {
84    /// A gauge not wired to any reporter; updates go nowhere.
85    #[must_use]
86    pub fn disconnected() -> Self {
87        Self {
88            inner: Arc::new(AtomicU64::new(0)),
89        }
90    }
91
92    /// Replace the reported byte count.
93    pub fn set(&self, bytes: u64) {
94        self.inner.store(bytes, Ordering::Relaxed);
95    }
96
97    /// Add to the reported byte count.
98    pub fn add(&self, bytes: u64) {
99        self.inner.fetch_add(bytes, Ordering::Relaxed);
100    }
101
102    /// Subtract from the reported byte count, saturating at zero.
103    pub fn sub(&self, bytes: u64) {
104        let mut current = self.inner.load(Ordering::Relaxed);
105        loop {
106            let next = current.saturating_sub(bytes);
107            match self.inner.compare_exchange_weak(
108                current,
109                next,
110                Ordering::Relaxed,
111                Ordering::Relaxed,
112            ) {
113                Ok(_) => return,
114                Err(observed) => current = observed,
115            }
116        }
117    }
118
119    /// Current reported byte count.
120    #[must_use]
121    pub fn current(&self) -> u64 {
122        self.inner.load(Ordering::Relaxed)
123    }
124
125    /// RAII attribution: add `bytes` to the gauge now and subtract the
126    /// same amount when the returned [`GaugeGuard`] drops. Hold one
127    /// alongside a resident allocation to attribute it to this gauge's
128    /// reporter for exactly that allocation's lifetime. Uses `add`/`sub`
129    /// so concurrent guards on the same reporter compose into one total.
130    #[must_use]
131    pub fn guard(&self, bytes: u64) -> GaugeGuard {
132        self.add(bytes);
133        GaugeGuard {
134            gauge: self.clone(),
135            bytes,
136        }
137    }
138}
139
140/// RAII guard that publishes a fixed byte count to a [`MemoryGauge`]
141/// for its lifetime. Created by [`MemoryGauge::guard`]; `Drop` removes
142/// the contribution again.
143#[derive(Debug)]
144pub struct GaugeGuard {
145    gauge: MemoryGauge,
146    bytes: u64,
147}
148
149impl Drop for GaugeGuard {
150    fn drop(&mut self) {
151        self.gauge.sub(self.bytes);
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use std::sync::Arc;
158
159    use super::{AtomicReporter, NonCacheReporter};
160
161    #[test]
162    fn gauge_updates_are_visible_through_reporter() {
163        let reporter = AtomicReporter::new("reasoner");
164        let gauge = reporter.gauge();
165        assert_eq!(reporter.current_bytes(), 0);
166        gauge.add(1000);
167        assert_eq!(reporter.current_bytes(), 1000);
168        gauge.set(4096);
169        assert_eq!(reporter.current_bytes(), 4096);
170        gauge.sub(100);
171        assert_eq!(reporter.current_bytes(), 3996);
172        // Saturating subtraction never underflows.
173        gauge.sub(u64::MAX);
174        assert_eq!(reporter.current_bytes(), 0);
175    }
176
177    #[test]
178    fn gauge_guard_adds_then_subtracts_on_drop() {
179        let reporter = AtomicReporter::new("executor");
180        let gauge = reporter.gauge();
181        {
182            let _g = gauge.guard(1024);
183            assert_eq!(reporter.current_bytes(), 1024);
184            // Concurrent guards on the same reporter compose additively.
185            let _g2 = gauge.guard(512);
186            assert_eq!(reporter.current_bytes(), 1536);
187        }
188        assert_eq!(reporter.current_bytes(), 0);
189    }
190
191    #[test]
192    fn reporter_name_is_exposed() {
193        let reporter = AtomicReporter::new("reasoner");
194        let dyn_reporter: Arc<dyn NonCacheReporter> = Arc::new(reporter);
195        assert_eq!(dyn_reporter.name(), "reasoner");
196    }
197}