Skip to content

turbo-tasks-backend: parent_count reference counting + garbage collection#95976

Draft
lukesandberg wants to merge 1 commit into
lukesandberg/gc-025-task-accessfrom
lukesandberg/gc-03-parent-count-collection
Draft

turbo-tasks-backend: parent_count reference counting + garbage collection#95976
lukesandberg wants to merge 1 commit into
lukesandberg/gc-025-task-accessfrom
lukesandberg/gc-03-parent-count-collection

Conversation

@lukesandberg

@lukesandberg lukesandberg commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

What

The core of the persistent-engine garbage collector: a durable per-task parent_count (number of persistent parents that list the task as a child), maintained incrementally, plus the pass that collects tasks whose count reaches 0.

Reference counting

  • parent_count and transient_ref_count as inline meta fields.
  • Incremental maintenance: a direct, suspend-free +1 co-located with the children-edge commit, and a queued −1 on edge removal (CleanupOldEdges).
  • The +1 is deliberately not a queued job: there must be no operation_suspend_point between committing the children edge and bumping the count, or a GC/snapshot could observe a reachable child as parent_count == 0 and delete it. The −1 stays queued (its window is benign — a pending −1 only under-collects for one pass).

Collection

  • backend/gc.rs: the pass. A self-feeding parallel job pool (scope_self_feeding) re-validates each parent_count == 0 candidate, tears it down via CleanupOldEdges (dropping children's counts, scrubbing reverse-dep edges, rebalancing the aggregation graph), soft-deletes it (kept resident, tombstoned at the next snapshot), and cascades into children driven to 0.
  • snapshot_coordinator: begin_gc/begin_snapshot exclusion + an atomic GC→snapshot hand-off that closes a resurrection race.
  • prevent_gc() as a GC pin (transient_ref_count); pin/unpin are exclusion-safe and no-op once the backend is stopping.
  • Uses the tombstone plumbing (PR below) to erase collected tasks on disk; the delete path in save_snapshot is parallelized/inlined.

Why

This replaces scan-based GC for the persistent engine: a count can only drop to 0 while the parent is in memory, so collectibility is detectable at that moment with no DB scan.

Safety

Opt-in via TURBO_ENGINE_GC (default off). No behavior change unless enabled. The subtle invariants (suspend-free count maintenance, the GC→snapshot hand-off, soft-deletion + resurrection) are the review focus.

Tests

gc_stress (re-rooting stays flat, wide-fanout collection), gc_resurrection (forward-dep scrub / reconnect), and the parent_count counting + collection suite.

Stack

Depends on the tombstone-plumbing and scope PRs below it.

lukesandberg commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Stats from current PR

🟢 2 improvements

Metric Canary PR Change Trend
node_modules Size 527 MB 516 MB 🟢 11.2 MB (-2%) █████
Webpack Warm (First Request) 3.498s 3.385s 🟢 113ms (-3%) ▁▁▁▃▁
📊 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) 812ms 811ms ▇▂▁▂▂
Cold (Ready in log) 777ms 773ms █▁▁▁▁
Cold (First Request) 1.300s 1.290s ▇▁▁▁▁
Warm (Listen) 812ms 813ms ▇▂▂▂▁
Warm (Ready in log) 781ms 778ms ▇▁▁▁▁
Warm (First Request) 1.292s 1.279s ▇▂▁▁▂
📦 Dev Server (Webpack) (Legacy)

📦 Dev Server (Webpack)

Metric Canary PR Change Trend
Cold (Listen) 814ms 813ms ▂▂▂▂▂
Cold (Ready in log) 791ms 782ms ▁▁▁▁▁
Cold (First Request) 3.455s 3.430s ▁▁▁▄▁
Warm (Listen) 813ms 813ms ▂▂▁▂▂
Warm (Ready in log) 794ms 793ms ▁▁▁▂▁
Warm (First Request) 3.498s 3.385s 🟢 113ms (-3%) ▁▁▁▃▁

⚡ Production Builds

Metric Canary PR Change Trend
Fresh Build 5.071s 5.061s ▇▃▂▁▁
Cached Build 5.092s 5.221s ▇▃▁▁▁
📦 Production Builds (Webpack) (Legacy)

📦 Production Builds (Webpack)

Metric Canary PR Change Trend
Fresh Build 26.174s 26.049s ▁▁▁▁▁
Cached Build 26.577s 26.695s ▁▁▁▁▁
node_modules Size 527 MB 516 MB 🟢 11.2 MB (-2%) █████
📦 Bundle Sizes

Bundle Sizes

⚡ Turbopack

Client

