Skip to main content

turbo_tasks_backend/
kv_backing_storage.rs

1use std::{
2    borrow::Borrow,
3    env,
4    path::PathBuf,
5    sync::{Arc, LazyLock, Mutex, PoisonError, Weak},
6};
7
8use anyhow::{Context, Result};
9use smallvec::SmallVec;
10use turbo_bincode::{new_turbo_bincode_decoder, turbo_bincode_decode, turbo_bincode_encode};
11use turbo_persistence::{ArcBytes, CommitStats};
12use turbo_tasks::{
13    DynTaskInputs, RawVc, TaskId,
14    macro_helpers::NativeFunction,
15    panic_hooks::{PanicHookGuard, register_panic_hook},
16    parallel,
17};
18
19use crate::{
20    GitVersionInfo,
21    backend::{SpecificTaskDataCategory, TtlCounter, storage_schema::TaskStorage},
22    backing_storage::{SnapshotItem, SnapshotMeta, compute_task_type_hash_from_components},
23    database::{
24        db_invalidation::{StartupCacheState, check_db_invalidation_and_cleanup, invalidate_db},
25        db_versioning::handle_db_versioning,
26        key_value_database::KeySpace,
27        turbo::{TurboKeyValueDatabase, TurboWriteBatch},
28        write_batch::WriteBuffer,
29    },
30    db_invalidation::invalidation_reasons,
31};
32
33/// The fixed keys in the [`KeySpace::Infra`] keyspace.
34#[derive(Clone, Copy)]
35#[repr(u8)]
36enum InfraKey {
37    NextFreeTaskId = 0,
38    GcRoots = 1,
39}
40
41impl InfraKey {
42    fn key(self) -> ByteKey {
43        ByteKey::new(self as u8)
44    }
45}
46
47struct ByteKey([u8; 1]);
48
49impl ByteKey {
50    fn new(value: u8) -> Self {
51        Self([value])
52    }
53}
54
55impl AsRef<[u8]> for ByteKey {
56    fn as_ref(&self) -> &[u8] {
57        &self.0
58    }
59}
60
61struct IntKey([u8; 4]);
62
63impl IntKey {
64    fn new(value: u32) -> Self {
65        Self(value.to_le_bytes())
66    }
67}
68
69impl AsRef<[u8]> for IntKey {
70    fn as_ref(&self) -> &[u8] {
71        &self.0
72    }
73}
74
75fn as_u32(bytes: impl Borrow<[u8]>) -> Result<u32> {
76    let n = u32::from_le_bytes(bytes.borrow().try_into()?);
77    Ok(n)
78}
79
80// We want to invalidate the cache on panic for most users, but this is a band-aid to underlying
81// problems in turbo-tasks.
82//
83// If we invalidate the cache upon panic and it "fixes" the issue upon restart, users typically
84// won't report bugs to us, and we'll never find root-causes for these problems.
85//
86// These overrides let us avoid the cache invalidation / error suppression within Vercel so that we
87// feel these pain points and fix the root causes of bugs.
88fn should_invalidate_on_panic() -> bool {
89    fn env_is_falsy(key: &str) -> bool {
90        env::var_os(key)
91            .is_none_or(|value| ["".as_ref(), "0".as_ref(), "false".as_ref()].contains(&&*value))
92    }
93    static SHOULD_INVALIDATE: LazyLock<bool> = LazyLock::new(|| {
94        env_is_falsy("TURBO_ENGINE_SKIP_INVALIDATE_ON_PANIC") && env_is_falsy("__NEXT_TEST_MODE")
95    });
96    *SHOULD_INVALIDATE
97}
98
99struct TurboBackingStorageInner {
100    database: TurboKeyValueDatabase,
101    /// Used when calling [`TurboBackingStorage::invalidate`]. Can be `None` in the
102    /// memory-only/no-op storage case.
103    base_path: Option<PathBuf>,
104    /// Used to skip calling [`invalidate_db`] when the database has already been invalidated.
105    invalidated: Mutex<bool>,
106    /// We configure a panic hook to invalidate the cache. This guard cleans up our panic hook upon
107    /// drop.
108    _panic_hook_guard: Option<PanicHookGuard>,
109}
110
111/// The higher-level backing storage passed to [`TurboTasksBackend::new`], used by
112/// [`crate::turbo_backing_storage`] and [`crate::noop_backing_storage`].
113///
114/// Wraps a low-level `TurboKeyValueDatabase` and adapts it into the persistence operations the
115/// backend needs (snapshots, task-candidate lookups, etc.).
116///
117/// [`TurboTasksBackend::new`]: crate::TurboTasksBackend::new
118pub struct TurboBackingStorage {
119    // wrapped so that `register_panic_hook` can hold a weak reference to `inner`.
120    inner: Arc<TurboBackingStorageInner>,
121}
122
123impl TurboBackingStorage {
124    pub(crate) fn new_in_memory(database: TurboKeyValueDatabase) -> Self {
125        Self {
126            inner: Arc::new(TurboBackingStorageInner {
127                database,
128                base_path: None,
129                invalidated: Mutex::new(false),
130                _panic_hook_guard: None,
131            }),
132        }
133    }
134
135    /// Handles boilerplate logic for an on-disk persisted database with versioning.
136    ///
137    /// - Creates a directory per version, with a maximum number of old versions and performs
138    ///   automatic cleanup of old versions.
139    /// - Checks for a database invalidation marker file, and cleans up the database as needed.
140    /// - [Registers a dynamic panic hook][turbo_tasks::panic_hooks] to invalidate the database upon
141    ///   a panic. This invalidates the database using [`invalidation_reasons::PANIC`].
142    ///
143    /// Along with returning a [`TurboBackingStorage`], this returns a
144    /// [`StartupCacheState`], which can be used by the application for logging information to the
145    /// user or telemetry about the cache.
146    pub(crate) fn open_versioned_on_disk(
147        base_path: PathBuf,
148        version_info: &GitVersionInfo,
149        is_ci: bool,
150        database: impl FnOnce(PathBuf) -> Result<TurboKeyValueDatabase>,
151    ) -> Result<(Self, StartupCacheState)> {
152        let startup_cache_state = check_db_invalidation_and_cleanup(&base_path)
153            .context("Failed to check database invalidation and cleanup")?;
154        let versioned_path = handle_db_versioning(&base_path, version_info, is_ci)
155            .context("Failed to handle database versioning")?;
156        let database = (database)(versioned_path).context("Failed to open database")?;
157        let backing_storage = Self {
158            inner: Arc::new_cyclic(move |weak_inner: &Weak<TurboBackingStorageInner>| {
159                let panic_hook_guard = if should_invalidate_on_panic() {
160                    let weak_inner = weak_inner.clone();
161                    Some(register_panic_hook(Box::new(move |_| {
162                        let Some(inner) = weak_inner.upgrade() else {
163                            return;
164                        };
165                        // If a panic happened that must mean something deep inside of turbopack
166                        // or turbo-tasks failed, and it may be hard to recover. We don't want
167                        // the cache to stick around, as that may persist bugs. Make a
168                        // best-effort attempt to invalidate the database (ignoring failures).
169                        let _ = inner.invalidate(invalidation_reasons::PANIC);
170                    })))
171                } else {
172                    None
173                };
174                TurboBackingStorageInner {
175                    database,
176                    base_path: Some(base_path),
177                    invalidated: Mutex::new(false),
178                    _panic_hook_guard: panic_hook_guard,
179                }
180            }),
181        };
182        Ok((backing_storage, startup_cache_state))
183    }
184}
185
186impl TurboBackingStorageInner {
187    fn invalidate(&self, reason_code: &str) -> Result<()> {
188        // `base_path` is `None` for in-memory backing storage (see `noop_backing_storage`).
189        if let Some(base_path) = &self.base_path {
190            // Invalidation could happen frequently if there's a bunch of panics. We only need to
191            // invalidate once, so grab a lock.
192            let mut invalidated_guard = self
193                .invalidated
194                .lock()
195                .unwrap_or_else(PoisonError::into_inner);
196            if *invalidated_guard {
197                return Ok(());
198            }
199            // Invalidate first, as it's a very fast atomic operation. `prevent_writes` is allowed
200            // to be slower (e.g. wait for a lock) and is allowed to corrupt the database with
201            // partial writes.
202            invalidate_db(base_path, reason_code)?;
203            self.database.prevent_writes();
204            // Avoid redundant invalidations from future panics
205            *invalidated_guard = true;
206        }
207        Ok(())
208    }
209
210    /// Used to read the next free task ID from the database.
211    fn get_infra_u32(&self, key: InfraKey) -> Result<Option<u32>> {
212        self.database
213            .get(KeySpace::Infra, key.key().as_ref())?
214            .map(as_u32)
215            .transpose()
216    }
217}
218
219impl TurboBackingStorage {
220    /// Called when the database should be invalidated upon re-initialization.
221    ///
222    /// This typically means that we'll restart the process or `turbo-tasks` soon with a fresh
223    /// database. If this happens, there's no point in writing anything else to disk, or flushing
224    /// during [`TurboTasksBackend::stop`].
225    ///
226    /// [`TurboTasksBackend::stop`]: turbo_tasks::backend::Backend::stop
227    pub(crate) fn invalidate(&self, reason_code: &str) -> Result<()> {
228        self.inner.invalidate(reason_code)
229    }
230
231    pub(crate) fn next_free_task_id(&self) -> Result<TaskId> {
232        Ok(self
233            .inner
234            .get_infra_u32(InfraKey::NextFreeTaskId)
235            .context("Unable to read next free task id from database")?
236            .map_or(Ok(TaskId::MIN), TaskId::try_from)?)
237    }
238
239    /// Reads the persisted GC roots set (see [`InfraKey::GcRoots`]). Empty on a fresh database.
240    pub(crate) fn roots(&self) -> Result<Vec<(TaskId, TtlCounter)>> {
241        fn get(database: &TurboKeyValueDatabase) -> Result<Vec<(TaskId, TtlCounter)>> {
242            let Some(roots) = database.get(KeySpace::Infra, InfraKey::GcRoots.key().as_ref())?
243            else {
244                return Ok(Vec::new());
245            };
246            let roots = turbo_bincode_decode(roots.borrow())?;
247            Ok(roots)
248        }
249        get(&self.inner.database).context("Unable to read GC roots from database")
250    }
251
252    pub(crate) fn save_snapshot<I>(
253        &self,
254        roots: Option<Vec<(TaskId, TtlCounter)>>,
255        snapshots: Vec<I>,
256    ) -> Result<SnapshotMeta>
257    where
258        I: IntoIterator<Item = SnapshotItem> + Send + Sync,
259    {
260        let _span = tracing::info_span!("save snapshot").entered();
261        let batch = self.inner.database.write_batch()?;
262
263        {
264            let span = tracing::trace_span!("update task data");
265            let mut snapshot_meta =
266                parallel::map_collect_owned::<_, _, Result<Vec<_>>>(snapshots, |shard: I| {
267                    let _span = span.clone().entered();
268                    let mut max_new_task_id = 0;
269                    let mut data_items = 0;
270                    let mut meta_items = 0;
271                    let mut task_cache_items = 0;
272                    for item in shard {
273                        match item {
274                            SnapshotItem::Put {
275                                task_id,
276                                meta,
277                                data,
278                                task_type_hash,
279                                ..
280                            } => {
281                                let key = IntKey::new(*task_id);
282                                let key = key.as_ref();
283                                if let Some(meta) = meta {
284                                    batch.put(
285                                        KeySpace::TaskMeta,
286                                        WriteBuffer::Borrowed(key),
287                                        WriteBuffer::SmallVec(meta),
288                                    )?;
289                                    meta_items += 1;
290                                }
291                                if let Some(data) = data {
292                                    batch.put(
293                                        KeySpace::TaskData,
294                                        WriteBuffer::Borrowed(key),
295                                        WriteBuffer::SmallVec(data),
296                                    )?;
297                                    data_items += 1;
298                                }
299                                // Register the task type only for new tasks.
300                                if let Some(task_type_hash) = task_type_hash {
301                                    batch.put(
302                                        KeySpace::TaskCache,
303                                        WriteBuffer::Borrowed(&task_type_hash),
304                                        WriteBuffer::Borrowed(key),
305                                    )?;
306                                    task_cache_items += 1;
307                                    max_new_task_id = max_new_task_id.max(*task_id);
308                                }
309                            }
310                            SnapshotItem::Delete {
311                                task_id,
312                                task_type_hash,
313                            } => {
314                                let key = IntKey::new(*task_id);
315                                let key = key.as_ref();
316                                batch.delete(KeySpace::TaskMeta, WriteBuffer::Borrowed(key))?;
317                                batch.delete(KeySpace::TaskData, WriteBuffer::Borrowed(key))?;
318                                // TaskCache is MultiValue, delete just this id from the bucket.
319                                batch.delete_value(
320                                    KeySpace::TaskCache,
321                                    WriteBuffer::Borrowed(&task_type_hash[..]),
322                                    WriteBuffer::Borrowed(key),
323                                )?;
324                            }
325                        }
326                    }
327                    Ok(SnapshotMeta {
328                        data_items,
329                        meta_items,
330                        task_cache_items,
331                        // The on-disk byte totals aren't known until the batch is committed
332                        // below; they're filled in from `CommitStats` after `batch.commit()`.
333                        bytes_written: 0,
334                        bytes_deleted: 0,
335                        max_next_task_id: max_new_task_id,
336                    })
337                })?
338                .into_iter()
339                .reduce(|t1, t2| t1.merge(t2))
340                .unwrap_or_default();
341
342            let span = tracing::trace_span!("flush task data");
343            parallel::try_for_each(
344                &[KeySpace::TaskMeta, KeySpace::TaskData, KeySpace::TaskCache],
345                |&key_space| {
346                    let _span = span.clone().entered();
347                    // Safety: `map_collect_owned` has returned, so no concurrent `put` or `delete`
348                    // on these key spaces are in-flight.
349                    unsafe { batch.flush(key_space) }
350                },
351            )?;
352
353            let mut next_task_id = get_next_free_task_id(&batch)?;
354            next_task_id = next_task_id.max(snapshot_meta.max_next_task_id + 1);
355
356            save_infra(&batch, next_task_id, roots)?;
357            {
358                let _span = tracing::trace_span!("commit").entered();
359                // Byte totals are the physical on-disk bytes (post-compression, including .sst /
360                // .blob / .meta files) produced and removed by the commit.
361                let stats = batch.commit().context("Unable to commit snapshot")?;
362                snapshot_meta.bytes_written = stats.bytes_written;
363                snapshot_meta.bytes_deleted = stats.bytes_deleted;
364            }
365            Ok(snapshot_meta)
366        }
367    }
368
369    pub(crate) fn lookup_task_candidates(
370        &self,
371        native_fn: &'static NativeFunction,
372        this: Option<RawVc>,
373        arg: &dyn DynTaskInputs,
374    ) -> Result<SmallVec<[TaskId; 1]>> {
375        let inner = &*self.inner;
376        if inner.database.is_empty() {
377            // Checking if the database is empty is a performance optimization
378            // to avoid computing the hash.
379            return Ok(SmallVec::new());
380        }
381        let hash = compute_task_type_hash_from_components(native_fn, this, arg);
382        let buffers = inner
383            .database
384            .get_multiple(KeySpace::TaskCache, &hash)
385            .with_context(|| {
386                format!("Looking up task id for {native_fn:?}(this={this:?}) from database failed")
387            })?;
388
389        let mut task_ids = SmallVec::with_capacity(buffers.len());
390        for bytes in buffers {
391            let bytes = Borrow::<[u8]>::borrow(&bytes).try_into()?;
392            let id = TaskId::try_from(u32::from_le_bytes(bytes)).unwrap();
393            task_ids.push(id);
394        }
395        Ok(task_ids)
396    }
397
398    /// Reads the stored `category` for `task_id`.
399    ///
400    /// `None` means the database had no key for this category. That is distinct from `Some`
401    /// of an empty [`TaskStorage`] (a key that decoded to nothing): `MustExist` treats a missing
402    /// requested category as a missing task, regardless of the other category.
403    pub(crate) fn lookup_data(
404        &self,
405        task_id: TaskId,
406        category: SpecificTaskDataCategory,
407    ) -> Result<Option<TaskStorage>> {
408        let inner = &*self.inner;
409        let Some(bytes) = inner
410            .database
411            .get(category.key_space(), IntKey::new(*task_id).as_ref())
412            .with_context(|| {
413                format!("Looking up task storage for {task_id} from database failed")
414            })?
415        else {
416            return Ok(None);
417        };
418        let mut storage = TaskStorage::default();
419        let mut decoder = new_turbo_bincode_decoder(bytes.borrow());
420        storage
421            .decode(category, &mut decoder)
422            .with_context(|| format!("Failed to decode {category:?}"))?;
423        Ok(Some(storage))
424    }
425
426    /// Batched [`Self::lookup_data`]: one entry per id, in order, `None` where the database had no
427    /// key. Decoding is parallelized for large batches, see [`PARALLEL_DECODE_THRESHOLD`].
428    pub(crate) fn batch_lookup_data(
429        &self,
430        task_ids: &[TaskId],
431        category: SpecificTaskDataCategory,
432    ) -> Result<Vec<Option<TaskStorage>>> {
433        let bytes = self.batch_lookup_raw(task_ids, category)?;
434        decode_task_storage_batch(&bytes, category, PARALLEL_DECODE_THRESHOLD)
435    }
436
437    /// Reads the raw (still encoded) bytes of `category` for every id, in order.
438    pub(crate) fn batch_lookup_raw(
439        &self,
440        task_ids: &[TaskId],
441        category: SpecificTaskDataCategory,
442    ) -> Result<Vec<Option<ArcBytes>>> {
443        let inner = &*self.inner;
444        let int_keys: Vec<_> = task_ids.iter().map(|&id| IntKey::new(*id)).collect();
445        let keys = int_keys.iter().map(|k| k.as_ref()).collect::<Vec<_>>();
446        inner
447            .database
448            .batch_get(category.key_space(), &keys)
449            .with_context(|| {
450                format!(
451                    "Looking up typed data for {} tasks from database failed",
452                    task_ids.len()
453                )
454            })
455    }
456
457    pub(crate) fn compact(&self) -> Result<Option<CommitStats>> {
458        self.inner.database.compact()
459    }
460
461    pub(crate) fn shutdown(&self) {
462        self.inner.database.shutdown()
463    }
464
465    pub(crate) fn has_unrecoverable_write_error(&self) -> bool {
466        self.inner.database.has_unrecoverable_write_error()
467    }
468}
469
470/// The batch size from which `TurboBackingStorage::batch_lookup_data` decodes in parallel.
471///
472/// Below this, the overhead of distributing the work across threads outweighs the decoding cost.
473/// On an 8 vCPU machine, the `batch_lookup` benchmark found Meta still slower in parallel at 1024
474/// tasks, while both Meta and Data were consistently faster at 2048 tasks and beyond. Re-run with:
475///
476/// ```bash
477/// TURBOPACK_BENCH_BATCH_LOOKUP=1 cargo bench -p turbo-tasks-backend --bench mod -- batch_lookup
478/// ```
479pub const PARALLEL_DECODE_THRESHOLD: usize = 2048;
480
481/// Decodes a batch of raw [`TaskStorage`] bytes for `category`, preserving order and `None`s.
482///
483/// Decodes in parallel when `bytes.len() >= parallel_threshold`. Fails if any entry fails to
484/// decode.
485pub fn decode_task_storage_batch(
486    bytes: &[Option<ArcBytes>],
487    category: SpecificTaskDataCategory,
488    parallel_threshold: usize,
489) -> Result<Vec<Option<TaskStorage>>> {
490    let decode = |opt_bytes: &Option<ArcBytes>| -> Result<Option<TaskStorage>> {
491        let Some(bytes) = opt_bytes else {
492            return Ok(None);
493        };
494        let mut storage = TaskStorage::new();
495        let mut decoder = new_turbo_bincode_decoder(Borrow::<[u8]>::borrow(bytes));
496        storage
497            .decode(category, &mut decoder)
498            .map_err(|e| anyhow::anyhow!("Failed to decode {category:?}: {e:?}"))?;
499        Ok(Some(storage))
500    };
501    if bytes.len() >= parallel_threshold {
502        parallel::map_collect::<_, _, Result<Vec<_>>>(bytes, decode)
503    } else {
504        bytes.iter().map(decode).collect()
505    }
506}
507
508fn get_next_free_task_id(batch: &TurboWriteBatch<'_>) -> Result<u32, anyhow::Error> {
509    Ok(
510        match batch.get(KeySpace::Infra, InfraKey::NextFreeTaskId.key().as_ref())? {
511            Some(bytes) => u32::from_le_bytes(Borrow::<[u8]>::borrow(&bytes).try_into()?),
512            None => 1,
513        },
514    )
515}
516
517fn save_infra(
518    batch: &TurboWriteBatch<'_>,
519    next_task_id: u32,
520    roots: Option<Vec<(TaskId, TtlCounter)>>,
521) -> Result<(), anyhow::Error> {
522    batch
523        .put(
524            KeySpace::Infra,
525            WriteBuffer::Borrowed(InfraKey::NextFreeTaskId.key().as_ref()),
526            WriteBuffer::Borrowed(&next_task_id.to_le_bytes()),
527        )
528        .context("Unable to write next free task id")?;
529    if let Some(roots) = roots {
530        let _span = tracing::trace_span!("update roots", roots = roots.len()).entered();
531        let roots = turbo_bincode_encode(&roots).context("Unable to serialize GC roots")?;
532        batch
533            .put(
534                KeySpace::Infra,
535                WriteBuffer::Borrowed(InfraKey::GcRoots.key().as_ref()),
536                WriteBuffer::SmallVec(roots),
537            )
538            .context("Unable to write GC roots")?;
539    }
540    // Safety: save_infra is called after all concurrent writes to Infra are done.
541    unsafe { batch.flush(KeySpace::Infra)? };
542    Ok(())
543}
544
545#[cfg(test)]
546mod tests {
547    use std::borrow::Borrow;
548
549    use turbo_tasks::TaskId;
550
551    use super::*;
552    use crate::{
553        BackingStorageOptions,
554        database::{turbo::TurboKeyValueDatabase, write_batch::WriteBuffer},
555        utils::test_temp_dir::test_temp_dir,
556    };
557
558    /// Options used by these tests. `is_short_session` disables background compaction, which
559    /// requires a turbo-tasks context that these tests don't set up.
560    const TEST_STORAGE_OPTIONS: BackingStorageOptions = BackingStorageOptions {
561        is_ci: false,
562        is_short_session: true,
563        skip_compaction: false,
564    };
565
566    /// Helper to write to the database using the concurrent batch API.
567    fn write_task_cache_entry(
568        db: &TurboKeyValueDatabase,
569        hash: u64,
570        task_id: TaskId,
571    ) -> Result<()> {
572        let batch = db.write_batch()?;
573        batch.put(
574            KeySpace::TaskCache,
575            WriteBuffer::Borrowed(&hash.to_le_bytes()),
576            WriteBuffer::Borrowed(&(*task_id).to_le_bytes()),
577        )?;
578        batch.commit()?;
579        Ok(())
580    }
581
582    /// Reads the TaskIds stored under `hash` in `TaskCache`, sorted for stable comparison.
583    fn task_cache_ids(db: &TurboKeyValueDatabase, hash: u64) -> Result<Vec<TaskId>> {
584        let mut ids: Vec<TaskId> = db
585            .get_multiple(KeySpace::TaskCache, &hash.to_le_bytes())?
586            .iter()
587            .map(|bytes| {
588                let bytes: [u8; 4] = Borrow::<[u8]>::borrow(bytes).try_into().unwrap();
589                TaskId::try_from(u32::from_le_bytes(bytes)).unwrap()
590            })
591            .collect();
592        ids.sort_by_key(|id| **id);
593        Ok(ids)
594    }
595
596    /// Encodes a distinct [`TaskStorage`] for `seed`, with both meta and data fields populated.
597    fn encoded_task_storage(seed: u32, category: SpecificTaskDataCategory) -> Vec<u8> {
598        use crate::{
599            backend::{TrackOutcome, storage_schema::TaskStorageAccessors},
600            data::{AggregationNumber, OutputValue},
601        };
602
603        /// Accessors over a bare [`TaskStorage`], without modification tracking.
604        struct Untracked(TaskStorage);
605        impl TaskStorageAccessors for Untracked {
606            fn typed(&self) -> &TaskStorage {
607                &self.0
608            }
609            fn typed_mut(&mut self) -> &mut TaskStorage {
610                &mut self.0
611            }
612            fn track_modification(
613                &mut self,
614                _category: SpecificTaskDataCategory,
615                _name: &str,
616            ) -> TrackOutcome {
617                TrackOutcome::NoChange
618            }
619            fn undo_track_modification(&mut self, _outcome: TrackOutcome) {}
620            fn check_access(&self, _category: SpecificTaskDataCategory) {}
621        }
622
623        let id = |n: u32| TaskId::try_from(n).unwrap();
624        let mut task = Untracked(TaskStorage::new());
625        task.set_output(OutputValue::Output(id(seed + 1)));
626        task.set_aggregation_number(AggregationNumber {
627            base: seed,
628            distance: 1,
629            effective: seed + 1,
630        });
631        task.add_upper(id(seed + 2), 1);
632        assert!(task.add_output_dependencies(id(seed + 3)));
633        assert!(task.add_children(id(seed + 4)));
634        let storage = task.0;
635        let mut buffer = turbo_bincode::TurboBincodeBuffer::new();
636        storage
637            .encode(
638                category,
639                &mut turbo_bincode::new_turbo_bincode_encoder(&mut buffer),
640            )
641            .unwrap();
642        buffer.to_vec()
643    }
644
645    fn reencode(
646        storages: &[Option<TaskStorage>],
647        category: SpecificTaskDataCategory,
648    ) -> Vec<Option<Vec<u8>>> {
649        storages
650            .iter()
651            .map(|storage| {
652                storage.as_ref().map(|storage| {
653                    let mut buffer = turbo_bincode::TurboBincodeBuffer::new();
654                    storage
655                        .encode(
656                            category,
657                            &mut turbo_bincode::new_turbo_bincode_encoder(&mut buffer),
658                        )
659                        .unwrap();
660                    buffer.to_vec()
661                })
662            })
663            .collect()
664    }
665
666    /// Sequential and parallel decoding must produce the same results, in order, with `None`s
667    /// preserved, and both must fail on malformed bytes.
668    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
669    async fn test_decode_task_storage_batch_sequential_matches_parallel() {
670        const SEQUENTIAL: usize = usize::MAX;
671        const PARALLEL: usize = 0;
672        for category in [
673            SpecificTaskDataCategory::Meta,
674            SpecificTaskDataCategory::Data,
675        ] {
676            let expected: Vec<Option<Vec<u8>>> = (0..200u32)
677                .map(|i| (i % 7 != 3).then(|| encoded_task_storage(i * 10 + 1, category)))
678                .collect();
679            assert_ne!(expected[0], expected[1], "inputs must be distinct");
680            let to_bytes = || -> Vec<Option<ArcBytes>> {
681                expected
682                    .iter()
683                    .map(|bytes| {
684                        bytes
685                            .as_ref()
686                            .map(|b| ArcBytes::from(b.clone().into_boxed_slice()))
687                    })
688                    .collect()
689            };
690
691            for threshold in [SEQUENTIAL, PARALLEL] {
692                let decoded = decode_task_storage_batch(&to_bytes(), category, threshold).unwrap();
693                assert_eq!(
694                    reencode(&decoded, category),
695                    expected,
696                    "{category:?} threshold={threshold}"
697                );
698
699                assert!(
700                    decode_task_storage_batch(&[], category, threshold)
701                        .unwrap()
702                        .is_empty()
703                );
704
705                let mut malformed = to_bytes();
706                malformed[150] = Some(ArcBytes::from(vec![0xff; 3].into_boxed_slice()));
707                assert!(
708                    decode_task_storage_batch(&malformed, category, threshold).is_err(),
709                    "{category:?} threshold={threshold} must fail on malformed bytes"
710                );
711            }
712        }
713    }
714
715    /// The production batch lookup preserves request order, duplicates and missing entries on
716    /// both sides of the parallel cutoff.
717    // Default worker count like the other database tests here: under the WASI test host, file
718    // descriptors are per thread, so committing SST files from extra runtime workers fails.
719    #[tokio::test(flavor = "multi_thread")]
720    async fn test_batch_lookup_data_roundtrip_at_parallel_threshold() -> Result<()> {
721        let tempdir = test_temp_dir()?;
722        let db = TurboKeyValueDatabase::new(tempdir.path().to_path_buf(), TEST_STORAGE_OPTIONS)?;
723        let stored_ids: Vec<TaskId> = (1..=(PARALLEL_DECODE_THRESHOLD as u32 + 64))
724            .map(|id| TaskId::try_from(id).unwrap())
725            .collect();
726        let batch = db.write_batch()?;
727        for category in [
728            SpecificTaskDataCategory::Meta,
729            SpecificTaskDataCategory::Data,
730        ] {
731            for &task_id in &stored_ids {
732                let key = IntKey::new(*task_id);
733                let value = encoded_task_storage(*task_id * 10, category);
734                batch.put(
735                    category.key_space(),
736                    WriteBuffer::Borrowed(key.as_ref()),
737                    WriteBuffer::Borrowed(&value),
738                )?;
739            }
740        }
741        batch.commit()?;
742        let storage = TurboBackingStorage::new_in_memory(db);
743
744        for category in [
745            SpecificTaskDataCategory::Meta,
746            SpecificTaskDataCategory::Data,
747        ] {
748            for len in [PARALLEL_DECODE_THRESHOLD - 1, PARALLEL_DECODE_THRESHOLD] {
749                let mut requested = stored_ids[..len - 3].to_vec();
750                requested.reverse();
751                requested.insert(len / 2, TaskId::try_from(999_999).unwrap());
752                requested.push(stored_ids[7]);
753                requested.push(TaskId::try_from(999_998).unwrap());
754                assert_eq!(requested.len(), len);
755
756                let expected: Vec<Option<Vec<u8>>> = requested
757                    .iter()
758                    .map(|task_id| {
759                        (**task_id <= stored_ids.len() as u32)
760                            .then(|| encoded_task_storage(**task_id * 10, category))
761                    })
762                    .collect();
763                let decoded = storage.batch_lookup_data(&requested, category)?;
764                assert_eq!(
765                    reencode(&decoded, category),
766                    expected,
767                    "{category:?} len={len}"
768                );
769            }
770        }
771
772        storage.inner.database.shutdown();
773        Ok(())
774    }
775
776    /// Tests that `get_multiple` correctly returns multiple TaskIds when the same hash key
777    /// is used (simulating a hash collision scenario).
778    ///
779    /// This is a lower-level test that verifies the database layer correctly handles
780    /// the case where multiple task IDs are stored under the same hash key.
781    #[tokio::test(flavor = "multi_thread")]
782    async fn test_hash_collision_returns_multiple_candidates() -> Result<()> {
783        let tempdir = test_temp_dir()?;
784        let path = tempdir.path();
785
786        let db = TurboKeyValueDatabase::new(path.to_path_buf(), TEST_STORAGE_OPTIONS)?;
787
788        // Simulate a hash collision by writing multiple TaskIds with the same hash key
789        let collision_hash: u64 = 0xDEADBEEF;
790        let task_id_1 = TaskId::try_from(100u32).unwrap();
791        let task_id_2 = TaskId::try_from(200u32).unwrap();
792        let task_id_3 = TaskId::try_from(300u32).unwrap();
793
794        // Write three task IDs under the same hash key (simulating collision)
795        // Each write creates a new SST file, so all three will be returned by get_multiple
796        write_task_cache_entry(&db, collision_hash, task_id_1)?;
797        write_task_cache_entry(&db, collision_hash, task_id_2)?;
798        write_task_cache_entry(&db, collision_hash, task_id_3)?;
799
800        // Now query using get_multiple - should return all three TaskIds
801        assert_eq!(
802            task_cache_ids(&db, collision_hash)?,
803            vec![task_id_1, task_id_2, task_id_3],
804            "Should return all 3 task IDs for the colliding hash"
805        );
806
807        db.shutdown();
808        Ok(())
809    }
810
811    /// Tests that multiple distinct keys written in a single batch with flush can be read back.
812    /// This mirrors the actual save_snapshot pattern: write many TaskCache entries, flush, commit.
813    // This test is too slow to run under Miri.
814    #[cfg(not(miri))]
815    #[tokio::test(flavor = "multi_thread")]
816    async fn test_batch_write_with_flush_and_reopen() -> Result<()> {
817        let tempdir = test_temp_dir()?;
818        let path = tempdir.path();
819
820        let n = 100_000;
821        let hashes: Vec<u64> = (0..n).map(|i| 0x1000 + i as u64).collect();
822        let task_ids: Vec<TaskId> = (1..=n as u32)
823            .map(|i| TaskId::try_from(i).unwrap())
824            .collect();
825
826        // Write all entries in a single batch with flush (like save_snapshot does)
827        {
828            let db = TurboKeyValueDatabase::new(path.to_path_buf(), TEST_STORAGE_OPTIONS)?;
829            let batch = db.write_batch()?;
830
831            for (hash, task_id) in hashes.iter().zip(task_ids.iter()) {
832                batch.put(
833                    KeySpace::TaskCache,
834                    WriteBuffer::Borrowed(&hash.to_le_bytes()),
835                    WriteBuffer::Borrowed(&(**task_id).to_le_bytes()),
836                )?;
837            }
838            // Flush TaskCache (like the new code does)
839            unsafe { batch.flush(KeySpace::TaskCache) }?;
840            batch.commit()?;
841
842            db.shutdown();
843        }
844
845        // Reopen and verify all entries are readable
846        {
847            let db = TurboKeyValueDatabase::new(path.to_path_buf(), TEST_STORAGE_OPTIONS)?;
848            let mut found = 0;
849            let mut missing = 0;
850            for (hash, expected_id) in hashes.iter().zip(task_ids.iter()) {
851                let results = db.get_multiple(KeySpace::TaskCache, &hash.to_le_bytes())?;
852                if results.is_empty() {
853                    missing += 1;
854                } else {
855                    found += 1;
856                    let bytes: [u8; 4] = Borrow::<[u8]>::borrow(&results[0]).try_into().unwrap();
857                    let id = TaskId::try_from(u32::from_le_bytes(bytes)).unwrap();
858                    assert_eq!(id, *expected_id, "Task ID mismatch for hash {hash:#x}");
859                }
860            }
861            assert_eq!(missing, 0, "Found {found}/{n} entries, missing {missing}");
862            db.shutdown();
863        }
864
865        Ok(())
866    }
867
868    /// `save_snapshot` delete path: a `Delete` item must erase the task's `TaskMeta` and
869    /// `TaskData` (`SingleValue`) entries and remove *only* that id from its `TaskCache`
870    /// (`MultiValue`) bucket.
871    ///
872    /// The colliding survivor is never read or rewritten — the key-value tombstone names the
873    /// single id it deletes, so anything else in the bucket is untouched whether or not this
874    /// commit knows about it.
875    #[tokio::test(flavor = "multi_thread")]
876    async fn test_save_snapshot_delete_tombstones_task() -> Result<()> {
877        let tempdir = test_temp_dir()?;
878        let path = tempdir.path();
879
880        let collision_hash: u64 = 0xC0FFEE;
881        let deleted_id = TaskId::try_from(111u32).unwrap();
882        let survivor_id = TaskId::try_from(222u32).unwrap();
883        let deleted_key = (*deleted_id).to_le_bytes();
884
885        let db = TurboKeyValueDatabase::new(
886            path.to_path_buf(),
887            BackingStorageOptions {
888                is_ci: false,
889                is_short_session: true,
890                skip_compaction: false,
891            },
892        )?;
893
894        // Both ids collide in one TaskCache bucket, purely on disk; the deleted task also has
895        // meta and data entries.
896        write_task_cache_entry(&db, collision_hash, deleted_id)?;
897        write_task_cache_entry(&db, collision_hash, survivor_id)?;
898        let batch = db.write_batch()?;
899        batch.put(
900            KeySpace::TaskMeta,
901            WriteBuffer::Borrowed(&deleted_key),
902            WriteBuffer::Borrowed(b"meta-bytes"),
903        )?;
904        batch.put(
905            KeySpace::TaskData,
906            WriteBuffer::Borrowed(&deleted_key),
907            WriteBuffer::Borrowed(b"data-bytes"),
908        )?;
909        batch.commit()?;
910
911        // Sanity: everything is present before the delete.
912        assert!(db.get(KeySpace::TaskMeta, &deleted_key)?.is_some());
913        assert!(db.get(KeySpace::TaskData, &deleted_key)?.is_some());
914        assert_eq!(
915            task_cache_ids(&db, collision_hash)?,
916            vec![deleted_id, survivor_id],
917        );
918
919        let storage = TurboBackingStorage::new_in_memory(db);
920
921        // Snapshot with no task data, just the one deletion.
922        storage.save_snapshot(
923            None,
924            vec![vec![SnapshotItem::Delete {
925                task_id: deleted_id,
926                task_type_hash: collision_hash.to_le_bytes(),
927            }]],
928        )?;
929
930        let db = &storage.inner.database;
931        assert!(
932            db.get(KeySpace::TaskMeta, &deleted_key)?.is_none(),
933            "TaskMeta should be tombstoned"
934        );
935        assert!(
936            db.get(KeySpace::TaskData, &deleted_key)?.is_none(),
937            "TaskData should be tombstoned"
938        );
939        assert_eq!(
940            task_cache_ids(db, collision_hash)?,
941            vec![survivor_id],
942            "save_snapshot should delete only the named id from the bucket"
943        );
944
945        db.shutdown();
946        Ok(())
947    }
948}