Skip to main content

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}