Skip to main content

turbo_tasks_backend/backend/
gc.rs

1//! Garbage collection for the persistent backend.
2//!
3//! GC identifies and tears down tasks that have no reverse references using the `parent_count` and
4//! `transient_ref_count`. Tasks are marked `deleted` and then have their outgoing edges teared down
5//! recursively.
6//!
7//! A collected task also has its cell data released immediately to deliver immediate memory wins.
8//!
9//! The pass runs under the coordinator's exclusion phase (see
10//! [`SnapshotCoordinator::begin_exclusion`](crate::backend::snapshot_coordinator)), which excludes
11//! normal operations. That exclusion is what lets a pass edit the graph without racing a mutation
12//! that could resurrect a task mid-collect, and hand its decisions straight to persistence: the
13//! same guard stays held across the snapshot that writes the tombstones.
14//!
15//! A pass has two phases: a fully parallel, unbounded job pool that tears down garbage, followed by
16//! a single scan that classifies GC roots once the graph is quiescent (see
17//! [`TurboTasksBackend::gc_collect`]).
18
19use std::{
20    fmt::Display,
21    ops::ControlFlow,
22    sync::atomic::{AtomicBool, Ordering},
23    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
24};
25
26use bincode::{Decode, Encode};
27use rustc_hash::{FxHashMap, FxHashSet};
28use turbo_tasks::{TaskId, TurboTasks, scope_unbounded::scope_unbounded_with};
29
30use crate::{
31    backend::{
32        TurboTasksBackend,
33        operation::{
34            AggregationUpdateQueue, ExecuteContext, capture_all_edges,
35            cleanup_old_edges_deletions_only,
36        },
37        snapshot_coordinator::SnapshotPhase,
38        storage::TaskDataCategory,
39        storage_schema::TaskStorageAccessors,
40    },
41    backing_storage::SnapshotItem,
42};
43
44/// How long a GC root may go un-anchored before it is collected.
45/// Default to 3 days so that a root that is at least occasionally used can survive a weekend.
46///
47/// Aging out roots solves the problem of missing `gc_unpin` calls.  We can miss them for structural
48/// reasons, bugs or just shutdown races (drops from native threads race with turbopack shutdown).
49/// So using a TTL to track roots that haven't shown up in new sessions we can solve this leak.
50///
51/// The TTL counter is serving as a check for both new sessions and time.  To be aged out you get
52/// one session to start the clock and then eventually the timer expires.  This is intentionally
53/// course.
54pub(crate) const DEFAULT_GC_ROOT_TTL: Duration = Duration::from_secs(3 * 24 * 60 * 60);
55
56/// How long a GC root has gone without being observed live, as stored in the persisted roots map.
57#[derive(Encode, Decode, Clone, Copy, PartialEq, Eq, Debug)]
58pub enum TtlCounter {
59    /// Observed live (a durable, anchored root) in the most recent session.
60    MostRecent,
61    /// System time millis at which a session's **first** GC pass first found this root not live.
62    FirstStale(u64),
63}
64
65/// One unit of GC work.
66enum GcJob {
67    /// Scan one shard of the resident map (by index) and enqueue its candidates as
68    /// [`GcJob::Collect`].
69    ScanShard(usize),
70    /// Collect a single task.
71    Collect(TaskId),
72}
73
74/// Decides when a GC pass should stop early because it is delaying real work.
75struct GcBudget<'a> {
76    phase: &'a SnapshotPhase<'a>,
77    started: Instant,
78    /// The minimum quantum of work this pass does before any interrupt is honoured.
79    min_progress: Duration,
80    /// Latched on the first trip.
81    stopped: AtomicBool,
82}
83
84impl GcBudget<'_> {
85    fn should_stop(&self) -> bool {
86        // We only stop if someone is waiting and we have already run for at least our
87        // `min_progress` To make querying the clock and phase cheaper we record it as a
88        // durable decision.
89        if self.stopped.load(Ordering::Relaxed) {
90            return true;
91        }
92        if !self.phase.operations_waiting() {
93            return false;
94        }
95        if self.started.elapsed() < self.min_progress {
96            return false;
97        }
98        // If we get here then there is an operation waiting _and_ we have already executed for at
99        // least our min_progress duration
100        self.stopped.store(true, Ordering::Relaxed);
101        true
102    }
103
104    fn was_interrupted(&self) -> bool {
105        self.stopped.load(Ordering::Relaxed)
106    }
107}
108
109/// Counters describing what one `TurboTasksBackend::gc_collect` pass did. Reported only; GC
110/// never reads them back.
111#[derive(Default)]
112pub struct GcStats {
113    /// Number of roots detected by the pass
114    pub gc_roots: usize,
115    /// Tasks collected (marked soft-deleted).
116    pub collected: usize,
117    /// Edges torn down across all collected tasks (children + forward-dependency reverse edges).
118    pub edges_deleted: usize,
119    /// Cross-session roots that aged out past the TTL.
120    pub aged_out_roots: usize,
121}
122
123/// What one `TurboTasksBackend::gc_collect` pass produced, as opposed to the [`GcStats`] it
124/// reports. The two collections are consumed before `gc_collect` returns; `interrupted` outlives
125/// it, since the caller uses it to decide whether to abandon the persistence loop.
126#[derive(Default)]
127pub struct GcPassResult {
128    /// Persisted roots that this pass collected, to be dropped from the roots map.
129    deleted_roots: Vec<TaskId>,
130    /// Aggregation rebalance requests that were deferred from the main GC loop.
131    deferred_balance_edges: FxHashSet<(TaskId, TaskId)>,
132    /// The gc loop was interrupted by competing work.
133    pub interrupted: bool,
134}
135
136impl Display for GcStats {
137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        write!(
139            f,
140            "gc_roots = {gc_roots}, collected: {collected}, edges_deleted: {edges_deleted}, \
141             aged_out_roots = {aged_out_roots}",
142            gc_roots = self.gc_roots,
143            collected = self.collected,
144            edges_deleted = self.edges_deleted,
145            aged_out_roots = self.aged_out_roots,
146        )
147    }
148}
149
150impl GcStats {
151    fn merge(mut self, other: Self) -> Self {
152        self.collected += other.collected;
153        self.edges_deleted += other.edges_deleted;
154        self.gc_roots += other.gc_roots;
155        self.aged_out_roots += other.aged_out_roots;
156        self
157    }
158}
159
160impl GcPassResult {
161    fn merge(mut self, mut other: Self) -> Self {
162        // Order doesn't matter, so keep the larger allocation and append the smaller one into it.
163        // One or both sides are usually empty.
164        if other.deleted_roots.len() > self.deleted_roots.len() {
165            other.deleted_roots.append(&mut self.deleted_roots);
166            self.deleted_roots = other.deleted_roots;
167        } else {
168            self.deleted_roots.append(&mut other.deleted_roots);
169        }
170        // merge into the larger set and keep that one
171        if other.deferred_balance_edges.len() > self.deferred_balance_edges.len() {
172            std::mem::swap(
173                &mut self.deferred_balance_edges,
174                &mut other.deferred_balance_edges,
175            );
176        }
177        self.deferred_balance_edges
178            .extend(other.deferred_balance_edges);
179        self.interrupted |= other.interrupted;
180        self
181    }
182}
183
184impl TurboTasksBackend {
185    /// Collect all garbage from the task-cache
186    ///
187    /// `phase` is the held exclusion; it is the caller's proof that no operation is running, which
188    /// is what makes it safe to mutate the graph here.
189    ///
190    /// `interruptible` controls whether we should abandon GC if other tasks are waiting to run.
191    /// Abandonment is controlled by [`GcBudget`] which ensures we can make a minimum amount of
192    /// progress even under load.
193    ///
194    /// Returns the [`GcStats`] and [`GcPassResult`] for the pass, and the new roots to persist if
195    /// any
196    pub(crate) fn gc_collect(
197        &self,
198        turbo_tasks: &TurboTasks<TurboTasksBackend>,
199        phase: &SnapshotPhase<'_>,
200        interruptible: bool,
201    ) -> (GcStats, GcPassResult, Option<Vec<(TaskId, TtlCounter)>>) {
202        // Record the time at the beginning of the loop to have a consistent timestamp for the roots
203        let now = SystemTime::now()
204            .duration_since(UNIX_EPOCH)
205            .map(|d| d.as_millis() as u64)
206            .unwrap_or(0);
207
208        let mut roots = self
209            .backing_storage
210            .roots()
211            .expect("reading gc roots should not fail")
212            .into_iter()
213            .collect::<FxHashMap<TaskId, TtlCounter>>();
214        let roots_before = roots.clone();
215
216        let aged_out = self.gc_roots_refresh_and_age_out(&mut roots, now);
217
218        let aged_out_count = aged_out.len();
219        // TODO(perf): recycle the task ids of collected tasks.
220        let budget = if interruptible {
221            Some(GcBudget {
222                phase,
223                started: Instant::now(),
224                min_progress: self.gc_min_progress,
225                stopped: AtomicBool::new(false),
226            })
227        } else {
228            None
229        };
230
231        let (mut stats, mut result): (GcStats, GcPassResult) = scope_unbounded_with(
232            // Start by scanning all shards and collecting the aged out roots from prior sessions.
233            (0..self.storage.shard_count())
234                .map(GcJob::ScanShard)
235                .chain(aged_out.into_iter().map(GcJob::Collect)),
236            Default::default,
237            |spawner, job, (stats, result): &mut (GcStats, GcPassResult)| {
238                // Abort the gc loop if we are interrupted
239                if let Some(budget) = &budget
240                    && budget.should_stop()
241                {
242                    return ControlFlow::Break(());
243                }
244                let task_id = match job {
245                    GcJob::ScanShard(index) => {
246                        let collector = |task_id| spawner.spawn(GcJob::Collect(task_id));
247                        self.storage.gc_scan_shard(index, collector);
248                        return ControlFlow::Continue(());
249                    }
250                    GcJob::Collect(task_id) => task_id,
251                };
252                let collector = |child_id| spawner.spawn(GcJob::Collect(child_id));
253                let mut ctx = ExecuteContext::new_for_gc(self, turbo_tasks, phase, &collector);
254                // `All` restores Data so `capture_all_edges` below can read the
255                // Data-category dependency sets. The recheck itself only needs Meta.
256                let mut task = ctx.task(task_id, TaskDataCategory::All);
257                // Recheck under the guard: the shard scan saw this task without holding it, and a
258                // racing teardown can add uppers that temporarily remove collectibility. Such a
259                // task is re-enqueued by a later pass.
260                if !task.is_gc_collectible() {
261                    return ControlFlow::Continue(());
262                }
263
264                let old_edges = capture_all_edges(&task);
265                // Clear `immutable` defensively so `resurrect_deleted` can mark the task dirty if
266                // it needs to
267                task.set_immutable(false);
268                // Drop the whole cell payload. This recovers most of the RAM while persistence
269                // writes the tombstone.
270                drop(task.take_cell_data());
271                task.set_deleted(true);
272                drop(task); // drop the lock so edge cleanup can run
273                stats.collected += 1;
274                stats.edges_deleted += old_edges.len();
275                // If we happened to delete a known root at this point record it so we can reconcile
276                // later.
277                if roots.contains_key(&task_id) {
278                    result.deleted_roots.push(task_id);
279                }
280                // Delete outgoing edges but don't update the aggregation graph yet.
281                // To avoid accidentally rebalancing on deleted tasks due to racing deletions,
282                // we defer all rebalancing to the end
283                let deferred = cleanup_old_edges_deletions_only(task_id, old_edges, &mut ctx);
284                result.deferred_balance_edges.extend(deferred.balance_edges);
285                ControlFlow::Continue(())
286            },
287            |(stats, result), (other_stats, other_result)| {
288                (stats.merge(other_stats), result.merge(other_result))
289            },
290        );
291
292        // Drop the entries for the roots this pass collected, recorded as they were deleted.
293        for id in &result.deleted_roots {
294            roots.remove(id);
295        }
296
297        // Process the rebalance requests now that deletion work is done
298        // Do this before computing roots so all uppers are settled
299        let deferred = std::mem::take(&mut result.deferred_balance_edges);
300        if !deferred.is_empty() {
301            let noop_collector = |_task_id| {};
302            let mut ctx = ExecuteContext::new_for_gc(self, turbo_tasks, phase, &noop_collector);
303            let mut queue = AggregationUpdateQueue::new();
304            queue.extend_balance_edges(deferred, &mut ctx);
305            queue.execute(&mut ctx);
306        }
307
308        // Collect all active roots
309        // We don't do this in the GC pass because a task detected as a root 'early' might become a
310        // non-root later due to other operations (e.g. it might get promoted to a live aggregation
311        // root).
312        for id in self.storage.gc_scan_roots() {
313            roots.insert(id, TtlCounter::MostRecent);
314        }
315
316        stats.gc_roots = roots.len();
317        stats.aged_out_roots = aged_out_count;
318        result.interrupted = budget
319            .as_ref()
320            .is_some_and(|budget| budget.was_interrupted());
321
322        // Only persist the roots map if it actually changed
323        let roots_to_persist: Option<Vec<_>> =
324            (roots != roots_before).then(|| roots.into_iter().collect());
325        (stats, result, roots_to_persist)
326    }
327
328    /// Compute which persisted roots have expired their TTL
329    /// Also
330    /// - start the staleness clock for roots that are no longer resident
331    /// - drop roots that are resident (the GC pass will pass judgement)
332    fn gc_roots_refresh_and_age_out(
333        &self,
334        map: &mut FxHashMap<TaskId, TtlCounter>,
335        now: u64,
336    ) -> Vec<TaskId> {
337        let ttl_ms = self.gc_root_ttl.as_millis() as u64;
338
339        let mut aged_out = Vec::new();
340        map.retain(|id, counter| {
341            if self.storage.with_task(*id, |_| ()).is_some() {
342                // Resident: `gc_scan_roots` decides. Drop it either way.
343                return false;
344            }
345            match *counter {
346                TtlCounter::MostRecent => {
347                    // It was recent in the last pass but not this one. Start the clock
348                    *counter = TtlCounter::FirstStale(now);
349                    true
350                }
351                TtlCounter::FirstStale(since) => {
352                    if now.saturating_sub(since) > ttl_ms {
353                        // Enqueue for collection, which restores it from disk and attempts the
354                        // delete. Dropped from the map: see the note above.
355                        aged_out.push(*id);
356                        false
357                    } else {
358                        true
359                    }
360                }
361            }
362        });
363
364        aged_out
365    }
366
367    pub(super) fn pin_task_for_gc(
368        &self,
369        task: TaskId,
370        turbo_tasks: &TurboTasks<TurboTasksBackend>,
371    ) {
372        self.gc_update_pin(task, 1, "pin_task_for_gc", turbo_tasks);
373    }
374
375    pub(super) fn unpin_task_for_gc(
376        &self,
377        task: TaskId,
378        turbo_tasks: &TurboTasks<TurboTasksBackend>,
379    ) {
380        self.gc_update_pin(task, -1, "unpin_task_for_gc", turbo_tasks);
381    }
382
383    /// Applies `delta` to a task's `transient_ref_count`
384    fn gc_update_pin(
385        &self,
386        task: TaskId,
387        delta: i32,
388        op: &'static str,
389        turbo_tasks: &TurboTasks<TurboTasksBackend>,
390    ) {
391        // We expect this call to come from outside a turbo-task context, at least sometimes
392        // So be defensive about conostructing a context.  If we get none then we are shutting down
393        // and it is too late for ref-counting.
394        let Some(mut ctx) = self.try_execute_context(turbo_tasks) else {
395            return;
396        };
397        // Technically we only need to manipulate transient data so meta is overkill. But the task
398        // must be resident if we are adding a pin so this isn't wasteful
399        let mut task = ctx.task(task, TaskDataCategory::Meta);
400        task.assert_not_deleted(op);
401        task.update_and_get_transient_ref_count(delta);
402    }
403
404    /// Runs a full GC pass under the GC phase and returns the number of tasks collected.
405    #[doc(hidden)]
406    pub fn gc_for_testing(&self, turbo_tasks: &TurboTasks<TurboTasksBackend>) -> usize {
407        // A pass sets `deleted` flags, and the persist path only knows how to tombstone those when
408        // GC is enabled. Running a pass on a GC-disabled backend would leave soft-deleted tasks
409        // that persistence refuses to handle, so require the backend to be configured for GC
410        // (`BackendOptions::gc`) rather than silently diverging from production.
411        assert!(
412            self.gc_enabled,
413            "gc_for_testing requires a GC-enabled backend: set `BackendOptions::gc = Some(true)`"
414        );
415        let _serialize = self.snapshot_in_progress.lock();
416        let phase = self
417            .snapshot_coord
418            .begin_snapshot(|slow| Self::report_slow_settle_for_snapshot(turbo_tasks, slow));
419        let (stats, _result, roots) =
420            self.gc_collect(turbo_tasks, &phase, /* interruptible= */ false);
421
422        // Persist the roots map this pass produced. Some tests query the roots set and GC itself
423        // does as well, this ensures it is available to the next cycle.
424        if let Some(roots) = roots
425            && let Err(err) = self
426                .backing_storage
427                .save_snapshot(Some(roots), Vec::<Vec<SnapshotItem>>::new())
428        {
429            panic!("gc_for_testing: failed to persist GC roots: {err:?}");
430        }
431        stats.collected
432    }
433}