Skip to main content

commonware_storage/archive/prunable/
mod.rs

1//! A prunable key-value store for ordered data.
2//!
3//! Data is stored across two backends: [crate::journal::segmented::fixed] for fixed-size index entries and
4//! [crate::journal::segmented::glob::Glob] for values (managed by [crate::journal::segmented::oversized]).
5//! The location of written data is stored in-memory by both index and key (via [crate::index::unordered::Index])
6//! to enable efficient lookups (on average).
7//!
8//! _Notably, [Archive] does not make use of compaction nor on-disk indexes (and thus has no read
9//! nor write amplification during normal operation).
10//!
11//! # Format
12//!
13//! [Archive] uses a two-journal structure for efficient page cache usage:
14//!
15//! **Index Journal (segmented/fixed)** - Fixed-size entries for fast startup replay:
16//! ```text
17//! +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
18//! | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |10 |11 |12 |13 |14 |15 |16 |17 |18 |19 |20 |21 |22 |23 |
19//! +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
20//! |          Index(u64)           |Key(Fixed Size)|        val_offset(u64)        | val_size(u32) |
21//! +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
22//! ```
23//!
24//! **Value Blob** - Raw values with CRC32 checksums (direct reads, no page cache):
25//! ```text
26//! +---+---+---+---+---+---+---+---+---+---+---+---+
27//! |     Compressed Data (variable)    |   CRC32   |
28//! +---+---+---+---+---+---+---+---+---+---+---+---+
29//! ```
30//!
31//! # Uniqueness
32//!
33//! Indices are unique for [Archive] and writing to an occupied index is a no-op. Duplicate
34//! indices can be stored via [`crate::archive::MultiArchive::put_multi`].
35//!
36//! Keys may be stored at multiple indices with either put variant. A lookup by
37//! [`crate::archive::Identifier::Key`] may return any of the values at that key. Entries
38//! whose index has been pruned are never returned or reported as present, so a key matching
39//! both a pruned and a non-pruned entry resolves to the non-pruned entry.
40//!
41//! ## Conflicts
42//!
43//! Because a translated representation of a key is only ever stored in memory, it is possible (and
44//! expected) that two keys will eventually be represented by the same translated key. To handle
45//! this case, [Archive] must check the persisted form of all conflicting keys to ensure data from
46//! the correct key is returned. To support efficient checks, [Archive] (via
47//! [crate::index::unordered::Index]) keeps a linked list of all keys with the same translated
48//! prefix:
49//!
50//! ```rust
51//! struct Record {
52//!     index: u64,
53//!
54//!     next: Option<Box<Record>>,
55//! }
56//! ```
57//!
58//! _To avoid random memory reads in the common case, the in-memory index directly stores the first
59//! item in the linked list instead of a pointer to the first item._
60//!
61//! `index` is the key to the map used to serve lookups by `index` that stores the position in the
62//! index journal (selected by `section = index / items_per_section * items_per_section` to minimize
63//! the number of open blobs):
64//!
65//! ```text
66//! // Maps index -> position in index journal
67//! indices: BTreeMap<u64, u64>
68//! ```
69//!
70//! _If the [Translator] provided by the caller does not uniformly distribute keys across the key
71//! space or uses a translated representation that means keys on average have many conflicts,
72//! performance will degrade._
73//!
74//! ## Memory Overhead
75//!
76//! [Archive] uses two maps to enable lookups by both index and key. The memory used to track each
77//! index item is `8 + 8` (where `8` is the index and `8` is the position in the index journal).
78//! The memory used to track each key item is `~translated(key).len() + 16` bytes (where `16` is the
79//! size of the `Record` struct). This means that an [Archive] employing a [Translator] that uses
80//! the first `8` bytes of a key will use `~40` bytes to index each key.
81//!
82//! ### MultiArchive Overhead
83//!
84//! [Archive] stores index positions in a dual-map layout:
85//! - `indices: BTreeMap<u64, u64>` tracks the first position for each index.
86//! - `extra_indices: BTreeMap<u64, Vec<u64>>` tracks additional positions for indices written via
87//!   [crate::archive::MultiArchive::put_multi].
88//!
89//! This means the baseline overhead above remains unchanged for the first item at an index. For
90//! indices with duplicates, the additional in-memory payload is:
91//! - one `Vec<u64>` header (`24` bytes), and
92//! - `n * 8` bytes for `n` additional positions.
93//!
94//! Equivalently, this is `24 + (n * 8)` bytes per duplicated index, excluding `BTreeMap` node
95//! overhead for `extra_indices`.
96//!
97//! # Pruning
98//!
99//! [Archive] supports pruning up to a minimum `index` using the `prune` method. After `prune` is
100//! called on a `section`, all interaction with a `section` less than the pruned `section` will
101//! return an error.
102//!
103//! ## Lazy Index Cleanup
104//!
105//! Instead of performing a full iteration of the in-memory index, storing an additional in-memory
106//! index per `section`, or replaying a `section` of the value blob,
107//! [Archive] lazily cleans up the [crate::index::unordered::Index] after pruning. When a new key is
108//! stored that overlaps (same translated value) with a pruned key, the pruned key is removed from
109//! the in-memory index.
110//!
111//! # Read Path
112//!
113//! All reads (by index or key) first read the index entry from the index journal to get the
114//! value location (offset and size), then read the value from the value blob. The index journal
115//! uses a page cache for caching, so hot entries are served from memory. Values are read directly
116//! from disk without caching to avoid polluting the page cache with large values.
117//!
118//! # Compression
119//!
120//! [Archive] supports compressing data before storing it on disk. This can be enabled by setting
121//! the `compression` field in the `Config` struct to a valid `zstd` compression level. This setting
122//! can be changed between initializations of [Archive], however, it must remain populated if any
123//! data was written with compression enabled.
124//!
125//! # Querying for Gaps
126//!
127//! [Archive] tracks gaps in the index space to enable the caller to efficiently fetch unknown keys
128//! using `next_gap`. This is a very common pattern when syncing blocks in a blockchain.
129//!
130//! # Example
131//!
132//! ```rust
133//! use commonware_runtime::{Spawner, Runner, deterministic, buffer::paged::CacheRef};
134//! use commonware_cryptography::{Hasher as _, Sha256};
135//! use commonware_storage::{
136//!     translator::FourCap,
137//!     archive::{
138//!         Archive as _,
139//!         prunable::{Archive, Config},
140//!     },
141//! };
142//! use commonware_utils::{NZUsize, NZU16, NZU64};
143//!
144//! let executor = deterministic::Runner::default();
145//! executor.start(|context| async move {
146//!     // Create an archive
147//!     let cfg = Config {
148//!         translator: FourCap,
149//!         key_partition: "demo-index".into(),
150//!         key_page_cache: CacheRef::from_pooler(&context, NZU16!(1024), NZUsize!(10)),
151//!         value_partition: "demo-value".into(),
152//!         compression: Some(3),
153//!         codec_config: (),
154//!         items_per_section: NZU64!(1024),
155//!         key_write_buffer: NZUsize!(1024 * 1024),
156//!         value_write_buffer: NZUsize!(1024 * 1024),
157//!         replay_buffer: NZUsize!(4096),
158//!     };
159//!     let mut archive = Archive::init(context, cfg).await.unwrap();
160//!
161//!     // Put a key
162//!     archive.put(1, Sha256::hash(b"data"), 10).await.unwrap();
163//!
164//!     // Sync the archive
165//!     archive.sync().await.unwrap();
166//! });
167//! ```
168
169use crate::translator::Translator;
170use commonware_runtime::buffer::paged::CacheRef;
171use std::num::{NonZeroU64, NonZeroUsize};
172
173mod storage;
174pub use storage::Archive;
175
176/// Configuration for [Archive] storage.
177#[derive(Clone)]
178pub struct Config<T: Translator, C> {
179    /// Logic to transform keys into their index representation.
180    ///
181    /// [Archive] assumes that all internal keys are spread uniformly across the key space.
182    /// If that is not the case, lookups may be O(n) instead of O(1).
183    pub translator: T,
184
185    /// The partition to use for the key journal (stores index+key metadata).
186    pub key_partition: String,
187
188    /// The page cache to use for the key journal.
189    pub key_page_cache: CacheRef,
190
191    /// The partition to use for the value blob (stores values).
192    pub value_partition: String,
193
194    /// The compression level to use for the value blob.
195    pub compression: Option<u8>,
196
197    /// The [commonware_codec::Codec] configuration to use for the value stored in the archive.
198    pub codec_config: C,
199
200    /// The number of items per section (the granularity of pruning).
201    pub items_per_section: NonZeroU64,
202
203    /// The amount of bytes that can be buffered for the key journal before being written to a
204    /// [commonware_runtime::Blob].
205    pub key_write_buffer: NonZeroUsize,
206
207    /// The amount of bytes that can be buffered for the value journal before being written to a
208    /// [commonware_runtime::Blob].
209    pub value_write_buffer: NonZeroUsize,
210
211    /// The buffer size to use when replaying a [commonware_runtime::Blob].
212    pub replay_buffer: NonZeroUsize,
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use crate::{
219        archive::{Archive as _, Error, Identifier, MultiArchive as _},
220        journal::Error as JournalError,
221        translator::{FourCap, TwoCap},
222    };
223    use commonware_codec::{DecodeExt, Error as CodecError};
224    use commonware_macros::{test_group, test_traced};
225    use commonware_runtime::{
226        deterministic,
227        mocks::{
228            fail_pending_syncs, release_next_pending_syncs, release_pending_syncs,
229            DelayedSyncContext, PendingSyncs,
230        },
231        telemetry::metrics::has_metric_value,
232        BufferPooler, Error as RError, Metrics as _, Runner, Spawner as _, Supervisor as _,
233    };
234    use commonware_utils::{sequence::FixedBytes, NZUsize, NZU16, NZU64};
235    use rand::RngExt as _;
236    use std::{
237        collections::BTreeMap,
238        num::{NonZeroU16, NonZeroU64},
239        sync::{
240            atomic::{AtomicUsize, Ordering},
241            Arc,
242        },
243    };
244
245    fn test_key(key: &str) -> FixedBytes<64> {
246        let mut buf = [0u8; 64];
247        let key = key.as_bytes();
248        assert!(key.len() <= buf.len());
249        buf[..key.len()].copy_from_slice(key);
250        FixedBytes::decode(buf.as_ref()).unwrap()
251    }
252
253    const DEFAULT_ITEMS_PER_SECTION: u64 = 65536;
254    const DEFAULT_WRITE_BUFFER: usize = 1024;
255    const DEFAULT_REPLAY_BUFFER: usize = 4096;
256    const PAGE_SIZE: NonZeroU16 = NZU16!(1024);
257    const PAGE_CACHE_SIZE: NonZeroUsize = NZUsize!(10);
258
259    fn test_config<E: BufferPooler>(
260        context: &E,
261        items_per_section: NonZeroU64,
262    ) -> Config<FourCap, ()> {
263        Config {
264            translator: FourCap,
265            key_partition: "test-index".into(),
266            key_page_cache: CacheRef::from_pooler(context, PAGE_SIZE, PAGE_CACHE_SIZE),
267            value_partition: "test-value".into(),
268            codec_config: (),
269            compression: None,
270            key_write_buffer: NZUsize!(DEFAULT_WRITE_BUFFER),
271            value_write_buffer: NZUsize!(DEFAULT_WRITE_BUFFER),
272            replay_buffer: NZUsize!(DEFAULT_REPLAY_BUFFER),
273            items_per_section,
274        }
275    }
276
277    #[test_traced]
278    fn test_put_after_start_sync_is_accepted_before_handle_completes() {
279        let executor = deterministic::Runner::default();
280        let (_, checkpoint) = executor.start_and_recover(|context| async move {
281            let pending = PendingSyncs::default();
282            let context = DelayedSyncContext {
283                inner: context,
284                pending: pending.clone(),
285            };
286            let cfg = test_config(&context, NZU64!(DEFAULT_ITEMS_PER_SECTION));
287            let mut archive = Archive::init(context.child("storage"), cfg)
288                .await
289                .expect("Failed to initialize archive");
290
291            let handle = archive
292                .put_start_sync(1, test_key("aaa"), 10)
293                .await
294                .expect("Failed to start sync");
295            let pending_after_start = pending.lock().len();
296            assert!(
297                pending_after_start > 0,
298                "put_start_sync should return while the sync handle is still pending"
299            );
300
301            archive
302                .put(2, test_key("bbb"), 20)
303                .await
304                .expect("archive should remain usable before sync completion");
305            assert_eq!(
306                pending.lock().len(),
307                pending_after_start,
308                "put should not issue a new storage sync while accepting later data"
309            );
310            assert_eq!(archive.get(Identifier::Index(1)).await.unwrap(), Some(10));
311
312            release_pending_syncs(&pending);
313            handle.await.expect("sync handle should complete");
314
315            let follow_up = archive
316                .start_sync()
317                .await
318                .expect("Failed to start next sync");
319            assert!(
320                !pending.lock().is_empty(),
321                "the later put must remain pending for a future sync"
322            );
323            release_pending_syncs(&pending);
324            follow_up.await.expect("follow-up sync should complete");
325        });
326
327        deterministic::Runner::from(checkpoint).start(|context| async move {
328            let cfg = test_config(&context, NZU64!(DEFAULT_ITEMS_PER_SECTION));
329            let archive = Archive::<_, _, FixedBytes<64>, i32>::init(context.child("reopen"), cfg)
330                .await
331                .expect("Failed to reopen archive");
332
333            assert_eq!(archive.get(Identifier::Index(1)).await.unwrap(), Some(10));
334            assert_eq!(archive.get(Identifier::Index(2)).await.unwrap(), Some(20));
335        });
336    }
337
338    #[test_traced]
339    fn test_duplicate_put_start_sync_observes_in_flight_sync() {
340        let executor = deterministic::Runner::default();
341        executor.start(|context| async move {
342            let pending = PendingSyncs::default();
343            let context = DelayedSyncContext {
344                inner: context,
345                pending: pending.clone(),
346            };
347            let cfg = test_config(&context, NZU64!(DEFAULT_ITEMS_PER_SECTION));
348            let mut archive = Archive::init(context.child("storage"), cfg)
349                .await
350                .expect("Failed to initialize archive");
351
352            let first = archive
353                .put_start_sync(1, test_key("aaa"), 10)
354                .await
355                .expect("Failed to start sync");
356            assert_eq!(pending.lock().len(), 2);
357
358            let second = archive
359                .put_start_sync(1, test_key("duplicate"), 99)
360                .await
361                .expect("Failed to start duplicate sync");
362            assert_eq!(
363                pending.lock().len(),
364                2,
365                "duplicate put_start_sync must not issue a new storage sync"
366            );
367
368            let started = Arc::new(AtomicUsize::new(0));
369            let completed = Arc::new(AtomicUsize::new(0));
370            let started_clone = started.clone();
371            let completed_clone = completed.clone();
372            let waiter = context.inner.child("duplicate").spawn(|_| async move {
373                started_clone.fetch_add(1, Ordering::Relaxed);
374                second.await.expect("duplicate sync handle should complete");
375                completed_clone.fetch_add(1, Ordering::Relaxed);
376            });
377
378            while started.load(Ordering::Relaxed) == 0 {
379                commonware_runtime::reschedule().await;
380            }
381            commonware_runtime::reschedule().await;
382            assert_eq!(
383                completed.load(Ordering::Relaxed),
384                0,
385                "duplicate put_start_sync must observe the original in-flight sync"
386            );
387
388            release_pending_syncs(&pending);
389            first.await.expect("first sync handle should complete");
390            while completed.load(Ordering::Relaxed) == 0 {
391                commonware_runtime::reschedule().await;
392            }
393            waiter.await.expect("duplicate waiter failed");
394
395            assert_eq!(archive.get(Identifier::Index(1)).await.unwrap(), Some(10));
396        });
397    }
398
399    #[test_traced]
400    fn test_overlapping_put_start_sync_waits_for_in_flight_sync() {
401        let executor = deterministic::Runner::default();
402        executor.start(|context| async move {
403            let pending = PendingSyncs::default();
404            let context = DelayedSyncContext {
405                inner: context,
406                pending: pending.clone(),
407            };
408            let cfg = test_config(&context, NZU64!(DEFAULT_ITEMS_PER_SECTION));
409            let mut archive = Archive::init(context.child("storage"), cfg)
410                .await
411                .expect("Failed to initialize archive");
412
413            let first = archive
414                .put_start_sync(1, test_key("aaa"), 10)
415                .await
416                .expect("Failed to start sync");
417            let pending_after_first = pending.lock().len();
418            assert!(pending_after_first > 0);
419
420            let started = Arc::new(AtomicUsize::new(0));
421            let completed = Arc::new(AtomicUsize::new(0));
422            let started_clone = started.clone();
423            let completed_clone = completed.clone();
424            let waiter = context.inner.child("second").spawn(|_| async move {
425                started_clone.fetch_add(1, Ordering::Relaxed);
426                let second = archive
427                    .put_start_sync(2, test_key("bbb"), 20)
428                    .await
429                    .expect("Failed to start second sync");
430                completed_clone.fetch_add(1, Ordering::Relaxed);
431                (archive, second)
432            });
433
434            while started.load(Ordering::Relaxed) == 0 {
435                commonware_runtime::reschedule().await;
436            }
437            commonware_runtime::reschedule().await;
438            assert_eq!(completed.load(Ordering::Relaxed), 0);
439            assert_eq!(
440                pending.lock().len(),
441                pending_after_first,
442                "second put_start_sync must not start new syncs before the first completes"
443            );
444
445            release_pending_syncs(&pending);
446            first.await.expect("first sync handle should complete");
447            while completed.load(Ordering::Relaxed) == 0 {
448                commonware_runtime::reschedule().await;
449            }
450            let (archive, second) = waiter.await.expect("second put task failed");
451            assert!(!pending.lock().is_empty());
452            release_pending_syncs(&pending);
453            second.await.expect("second sync handle should complete");
454
455            assert_eq!(archive.get(Identifier::Index(1)).await.unwrap(), Some(10));
456            assert_eq!(archive.get(Identifier::Index(2)).await.unwrap(), Some(20));
457        });
458    }
459
460    #[test_traced]
461    fn test_sync_after_put_start_sync_waits_for_in_flight_sync() {
462        let executor = deterministic::Runner::default();
463        executor.start(|context| async move {
464            let pending = PendingSyncs::default();
465            let context = DelayedSyncContext {
466                inner: context,
467                pending: pending.clone(),
468            };
469            let cfg = test_config(&context, NZU64!(DEFAULT_ITEMS_PER_SECTION));
470            let mut archive = Archive::init(context.child("storage"), cfg)
471                .await
472                .expect("Failed to initialize archive");
473
474            let first = archive
475                .put_start_sync(1, test_key("aaa"), 10)
476                .await
477                .expect("Failed to start sync");
478            assert!(!pending.lock().is_empty());
479
480            let started = Arc::new(AtomicUsize::new(0));
481            let completed = Arc::new(AtomicUsize::new(0));
482            let started_clone = started.clone();
483            let completed_clone = completed.clone();
484            let waiter = context.inner.child("sync").spawn(|_| async move {
485                started_clone.fetch_add(1, Ordering::Relaxed);
486                archive.sync().await.expect("sync should complete");
487                completed_clone.fetch_add(1, Ordering::Relaxed);
488                archive
489            });
490
491            while started.load(Ordering::Relaxed) == 0 {
492                commonware_runtime::reschedule().await;
493            }
494            commonware_runtime::reschedule().await;
495            assert_eq!(
496                completed.load(Ordering::Relaxed),
497                0,
498                "shutdown sync must wait for the in-flight put_start_sync handle"
499            );
500
501            release_pending_syncs(&pending);
502            first.await.expect("first sync handle should complete");
503            while completed.load(Ordering::Relaxed) == 0 {
504                commonware_runtime::reschedule().await;
505            }
506            let archive = waiter.await.expect("sync task failed");
507            assert_eq!(archive.get(Identifier::Index(1)).await.unwrap(), Some(10));
508        });
509    }
510
511    #[test_traced]
512    fn test_destroy_after_put_start_sync_waits_for_in_flight_sync() {
513        let executor = deterministic::Runner::default();
514        executor.start(|context| async move {
515            let pending = PendingSyncs::default();
516            let context = DelayedSyncContext {
517                inner: context,
518                pending: pending.clone(),
519            };
520            let cfg = test_config(&context, NZU64!(DEFAULT_ITEMS_PER_SECTION));
521            let mut archive =
522                Archive::<_, _, FixedBytes<64>, i32>::init(context.child("storage"), cfg)
523                    .await
524                    .expect("Failed to initialize archive");
525
526            let first = archive
527                .put_start_sync(1, test_key("aaa"), 10)
528                .await
529                .expect("Failed to start sync");
530            assert!(!pending.lock().is_empty());
531
532            let started = Arc::new(AtomicUsize::new(0));
533            let completed = Arc::new(AtomicUsize::new(0));
534            let started_clone = started.clone();
535            let completed_clone = completed.clone();
536            let waiter = context.inner.child("destroy").spawn(|_| async move {
537                started_clone.fetch_add(1, Ordering::Relaxed);
538                archive.destroy().await.expect("destroy should complete");
539                completed_clone.fetch_add(1, Ordering::Relaxed);
540            });
541
542            while started.load(Ordering::Relaxed) == 0 {
543                commonware_runtime::reschedule().await;
544            }
545            commonware_runtime::reschedule().await;
546            assert_eq!(
547                completed.load(Ordering::Relaxed),
548                0,
549                "destroy must wait for the in-flight put_start_sync handle"
550            );
551
552            release_pending_syncs(&pending);
553            first.await.expect("first sync handle should complete");
554            while completed.load(Ordering::Relaxed) == 0 {
555                commonware_runtime::reschedule().await;
556            }
557            waiter.await.expect("destroy task failed");
558        });
559    }
560
561    #[test_traced]
562    fn test_prune_after_put_start_sync_waits_for_in_flight_sync() {
563        let executor = deterministic::Runner::default();
564        executor.start(|context| async move {
565            let pending = PendingSyncs::default();
566            let context = DelayedSyncContext {
567                inner: context,
568                pending: pending.clone(),
569            };
570            let cfg = test_config(&context, NZU64!(1));
571            let mut archive =
572                Archive::<_, _, FixedBytes<64>, i32>::init(context.child("storage"), cfg)
573                    .await
574                    .expect("Failed to initialize archive");
575
576            let first = archive
577                .put_start_sync(1, test_key("aaa"), 10)
578                .await
579                .expect("Failed to start sync");
580            assert!(!pending.lock().is_empty());
581
582            let started = Arc::new(AtomicUsize::new(0));
583            let completed = Arc::new(AtomicUsize::new(0));
584            let started_clone = started.clone();
585            let completed_clone = completed.clone();
586            let waiter = context.inner.child("prune").spawn(|_| async move {
587                started_clone.fetch_add(1, Ordering::Relaxed);
588                archive.prune(2).await.expect("prune should complete");
589                completed_clone.fetch_add(1, Ordering::Relaxed);
590                archive
591            });
592
593            while started.load(Ordering::Relaxed) == 0 {
594                commonware_runtime::reschedule().await;
595            }
596            commonware_runtime::reschedule().await;
597            assert_eq!(
598                completed.load(Ordering::Relaxed),
599                0,
600                "prune must wait for in-flight syncs on pruned sections"
601            );
602
603            release_pending_syncs(&pending);
604            first
605                .await
606                .expect("sync handle should complete despite pruning");
607            while completed.load(Ordering::Relaxed) == 0 {
608                commonware_runtime::reschedule().await;
609            }
610            let archive = waiter.await.expect("prune task failed");
611            assert_eq!(archive.get(Identifier::Index(1)).await.unwrap(), None);
612        });
613    }
614
615    #[test_traced]
616    fn test_prune_surfaces_failed_in_flight_sync() {
617        let executor = deterministic::Runner::default();
618        executor.start(|context| async move {
619            let pending = PendingSyncs::default();
620            let context = DelayedSyncContext {
621                inner: context,
622                pending: pending.clone(),
623            };
624            let cfg = test_config(&context, NZU64!(1));
625            let mut archive =
626                Archive::<_, _, FixedBytes<64>, i32>::init(context.child("storage"), cfg)
627                    .await
628                    .expect("Failed to initialize archive");
629
630            let first = archive
631                .put_start_sync(1, test_key("aaa"), 10)
632                .await
633                .expect("Failed to start sync");
634            fail_pending_syncs(&pending);
635
636            let err = archive
637                .prune(2)
638                .await
639                .expect_err("prune must surface a failed in-flight sync");
640            assert!(matches!(
641                err,
642                Error::Journal(JournalError::Runtime(RError::Io(_)))
643            ));
644
645            let err = first.await.expect_err("first sync handle should fail");
646            assert!(matches!(err, RError::Io(_)));
647        });
648    }
649
650    #[test_traced]
651    fn test_put_start_sync_after_prune_drops_pruned_sync_requests() {
652        let executor = deterministic::Runner::default();
653        executor.start(|context| async move {
654            let pending = PendingSyncs::default();
655            let context = DelayedSyncContext {
656                inner: context,
657                pending: pending.clone(),
658            };
659            let cfg = test_config(&context, NZU64!(1));
660            let mut archive =
661                Archive::<_, _, FixedBytes<64>, i32>::init(context.child("storage"), cfg)
662                    .await
663                    .expect("Failed to initialize archive");
664
665            let first = archive
666                .put_start_sync(1, test_key("aaa"), 10)
667                .await
668                .expect("Failed to start sync");
669            release_pending_syncs(&pending);
670            first.await.expect("first sync handle should complete");
671
672            archive.prune(2).await.expect("Failed to prune");
673
674            // If pruning left section 1 in the retained sync-request set, these calls would trip
675            // the journal's prune guard.
676            let second = archive
677                .put_start_sync(2, test_key("bbb"), 20)
678                .await
679                .expect("put_start_sync after prune should succeed");
680            release_pending_syncs(&pending);
681            second.await.expect("second sync handle should complete");
682            archive
683                .sync()
684                .await
685                .expect("sync after prune should succeed");
686
687            assert_eq!(archive.get(Identifier::Index(1)).await.unwrap(), None);
688            assert_eq!(archive.get(Identifier::Index(2)).await.unwrap(), Some(20));
689        });
690    }
691
692    #[test_traced]
693    fn test_overlapping_put_start_sync_restarts_after_all_handles_complete() {
694        let executor = deterministic::Runner::default();
695        let (_, checkpoint) = executor.start_and_recover(|context| async move {
696            let pending = PendingSyncs::default();
697            let context = DelayedSyncContext {
698                inner: context,
699                pending: pending.clone(),
700            };
701            let cfg = test_config(&context, NZU64!(1));
702            let mut archive =
703                Archive::<_, _, FixedBytes<64>, i32>::init(context.child("storage"), cfg)
704                    .await
705                    .expect("Failed to initialize archive");
706
707            let first = archive
708                .put_start_sync(1, test_key("aaa"), 10)
709                .await
710                .expect("Failed to start first sync");
711            assert_eq!(pending.lock().len(), 2);
712
713            let second = archive
714                .put_start_sync(2, test_key("bbb"), 20)
715                .await
716                .expect("Failed to start second sync");
717            assert_eq!(
718                pending.lock().len(),
719                4,
720                "different sections should be able to have independent in-flight syncs"
721            );
722
723            release_pending_syncs(&pending);
724            first.await.expect("first sync handle should complete");
725            second.await.expect("second sync handle should complete");
726        });
727
728        deterministic::Runner::from(checkpoint).start(|context| async move {
729            let cfg = test_config(&context, NZU64!(1));
730            let archive = Archive::<_, _, FixedBytes<64>, i32>::init(context.child("reopen"), cfg)
731                .await
732                .expect("Failed to reopen archive");
733
734            assert_eq!(archive.get(Identifier::Index(1)).await.unwrap(), Some(10));
735            assert_eq!(archive.get(Identifier::Index(2)).await.unwrap(), Some(20));
736        });
737    }
738
739    #[test_traced]
740    fn test_overlapping_put_start_sync_restarts_only_completed_handles() {
741        let executor = deterministic::Runner::default();
742        let (_, checkpoint) = executor.start_and_recover(|context| async move {
743            let pending = PendingSyncs::default();
744            let context = DelayedSyncContext {
745                inner: context,
746                pending: pending.clone(),
747            };
748            let cfg = test_config(&context, NZU64!(1));
749            let mut archive =
750                Archive::<_, _, FixedBytes<64>, i32>::init(context.child("storage"), cfg)
751                    .await
752                    .expect("Failed to initialize archive");
753
754            let first = archive
755                .put_start_sync(1, test_key("aaa"), 10)
756                .await
757                .expect("Failed to start first sync");
758            let second = archive
759                .put_start_sync(2, test_key("bbb"), 20)
760                .await
761                .expect("Failed to start second sync");
762            assert_eq!(pending.lock().len(), 4);
763
764            release_next_pending_syncs(&pending, 2);
765            first.await.expect("first sync handle should complete");
766
767            drop(second);
768            drop(archive);
769        });
770
771        deterministic::Runner::from(checkpoint).start(|context| async move {
772            let cfg = test_config(&context, NZU64!(1));
773            let archive = Archive::<_, _, FixedBytes<64>, i32>::init(context.child("reopen"), cfg)
774                .await
775                .expect("Failed to reopen archive");
776
777            assert_eq!(archive.get(Identifier::Index(1)).await.unwrap(), Some(10));
778            assert_eq!(archive.get(Identifier::Index(2)).await.unwrap(), None);
779        });
780    }
781
782    #[test_traced]
783    fn test_failed_start_sync_is_returned_by_next_start_sync_handle() {
784        let executor = deterministic::Runner::default();
785        executor.start(|context| async move {
786            let pending = PendingSyncs::default();
787            let context = DelayedSyncContext {
788                inner: context,
789                pending: pending.clone(),
790            };
791            let cfg = test_config(&context, NZU64!(DEFAULT_ITEMS_PER_SECTION));
792            let mut archive =
793                Archive::<_, _, FixedBytes<64>, i32>::init(context.child("storage"), cfg)
794                    .await
795                    .expect("Failed to initialize archive");
796
797            let first = archive
798                .put_start_sync(1, test_key("aaa"), 10)
799                .await
800                .expect("Failed to start sync");
801            assert_eq!(pending.lock().len(), 2);
802            fail_pending_syncs(&pending);
803
804            archive
805                .put(2, test_key("bbb"), 20)
806                .await
807                .expect("write should be accepted before observing the failed sync");
808
809            let second = archive
810                .start_sync()
811                .await
812                .expect("start_sync should return a handle for the failed sync");
813            let err = second
814                .await
815                .expect_err("next start_sync handle should observe failed in-flight sync");
816            assert!(matches!(err, RError::Io(_)));
817
818            let err = first.await.expect_err("first sync handle should fail");
819            assert!(matches!(err, RError::Io(_)));
820        });
821    }
822
823    #[test_traced]
824    fn test_archive_compression_then_none() {
825        // Initialize the deterministic context
826        let executor = deterministic::Runner::default();
827        executor.start(|context| async move {
828            // Initialize the archive
829            let cfg = Config {
830                translator: FourCap,
831                key_partition: "test-index".into(),
832                key_page_cache: CacheRef::from_pooler(&context, PAGE_SIZE, PAGE_CACHE_SIZE),
833                value_partition: "test-value".into(),
834                codec_config: (),
835                compression: Some(3),
836                key_write_buffer: NZUsize!(DEFAULT_WRITE_BUFFER),
837                value_write_buffer: NZUsize!(DEFAULT_WRITE_BUFFER),
838                replay_buffer: NZUsize!(DEFAULT_REPLAY_BUFFER),
839                items_per_section: NZU64!(DEFAULT_ITEMS_PER_SECTION),
840            };
841            let mut archive = Archive::init(context.child("first"), cfg.clone())
842                .await
843                .expect("Failed to initialize archive");
844
845            // Put the key-data pair
846            let index = 1u64;
847            let key = test_key("testkey");
848            let data = 1;
849            archive
850                .put(index, key.clone(), data)
851                .await
852                .expect("Failed to put data");
853
854            // Sync and drop the archive
855            archive.sync().await.expect("Failed to sync archive");
856            drop(archive);
857
858            // Initialize the archive again without compression.
859            // Index journal replay succeeds (no compression), but value reads will fail.
860            let cfg = Config {
861                translator: FourCap,
862                key_partition: "test-index".into(),
863                key_page_cache: CacheRef::from_pooler(&context, PAGE_SIZE, PAGE_CACHE_SIZE),
864                value_partition: "test-value".into(),
865                codec_config: (),
866                compression: None,
867                key_write_buffer: NZUsize!(DEFAULT_WRITE_BUFFER),
868                value_write_buffer: NZUsize!(DEFAULT_WRITE_BUFFER),
869                replay_buffer: NZUsize!(DEFAULT_REPLAY_BUFFER),
870                items_per_section: NZU64!(DEFAULT_ITEMS_PER_SECTION),
871            };
872            let archive =
873                Archive::<_, _, FixedBytes<64>, i32>::init(context.child("second"), cfg.clone())
874                    .await
875                    .unwrap();
876
877            // Getting the value should fail because compression settings mismatch.
878            // Without compression, the codec sees extra bytes after decoding the value
879            // (because the compressed data doesn't match the expected format).
880            let result: Result<Option<i32>, _> = archive.get(Identifier::Index(index)).await;
881            assert!(matches!(
882                result,
883                Err(Error::Journal(JournalError::Codec(CodecError::ExtraData(
884                    _
885                ))))
886            ));
887        });
888    }
889
890    #[test_traced]
891    fn test_archive_overlapping_key_basic() {
892        // Initialize the deterministic context
893        let executor = deterministic::Runner::default();
894        executor.start(|context| async move {
895            // Initialize the archive
896            let cfg = Config {
897                translator: FourCap,
898                key_partition: "test-index".into(),
899                key_page_cache: CacheRef::from_pooler(&context, PAGE_SIZE, PAGE_CACHE_SIZE),
900                value_partition: "test-value".into(),
901                codec_config: (),
902                compression: None,
903                key_write_buffer: NZUsize!(DEFAULT_WRITE_BUFFER),
904                value_write_buffer: NZUsize!(DEFAULT_WRITE_BUFFER),
905                replay_buffer: NZUsize!(DEFAULT_REPLAY_BUFFER),
906                items_per_section: NZU64!(DEFAULT_ITEMS_PER_SECTION),
907            };
908            let mut archive = Archive::init(context.child("storage"), cfg.clone())
909                .await
910                .expect("Failed to initialize archive");
911
912            let index1 = 1u64;
913            let key1 = test_key("keys1");
914            let data1 = 1;
915            let index2 = 2u64;
916            let key2 = test_key("keys2");
917            let data2 = 2;
918
919            // Put the key-data pair
920            archive
921                .put(index1, key1.clone(), data1)
922                .await
923                .expect("Failed to put data");
924
925            // Put the key-data pair
926            archive
927                .put(index2, key2.clone(), data2)
928                .await
929                .expect("Failed to put data");
930
931            // Get the data back
932            let retrieved = archive
933                .get(Identifier::Key(&key1))
934                .await
935                .expect("Failed to get data")
936                .expect("Data not found");
937            assert_eq!(retrieved, data1);
938
939            // Get the data back
940            let retrieved = archive
941                .get(Identifier::Key(&key2))
942                .await
943                .expect("Failed to get data")
944                .expect("Data not found");
945            assert_eq!(retrieved, data2);
946
947            // Check metrics
948            let buffer = context.encode();
949            assert!(has_metric_value(&buffer, "items_tracked", 2));
950            assert!(buffer.contains("unnecessary_reads_total 1"));
951            assert!(buffer.contains("gets_total 2"));
952        });
953    }
954
955    #[test_traced]
956    fn test_archive_overlapping_key_multiple_sections() {
957        // Initialize the deterministic context
958        let executor = deterministic::Runner::default();
959        executor.start(|context| async move {
960            // Initialize the archive
961            let cfg = Config {
962                translator: FourCap,
963                key_partition: "test-index".into(),
964                key_page_cache: CacheRef::from_pooler(&context, PAGE_SIZE, PAGE_CACHE_SIZE),
965                value_partition: "test-value".into(),
966                codec_config: (),
967                compression: None,
968                key_write_buffer: NZUsize!(DEFAULT_WRITE_BUFFER),
969                value_write_buffer: NZUsize!(DEFAULT_WRITE_BUFFER),
970                replay_buffer: NZUsize!(DEFAULT_REPLAY_BUFFER),
971                items_per_section: NZU64!(DEFAULT_ITEMS_PER_SECTION),
972            };
973            let mut archive = Archive::init(context.child("storage"), cfg.clone())
974                .await
975                .expect("Failed to initialize archive");
976
977            let index1 = 1u64;
978            let key1 = test_key("keys1");
979            let data1 = 1;
980            let index2 = 2_000_000u64;
981            let key2 = test_key("keys2");
982            let data2 = 2;
983
984            // Put the key-data pair
985            archive
986                .put(index1, key1.clone(), data1)
987                .await
988                .expect("Failed to put data");
989
990            // Put the key-data pair
991            archive
992                .put(index2, key2.clone(), data2)
993                .await
994                .expect("Failed to put data");
995
996            // Get the data back
997            let retrieved = archive
998                .get(Identifier::Key(&key1))
999                .await
1000                .expect("Failed to get data")
1001                .expect("Data not found");
1002            assert_eq!(retrieved, data1);
1003
1004            // Get the data back
1005            let retrieved = archive
1006                .get(Identifier::Key(&key2))
1007                .await
1008                .expect("Failed to get data")
1009                .expect("Data not found");
1010            assert_eq!(retrieved, data2);
1011        });
1012    }
1013
1014    #[test_traced]
1015    fn test_archive_prune_keys() {
1016        // Initialize the deterministic context
1017        let executor = deterministic::Runner::default();
1018        executor.start(|context| async move {
1019            // Initialize the archive
1020            let cfg = Config {
1021                translator: FourCap,
1022                key_partition: "test-index".into(),
1023                key_page_cache: CacheRef::from_pooler(&context, PAGE_SIZE, PAGE_CACHE_SIZE),
1024                value_partition: "test-value".into(),
1025                codec_config: (),
1026                compression: None,
1027                key_write_buffer: NZUsize!(DEFAULT_WRITE_BUFFER),
1028                value_write_buffer: NZUsize!(DEFAULT_WRITE_BUFFER),
1029                replay_buffer: NZUsize!(DEFAULT_REPLAY_BUFFER),
1030                items_per_section: NZU64!(1), // no mask - each item is its own section
1031            };
1032            let mut archive = Archive::init(context.child("storage"), cfg.clone())
1033                .await
1034                .expect("Failed to initialize archive");
1035
1036            // Insert multiple keys across different sections
1037            let keys = vec![
1038                (1u64, test_key("key1-blah"), 1),
1039                (2u64, test_key("key2-blah"), 2),
1040                (3u64, test_key("key3-blah"), 3),
1041                (4u64, test_key("key3-bleh"), 3),
1042                (5u64, test_key("key4-blah"), 4),
1043            ];
1044
1045            for (index, key, data) in &keys {
1046                archive
1047                    .put(*index, key.clone(), *data)
1048                    .await
1049                    .expect("Failed to put data");
1050            }
1051
1052            // Check metrics
1053            let buffer = context.encode();
1054            assert!(has_metric_value(&buffer, "items_tracked", 5));
1055
1056            // Prune sections less than 3
1057            archive.prune(3).await.expect("Failed to prune");
1058
1059            // Ensure keys 1 and 2 are no longer present
1060            for (index, key, data) in keys {
1061                let retrieved = archive
1062                    .get(Identifier::Key(&key))
1063                    .await
1064                    .expect("Failed to get data");
1065                if index < 3 {
1066                    assert!(retrieved.is_none());
1067                } else {
1068                    assert_eq!(retrieved.expect("Data not found"), data);
1069                }
1070            }
1071
1072            // Check metrics
1073            let buffer = context.encode();
1074            assert!(has_metric_value(&buffer, "items_tracked", 3));
1075            assert!(has_metric_value(&buffer, "indices_pruned_total", 2));
1076            assert!(has_metric_value(&buffer, "pruned_total", 0)); // no lazy cleanup yet
1077
1078            // Try to prune older section
1079            archive.prune(2).await.expect("Failed to prune");
1080
1081            // Try to prune current section again
1082            archive.prune(3).await.expect("Failed to prune");
1083
1084            // Try to put older index
1085            let result = archive.put(1, test_key("key1-blah"), 1).await;
1086            assert!(matches!(result, Err(Error::AlreadyPrunedTo(3))));
1087
1088            // Trigger lazy removal of keys
1089            archive
1090                .put(6, test_key("key2-blfh"), 5)
1091                .await
1092                .expect("Failed to put data");
1093
1094            // Check metrics
1095            let buffer = context.encode();
1096            assert!(has_metric_value(&buffer, "items_tracked", 4)); // lazily remove one, add one
1097            assert!(has_metric_value(&buffer, "indices_pruned_total", 2));
1098            assert!(has_metric_value(&buffer, "pruned_total", 1));
1099        });
1100    }
1101
1102    fn test_archive_keys_and_restart(num_keys: usize) -> String {
1103        // Initialize the deterministic context
1104        let executor = deterministic::Runner::default();
1105        executor.start(|mut context| async move {
1106            // Initialize the archive
1107            let items_per_section = 256u64;
1108            let cfg = Config {
1109                translator: TwoCap,
1110                key_partition: "test-index".into(),
1111                key_page_cache: CacheRef::from_pooler(&context, PAGE_SIZE, PAGE_CACHE_SIZE),
1112                value_partition: "test-value".into(),
1113                codec_config: (),
1114                compression: None,
1115                key_write_buffer: NZUsize!(DEFAULT_WRITE_BUFFER),
1116                value_write_buffer: NZUsize!(DEFAULT_WRITE_BUFFER),
1117                replay_buffer: NZUsize!(DEFAULT_REPLAY_BUFFER),
1118                items_per_section: NZU64!(items_per_section),
1119            };
1120            let mut archive = Archive::init(
1121                context.child("init").with_attribute("index", 1),
1122                cfg.clone(),
1123            )
1124            .await
1125            .expect("Failed to initialize archive");
1126
1127            // Insert multiple keys across different sections
1128            let mut keys = BTreeMap::new();
1129            while keys.len() < num_keys {
1130                let index = keys.len() as u64;
1131                let mut key = [0u8; 64];
1132                context.fill(&mut key);
1133                let key = FixedBytes::<64>::decode(key.as_ref()).unwrap();
1134                let mut data = [0u8; 1024];
1135                context.fill(&mut data);
1136                let data = FixedBytes::<1024>::decode(data.as_ref()).unwrap();
1137
1138                archive
1139                    .put(index, key.clone(), data.clone())
1140                    .await
1141                    .expect("Failed to put data");
1142                keys.insert(key, (index, data));
1143            }
1144
1145            // Ensure all keys can be retrieved
1146            for (key, (index, data)) in &keys {
1147                let retrieved = archive
1148                    .get(Identifier::Index(*index))
1149                    .await
1150                    .expect("Failed to get data")
1151                    .expect("Data not found");
1152                assert_eq!(&retrieved, data);
1153                let retrieved = archive
1154                    .get(Identifier::Key(key))
1155                    .await
1156                    .expect("Failed to get data")
1157                    .expect("Data not found");
1158                assert_eq!(&retrieved, data);
1159            }
1160
1161            // Check metrics
1162            let buffer = context.encode();
1163            assert!(has_metric_value(&buffer, "items_tracked", num_keys));
1164            assert!(has_metric_value(&buffer, "pruned_total", 0));
1165
1166            // Sync and drop the archive
1167            archive.sync().await.expect("Failed to sync archive");
1168            drop(archive);
1169
1170            // Reinitialize the archive
1171            let cfg = Config {
1172                translator: TwoCap,
1173                key_partition: "test-index".into(),
1174                key_page_cache: CacheRef::from_pooler(&context, PAGE_SIZE, PAGE_CACHE_SIZE),
1175                value_partition: "test-value".into(),
1176                codec_config: (),
1177                compression: None,
1178                key_write_buffer: NZUsize!(DEFAULT_WRITE_BUFFER),
1179                value_write_buffer: NZUsize!(DEFAULT_WRITE_BUFFER),
1180                replay_buffer: NZUsize!(DEFAULT_REPLAY_BUFFER),
1181                items_per_section: NZU64!(items_per_section),
1182            };
1183            let mut archive = Archive::<_, _, _, FixedBytes<1024>>::init(
1184                context.child("init").with_attribute("index", 2),
1185                cfg.clone(),
1186            )
1187            .await
1188            .expect("Failed to initialize archive");
1189
1190            // Ensure all keys can be retrieved
1191            for (key, (index, data)) in &keys {
1192                let retrieved = archive
1193                    .get(Identifier::Index(*index))
1194                    .await
1195                    .expect("Failed to get data")
1196                    .expect("Data not found");
1197                assert_eq!(&retrieved, data);
1198                let retrieved = archive
1199                    .get(Identifier::Key(key))
1200                    .await
1201                    .expect("Failed to get data")
1202                    .expect("Data not found");
1203                assert_eq!(&retrieved, data);
1204            }
1205
1206            // Prune first half
1207            let min = (keys.len() / 2) as u64;
1208            archive.prune(min).await.expect("Failed to prune");
1209
1210            // Ensure all keys can be retrieved that haven't been pruned
1211            let min = (min / items_per_section) * items_per_section;
1212            let mut removed = 0;
1213            for (key, (index, data)) in keys {
1214                if index >= min {
1215                    let retrieved = archive
1216                        .get(Identifier::Key(&key))
1217                        .await
1218                        .expect("Failed to get data")
1219                        .expect("Data not found");
1220                    assert_eq!(retrieved, data);
1221
1222                    // Check range
1223                    let (current_end, start_next) = archive.next_gap(index);
1224                    assert_eq!(current_end.unwrap(), num_keys as u64 - 1);
1225                    assert!(start_next.is_none());
1226                } else {
1227                    let retrieved = archive
1228                        .get(Identifier::Key(&key))
1229                        .await
1230                        .expect("Failed to get data");
1231                    assert!(retrieved.is_none());
1232                    removed += 1;
1233
1234                    // Check range
1235                    let (current_end, start_next) = archive.next_gap(index);
1236                    assert!(current_end.is_none());
1237                    assert_eq!(start_next.unwrap(), min);
1238                }
1239            }
1240
1241            // Check metrics
1242            let buffer = context.encode();
1243            assert!(has_metric_value(
1244                &buffer,
1245                "items_tracked",
1246                num_keys - removed
1247            ));
1248            assert!(has_metric_value(&buffer, "indices_pruned_total", removed));
1249            assert!(has_metric_value(&buffer, "pruned_total", 0)); // have not lazily removed keys yet
1250
1251            context.auditor().state()
1252        })
1253    }
1254
1255    #[test_group("slow")]
1256    #[test_traced]
1257    fn test_archive_many_keys_and_restart() {
1258        test_archive_keys_and_restart(100_000);
1259    }
1260
1261    #[test_group("slow")]
1262    #[test_traced]
1263    fn test_determinism() {
1264        let state1 = test_archive_keys_and_restart(5_000);
1265        let state2 = test_archive_keys_and_restart(5_000);
1266        assert_eq!(state1, state2);
1267    }
1268
1269    /// Regression: when the same key is stored at multiple indices and the
1270    /// earlier index is pruned, a subsequent `get`/`has` by key must resolve
1271    /// to the surviving, non-pruned entry rather than report the pruned one.
1272    /// Callers such as consensus's marshal cache rely on this to retain a
1273    /// reproposal of the same block at a later index even after the
1274    /// earlier index's retention window closes.
1275    #[test_traced]
1276    fn test_archive_key_lookup_skips_pruned_duplicates() {
1277        let executor = deterministic::Runner::default();
1278        executor.start(|context| async move {
1279            let cfg = Config {
1280                translator: FourCap,
1281                key_partition: "test-index".into(),
1282                key_page_cache: CacheRef::from_pooler(&context, PAGE_SIZE, PAGE_CACHE_SIZE),
1283                value_partition: "test-value".into(),
1284                codec_config: (),
1285                compression: None,
1286                key_write_buffer: NZUsize!(DEFAULT_WRITE_BUFFER),
1287                value_write_buffer: NZUsize!(DEFAULT_WRITE_BUFFER),
1288                replay_buffer: NZUsize!(DEFAULT_REPLAY_BUFFER),
1289                items_per_section: NZU64!(1),
1290            };
1291            let mut archive = Archive::init(context.child("storage"), cfg)
1292                .await
1293                .expect("Failed to initialize archive");
1294
1295            // Same key stored at two different indices. Distinct values only
1296            // to make it observable which entry wins; a real caller would
1297            // store the same value (e.g. the same block) at both indices.
1298            let key = test_key("dupe-key");
1299            archive.put(2, key.clone(), 20).await.unwrap();
1300            archive.put(5, key.clone(), 50).await.unwrap();
1301
1302            // Before pruning, either entry is a permitted answer per the
1303            // trait contract. The implementation happens to return the
1304            // earlier index, but we only assert a value is present.
1305            assert!(archive.get(Identifier::Key(&key)).await.unwrap().is_some());
1306            assert!(archive.has(Identifier::Key(&key)).await.unwrap());
1307
1308            // Prune the earlier index (section 2). The later index must be
1309            // the sole surviving answer.
1310            archive.prune(3).await.unwrap();
1311            let got = archive.get(Identifier::Key(&key)).await.unwrap();
1312            assert_eq!(
1313                got,
1314                Some(50),
1315                "key lookup must skip the pruned entry and return the surviving one"
1316            );
1317            assert!(archive.has(Identifier::Key(&key)).await.unwrap());
1318
1319            // Prune past the later index too — now nothing survives.
1320            archive.prune(6).await.unwrap();
1321            assert_eq!(archive.get(Identifier::Key(&key)).await.unwrap(), None);
1322            assert!(!archive.has(Identifier::Key(&key)).await.unwrap());
1323        });
1324    }
1325
1326    #[test_traced]
1327    fn test_get_all_after_prune() {
1328        let executor = deterministic::Runner::default();
1329        executor.start(|context| async move {
1330            let cfg = Config {
1331                translator: FourCap,
1332                key_partition: "test-index".into(),
1333                key_page_cache: CacheRef::from_pooler(&context, PAGE_SIZE, PAGE_CACHE_SIZE),
1334                value_partition: "test-value".into(),
1335                codec_config: (),
1336                compression: None,
1337                key_write_buffer: NZUsize!(DEFAULT_WRITE_BUFFER),
1338                value_write_buffer: NZUsize!(DEFAULT_WRITE_BUFFER),
1339                replay_buffer: NZUsize!(DEFAULT_REPLAY_BUFFER),
1340                items_per_section: NZU64!(1),
1341            };
1342            let mut archive = Archive::init(context.child("storage"), cfg)
1343                .await
1344                .expect("Failed to initialize archive");
1345
1346            archive.put_multi(1, test_key("aaa"), 10).await.unwrap();
1347            archive.put_multi(1, test_key("bbb"), 20).await.unwrap();
1348            archive.put_multi(3, test_key("ccc"), 30).await.unwrap();
1349
1350            // Prune below index 3
1351            archive.prune(3).await.unwrap();
1352
1353            // Pruned index returns None
1354            let all = archive.get_all(1).await.unwrap();
1355            assert_eq!(all, None);
1356
1357            // Surviving index still works
1358            let all = archive.get_all(3).await.unwrap();
1359            assert_eq!(all, Some(vec![30]));
1360        });
1361    }
1362
1363    #[test_traced]
1364    fn test_has_at() {
1365        let executor = deterministic::Runner::default();
1366        let (_, checkpoint) = executor.start_and_recover(|context| async move {
1367            let cfg = test_config(&context, NZU64!(2));
1368            let mut archive = Archive::init(context.child("storage"), cfg)
1369                .await
1370                .expect("Failed to initialize archive");
1371
1372            // Vacant index
1373            assert!(!archive.has_at(1, &test_key("aaaa1")).await.unwrap());
1374
1375            // Exact key at the index
1376            archive.put_multi(1, test_key("aaaa1"), 10).await.unwrap();
1377            assert!(archive.has_at(1, &test_key("aaaa1")).await.unwrap());
1378
1379            // Same key is not reported at other indices
1380            assert!(!archive.has_at(2, &test_key("aaaa1")).await.unwrap());
1381
1382            // A translated-key collision (FourCap shares the "aaaa" prefix)
1383            // must not produce a false positive
1384            assert!(!archive.has_at(1, &test_key("aaaa2")).await.unwrap());
1385
1386            // A second entry at the same index is visible alongside the first
1387            archive.put_multi(1, test_key("aaaa2"), 20).await.unwrap();
1388            assert!(archive.has_at(1, &test_key("aaaa1")).await.unwrap());
1389            assert!(archive.has_at(1, &test_key("aaaa2")).await.unwrap());
1390
1391            // A different key at an occupied index is absent
1392            assert!(!archive.has_at(1, &test_key("bbbb")).await.unwrap());
1393
1394            archive.put_multi(3, test_key("cccc"), 30).await.unwrap();
1395            archive.sync().await.unwrap();
1396        });
1397
1398        deterministic::Runner::from(checkpoint).start(|context| async move {
1399            let cfg = test_config(&context, NZU64!(2));
1400            let mut archive =
1401                Archive::<_, _, FixedBytes<64>, i32>::init(context.child("reopen"), cfg)
1402                    .await
1403                    .expect("Failed to reopen archive");
1404
1405            // Replay rebuilds both entries at the shared index
1406            assert!(archive.has_at(1, &test_key("aaaa1")).await.unwrap());
1407            assert!(archive.has_at(1, &test_key("aaaa2")).await.unwrap());
1408            assert!(!archive.has_at(1, &test_key("bbbb")).await.unwrap());
1409
1410            // Pruned indices report absent
1411            archive.prune(2).await.unwrap();
1412            assert!(!archive.has_at(1, &test_key("aaaa1")).await.unwrap());
1413            assert!(!archive.has_at(1, &test_key("aaaa2")).await.unwrap());
1414            assert!(archive.has_at(3, &test_key("cccc")).await.unwrap());
1415
1416            archive.destroy().await.unwrap();
1417        });
1418    }
1419
1420    #[test_traced]
1421    fn test_has_key() {
1422        let executor = deterministic::Runner::default();
1423        executor.start(|context| async move {
1424            let cfg = test_config(&context, NZU64!(2));
1425            let mut archive = Archive::init(context.child("storage"), cfg)
1426                .await
1427                .expect("Failed to initialize archive");
1428
1429            // Absent key
1430            let key = test_key("aaaa1");
1431            assert!(!archive.has(Identifier::Key(&key)).await.unwrap());
1432
1433            // Exact key
1434            archive.put(1, key.clone(), 10).await.unwrap();
1435            assert!(archive.has(Identifier::Key(&key)).await.unwrap());
1436
1437            // A translated-key collision (FourCap shares the "aaaa" prefix)
1438            // must not produce a false positive
1439            let collision = test_key("aaaa2");
1440            assert!(!archive.has(Identifier::Key(&collision)).await.unwrap());
1441            archive.put(2, collision.clone(), 20).await.unwrap();
1442            assert!(archive.has(Identifier::Key(&collision)).await.unwrap());
1443
1444            // Pruned keys report absent. Pruning is section-granular
1445            // (items_per_section = 2), so prune at a section boundary that
1446            // drops indices 1 and 2 while retaining index 4.
1447            archive.put(4, test_key("cccc"), 30).await.unwrap();
1448            archive.prune(4).await.unwrap();
1449            assert!(!archive.has(Identifier::Key(&key)).await.unwrap());
1450            assert!(!archive.has(Identifier::Key(&collision)).await.unwrap());
1451            assert!(archive
1452                .has(Identifier::Key(&test_key("cccc")))
1453                .await
1454                .unwrap());
1455
1456            archive.destroy().await.unwrap();
1457        });
1458    }
1459
1460    #[test_traced]
1461    fn test_put_multi_prune() {
1462        let executor = deterministic::Runner::default();
1463        executor.start(|context| async move {
1464            let cfg = Config {
1465                translator: FourCap,
1466                key_partition: "test-index".into(),
1467                key_page_cache: CacheRef::from_pooler(&context, PAGE_SIZE, PAGE_CACHE_SIZE),
1468                value_partition: "test-value".into(),
1469                codec_config: (),
1470                compression: None,
1471                key_write_buffer: NZUsize!(DEFAULT_WRITE_BUFFER),
1472                value_write_buffer: NZUsize!(DEFAULT_WRITE_BUFFER),
1473                replay_buffer: NZUsize!(DEFAULT_REPLAY_BUFFER),
1474                items_per_section: NZU64!(1),
1475            };
1476            let mut archive = Archive::init(context.child("storage"), cfg)
1477                .await
1478                .expect("Failed to initialize archive");
1479
1480            // Two items at index 1, one at index 3
1481            archive.put_multi(1, test_key("aaa"), 10).await.unwrap();
1482            archive.put_multi(1, test_key("bbb"), 20).await.unwrap();
1483            archive.put_multi(3, test_key("ccc"), 30).await.unwrap();
1484
1485            let buffer = context.encode();
1486            assert!(has_metric_value(&buffer, "items_tracked", 2));
1487
1488            // Prune below index 3
1489            archive.prune(3).await.unwrap();
1490
1491            // Both items at index 1 are gone
1492            assert_eq!(
1493                archive
1494                    .get(Identifier::Key(&test_key("aaa")))
1495                    .await
1496                    .unwrap(),
1497                None
1498            );
1499            assert_eq!(
1500                archive
1501                    .get(Identifier::Key(&test_key("bbb")))
1502                    .await
1503                    .unwrap(),
1504                None
1505            );
1506
1507            // Item at index 3 survives
1508            assert_eq!(
1509                archive
1510                    .get(Identifier::Key(&test_key("ccc")))
1511                    .await
1512                    .unwrap(),
1513                Some(30)
1514            );
1515
1516            let buffer = context.encode();
1517            assert!(has_metric_value(&buffer, "items_tracked", 1));
1518            assert!(has_metric_value(&buffer, "indices_pruned_total", 1));
1519
1520            // put_multi below pruned index is rejected
1521            let result = archive.put_multi(2, test_key("ddd"), 40).await;
1522            assert!(matches!(result, Err(Error::AlreadyPrunedTo(3))));
1523        });
1524    }
1525}