Skip to main content

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}