Main Bundles
Canary PR Change
03gmyxr7dukvs.js gzip 10.3 kB N/A -
040t1q17lo593.js gzip 8.77 kB N/A -
0bb0vtp8dwsez.js gzip 450 B N/A -
0cz1d0mv5g_q7.js gzip 39.4 kB 39.4 kB
0l0sr6gau5xjn.js gzip 3.53 kB N/A -
0nst2xc-t0y6v.js gzip 160 B N/A -
0nxqz3q165m2l.js gzip 220 B N/A -
0qr-1tnb7_hg5.js gzip 5.72 kB N/A -
0qtrl0m2m47zb.js gzip 155 B N/A -
0uilzvklme4ea.js gzip 8.76 kB N/A -
0z0y83mfx-l-h.js gzip 10 kB N/A -
1_dj4hrtb4p26.js gzip 7.4 kB N/A -
15ok8ydf274z0.js gzip 10.6 kB N/A -
16cqqcigykfea.js gzip 13.6 kB N/A -
17h18zxt1915l.js gzip 1.46 kB N/A -
1elt1qium-r2m.css gzip 115 B 115 B
1fq1h7dxad4cv.js gzip 9.44 kB N/A -
1fym-adbk_69_.js gzip 13.1 kB N/A -
1gav7nfh4y8ln.js gzip 8.76 kB N/A -
1o34vh44ndvtu.js gzip 13.2 kB N/A -
2-u463-ditelp.js gzip 156 B N/A -
23rhpy3kbs-7u.js gzip 65.6 kB N/A -
2dvqqp3zqpnnq.js gzip 160 B N/A -
2guls-_0xkbp2.js gzip 153 B N/A -
2lcep9_t56iw5.js gzip 166 B N/A -
2rypqffktcxqh.js gzip 8.69 kB N/A -
2uqsizxtehgqf.js gzip 8.79 kB N/A -
2vzktdqwbijqc.js gzip 8.74 kB N/A -
2ydpbosxm0bih.js gzip 156 B N/A -
2zezpu-768ip-.js gzip 155 B N/A -
3_4kzn9ufgy-9.js gzip 45.2 kB N/A -
32874-2qs3sy7.js gzip 155 B N/A -
3a80mp1w6p7__.js gzip 153 B N/A -
3c244akvx_-be.js gzip 8.74 kB N/A -
3dmlemitx3ef3.js gzip 2.29 kB N/A -
3eqn7bfc1f1vc.js gzip 71.1 kB N/A -
3i1ah-rd8gyl6.js gzip 156 B N/A -
3kxmazxf0zw6v.js gzip 156 B N/A -
3za9ke3-tiboq.js gzip 153 B N/A -
4237vdj6eyslt.js gzip 8.7 kB N/A -
turbopack-0q..obj3.js gzip 3.8 kB N/A -
turbopack-0s..e00r.js gzip 3.79 kB N/A -
turbopack-10..kg69.js gzip 3.8 kB N/A -
turbopack-13..bkpr.js gzip 3.8 kB N/A -
turbopack-15..07mf.js gzip 3.8 kB N/A -
turbopack-1c..8d9x.js gzip 3.8 kB N/A -
turbopack-1g..5cux.js gzip 3.8 kB N/A -
turbopack-1i..0hfx.js gzip 3.8 kB N/A -
turbopack-20..mf5k.js gzip 3.8 kB N/A -
turbopack-2l..5n26.js gzip 3.8 kB N/A -
turbopack-2y..j_7q.js gzip 3.8 kB N/A -
turbopack-31..2tc1.js gzip 3.82 kB N/A -
turbopack-3f..g_b7.js gzip 3.8 kB N/A -
turbopack-3r..ik36.js gzip 3.8 kB N/A -
0_brf1o8xtfgw.js gzip N/A 156 B -
00tc16j5-eos9.js gzip N/A 1.46 kB -
0gaw1gmpwuwkn.js gzip N/A 8.8 kB -
0lfd6n4hnvfus.js gzip N/A 156 B -
0mwo2e6a43qd6.js gzip N/A 154 B -
0oxxm8ra6x40w.js gzip N/A 155 B -
12zyjjmq4oad1.js gzip N/A 5.72 kB -
1b_5lm2rb0cxg.js gzip N/A 65.6 kB -
1k9cc4-gpa2cx.js gzip N/A 13.1 kB -
1kfiwrcr725kl.js gzip N/A 13.2 kB -
1n59uogejo81s.js gzip N/A 10.3 kB -
1npei3hlexdv3.js gzip N/A 9.99 kB -
1q-0qyccjh4q3.js gzip N/A 158 B -
1ssuiwj_rnpc5.js gzip N/A 8.77 kB -
2-phc9obd3nyd.js gzip N/A 8.77 kB -
20z9kb96_57f4.js gzip N/A 164 B -
24utq57r3l1cw.js gzip N/A 8.7 kB -
29tie9lu2une4.js gzip N/A 71 kB -
2aw0kxkhyra0_.js gzip N/A 153 B -
2eemzo7z71a10.js gzip N/A 154 B -
2fhjq6ek3srmr.js gzip N/A 8.69 kB -
2g0t7upz_v-hf.js gzip N/A 10.6 kB -
2ia9qqy99aie9.js gzip N/A 13.6 kB -
2n5fp99a5xt9s.js gzip N/A 7.4 kB -
2oza28kdqvoay.js gzip N/A 8.74 kB -
2s1ku9sx60j5g.js gzip N/A 155 B -
2smk18o7qzte6.js gzip N/A 154 B -
2zh8-mmhujy8o.js gzip N/A 2.29 kB -
30h_m0irj-q6p.js gzip N/A 221 B -
37ejbfx1u9lpo.js gzip N/A 8.75 kB -
38c63ct4i1cy7.js gzip N/A 449 B -
38pfb-vjc58zl.js gzip N/A 160 B -
3jimpal3lvz5e.js gzip N/A 9.44 kB -
3q86bccmeq9ht.js gzip N/A 45.1 kB -
3su6a4l8jbfth.js gzip N/A 3.53 kB -
3vp18tbs9ie7b.js gzip N/A 156 B -
3wb4pe4z4-aim.js gzip N/A 8.77 kB -
43cuc9gzge2dj.js gzip N/A 151 B -
turbopack-0c..ceu6.js gzip N/A 3.76 kB -
turbopack-0r..o21k.js gzip N/A 3.77 kB -
turbopack-0v..6n4f.js gzip N/A 3.78 kB -
turbopack-12..bfke.js gzip N/A 3.77 kB -
turbopack-1a..ll78.js gzip N/A 3.77 kB -
turbopack-1j..lqtq.js gzip N/A 3.77 kB -
turbopack-1v..tzol.js gzip N/A 3.79 kB -
turbopack-1x..lsi3.js gzip N/A 3.78 kB -
turbopack-2i..bzur.js gzip N/A 3.77 kB -
turbopack-3_..wlef.js gzip N/A 3.77 kB -
turbopack-3-..hdvm.js gzip N/A 3.77 kB -
turbopack-3l..eco6.js gzip N/A 3.77 kB -
turbopack-3m..txtb.js gzip N/A 3.77 kB -
turbopack-3w..c3ex.js gzip N/A 3.78 kB -
Total 448 kB 447 kB ✅ -421 B

