Skip to content

turbo-tasks-backend: parent_count-driven garbage collection - #97282

Draft
lukesandberg wants to merge 4 commits into
lukesandberg/gc-03-parent-count-collectionfrom
lukesandberg/gc-035-gc-collection
Draft

turbo-tasks-backend: parent_count-driven garbage collection#97282
lukesandberg wants to merge 4 commits into
lukesandberg/gc-03-parent-count-collectionfrom
lukesandberg/gc-035-gc-collection

Conversation

@lukesandberg

@lukesandberg lukesandberg commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Adds garbage collection, built on the parent_count reference counting

How collection works

  • we scan all storage shards for collectible tasks to seed the sweep
  • For each collectible task we
    1. mark it as deleted
    2. remove all outgoing edges
    3. queue new tasks for deletion if the edge removals triggered it

For removing edges we rely on the existing CleanupOldEdges operation, though it is enhanced to collect the tasks that become collectible.

A collected task is not erased immediately. It is flagged deleted and stays resident until the next snapshot tombstones its on-disk copy. This opens up a race where a new reference to the task can be added after deletion. This is handled via new resurrect_deleted function that manages this when connecting children.

Coordinator changes

GC mutates the graph, so it needs the same exclusion against running operations that the snapshot already has. The coordinator goes from one exclusion to two, with a shared begin_exclusion both entry points delegate to.

The subtle piece is GcPhase::into_snapshot: GC has to hand its exclusion directly to the snapshot that tombstones what it collected. Swapping the flags under one lock leaves no window where an operation could start and resurrect a just-collected task,

Enabling it

Off by default; TURBO_ENGINE_GC turns it on. Forced off on a ReadWrite backend with eviction disabled, where soft-deleted tasks would never actually be reclaimed.

prevent_gc() becomes real, pinning the calling task through transient_ref_count. OperationVc::task_id lets a detached handle held outside the tracked graph pin its task the same way.

Testing

  • gc_collection.rs — the collection cascade, pinning, and the erase-while-referenced invariant.
  • gc_resurrection.rs — the races soft-deletion exists to close: an aggregation rebalance that must cascade in the same pass, a forward-dependency scrub racing a concurrent collect, and reconnecting a collected task before its tombstoning snapshot.
  • gc_stress.rs — the anti-leak test (repeated re-rooting must return the resident count to a flat baseline rather than climbing per round), plus a wide per-task fan-out torn down in a single collect.
  • benches/gc.rs, behind TURBOPACK_BENCH_GC, measures collection throughput over wide disconnected graphs.

@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Stats from current PR

✅ No significant changes detected

📊 All Metrics
📖 Metrics Glossary

Dev Server Metrics:

  • Listen = TCP port starts accepting connections
  • First Request = HTTP server returns successful response
  • Cold = Fresh build (no cache)
  • Warm = With cached build artifacts

Build Metrics:

  • Fresh = Clean build (no .next directory)
  • Cached = With existing .next directory

Change Thresholds:

  • Time: Changes < 50ms AND < 10%, OR < 2% are insignificant
  • Size: Changes < 1KB AND < 1% are insignificant
  • All other changes are flagged to catch regressions

⚡ Dev Server

Metric Canary PR Change Trend
Cold (Listen) 813ms 812ms ▂▁▂▂▂
Cold (Ready in log) 797ms 795ms ▁▁▁▁▁
Cold (First Request) 1.324s 1.317s ▁▁▁▂▁
Warm (Listen) 813ms 813ms ▁▁▁▁▁
Warm (Ready in log) 793ms 794ms ▁▁▁▁▁
Warm (First Request) 1.306s 1.302s ▁▁▁▂▁
📦 Dev Server (Webpack) (Legacy)

📦 Dev Server (Webpack)

Metric Canary PR Change Trend
Cold (Listen) 814ms 813ms ▂▂▂▂▂
Cold (Ready in log) 789ms 794ms ▁▂▁▁▁
Cold (First Request) 3.433s 3.451s ▁▂▁▁▂
Warm (Listen) 813ms 812ms ▂▂▂▂▂
Warm (Ready in log) 796ms 793ms ▁▂▁▁▂
Warm (First Request) 3.475s 3.464s ▁▂▁▁▂

⚡ Production Builds

Metric Canary PR Change Trend
Fresh Build 6.071s 5.836s ▁▁▁▂▁
Cached Build 2.996s 3.006s ▁▁▁▃▁
📦 Production Builds (Webpack) (Legacy)

📦 Production Builds (Webpack)

