Skip to content

turbo-tasks-backend: keep recorded GC roots until their TTL expires - #100279

Merged
lukesandberg merged 4 commits into
canaryfrom
fleet/gc-root-ttl-resident-roots
Nov 3, 2026
Merged

lukesandberg merged 4 commits into
canaryfrom
fleet/gc-root-ttl-resident-roots

Conversation

@lukesandberg

@lukesandberg lukesandberg commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

What?

With Turbopack GC enabled, a task recorded in the persisted GC roots map now stays protected from collection until its TTL expires. Before, a recorded root was dropped from the map as soon as it became resident again. This also fixes pinned roots falling out of the roots map after their Meta was evicted.

Why?

At refresh time, gc_roots_refresh_and_age_out dropped every resident root from the persisted roots map, assuming the pass's root scan would judge it. But the judgement would ~always judge them as dead. A task becomes a root because it had only transient pins from a prior session, if we read it back to 'refresh' then it doens't have those transient pins and will always be collected. On a warm next dev start, startup invalidation propagates through the aggregation graph and restores almost every prior root before anything requests it. So every root that hadn't been re-pinned yet was collected in the first GC pass, along with its whole exclusive subtree. The 3-day TTL never applied, and the next session had to recompute and re-persist everything.

On bench/nested-deps-app-router-many-pages with GC enabled:

  • a warm session that idled before its first request collected about 4.3M tasks;
  • the next session then ran as slowly as a cold one.

A related bug: the root scan can't see a task whose Meta has been evicted, so the refresh also dropped roots that were still pinned.

How?

Once a root, always a root, until it ages out.

In theory a prior session root could gain a 'persistent pin' and then lose it which might mean we could collect it, this case is currently uncovered (and completely theoretical).

Verification

  • cargo nextest run -p turbo-tasks-backend: all tests pass.
  • cargo clippy -p turbo-tasks-backend --all-targets and cargo check -p turbo-tasks-backend --release are clean.
  • The end-to-end numbers above were measured with an earlier revision of this PR. In that run, an idle warm session collected 0 tasks and kept all 246 roots, and the next session ran at warm speed. I haven't re-run them on this revision. The cold-to-warm path they measured still keeps prior roots protected, and it now has fewer special cases.

Not covered here: retention cost (memory and cache size) and persisted-byte amplification.

@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Tests Passed

Commit: 1f2e702

@github-actions github-actions Bot added created-by: Turbopack team PRs by the Turbopack team. Turbopack Related to Turbopack with Next.js. labels Oct 15, 2026
@lukesandberg lukesandberg changed the title turbo-tasks-backend: honor GC root TTL for resident prior-session roots turbo-tasks-backend: keep recorded GC roots until their TTL expires Oct 19, 2026
@lukesandberg
lukesandberg requested a review from a team October 19, 2026 20:41
@lukesandberg
lukesandberg marked this pull request as ready for review October 19, 2026 20:41
@lukesandberg
lukesandberg requested a review from bgw October 28, 2026 16:46
vercel-fleet-prod Bot and others added 4 commits November 3, 2026 18:30
Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
@lukesandberg
lukesandberg force-pushed the fleet/gc-root-ttl-resident-roots branch from 96e0ca6 to 1f2e702 Compare November 3, 2026 18:30
@lukesandberg
lukesandberg merged commit dd54647 into canary Nov 3, 2026
107 checks passed
@lukesandberg
lukesandberg deleted the fleet/gc-root-ttl-resident-roots branch November 3, 2026 23:18
lukesandberg added a commit that referenced this pull request Nov 6, 2026
…100279)

With Turbopack GC enabled, a task recorded in the persisted GC roots map
now stays protected from collection until its TTL expires. Before, a
recorded root was dropped from the map as soon as it became resident
again. This also fixes pinned roots falling out of the roots map after
their `Meta` was evicted.

At refresh time, `gc_roots_refresh_and_age_out` dropped every *resident*
root from the persisted roots map, assuming the pass's root scan would
judge it. But the judgement would ~always judge them as dead. A task
becomes a root because it had only transient pins from a prior session,
if we read it back to 'refresh' then it doens't have those transient
pins and will always be collected. On a warm `next dev` start, startup
invalidation propagates through the aggregation graph and restores
almost every prior root before anything requests it. So every root that
hadn't been re-pinned yet was collected in the first GC pass, along with
its whole exclusive subtree. The 3-day TTL never applied, and the next
session had to recompute and re-persist everything.