Server

Middleware
Canary PR Change
middleware-b..fest.js gzip 778 B 749 B 🟢 29 B (-4%)
Total 778 B 749 B ✅ -29 B
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 427 B 436 B 🔴 +9 B (+2%)
Total 427 B 436 B ⚠️ +9 B

📦 Webpack

Client

Main Bundles
Canary PR Change
2486.HASH.js gzip 169 B N/A -
3146-HASH.js gzip 64.2 kB N/A -
39fcf99b-HASH.js gzip 62.9 kB N/A -
8443-HASH.js gzip 4.68 kB N/A -
9431-HASH.js gzip 5.62 kB N/A -
framework-HASH.js gzip 59.8 kB 59.8 kB
main-app-HASH.js gzip 255 B 253 B
main-HASH.js gzip 39.7 kB 40.1 kB 🔴 +452 B (+1%)
webpack-HASH.js gzip 1.68 kB 1.68 kB
6105-HASH.js gzip N/A 5.63 kB -
764.HASH.js gzip N/A 169 B -
8898-HASH.js gzip N/A 63.5 kB -
9597-HASH.js gzip N/A 4.65 kB -
e1ccab69-HASH.js gzip N/A 62.9 kB -
Total 239 kB 239 kB ✅ -268 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 194 B
_error-HASH.js gzip 183 B 182 B
css-HASH.js gzip 335 B 335 B
dynamic-HASH.js gzip 1.8 kB 1.8 kB
edge-ssr-HASH.js gzip 255 B 254 B
head-HASH.js gzip 351 B 349 B
hooks-HASH.js gzip 384 B 384 B
image-HASH.js gzip 581 B 580 B
index-HASH.js gzip 259 B 259 B
link-HASH.js gzip 2.49 kB 2.49 kB
routerDirect..HASH.js gzip 319 B 319 B
script-HASH.js gzip 386 B 386 B
withRouter-HASH.js gzip 315 B 313 B
1afbb74e6ecf..834.css gzip 106 B 106 B
Total 7.96 kB 7.95 kB ✅ -8 B

Server

Edge SSR
Canary PR Change
edge-ssr.js gzip 128 kB 128 kB
page.js gzip 285 kB 285 kB
Total 413 kB 413 kB ✅ -405 B
Middleware
Canary PR Change
middleware-b..fest.js gzip 615 B 615 B
middleware-r..fest.js gzip 156 B 155 B
middleware.js gzip 45.4 kB 45.3 kB
edge-runtime..pack.js gzip 842 B 842 B
Total 47 kB 46.9 kB ✅ -116 B
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 720 B 720 B
Total 720 B 720 B
Build Cache
Canary PR Change
0.pack gzip 4.83 MB 4.8 MB 🟢 20.3 kB (0%)
index.pack gzip 118 kB 119 kB
index.pack.old gzip 120 kB 119 kB
Total 5.06 MB 5.04 MB ✅ -20.5 kB

🔄 Shared (bundler-independent)

Runtimes
Canary PR Change
app-page-exp...dev.js gzip 366 kB 364 kB
app-page-exp..prod.js gzip 202 kB 201 kB
app-page-tur...dev.js gzip 365 kB 363 kB
app-page-tur..prod.js gzip 202 kB 201 kB
app-page-tur...dev.js gzip 362 kB 360 kB
app-page-tur..prod.js gzip 200 kB 199 kB
app-page.run...dev.js gzip 362 kB 360 kB
app-page.run..prod.js gzip 200 kB 199 kB
app-route-ex...dev.js gzip 81.7 kB 81.5 kB
app-route-ex..prod.js gzip 55.6 kB 55.4 kB
app-route-tu...dev.js gzip 81.8 kB 81.5 kB
app-route-tu..prod.js gzip 55.6 kB 55.4 kB
app-route-tu...dev.js gzip 81.4 kB 81.1 kB
app-route-tu..prod.js gzip 55.4 kB 55.1 kB
app-route.ru...dev.js gzip 81.3 kB 81.1 kB
app-route.ru..prod.js gzip 55.3 kB 55.1 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 45.4 kB 45.2 kB
pages-api-tu..prod.js gzip 34.1 kB 33.9 kB
pages-api.ru...dev.js gzip 45.4 kB 45.2 kB
pages-api.ru..prod.js gzip 34.1 kB 33.9 kB
pages-turbo....dev.js gzip 54.9 kB 54.6 kB
pages-turbo...prod.js gzip 39.7 kB 39.6 kB
pages.runtim...dev.js gzip 54.8 kB 54.6 kB
pages.runtim..prod.js gzip 39.7 kB 39.5 kB
server.runti..prod.js gzip 67.8 kB 67.5 kB
use-cache-pr...dev.js gzip 71.6 kB 71.4 kB
use-cache-pr...dev.js gzip 71.6 kB 71.4 kB
use-cache-pr...dev.js gzip 69.9 kB 69.7 kB
use-cache-pr...dev.js gzip 69.9 kB 69.7 kB
Total 3.51 MB 3.49 MB ✅ -15.6 kB
📝 Changed Files (29 files)

