Skip to main content

commonware_storage/journal/contiguous/
fixed.rs

1//! An append-only log for storing fixed length _items_ on disk.
2//!
3//! In addition to replay, stored items can be fetched directly by their `position` in the journal,
4//! where position is defined as the item's order of insertion starting from 0, unaffected by
5//! pruning.
6//!
7//! _See [super::variable] for a journal that supports variable length items._
8//!
9//! # Format
10//!
11//! Data stored in a `fixed::Journal` is persisted in one of many Blobs. Each `Blob` contains a
12//! configurable maximum of `items_per_blob`, with page-level data integrity provided by a buffer
13//! pool.
14//!
15//! ```text
16//! +--------+--------+-----+----------+
17//! | item_0 | item_1 | ... | item_n-1 |
18//! +--------+--------+-----+----------+
19//!
20//! n = config.items_per_blob
21//! ```
22//!
23//! The most recent blob may not necessarily be full, in which case it will contain fewer than the
24//! maximum number of items.
25//!
26//! Data fetched from disk is always checked for integrity before being returned. If the data is
27//! found to be invalid, an error is returned instead.
28//!
29//! # Architecture
30//!
31//! Three types divide the work:
32//!
33//! - [`Journal`] tracks which positions are readable (`bounds`), maps each position to a blob
34//!   and byte offset, and remembers which blobs have writes that are not yet fsynced
35//!   (`dirty_from_blob`) so commit/sync only fsync what changed.
36//!
37//! - `Writable` owns the files: the contiguous sealed blobs plus the one writable tail.
38//!
39//! - `Checkpoint` owns the durable recovery hints (mid-blob pruning boundary, recovery
40//!   watermark, staged clear target) consulted before trusting blob state on startup.
41//!
42//! # Open Blobs
43//!
44//! Every retained blob is held open; a pruned blob stays open until the last snapshot holding it
45//! drops. Use a larger `items_per_blob` or prune to bound the count.
46//!
47//! # Partition
48//!
49//! Blobs are stored in the legacy partition (`cfg.partition`) if it already contains data;
50//! otherwise they are stored in `{cfg.partition}-blobs`.
51//!
52//! The checkpoint (the durable recovery record: pruning boundary, recovery watermark, and any
53//! in-progress clear intent) is stored in `{cfg.partition}-metadata`.
54//!
55//! # Recovery
56//!
57//! Blobs are filled sequentially. Recovery walks the blob range from oldest to newest and
58//! compares each blob's item count to its logical capacity:
59//!
60//! - A short or missing non-newest blob indicates a gap in durable data; recovery stops there
61//!   and truncates newer blobs.
62//! - The newest blob may be short, since it is the normal append frontier. Recovery includes
63//!   its items.
64//!
65//! The recovered size is the logical end of this contiguous prefix. If the persisted watermark
66//! exceeds the recovered size, recovery returns a corruption error. Both the pruning boundary
67//! and watermark are persisted before `init` returns.
68//!
69//! The recovery watermark is therefore an external recovery checkpoint, not a complete record of
70//! every item that may have become durable through `commit` or storage behavior.
71//!
72//! # Consistency
73//!
74//! Data written to `Journal` may not be immediately persisted to `Storage`. It is up to the caller
75//! to determine when to force pending data to be durably written using `commit` or `sync`.
76//!
77//! # Pruning
78//!
79//! The `prune` method allows the `Journal` to prune blobs consisting entirely of items prior to a
80//! given point in history.
81//!
82//! # Clearing / reset
83//!
84//! Clearing wipes all data and restarts the journal at a new size.
85//!
86//! To stay crash-safe, a clear records its target size in the checkpoint *before* deleting any
87//! blob. If a crash interrupts the deletion, reopening sees that recorded target and finishes the
88//! clear, rather than mistaking the half-deleted blobs for corruption.
89//!
90//! Callers reach this through `clear_to_size` (clear an open journal) or `init_at_size` (open
91//! straight into a cleared, empty journal at a given size).
92//!
93//! # Replay
94//!
95//! The `replay` method supports fast reading of all unpruned items into memory.
96
97use super::{
98    blobs::{Blob, Blobs, Partition, Replay as BlobReplay, Writable},
99    checkpoint::Checkpoint,
100};
101#[commonware_macros::stability(ALPHA)]
102use crate::{journal::authenticated, merkle};
103use crate::{
104    journal::{
105        contiguous::{metrics::Metrics, Many, Mutable},
106        Error,
107    },
108    Context,
109};
110use commonware_codec::{CodecFixedShared, DecodeExt as _, ReadExt as _};
111use commonware_runtime::{
112    buffer::paged::{CacheRef, Writer},
113    Blob as RBlob, Buf, IoBuf,
114};
115use commonware_utils::Cached;
116use futures::{future::try_join_all, Stream};
117use std::{
118    collections::BTreeMap,
119    future::Future,
120    marker::PhantomData,
121    num::{NonZeroU64, NonZeroUsize},
122    ops::Range,
123    sync::Arc,
124};
125use tracing::warn;
126
127// Reusable scratch for [`Reader::probe_items`], grown to the largest probe served on the
128// thread. Probes run per shard on the hot read path, where a fresh zeroed allocation per call
129// contends under the pool's fan-out.
130commonware_utils::thread_local_cache!(static PROBE_SCRATCH: Vec<u8>);
131
132/// Items encoded for a deferred append, created by [`Journal::prepare_append`] and consumed by
133/// [`Journal::append_prepared`].
134pub struct PreparedAppend<A> {
135    buf: Vec<u8>,
136    _marker: PhantomData<A>,
137}
138
139/// Return the first retained logical position in `blob`.
140#[inline]
141fn first_in_blob(pruning_boundary: u64, blob: u64, items_per_blob: u64) -> Result<u64, Error> {
142    let start = super::blob_first_position(blob, items_per_blob)?;
143    Ok(pruning_boundary.max(start))
144}
145
146/// Build a replay stream over the retained blob range.
147///
148/// The stream is split into one state per blob so replay can start at a mid-blob pruning boundary,
149/// stop at the journal's logical end, and avoid reading across blob files. `buffer` is a byte
150/// budget for each blob replay, not an item count.
151fn replay_stream<'a, B: RBlob, A: CodecFixedShared>(
152    blobs: &Blobs<'a, B>,
153    bounds: Range<u64>,
154    items_per_blob: NonZeroU64,
155    start_pos: u64,
156    buffer: NonZeroUsize,
157) -> Result<impl Stream<Item = Result<(u64, A), Error>> + Send + use<'a, B, A>, Error> {
158    if start_pos > bounds.end {
159        return Err(Error::ItemOutOfRange(start_pos));
160    }
161    if start_pos < bounds.start {
162        return Err(Error::ItemPruned(start_pos));
163    }
164
165    let mut states = Vec::new();
166    if start_pos < bounds.end {
167        let items_per_blob = items_per_blob.get();
168        let start_blob = super::position_to_blob(start_pos, items_per_blob);
169        let end_blob = super::position_to_blob(bounds.end - 1, items_per_blob);
170        let items_per_batch = (buffer.get() / A::SIZE).max(1);
171
172        for blob in start_blob..=end_blob {
173            // The oldest retained blob may begin after its natural blob boundary when pruning
174            // kept only a suffix.
175            let blob_first = first_in_blob(bounds.start, blob, items_per_blob)?;
176            let first_pos = if blob == start_blob {
177                start_pos
178            } else {
179                blob_first
180            };
181            let blob_end = super::blob_end_position(blob, items_per_blob, bounds.end);
182            let offset = (first_pos - blob_first)
183                .checked_mul(A::SIZE as u64)
184                .ok_or(Error::OffsetOverflow)?;
185            let blob = blobs
186                .get(blob)
187                .expect("positions in bounds map to a retained blob");
188
189            states.push(FixedReplayState::<B, A> {
190                replay: blob.replay_from(offset, buffer)?,
191                pos: first_pos,
192                end_pos: blob_end,
193                items_per_batch,
194                _marker: PhantomData,
195            });
196        }
197    }
198
199    Ok(super::replay_stream_from_states(states))
200}
201
202/// Replay state for one fixed-size blob.
203struct FixedReplayState<'a, B: RBlob, A> {
204    /// Sequential logical bytes for this blob.
205    replay: BlobReplay<'a, B>,
206    /// Next position to yield.
207    pos: u64,
208    /// Exclusive end position within this blob.
209    end_pos: u64,
210    /// Maximum number of items decoded per stream poll.
211    items_per_batch: usize,
212    _marker: PhantomData<A>,
213}
214
215impl<B: RBlob, A: CodecFixedShared> super::ReplayBatchState for FixedReplayState<'_, B, A> {
216    type Item = A;
217
218    /// Decode the next batch of fixed-size items from this blob.
219    async fn next_batch(mut self) -> Option<(Vec<Result<(u64, A), Error>>, Self)> {
220        if self.pos == self.end_pos {
221            return None;
222        }
223
224        // Require at least one whole item so a short blob is reported as corruption at the
225        // current position. Additional already-buffered items are decoded below.
226        let mut batch = Vec::new();
227        match self.replay.ensure(A::SIZE).await {
228            Ok(true) => {}
229            Ok(false) => {
230                batch.push(Err(Error::Corruption(format!(
231                    "blob ended before position {}",
232                    self.pos
233                ))));
234                self.pos = self.end_pos;
235                return Some((batch, self));
236            }
237            Err(err) => {
238                batch.push(Err(err));
239                self.pos = self.end_pos;
240                return Some((batch, self));
241            }
242        }
243
244        // Decode only whole items that are already buffered, capped by the replay byte budget and
245        // this blob's logical end.
246        let available = (self.replay.remaining() / A::SIZE) as u64;
247        let remaining = self.end_pos - self.pos;
248        let count = available.min(self.items_per_batch as u64).min(remaining) as usize;
249        let Some(next_pos) = self.pos.checked_add(count as u64) else {
250            batch.push(Err(Error::OffsetOverflow));
251            self.pos = self.end_pos;
252            return Some((batch, self));
253        };
254        batch.reserve(count);
255
256        let base = self.pos;
257        for i in 0..count {
258            match A::read(&mut self.replay) {
259                Ok(item) => batch.push(Ok((base + i as u64, item))),
260                Err(err) => {
261                    batch.push(Err(Error::Codec(err)));
262                    self.pos = self.end_pos;
263                    return Some((batch, self));
264                }
265            }
266        }
267        self.pos = next_pos;
268        Some((batch, self))
269    }
270}
271
272/// How a blob's on-disk item count compares to its logical capacity.
273enum BlobFill {
274    Full { len: u64 },
275    Short { len: u64 },
276    Overfull { len: u64, capacity: u64 },
277}
278
279/// The recovered journal bounds and any pending tail repair, reconciled from the checkpoint hints
280/// and the on-disk blob lengths.
281struct RecoveredBounds {
282    /// First retained position.
283    pruning_boundary: u64,
284    /// Size: one past the last recovered item.
285    size: u64,
286    /// Recovery watermark to persist (a floor on durable size).
287    recovery_watermark: u64,
288    /// If set, the byte length to truncate the recovered tail blob to; every blob newer than the
289    /// tail must be removed.
290    repair: Option<u64>,
291}
292
293/// Configuration for `Journal` storage.
294#[derive(Clone)]
295pub struct Config {
296    /// Prefix for the journal partitions.
297    ///
298    /// Blobs are stored in `partition` (legacy) if it contains data, otherwise in
299    /// `{partition}-blobs`. Metadata is stored in `{partition}-metadata`.
300    pub partition: String,
301
302    /// The maximum number of journal items to store in each blob.
303    ///
304    /// Retained non-tail blobs are expected to be full relative to their logical capacity. A
305    /// mid-blob oldest blob may physically hold fewer than this many items, and the newest blob
306    /// may contain fewer items.
307    pub items_per_blob: NonZeroU64,
308
309    /// The page cache to use for caching data.
310    pub page_cache: CacheRef,
311
312    /// The size of the write buffer to use for each blob.
313    pub write_buffer: NonZeroUsize,
314}
315
316/// Implementation of [super::Mutable] for fixed-size value journals.
317///
318/// # Repair
319///
320/// Like
321/// [sqlite](https://github.com/sqlite/sqlite/blob/8658a8df59f00ec8fcfea336a2a6a4b5ef79d2ee/src/wal.c#L1504-L1505)
322/// and
323/// [rocksdb](https://github.com/facebook/rocksdb/blob/0c533e61bc6d89fdf1295e8e0bcee4edb3aef401/include/rocksdb/options.h#L441-L445),
324/// the first invalid data read will be considered the new end of the journal (and the
325/// underlying blob will be truncated to the last valid item). Repair is performed during init.
326pub struct Journal<E: Context, A> {
327    /// The blobs that comprise the journal.
328    blobs: Writable<E>,
329
330    /// The durable recovery checkpoint.
331    checkpoint: Checkpoint<E>,
332
333    /// The readable positions; `bounds.end` is the next append position.
334    bounds: Range<u64>,
335
336    /// Earliest blob modified since the last `commit()` or `sync()`.
337    dirty_from_blob: Option<u64>,
338
339    /// The maximum number of items per blob.
340    items_per_blob: NonZeroU64,
341
342    /// Shared with [Reader]s.
343    metrics: Arc<Metrics<E>>,
344
345    _phantom: PhantomData<A>,
346}
347
348impl<E: Context, A: CodecFixedShared> Journal<E, A> {
349    /// Size of each entry in bytes. Evaluating this rejects zero-size item types at compile
350    /// time, which would otherwise divide by zero in the chunk math.
351    pub const CHUNK_SIZE: NonZeroUsize = match NonZeroUsize::new(A::SIZE) {
352        Some(size) => size,
353        None => panic!("journal item size must be nonzero"),
354    };
355
356    /// Size of each entry in bytes (as u64).
357    pub const CHUNK_SIZE_U64: u64 = Self::CHUNK_SIZE.get() as u64;
358
359    /// Convert an item count to a byte length, failing on overflow.
360    fn items_to_bytes(items: u64) -> Result<u64, Error> {
361        items
362            .checked_mul(Self::CHUNK_SIZE_U64)
363            .ok_or(Error::OffsetOverflow)
364    }
365
366    /// Mark all blobs from `blob` onward as dirty.
367    fn mark_dirty_from(&mut self, blob: u64) {
368        self.dirty_from_blob = Some(
369            self.dirty_from_blob
370                .map_or(blob, |existing| existing.min(blob)),
371        );
372    }
373
374    /// Construct a journal from recovered blobs.
375    fn from_blobs(
376        blobs: Writable<E>,
377        checkpoint: Checkpoint<E>,
378        bounds: Range<u64>,
379        dirty_from_blob: Option<u64>,
380        items_per_blob: NonZeroU64,
381        metrics: Metrics<E>,
382    ) -> Self {
383        Self {
384            blobs,
385            checkpoint,
386            bounds,
387            dirty_from_blob,
388            items_per_blob,
389            metrics: Arc::new(metrics),
390            _phantom: PhantomData,
391        }
392    }
393
394    /// Initialize a new `Journal` instance.
395    ///
396    /// All backing blobs are opened but not read during initialization. The `replay` method can be
397    /// used to iterate over all items in the `Journal`.
398    pub async fn init(context: E, cfg: Config) -> Result<Self, Error> {
399        let checkpoint = Checkpoint::open(context.child("meta"), &cfg.partition).await?;
400        Self::init_with_checkpoint(context, cfg, checkpoint).await
401    }
402
403    /// Finish initialization using an already-open checkpoint.
404    async fn init_with_checkpoint(
405        context: E,
406        cfg: Config,
407        mut checkpoint: Checkpoint<E>,
408    ) -> Result<Self, Error> {
409        // A staged clear intent means all old blob data is about to be discarded. Honor it before
410        // scanning or opening blobs so corrupt stale blobs cannot block recovery of the reset.
411        if let Some(clear_target) = checkpoint.clear_target() {
412            return Self::complete_staged_clear(context, cfg, checkpoint, clear_target).await;
413        }
414
415        let blob_partition = Partition::select(&context, &cfg.partition).await?;
416        let partition = Partition::new(
417            context.child("blobs"),
418            blob_partition,
419            cfg.page_cache,
420            cfg.write_buffer,
421        );
422        let mut pending = partition.open_all().await?;
423
424        // Truncate any trailing non-chunk-aligned bytes on every blob before recovery. Items
425        // are fixed size, so a blob ending in fewer than `CHUNK_SIZE` trailing bytes is junk
426        // from an incomplete write (the page-CRC layer surfaces it as a partial logical tail).
427        // The truncation is synced before `recover_bounds` queries lengths.
428        for (&blob, writer) in &mut pending {
429            let size = writer.size();
430            let valid_size = Self::items_to_bytes(size / Self::CHUNK_SIZE_U64)?;
431            if valid_size != size {
432                warn!(
433                    blob,
434                    invalid_size = size,
435                    new_size = valid_size,
436                    "trailing bytes detected: truncating"
437                );
438                writer.resize(valid_size).await.map_err(Error::Runtime)?;
439                writer.sync().await.map_err(Error::Runtime)?;
440            }
441        }
442
443        let RecoveredBounds {
444            pruning_boundary,
445            size,
446            recovery_watermark,
447            repair,
448        } = Self::recover_bounds(
449            &pending,
450            cfg.items_per_blob.get(),
451            checkpoint.boundary_hint(),
452            checkpoint.watermark(),
453        )?;
454
455        // Persist any lowered checkpoint before applying blob repairs that move recovered state
456        // backward.
457        checkpoint
458            .persist(
459                cfg.items_per_blob.get(),
460                pruning_boundary,
461                recovery_watermark,
462            )
463            .await?;
464
465        // Apply repair (if any). The short blob becomes the new tail; blobs strictly newer
466        // than it are removed (newest-first) and the truncation is synced, so the repair is
467        // durable before sealing.
468        let tail_blob = super::position_to_blob(size, cfg.items_per_blob.get());
469        if let Some(truncate_to) = repair {
470            while let Some((&newest, _)) = pending.last_key_value() {
471                if newest <= tail_blob {
472                    break;
473                }
474                drop(pending.remove(&newest));
475                partition.remove(newest).await?;
476            }
477            if let Some(writer) = pending.get_mut(&tail_blob) {
478                if truncate_to < writer.size() {
479                    writer.resize(truncate_to).await.map_err(Error::Runtime)?;
480                    writer.sync().await.map_err(Error::Runtime)?;
481                }
482            }
483        }
484
485        // Seal every blob below the tail and assemble the blobs.
486        let blobs = Writable::recover(partition, pending, tail_blob).await?;
487
488        // Bytes beyond the persisted recovery watermark may be readable after reopen without
489        // being crash-durable, so the next commit/sync must force a data sync before advancing it.
490        let dirty_from_blob = (recovery_watermark < size)
491            .then(|| super::position_to_blob(recovery_watermark, cfg.items_per_blob.get()));
492
493        let metrics = Metrics::new(context);
494        metrics.update(size, pruning_boundary, cfg.items_per_blob.get());
495
496        Ok(Self::from_blobs(
497            blobs,
498            checkpoint,
499            pruning_boundary..size,
500            dirty_from_blob,
501            cfg.items_per_blob,
502            metrics,
503        ))
504    }
505
506    /// Complete an interrupted clear: discard all blob partitions and start fresh at
507    /// `clear_target`, then finalize the checkpoint the crashed clear left staged.
508    async fn complete_staged_clear(
509        context: E,
510        cfg: Config,
511        mut checkpoint: Checkpoint<E>,
512        clear_target: u64,
513    ) -> Result<Self, Error> {
514        warn!(clear_target, "crash repair: completing interrupted clear");
515        let new_partition = format!("{}-blobs", cfg.partition);
516        Partition::<E>::remove_all(&context, &cfg.partition).await?;
517        Partition::<E>::remove_all(&context, &new_partition).await?;
518        let partition = Partition::new(
519            context.child("blobs"),
520            new_partition,
521            cfg.page_cache,
522            cfg.write_buffer,
523        );
524        let tail_blob = super::position_to_blob(clear_target, cfg.items_per_blob.get());
525        let blobs = Writable::recover(partition, BTreeMap::new(), tail_blob).await?;
526        checkpoint
527            .finish_clear(cfg.items_per_blob.get(), clear_target)
528            .await?;
529
530        let metrics = Metrics::new(context);
531        metrics.update(clear_target, clear_target, cfg.items_per_blob.get());
532        Ok(Self::from_blobs(
533            blobs,
534            checkpoint,
535            clear_target..clear_target,
536            None,
537            cfg.items_per_blob,
538            metrics,
539        ))
540    }
541
542    /// Recover the journal bounds and any tail repair from the checkpoint and blob state.
543    ///
544    /// A boundary hint that lags blob state is repaired from the blob boundary; a hint ahead of
545    /// blob state or a watermark beyond the recovered size is corruption. The caller persists the
546    /// checkpoint before applying the returned repair (see comment at the call site).
547    fn recover_bounds(
548        pending: &BTreeMap<u64, Writer<E::Blob>>,
549        items_per_blob: u64,
550        boundary_hint: Option<u64>,
551        watermark_hint: Option<u64>,
552    ) -> Result<RecoveredBounds, Error> {
553        let pruning_boundary = Self::recover_pruning_boundary(
554            boundary_hint,
555            pending.keys().next().copied(),
556            items_per_blob,
557        )?;
558
559        let (size, repair) =
560            Self::recover_by_walking_lengths(pending, items_per_blob, pruning_boundary)?;
561
562        let recovery_watermark = match watermark_hint {
563            Some(watermark) if watermark > size => {
564                // The dual-CRC page mechanism prevents losing previously-synced data, and
565                // clear_to_size updates the watermark atomically via the staged clear intent. A
566                // watermark beyond the recoverable size indicates external corruption.
567                return Err(Error::Corruption(format!(
568                    "recovery watermark {watermark} exceeds recoverable size {size}"
569                )));
570            }
571            Some(watermark) => watermark,
572            None if repair.is_some() => {
573                // A legacy journal with a short non-tail blob violates the old rollover-sync
574                // invariant (each blob was fsynced before the next received writes).
575                return Err(Error::Corruption(
576                    "legacy journal has a short non-tail blob".into(),
577                ));
578            }
579            // Legacy journals have no watermark. Under the old rollover-sync invariant, all
580            // non-tail blobs are durable; only the tail may have unfsynced data.
581            None => first_in_blob(
582                pruning_boundary,
583                super::position_to_blob(size, items_per_blob),
584                items_per_blob,
585            )?,
586        };
587
588        Ok(RecoveredBounds {
589            pruning_boundary,
590            size,
591            recovery_watermark,
592            repair,
593        })
594    }
595
596    /// Recover the pruning boundary from the checkpoint hint if it still matches the oldest
597    /// retained blob.
598    ///
599    /// A missing or blob-aligned hint means the blob boundary is authoritative. A mid-blob hint
600    /// is trusted only when it belongs to the current oldest blob.
601    fn recover_pruning_boundary(
602        boundary_hint: Option<u64>,
603        oldest_blob: Option<u64>,
604        items_per_blob: u64,
605    ) -> Result<u64, Error> {
606        let blob_boundary = match oldest_blob {
607            Some(oldest) => super::blob_first_position(oldest, items_per_blob)?,
608            None => 0,
609        };
610
611        let Some(boundary_hint) = boundary_hint else {
612            return Ok(blob_boundary);
613        };
614        if boundary_hint.is_multiple_of(items_per_blob) {
615            return Ok(blob_boundary);
616        }
617
618        let hint_blob = super::position_to_blob(boundary_hint, items_per_blob);
619        match oldest_blob {
620            Some(oldest_blob) if hint_blob == oldest_blob => Ok(boundary_hint),
621            Some(oldest_blob) if hint_blob < oldest_blob => {
622                warn!(
623                    hint_blob,
624                    oldest_blob, "crash repair: boundary hint stale, computing from blobs"
625                );
626                Ok(blob_boundary)
627            }
628            Some(oldest_blob) => {
629                // A hint ahead of blob state should never arise: prune removes blobs before
630                // sync persists the checkpoint, and clear_to_size stages a clear intent.
631                Err(Error::Corruption(format!(
632                    "boundary hint references blob {hint_blob} \
633                     but oldest blob is blob {oldest_blob}"
634                )))
635            }
636            None => {
637                // A mid-blob hint with no blobs should never arise: a staged clear is completed
638                // before we get here, and no other operation removes all blobs without updating
639                // the checkpoint.
640                Err(Error::Corruption(format!(
641                    "boundary hint references blob {hint_blob} but no blobs exist"
642                )))
643            }
644        }
645    }
646
647    /// Classify a blob's untrusted on-disk length against its capacity. A missing blob counts
648    /// as zero length, surfacing as a gap.
649    fn classify_fill(
650        pending: &BTreeMap<u64, Writer<E::Blob>>,
651        items_per_blob: u64,
652        pruning_boundary: u64,
653        blob: u64,
654    ) -> Result<BlobFill, Error> {
655        let len = pending
656            .get(&blob)
657            .map_or(0, |writer| writer.size() / Self::CHUNK_SIZE_U64);
658        // A blob's capacity is `items_per_blob`, unless the pruning boundary falls mid-blob
659        // (from `init_at_size`), in which case the skipped prefix reduces it.
660        let start = super::blob_first_position(blob, items_per_blob)?;
661        let skipped = pruning_boundary.saturating_sub(start).min(items_per_blob);
662        let capacity = items_per_blob - skipped;
663        Ok(match len.cmp(&capacity) {
664            std::cmp::Ordering::Less => BlobFill::Short { len },
665            std::cmp::Ordering::Equal => BlobFill::Full { len },
666            std::cmp::Ordering::Greater => BlobFill::Overfull { len, capacity },
667        })
668    }
669
670    /// Recover size by walking blob lengths from oldest to newest, truncating at the
671    /// first short or missing non-tail blob.
672    ///
673    /// `pruning_boundary` is trusted (already reconciled by `recover_pruning_boundary`); blob
674    /// lengths are untrusted disk state. The returned size is chunk-exact and the retained
675    /// prefix is contiguous.
676    fn recover_by_walking_lengths(
677        pending: &BTreeMap<u64, Writer<E::Blob>>,
678        items_per_blob: u64,
679        pruning_boundary: u64,
680    ) -> Result<(u64, Option<u64>), Error> {
681        let oldest = pending.keys().next().copied();
682        let newest = pending.keys().next_back().copied();
683
684        let (Some(oldest), Some(newest)) = (oldest, newest) else {
685            return Ok((pruning_boundary, None));
686        };
687
688        let mut size = pruning_boundary;
689        for blob in oldest..=newest {
690            let fill = Self::classify_fill(pending, items_per_blob, pruning_boundary, blob)?;
691            match fill {
692                // Complete: count its items and keep walking.
693                BlobFill::Full { len } => {
694                    size = size.checked_add(len).ok_or(Error::OffsetOverflow)?;
695                }
696                // The newest blob is the append frontier; short is normal.
697                BlobFill::Short { len } if blob == newest => {
698                    size = size.checked_add(len).ok_or(Error::OffsetOverflow)?;
699                    return Ok((size, None));
700                }
701                // A short or missing interior blob is a gap in durable data: everything newer
702                // is unreachable. Truncate here.
703                BlobFill::Short { len } => {
704                    size = size.checked_add(len).ok_or(Error::OffsetOverflow)?;
705                    return Ok((size, Some(Self::items_to_bytes(len)?)));
706                }
707                BlobFill::Overfull { len, capacity } => {
708                    return Err(Error::Corruption(format!(
709                        "blob {blob} has too many items: expected at most {capacity}, got {len}"
710                    )));
711                }
712            }
713        }
714
715        Ok((size, None))
716    }
717
718    /// Initialize a `Journal` in a fully-pruned state at `size`: existing data is cleared and the
719    /// journal behaves as if `size` items were appended then pruned. It is empty (`bounds` is
720    /// `size..size`) and the next `append` writes at position `size`. Used for state sync.
721    ///
722    /// # Crash Safety
723    /// In the event of a crash during this call, upon restart recovery will ensure the journal is
724    /// either still in its prior state, or has bounds `size..size`.
725    #[commonware_macros::stability(ALPHA)]
726    pub async fn init_at_size(context: E, cfg: Config, size: u64) -> Result<Self, Error> {
727        // Fail before writing intent if existing blob partitions are already inconsistent.
728        Partition::select(&context, &cfg.partition).await?;
729        Self::init_at_size_cleared(context, cfg, size, || async { Ok(()) }).await
730    }
731
732    /// Like [Self::init_at_size], but awaits `clear_dependents` after the reset intent is durably
733    /// staged and before it completes.
734    ///
735    /// Callers that key dependent state off this journal use this to discard that state atomically
736    /// with the reset. A crash at any point leaves a durable intent that the next `init` (or
737    /// [Self::init_cleared]) finishes.
738    #[commonware_macros::stability(ALPHA)]
739    pub(in crate::journal::contiguous) async fn init_at_size_cleared<F, Fut>(
740        context: E,
741        cfg: Config,
742        size: u64,
743        clear_dependents: F,
744    ) -> Result<Self, Error>
745    where
746        F: FnOnce() -> Fut,
747        Fut: Future<Output = Result<(), Error>>,
748    {
749        // A journal sized at `u64::MAX` can never accept an append (the successor size
750        // overflows), so reject it before staging any reset intent.
751        if size == u64::MAX {
752            return Err(Error::SizeOverflow);
753        }
754
755        // Stage the reset intent durably. `init_with_checkpoint` will detect the intent and
756        // complete the clear before recovering bounds.
757        let mut checkpoint = Checkpoint::open(context.child("meta"), &cfg.partition).await?;
758        checkpoint.stage_clear(size).await?;
759        clear_dependents().await?;
760        Self::init_with_checkpoint(context, cfg, checkpoint).await
761    }
762
763    /// Like [Self::init], but awaits `clear_dependents` before completing a staged clear.
764    ///
765    /// If a prior (possibly crashed) [Self::init_at_size_cleared] or
766    /// [Self::stage_clear_intent] staged a reset, `clear_dependents` runs before recovery so
767    /// callers can discard dependent state that the staged clear must reconcile against. With no
768    /// staged reset this behaves exactly like [Self::init].
769    pub(in crate::journal::contiguous) async fn init_cleared<F, Fut>(
770        context: E,
771        cfg: Config,
772        clear_dependents: F,
773    ) -> Result<Self, Error>
774    where
775        F: FnOnce() -> Fut,
776        Fut: Future<Output = Result<(), Error>>,
777    {
778        let checkpoint = Checkpoint::open(context.child("meta"), &cfg.partition).await?;
779        if checkpoint.clear_target().is_some() {
780            clear_dependents().await?;
781        }
782        Self::init_with_checkpoint(context, cfg, checkpoint).await
783    }
784
785    /// Make dirty blobs durable.
786    async fn flush_dirty_blobs(&mut self) -> Result<(), Error> {
787        let Some(start_blob) = self.dirty_from_blob else {
788            return Ok(());
789        };
790        self.blobs.sync_from(start_blob).await
791    }
792
793    /// Durably persists the current state of the structure.
794    ///
795    /// Does not advance the recovery watermark, so external consumers may need to replay entries
796    /// beyond the previous `sync()`. Use `sync()` to advance the watermark and to ensure that a
797    /// crash after this call doesn't require any recovery.
798    pub async fn commit(&mut self) -> Result<(), Error> {
799        let _timer = self.metrics.commit_timer();
800        self.metrics.commit_calls.inc();
801        self.flush_dirty_blobs().await?;
802        self.dirty_from_blob = None;
803        Ok(())
804    }
805
806    /// Durably persist the current state of the structure, ensuring no recovery is required in the
807    /// event of a crash following this call.
808    ///
809    /// Advances the recovery watermark to the current size.
810    pub async fn sync(&mut self) -> Result<(), Error> {
811        let _timer = self.metrics.sync_timer();
812        self.metrics.sync_calls.inc();
813        self.flush_dirty_blobs().await?;
814        self.dirty_from_blob = None;
815        self.checkpoint
816            .persist(
817                self.items_per_blob.get(),
818                self.bounds.start,
819                self.bounds.end,
820            )
821            .await
822    }
823
824    /// Capture an owned snapshot ([`Reader`]) over the current journal. Bounds are frozen at
825    /// creation, and the snapshot stays readable across concurrent appends and prunes.
826    ///
827    /// If the journal later rewinds or truncates into the returned reader's range, subsequent reads
828    /// from that range may observe unspecified contents.
829    pub async fn snapshot(&mut self) -> Result<Reader<'static, E, A>, Error> {
830        Ok(Reader {
831            blobs: self.blobs.snapshot().await?,
832            bounds: self.bounds.clone(),
833            items_per_blob: self.items_per_blob,
834            metrics: self.metrics.clone(),
835            _phantom: PhantomData,
836        })
837    }
838
839    /// A reader borrowing the journal's live state.
840    pub(super) fn reader(&self) -> Reader<'_, E, A> {
841        Reader {
842            blobs: self.blobs.reader(),
843            bounds: self.bounds.clone(),
844            items_per_blob: self.items_per_blob,
845            metrics: self.metrics.clone(),
846            _phantom: PhantomData,
847        }
848    }
849
850    /// Return the recovery watermark.
851    pub(super) fn recovery_watermark(&self) -> u64 {
852        self.checkpoint
853            .watermark()
854            .expect("recovery watermark must exist after init")
855    }
856
857    /// Return the total number of items in the journal, irrespective of pruning. The next value
858    /// appended to the journal will be at this position.
859    pub const fn size(&self) -> u64 {
860        self.bounds.end
861    }
862
863    /// Append a new item to the journal, returning its position.
864    ///
865    /// # Errors
866    ///
867    /// Returns an error if the underlying storage operation fails.
868    pub async fn append(&mut self, item: &A) -> Result<u64, Error> {
869        let _timer = self.metrics.append_timer();
870        self.metrics.append_calls.inc();
871        self.append_many_inner(Many::Flat(std::slice::from_ref(item)))
872            .await
873    }
874
875    /// Append items to the journal, returning the position of the last item appended.
876    ///
877    /// Returns [Error::EmptyAppend] if items is empty.
878    pub async fn append_many<'a>(&'a mut self, items: Many<'a, A>) -> Result<u64, Error> {
879        let _timer = self.metrics.append_many_timer();
880        self.metrics.append_many_calls.inc();
881        self.append_many_inner(items).await
882    }
883
884    // Shared implementation for `append` and `append_many`; public wrappers record metrics.
885    async fn append_many_inner<'a>(&'a mut self, items: Many<'a, A>) -> Result<u64, Error> {
886        let prepared = self.prepare_append(items);
887        self.write_encoded(prepared).await
888    }
889
890    /// Encode `items` into a buffer that can be appended later with [`Self::append_prepared`].
891    ///
892    /// This lets callers serialize borrowed items synchronously, release those borrows, and
893    /// perform the append without holding unrelated locks across journal I/O.
894    pub fn prepare_append(&self, items: Many<'_, A>) -> PreparedAppend<A> {
895        // Encode all items into a single contiguous buffer up front.
896        // Uses Write::write directly to avoid per-item Bytes allocations from Encode::encode.
897        let mut buf = Vec::with_capacity(items.len() * A::SIZE);
898        match items {
899            Many::Flat(items) => {
900                for item in items {
901                    item.write(&mut buf);
902                }
903            }
904            Many::Nested(nested_items) => {
905                for items in nested_items {
906                    for item in *items {
907                        item.write(&mut buf);
908                    }
909                }
910            }
911        }
912        PreparedAppend {
913            buf,
914            _marker: PhantomData,
915        }
916    }
917
918    /// Append items encoded by [`Self::prepare_append`], returning the position of the last item
919    /// appended.
920    ///
921    /// Returns [Error::EmptyAppend] if `prepared` contains no items.
922    pub async fn append_prepared(&mut self, prepared: PreparedAppend<A>) -> Result<u64, Error> {
923        let _timer = self.metrics.append_prepared_timer();
924        self.metrics.append_prepared_calls.inc();
925        self.write_encoded(prepared).await
926    }
927
928    // Write pre-encoded items; shared by all append paths. Records no call metrics.
929    async fn write_encoded(&mut self, prepared: PreparedAppend<A>) -> Result<u64, Error> {
930        let items_buf = prepared.buf;
931        let items_count = items_buf.len() / A::SIZE;
932        if items_count == 0 {
933            return Err(Error::EmptyAppend);
934        }
935        let items_buf = IoBuf::from(items_buf);
936
937        // Reject the append before writing anything if it would push the size past `u64::MAX`.
938        // This keeps the in-loop size arithmetic safe.
939        self.bounds
940            .end
941            .checked_add(items_count as u64)
942            .ok_or(Error::SizeOverflow)?;
943
944        let first_dirty_blob = super::position_to_blob(self.bounds.end, self.items_per_blob.get());
945        self.mark_dirty_from(first_dirty_blob);
946        let mut written = 0;
947        while written < items_count {
948            let batch_count = super::batch_count_to_blob_boundary(
949                self.bounds.end,
950                items_count - written,
951                self.items_per_blob.get(),
952            );
953            let start = written * A::SIZE;
954            let end = start + batch_count * A::SIZE;
955            // Overflow checked above.
956            let new_size = self.bounds.end + batch_count as u64;
957
958            self.blobs
959                .tail_writer()
960                .append_owned(items_buf.slice(start..end))
961                .await
962                .map_err(Error::Runtime)?;
963            self.bounds.end = new_size;
964            written += batch_count;
965
966            // Seal the just-filled tail and open the next blob as the new tail. This does not
967            // fsync the old blob; dirty tracking still covers it until commit/sync.
968            if new_size.is_multiple_of(self.items_per_blob.get()) {
969                self.blobs.seal_tail().await?;
970            }
971        }
972
973        self.metrics.update(
974            self.bounds.end,
975            self.bounds.start,
976            self.items_per_blob.get(),
977        );
978        Ok(self.bounds.end - 1)
979    }
980
981    /// Rewind the journal to the given `size`. Returns [Error::InvalidRewind] if `size` is beyond
982    /// the current size, or [Error::ItemPruned] if it precedes the pruning boundary. The journal
983    /// is not synced after rewinding.
984    ///
985    /// # Warnings
986    ///
987    /// * This operation is not guaranteed to survive restarts until `commit()` or `sync()` is
988    ///   called.
989    /// * This operation is not atomic. Its on-disk updates are ordered (blobs removed
990    ///   newest-to-oldest) so that restart recovery always rebuilds a contiguous retained prefix.
991    /// * Readers returned by [`snapshot`](Self::snapshot) may observe unspecified contents if this
992    ///   rewind truncates into their range.
993    pub async fn rewind(&mut self, size: u64) -> Result<(), Error> {
994        match size.cmp(&self.bounds.end) {
995            std::cmp::Ordering::Greater => return Err(Error::InvalidRewind(size)),
996            std::cmp::Ordering::Equal => return Ok(()),
997            std::cmp::Ordering::Less => {}
998        }
999
1000        if size < self.bounds.start {
1001            return Err(Error::ItemPruned(size));
1002        }
1003
1004        let blob = super::position_to_blob(size, self.items_per_blob.get());
1005        let pos_in_blob = size - first_in_blob(self.bounds.start, blob, self.items_per_blob.get())?;
1006        let byte_offset = Self::items_to_bytes(pos_in_blob)?;
1007
1008        // Persist a lowered recovery watermark before blob state moves backward.
1009        if self.checkpoint.lower_watermark(size) {
1010            self.checkpoint.sync().await?;
1011        }
1012
1013        if blob == self.blobs.tail_blob_index() {
1014            self.blobs.rewind_tail(byte_offset).await?;
1015        } else {
1016            self.blobs.rewind_into_sealed(blob, byte_offset).await?;
1017        }
1018
1019        self.bounds.end = size;
1020        self.mark_dirty_from(blob);
1021        self.metrics.update(
1022            self.bounds.end,
1023            self.bounds.start,
1024            self.items_per_blob.get(),
1025        );
1026
1027        Ok(())
1028    }
1029
1030    /// Return the location before which all items have been pruned.
1031    pub const fn pruning_boundary(&self) -> u64 {
1032        self.bounds.start
1033    }
1034
1035    /// Allow the journal to prune items older than `min_item_pos`. The journal may not prune all
1036    /// such items in order to preserve blob boundaries, but the amount of such items will always be
1037    /// less than the configured number of items per blob. Returns true if any items were pruned.
1038    ///
1039    /// Readers holding earlier snapshots keep reading pruned blobs through their own handles;
1040    /// later snapshots observe [Error::ItemPruned].
1041    ///
1042    /// Note that this operation may NOT be atomic, however it's guaranteed not to leave gaps in the
1043    /// event of failure as items are always pruned in order from oldest to newest.
1044    pub async fn prune(&mut self, min_item_pos: u64) -> Result<bool, Error> {
1045        // Calculate the blob that would contain min_item_pos, capped to the tail (which is
1046        // guaranteed to exist by our invariant).
1047        let target_blob = super::position_to_blob(min_item_pos, self.items_per_blob.get());
1048        let tail_blob = super::position_to_blob(self.bounds.end, self.items_per_blob.get());
1049        let min_blob = std::cmp::min(target_blob, tail_blob);
1050
1051        if min_blob <= self.blobs.oldest_blob_index() {
1052            return Ok(false);
1053        }
1054
1055        // Make all dirty blobs durable before removing any: the prune target may be
1056        // justified by an appended-but-unflushed item (e.g. a consumer's commit record), and
1057        // removals are durable, so pruning without this barrier could leave a recovered
1058        // journal whose surviving items no longer justify its boundary. Dirty blobs below the
1059        // prune point are flushed too: removal may be interrupted, and recovery truncates at
1060        // the first torn item, so an unsynced survivor below the boundary could discard
1061        // every synced blob behind it.
1062        self.flush_dirty_blobs().await?;
1063        self.dirty_from_blob = None;
1064
1065        let new_boundary = super::blob_first_position(min_blob, self.items_per_blob.get())?;
1066        self.blobs.prune(min_blob).await?;
1067        self.bounds.start = new_boundary;
1068
1069        self.metrics.update(
1070            self.bounds.end,
1071            self.bounds.start,
1072            self.items_per_blob.get(),
1073        );
1074
1075        Ok(true)
1076    }
1077
1078    /// Remove any persisted data created by the journal.
1079    ///
1080    /// # Crash Safety
1081    ///
1082    /// This operation is intended for final teardown and is not crash-safe. If interrupted,
1083    /// reopening the same partition may observe partially removed state. Use [Self::init_at_size]
1084    /// for a recoverable reset.
1085    pub async fn destroy(self) -> Result<(), Error> {
1086        self.blobs.destroy().await?;
1087        self.checkpoint.destroy().await?;
1088        Ok(())
1089    }
1090
1091    /// Clear all data and reset the journal to a new starting position.
1092    ///
1093    /// Unlike `destroy`, this keeps the journal alive so it can be reused. After clearing, the
1094    /// journal will behave as if initialized with `init_at_size(new_size)`.
1095    ///
1096    /// # Crash Safety
1097    ///
1098    /// In the event of a crash during this call, upon restart recovery will ensure the journal is
1099    /// either still in its prior state, or has bounds `new_size..new_size`.
1100    pub(crate) async fn clear_to_size(&mut self, new_size: u64) -> Result<(), Error> {
1101        // A journal sized at `u64::MAX` can never accept an append, matching `init_at_size`.
1102        if new_size == u64::MAX {
1103            return Err(Error::SizeOverflow);
1104        }
1105
1106        // Durably record the intent first, so a crash mid-clear is finished on reopen.
1107        self.checkpoint.stage_clear(new_size).await?;
1108
1109        // Remove every blob, then start fresh at the new size.
1110        self.blobs
1111            .clear(super::position_to_blob(new_size, self.items_per_blob.get()))
1112            .await?;
1113        self.bounds = new_size..new_size;
1114        self.dirty_from_blob = None;
1115
1116        // Complete the clear in the checkpoint.
1117        self.checkpoint
1118            .finish_clear(self.items_per_blob.get(), new_size)
1119            .await?;
1120
1121        self.metrics.update(
1122            self.bounds.end,
1123            self.bounds.start,
1124            self.items_per_blob.get(),
1125        );
1126        Ok(())
1127    }
1128
1129    /// Durably stage a clear to `new_size` without completing it.
1130    ///
1131    /// This records a recoverable intent so a caller can clear dependent sibling state before
1132    /// calling `clear_to_size` to finish. If a crash interrupts the sequence, the next `init`
1133    /// completes the staged clear. The follow-up `clear_to_size` re-stages the same target
1134    /// idempotently.
1135    #[commonware_macros::stability(ALPHA)]
1136    pub(super) async fn stage_clear_intent(&mut self, new_size: u64) -> Result<(), Error> {
1137        // A journal sized at `u64::MAX` can never accept an append, matching `init_at_size`.
1138        if new_size == u64::MAX {
1139            return Err(Error::SizeOverflow);
1140        }
1141        self.checkpoint.stage_clear(new_size).await
1142    }
1143}
1144
1145/// A reader over a fixed journal.
1146pub struct Reader<'a, E: Context, A> {
1147    blobs: Blobs<'a, E::Blob>,
1148    bounds: Range<u64>,
1149    items_per_blob: NonZeroU64,
1150    metrics: Arc<Metrics<E>>,
1151    _phantom: PhantomData<A>,
1152}
1153
1154impl<E: Context, A: CodecFixedShared> Reader<'_, E, A> {
1155    /// Validate a position to be read: must lie within `bounds`.
1156    const fn validate_readable(&self, pos: u64) -> Result<(), Error> {
1157        if pos >= self.bounds.end {
1158            return Err(Error::ItemOutOfRange(pos));
1159        }
1160        if pos < self.bounds.start {
1161            return Err(Error::ItemPruned(pos));
1162        }
1163        Ok(())
1164    }
1165
1166    /// Resolve a blob-sharing group of positions to its blob number and per-position byte
1167    /// offsets within the blob.
1168    fn locate_group(&self, group: &[u64]) -> Result<(u64, Vec<u64>), Error> {
1169        let items_per_blob = self.items_per_blob.get();
1170        let blob = super::position_to_blob(group[0], items_per_blob);
1171        let first_position = first_in_blob(self.bounds.start, blob, items_per_blob)?;
1172        let offsets = group
1173            .iter()
1174            .map(|&pos| Journal::<E, A>::items_to_bytes(pos - first_position))
1175            .collect::<Result<Vec<u64>, _>>()?;
1176        Ok((blob, offsets))
1177    }
1178
1179    /// Shared body of [`super::Contiguous::read_many`] and the variable journal's offsets
1180    /// reads; the callers record the batch-read metrics, so routing them through `read_many`
1181    /// would count every batch twice.
1182    pub(super) async fn read_many_inner(&self, positions: &[u64]) -> Result<Vec<A>, Error> {
1183        if positions.is_empty() {
1184            return Ok(Vec::new());
1185        }
1186        assert!(
1187            positions.is_sorted_by(|a, b| a < b),
1188            "positions must be strictly increasing"
1189        );
1190        for &pos in positions {
1191            self.validate_readable(pos)?;
1192        }
1193
1194        let items_per_blob = self.items_per_blob.get();
1195
1196        // Read all positions grouped by blob. Positions are sorted, so `chunk_by` splits them into
1197        // maximal runs that share one blob. Each group goes through the blob's batched read,
1198        // which serves page-cache and tip-buffer hits under a single lock acquisition and reads only
1199        // true misses from the blob (concurrently).
1200        let mut result: Vec<A> = Vec::with_capacity(positions.len());
1201        let mut reusable_buf = vec![0u8; positions.len() * A::SIZE];
1202
1203        // The buffer is pre-sized for every position, so each group can own a disjoint slice and
1204        // all groups can read concurrently.
1205        let mut reads = Vec::new();
1206        let mut remaining_buf = reusable_buf.as_mut_slice();
1207        for group in positions.chunk_by(|a, b| {
1208            super::position_to_blob(*a, items_per_blob)
1209                == super::position_to_blob(*b, items_per_blob)
1210        }) {
1211            let (blob_num, blob_offsets) = self.locate_group(group)?;
1212            let blob = self
1213                .blobs
1214                .get(blob_num)
1215                .expect("positions in bounds map to a retained blob");
1216            let (buf, rest) = remaining_buf.split_at_mut(group.len() * A::SIZE);
1217            remaining_buf = rest;
1218            reads.push(async move {
1219                blob.read_many_into(buf, &blob_offsets, Journal::<E, A>::CHUNK_SIZE)
1220                    .await
1221            });
1222        }
1223        let hits: u64 = try_join_all(reads)
1224            .await?
1225            .into_iter()
1226            .map(|group_hits| group_hits as u64)
1227            .sum();
1228
1229        for slice in reusable_buf.chunks_exact(A::SIZE) {
1230            result.push(A::decode(slice).map_err(Error::Codec)?);
1231        }
1232
1233        self.metrics.cache_hits.inc_by(hits);
1234        self.metrics
1235            .cache_misses
1236            .inc_by(positions.len() as u64 - hits);
1237        self.metrics.items_read.inc_by(positions.len() as u64);
1238        Ok(result)
1239    }
1240
1241    /// Resolve `pos` to its blob and byte offset within the blob.
1242    fn locate(&self, pos: u64) -> Result<(Blob<'_, E::Blob>, u64), Error> {
1243        self.validate_readable(pos)?;
1244        let items_per_blob = self.items_per_blob.get();
1245        let blob = super::position_to_blob(pos, items_per_blob);
1246        let pos_in_blob = pos - first_in_blob(self.bounds.start, blob, items_per_blob)?;
1247        let offset = Journal::<E, A>::items_to_bytes(pos_in_blob)?;
1248        let blob = self
1249            .blobs
1250            .get(blob)
1251            .expect("position in bounds maps to a retained blob");
1252        Ok((blob, offset))
1253    }
1254
1255    /// Probe `positions` (strictly increasing) against the page cache, returning one slot per
1256    /// position: `Some(item)` for sync hits and `None` for positions that require I/O, fail to
1257    /// decode, or fall outside `bounds()`.
1258    pub(super) fn probe_items(&self, positions: &[u64]) -> Vec<Option<A>> {
1259        assert!(
1260            positions.is_sorted_by(|a, b| a < b),
1261            "positions must be strictly increasing"
1262        );
1263        let mut out: Vec<Option<A>> = (0..positions.len()).map(|_| None).collect();
1264
1265        // Sorted positions put pruned ones in a prefix and out-of-range ones in a suffix, so
1266        // validation trims the batch instead of poisoning a blob group that also holds valid
1267        // positions.
1268        let start = positions.partition_point(|&pos| pos < self.bounds.start);
1269        let end = positions.partition_point(|&pos| pos < self.bounds.end);
1270        let valid = &positions[start..end];
1271        if valid.is_empty() {
1272            return out;
1273        }
1274
1275        // Serve the probe from the per-thread scratch buffer. Stale bytes from a previous probe
1276        // are harmless: slots the cache cannot serve are reported as misses and never decoded.
1277        let items_per_blob = self.items_per_blob.get();
1278        let mut scratch =
1279            Cached::take(&PROBE_SCRATCH, || Ok::<_, ()>(Vec::new()), |_| Ok(())).unwrap();
1280        let need = valid.len() * A::SIZE;
1281        if scratch.len() < need {
1282            scratch.resize(need, 0);
1283        }
1284        let buf = &mut scratch[..need];
1285        let mut hits = 0u64;
1286        let mut group_base = start;
1287        for group in valid.chunk_by(|a, b| {
1288            super::position_to_blob(*a, items_per_blob)
1289                == super::position_to_blob(*b, items_per_blob)
1290        }) {
1291            let base = group_base;
1292            group_base += group.len();
1293            let Ok((blob_num, blob_offsets)) = self.locate_group(group) else {
1294                continue;
1295            };
1296            let Some(blob) = self.blobs.get(blob_num) else {
1297                continue;
1298            };
1299            let buf = &mut buf[..group.len() * A::SIZE];
1300            let misses =
1301                blob.try_read_many_sync_into(buf, &blob_offsets, Journal::<E, A>::CHUNK_SIZE);
1302            let mut misses = misses.into_iter().peekable();
1303            for (idx, slice) in buf.chunks_exact(A::SIZE).enumerate() {
1304                if misses.peek() == Some(&idx) {
1305                    misses.next();
1306                    continue;
1307                }
1308                // A decode failure declines to a miss: the async completion re-reads the
1309                // item and bubbles the failure as [Error::Codec], like every async read path.
1310                if let Ok(item) = A::decode(slice) {
1311                    out[base + idx] = Some(item);
1312                    hits += 1;
1313                }
1314            }
1315        }
1316        self.metrics.cache_hits.inc_by(hits);
1317        self.metrics.items_read.inc_by(hits);
1318        out
1319    }
1320}
1321
1322impl<E: Context, A: CodecFixedShared> super::Contiguous for Reader<'_, E, A> {
1323    type Item = A;
1324
1325    fn bounds(&self) -> Range<u64> {
1326        self.bounds.clone()
1327    }
1328
1329    async fn read(&self, pos: u64) -> Result<A, Error> {
1330        self.metrics.read_calls.inc();
1331
1332        // Serve from the page cache synchronously when possible, avoiding the async storage path.
1333        if let Some(item) = self.try_read_sync(pos) {
1334            return Ok(item);
1335        }
1336
1337        let _timer = self.metrics.read_timer();
1338        let (blob, offset) = self.locate(pos)?;
1339        self.metrics.cache_misses.inc();
1340        let bufs = blob.read_at(offset, A::SIZE).await?;
1341        let item = A::decode(bufs.coalesce()).map_err(Error::Codec)?;
1342        self.metrics.items_read.inc();
1343        Ok(item)
1344    }
1345
1346    async fn read_many(&self, positions: &[u64]) -> Result<Vec<A>, Error> {
1347        if positions.is_empty() {
1348            return Ok(Vec::new());
1349        }
1350        let _timer = self.metrics.read_many_timer();
1351        self.metrics.read_many_calls.inc();
1352        self.read_many_inner(positions).await
1353    }
1354
1355    fn try_read_sync(&self, pos: u64) -> Option<A> {
1356        let mut buf = vec![0u8; A::SIZE];
1357        let item = match self.locate(pos) {
1358            Ok((blob, offset)) if blob.try_read_sync_into(&mut buf, offset) => {
1359                A::decode(&buf[..]).ok()
1360            }
1361            _ => None,
1362        };
1363        if item.is_some() {
1364            self.metrics.cache_hits.inc();
1365            self.metrics.items_read.inc();
1366        }
1367        item
1368    }
1369
1370    fn try_read_many_sync(&self, positions: &[u64]) -> Vec<Option<A>> {
1371        self.probe_items(positions)
1372    }
1373
1374    async fn replay(
1375        &self,
1376        start_pos: u64,
1377        buffer: NonZeroUsize,
1378    ) -> Result<impl Stream<Item = Result<(u64, A), Error>> + Send, Error> {
1379        replay_stream(
1380            &self.blobs,
1381            self.bounds.clone(),
1382            self.items_per_blob,
1383            start_pos,
1384            buffer,
1385        )
1386    }
1387}
1388
1389impl<E: Context, A: CodecFixedShared> super::Contiguous for Journal<E, A> {
1390    type Item = A;
1391
1392    fn bounds(&self) -> Range<u64> {
1393        self.bounds.clone()
1394    }
1395
1396    async fn read(&self, pos: u64) -> Result<A, Error> {
1397        self.reader().read(pos).await
1398    }
1399
1400    async fn read_many(&self, positions: &[u64]) -> Result<Vec<A>, Error> {
1401        self.reader().read_many(positions).await
1402    }
1403
1404    fn try_read_sync(&self, pos: u64) -> Option<A> {
1405        self.reader().try_read_sync(pos)
1406    }
1407
1408    fn try_read_many_sync(&self, positions: &[u64]) -> Vec<Option<A>> {
1409        self.reader().probe_items(positions)
1410    }
1411
1412    async fn replay(
1413        &self,
1414        start_pos: u64,
1415        buffer: NonZeroUsize,
1416    ) -> Result<impl Stream<Item = Result<(u64, A), Error>> + Send, Error> {
1417        let blobs = self.blobs.reader();
1418        replay_stream(
1419            &blobs,
1420            self.bounds.clone(),
1421            self.items_per_blob,
1422            start_pos,
1423            buffer,
1424        )
1425    }
1426}
1427
1428impl<E: Context, A: CodecFixedShared> Mutable for Journal<E, A> {
1429    async fn append(&mut self, item: &Self::Item) -> Result<u64, Error> {
1430        Self::append(self, item).await
1431    }
1432
1433    async fn append_many<'a>(&'a mut self, items: Many<'a, Self::Item>) -> Result<u64, Error> {
1434        Self::append_many(self, items).await
1435    }
1436
1437    async fn prune(&mut self, min_position: u64) -> Result<bool, Error> {
1438        Self::prune(self, min_position).await
1439    }
1440
1441    async fn rewind(&mut self, size: u64) -> Result<(), Error> {
1442        Self::rewind(self, size).await
1443    }
1444
1445    async fn commit(&mut self) -> Result<(), Error> {
1446        Self::commit(self).await
1447    }
1448
1449    async fn sync(&mut self) -> Result<(), Error> {
1450        Self::sync(self).await
1451    }
1452
1453    async fn destroy(self) -> Result<(), Error> {
1454        Self::destroy(self).await
1455    }
1456}
1457
1458#[commonware_macros::stability(ALPHA)]
1459impl<E: Context, A: CodecFixedShared> authenticated::Inner<E> for Journal<E, A> {
1460    type Config = Config;
1461
1462    async fn init<
1463        F: merkle::Family,
1464        H: commonware_cryptography::Hasher,
1465        S: commonware_parallel::Strategy,
1466    >(
1467        context: E,
1468        merkle_cfg: merkle::full::Config<S>,
1469        journal_cfg: Self::Config,
1470        rewind_predicate: fn(&A) -> bool,
1471        bagging: merkle::Bagging,
1472    ) -> Result<authenticated::Journal<F, E, Self, H, S>, authenticated::Error<F>> {
1473        authenticated::Journal::<F, E, Self, H, S>::new(
1474            context,
1475            merkle_cfg,
1476            journal_cfg,
1477            rewind_predicate,
1478            bagging,
1479        )
1480        .await
1481    }
1482}
1483
1484#[cfg(test)]
1485mod tests {
1486    use super::*;
1487    use crate::journal::contiguous::Contiguous as _;
1488    use commonware_codec::FixedSize;
1489    use commonware_cryptography::{sha256::Digest, Hasher as _, Sha256};
1490    use commonware_macros::test_traced;
1491    use commonware_runtime::{
1492        buffer::paged::Writer,
1493        deterministic::{self, Context},
1494        Blob, BufferPooler, Error as RuntimeError, Metrics as _, Runner, Spawner as _, Storage,
1495        Supervisor as _,
1496    };
1497    use commonware_utils::{NZUsize, NZU16, NZU64};
1498    use futures::{pin_mut, StreamExt};
1499    use std::num::NonZeroU16;
1500
1501    const PAGE_SIZE: NonZeroU16 = NZU16!(44);
1502    const PAGE_CACHE_SIZE: NonZeroUsize = NZUsize!(3);
1503
1504    /// Generate a SHA-256 digest for the given value.
1505    fn test_digest(value: u64) -> Digest {
1506        Sha256::hash(&value.to_be_bytes())
1507    }
1508
1509    fn test_cfg(pooler: &impl BufferPooler, items_per_blob: NonZeroU64) -> Config {
1510        Config {
1511            partition: "test-partition".into(),
1512            items_per_blob,
1513            page_cache: CacheRef::from_pooler(pooler, PAGE_SIZE, PAGE_CACHE_SIZE),
1514            write_buffer: NZUsize!(2048),
1515        }
1516    }
1517
1518    fn blob_partition(cfg: &Config) -> String {
1519        format!("{}-blobs", cfg.partition)
1520    }
1521
1522    /// Extract a metric counter's value from encoded metrics output.
1523    fn counter(buffer: &str, name: &str) -> u64 {
1524        buffer
1525            .lines()
1526            .find(|l| l.contains(name) && !l.starts_with('#'))
1527            .and_then(|l| l.split_whitespace().last())
1528            .and_then(|v| v.parse().ok())
1529            .expect("counter missing")
1530    }
1531
1532    impl<E: crate::Context, A: CodecFixedShared> Journal<E, A> {
1533        /// Test helper: Get the oldest blob from the blob store.
1534        pub(crate) const fn test_oldest_blob(&self) -> Option<u64> {
1535            Some(self.blobs.oldest_blob_index())
1536        }
1537
1538        /// Test helper: Get the newest blob from the blob store.
1539        pub(crate) fn test_newest_blob(&self) -> Option<u64> {
1540            Some(self.blobs.tail_blob_index())
1541        }
1542
1543        /// Test helper: Make one blob durable (sealed history or the tail).
1544        pub(crate) async fn test_sync_blob(&mut self, blob: u64) -> Result<(), Error> {
1545            self.blobs.sync_blob(blob).await
1546        }
1547
1548        /// Test helper: Set and persist the recovery watermark directly.
1549        pub(crate) async fn test_set_recovery_watermark(
1550            &mut self,
1551            watermark: u64,
1552        ) -> Result<(), Error> {
1553            self.checkpoint.set_watermark(Some(watermark));
1554            self.checkpoint.sync().await
1555        }
1556
1557        /// Test helper: Durably stage a clear intent in the journal's checkpoint.
1558        pub(crate) async fn test_stage_clear(
1559            context: E,
1560            partition: &str,
1561            target: u64,
1562        ) -> Result<(), Error> {
1563            let mut checkpoint = Checkpoint::open(context, partition).await?;
1564            checkpoint.stage_clear(target).await
1565        }
1566    }
1567
1568    #[test_traced]
1569    fn test_fixed_init_marks_suffix_past_recovery_watermark_dirty() {
1570        let executor = deterministic::Runner::default();
1571        executor.start(|context| async move {
1572            let mut cfg = test_cfg(&context, NZU64!(10));
1573            cfg.partition = "init-adopted-fixed".into();
1574
1575            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
1576                .await
1577                .unwrap();
1578            journal.append(&1).await.unwrap();
1579            journal.append(&2).await.unwrap();
1580            journal.sync().await.unwrap();
1581            // Simulate the state left by a crash after item 2 became visible to recovery, but
1582            // before the persisted recovery watermark advanced past item 1.
1583            journal.test_set_recovery_watermark(1).await.unwrap();
1584            drop(journal);
1585
1586            let mut journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
1587                .await
1588                .unwrap();
1589            assert_eq!(journal.size(), 2);
1590
1591            // Regression: init used to recover size 2 while marking no data blobs dirty.
1592            // commit() would then skip blob syncs and succeed even though the recovered suffix
1593            // had not been durably adopted. With the fix, item 2's blob is dirty, so the forced
1594            // sync failure below must surface.
1595            *context.storage_fault_config().write() = deterministic::FaultConfig {
1596                sync_rate: Some(1.0),
1597                ..Default::default()
1598            };
1599            assert!(
1600                journal.commit().await.is_err(),
1601                "commit() must sync recovered data beyond the persisted recovery watermark"
1602            );
1603        });
1604    }
1605
1606    async fn scan_partition(context: &Context, partition: &str) -> Vec<Vec<u8>> {
1607        match context.scan(partition).await {
1608            Ok(blobs) => blobs,
1609            Err(RuntimeError::PartitionMissing(_)) => Vec::new(),
1610            Err(err) => panic!("Failed to scan partition {partition}: {err}"),
1611        }
1612    }
1613
1614    #[test_traced]
1615    fn test_fixed_journal_init_conflicting_partitions() {
1616        let executor = deterministic::Runner::default();
1617        executor.start(|context| async move {
1618            let cfg = test_cfg(&context, NZU64!(2));
1619            let legacy_partition = cfg.partition.clone();
1620            let blobs_partition = blob_partition(&cfg);
1621
1622            let (legacy_blob, _) = context
1623                .open(&legacy_partition, &0u64.to_be_bytes())
1624                .await
1625                .expect("Failed to open legacy blob");
1626            legacy_blob
1627                .write_at_sync(0, vec![0u8; 1])
1628                .await
1629                .expect("Failed to write legacy blob");
1630
1631            let (new_blob, _) = context
1632                .open(&blobs_partition, &0u64.to_be_bytes())
1633                .await
1634                .expect("Failed to open new blob");
1635            new_blob
1636                .write_at_sync(0, vec![0u8; 1])
1637                .await
1638                .expect("Failed to write new blob");
1639
1640            let result = Journal::<_, Digest>::init(context.child("second"), cfg.clone()).await;
1641            assert!(matches!(result, Err(Error::Corruption(_))));
1642        });
1643    }
1644
1645    #[test_traced]
1646    fn test_fixed_journal_init_prefers_legacy_partition() {
1647        let executor = deterministic::Runner::default();
1648        executor.start(|context| async move {
1649            let cfg = test_cfg(&context, NZU64!(2));
1650            let legacy_partition = cfg.partition.clone();
1651            let blobs_partition = blob_partition(&cfg);
1652
1653            // Seed legacy partition so it is selected.
1654            let (legacy_blob, _) = context
1655                .open(&legacy_partition, &0u64.to_be_bytes())
1656                .await
1657                .expect("Failed to open legacy blob");
1658            legacy_blob
1659                .write_at_sync(0, vec![0u8; 1])
1660                .await
1661                .expect("Failed to write legacy blob");
1662
1663            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
1664                .await
1665                .expect("failed to initialize journal");
1666            journal.append(&test_digest(1)).await.unwrap();
1667            journal.sync().await.unwrap();
1668            drop(journal);
1669
1670            let legacy_blobs = scan_partition(&context, &legacy_partition).await;
1671            let new_blobs = scan_partition(&context, &blobs_partition).await;
1672            assert!(!legacy_blobs.is_empty());
1673            assert!(new_blobs.is_empty());
1674        });
1675    }
1676
1677    #[test_traced]
1678    fn test_fixed_journal_init_defaults_to_blobs_partition() {
1679        let executor = deterministic::Runner::default();
1680        executor.start(|context| async move {
1681            let cfg = test_cfg(&context, NZU64!(2));
1682            let legacy_partition = cfg.partition.clone();
1683            let blobs_partition = blob_partition(&cfg);
1684
1685            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
1686                .await
1687                .expect("failed to initialize journal");
1688            journal.append(&test_digest(1)).await.unwrap();
1689            journal.sync().await.unwrap();
1690            drop(journal);
1691
1692            let legacy_blobs = scan_partition(&context, &legacy_partition).await;
1693            let new_blobs = scan_partition(&context, &blobs_partition).await;
1694            assert!(legacy_blobs.is_empty());
1695            assert!(!new_blobs.is_empty());
1696        });
1697    }
1698
1699    #[test_traced]
1700    fn test_fixed_journal_append_and_prune() {
1701        // Initialize the deterministic context
1702        let executor = deterministic::Runner::default();
1703
1704        // Start the test within the executor
1705        executor.start(|context| async move {
1706            // Initialize the journal, allowing a max of 2 items per blob.
1707            let cfg = test_cfg(&context, NZU64!(2));
1708            let mut journal = Journal::init(context.child("first"), cfg.clone())
1709                .await
1710                .expect("failed to initialize journal");
1711
1712            // Append an item to the journal
1713            let mut pos = journal
1714                .append(&test_digest(0))
1715                .await
1716                .expect("failed to append data 0");
1717            assert_eq!(pos, 0);
1718
1719            // Drop the journal and re-initialize it to simulate a restart
1720            journal.sync().await.expect("Failed to sync journal");
1721            drop(journal);
1722
1723            let cfg = test_cfg(&context, NZU64!(2));
1724            let mut journal = Journal::init(context.child("second"), cfg.clone())
1725                .await
1726                .expect("failed to re-initialize journal");
1727            assert_eq!(journal.size(), 1);
1728
1729            // Append two more items to the journal to trigger a new blob creation
1730            pos = journal
1731                .append(&test_digest(1))
1732                .await
1733                .expect("failed to append data 1");
1734            assert_eq!(pos, 1);
1735            pos = journal
1736                .append(&test_digest(2))
1737                .await
1738                .expect("failed to append data 2");
1739            assert_eq!(pos, 2);
1740
1741            // Read the items back
1742            let item0 = journal.read(0).await.expect("failed to read data 0");
1743            assert_eq!(item0, test_digest(0));
1744            let item1 = journal.read(1).await.expect("failed to read data 1");
1745            assert_eq!(item1, test_digest(1));
1746            let item2 = journal.read(2).await.expect("failed to read data 2");
1747            assert_eq!(item2, test_digest(2));
1748            let err = journal.read(3).await.expect_err("expected read to fail");
1749            assert!(matches!(err, Error::ItemOutOfRange(3)));
1750
1751            // Sync the journal
1752            journal.sync().await.expect("failed to sync journal");
1753
1754            // Pruning to 1 should be a no-op because there's no blob with only older items.
1755            journal.prune(1).await.expect("failed to prune journal 1");
1756
1757            // Pruning to 2 should allow the first blob to be pruned.
1758            journal.prune(2).await.expect("failed to prune journal 2");
1759            assert_eq!(journal.bounds().start, 2);
1760
1761            // Reading from the first blob should fail since it's now pruned
1762            let result0 = journal.read(0).await;
1763            assert!(matches!(result0, Err(Error::ItemPruned(0))));
1764            let result1 = journal.read(1).await;
1765            assert!(matches!(result1, Err(Error::ItemPruned(1))));
1766
1767            // Third item should still be readable
1768            let result2 = journal.read(2).await.unwrap();
1769            assert_eq!(result2, test_digest(2));
1770
1771            // Should be able to continue to append items
1772            for i in 3..10 {
1773                let pos = journal
1774                    .append(&test_digest(i))
1775                    .await
1776                    .expect("failed to append data");
1777                assert_eq!(pos, i);
1778            }
1779
1780            // Check no-op pruning
1781            journal.prune(0).await.expect("no-op pruning failed");
1782            assert_eq!(journal.test_oldest_blob(), Some(1));
1783            assert_eq!(journal.test_newest_blob(), Some(5));
1784            assert_eq!(journal.bounds().start, 2);
1785
1786            // Prune first 3 blobs (6 items)
1787            journal
1788                .prune(3 * cfg.items_per_blob.get())
1789                .await
1790                .expect("failed to prune journal 2");
1791            assert_eq!(journal.test_oldest_blob(), Some(3));
1792            assert_eq!(journal.test_newest_blob(), Some(5));
1793            assert_eq!(journal.bounds().start, 6);
1794
1795            // Try pruning (more than) everything in the journal.
1796            journal
1797                .prune(10000)
1798                .await
1799                .expect("failed to max-prune journal");
1800            let size = journal.size();
1801            assert_eq!(size, 10);
1802            assert_eq!(journal.test_oldest_blob(), Some(5));
1803            assert_eq!(journal.test_newest_blob(), Some(5));
1804            // Since the size of the journal is currently a multiple of items_per_blob, the newest blob
1805            // will be empty, and there will be no retained items.
1806            let bounds = journal.bounds();
1807            assert!(bounds.is_empty());
1808            // bounds.start should equal bounds.end when empty.
1809            assert_eq!(bounds.start, size);
1810
1811            // Replaying from 0 should fail since all items before bounds.start are pruned
1812            {
1813                let reader = journal.snapshot().await.unwrap();
1814                let result = reader.replay(0, NZUsize!(1024)).await;
1815                assert!(matches!(result, Err(Error::ItemPruned(0))));
1816            }
1817
1818            // Replaying from pruning_boundary should return empty stream
1819            {
1820                let reader = journal.snapshot().await.unwrap();
1821                let res = reader.replay(0, NZUsize!(1024)).await;
1822                assert!(matches!(res, Err(Error::ItemPruned(_))));
1823
1824                let reader = journal.snapshot().await.unwrap();
1825                let stream = reader
1826                    .replay(journal.bounds().start, NZUsize!(1024))
1827                    .await
1828                    .expect("failed to replay journal from pruning boundary");
1829                pin_mut!(stream);
1830                let mut items = Vec::new();
1831                while let Some(result) = stream.next().await {
1832                    match result {
1833                        Ok((pos, item)) => {
1834                            assert_eq!(test_digest(pos), item);
1835                            items.push(pos);
1836                        }
1837                        Err(err) => panic!("Failed to read item: {err}"),
1838                    }
1839                }
1840                assert_eq!(items, Vec::<u64>::new());
1841            }
1842
1843            journal.destroy().await.unwrap();
1844        });
1845    }
1846
1847    /// Append a lot of data to make sure we exercise page cache paging boundaries.
1848    #[test_traced]
1849    fn test_fixed_journal_append_a_lot_of_data() {
1850        // Initialize the deterministic context
1851        let executor = deterministic::Runner::default();
1852        const ITEMS_PER_BLOB: NonZeroU64 = NZU64!(10000);
1853        executor.start(|context| async move {
1854            let cfg = test_cfg(&context, ITEMS_PER_BLOB);
1855            let mut journal = Journal::init(context.child("first"), cfg.clone())
1856                .await
1857                .expect("failed to initialize journal");
1858            // Append 2 blobs worth of items.
1859            for i in 0u64..ITEMS_PER_BLOB.get() * 2 - 1 {
1860                journal
1861                    .append(&test_digest(i))
1862                    .await
1863                    .expect("failed to append data");
1864            }
1865            // Sync, reopen, then read back.
1866            journal.sync().await.expect("failed to sync journal");
1867            drop(journal);
1868            let journal = Journal::init(context.child("second"), cfg.clone())
1869                .await
1870                .expect("failed to re-initialize journal");
1871            for i in 0u64..10000 {
1872                let item: Digest = journal.read(i).await.expect("failed to read data");
1873                assert_eq!(item, test_digest(i));
1874            }
1875            journal.destroy().await.expect("failed to destroy journal");
1876        });
1877    }
1878
1879    #[test_traced]
1880    fn test_fixed_journal_replay() {
1881        const ITEMS_PER_BLOB: NonZeroU64 = NZU64!(7);
1882        // Initialize the deterministic context
1883        let executor = deterministic::Runner::default();
1884
1885        // Start the test within the executor
1886        executor.start(|context| async move {
1887            // Initialize the journal, allowing a max of 7 items per blob.
1888            let cfg = test_cfg(&context, ITEMS_PER_BLOB);
1889            let mut journal = Journal::init(context.child("first"), cfg.clone())
1890                .await
1891                .expect("failed to initialize journal");
1892
1893            // Append many items, filling 100 blobs and part of the 101st
1894            for i in 0u64..(ITEMS_PER_BLOB.get() * 100 + ITEMS_PER_BLOB.get() / 2) {
1895                let pos = journal
1896                    .append(&test_digest(i))
1897                    .await
1898                    .expect("failed to append data");
1899                assert_eq!(pos, i);
1900            }
1901
1902            // Read them back the usual way.
1903            for i in 0u64..(ITEMS_PER_BLOB.get() * 100 + ITEMS_PER_BLOB.get() / 2) {
1904                let item: Digest = journal.read(i).await.expect("failed to read data");
1905                assert_eq!(item, test_digest(i), "i={i}");
1906            }
1907
1908            // Replay should return all items
1909            {
1910                let reader = journal.snapshot().await.unwrap();
1911                let stream = reader
1912                    .replay(0, NZUsize!(1024))
1913                    .await
1914                    .expect("failed to replay journal");
1915                let mut items = Vec::new();
1916                pin_mut!(stream);
1917                while let Some(result) = stream.next().await {
1918                    match result {
1919                        Ok((pos, item)) => {
1920                            assert_eq!(test_digest(pos), item, "pos={pos}, item={item:?}");
1921                            items.push(pos);
1922                        }
1923                        Err(err) => panic!("Failed to read item: {err}"),
1924                    }
1925                }
1926
1927                // Make sure all items were replayed
1928                assert_eq!(
1929                    items.len(),
1930                    ITEMS_PER_BLOB.get() as usize * 100 + ITEMS_PER_BLOB.get() as usize / 2
1931                );
1932                items.sort();
1933                for (i, pos) in items.iter().enumerate() {
1934                    assert_eq!(i as u64, *pos);
1935                }
1936            }
1937
1938            journal.sync().await.expect("Failed to sync journal");
1939            drop(journal);
1940
1941            // Corrupt one of the bytes and make sure it's detected.
1942            let (blob, _) = context
1943                .open(&blob_partition(&cfg), &40u64.to_be_bytes())
1944                .await
1945                .expect("Failed to open blob");
1946            // Write junk bytes.
1947            let bad_bytes = 123456789u32;
1948            blob.write_at_sync(1, bad_bytes.to_be_bytes().to_vec())
1949                .await
1950                .expect("Failed to write bad bytes");
1951
1952            // Re-initialize the journal to simulate a restart
1953            let mut journal = Journal::init(context.child("second"), cfg.clone())
1954                .await
1955                .expect("Failed to re-initialize journal");
1956
1957            // Make sure reading an item that resides in the corrupted page fails.
1958            let err = journal
1959                .read(40 * ITEMS_PER_BLOB.get() + 1)
1960                .await
1961                .unwrap_err();
1962            assert!(matches!(err, Error::Runtime(_)));
1963
1964            // Replay all items.
1965            {
1966                let mut error_found = false;
1967                let reader = journal.snapshot().await.unwrap();
1968                let stream = reader
1969                    .replay(0, NZUsize!(1024))
1970                    .await
1971                    .expect("failed to replay journal");
1972                let mut items = Vec::new();
1973                pin_mut!(stream);
1974                while let Some(result) = stream.next().await {
1975                    match result {
1976                        Ok((pos, item)) => {
1977                            assert_eq!(test_digest(pos), item);
1978                            items.push(pos);
1979                        }
1980                        Err(err) => {
1981                            error_found = true;
1982                            assert!(matches!(err, Error::Runtime(_)));
1983                            assert!(stream.next().await.is_none());
1984                            break;
1985                        }
1986                    }
1987                }
1988                assert!(error_found); // error should abort replay
1989            }
1990        });
1991    }
1992
1993    #[test_traced]
1994    fn test_fixed_replay_stops_after_error() {
1995        let executor = deterministic::Runner::default();
1996        executor.start(|context| async move {
1997            let cfg = test_cfg(&context, NZU64!(10));
1998            let mut journal = Journal::init(context.child("first"), cfg.clone())
1999                .await
2000                .unwrap();
2001
2002            for i in 0u64..30 {
2003                journal.append(&test_digest(i)).await.unwrap();
2004            }
2005            journal.sync().await.unwrap();
2006            drop(journal);
2007
2008            let (blob, _) = context
2009                .open(&blob_partition(&cfg), &1u64.to_be_bytes())
2010                .await
2011                .unwrap();
2012            blob.write_at_sync(1, 123456789u32.to_be_bytes().to_vec())
2013                .await
2014                .unwrap();
2015
2016            let mut journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
2017                .await
2018                .unwrap();
2019            let reader = journal.snapshot().await.unwrap();
2020            let stream = reader.replay(0, NZUsize!(1024)).await.unwrap();
2021            pin_mut!(stream);
2022
2023            for i in 0u64..10 {
2024                let (pos, item) = stream.next().await.unwrap().unwrap();
2025                assert_eq!(pos, i);
2026                assert_eq!(item, test_digest(i));
2027            }
2028            assert!(matches!(
2029                stream.next().await.unwrap(),
2030                Err(Error::Runtime(_))
2031            ));
2032            assert!(stream.next().await.is_none());
2033
2034            journal.destroy().await.unwrap();
2035        });
2036    }
2037
2038    #[test_traced]
2039    fn test_fixed_journal_replay_with_missing_historical_blob() {
2040        let executor = deterministic::Runner::default();
2041        executor.start(|context| async move {
2042            let cfg = test_cfg(&context, NZU64!(2));
2043            let mut journal = Journal::init(context.child("first"), cfg.clone())
2044                .await
2045                .unwrap();
2046            for i in 0u64..5 {
2047                journal.append(&test_digest(i)).await.unwrap();
2048            }
2049            journal.sync().await.unwrap();
2050            drop(journal);
2051
2052            // Delete a middle blob (external corruption). The watermark (5) now exceeds the
2053            // recoverable contiguous prefix, which is corruption.
2054            context
2055                .remove(&blob_partition(&cfg), Some(&1u64.to_be_bytes()))
2056                .await
2057                .unwrap();
2058
2059            let result = Journal::<_, Digest>::init(context.child("second"), cfg.clone()).await;
2060            assert!(matches!(result, Err(Error::Corruption(_))));
2061        });
2062    }
2063
2064    #[test_traced]
2065    fn test_fixed_journal_partial_replay() {
2066        const ITEMS_PER_BLOB: NonZeroU64 = NZU64!(7);
2067        // 53 % 7 = 4, which will trigger a non-trivial seek in the starting blob to reach the
2068        // starting position.
2069        const START_POS: u64 = 53;
2070
2071        // Initialize the deterministic context
2072        let executor = deterministic::Runner::default();
2073        // Start the test within the executor
2074        executor.start(|context| async move {
2075            // Initialize the journal, allowing a max of 7 items per blob.
2076            let cfg = test_cfg(&context, ITEMS_PER_BLOB);
2077            let mut journal = Journal::init(context.child("storage"), cfg.clone())
2078                .await
2079                .expect("failed to initialize journal");
2080
2081            // Append many items, filling 100 blobs and part of the 101st
2082            for i in 0u64..(ITEMS_PER_BLOB.get() * 100 + ITEMS_PER_BLOB.get() / 2) {
2083                let pos = journal
2084                    .append(&test_digest(i))
2085                    .await
2086                    .expect("failed to append data");
2087                assert_eq!(pos, i);
2088            }
2089
2090            // Replay should return all items except the first `START_POS`.
2091            {
2092                let reader = journal.snapshot().await.unwrap();
2093                let stream = reader
2094                    .replay(START_POS, NZUsize!(1024))
2095                    .await
2096                    .expect("failed to replay journal");
2097                let mut items = Vec::new();
2098                pin_mut!(stream);
2099                while let Some(result) = stream.next().await {
2100                    match result {
2101                        Ok((pos, item)) => {
2102                            assert!(pos >= START_POS, "pos={pos}, expected >= {START_POS}");
2103                            assert_eq!(
2104                                test_digest(pos),
2105                                item,
2106                                "Item at position {pos} did not match expected digest"
2107                            );
2108                            items.push(pos);
2109                        }
2110                        Err(err) => panic!("Failed to read item: {err}"),
2111                    }
2112                }
2113
2114                // Make sure all items were replayed
2115                assert_eq!(
2116                    items.len(),
2117                    ITEMS_PER_BLOB.get() as usize * 100 + ITEMS_PER_BLOB.get() as usize / 2
2118                        - START_POS as usize
2119                );
2120                items.sort();
2121                for (i, pos) in items.iter().enumerate() {
2122                    assert_eq!(i as u64, *pos - START_POS);
2123                }
2124            }
2125
2126            journal.destroy().await.unwrap();
2127        });
2128    }
2129
2130    #[test_traced]
2131    fn test_fixed_journal_rejects_corrupted_tail_blob() {
2132        let executor = deterministic::Runner::default();
2133        executor.start(|context| async move {
2134            let cfg = test_cfg(&context, NZU64!(3));
2135            let mut journal = Journal::init(context.child("first"), cfg.clone())
2136                .await
2137                .unwrap();
2138            for i in 0..5 {
2139                journal.append(&test_digest(i)).await.unwrap();
2140            }
2141            journal.sync().await.unwrap();
2142            drop(journal);
2143
2144            // Truncate the tail blob by 1 byte (external corruption). The watermark (5) now
2145            // exceeds the recoverable size, which is corruption.
2146            let (blob, size) = context
2147                .open(&blob_partition(&cfg), &1u64.to_be_bytes())
2148                .await
2149                .unwrap();
2150            blob.resize(size - 1).await.unwrap();
2151            blob.sync().await.unwrap();
2152
2153            let result = Journal::<_, Digest>::init(context.child("second"), cfg.clone()).await;
2154            assert!(matches!(result, Err(Error::Corruption(_))));
2155        });
2156    }
2157
2158    /// Simulate a crash after recovery persists metadata but before the rewind repair completes.
2159    /// The stale blobs beyond the repair point still exist. The next init must succeed: it
2160    /// re-derives the same size from blob lengths, and the persisted watermark is still within
2161    /// the recovered size.
2162    #[test_traced]
2163    fn test_fixed_journal_crash_during_recovery_repair() {
2164        let executor = deterministic::Runner::default();
2165        executor.start(|context| async move {
2166            let cfg = test_cfg(&context, NZU64!(5));
2167            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
2168                .await
2169                .unwrap();
2170
2171            // Fill 3 blobs (0..15), sync everything.
2172            for i in 0..15u64 {
2173                journal.append(&test_digest(i)).await.unwrap();
2174            }
2175            journal.sync().await.unwrap();
2176            assert_eq!(journal.recovery_watermark(), 15);
2177
2178            // Persist the recovered metadata (watermark=9) as init_with_checkpoint does before
2179            // applying the rewind repair. This simulates a crash after metadata sync but before
2180            // the repair removes stale blobs.
2181            journal
2182                .checkpoint
2183                .persist(cfg.items_per_blob.get(), 0, 9)
2184                .await
2185                .unwrap();
2186            drop(journal);
2187
2188            // Shorten blob 1 to simulate a short non-tail blob. Recovery will compute
2189            // size=9 (blob 0 full + 4 items in blob 1) and generate a repair.
2190            {
2191                let cache_ref = CacheRef::from_pooler(&context, PAGE_SIZE, PAGE_CACHE_SIZE);
2192                let (blob, blob_size) = context
2193                    .open(&blob_partition(&cfg), &1u64.to_be_bytes())
2194                    .await
2195                    .expect("failed to open blob 1");
2196                let mut append = Writer::new(blob, blob_size, 2048, cache_ref)
2197                    .await
2198                    .expect("failed to wrap blob 1");
2199                append
2200                    .resize(4 * Digest::SIZE as u64)
2201                    .await
2202                    .expect("failed to shorten blob 1");
2203                append
2204                    .sync()
2205                    .await
2206                    .expect("failed to sync shortened blob 1");
2207            }
2208
2209            // Blobs 2 (and the empty tail at 3) still exist. Init must succeed and the
2210            // rewind must remove the stale blobs.
2211            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
2212                .await
2213                .expect("init should succeed after crash during recovery repair");
2214            assert_eq!(journal.bounds(), 0..9);
2215            assert_eq!(journal.recovery_watermark(), 9);
2216            assert_eq!(journal.read(8).await.unwrap(), test_digest(8));
2217            assert!(matches!(
2218                journal.read(9).await,
2219                Err(Error::ItemOutOfRange(9))
2220            ));
2221            assert_eq!(
2222                journal.test_newest_blob(),
2223                Some(1),
2224                "stale blobs beyond the repair point should be removed"
2225            );
2226
2227            journal.destroy().await.unwrap();
2228        });
2229    }
2230
2231    #[test_traced]
2232    fn test_fixed_journal_recover_accepts_clean_short_tail() {
2233        let executor = deterministic::Runner::default();
2234        executor.start(|context| async move {
2235            let cfg = test_cfg(&context, NZU64!(5));
2236
2237            // Set up via the public API: 5 items in blob 0 (full) + 2 items in blob 1
2238            // (partial), then sync and drop.
2239            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
2240                .await
2241                .unwrap();
2242            for i in 0..7 {
2243                journal.append(&test_digest(i)).await.unwrap();
2244            }
2245            journal.sync().await.unwrap();
2246            drop(journal);
2247
2248            // Reopen and verify the size is exactly 7 with no repair (a clean short tail).
2249            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
2250                .await
2251                .unwrap();
2252            assert_eq!(journal.size(), 7);
2253            // Blobs 0 and 1 exist and we can read every position.
2254            for i in 0..7u64 {
2255                assert_eq!(journal.read(i).await.unwrap(), test_digest(i));
2256            }
2257            journal.destroy().await.unwrap();
2258        });
2259    }
2260
2261    #[test_traced]
2262    fn test_fixed_journal_recover_accepts_clean_empty_tail() {
2263        let executor = deterministic::Runner::default();
2264        executor.start(|context| async move {
2265            let cfg = test_cfg(&context, NZU64!(5));
2266
2267            // Set up via the public API: 5 items in blob 0 (full); rolling over implicitly
2268            // creates an empty blob 1 as the tail. Sync and drop.
2269            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
2270                .await
2271                .unwrap();
2272            for i in 0..5 {
2273                journal.append(&test_digest(i)).await.unwrap();
2274            }
2275            journal.sync().await.unwrap();
2276            drop(journal);
2277
2278            // Reopen: blob 0 is full, blob 1 is the empty tail. Size = 5, no repair.
2279            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
2280                .await
2281                .unwrap();
2282            assert_eq!(journal.size(), 5);
2283            for i in 0..5u64 {
2284                assert_eq!(journal.read(i).await.unwrap(), test_digest(i));
2285            }
2286            assert_eq!(journal.test_newest_blob(), Some(1));
2287            journal.destroy().await.unwrap();
2288        });
2289    }
2290
2291    #[test_traced]
2292    fn test_fixed_journal_recover_sparse_blob_ids_repairs_at_gap() {
2293        let executor = deterministic::Runner::default();
2294        executor.start(|context| async move {
2295            let cfg = test_cfg(&context, NZU64!(1));
2296            let blob_partition = blob_partition(&cfg);
2297
2298            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
2299                .await
2300                .unwrap();
2301            journal.append(&test_digest(0)).await.unwrap();
2302            journal.sync().await.unwrap();
2303            drop(journal);
2304
2305            // Add a far-future blob directly. Recovery should inspect actual blob ids and
2306            // repair at the first missing boundary instead of walking the entire numeric range.
2307            let cache_ref = CacheRef::from_pooler(&context, PAGE_SIZE, PAGE_CACHE_SIZE);
2308            let (blob, blob_size) = context
2309                .open(&blob_partition, &u64::MAX.to_be_bytes())
2310                .await
2311                .unwrap();
2312            let mut append = Writer::new(blob, blob_size, 2048, cache_ref).await.unwrap();
2313            let extra = test_digest(999);
2314            append.append(extra.as_ref()).await.unwrap();
2315            append.sync().await.unwrap();
2316            drop(append);
2317
2318            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
2319                .await
2320                .unwrap();
2321            assert_eq!(journal.bounds(), 0..1);
2322            assert_eq!(journal.read(0).await.unwrap(), test_digest(0));
2323            assert!(matches!(
2324                journal.read(1).await,
2325                Err(Error::ItemOutOfRange(1))
2326            ));
2327            assert_eq!(journal.test_newest_blob(), Some(1));
2328
2329            journal.destroy().await.unwrap();
2330        });
2331    }
2332
2333    #[test_traced]
2334    fn test_fixed_journal_recover_fallback_truncates_after_short_oldest_blob() {
2335        let executor = deterministic::Runner::default();
2336        executor.start(|context| async move {
2337            let cfg = test_cfg(&context, NZU64!(5));
2338            let mut journal =
2339                Journal::<_, Digest>::init_at_size(context.child("first"), cfg.clone(), 7)
2340                    .await
2341                    .expect("failed to initialize journal at size");
2342
2343            for i in 0..8u64 {
2344                journal
2345                    .append(&test_digest(100 + i))
2346                    .await
2347                    .expect("failed to append data");
2348            }
2349            journal.sync().await.expect("failed to sync journal");
2350            assert_eq!(journal.bounds(), 7..15);
2351
2352            {
2353                journal.checkpoint.set_watermark(Some(6));
2354                journal
2355                    .checkpoint
2356                    .sync()
2357                    .await
2358                    .expect("failed to sync lower recovery watermark");
2359            }
2360            drop(journal);
2361
2362            let (blob, size) = context
2363                .open(&blob_partition(&cfg), &1u64.to_be_bytes())
2364                .await
2365                .expect("failed to open oldest blob");
2366            blob.resize(size - 1).await.expect("failed to corrupt blob");
2367            blob.sync().await.expect("failed to sync blob");
2368
2369            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
2370                .await
2371                .expect("failed to recover journal");
2372            assert_eq!(journal.bounds(), 7..9);
2373            assert_eq!(journal.read(7).await.unwrap(), test_digest(100));
2374            assert_eq!(journal.read(8).await.unwrap(), test_digest(101));
2375            assert!(matches!(
2376                journal.read(9).await,
2377                Err(Error::ItemOutOfRange(9))
2378            ));
2379            assert_eq!(journal.test_oldest_blob(), Some(1));
2380            assert_eq!(journal.test_newest_blob(), Some(1));
2381
2382            journal.destroy().await.unwrap();
2383        });
2384    }
2385
2386    #[test_traced]
2387    fn test_fixed_journal_stale_pruning_metadata_preserves_watermark() {
2388        let executor = deterministic::Runner::default();
2389        executor.start(|context| async move {
2390            let cfg = test_cfg(&context, NZU64!(5));
2391            let mut journal =
2392                Journal::<_, Digest>::init_at_size(context.child("first"), cfg.clone(), 7)
2393                    .await
2394                    .expect("failed to initialize journal at size");
2395
2396            for i in 0..10u64 {
2397                journal
2398                    .append(&test_digest(i))
2399                    .await
2400                    .expect("failed to append data");
2401            }
2402            journal.sync().await.expect("failed to sync journal");
2403            assert_eq!(journal.bounds(), 7..17);
2404
2405            // Stage the stale forward-looking watermark while the journal is alive (so we go
2406            // through the public metadata path), then drop and corrupt the underlying blob.
2407            {
2408                journal.checkpoint.set_watermark(Some(12));
2409                journal
2410                    .checkpoint
2411                    .sync()
2412                    .await
2413                    .expect("failed to sync recovery watermark");
2414            }
2415            drop(journal);
2416
2417            // Shorten blob 2 to two items via Append::resize so the on-disk logical view
2418            // matches the staged watermark of 12.
2419            {
2420                let cache_ref = CacheRef::from_pooler(&context, PAGE_SIZE, PAGE_CACHE_SIZE);
2421                let (blob, blob_size) = context
2422                    .open(&blob_partition(&cfg), &2u64.to_be_bytes())
2423                    .await
2424                    .expect("failed to open blob 2");
2425                let mut append = Writer::new(blob, blob_size, 2048, cache_ref)
2426                    .await
2427                    .expect("failed to wrap blob 2");
2428                append
2429                    .resize(2 * Digest::SIZE as u64)
2430                    .await
2431                    .expect("failed to shorten anchored blob");
2432                append.sync().await.expect("failed to sync blob 2");
2433            }
2434
2435            // Remove the checkpoint's oldest blob so the boundary hint of 7 is stale. The
2436            // watermark is preserved because length-based recovery ends at the same point.
2437            context
2438                .remove(&blob_partition(&cfg), Some(&1u64.to_be_bytes()))
2439                .await
2440                .expect("failed to remove stale oldest blob");
2441
2442            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
2443                .await
2444                .expect("failed to recover journal");
2445            assert_eq!(journal.bounds(), 10..12);
2446            assert_eq!(journal.recovery_watermark(), 12);
2447            assert_eq!(journal.read(10).await.unwrap(), test_digest(3));
2448            assert_eq!(journal.read(11).await.unwrap(), test_digest(4));
2449            assert!(matches!(
2450                journal.read(12).await,
2451                Err(Error::ItemOutOfRange(12))
2452            ));
2453
2454            journal.destroy().await.unwrap();
2455        });
2456    }
2457
2458    #[test_traced]
2459    fn test_fixed_journal_stale_pruning_metadata_without_watermark_walks_lengths() {
2460        let executor = deterministic::Runner::default();
2461        executor.start(|context| async move {
2462            let cfg = test_cfg(&context, NZU64!(5));
2463            let mut journal =
2464                Journal::<_, Digest>::init_at_size(context.child("first"), cfg.clone(), 7)
2465                    .await
2466                    .expect("failed to initialize journal at size");
2467
2468            for i in 0..10u64 {
2469                journal
2470                    .append(&test_digest(i))
2471                    .await
2472                    .expect("failed to append data");
2473            }
2474            journal.sync().await.expect("failed to sync journal");
2475            assert_eq!(journal.bounds(), 7..17);
2476
2477            {
2478                journal.checkpoint.set_watermark(None);
2479                journal
2480                    .checkpoint
2481                    .sync()
2482                    .await
2483                    .expect("failed to remove recovery watermark");
2484            }
2485            drop(journal);
2486
2487            // Remove the checkpoint's oldest blob so the boundary hint of 7 is stale. Without a
2488            // recovery watermark, recovery must still walk lengths from the recovered blob boundary.
2489            context
2490                .remove(&blob_partition(&cfg), Some(&1u64.to_be_bytes()))
2491                .await
2492                .expect("failed to remove stale oldest blob");
2493
2494            let mut journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
2495                .await
2496                .expect("failed to recover journal");
2497            assert_eq!(journal.bounds(), 10..17);
2498            // No watermark: watermark at the tail blob start, not size.
2499            assert_eq!(journal.recovery_watermark(), 15);
2500            assert_eq!(journal.read(10).await.unwrap(), test_digest(3));
2501            assert_eq!(journal.read(16).await.unwrap(), test_digest(9));
2502
2503            // After sync, watermark advances to the full recovered size.
2504            journal.sync().await.expect("failed to sync");
2505            assert_eq!(journal.recovery_watermark(), 17);
2506
2507            journal.destroy().await.unwrap();
2508        });
2509    }
2510
2511    /// A boundary hint ahead of the oldest blob is not a reachable crash state: prune removes
2512    /// blobs before sync persists the checkpoint, and clear_to_size stages a clear intent for
2513    /// atomicity. Verify it is rejected as corruption.
2514    #[test_traced]
2515    fn test_fixed_journal_boundary_hint_ahead_of_blobs_is_corruption() {
2516        let executor = deterministic::Runner::default();
2517        executor.start(|context| async move {
2518            let cfg = test_cfg(&context, NZU64!(5));
2519            let mut journal =
2520                Journal::<_, Digest>::init_at_size(context.child("first"), cfg.clone(), 3)
2521                    .await
2522                    .unwrap();
2523
2524            // Append 12 items (positions 3..15) spanning blobs 0, 1, 2.
2525            for i in 0..12u64 {
2526                journal.append(&test_digest(i)).await.unwrap();
2527            }
2528            journal.sync().await.unwrap();
2529            assert_eq!(journal.bounds(), 3..15);
2530
2531            // Set the boundary hint to 8 (blob 1) and lower the watermark so it won't
2532            // independently trigger the watermark > size corruption check. Then remove blob 1's
2533            // blob so blob 0 is the oldest. The boundary hint now references a blob ahead
2534            // of the oldest blob, which is the corruption we're testing.
2535            {
2536                journal.checkpoint.set_boundary_hint(8);
2537                journal.checkpoint.set_watermark(Some(3));
2538                journal.checkpoint.sync().await.unwrap();
2539            }
2540            drop(journal);
2541
2542            context
2543                .remove(&blob_partition(&cfg), Some(&1u64.to_be_bytes()))
2544                .await
2545                .unwrap();
2546
2547            let result = Journal::<_, Digest>::init(context.child("second"), cfg.clone()).await;
2548            assert!(matches!(result, Err(Error::Corruption(_))));
2549        });
2550    }
2551
2552    /// A mid-blob boundary hint with no blobs is not a reachable crash state (see comment in
2553    /// `recover_bounds`). Verify it is rejected as corruption rather than silently recovering empty.
2554    #[test_traced]
2555    fn test_fixed_journal_boundary_hint_with_no_blobs_is_corruption() {
2556        let executor = deterministic::Runner::default();
2557        executor.start(|context| async move {
2558            let cfg = test_cfg(&context, NZU64!(5));
2559            let mut journal =
2560                Journal::<_, Digest>::init_at_size(context.child("first"), cfg.clone(), 7)
2561                    .await
2562                    .unwrap();
2563
2564            for i in 0..3u64 {
2565                journal.append(&test_digest(i)).await.unwrap();
2566            }
2567            journal.sync().await.unwrap();
2568            drop(journal);
2569
2570            // Remove all blobs but leave the checkpoint (with a boundary hint of 7) intact.
2571            for name in scan_partition(&context, &blob_partition(&cfg)).await {
2572                context
2573                    .remove(&blob_partition(&cfg), Some(&name))
2574                    .await
2575                    .unwrap();
2576            }
2577
2578            let result = Journal::<_, Digest>::init(context.child("second"), cfg.clone()).await;
2579            assert!(matches!(result, Err(Error::Corruption(_))));
2580        });
2581    }
2582
2583    #[test_traced]
2584    fn test_fixed_journal_legacy_recovery_installs_watermark() {
2585        let executor = deterministic::Runner::default();
2586        executor.start(|context| async move {
2587            let cfg = test_cfg(&context, NZU64!(5));
2588            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
2589                .await
2590                .expect("failed to initialize journal");
2591
2592            for i in 0..12u64 {
2593                journal
2594                    .append(&test_digest(i))
2595                    .await
2596                    .expect("failed to append data");
2597            }
2598            journal.sync().await.expect("failed to sync journal");
2599
2600            {
2601                journal.checkpoint.set_watermark(None);
2602                journal
2603                    .checkpoint
2604                    .sync()
2605                    .await
2606                    .expect("failed to remove recovery watermark");
2607            }
2608            drop(journal);
2609
2610            // Legacy recovery sets watermark to the tail blob start, not size, so the tail
2611            // is marked dirty and fsynced before the watermark advances.
2612            let mut journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
2613                .await
2614                .expect("failed to recover legacy journal");
2615            assert_eq!(journal.bounds(), 0..12);
2616            assert_eq!(journal.recovery_watermark(), 10);
2617
2618            // After sync, the watermark advances to the full size.
2619            journal
2620                .sync()
2621                .await
2622                .expect("failed to sync after legacy recovery");
2623            assert_eq!(journal.recovery_watermark(), 12);
2624
2625            journal.destroy().await.unwrap();
2626        });
2627    }
2628
2629    /// Regression: legacy upgrade (no recovery watermark) must mark all recovered blobs
2630    /// dirty so they are fsynced before the watermark advances. Without this, init could install
2631    /// a durable watermark for data that was only in the OS page cache.
2632    #[test_traced]
2633    fn test_fixed_journal_legacy_upgrade_marks_recovered_blobs_dirty() {
2634        let executor = deterministic::Runner::default();
2635        executor.start(|context| async move {
2636            let cfg = test_cfg(&context, NZU64!(5));
2637            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
2638                .await
2639                .unwrap();
2640
2641            for i in 0..7u64 {
2642                journal.append(&test_digest(i)).await.unwrap();
2643            }
2644            journal.sync().await.unwrap();
2645
2646            // Remove the watermark to simulate a legacy journal.
2647            {
2648                journal.checkpoint.set_watermark(None);
2649                journal.checkpoint.sync().await.unwrap();
2650            }
2651            drop(journal);
2652
2653            let mut journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
2654                .await
2655                .unwrap();
2656            assert_eq!(journal.size(), 7);
2657            // Watermark at tail blob start (blob 1 = position 5).
2658            assert_eq!(journal.recovery_watermark(), 5);
2659
2660            // Inject sync faults. If recovered blobs were not marked dirty, commit would
2661            // skip the data sync and succeed despite the fault.
2662            *context.storage_fault_config().write() = deterministic::FaultConfig {
2663                sync_rate: Some(1.0),
2664                ..Default::default()
2665            };
2666            assert!(
2667                journal.commit().await.is_err(),
2668                "commit must sync recovered data before the watermark can advance"
2669            );
2670        });
2671    }
2672
2673    #[test_traced]
2674    fn test_fixed_journal_commit_does_not_advance_recovery_watermark() {
2675        let executor = deterministic::Runner::default();
2676        executor.start(|context| async move {
2677            let cfg = test_cfg(&context, NZU64!(5));
2678            let mut journal = Journal::<_, Digest>::init(context.child("journal"), cfg.clone())
2679                .await
2680                .unwrap();
2681
2682            journal.append(&test_digest(0)).await.unwrap();
2683            journal.sync().await.unwrap();
2684            assert_eq!(journal.recovery_watermark(), 1);
2685
2686            journal.append(&test_digest(1)).await.unwrap();
2687            journal.commit().await.unwrap();
2688            assert_eq!(
2689                journal.recovery_watermark(),
2690                1,
2691                "commit must make dirty blobs durable without advancing the recovery watermark",
2692            );
2693
2694            journal.sync().await.unwrap();
2695            assert_eq!(journal.recovery_watermark(), 2);
2696            journal.destroy().await.unwrap();
2697        });
2698    }
2699
2700    #[test_traced]
2701    fn test_fixed_journal_prune_to_blob_boundary_removes_pruning_metadata() {
2702        let executor = deterministic::Runner::default();
2703        executor.start(|context| async move {
2704            let cfg = test_cfg(&context, NZU64!(5));
2705            let mut journal =
2706                Journal::<_, Digest>::init_at_size(context.child("first"), cfg.clone(), 7)
2707                    .await
2708                    .expect("failed to initialize journal at size");
2709
2710            for i in 0..8u64 {
2711                journal
2712                    .append(&test_digest(i))
2713                    .await
2714                    .expect("failed to append data");
2715            }
2716            journal.sync().await.expect("failed to sync journal");
2717            assert_eq!(journal.bounds(), 7..15);
2718
2719            journal.prune(10).await.expect("failed to prune journal");
2720            journal.sync().await.expect("failed to sync pruned journal");
2721            assert_eq!(journal.bounds(), 10..15);
2722            drop(journal);
2723
2724            let checkpoint = Checkpoint::open(context.child("metadata"), &cfg.partition)
2725                .await
2726                .expect("failed to reopen checkpoint");
2727            assert!(checkpoint.boundary_hint().is_none());
2728            drop(checkpoint);
2729
2730            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
2731                .await
2732                .expect("failed to reopen journal");
2733            assert_eq!(journal.bounds(), 10..15);
2734            assert_eq!(journal.read(10).await.unwrap(), test_digest(3));
2735            journal.destroy().await.unwrap();
2736        });
2737    }
2738
2739    #[test_traced]
2740    fn test_fixed_journal_recover_rejects_overlong_blob() {
2741        let executor = deterministic::Runner::default();
2742        executor.start(|context| async move {
2743            let cfg = test_cfg(&context, NZU64!(5));
2744            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
2745                .await
2746                .expect("failed to initialize journal");
2747
2748            for i in 0..5u64 {
2749                journal
2750                    .append(&test_digest(i))
2751                    .await
2752                    .expect("failed to append data");
2753            }
2754            journal.sync().await.expect("failed to sync journal");
2755            drop(journal);
2756
2757            // Inject an extra item into blob 0 at the blob level so its length exceeds
2758            // items_per_blob -- this is what `recover_bounds` validates and rejects as Corruption.
2759            {
2760                let extra = test_digest(99);
2761                let cache_ref = CacheRef::from_pooler(&context, PAGE_SIZE, PAGE_CACHE_SIZE);
2762                let (blob, blob_size) = context
2763                    .open(&blob_partition(&cfg), &0u64.to_be_bytes())
2764                    .await
2765                    .expect("failed to open blob 0");
2766                let mut append = Writer::new(blob, blob_size, 2048, cache_ref)
2767                    .await
2768                    .expect("failed to wrap blob 0");
2769                append
2770                    .append(extra.as_ref())
2771                    .await
2772                    .expect("failed to append extra item");
2773                append.sync().await.expect("failed to sync corrupted blob");
2774            }
2775
2776            let result = Journal::<_, Digest>::init(context.child("second"), cfg.clone()).await;
2777            assert!(matches!(result, Err(Error::Corruption(_))));
2778        });
2779    }
2780
2781    #[test_traced("DEBUG")]
2782    fn test_fixed_journal_recover_from_unwritten_data() {
2783        let executor = deterministic::Runner::default();
2784        executor.start(|context| async move {
2785            // Initialize the journal, allowing a max of 10 items per blob.
2786            let cfg = test_cfg(&context, NZU64!(10));
2787            let mut journal = Journal::init(context.child("first"), cfg.clone())
2788                .await
2789                .expect("failed to initialize journal");
2790
2791            // Add only a single item
2792            journal
2793                .append(&test_digest(0))
2794                .await
2795                .expect("failed to append data");
2796            assert_eq!(journal.size(), 1);
2797            journal.sync().await.expect("Failed to sync journal");
2798            drop(journal);
2799
2800            // Manually extend the blob to simulate a failure where the file was extended, but no
2801            // bytes were written due to failure.
2802            let (blob, size) = context
2803                .open(&blob_partition(&cfg), &0u64.to_be_bytes())
2804                .await
2805                .expect("Failed to open blob");
2806            blob.write_at_sync(size, vec![0u8; PAGE_SIZE.get() as usize * 3])
2807                .await
2808                .expect("Failed to extend blob");
2809
2810            // Re-initialize the journal to simulate a restart
2811            let mut journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
2812                .await
2813                .expect("Failed to re-initialize journal");
2814
2815            // The zero-filled pages are detected as invalid (bad checksum) and truncated.
2816            // No items should be lost since we called sync before the corruption.
2817            assert_eq!(journal.size(), 1);
2818
2819            // Make sure journal still works for appending.
2820            journal
2821                .append(&test_digest(1))
2822                .await
2823                .expect("failed to append data");
2824
2825            journal.destroy().await.unwrap();
2826        });
2827    }
2828
2829    #[test_traced]
2830    fn test_fixed_journal_rewinding() {
2831        let executor = deterministic::Runner::default();
2832        executor.start(|context| async move {
2833            // Initialize the journal, allowing a max of 2 items per blob.
2834            let cfg = test_cfg(&context, NZU64!(2));
2835            let mut journal = Journal::init(context.child("first"), cfg.clone())
2836                .await
2837                .expect("failed to initialize journal");
2838            assert!(matches!(journal.rewind(0).await, Ok(())));
2839            assert!(matches!(
2840                journal.rewind(1).await,
2841                Err(Error::InvalidRewind(1))
2842            ));
2843
2844            // Append an item to the journal
2845            journal
2846                .append(&test_digest(0))
2847                .await
2848                .expect("failed to append data 0");
2849            assert_eq!(journal.size(), 1);
2850            assert!(matches!(journal.rewind(1).await, Ok(()))); // should be no-op
2851            assert!(matches!(journal.rewind(0).await, Ok(())));
2852            assert_eq!(journal.size(), 0);
2853
2854            // append 7 items
2855            for i in 0..7 {
2856                let pos = journal
2857                    .append(&test_digest(i))
2858                    .await
2859                    .expect("failed to append data");
2860                assert_eq!(pos, i);
2861            }
2862            assert_eq!(journal.size(), 7);
2863
2864            // rewind back to item #4, which should prune 2 blobs
2865            assert!(matches!(journal.rewind(4).await, Ok(())));
2866            assert_eq!(journal.size(), 4);
2867
2868            // rewind back to empty and ensure all blobs are rewound over
2869            assert!(matches!(journal.rewind(0).await, Ok(())));
2870            assert_eq!(journal.size(), 0);
2871
2872            // stress test: add 100 items, rewind 49, repeat x10.
2873            for _ in 0..10 {
2874                for i in 0..100 {
2875                    journal
2876                        .append(&test_digest(i))
2877                        .await
2878                        .expect("failed to append data");
2879                }
2880                journal.rewind(journal.size() - 49).await.unwrap();
2881            }
2882            const ITEMS_REMAINING: u64 = 10 * (100 - 49);
2883            assert_eq!(journal.size(), ITEMS_REMAINING);
2884
2885            journal.sync().await.expect("Failed to sync journal");
2886            drop(journal);
2887
2888            // Repeat with a different blob size (3 items per blob)
2889            let mut cfg = test_cfg(&context, NZU64!(3));
2890            cfg.partition = "test-partition-2".into();
2891            let mut journal = Journal::init(context.child("second"), cfg.clone())
2892                .await
2893                .expect("failed to initialize journal");
2894            for _ in 0..10 {
2895                for i in 0..100 {
2896                    journal
2897                        .append(&test_digest(i))
2898                        .await
2899                        .expect("failed to append data");
2900                }
2901                journal.rewind(journal.size() - 49).await.unwrap();
2902            }
2903            assert_eq!(journal.size(), ITEMS_REMAINING);
2904
2905            journal.sync().await.expect("Failed to sync journal");
2906            drop(journal);
2907
2908            // Make sure re-opened journal is as expected
2909            let mut journal: Journal<_, Digest> =
2910                Journal::init(context.child("third"), cfg.clone())
2911                    .await
2912                    .expect("failed to re-initialize journal");
2913            assert_eq!(journal.size(), 10 * (100 - 49));
2914
2915            // Make sure rewinding works after pruning
2916            journal.prune(300).await.expect("pruning failed");
2917            assert_eq!(journal.size(), ITEMS_REMAINING);
2918            // Rewinding prior to our prune point should fail.
2919            assert!(matches!(
2920                journal.rewind(299).await,
2921                Err(Error::ItemPruned(299))
2922            ));
2923            // Rewinding to the prune point should work.
2924            // always remain in the journal.
2925            assert!(matches!(journal.rewind(300).await, Ok(())));
2926            let bounds = journal.bounds();
2927            assert_eq!(bounds.end, 300);
2928            assert!(bounds.is_empty());
2929
2930            journal.destroy().await.unwrap();
2931        });
2932    }
2933
2934    #[test_traced]
2935    fn test_fixed_journal_rewind_commit_reopen() {
2936        let executor = deterministic::Runner::default();
2937        executor.start(|context| async move {
2938            let cfg = test_cfg(&context, NZU64!(5));
2939            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
2940                .await
2941                .expect("failed to initialize journal");
2942
2943            for i in 0..12u64 {
2944                journal
2945                    .append(&test_digest(i))
2946                    .await
2947                    .expect("failed to append data");
2948            }
2949            journal.sync().await.expect("failed to sync journal");
2950
2951            journal.rewind(7).await.expect("failed to rewind journal");
2952            journal.commit().await.expect("failed to commit journal");
2953            drop(journal);
2954
2955            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
2956                .await
2957                .expect("failed to re-initialize journal");
2958            assert_eq!(journal.bounds(), 0..7);
2959            for i in 0..7u64 {
2960                assert_eq!(journal.read(i).await.unwrap(), test_digest(i));
2961            }
2962            assert!(matches!(
2963                journal.read(7).await,
2964                Err(Error::ItemOutOfRange(7))
2965            ));
2966
2967            journal.destroy().await.unwrap();
2968        });
2969    }
2970
2971    #[test_traced]
2972    fn test_fixed_journal_rewind_persists_lower_watermark() {
2973        let executor = deterministic::Runner::default();
2974        executor.start(|context| async move {
2975            let cfg = test_cfg(&context, NZU64!(5));
2976            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
2977                .await
2978                .expect("failed to initialize journal");
2979
2980            for i in 0..12u64 {
2981                journal
2982                    .append(&test_digest(i))
2983                    .await
2984                    .expect("failed to append data");
2985            }
2986            journal.sync().await.expect("failed to sync journal");
2987            journal.rewind(7).await.expect("failed to rewind journal");
2988            drop(journal);
2989
2990            let checkpoint = Checkpoint::open(context.child("metadata"), &cfg.partition)
2991                .await
2992                .expect("failed to reopen checkpoint");
2993            let persisted_watermark = checkpoint
2994                .watermark()
2995                .expect("missing recovery watermark after rewind");
2996            assert_eq!(persisted_watermark, 7);
2997            drop(checkpoint);
2998
2999            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
3000                .await
3001                .expect("failed to re-initialize journal");
3002            journal.destroy().await.unwrap();
3003        });
3004    }
3005
3006    #[test_traced]
3007    fn test_fixed_journal_recover_after_watermark_lowered_before_rewind() {
3008        let executor = deterministic::Runner::default();
3009        executor.start(|context| async move {
3010            let cfg = test_cfg(&context, NZU64!(5));
3011            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
3012                .await
3013                .expect("failed to initialize journal");
3014
3015            for i in 0..12u64 {
3016                journal
3017                    .append(&test_digest(i))
3018                    .await
3019                    .expect("failed to append data");
3020            }
3021            journal.sync().await.expect("failed to sync journal");
3022
3023            {
3024                journal.checkpoint.set_watermark(Some(7));
3025                journal
3026                    .checkpoint
3027                    .sync()
3028                    .await
3029                    .expect("failed to lower recovery watermark");
3030            }
3031            drop(journal);
3032
3033            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
3034                .await
3035                .expect("failed to recover journal");
3036            assert_eq!(journal.bounds(), 0..12);
3037            assert_eq!(journal.recovery_watermark(), 7);
3038            assert_eq!(journal.read(11).await.unwrap(), test_digest(11));
3039            journal.destroy().await.unwrap();
3040        });
3041    }
3042
3043    #[test_traced]
3044    fn test_fixed_journal_rewind_append_commit_reopen() {
3045        let executor = deterministic::Runner::default();
3046        executor.start(|context| async move {
3047            let cfg = test_cfg(&context, NZU64!(5));
3048            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
3049                .await
3050                .expect("failed to initialize journal");
3051
3052            for i in 0..12u64 {
3053                journal
3054                    .append(&test_digest(i))
3055                    .await
3056                    .expect("failed to append data");
3057            }
3058            journal.sync().await.expect("failed to sync journal");
3059
3060            journal.rewind(7).await.expect("failed to rewind journal");
3061            for i in 0..3u64 {
3062                journal
3063                    .append(&test_digest(100 + i))
3064                    .await
3065                    .expect("failed to append data");
3066            }
3067            journal.commit().await.expect("failed to commit journal");
3068            drop(journal);
3069
3070            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
3071                .await
3072                .expect("failed to re-initialize journal");
3073            assert_eq!(journal.bounds(), 0..10);
3074            assert_eq!(journal.recovery_watermark(), 7);
3075            for i in 0..7u64 {
3076                assert_eq!(journal.read(i).await.unwrap(), test_digest(i));
3077            }
3078            for i in 0..3u64 {
3079                assert_eq!(journal.read(7 + i).await.unwrap(), test_digest(100 + i));
3080            }
3081            assert!(matches!(
3082                journal.read(10).await,
3083                Err(Error::ItemOutOfRange(10))
3084            ));
3085
3086            journal.destroy().await.unwrap();
3087        });
3088    }
3089
3090    #[test_traced]
3091    fn test_fixed_recovery_handles_multiple_empty_data_tail_blobs() {
3092        let executor = deterministic::Runner::default();
3093        executor.start(|context| async move {
3094            let cfg = test_cfg(&context, NZU64!(1));
3095            let mut journal = Journal::<_, Digest>::init(context.child("journal"), cfg.clone())
3096                .await
3097                .unwrap();
3098
3099            // Persist a prefix, then append across multiple blob boundaries without syncing. The
3100            // unsynced item bytes are lost on drop, but their blobs remain visible.
3101            assert_eq!(journal.append(&test_digest(10)).await.unwrap(), 0);
3102            journal.sync().await.unwrap();
3103            assert_eq!(journal.append(&test_digest(20)).await.unwrap(), 1);
3104            assert_eq!(journal.append(&test_digest(30)).await.unwrap(), 2);
3105            drop(journal);
3106
3107            let blobs = scan_partition(&context, &blob_partition(&cfg)).await;
3108            assert!(
3109                blobs.len() > 2,
3110                "expected multiple empty trailing blobs, got {}",
3111                blobs.len()
3112            );
3113
3114            let journal = Journal::<_, Digest>::init(context.child("recovered"), cfg.clone())
3115                .await
3116                .unwrap();
3117            assert_eq!(journal.bounds(), 0..1);
3118            assert_eq!(journal.read(0).await.unwrap(), test_digest(10));
3119            drop(journal);
3120
3121            // Recovery should remove the empty trailing blobs, leaving only the durable prefix's
3122            // blob and the recreated tail.
3123            let blobs = scan_partition(&context, &blob_partition(&cfg)).await;
3124            assert_eq!(blobs.len(), 2);
3125
3126            let mut journal = Journal::<_, Digest>::init(context.child("recovered"), cfg.clone())
3127                .await
3128                .unwrap();
3129            assert_eq!(journal.append(&test_digest(42)).await.unwrap(), 1);
3130            assert_eq!(journal.read(1).await.unwrap(), test_digest(42));
3131            journal.destroy().await.unwrap();
3132        });
3133    }
3134
3135    #[test_traced]
3136    fn test_fixed_recovery_handles_empty_data_with_no_durable_items() {
3137        let executor = deterministic::Runner::default();
3138        executor.start(|context| async move {
3139            let cfg = test_cfg(&context, NZU64!(1));
3140            let mut journal = Journal::<_, Digest>::init(context.child("journal"), cfg.clone())
3141                .await
3142                .unwrap();
3143
3144            // Append across multiple blob boundaries without ever syncing. No item bytes become
3145            // durable, so recovery sees multiple empty blobs and no durable data.
3146            assert_eq!(journal.append(&test_digest(10)).await.unwrap(), 0);
3147            assert_eq!(journal.append(&test_digest(20)).await.unwrap(), 1);
3148            drop(journal);
3149
3150            let blobs = scan_partition(&context, &blob_partition(&cfg)).await;
3151            assert!(
3152                blobs.len() > 1,
3153                "expected multiple empty blobs, got {}",
3154                blobs.len()
3155            );
3156
3157            let journal = Journal::<_, Digest>::init(context.child("recovered"), cfg.clone())
3158                .await
3159                .unwrap();
3160            assert_eq!(journal.bounds(), 0..0);
3161            drop(journal);
3162
3163            // Recovery should remove the extra empty blobs, leaving only the recreated tail.
3164            let blobs = scan_partition(&context, &blob_partition(&cfg)).await;
3165            assert_eq!(blobs.len(), 1);
3166
3167            let mut journal = Journal::<_, Digest>::init(context.child("recovered"), cfg.clone())
3168                .await
3169                .unwrap();
3170            assert_eq!(journal.append(&test_digest(42)).await.unwrap(), 0);
3171            assert_eq!(journal.read(0).await.unwrap(), test_digest(42));
3172            journal.destroy().await.unwrap();
3173        });
3174    }
3175
3176    /// Test that a crash partway through a multi-blob sync leaves a contiguous durable prefix
3177    /// that recovery preserves.
3178    ///
3179    /// `flush_dirty_blobs` syncs dirty blobs, and mutators take `&mut self` so no concurrent
3180    /// sync can interleave. This reproduces a crash after blobs 0 and 1 were synced but before
3181    /// blob 2, then asserts recovery keeps exactly the contiguous prefix 0..20.
3182    #[test_traced]
3183    fn test_fixed_recovery_partial_sync_loop_keeps_contiguous_prefix() {
3184        let executor = deterministic::Runner::default();
3185        executor.start(|context| async move {
3186            let cfg = test_cfg(&context, NZU64!(10));
3187            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
3188                .await
3189                .unwrap();
3190
3191            // Fill blobs 0 and 1 and partially fill blob 2 (positions 20..25). Nothing is
3192            // synced yet, so only the created blobs are durable, all still empty.
3193            for i in 0..25u64 {
3194                journal.append(&test_digest(i)).await.unwrap();
3195            }
3196
3197            // Sync blobs 0 and 1 but not blob 2, simulating a crash after part of a
3198            // multi-blob sync became durable.
3199            {
3200                journal.test_sync_blob(0).await.unwrap();
3201                journal.test_sync_blob(1).await.unwrap();
3202            }
3203            drop(journal);
3204
3205            // The durable data is exactly the contiguous prefix: blobs 0 and 1 hold items and
3206            // blob 2 is an empty trailing blob.
3207            let names = scan_partition(&context, &blob_partition(&cfg)).await;
3208            assert_eq!(names.len(), 3);
3209            for (blob, name) in names.iter().enumerate() {
3210                let (_blob, size) = context.open(&blob_partition(&cfg), name).await.unwrap();
3211                if blob < 2 {
3212                    assert!(size > 0, "blob {blob} should be durable");
3213                } else {
3214                    assert_eq!(size, 0, "blob {blob} should be empty");
3215                }
3216            }
3217
3218            // Recovery preserves exactly the contiguous prefix 0..20.
3219            let mut journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
3220                .await
3221                .unwrap();
3222            assert_eq!(journal.bounds(), 0..20);
3223            for i in 0..20u64 {
3224                assert_eq!(journal.read(i).await.unwrap(), test_digest(i));
3225            }
3226            assert!(matches!(
3227                journal.read(20).await,
3228                Err(Error::ItemOutOfRange(20))
3229            ));
3230
3231            // Appends resume cleanly from the recovered boundary.
3232            assert_eq!(journal.append(&test_digest(999)).await.unwrap(), 20);
3233            assert_eq!(journal.read(20).await.unwrap(), test_digest(999));
3234
3235            journal.destroy().await.unwrap();
3236        });
3237    }
3238
3239    /// Test that a durable blob above the sync watermark, sitting beyond an empty intermediate
3240    /// blob, is rolled back to the contiguous boundary during recovery.
3241    ///
3242    /// Since #3790 removed the append-time sync when crossing blob boundaries, a process crash can
3243    /// leave a later blob incidentally durable while an earlier blob stayed buffered and was
3244    /// lost, producing a physical gap. Length-based recovery walks blobs from oldest and
3245    /// truncates at the first short non-tail blob, so the post-gap blob is discarded and only
3246    /// the synced prefix survives.
3247    #[test_traced]
3248    fn test_fixed_recovery_rolls_back_durable_blob_after_gap() {
3249        let executor = deterministic::Runner::default();
3250        executor.start(|context| async move {
3251            let cfg = test_cfg(&context, NZU64!(10));
3252            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
3253                .await
3254                .unwrap();
3255
3256            // Durably commit blob 0 (positions 0..10), advancing the recovery watermark to 10.
3257            for i in 0..10u64 {
3258                journal.append(&test_digest(i)).await.unwrap();
3259            }
3260            journal.sync().await.unwrap();
3261
3262            // Append blob 1 and part of blob 2 without committing. Manually sync only blob
3263            // 2 to mimic its writes surviving a crash, while blob 1 stays buffered and is lost.
3264            for i in 10..28u64 {
3265                journal.append(&test_digest(i)).await.unwrap();
3266            }
3267            {
3268                journal.test_sync_blob(2).await.unwrap();
3269            }
3270            drop(journal);
3271
3272            // Durable state: blob 0 (10 items), blob 1 (empty gap), blob 2 (8 items).
3273            let names = scan_partition(&context, &blob_partition(&cfg)).await;
3274            assert_eq!(names.len(), 3);
3275            let mut sizes = Vec::new();
3276            for name in &names {
3277                let (_blob, size) = context.open(&blob_partition(&cfg), name).await.unwrap();
3278                sizes.push(size);
3279            }
3280            assert!(sizes[0] > 0, "blob 0 should be durable");
3281            assert_eq!(sizes[1], 0, "blob 1 should be the gap");
3282            assert!(sizes[2] > 0, "blob 2 should be incidentally durable");
3283
3284            // Recovery rolls back to the watermark boundary: only the synced prefix survives and the
3285            // gapped blob 2 is truncated away.
3286            let mut journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
3287                .await
3288                .unwrap();
3289            assert_eq!(journal.bounds(), 0..10);
3290            for i in 0..10u64 {
3291                assert_eq!(journal.read(i).await.unwrap(), test_digest(i));
3292            }
3293            assert!(matches!(
3294                journal.read(10).await,
3295                Err(Error::ItemOutOfRange(10))
3296            ));
3297
3298            // The orphaned blob 2 is gone; the truncated blob 1 remains as the recovered tail.
3299            let names = scan_partition(&context, &blob_partition(&cfg)).await;
3300            assert_eq!(names.len(), 2);
3301
3302            // Appends resume cleanly from the recovered boundary.
3303            assert_eq!(journal.append(&test_digest(999)).await.unwrap(), 10);
3304            assert_eq!(journal.read(10).await.unwrap(), test_digest(999));
3305
3306            journal.destroy().await.unwrap();
3307        });
3308    }
3309
3310    /// A crash right after pruning must not lose retained items that were appended but never
3311    /// synced. Blob removal is durable, so prune flushes every dirty blob first: otherwise the
3312    /// unsynced tail would vanish with the crash and recovery would truncate the journal to
3313    /// empty even though the removal survived.
3314    #[test_traced]
3315    fn test_fixed_recovery_prune_crash_retains_unsynced_tail() {
3316        let executor = deterministic::Runner::default();
3317        executor.start(|context| async move {
3318            let cfg = test_cfg(&context, NZU64!(10));
3319            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
3320                .await
3321                .unwrap();
3322
3323            // Durably persist blob 0 (positions 0..10), then append positions 10..25 across
3324            // blobs 1 and 2 without syncing.
3325            for i in 0..10u64 {
3326                journal.append(&test_digest(i)).await.unwrap();
3327            }
3328            journal.sync().await.unwrap();
3329            for i in 10..25u64 {
3330                journal.append(&test_digest(i)).await.unwrap();
3331            }
3332
3333            // Prune away blob 0, then crash before any sync.
3334            assert!(journal.prune(10).await.unwrap());
3335            drop(journal);
3336
3337            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
3338                .await
3339                .unwrap();
3340            assert_eq!(journal.bounds(), 10..25);
3341            for i in 10..25u64 {
3342                assert_eq!(journal.read(i).await.unwrap(), test_digest(i));
3343            }
3344            journal.destroy().await.unwrap();
3345        });
3346    }
3347
3348    /// Test recovery when the oldest blob is empty but a newer blob still holds durable items.
3349    ///
3350    /// This is the fixed-journal analog of the variable-journal empty-oldest-blob gap bug. A
3351    /// contiguous journal can only populate a later blob after filling the earlier one, so an
3352    /// empty oldest blob with a populated newer blob is an orphaned gap. Length-based recovery
3353    /// walks from the oldest blob, finds it short (empty), and truncates everything from there,
3354    /// aligning the journal to empty without panicking.
3355    #[test_traced]
3356    fn test_fixed_recovery_empty_oldest_blob_orphaned_newer_blob() {
3357        let executor = deterministic::Runner::default();
3358        executor.start(|context| async move {
3359            let cfg = test_cfg(&context, NZU64!(10));
3360
3361            // Durably persist blobs 0 and 1 (positions 0..20).
3362            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
3363                .await
3364                .unwrap();
3365            for i in 0..20u64 {
3366                journal.append(&test_digest(i)).await.unwrap();
3367            }
3368            journal.sync().await.unwrap();
3369            drop(journal);
3370
3371            // Empty the oldest blob (external corruption). The watermark (20) now exceeds the
3372            // recoverable size (0), which is corruption.
3373            let (blob0, size0) = context
3374                .open(&blob_partition(&cfg), &0u64.to_be_bytes())
3375                .await
3376                .unwrap();
3377            assert!(size0 > 0);
3378            blob0.resize(0).await.unwrap();
3379            blob0.sync().await.unwrap();
3380
3381            let result = Journal::<_, Digest>::init(context.child("second"), cfg.clone()).await;
3382            assert!(matches!(result, Err(Error::Corruption(_))));
3383        });
3384    }
3385
3386    /// Test the contiguous fixed journal with items_per_blob: 1.
3387    ///
3388    /// This is an edge case where each item creates its own blob, and the
3389    /// tail blob is always empty after sync (because the item fills the blob
3390    /// and a new empty one is created).
3391    #[test_traced]
3392    fn test_single_item_per_blob() {
3393        let executor = deterministic::Runner::default();
3394        executor.start(|context| async move {
3395            let cfg = Config {
3396                partition: "single-item-per-blob".into(),
3397                items_per_blob: NZU64!(1),
3398                page_cache: CacheRef::from_pooler(&context, PAGE_SIZE, PAGE_CACHE_SIZE),
3399                write_buffer: NZUsize!(2048),
3400            };
3401
3402            // === Test 1: Basic single item operation ===
3403            let mut journal = Journal::init(context.child("first"), cfg.clone())
3404                .await
3405                .expect("failed to initialize journal");
3406
3407            // Verify empty state
3408            let bounds = journal.bounds();
3409            assert_eq!(bounds.end, 0);
3410            assert!(bounds.is_empty());
3411
3412            // Append 1 item
3413            let pos = journal
3414                .append(&test_digest(0))
3415                .await
3416                .expect("failed to append");
3417            assert_eq!(pos, 0);
3418            assert_eq!(journal.size(), 1);
3419
3420            // Sync
3421            journal.sync().await.expect("failed to sync");
3422
3423            // Read from size() - 1
3424            let value = journal
3425                .read(journal.size() - 1)
3426                .await
3427                .expect("failed to read");
3428            assert_eq!(value, test_digest(0));
3429
3430            // === Test 2: Multiple items with single item per blob ===
3431            for i in 1..10u64 {
3432                let pos = journal
3433                    .append(&test_digest(i))
3434                    .await
3435                    .expect("failed to append");
3436                assert_eq!(pos, i);
3437                assert_eq!(journal.size(), i + 1);
3438
3439                // Verify we can read the just-appended item at size() - 1
3440                let value = journal
3441                    .read(journal.size() - 1)
3442                    .await
3443                    .expect("failed to read");
3444                assert_eq!(value, test_digest(i));
3445            }
3446
3447            // Verify all items can be read
3448            for i in 0..10u64 {
3449                assert_eq!(journal.read(i).await.unwrap(), test_digest(i));
3450            }
3451
3452            journal.sync().await.expect("failed to sync");
3453
3454            // === Test 3: Pruning with single item per blob ===
3455            // Prune to position 5 (removes positions 0-4)
3456            journal.prune(5).await.expect("failed to prune");
3457
3458            // Size should still be 10
3459            assert_eq!(journal.size(), 10);
3460
3461            // bounds.start should be 5
3462            assert_eq!(journal.bounds().start, 5);
3463
3464            // Reading from size() - 1 (position 9) should still work
3465            let value = journal
3466                .read(journal.size() - 1)
3467                .await
3468                .expect("failed to read");
3469            assert_eq!(value, test_digest(9));
3470
3471            // Reading from pruned positions should return ItemPruned
3472            for i in 0..5 {
3473                assert!(matches!(journal.read(i).await, Err(Error::ItemPruned(_))));
3474            }
3475
3476            // Reading from retained positions should work
3477            for i in 5..10u64 {
3478                assert_eq!(journal.read(i).await.unwrap(), test_digest(i));
3479            }
3480
3481            // Append more items after pruning
3482            for i in 10..15u64 {
3483                let pos = journal
3484                    .append(&test_digest(i))
3485                    .await
3486                    .expect("failed to append");
3487                assert_eq!(pos, i);
3488
3489                // Verify we can read from size() - 1
3490                let value = journal
3491                    .read(journal.size() - 1)
3492                    .await
3493                    .expect("failed to read");
3494                assert_eq!(value, test_digest(i));
3495            }
3496
3497            journal.sync().await.expect("failed to sync");
3498            drop(journal);
3499
3500            // === Test 4: Restart persistence with single item per blob ===
3501            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
3502                .await
3503                .expect("failed to re-initialize journal");
3504
3505            // Verify size is preserved
3506            assert_eq!(journal.size(), 15);
3507
3508            // Verify bounds.start is preserved
3509            assert_eq!(journal.bounds().start, 5);
3510
3511            // Reading from size() - 1 should work after restart
3512            let value = journal
3513                .read(journal.size() - 1)
3514                .await
3515                .expect("failed to read");
3516            assert_eq!(value, test_digest(14));
3517
3518            // Reading all retained positions should work
3519            for i in 5..15u64 {
3520                assert_eq!(journal.read(i).await.unwrap(), test_digest(i));
3521            }
3522
3523            journal.destroy().await.expect("failed to destroy journal");
3524
3525            // === Test 5: Restart after pruning with non-zero index ===
3526            // Fresh journal for this test
3527            let mut journal = Journal::init(context.child("third"), cfg.clone())
3528                .await
3529                .expect("failed to initialize journal");
3530
3531            // Append 10 items (positions 0-9)
3532            for i in 0..10u64 {
3533                journal.append(&test_digest(i + 100)).await.unwrap();
3534            }
3535
3536            // Prune to position 5 (removes positions 0-4)
3537            journal.prune(5).await.unwrap();
3538            let bounds = journal.bounds();
3539            assert_eq!(bounds.end, 10);
3540            assert_eq!(bounds.start, 5);
3541
3542            // Sync and restart
3543            journal.sync().await.unwrap();
3544            drop(journal);
3545
3546            // Re-open journal
3547            let journal = Journal::<_, Digest>::init(context.child("fourth"), cfg.clone())
3548                .await
3549                .expect("failed to re-initialize journal");
3550
3551            // Verify state after restart
3552            let bounds = journal.bounds();
3553            assert_eq!(bounds.end, 10);
3554            assert_eq!(bounds.start, 5);
3555
3556            // Reading from size() - 1 (position 9) should work
3557            let value = journal.read(journal.size() - 1).await.unwrap();
3558            assert_eq!(value, test_digest(109));
3559
3560            // Verify all retained positions (5-9) work
3561            for i in 5..10u64 {
3562                assert_eq!(journal.read(i).await.unwrap(), test_digest(i + 100));
3563            }
3564
3565            journal.destroy().await.expect("failed to destroy journal");
3566
3567            // === Test 6: Prune all items (edge case) ===
3568            let mut journal = Journal::init(context.child("storage"), cfg.clone())
3569                .await
3570                .expect("failed to initialize journal");
3571
3572            for i in 0..5u64 {
3573                journal.append(&test_digest(i + 200)).await.unwrap();
3574            }
3575            journal.sync().await.unwrap();
3576
3577            // Prune all items
3578            journal.prune(5).await.unwrap();
3579            let bounds = journal.bounds();
3580            assert_eq!(bounds.end, 5); // Size unchanged
3581            assert!(bounds.is_empty()); // All pruned
3582
3583            // size() - 1 = 4, but position 4 is pruned
3584            let result = journal.read(journal.size() - 1).await;
3585            assert!(matches!(result, Err(Error::ItemPruned(4))));
3586
3587            // After appending, reading works again
3588            journal.append(&test_digest(205)).await.unwrap();
3589            assert_eq!(journal.bounds().start, 5);
3590            assert_eq!(
3591                journal.read(journal.size() - 1).await.unwrap(),
3592                test_digest(205)
3593            );
3594
3595            journal.destroy().await.expect("failed to destroy journal");
3596        });
3597    }
3598
3599    #[test_traced]
3600    fn test_fixed_journal_init_at_size_zero() {
3601        let executor = deterministic::Runner::default();
3602        executor.start(|context| async move {
3603            let cfg = test_cfg(&context, NZU64!(5));
3604            let mut journal =
3605                Journal::<_, Digest>::init_at_size(context.child("storage"), cfg.clone(), 0)
3606                    .await
3607                    .unwrap();
3608
3609            let bounds = journal.bounds();
3610            assert_eq!(bounds.end, 0);
3611            assert!(bounds.is_empty());
3612
3613            // Next append should get position 0
3614            let pos = journal.append(&test_digest(100)).await.unwrap();
3615            assert_eq!(pos, 0);
3616            assert_eq!(journal.size(), 1);
3617            assert_eq!(journal.read(0).await.unwrap(), test_digest(100));
3618
3619            journal.destroy().await.unwrap();
3620        });
3621    }
3622
3623    #[test_traced]
3624    fn test_fixed_journal_init_at_max_size_rejected() {
3625        let executor = deterministic::Runner::default();
3626        executor.start(|context| async move {
3627            let mut cfg = test_cfg(&context, NZU64!(1));
3628            cfg.partition = "max-size-rejected".into();
3629
3630            // A journal sized at `u64::MAX` could never accept an append, so init rejects it.
3631            assert!(matches!(
3632                Journal::<_, Digest>::init_at_size(context.child("storage"), cfg, u64::MAX).await,
3633                Err(Error::SizeOverflow)
3634            ));
3635        });
3636    }
3637
3638    #[test_traced]
3639    fn test_fixed_journal_append_size_overflow() {
3640        let executor = deterministic::Runner::default();
3641        executor.start(|context| async move {
3642            let mut cfg = test_cfg(&context, NZU64!(1));
3643            cfg.partition = "append-size-overflow".into();
3644
3645            // Initialize one item shy of the maximum size.
3646            let mut journal =
3647                Journal::<_, Digest>::init_at_size(context.child("near_max"), cfg, u64::MAX - 1)
3648                    .await
3649                    .unwrap();
3650
3651            // The first append fills the last representable position.
3652            assert_eq!(journal.append(&test_digest(7)).await.unwrap(), u64::MAX - 1);
3653            assert_eq!(journal.size(), u64::MAX);
3654
3655            // The next append would overflow the size; it must return a recoverable error
3656            // rather than panicking.
3657            assert!(matches!(
3658                journal.append(&test_digest(8)).await,
3659                Err(Error::SizeOverflow)
3660            ));
3661
3662            journal.destroy().await.unwrap();
3663        });
3664    }
3665
3666    #[test_traced]
3667    fn test_fixed_journal_replay_near_max_size() {
3668        let executor = deterministic::Runner::default();
3669        executor.start(|context| async move {
3670            let mut cfg = test_cfg(&context, NZU64!(10));
3671            cfg.partition = "replay-near-max-size".into();
3672
3673            let mut journal =
3674                Journal::<_, Digest>::init_at_size(context.child("near_max"), cfg, u64::MAX - 1)
3675                    .await
3676                    .unwrap();
3677            let expected = test_digest(7);
3678            assert_eq!(journal.append(&expected).await.unwrap(), u64::MAX - 1);
3679
3680            {
3681                let reader = journal.snapshot().await.unwrap();
3682                let stream = reader.replay(u64::MAX - 1, NZUsize!(1024)).await.unwrap();
3683                pin_mut!(stream);
3684                let (pos, item) = stream.next().await.unwrap().unwrap();
3685                assert_eq!(pos, u64::MAX - 1);
3686                assert_eq!(item, expected);
3687                assert!(stream.next().await.is_none());
3688            }
3689
3690            journal.destroy().await.unwrap();
3691        });
3692    }
3693
3694    #[test_traced]
3695    fn test_fixed_journal_init_at_size_blob_boundary() {
3696        let executor = deterministic::Runner::default();
3697        executor.start(|context| async move {
3698            let cfg = test_cfg(&context, NZU64!(5));
3699
3700            // Initialize at position 10 (exactly at blob 2 boundary with items_per_blob=5)
3701            let mut journal =
3702                Journal::<_, Digest>::init_at_size(context.child("storage"), cfg.clone(), 10)
3703                    .await
3704                    .unwrap();
3705
3706            let bounds = journal.bounds();
3707            assert_eq!(bounds.end, 10);
3708            assert!(bounds.is_empty());
3709
3710            // Next append should get position 10
3711            let pos = journal.append(&test_digest(1000)).await.unwrap();
3712            assert_eq!(pos, 10);
3713            assert_eq!(journal.size(), 11);
3714            assert_eq!(journal.read(10).await.unwrap(), test_digest(1000));
3715
3716            // Can continue appending
3717            let pos = journal.append(&test_digest(1001)).await.unwrap();
3718            assert_eq!(pos, 11);
3719            assert_eq!(journal.read(11).await.unwrap(), test_digest(1001));
3720
3721            journal.destroy().await.unwrap();
3722        });
3723    }
3724
3725    #[test_traced]
3726    fn test_fixed_journal_init_at_size_mid_blob() {
3727        let executor = deterministic::Runner::default();
3728        executor.start(|context| async move {
3729            let cfg = test_cfg(&context, NZU64!(5));
3730
3731            // Initialize at position 7 (middle of blob 1 with items_per_blob=5)
3732            let mut journal =
3733                Journal::<_, Digest>::init_at_size(context.child("storage"), cfg.clone(), 7)
3734                    .await
3735                    .unwrap();
3736
3737            let bounds = journal.bounds();
3738            assert_eq!(bounds.end, 7);
3739            // No data exists yet after init_at_size
3740            assert!(bounds.is_empty());
3741
3742            // Reading before bounds.start should return ItemPruned
3743            assert!(matches!(journal.read(5).await, Err(Error::ItemPruned(5))));
3744            assert!(matches!(journal.read(6).await, Err(Error::ItemPruned(6))));
3745
3746            // Next append should get position 7
3747            let pos = journal.append(&test_digest(700)).await.unwrap();
3748            assert_eq!(pos, 7);
3749            assert_eq!(journal.size(), 8);
3750            assert_eq!(journal.read(7).await.unwrap(), test_digest(700));
3751            // Now bounds.start should be 7 (first data position)
3752            assert_eq!(journal.bounds().start, 7);
3753
3754            journal.destroy().await.unwrap();
3755        });
3756    }
3757
3758    #[test_traced]
3759    fn test_fixed_journal_append_many_after_mid_blob_start() {
3760        let executor = deterministic::Runner::default();
3761        executor.start(|context| async move {
3762            let cfg = test_cfg(&context, NZU64!(100));
3763            let mut journal =
3764                Journal::<_, Digest>::init_at_size(context.child("first"), cfg.clone(), 150)
3765                    .await
3766                    .unwrap();
3767
3768            let items: Vec<_> = (0..100u64).map(|i| test_digest(1500 + i)).collect();
3769            let last = journal.append_many(Many::Flat(&items)).await.unwrap();
3770            assert_eq!(last, 249);
3771            assert_eq!(journal.bounds(), 150..250);
3772
3773            for (position, index) in [(150, 0), (199, 49), (200, 50), (249, 99)] {
3774                assert_eq!(
3775                    journal.read(position).await.unwrap(),
3776                    items[index],
3777                    "item at position {position} did not match"
3778                );
3779            }
3780
3781            journal.sync().await.unwrap();
3782            drop(journal);
3783
3784            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
3785                .await
3786                .unwrap();
3787            assert_eq!(journal.bounds(), 150..250);
3788            for (position, index) in [(150, 0), (199, 49), (200, 50), (249, 99)] {
3789                assert_eq!(
3790                    journal.read(position).await.unwrap(),
3791                    items[index],
3792                    "item at position {position} did not match after reopen"
3793                );
3794            }
3795
3796            journal.destroy().await.unwrap();
3797        });
3798    }
3799
3800    #[test_traced]
3801    fn test_fixed_journal_init_at_size_persistence() {
3802        let executor = deterministic::Runner::default();
3803        executor.start(|context| async move {
3804            let cfg = test_cfg(&context, NZU64!(5));
3805
3806            // Initialize at position 15
3807            let mut journal =
3808                Journal::<_, Digest>::init_at_size(context.child("first"), cfg.clone(), 15)
3809                    .await
3810                    .unwrap();
3811
3812            // Append some items
3813            for i in 0..5u64 {
3814                let pos = journal.append(&test_digest(1500 + i)).await.unwrap();
3815                assert_eq!(pos, 15 + i);
3816            }
3817
3818            assert_eq!(journal.size(), 20);
3819
3820            // Sync and reopen
3821            journal.sync().await.unwrap();
3822            drop(journal);
3823
3824            let mut journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
3825                .await
3826                .unwrap();
3827
3828            // Size and data should be preserved
3829            let bounds = journal.bounds();
3830            assert_eq!(bounds.end, 20);
3831            assert_eq!(bounds.start, 15);
3832
3833            // Verify data
3834            for i in 0..5u64 {
3835                assert_eq!(journal.read(15 + i).await.unwrap(), test_digest(1500 + i));
3836            }
3837
3838            // Can continue appending
3839            let pos = journal.append(&test_digest(9999)).await.unwrap();
3840            assert_eq!(pos, 20);
3841            assert_eq!(journal.read(20).await.unwrap(), test_digest(9999));
3842
3843            journal.destroy().await.unwrap();
3844        });
3845    }
3846
3847    #[test_traced]
3848    fn test_fixed_journal_init_at_size_persistence_without_data() {
3849        let executor = deterministic::Runner::default();
3850        executor.start(|context| async move {
3851            let cfg = test_cfg(&context, NZU64!(5));
3852
3853            // Initialize at position 15
3854            let journal =
3855                Journal::<_, Digest>::init_at_size(context.child("first"), cfg.clone(), 15)
3856                    .await
3857                    .unwrap();
3858
3859            let bounds = journal.bounds();
3860            assert_eq!(bounds.end, 15);
3861            assert!(bounds.is_empty());
3862
3863            // Drop without writing any data
3864            drop(journal);
3865
3866            // Reopen and verify size persisted
3867            let mut journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
3868                .await
3869                .unwrap();
3870
3871            let bounds = journal.bounds();
3872            assert_eq!(bounds.end, 15);
3873            assert!(bounds.is_empty());
3874
3875            // Can append starting at position 15
3876            let pos = journal.append(&test_digest(1500)).await.unwrap();
3877            assert_eq!(pos, 15);
3878            assert_eq!(journal.read(15).await.unwrap(), test_digest(1500));
3879
3880            journal.destroy().await.unwrap();
3881        });
3882    }
3883
3884    #[test_traced]
3885    fn test_fixed_journal_init_at_size_large_offset() {
3886        let executor = deterministic::Runner::default();
3887        executor.start(|context| async move {
3888            let cfg = test_cfg(&context, NZU64!(5));
3889
3890            // Initialize at a large position (position 1000)
3891            let mut journal =
3892                Journal::<_, Digest>::init_at_size(context.child("storage"), cfg.clone(), 1000)
3893                    .await
3894                    .unwrap();
3895
3896            let bounds = journal.bounds();
3897            assert_eq!(bounds.end, 1000);
3898            assert!(bounds.is_empty());
3899
3900            // Next append should get position 1000
3901            let pos = journal.append(&test_digest(100000)).await.unwrap();
3902            assert_eq!(pos, 1000);
3903            assert_eq!(journal.read(1000).await.unwrap(), test_digest(100000));
3904
3905            journal.destroy().await.unwrap();
3906        });
3907    }
3908
3909    #[test_traced]
3910    fn test_fixed_journal_init_at_size_prune_and_append() {
3911        let executor = deterministic::Runner::default();
3912        executor.start(|context| async move {
3913            let cfg = test_cfg(&context, NZU64!(5));
3914
3915            // Initialize at position 20
3916            let mut journal =
3917                Journal::<_, Digest>::init_at_size(context.child("storage"), cfg.clone(), 20)
3918                    .await
3919                    .unwrap();
3920
3921            // Append items 20-29
3922            for i in 0..10u64 {
3923                journal.append(&test_digest(2000 + i)).await.unwrap();
3924            }
3925
3926            assert_eq!(journal.size(), 30);
3927
3928            // Prune to position 25
3929            journal.prune(25).await.unwrap();
3930
3931            let bounds = journal.bounds();
3932            assert_eq!(bounds.end, 30);
3933            assert_eq!(bounds.start, 25);
3934
3935            // Verify remaining items are readable
3936            for i in 25..30u64 {
3937                assert_eq!(journal.read(i).await.unwrap(), test_digest(2000 + (i - 20)));
3938            }
3939
3940            // Continue appending
3941            let pos = journal.append(&test_digest(3000)).await.unwrap();
3942            assert_eq!(pos, 30);
3943
3944            journal.destroy().await.unwrap();
3945        });
3946    }
3947
3948    #[test_traced]
3949    fn test_fixed_journal_clear_to_size() {
3950        let executor = deterministic::Runner::default();
3951        executor.start(|context| async move {
3952            let cfg = test_cfg(&context, NZU64!(10));
3953            let mut journal = Journal::init(context.child("journal"), cfg.clone())
3954                .await
3955                .expect("failed to initialize journal");
3956
3957            // Append 25 items (positions 0-24, spanning 3 blobs)
3958            for i in 0..25u64 {
3959                journal.append(&test_digest(i)).await.unwrap();
3960            }
3961            assert_eq!(journal.size(), 25);
3962            journal.sync().await.unwrap();
3963
3964            // Clear to position 100, effectively resetting the journal
3965            journal.clear_to_size(100).await.unwrap();
3966            assert_eq!(journal.size(), 100);
3967
3968            // Old positions should fail
3969            for i in 0..25 {
3970                assert!(matches!(journal.read(i).await, Err(Error::ItemPruned(_))));
3971            }
3972
3973            // Verify size persists after restart without writing any data
3974            drop(journal);
3975            let mut journal =
3976                Journal::<_, Digest>::init(context.child("journal_after_clear"), cfg.clone())
3977                    .await
3978                    .expect("failed to re-initialize journal after clear");
3979            assert_eq!(journal.size(), 100);
3980
3981            // Append new data starting at position 100
3982            for i in 100..105u64 {
3983                let pos = journal.append(&test_digest(i)).await.unwrap();
3984                assert_eq!(pos, i);
3985            }
3986            assert_eq!(journal.size(), 105);
3987
3988            // New positions should be readable
3989            for i in 100..105u64 {
3990                assert_eq!(journal.read(i).await.unwrap(), test_digest(i));
3991            }
3992
3993            // Sync and re-init to verify persistence
3994            journal.sync().await.unwrap();
3995            drop(journal);
3996
3997            let journal = Journal::<_, Digest>::init(context.child("journal_reopened"), cfg)
3998                .await
3999                .expect("failed to re-initialize journal");
4000
4001            assert_eq!(journal.size(), 105);
4002            for i in 100..105u64 {
4003                assert_eq!(journal.read(i).await.unwrap(), test_digest(i));
4004            }
4005
4006            journal.destroy().await.unwrap();
4007        });
4008    }
4009
4010    #[test_traced]
4011    fn test_fixed_journal_clear_to_size_rejects_max() {
4012        // `clear_to_size` must reject `u64::MAX` like `init_at_size`: such a journal could never
4013        // accept an append.
4014        let executor = deterministic::Runner::default();
4015        executor.start(|context| async move {
4016            let cfg = test_cfg(&context, NZU64!(10));
4017            let mut journal = Journal::<_, Digest>::init(context.child("journal"), cfg)
4018                .await
4019                .unwrap();
4020            assert!(matches!(
4021                journal.clear_to_size(u64::MAX).await,
4022                Err(Error::SizeOverflow)
4023            ));
4024            assert!(matches!(
4025                journal.stage_clear_intent(u64::MAX).await,
4026                Err(Error::SizeOverflow)
4027            ));
4028            journal.destroy().await.unwrap();
4029        });
4030    }
4031
4032    #[test_traced]
4033    fn test_fixed_journal_sync_crash_meta_none_boundary_aligned() {
4034        // Old meta = None (aligned), new boundary = aligned.
4035        let executor = deterministic::Runner::default();
4036        executor.start(|context| async move {
4037            let cfg = test_cfg(&context, NZU64!(5));
4038            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
4039                .await
4040                .unwrap();
4041
4042            for i in 0..5u64 {
4043                journal.append(&test_digest(i)).await.unwrap();
4044            }
4045            journal.commit().await.unwrap();
4046            drop(journal);
4047
4048            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
4049                .await
4050                .unwrap();
4051            let bounds = journal.bounds();
4052            assert_eq!(bounds.start, 0);
4053            assert_eq!(bounds.end, 5);
4054            journal.destroy().await.unwrap();
4055        });
4056    }
4057
4058    #[test_traced]
4059    fn test_fixed_journal_missing_metadata_with_short_blob_is_corruption() {
4060        // Clearing all metadata leaves no watermark. Recovery falls back to the blob boundary
4061        // and finds a short non-tail blob, violating the legacy rollover-sync invariant.
4062        let executor = deterministic::Runner::default();
4063        executor.start(|context| async move {
4064            let cfg = test_cfg(&context, NZU64!(5));
4065            let mut journal =
4066                Journal::<_, Digest>::init_at_size(context.child("first"), cfg.clone(), 7)
4067                    .await
4068                    .unwrap();
4069            for i in 0..3u64 {
4070                journal.append(&test_digest(i)).await.unwrap();
4071            }
4072            journal.sync().await.unwrap();
4073
4074            // Simulate metadata deletion (corruption).
4075            journal.checkpoint.clear();
4076            journal.checkpoint.sync().await.unwrap();
4077            drop(journal);
4078
4079            let result = Journal::<_, Digest>::init(context.child("second"), cfg.clone()).await;
4080            assert!(matches!(result, Err(Error::Corruption(_))));
4081        });
4082    }
4083
4084    #[test_traced]
4085    fn test_fixed_journal_sync_crash_meta_mid_boundary_unchanged() {
4086        // Old meta = Some(mid), new boundary = mid-blob (same value).
4087        let executor = deterministic::Runner::default();
4088        executor.start(|context| async move {
4089            let cfg = test_cfg(&context, NZU64!(5));
4090            let mut journal =
4091                Journal::<_, Digest>::init_at_size(context.child("first"), cfg.clone(), 7)
4092                    .await
4093                    .unwrap();
4094            for i in 0..3u64 {
4095                journal.append(&test_digest(i)).await.unwrap();
4096            }
4097            journal.commit().await.unwrap();
4098            drop(journal);
4099
4100            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
4101                .await
4102                .unwrap();
4103            let bounds = journal.bounds();
4104            assert_eq!(bounds.start, 7);
4105            assert_eq!(bounds.end, 10);
4106            journal.destroy().await.unwrap();
4107        });
4108    }
4109    #[test_traced]
4110    fn test_fixed_journal_sync_crash_meta_mid_to_aligned_becomes_stale() {
4111        // Old meta = Some(mid), new boundary = aligned.
4112        let executor = deterministic::Runner::default();
4113        executor.start(|context| async move {
4114            let cfg = test_cfg(&context, NZU64!(5));
4115            let mut journal =
4116                Journal::<_, Digest>::init_at_size(context.child("first"), cfg.clone(), 7)
4117                    .await
4118                    .unwrap();
4119            for i in 0..10u64 {
4120                journal.append(&test_digest(i)).await.unwrap();
4121            }
4122            assert_eq!(journal.size(), 17);
4123            journal.prune(10).await.unwrap();
4124
4125            journal.commit().await.unwrap();
4126            drop(journal);
4127
4128            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
4129                .await
4130                .unwrap();
4131            let bounds = journal.bounds();
4132            assert_eq!(bounds.start, 10);
4133            assert_eq!(bounds.end, 17);
4134            journal.destroy().await.unwrap();
4135        });
4136    }
4137
4138    #[test_traced]
4139    fn test_fixed_journal_prune_does_not_move_boundary_backwards() {
4140        // Pruning to a position earlier than pruning_boundary (within the same blob)
4141        // should not move the boundary backwards.
4142        let executor = deterministic::Runner::default();
4143        executor.start(|context| async move {
4144            let cfg = test_cfg(&context, NZU64!(5));
4145            // init_at_size(7) sets pruning_boundary = 7 (mid-blob in blob 1)
4146            let mut journal =
4147                Journal::<_, Digest>::init_at_size(context.child("first"), cfg.clone(), 7)
4148                    .await
4149                    .unwrap();
4150            // Append 5 items at positions 7-11, filling blob 1 and part of blob 2
4151            for i in 0..5u64 {
4152                journal.append(&test_digest(i)).await.unwrap();
4153            }
4154            // Prune to position 5 (blob 1 start) should NOT move boundary back from 7 to 5
4155            journal.prune(5).await.unwrap();
4156            assert_eq!(journal.bounds().start, 7);
4157            journal.destroy().await.unwrap();
4158        });
4159    }
4160
4161    /// Prune flushes every dirty blob and clears the dirty state, so a commit issued right
4162    /// after must not attempt to sync the removed blobs.
4163    #[test_traced]
4164    fn test_fixed_journal_commit_after_prune() {
4165        let executor = deterministic::Runner::default();
4166        executor.start(|context| async move {
4167            let cfg = test_cfg(&context, NZU64!(5));
4168            let mut journal = Journal::<_, Digest>::init(context.child("journal"), cfg.clone())
4169                .await
4170                .unwrap();
4171
4172            for i in 0..12 {
4173                journal.append(&test_digest(i)).await.unwrap();
4174            }
4175
4176            journal.prune(5).await.unwrap();
4177            journal
4178                .commit()
4179                .await
4180                .expect("commit should not try to sync pruned blobs");
4181            assert_eq!(journal.bounds(), 5..12);
4182            journal.destroy().await.unwrap();
4183        });
4184    }
4185
4186    #[test_traced]
4187    fn test_fixed_journal_replay_after_init_at_size_spanning_blobs() {
4188        // Test replay when first blob begins mid-blob: init_at_size creates a journal
4189        // where pruning_boundary is mid-blob, then we append across multiple blobs.
4190        let executor = deterministic::Runner::default();
4191        executor.start(|context| async move {
4192            let cfg = test_cfg(&context, NZU64!(5));
4193
4194            // Initialize at position 7 (mid-blob with items_per_blob=5)
4195            // Blob 1 (positions 5-9) begins mid-blob: only positions 7, 8, 9 have data
4196            let mut journal =
4197                Journal::<_, Digest>::init_at_size(context.child("storage"), cfg.clone(), 7)
4198                    .await
4199                    .unwrap();
4200
4201            // Append 13 items (positions 7-19), spanning blobs 1, 2, 3
4202            for i in 0..13u64 {
4203                let pos = journal.append(&test_digest(100 + i)).await.unwrap();
4204                assert_eq!(pos, 7 + i);
4205            }
4206            assert_eq!(journal.size(), 20);
4207            journal.sync().await.unwrap();
4208
4209            // Replay from pruning_boundary
4210            {
4211                let reader = journal.snapshot().await.unwrap();
4212                let stream = reader
4213                    .replay(7, NZUsize!(1024))
4214                    .await
4215                    .expect("failed to replay");
4216                pin_mut!(stream);
4217                let mut items: Vec<(u64, Digest)> = Vec::new();
4218                while let Some(result) = stream.next().await {
4219                    items.push(result.expect("replay item failed"));
4220                }
4221
4222                // Should get all 13 items with correct logical positions
4223                assert_eq!(items.len(), 13);
4224                for (i, (pos, item)) in items.iter().enumerate() {
4225                    assert_eq!(*pos, 7 + i as u64);
4226                    assert_eq!(*item, test_digest(100 + i as u64));
4227                }
4228            }
4229
4230            // Replay from mid-stream (position 12)
4231            {
4232                let reader = journal.snapshot().await.unwrap();
4233                let stream = reader
4234                    .replay(12, NZUsize!(1024))
4235                    .await
4236                    .expect("failed to replay from mid-stream");
4237                pin_mut!(stream);
4238                let mut items: Vec<(u64, Digest)> = Vec::new();
4239                while let Some(result) = stream.next().await {
4240                    items.push(result.expect("replay item failed"));
4241                }
4242
4243                // Should get items from position 12 onwards
4244                assert_eq!(items.len(), 8);
4245                for (i, (pos, item)) in items.iter().enumerate() {
4246                    assert_eq!(*pos, 12 + i as u64);
4247                    assert_eq!(*item, test_digest(100 + 5 + i as u64));
4248                }
4249            }
4250
4251            journal.destroy().await.unwrap();
4252        });
4253    }
4254
4255    #[test_traced]
4256    fn test_fixed_journal_rewind_error_before_bounds_start() {
4257        // Test that rewind returns error when trying to rewind before bounds.start
4258        let executor = deterministic::Runner::default();
4259        executor.start(|context| async move {
4260            let cfg = test_cfg(&context, NZU64!(5));
4261
4262            let mut journal =
4263                Journal::<_, Digest>::init_at_size(context.child("storage"), cfg.clone(), 10)
4264                    .await
4265                    .unwrap();
4266
4267            // Append a few items (positions 10, 11, 12)
4268            for i in 0..3u64 {
4269                journal.append(&test_digest(i)).await.unwrap();
4270            }
4271            assert_eq!(journal.size(), 13);
4272
4273            // Rewind to position 11 should work
4274            journal.rewind(11).await.unwrap();
4275            assert_eq!(journal.size(), 11);
4276
4277            // Rewind to position 10 (pruning_boundary) should work
4278            journal.rewind(10).await.unwrap();
4279            assert_eq!(journal.size(), 10);
4280
4281            // Rewind to before pruning_boundary should fail
4282            let result = journal.rewind(9).await;
4283            assert!(matches!(result, Err(Error::ItemPruned(9))));
4284
4285            journal.destroy().await.unwrap();
4286        });
4287    }
4288
4289    #[test_traced]
4290    fn test_fixed_journal_init_at_size_crash_scenarios() {
4291        let executor = deterministic::Runner::default();
4292        executor.start(|context| async move {
4293            let cfg = test_cfg(&context, NZU64!(5));
4294
4295            // Setup: Create a journal with some data and mid-blob metadata
4296            let mut journal =
4297                Journal::<_, Digest>::init_at_size(context.child("first"), cfg.clone(), 7)
4298                    .await
4299                    .unwrap();
4300            for i in 0..5u64 {
4301                journal.append(&test_digest(i)).await.unwrap();
4302            }
4303            journal.sync().await.unwrap();
4304            drop(journal);
4305
4306            // Crash Scenario 1: after clear intent is synced and blobs are removed, but before
4307            // the new tail blob is created.
4308            let blob_part = blob_partition(&cfg);
4309            let mut checkpoint = Checkpoint::open(context.child("intent_meta"), &cfg.partition)
4310                .await
4311                .unwrap();
4312            checkpoint.set_clear_target(12);
4313            checkpoint.sync().await.unwrap();
4314            drop(checkpoint);
4315            context.remove(&blob_part, None).await.unwrap();
4316
4317            // Recovery should complete the interrupted init_at_size(12).
4318            let journal = Journal::<_, Digest>::init(
4319                context.child("crash").with_attribute("index", 1),
4320                cfg.clone(),
4321            )
4322            .await
4323            .expect("init failed after clear crash");
4324            let bounds = journal.bounds();
4325            assert_eq!(bounds.end, 12);
4326            assert_eq!(bounds.start, 12);
4327            drop(journal);
4328
4329            // Restore metadata for next scenario (it might have been removed by init)
4330            let mut checkpoint = Checkpoint::open(context.child("restore_meta"), &cfg.partition)
4331                .await
4332                .unwrap();
4333            checkpoint.set_boundary_hint(7);
4334            checkpoint.set_clear_target(2);
4335            checkpoint.sync().await.unwrap();
4336            drop(checkpoint);
4337
4338            // Crash Scenario 2: after the new tail blob is created, but before final metadata
4339            // replaces the clear intent.
4340            let (blob, _) = context.open(&blob_part, &0u64.to_be_bytes()).await.unwrap();
4341            blob.sync().await.unwrap(); // Ensure it exists
4342            drop(blob);
4343
4344            // Recovery should complete the interrupted init_at_size(2).
4345            let journal = Journal::<_, Digest>::init(
4346                context.child("crash").with_attribute("index", 2),
4347                cfg.clone(),
4348            )
4349            .await
4350            .expect("init failed after create crash");
4351
4352            let bounds = journal.bounds();
4353            assert_eq!(bounds.start, 2);
4354            assert_eq!(bounds.end, 2);
4355            journal.destroy().await.unwrap();
4356        });
4357    }
4358
4359    #[test_traced]
4360    fn test_fixed_journal_clear_to_size_crash_scenarios() {
4361        let executor = deterministic::Runner::default();
4362        executor.start(|context| async move {
4363            let cfg = test_cfg(&context, NZU64!(5));
4364
4365            // Setup: Init at 12 (Blob 2, offset 2)
4366            // Metadata = 12
4367            let mut journal =
4368                Journal::<_, Digest>::init_at_size(context.child("first"), cfg.clone(), 12)
4369                    .await
4370                    .unwrap();
4371            journal.sync().await.unwrap();
4372            drop(journal);
4373
4374            // Crash Scenario: clear_to_size(2) after the intent is synced and blob 0 is created,
4375            // but before final metadata replaces the clear intent.
4376
4377            let blob_part = blob_partition(&cfg);
4378            let mut checkpoint = Checkpoint::open(context.child("meta"), &cfg.partition)
4379                .await
4380                .unwrap();
4381            checkpoint.set_clear_target(2);
4382            checkpoint.sync().await.unwrap();
4383            drop(checkpoint);
4384
4385            context.remove(&blob_part, None).await.unwrap();
4386
4387            let (blob, _) = context.open(&blob_part, &0u64.to_be_bytes()).await.unwrap();
4388            blob.sync().await.unwrap();
4389
4390            let journal = Journal::<_, Digest>::init(context.child("crash_clear"), cfg.clone())
4391                .await
4392                .expect("init failed after clear_to_size crash");
4393
4394            let bounds = journal.bounds();
4395            assert_eq!(bounds.start, 2);
4396            assert_eq!(bounds.end, 2);
4397            journal.destroy().await.unwrap();
4398        });
4399    }
4400
4401    #[test_traced]
4402    fn test_fixed_journal_clear_to_size_crash_after_intent_before_blobs() {
4403        let executor = deterministic::Runner::default();
4404        executor.start(|context| async move {
4405            let cfg = test_cfg(&context, NZU64!(5));
4406            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
4407                .await
4408                .unwrap();
4409            for i in 0..12u64 {
4410                journal.append(&test_digest(i)).await.unwrap();
4411            }
4412            journal.sync().await.unwrap();
4413
4414            let mut checkpoint = Checkpoint::open(context.child("meta"), &cfg.partition)
4415                .await
4416                .unwrap();
4417            checkpoint.set_clear_target(100);
4418            checkpoint.sync().await.unwrap();
4419            drop(checkpoint);
4420            drop(journal);
4421
4422            let mut journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
4423                .await
4424                .expect("init failed after clear intent crash");
4425            assert_eq!(journal.bounds(), 100..100);
4426            let pos = journal.append(&test_digest(100)).await.unwrap();
4427            assert_eq!(pos, 100);
4428            journal.destroy().await.unwrap();
4429        });
4430    }
4431
4432    #[test_traced]
4433    fn test_fixed_journal_clear_intent_skips_corrupt_stale_blobs() {
4434        let executor = deterministic::Runner::default();
4435        executor.start(|context| async move {
4436            let cfg = test_cfg(&context, NZU64!(5));
4437            let blob_part = blob_partition(&cfg);
4438            let mut checkpoint = Checkpoint::open(context.child("meta"), &cfg.partition)
4439                .await
4440                .unwrap();
4441            checkpoint.set_clear_target(12);
4442            checkpoint.sync().await.unwrap();
4443            drop(checkpoint);
4444
4445            // This name would fail `Partition::open_all` if init tried to parse stale blobs before
4446            // honoring the clear intent.
4447            let (blob, _) = context.open(&blob_part, b"not-u64").await.unwrap();
4448            blob.write_at_sync(0, vec![1, 2, 3]).await.unwrap();
4449            drop(blob);
4450
4451            let journal = Journal::<_, Digest>::init(context.child("recover"), cfg.clone())
4452                .await
4453                .expect("clear intent should discard stale corrupt blobs before blob parsing");
4454            assert_eq!(journal.bounds(), 12..12);
4455            assert_eq!(journal.recovery_watermark(), 12);
4456            journal.destroy().await.unwrap();
4457        });
4458    }
4459
4460    #[test_traced]
4461    fn test_fixed_journal_clear_to_size_crash_after_mid_blob_intent_with_old_blobs_present() {
4462        let executor = deterministic::Runner::default();
4463        executor.start(|context| async move {
4464            let cfg = test_cfg(&context, NZU64!(10));
4465            let mut journal =
4466                Journal::<_, Digest>::init_at_size(context.child("first"), cfg.clone(), 10)
4467                    .await
4468                    .unwrap();
4469
4470            for i in 0..6u64 {
4471                let pos = journal.append(&test_digest(i)).await.unwrap();
4472                assert_eq!(pos, 10 + i);
4473            }
4474            journal.sync().await.unwrap();
4475
4476            let mut checkpoint = Checkpoint::open(context.child("meta"), &cfg.partition)
4477                .await
4478                .unwrap();
4479            checkpoint.set_clear_target(15);
4480            checkpoint.sync().await.unwrap();
4481            drop(checkpoint);
4482            drop(journal);
4483
4484            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
4485                .await
4486                .expect("init failed after mid-blob clear intent crash");
4487            assert_eq!(journal.bounds(), 15..15);
4488            drop(journal);
4489
4490            let mut journal = Journal::<_, Digest>::init(context.child("third"), cfg.clone())
4491                .await
4492                .expect("init failed after completing mid-blob clear intent");
4493            assert_eq!(journal.bounds(), 15..15);
4494            assert!(matches!(journal.read(14).await, Err(Error::ItemPruned(14))));
4495            let pos = journal.append(&test_digest(100)).await.unwrap();
4496            assert_eq!(pos, 15);
4497            assert_eq!(journal.read(15).await.unwrap(), test_digest(100));
4498            journal.destroy().await.unwrap();
4499        });
4500    }
4501
4502    #[test_traced]
4503    fn test_fixed_journal_rejects_watermark_with_aligned_empty_tail() {
4504        // Watermark beyond the recovered size with an aligned pruning boundary.
4505        let executor = deterministic::Runner::default();
4506        executor.start(|context| async move {
4507            let cfg = test_cfg(&context, NZU64!(5));
4508
4509            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
4510                .await
4511                .unwrap();
4512            for i in 0..10u64 {
4513                journal.append(&test_digest(i)).await.unwrap();
4514            }
4515            journal.sync().await.unwrap();
4516            drop(journal);
4517
4518            // Remove all blobs and create a single empty blob 1, leaving
4519            // recovery_watermark=10 in metadata.
4520            let blob_part = blob_partition(&cfg);
4521            context.remove(&blob_part, None).await.unwrap();
4522            let (blob, _) = context.open(&blob_part, &1u64.to_be_bytes()).await.unwrap();
4523            blob.sync().await.unwrap();
4524
4525            let result = Journal::<_, Digest>::init(context.child("crash"), cfg.clone()).await;
4526            assert!(matches!(result, Err(Error::Corruption(_))));
4527        });
4528    }
4529
4530    #[test_traced]
4531    fn test_fixed_journal_rejects_far_watermark_with_aligned_empty_tail() {
4532        // Same as above but the watermark is multiple blobs past the empty tail.
4533        let executor = deterministic::Runner::default();
4534        executor.start(|context| async move {
4535            let cfg = test_cfg(&context, NZU64!(5));
4536
4537            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
4538                .await
4539                .unwrap();
4540            for i in 0..10u64 {
4541                journal.append(&test_digest(i)).await.unwrap();
4542            }
4543            journal.sync().await.unwrap();
4544            drop(journal);
4545
4546            // Remove all blobs and create a single empty blob 0, leaving
4547            // recovery_watermark=10 in metadata.
4548            let blob_part = blob_partition(&cfg);
4549            context.remove(&blob_part, None).await.unwrap();
4550            let (blob, _) = context.open(&blob_part, &0u64.to_be_bytes()).await.unwrap();
4551            blob.sync().await.unwrap();
4552
4553            let result = Journal::<_, Digest>::init(context.child("crash"), cfg.clone()).await;
4554            assert!(matches!(result, Err(Error::Corruption(_))));
4555        });
4556    }
4557
4558    #[test_traced]
4559    fn test_read_many_empty() {
4560        let executor = deterministic::Runner::default();
4561        executor.start(|context| async move {
4562            let cfg = test_cfg(&context, NZU64!(10));
4563            let mut journal = Journal::<_, Digest>::init(context.child("j"), cfg)
4564                .await
4565                .unwrap();
4566
4567            let items = journal
4568                .snapshot()
4569                .await
4570                .unwrap()
4571                .read_many(&[])
4572                .await
4573                .unwrap();
4574            assert!(items.is_empty());
4575
4576            journal.destroy().await.unwrap();
4577        });
4578    }
4579
4580    #[test_traced]
4581    fn test_read_many_single_blob() {
4582        // All positions within one blob.
4583        let executor = deterministic::Runner::default();
4584        executor.start(|context| async move {
4585            let cfg = test_cfg(&context, NZU64!(10));
4586            let mut journal = Journal::init(context.child("j"), cfg).await.unwrap();
4587
4588            for i in 0..5u64 {
4589                journal.append(&test_digest(i)).await.unwrap();
4590            }
4591            assert_eq!(journal.size(), 5);
4592
4593            let items = journal
4594                .snapshot()
4595                .await
4596                .unwrap()
4597                .read_many(&[0, 2, 4])
4598                .await
4599                .unwrap();
4600            assert_eq!(items, vec![test_digest(0), test_digest(2), test_digest(4)]);
4601
4602            journal.destroy().await.unwrap();
4603        });
4604    }
4605
4606    #[test_traced]
4607    #[should_panic(expected = "positions must be strictly increasing")]
4608    fn test_read_many_rejects_unsorted_positions() {
4609        let executor = deterministic::Runner::default();
4610        executor.start(|context| async move {
4611            let cfg = test_cfg(&context, NZU64!(10));
4612            let mut journal = Journal::init(context.child("j"), cfg).await.unwrap();
4613            for i in 0..5u64 {
4614                journal.append(&test_digest(i)).await.unwrap();
4615            }
4616
4617            let _ = journal.snapshot().await.unwrap().read_many(&[2, 1]).await;
4618        });
4619    }
4620
4621    #[test_traced]
4622    #[should_panic(expected = "positions must be strictly increasing")]
4623    fn test_read_many_rejects_duplicate_positions() {
4624        // Duplicates are not strictly increasing either.
4625        let executor = deterministic::Runner::default();
4626        executor.start(|context| async move {
4627            let cfg = test_cfg(&context, NZU64!(10));
4628            let mut journal = Journal::init(context.child("j"), cfg).await.unwrap();
4629            for i in 0..5u64 {
4630                journal.append(&test_digest(i)).await.unwrap();
4631            }
4632
4633            let _ = journal.snapshot().await.unwrap().read_many(&[1, 1]).await;
4634        });
4635    }
4636
4637    #[test_traced]
4638    fn test_read_many_across_blobs() {
4639        // Positions spanning multiple blobs (items_per_blob=3).
4640        let executor = deterministic::Runner::default();
4641        executor.start(|context| async move {
4642            let cfg = test_cfg(&context, NZU64!(3));
4643            let mut journal = Journal::init(context.child("j"), cfg).await.unwrap();
4644
4645            for i in 0..9u64 {
4646                journal.append(&test_digest(i)).await.unwrap();
4647            }
4648            assert_eq!(journal.size(), 9);
4649            // Blobs: [0,1,2], [3,4,5], [6,7,8]
4650
4651            let items = journal
4652                .snapshot()
4653                .await
4654                .unwrap()
4655                .read_many(&[1, 4, 7])
4656                .await
4657                .unwrap();
4658            assert_eq!(items, vec![test_digest(1), test_digest(4), test_digest(7)]);
4659
4660            journal.destroy().await.unwrap();
4661        });
4662    }
4663
4664    #[test_traced]
4665    fn test_read_many_after_prune() {
4666        // Read from positions that survive pruning.
4667        let executor = deterministic::Runner::default();
4668        executor.start(|context| async move {
4669            let cfg = test_cfg(&context, NZU64!(3));
4670            let mut journal = Journal::init(context.child("j"), cfg).await.unwrap();
4671
4672            for i in 0..9u64 {
4673                journal.append(&test_digest(i)).await.unwrap();
4674            }
4675            assert_eq!(journal.size(), 9);
4676            journal.sync().await.unwrap();
4677
4678            // Prune first blob [0,1,2].
4679            journal.prune(3).await.unwrap();
4680            assert_eq!(journal.bounds(), 3..9);
4681
4682            let items = journal
4683                .snapshot()
4684                .await
4685                .unwrap()
4686                .read_many(&[3, 5, 8])
4687                .await
4688                .unwrap();
4689            assert_eq!(items, vec![test_digest(3), test_digest(5), test_digest(8)]);
4690
4691            // Pruned position should error.
4692            let err = journal
4693                .snapshot()
4694                .await
4695                .unwrap()
4696                .read_many(&[1])
4697                .await
4698                .unwrap_err();
4699            assert!(matches!(err, Error::ItemPruned(1)));
4700
4701            journal.destroy().await.unwrap();
4702        });
4703    }
4704
4705    #[test_traced]
4706    fn test_read_many_out_of_range() {
4707        let executor = deterministic::Runner::default();
4708        executor.start(|context| async move {
4709            let cfg = test_cfg(&context, NZU64!(10));
4710            let mut journal = Journal::init(context.child("j"), cfg).await.unwrap();
4711
4712            for i in 0..3u64 {
4713                journal.append(&test_digest(i)).await.unwrap();
4714            }
4715            assert_eq!(journal.size(), 3);
4716
4717            let err = journal
4718                .snapshot()
4719                .await
4720                .unwrap()
4721                .read_many(&[0, 5])
4722                .await
4723                .unwrap_err();
4724            assert!(matches!(err, Error::ItemOutOfRange(5)));
4725
4726            journal.destroy().await.unwrap();
4727        });
4728    }
4729
4730    #[test_traced]
4731    fn test_read_many_matches_read() {
4732        // Verify batch read matches individual reads across blobs.
4733        let executor = deterministic::Runner::default();
4734        executor.start(|context| async move {
4735            let cfg = test_cfg(&context, NZU64!(4));
4736            let mut journal = Journal::init(context.child("j"), cfg).await.unwrap();
4737
4738            for i in 0..20u64 {
4739                journal.append(&test_digest(i)).await.unwrap();
4740            }
4741            assert_eq!(journal.size(), 20);
4742            journal.sync().await.unwrap();
4743
4744            let positions: Vec<u64> = (0..20).collect();
4745            let reader = journal.snapshot().await.unwrap();
4746            let batch = reader.read_many(&positions).await.unwrap();
4747
4748            for &pos in &positions {
4749                let single = reader.read(pos).await.unwrap();
4750                assert_eq!(batch[pos as usize], single);
4751            }
4752            drop(reader);
4753
4754            journal.destroy().await.unwrap();
4755        });
4756    }
4757
4758    #[test_traced]
4759    fn test_try_read_many_sync_matches_read_many() {
4760        // Cached positions are served synchronously and match the async batched read.
4761        // Positions that fail validation are misses rather than errors.
4762        let executor = deterministic::Runner::default();
4763        executor.start(|context| async move {
4764            let cfg = test_cfg(&context, NZU64!(4));
4765            let mut journal = Journal::init(context.child("j"), cfg).await.unwrap();
4766
4767            for i in 0..20u64 {
4768                journal.append(&test_digest(i)).await.unwrap();
4769            }
4770            journal.sync().await.unwrap();
4771
4772            let positions: Vec<u64> = (0..20).collect();
4773            let reader = journal.snapshot().await.unwrap();
4774            let expected = reader.read_many(&positions).await.unwrap();
4775
4776            // Every synchronously served item must match the async read. Positions the
4777            // 3-page test cache cannot hold are misses, never wrong values.
4778            let served = reader.try_read_many_sync(&positions);
4779            assert_eq!(served.len(), positions.len());
4780            for (item, expected) in served.iter().zip(&expected) {
4781                if let Some(item) = item {
4782                    assert_eq!(item, expected);
4783                }
4784            }
4785
4786            // The last blob (positions 16..20) spans at most the cache capacity, so after
4787            // warming exactly those positions they are all served synchronously.
4788            let tail: Vec<u64> = (16..20).collect();
4789            reader.read_many(&tail).await.unwrap();
4790            let served = reader.try_read_many_sync(&tail);
4791            for (item, pos) in served.iter().zip(&tail) {
4792                assert_eq!(
4793                    item.as_ref().expect("warmed position is served"),
4794                    &expected[*pos as usize]
4795                );
4796            }
4797
4798            // A long-evicted position is a miss.
4799            assert!(reader.try_read_many_sync(&[0])[0].is_none());
4800
4801            // An out-of-range position is a miss, not an error, and does not poison the
4802            // valid position grouped before it.
4803            let served = reader.try_read_many_sync(&[19, 20]);
4804            assert!(served[0].is_some());
4805            assert!(served[1].is_none());
4806            drop(served);
4807            drop(reader);
4808
4809            // After a rewind the journal's end is not blob-aligned, so an out-of-range
4810            // position can share a blob with a valid one. Validation trims the batch
4811            // instead of poisoning the shared group.
4812            journal.rewind(18).await.unwrap();
4813            let reader = journal.snapshot().await.unwrap();
4814            reader.read_many(&[17]).await.unwrap();
4815            let served = reader.try_read_many_sync(&[17, 18]);
4816            assert!(served[0].is_some());
4817            assert!(served[1].is_none());
4818            drop(served);
4819            drop(reader);
4820
4821            // A pruned position is trimmed from the prefix rather than reaching offset
4822            // derivation, and the valid remainder is still served.
4823            journal.prune(8).await.unwrap();
4824            let reader = journal.snapshot().await.unwrap();
4825            reader.read_many(&[9]).await.unwrap();
4826            let served = reader.try_read_many_sync(&[3, 9]);
4827            assert!(served[0].is_none());
4828            assert!(served[1].is_some());
4829            drop(served);
4830            drop(reader);
4831
4832            journal.destroy().await.unwrap();
4833        });
4834    }
4835
4836    #[test_traced]
4837    fn test_probe_then_read_many_matches_read_many() {
4838        // A probe completed by one batched read over its declined positions returns the same
4839        // items as read_many, cold and warm.
4840        let executor = deterministic::Runner::default();
4841        executor.start(|context| async move {
4842            let cfg = test_cfg(&context, NZU64!(4));
4843            let mut journal = Journal::init(context.child("j"), cfg).await.unwrap();
4844
4845            for i in 0..20u64 {
4846                journal.append(&test_digest(i)).await.unwrap();
4847            }
4848            journal.sync().await.unwrap();
4849
4850            let positions: Vec<u64> = (0..20).collect();
4851            let reader = journal.snapshot().await.unwrap();
4852            let expected: Vec<_> = (0..20).map(test_digest).collect();
4853            for _ in 0..2 {
4854                let mut served = reader.try_read_many_sync(&positions);
4855                let misses: Vec<u64> = positions
4856                    .iter()
4857                    .zip(&served)
4858                    .filter_map(|(&pos, item)| item.is_none().then_some(pos))
4859                    .collect();
4860                let mut fetched = reader.read_many(&misses).await.unwrap().into_iter();
4861                for item in served.iter_mut().filter(|item| item.is_none()) {
4862                    *item = fetched.next();
4863                }
4864                let completed: Vec<_> = served.into_iter().map(Option::unwrap).collect();
4865                assert_eq!(completed, expected);
4866            }
4867            assert_eq!(reader.read_many(&positions).await.unwrap(), expected);
4868            drop(reader);
4869
4870            journal.destroy().await.unwrap();
4871        });
4872    }
4873
4874    #[test_traced]
4875    fn test_fixed_journal_metrics() {
4876        let executor = deterministic::Runner::default();
4877        executor.start(|context| async move {
4878            let cfg = test_cfg(&context, NZU64!(2));
4879            let mut journal =
4880                Journal::<_, Digest>::init(context.child("fixed_metrics"), cfg.clone())
4881                    .await
4882                    .unwrap();
4883
4884            let items: Vec<_> = (0..5).map(test_digest).collect();
4885            journal.append_many(Many::Flat(&items)).await.unwrap();
4886            journal.append(&test_digest(5)).await.unwrap();
4887            journal.commit().await.unwrap();
4888            journal.sync().await.unwrap();
4889            journal.snapshot().await.unwrap().read(0).await.unwrap();
4890            journal.snapshot().await.unwrap().try_read_sync(0).unwrap();
4891            journal
4892                .snapshot()
4893                .await
4894                .unwrap()
4895                .read_many(&[1, 2, 4])
4896                .await
4897                .unwrap();
4898            journal.prune(2).await.unwrap();
4899            journal.rewind(4).await.unwrap();
4900
4901            let buffer = context.encode();
4902            for expected in [
4903                "fixed_metrics_size 4",
4904                "fixed_metrics_pruning_boundary 2",
4905                "fixed_metrics_retained 2",
4906                "fixed_metrics_tail_items 2",
4907                "fixed_metrics_append_calls_total 1",
4908                "fixed_metrics_append_many_calls_total 1",
4909                "fixed_metrics_read_calls_total 1",
4910                "fixed_metrics_read_many_calls_total 1",
4911                "fixed_metrics_items_read_total 5",
4912                "fixed_metrics_commit_calls_total 1",
4913                "fixed_metrics_sync_calls_total 1",
4914                "fixed_metrics_append_duration_count 1",
4915                "fixed_metrics_append_many_duration_count 1",
4916                "fixed_metrics_read_duration_count 0",
4917                "fixed_metrics_read_many_duration_count 1",
4918                "fixed_metrics_commit_duration_count 1",
4919                "fixed_metrics_sync_duration_count 1",
4920                "fixed_metrics_cache_hits_total",
4921                "fixed_metrics_cache_misses_total",
4922                "fixed_metrics_blobs_tracked",
4923            ] {
4924                assert!(buffer.contains(expected), "{expected}\n{buffer}");
4925            }
4926
4927            journal.destroy().await.unwrap();
4928        });
4929    }
4930    /// A snapshot's bounds and contents are frozen across appends and rolls.
4931    #[test_traced]
4932    fn test_snapshot_frozen_across_roll() {
4933        let executor = deterministic::Runner::default();
4934        executor.start(|context| async move {
4935            let cfg = test_cfg(&context, NZU64!(5));
4936            let mut journal = Journal::<_, Digest>::init(context.child("j"), cfg)
4937                .await
4938                .unwrap();
4939            for i in 0..7u64 {
4940                journal.append(&test_digest(i)).await.unwrap();
4941            }
4942
4943            let snapshot = journal.snapshot().await.unwrap();
4944            assert_eq!(snapshot.bounds(), 0..7);
4945
4946            // Appending past the blob boundary rolls the snapshot's tail blob into
4947            // history; the snapshot keeps reading it through its own handle.
4948            for i in 7..23u64 {
4949                journal.append(&test_digest(i)).await.unwrap();
4950            }
4951            assert_eq!(snapshot.bounds(), 0..7);
4952            for i in 0..7u64 {
4953                assert_eq!(snapshot.read(i).await.unwrap(), test_digest(i));
4954            }
4955            assert!(matches!(
4956                snapshot.read(7).await,
4957                Err(Error::ItemOutOfRange(7))
4958            ));
4959
4960            let fresh = journal.snapshot().await.unwrap();
4961            assert_eq!(fresh.bounds(), 0..23);
4962            assert_eq!(fresh.read(22).await.unwrap(), test_digest(22));
4963
4964            drop(snapshot);
4965            drop(fresh);
4966            journal.destroy().await.unwrap();
4967        });
4968    }
4969
4970    /// A snapshot taken before a prune keeps reading the pruned range; later snapshots observe
4971    /// the new boundary.
4972    #[test_traced]
4973    fn test_prune_under_snapshot() {
4974        let executor = deterministic::Runner::default();
4975        executor.start(|context| async move {
4976            let cfg = test_cfg(&context, NZU64!(5));
4977            let mut journal = Journal::<_, Digest>::init(context.child("j"), cfg)
4978                .await
4979                .unwrap();
4980            for i in 0..17u64 {
4981                journal.append(&test_digest(i)).await.unwrap();
4982            }
4983            journal.sync().await.unwrap();
4984
4985            let snapshot = journal.snapshot().await.unwrap();
4986            assert!(journal.prune(12).await.unwrap());
4987
4988            // The straggler reads the pruned range through its own handles.
4989            assert_eq!(snapshot.bounds(), 0..17);
4990            for i in 0..17u64 {
4991                assert_eq!(snapshot.read(i).await.unwrap(), test_digest(i));
4992            }
4993
4994            let fresh = journal.snapshot().await.unwrap();
4995            assert_eq!(fresh.bounds(), 10..17);
4996            assert!(matches!(fresh.read(3).await, Err(Error::ItemPruned(3))));
4997
4998            drop(snapshot);
4999            drop(fresh);
5000            journal.destroy().await.unwrap();
5001        });
5002    }
5003
5004    /// Every snapshot shipped to a concurrent task is fully readable while the writer keeps
5005    /// appending and rolling.
5006    #[test_traced]
5007    fn test_snapshots_readable_during_concurrent_appends() {
5008        let executor = deterministic::Runner::seeded(7);
5009        executor.start(|context| async move {
5010            let cfg = test_cfg(&context, NZU64!(5));
5011            let mut journal = Journal::<_, Digest>::init(context.child("j"), cfg)
5012                .await
5013                .unwrap();
5014
5015            let (mut tx, mut rx) =
5016                futures::channel::mpsc::channel::<Reader<'static, Context, Digest>>(8);
5017            let validator = context.child("validator").spawn(|_| async move {
5018                let mut validated = 0usize;
5019                while let Some(snapshot) = rx.next().await {
5020                    let bounds = snapshot.bounds();
5021                    for i in bounds.clone() {
5022                        assert_eq!(snapshot.read(i).await.unwrap(), test_digest(i));
5023                    }
5024                    validated += (bounds.end - bounds.start) as usize;
5025                }
5026                validated
5027            });
5028
5029            for i in 0..40u64 {
5030                journal.append(&test_digest(i)).await.unwrap();
5031                if i % 7 == 0 {
5032                    let snapshot = journal.snapshot().await.unwrap();
5033                    if tx.try_send(snapshot).is_err() {
5034                        break;
5035                    }
5036                }
5037            }
5038            drop(tx);
5039            assert!(validator.await.unwrap() > 0);
5040
5041            journal.destroy().await.unwrap();
5042        });
5043    }
5044
5045    /// A snapshot taken before rolls and a prune replays its full frozen range, streaming its
5046    /// then-tail blob through the snapshot's own handle.
5047    #[test_traced]
5048    fn test_replay_from_stale_snapshot() {
5049        let executor = deterministic::Runner::default();
5050        executor.start(|context| async move {
5051            let cfg = test_cfg(&context, NZU64!(5));
5052            let mut journal = Journal::<_, Digest>::init(context.child("j"), cfg)
5053                .await
5054                .unwrap();
5055            for i in 0..7u64 {
5056                journal.append(&test_digest(i)).await.unwrap();
5057            }
5058
5059            // Positions 5..7 live in the snapshot's tail blob.
5060            let snapshot = journal.snapshot().await.unwrap();
5061            assert_eq!(snapshot.bounds(), 0..7);
5062
5063            // Roll the snapshot's tail into history, then prune both of its blobs away.
5064            for i in 7..23u64 {
5065                journal.append(&test_digest(i)).await.unwrap();
5066            }
5067            assert!(journal.prune(12).await.unwrap());
5068
5069            {
5070                let stream = snapshot.replay(0, NZUsize!(1024)).await.unwrap();
5071                pin_mut!(stream);
5072                let mut expected = 0u64;
5073                while let Some(result) = stream.next().await {
5074                    let (pos, item) = result.unwrap();
5075                    assert_eq!(pos, expected);
5076                    assert_eq!(item, test_digest(pos));
5077                    expected += 1;
5078                }
5079                assert_eq!(expected, 7);
5080            }
5081
5082            drop(snapshot);
5083            journal.destroy().await.unwrap();
5084        });
5085    }
5086
5087    #[test_traced]
5088    fn test_read_many_sparse_sections_and_hit_accounting() {
5089        // Verify the batched read path is byte-identical to per-item reads across multiple
5090        // blobs, with a mid-blob pruning boundary, a sparse subset of positions, and
5091        // exact hit/miss accounting over a mixed cached/uncached batch.
5092        let executor = deterministic::Runner::default();
5093        executor.start(|context| async move {
5094            let mut cfg = test_cfg(&context, NZU64!(8));
5095            // Keep the whole batch resident so hit accounting is stable. Otherwise, the batch may
5096            // evict a page that a later per-item probe still expects to hit.
5097            cfg.page_cache = CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(16));
5098            let mut journal = Journal::init(context.child("j"), cfg).await.unwrap();
5099
5100            for i in 0..50u64 {
5101                journal.append(&test_digest(i)).await.unwrap();
5102            }
5103            journal.sync().await.unwrap();
5104            // Prune mid-blob so first_in_blob differs from the blob start.
5105            journal.prune(11).await.unwrap();
5106
5107            let reader = journal.snapshot().await.unwrap();
5108
5109            // Sparse subset spanning multiple blobs, including the pruning boundary.
5110            // `try_read_sync` probes do not populate the cache, so the cached subset is
5111            // whatever the append path left resident; derive the expected hit count from
5112            // probes so the batch read's hit/miss accounting is asserted exactly.
5113            let positions: Vec<u64> = vec![11, 12, 19, 20, 23, 31, 40, 47, 49];
5114            let expected_hits = positions
5115                .iter()
5116                .filter(|&&pos| reader.try_read_sync(pos).is_some())
5117                .count() as u64;
5118            let before = context.encode();
5119            let batch = reader.read_many(&positions).await.unwrap();
5120            let after = context.encode();
5121            assert_eq!(batch.len(), positions.len());
5122            assert_eq!(
5123                counter(&after, "cache_hits") - counter(&before, "cache_hits"),
5124                expected_hits,
5125                "batch read hit count should match the cached subset"
5126            );
5127            assert_eq!(
5128                counter(&after, "cache_misses") - counter(&before, "cache_misses"),
5129                positions.len() as u64 - expected_hits,
5130                "batch read miss count should cover the rest"
5131            );
5132            for (i, &pos) in positions.iter().enumerate() {
5133                let single = reader.read(pos).await.unwrap();
5134                assert_eq!(batch[i], single);
5135                assert_eq!(batch[i], test_digest(pos));
5136            }
5137
5138            // Full contiguous range over retained items.
5139            let all: Vec<u64> = (11..50).collect();
5140            let batch = reader.read_many(&all).await.unwrap();
5141            for (i, &pos) in all.iter().enumerate() {
5142                assert_eq!(batch[i], reader.read(pos).await.unwrap());
5143            }
5144            drop(reader);
5145
5146            journal.destroy().await.unwrap();
5147        });
5148    }
5149
5150    #[test_traced]
5151    fn test_read_many_cold_blob_groups() {
5152        // Read a batch whose positions are all cache misses spread over four blobs, so the whole
5153        // batch is served by per-blob group reads into disjoint slices of the shared output
5154        // buffer. Each digest encodes its position, so a group read into the wrong slice fails
5155        // the value assertions.
5156        let executor = deterministic::Runner::default();
5157        executor.start(|context| async move {
5158            let mut cfg = test_cfg(&context, NZU64!(8));
5159            cfg.page_cache = CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(32));
5160            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
5161                .await
5162                .unwrap();
5163            for i in 0..40u64 {
5164                journal.append(&test_digest(i)).await.unwrap();
5165            }
5166            journal.sync().await.unwrap();
5167            drop(journal);
5168
5169            // Reopen with a fresh page cache so every full page is cold. The positions avoid
5170            // each blob's trailing bytes, which sealed blobs keep in memory and always serve
5171            // synchronously.
5172            cfg.page_cache = CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(32));
5173            let mut journal = Journal::<_, Digest>::init(context.child("second"), cfg)
5174                .await
5175                .unwrap();
5176            let reader = journal.snapshot().await.unwrap();
5177            let positions = [1, 5, 10, 14, 17, 22, 25, 30];
5178            for pos in positions {
5179                assert!(reader.try_read_sync(pos).is_none(), "position {pos}");
5180            }
5181
5182            // Every item requires a blob read, split into four groups of two.
5183            let before = context.encode();
5184            let batch = reader.read_many(&positions).await.unwrap();
5185            let after = context.encode();
5186            assert_eq!(
5187                counter(&after, "second_cache_misses") - counter(&before, "second_cache_misses"),
5188                positions.len() as u64
5189            );
5190            assert_eq!(
5191                counter(&after, "second_cache_hits"),
5192                counter(&before, "second_cache_hits")
5193            );
5194            for (i, &pos) in positions.iter().enumerate() {
5195                assert_eq!(batch[i], test_digest(pos), "position {pos}");
5196            }
5197
5198            // The first pass cached every page it faulted, so a second pass is all hits and must
5199            // return the same items.
5200            let before = context.encode();
5201            let batch = reader.read_many(&positions).await.unwrap();
5202            let after = context.encode();
5203            assert_eq!(
5204                counter(&after, "second_cache_hits") - counter(&before, "second_cache_hits"),
5205                positions.len() as u64
5206            );
5207            for (i, &pos) in positions.iter().enumerate() {
5208                assert_eq!(batch[i], test_digest(pos), "position {pos}");
5209            }
5210            drop(reader);
5211
5212            journal.destroy().await.unwrap();
5213        });
5214    }
5215
5216    #[test_traced]
5217    fn test_fixed_journal_read_miss_timed() {
5218        // Reads served from storage record a read_duration sample; cache hits do not.
5219        let executor = deterministic::Runner::default();
5220        executor.start(|context| async move {
5221            let mut journal =
5222                Journal::<_, Digest>::init(context.child("miss"), test_cfg(&context, NZU64!(2)))
5223                    .await
5224                    .unwrap();
5225            for i in 0..20 {
5226                journal.append(&test_digest(i)).await.unwrap();
5227            }
5228            journal.sync().await.unwrap();
5229
5230            // The page cache cannot hold every page, so some position must be cold.
5231            let reader = journal.snapshot().await.unwrap();
5232            let pos = (0..20)
5233                .find(|&pos| reader.try_read_sync(pos).is_none())
5234                .expect("some position should be cold");
5235            assert_eq!(reader.read(pos).await.unwrap(), test_digest(pos));
5236            drop(reader);
5237
5238            let buffer = context.encode();
5239            assert!(buffer.contains("miss_read_duration_count 1"), "{buffer}");
5240
5241            journal.destroy().await.unwrap();
5242        });
5243    }
5244}