Metric Canary PR Change Trend
Fresh Build 24.753s 24.712s ▁▁▁▁▁
Cached Build 24.740s 24.842s ▂▂▁▁▂
node_modules Size 553 MB 553 MB ▁▁▁▁▁
📦 Bundle Sizes

Bundle Sizes

⚡ Turbopack

Client

Main Bundles
Canary PR Change
03lzpt71kg1qg.js gzip 47 kB N/A -
03o8j4gdcreqe.js gzip 170 B N/A -
0405hwyamng46.js gzip 157 B N/A -
050icza-xjz0i.js gzip 5.73 kB N/A -
07jdby0ue616s.js gzip 450 B N/A -
0bjdc8muo74n5.js gzip 8.71 kB N/A -
0cz1d0mv5g_q7.js gzip 39.4 kB 39.4 kB
0q36apx7b1q_e.js gzip 7.54 kB N/A -
0rci1f3or1a19.js gzip 13.3 kB N/A -
1_2x714--ii1i.js gzip 8.76 kB N/A -
1-3y752pkth5-.js gzip 10 kB N/A -
1elt1qium-r2m.css gzip 115 B 115 B
1ewkwfyqom2y8.js gzip 158 B N/A -
1gyf22uwo685a.js gzip 158 B N/A -
1k6dd7su4cfei.js gzip 3.57 kB N/A -
1tf1phijqlx9j.js gzip 220 B 220 B
1uzabyd1120a1.js gzip 8.71 kB N/A -
1vvaqv96pfhdm.js gzip 156 B N/A -
2-ufv8lc-g7gg.js gzip 10.6 kB N/A -
21kmjy_10x14f.js gzip 8.81 kB N/A -
28dhc6t85q1_p.js gzip 8.78 kB N/A -
2f-ilvczue-tp.js gzip 9.46 kB N/A -
2f1u17u5c8iny.js gzip 8.79 kB N/A -
2ifum2q5l9pnn.js gzip 155 B N/A -
2ikltg_8iegxw.js gzip 10.3 kB N/A -
2vxi673cz1-t4.js gzip 8.79 kB N/A -
2w4laifzt1o9q.js gzip 157 B N/A -
31dg94q7iv1fe.js gzip 160 B N/A -
38-q43pzktqhs.js gzip 1.46 kB N/A -
3g6mznuaqjizb.js gzip 71.6 kB N/A -
3j981cx6o_t11.js gzip 155 B N/A -
3ki92ksbh68ff.js gzip 152 B N/A -
3l9pr36yz-v2o.js gzip 161 B N/A -
3mrwxzed0ylgv.js gzip 13.1 kB N/A -
3nbojhxiy1qv_.js gzip 13.7 kB N/A -
3qn872h2wdhw7.js gzip 157 B N/A -
3ueg8wcmwx2u-.js gzip 156 B N/A -
41u5s3oe2-erp.js gzip 2.29 kB N/A -
445s_9hf8o7ao.js gzip 8.76 kB N/A -
44i244dmnulb-.js gzip 65.6 kB N/A -
turbopack-0d..9ejy.js gzip 3.74 kB 3.74 kB
0-6bcj16ji2wf.js gzip N/A 10.6 kB -
056018sbl3xxg.js gzip N/A 156 B -
07ryk0jced-sc.js gzip N/A 8.78 kB -
0a9npihj78qly.js gzip N/A 157 B -
0jwho9fkrb_t3.js gzip N/A 2.29 kB -
0roh390ijzxa5.js gzip N/A 8.75 kB -
0rvbkygoup2y6.js gzip N/A 157 B -
0vex9w55ursqj.js gzip N/A 8.79 kB -
0z8h9xivq9k4i.js gzip N/A 156 B -
0zn9pf1bv9yel.js gzip N/A 156 B -
17oe55cu76cd7.js gzip N/A 450 B -
19om32e1m-0cq.js gzip N/A 160 B -
1din_zw2g87o2.js gzip N/A 168 B -
1drww5xikb-c-.js gzip N/A 9.46 kB -
1f2amrrz84mzg.js gzip N/A 151 B -
1g60xde_dv17t.js gzip N/A 8.79 kB -
1o20zijn7kx7u.js gzip N/A 155 B -
1uzv47btzam64.js gzip N/A 5.73 kB -
1vy7n7wxv_rh5.js gzip N/A 8.81 kB -
1x0gfa0ls84m6.js gzip N/A 65.6 kB -
2-0i7pl900-ou.js gzip N/A 8.71 kB -
2-kcbngm7ik7y.js gzip N/A 8.75 kB -
29p5-xa4jmdhg.js gzip N/A 13.7 kB -
2k9c3hd4n9ult.js gzip N/A 71.6 kB -
2lpk5_hknut8q.js gzip N/A 13.1 kB -
2o4cibp3awtbw.js gzip N/A 3.56 kB -
2ugc69z0t0ypz.js gzip N/A 1.46 kB -
3-_0ow7671xjh.js gzip N/A 7.55 kB -
336mo8ag01xm6.js gzip N/A 156 B -
36dn8i-_3dnq5.js gzip N/A 10 kB -
3fljpmwcjxqhx.js gzip N/A 10.3 kB -
3gz44skqdlsrb.js gzip N/A 8.71 kB -
3nk46g6u37ekv.js gzip N/A 47 kB -
3q5e1j1558-w2.js gzip N/A 155 B -
3y9tus7kb5su0.js gzip N/A 13.3 kB -
446udw0vqke67.js gzip N/A 157 B -
44zkt6f12xfx-.js gzip N/A 162 B -
Total 401 kB 401 kB ⚠️ +5 B

Server

Middleware
Canary PR Change
middleware-b..fest.js gzip 1.06 kB 1.06 kB
Total 1.06 kB 1.06 kB ✅ -1 B
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 878 B 878 B
Total 878 B 878 B
Build Cache
Canary PR Change
00000001.sst gzip 15.2 MB 14.5 MB 🟢 678 kB (-4%)
00000002.sst gzip 13 MB 14.5 MB 🔴 +1.54 MB (+12%)
00000003.sst gzip 13.1 MB 14 MB 🔴 +936 kB (+7%)
00000004.sst gzip 13.9 MB 12.1 MB 🟢 1.76 MB (-13%)
00000005.sst gzip 10.2 MB 10.2 MB 🔴 +26.2 kB (+0%)
00000006.sst gzip 2.8 MB 2.8 MB
00000007.sst gzip 59 B 59 B
00000008.meta gzip 89 B 89 B
00000009.meta gzip 298 kB 298 kB
00000010.meta gzip 298 kB 298 kB
00000011.meta gzip 298 kB 298 kB
00000012.sst gzip 51.9 kB 51.9 kB
00000013.sst gzip 1.79 MB 1.78 MB 🟢 2.85 kB (0%)
00000014.sst gzip 59 B 59 B
00000015.meta gzip 116 B 116 B
00000016.meta gzip 326 kB 326 kB
00000017.meta gzip 406 kB 405 kB
00000018.sst gzip 52.6 kB 52.6 kB
00000019.sst gzip 1.37 MB 1.4 MB 🔴 +32.9 kB (+2%)
00000020.sst gzip 59 B 59 B
00000021.meta gzip 116 B 116 B
00000022.meta gzip 326 kB 326 kB
00000023.meta gzip 372 kB 372 kB
00000024.sst gzip 52.7 kB 52.6 kB
00000025.sst gzip 1.37 MB 1.4 MB 🔴 +32.3 kB (+2%)
00000026.sst gzip 59 B 59 B
00000027.meta gzip 116 B 116 B
00000028.meta gzip 326 kB 326 kB
00000029.meta gzip 372 kB 372 kB
00000030.sst gzip 52.1 kB 52.1 kB
00000031.sst gzip 1.37 MB 1.4 MB 🔴 +32.3 kB (+2%)
00000032.sst gzip 59 B 59 B
00000033.meta gzip 116 B 116 B
00000034.meta gzip 326 kB 326 kB
00000035.meta gzip 372 kB 372 kB
00000036.sst gzip 52.6 kB 52.6 kB
00000037.sst gzip 1.37 MB 1.4 MB 🔴 +32.3 kB (+2%)
00000038.sst gzip 59 B 59 B
00000039.meta gzip 116 B 116 B
00000040.meta gzip 326 kB 326 kB
00000041.meta gzip 372 kB 372 kB
CURRENT gzip 94 B 94 B
LOG gzip 677 B 660 B 🟢 17 B (-3%)
Total 80.1 MB 80.3 MB ⚠️ +187 kB

📦 Webpack

Client

Main Bundles
Canary PR Change
3322-HASH.js gzip 66.2 kB N/A -
4191.HASH.js gzip 169 B N/A -
7920-HASH.js gzip 4.67 kB N/A -
9784-HASH.js gzip 5.63 kB N/A -
b1ad9f4c-HASH.js gzip 63.2 kB N/A -
framework-HASH.js gzip 59.7 kB 59.7 kB
main-app-HASH.js gzip 255 B 252 B 🟢 3 B (-1%)
main-HASH.js gzip 40.1 kB 40.1 kB
webpack-HASH.js gzip 1.68 kB 1.68 kB
3577.HASH.js gzip N/A 168 B -
578-HASH.js gzip N/A 66.8 kB -
8590-HASH.js gzip N/A 5.61 kB -
9750-HASH.js gzip N/A 4.68 kB -
a8984546-HASH.js gzip N/A 63.2 kB -
Total 242 kB 242 kB ⚠️ +615 B
Polyfills
Canary PR Change
polyfills-HASH.js gzip 39.4 kB 39.4 kB
Total 39.4 kB 39.4 kB
Pages
Canary PR Change
_app-HASH.js gzip 194 B 193 B
_error-HASH.js gzip 181 B 182 B
css-HASH.js gzip 334 B 331 B
dynamic-HASH.js gzip 1.81 kB 1.81 kB
edge-ssr-HASH.js gzip 255 B 253 B
head-HASH.js gzip 349 B 351 B
hooks-HASH.js gzip 382 B 384 B
image-HASH.js gzip 581 B 582 B
index-HASH.js gzip 260 B 259 B
link-HASH.js gzip 2.48 kB 2.48 kB
routerDirect..HASH.js gzip 317 B 318 B
script-HASH.js gzip 384 B 386 B
withRouter-HASH.js gzip 316 B 315 B
1afbb74e6ecf..834.css gzip 106 B 106 B
Total 7.95 kB 7.96 kB ⚠️ +4 B

Server

Edge SSR
Canary PR Change
edge-ssr.js gzip 129 kB 129 kB
page.js gzip 294 kB 295 kB
Total 423 kB 423 kB ⚠️ +27 B
Middleware
Canary PR Change
middleware-b..fest.js gzip 616 B 618 B
middleware-r..fest.js gzip 156 B 156 B
middleware.js gzip 45.6 kB 45.4 kB
edge-runtime..pack.js gzip 842 B 842 B
Total 47.3 kB 47 kB ✅ -253 B
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 717 B 718 B
Total 717 B 718 B ⚠️ +1 B
Build Cache
Canary PR Change
0.pack gzip 4.81 MB 4.8 MB
index.pack gzip 122 kB 121 kB
index.pack.old gzip 122 kB 121 kB
Total 5.05 MB 5.05 MB ✅ -4.51 kB

🔄 Shared (bundler-independent)

Runtimes
Canary PR Change
app-page-exp...dev.js gzip 375 kB 375 kB
app-page-exp..prod.js gzip 207 kB 207 kB
app-page-tur...dev.js gzip 375 kB 375 kB
app-page-tur..prod.js gzip 207 kB 207 kB
app-page-tur...dev.js gzip 371 kB 371 kB
app-page-tur..prod.js gzip 205 kB 205 kB
app-page.run...dev.js gzip 371 kB 371 kB
app-page.run..prod.js gzip 205 kB 205 kB
app-route-ex...dev.js gzip 82.9 kB 82.9 kB
app-route-ex..prod.js gzip 56.1 kB 56.1 kB
app-route-tu...dev.js gzip 83 kB 83 kB
app-route-tu..prod.js gzip 56.1 kB 56.1 kB
app-route-tu...dev.js gzip 82.5 kB 82.5 kB
app-route-tu..prod.js gzip 55.9 kB 55.9 kB
app-route.ru...dev.js gzip 82.5 kB 82.5 kB
app-route.ru..prod.js gzip 55.8 kB 55.8 kB
dev-validati...dev.js gzip 134 kB 134 kB
dev-validati...dev.js gzip 134 kB 134 kB
dev-validati...dev.js gzip 131 kB 131 kB
dev-validati...dev.js gzip 131 kB 131 kB
dist_client_...dev.js gzip 324 B 324 B
dist_client_...dev.js gzip 326 B 326 B
dist_client_...dev.js gzip 318 B 318 B
dist_client_...dev.js gzip 317 B 317 B
pages-api-tu...dev.js gzip 46.4 kB 46.4 kB
pages-api-tu..prod.js gzip 34.6 kB 34.6 kB
pages-api.ru...dev.js gzip 46.4 kB 46.4 kB
pages-api.ru..prod.js gzip 34.5 kB 34.5 kB
pages-turbo....dev.js gzip 55.2 kB 55.2 kB
pages-turbo...prod.js gzip 40.1 kB 40.1 kB
pages.runtim...dev.js gzip 55.1 kB 55.1 kB
pages.runtim..prod.js gzip 40.1 kB 40.1 kB
server.runti..prod.js gzip 67.1 kB 67.1 kB
use-cache-pr...dev.js gzip 72.6 kB 72.6 kB
use-cache-pr...dev.js gzip 72.6 kB 72.6 kB
use-cache-pr...dev.js gzip 70.8 kB 70.8 kB
use-cache-pr...dev.js gzip 70.8 kB 70.8 kB
Total 4.11 MB 4.11 MB
📎 Tarball URL
https://vercel-packages.vercel.app/next/commits/042f0357fb3eea4d369514d03b0636b7e0a851bb/next