On `bench/nested-deps-app-router-many-pages` with GC enabled:
- a warm session that idled before its first request collected about
4.3M tasks;
- the next session then ran as slowly as a cold one.

A related bug: the root scan can't see a task whose `Meta` has been
evicted, so the refresh also dropped roots that were still pinned.

**Once a root, always a root, until it ages out.**

In theory a prior session root could gain a 'persistent pin' and then
lose it which might mean we could collect it, this case is currently
uncovered (and completely theoretical).

- `cargo nextest run -p turbo-tasks-backend`: all tests pass.
- `cargo clippy -p turbo-tasks-backend --all-targets` and `cargo check
-p turbo-tasks-backend --release` are clean.
- The end-to-end numbers above were measured with an earlier revision of
this PR. In that run, an idle warm session collected 0 tasks and kept
all 246 roots, and the next session ran at warm speed. I haven't re-run
them on this revision. The cold-to-warm path they measured still keeps
prior roots protected, and it now has fewer special cases.

Not covered here: retention cost (memory and cache size) and
persisted-byte amplification.

<!-- NEXT_JS_LLM -->

<!-- fleet 96d8c261-a703-457c-b145-0fb472af31a7 -->

---------

Co-authored-by: vercel-fleet-prod[bot] <318278635+vercel-fleet-prod[bot]@users.noreply.github.com>
Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
(cherry picked from commit dd54647)

Co-authored-by: Luke Sandberg <lukesandberg@users.noreply.github.com>
lukesandberg added a commit that referenced this pull request Nov 7, 2026
Backports these two Turbopack GC fixes to `releases/lts/active`:
- [turbo-tasks-backend: keep recorded GC roots until their TTL expires
(#100279)](#100279)
- [[turbopack] Fix GC/eviction race by dropping task resurrection
(#100370)](#100370)

It builds on #100209 (the #99902 backport) and #100002 (snapshot
consistency fixes), both already on this branch. GC is opt-in on 16.4
(`experimental.turbopackGc`), so this only affects apps that enable it.

## Conflict resolution

**#100279**
- `gc.rs` / `mod.rs` doc comments: took canary's text, which uses plain
backticks instead of intra-doc links.
- `snapshot_and_persist`: the `process` closure keeps this branch's
3-argument signature. #99747, which added a `category` parameter, isn't
on this branch. The PR's `#[cfg(debug_assertions)] gc_protected_roots`
set and the extended `debug_assert!` were added unchanged.

**#100370**
- `aggregation_update.rs` `increase_active_count`: replaced
`resurrect_deleted(...)` with `task.assert_not_deleted(...)`. Kept the
`self.check_optimization_pending(&task)` call that follows it on this
branch; canary removed it in #100146/#100147, which aren't here.
- `aggregation_update.rs` `optimize_queue.pop_front()` arm: kept the
`optimization_pending_flag_already_set` binding. Canary renames it to
`_`, but this branch still reads it in the per-job budget-exhausted path
(`lock_and_mark_optimization_pending`). Canary replaced that path with a
bulk drain.

Everything else applied as-is.

## Verification

- `cargo nextest run -p turbo-tasks-backend`: 289/289 passed. This
includes the new `gc_cross_session` TTL tests and the
`task_by_type_tests` lookup tests.
- `cargo clippy -p turbo-tasks-backend --tests` is clean.
- `cargo check -p turbo-tasks-backend` passes with `--release` and with
`--features task_dirty_cause`.
- `cargo fmt --check` is clean.

CI may show the Tailwind failures currently affecting all
`releases/lts/active` PRs until the Tailwind pin backport (#101307)
lands.

<!-- NEXT_JS_LLM -->


<!-- fleet 91f3474f-6748-4c06-8329-d8d3673533dd -->

---------

Co-authored-by: vercel-fleet-prod[bot] <318278635+vercel-fleet-prod[bot]@users.noreply.github.com>
Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

created-by: Turbopack team PRs by the Turbopack team. Turbopack Related to Turbopack with Next.js.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants