turbo_tasks_backend/backend/eviction.rs
1//! Eviction policy for the background snapshot loop.
2//!
3//! When the persistent cache is enabled, the backend periodically snapshots its
4//! in-memory state to disk. After a snapshot it may evict the evictable tasks
5//! from memory and reload them from disk on demand. [`EvictionControl`] decides
6//! whether each snapshot cycle should run such a sweep, based on the configured
7//! [`EvictionMode`].
8
9use std::sync::LazyLock;
10
11use turbo_tasks_malloc::TurboMalloc;
12
13/// Strategy for evicting evictable tasks from in-memory storage after a
14/// snapshot.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum EvictionMode {
17 /// Never evict.
18 Off,
19 /// Evict after a snapshot only once enough memory has been allocated since
20 /// the last eviction to justify the cost of restoring evicted tasks on
21 /// demand. Uses allocator statistics to estimate reclaimable memory.
22 /// See [`EvictionControl::auto_threshold_exceeded`].
23 Auto,
24 /// After every snapshot, evict all evictable tasks from memory, reloading
25 /// them from disk on demand.
26 Full,
27}
28
29/// Owns the eviction policy for the development time background snapshot loop: the configured
30/// [`EvictionMode`] plus the threshold bookkeeping for [`EvictionMode::Auto`].
31pub(crate) struct EvictionControl {
32 mode: EvictionMode,
33 /// The lowest global net live bytes ([`TurboMalloc::memory_usage`]) observed
34 /// since the most recent eviction, or `None` before the first eviction.
35 /// Only meaningful in [`EvictionMode::Auto`].
36 ///
37 /// Tracking the running minimum (rather than trusting the single post-eviction sample) matters
38 /// because memory can keep falling *after* a sweep returns typically because some other
39 /// threads are holding onto some Arc managed values that will `drop` once their temporary
40 /// holds are gone.
41 memory_floor: Option<usize>,
42}
43
44impl EvictionControl {
45 pub(crate) fn new(mode: EvictionMode) -> Self {
46 Self {
47 mode,
48 memory_floor: None,
49 }
50 }
51
52 /// Whether any prior cycle has evicted. Derived from the recorded baseline,
53 /// which [`EvictionControl::record_eviction`] sets after each sweep.
54 fn has_evicted_before(&self) -> bool {
55 self.memory_floor.is_some()
56 }
57
58 /// Whether to run an eviction sweep this snapshot cycle.
59 ///
60 /// `snapshot_had_new_data` is whether the just-completed snapshot persisted
61 /// new data. Used only by the `full` variant
62 ///
63 /// Within that, `Off` never evicts, `Full` always evicts, and `Auto` requires
64 /// enough net memory allocated since the last eviction to justify the
65 /// restore-then-re-evict churn (always evicting the first time, since there's
66 /// no prior baseline).
67 pub(crate) fn should_evict(&mut self, snapshot_had_new_data: bool) -> bool {
68 // Only evict when there's new data to persist, or on the very first
69 // eviction after startup (restored on-disk state can be reclaimed even
70 // when this snapshot had no new data).
71
72 match self.mode {
73 EvictionMode::Off => false,
74 EvictionMode::Full => {
75 // In full mode we only skip evicting if we didn't save anything and have already
76 // evicted
77 snapshot_had_new_data || !self.has_evicted_before()
78 }
79 EvictionMode::Auto => self.auto_threshold_exceeded(),
80 }
81 }
82
83 /// For [`EvictionMode::Auto`]: whether enough net memory has been allocated
84 /// since the last eviction to justify another sweep. Always evicts the first
85 /// time (no prior baseline). The threshold scales down under OS memory
86 /// pressure so we evict more eagerly when memory is tight.
87 fn auto_threshold_exceeded(&mut self) -> bool {
88 /// Minimum net bytes ([`TurboMalloc::memory_usage`] delta) that must be
89 /// allocated since the last eviction before another is worthwhile.
90 /// Allocated bytes are the proxy for how much a sweep would reclaim.
91 /// Default 128 MiB; overridable via `TURBO_ENGINE_EVICT_MIN_BYTES`.
92 static MIN_EVICT_BYTES: LazyLock<usize> = LazyLock::new(|| {
93 std::env::var("TURBO_ENGINE_EVICT_MIN_BYTES")
94 .ok()
95 .and_then(|s| {
96 let s = s.trim();
97 let lower = s.to_ascii_lowercase();
98 let (num, mult) = if let Some(n) = lower.strip_suffix('g') {
99 (n, 1024 * 1024 * 1024)
100 } else if let Some(n) = lower.strip_suffix('m') {
101 (n, 1024 * 1024)
102 } else if let Some(n) = lower.strip_suffix('k') {
103 (n, 1024)
104 } else {
105 (lower.as_str(), 1)
106 };
107 match num.trim().parse::<usize>() {
108 Ok(n) => Some(n * mult),
109 Err(e) => {
110 eprintln!(
111 "error: could not parse `TURBO_ENGINE_EVICT_MIN_BYTES` value: \
112 {e:?}"
113 );
114 None
115 }
116 }
117 })
118 .unwrap_or(128 * 1024 * 1024)
119 });
120
121 let current = TurboMalloc::memory_usage();
122 let threshold = scale_threshold(*MIN_EVICT_BYTES, TurboMalloc::memory_pressure());
123 let (lowered_floor, evict) = evaluate_threshold(self.memory_floor, current, threshold);
124 // Only a memory drop lowers the floor here; seeding it is left to
125 // `record_eviction` so a decision alone never counts as "has evicted".
126 if let Some(floor) = lowered_floor {
127 self.memory_floor = Some(floor);
128 }
129 evict
130 }
131
132 /// Call after completing an eviction cycle and after mimalloc is cleaned up
133 /// with [`TurboMalloc::collect`], since the freed memory is only reflected
134 /// in [`TurboMalloc::memory_usage`] once it has been. Seeds the memory floor
135 /// with the post-eviction usage; later cycles lower it further as memory
136 /// settles.
137 pub(crate) fn record_eviction(&mut self) {
138 self.memory_floor = Some(TurboMalloc::memory_usage());
139 }
140}
141
142/// The pure decision behind [`EvictionControl::auto_threshold_exceeded`], split
143/// out so it can be unit-tested without touching the allocator.
144fn evaluate_threshold(
145 floor: Option<usize>,
146 current: usize,
147 threshold: usize,
148) -> (Option<usize>, bool) {
149 match floor {
150 None => (None, true),
151 Some(floor) if current < floor => (Some(current), false),
152 Some(floor) => (None, current - floor >= threshold),
153 }
154}
155
156/// Scale a base eviction threshold down linearly with OS memory pressure.
157///
158/// `threshold = base * (1 - pressure / 100)`. At pressure 0 the base is
159/// unchanged; at pressure 100 the threshold is 0, so every cycle evicts (the
160/// `Auto` mode degrades to `Full` when memory is maxed out, reclaiming as much
161/// as possible). When pressure is unavailable (`None`) the base is returned
162/// unchanged.
163fn scale_threshold(base: usize, pressure: Option<u8>) -> usize {
164 match pressure {
165 Some(p) => (base as f64 * (1.0 - p.min(100) as f64 / 100.0)).round() as usize,
166 None => base,
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::{EvictionControl, EvictionMode, evaluate_threshold, scale_threshold};
173
174 const MIB: usize = 1024 * 1024;
175
176 #[test]
177 fn evaluate_threshold_first_eviction_always_runs() {
178 // No floor yet → evict; floor not seeded here (record_eviction does that).
179 assert_eq!(evaluate_threshold(None, 500 * MIB, 128 * MIB), (None, true));
180 }
181
182 #[test]
183 fn evaluate_threshold_growth_below_threshold_skips() {
184 // Grew 64 MiB since the floor, threshold is 128 MiB → skip, floor unchanged.
185 assert_eq!(
186 evaluate_threshold(Some(900 * MIB), 964 * MIB, 128 * MIB),
187 (None, false)
188 );
189 }
190
191 #[test]
192 fn evaluate_threshold_growth_at_threshold_evicts() {
193 // Grew exactly the threshold → evict, floor unchanged (record_eviction resets it).
194 assert_eq!(
195 evaluate_threshold(Some(900 * MIB), 1028 * MIB, 128 * MIB),
196 (None, true)
197 );
198 }
199
200 #[test]
201 fn evaluate_threshold_drop_below_floor_lowers_floor_and_skips() {
202 // Regression: after a sweep we recorded ~926 MiB, but memory kept falling
203 // to ~677 MiB. The floor must follow memory down and we must not evict.
204 assert_eq!(
205 evaluate_threshold(Some(926 * MIB), 677 * MIB, 56 * MIB),
206 (Some(677 * MIB), false)
207 );
208 }
209
210 #[test]
211 fn off_mode_never_evicts() {
212 let mut control = EvictionControl::new(EvictionMode::Off);
213 for &new_data in &[true, false] {
214 assert!(!control.should_evict(new_data));
215 // Even after a (forced) eviction, Off never evicts.
216 control.record_eviction();
217 assert!(!control.should_evict(new_data));
218 }
219 }
220
221 #[test]
222 fn full_mode_evicts_on_new_data() {
223 let mut control = EvictionControl::new(EvictionMode::Full);
224 // New data → always evict, before and after a prior eviction.
225 assert!(control.should_evict(true));
226 control.record_eviction();
227 assert!(control.should_evict(true));
228 }
229
230 #[test]
231 fn full_mode_evicts_first_time_without_new_data() {
232 let mut control = EvictionControl::new(EvictionMode::Full);
233 // No new data, but never evicted before → first eviction still runs.
234 assert!(control.should_evict(false));
235 // No new data and already evicted → skip.
236 control.record_eviction();
237 assert!(!control.should_evict(false));
238 }
239
240 #[test]
241 fn auto_mode_evicts_first_time() {
242 // Fresh control has no baseline, so the first eligible cycle always
243 // evicts regardless of the memory threshold.
244 let mut control = EvictionControl::new(EvictionMode::Auto);
245 assert!(control.should_evict(true));
246 assert!(control.should_evict(false));
247 // But still respects the new-data/first-time trigger once it has evicted.
248 control.record_eviction();
249 assert!(!control.should_evict(false));
250 }
251
252 #[test]
253 fn scale_threshold_behavior() {
254 assert_eq!(scale_threshold(100, Some(0)), 100);
255 assert_eq!(scale_threshold(100, Some(50)), 50);
256 assert_eq!(scale_threshold(100, Some(100)), 0);
257 // Pressure is documented as 0..=100; values above clamp to 100.
258 assert_eq!(scale_threshold(100, Some(200)), 0);
259 }
260
261 #[test]
262 fn scale_threshold_is_monotonic_non_increasing() {
263 let mut prev = scale_threshold(100, Some(0));
264 for p in 1..=100u8 {
265 let cur = scale_threshold(100, Some(p));
266 assert!(
267 cur <= prev,
268 "threshold should not increase with pressure: p={p}, cur={cur}, prev={prev}"
269 );
270 prev = cur;
271 }
272 }
273}