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 all observed memory: neither compression nor allocations
29        // outside jemalloc may conceal an overrun.
30        if let Some(ceiling) = self.state.config.hard_ceiling_bytes()
31            && snapshot.rss_bytes > ceiling
32        {
33            tracing::error!(
34                target: "memory_budget::budget",
35                rss_bytes = snapshot.rss_bytes,
36                rss_sysinfo_bytes = snapshot.rss_sysinfo_bytes,
37                ceiling_bytes = ceiling,
38                "hard ceiling exceeded — aborting process"
39            );
40            std::process::abort();
41        }
42        snapshot
43    }
44
45    /// Spawn a background tokio task that calls [`MemoryBudget::tick_now`]
46    /// at the configured cadence. Returns a handle that aborts the
47    /// task on drop.
48    pub fn spawn(&self) -> tokio::task::JoinHandle<()> {
49        let state = Arc::clone(&self.state);
50        let interval = state.config.tick();
51        tokio::spawn(async move {
52            let handle = MemoryBudget { state };
53            let mut ticker = tokio::time::interval(interval);
54            ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
55            loop {
56                ticker.tick().await;
57                handle.tick_now();
58            }
59        })
60    }
61
62    /// Apply one reapportionment. `rss_sysinfo_bytes` is the raw
63    /// sysinfo reading; the policy uses the maximum of OS process usage,
64    /// jemalloc residency and live allocated bytes.
65    fn run_tick(&self, rss_sysinfo_bytes: u64) -> BudgetSnapshot {
66        let effective_target = self
67            .state
68            .config
69            .target_bytes()
70            .saturating_sub(self.state.active_lease_bytes.load(Ordering::Relaxed));
71        let jemalloc = self.state.jemalloc.read();
72        let rss_bytes = jemalloc.observed_usage_bytes(rss_sysinfo_bytes);
73        let live = self.live_views();
74        let previous_cache_bytes = self.state.previous_cache_bytes.load(Ordering::Relaxed);
75        let previous_cache_count = self.state.previous_cache_count.load(Ordering::Relaxed) as u32;
76        let previous_rss_bytes = self.state.previous_rss_bytes.load(Ordering::Relaxed);
77        let input = PolicyInput {
78            effective_target_bytes: effective_target,
79            rss_bytes,
80            caches: live.views,
81            previous_cache_bytes,
82            previous_cache_count,
83            previous_rss_bytes,
84        };
85        let output = self.state.policy.reapportion(&input);
86        for (cache, new_max) in live.live_caches.iter().zip(output.new_max_bytes.iter()) {
87            cache.set_max_bytes(*new_max);
88        }
89        // Update the trend history *after* the policy has read it.
90        // Use the pre-reapportionment `current_bytes` since
91        // `set_max_bytes` above may have triggered eviction, and we
92        // want the next tick to compare against what this tick saw
93        // before its decision took effect.
94        let current_cache_bytes: u64 = input.caches.iter().map(|c| c.current_bytes).sum();
95        self.state
96            .previous_cache_bytes
97            .store(current_cache_bytes, Ordering::Relaxed);
98        self.state
99            .previous_cache_count
100            .store(input.caches.len() as u64, Ordering::Relaxed);
101        self.state
102            .previous_rss_bytes
103            .store(rss_bytes, Ordering::Relaxed);
104        let verdict_label = output.verdict.label();
105        let (verdict_streak, previous_verdict_label, previous_verdict_streak) =
106            self.advance_verdict_streak(verdict_label);
107        let reporters = self.live_reporters();
108        BudgetSnapshot {
109            target_bytes: self.state.config.target_bytes(),
110            effective_target_bytes: effective_target,
111            rss_bytes,
112            rss_sysinfo_bytes,
113            lease_bytes_active: self.state.active_lease_bytes.load(Ordering::Relaxed),
114            jemalloc,
115            verdict: output.verdict,
116            verdict_streak,
117            previous_verdict_label,
118            previous_verdict_streak,
119            reporters,
120            caches: input
121                .caches
122                .into_iter()
123                .zip(output.new_max_bytes)
124                .map(|(view, new_max)| CacheSnapshot {
125                    name: view.name,
126                    current_bytes: view.current_bytes,
127                    previous_max_bytes: view.max_bytes,
128                    new_max_bytes: new_max,
129                    hit_rate: view.stats.hit_rate(),
130                })
131                .collect(),
132        }
133    }
134
135    /// Update the verdict-streak run-length state for `label`. Returns
136    /// `(streak_now, previous_label, previous_streak)`. `previous_*`
137    /// are `Some` only when the label changed at this tick — i.e.
138    /// the previous run ended.
139    fn advance_verdict_streak(
140        &self,
141        label: &'static str,
142    ) -> (u32, Option<&'static str>, Option<u32>) {
143        let mut guard = self
144            .state
145            .verdict_streak
146            .lock()
147            .expect("verdict streak lock");
148        if guard.label == label && guard.length > 0 {
149            guard.length = guard.length.saturating_add(1);
150            (guard.length, None, None)
151        } else {
152            let prev_label = if guard.length > 0 {
153                Some(guard.label)
154            } else {
155                None
156            };
157            let prev_streak = if guard.length > 0 {
158                Some(guard.length)
159            } else {
160                None
161            };
162            guard.label = label;
163            guard.length = 1;
164            (1, prev_label, prev_streak)
165        }
166    }
167
168    /// Walk the weak registrations, drop dead entries, and produce
169    /// the parallel (Arc, view) lists used by the tick.
170    fn live_views(&self) -> LiveViews {
171        let mut guard = self.state.registrations.lock().expect("registrations lock");
172        let mut views = Vec::with_capacity(guard.len());
173        let mut live_caches: Vec<Arc<dyn Resizable>> = Vec::with_capacity(guard.len());
174        guard.retain(|reg| {
175            let Some(cache) = reg.cache.upgrade() else {
176                return false;
177            };
178            views.push(CacheView {
179                name: cache.name().to_owned(),
180                max_bytes: cache.max_bytes(),
181                preferred_max_bytes: reg.preferred_max_bytes,
182                current_bytes: cache.current_bytes(),
183                stats: cache.stats(),
184            });
185            live_caches.push(cache);
186            true
187        });
188        LiveViews { views, live_caches }
189    }
190
191    /// Walk the weak reporter registrations, drop dead entries, and
192    /// snapshot each live reporter's current byte count.
193    fn live_reporters(&self) -> Vec<ReporterSnapshot> {
194        let mut guard = self.state.reporters.lock().expect("reporters lock");
195        let mut out = Vec::with_capacity(guard.len());
196        guard.retain(|weak| {
197            let Some(reporter) = weak.upgrade() else {
198                return false;
199            };
200            out.push(ReporterSnapshot {
201                name: reporter.name().to_owned(),
202                current_bytes: reporter.current_bytes(),
203            });
204            true
205        });
206        out
207    }
208}