Files with changes:

  • app-page-exp..ntime.dev.js
  • app-page-exp..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page.runtime.dev.js
  • app-page.runtime.prod.js
  • app-route-ex..ntime.dev.js
  • app-route-ex..time.prod.js
  • app-route-tu..ntime.dev.js
  • app-route-tu..time.prod.js
  • app-route-tu..ntime.dev.js
  • app-route-tu..time.prod.js
  • app-route.runtime.dev.js
  • app-route.ru..time.prod.js
  • pages-api-tu..ntime.dev.js
  • pages-api-tu..time.prod.js
  • pages-api.runtime.dev.js
  • pages-api.ru..time.prod.js
  • ... and 9 more
View diffs
app-page-exp..ntime.dev.js
failed to diff
app-page-exp..time.prod.js
failed to diff
app-page-tur..ntime.dev.js
failed to diff
app-page-tur..time.prod.js
failed to diff
app-page-tur..ntime.dev.js
failed to diff
app-page-tur..time.prod.js
failed to diff
app-page.runtime.dev.js
failed to diff
app-page.runtime.prod.js
failed to diff
app-route-ex..ntime.dev.js

Diff too large to display

app-route-ex..time.prod.js

Diff too large to display

app-route-tu..ntime.dev.js

Diff too large to display

app-route-tu..time.prod.js

Diff too large to display

app-route-tu..ntime.dev.js

Diff too large to display

app-route-tu..time.prod.js

Diff too large to display

app-route.runtime.dev.js

Diff too large to display

app-route.ru..time.prod.js

Diff too large to display

pages-api-tu..ntime.dev.js

Diff too large to display

pages-api-tu..time.prod.js

Diff too large to display

pages-api.runtime.dev.js

Diff too large to display

pages-api.ru..time.prod.js

Diff too large to display

pages-turbo...ntime.dev.js

Diff too large to display

pages-turbo...time.prod.js

Diff too large to display

pages.runtime.dev.js

Diff too large to display

pages.runtime.prod.js

Diff too large to display

server.runtime.prod.js

Diff too large to display

use-cache-pr..ntime.dev.js

Diff too large to display

use-cache-pr..ntime.dev.js

Diff too large to display

use-cache-pr..ntime.dev.js

Diff too large to display

use-cache-pr..ntime.dev.js

Diff too large to display

📎 Tarball URL
https://vercel-packages.vercel.app/next/commits/f844d1851276ffb5d7742c08b96066f7fcdccfa6/next

Commit: f844d18

@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Tests Passed

Commit: f844d18

