Skip to content

Commit ae6878d

Browse files
committed
eviction
1 parent 2904599 commit ae6878d

17 files changed

Lines changed: 1210 additions & 18 deletions

File tree

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

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

turbopack/crates/turbo-tasks-backend/src/backend/mod.rs

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ use turbo_tasks::{
4747

4848
pub use self::{
4949
operation::AnyOperation,
50-
storage::{SpecificTaskDataCategory, TaskDataCategory},
50+
storage::{EvictionCounts, SpecificTaskDataCategory, TaskDataCategory},
5151
};
5252
#[cfg(feature = "trace_task_dirty")]
5353
use crate::backend::operation::TaskDirtyCause;
@@ -86,13 +86,13 @@ const DEPENDENT_TASKS_DIRTY_PARALLIZATION_THRESHOLD: usize = 10000;
8686
const SNAPSHOT_REQUESTED_BIT: usize = 1 << (usize::BITS - 1);
8787

8888
/// Configurable idle timeout for snapshot persistence.
89-
/// Defaults to 2 seconds if not set or if the value is invalid.
89+
/// Defaults to 10 seconds if not set or if the value is invalid.
9090
static IDLE_TIMEOUT: LazyLock<Duration> = LazyLock::new(|| {
9191
std::env::var("TURBO_ENGINE_SNAPSHOT_IDLE_TIMEOUT_MILLIS")
9292
.ok()
9393
.and_then(|v| v.parse::<u64>().ok())
9494
.map(Duration::from_millis)
95-
.unwrap_or(Duration::from_secs(2))
95+
.unwrap_or(Duration::from_secs(10))
9696
});
9797

9898
struct SnapshotRequest {
@@ -143,6 +143,11 @@ pub struct BackendOptions {
143143

144144
/// Avoid big preallocations for faster startup. Should only be used for testing purposes.
145145
pub small_preallocation: bool,
146+
147+
/// When enabled, evict all evictable tasks from in-memory storage after every snapshot.
148+
/// This reclaims memory by clearing persisted data that can be re-loaded from disk on demand.
149+
/// This is an EXPERIMENTAL FEATURE under development
150+
pub evict_after_snapshot: bool,
146151
}
147152

148153
impl Default for BackendOptions {
@@ -153,6 +158,7 @@ impl Default for BackendOptions {
153158
storage_mode: Some(StorageMode::ReadWrite),
154159
num_workers: None,
155160
small_preallocation: false,
161+
evict_after_snapshot: false,
156162
}
157163
}
158164
}
@@ -220,6 +226,19 @@ impl<B: BackingStorage> TurboTasksBackend<B> {
220226
pub fn backing_storage(&self) -> &B {
221227
&self.0.backing_storage
222228
}
229+
230+
/// Perform a snapshot and then evict all evictable tasks from memory.
231+
///
232+
/// This is exposed for integration tests that need to verify the
233+
/// snapshot → evict → restore cycle works correctly.
234+
///
235+
/// Returns `(snapshot_had_new_data, eviction_counts)`.
236+
pub fn snapshot_and_evict(
237+
&self,
238+
turbo_tasks: &dyn TurboTasksBackendApi<TurboTasksBackend<B>>,
239+
) -> (bool, EvictionCounts) {
240+
self.0.snapshot_and_evict(turbo_tasks)
241+
}
223242
}
224243

225244
impl<B: BackingStorage> TurboTasksBackendInner<B> {
@@ -339,6 +358,38 @@ impl<B: BackingStorage> TurboTasksBackendInner<B> {
339358
)
340359
}
341360

361+
fn should_evict(&self) -> bool {
362+
self.options.evict_after_snapshot && self.should_persist()
363+
}
364+
365+
/// Perform a snapshot and then evict all evictable tasks from memory.
366+
///
367+
/// This is exposed for integration tests that need to verify the
368+
/// snapshot → evict → restore cycle works correctly.
369+
///
370+
/// Returns `(snapshot_had_new_data, eviction_counts)`.
371+
pub fn snapshot_and_evict(
372+
&self,
373+
turbo_tasks: &dyn TurboTasksBackendApi<TurboTasksBackend<B>>,
374+
) -> (bool, EvictionCounts) {
375+
assert!(
376+
self.should_persist(),
377+
"snapshot_and_evict requires persistence"
378+
);
379+
let snapshot_result = self.snapshot_and_persist(None, "test", turbo_tasks);
380+
let had_new_data = match snapshot_result {
381+
Some((_, new_data)) => new_data,
382+
None => {
383+
// Snapshot/persist failed — skip eviction since the data may not
384+
// be on disk yet. Evicting now could lose in-memory state that
385+
// can't be restored.
386+
return (false, EvictionCounts::default());
387+
}
388+
};
389+
let counts = self.storage.evict_after_snapshot();
390+
(had_new_data, counts)
391+
}
392+
342393
fn should_restore(&self) -> bool {
343394
self.options.storage_mode.is_some()
344395
}
@@ -2775,6 +2826,8 @@ impl<B: BackingStorage> TurboTasksBackendInner<B> {
27752826
let mut last_snapshot = self.start_time + Duration::from_millis(last_snapshot);
27762827
let mut idle_start_listener = self.idle_start_event.listen();
27772828
let mut idle_end_listener = self.idle_end_event.listen();
2829+
// Whether to immediately set an idle timeout if possible
2830+
// set to false if we don't persist anything in a cycle.
27782831
let mut fresh_idle = true;
27792832
loop {
27802833
const FIRST_SNAPSHOT_WAIT: Duration = Duration::from_secs(300);
@@ -2809,7 +2862,7 @@ impl<B: BackingStorage> TurboTasksBackendInner<B> {
28092862
idle_start_listener = self.idle_start_event.listen()
28102863
},
28112864
_ = &mut idle_end_listener => {
2812-
idle_time = until + idle_timeout;
2865+
idle_time = far_future();
28132866
idle_end_listener = self.idle_end_event.listen()
28142867
},
28152868
_ = tokio::time::sleep_until(until) => {
@@ -2836,6 +2889,41 @@ impl<B: BackingStorage> TurboTasksBackendInner<B> {
28362889
if let Some((snapshot_start, new_data)) = snapshot {
28372890
last_snapshot = snapshot_start;
28382891

2892+
// Evict persisted tasks from memory to reclaim space.
2893+
// Like compaction, this runs after snapshot_and_persist
2894+
// as a separate concern.
2895+
2896+
// TODO: should we only run if we stored new data? syncing data to disk
2897+
// implies that some of it is eligible for eviction, but if nothing was
2898+
// stored then that isn't true. on the other hand pre-fetching might
2899+
// bring unused data into the heap.
2900+
if this.should_evict() && new_data {
2901+
let idle_ended = tokio::select! {
2902+
biased;
2903+
_ = &mut idle_end_listener => {
2904+
idle_end_listener = self.idle_end_event.listen();
2905+
true
2906+
},
2907+
_ = std::future::ready(()) => false,
2908+
};
2909+
if !idle_ended {
2910+
let evict_span = tracing::info_span!(
2911+
parent: background_span.id(),
2912+
"evict tasks",
2913+
full = tracing::field::Empty,
2914+
data_and_meta = tracing::field::Empty,
2915+
data_only = tracing::field::Empty,
2916+
meta_only = tracing::field::Empty,
2917+
);
2918+
let _guard = evict_span.enter();
2919+
let counts = this.storage.evict_after_snapshot();
2920+
evict_span.record("full", counts.full);
2921+
evict_span.record("data_and_meta", counts.data_and_meta);
2922+
evict_span.record("data_only", counts.data_only);
2923+
evict_span.record("meta_only", counts.meta_only);
2924+
}
2925+
}
2926+
28392927
// Compact while idle (up to limit), regardless of
28402928
// whether the snapshot had new data.
28412929
// `background_span` is not entered here because

turbopack/crates/turbo-tasks-backend/src/backend/operation/mod.rs

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use turbo_tasks::{
1818
TurboTasksCallApi, TypedSharedReference, backend::CachedTaskType,
1919
};
2020

21-
use self::aggregation_update::ComputeDirtyAndCleanUpdate;
21+
pub use self::aggregation_update::ComputeDirtyAndCleanUpdate;
2222
use crate::{
2323
backend::{
2424
EventDescription, OperationGuard, TaskDataCategory, TurboTasksBackend,
@@ -266,18 +266,22 @@ impl<'e, B: BackingStorage> ExecuteContextImpl<'e, B> {
266266
for (i, &(task_id, category, _, _)) in tasks.iter().enumerate() {
267267
self.task_lock_counter.acquire();
268268

269-
let task = self.backend.storage.access_mut(task_id);
269+
let mut task = self.backend.storage.access_mut(task_id);
270270
let mut ready = true;
271271
if matches!(category, TaskDataCategory::Data | TaskDataCategory::All)
272272
&& !task.flags.is_restored(TaskDataCategory::Data)
273273
{
274+
// Mark as restoring so eviction backs off while we do I/O
275+
// without the lock held.
276+
task.flags.set_data_restoring(true);
274277
tasks_to_restore_for_data.push(task_id);
275278
tasks_to_restore_for_data_indicies.push(i);
276279
ready = false;
277280
}
278281
if matches!(category, TaskDataCategory::Meta | TaskDataCategory::All)
279282
&& !task.flags.is_restored(TaskDataCategory::Meta)
280283
{
284+
task.flags.set_meta_restoring(true);
281285
tasks_to_restore_for_meta.push(task_id);
282286
tasks_to_restore_for_meta_indicies.push(i);
283287
ready = false;
@@ -355,13 +359,15 @@ impl<'e, B: BackingStorage> ExecuteContextImpl<'e, B> {
355359
{
356360
task.restore_from(storage, TaskDataCategory::Data);
357361
task.flags.set_restored(TaskDataCategory::Data);
362+
task.flags.set_data_restoring(false);
358363
task_type = task.get_persistent_task_type().cloned()
359364
}
360365
if let Some(storage) = storage_for_meta
361366
&& !task.flags.is_restored(TaskDataCategory::Meta)
362367
{
363368
task.restore_from(storage, TaskDataCategory::Meta);
364369
task.flags.set_restored(TaskDataCategory::Meta);
370+
task.flags.set_meta_restoring(false);
365371
}
366372
self.task_lock_counter.release();
367373
prepared_task_callback(self, task_id, category, task);
@@ -487,6 +493,21 @@ impl<'e, B: BackingStorage> ExecuteContext<'e> for ExecuteContextImpl<'e, B> {
487493
category.includes_meta() && !task2.flags.is_restored(TaskDataCategory::Meta);
488494

489495
if needs_data1 || needs_meta1 || needs_data2 || needs_meta2 {
496+
// Mark as restoring so eviction backs off while we release
497+
// the locks to do I/O.
498+
if needs_data1 {
499+
task1.flags.set_data_restoring(true);
500+
}
501+
if needs_meta1 {
502+
task1.flags.set_meta_restoring(true);
503+
}
504+
if needs_data2 {
505+
task2.flags.set_data_restoring(true);
506+
}
507+
if needs_meta2 {
508+
task2.flags.set_meta_restoring(true);
509+
}
510+
490511
// Avoid holding the lock too long since this can also affect other tasks
491512
// Drop locks once, do all I/O, then re-acquire once
492513
drop(task1);
@@ -505,30 +526,35 @@ impl<'e, B: BackingStorage> ExecuteContext<'e> for ExecuteContextImpl<'e, B> {
505526
task1 = t1;
506527
task2 = t2;
507528

508-
// Merge results, handling race conditions
529+
// Merge results, handling race conditions — only clear restoring
530+
// flag if we win the race (see comment in task() above).
509531
if let Some(storage) = storage_data1
510532
&& !task1.flags.is_restored(TaskDataCategory::Data)
511533
{
512534
task1.restore_from(storage, TaskDataCategory::Data);
513535
task1.flags.set_restored(TaskDataCategory::Data);
536+
task1.flags.set_data_restoring(false);
514537
}
515538
if let Some(storage) = storage_meta1
516539
&& !task1.flags.is_restored(TaskDataCategory::Meta)
517540
{
518541
task1.restore_from(storage, TaskDataCategory::Meta);
519542
task1.flags.set_restored(TaskDataCategory::Meta);
543+
task1.flags.set_meta_restoring(false);
520544
}
521545
if let Some(storage) = storage_data2
522546
&& !task2.flags.is_restored(TaskDataCategory::Data)
523547
{
524548
task2.restore_from(storage, TaskDataCategory::Data);
525549
task2.flags.set_restored(TaskDataCategory::Data);
550+
task2.flags.set_data_restoring(false);
526551
}
527552
if let Some(storage) = storage_meta2
528553
&& !task2.flags.is_restored(TaskDataCategory::Meta)
529554
{
530555
task2.restore_from(storage, TaskDataCategory::Meta);
531556
task2.flags.set_restored(TaskDataCategory::Meta);
557+
task2.flags.set_meta_restoring(false);
532558
}
533559
}
534560
(
@@ -658,6 +684,7 @@ impl Display for TaskTypeRef<'_> {
658684
}
659685
}
660686

687+
#[derive(Debug)]
661688
pub enum TaskType {
662689
Cached(Arc<CachedTaskType>),
663690
Transient(Arc<TransientTask>),
@@ -956,12 +983,12 @@ pub trait TaskGuard: Debug + TaskStorageAccessors {
956983
fn get_task_desc_fn(&self) -> impl Fn() -> String + Send + Sync + 'static {
957984
let task_type = self.get_task_type().to_owned();
958985
let task_id = self.id();
959-
move || format!("{task_id:?} {task_type}")
986+
move || format!("{task_id:?} {task_type:?}")
960987
}
961988
fn get_task_description(&self) -> String {
962989
let task_type = self.get_task_type().to_owned();
963990
let task_id = self.id();
964-
format!("{task_id:?} {task_type}")
991+
format!("{task_id:?} {task_type:?}")
965992
}
966993
fn get_task_name(&self) -> String {
967994
let task_type = self.get_task_type().to_owned();

0 commit comments

Comments
 (0)