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}