/// garbage-collection pass (tearing down tasks whose persistent parent count reached 0). Opt-in
/// until it has been proven on trusted apps; the eventual default-on flip gets its own escape
/// hatch.
static GC_ENABLED: LazyLock<bool> = LazyLock::new(|| {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

nit: move into gc_enabled

///
/// (A single-variant enum today; kept as an enum so the self-feeding pool's job type has room to
/// grow — e.g. if the per-task teardown is later split to fan a huge task's edges across workers.)
enum GcJob {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

simplify, this doesn't need to be an enum

Comment on lines +51 to +59
/// A unit of garbage-collection work fed through the self-feeding parallel pool in
/// [`TurboTasksBackend::gc_collect`]: collecting one task. Parallelism is *across* tasks — the pool
/// runs many `Collect` jobs on different workers — while each task's own teardown (its
/// `CleanupOldEdges` run) is sequential. A `Collect` discovers more `Collect` jobs (each child the
/// cleanup drives to `parent_count == 0` and that is itself collectible), which flow straight back
/// into the pool until it drains.
///
/// (A single-variant enum today; kept as an enum so the self-feeding pool's job type has room to
/// grow — e.g. if the per-task teardown is later split to fan a huge task's edges across workers.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

comment could just be

A Request to delete a given task. Once deleted Cleans up old edges and enqueues new jobs as needed

Comment on lines +133 to +141
// TODO(perf): recycle the task ids of collected tasks. `persisted_task_id_factory`
// (`IdFactoryWithReuse`) can hand out freed ids, and the persisted `next_free_task_id`
// high-water mark only grows today, so the id space grows unboundedly across churn even
// though the task set stays flat. Reuse must happen only AFTER the `save_snapshot` that
// tombstoned the id has committed (a crash before commit leaves the task on disk — reusing
// its id would alias it), and must be guarded against resurrection: between removal and
// commit a `get_or_create_task` for the same type could re-mint the id, and the id must not
// be handed out while any live `OperationVc`/`DetachedVc` still references it. Feed the
// recycled ids into `persisted_task_id_factory` so the high-water mark can stop growing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

we should accumulate deleted tasks in a RoaringBitmap that we

  1. persist
  2. use to make accessing deleted tasks a hard error

}

// Written once per collected task (not per child/dep), so the atomic is not a hot path.
let collected = AtomicUsize::new(0);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

we should consider other stats

edges deleted

roots detected

@lukesandberg
lukesandberg force-pushed the lukesandberg/gc-03-parent-count-collection branch from a6f5c07 to 5d4aadd Compare July 20, 2026 22:43
@lukesandberg
lukesandberg force-pushed the lukesandberg/gc-02-tombstone-plumbing branch from b80ac91 to 14080b5 Compare July 20, 2026 22:43
/// Runs one [`GcJob`], possibly spawning follow-up jobs into the same pool. See
/// [`Self::gc_collect`] for the concurrency argument. Each job builds its own GC
/// [`ExecuteContext`].
fn gc_run_job(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

just inline this into the scope_self_feeding closure?

Comment on lines +185 to +193
// Capture ALL of this task's edges as `OutdatedEdge`s, then hand them to the same
// `CleanupOldEdges` operation a re-executing task uses. This is what makes GC
// teardown correct: alongside dropping each child's `parent_count` and scrubbing
// the forward-dep reverse edges, it PROPAGATES THE AGGREGATION
// REBALANCE (removes this task from its children's `upper` sets via
// `InnerOfUppersLostFollowers`). Without that, collected children
// would be left with a dangling upper edge and never
// become collectible themselves. The op opens `ctx.task(task_id)` to remove the
// child edges, so it must run while `task_id` is still resident.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

simplify the comment

just collect all edges and delete them before tagging the task itself as deleted

Comment on lines +221 to +225
// The edges are gone and the children are rebalanced. Instead of removing the task
// from the map now (which would let a later `ctx.task` on it — e.g. a sibling's
// `CleanupOldEdges` forward-dep scrub — resurrect it from disk as a zombie), mark
// it soft-deleted and keep it resident. The next snapshot tombstones its on-disk
// copy; a later step hard-deletes it from memory once the tombstone has committed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

simplify comment

!child.is_transient(),
"gc: a transient task should never have a persistent parent_count to zero"
);
if ctx.task(child, TaskDataCategory::Meta).is_gc_collectible() {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

CleanupOldEdges must have fetched All and the next job will do the same... not sure if it matters but should we do the same here?

Comment on lines +212 to +219
drop(task);

CleanupOldEdgesOperation::run(
task_id,
old_edges,
AggregationUpdateQueue::new(),
&mut ctx,
);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

does the relative order of task_deleted and CleanupOldEdges actually matter?

if we mark deleted first, we could save a lock acquisition

Comment on lines +249 to +281
/// Marks a collected task **soft-deleted**: it stays resident (in the map and `task_cache`) but
/// is flagged for deletion and forced into the next snapshot's modified scan. The task's edges
/// and the aggregation rebalance were already handled by the `CleanupOldEdges` run in
/// [`Self::gc_run_job`] before this is called.
///
/// Keeping the task resident is the whole point: during the pass another job's `ctx.task` on it
/// (e.g. a sibling's forward-dep scrub inside `CleanupOldEdges`) finds a real entry rather than
/// restoring a zombie from disk. The next snapshot's `process` closure sees the `deleted` flag,
/// tombstones the on-disk copy (task meta/data + `TaskCache` bucket) instead of persisting, and
/// clears the modified flags; a later step (`evict_after_snapshot`, or the drain snapshot at
/// shutdown) hard-deletes it from memory once the tombstone has committed. If the task is
/// resurrected by a connect before then, the marker is cleared and it is made dirty. Must be
/// called while holding the GC phase.
fn gc_mark_deleted(&self, task_id: TaskId, ctx: &mut impl ExecuteContext<'_>) {
let mut task = ctx.task(task_id, TaskDataCategory::Meta);
task.set_deleted(true);
// Force the task into the next snapshot's per-shard modified scan so `process` can
// tombstone it. This can't be an assert-it's-already-modified: a collected task need NOT
// have been modified this cycle. The count-to-0 cascade DOES dirty meta (`parent_count` is
// a tracked meta field) and so does an aggregation-graph disconnect, but two collectible
// states leave meta clean —
// 1. a parentless (`parent_count == 0`) task kept alive only by a pin, then unpinned:
// `transient_ref_count` is a *transient* field, so dropping it to 0 tracks nothing;
// and
// 2. a task persisted parentless in a prior session, restored this cycle (restore sets no
// modified flags) and collected with no intervening mutation.
// In those cases this call is what carries the tombstone into the snapshot (it flips
// `has_modifications`, so the modified scan runs). When meta is already dirty (the common
// cascade case) it's a cheap no-op. Runs before `start_snapshot`, so it takes the
// not-in-snapshot-mode path and bumps the shard modified count. The returned `TrackOutcome`
// is only for undo; GC's mark is permanent, so discard it.
let _ = task.track_modification(SpecificTaskDataCategory::Meta, "gc_deleted");
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

all the comments here can be massively simplified

soft deletion is necessary because of racing edge deletions and track_modification is required to ensure that snapshotting sees it

actually i wonder if deleted should be a meta category if only to trigger implicit track_modification behavior

it would never actually be persisted which is slightly amusing but it would simplify reasoning

Comment on lines +287 to +323
// Once the backend is stopping, GC bookkeeping is irrelevant (the whole task map is torn
// down in `stop()`), so pin/unpin become no-ops. This also makes them safe against handles
// finalized during shutdown — e.g. a `DetachedVc` handed to JS across NAPI is dropped
// (which unpins) during Node worker teardown, *after* `stop()` has dropped the map.
if self.stopping.load(Ordering::Acquire) {
return;
}
// Build a normal (operation-guarded) execute context so pin cannot interleave with a GC
// pass: it runs strictly before or strictly after a collection, never concurrently with
// `is_gc_collectible` / task removal. This is deadlock-free because neither pin caller
// holds a guard already — `prevent_gc` runs in the unguarded user-future region,
// and `DetachedVc` pins from a NAPI thread — and the GC pass itself never calls
// pin/unpin.
let mut ctx = self.execute_context(turbo_tasks);
// A pin is an in-session reference from outside the tracked graph (an explicit
// `prevent_gc`, or a detached handle like `DetachedVc` holding the task's `OperationVc`
// across NAPI). It is counted the same way a transient parent's edge is: bump
// `transient_ref_count`. While that count is > 0 the task is uncollectible (see
// `is_gc_collectible`) and unevictable (see `evictability`, so the transient count can't be
// lost). Counting (rather than a bool flag) makes nested/cloned pins correct — each pin is
// balanced by its own unpin.
//
// Use the non-inserting `resident_task`: a pin always targets a live reference, so the task
// must be resident. A missing entry means the caller pinned an already-collected task (a
// "zombie `OperationVc`") — a bug we surface via debug_assert rather than paper over by
// resurrecting a blank entry (which would leave an orphaned zombie in the map). Going
// through the guard routes the count through the same `update_and_get_*` mutation the
// `AdjustTransientRefCount` queue job uses.
let existed = ctx.resident_task(task);
debug_assert!(
existed.is_some(),
"pin_task_for_gc: task {task} has no resident entry (pinned an already-collected \
task?)"
);
if let Some(mut guard) = existed {
guard.update_and_get_transient_ref_count(1);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

comments are too volumninous

// `ReadWriteOnShutdown` drain path drops the whole map wholesale (no per-cycle eviction
// needed), and `ReadOnly` never persists/GCs. In debug builds, refuse the unsafe combo by
// forcing GC off with a warning; release builds trust the caller's configuration.
let mut gc_enabled = gc::gc_enabled();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

should the gc_enabled function/lazylock just be moved here?

@lukesandberg
lukesandberg force-pushed the lukesandberg/gc-02-tombstone-plumbing branch from 14080b5 to 5dbf6f4 Compare July 20, 2026 23:18
@lukesandberg
lukesandberg force-pushed the lukesandberg/gc-03-parent-count-collection branch from 5d4aadd to d370353 Compare July 20, 2026 23:18
Comment on lines +336 to +339
/// A snapshot run after a [`Self::gc_for_testing`] pass commits that pass's tombstones
/// automatically: GC leaves collected tasks resident with their `deleted` flag set, and the
/// snapshot derives the on-disk tombstones from that flag (no tombstones need threading in).
///

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

pointless comment, drop it

Comment on lines +1024 to +1038
// Garbage-collection pass runs immediately before the snapshot, so we can compute keys to
// tombstone in the database and apply it during the snapshot. Opt-in via TURBO_ENGINE_GC.
// Run the GC pass and transition to the snapshot **atomically**, without ever releasing
// operation exclusion in between. `begin_gc` drains all operations; `gc_collect` tears down
// collectible tasks and cascades `parent_count` decrements to their children; then
// `GcPhase::into_snapshot` hands the exclusion straight to the snapshot phase (swapping the
// GC request bit for the snapshot bit under one lock). Because no operation can run between
// the cascade and the snapshot, a task the cascade decremented cannot be resurrected before
// it is persisted — so the decremented children counts and the snapshot are consistent, and
// GC-collected tasks' tombstones ride this same commit.
//
// Without GC we just begin the snapshot directly. `deletes` are the GC tombstones for this
// commit: in the production (GC-enabled) path they are produced right here under the *same
// continuous exclusion* as the snapshot (the atomic `into_snapshot` hand-off), so nothing
// could have resurrected a collected task.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

simplify the comment

Comment on lines +1077 to +1078
// No tasks modified since the last snapshot (GC-collected tasks count as modified) —
// drop the guard (which calls end_snapshot) and skip the expensive O(N) scan.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

revert diff

Comment on lines +1287 to +1294
// A GC-soft-deleted task is not persisted; instead its on-disk copy (task meta/data +
// its `TaskCache` entry) is tombstoned in this same commit. We represent that as a
// `SnapshotItem::Delete` so it rides the streaming shard iterator that `save_snapshot`
// consumes (rather than a side-channel that would have to be fully populated before the
// put loop runs). GC marks such tasks modified so the per-shard scan visits them here,
// and the iterator clears their modified flags, so a still-`deleted` task not
// hard-deleted this cycle won't be re-tombstoned next snapshot. Only persistent tasks
// are collected, so a persistent task type (the `TaskCache` key) is always present.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

for tasks that are new and delete we should skip persisting them altogether, there is nothing to tombstone

Comment on lines +2183 to +2184
// Note: dirtying any unfinished new children is folded into `connect_children`'s
// parent_count pass (a single guard per child), so there is no separate dirty pass here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

drop comment

Comment on lines +306 to +313
/// Whether this increase is for a task being connected as a genuinely-new child (the
/// direct-connect chokepoint in `ConnectChildOperation::run`). When set, the handler also
/// resurrects the task if GC had soft-deleted it — riding the child guard it takes anyway,
/// so the common (not-deleted) case is a free Meta flag read. `false` for the aggregation
/// propagation producers of this job, which never target a `deleted` task (a collected
/// task has no `upper`/`followers` to be propagated to).
#[bincode(skip, default = "Default::default")]
resurrect: bool,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

i wonder if we need this bit? couldn't we just always ressurrect on activation, it would be redundant sometimes, but checkined a deleted flag on a guard we already have is fine

// only when actually deleted does `resurrect_deleted` consume it, re-acquire `All` to
// double-check and atomically clear+re-dirty, and hand back a fresh guard.
if resurrect && task.deleted() {
task = crate::backend::operation::connect_child::resurrect_deleted(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

import the function

// real edge removal. Gating on the `remove_children` return makes
// this replay-safe: a resumed CleanupOldEdges re-removes the same
// edges, and edges already gone (returning false) are skipped.
let mut removed_persistent_children: SmallVec<[TaskId; 4]> =

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

we probably don't need the explicit type declaration

Comment on lines +111 to +118
// Each removed persistent child loses a parent. A persistent parent
// drops the child's durable `parent_count` (queued so it is
// crash-consistent); a transient parent drops the session-only
// `transient_ref_count` (skipped during serialization, never
// replayed). Both ride the queue via the same job family, so the
// handler — not this site — adjusts the right counter and takes the
// child guards (avoiding a second guard while `task` is still
// held).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

simplify comment

Comment on lines +207 to +213
// Under the GC phase, a collected task stays resident
// (soft-deleted) so this scrub
// finds a live entry; a non-resident target would mean
// `ctx.task` is about to resurrect an already-collected task from
// disk (the bug soft-deletion fixes). `gc_target_resident` is
// `None` outside GC, where
// restoring a missing target is legitimate.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

simplify comment, we should also generalize this check since it generic for gc corrupts state

Comment on lines +17 to +35
/// Revive a task the caller observed as GC-soft-deleted (via `guard.deleted()` on a guard it
/// already holds during the connect handshake). The caller passes that guard **by value**; this
/// consumes it, performs the revival, and returns a freshly re-acquired guard of the same
/// `category`, so the call site is simply `guard = resurrect_deleted(guard, ..)` with the
/// drop/re-acquire encapsulated here.
///
/// The passed guard is dropped and an `All` guard re-acquired (needed for the `immutable()` read),
/// under which `deleted` is **re-checked** — double-checked locking: between the caller's cheap
/// peek and this re-acquire, a concurrent connect of the same task could have already revived it,
/// in which case there is nothing to do. When still deleted, the clear and the re-dirty happen
/// **under that single `All` guard without an intervening drop**, so no operation can observe the
/// intermediate `!deleted && !dirty` state (a task that looks live but still holds the stale/empty
/// edges GC scrubbed). A **mutable** task is cleared and made dirty so it re-executes and rebuilds
/// those edges; an **immutable** task cannot be made dirty (invariant in `make_task_dirty`) and
/// does not need to be — its output is deterministic and its edges self-contained — so clearing the
/// marker alone suffices, leaving it immediately valid for any concurrent observer.
///
/// GC collection is the single producer of the `deleted` flag and runs under an exclusion that
/// stops all operations, so once cleared here the flag cannot be re-set concurrently.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

simplify comment

Comment on lines +86 to +94
// Resurrection of a GC-soft-deleted child (clearing the `deleted` marker + re-dirtying it)
// is folded into the child guard the connect handshake takes below — the activeness path
// does it in `increase_active_count`, the non-activeness path inline — so the common
// (not-deleted) case costs only a Meta flag read on a guard we hold anyway, with no extra
// acquisition. A `deleted` child is always in NEITHER the parent's `children` nor its
// `new_children` when first connected (GC only collects `parent_count == 0` tasks, so it is
// in no live parent's children; and a prior connect in this execution would have already
// staged+resurrected it), so the dedup early-returns below never skip a needed
// resurrection.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

drop comment

Comment on lines +147 to +152
// Peek for GC soft-deletion on the guard we already hold — common path: one Meta flag
// read, no extra lock. Rare (was deleted): `resurrect_deleted` consumes this guard,
// re-acquires `All` to double-check and atomically clear+re-dirty, and hands back a
// fresh Meta guard for the schedule decision below. Resurrection thus completes before
// scheduling, matching the original resurrect-first order.
if child_task.deleted() {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

now that ressurrect_deleted takes a guard

it should really include the deleted check insternally and can just be child_task = resurrect_delete and it can sometimes be a no-op

Comment on lines +61 to +96
// Single pass over the newly-connected children doing two things per child under one guard,
// so we don't lock each child twice (once to bump its ref count, once to dirty it):
//
// 1. Maintain the child-side parent reference count for **persistent** children: a
// persistent parent bumps the durable `parent_count`, a transient parent the
// session-only `transient_ref_count` (not persisted; transient parents re-establish
// their edges by re-executing on restart). Transient children are never collected, so
// they take no count.
//
// CRITICAL: this is a **direct** adjustment, NOT a queued `AdjustParentCount` job, and
// it MUST run before `queue.execute` below (the first `operation_suspend_point`
// this function reaches). GC reads `parent_count` to decide collectibility and
// only observes state at suspend points (`begin_gc` drains operations to a
// suspended/quiescent state). If the `+1` rode the `AggregationUpdateQueue`
// instead, that queue's per-iteration suspend point could suspend with the `+1`
// still pending — leaving a window where the `children` edge is committed
// (`extend_children` in the caller) but `parent_count` is not yet incremented. A
// GC pass landing in that window would read the under-counted `parent_count ==
// 0`, judge the (genuinely reachable) child collectible, and delete it. There is
// no `operation_suspend_point` between the caller's `extend_children` and here
// (`make_task_dirty_internal` only enqueues; the queue runs later), so no snapshot/GC
// can observe the edge without the count. In the parallelized path this runs on
// a child context (which never suspends — it holds no operation guard), inside
// the `scope_and_block` barrier that completes within the parent operation, so
// the property holds there too. (The `-1` on edge removal in `CleanupOldEdges`
// has the opposite, benign failure mode — a pending `-1` makes GC
// *under*-collect for one pass, self-correcting — so it stays on the durable
// queue there.)
//
// 2. Make any child that has not produced output yet dirty, so it gets scheduled and
// computes. This was formerly a separate `for_each_task_all` pass over the same children
// (`task_execution_completed_unfinished_children_dirty`) before the connect; folding it
// here reuses this guard and, on the parallelized path, chunks it with the rest. It only
// runs on the successful connect (not the stale/cancel early-returns in
// `task_execution_completed_connect`), which is consistent with those paths undoing the
// children's temporarily-increased active count rather than keeping them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

simplify comment

we don't need to discuss the various alternatives that were explored

task_id,
true,
#[cfg(feature = "task_dirty_cause")]
crate::backend::operation::invalidate::TaskDirtyCause::Unknown,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Create a new TaskDirtyCause Resurrected

Comment on lines +203 to +207
/// GC-only: ids whose persistent `parent_count` reached 0 during this context's operations
/// (recorded by `note_gc_parent_count_zeroed`). `None` for normal operation contexts (the
/// recording hook is a no-op there); `Some` only for the GC context, drained by the collector
/// via [`Self::take_gc_zeroed`] to discover cascade-collectible tasks.
gc_zeroed: Option<Vec<TaskId>>,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

we should consider having a dedicated trait implementation for the GC context instead of these optional fields

Comment on lines +427 to +436
/// Atomically transitions from the GC phase directly into a snapshot phase **without ever
/// releasing operation exclusion**. Under the state lock it clears the GC request bit and sets
/// the snapshot request bit in one critical section, so no operation can start in between (an
/// operation blocks while *either* bit is set). This closes the race where a mutation could
/// resurrect a just-collected task in the gap between the GC pass and the snapshot: with an
/// atomic hand-off there is no such gap, so the GC cascade's `parent_count` decrements and the
/// snapshot see a consistent graph.
///
/// Because operations were already drained to zero by `begin_gc`, no further draining is
/// needed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

simplfiy comment

@lukesandberg
lukesandberg force-pushed the lukesandberg/gc-02-tombstone-plumbing branch from 5dbf6f4 to 73dc675 Compare July 21, 2026 16:08
@lukesandberg
lukesandberg force-pushed the lukesandberg/gc-03-parent-count-collection branch from d370353 to 223e74f Compare July 21, 2026 16:08
@lukesandberg
lukesandberg force-pushed the lukesandberg/gc-02-tombstone-plumbing branch from 73dc675 to b5ba401 Compare July 21, 2026 16:29
@lukesandberg
lukesandberg force-pushed the lukesandberg/gc-03-parent-count-collection branch from 223e74f to 8bc08cb Compare July 21, 2026 16:29
@lukesandberg
lukesandberg force-pushed the lukesandberg/gc-02-tombstone-plumbing branch from b5ba401 to 011b444 Compare July 21, 2026 16:41
@lukesandberg
lukesandberg force-pushed the lukesandberg/gc-03-parent-count-collection branch from 8bc08cb to 388dd36 Compare July 21, 2026 16:41
@lukesandberg
lukesandberg force-pushed the lukesandberg/gc-02-tombstone-plumbing branch from 011b444 to d903753 Compare July 21, 2026 16:46
@lukesandberg
lukesandberg force-pushed the lukesandberg/gc-03-parent-count-collection branch 2 times, most recently from 2839c72 to b9f23e7 Compare July 21, 2026 17:02
@lukesandberg
lukesandberg force-pushed the lukesandberg/gc-02-tombstone-plumbing branch from d903753 to 37de3fe Compare July 21, 2026 17:02
@lukesandberg
lukesandberg force-pushed the lukesandberg/gc-03-parent-count-collection branch 2 times, most recently from 0b657eb to b9a39db Compare July 21, 2026 20:07
…tion

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lukesandberg
lukesandberg changed the base branch from lukesandberg/gc-02-tombstone-plumbing to graphite-base/95976 July 22, 2026 05:05
@lukesandberg
lukesandberg force-pushed the lukesandberg/gc-03-parent-count-collection branch from b9a39db to f844d18 Compare July 22, 2026 05:05
@lukesandberg
lukesandberg changed the base branch from graphite-base/95976 to lukesandberg/gc-025-task-access July 22, 2026 05:05
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