Skip to content

Commit d81d5ab

Browse files
authored
Proof of concept: task eviction after snapshot for turbo-tasks-backend (#91790)
> **Note:** This is a **proof of concept** implementation. It is not yet ready for production use. ## Summary Implements memory eviction for the turbo-tasks engine. After a persistence snapshot completes, tasks that are safe to remove are evicted from in-memory storage and transparently restored from disk on next access. ### Eviction levels - **Full eviction**: Entire task removed from the in-memory map (restored from disk on access). Only possible when the task has no meaningful transient state (and other state is already on disk) - **DataAndMeta eviction**: Both data and meta categories cleared, but the task stays in the map to preserve transient state (e.g. `current_session_clean`, aggregated session-clean counts). - **DataOnly eviction**: Only data-category fields cleared; meta (graph structure, output, dirty state) stays in memory. - **MetaOnly eviction**: Only meta-category fields cleared; data stays in memory. Data and meta evictability are computed independently — if one category is modified but the other is clean, the clean category can still be dropped. Eviction is gated behind `BackendOptions::evict_after_snapshot` (off by default), and can be enabled in Next.js via the `TURBO_ENGINE_EVICT_AFTER_SNAPSHOT=1` env var for testing. ## Key changes - **Orthogonal eviction decision tree** (`storage_schema.rs`): Data and meta evictability are computed independently. Full eviction additionally requires no meaningful transient state (session-clean flags, aggregated session-clean counts). Replaces the previous sequential bail-out approach which was too aggressive on full eviction (losing transient session state on leaf tasks) and not aggressive enough on partial eviction (blocking all eviction when only one category was modified). - **`drop_partial()` codegen** (`task_storage_macro.rs`): New generated methods to drop data - **`restore_from_*()` codegen changes** (`task_storage_macro.rs`): New semantics for merging persistent data from the backend with transient data stored in memory. - **`task_cache` moved into `Storage`** (`storage.rs`): The `CachedTaskType → TaskId` deduplication map was previously a separate field on `TurboTasksBackendInner`. It is now owned by `Storage` so eviction can remove entries when a task is fully evicted. Because `task_cache` is a pure performance cache (entries are re-populated by `task_by_type()` on miss once the task type is persisted to backing storage), evicting entries is safe. After bulk eviction the map is shrunk when it is less than half full. - **Parallel shard eviction** (`storage.rs`): Eviction iterates all storage shards in parallel after snapshot, applying the appropriate eviction level per task. Each shard is shrunk after bulk eviction to reclaim slack capacity. - In principle this is O(N) work to scan, but because each pass drops >98% of tasks there isn't wasted work and the logic is fast, taking <100ms for even the largest applications. ## Design notes - **SessionDependent tasks**: SessionDependent tasks can still be evicted but if `current_session_clean` is set we prevent full eviction to avoid rechecking. Within a session the file-watchers are responsible for invalidations after setting `current_session_clean`. ## Known limitations (proof of concept) - No LRU or access-frequency tracking — all eligible tasks are evicted on every snapshot cycle - No memory pressure feedback — eviction runs on a timer, not in response to actual memory pressure - Only runs after snapshotting which tends to be a high point in memory - Future work will explore interleaving this logic with snapshotting to trim the peak <!-- NEXT_JS_LLM_PR -->
1 parent ed9a295 commit d81d5ab

34 files changed

Lines changed: 2989 additions & 407 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/next-api/src/project.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1495,8 +1495,8 @@ impl Project {
14951495

14961496
/// Computes the whole app module graph without dropping issues.
14971497
///
1498-
/// Use this instead of [`whole_app_module_graphs`] when you need to collect issues from the
1499-
/// computation (e.g. for the `get_compilation_issues` MCP tool).
1498+
/// Use this instead of [Self::whole_app_module_graphs] when you need to collect issues from
1499+
/// the computation (e.g. for the `get_compilation_issues` MCP tool).
15001500
#[turbo_tasks::function]
15011501
pub async fn whole_app_module_graphs_without_dropping_issues(
15021502
self: ResolvedVc<Self>,

crates/next-napi-bindings/src/next_api/turbopack_ctx.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,8 @@ pub fn create_turbo_tasks(
256256
}),
257257
dependency_tracking,
258258
num_workers: Some(tokio::runtime::Handle::current().metrics().num_workers()),
259+
evict_after_snapshot: std::env::var("TURBO_ENGINE_EVICT_AFTER_SNAPSHOT")
260+
.is_ok_and(|v| v == "1" || v == "true"),
259261
..Default::default()
260262
},
261263
Either::Left(backing_storage),
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { nextTestSetup, isNextDev } from 'e2e-utils'
2+
import { retry, waitFor } from 'next-test-utils'
3+
4+
// Eviction requires the dev server (HMR) and persistent caching (Turbopack).
5+
// Skip entirely in prod/start mode.
6+
;(isNextDev ? describe : describe.skip)('evict-after-snapshot', () => {
7+
const envVars = [
8+
'ENABLE_CACHING=1',
9+
'TURBO_ENGINE_IGNORE_DIRTY=1',
10+
'TURBO_ENGINE_SNAPSHOT_IDLE_TIMEOUT_MILLIS=1000',
11+
'TURBO_ENGINE_EVICT_AFTER_SNAPSHOT=1',
12+
].join(' ')
13+
14+
const { skipped, next } = nextTestSetup({
15+
files: __dirname,
16+
skipDeployment: true,
17+
packageJson: {
18+
scripts: {
19+
dev: `${envVars} next dev`,
20+
},
21+
},
22+
installCommand: 'npm i',
23+
startCommand: 'npm run dev',
24+
})
25+
26+
if (skipped) {
27+
return
28+
}
29+
30+
async function waitForSnapshotAndEviction() {
31+
// The idle timeout is 1s, give extra time for snapshot + eviction to complete
32+
await waitFor(5000)
33+
}
34+
35+
// Turbopack-only: eviction requires persistent caching
36+
;(process.env.IS_TURBOPACK_TEST ? it : it.skip)(
37+
'should serve correct content after eviction and HMR',
38+
async () => {
39+
const browser = await next.browser('/')
40+
await retry(async () => {
41+
expect(await browser.elementByCss('p').text()).toBe('hello world')
42+
})
43+
44+
let currentContent = 'hello world'
45+
for (let cycle = 1; cycle <= 3; cycle++) {
46+
await waitForSnapshotAndEviction()
47+
48+
const prevContent = currentContent
49+
const nextContent = `cycle ${cycle}`
50+
await next.patchFile('app/page.tsx', (content) =>
51+
content.replace(prevContent, nextContent)
52+
)
53+
currentContent = nextContent
54+
55+
const expected = currentContent
56+
await retry(async () => {
57+
expect(await browser.elementByCss('p').text()).toBe(expected)
58+
}, 10000)
59+
}
60+
61+
await browser.close()
62+
},
63+
90000
64+
)
65+
;(process.env.IS_TURBOPACK_TEST ? it : it.skip)(
66+
'should handle client component HMR after eviction',
67+
async () => {
68+
const browser = await next.browser('/client')
69+
await retry(async () => {
70+
expect(await browser.elementByCss('p').text()).toBe('hello world')
71+
})
72+
73+
await waitForSnapshotAndEviction()
74+
75+
await next.patchFile(
76+
'app/client/page.tsx',
77+
(content) => content.replace('hello world', 'hello eviction'),
78+
async () => {
79+
await retry(async () => {
80+
expect(await browser.elementByCss('p').text()).toBe(
81+
'hello eviction'
82+
)
83+
}, 10000)
84+
}
85+
)
86+
87+
await browser.close()
88+
},
89+
90000
90+
)
91+
})

turbopack/crates/turbo-persistence/src/db.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -619,8 +619,7 @@ impl<S: ParallelScheduler, const FAMILIES: usize> TurboPersistence<S, FAMILIES>
619619

620620
/// Clears all caches of the database.
621621
pub fn clear_cache(&self) {
622-
self.key_block_cache.clear();
623-
self.value_block_cache.clear();
622+
self.clear_block_caches();
624623
for meta in self.inner.write().meta_files.iter_mut() {
625624
meta.clear_cache();
626625
}

turbopack/crates/turbo-tasks-auto-hash-map/src/set.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,14 @@ impl<K: Hash + Eq, H: BuildHasher + Default, const I: usize> AutoSet<K, H, I> {
9797
pub fn contains(&self, key: &K) -> bool {
9898
self.map.contains_key(key)
9999
}
100+
101+
/// see [HashSet::retain](https://doc.rust-lang.org/std/collections/hash_set/struct.HashSet.html#method.retain)
102+
pub fn retain<F>(&mut self, mut f: F)
103+
where
104+
F: FnMut(&K) -> bool,
105+
{
106+
self.map.retain(|k, _| f(k));
107+
}
100108
}
101109

102110
impl<K, H, const I: usize> AutoSet<K, H, I> {

turbopack/crates/turbo-tasks-backend/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ turbo-persistence = { workspace = true }
6060
turbo-rcstr = { workspace = true }
6161
turbo-tasks = { workspace = true }
6262
turbo-tasks-hash = { workspace = true }
63+
turbo-tasks-malloc = { workspace = true, default-features = false }
6364
thread_local = { workspace = true }
6465

6566
[dev-dependencies]
@@ -68,6 +69,7 @@ futures = { workspace = true }
6869
indoc = { workspace = true }
6970
regex = { workspace = true }
7071
tempfile = { workspace = true }
72+
triomphe = { workspace = true }
7173
turbo-tasks-malloc = { workspace = true }
7274
rstest = { workspace = true }
7375
turbo-tasks-testing = { workspace = true }

0 commit comments

Comments
 (0)