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        AnyOperation, TurboTasksBackend,
33        operation::{
34            AggregationUpdateJob, AggregationUpdateQueue, CleanupOldEdgesOperation, ExecuteContext,
35            ExecuteContextImpl, TaskGuard, capture_all_edges,
36        },
37        snapshot_coordinator::SnapshotPhase,
38        storage::{SpecificTaskDataCategory, 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, AnyOperation>,
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    /// Dependents of collected tasks whose forward edge was scrubbed, deferred from the main GC
133    /// loop. Dirtying propagates through the aggregation graph, so it must not race the cascade.
134    deferred_dirty_dependents: FxHashSet<TaskId>,
135    /// The gc loop was interrupted by competing work.
136    pub interrupted: bool,
137}
138
139impl Display for GcStats {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        write!(
142            f,
143            "gc_roots = {gc_roots}, collected: {collected}, edges_deleted: {edges_deleted}, \
144             aged_out_roots = {aged_out_roots}",
145            gc_roots = self.gc_roots,
146            collected = self.collected,
147            edges_deleted = self.edges_deleted,
148            aged_out_roots = self.aged_out_roots,
149        )
150    }
151}
152
153impl GcStats {
154    fn merge(mut self, other: Self) -> Self {
155        self.collected += other.collected;
156        self.edges_deleted += other.edges_deleted;
157        self.gc_roots += other.gc_roots;
158        self.aged_out_roots += other.aged_out_roots;
159        self
160    }
161}
162
163impl GcPassResult {
164    fn merge(mut self, mut other: Self) -> Self {
165        // Order doesn't matter, so keep the larger allocation and append the smaller one into it.
166        // One or both sides are usually empty.
167        if other.deleted_roots.len() > self.deleted_roots.len() {
168            other.deleted_roots.append(&mut self.deleted_roots);
169            self.deleted_roots = other.deleted_roots;
170        } else {
171            self.deleted_roots.append(&mut other.deleted_roots);
172        }
173        // merge into the larger set and keep that one
174        if other.deferred_balance_edges.len() > self.deferred_balance_edges.len() {
175            std::mem::swap(
176                &mut self.deferred_balance_edges,
177                &mut other.deferred_balance_edges,
178            );
179        }
180        self.deferred_balance_edges
181            .extend(other.deferred_balance_edges);
182        // merge into the larger set and keep that one
183        if other.deferred_dirty_dependents.len() > self.deferred_dirty_dependents.len() {
184            std::mem::swap(
185                &mut self.deferred_dirty_dependents,
186                &mut other.deferred_dirty_dependents,
187            );
188        }
189        self.deferred_dirty_dependents
190            .extend(other.deferred_dirty_dependents);
191        self.interrupted |= other.interrupted;
192        self
193    }
194}
195
196impl TurboTasksBackend {
197    /// Collect all garbage from the task-cache
198    ///
199    /// `phase` is the held exclusion; it is the caller's proof that no operation is running, which
200    /// is what makes it safe to mutate the graph here.
201    ///
202    /// `interruptible` controls whether we should abandon GC if other tasks are waiting to run.
203    /// Abandonment is controlled by [`GcBudget`] which ensures we can make a minimum amount of
204    /// progress even under load.
205    ///
206    /// Returns the [`GcStats`] and [`GcPassResult`] for the pass, and the new roots to persist if
207    /// any
208    pub(crate) fn gc_collect(
209        &self,
210        turbo_tasks: &TurboTasks<TurboTasksBackend>,
211        phase: &SnapshotPhase<'_, AnyOperation>,
212        interruptible: bool,
213    ) -> (GcStats, GcPassResult, Option<Vec<(TaskId, TtlCounter)>>) {
214        // Record the time at the beginning of the loop to have a consistent timestamp for the roots
215        let now = SystemTime::now()
216            .duration_since(UNIX_EPOCH)
217            .map(|d| d.as_millis() as u64)
218            .unwrap_or(0);
219
220        let mut roots = self
221            .backing_storage
222            .roots()
223            .expect("reading gc roots should not fail")
224            .into_iter()
225            .collect::<FxHashMap<TaskId, TtlCounter>>();
226        let roots_before = roots.clone();
227
228        let aged_out = self.gc_roots_refresh_and_age_out(&mut roots, now);
229
230        let aged_out_count = aged_out.len();
231        // TODO(perf): recycle the task ids of collected tasks.
232        let budget = if interruptible {
233            Some(GcBudget {
234                phase,
235                started: Instant::now(),
236                min_progress: self.gc_min_progress,
237                stopped: AtomicBool::new(false),
238            })
239        } else {
240            None
241        };
242
243        let (mut stats, mut result): (GcStats, GcPassResult) = scope_unbounded_with(
244            // Start by scanning all shards and collecting the aged out roots from prior sessions.
245            (0..self.storage.shard_count())
246                .map(GcJob::ScanShard)
247                .chain(aged_out.into_iter().map(GcJob::Collect)),
248            Default::default,
249            |spawner, job, (stats, result): &mut (GcStats, GcPassResult)| {
250                // Abort the gc loop if we are interrupted
251                if let Some(budget) = &budget
252                    && budget.should_stop()
253                {
254                    return ControlFlow::Break(());
255                }
256                let task_id = match job {
257                    GcJob::ScanShard(index) => {
258                        let collector = |task_id| spawner.spawn(GcJob::Collect(task_id));
259                        self.storage.gc_scan_shard(index, collector);
260                        return ControlFlow::Continue(());
261                    }
262                    GcJob::Collect(task_id) => task_id,
263                };
264                let collector = |child_id| spawner.spawn(GcJob::Collect(child_id));
265                let mut ctx = ExecuteContextImpl::new_for_gc(self, turbo_tasks, phase, &collector);
266                // `All` restores Data so `capture_all_edges` below can read the
267                // Data-category dependency sets. The recheck itself only needs Meta.
268                let mut task = ctx.task(task_id, TaskDataCategory::All);
269                // Recheck under the guard: the shard scan saw this task without holding it, and a
270                // racing teardown can add uppers that temporarily remove collectibility. Such a
271                // task is re-enqueued by a later pass.
272                if !task.is_gc_collectible() {
273                    return ControlFlow::Continue(());
274                }
275
276                let old_edges = capture_all_edges(&task);
277                // Clear `immutable` defensively so `resurrect_deleted` can mark the task dirty if
278                // it needs to
279                task.set_immutable(false);
280                // Drop the whole cell payload. This recovers most of the RAM while persistence
281                // writes the tombstone.
282                drop(task.take_cell_data());
283                task.set_deleted(true);
284                if task.new_task() {
285                    task.discard_modifications_for_gc_new_task();
286                } else {
287                    // Persisted ensure it is marked modified so the next snapshot tombstones it.
288                    // It is almost certainly already marked modified, so this is mostly a no-op.
289                    let _ = task.track_modification(SpecificTaskDataCategory::Meta, "gc_deleted");
290                }
291                drop(task); // drop the lock so CleanupOldEdgesOperation can run
292                stats.collected += 1;
293                stats.edges_deleted += old_edges.len();
294                // If we happened to delete a known root at this point record it so we can reconcile
295                // later.
296                if roots.contains_key(&task_id) {
297                    result.deleted_roots.push(task_id);
298                }
299                // Delete outgoing edges but don't update the aggregation graph yet.
300                // To avoid accidentally rebalancing on deleted tasks due to racing deletions,
301                // we defer all rebalancing to the end
302                let deferred =
303                    CleanupOldEdgesOperation::run_edge_deletions_only(task_id, old_edges, &mut ctx);
304                result.deferred_balance_edges.extend(deferred.balance_edges);
305                result
306                    .deferred_dirty_dependents
307                    .extend(deferred.dirty_dependents);
308                ControlFlow::Continue(())
309            },
310            |(stats, result), (other_stats, other_result)| {
311                (stats.merge(other_stats), result.merge(other_result))
312            },
313        );
314
315        // Drop the entries for the roots this pass collected, recorded as they were deleted.
316        for id in &result.deleted_roots {
317            roots.remove(id);
318        }
319
320        // Process the rebalance requests now that deletion work is done
321        // Do this before computing roots so all uppers are settled
322        let deferred = std::mem::take(&mut result.deferred_balance_edges);
323        if !deferred.is_empty() {
324            let noop_collector = |_task_id| {};
325            let mut ctx = ExecuteContextImpl::new_for_gc(self, turbo_tasks, phase, &noop_collector);
326            let mut queue = AggregationUpdateQueue::new();
327            queue.extend_balance_edges(deferred, &mut ctx);
328            while !queue.process(&mut ctx) {}
329        }
330
331        // Dirty the dependents whose edges were scrubbed. After the rebalance above so the
332        // aggregation graph is settled, and before the root scan below because dirtying can change
333        // activeness and therefore rootness.
334        let dirty_dependents = std::mem::take(&mut result.deferred_dirty_dependents);
335        if !dirty_dependents.is_empty() {
336            let noop_collector = |_task_id| {};
337            let mut ctx = ExecuteContextImpl::new_for_gc(self, turbo_tasks, phase, &noop_collector);
338            let mut queue = AggregationUpdateQueue::new();
339            // A dependent collected by this same pass is skipped: the job is weak by construction.
340            queue.push(AggregationUpdateJob::InvalidateDueToDependencyTornDown {
341                task_ids: dirty_dependents.into_iter().collect(),
342            });
343            while !queue.process(&mut ctx) {}
344        }
345
346        // Collect all active roots
347        // We don't do this in the GC pass because a task detected as a root 'early' might become a
348        // non-root later due to other operations (e.g. it might get promoted to a live aggregation
349        // root).
350        for id in self.storage.gc_scan_roots() {
351            roots.insert(id, TtlCounter::MostRecent);
352        }
353
354        stats.gc_roots = roots.len();
355        stats.aged_out_roots = aged_out_count;
356        result.interrupted = budget
357            .as_ref()
358            .is_some_and(|budget| budget.was_interrupted());
359
360        // Only persist the roots map if it actually changed
361        let roots_to_persist: Option<Vec<_>> =
362            (roots != roots_before).then(|| roots.into_iter().collect());
363        (stats, result, roots_to_persist)
364    }
365
366    /// Compute which persisted roots have expired their TTL
367    /// Also
368    /// - start the staleness clock for roots that are no longer resident
369    /// - drop roots that are resident (the GC pass will pass judgement)
370    fn gc_roots_refresh_and_age_out(
371        &self,
372        map: &mut FxHashMap<TaskId, TtlCounter>,
373        now: u64,
374    ) -> Vec<TaskId> {
375        let ttl_ms = self.gc_root_ttl.as_millis() as u64;
376
377        let mut aged_out = Vec::new();
378        map.retain(|id, counter| {
379            if self.storage.with_task(*id, |_| ()).is_some() {
380                // Resident: `gc_scan_roots` decides. Drop it either way.
381                return false;
382            }
383            match *counter {
384                TtlCounter::MostRecent => {
385                    // It was recent in the last pass but not this one. Start the clock
386                    *counter = TtlCounter::FirstStale(now);
387                    true
388                }
389                TtlCounter::FirstStale(since) => {
390                    if now.saturating_sub(since) > ttl_ms {
391                        // Enqueue for collection, which restores it from disk and attempts the
392                        // delete. Dropped from the map: see the note above.
393                        aged_out.push(*id);
394                        false
395                    } else {
396                        true
397                    }
398                }
399            }
400        });
401
402        aged_out
403    }
404
405    pub(super) fn pin_task_for_gc(
406        &self,
407        task: TaskId,
408        turbo_tasks: &TurboTasks<TurboTasksBackend>,
409    ) {
410        self.gc_update_pin(task, 1, "pin_task_for_gc", turbo_tasks);
411    }
412
413    pub(super) fn unpin_task_for_gc(
414        &self,
415        task: TaskId,
416        turbo_tasks: &TurboTasks<TurboTasksBackend>,
417    ) {
418        self.gc_update_pin(task, -1, "unpin_task_for_gc", turbo_tasks);
419    }
420
421    /// Applies `delta` to a task's `transient_ref_count`
422    fn gc_update_pin(
423        &self,
424        task: TaskId,
425        delta: i32,
426        op: &'static str,
427        turbo_tasks: &TurboTasks<TurboTasksBackend>,
428    ) {
429        // We expect this call to come from outside a turbo-task context, at least sometimes
430        // So be defensive about conostructing a context.  If we get none then we are shutting down
431        // and it is too late for ref-counting.
432        let Some(mut ctx) = self.try_execute_context(turbo_tasks) else {
433            return;
434        };
435        // Technically we only need to manipulate transient data so meta is overkill. But the task
436        // must be resident if we are adding a pin so this isn't wasteful
437        let mut task = ctx.task(task, TaskDataCategory::Meta);
438        task.assert_not_deleted(op);
439        task.update_and_get_transient_ref_count(delta);
440    }
441
442    /// Runs a full GC pass under the GC phase and returns the number of tasks collected.
443    #[doc(hidden)]
444    pub fn gc_for_testing(&self, turbo_tasks: &TurboTasks<TurboTasksBackend>) -> usize {
445        // A pass sets `deleted` flags, and the persist path only knows how to tombstone those when
446        // GC is enabled. Running a pass on a GC-disabled backend would leave soft-deleted tasks
447        // that persistence refuses to handle, so require the backend to be configured for GC
448        // (`BackendOptions::gc`) rather than silently diverging from production.
449        assert!(
450            self.gc_enabled,
451            "gc_for_testing requires a GC-enabled backend: set `BackendOptions::gc = Some(true)`"
452        );
453        let _serialize = self.snapshot_in_progress.lock();
454        let phase = self.snapshot_coord.begin_snapshot();
455        let (stats, _result, roots) =
456            self.gc_collect(turbo_tasks, &phase, /* interruptible= */ false);
457
458        // Persist the roots map this pass produced. Some tests query the roots set and GC itself
459        // does as well, this ensures it is available to the next cycle.
460        if let Some(roots) = roots
461            && let Err(err) = self.backing_storage.save_snapshot(
462                Vec::new(),
463                Some(roots),
464                Vec::<Vec<SnapshotItem>>::new(),
465            )
466        {
467            panic!("gc_for_testing: failed to persist GC roots: {err:?}");
468        }
469        stats.collected
470    }
471}