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. Seeds the memory floor with the
133 /// post-eviction usage; later cycles lower it further as memory settles.
134 pub(crate) fn record_eviction(&mut self) {
135 self.memory_floor = Some(TurboMalloc::memory_usage());
136 }
137}
138
139/// The pure decision behind [`EvictionControl::auto_threshold_exceeded`], split
140/// out so it can be unit-tested without touching the allocator.
141fn evaluate_threshold(
142 floor: Option<usize>,
143 current: usize,
144 threshold: usize,
145) -> (Option<usize>, bool) {
146 match floor {
147 None => (None, true),
148 Some(floor) if current < floor => (Some(current), false),
149 Some(floor) => (None, current - floor >= threshold),
150 }
151}
152
153/// Scale a base eviction threshold down linearly with OS memory pressure.
154///
155/// `threshold = base * (1 - pressure / 100)`. At pressure 0 the base is
156/// unchanged; at pressure 100 the threshold is 0, so every cycle evicts (the
157/// `Auto` mode degrades to `Full` when memory is maxed out, reclaiming as much
158/// as possible). When pressure is unavailable (`None`) the base is returned
159/// unchanged.
160fn scale_threshold(base: usize, pressure: Option<u8>) -> usize {
161 match pressure {
162 Some(p) => (base as f64 * (1.0 - p.min(100) as f64 / 100.0)).round() as usize,
163 None => base,
164 }
165}
166
167#[cfg(test)]
168mod tests {
169 use super::{EvictionControl, EvictionMode, evaluate_threshold, scale_threshold};
170
171 const MIB: usize = 1024 * 1024;
172
173 #[test]
174 fn evaluate_threshold_first_eviction_always_runs() {
175 // No floor yet → evict; floor not seeded here (record_eviction does that).
176 assert_eq!(evaluate_threshold(None, 500 * MIB, 128 * MIB), (None, true));
177 }
178
179 #[test]
180 fn evaluate_threshold_growth_below_threshold_skips() {
181 // Grew 64 MiB since the floor, threshold is 128 MiB → skip, floor unchanged.
182 assert_eq!(
183 evaluate_threshold(Some(900 * MIB), 964 * MIB, 128 * MIB),
184 (None, false)
185 );
186 }
187
188 #[test]
189 fn evaluate_threshold_growth_at_threshold_evicts() {
190 // Grew exactly the threshold → evict, floor unchanged (record_eviction resets it).
191 assert_eq!(
192 evaluate_threshold(Some(900 * MIB), 1028 * MIB, 128 * MIB),
193 (None, true)
194 );
195 }
196
197 #[test]
198 fn evaluate_threshold_drop_below_floor_lowers_floor_and_skips() {
199 // Regression: after a sweep we recorded ~926 MiB, but memory kept falling
200 // to ~677 MiB. The floor must follow memory down and we must not evict.
201 assert_eq!(
202 evaluate_threshold(Some(926 * MIB), 677 * MIB, 56 * MIB),
203 (Some(677 * MIB), false)
204 );
205 }
206
207 #[test]
208 fn off_mode_never_evicts() {
209 let mut control = EvictionControl::new(EvictionMode::Off);
210 for &new_data in &[true, false] {
211 assert!(!control.should_evict(new_data));
212 // Even after a (forced) eviction, Off never evicts.
213 control.record_eviction();
214 assert!(!control.should_evict(new_data));
215 }
216 }
217
218 #[test]
219 fn full_mode_evicts_on_new_data() {
220 let mut control = EvictionControl::new(EvictionMode::Full);
221 // New data → always evict, before and after a prior eviction.
222 assert!(control.should_evict(true));
223 control.record_eviction();
224 assert!(control.should_evict(true));
225 }
226
227 #[test]
228 fn full_mode_evicts_first_time_without_new_data() {
229 let mut control = EvictionControl::new(EvictionMode::Full);
230 // No new data, but never evicted before → first eviction still runs.
231 assert!(control.should_evict(false));
232 // No new data and already evicted → skip.
233 control.record_eviction();
234 assert!(!control.should_evict(false));
235 }
236
237 #[test]
238 fn auto_mode_evicts_first_time() {
239 // Fresh control has no baseline, so the first eligible cycle always
240 // evicts regardless of the memory threshold.
241 let mut control = EvictionControl::new(EvictionMode::Auto);
242 assert!(control.should_evict(true));
243 assert!(control.should_evict(false));
244 // But still respects the new-data/first-time trigger once it has evicted.
245 control.record_eviction();
246 assert!(!control.should_evict(false));
247 }
248
249 #[test]
250 fn scale_threshold_behavior() {
251 assert_eq!(scale_threshold(100, Some(0)), 100);
252 assert_eq!(scale_threshold(100, Some(50)), 50);
253 assert_eq!(scale_threshold(100, Some(100)), 0);
254 // Pressure is documented as 0..=100; values above clamp to 100.
255 assert_eq!(scale_threshold(100, Some(200)), 0);
256 }
257
258 #[test]
259 fn scale_threshold_is_monotonic_non_increasing() {
260 let mut prev = scale_threshold(100, Some(0));
261 for p in 1..=100u8 {
262 let cur = scale_threshold(100, Some(p));
263 assert!(
264 cur <= prev,
265 "threshold should not increase with pressure: p={p}, cur={cur}, prev={prev}"
266 );
267 prev = cur;
268 }
269 }
270}