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}