turbo_tasks_backend/backend/storage.rs
1use std::{
2 cell::Cell,
3 fmt::{Display, Formatter},
4 hash::{BuildHasher, Hash},
5 ops::{Deref, DerefMut},
6 sync::{
7 Arc,
8 atomic::{AtomicBool, AtomicU64, Ordering},
9 },
10};
11
12use dashmap::SharedValue;
13use hashbrown::raw::RawIntoIter;
14use thread_local::ThreadLocal;
15use tracing::span::Id;
16use turbo_bincode::TurboBincodeBuffer;
17use turbo_tasks::{FxDashMap, TaskId, backend::CachedTaskTypeArc, event::Event, parallel};
18
19use crate::{
20 backend::storage_schema::{
21 DropPartialOutcome, KeyEvictability, TaskStorage, UnevictableReason, ValueEvictability,
22 },
23 backing_storage::SnapshotItem,
24 database::key_value_database::KeySpace,
25 utils::{
26 dash_map_drop_contents::drop_contents,
27 dash_map_multi::{RefMut, get_multiple_mut},
28 dash_map_raw_entry::{TryLockAndRemove, try_lock_and_remove},
29 },
30};
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33pub enum TaskDataCategory {
34 Meta,
35 Data,
36 All,
37}
38
39/// Counts of tasks evicted at each level.
40#[derive(Debug, Default)]
41pub struct EvictionCounts {
42 pub key_evictions: usize,
43 pub full: usize,
44 pub data_and_meta: usize,
45 pub data_only: usize,
46 pub meta_only: usize,
47 /// Per-reason counts of tasks we considered but could not evict, indexed by
48 /// `UnevictableReason::index()`.
49 pub unevictable_reasons: [usize; UnevictableReason::COUNT],
50}
51
52impl std::ops::AddAssign for EvictionCounts {
53 fn add_assign(&mut self, rhs: Self) {
54 self.key_evictions += rhs.key_evictions;
55 self.full += rhs.full;
56 self.data_and_meta += rhs.data_and_meta;
57 self.data_only += rhs.data_only;
58 self.meta_only += rhs.meta_only;
59 for i in 0..UnevictableReason::COUNT {
60 self.unevictable_reasons[i] += rhs.unevictable_reasons[i];
61 }
62 }
63}
64
65impl Display for EvictionCounts {
66 /// Compact `field=value,...` form used as a single tracing span field so that
67 /// adding a new counter or `UnevictableReason` variant doesn't require updating
68 /// the span field list.
69 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
70 let skipped: usize = self.unevictable_reasons.iter().sum();
71 write!(
72 f,
73 "task_cache_evictions={},full={},data_and_meta={},data_only={},meta_only={},skipped={}",
74 self.key_evictions,
75 self.full,
76 self.data_and_meta,
77 self.data_only,
78 self.meta_only,
79 skipped,
80 )?;
81 for reason in UnevictableReason::ALL {
82 write!(
83 f,
84 ",{}={}",
85 reason.span_name(),
86 self.unevictable_reasons[reason.index()],
87 )?;
88 }
89 Ok(())
90 }
91}
92
93impl TaskDataCategory {
94 pub fn includes_data(self) -> bool {
95 matches!(self, TaskDataCategory::Data | TaskDataCategory::All)
96 }
97
98 pub fn includes_meta(self) -> bool {
99 matches!(self, TaskDataCategory::Meta | TaskDataCategory::All)
100 }
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
104pub enum SpecificTaskDataCategory {
105 Meta,
106 Data,
107}
108
109impl From<SpecificTaskDataCategory> for TaskDataCategory {
110 fn from(category: SpecificTaskDataCategory) -> Self {
111 match category {
112 SpecificTaskDataCategory::Meta => TaskDataCategory::Meta,
113 SpecificTaskDataCategory::Data => TaskDataCategory::Data,
114 }
115 }
116}
117
118impl SpecificTaskDataCategory {
119 /// Returns the KeySpace for storing data of this category
120 pub fn key_space(self) -> KeySpace {
121 match self {
122 SpecificTaskDataCategory::Meta => KeySpace::TaskMeta,
123 SpecificTaskDataCategory::Data => KeySpace::TaskData,
124 }
125 }
126}
127
128/// Records exactly what a `track_modification` call changed, so that
129/// [`StorageWriteGuard::undo_track_modification`] can reverse it precisely when the mutation it
130/// guarded turns out to be a no-op. This allows us to track modifications 'optimistically' and
131/// undo it if the modification turned out to be a no op. Useful when dealing with datastructures
132/// like `AutoSet` that can efficiently say whether or not they were modified.
133#[must_use = "a no-op mutation must undo its TrackOutcome; dropping it leaks an over-track"]
134pub enum TrackOutcome {
135 /// Nothing was tracked: either the category was already modified, or (in snapshot mode) it was
136 /// already modified-during-snapshot. Undo is a no-op.
137 NoChange,
138 /// Non-snapshot path: `modified(category)` was set. `bumped` is true if this call also
139 /// incremented the per-shard modified counter (i.e. the task had no prior modifications).
140 Tracked {
141 category: SpecificTaskDataCategory,
142 bumped: bool,
143 },
144 /// Snapshot path: `modified_during_snapshot(category)` was set. `inserted_snapshot` is true if
145 /// this call also inserted the task's entry into the `snapshots` map (the pre-mutation copy or
146 /// a `None` marker).
147 TrackedDuringSnapshot {
148 category: SpecificTaskDataCategory,
149 inserted_snapshot: bool,
150 },
151}
152
153pub struct Storage {
154 snapshot_mode: AtomicBool,
155 /// Per-shard counts of tasks with modified flags set. Incremented when a task
156 /// transitions from unmodified to modified (outside snapshot mode). Reset to zero when
157 /// snapshot mode begins, and re-incremented in `end_snapshot` for tasks that still have
158 /// modifications (promoted from `modified_during_snapshot`). Used to skip unmodified shards
159 /// in `take_snapshot`, avoiding unnecessary iteration and enabling early returns
160 ///
161 /// Indexed by `map.determine_shard(map.hash_usize(&key))` and guaranteed by construction so
162 /// that `shard_modified_counts.len()==map.shards().len()`
163 ///
164 /// Should only be modified while holding the corresponding dashmap shard lock.
165 shard_modified_counts: Box<[AtomicU64]>,
166 /// Stores snapshots of task state for tasks accessed during snapshot mode.
167 /// - `Some(snapshot)`: Task was modified before snapshot mode and accessed again during it.
168 /// Contains a copy of the pre-snapshot state that needs to be persisted.
169 /// - `None`: Task was first modified during snapshot mode (not part of current snapshot). Will
170 /// be marked as modified at the beginning of the next snapshot cycle.
171 ///
172 /// Lock Ordering: `snapshots` locks are acquired **after** `map` locks (see the comment on
173 /// `map` below). Holding a `snapshots` shard write lock and then trying to take a `map` shard
174 /// write lock is forbidden — it would deadlock against `track_modification_internal` /
175 /// `SnapshotShardIter::next`, which take map first.
176 ///
177 /// Shard Invariant: `snapshots` is constructed with the same `shard_amount`, the same key
178 /// type (`TaskId`), and the same stateless hasher (`FxBuildHasher`) as `map`. Therefore shard
179 /// index `N` in `snapshots` corresponds exactly to shard index `N` in `map`: any `TaskId`
180 /// present in `snapshots.shards()[N]` (if present in `map` at all) is in `map.shards()[N]`.
181 /// Code that walks both maps in parallel (e.g. `end_snapshot`) relies on this to lock pairs
182 /// of shards by index instead of going through the top-level `DashMap` accessors.
183 snapshots: FxDashMap<TaskId, Option<Box<TaskStorage>>>,
184 /// The main storage map
185 ///
186 /// Lock Ordering: Task creation acquires a `task_cache` lock and then inserts into this map.
187 /// Because both datastructures are sharded on different keys, the locks are not 'strictly'
188 /// ordered but we should treat them as such
189 /// Acquiring locks in the opposite order should be defensive
190 ///
191 /// Lock Ordering vs. `snapshots`: `map` locks are acquired **before** `snapshots` locks.
192 /// `track_modification_internal` and `SnapshotShardIter::next` both hold a `map` shard write
193 /// lock (via `StorageWriteGuard` / `map.get_mut`) and then take a `snapshots` shard lock.
194 /// `end_snapshot` must lock in the same order — see the shard-zipping pattern there.
195 map: FxDashMap<TaskId, Box<TaskStorage>>,
196 /// A shared event notified whenever any task finishes restoring (successfully or not).
197 ///
198 /// Threads waiting for another thread's in-progress restore subscribe to this event,
199 /// then re-check the specific task's `restoring`/`restored` bits after waking.
200 pub(crate) restored: Event,
201 /// Maps `CachedTaskType` → `TaskId` for deduplication of persistent task creation.
202 /// This is backed by the TaskCache table in the database.
203 ///
204 /// LockOrdering: See the comments on [map].
205 pub task_cache: FxDashMap<CachedTaskTypeArc, TaskId>,
206}
207
208impl Storage {
209 pub fn new(shard_amount: usize, small_preallocation: bool) -> Self {
210 let map_capacity: usize = if small_preallocation {
211 1024
212 } else {
213 1024 * 1024
214 };
215
216 let map = FxDashMap::with_capacity_and_hasher_and_shard_amount(
217 map_capacity,
218 Default::default(),
219 shard_amount,
220 );
221 let shard_modified_counts = (0..shard_amount)
222 .map(|_| AtomicU64::new(0))
223 .collect::<Vec<_>>()
224 .into_boxed_slice();
225 Self {
226 snapshot_mode: AtomicBool::new(false),
227 shard_modified_counts,
228 snapshots: FxDashMap::with_capacity_and_hasher_and_shard_amount(
229 // We expect very few updates to this map since it will only happen when updates
230 // race with snapshots. This never happens in a build and only rarely happens in
231 // dev sessions
232 0,
233 Default::default(),
234 shard_amount,
235 ),
236 map,
237 restored: Event::new(|| || "Storage::restored".to_string()),
238 task_cache: FxDashMap::default(),
239 }
240 }
241
242 /// Returns the shard index for the given key in the `map` DashMap.
243 fn shard_index(&self, key: &TaskId) -> usize {
244 let hash = self.map.hash_usize(key);
245 self.map.determine_shard(hash)
246 }
247
248 /// Promote `modified_during_snapshot` → `modified` flags on a task, and increment the
249 /// per-shard modified count if the task was not already marked as modified.
250 ///
251 /// This is used after persisting a snapshot: _during_snapshot flags represent changes
252 /// that occurred concurrently and were not included in the persisted snapshot, so they
253 /// must be carried forward as `modified` for the next snapshot cycle.
254 fn promote_during_snapshot_flags(&self, task: &mut TaskStorage, shard_idx: usize) {
255 let already_modified = task.flags.any_modified();
256 let mut promoted = false;
257 if task.flags.meta_modified_during_snapshot() {
258 task.flags.set_meta_modified_during_snapshot(false);
259 task.flags.set_meta_modified(true);
260 promoted = true;
261 }
262 if task.flags.data_modified_during_snapshot() {
263 task.flags.set_data_modified_during_snapshot(false);
264 task.flags.set_data_modified(true);
265 promoted = true;
266 }
267 if !already_modified && promoted {
268 self.shard_modified_counts[shard_idx].fetch_add(1, Ordering::Relaxed);
269 }
270 }
271
272 /// Mark a newly allocated task as restored (skip DB queries) and new (include in persistence
273 /// snapshots). Optionally sets the `persistent_task_type` eagerly so it's available for
274 /// persistence snapshots without needing to propagate it through `connect_child`.
275 pub fn initialize_new_task(&self, task_id: TaskId, task_type: Option<CachedTaskTypeArc>) {
276 let mut task = self.access_mut(task_id);
277 task.flags.set_restored(TaskDataCategory::All);
278 task.flags.set_new_task(true);
279 if let Some(task_type) = task_type {
280 task.set_persistent_task_type(task_type);
281 if !task_id.is_transient() {
282 // Unconditional track: a new task's type is always a real persistable change.
283 let _ =
284 task.track_modification(SpecificTaskDataCategory::Data, "persistent_task_type");
285 }
286 }
287 }
288
289 /// Processes every modified item (resp. a snapshot of it) with the given function and returns
290 /// the results. Ends snapshot mode when the returned `SnapshotGuard` (held by each shard) is
291 /// dropped.
292 ///
293 /// `process` is called while holding a read lock on the task storage, so it can access
294 /// the TaskStorage directly without cloning.
295 ///
296 /// Both callbacks receive a mutable scratch buffer that can be reused across iterations
297 /// to avoid repeated allocations.
298 ///
299 /// The returned shards implement `IntoIterator`. Empty shards (no modified or snapshot
300 /// entries) are filtered out, but shards may still yield no items if all entries produce
301 /// empty `SnapshotItem`s (this is rare and only happens under error conditions).
302 ///
303 /// When `drain_entries` is true (shutdown only), the scan drains the map: unmodified entries
304 /// are erased and freed immediately, and the modified entries are moved out into the
305 /// returned shard iterators, which free each task's memory as it is serialized rather than
306 /// after the whole batch is written.
307 pub fn take_snapshot<
308 'l,
309 P: for<'a> Fn(TaskId, &'a TaskStorage, &mut TurboBincodeBuffer) -> SnapshotItem + Sync,
310 >(
311 &'l self,
312 guard: SnapshotGuard<'l>,
313 process: &'l P,
314 drain_entries: bool,
315 ) -> Vec<SnapshotShard<'l, P>> {
316 let guard = Arc::new(guard);
317
318 let shards: Vec<_> = self.map.shards().iter().enumerate().collect();
319
320 // The number of shards is much larger than the number of threads, so the effect of the
321 // locks held is negligible.
322 parallel::map_collect::<_, _, Vec<_>>(&shards, |&(shard_idx, shard)| {
323 // Check how many modifications there are in this shard, because we have entered
324 // snapshot_mode, there are no racing writes
325 // So we can safely clear it out now that we are processing the modifications
326 let modified_count = self.shard_modified_counts[shard_idx].swap(0, Ordering::Relaxed);
327
328 if modified_count == 0 && !drain_entries {
329 // Nothing to persist in this shard and we're keeping the map, so skip the scan.
330 // TODO: when not draining but eviction is enabled we should run that logic here as
331 // well
332 return None;
333 }
334
335 // Scan the shard once, building the work this shard's iterator will perform. The two
336 // modes carry different data so that `next` has no per-item `drain` branch:
337 // - keep mode collects the modified `TaskId`s and looks them up again while iterating.
338 // - drain mode erases the unmodified entries here and then moves the remaining
339 // (modified-only) table out of the map, so the iterator owns and drains it directly.
340 let work = {
341 let mut shard_guard = shard.write();
342 if drain_entries {
343 // SAFETY: shard_guard outlives the iterator and we hold it for the whole scan.
344 for bucket in unsafe { shard_guard.iter() } {
345 // Read the key and modified flag, then drop the borrow before any erase.
346 // SAFETY: the guard outlives the bucket reference.
347 let (key, modified_task) = {
348 let (key, shared_value) = unsafe { bucket.as_ref() };
349 (*key, shared_value.get().flags.any_modified())
350 };
351 if modified_task {
352 debug_assert!(
353 !key.is_transient(),
354 "found a modified transient task: {key:?}"
355 );
356 } else {
357 // Unmodified entries are not part of the snapshot. Erase and free them
358 // now so the table we move out below holds only modified entries.
359 unsafe { shard_guard.erase(bucket) };
360 }
361 }
362 if shard_guard.is_empty() {
363 // The shard held only unmodified entries, which we've now erased and freed.
364 // No iterator is created for an empty shard.
365 return None;
366 }
367 // Move the modified-only table out of the map. Iterating it frees each task box
368 // as it is serialized, and the shard's table allocation is released here.
369 ShardWork::Drain(std::mem::take(&mut *shard_guard).into_iter())
370 } else {
371 let mut modified = Vec::with_capacity(modified_count as usize);
372 // SAFETY: shard_guard outlives the iterator and we hold it for the whole scan.
373 for bucket in unsafe { shard_guard.iter() } {
374 // SAFETY: the guard outlives the bucket reference.
375 let (key, shared_value) = unsafe { bucket.as_ref() };
376 // Only check modified flags — transient tasks never have modified flags set
377 // (track_modification guards against it), so this naturally excludes them.
378 // new_task always comes with modified flags (set_persistent_task_type calls
379 // track_modification), so any_modified() is sufficient.
380 if shared_value.get().flags.any_modified() {
381 debug_assert!(
382 !key.is_transient(),
383 "found a modified transient task: {key:?}"
384 );
385 modified.push(*key);
386 }
387 }
388 // modified_count > 0 (we returned early otherwise), so this is never empty.
389 debug_assert!(!modified.is_empty());
390 ShardWork::Keep(modified)
391 }
392 };
393
394 Some(SnapshotShard {
395 shard_idx,
396 work,
397 storage: self,
398 process,
399 _guard: guard.clone(),
400 })
401 })
402 .into_iter()
403 .flatten()
404 .collect()
405 }
406
407 /// Enter snapshot mode and return a guard that will call `end_snapshot` on drop.
408 ///
409 /// Returns whether any shard has modifications. Per-shard counts are reset
410 /// in `take_snapshot` as each shard is processed, not here — resetting eagerly
411 /// would lose track of modifications for shards that haven't been persisted yet.
412 ///
413 /// Safety invariant: `start_snapshot` and `end_snapshot` are always called
414 /// sequentially within a single `snapshot_and_persist` invocation (the sole
415 /// caller). There is no concurrent snapshot lifecycle, so they cannot race.
416 pub fn start_snapshot(&self) -> (SnapshotGuard<'_>, bool) {
417 // Enter snapshot mode first so concurrent track_modification calls switch
418 // to the _during_snapshot path and stop incrementing shard_modified_counts.
419 self.snapshot_mode.store(true, Ordering::Release);
420 // Check if any shard has modifications. Don't reset counts here —
421 // take_snapshot resets per-shard counts as it processes each shard,
422 // which avoids losing track of modifications for shards that haven't
423 // been persisted yet.
424 let has_modifications = self
425 .shard_modified_counts
426 .iter()
427 .any(|c| c.load(Ordering::Relaxed) > 0);
428 (SnapshotGuard::new(self), has_modifications)
429 }
430
431 /// End snapshot mode.
432 ///
433 /// Modified/new flags on tasks are cleared incrementally during snapshot iteration
434 /// (in `take_snapshot` for direct_snapshots, and in `SnapshotShardIter::next` for
435 /// modified tasks), so no full-map scan is needed here.
436 ///
437 /// This method only needs to:
438 /// 1. Leave snapshot mode so new modifications go to the modified flags directly.
439 /// 2. Promote `modified_during_snapshot` → `modified` for tasks that were accessed during
440 /// snapshot mode (tracked in the small `snapshots` map).
441 fn end_snapshot(&self) {
442 // Leave snapshot mode first. After this, concurrent track_modification calls
443 // will set modified flags directly instead of going through the snapshots map.
444 self.snapshot_mode.store(false, Ordering::Release);
445
446 // Promote modified_during_snapshot → modified for tasks that had snapshots.
447 // The snapshots map should be small (only tasks concurrently accessed during snapshot
448 // mode). Increment the per-shard modified counts for promoted tasks.
449
450 // Lock Ordering: we must acquire `map` shards BEFORE `snapshots` shards, matching the
451 // order used by `track_modification_internal` and `SnapshotShardIter::next`. The
452 // previous implementation drained `snapshots` first and then called `self.map.get_mut`,
453 // which is the opposite order — a concurrent `track_modification` (holding map[N], about
454 // to insert into snapshots[N]) could deadlock against it through the
455 // `snapshot_mode = false` race window.
456 //
457 // Shard pairing: `map` and `snapshots` are constructed with the same `shard_amount`,
458 // same `TaskId` keys, and the same stateless `FxBuildHasher`. Therefore shard `N` in
459 // `snapshots` pairs with shard `N` in `map`: every key drained from `snapshots[N]` (if
460 // it still exists in `map`) lives in `map[N]`. We zip them and lock each pair in order.
461 let map_shards = self.map.shards();
462 let snapshot_shards = self.snapshots.shards();
463 debug_assert_eq!(
464 map_shards.len(),
465 snapshot_shards.len(),
466 "map and snapshots must share shard count for zipped locking; see Shard Invariant on \
467 `snapshots` field"
468 );
469
470 let shard_indices: Vec<usize> = (0..map_shards.len()).collect();
471 parallel::for_each(&shard_indices, |&shard_idx| {
472 let map_shard = &map_shards[shard_idx];
473 let snap_shard = &snapshot_shards[shard_idx];
474
475 // Acquire in documented order: map first, snapshots second.
476 let map_guard = map_shard.write();
477 let mut snap_guard = snap_shard.write();
478
479 for (key, _) in snap_guard.drain() {
480 // The key is in this shard's `map` (or absent entirely), by the shard
481 // invariant above. Resolve directly in the held map guard rather than going
482 // through `self.map.get_mut`, which would attempt to re-acquire this shard's
483 // write lock and would also obscure the pairing.
484 let hash = self.map.hasher().hash_one(key);
485 if let Some(bucket) = map_guard.find(hash, |(k, _)| *k == key) {
486 // SAFETY: We hold `map_shard`'s write lock for the duration of this
487 // access, so the bucket pointer is valid and no other thread can alias it.
488 let (_, shared_value) = unsafe { bucket.as_mut() };
489 self.promote_during_snapshot_flags(shared_value.get_mut(), shard_idx);
490 }
491 }
492 // If we are saving a non-trivial amount of memory just clear it out.
493 if snap_guard.capacity() > 1024 {
494 snap_guard.shrink_to(0, |_entry| {
495 unreachable!("nothing is hashed when resizing an empty shard to zero");
496 });
497 }
498
499 drop(snap_guard);
500 drop(map_guard);
501 });
502 }
503
504 /// Returns true if actively snapshotting (modifications should go to snapshots map).
505 /// Returns false if inactive (modifications go to modified list).
506 fn snapshot_mode(&self) -> bool {
507 self.snapshot_mode.load(Ordering::Acquire)
508 }
509
510 pub fn access_mut(&self, key: TaskId) -> StorageWriteGuard<'_> {
511 let inner = match self.map.entry(key) {
512 dashmap::mapref::entry::Entry::Occupied(e) => e.into_ref(),
513 dashmap::mapref::entry::Entry::Vacant(e) => e.insert(Box::new(TaskStorage::new())),
514 };
515 StorageWriteGuard {
516 storage: self,
517 inner: inner.into(),
518 }
519 }
520
521 pub fn access_pair_mut(
522 &self,
523 key1: TaskId,
524 key2: TaskId,
525 ) -> (StorageWriteGuard<'_>, StorageWriteGuard<'_>) {
526 let (a, b) = get_multiple_mut(&self.map, key1, key2, || Box::new(TaskStorage::new()));
527 (
528 StorageWriteGuard {
529 storage: self,
530 inner: a,
531 },
532 StorageWriteGuard {
533 storage: self,
534 inner: b,
535 },
536 )
537 }
538
539 pub fn drop_contents(&self) {
540 drop_contents(&self.map);
541 drop_contents(&self.snapshots);
542 }
543
544 /// Drop the `task_cache` map, freeing its memory.
545 pub(crate) fn drop_task_cache(&self) {
546 drop_contents(&self.task_cache);
547 }
548
549 /// Evict tasks from in-memory storage after a successful snapshot.
550 ///
551 /// Iterates all tasks and applies the eviction level returned by
552 /// `TaskStorage::evictability()`:
553 /// - `Full`: remove from map entirely
554 /// - `DataAndMeta`: drop both data and meta fields, keep task in map
555 /// - `DataOnly`: drop data fields only
556 /// - `MetaOnly`: drop meta fields only
557 /// - `No`: skip
558 ///
559 /// Must be called when NOT in snapshot mode (i.e., after `end_snapshot()`).
560 pub fn evict_after_snapshot(&self, parent_span: Option<Id>) -> EvictionCounts {
561 let span = tracing::trace_span!(
562 parent: parent_span,
563 "evict_after_snapshot",
564 total_task_cache_keys = self.task_cache.len(),
565 total_map_keys = self.map.len(),
566 counts = tracing::field::Empty,
567 )
568 .entered();
569 debug_assert!(
570 !self.snapshot_mode(),
571 "evict_after_snapshot must not be called during snapshot mode"
572 );
573
574 let counts: Vec<EvictionCounts> = parallel::map_collect(self.map.shards(), |shard| {
575 let mut shard = shard.write();
576 let mut evicted = EvictionCounts::default();
577 // task_cache removals that we couldn't perform inline because the target shard
578 // was contended. We defer them until after the map shard lock is released to
579 // avoid a lock cycle with get_or_create_persistent_task, which takes task_cache
580 // before map. Allocated lazily on first conflict.
581 let mut deferred_task_cache_removals: Vec<CachedTaskTypeArc> = Vec::new();
582 // SAFETY: We hold the write lock for the duration of iteration.
583 for bucket in unsafe { shard.iter() } {
584 // SAFETY: The write lock guard outlives the bucket reference.
585 let (task_id, task) = unsafe { bucket.as_mut() };
586 if task_id.is_transient() {
587 evicted.unevictable_reasons[UnevictableReason::Transient.index()] += 1;
588 continue;
589 }
590 let (key_evictability, value_evictability) = task.get().evictability();
591 match key_evictability {
592 KeyEvictability::Evictable => {
593 // The task type is persisted to backing storage (new_task = false),
594 // so task_cache is a pure perf cache. Remove it now; it will be
595 // re-populated by task_by_type() on the next cache miss.
596 let task_type = task.get().get_persistent_task_type().unwrap();
597 // Only try to acquire the lock, if we cannot just remove at the end
598 // Because `get_or_create_task` acquires 'task_cache' then `storage.map` and
599 // we do the opposite we need to be defensive here. Attempting here is just
600 // an optimization to avoid pushing into `deferred_task_cache_removals`
601 match try_lock_and_remove(&self.task_cache, task_type.as_ref()) {
602 TryLockAndRemove::Removed => {
603 evicted.key_evictions += 1;
604 }
605 TryLockAndRemove::NotFound => {
606 // Generally this should be rare, it more or less implies something
607 // else is concurrently holding the Arc
608 }
609 TryLockAndRemove::WouldBlock => {
610 // Contention, to avoid a deadlock just defer
611 deferred_task_cache_removals.push(task_type.clone());
612 }
613 }
614 }
615 KeyEvictability::AlreadyEvicted | KeyEvictability::Unevictable => {}
616 }
617 match value_evictability {
618 ValueEvictability::Evictable { meta, data } => {
619 match task.get_mut().drop_partial(data, meta) {
620 DropPartialOutcome::Empty => {
621 unsafe {
622 shard.erase(bucket);
623 }
624 evicted.full += 1;
625 }
626 DropPartialOutcome::HasResidue => {
627 if data && meta {
628 evicted.data_and_meta += 1;
629 } else if data {
630 evicted.data_only += 1;
631 } else {
632 debug_assert!(meta);
633 evicted.meta_only += 1;
634 }
635 }
636 }
637 }
638 ValueEvictability::Unevictable(reason) => {
639 evicted.unevictable_reasons[reason.index()] += 1;
640 }
641 }
642 }
643 // Shrink the shard if it's less than half full, to reclaim slack capacity
644 // after bulk evictions. We already hold the write lock, so this is free
645 // from a locking perspective. TaskId hashing is cheap (it's just an integer).
646 let len = shard.len();
647 if shard.capacity() > len * 2 {
648 shard.shrink_to(len, |(k, _v)| self.map.hasher().hash_one(k));
649 }
650 // Release the map shard lock before draining deferred removals so that a thread
651 // holding a task_cache shard lock and waiting on this map shard can make progress.
652 drop(shard);
653 for task_type in deferred_task_cache_removals {
654 if self.task_cache.remove(task_type.as_ref()).is_some() {
655 evicted.key_evictions += 1;
656 }
657 }
658 evicted
659 });
660
661 let mut totals = EvictionCounts::default();
662 for evicted in counts {
663 totals += evicted;
664 }
665 // Shrink task_cache only when we evicted more entries than remain — i.e. the map
666 // is less than half full. Rehashing each surviving CachedTaskType isn't free, so
667 // we gate it on meaningful slack. Within that, walk shards in parallel and shrink
668 // each one independently if it is itself less than half full.
669 if totals.key_evictions > self.task_cache.len() {
670 parallel::for_each(self.task_cache.shards(), |shard| {
671 let mut shard = shard.write();
672 let len = shard.len();
673 if shard.capacity() > len * 2 {
674 shard.shrink_to(len, |(k, _v)| self.task_cache.hasher().hash_one(k));
675 }
676 });
677 }
678 span.record("counts", tracing::field::display(&totals));
679
680 totals
681 }
682}
683
684pub struct StorageWriteGuard<'a> {
685 storage: &'a Storage,
686 inner: RefMut<'a, TaskId, Box<TaskStorage>>,
687}
688
689impl StorageWriteGuard<'_> {
690 /// Tracks mutation of this task.
691 #[inline(always)]
692 pub fn track_modification(
693 &mut self,
694 category: SpecificTaskDataCategory,
695 #[allow(unused_variables)] name: &str,
696 ) -> TrackOutcome {
697 debug_assert!(
698 !self.inner.key().is_transient(),
699 "transient task_ids should never be enqueued to be persisted"
700 );
701 self.track_modification_internal(
702 category,
703 #[cfg(feature = "trace_task_modification")]
704 name,
705 )
706 }
707
708 fn track_modification_internal(
709 &mut self,
710 category: SpecificTaskDataCategory,
711 #[cfg(feature = "trace_task_modification")] name: &str,
712 ) -> TrackOutcome {
713 // Transient tasks are never persisted, so tracking modifications is meaningless.
714 // All callers (TaskGuard, invalidate_serialization) already
715 // guard against this, but we enforce it here as defense-in-depth.
716 debug_assert!(
717 !self.inner.key().is_transient(),
718 "track_modification called on transient task {:?}",
719 self.inner.key()
720 );
721 let flags = &self.inner.flags;
722 if flags.is_modified_during_snapshot(category) {
723 // We can early return since `end_snapshot` is responsible for reconciling.
724 return TrackOutcome::NoChange;
725 }
726 #[cfg(feature = "trace_task_modification")]
727 let _span = (!modified).then(|| tracing::trace_span!("mark_modified", name).entered());
728 match (self.storage.snapshot_mode(), flags.is_modified(category)) {
729 (false, false) => {
730 // Not in snapshot mode and item is unmodified
731 let bumped = !flags.any_modified();
732 if bumped {
733 let shard_idx = self.storage.shard_index(self.inner.key());
734 self.storage.shard_modified_counts[shard_idx].fetch_add(1, Ordering::Relaxed);
735 }
736 self.inner.flags.set_modified(category, true);
737 TrackOutcome::Tracked { category, bumped }
738 }
739 (false, true) => {
740 // Not in snapshot mode and item is already modified
741 // Do nothing
742 TrackOutcome::NoChange
743 }
744 (true, false) => {
745 // In snapshot mode and item is unmodified (so it's not part of the snapshot)
746 // Mark it so it gets re-added as Modified after this snapshot completes.
747 // Insert a None entry into snapshots so end_snapshot discovers this task
748 // and promotes its _during_snapshot flags.
749 let inserted_snapshot = !flags.any_modified_during_snapshot();
750 if inserted_snapshot {
751 self.storage.snapshots.insert(*self.inner.key(), None);
752 }
753 self.inner
754 .flags
755 .set_modified_during_snapshot(category, true);
756 TrackOutcome::TrackedDuringSnapshot {
757 category,
758 inserted_snapshot,
759 }
760 }
761 (true, true) => {
762 // In snapshot mode and item is modified (so it's part of the snapshot)
763 // We need to store the original version that is part of the snapshot
764 let inserted_snapshot = !flags.any_modified_during_snapshot();
765 if inserted_snapshot {
766 // Snapshot all non-transient fields, carrying the modified bits into
767 // the copy so the iterator knows which categories to persist.
768 let mut snapshot = self.inner.clone_snapshot();
769 snapshot.flags.set_data_modified(flags.data_modified());
770 snapshot.flags.set_meta_modified(flags.meta_modified());
771 snapshot.flags.set_new_task(flags.new_task());
772 self.storage
773 .snapshots
774 .insert(*self.inner.key(), Some(Box::new(snapshot)));
775 }
776 self.inner
777 .flags
778 .set_modified_during_snapshot(category, true);
779 TrackOutcome::TrackedDuringSnapshot {
780 category,
781 inserted_snapshot,
782 }
783 }
784 }
785 }
786
787 /// Reverse a [`TrackOutcome`] produced by [`Self::track_modification`] when the mutation it
788 /// guarded changed nothing persistable.
789 ///
790 /// # Correctness
791 ///
792 /// The `outcome` MUST be applied to the **same `StorageWriteGuard`** that produced it, with the
793 /// map shard write lock held continuously in between — i.e. `track_modification`, the mutation,
794 /// and `undo_track_modification` all run within one guard's lifetime. The guard holds its shard
795 /// write lock for its whole lifetime, so this guarantees no other thread observed the tracked
796 /// state, and that `bumped` / `inserted_snapshot` still describe reality (the counter and
797 /// `snapshots` entry are only mutated under that lock). Because those flags record whether
798 /// *this* call created the state, undo never clears a flag, counter, or snapshot entry that a
799 /// prior modification owns.
800 pub fn undo_track_modification(&mut self, outcome: TrackOutcome) {
801 match outcome {
802 TrackOutcome::NoChange => {}
803 TrackOutcome::Tracked { category, bumped } => {
804 self.inner.flags.set_modified(category, false);
805 if bumped {
806 let shard_idx = self.storage.shard_index(self.inner.key());
807 self.storage.shard_modified_counts[shard_idx].fetch_sub(1, Ordering::Relaxed);
808 }
809 }
810 TrackOutcome::TrackedDuringSnapshot {
811 category,
812 inserted_snapshot,
813 } => {
814 self.inner
815 .flags
816 .set_modified_during_snapshot(category, false);
817 if inserted_snapshot {
818 self.storage.snapshots.remove(self.inner.key());
819 }
820 }
821 }
822 }
823}
824
825impl Deref for StorageWriteGuard<'_> {
826 type Target = TaskStorage;
827
828 fn deref(&self) -> &Self::Target {
829 &self.inner
830 }
831}
832
833impl DerefMut for StorageWriteGuard<'_> {
834 fn deref_mut(&mut self) -> &mut Self::Target {
835 &mut self.inner
836 }
837}
838
839/// How big of a buffer to allocate initially. Based on metrics from a large
840/// application this should cover about 98% of values with no resizes.
841const SCRATCH_BUFFER_INITIAL_SIZE: usize = 4096;
842
843/// State machine for a per-thread scratch buffer slot.
844///
845/// Transitions:
846/// - `Uninit` → `Taken` (first take)
847/// - `Available` → `Taken` (subsequent takes)
848/// - `Taken` → `Available` (return)
849///
850/// Any other transition is a bug (e.g. double-take or double-return).
851#[derive(Default)]
852enum ScratchBufferSlot {
853 /// No buffer has been allocated on this thread yet.
854 #[default]
855 Uninit,
856 /// The buffer is currently checked out.
857 Taken,
858 /// The buffer is available for reuse.
859 Available(TurboBincodeBuffer),
860}
861
862pub struct SnapshotGuard<'l> {
863 storage: &'l Storage,
864 /// Per-thread scratch buffers for encoding task data. Buffers are taken
865 /// by `SnapshotShardIter` on creation and returned on drop, allowing reuse
866 /// across multiple shards processed by the same thread. When the guard is
867 /// dropped (after all iterators are done), the `ThreadLocal` drops too,
868 /// freeing all buffers.
869 scratch_buffers: ThreadLocal<Cell<ScratchBufferSlot>>,
870}
871
872impl<'l> SnapshotGuard<'l> {
873 fn new(storage: &'l Storage) -> Self {
874 Self {
875 storage,
876 scratch_buffers: ThreadLocal::new(),
877 }
878 }
879
880 fn take_scratch_buffer(&self) -> TurboBincodeBuffer {
881 let cell = self.scratch_buffers.get_or_default();
882 match cell.take() {
883 ScratchBufferSlot::Available(buf) => {
884 cell.set(ScratchBufferSlot::Taken);
885 buf
886 }
887 ScratchBufferSlot::Uninit => {
888 cell.set(ScratchBufferSlot::Taken);
889 TurboBincodeBuffer::with_capacity(SCRATCH_BUFFER_INITIAL_SIZE)
890 }
891 ScratchBufferSlot::Taken => {
892 panic!("scratch buffer taken twice without being returned");
893 }
894 }
895 }
896
897 fn return_scratch_buffer(&self, buffer: TurboBincodeBuffer) {
898 let cell = self.scratch_buffers.get_or_default();
899 match cell.take() {
900 ScratchBufferSlot::Taken => cell.set(ScratchBufferSlot::Available(buffer)),
901 ScratchBufferSlot::Available(_) => {
902 panic!("scratch buffer returned without being taken (already available)");
903 }
904 ScratchBufferSlot::Uninit => {
905 panic!("scratch buffer returned without being taken (uninit)");
906 }
907 }
908 }
909}
910
911impl Drop for SnapshotGuard<'_> {
912 fn drop(&mut self) {
913 self.storage.end_snapshot();
914 }
915}
916
917/// The work a single shard's iterator performs, with the snapshot mode encoded in the data rather
918/// than a runtime flag re-checked per item. Built by `take_snapshot`'s scan.
919enum ShardWork {
920 /// Normal snapshot: look each task up in the map while iterating, serialize it, then clear and
921 /// promote its modified flags so it stays dirty for the next snapshot cycle.
922 Keep(Vec<TaskId>),
923 /// Shutdown drain: the scan already erased the unmodified entries and moved the remaining
924 /// (modified-only) shard table out of the map. The iterator owns that table and drains it
925 /// directly, freeing each task box as it is serialized. No second map lookup, no flag
926 /// bookkeeping (the whole map is discarded right after this snapshot).
927 Drain(RawIntoIter<(TaskId, SharedValue<Box<TaskStorage>>)>),
928}
929
930pub struct SnapshotShard<'l, P> {
931 shard_idx: usize,
932 work: ShardWork,
933 storage: &'l Storage,
934 process: &'l P,
935 /// Held for its `Drop` impl — ensures snapshot mode ends when all shards are done.
936 _guard: Arc<SnapshotGuard<'l>>,
937}
938
939impl<'l, P> IntoIterator for SnapshotShard<'l, P>
940where
941 P: Fn(TaskId, &TaskStorage, &mut TurboBincodeBuffer) -> SnapshotItem + Sync,
942{
943 type Item = SnapshotItem;
944 type IntoIter = SnapshotShardIter<'l, P>;
945
946 fn into_iter(self) -> Self::IntoIter {
947 let buffer = self._guard.take_scratch_buffer();
948 SnapshotShardIter {
949 shard: self,
950 buffer,
951 }
952 }
953}
954
955/// Iterator over a single shard's snapshot items. Holds a thread-local scratch
956/// buffer for the duration of iteration and returns it on drop.
957pub struct SnapshotShardIter<'l, P> {
958 shard: SnapshotShard<'l, P>,
959 buffer: TurboBincodeBuffer,
960}
961
962impl<'l, P> Iterator for SnapshotShardIter<'l, P>
963where
964 P: Fn(TaskId, &TaskStorage, &mut TurboBincodeBuffer) -> SnapshotItem + Sync,
965{
966 type Item = SnapshotItem;
967
968 fn next(&mut self) -> Option<Self::Item> {
969 let process = self.shard.process;
970 let snapshots = &self.shard.storage.snapshots;
971 let buffer = &mut self.buffer;
972 let mut serialize_task = |task_id: TaskId, inner: &TaskStorage| {
973 // If the task was re-modified during snapshot, the snapshots map may
974 // hold a pre-modification copy we must serialize instead of the live
975 // data. Remove the entry so end_snapshot doesn't double-promote it;
976 // we promote manually below.
977 if inner.flags.any_modified_during_snapshot() {
978 match snapshots.remove(&task_id) {
979 Some((_, Some(snapshot))) => process(task_id, &snapshot, buffer),
980 Some((_, None)) | None => process(task_id, inner, buffer),
981 }
982 } else {
983 process(task_id, inner, buffer)
984 }
985 };
986
987 match &mut self.shard.work {
988 ShardWork::Keep(modified) => {
989 let task_id = modified.pop()?;
990 let mut inner = self.shard.storage.map.get_mut(&task_id).unwrap();
991 let item = serialize_task(task_id, &inner);
992 // Clear the modified flags that were captured into the snapshot copy,
993 // then promote modified_during_snapshot → modified so the task stays
994 // dirty for the next snapshot cycle.
995 inner.flags.set_data_modified(false);
996 inner.flags.set_meta_modified(false);
997 inner.flags.set_new_task(false);
998 self.shard
999 .storage
1000 .promote_during_snapshot_flags(&mut inner, self.shard.shard_idx);
1001 Some(item)
1002 }
1003 ShardWork::Drain(entries) => {
1004 // Shutdown only: the scan already moved this shard's modified entries out of the
1005 // map, so we own each `Box<TaskStorage>` here. Serialize from a borrow of the owned
1006 // box and let it drop at the end of this branch — freeing the task's memory as it
1007 // is persisted rather than after the whole batch is written. We skip the flag
1008 // bookkeeping the normal path does, since the entire map is discarded right after
1009 // this snapshot.
1010 let (task_id, inner) = entries.next()?;
1011 let inner = inner.into_inner();
1012 Some(serialize_task(task_id, &inner))
1013 // we don't need to update any bits because everything is getting dropped.
1014 }
1015 }
1016 }
1017}
1018
1019impl<P> Drop for SnapshotShardIter<'_, P> {
1020 fn drop(&mut self) {
1021 self.shard
1022 ._guard
1023 .return_scratch_buffer(std::mem::take(&mut self.buffer));
1024 }
1025}
1026
1027#[cfg(test)]
1028mod tests {
1029 use turbo_bincode::TurboBincodeBuffer;
1030 use turbo_tasks::TaskId;
1031
1032 use super::{SpecificTaskDataCategory, Storage, TrackOutcome};
1033 use crate::backing_storage::SnapshotItem;
1034
1035 fn non_transient_task(id: u32) -> TaskId {
1036 // TRANSIENT_TASK_BIT is 0x2000_0000; any id without that bit is non-transient.
1037 TaskId::new(id).expect("id must be non-zero")
1038 }
1039
1040 /// A process fn that returns a non-empty SnapshotItem so the iterator doesn't
1041 /// silently skip items via the "encoding failed" error path.
1042 fn dummy_process(
1043 task_id: TaskId,
1044 _: &super::TaskStorage,
1045 _: &mut TurboBincodeBuffer,
1046 ) -> SnapshotItem {
1047 SnapshotItem {
1048 task_id,
1049 meta: Some(TurboBincodeBuffer::default()),
1050 data: None,
1051 task_type_hash: None,
1052 }
1053 }
1054
1055 /// Regression test: a task modified before a snapshot and then modified *again* during
1056 /// snapshot iteration must serialize the pre-snapshot state and carry the during-snapshot
1057 /// modification forward to the next cycle.
1058 ///
1059 /// Sequence of events:
1060 /// 1. Task is modified (data_modified = true) → added to shard_modified_counts.
1061 /// 2. `start_snapshot` puts us in snapshot mode.
1062 /// 3. `take_snapshot` scans the shard: task has `any_modified()=true` → goes into the
1063 /// `modified` list.
1064 /// 4. **Between scan and iteration**: `track_modification` is called on the same category. This
1065 /// is the `(true, true)` branch: already modified AND in snapshot mode. A snapshot copy of
1066 /// the pre-second-modification state is stored in `snapshots` as `Some(copy)`, and
1067 /// `data_modified_during_snapshot` is set.
1068 /// 5. `SnapshotShardIter::next` processes the task from the `modified` list, detects
1069 /// `any_modified_during_snapshot()=true`, finds the `Some(copy)` in `snapshots`, encodes the
1070 /// pre-snapshot copy, clears the live modified flags, removes the snapshots entry, and
1071 /// promotes `data_modified_during_snapshot → data_modified` for the next cycle.
1072 // `end_snapshot` uses `parallel::for_each` which calls `block_in_place` internally,
1073 // requiring a multi-threaded Tokio runtime.
1074 #[tokio::test(flavor = "multi_thread")]
1075 async fn modify_during_snapshot_clears_live_modified_flags() {
1076 let storage = Storage::new(2, true);
1077 let task_id = non_transient_task(1);
1078
1079 // Step 1: modify the task outside snapshot mode (data_modified = true).
1080 {
1081 let mut guard = storage.access_mut(task_id);
1082 let _ = guard.track_modification(SpecificTaskDataCategory::Data, "test");
1083 }
1084
1085 // Step 2: enter snapshot mode.
1086 let (snapshot_guard, has_modifications) = storage.start_snapshot();
1087 assert!(has_modifications);
1088
1089 // Step 3: `take_snapshot` scans the shard. At this point the task has
1090 // `any_modified()=true` and `any_modified_during_snapshot()=false`, so it
1091 // goes into the `modified` list inside the returned `SnapshotShard`.
1092 let shards = storage.take_snapshot(snapshot_guard, &dummy_process, false);
1093
1094 // Step 4: now that the scan is done but before we consume the iterator,
1095 // modify the task again. We're still in snapshot mode, the task is already
1096 // modified → `(true, true)` branch: creates a snapshot copy (carrying the
1097 // modified bits) and sets `data_modified_during_snapshot=true`.
1098 {
1099 let mut guard = storage.access_mut(task_id);
1100 let _ = guard.track_modification(SpecificTaskDataCategory::Data, "test");
1101 // We should have set a snapshot bit
1102 assert!(guard.flags.data_modified_during_snapshot())
1103 }
1104
1105 // Step 5: consume the iterator. The iterator encodes from the pre-snapshot copy,
1106 // clears the live modified flags, removes the snapshots entry, and promotes
1107 // `data_modified_during_snapshot → data_modified` for the next cycle.
1108 let items: Vec<_> = shards
1109 .into_iter()
1110 .flat_map(|shard| shard.into_iter())
1111 .collect();
1112
1113 // The pre-snapshot snapshot copy should have been encoded and returned.
1114 assert_eq!(items.len(), 1);
1115 assert_eq!(items[0].task_id, task_id);
1116
1117 {
1118 let guard = storage.access_mut(task_id);
1119 // The iterator should have promoted modified_during_snapshot → modified.
1120 assert!(guard.flags.data_modified());
1121 }
1122
1123 // The during-snapshot modification must be reflected in shard_modified_counts so
1124 // the next snapshot cycle picks it up. Verify by starting another snapshot.
1125 let (_guard2, has_modifications) = storage.start_snapshot();
1126 assert!(
1127 has_modifications,
1128 "shard_modified_counts must be non-zero after promoting modified_during_snapshot"
1129 );
1130 }
1131
1132 /// Regression test for the `(true, false)` during-snapshot case: a task modified in one
1133 /// category before a snapshot, then modified in a *different* category during snapshot
1134 /// iteration, must not panic and must carry both modifications forward correctly.
1135 ///
1136 /// Sequence of events:
1137 /// 1. Task meta is modified (meta_modified = true).
1138 /// 2. `start_snapshot` puts us in snapshot mode.
1139 /// 3. `take_snapshot` scans the shard: task goes into the `modified` list.
1140 /// 4. Task data is modified during snapshot → `(true, false)` branch: data was not previously
1141 /// modified, so `snapshots` gets a `None` entry and `data_modified_during_snapshot` is set.
1142 /// 5. `SnapshotShardIter::next` processes the task: finds `any_modified_during_snapshot()`,
1143 /// sees `None` in snapshots, encodes from live data (correct — live data for the
1144 /// unmodified-before-snapshot category is still the pre-snapshot state), clears pre-snapshot
1145 /// flags, and promotes `data_modified_during_snapshot → data_modified`.
1146 #[tokio::test(flavor = "multi_thread")]
1147 async fn modify_different_category_during_snapshot() {
1148 let storage = Storage::new(2, true);
1149 let task_id = non_transient_task(1);
1150
1151 // Step 1: modify meta only, outside snapshot mode.
1152 {
1153 let mut guard = storage.access_mut(task_id);
1154 let _ = guard.track_modification(SpecificTaskDataCategory::Meta, "test");
1155 assert!(guard.flags.meta_modified());
1156 assert!(!guard.flags.data_modified());
1157 }
1158
1159 // Step 2: enter snapshot mode.
1160 let (snapshot_guard, has_modifications) = storage.start_snapshot();
1161 assert!(has_modifications);
1162
1163 // Step 3: take_snapshot — task goes into modified list (meta_modified = true).
1164 let shards = storage.take_snapshot(snapshot_guard, &dummy_process, false);
1165
1166 // Step 4: modify data during snapshot. The `(true, false)` branch fires:
1167 // data was not previously modified, so snapshots gets a None entry.
1168 {
1169 let mut guard = storage.access_mut(task_id);
1170 let _ = guard.track_modification(SpecificTaskDataCategory::Data, "test");
1171 assert!(guard.flags.data_modified_during_snapshot());
1172 assert!(!guard.flags.meta_modified_during_snapshot());
1173 }
1174
1175 // Step 5: consume the iterator — must not panic.
1176 let items: Vec<_> = shards
1177 .into_iter()
1178 .flat_map(|shard| shard.into_iter())
1179 .collect();
1180
1181 assert_eq!(items.len(), 1);
1182 assert_eq!(items[0].task_id, task_id);
1183
1184 {
1185 let guard = storage.access_mut(task_id);
1186 // meta_modified was cleared by the iterator (it was the pre-snapshot flag).
1187 assert!(!guard.flags.meta_modified());
1188 // data_modified_during_snapshot was promoted to data_modified.
1189 assert!(guard.flags.data_modified());
1190 assert!(!guard.flags.data_modified_during_snapshot());
1191 }
1192
1193 // Next snapshot cycle must pick up the promoted data_modified.
1194 let (_guard2, has_modifications) = storage.start_snapshot();
1195 assert!(
1196 has_modifications,
1197 "shard_modified_counts must be non-zero after promoting data_modified_during_snapshot"
1198 );
1199 }
1200
1201 /// With `drain_entries = true` (shutdown path), the modified entries are moved out of the map
1202 /// (during the scan) and serialized by the iterator, freeing each task's memory as it is
1203 /// persisted rather than retaining it until the whole snapshot is written. Either way the
1204 /// entry must be gone from the map by the time the snapshot is consumed.
1205 #[tokio::test(flavor = "multi_thread")]
1206 async fn drain_entries_removes_entry_from_map() {
1207 let storage = Storage::new(2, true);
1208 let task_id = non_transient_task(1);
1209
1210 // Modify the task outside snapshot mode so it lands in the modified list.
1211 {
1212 let mut guard = storage.access_mut(task_id);
1213 let _ = guard.track_modification(SpecificTaskDataCategory::Data, "test");
1214 }
1215 assert!(storage.map.get(&task_id).is_some());
1216
1217 let (snapshot_guard, has_modifications) = storage.start_snapshot();
1218 assert!(has_modifications);
1219
1220 // Take the snapshot in drain mode.
1221 let shards = storage.take_snapshot(snapshot_guard, &dummy_process, true);
1222
1223 // Consume the iterator: the task is serialized and then removed from the map.
1224 let items: Vec<_> = shards
1225 .into_iter()
1226 .flat_map(|shard| shard.into_iter())
1227 .collect();
1228
1229 assert_eq!(items.len(), 1);
1230 assert_eq!(items[0].task_id, task_id);
1231
1232 // The entry must be gone from the map now that it has been persisted.
1233 assert!(
1234 storage.map.get(&task_id).is_none(),
1235 "task entry should be removed from the map after being persisted in drain mode"
1236 );
1237 }
1238
1239 /// In drain mode, fully consuming the iterators should release each drained shard's table
1240 /// allocation entirely (reset-to-empty in `SnapshotShardIter::drop`), not just shrink it.
1241 #[tokio::test(flavor = "multi_thread")]
1242 async fn drain_entries_releases_drained_shards() {
1243 // dashmap requires at least 2 shards.
1244 let storage = Storage::new(2, true);
1245
1246 // Insert and modify enough tasks to grow the shards' tables beyond their minimum.
1247 let task_ids: Vec<_> = (1..=256).map(non_transient_task).collect();
1248 for &task_id in &task_ids {
1249 let mut guard = storage.access_mut(task_id);
1250 let _ = guard.track_modification(SpecificTaskDataCategory::Data, "test");
1251 }
1252 let grown_capacity: usize = storage
1253 .map
1254 .shards()
1255 .iter()
1256 .map(|s| s.read().capacity())
1257 .sum();
1258 assert!(grown_capacity >= task_ids.len());
1259
1260 let (snapshot_guard, has_modifications) = storage.start_snapshot();
1261 assert!(has_modifications);
1262
1263 let shards = storage.take_snapshot(snapshot_guard, &dummy_process, true);
1264 let items: Vec<_> = shards
1265 .into_iter()
1266 .flat_map(|shard| shard.into_iter())
1267 .collect();
1268 assert_eq!(items.len(), task_ids.len());
1269
1270 // Every shard is now empty and its table allocation has been released (capacity 0),
1271 // since the reset swaps in the allocation-free default table.
1272 for shard in storage.map.shards() {
1273 let shard = shard.read();
1274 assert_eq!(shard.len(), 0);
1275 assert_eq!(
1276 shard.capacity(),
1277 0,
1278 "drained shard should have released its table allocation"
1279 );
1280 }
1281 }
1282
1283 /// In drain mode, `take_snapshot`'s scan removes *both* kinds of entry from the map: unmodified
1284 /// entries are erased and freed (never serialized), and the remaining modified-only table is
1285 /// moved out into the shard iterators (to be serialized, then freed as each is consumed). So
1286 /// the map is already empty when `take_snapshot` returns, and only the modified task is
1287 /// yielded.
1288 #[tokio::test(flavor = "multi_thread")]
1289 async fn drain_entries_removes_unmodified_during_take_snapshot() {
1290 let storage = Storage::new(2, true);
1291 let modified_id = non_transient_task(1);
1292 let unmodified_id = non_transient_task(2);
1293
1294 // One modified task (gets serialized) and one unmodified task (e.g. restored from disk but
1295 // never dirtied) that just occupies memory and must not be serialized.
1296 {
1297 let mut guard = storage.access_mut(modified_id);
1298 let _ = guard.track_modification(SpecificTaskDataCategory::Data, "test");
1299 }
1300 // `access_mut` inserts an entry; leaving it without track_modification keeps it unmodified.
1301 let _ = storage.access_mut(unmodified_id);
1302 assert!(storage.map.get(&unmodified_id).is_some());
1303
1304 let (snapshot_guard, has_modifications) = storage.start_snapshot();
1305 assert!(has_modifications);
1306
1307 let shards = storage.take_snapshot(snapshot_guard, &dummy_process, true);
1308
1309 // The scan moved the modified table out and freed the unmodified entry, so both ids are
1310 // already absent from the map before any iterator is consumed.
1311 assert!(
1312 storage.map.get(&unmodified_id).is_none(),
1313 "unmodified entry should be removed during take_snapshot in drain mode"
1314 );
1315 assert!(
1316 storage.map.get(&modified_id).is_none(),
1317 "modified entry should be moved out of the map during take_snapshot in drain mode"
1318 );
1319
1320 // Consuming the iterators yields only the modified task (the unmodified one was never part
1321 // of the snapshot).
1322 let items: Vec<_> = shards
1323 .into_iter()
1324 .flat_map(|shard| shard.into_iter())
1325 .collect();
1326 assert_eq!(items.len(), 1);
1327 assert_eq!(items[0].task_id, modified_id);
1328 }
1329
1330 #[tokio::test(flavor = "multi_thread")]
1331 async fn undo_non_snapshot_reverses_flag_and_counter() {
1332 let storage = Storage::new(2, true);
1333 let task_id = non_transient_task(1);
1334
1335 {
1336 let mut guard = storage.access_mut(task_id);
1337 let outcome = guard.track_modification(SpecificTaskDataCategory::Data, "test");
1338 assert!(guard.flags.data_modified());
1339 guard.undo_track_modification(outcome);
1340 assert!(!guard.flags.data_modified());
1341 assert!(!guard.flags.any_modified());
1342 }
1343
1344 // Counter is back to zero: the next snapshot sees no modifications.
1345 let (_guard, has_modifications) = storage.start_snapshot();
1346 assert!(
1347 !has_modifications,
1348 "undo must decrement the shard counter so no modifications remain"
1349 );
1350 }
1351
1352 /// A second track on an already-modified category returns `NoChange`; undoing it is a no-op and
1353 /// must NOT clear the real modification recorded by the first track.
1354 #[tokio::test(flavor = "multi_thread")]
1355 async fn undo_nochange_preserves_prior_modification() {
1356 let storage = Storage::new(2, true);
1357 let task_id = non_transient_task(1);
1358
1359 let mut guard = storage.access_mut(task_id);
1360 // First track is the real modification.
1361 let _first = guard.track_modification(SpecificTaskDataCategory::Data, "test");
1362 // Second track on the same category changes nothing.
1363 let second = guard.track_modification(SpecificTaskDataCategory::Data, "test");
1364 assert!(matches!(second, TrackOutcome::NoChange));
1365 // Undoing the no-op must leave the prior modification intact.
1366 guard.undo_track_modification(second);
1367 assert!(
1368 guard.flags.data_modified(),
1369 "undoing a NoChange outcome must not clear a real prior modification"
1370 );
1371 }
1372
1373 /// Undo only reverses the category it tracked: tracking Data then Meta, undoing only the Meta
1374 /// outcome must leave Data modified and the shard counter still non-zero.
1375 #[tokio::test(flavor = "multi_thread")]
1376 async fn undo_only_reverses_its_own_category() {
1377 let storage = Storage::new(2, true);
1378 let task_id = non_transient_task(1);
1379
1380 {
1381 let mut guard = storage.access_mut(task_id);
1382 let _data = guard.track_modification(SpecificTaskDataCategory::Data, "test");
1383 let meta = guard.track_modification(SpecificTaskDataCategory::Meta, "test");
1384 assert!(guard.flags.meta_modified());
1385 guard.undo_track_modification(meta);
1386 assert!(!guard.flags.meta_modified());
1387 assert!(guard.flags.data_modified());
1388 }
1389
1390 // Data is still modified, so the counter is still non-zero.
1391 let (_guard, has_modifications) = storage.start_snapshot();
1392 assert!(has_modifications);
1393 }
1394
1395 /// During-snapshot `(true, false)` arm: a task unmodified-before-snapshot, tracked during
1396 /// snapshot, inserts a `None` marker into `snapshots` and sets the `_during_snapshot` bit.
1397 /// Undo must remove the marker and clear the bit.
1398 #[tokio::test(flavor = "multi_thread")]
1399 async fn undo_during_snapshot_true_false_removes_marker() {
1400 let storage = Storage::new(2, true);
1401 let task_id = non_transient_task(1);
1402 // Insert the task (unmodified) so it exists in the map.
1403 let _ = storage.access_mut(task_id);
1404
1405 let (_snapshot_guard, _) = storage.start_snapshot();
1406
1407 let mut guard = storage.access_mut(task_id);
1408 let outcome = guard.track_modification(SpecificTaskDataCategory::Data, "test");
1409 assert!(matches!(
1410 outcome,
1411 TrackOutcome::TrackedDuringSnapshot {
1412 inserted_snapshot: true,
1413 ..
1414 }
1415 ));
1416 assert!(guard.flags.data_modified_during_snapshot());
1417 assert!(storage.snapshots.get(&task_id).is_some());
1418
1419 guard.undo_track_modification(outcome);
1420 assert!(!guard.flags.data_modified_during_snapshot());
1421 assert!(
1422 storage.snapshots.get(&task_id).is_none(),
1423 "undo must remove the snapshots marker it inserted"
1424 );
1425 }
1426
1427 /// During-snapshot `(true, true)` arm: a task modified-before-snapshot, tracked again during
1428 /// snapshot, stores a pre-mutation copy in `snapshots`. Undo must remove that copy and clear
1429 /// the `_during_snapshot` bit, while leaving the pre-existing `modified` flag intact (it
1430 /// belongs to the snapshot, not to this call).
1431 #[tokio::test(flavor = "multi_thread")]
1432 async fn undo_during_snapshot_true_true_removes_copy_preserves_modified() {
1433 let storage = Storage::new(2, true);
1434 let task_id = non_transient_task(1);
1435
1436 // Modify before snapshot so the category is part of the snapshot.
1437 {
1438 let mut guard = storage.access_mut(task_id);
1439 let _ = guard.track_modification(SpecificTaskDataCategory::Data, "test");
1440 }
1441
1442 let (_snapshot_guard, _) = storage.start_snapshot();
1443
1444 let mut guard = storage.access_mut(task_id);
1445 let outcome = guard.track_modification(SpecificTaskDataCategory::Data, "test");
1446 assert!(matches!(
1447 outcome,
1448 TrackOutcome::TrackedDuringSnapshot {
1449 inserted_snapshot: true,
1450 ..
1451 }
1452 ));
1453 assert!(matches!(
1454 storage.snapshots.get(&task_id).as_deref(),
1455 Some(Some(_))
1456 ));
1457
1458 guard.undo_track_modification(outcome);
1459 assert!(!guard.flags.data_modified_during_snapshot());
1460 assert!(
1461 guard.flags.data_modified(),
1462 "the pre-snapshot modification belongs to the snapshot and must survive undo"
1463 );
1464 assert!(
1465 storage.snapshots.get(&task_id).is_none(),
1466 "undo must remove the pre-mutation copy it inserted"
1467 );
1468 }
1469}