Commit: 042f035

@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Failing CI jobs

Commit: 042f035 | About building and testing Next.js

@lukesandberg lukesandberg changed the title lukesandberg/gc 035 gc collection turbo-tasks-backend: parent_count-driven garbage collection Aug 13, 2026
// Never persisted: there is nothing on disk to tombstone, so drop it out of the
// next snapshot's scan entirely (clearing its modified bits + shard count).
// Eviction still removes the resident, soft-`deleted` task.
task.discard_modifications_for_gc_new_task();

@vercel vercel Bot Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GC clearing new_task on a never-persisted collected task, followed by resurrection before eviction, persists the task without its TaskCache (type→id) mapping, breaking cache dedup on the next session restore.

Fix on Vercel

…ator

GC mutates the task graph, so it needs the same exclusion against running
operations that the snapshot already has. Generalize the coordinator from one
exclusion (snapshot) to two (snapshot, GC): a second request bit, a shared
`begin_exclusion` that both entry points delegate to, and `snapshot_pending`
renamed to `exclusion_pending` now that it covers both.

The important piece is `GcPhase::into_snapshot`: GC must hand its exclusion
directly to the snapshot that tombstones what it collected. Swapping the flags
under one lock leaves no window where an operation could start and resurrect a
just-collected task.

`suspend_point` now records the suspended operation unconditionally, because a
GC phase can hand off to a snapshot that must persist it for replay.
A collected task is not erased immediately. It is flagged `deleted` and stays
resident until the next snapshot tombstones its on-disk copy — that residency is
what closes the resurrection window, since any `ctx.task` during the pass finds
a live entry instead of restoring a zombie from disk. `resurrect_deleted`
clears the flag and re-dirties the task if something connects to it first.

`is_gc_collectible` is the authoritative predicate; `gc_maybe_collectible` is
the storage-only half so the resident-map scan can use it without a guard. Its
`is_restored(Meta)` gate is load-bearing: `parent_count` and the aggregation
edges are Meta fields, so an evicted task reads them as 0/empty and would look
collectible when its persisted Meta says otherwise.

The collector discovers cascade candidates through two context buffers — tasks
whose `parent_count` hit 0, and tasks that lost their last aggregation edge.
Both are no-ops for every normal operation context.

`TaskGuardImpl::category` becomes `Option` so `resident_task` can hand out a
guard that restored no category at all: it touches only transient fields, and
`check_access` must reject any Meta/Data read through it.
The collection pass. Seeds one job per resident-map shard, scans each for tasks
passing the cheap collectibility pre-filter, and collects them in parallel over
the self-feeding work queue: re-check under the write guard, soft-delete, scrub
the outgoing edges via CleanupOldEdges, then cascade into whatever that freed.
A task never persisted is dropped from the snapshot scan outright; there is
nothing on disk to tombstone.

Deletion completes across the following snapshot and eviction: the snapshot
emits a `Delete` tombstone for each soft-deleted task, and eviction hard-deletes
the entry, re-checking the flag under the shard write lock so a resurrection in
between falls through to normal eviction instead.

`prevent_gc()` becomes real, pinning the calling task through
`transient_ref_count`. `OperationVc::task_id` lets a detached handle held
outside the tracked graph pin its task the same way.

Off by default; `TURBO_ENGINE_GC` enables it. Forced off on a ReadWrite backend
with eviction disabled, where soft-deleted tasks would never be reclaimed.
Splits the GC tests out of `parent_count.rs` into `gc_collection.rs` (the
collection cascade, pinning, and the erase-while-referenced invariant), leaving
that file to the reference-counting tests.

`gc_resurrection.rs` covers the races soft-deletion exists to close: an
aggregation rebalance that must cascade in the same pass, a forward-dependency
scrub racing a concurrent collect, and reconnecting a collected task before its
tombstoning snapshot.

`gc_stress.rs` is the anti-leak test — repeated re-rooting must return the
resident count to a flat baseline rather than climbing per round — plus a
wide per-task fan-out torn down in a single collect.

`benches/gc.rs` (behind `TURBOPACK_BENCH_GC`) measures collection throughput
over wide disconnected graphs.
@lukesandberg
lukesandberg force-pushed the lukesandberg/gc-035-gc-collection branch from 8fb7af4 to 042f035 Compare August 13, 2026 06:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant