Skip to main content

memory_budget/budget/memory/
tick.rs

1//! The reapportionment tick loop and its supporting helpers.
2
3use std::sync::Arc;
4use std::sync::atomic::Ordering;
5
6use super::super::snapshot::{BudgetSnapshot, CacheSnapshot, ReporterSnapshot};
7use super::super::state::LiveViews;
8use super::handle::MemoryBudget;
9use crate::policy::{CacheView, PolicyInput};
10use crate::resizable::Resizable;
11
12impl MemoryBudget {
13    /// Run one reapportionment tick synchronously. Returns the
14    /// snapshot used for diagnostic emission. Exposed publicly so
15    /// tests can drive the budget without spawning a tokio task.
16    pub fn tick_now(&self) -> BudgetSnapshot {
17        let rss_sysinfo_bytes = self.state.rss.read_rss_bytes();
18        let snapshot = self.run_tick(rss_sysinfo_bytes);
19        self.emit(&snapshot);
20        // Capture a heap profile when resident RSS crosses the next
21        // threshold, so a `non_cache_other` balloon is attributed to its
22        // allocation site. Inert unless the `heap-profiling` build is
23        // armed via `MEMORY_BUDGET_HEAP_PROFILE_DIR`.
24        #[cfg(feature = "heap-profiling")]
25        if let Some(profiler) = self.state.heap_profiler.as_ref() {
26            profiler.maybe_dump(snapshot.rss_bytes);
27        }
28        // Check the ceiling against the *effective* RSS (jemalloc
29        // resident when present), not the sysinfo reading — on macOS
30        // the latter is compressed-footprint and would never trip a
31        // ceiling that the real allocation has already blown past.
32        if let Some(ceiling) = self.state.config.hard_ceiling_bytes()
33            && snapshot.rss_bytes > ceiling
34        {
35            tracing::error!(
36                target: "memory_budget::budget",
37                rss_bytes = snapshot.rss_bytes,
38                rss_sysinfo_bytes = snapshot.rss_sysinfo_bytes,
39                ceiling_bytes = ceiling,
40                "hard ceiling exceeded — aborting process"
41            );
42            std::process::abort();
43        }
44        snapshot
45    }
46
47    /// Spawn a background tokio task that calls [`MemoryBudget::tick_now`]
48    /// at the configured cadence. Returns a handle that aborts the
49    /// task on drop.
50    pub fn spawn(&self) -> tokio::task::JoinHandle<()> {
51        let state = Arc::clone(&self.state);
52        let interval = state.config.tick();
53        tokio::spawn(async move {
54            let handle = MemoryBudget { state };
55            let mut ticker = tokio::time::interval(interval);
56            ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
57            loop {
58                ticker.tick().await;
59                handle.tick_now();
60            }
61        })
62    }
63
64    /// Apply one reapportionment. `rss_sysinfo_bytes` is the raw
65    /// sysinfo reading; the policy runs off the **effective** RSS —
66    /// `jemalloc.resident_bytes` when jemalloc stats are present,
67    /// else the sysinfo value (see [`BudgetSnapshot::rss_bytes`]).
68    fn run_tick(&self, rss_sysinfo_bytes: u64) -> BudgetSnapshot {
69        let effective_target = self
70            .state
71            .config
72            .target_bytes()
73            .saturating_sub(self.state.active_lease_bytes.load(Ordering::Relaxed));
74        let jemalloc = self.state.jemalloc.read();
75        // Prefer jemalloc's resident estimate as the RSS signal: on
76        // macOS sysinfo's phys_footprint runs far below the real
77        // allocation (compression), which would leave the policy
78        // permanently in `grow`. Fall back to sysinfo when jemalloc
79        // stats are absent (non-jemalloc binaries / mallctl init
80        // failure → resident reads 0).
81        let rss_bytes = if jemalloc.resident_bytes > 0 {
82            jemalloc.resident_bytes
83        } else {
84            rss_sysinfo_bytes
85        };
86        let live = self.live_views();
87        let previous_cache_bytes = self.state.previous_cache_bytes.load(Ordering::Relaxed);
88        let previous_cache_count = self.state.previous_cache_count.load(Ordering::Relaxed) as u32;
89        let previous_rss_bytes = self.state.previous_rss_bytes.load(Ordering::Relaxed);
90        let input = PolicyInput {
91            effective_target_bytes: effective_target,
92            rss_bytes,
93            caches: live.views,
94            previous_cache_bytes,
95            previous_cache_count,
96            previous_rss_bytes,
97        };
98        let output = self.state.policy.reapportion(&input);
99        for (cache, new_max) in live.live_caches.iter().zip(output.new_max_bytes.iter()) {
100            cache.set_max_bytes(*new_max);
101        }
102        // Update the trend history *after* the policy has read it.
103        // Use the pre-reapportionment `current_bytes` since
104        // `set_max_bytes` above may have triggered eviction, and we
105        // want the next tick to compare against what this tick saw
106        // before its decision took effect.
107        let current_cache_bytes: u64 = input.caches.iter().map(|c| c.current_bytes).sum();
108        self.state
109            .previous_cache_bytes
110            .store(current_cache_bytes, Ordering::Relaxed);
111        self.state
112            .previous_cache_count
113            .store(input.caches.len() as u64, Ordering::Relaxed);
114        self.state
115            .previous_rss_bytes
116            .store(rss_bytes, Ordering::Relaxed);
117        let verdict_label = output.verdict.label();
118        let (verdict_streak, previous_verdict_label, previous_verdict_streak) =
119            self.advance_verdict_streak(verdict_label);
120        let reporters = self.live_reporters();
121        BudgetSnapshot {
122            target_bytes: self.state.config.target_bytes(),
123            effective_target_bytes: effective_target,
124            rss_bytes,
125            rss_sysinfo_bytes,
126            lease_bytes_active: self.state.active_lease_bytes.load(Ordering::Relaxed),
127            jemalloc,
128            verdict: output.verdict,
129            verdict_streak,
130            previous_verdict_label,
131            previous_verdict_streak,
132            reporters,
133            caches: input
134                .caches
135                .into_iter()
136                .zip(output.new_max_bytes)
137                .map(|(view, new_max)| CacheSnapshot {
138                    name: view.name,
139                    current_bytes: view.current_bytes,
140                    previous_max_bytes: view.max_bytes,
141                    new_max_bytes: new_max,
142                    hit_rate: view.stats.hit_rate(),
143                })
144                .collect(),
145        }
146    }
147
148    /// Update the verdict-streak run-length state for `label`. Returns
149    /// `(streak_now, previous_label, previous_streak)`. `previous_*`
150    /// are `Some` only when the label changed at this tick — i.e.
151    /// the previous run ended.
152    fn advance_verdict_streak(
153        &self,
154        label: &'static str,
155    ) -> (u32, Option<&'static str>, Option<u32>) {
156        let mut guard = self
157            .state
158            .verdict_streak
159            .lock()
160            .expect("verdict streak lock");
161        if guard.label == label && guard.length > 0 {
162            guard.length = guard.length.saturating_add(1);
163            (guard.length, None, None)
164        } else {
165            let prev_label = if guard.length > 0 {
166                Some(guard.label)
167            } else {
168                None
169            };
170            let prev_streak = if guard.length > 0 {
171                Some(guard.length)
172            } else {
173                None
174            };
175            guard.label = label;
176            guard.length = 1;
177            (1, prev_label, prev_streak)
178        }
179    }
180
181    /// Walk the weak registrations, drop dead entries, and produce
182    /// the parallel (Arc, view) lists used by the tick.
183    fn live_views(&self) -> LiveViews {
184        let mut guard = self.state.registrations.lock().expect("registrations lock");
185        let mut views = Vec::with_capacity(guard.len());
186        let mut live_caches: Vec<Arc<dyn Resizable>> = Vec::with_capacity(guard.len());
187        guard.retain(|reg| {
188            let Some(cache) = reg.cache.upgrade() else {
189                return false;
190            };
191            views.push(CacheView {
192                name: cache.name().to_owned(),
193                max_bytes: cache.max_bytes(),
194                preferred_max_bytes: reg.preferred_max_bytes,
195                current_bytes: cache.current_bytes(),
196                stats: cache.stats(),
197            });
198            live_caches.push(cache);
199            true
200        });
201        LiveViews { views, live_caches }
202    }
203
204    /// Walk the weak reporter registrations, drop dead entries, and
205    /// snapshot each live reporter's current byte count.
206    fn live_reporters(&self) -> Vec<ReporterSnapshot> {
207        let mut guard = self.state.reporters.lock().expect("reporters lock");
208        let mut out = Vec::with_capacity(guard.len());
209        guard.retain(|weak| {
210            let Some(reporter) = weak.upgrade() else {
211                return false;
212            };
213            out.push(ReporterSnapshot {
214                name: reporter.name().to_owned(),
215                current_bytes: reporter.current_bytes(),
216            });
217            true
218        });
219        out
220    }
221}