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.
13//!
14//! ```text
15//! +--------+--------+-----+----------+
16//! | item_0 | item_1 | ... | item_n-1 |
17//! +--------+--------+-----+----------+
18//!
19//! n = config.items_per_blob
20//! ```
21//!
22//! The most recent blob may not necessarily be full, in which case it will contain fewer than the
23//! maximum number of items.
24//!
25//! Data fetched from disk is always checked for integrity before being returned. If the data is
26//! found to be invalid, an error is returned instead.
27//!
28//! # Architecture
29//!
30//! Three types divide the work:
31//!
32//! - [`Journal`] tracks which positions are readable (`bounds`) and maps each position to a blob
33//!   and byte offset.
34//!
35//! - `Writable` owns the files: the contiguous sealed blobs plus the one writable tail. When the
36//!   tail fills, it is sealed and an fsync of it begins. `Writable` tracks that in-flight sync,
37//!   and its tail writer tracks any started sync of the new 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//! # Durability invariant
66//!
67//! When an append fills the tail blob, the journal rolls over: it seals the full blob, starts an
68//! fsync of it, and opens the next blob as the new tail. Each rollover first waits for the
69//! previous rollover's fsync, so only the tail and its predecessor (the blob sealed by the last
70//! rollover) can ever hold non-durable data. Every older blob is fully durable.
71//!
72//! A crash while one of those fsyncs is in flight can persist the blob's pages out of order, so
73//! an earlier page may be lost while later pages, including a valid last page, survive. Sizing a
74//! blob by its last valid page cannot detect such a hole, so recovery re-reads the two newest
75//! blobs from the acknowledged prefix onward, which a bounded open lowers to its cap, and
76//! truncates each at the first missing or corrupt page. Pages beneath that start had their
77//! covering fsync complete, so recovery never re-reads them. Damage there is external corruption
78//! that surfaces as a read error, while recovery still fails loudly when a blob no longer
79//! physically backs its acknowledged items.
80//!
81//! The recovered size is the logical end of this contiguous prefix. If the persisted watermark
82//! exceeds the recovered size, an unbounded open returns a corruption error. A bounded open
83//! (`init_at_most`) compares the watermark clamped to its cap, since blobs at or above the cap
84//! are discarded, and publication persists the retained end as the watermark. Both the pruning
85//! boundary and watermark are persisted before `init` returns.
86//!
87//! The recovery watermark is therefore an external recovery checkpoint, not a complete record of
88//! every item that may have become durable through `commit` or storage behavior.
89//!
90//! # Watermark advancement
91//!
92//! The watermark must never exceed what is durably on disk. `sync()` completes its data sync
93//! before writing the watermark, so it can advance it to the current size. `start_sync()`
94//! returns before its data sync completes, so it advances the watermark using an older proof:
95//! the journal's *barrier*, the highest size whose sync it has already observed complete.
96//! A durable value is safe to write at any time, so the watermark write needs no ordering
97//! against the in-flight data sync.
98//!
99//! An advance that fails to start fails the call, destroying the journal. A started advance's
100//! failure surfaces on the returned handle, and the next checkpoint write observes it and
101//! fails before writing.
102//!
103//! The invariants:
104//!
105//! - The watermark only takes values the barrier has held (never an in-flight size).
106//! - The barrier advances only on an observed sync success.
107//! - Initialization recovery persists a lower watermark before discarding a suffix. Live
108//!   handles never move their retained end backward.
109//!
110//! # Consistency
111//!
112//! Data written to `Journal` may not be immediately persisted to `Storage`. It is up to the caller
113//! to determine when to force pending data to be durably written using `commit` or `sync`.
114//!
115//! # Pruning
116//!
117//! The `prune` method allows the `Journal` to prune blobs consisting entirely of items prior to a
118//! given point in history.
119//!
120//! # Clearing / reset
121//!
122//! Clearing wipes all data and restarts the journal at a requested size. An open journal can only
123//! clear to a size at or beyond its current end.
124//!
125//! To stay crash-safe, a clear records its target size in the checkpoint *before* deleting any
126//! blob. If a crash interrupts the deletion, reopening sees that recorded target and finishes the
127//! clear, rather than mistaking the half-deleted blobs for corruption.
128//!
129//! Callers reach this through `clear_to_size` (clear an open journal) or `init_at_size` (open
130//! straight into a cleared, empty journal at a given size).
131//!
132//! # Replay
133//!
134//! The `replay` method supports fast reading of all unpruned items into memory.
135
136use super::{
137    Batch, Decoded,
138    blobs::{Blob, Blobs, Partition, Replay as BlobReplay, Writable},
139    checkpoint::Checkpoint,
140};
141#[commonware_macros::stability(ALPHA)]
142use crate::journal::authenticated;
143use crate::{
144    Context, SyncCompletion,
145    journal::{
146        Error,
147        contiguous::{Many, Mutable, metrics::Metrics},
148        durability::Barrier,
149    },
150};
151use bytes::Bytes;
152use commonware_codec::{CodecFixedShared, Copying, DecodeExt as _, ReadExt as _};
153use commonware_runtime::{
154    Blob as RBlob, Buf, Handle, IoBuf, ReadOptions,
155    buffer::paged::{CacheRef, Recovery as PagedRecovery},
156};
157use commonware_utils::{Cached, futures::try_join_all};
158use futures::{FutureExt as _, Stream};
159use std::{
160    collections::{BTreeMap, btree_map::Entry},
161    future::Future,
162    marker::PhantomData,
163    num::{NonZeroU64, NonZeroUsize},
164    ops::Range,
165    sync::Arc,
166};
167use tracing::warn;
168
169// Reusable scratch for the synchronous read paths ([`Reader::try_read_sync`] and
170// [`Reader::probe_items`]), grown to the largest probe served on the thread. Both run on the
171// hot read path, where a fresh zeroed allocation per call contends under the pool's fan-out.
172commonware_utils::thread_local_cache!(static PROBE_SCRATCH: Vec<u8>);
173
174/// Items encoded for a deferred append, created by [`Journal::prepare_append`] and consumed by
175/// [`Journal::append_prepared`].
176pub struct PreparedAppend<A> {
177    buf: Vec<u8>,
178    _marker: PhantomData<A>,
179}
180
181/// Return the first retained logical position in `blob`.
182#[inline]
183fn first_in_blob(pruning_boundary: u64, blob: u64, items_per_blob: u64) -> Result<u64, Error> {
184    let start = super::blob_first_position(blob, items_per_blob)?;
185    Ok(pruning_boundary.max(start))
186}
187
188/// Build a replay batch stream over the retained blob range.
189///
190/// The stream is split into one state per blob so replay can start at a mid-blob pruning boundary,
191/// stop at the journal's logical end, and avoid reading across blob files. `buffer` is a byte
192/// budget for each blob replay, not an item count.
193fn replay_stream<'a, B: RBlob, A: CodecFixedShared>(
194    blobs: &Blobs<'a, B>,
195    bounds: Range<u64>,
196    items_per_blob: NonZeroU64,
197    range: Range<u64>,
198    buffer: NonZeroUsize,
199    read_options: ReadOptions,
200) -> Result<impl Stream<Item = Result<Batch<A>, Error>> + Send + use<'a, B, A>, Error> {
201    if range.start > range.end || range.end > bounds.end {
202        return Err(Error::ItemOutOfRange(if range.start > range.end {
203            range.start
204        } else {
205            range.end
206        }));
207    }
208    if range.start < bounds.start {
209        return Err(Error::ItemPruned(range.start));
210    }
211
212    let mut states = Vec::new();
213    if range.start < range.end {
214        let items_per_blob = items_per_blob.get();
215        let start_pos = range.start;
216        let start_blob = super::position_to_blob(start_pos, items_per_blob);
217        let end_blob = super::position_to_blob(range.end - 1, items_per_blob);
218        let items_per_batch = (buffer.get() / A::SIZE).max(1);
219
220        for blob in start_blob..=end_blob {
221            // The oldest retained blob may begin after its natural blob boundary when pruning
222            // kept only a suffix.
223            let blob_first = first_in_blob(bounds.start, blob, items_per_blob)?;
224            let first_pos = if blob == start_blob {
225                start_pos
226            } else {
227                blob_first
228            };
229            let blob_end = super::blob_end_position(blob, items_per_blob, range.end);
230            let offset = (first_pos - blob_first)
231                .checked_mul(A::SIZE as u64)
232                .ok_or(Error::OffsetOverflow)?;
233            let blob = blobs
234                .get(blob)
235                .expect("positions in bounds map to a retained blob");
236
237            states.push(FixedReplayState::<B, A> {
238                replay: blob.replay_from(offset, buffer, read_options)?,
239                pos: first_pos,
240                end_pos: blob_end,
241                items_per_batch,
242                _marker: PhantomData,
243            });
244        }
245    }
246
247    Ok(super::replay_stream_from_states(states))
248}
249
250/// Replay state for one fixed-size blob.
251struct FixedReplayState<'a, B: RBlob, A> {
252    /// Sequential logical bytes for this blob.
253    replay: BlobReplay<'a, B>,
254    /// Next position to yield.
255    pos: u64,
256    /// Exclusive end position within this blob.
257    end_pos: u64,
258    /// Maximum number of items decoded per batch.
259    items_per_batch: usize,
260    _marker: PhantomData<A>,
261}
262
263impl<B: RBlob, A: CodecFixedShared> super::BatchDecoder for FixedReplayState<'_, B, A> {
264    type Item = A;
265
266    /// Decode the next batch of fixed-size items from this blob.
267    async fn next(mut self) -> Option<(Decoded<A>, Self)> {
268        if self.pos == self.end_pos {
269            return None;
270        }
271
272        // Require at least one whole item so a short blob is reported as corruption at the
273        // current position. Additional already-buffered items are decoded below.
274        let start = self.pos;
275        match self.replay.ensure(A::SIZE).await {
276            Ok(true) => {}
277            Ok(false) => {
278                let error = Error::Corruption(format!("blob ended before position {start}"));
279                let decoded = Decoded::failed(start, Vec::new(), error);
280                return Some((decoded, self));
281            }
282            Err(err) => {
283                let decoded = Decoded::failed(start, Vec::new(), err);
284                return Some((decoded, self));
285            }
286        }
287
288        // Decode only whole items that are already buffered, capped by the replay byte budget and
289        // this blob's logical end.
290        let available = (self.replay.remaining() / A::SIZE) as u64;
291        let remaining = self.end_pos - self.pos;
292        let count = available.min(self.items_per_batch as u64).min(remaining) as usize;
293
294        // Decode the whole items of a buffered page from one view of it, which costs one page
295        // lookup per page instead of one per item and shares the page's allocation with retained
296        // fields. An item that straddles a page boundary, or a read boundary while replaying a
297        // live view, goes through the general path. Stop this blob on error because decoding may
298        // have consumed only part of a record.
299        let mut items = Vec::with_capacity(count);
300        while items.len() < count {
301            let n = (self.replay.chunk().len() / A::SIZE).min(count - items.len());
302            if n > 0 {
303                let mut page = self.replay.copy_to_bytes(n * A::SIZE);
304                for _ in 0..n {
305                    match A::read(&mut page) {
306                        Ok(item) => items.push(item),
307                        Err(err) => {
308                            let decoded = Decoded::failed(start, items, Error::Codec(err));
309                            return Some((decoded, self));
310                        }
311                    }
312                }
313            } else {
314                match self.replay.read::<A>() {
315                    Ok(item) => items.push(item),
316                    Err(err) => {
317                        let decoded = Decoded::failed(start, items, Error::Codec(err));
318                        return Some((decoded, self));
319                    }
320                }
321            }
322        }
323        self.pos += count as u64;
324        Some((
325            Decoded {
326                batch: Batch { start, items },
327                error: None,
328            },
329            self,
330        ))
331    }
332}
333
334/// How a blob's on-disk item count compares to its logical capacity.
335enum BlobFill {
336    Full { len: u64 },
337    Short { len: u64 },
338    Overfull { len: u64, capacity: u64 },
339}
340
341/// The recovered journal size and durability floor derived from the reconciled pruning boundary
342/// and on-disk blob lengths.
343struct RecoveredBounds {
344    /// Size: one past the last recovered item.
345    size: u64,
346    /// Recovery watermark to persist (a floor on durable size).
347    recovery_watermark: u64,
348}
349
350/// Configuration for `Journal` storage.
351#[derive(Clone)]
352pub struct Config {
353    /// Prefix for the journal partitions.
354    ///
355    /// Blobs are stored in `partition` (legacy) if it contains data, otherwise in
356    /// `{partition}-blobs`. Metadata is stored in `{partition}-metadata`.
357    pub partition: String,
358
359    /// The maximum number of journal items to store in each blob.
360    ///
361    /// Retained non-tail blobs are expected to be full relative to their logical capacity. A
362    /// mid-blob oldest blob may physically hold fewer than this many items, and the newest blob
363    /// may contain fewer items.
364    pub items_per_blob: NonZeroU64,
365
366    /// The page cache to use for caching data.
367    pub page_cache: CacheRef,
368
369    /// The size of the write buffer to use for each blob.
370    pub write_buffer: NonZeroUsize,
371
372    /// Buffer size for sequential reads during recovery.
373    pub replay_buffer: NonZeroUsize,
374}
375
376/// The journal's state, boxed so the public [Journal] handle stays pointer-sized.
377pub(super) struct Inner<E: Context, A> {
378    /// The blobs that comprise the journal.
379    blobs: Writable<E>,
380
381    /// The durable recovery checkpoint.
382    checkpoint: Checkpoint<E>,
383
384    /// The readable positions; `bounds.end` is the next append position.
385    bounds: Range<u64>,
386
387    /// The maximum number of items per blob.
388    items_per_blob: NonZeroU64,
389
390    /// Shared with [Reader]s.
391    metrics: Arc<Metrics<E>>,
392
393    /// The known-durable size of the journal.
394    barrier: Barrier,
395
396    _phantom: PhantomData<A>,
397}
398
399/// Storage under recovery, before append or snapshot access is available.
400pub struct Recovery<E: Context, A> {
401    /// Runtime context for partition resets and the published journal's metrics.
402    context: E,
403
404    /// Blob layout and I/O settings shared by recovery and the published journal.
405    cfg: Config,
406
407    /// Recovery checkpoint, persisted before discarding any acknowledged suffix.
408    checkpoint: Checkpoint<E>,
409
410    /// Active blob partition used to open and remove blobs until publication.
411    partition: Partition<E>,
412
413    /// Opened recovery blobs keyed by physical index, including suffix candidates awaiting repair.
414    pending: BTreeMap<u64, PagedRecovery<E::Blob>>,
415
416    /// Blob indices excluded by the initial cap; repair removes them unless needed as an empty tail.
417    discarded: Vec<u64>,
418
419    /// Selected logical item range; its end is exclusive.
420    bounds: Range<u64>,
421
422    /// Durable lower bound on the logical item end, lowered before removing an acknowledged suffix.
423    watermark: u64,
424
425    /// Whether an explicit opening cap requires publication to persist the selected end.
426    bounded: bool,
427
428    _marker: PhantomData<A>,
429}
430
431impl<E: Context, A: CodecFixedShared> Recovery<E, A> {
432    /// Recover available items without sealing the retained blobs.
433    async fn open(
434        context: E,
435        cfg: Config,
436        checkpoint: Checkpoint<E>,
437        max_size: Option<u64>,
438    ) -> Result<Self, Error> {
439        let ceiling = max_size.unwrap_or(u64::MAX);
440        if let Some(target) = checkpoint.clear_target() {
441            warn!(
442                clear_target = target,
443                "crash repair: completing interrupted clear"
444            );
445            let recovery = Self::complete_clear(context, cfg, checkpoint, target).await?;
446
447            // A persisted reset is authoritative even when the open requests another cap. The cap
448            // is only checked against the reset size afterwards.
449            if ceiling < target {
450                return Err(Error::ItemPruned(ceiling));
451            }
452            return Ok(recovery);
453        }
454        let items_per_blob = cfg.items_per_blob.get();
455
456        // Select the active partition and reconcile its oldest blob with the checkpoint's retained
457        // start before excluding any suffix.
458        let (blob_partition, names) = Partition::select(&context, &cfg.partition).await?;
459        let partition = Partition::new(
460            context.child("blobs"),
461            blob_partition,
462            cfg.page_cache.clone(),
463            cfg.write_buffer,
464        );
465        let indices = Partition::<E>::indices(names)?;
466        let pruning_boundary = Inner::<E, A>::recover_pruning_boundary(
467            checkpoint.boundary_hint(),
468            indices.first().copied(),
469            items_per_blob,
470        )?;
471        if ceiling < pruning_boundary {
472            return Err(Error::ItemPruned(ceiling));
473        }
474
475        // Whole discarded blobs need no valid pages or item encodings.
476        let mut pending = BTreeMap::new();
477        let mut discarded = Vec::new();
478        for blob in indices {
479            let first = first_in_blob(pruning_boundary, blob, items_per_blob)?;
480            if max_size.is_some() && first >= ceiling {
481                discarded.push(blob);
482            } else {
483                pending.insert(blob, partition.open_recovery(blob).await?);
484            }
485        }
486
487        // Check the two newest blobs for interior holes before any resize. Only they can hold
488        // non-durable data, and a crash during an in-flight fsync can lose an interior page while
489        // later pages survive. `PagedRecovery::open` sizes a blob by its last valid page, so it
490        // cannot see such a hole. An item-aligned resize can land within a page, which
491        // `Recovery::truncate` must read and validate before rewriting its partial tip. The scan
492        // starts at the acknowledged in-blob prefix, which a bounded open lowers to its cap. Pages
493        // below that start are covered by a completed fsync, so in-model holes are impossible
494        // there and any later damage surfaces lazily at read. Above it, first move the target
495        // below any hole and round it down to whole items so `recover_bounds` sees only intact
496        // data.
497        let floor = checkpoint.watermark().unwrap_or(0).min(ceiling);
498        let floor_blob = super::position_to_blob(floor, items_per_blob);
499        let suspects: Vec<u64> = pending.keys().rev().take(2).copied().collect();
500        for blob in suspects {
501            if blob < floor_blob {
502                continue;
503            }
504
505            // Retain the watermark's in-blob prefix in its containing blob and nothing above.
506            let acknowledged = if blob == floor_blob {
507                Inner::<E, A>::items_to_bytes(floor.saturating_sub(first_in_blob(
508                    pruning_boundary,
509                    blob,
510                    items_per_blob,
511                )?))?
512            } else {
513                0
514            };
515            let writer = pending.get(&blob).expect("suspect blob is present");
516            let required_items = ceiling
517                .saturating_sub(first_in_blob(pruning_boundary, blob, items_per_blob)?)
518                .min(items_per_blob);
519
520            // Reads before publication use the page cache, which refuses a non-tip page that does
521            // not validate in full, so the scan runs to the end of the cap's page and repairs a
522            // page valid only under its shorter durable slot, even when that slot reaches the cap.
523            // The watermark advances only after its fsync and rewrites keep the durable slot
524            // intact, so under the interrupted-write model that slot covers every byte below the
525            // watermark and the repair keeps every acknowledged item, even with the cap below it.
526            let limit = if max_size.is_some() {
527                let page = u64::from(cfg.page_cache.page_size().get());
528                required_items
529                    .saturating_mul(Inner::<E, A>::CHUNK_SIZE_U64)
530                    .div_ceil(page)
531                    .saturating_mul(page)
532                    .min(writer.size())
533            } else {
534                writer.size()
535            };
536            let recoverable = writer
537                .recoverable_prefix_len_at_most(
538                    acknowledged,
539                    limit,
540                    cfg.replay_buffer,
541                    ReadOptions::default(),
542                )
543                .await?;
544            let valid_items = recoverable / Inner::<E, A>::CHUNK_SIZE_U64;
545            let valid = Inner::<E, A>::items_to_bytes(valid_items)?;
546            if valid == writer.size()
547                || (max_size.is_some() && recoverable == limit && valid_items >= required_items)
548            {
549                continue;
550            }
551
552            // Missing acknowledged pages surface as `valid < acknowledged`. The
553            // scan clamps to the pages physically present, so it can never exceed the size.
554            if valid < acknowledged {
555                return Err(Error::Corruption(format!(
556                    "blob {blob} no longer backs acknowledged items: well-formed prefix {valid} \
557                     of size {}",
558                    writer.size()
559                )));
560            }
561            warn!(
562                blob,
563                valid,
564                size = writer.size(),
565                "truncating to recoverable item prefix"
566            );
567            let writer = pending.remove(&blob).expect("suspect blob is present");
568            pending.insert(blob, writer.truncate(valid).await?);
569        }
570
571        let RecoveredBounds {
572            size,
573            recovery_watermark,
574        } = Inner::<E, A>::recover_bounds(
575            &pending,
576            items_per_blob,
577            pruning_boundary,
578            checkpoint
579                .watermark()
580                .map(|watermark| watermark.min(ceiling)),
581        )?;
582
583        Ok(Self {
584            context,
585            cfg,
586            checkpoint,
587            partition,
588            pending,
589            discarded,
590            bounds: pruning_boundary..size.min(ceiling),
591            watermark: recovery_watermark.min(ceiling),
592            bounded: max_size.is_some(),
593            _marker: PhantomData,
594        })
595    }
596
597    /// Complete the reset to `target` staged in `checkpoint` without opening any stored blob.
598    async fn complete_clear(
599        context: E,
600        cfg: Config,
601        checkpoint: Checkpoint<E>,
602        target: u64,
603    ) -> Result<Self, Error> {
604        let items_per_blob = cfg.items_per_blob.get();
605        let new_partition = format!("{}-blobs", cfg.partition);
606        Partition::<E>::remove_all(&context, &cfg.partition).await?;
607        Partition::<E>::remove_all(&context, &new_partition).await?;
608        let partition = Partition::new(
609            context.child("blobs"),
610            new_partition,
611            cfg.page_cache.clone(),
612            cfg.write_buffer,
613        );
614
615        // Recreate the target tail before the checkpoint publishes reset completion.
616        let tail = super::position_to_blob(target, items_per_blob);
617        let mut pending = BTreeMap::new();
618        pending.insert(tail, partition.open_recovery(tail).await?);
619        let checkpoint = checkpoint.finish_clear(items_per_blob, target).await?;
620        Ok(Self {
621            context,
622            cfg,
623            checkpoint,
624            partition,
625            pending,
626            discarded: Vec::new(),
627            bounds: target..target,
628            watermark: target,
629
630            // finish_clear persisted `target` as the watermark, so publication has no selected end
631            // to raise.
632            bounded: false,
633            _marker: PhantomData,
634        })
635    }
636
637    /// Stage a reset to `size` in `checkpoint`, await `clear_dependents`, then complete the reset
638    /// without opening any stored blob. A crash after staging leaves an intent that the next open
639    /// completes.
640    #[commonware_macros::stability(ALPHA)]
641    pub(super) async fn open_cleared<F, Fut>(
642        context: E,
643        cfg: Config,
644        checkpoint: Checkpoint<E>,
645        size: u64,
646        clear_dependents: F,
647    ) -> Result<Self, Error>
648    where
649        F: FnOnce() -> Fut,
650        Fut: Future<Output = Result<(), Error>>,
651    {
652        // A journal sized at `u64::MAX` can never accept an append (the successor size
653        // overflows), so reject it before staging any reset intent.
654        if size == u64::MAX {
655            return Err(Error::SizeOverflow);
656        }
657        let checkpoint = checkpoint.stage_clear(size).await?;
658        clear_dependents().await?;
659        Self::complete_clear(context, cfg, checkpoint, size).await
660    }
661
662    /// Open recovery while completing any previously staged reset callback.
663    pub(super) async fn init_cleared<F, Fut>(
664        context: E,
665        cfg: Config,
666        max_size: Option<u64>,
667        clear_dependents: F,
668    ) -> Result<Self, Error>
669    where
670        F: FnOnce() -> Fut,
671        Fut: Future<Output = Result<(), Error>>,
672    {
673        let checkpoint = Checkpoint::open(context.child("meta"), &cfg.partition).await?;
674        if checkpoint.clear_target().is_some() {
675            clear_dependents().await?;
676        }
677        Self::open(context, cfg, checkpoint, max_size).await
678    }
679
680    /// Positions the stored blobs may hold, from blob names and the checkpoint without opening
681    /// their contents. The end is an upper bound based on the newest blob's capacity. A staged
682    /// clear has not yet been applied.
683    #[commonware_macros::stability(ALPHA)]
684    pub(super) async fn span(
685        context: &E,
686        cfg: &Config,
687        checkpoint: &Checkpoint<E>,
688    ) -> Result<Range<u64>, Error> {
689        let per_blob = cfg.items_per_blob.get();
690        let (_, names) = Partition::select(context, &cfg.partition).await?;
691        let indices = Partition::<E>::indices(names)?;
692        let start = Inner::<E, A>::recover_pruning_boundary(
693            checkpoint.boundary_hint(),
694            indices.first().copied(),
695            per_blob,
696        )?;
697        let end = match indices.last() {
698            Some(&newest) => super::blob_first_position(newest, per_blob)?.saturating_add(per_blob),
699            None => start,
700        };
701        Ok(start..end)
702    }
703
704    /// Exclusive recovered item end.
705    pub(super) const fn size(&self) -> u64 {
706        self.bounds.end
707    }
708
709    /// First retained item position.
710    pub(super) const fn pruning_boundary(&self) -> u64 {
711        self.bounds.start
712    }
713
714    /// Previously acknowledged item end, bounded by the recovery cap.
715    pub(super) const fn recovery_watermark(&self) -> u64 {
716        self.watermark
717    }
718
719    /// Read an item for recovery selection or validation.
720    pub(super) async fn item(&self, pos: u64) -> Result<A, Error> {
721        if pos < self.bounds.start {
722            return Err(Error::ItemPruned(pos));
723        }
724        if pos >= self.bounds.end {
725            return Err(Error::ItemOutOfRange(pos));
726        }
727        let blob = super::position_to_blob(pos, self.cfg.items_per_blob.get());
728        let first = first_in_blob(self.bounds.start, blob, self.cfg.items_per_blob.get())?;
729        let offset = Inner::<E, A>::items_to_bytes(pos - first)?;
730
731        // `bounds.end` is derived by walking `pending`, and every mutation keeps `pending`
732        // contiguous over `bounds`, so a position inside `bounds` always has a blob.
733        let writer = self
734            .pending
735            .get(&blob)
736            .expect("positions inside bounds map to a pending recovery blob");
737        Ok(A::decode(
738            writer.read_at(offset, A::SIZE).await?.coalesce(),
739        )?)
740    }
741
742    /// Append entries while initialization exclusively owns the recovered blobs.
743    ///
744    /// The first append must follow a successful truncate or reset.
745    pub(crate) async fn append(mut self: Box<Self>, item: &A) -> Result<Box<Self>, Error> {
746        let pos = self.bounds.end;
747        let end = pos.checked_add(1).ok_or(Error::SizeOverflow)?;
748        let blob = super::position_to_blob(pos, self.cfg.items_per_blob.get());
749
750        // Encode directly into the write buffer when the item fits.
751        let appended = self
752            .pending
753            .get_mut(&blob)
754            .is_some_and(|writer| writer.try_append_value(item).is_some());
755
756        let completed = end.is_multiple_of(self.cfg.items_per_blob.get());
757
758        // Take the writer only to open it, append through the owned fallback, or flush it. The
759        // owned fallback handles flushing and items larger than the buffer.
760        if !appended || completed {
761            let mut writer = match self.pending.remove(&blob) {
762                Some(writer) => writer,
763                None => self.partition.open_recovery(blob).await?,
764            };
765            if !appended && writer.try_append_value(item).is_none() {
766                (writer, _) = writer.append_owned(item.encode_mut().into()).await?;
767            }
768
769            // Completed blobs stay open until publication. Flush them to bound retained buffers,
770            // without advancing the checkpoint before the recovered prefix is ready to publish.
771            if completed {
772                writer = writer.sync().await?;
773            }
774            self.pending.insert(blob, writer);
775        }
776        self.bounds.end = end;
777        Ok(self)
778    }
779
780    /// Durably retain at most `max_size` before accepting recovery appends.
781    pub(crate) async fn truncate(mut self: Box<Self>, max_size: u64) -> Result<Box<Self>, Error> {
782        *self = self.repair_to(max_size).await?;
783        Ok(self)
784    }
785
786    /// Durably reconcile the recovered blobs and checkpoint to a prefix no longer than
787    /// `max_size`.
788    async fn repair_to(mut self, max_size: u64) -> Result<Self, Error> {
789        let size = self.bounds.end.min(max_size);
790        if size < self.bounds.start {
791            return Err(Error::ItemPruned(size));
792        }
793        let items_per_blob = self.cfg.items_per_blob.get();
794        let tail_blob = super::position_to_blob(size, items_per_blob);
795        let bytes = Inner::<E, A>::items_to_bytes(
796            size - first_in_blob(self.bounds.start, tail_blob, items_per_blob)?,
797        )?;
798
799        // The checkpoint must not acknowledge a suffix once blob removal begins.
800        self.watermark = self.watermark.min(size);
801        let boundary_hint =
802            (!self.bounds.start.is_multiple_of(items_per_blob)).then_some(self.bounds.start);
803        if self.checkpoint.watermark() != Some(self.watermark)
804            || self.checkpoint.boundary_hint() != boundary_hint
805        {
806            self.checkpoint = self
807                .checkpoint
808                .persist(items_per_blob, self.bounds.start, self.watermark)
809                .await?;
810        }
811        if size == self.bounds.start
812            && (!self.discarded.is_empty() || self.pending.keys().any(|&blob| blob > tail_blob))
813        {
814            // An empty prefix needs a durable boundary blob before its last backing blob is
815            // removed.
816            if let Entry::Vacant(entry) = self.pending.entry(tail_blob) {
817                let writer = self.partition.open_recovery(tail_blob).await?;
818                entry.insert(writer.sync().await?);
819            }
820            self.discarded.retain(|&blob| blob != tail_blob);
821        }
822
823        // Make the selected tail newest before changing its partial-page checksum. Remove suffix
824        // blobs newest-first so every crash cut leaves a contiguous physical prefix.
825        while let Some(blob) = self.discarded.pop() {
826            self.partition.remove(blob).await?;
827        }
828        while let Some((&blob, _)) = self.pending.last_key_value() {
829            if blob <= tail_blob {
830                break;
831            }
832            let writer = self.pending.remove(&blob);
833            self.partition.remove(blob).await?;
834            drop(writer);
835        }
836
837        // A truncated tail is made durable by truncation. An appended tail needs an explicit
838        // barrier before publication can raise the watermark.
839        if let Some(writer) = self.pending.remove(&tail_blob) {
840            let writer = if bytes < writer.size() {
841                writer.truncate(bytes).await?
842            } else {
843                writer.sync().await?
844            };
845            self.pending.insert(tail_blob, writer);
846        }
847        self.bounds.end = size;
848        Ok(self)
849    }
850
851    /// Make every recovered blob durable. Clean blobs stay in place. An error drops the extracted
852    /// blobs.
853    async fn sync_pending(&mut self) -> Result<(), Error> {
854        let futures: Vec<_> = self
855            .pending
856            .extract_if(.., |_, writer| writer.needs_sync())
857            .map(|(blob, writer)| async move { writer.sync().await.map(|writer| (blob, writer)) })
858            .collect();
859        let synced = try_join_all(futures).await?;
860        self.pending.extend(synced);
861        Ok(())
862    }
863
864    /// Flush recovery data and publish the current end as the watermark.
865    pub(crate) async fn sync(mut self: Box<Self>) -> Result<Box<Self>, Error> {
866        self.sync_pending().await?;
867        self.watermark = self.bounds.end;
868        self.checkpoint = self
869            .checkpoint
870            .persist(
871                self.cfg.items_per_blob.get(),
872                self.bounds.start,
873                self.watermark,
874            )
875            .await?;
876        Ok(self)
877    }
878
879    /// Reconcile a pruning boundary while recovery owns the blobs.
880    pub(crate) async fn prune(
881        mut self: Box<Self>,
882        position: u64,
883    ) -> Result<(Box<Self>, bool), Error> {
884        // Recovery pruning advances only to the whole-blob boundary containing the requested
885        // retained position.
886        let per_blob = self.cfg.items_per_blob.get();
887        let blob = super::position_to_blob(position.min(self.bounds.end), per_blob);
888        let boundary = super::blob_first_position(blob, per_blob)?;
889        if boundary <= self.bounds.start {
890            return Ok((self, false));
891        }
892
893        // Preserve the retained position before removing its last backing blob. A bounded
894        // recovery may have discarded the empty tail that ordinarily records this boundary.
895        if let Entry::Vacant(entry) = self.pending.entry(blob) {
896            entry.insert(self.partition.open_recovery(blob).await?);
897        }
898
899        // The retained anchor must be durable before any older blob is removed.
900        self.sync_pending().await?;
901        while let Some((&oldest, _)) = self.pending.first_key_value() {
902            if oldest >= blob {
903                break;
904            }
905            drop(self.pending.remove(&oldest));
906            self.partition.remove(oldest).await?;
907        }
908        self.bounds.start = boundary;
909        Ok((self, true))
910    }
911
912    /// Complete a reset while recovery owns the blobs.
913    pub(crate) async fn clear_to_size(self: Box<Self>, size: u64) -> Result<Box<Self>, Error> {
914        self.clear_to_size_cleared(size, || async { Ok(()) }).await
915    }
916
917    /// Stage one durable reset intent, run the reset callback, and replace all journal blobs.
918    /// Recovery may preserve an exhausted journal at `u64::MAX`. Explicit resets reject it.
919    pub(super) async fn clear_to_size_cleared<F, Fut>(
920        self: Box<Self>,
921        size: u64,
922        clear_dependents: F,
923    ) -> Result<Box<Self>, Error>
924    where
925        F: FnOnce() -> Fut,
926        Fut: Future<Output = Result<(), Error>>,
927    {
928        // A journal sized at `u64::MAX` can never accept an append, so reject it before staging
929        // any reset intent.
930        if size == u64::MAX {
931            return Err(Error::SizeOverflow);
932        }
933        let Self {
934            context,
935            cfg,
936            checkpoint,
937            partition,
938            pending,
939            ..
940        } = *self;
941
942        // Release the recovered blob handles and their partition before the reset recreates both.
943        drop(pending);
944        drop(partition);
945
946        // The durable intent makes `size` authoritative across every later crash cut.
947        let checkpoint = checkpoint.stage_clear(size).await?;
948
949        // Dependent state is cleared under the same intent before either journal partition is
950        // removed.
951        clear_dependents().await?;
952        Ok(Box::new(
953            Self::complete_clear(context, cfg, checkpoint, size).await?,
954        ))
955    }
956
957    /// Select a retained prefix and finish it before creating a live journal.
958    pub(super) async fn publish(mut self, max_size: u64) -> Result<Inner<E, A>, Error> {
959        // Complete the durable prefix selection before sealing blobs for live access.
960        self = self.repair_to(max_size).await?;
961        let size = self.bounds.end;
962        let items_per_blob = self.cfg.items_per_blob.get();
963        let tail_blob = super::position_to_blob(size, items_per_blob);
964        let blobs = Writable::recover(self.partition, self.pending, tail_blob).await?;
965
966        // A bounded open publishes the selected end after every retained blob is durable.
967        if self.bounded && self.checkpoint.watermark() != Some(size) {
968            self.checkpoint = self
969                .checkpoint
970                .persist(items_per_blob, self.bounds.start, size)
971                .await?;
972        }
973        let metrics = Metrics::new(self.context);
974        metrics.update(size, self.bounds.start, items_per_blob);
975        Ok(Inner::from_blobs(
976            blobs,
977            self.checkpoint,
978            self.bounds.start..size,
979            self.cfg.items_per_blob,
980            metrics,
981        ))
982    }
983}
984
985impl<E: Context, A: CodecFixedShared> Inner<E, A> {
986    /// Size of each entry in bytes. Evaluating this rejects zero-size item types at compile
987    /// time, which would otherwise divide by zero in the chunk math.
988    pub const CHUNK_SIZE: NonZeroUsize = match NonZeroUsize::new(A::SIZE) {
989        Some(size) => size,
990        None => panic!("journal item size must be nonzero"),
991    };
992
993    /// Size of each entry in bytes (as u64).
994    pub const CHUNK_SIZE_U64: u64 = Self::CHUNK_SIZE.get() as u64;
995
996    /// Convert an item count to a byte length, failing on overflow.
997    fn items_to_bytes(items: u64) -> Result<u64, Error> {
998        items
999            .checked_mul(Self::CHUNK_SIZE_U64)
1000            .ok_or(Error::OffsetOverflow)
1001    }
1002
1003    /// Construct a journal from recovered blobs.
1004    fn from_blobs(
1005        blobs: Writable<E>,
1006        checkpoint: Checkpoint<E>,
1007        bounds: Range<u64>,
1008        items_per_blob: NonZeroU64,
1009        metrics: Metrics<E>,
1010    ) -> Self {
1011        Self {
1012            blobs,
1013            barrier: Barrier::new(
1014                checkpoint
1015                    .watermark()
1016                    .expect("recovery watermark must exist after init"),
1017            ),
1018            checkpoint,
1019            bounds,
1020            items_per_blob,
1021            metrics: Arc::new(metrics),
1022            _phantom: PhantomData,
1023        }
1024    }
1025
1026    /// See [Journal::init].
1027    pub(crate) async fn init(context: E, cfg: Config) -> Result<Self, Error> {
1028        let checkpoint = Checkpoint::open(context.child("meta"), &cfg.partition).await?;
1029        Recovery::<E, A>::open(context, cfg, checkpoint, None)
1030            .await?
1031            .publish(u64::MAX)
1032            .await
1033    }
1034
1035    /// Recover the journal bounds and any tail repair from the reconciled pruning boundary and
1036    /// blob state.
1037    ///
1038    /// Blob lengths recover the contiguous size from the supplied boundary. A watermark beyond
1039    /// that size is corruption. The caller persists the checkpoint before repairing the suffix.
1040    fn recover_bounds(
1041        pending: &BTreeMap<u64, PagedRecovery<E::Blob>>,
1042        items_per_blob: u64,
1043        pruning_boundary: u64,
1044        watermark_hint: Option<u64>,
1045    ) -> Result<RecoveredBounds, Error> {
1046        let (size, has_gap) =
1047            Self::recover_by_walking_lengths(pending, items_per_blob, pruning_boundary)?;
1048
1049        let recovery_watermark = match watermark_hint {
1050            Some(watermark) if watermark > size => {
1051                // The dual-CRC page mechanism prevents losing previously-synced data, and
1052                // clear_to_size updates the watermark atomically via the staged clear intent. A
1053                // watermark beyond the recoverable size indicates external corruption.
1054                return Err(Error::Corruption(format!(
1055                    "recovery watermark {watermark} exceeds recoverable size {size}"
1056                )));
1057            }
1058            Some(watermark) => watermark,
1059            None if has_gap => {
1060                // A legacy journal with a short non-tail blob violates the old rollover-sync
1061                // invariant (each blob was fsynced before the next received writes).
1062                return Err(Error::Corruption(
1063                    "legacy journal has a short non-tail blob".into(),
1064                ));
1065            }
1066            // Legacy journals have no watermark. Under the old rollover-sync invariant, all
1067            // non-tail blobs are durable; only the tail may have unfsynced data.
1068            None => first_in_blob(
1069                pruning_boundary,
1070                super::position_to_blob(size, items_per_blob),
1071                items_per_blob,
1072            )?,
1073        };
1074
1075        Ok(RecoveredBounds {
1076            size,
1077            recovery_watermark,
1078        })
1079    }
1080
1081    /// Recover the pruning boundary from the checkpoint hint if it still matches the oldest
1082    /// retained blob.
1083    ///
1084    /// A missing or blob-aligned hint means the blob boundary is authoritative. A mid-blob hint
1085    /// is trusted only when it belongs to the current oldest blob.
1086    fn recover_pruning_boundary(
1087        boundary_hint: Option<u64>,
1088        oldest_blob: Option<u64>,
1089        items_per_blob: u64,
1090    ) -> Result<u64, Error> {
1091        let blob_boundary = match oldest_blob {
1092            Some(oldest) => super::blob_first_position(oldest, items_per_blob)?,
1093            None => 0,
1094        };
1095
1096        let Some(boundary_hint) = boundary_hint else {
1097            return Ok(blob_boundary);
1098        };
1099        if boundary_hint.is_multiple_of(items_per_blob) {
1100            return Ok(blob_boundary);
1101        }
1102
1103        let hint_blob = super::position_to_blob(boundary_hint, items_per_blob);
1104        match oldest_blob {
1105            Some(oldest_blob) if hint_blob == oldest_blob => Ok(boundary_hint),
1106            Some(oldest_blob) if hint_blob < oldest_blob => {
1107                warn!(
1108                    hint_blob,
1109                    oldest_blob, "crash repair: boundary hint stale, computing from blobs"
1110                );
1111                Ok(blob_boundary)
1112            }
1113            Some(oldest_blob) => {
1114                // A hint ahead of blob state should never arise: prune removes blobs before
1115                // sync persists the checkpoint, and clear_to_size stages a clear intent.
1116                Err(Error::Corruption(format!(
1117                    "boundary hint references blob {hint_blob} \
1118                     but oldest blob is blob {oldest_blob}"
1119                )))
1120            }
1121            None => {
1122                // A mid-blob hint with no blobs should never arise: a staged clear is completed
1123                // before we get here, and no other operation removes all blobs without updating
1124                // the checkpoint.
1125                Err(Error::Corruption(format!(
1126                    "boundary hint references blob {hint_blob} but no blobs exist"
1127                )))
1128            }
1129        }
1130    }
1131
1132    /// Classify a blob's untrusted on-disk length against its capacity. A missing blob counts
1133    /// as zero length, surfacing as a gap.
1134    fn classify_fill(
1135        pending: &BTreeMap<u64, PagedRecovery<E::Blob>>,
1136        items_per_blob: u64,
1137        pruning_boundary: u64,
1138        blob: u64,
1139    ) -> Result<BlobFill, Error> {
1140        let len = pending
1141            .get(&blob)
1142            .map_or(0, |writer| writer.size() / Self::CHUNK_SIZE_U64);
1143        // A blob's capacity is `items_per_blob`, unless the pruning boundary falls mid-blob
1144        // (from `init_at_size`), in which case the skipped prefix reduces it.
1145        let start = super::blob_first_position(blob, items_per_blob)?;
1146        let skipped = pruning_boundary.saturating_sub(start).min(items_per_blob);
1147        let capacity = items_per_blob - skipped;
1148        Ok(match len.cmp(&capacity) {
1149            std::cmp::Ordering::Less => BlobFill::Short { len },
1150            std::cmp::Ordering::Equal => BlobFill::Full { len },
1151            std::cmp::Ordering::Greater => BlobFill::Overfull { len, capacity },
1152        })
1153    }
1154
1155    /// Recover size by walking blob lengths from oldest to newest, truncating at the
1156    /// first short or missing non-tail blob.
1157    ///
1158    /// `pruning_boundary` is trusted (already reconciled by `recover_pruning_boundary`); blob
1159    /// lengths are untrusted disk state. The returned size is chunk-exact and the retained
1160    /// prefix is contiguous.
1161    fn recover_by_walking_lengths(
1162        pending: &BTreeMap<u64, PagedRecovery<E::Blob>>,
1163        items_per_blob: u64,
1164        pruning_boundary: u64,
1165    ) -> Result<(u64, bool), Error> {
1166        let oldest = pending.keys().next().copied();
1167        let newest = pending.keys().next_back().copied();
1168
1169        let (Some(oldest), Some(newest)) = (oldest, newest) else {
1170            return Ok((pruning_boundary, false));
1171        };
1172
1173        let mut size = pruning_boundary;
1174        for blob in oldest..=newest {
1175            let fill = Self::classify_fill(pending, items_per_blob, pruning_boundary, blob)?;
1176            match fill {
1177                // Complete: count its items and keep walking.
1178                BlobFill::Full { len } => {
1179                    size = size.checked_add(len).ok_or(Error::OffsetOverflow)?;
1180                }
1181                // The newest blob is the append frontier; short is normal.
1182                BlobFill::Short { len } if blob == newest => {
1183                    size = size.checked_add(len).ok_or(Error::OffsetOverflow)?;
1184                    return Ok((size, false));
1185                }
1186                // A short or missing interior blob is a gap in durable data: everything newer
1187                // is unreachable. Truncate here.
1188                BlobFill::Short { len } => {
1189                    size = size.checked_add(len).ok_or(Error::OffsetOverflow)?;
1190                    return Ok((size, true));
1191                }
1192                BlobFill::Overfull { len, capacity } => {
1193                    return Err(Error::Corruption(format!(
1194                        "blob {blob} has too many items: expected at most {capacity}, got {len}"
1195                    )));
1196                }
1197            }
1198        }
1199
1200        Ok((size, false))
1201    }
1202
1203    /// See [Journal::init_at_size].
1204    #[commonware_macros::stability(ALPHA)]
1205    pub(crate) async fn init_at_size(context: E, cfg: Config, size: u64) -> Result<Self, Error> {
1206        // Fail before writing intent if existing blob partitions are already inconsistent.
1207        Partition::select(&context, &cfg.partition).await?;
1208
1209        // A journal sized at `u64::MAX` can never accept an append (the successor size
1210        // overflows), so reject it before opening the checkpoint.
1211        if size == u64::MAX {
1212            return Err(Error::SizeOverflow);
1213        }
1214        let checkpoint = Checkpoint::open(context.child("meta"), &cfg.partition).await?;
1215        Recovery::<E, A>::open_cleared(context, cfg, checkpoint, size, || async { Ok(()) })
1216            .await?
1217            .publish(u64::MAX)
1218            .await
1219    }
1220
1221    /// Begin durably persisting the data blobs.
1222    pub(super) async fn start_data_sync(
1223        mut self: Box<Self>,
1224    ) -> Result<(Box<Self>, Handle<()>), Error> {
1225        let (blobs, handle) = self.blobs.start_sync().await?;
1226        self.blobs = blobs;
1227        let completion: SyncCompletion = handle.boxed().shared();
1228        self.barrier.record(self.bounds.end, completion.clone());
1229        Ok((self, Handle::from_future(completion)))
1230    }
1231
1232    /// Begin raising the recovery watermark toward `size`, capped at the barrier.
1233    pub(super) async fn start_watermark_sync(
1234        mut self: Box<Self>,
1235        size: u64,
1236    ) -> Result<(Box<Self>, Handle<()>), Error> {
1237        let size = size.min(self.barrier.boundary());
1238        let (checkpoint, handle) = self.checkpoint.start_watermark_sync(size).await?;
1239        self.checkpoint = checkpoint;
1240        Ok((self, handle))
1241    }
1242
1243    /// See [Journal::start_sync].
1244    pub(crate) async fn start_sync(self: Box<Self>) -> Result<(Box<Self>, Handle<()>), Error> {
1245        self.metrics.start_sync_calls.inc();
1246
1247        // Start data durability, then cap watermark publication at the completed barrier.
1248        let (mut journal, data) = self.start_data_sync().await?;
1249        let size = journal.barrier.boundary();
1250        let (journal, watermark) = journal.start_watermark_sync(size).await?;
1251
1252        // Completion requires both the data and the selected watermark to be durable.
1253        let handle = Handle::from_future(async move {
1254            data.await?;
1255            watermark.await
1256        });
1257        Ok((journal, handle))
1258    }
1259
1260    /// See [Journal::commit].
1261    pub(crate) async fn commit(mut self: Box<Self>) -> Result<Box<Self>, Error> {
1262        let _timer = self.metrics.commit_timer();
1263        self.metrics.commit_calls.inc();
1264        let size = self.bounds.end;
1265        let (blobs, handle) = self.blobs.start_sync().await?;
1266        self.blobs = blobs;
1267        handle.await?;
1268        self.barrier.mark_durable(size);
1269        Ok(self)
1270    }
1271
1272    /// See [Journal::sync].
1273    pub(crate) async fn sync(mut self: Box<Self>) -> Result<Box<Self>, Error> {
1274        let _timer = self.metrics.sync_timer();
1275        self.metrics.sync_calls.inc();
1276
1277        // Complete data durability before advancing the checkpoint that acknowledges it.
1278        let size = self.bounds.end;
1279        let (blobs, handle) = self.blobs.start_sync().await?;
1280        self.blobs = blobs;
1281        handle.await?;
1282        self.barrier.mark_durable(size);
1283
1284        // Publish the durable prefix as the recovery watermark.
1285        self.checkpoint = self
1286            .checkpoint
1287            .persist(self.items_per_blob.get(), self.bounds.start, size)
1288            .await?;
1289        Ok(self)
1290    }
1291
1292    /// See [Journal::snapshot].
1293    pub(crate) async fn snapshot(
1294        mut self: Box<Self>,
1295    ) -> Result<(Box<Self>, Reader<'static, E, A>), Error> {
1296        let (blobs, snapshot) = self.blobs.snapshot().await?;
1297        self.blobs = blobs;
1298        let reader = Reader {
1299            blobs: snapshot,
1300            bounds: self.bounds.clone(),
1301            items_per_blob: self.items_per_blob,
1302            metrics: self.metrics.clone(),
1303            _phantom: PhantomData,
1304        };
1305        Ok((self, reader))
1306    }
1307
1308    /// A reader borrowing the journal's live state.
1309    pub(super) fn reader(&self) -> Reader<'_, E, A> {
1310        Reader {
1311            blobs: self.blobs.reader(),
1312            bounds: self.bounds.clone(),
1313            items_per_blob: self.items_per_blob,
1314            metrics: self.metrics.clone(),
1315            _phantom: PhantomData,
1316        }
1317    }
1318
1319    /// Return the total number of items in the journal, irrespective of pruning. The next value
1320    /// appended to the journal will be at this position.
1321    pub const fn size(&self) -> u64 {
1322        self.bounds.end
1323    }
1324
1325    /// See [Journal::append].
1326    pub(crate) async fn append(mut self: Box<Self>, item: &A) -> Result<(Box<Self>, u64), Error> {
1327        let _timer = self.metrics.append_timer();
1328        self.metrics.append_calls.inc();
1329        let new_size = self.bounds.end.checked_add(1).ok_or(Error::SizeOverflow)?;
1330        if self.blobs.try_append_value(item).is_none() {
1331            return self
1332                .append_many_inner(Many::Flat(std::slice::from_ref(item)))
1333                .await;
1334        }
1335        self = self.advance_tail(new_size).await?;
1336        let position = self.finish_append();
1337        Ok((self, position))
1338    }
1339
1340    /// See [Journal::append_many].
1341    pub(crate) async fn append_many(
1342        self: Box<Self>,
1343        items: Many<'_, A>,
1344    ) -> Result<(Box<Self>, u64), Error> {
1345        let _timer = self.metrics.append_many_timer();
1346        self.metrics.append_many_calls.inc();
1347        self.append_many_inner(items).await
1348    }
1349
1350    // Shared implementation for `append` and `append_many`; public wrappers record metrics.
1351    async fn append_many_inner(
1352        self: Box<Self>,
1353        items: Many<'_, A>,
1354    ) -> Result<(Box<Self>, u64), Error> {
1355        let prepared = self.prepare_append(items);
1356        self.write_encoded(prepared).await
1357    }
1358
1359    /// See [Journal::prepare_append].
1360    pub(crate) fn prepare_append(&self, items: Many<'_, A>) -> PreparedAppend<A> {
1361        // Encode all items into a single contiguous buffer up front.
1362        // Uses Write::write directly to avoid per-item Bytes allocations from Encode::encode.
1363        let mut buf = Vec::with_capacity(items.len() * A::SIZE);
1364        match items {
1365            Many::Flat(items) => {
1366                for item in items {
1367                    item.write(&mut buf);
1368                }
1369            }
1370            Many::Nested(nested_items) => {
1371                for items in nested_items {
1372                    for item in *items {
1373                        item.write(&mut buf);
1374                    }
1375                }
1376            }
1377        }
1378        PreparedAppend {
1379            buf,
1380            _marker: PhantomData,
1381        }
1382    }
1383
1384    /// See [Journal::append_prepared].
1385    pub(crate) async fn append_prepared(
1386        self: Box<Self>,
1387        prepared: PreparedAppend<A>,
1388    ) -> Result<(Box<Self>, u64), Error> {
1389        let _timer = self.metrics.append_prepared_timer();
1390        self.metrics.append_prepared_calls.inc();
1391        self.write_encoded(prepared).await
1392    }
1393
1394    // Write pre-encoded items; shared by all append paths. Records no call metrics.
1395    async fn write_encoded(
1396        mut self: Box<Self>,
1397        prepared: PreparedAppend<A>,
1398    ) -> Result<(Box<Self>, u64), Error> {
1399        let items_buf = prepared.buf;
1400        let items_count = items_buf.len() / A::SIZE;
1401        if items_count == 0 {
1402            return Err(Error::EmptyAppend);
1403        }
1404        let items_buf = IoBuf::from(items_buf);
1405
1406        // Reject the append before writing anything if it would push the size past `u64::MAX`.
1407        // This keeps the in-loop size arithmetic safe.
1408        self.bounds
1409            .end
1410            .checked_add(items_count as u64)
1411            .ok_or(Error::SizeOverflow)?;
1412
1413        // Split the batch at blob boundaries so each full tail can be sealed before continuing.
1414        let mut written = 0;
1415        while written < items_count {
1416            let batch_count = super::batch_count_to_blob_boundary(
1417                self.bounds.end,
1418                items_count - written,
1419                self.items_per_blob.get(),
1420            );
1421            let start = written * A::SIZE;
1422            let end = start + batch_count * A::SIZE;
1423            let new_size = self.bounds.end + batch_count as u64;
1424
1425            // Buffer a batch in place when it fits. Otherwise own the writer across the flush.
1426            if self
1427                .blobs
1428                .try_append(&items_buf.as_ref()[start..end])
1429                .is_none()
1430            {
1431                (self.blobs, _) = self.blobs.append_owned(items_buf.slice(start..end)).await?;
1432            }
1433            self = self.advance_tail(new_size).await?;
1434            written += batch_count;
1435        }
1436
1437        let position = self.finish_append();
1438        Ok((self, position))
1439    }
1440
1441    // Advance appended bounds and rotate a full tail. Both append paths use the same ordering.
1442    async fn advance_tail(mut self: Box<Self>, new_size: u64) -> Result<Box<Self>, Error> {
1443        self.bounds.end = new_size;
1444        if new_size.is_multiple_of(self.items_per_blob.get()) {
1445            self.blobs = self.blobs.seal_tail().await?;
1446        }
1447        Ok(self)
1448    }
1449
1450    // Record state metrics once after a successful append, including multi-blob batches.
1451    fn finish_append(&self) -> u64 {
1452        self.metrics.update(
1453            self.bounds.end,
1454            self.bounds.start,
1455            self.items_per_blob.get(),
1456        );
1457        self.bounds.end - 1
1458    }
1459
1460    /// Return the location before which all items have been pruned.
1461    pub const fn pruning_boundary(&self) -> u64 {
1462        self.bounds.start
1463    }
1464
1465    /// See [Journal::prune].
1466    pub(crate) async fn prune(
1467        mut self: Box<Self>,
1468        min_item_pos: u64,
1469    ) -> Result<(Box<Self>, bool), Error> {
1470        // Calculate the blob that would contain min_item_pos, capped to the tail (which is
1471        // guaranteed to exist by our invariant).
1472        let target_blob = super::position_to_blob(min_item_pos, self.items_per_blob.get());
1473        let tail_blob = super::position_to_blob(self.bounds.end, self.items_per_blob.get());
1474        let min_blob = std::cmp::min(target_blob, tail_blob);
1475
1476        if min_blob <= self.blobs.oldest_blob_index() {
1477            return Ok((self, false));
1478        }
1479
1480        // Make all data durable before removing any: the prune target may be justified by an
1481        // appended-but-unflushed item (e.g. a consumer's commit record), and removals are
1482        // durable, so pruning without this sync could leave a recovered journal whose
1483        // surviving items no longer justify its boundary. The sync also covers unsynced
1484        // survivors above the boundary: removal may be interrupted, and recovery truncates at
1485        // the first torn item, so an unsynced survivor could discard every synced blob
1486        // behind it.
1487        let (blobs, sync) = self.blobs.start_sync().await?;
1488        self.blobs = blobs;
1489        sync.await?;
1490        self.barrier.mark_durable(self.bounds.end);
1491
1492        let new_boundary = super::blob_first_position(min_blob, self.items_per_blob.get())?;
1493        self.blobs = self.blobs.prune(min_blob).await?;
1494        self.bounds.start = new_boundary;
1495
1496        self.metrics.update(
1497            self.bounds.end,
1498            self.bounds.start,
1499            self.items_per_blob.get(),
1500        );
1501
1502        Ok((self, true))
1503    }
1504
1505    /// Wait for every started sync of the data and the checkpoint to complete, surfacing a
1506    /// failure without changing storage.
1507    pub(super) async fn wait_for_sync(mut self) -> Result<Self, Error> {
1508        self.blobs = self.blobs.wait_for_sync().await?;
1509        self.checkpoint.wait_for_sync().await?;
1510        Ok(self)
1511    }
1512
1513    /// See [Journal::destroy].
1514    pub(crate) async fn destroy(self) -> Result<(), Error> {
1515        // A failed checkpoint sync must surface before any data is removed, since the surviving
1516        // checkpoint would otherwise acknowledge items the removed blobs no longer back.
1517        let mut journal = self.wait_for_sync().await?;
1518
1519        // Stage a clear to zero before removing anything: a destroy interrupted after the intent is
1520        // durable reopens as a fresh journal, and one interrupted before it reopens as a crash
1521        // would recover it.
1522        journal.checkpoint = journal.checkpoint.stage_clear(0).await?;
1523        journal.blobs.destroy().await?;
1524        journal.checkpoint.destroy().await
1525    }
1526
1527    /// Clear all data and advance the journal to a new starting position.
1528    ///
1529    /// Unlike `destroy`, this keeps the journal alive so it can be reused. After clearing, the
1530    /// journal will behave as if initialized with `init_at_size(new_size)`.
1531    ///
1532    /// # Crash Safety
1533    ///
1534    /// In the event of a crash during this call, upon restart recovery will ensure the journal is
1535    /// either still in its prior state, or has bounds `new_size..new_size`.
1536    #[commonware_macros::stability(ALPHA)]
1537    pub(crate) async fn clear_to_size(
1538        mut self: Box<Self>,
1539        new_size: u64,
1540    ) -> Result<Box<Self>, Error> {
1541        // Live reset advances the append position. Prefix selection belongs to initialization.
1542        if new_size < self.bounds.end {
1543            return Err(Error::ItemOutOfRange(new_size));
1544        }
1545
1546        // A journal sized at `u64::MAX` can never accept an append, matching `init_at_size`.
1547        if new_size == u64::MAX {
1548            return Err(Error::SizeOverflow);
1549        }
1550
1551        // Durably record the intent first, so a crash mid-clear is finished on reopen.
1552        self.checkpoint = self.checkpoint.stage_clear(new_size).await?;
1553
1554        // Remove every blob, then start fresh at the new size.
1555        self.blobs = self
1556            .blobs
1557            .clear(super::position_to_blob(new_size, self.items_per_blob.get()))
1558            .await?;
1559        self.bounds = new_size..new_size;
1560        self.barrier = Barrier::new(new_size);
1561
1562        // Complete the clear in the checkpoint.
1563        self.checkpoint = self
1564            .checkpoint
1565            .finish_clear(self.items_per_blob.get(), new_size)
1566            .await?;
1567
1568        self.metrics.update(
1569            self.bounds.end,
1570            self.bounds.start,
1571            self.items_per_blob.get(),
1572        );
1573        Ok(self)
1574    }
1575
1576    /// Durably stage a clear to `new_size` without completing it.
1577    ///
1578    /// This records a recoverable intent so a caller can clear dependent sibling state before
1579    /// calling `clear_to_size` to finish. If a crash interrupts the sequence, the next `init`
1580    /// completes the staged clear. The follow-up `clear_to_size` re-stages the same target
1581    /// idempotently.
1582    #[commonware_macros::stability(ALPHA)]
1583    pub(super) async fn stage_clear_intent(
1584        mut self: Box<Self>,
1585        new_size: u64,
1586    ) -> Result<Box<Self>, Error> {
1587        // A journal sized at `u64::MAX` can never accept an append, matching `init_at_size`.
1588        if new_size == u64::MAX {
1589            return Err(Error::SizeOverflow);
1590        }
1591        if new_size < self.bounds.end {
1592            return Err(Error::ItemOutOfRange(new_size));
1593        }
1594        self.checkpoint = self.checkpoint.stage_clear(new_size).await?;
1595        Ok(self)
1596    }
1597}
1598
1599/// Implementation of [super::Mutable] for fixed-size value journals.
1600///
1601/// # Repair
1602///
1603/// Like
1604/// [sqlite](https://github.com/sqlite/sqlite/blob/8658a8df59f00ec8fcfea336a2a6a4b5ef79d2ee/src/wal.c#L1504-L1505)
1605/// and
1606/// [rocksdb](https://github.com/facebook/rocksdb/blob/0c533e61bc6d89fdf1295e8e0bcee4edb3aef401/include/rocksdb/options.h#L441-L445),
1607/// the first invalid data read above the recovery watermark's acknowledged prefix is considered
1608/// the new end of the journal (and the underlying blob is truncated to the last valid item).
1609/// Data at or below that prefix is never repaired: a blob that no longer backs its acknowledged
1610/// items fails init, and other damage there surfaces as a read error. Repair is performed during
1611/// init.
1612///
1613/// Mutating functions consume the journal and return it only on success: an error (or a dropped
1614/// future) destroys the handle.
1615pub struct Journal<E: Context, A>(Box<Inner<E, A>>);
1616
1617impl<E: Context, A: CodecFixedShared> std::fmt::Debug for Journal<E, A> {
1618    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1619        f.debug_struct("Journal")
1620            .field("bounds", &super::Contiguous::bounds(self))
1621            .finish_non_exhaustive()
1622    }
1623}
1624
1625impl<E: Context, A: CodecFixedShared> Journal<E, A> {
1626    /// Initialize a new `Journal` instance.
1627    ///
1628    /// All backing blobs are opened during initialization. Recovery scans the two newest blobs,
1629    /// skipping blobs and pages the checkpoint watermark already acknowledges, and truncates
1630    /// each to the whole items backed by valid pages. The `replay` method can be used to
1631    /// iterate over all items in the `Journal`.
1632    pub async fn init(context: E, cfg: Config) -> Result<Self, Error> {
1633        Ok(Self(Box::new(Inner::init(context, cfg).await?)))
1634    }
1635
1636    /// Open a retained prefix containing at most `max_size` items, including pruned positions.
1637    ///
1638    /// A cap above the recovered end preserves that end. A cap below the retained start returns
1639    /// [Error::ItemPruned]. Successful initialization durably discards the suffix. Subsequent
1640    /// appends may exceed the cap.
1641    pub async fn init_at_most(context: E, cfg: Config, max_size: u64) -> Result<Self, Error> {
1642        let checkpoint = Checkpoint::open(context.child("meta"), &cfg.partition).await?;
1643        let recovery = Recovery::<E, A>::open(context, cfg, checkpoint, Some(max_size)).await?;
1644        Ok(Self(Box::new(recovery.publish(max_size).await?)))
1645    }
1646
1647    /// Initialize a `Journal` in a fully-pruned state at `size`: existing data is cleared and the
1648    /// journal behaves as if `size` items were appended then pruned. It is empty (`bounds` is
1649    /// `size..size`) and the next `append` writes at position `size`. Used for state sync.
1650    ///
1651    /// # Crash Safety
1652    /// In the event of a crash during this call, upon restart recovery will ensure the journal is
1653    /// either still in its prior state, or has bounds `size..size`.
1654    #[commonware_macros::stability(ALPHA)]
1655    pub async fn init_at_size(context: E, cfg: Config, size: u64) -> Result<Self, Error> {
1656        Ok(Self(Box::new(
1657            Inner::init_at_size(context, cfg, size).await?,
1658        )))
1659    }
1660
1661    /// Discard all items and reposition the journal at or beyond its current end.
1662    #[commonware_macros::stability(ALPHA)]
1663    pub(crate) async fn clear_to_size(mut self, new_size: u64) -> Result<Self, Error> {
1664        self.0 = self.0.clear_to_size(new_size).await?;
1665        Ok(self)
1666    }
1667
1668    /// Durably persists the current state of the structure.
1669    ///
1670    /// Does not advance the recovery watermark, so reopen may replay entries above it. Use
1671    /// `sync()` to advance the watermark and to ensure that a crash after this call doesn't
1672    /// require any recovery.
1673    pub async fn commit(mut self) -> Result<Self, Error> {
1674        self.0 = self.0.commit().await?;
1675        Ok(self)
1676    }
1677
1678    /// Begin durably persisting the current state of the structure.
1679    ///
1680    /// Awaiting the returned [Handle] guarantees state appended before this call survives a
1681    /// crash. Also tries to advance the recovery watermark to the previous proven durable
1682    /// size, bounding startup recovery. Only `sync()` guarantees a current watermark.
1683    ///
1684    /// At most one data sync and one watermark sync are in flight at a time: this call waits for
1685    /// the prior call's syncs before starting new ones. It does not wait for a pending rollover
1686    /// fsync: the returned handle joins it, so an earlier call's handle may still be pending when
1687    /// this call returns. Reads proceed while the returned handle is pending, and appends proceed
1688    /// while they fit in the write buffer.
1689    ///
1690    /// Flush errors are returned directly. Dropping the handle does not cancel the sync or lose its
1691    /// failure: a later operation reports it, no later than the next [Self::sync].
1692    pub async fn start_sync(mut self) -> Result<(Self, Handle<()>), Error> {
1693        let (inner, handle) = self.0.start_sync().await?;
1694        self.0 = inner;
1695        Ok((self, handle))
1696    }
1697
1698    /// Durably persist the current state of the structure, ensuring no recovery is required in the
1699    /// event of a crash following this call.
1700    ///
1701    /// Advances the recovery watermark to the current size.
1702    pub async fn sync(mut self) -> Result<Self, Error> {
1703        self.0 = self.0.sync().await?;
1704        Ok(self)
1705    }
1706
1707    /// Capture an owned snapshot ([`Reader`]) over the current journal. Bounds are frozen at
1708    /// creation, and the snapshot stays readable across concurrent appends and prunes. It keeps
1709    /// the journal's blobs open, so reopening a partition that still holds one of them fails
1710    /// while the snapshot is alive.
1711    pub async fn snapshot(mut self) -> Result<(Self, Reader<'static, E, A>), Error> {
1712        let (inner, reader) = self.0.snapshot().await?;
1713        self.0 = inner;
1714        Ok((self, reader))
1715    }
1716
1717    /// Return the total number of items in the journal, irrespective of pruning. The next value
1718    /// appended to the journal will be at this position.
1719    pub fn size(&self) -> u64 {
1720        self.0.size()
1721    }
1722
1723    /// Append a new item to the journal, returning its position.
1724    ///
1725    /// # Errors
1726    ///
1727    /// Returns an error if the underlying storage operation fails.
1728    pub async fn append(mut self, item: &A) -> Result<(Self, u64), Error> {
1729        let (inner, position) = self.0.append(item).await?;
1730        self.0 = inner;
1731        Ok((self, position))
1732    }
1733
1734    /// Append items to the journal, returning the position of the last item appended.
1735    ///
1736    /// Returns [Error::EmptyAppend] if items is empty.
1737    pub async fn append_many(mut self, items: Many<'_, A>) -> Result<(Self, u64), Error> {
1738        let (inner, position) = self.0.append_many(items).await?;
1739        self.0 = inner;
1740        Ok((self, position))
1741    }
1742
1743    /// Encode `items` into a buffer that can be appended later with [`Self::append_prepared`].
1744    ///
1745    /// This lets callers serialize borrowed items synchronously, release those borrows, and
1746    /// perform the append without holding unrelated locks across journal I/O.
1747    pub fn prepare_append(&self, items: Many<'_, A>) -> PreparedAppend<A> {
1748        self.0.prepare_append(items)
1749    }
1750
1751    /// Append items encoded by [`Self::prepare_append`], returning the position of the last item
1752    /// appended.
1753    ///
1754    /// Returns [Error::EmptyAppend] if `prepared` contains no items.
1755    pub async fn append_prepared(
1756        mut self,
1757        prepared: PreparedAppend<A>,
1758    ) -> Result<(Self, u64), Error> {
1759        let (inner, position) = self.0.append_prepared(prepared).await?;
1760        self.0 = inner;
1761        Ok((self, position))
1762    }
1763
1764    /// Return the location before which all items have been pruned.
1765    pub fn pruning_boundary(&self) -> u64 {
1766        self.0.pruning_boundary()
1767    }
1768
1769    /// Allow the journal to prune items older than `min_item_pos`. The journal may not prune all
1770    /// such items in order to preserve blob boundaries, but the amount of such items will always be
1771    /// less than the configured number of items per blob. Returns true if any items were pruned.
1772    ///
1773    /// Readers holding earlier snapshots keep reading pruned blobs through their own handles;
1774    /// later snapshots observe [Error::ItemPruned].
1775    ///
1776    /// Note that this operation may NOT be atomic, however it's guaranteed not to leave gaps in the
1777    /// event of failure as items are always pruned in order from oldest to newest.
1778    pub async fn prune(mut self, min_item_pos: u64) -> Result<(Self, bool), Error> {
1779        let (inner, pruned) = self.0.prune(min_item_pos).await?;
1780        self.0 = inner;
1781        Ok((self, pruned))
1782    }
1783
1784    /// Remove any persisted data created by the journal. A started sync that failed is reported
1785    /// before anything is removed.
1786    ///
1787    /// # Crash Safety
1788    ///
1789    /// An interrupted destroy leaves a journal that reopens either as a crash would recover it or
1790    /// as a fresh journal at position zero, and that a retried destroy removes.
1791    pub async fn destroy(self) -> Result<(), Error> {
1792        self.0.destroy().await
1793    }
1794}
1795
1796/// A reader over a fixed journal.
1797pub struct Reader<'a, E: Context, A> {
1798    blobs: Blobs<'a, E::Blob>,
1799    bounds: Range<u64>,
1800    items_per_blob: NonZeroU64,
1801    metrics: Arc<Metrics<E>>,
1802    _phantom: PhantomData<A>,
1803}
1804
1805impl<E: Context, A: CodecFixedShared> Reader<'_, E, A> {
1806    /// Validate a position to be read: must lie within `bounds`.
1807    const fn validate_readable(&self, pos: u64) -> Result<(), Error> {
1808        if pos >= self.bounds.end {
1809            return Err(Error::ItemOutOfRange(pos));
1810        }
1811        if pos < self.bounds.start {
1812            return Err(Error::ItemPruned(pos));
1813        }
1814        Ok(())
1815    }
1816
1817    /// Resolve a blob-sharing group of positions to its blob number and per-position byte
1818    /// offsets within the blob.
1819    fn locate_group(&self, group: &[u64]) -> Result<(u64, Vec<u64>), Error> {
1820        let items_per_blob = self.items_per_blob.get();
1821        let blob = super::position_to_blob(group[0], items_per_blob);
1822        let first_position = first_in_blob(self.bounds.start, blob, items_per_blob)?;
1823        let mut offsets = Vec::with_capacity(group.len());
1824        for &pos in group {
1825            offsets.push(Inner::<E, A>::items_to_bytes(pos - first_position)?);
1826        }
1827        Ok((blob, offsets))
1828    }
1829
1830    /// Shared body of [`super::Contiguous::read_many`] and the variable journal's offsets
1831    /// reads; the callers record the batch-read metrics, so routing them through `read_many`
1832    /// would count every batch twice.
1833    pub(super) async fn read_many_inner(&self, positions: &[u64]) -> Result<Vec<A>, Error> {
1834        if positions.is_empty() {
1835            return Ok(Vec::new());
1836        }
1837        assert!(
1838            positions.is_sorted_by(|a, b| a < b),
1839            "positions must be strictly increasing"
1840        );
1841        for &pos in positions {
1842            self.validate_readable(pos)?;
1843        }
1844
1845        let items_per_blob = self.items_per_blob.get();
1846
1847        // Read all positions grouped by blob. Positions are sorted, so `chunk_by` splits them into
1848        // maximal runs that share one blob. Each group goes through the blob's batched read,
1849        // which serves page-cache and tip-buffer hits under a single lock acquisition and reads only
1850        // true misses from the blob (concurrently).
1851        let mut result: Vec<A> = Vec::with_capacity(positions.len());
1852        let mut buf = vec![0u8; positions.len() * A::SIZE];
1853
1854        // The buffer is pre-sized for every position, so each group can own a disjoint slice and
1855        // all groups can read concurrently.
1856        let mut reads = Vec::new();
1857        let mut remaining_buf = buf.as_mut_slice();
1858        for group in positions.chunk_by(|a, b| {
1859            super::position_to_blob(*a, items_per_blob)
1860                == super::position_to_blob(*b, items_per_blob)
1861        }) {
1862            let (blob_num, blob_offsets) = self.locate_group(group)?;
1863            let blob = self
1864                .blobs
1865                .get(blob_num)
1866                .expect("positions in bounds map to a retained blob");
1867            let (group_buf, rest) = remaining_buf.split_at_mut(group.len() * A::SIZE);
1868            remaining_buf = rest;
1869            reads.push(async move {
1870                blob.read_many_into(group_buf, &blob_offsets, Inner::<E, A>::CHUNK_SIZE)
1871                    .await
1872            });
1873        }
1874        let hits: u64 = try_join_all(reads)
1875            .await?
1876            .into_iter()
1877            .map(|group_hits| group_hits as u64)
1878            .sum();
1879
1880        let mut bytes = Bytes::from(buf);
1881        for _ in positions {
1882            result.push(A::decode((&mut bytes).take(A::SIZE)).map_err(Error::Codec)?);
1883        }
1884
1885        self.metrics.cache_hits.inc_by(hits);
1886        self.metrics
1887            .cache_misses
1888            .inc_by(positions.len() as u64 - hits);
1889        self.metrics.items_read.inc_by(positions.len() as u64);
1890        Ok(result)
1891    }
1892
1893    /// Resolve `pos` to its blob and byte offset within the blob.
1894    fn locate(&self, pos: u64) -> Result<(Blob<'_, E::Blob>, u64), Error> {
1895        self.validate_readable(pos)?;
1896        let items_per_blob = self.items_per_blob.get();
1897        let blob = super::position_to_blob(pos, items_per_blob);
1898        let pos_in_blob = pos - first_in_blob(self.bounds.start, blob, items_per_blob)?;
1899        let offset = Inner::<E, A>::items_to_bytes(pos_in_blob)?;
1900        let blob = self
1901            .blobs
1902            .get(blob)
1903            .expect("position in bounds maps to a retained blob");
1904        Ok((blob, offset))
1905    }
1906
1907    /// Read `pos` from storage, for callers that already missed the page cache.
1908    pub(super) async fn read_miss(&self, pos: u64) -> Result<A, Error> {
1909        let _timer = self.metrics.read_timer();
1910        let (blob, offset) = self.locate(pos)?;
1911        self.metrics.cache_misses.inc();
1912        let bufs = blob.read_at(offset, A::SIZE).await?;
1913        let item = A::decode(bufs.coalesce()).map_err(Error::Codec)?;
1914        self.metrics.items_read.inc();
1915        Ok(item)
1916    }
1917
1918    /// Probe `positions` (strictly increasing) against the page cache, returning one slot per
1919    /// position: `Some(item)` for sync hits and `None` for positions that require I/O, fail to
1920    /// decode, or fall outside `bounds()`.
1921    pub(super) fn probe_items(&self, positions: &[u64]) -> Vec<Option<A>> {
1922        assert!(
1923            positions.is_sorted_by(|a, b| a < b),
1924            "positions must be strictly increasing"
1925        );
1926        let mut out: Vec<Option<A>> = (0..positions.len()).map(|_| None).collect();
1927
1928        // Sorted positions put pruned ones in a prefix and out-of-range ones in a suffix, so
1929        // validation trims the batch instead of poisoning a blob group that also holds valid
1930        // positions.
1931        let start = positions.partition_point(|&pos| pos < self.bounds.start);
1932        let end = positions.partition_point(|&pos| pos < self.bounds.end);
1933        let valid = &positions[start..end];
1934        if valid.is_empty() {
1935            return out;
1936        }
1937
1938        // Serve the probe from the per-thread scratch buffer. Stale bytes from a previous probe
1939        // are harmless: slots the cache cannot serve are reported as misses and never decoded.
1940        let items_per_blob = self.items_per_blob.get();
1941        let mut scratch =
1942            Cached::take(&PROBE_SCRATCH, || Ok::<_, ()>(Vec::new()), |_| Ok(())).unwrap();
1943        let need = valid.len() * A::SIZE;
1944        if scratch.len() < need {
1945            scratch.resize(need, 0);
1946        }
1947        let buf = &mut scratch[..need];
1948        let mut hits = 0u64;
1949        let mut group_base = start;
1950        for group in valid.chunk_by(|a, b| {
1951            super::position_to_blob(*a, items_per_blob)
1952                == super::position_to_blob(*b, items_per_blob)
1953        }) {
1954            let base = group_base;
1955            group_base += group.len();
1956            let Ok((blob_num, blob_offsets)) = self.locate_group(group) else {
1957                continue;
1958            };
1959            let Some(blob) = self.blobs.get(blob_num) else {
1960                continue;
1961            };
1962            let buf = &mut buf[..group.len() * A::SIZE];
1963            let misses =
1964                blob.try_read_many_sync_into(buf, &blob_offsets, Inner::<E, A>::CHUNK_SIZE);
1965            let mut misses = misses.into_iter().peekable();
1966            #[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)]
1967            for (idx, slice) in buf.chunks_exact(A::SIZE).enumerate() {
1968                if misses.peek() == Some(&idx) {
1969                    misses.next();
1970                    continue;
1971                }
1972                // A decode failure declines to a miss: the async completion re-reads the
1973                // item and bubbles the failure as [Error::Codec], like every async read path.
1974                if let Ok(item) = A::decode(Copying(slice)) {
1975                    out[base + idx] = Some(item);
1976                    hits += 1;
1977                }
1978            }
1979        }
1980        self.metrics.cache_hits.inc_by(hits);
1981        self.metrics.items_read.inc_by(hits);
1982        out
1983    }
1984}
1985
1986impl<E: Context, A: CodecFixedShared> super::Contiguous for Reader<'_, E, A> {
1987    type Item = A;
1988
1989    fn bounds(&self) -> Range<u64> {
1990        self.bounds.clone()
1991    }
1992
1993    async fn read(&self, pos: u64) -> Result<A, Error> {
1994        self.metrics.read_calls.inc();
1995
1996        // Serve from the page cache synchronously when possible, avoiding the async storage path.
1997        if let Some(item) = self.try_read_sync(pos) {
1998            return Ok(item);
1999        }
2000        self.read_miss(pos).await
2001    }
2002
2003    async fn read_many(&self, positions: &[u64]) -> Result<Vec<A>, Error> {
2004        if positions.is_empty() {
2005            return Ok(Vec::new());
2006        }
2007        let _timer = self.metrics.read_many_timer();
2008        self.metrics.read_many_calls.inc();
2009        self.read_many_inner(positions).await
2010    }
2011
2012    fn try_read_sync(&self, pos: u64) -> Option<A> {
2013        let (blob, offset) = self.locate(pos).ok()?;
2014        let mut scratch =
2015            Cached::take(&PROBE_SCRATCH, || Ok::<_, ()>(Vec::new()), |_| Ok(())).unwrap();
2016        if scratch.len() < A::SIZE {
2017            scratch.resize(A::SIZE, 0);
2018        }
2019        let buf = &mut scratch[..A::SIZE];
2020        let item = if blob.try_read_sync_into(buf, offset) {
2021            A::decode(Copying(buf)).ok()
2022        } else {
2023            None
2024        };
2025        if item.is_some() {
2026            self.metrics.cache_hits.inc();
2027            self.metrics.items_read.inc();
2028        }
2029        item
2030    }
2031
2032    fn try_read_many_sync(&self, positions: &[u64]) -> Vec<Option<A>> {
2033        self.probe_items(positions)
2034    }
2035
2036    async fn replay(
2037        &self,
2038        range: Range<u64>,
2039        buffer: NonZeroUsize,
2040        read_options: ReadOptions,
2041    ) -> Result<impl Stream<Item = Result<Batch<A>, Error>> + Send, Error> {
2042        replay_stream(
2043            &self.blobs,
2044            self.bounds.clone(),
2045            self.items_per_blob,
2046            range,
2047            buffer,
2048            read_options,
2049        )
2050    }
2051}
2052
2053impl<E: Context, A: CodecFixedShared> super::Contiguous for Journal<E, A> {
2054    type Item = A;
2055
2056    fn bounds(&self) -> Range<u64> {
2057        self.0.bounds.clone()
2058    }
2059
2060    async fn read(&self, pos: u64) -> Result<A, Error> {
2061        self.0.reader().read(pos).await
2062    }
2063
2064    async fn read_many(&self, positions: &[u64]) -> Result<Vec<A>, Error> {
2065        self.0.reader().read_many(positions).await
2066    }
2067
2068    fn try_read_sync(&self, pos: u64) -> Option<A> {
2069        self.0.reader().try_read_sync(pos)
2070    }
2071
2072    fn try_read_many_sync(&self, positions: &[u64]) -> Vec<Option<A>> {
2073        self.0.reader().probe_items(positions)
2074    }
2075
2076    async fn replay(
2077        &self,
2078        range: Range<u64>,
2079        buffer: NonZeroUsize,
2080        read_options: ReadOptions,
2081    ) -> Result<impl Stream<Item = Result<Batch<A>, Error>> + Send, Error> {
2082        let blobs = self.0.blobs.reader();
2083        replay_stream(
2084            &blobs,
2085            self.0.bounds.clone(),
2086            self.0.items_per_blob,
2087            range,
2088            buffer,
2089            read_options,
2090        )
2091    }
2092}
2093
2094impl<E: Context, A: CodecFixedShared> Mutable for Journal<E, A> {
2095    async fn append(self, item: &Self::Item) -> Result<(Self, u64), Error> {
2096        Self::append(self, item).await
2097    }
2098
2099    async fn append_many(self, items: Many<'_, Self::Item>) -> Result<(Self, u64), Error> {
2100        Self::append_many(self, items).await
2101    }
2102
2103    async fn prune(self, min_position: u64) -> Result<(Self, bool), Error> {
2104        Self::prune(self, min_position).await
2105    }
2106
2107    async fn start_sync(self) -> Result<(Self, Handle<()>), Error> {
2108        Self::start_sync(self).await
2109    }
2110
2111    async fn commit(self) -> Result<Self, Error> {
2112        Self::commit(self).await
2113    }
2114
2115    async fn sync(self) -> Result<Self, Error> {
2116        Self::sync(self).await
2117    }
2118
2119    async fn destroy(self) -> Result<(), Error> {
2120        Self::destroy(self).await
2121    }
2122}
2123
2124#[commonware_macros::stability(ALPHA)]
2125impl<E: Context, A: CodecFixedShared> authenticated::BackingRecovery for Recovery<E, A> {
2126    type Journal = Journal<E, A>;
2127
2128    fn bounds(&self) -> Range<u64> {
2129        self.bounds.clone()
2130    }
2131
2132    async fn read(&self, pos: u64) -> Result<A, Error> {
2133        Self::item(self, pos).await
2134    }
2135
2136    async fn reset(self, size: u64) -> Result<Self, Error> {
2137        Ok(*Box::new(self).clear_to_size(size).await?)
2138    }
2139
2140    async fn finish(self, size: u64) -> Result<Self::Journal, Error> {
2141        Journal(Box::new(Self::publish(self, size).await?))
2142            .commit()
2143            .await
2144    }
2145}
2146
2147#[commonware_macros::stability(ALPHA)]
2148impl<E: Context, A: CodecFixedShared> authenticated::Backing<E> for Journal<E, A> {
2149    type Recovery = Recovery<E, A>;
2150
2151    async fn recover(
2152        context: E,
2153        cfg: Self::Config,
2154        max_size: Option<u64>,
2155    ) -> Result<Self::Recovery, Error> {
2156        let checkpoint = Checkpoint::open(context.child("meta"), &cfg.partition).await?;
2157        Recovery::open(context, cfg, checkpoint, max_size).await
2158    }
2159
2160    async fn clear(context: E, cfg: Self::Config, size: u64) -> Result<Self::Recovery, Error> {
2161        let checkpoint = Checkpoint::open(context.child("meta"), &cfg.partition).await?;
2162
2163        // A staged clear already owns both blob partitions. Otherwise fail before writing intent
2164        // if they are inconsistent.
2165        if checkpoint.clear_target().is_none() {
2166            Partition::select(&context, &cfg.partition).await?;
2167        }
2168        Recovery::open_cleared(context, cfg, checkpoint, size, || async { Ok(()) }).await
2169    }
2170
2171    async fn span(context: E, cfg: &Self::Config) -> Result<Range<u64>, Error> {
2172        let checkpoint = Checkpoint::open(context.child("meta"), &cfg.partition).await?;
2173        match checkpoint.clear_target() {
2174            Some(target) => Ok(target..target),
2175            None => Recovery::<E, A>::span(&context, cfg, &checkpoint).await,
2176        }
2177    }
2178
2179    type Config = Config;
2180}
2181
2182#[cfg(test)]
2183impl<E: crate::Context, A: CodecFixedShared> Inner<E, A> {
2184    /// Return the recovery watermark.
2185    pub(super) fn recovery_watermark(&self) -> u64 {
2186        self.checkpoint
2187            .watermark()
2188            .expect("recovery watermark must exist after init")
2189    }
2190
2191    /// Sync, then reopen a shorter prefix for recovery/fault fixtures.
2192    pub(super) async fn test_truncate(self: Box<Self>, cap: u64) -> Result<Box<Self>, Error> {
2193        self.sync().await?.test_reopen_at_most(cap).await
2194    }
2195
2196    /// Reopen a shorter prefix without syncing first, releasing the previous owner. Faults
2197    /// armed by the caller therefore reach recovery rather than the closing sync.
2198    pub(super) async fn test_reopen_at_most(self: Box<Self>, cap: u64) -> Result<Box<Self>, Error> {
2199        let (context, name, page_cache, write_buffer) = self.blobs.test_configuration();
2200        let cfg = Config {
2201            partition: name.strip_suffix("-blobs").unwrap().to_owned(),
2202            items_per_blob: self.items_per_blob,
2203            page_cache,
2204            write_buffer,
2205            replay_buffer: write_buffer,
2206        };
2207        drop(self);
2208        let checkpoint = Checkpoint::open(context.child("meta"), &cfg.partition).await?;
2209        let recovery = Recovery::<E, A>::open(context, cfg, checkpoint, Some(cap)).await?;
2210        Ok(Box::new(recovery.publish(cap).await?))
2211    }
2212
2213    /// Test helper: Get the oldest blob from the blob store.
2214    pub(crate) const fn test_oldest_blob(&self) -> Option<u64> {
2215        Some(self.blobs.oldest_blob_index())
2216    }
2217
2218    /// Test helper: Get the newest blob from the blob store.
2219    pub(crate) fn test_newest_blob(&self) -> Option<u64> {
2220        Some(self.blobs.tail_blob_index())
2221    }
2222
2223    /// Test helper: Make one blob durable (sealed history or the tail).
2224    pub(crate) async fn test_sync_blob(mut self: Box<Self>, blob: u64) -> Result<Box<Self>, Error> {
2225        self.blobs = self.blobs.sync_blob(blob).await?;
2226        Ok(self)
2227    }
2228
2229    /// Test helper: Set and persist the recovery watermark directly.
2230    pub(crate) async fn test_set_recovery_watermark(
2231        mut self: Box<Self>,
2232        watermark: u64,
2233    ) -> Result<Box<Self>, Error> {
2234        self.checkpoint.set_watermark(Some(watermark));
2235        self.checkpoint = self.checkpoint.sync().await?;
2236        Ok(self)
2237    }
2238
2239    /// Test helper: Durably stage a clear intent in the journal's checkpoint.
2240    pub(crate) async fn test_stage_clear(
2241        context: E,
2242        partition: &str,
2243        target: u64,
2244    ) -> Result<(), Error> {
2245        let checkpoint = Checkpoint::open(context, partition).await?;
2246        checkpoint.stage_clear(target).await?;
2247        Ok(())
2248    }
2249}
2250
2251#[cfg(test)]
2252impl<E: crate::Context, A: CodecFixedShared> Journal<E, A> {
2253    /// Read the recovery watermark persisted for `partition` without opening the journal.
2254    pub(crate) async fn persisted_watermark(
2255        context: E,
2256        partition: &str,
2257    ) -> Result<Option<u64>, Error> {
2258        Ok(Checkpoint::open(context, partition).await?.watermark())
2259    }
2260
2261    /// Test helper: Get the oldest blob from the blob store.
2262    pub(crate) fn test_oldest_blob(&self) -> Option<u64> {
2263        self.0.test_oldest_blob()
2264    }
2265
2266    /// Test helper: Get the newest blob from the blob store.
2267    pub(crate) fn test_newest_blob(&self) -> Option<u64> {
2268        self.0.test_newest_blob()
2269    }
2270
2271    /// Test helper: Make one blob durable (sealed history or the tail).
2272    pub(crate) async fn test_sync_blob(mut self, blob: u64) -> Result<Self, Error> {
2273        self.0 = self.0.test_sync_blob(blob).await?;
2274        Ok(self)
2275    }
2276
2277    /// Test helper: Set and persist the recovery watermark directly.
2278    pub(crate) async fn test_set_recovery_watermark(
2279        mut self,
2280        watermark: u64,
2281    ) -> Result<Self, Error> {
2282        self.0 = self.0.test_set_recovery_watermark(watermark).await?;
2283        Ok(self)
2284    }
2285
2286    /// Test helper: Durably stage a clear intent in the journal's checkpoint.
2287    pub(crate) async fn test_stage_clear(
2288        context: E,
2289        partition: &str,
2290        target: u64,
2291    ) -> Result<(), Error> {
2292        Inner::<E, A>::test_stage_clear(context, partition, target).await
2293    }
2294}
2295
2296#[cfg(test)]
2297mod tests {
2298    use super::*;
2299    use crate::{
2300        journal::{
2301            authenticated,
2302            contiguous::{
2303                Contiguous as _,
2304                tests::{replay_items, tear_page_rewrite},
2305            },
2306        },
2307        utils::codec::View,
2308    };
2309    use commonware_codec::FixedSize;
2310    use commonware_cryptography::{Hasher as _, Sha256, sha256::Digest};
2311    use commonware_macros::test_traced;
2312    use commonware_runtime::{
2313        Blob, Error as RuntimeError, Metrics as _, Runner, Spawner as _, Storage, Supervisor as _,
2314        WriteOptions,
2315        buffer::paged::{Writer, corrupt_page},
2316        deterministic::{self, Context},
2317        mocks::{
2318            DelayedSyncContext, PendingSyncs, RecordingContext, WriteFaultContext, WriteFaults,
2319            drive_pending_syncs, fail_pending_syncs, next_pending_sync, release_pending_syncs,
2320        },
2321    };
2322    use commonware_utils::{NZU16, NZU64, NZUsize, probability};
2323    use futures::{StreamExt, pin_mut};
2324    use std::num::NonZeroU16;
2325
2326    const PAGE_SIZE: NonZeroU16 = NZU16!(44);
2327    const PAGE_CACHE_SIZE: NonZeroUsize = NZUsize!(3);
2328
2329    /// Generate a SHA-256 digest for the given value.
2330    fn test_digest(value: u64) -> Digest {
2331        Sha256::hash(&[&value.to_be_bytes()])
2332    }
2333
2334    fn test_cfg(items_per_blob: NonZeroU64) -> Config {
2335        Config {
2336            partition: "test-partition".into(),
2337            items_per_blob,
2338            page_cache: CacheRef::new(PAGE_SIZE, PAGE_CACHE_SIZE),
2339            write_buffer: NZUsize!(2048),
2340            replay_buffer: NZUsize!(2048),
2341        }
2342    }
2343
2344    fn blob_partition(cfg: &Config) -> String {
2345        format!("{}-blobs", cfg.partition)
2346    }
2347
2348    #[test]
2349    fn test_unbounded_fixed_repairs_hole_after_full_capacity() {
2350        deterministic::Runner::default().start(|context| async move {
2351            let cache = CacheRef::new(NZU16!(8), NZUsize!(8));
2352            let cfg = Config {
2353                partition: "initialization-extra-tail".into(),
2354                items_per_blob: NZU64!(2),
2355                page_cache: cache.clone(),
2356                write_buffer: NZUsize!(128),
2357                replay_buffer: NZUsize!(128),
2358            };
2359            let (blob, size) = context
2360                .open("initialization-extra-tail-blobs", &0u64.to_be_bytes())
2361                .await
2362                .unwrap();
2363            let mut writer = Writer::new(blob, size, 128, cache).await.unwrap();
2364            (writer, _) = writer.append(&[1; 32]).await.unwrap();
2365            writer = writer.sync().await.unwrap();
2366            drop(writer);
2367            corrupt_page(
2368                &context,
2369                "initialization-extra-tail-blobs",
2370                &0u64.to_be_bytes(),
2371                2,
2372                8,
2373            )
2374            .await;
2375            let result = Journal::<_, u64>::init(context.child("open"), cfg).await;
2376            assert!(
2377                result.is_ok(),
2378                "unbounded recovery must trim a hole after capacity: {result:?}"
2379            );
2380
2381            let journal = result.unwrap();
2382            assert_eq!(journal.bounds(), 0..2);
2383            let item = u64::from_be_bytes([1; 8]);
2384            assert_eq!(journal.read(0).await.unwrap(), item);
2385            assert_eq!(journal.read(1).await.unwrap(), item);
2386        });
2387    }
2388
2389    #[test]
2390    fn test_fixed_bounded_recovery_read_count() {
2391        for count in [257u64, 4097] {
2392            for capacity in [10_000, u64::MAX] {
2393                deterministic::Runner::default().start(|context| async move {
2394                    let (context, recordings) = RecordingContext::new(context);
2395                    let mut cfg = test_cfg(capacity.try_into().unwrap());
2396                    cfg.page_cache = CacheRef::new(NZU16!(256), NZUsize!(3));
2397                    cfg.replay_buffer = NZUsize!(1);
2398                    let mut journal = Journal::<_, u64>::init(context.child("seed"), cfg.clone())
2399                        .await
2400                        .unwrap();
2401                    for value in 0..count {
2402                        (journal, _) = journal.append(&value).await.unwrap();
2403                    }
2404                    drop(journal.sync().await.unwrap());
2405                    for cap in [
2406                        None,
2407                        Some(33),
2408                        Some(32),
2409                        Some(count),
2410                        Some(count + 1),
2411                        Some(u64::MAX),
2412                    ] {
2413                        let checkpoint =
2414                            Checkpoint::open(context.child("checkpoint"), &cfg.partition)
2415                                .await
2416                                .unwrap();
2417                        assert_eq!(checkpoint.watermark(), Some(count));
2418                        recordings.clear();
2419                        let recovery = Recovery::<_, u64>::open(
2420                            context.child("recovery"),
2421                            cfg.clone(),
2422                            checkpoint,
2423                            cap,
2424                        )
2425                        .await
2426                        .unwrap();
2427                        assert_eq!(recovery.size(), count.min(cap.unwrap_or(count)));
2428                        assert_eq!(recovery.pending.get(&0).unwrap().size(), count * 8);
2429                        let expected = if cap == Some(32) { 1 } else { 2 };
2430                        assert_eq!(
2431                            recordings.snapshot().reads.len(),
2432                            expected,
2433                            "count={count}, capacity={capacity}, cap={cap:?}"
2434                        );
2435                    }
2436                });
2437            }
2438        }
2439    }
2440
2441    #[test]
2442    fn test_fixed_recovery_large_capacity() {
2443        for count in [0, 1] {
2444            for cap in [
2445                None,
2446                Some(0),
2447                Some(1),
2448                Some(u64::MAX / 8 + 1),
2449                Some(u64::MAX),
2450            ] {
2451                deterministic::Runner::default().start(|context| async move {
2452                    let cfg = test_cfg(NZU64!(u64::MAX));
2453                    let mut journal = Journal::<_, u64>::init(context.child("seed"), cfg.clone())
2454                        .await
2455                        .unwrap();
2456                    for value in 0..count {
2457                        (journal, _) = journal.append(&value).await.unwrap();
2458                    }
2459                    drop(journal.sync().await.unwrap());
2460                    let journal = match cap {
2461                        None => Journal::<_, u64>::init(context.child("reopen"), cfg).await,
2462                        Some(cap) => {
2463                            Journal::<_, u64>::init_at_most(context.child("reopen"), cfg, cap).await
2464                        }
2465                    }
2466                    .unwrap();
2467                    let retained = count.min(cap.unwrap_or(u64::MAX));
2468                    assert_eq!(journal.bounds(), 0..retained);
2469                    if retained != 0 {
2470                        assert_eq!(journal.read(0).await.unwrap(), 0);
2471                    }
2472                });
2473            }
2474        }
2475    }
2476
2477    #[test_traced]
2478    fn test_fixed_append_buffer_fallback_rotation_and_snapshot() {
2479        for write_buffer in [NZUsize!(44), NZUsize!(2048)] {
2480            deterministic::Runner::default().start(|context| async move {
2481                let mut cfg = test_cfg(NZU64!(7));
2482                cfg.write_buffer = write_buffer;
2483                let mut journal = Journal::<_, Digest>::init(context.child("initial"), cfg.clone())
2484                    .await
2485                    .unwrap();
2486                for i in 0..3 {
2487                    let position;
2488                    (journal, position) = journal.append(&test_digest(i)).await.unwrap();
2489                    assert_eq!(position, i);
2490                }
2491                let snapshot;
2492                (journal, snapshot) = journal.snapshot().await.unwrap();
2493
2494                // Mix direct and prepared appends across buffer and blob boundaries.
2495                for i in 3..20 {
2496                    let position;
2497                    if i % 3 == 0 {
2498                        let item = test_digest(i);
2499                        let prepared = journal.prepare_append(Many::Flat(&[item]));
2500                        (journal, position) = journal.append_prepared(prepared).await.unwrap();
2501                    } else {
2502                        (journal, position) = journal.append(&test_digest(i)).await.unwrap();
2503                    }
2504                    assert_eq!(position, i);
2505                    assert_eq!(journal.size(), i + 1);
2506                }
2507                for i in 0..3 {
2508                    assert_eq!(snapshot.read(i).await.unwrap(), test_digest(i));
2509                }
2510                journal = journal.sync().await.unwrap();
2511                drop(snapshot);
2512                drop(journal);
2513
2514                let journal = Journal::<_, Digest>::init(context, cfg).await.unwrap();
2515                assert_eq!(journal.size(), 20);
2516                for i in 0..20 {
2517                    assert_eq!(journal.read(i).await.unwrap(), test_digest(i));
2518                }
2519                journal.destroy().await.unwrap();
2520            });
2521        }
2522    }
2523
2524    #[test_traced]
2525    fn test_fixed_cached_read_preserves_owned_byte_fields() {
2526        deterministic::Runner::default().start(|context| async move {
2527            let cfg = test_cfg(NZU64!(3));
2528            let mut journal = Journal::init(context, cfg).await.unwrap();
2529            (journal, _) = journal.append(&View::new(7)).await.unwrap();
2530            let decoded = journal.try_read_sync(0).unwrap();
2531            (journal, _) = journal.append(&View::new(8)).await.unwrap();
2532            let next = journal.try_read_sync(1).unwrap();
2533            journal.destroy().await.unwrap();
2534            assert_eq!(decoded.bytes.as_ref(), &7u64.to_be_bytes());
2535            assert_eq!(next.bytes.as_ref(), &8u64.to_be_bytes());
2536        });
2537    }
2538
2539    #[test_traced]
2540    fn test_fixed_batch_read_preserves_owned_byte_fields() {
2541        deterministic::Runner::default().start(|context| async move {
2542            let cfg = test_cfg(NZU64!(3));
2543            let mut journal = Journal::init(context, cfg).await.unwrap();
2544            for i in 0..5 {
2545                (journal, _) = journal.append(&View::new(i)).await.unwrap();
2546            }
2547            let decoded = journal.read_many(&[0, 2, 3, 4]).await.unwrap();
2548            let probed = journal.try_read_many_sync(&[0, 2, 3, 4]);
2549            journal.destroy().await.unwrap();
2550            for (value, expected) in decoded.iter().zip([0u64, 2, 3, 4]) {
2551                assert_eq!(value.bytes.as_ref(), &expected.to_be_bytes());
2552                value.assert_shared();
2553            }
2554            for (value, expected) in probed.iter().zip([0u64, 2, 3, 4]) {
2555                let value = value.as_ref().expect("probe should hit after read_many");
2556                assert_eq!(value.bytes.as_ref(), &expected.to_be_bytes());
2557            }
2558        });
2559    }
2560
2561    #[test]
2562    fn test_bounded_empty_prefix_at_maximum_position() {
2563        deterministic::Runner::default().start(|context| async move {
2564            let cfg = test_cfg(NZU64!(1));
2565            let mut journal =
2566                Journal::<_, u64>::init_at_size(context.child("seed"), cfg.clone(), u64::MAX - 1)
2567                    .await
2568                    .unwrap();
2569            (journal, _) = journal.append(&99).await.unwrap();
2570            (journal, _) = journal.prune(u64::MAX).await.unwrap();
2571            _ = journal.sync().await.unwrap();
2572            let journal =
2573                Journal::<_, u64>::init_at_most(context.child("capped"), cfg.clone(), u64::MAX)
2574                    .await
2575                    .unwrap();
2576            assert_eq!(journal.bounds(), u64::MAX..u64::MAX);
2577            drop(journal);
2578            let journal = Journal::<_, u64>::init(context.child("ordinary"), cfg)
2579                .await
2580                .unwrap();
2581            assert_eq!(journal.bounds(), u64::MAX..u64::MAX);
2582        });
2583    }
2584
2585    #[test]
2586    fn test_bounded_empty_prefix_interrupted_repair() {
2587        deterministic::Runner::default().start(|context| async move {
2588            let cfg = test_cfg(NZU64!(5));
2589            let mut journal =
2590                Journal::<_, Digest>::init_at_size(context.child("seed"), cfg.clone(), 7)
2591                    .await
2592                    .unwrap();
2593            for i in 7..13 {
2594                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
2595            }
2596            _ = journal.sync().await.unwrap();
2597            let checkpoint = Checkpoint::open(context.child("checkpoint"), &cfg.partition)
2598                .await
2599                .unwrap();
2600            let recovery =
2601                Recovery::<_, Digest>::open(context.child("cap"), cfg.clone(), checkpoint, Some(7))
2602                    .await
2603                    .unwrap();
2604
2605            // Interrupt initialization after durable repair and before publication.
2606            drop(recovery.repair_to(7).await.unwrap());
2607            let journal = Journal::<_, Digest>::init(context.child("retry"), cfg)
2608                .await
2609                .unwrap();
2610            assert_eq!(journal.bounds(), 7..7);
2611        });
2612    }
2613
2614    #[test]
2615    fn test_recovery_rebuilds_pending_gap() {
2616        deterministic::Runner::default().start(|context| async move {
2617            // The two-page write buffer holds two digests, so the third digest in each blob takes
2618            // the owned fallback during recovery.
2619            let mut cfg = test_cfg(NZU64!(5));
2620            cfg.write_buffer = NZUsize!(88);
2621            let mut journal = Journal::<_, Digest>::init(context.child("seed"), cfg.clone())
2622                .await
2623                .unwrap();
2624            for i in 0..13 {
2625                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
2626            }
2627            for blob in 0..3 {
2628                journal = journal.test_sync_blob(blob).await.unwrap();
2629            }
2630            drop(journal);
2631
2632            // Lose an unacknowledged index prefix while newer index blobs survive.
2633            let (blob, _) = context
2634                .open(&blob_partition(&cfg), &0u64.to_be_bytes())
2635                .await
2636                .unwrap();
2637            blob.resize(0).await.unwrap();
2638            blob.sync().await.unwrap();
2639            drop(blob);
2640            let checkpoint = Checkpoint::open(context.child("checkpoint"), &cfg.partition)
2641                .await
2642                .unwrap();
2643            let mut recovery = Box::new(
2644                Recovery::<_, Digest>::open(context.child("repair"), cfg.clone(), checkpoint, None)
2645                    .await
2646                    .unwrap(),
2647            );
2648            assert_eq!(recovery.size(), 0);
2649            assert_eq!(recovery.recovery_watermark(), 0);
2650            recovery = recovery.truncate(0).await.unwrap();
2651
2652            // Rebuild with values the surviving blobs never held so stale pages cannot satisfy
2653            // the reads below.
2654            for i in 0..13 {
2655                recovery = recovery.append(&test_digest(100 + i)).await.unwrap();
2656            }
2657            let journal = recovery.publish(13).await.unwrap();
2658            _ = Box::new(journal).sync().await.unwrap();
2659            let journal = Journal::<_, Digest>::init(context.child("retry"), cfg)
2660                .await
2661                .unwrap();
2662            assert_eq!(journal.bounds(), 0..13);
2663            for value in 0..13 {
2664                assert_eq!(journal.read(value).await.unwrap(), test_digest(100 + value));
2665            }
2666        });
2667    }
2668
2669    /// A bounded publication raises the watermark to the retained end, so bytes rebuilt into
2670    /// the tail during recovery must be durable before that raise.
2671    #[test]
2672    fn test_bounded_publish_syncs_rebuilt_tail_before_watermark_raise() {
2673        let ((), checkpoint) =
2674            deterministic::Runner::default().start_and_recover(|context| async move {
2675                let cfg = test_cfg(NZU64!(5));
2676                let checkpoint = Checkpoint::open(context.child("checkpoint"), &cfg.partition)
2677                    .await
2678                    .unwrap();
2679                let mut recovery = Box::new(
2680                    Recovery::<_, Digest>::open(
2681                        context.child("rebuild"),
2682                        cfg.clone(),
2683                        checkpoint,
2684                        Some(7),
2685                    )
2686                    .await
2687                    .unwrap(),
2688                );
2689                recovery = recovery.truncate(0).await.unwrap();
2690                for i in 0..7 {
2691                    recovery = recovery.append(&test_digest(i)).await.unwrap();
2692                }
2693                let journal = recovery.publish(7).await.unwrap();
2694                assert_eq!(journal.size(), 7);
2695                assert_eq!(journal.recovery_watermark(), 7);
2696                drop(journal);
2697            });
2698        deterministic::Runner::from(checkpoint).start(|context| async move {
2699            let cfg = test_cfg(NZU64!(5));
2700            let journal = Journal::<_, Digest>::init(context.child("reopen"), cfg)
2701                .await
2702                .unwrap();
2703            assert_eq!(journal.bounds(), 0..7);
2704            for i in 0..7 {
2705                assert_eq!(journal.read(i).await.unwrap(), test_digest(i));
2706            }
2707        });
2708    }
2709
2710    #[test]
2711    fn test_bounded_initialization_retries_after_storage_faults() {
2712        for start in [0, 7] {
2713            for kind in 0..3 {
2714                for numerator in [1, 3, 7, 10] {
2715                    let (succeeded, checkpoint) = deterministic::Runner::default()
2716                        .start_and_recover(|context| async move {
2717                            let cfg = test_cfg(NZU64!(5));
2718                            let mut journal = Journal::<_, u64>::init_at_size(
2719                                context.child("seed"),
2720                                cfg.clone(),
2721                                start,
2722                            )
2723                            .await
2724                            .unwrap();
2725                            for value in start..13 {
2726                                (journal, _) = journal.append(&value).await.unwrap();
2727                            }
2728                            _ = journal.sync().await.unwrap();
2729                            let rate = commonware_utils::Probability::new(numerator, 10).unwrap();
2730                            *context.storage_fault_config().write() = deterministic::FaultConfig {
2731                                sync_rate: (kind == 0).then_some(rate),
2732                                remove_rate: (kind == 1).then_some(rate),
2733                                resize_rate: (kind == 2).then_some(deterministic::ResizeConfig {
2734                                    failure_rate: rate,
2735                                    partial_rate: rate,
2736                                }),
2737                                ..Default::default()
2738                            };
2739
2740                            // The failed initializer is consumed. Inspect only freshly opened
2741                            // storage.
2742                            Journal::<_, u64>::init_at_most(context.child("faulted"), cfg, 7)
2743                                .await
2744                                .is_ok()
2745                        });
2746                    deterministic::Runner::from(checkpoint).start(|context| async move {
2747                        *context.storage_fault_config().write() =
2748                            deterministic::FaultConfig::default();
2749                        let cfg = test_cfg(NZU64!(5));
2750                        if succeeded {
2751                            let journal =
2752                                Journal::<_, u64>::init(context.child("ordinary"), cfg.clone())
2753                                    .await
2754                                    .unwrap();
2755                            assert_eq!(journal.bounds(), start..7);
2756                            drop(journal);
2757                        }
2758                        let journal =
2759                            Journal::<_, u64>::init_at_most(context.child("retry"), cfg.clone(), 7)
2760                                .await
2761                                .unwrap();
2762                        assert_eq!(journal.bounds(), start..7);
2763                        for value in start..7 {
2764                            assert_eq!(journal.read(value).await.unwrap(), value);
2765                        }
2766                        drop(journal);
2767                        let mut journal =
2768                            Journal::<_, u64>::init(context.child("restart"), cfg.clone())
2769                                .await
2770                                .unwrap();
2771                        assert_eq!(journal.bounds(), start..7);
2772                        (journal, _) = journal.append(&99).await.unwrap();
2773                        _ = journal.sync().await.unwrap();
2774                        let journal = Journal::<_, u64>::init(context.child("verify"), cfg)
2775                            .await
2776                            .unwrap();
2777                        assert_eq!(journal.bounds(), start..8);
2778                        assert_eq!(journal.read(7).await.unwrap(), 99);
2779                    });
2780                }
2781            }
2782        }
2783    }
2784
2785    #[test]
2786    fn test_init_at_most_restart_and_append() {
2787        for start in [0, 7] {
2788            for cap in [0, 1, 5, 7, 8, 10, 13, 20, u64::MAX] {
2789                deterministic::Runner::default().start(|context| async move {
2790                    let cfg = test_cfg(NZU64!(5));
2791                    let mut journal = Journal::<_, u64>::init_at_size(
2792                        context.child("storage"),
2793                        cfg.clone(),
2794                        start,
2795                    )
2796                    .await
2797                    .unwrap();
2798                    for value in start..13 {
2799                        (journal, _) = journal.append(&value).await.unwrap();
2800                    }
2801                    journal = journal.sync().await.unwrap();
2802                    drop(journal);
2803
2804                    let opened =
2805                        Journal::<_, u64>::init_at_most(context.child("storage"), cfg.clone(), cap)
2806                            .await;
2807                    if cap < start {
2808                        assert!(matches!(opened, Err(Error::ItemPruned(value)) if value == cap));
2809                        let journal = Journal::<_, u64>::init(context.child("storage"), cfg)
2810                            .await
2811                            .unwrap();
2812                        assert_eq!(journal.bounds(), start..13);
2813                        return;
2814                    }
2815                    let end = cap.min(13);
2816                    let journal = opened.unwrap();
2817                    assert_eq!(journal.bounds(), start..end);
2818                    for value in start..end {
2819                        assert_eq!(journal.read(value).await.unwrap(), value);
2820                    }
2821                    drop(journal);
2822
2823                    // Opening alone must durably establish the selected prefix.
2824                    let mut journal =
2825                        Journal::<_, u64>::init(context.child("storage"), cfg.clone())
2826                            .await
2827                            .unwrap();
2828                    assert_eq!(journal.bounds(), start..end);
2829                    for value in end..end + 8 {
2830                        let pos;
2831                        (journal, pos) = journal.append(&(value + 100)).await.unwrap();
2832                        assert_eq!(pos, value);
2833                    }
2834                    journal = journal.sync().await.unwrap();
2835                    drop(journal);
2836                    let journal = Journal::<_, u64>::init(context.child("storage"), cfg)
2837                        .await
2838                        .unwrap();
2839                    for value in start..end + 8 {
2840                        let expected = if value < end { value } else { value + 100 };
2841                        assert_eq!(journal.read(value).await.unwrap(), expected);
2842                    }
2843                });
2844            }
2845        }
2846    }
2847
2848    #[test]
2849    fn test_init_at_most_skips_discarded_blobs() {
2850        deterministic::Runner::default().start(|context| async move {
2851            let cfg = test_cfg(NZU64!(5));
2852            let mut journal = Journal::<_, u64>::init(context.child("storage"), cfg.clone())
2853                .await
2854                .unwrap();
2855            for value in 0..23 {
2856                (journal, _) = journal.append(&value).await.unwrap();
2857            }
2858            journal = journal.sync().await.unwrap();
2859            drop(journal);
2860            let (blob, _) = context
2861                .open(&blob_partition(&cfg), &3u64.to_be_bytes())
2862                .await
2863                .unwrap();
2864            blob.resize(1).await.unwrap();
2865            blob.sync().await.unwrap();
2866            drop(blob);
2867            let journal = Journal::<_, u64>::init_at_most(context.child("storage"), cfg.clone(), 7)
2868                .await
2869                .unwrap();
2870            assert_eq!(journal.bounds(), 0..7);
2871            drop(journal);
2872            let journal = Journal::<_, u64>::init(context.child("storage"), cfg)
2873                .await
2874                .unwrap();
2875            assert_eq!(journal.bounds(), 0..7);
2876        });
2877    }
2878
2879    #[test]
2880    fn test_start_sync_keeps_predecessor_sync() {
2881        let executor = deterministic::Runner::default();
2882        executor.start(|context| async move {
2883            let cfg = test_cfg(NZU64!(3));
2884            let mut journal = Box::new(Inner::<_, u64>::init(context, cfg).await.unwrap());
2885
2886            // Rollover starts a predecessor sync.
2887            (journal, _) = journal
2888                .append_many(Many::Flat(&[1, 2, 3, 4]))
2889                .await
2890                .unwrap();
2891            assert!(journal.blobs.has_tail_predecessor_sync());
2892
2893            // Handle includes predecessor; slot drains later.
2894            let (journal, handle) = journal.start_sync().await.unwrap();
2895            assert!(journal.blobs.has_tail_predecessor_sync());
2896            handle.await.unwrap();
2897            assert!(journal.blobs.has_tail_predecessor_sync());
2898
2899            journal.destroy().await.unwrap();
2900        });
2901    }
2902
2903    #[test_traced]
2904    fn test_start_sync_advances_watermark_lagged() {
2905        let executor = deterministic::Runner::default();
2906        executor.start(|context| async move {
2907            let pending = PendingSyncs::default();
2908            let cfg = test_cfg(NZU64!(100));
2909            let make = |pending: PendingSyncs| {
2910                Inner::<_, u64>::init(
2911                    DelayedSyncContext {
2912                        inner: context.child("journal"),
2913                        pending,
2914                    },
2915                    cfg.clone(),
2916                )
2917            };
2918            let mut journal = Box::new(make(pending.clone()).await.unwrap());
2919
2920            // Nothing proven while the first sync is parked: the watermark must not move.
2921            (journal, _) = journal.append_many(Many::Flat(&[1, 2, 3])).await.unwrap();
2922            let (mut journal, h1) = journal.start_sync().await.unwrap();
2923            assert_eq!(journal.recovery_watermark(), 0);
2924
2925            release_pending_syncs(&pending);
2926            h1.await.unwrap();
2927
2928            // The first sync is proven, so the next call advances the watermark to its size,
2929            // one interval behind the tip.
2930            (journal, _) = journal.append(&4).await.unwrap();
2931            let (journal, h2) = journal.start_sync().await.unwrap();
2932            assert_eq!(journal.recovery_watermark(), 3);
2933            drive_pending_syncs(&pending, h2).await.unwrap();
2934
2935            // A third call catches the watermark up to the second sync's size.
2936            let (journal, h3) = drive_pending_syncs(&pending, journal.start_sync())
2937                .await
2938                .unwrap();
2939            assert_eq!(journal.recovery_watermark(), 4);
2940            drive_pending_syncs(&pending, h3).await.unwrap();
2941
2942            // The advanced watermark is durable: a reopen resumes from it.
2943            pending.unblock();
2944            drop(journal);
2945            let journal = make(pending.clone()).await.unwrap();
2946            assert_eq!(journal.recovery_watermark(), 4);
2947            assert_eq!(journal.bounds, 0..4);
2948            journal.destroy().await.unwrap();
2949        });
2950    }
2951
2952    /// A failed sync never advances the watermark. A failed sealed-blob sync leaves the next
2953    /// `start_sync` successful, and a failed tail sync fails it.
2954    #[test_traced]
2955    fn test_start_sync_failure_blocks_watermark() {
2956        let executor = deterministic::Runner::default();
2957        executor.start(|context| async move {
2958            let pending = PendingSyncs::default();
2959            let cfg = test_cfg(NZU64!(3));
2960            let mut journal = Box::new(
2961                Inner::<_, u64>::init(
2962                    DelayedSyncContext {
2963                        inner: context.child("journal"),
2964                        pending: pending.clone(),
2965                    },
2966                    cfg,
2967                )
2968                .await
2969                .unwrap(),
2970            );
2971
2972            // Filling the first blob seals it and starts its sync. The next start_sync syncs
2973            // the fresh tail.
2974            (journal, _) = journal.append_many(Many::Flat(&[1, 2, 3])).await.unwrap();
2975            let (mut journal, h1) = journal.start_sync().await.unwrap();
2976
2977            // Fail the sealed blob's sync and let the tail sync land.
2978            next_pending_sync(&pending)
2979                .release
2980                .send(Err(RuntimeError::Io(
2981                    std::io::Error::other("injected sync failure").into(),
2982                )))
2983                .unwrap();
2984            release_pending_syncs(&pending);
2985            assert!(h1.await.is_err());
2986
2987            // The failed sync proves nothing. The next call succeeds because the tail sync
2988            // landed, but it must not advance the watermark past the lost blob.
2989            assert_eq!(journal.barrier.boundary(), 0);
2990            let (mut journal, h2) = journal.start_sync().await.unwrap();
2991            assert_eq!(journal.recovery_watermark(), 0);
2992            assert!(h2.await.is_err());
2993
2994            // A failed tail sync fails the next call.
2995            (journal, _) = journal.append(&4).await.unwrap();
2996            let (journal, _) = journal.start_sync().await.unwrap();
2997            fail_pending_syncs(&pending);
2998            assert!(journal.start_sync().await.is_err());
2999        });
3000    }
3001
3002    #[test_traced]
3003    fn test_truncate_drains_parked_watermark_advance() {
3004        let executor = deterministic::Runner::default();
3005        executor.start(|context| async move {
3006            let pending = PendingSyncs::default();
3007            let cfg = test_cfg(NZU64!(100));
3008            let make = |pending: PendingSyncs| {
3009                Inner::<_, u64>::init(
3010                    DelayedSyncContext {
3011                        inner: context.child("journal"),
3012                        pending,
3013                    },
3014                    cfg.clone(),
3015                )
3016            };
3017            let mut journal = Box::new(make(pending.clone()).await.unwrap());
3018
3019            (journal, _) = journal.append_many(Many::Flat(&[1, 2, 3])).await.unwrap();
3020            let (mut journal, h1) = journal.start_sync().await.unwrap();
3021            release_pending_syncs(&pending);
3022            h1.await.unwrap();
3023
3024            // This parks the metadata sync advancing the watermark to 3.
3025            (journal, _) = journal.append(&4).await.unwrap();
3026            let (journal, h2) = journal.start_sync().await.unwrap();
3027            assert_eq!(journal.recovery_watermark(), 3);
3028
3029            // Truncate below the in-flight advance. The lowered value must win on reopen.
3030            let journal = drive_pending_syncs(&pending, journal.test_truncate(2))
3031                .await
3032                .unwrap();
3033            assert_eq!(journal.recovery_watermark(), 2);
3034            drop(h2);
3035
3036            // Reopen: the lowered watermark held, and no corruption is reported.
3037            pending.unblock();
3038            drop(journal);
3039            let journal = make(pending.clone()).await.unwrap();
3040            assert_eq!(journal.recovery_watermark(), 2);
3041            assert_eq!(journal.bounds, 0..2);
3042            journal.destroy().await.unwrap();
3043        });
3044    }
3045
3046    #[test_traced]
3047    fn test_start_sync_watermark_advance_inline_failure() {
3048        let executor = deterministic::Runner::default();
3049        executor.start(|context| async move {
3050            let faults = WriteFaults::default();
3051            let cfg = test_cfg(NZU64!(100));
3052            let make = |faults: WriteFaults| {
3053                Inner::<_, u64>::init(
3054                    WriteFaultContext {
3055                        inner: context.child("journal"),
3056                        faults,
3057                    },
3058                    cfg.clone(),
3059                )
3060            };
3061            let mut journal = Box::new(make(faults.clone()).await.unwrap());
3062
3063            // Prove three items durable (watermark 3), then one more so the next call has an
3064            // advance to start.
3065            (journal, _) = journal.append_many(Many::Flat(&[1, 2, 3])).await.unwrap();
3066            let (mut journal, h1) = journal.start_sync().await.unwrap();
3067            h1.await.unwrap();
3068            (journal, _) = journal.append(&4).await.unwrap();
3069            let journal = journal.commit().await.unwrap();
3070
3071            // The advance's inline metadata writes fail: the call fails, consuming the
3072            // journal, even though the data is durable.
3073            faults.arm();
3074            assert!(journal.start_sync().await.is_err());
3075            faults.disarm();
3076
3077            // The failed advance never compromised the data: a reopen recovers it all and a
3078            // fresh sync succeeds.
3079            let journal = Box::new(make(faults).await.unwrap());
3080            assert_eq!(journal.bounds, 0..4);
3081            let journal = journal.sync().await.unwrap();
3082            assert_eq!(journal.recovery_watermark(), 4);
3083            journal.destroy().await.unwrap();
3084        });
3085    }
3086
3087    #[test_traced]
3088    fn test_start_sync_watermark_advance_deferred_failure() {
3089        let executor = deterministic::Runner::default();
3090        executor.start(|context| async move {
3091            let pending = PendingSyncs::default();
3092            let cfg = test_cfg(NZU64!(100));
3093            let mut journal = Box::new(
3094                Inner::<_, u64>::init(
3095                    DelayedSyncContext {
3096                        inner: context.child("journal"),
3097                        pending: pending.clone(),
3098                    },
3099                    cfg,
3100                )
3101                .await
3102                .unwrap(),
3103            );
3104
3105            (journal, _) = journal.append_many(Many::Flat(&[1, 2, 3])).await.unwrap();
3106            let (journal, h1) = journal.start_sync().await.unwrap();
3107            release_pending_syncs(&pending);
3108            h1.await.unwrap();
3109
3110            // Only the advance's metadata fsync is in flight: its failure surfaces on the
3111            // journal handle even though the data is durable.
3112            let (mut journal, h2) = journal.start_sync().await.unwrap();
3113            fail_pending_syncs(&pending);
3114            assert!(h2.await.is_err());
3115
3116            // An advance at or below the staged value is skipped, so the next handle succeeds:
3117            // the failure is observed only by the next checkpoint write.
3118            (journal, _) = journal.append(&4).await.unwrap();
3119            let (journal, h3) = journal.start_sync().await.unwrap();
3120            drive_pending_syncs(&pending, h3).await.unwrap();
3121
3122            // The failed advance's completion stays pending until observed: commit (which
3123            // does not write the checkpoint) still succeeds, and the next checkpoint write
3124            // observes the failure and fails.
3125            let journal = drive_pending_syncs(&pending, journal.commit())
3126                .await
3127                .unwrap();
3128            assert!(drive_pending_syncs(&pending, journal.sync()).await.is_err());
3129        });
3130    }
3131
3132    #[test_traced]
3133    fn test_truncate_watermark_lowering_failure_keeps_blobs() {
3134        let executor = deterministic::Runner::default();
3135        executor.start(|context| async move {
3136            let faults = WriteFaults::default();
3137
3138            // Two items per page make the two-item cap page aligned, so the armed write fault can
3139            // only reach the checkpoint rewrite that lowers the watermark.
3140            let cfg = Config {
3141                page_cache: CacheRef::new(NZU16!(16), PAGE_CACHE_SIZE),
3142                ..test_cfg(NZU64!(100))
3143            };
3144            let make = |faults: WriteFaults| {
3145                Inner::<_, u64>::init(
3146                    WriteFaultContext {
3147                        inner: context.child("journal"),
3148                        faults,
3149                    },
3150                    cfg.clone(),
3151                )
3152            };
3153            let mut journal = Box::new(make(faults.clone()).await.unwrap());
3154            (journal, _) = journal
3155                .append_many(Many::Flat(&[1, 2, 3, 4]))
3156                .await
3157                .unwrap();
3158            let journal = journal.sync().await.unwrap();
3159            assert_eq!(journal.recovery_watermark(), 4);
3160
3161            // Recovery must durably lower the watermark before touching blob state. When the
3162            // lowering fails, the reopen fails with the blobs intact.
3163            faults.arm();
3164            assert!(journal.test_reopen_at_most(2).await.is_err());
3165            faults.disarm();
3166
3167            let journal = make(faults).await.unwrap();
3168            assert_eq!(journal.recovery_watermark(), 4);
3169            assert_eq!(journal.bounds, 0..4);
3170            journal.destroy().await.unwrap();
3171        });
3172    }
3173
3174    #[test_traced]
3175    fn test_truncate_truncates_durable_size() {
3176        let executor = deterministic::Runner::default();
3177        executor.start(|context| async move {
3178            let pending = PendingSyncs::default();
3179            let cfg = test_cfg(NZU64!(100));
3180            let mut journal = Box::new(
3181                Inner::<_, u64>::init(
3182                    DelayedSyncContext {
3183                        inner: context.child("journal"),
3184                        pending: pending.clone(),
3185                    },
3186                    cfg,
3187                )
3188                .await
3189                .unwrap(),
3190            );
3191            (journal, _) = journal.append_many(Many::Flat(&[1, 2, 3])).await.unwrap();
3192            let journal = drive_pending_syncs(&pending, journal.sync()).await.unwrap();
3193            assert_eq!(journal.recovery_watermark(), 3);
3194
3195            // Truncate discards the proof for position 2. While re-appended data is still syncing,
3196            // the next call's advance must not raise the watermark past the truncate point.
3197            let mut journal = drive_pending_syncs(&pending, journal.test_truncate(2))
3198                .await
3199                .unwrap();
3200            (journal, _) = journal.append(&9).await.unwrap();
3201            let (journal, handle) = journal.start_sync().await.unwrap();
3202            assert_eq!(journal.recovery_watermark(), 2);
3203
3204            pending.unblock();
3205            handle.await.unwrap();
3206            journal.destroy().await.unwrap();
3207        });
3208    }
3209
3210    /// A failed flush inside `start_sync` returns an error.
3211    #[test_traced]
3212    fn test_fixed_start_sync_flush_failure_returns_err() {
3213        let executor = deterministic::Runner::default();
3214        executor.start(|context| async move {
3215            let cfg = test_cfg(NZU64!(3));
3216            let mut journal = Box::new(
3217                Inner::<_, u64>::init(context.child("journal"), cfg)
3218                    .await
3219                    .unwrap(),
3220            );
3221
3222            // Buffer an item so start_sync must flush it.
3223            (journal, _) = journal.append(&0).await.unwrap();
3224
3225            // The flush inside start_sync fails.
3226            *context.storage_fault_config().write() = deterministic::FaultConfig {
3227                write_rate: Some(deterministic::WriteConfig {
3228                    failure_rate: probability!(1.0),
3229                    retention_rate: probability!(0.0),
3230                    mode: deterministic::PartialWriteMode::Prefix,
3231                }),
3232                ..Default::default()
3233            };
3234            assert!(matches!(journal.start_sync().await, Err(Error::Runtime(_))));
3235        });
3236    }
3237
3238    /// The operation that must observe the failed sync.
3239    enum Boundary {
3240        Rollover,
3241        Clear,
3242        Destroy,
3243    }
3244
3245    /// The unobserved sync that fails.
3246    enum FailedSync {
3247        /// The live tail's started sync, whose handle is dropped.
3248        Tail,
3249        /// The sync started when the previous tail was sealed.
3250        Predecessor,
3251    }
3252
3253    /// A failed sync nobody observed prevents data blob creation and removal.
3254    #[rstest::rstest]
3255    #[test_traced]
3256    fn test_fixed_dropped_failed_start_sync_blocks_blob_changes(
3257        #[values(FailedSync::Tail, FailedSync::Predecessor)] failed: FailedSync,
3258        #[values(Boundary::Rollover, Boundary::Clear, Boundary::Destroy)] boundary: Boundary,
3259    ) {
3260        let executor = deterministic::Runner::default();
3261        executor.start(|context| async move {
3262            let cfg = test_cfg(NZU64!(3));
3263            let data_partition = blob_partition(&cfg);
3264            let mut journal = Box::new(
3265                Inner::<_, u64>::init(context.child("journal"), cfg)
3266                    .await
3267                    .unwrap(),
3268            );
3269            let fail_syncs = |rate| {
3270                *context.storage_fault_config().write() = deterministic::FaultConfig {
3271                    sync_rate: Some(rate),
3272                    ..Default::default()
3273                };
3274            };
3275
3276            // Fail a sync without observing it: either the tail's started sync, whose handle is
3277            // dropped, or the sync started by the rollover that seals the first blob.
3278            let blobs = match failed {
3279                FailedSync::Tail => {
3280                    (journal, _) = journal.append(&0).await.unwrap();
3281                    fail_syncs(probability!(1.0));
3282                    let handle;
3283                    (journal, handle) = journal.start_sync().await.unwrap();
3284                    drop(handle);
3285                    1
3286                }
3287                FailedSync::Predecessor => {
3288                    (journal, _) = journal.append_many(Many::Flat(&[0, 1])).await.unwrap();
3289                    fail_syncs(probability!(1.0));
3290                    (journal, _) = journal.append(&2).await.unwrap();
3291                    2
3292                }
3293            };
3294            fail_syncs(probability!(0.0));
3295
3296            // The failure must surface before the operation changes the data blob namespace.
3297            // Clearing may persist its checkpoint intent before reaching this boundary.
3298            let size = journal.size();
3299            let result = match boundary {
3300                Boundary::Rollover => journal
3301                    .append_many(Many::Flat(&[size, size + 1, size + 2]))
3302                    .await
3303                    .map(|_| ()),
3304                Boundary::Clear => journal.clear_to_size(size).await.map(|_| ()),
3305                Boundary::Destroy => journal.destroy().await,
3306            };
3307            assert!(matches!(result, Err(Error::Runtime(_))));
3308            let mut names = scan_partition(&context, &data_partition).await;
3309            names.sort();
3310            let expected: Vec<_> = (0..blobs)
3311                .map(|blob: u64| blob.to_be_bytes().to_vec())
3312                .collect();
3313            assert_eq!(names, expected);
3314        });
3315    }
3316
3317    /// A failed tail sync whose handle was dropped surfaces on the next in-blob flush.
3318    #[test_traced]
3319    fn test_fixed_dropped_failed_start_sync_surfaces_before_rollover() {
3320        let executor = deterministic::Runner::default();
3321        executor.start(|context| async move {
3322            let cfg = test_cfg(NZU64!(1000));
3323            let mut journal = Box::new(
3324                Inner::<_, u64>::init(context.child("journal"), cfg)
3325                    .await
3326                    .unwrap(),
3327            );
3328
3329            // Prove one item durable, then buffer more so the next start_sync rewrites the
3330            // tail page. Fail the sync it starts and drop the returned handle unobserved.
3331            (journal, _) = journal.append(&0).await.unwrap();
3332            journal = journal.sync().await.unwrap();
3333            (journal, _) = journal.append_many(Many::Flat(&[1, 2, 3])).await.unwrap();
3334            *context.storage_fault_config().write() = deterministic::FaultConfig {
3335                sync_rate: Some(probability!(1.0)),
3336                ..Default::default()
3337            };
3338            let (journal, handle) = journal.start_sync().await.unwrap();
3339            drop(handle);
3340            *context.storage_fault_config().write() = deterministic::FaultConfig::default();
3341
3342            // The flush must observe the failed sync before reusing a checksum slot that still
3343            // protects the durable prefix.
3344            let overflow = vec![0u64; 300];
3345            assert!(matches!(
3346                journal.append_many(Many::Flat(&overflow[..])).await,
3347                Err(Error::Runtime(_))
3348            ));
3349        });
3350    }
3351
3352    /// Extract a metric counter's value from encoded metrics output.
3353    fn counter(buffer: &str, name: &str) -> u64 {
3354        buffer
3355            .lines()
3356            .find(|l| l.contains(name) && !l.starts_with('#'))
3357            .and_then(|l| l.split_whitespace().last())
3358            .and_then(|v| v.parse().ok())
3359            .expect("counter missing")
3360    }
3361
3362    async fn scan_partition(context: &Context, partition: &str) -> Vec<Vec<u8>> {
3363        match context.scan(partition).await {
3364            Ok(blobs) => blobs,
3365            Err(RuntimeError::PartitionMissing(_)) => Vec::new(),
3366            Err(err) => panic!("Failed to scan partition {partition}: {err}"),
3367        }
3368    }
3369
3370    #[test_traced]
3371    fn test_fixed_journal_init_conflicting_partitions() {
3372        let executor = deterministic::Runner::default();
3373        executor.start(|context| async move {
3374            let cfg = test_cfg(NZU64!(2));
3375            let legacy_partition = cfg.partition.clone();
3376            let blobs_partition = blob_partition(&cfg);
3377
3378            let (legacy_blob, _) = context
3379                .open(&legacy_partition, &0u64.to_be_bytes())
3380                .await
3381                .expect("Failed to open legacy blob");
3382            legacy_blob
3383                .write_at(0, vec![0u8; 1], WriteOptions::SYNC)
3384                .await
3385                .expect("Failed to write legacy blob");
3386
3387            let (new_blob, _) = context
3388                .open(&blobs_partition, &0u64.to_be_bytes())
3389                .await
3390                .expect("Failed to open new blob");
3391            new_blob
3392                .write_at(0, vec![0u8; 1], WriteOptions::SYNC)
3393                .await
3394                .expect("Failed to write new blob");
3395
3396            let result = Journal::<_, Digest>::init(context.child("second"), cfg.clone()).await;
3397            assert!(matches!(result, Err(Error::Corruption(_))));
3398        });
3399    }
3400
3401    #[test_traced]
3402    fn test_fixed_journal_init_prefers_legacy_partition() {
3403        let executor = deterministic::Runner::default();
3404        executor.start(|context| async move {
3405            let cfg = test_cfg(NZU64!(2));
3406            let legacy_partition = cfg.partition.clone();
3407            let blobs_partition = blob_partition(&cfg);
3408
3409            // Seed legacy partition so it is selected.
3410            let (legacy_blob, _) = context
3411                .open(&legacy_partition, &0u64.to_be_bytes())
3412                .await
3413                .expect("Failed to open legacy blob");
3414            legacy_blob
3415                .write_at(0, vec![0u8; 1], WriteOptions::SYNC)
3416                .await
3417                .expect("Failed to write legacy blob");
3418
3419            drop(legacy_blob);
3420            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
3421                .await
3422                .expect("failed to initialize journal");
3423            (journal, _) = journal.append(&test_digest(1)).await.unwrap();
3424            let journal = journal.sync().await.unwrap();
3425            drop(journal);
3426
3427            let legacy_blobs = scan_partition(&context, &legacy_partition).await;
3428            let new_blobs = scan_partition(&context, &blobs_partition).await;
3429            assert!(!legacy_blobs.is_empty());
3430            assert!(new_blobs.is_empty());
3431        });
3432    }
3433
3434    #[test_traced]
3435    fn test_fixed_journal_init_defaults_to_blobs_partition() {
3436        let executor = deterministic::Runner::default();
3437        executor.start(|context| async move {
3438            let cfg = test_cfg(NZU64!(2));
3439            let legacy_partition = cfg.partition.clone();
3440            let blobs_partition = blob_partition(&cfg);
3441
3442            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
3443                .await
3444                .expect("failed to initialize journal");
3445            (journal, _) = journal.append(&test_digest(1)).await.unwrap();
3446            let journal = journal.sync().await.unwrap();
3447            drop(journal);
3448
3449            let legacy_blobs = scan_partition(&context, &legacy_partition).await;
3450            let new_blobs = scan_partition(&context, &blobs_partition).await;
3451            assert!(legacy_blobs.is_empty());
3452            assert!(!new_blobs.is_empty());
3453        });
3454    }
3455
3456    #[test_traced]
3457    fn test_fixed_journal_append_and_prune() {
3458        // Initialize the deterministic context
3459        let executor = deterministic::Runner::default();
3460
3461        // Start the test within the executor
3462        executor.start(|context| async move {
3463            // Initialize the journal, allowing a max of 2 items per blob.
3464            let cfg = test_cfg(NZU64!(2));
3465            let mut journal = Journal::init(context.child("first"), cfg.clone())
3466                .await
3467                .expect("failed to initialize journal");
3468
3469            // Append an item to the journal
3470            let mut pos;
3471            (journal, pos) = journal
3472                .append(&test_digest(0))
3473                .await
3474                .expect("failed to append data 0");
3475            assert_eq!(pos, 0);
3476
3477            // Drop the journal and re-initialize it to simulate a restart
3478            let journal = journal.sync().await.expect("Failed to sync journal");
3479            drop(journal);
3480
3481            let cfg = test_cfg(NZU64!(2));
3482            let mut journal = Journal::init(context.child("second"), cfg.clone())
3483                .await
3484                .expect("failed to re-initialize journal");
3485            assert_eq!(journal.size(), 1);
3486
3487            // Append two more items to the journal to trigger a new blob creation
3488            (journal, pos) = journal
3489                .append(&test_digest(1))
3490                .await
3491                .expect("failed to append data 1");
3492            assert_eq!(pos, 1);
3493            (journal, pos) = journal
3494                .append(&test_digest(2))
3495                .await
3496                .expect("failed to append data 2");
3497            assert_eq!(pos, 2);
3498
3499            // Read the items back
3500            let item0 = journal.read(0).await.expect("failed to read data 0");
3501            assert_eq!(item0, test_digest(0));
3502            let item1 = journal.read(1).await.expect("failed to read data 1");
3503            assert_eq!(item1, test_digest(1));
3504            let item2 = journal.read(2).await.expect("failed to read data 2");
3505            assert_eq!(item2, test_digest(2));
3506            let err = journal.read(3).await.expect_err("expected read to fail");
3507            assert!(matches!(err, Error::ItemOutOfRange(3)));
3508
3509            // Sync the journal
3510            journal = journal.sync().await.expect("failed to sync journal");
3511
3512            // Pruning to 1 should be a no-op because there's no blob with only older items.
3513            (journal, _) = journal.prune(1).await.expect("failed to prune journal 1");
3514
3515            // Pruning to 2 should allow the first blob to be pruned.
3516            (journal, _) = journal.prune(2).await.expect("failed to prune journal 2");
3517            assert_eq!(journal.bounds().start, 2);
3518
3519            // Reading from the first blob should fail since it's now pruned
3520            let result0 = journal.read(0).await;
3521            assert!(matches!(result0, Err(Error::ItemPruned(0))));
3522            let result1 = journal.read(1).await;
3523            assert!(matches!(result1, Err(Error::ItemPruned(1))));
3524
3525            // Third item should still be readable
3526            let result2 = journal.read(2).await.unwrap();
3527            assert_eq!(result2, test_digest(2));
3528
3529            // Should be able to continue to append items
3530            for i in 3..10 {
3531                let pos;
3532                (journal, pos) = journal
3533                    .append(&test_digest(i))
3534                    .await
3535                    .expect("failed to append data");
3536                assert_eq!(pos, i);
3537            }
3538
3539            // Check no-op pruning
3540            (journal, _) = journal.prune(0).await.expect("no-op pruning failed");
3541            assert_eq!(journal.test_oldest_blob(), Some(1));
3542            assert_eq!(journal.test_newest_blob(), Some(5));
3543            assert_eq!(journal.bounds().start, 2);
3544
3545            // Prune first 3 blobs (6 items)
3546            (journal, _) = journal
3547                .prune(3 * cfg.items_per_blob.get())
3548                .await
3549                .expect("failed to prune journal 2");
3550            assert_eq!(journal.test_oldest_blob(), Some(3));
3551            assert_eq!(journal.test_newest_blob(), Some(5));
3552            assert_eq!(journal.bounds().start, 6);
3553
3554            // Try pruning (more than) everything in the journal.
3555            (journal, _) = journal
3556                .prune(10000)
3557                .await
3558                .expect("failed to max-prune journal");
3559            let size = journal.size();
3560            assert_eq!(size, 10);
3561            assert_eq!(journal.test_oldest_blob(), Some(5));
3562            assert_eq!(journal.test_newest_blob(), Some(5));
3563            // Since the size of the journal is currently a multiple of items_per_blob, the newest blob
3564            // will be empty, and there will be no retained items.
3565            let bounds = journal.bounds();
3566            assert!(bounds.is_empty());
3567            // bounds.start should equal bounds.end when empty.
3568            assert_eq!(bounds.start, size);
3569
3570            // Replaying from 0 should fail since all items before bounds.start are pruned
3571            {
3572                let reader;
3573                (journal, reader) = journal.snapshot().await.unwrap();
3574                let result = replay_items(
3575                    &reader,
3576                    0..reader.bounds().end,
3577                    NZUsize!(1024),
3578                    ReadOptions::default(),
3579                )
3580                .await;
3581                assert!(matches!(result, Err(Error::ItemPruned(0))));
3582            }
3583
3584            // Replaying from pruning_boundary should return empty stream
3585            {
3586                let reader;
3587                (journal, reader) = journal.snapshot().await.unwrap();
3588                let res = replay_items(
3589                    &reader,
3590                    0..reader.bounds().end,
3591                    NZUsize!(1024),
3592                    ReadOptions::default(),
3593                )
3594                .await;
3595                assert!(matches!(res, Err(Error::ItemPruned(_))));
3596
3597                let reader;
3598                (journal, reader) = journal.snapshot().await.unwrap();
3599                let stream = replay_items(
3600                    &reader,
3601                    journal.bounds().start..reader.bounds().end,
3602                    NZUsize!(1024),
3603                    ReadOptions::default(),
3604                )
3605                .await
3606                .expect("failed to replay journal from pruning boundary");
3607                pin_mut!(stream);
3608                let mut items = Vec::new();
3609                while let Some(result) = stream.next().await {
3610                    match result {
3611                        Ok((pos, item)) => {
3612                            assert_eq!(test_digest(pos), item);
3613                            items.push(pos);
3614                        }
3615                        Err(err) => panic!("Failed to read item: {err}"),
3616                    }
3617                }
3618                assert_eq!(items, Vec::<u64>::new());
3619            }
3620
3621            journal.destroy().await.unwrap();
3622        });
3623    }
3624
3625    /// Append a lot of data to make sure we exercise page cache paging boundaries.
3626    #[test_traced]
3627    fn test_fixed_journal_append_a_lot_of_data() {
3628        // Initialize the deterministic context
3629        let executor = deterministic::Runner::default();
3630        const ITEMS_PER_BLOB: NonZeroU64 = NZU64!(10000);
3631        executor.start(|context| async move {
3632            let cfg = test_cfg(ITEMS_PER_BLOB);
3633            let mut journal = Journal::init(context.child("first"), cfg.clone())
3634                .await
3635                .expect("failed to initialize journal");
3636            // Append 2 blobs worth of items.
3637            for i in 0u64..ITEMS_PER_BLOB.get() * 2 - 1 {
3638                (journal, _) = journal
3639                    .append(&test_digest(i))
3640                    .await
3641                    .expect("failed to append data");
3642            }
3643            // Sync, reopen, then read back.
3644            journal.sync().await.expect("failed to sync journal");
3645            let journal = Journal::init(context.child("second"), cfg.clone())
3646                .await
3647                .expect("failed to re-initialize journal");
3648            for i in 0u64..10000 {
3649                let item: Digest = journal.read(i).await.expect("failed to read data");
3650                assert_eq!(item, test_digest(i));
3651            }
3652            journal.destroy().await.expect("failed to destroy journal");
3653        });
3654    }
3655
3656    #[test_traced]
3657    fn test_fixed_journal_replay() {
3658        const ITEMS_PER_BLOB: NonZeroU64 = NZU64!(7);
3659        // Initialize the deterministic context
3660        let executor = deterministic::Runner::default();
3661
3662        // Start the test within the executor
3663        executor.start(|context| async move {
3664            // Initialize the journal, allowing a max of 7 items per blob.
3665            let cfg = test_cfg(ITEMS_PER_BLOB);
3666            let mut journal = Journal::init(context.child("first"), cfg.clone())
3667                .await
3668                .expect("failed to initialize journal");
3669
3670            // Append many items, filling 100 blobs and part of the 101st
3671            for i in 0u64..(ITEMS_PER_BLOB.get() * 100 + ITEMS_PER_BLOB.get() / 2) {
3672                let pos;
3673                (journal, pos) = journal
3674                    .append(&test_digest(i))
3675                    .await
3676                    .expect("failed to append data");
3677                assert_eq!(pos, i);
3678            }
3679
3680            // Read them back the usual way.
3681            for i in 0u64..(ITEMS_PER_BLOB.get() * 100 + ITEMS_PER_BLOB.get() / 2) {
3682                let item: Digest = journal.read(i).await.expect("failed to read data");
3683                assert_eq!(item, test_digest(i), "i={i}");
3684            }
3685
3686            // Replay should return all items
3687            {
3688                let reader;
3689                (journal, reader) = journal.snapshot().await.unwrap();
3690                let stream = replay_items(
3691                    &reader,
3692                    0..reader.bounds().end,
3693                    NZUsize!(1024),
3694                    ReadOptions::default(),
3695                )
3696                .await
3697                .expect("failed to replay journal");
3698                let mut items = Vec::new();
3699                pin_mut!(stream);
3700                while let Some(result) = stream.next().await {
3701                    match result {
3702                        Ok((pos, item)) => {
3703                            assert_eq!(test_digest(pos), item, "pos={pos}, item={item:?}");
3704                            items.push(pos);
3705                        }
3706                        Err(err) => panic!("Failed to read item: {err}"),
3707                    }
3708                }
3709
3710                // Make sure all items were replayed
3711                assert_eq!(
3712                    items.len(),
3713                    ITEMS_PER_BLOB.get() as usize * 100 + ITEMS_PER_BLOB.get() as usize / 2
3714                );
3715                items.sort();
3716                for (i, pos) in items.iter().enumerate() {
3717                    assert_eq!(i as u64, *pos);
3718                }
3719            }
3720
3721            let journal = journal.sync().await.expect("Failed to sync journal");
3722            drop(journal);
3723
3724            // Corrupt one of the bytes and make sure it's detected.
3725            let (blob, _) = context
3726                .open(&blob_partition(&cfg), &40u64.to_be_bytes())
3727                .await
3728                .expect("Failed to open blob");
3729            // Write junk bytes.
3730            let bad_bytes = 123456789u32;
3731            blob.write_at(1, bad_bytes.to_be_bytes().to_vec(), WriteOptions::SYNC)
3732                .await
3733                .expect("Failed to write bad bytes");
3734
3735            // Re-initialize the journal to simulate a restart
3736            drop(blob);
3737            let journal = Journal::init(context.child("second"), cfg.clone())
3738                .await
3739                .expect("Failed to re-initialize journal");
3740
3741            // Make sure reading an item that resides in the corrupted page fails.
3742            let err = journal
3743                .read(40 * ITEMS_PER_BLOB.get() + 1)
3744                .await
3745                .unwrap_err();
3746            assert!(matches!(err, Error::Runtime(_)));
3747
3748            // Replay all items.
3749            {
3750                let mut error_found = false;
3751                let (_journal, reader) = journal.snapshot().await.unwrap();
3752                let stream = replay_items(
3753                    &reader,
3754                    0..reader.bounds().end,
3755                    NZUsize!(1024),
3756                    ReadOptions::default(),
3757                )
3758                .await
3759                .expect("failed to replay journal");
3760                let mut items = Vec::new();
3761                pin_mut!(stream);
3762                while let Some(result) = stream.next().await {
3763                    match result {
3764                        Ok((pos, item)) => {
3765                            assert_eq!(test_digest(pos), item);
3766                            items.push(pos);
3767                        }
3768                        Err(err) => {
3769                            error_found = true;
3770                            assert!(matches!(err, Error::Runtime(_)));
3771                            assert!(stream.next().await.is_none());
3772                            break;
3773                        }
3774                    }
3775                }
3776                assert!(error_found); // error should abort replay
3777            }
3778        });
3779    }
3780
3781    #[test_traced]
3782    fn test_replay_read_options_reach_history_and_tip() {
3783        const ITEMS_PER_BLOB: NonZeroU64 = NZU64!(6);
3784
3785        let executor = deterministic::Runner::default();
3786        executor.start(|context| async move {
3787            let (context, recordings) = RecordingContext::new(context);
3788            let cfg = test_cfg(ITEMS_PER_BLOB);
3789            let page_cache = cfg.page_cache.clone();
3790            let mut journal = Journal::init(context.child("journal"), cfg)
3791                .await
3792                .expect("failed to initialize journal");
3793
3794            // The tip blob holds three items across two full pages and a partial page.
3795            for i in 0..9 {
3796                (journal, _) = journal
3797                    .append(&test_digest(i))
3798                    .await
3799                    .expect("failed to append");
3800            }
3801            journal = journal.sync().await.expect("failed to sync journal");
3802
3803            // Sealed history receives the replay operation's read policy directly.
3804            page_cache.clear();
3805            recordings.clear();
3806            {
3807                let stream = replay_items(
3808                    &journal,
3809                    0..journal.bounds().end,
3810                    NZUsize!(56),
3811                    ReadOptions::DONT_CACHE,
3812                )
3813                .await
3814                .expect("failed to replay sealed history");
3815                pin_mut!(stream);
3816                let (position, item) = stream
3817                    .next()
3818                    .await
3819                    .expect("missing sealed replay item")
3820                    .expect("failed to replay sealed item");
3821                assert_eq!(position, 0);
3822                assert_eq!(item, test_digest(0));
3823
3824                let reads = recordings.snapshot().reads;
3825                assert!(!reads.is_empty());
3826                assert!(
3827                    reads
3828                        .iter()
3829                        .all(|options| *options == ReadOptions::DONT_CACHE)
3830                );
3831            }
3832
3833            // A synced writable tip has no unwritten full page, so it is replayed like sealed
3834            // history and its full pages arrive in one read that carries the caller's policy.
3835            for options in [ReadOptions::default(), ReadOptions::DONT_CACHE] {
3836                page_cache.clear();
3837                recordings.clear();
3838                {
3839                    let stream =
3840                        replay_items(&journal, 6..journal.bounds().end, NZUsize!(2048), options)
3841                            .await
3842                            .expect("failed to replay writable tip");
3843                    pin_mut!(stream);
3844                    for i in 6..9 {
3845                        let (position, item) = stream
3846                            .next()
3847                            .await
3848                            .expect("missing writable replay item")
3849                            .expect("failed to replay writable item");
3850                        assert_eq!(position, i);
3851                        assert_eq!(item, test_digest(i));
3852                    }
3853                    assert!(stream.next().await.is_none());
3854                    assert_eq!(recordings.snapshot().reads, vec![options]);
3855                }
3856                recordings.clear();
3857                assert_eq!(journal.read(6).await.unwrap(), test_digest(6));
3858                assert!(!recordings.snapshot().reads.is_empty());
3859                recordings.clear();
3860                assert_eq!(journal.read(6).await.unwrap(), test_digest(6));
3861                assert!(recordings.snapshot().reads.is_empty());
3862            }
3863
3864            // Once the tip buffers an unwritten full page (without filling the blob, which would
3865            // seal it), replay goes through the page cache, which faults each missing page with
3866            // DONT_CACHE regardless of the caller's policy.
3867            for i in 9..11 {
3868                (journal, _) = journal
3869                    .append(&test_digest(i))
3870                    .await
3871                    .expect("failed to append");
3872            }
3873            page_cache.clear();
3874            recordings.clear();
3875            {
3876                let stream = replay_items(
3877                    &journal,
3878                    6..journal.bounds().end,
3879                    NZUsize!(2048),
3880                    ReadOptions::default(),
3881                )
3882                .await
3883                .expect("failed to replay buffered tip");
3884                pin_mut!(stream);
3885                let (position, item) = stream
3886                    .next()
3887                    .await
3888                    .expect("missing buffered replay item")
3889                    .expect("failed to replay buffered item");
3890                assert_eq!(position, 6);
3891                assert_eq!(item, test_digest(6));
3892                assert_eq!(
3893                    recordings.snapshot().reads,
3894                    vec![ReadOptions::DONT_CACHE, ReadOptions::DONT_CACHE]
3895                );
3896            }
3897
3898            journal.destroy().await.expect("failed to destroy journal");
3899        });
3900    }
3901
3902    #[test_traced]
3903    fn test_fixed_replay_stops_after_error() {
3904        let executor = deterministic::Runner::default();
3905        executor.start(|context| async move {
3906            let cfg = test_cfg(NZU64!(10));
3907            let mut journal = Journal::init(context.child("first"), cfg.clone())
3908                .await
3909                .unwrap();
3910
3911            for i in 0u64..30 {
3912                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
3913            }
3914            let journal = journal.sync().await.unwrap();
3915            drop(journal);
3916
3917            let (blob, _) = context
3918                .open(&blob_partition(&cfg), &1u64.to_be_bytes())
3919                .await
3920                .unwrap();
3921            blob.write_at(1, 123456789u32.to_be_bytes().to_vec(), WriteOptions::SYNC)
3922                .await
3923                .unwrap();
3924
3925            drop(blob);
3926            let mut journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
3927                .await
3928                .unwrap();
3929            let reader;
3930            (journal, reader) = journal.snapshot().await.unwrap();
3931            let stream = replay_items(
3932                &reader,
3933                0..reader.bounds().end,
3934                NZUsize!(1024),
3935                ReadOptions::default(),
3936            )
3937            .await
3938            .unwrap();
3939            pin_mut!(stream);
3940
3941            for i in 0u64..10 {
3942                let (pos, item) = stream.next().await.unwrap().unwrap();
3943                assert_eq!(pos, i);
3944                assert_eq!(item, test_digest(i));
3945            }
3946            assert!(matches!(
3947                stream.next().await.unwrap(),
3948                Err(Error::Runtime(_))
3949            ));
3950            assert!(stream.next().await.is_none());
3951
3952            journal.destroy().await.unwrap();
3953        });
3954    }
3955
3956    #[test_traced]
3957    fn test_fixed_journal_replay_with_missing_historical_blob() {
3958        let executor = deterministic::Runner::default();
3959        executor.start(|context| async move {
3960            let cfg = test_cfg(NZU64!(2));
3961            let mut journal = Journal::init(context.child("first"), cfg.clone())
3962                .await
3963                .unwrap();
3964            for i in 0u64..5 {
3965                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
3966            }
3967            let journal = journal.sync().await.unwrap();
3968            drop(journal);
3969
3970            // Delete a middle blob (external corruption). The watermark (5) now exceeds the
3971            // recoverable contiguous prefix, which is corruption.
3972            context
3973                .remove(&blob_partition(&cfg), Some(&1u64.to_be_bytes()))
3974                .await
3975                .unwrap();
3976
3977            let result = Journal::<_, Digest>::init(context.child("second"), cfg.clone()).await;
3978            assert!(matches!(result, Err(Error::Corruption(_))));
3979        });
3980    }
3981
3982    #[test_traced]
3983    fn test_fixed_journal_partial_replay() {
3984        const ITEMS_PER_BLOB: NonZeroU64 = NZU64!(7);
3985        // 53 % 7 = 4, which will trigger a non-trivial seek in the starting blob to reach the
3986        // starting position.
3987        const START_POS: u64 = 53;
3988
3989        // Initialize the deterministic context
3990        let executor = deterministic::Runner::default();
3991        // Start the test within the executor
3992        executor.start(|context| async move {
3993            // Initialize the journal, allowing a max of 7 items per blob.
3994            let cfg = test_cfg(ITEMS_PER_BLOB);
3995            let mut journal = Journal::init(context.child("storage"), cfg.clone())
3996                .await
3997                .expect("failed to initialize journal");
3998
3999            // Append many items, filling 100 blobs and part of the 101st
4000            for i in 0u64..(ITEMS_PER_BLOB.get() * 100 + ITEMS_PER_BLOB.get() / 2) {
4001                let pos;
4002                (journal, pos) = journal
4003                    .append(&test_digest(i))
4004                    .await
4005                    .expect("failed to append data");
4006                assert_eq!(pos, i);
4007            }
4008
4009            // Replay should return all items except the first `START_POS`.
4010            {
4011                let reader;
4012                (journal, reader) = journal.snapshot().await.unwrap();
4013                let stream = replay_items(
4014                    &reader,
4015                    START_POS..reader.bounds().end,
4016                    NZUsize!(1024),
4017                    ReadOptions::default(),
4018                )
4019                .await
4020                .expect("failed to replay journal");
4021                let mut items = Vec::new();
4022                pin_mut!(stream);
4023                while let Some(result) = stream.next().await {
4024                    match result {
4025                        Ok((pos, item)) => {
4026                            assert!(pos >= START_POS, "pos={pos}, expected >= {START_POS}");
4027                            assert_eq!(
4028                                test_digest(pos),
4029                                item,
4030                                "Item at position {pos} did not match expected digest"
4031                            );
4032                            items.push(pos);
4033                        }
4034                        Err(err) => panic!("Failed to read item: {err}"),
4035                    }
4036                }
4037
4038                // Make sure all items were replayed
4039                assert_eq!(
4040                    items.len(),
4041                    ITEMS_PER_BLOB.get() as usize * 100 + ITEMS_PER_BLOB.get() as usize / 2
4042                        - START_POS as usize
4043                );
4044                items.sort();
4045                for (i, pos) in items.iter().enumerate() {
4046                    assert_eq!(i as u64, *pos - START_POS);
4047                }
4048            }
4049
4050            journal.destroy().await.unwrap();
4051        });
4052    }
4053
4054    /// Replay tiles a range in position order without crossing blob boundaries, through
4055    /// sealed blobs and the unsynced tail, at budgets of one item, part of a blob, and the whole
4056    /// journal.
4057    #[test_traced]
4058    fn test_fixed_journal_replay_batches() {
4059        const ITEMS_PER_BLOB: NonZeroU64 = NZU64!(7);
4060
4061        let executor = deterministic::Runner::default();
4062        executor.start(|context| async move {
4063            let cfg = test_cfg(ITEMS_PER_BLOB);
4064            let mut journal = Journal::init(context.child("storage"), cfg.clone())
4065                .await
4066                .expect("failed to initialize journal");
4067
4068            // Fill ten blobs and part of the eleventh.
4069            let total = ITEMS_PER_BLOB.get() * 10 + 3;
4070            for i in 0u64..total {
4071                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
4072            }
4073
4074            for buffer in [Digest::SIZE, 100, 2048] {
4075                for range in [0..total, 12..40, 20..21, 30..30, 70..total] {
4076                    crate::journal::contiguous::tests::check_replay_batches(
4077                        &journal,
4078                        range,
4079                        buffer,
4080                        ITEMS_PER_BLOB.get(),
4081                        Digest::SIZE,
4082                        test_digest,
4083                    )
4084                    .await;
4085                }
4086            }
4087
4088            // A synced tail is replayed through its sealed pages.
4089            let journal = journal.sync().await.unwrap();
4090            crate::journal::contiguous::tests::check_replay_batches(
4091                &journal,
4092                65..total,
4093                100,
4094                ITEMS_PER_BLOB.get(),
4095                Digest::SIZE,
4096                test_digest,
4097            )
4098            .await;
4099
4100            journal.destroy().await.unwrap();
4101        });
4102    }
4103
4104    #[test]
4105    fn test_fixed_replay_bulk_retains_original_allocation() {
4106        deterministic::Runner::default().start(|context| async move {
4107            let data: Vec<u8> = (0u64..16).flat_map(u64::to_be_bytes).collect();
4108            let cache = CacheRef::new(NZU16!(64), NZUsize!(4));
4109            let (blob, size) = context
4110                .open("fixed-replay-sharing", b"synced")
4111                .await
4112                .unwrap();
4113            let writer = Writer::new(blob, size, 256, cache.clone()).await.unwrap();
4114            let (writer, _) = writer.append(&data).await.unwrap();
4115            let (writer, snapshot) = writer.snapshot().await.unwrap();
4116            let (blob, size) = context
4117                .open("fixed-replay-sharing", b"pending")
4118                .await
4119                .unwrap();
4120            let pending = Writer::new(blob, size, 256, cache).await.unwrap();
4121            let (pending, _) = pending.append(&data).await.unwrap();
4122            assert!(pending.try_snapshot().is_none());
4123
4124            for source in [
4125                super::super::blobs::Blob::Sealed(snapshot),
4126                super::super::blobs::Blob::Writer(&writer),
4127                super::super::blobs::Blob::Writer(&pending),
4128            ] {
4129                let mut state = FixedReplayState::<_, View> {
4130                    replay: source
4131                        .replay_from(0, NZUsize!(256), ReadOptions::DONT_CACHE)
4132                        .unwrap(),
4133                    pos: 0,
4134                    end_pos: 16,
4135                    items_per_batch: 32,
4136                    _marker: PhantomData,
4137                };
4138                assert!(state.replay.ensure(View::SIZE).await.unwrap());
4139                let first_chunk = state.replay.chunk();
4140                let source = first_chunk.as_ptr() as usize;
4141                let count = first_chunk.len() / View::SIZE;
4142                let batch = {
4143                    let stream = super::super::replay_stream_from_states(vec![state]);
4144                    pin_mut!(stream);
4145                    stream.next().await.unwrap().unwrap()
4146                };
4147                assert!(!batch.items.is_empty());
4148                for (index, item) in batch.items.iter().enumerate() {
4149                    assert_eq!(item.bytes.as_ref(), &(index as u64).to_be_bytes());
4150                    if index < count {
4151                        assert_eq!(item.bytes.as_ptr() as usize, source + index * View::SIZE);
4152                    }
4153                    item.assert_shared();
4154                }
4155            }
4156        });
4157    }
4158
4159    /// A page with a bad checksum inside a batch read ahead of the decoder ends the stream after
4160    /// the batches before it, with one checksum error, whether the page starts its batch or sits
4161    /// inside it, at buffers of 8 and 64 physical pages. Valid pages ahead of the bad one in the
4162    /// same batch are not yielded, since a fill validates its whole batch.
4163    #[test_traced]
4164    fn test_bad_page_in_batch_read_ahead_ends_replay() {
4165        const PAGE: usize = 64;
4166        const ITEMS: u64 = 1280;
4167        let executor = deterministic::Runner::default();
4168        executor.start(|context| async move {
4169            let physical_page = PAGE + commonware_runtime::buffer::paged::CHECKSUM_SIZE as usize;
4170            let items_per_page = PAGE / u64::SIZE;
4171            for (buffer_pages, within_batch) in [(8, 0), (8, 1), (64, 0), (64, 1)] {
4172                // The buffer splits into four batches. Corrupt a page of the third one.
4173                let batch_pages = buffer_pages / 4;
4174                let bad_page = 2 * batch_pages + within_batch;
4175                let expected_items = (2 * batch_pages * items_per_page) as u64;
4176                let cfg = Config {
4177                    partition: format!("bad-page-{buffer_pages}-{within_batch}"),
4178                    items_per_blob: NZU64!(ITEMS),
4179                    page_cache: CacheRef::new(NZU16!(PAGE as u16), NZUsize!(4)),
4180                    write_buffer: NZUsize!(2048),
4181                    replay_buffer: NZUsize!(2048),
4182                };
4183                let mut journal = Journal::<_, u64>::init(context.child("write"), cfg.clone())
4184                    .await
4185                    .unwrap();
4186                for item in 0..ITEMS {
4187                    (journal, _) = journal.append(&item).await.unwrap();
4188                }
4189                drop(journal.sync().await.unwrap());
4190                corrupt_page(
4191                    &context,
4192                    &format!("{}-blobs", cfg.partition),
4193                    &0u64.to_be_bytes(),
4194                    bad_page as u64,
4195                    PAGE as u64,
4196                )
4197                .await;
4198                cfg.page_cache.clear();
4199
4200                let journal = Journal::<_, u64>::init(context.child("replay"), cfg)
4201                    .await
4202                    .unwrap();
4203                let (journal, reader) = journal.snapshot().await.unwrap();
4204                let stream = reader
4205                    .replay(
4206                        0..ITEMS,
4207                        NZUsize!(buffer_pages * physical_page),
4208                        ReadOptions::DONT_CACHE,
4209                    )
4210                    .await
4211                    .unwrap();
4212                pin_mut!(stream);
4213                let mut next = 0u64;
4214                loop {
4215                    match stream.next().await.expect("checksum error before end") {
4216                        Ok(batch) => {
4217                            for (position, item) in batch {
4218                                assert_eq!(position, next);
4219                                assert_eq!(item, next);
4220                                next += 1;
4221                            }
4222                        }
4223                        Err(error) => {
4224                            assert!(matches!(
4225                                error,
4226                                Error::Runtime(RuntimeError::InvalidChecksum)
4227                            ));
4228                            break;
4229                        }
4230                    }
4231                }
4232                assert_eq!(
4233                    next, expected_items,
4234                    "{buffer_pages} pages, page {bad_page}"
4235                );
4236                assert!(stream.next().await.is_none());
4237                drop(journal);
4238            }
4239        });
4240    }
4241
4242    /// A codec error ends the batch stream after the items decoded before it, whether the failing
4243    /// item is decoded in bulk from the first page, through the per-item path because it straddles
4244    /// a page boundary, or in bulk from a later page.
4245    #[test_traced]
4246    fn test_fixed_journal_replay_batches_stop_at_codec_error() {
4247        const ITEMS: u64 = 100;
4248
4249        let executor = deterministic::Runner::default();
4250        executor.start(|context| async move {
4251            // With three-byte items on 44-byte logical pages, items 0..14 fit in page 0, item 14
4252            // straddles pages 0 and 1, and item 20 lies inside page 1.
4253            for invalid in [5u64, 14, 20] {
4254                let mut cfg = test_cfg(NZU64!(1000));
4255                cfg.partition = format!("codec-error-{invalid}");
4256
4257                // Write byte triples that decode as `[bool; 3]` except at `invalid`.
4258                let mut journal = Journal::<_, [u8; 3]>::init(context.child("writer"), cfg.clone())
4259                    .await
4260                    .unwrap();
4261                for i in 0..ITEMS {
4262                    let item = if i == invalid { [0, 2, 0] } else { [0, 1, 0] };
4263                    (journal, _) = journal.append(&item).await.unwrap();
4264                }
4265                let journal = journal.sync().await.unwrap();
4266                drop(journal);
4267
4268                // Replay the same bytes as `[bool; 3]` to get the valid prefix and then the error.
4269                let journal = Journal::<_, [bool; 3]>::init(context.child("reader"), cfg)
4270                    .await
4271                    .unwrap();
4272                {
4273                    let stream = journal
4274                        .replay(0..ITEMS, NZUsize!(2048), ReadOptions::default())
4275                        .await
4276                        .unwrap();
4277                    pin_mut!(stream);
4278                    let mut decoded = 0u64;
4279                    loop {
4280                        match stream.next().await.unwrap() {
4281                            Ok(batch) => {
4282                                assert_eq!(batch.start, decoded, "invalid item {invalid}");
4283                                assert!(
4284                                    batch.items.iter().all(|item| *item == [false, true, false])
4285                                );
4286                                decoded += batch.items.len() as u64;
4287                            }
4288                            Err(Error::Codec(_)) => break,
4289                            Err(err) => panic!("unexpected error for item {invalid}: {err}"),
4290                        }
4291                    }
4292                    assert_eq!(decoded, invalid, "invalid item {invalid}");
4293                    assert!(stream.next().await.is_none());
4294                }
4295
4296                journal.destroy().await.unwrap();
4297            }
4298        });
4299    }
4300
4301    /// Replay yields exactly the requested positions, spanning blob boundaries, and
4302    /// validates the range against `bounds()`.
4303    #[test_traced]
4304    fn test_fixed_journal_replay_range() {
4305        const ITEMS_PER_BLOB: NonZeroU64 = NZU64!(7);
4306
4307        let executor = deterministic::Runner::default();
4308        executor.start(|context| async move {
4309            let cfg = test_cfg(ITEMS_PER_BLOB);
4310            let mut journal = Journal::init(context.child("storage"), cfg.clone())
4311                .await
4312                .expect("failed to initialize journal");
4313
4314            // Fill ten blobs and part of the eleventh.
4315            let total = ITEMS_PER_BLOB.get() * 10 + 3;
4316            for i in 0u64..total {
4317                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
4318            }
4319
4320            // A mid-journal range crossing several blob boundaries yields exactly [start, end).
4321            let (start, end) = (12u64, 40u64);
4322            {
4323                let stream =
4324                    replay_items(&journal, start..end, NZUsize!(1024), ReadOptions::default())
4325                        .await
4326                        .expect("failed to replay range");
4327                pin_mut!(stream);
4328                let mut next = start;
4329                while let Some(result) = stream.next().await {
4330                    let (pos, item) = result.expect("failed to read item");
4331                    assert_eq!(pos, next);
4332                    assert_eq!(item, test_digest(pos));
4333                    next += 1;
4334                }
4335                assert_eq!(next, end);
4336            }
4337
4338            // An empty range yields an empty stream.
4339            {
4340                let stream = replay_items(&journal, 5..5, NZUsize!(1024), ReadOptions::default())
4341                    .await
4342                    .expect("failed to replay empty range");
4343                pin_mut!(stream);
4344                assert!(stream.next().await.is_none());
4345            }
4346
4347            // A range past the journal's end is rejected.
4348            let err = replay_items(
4349                &journal,
4350                0..total + 1,
4351                NZUsize!(1024),
4352                ReadOptions::default(),
4353            )
4354            .await
4355            .map(|_| ())
4356            .unwrap_err();
4357            assert!(matches!(err, Error::ItemOutOfRange(pos) if pos == total + 1));
4358
4359            // An inverted range is rejected.
4360            #[allow(clippy::reversed_empty_ranges)]
4361            let err = replay_items(&journal, 10..5, NZUsize!(1024), ReadOptions::default())
4362                .await
4363                .map(|_| ())
4364                .unwrap_err();
4365            assert!(matches!(err, Error::ItemOutOfRange(10)));
4366
4367            journal.destroy().await.unwrap();
4368        });
4369    }
4370
4371    #[test_traced]
4372    fn test_fixed_journal_rejects_corrupted_tail_blob() {
4373        let executor = deterministic::Runner::default();
4374        executor.start(|context| async move {
4375            let cfg = test_cfg(NZU64!(3));
4376            let mut journal = Journal::init(context.child("first"), cfg.clone())
4377                .await
4378                .unwrap();
4379            for i in 0..5 {
4380                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
4381            }
4382            let journal = journal.sync().await.unwrap();
4383            drop(journal);
4384
4385            // Truncate the tail blob by 1 byte (external corruption). The watermark (5) now
4386            // exceeds the recoverable size, which is corruption.
4387            let (blob, size) = context
4388                .open(&blob_partition(&cfg), &1u64.to_be_bytes())
4389                .await
4390                .unwrap();
4391            blob.resize(size - 1).await.unwrap();
4392            blob.sync().await.unwrap();
4393
4394            drop(blob);
4395            let result = Journal::<_, Digest>::init(context.child("second"), cfg.clone()).await;
4396            assert!(matches!(result, Err(Error::Corruption(_))));
4397        });
4398    }
4399
4400    /// A destroy interrupted at any point leaves a journal that reopens as a crash would recover
4401    /// it or as a fresh journal, and that a retried destroy removes.
4402    #[test_traced]
4403    fn test_interrupted_destroy_reopens() {
4404        let (mut unchanged, mut emptied) = (0, 0);
4405        for seed in 0..32u64 {
4406            let executor = deterministic::Runner::seeded(seed);
4407            let outcome = executor.start(|context| async move {
4408                // Start mid-blob, where the checkpoint's boundary hint is load-bearing, and append
4409                // six items across four blobs.
4410                let cfg = test_cfg(NZU64!(2));
4411                let mut journal =
4412                    Journal::<_, Digest>::init_at_size(context.child("first"), cfg.clone(), 5)
4413                        .await
4414                        .unwrap();
4415                for i in 5..11 {
4416                    (journal, _) = journal.append(&test_digest(i)).await.unwrap();
4417                }
4418                let journal = journal.sync().await.unwrap();
4419
4420                // Fail a sample of syncs and removals so destroy stops at varying depths, from the
4421                // staged clear through the checkpoint's own blobs.
4422                context.storage_fault_config().write().sync_rate = Some(probability!(0.3));
4423                context.storage_fault_config().write().remove_rate = Some(probability!(0.1));
4424                let result = journal.destroy().await;
4425                context.storage_fault_config().write().sync_rate = None;
4426                context.storage_fault_config().write().remove_rate = None;
4427                if result.is_ok() {
4428                    return 0;
4429                }
4430
4431                // The reopened journal is the synced one or a fresh one.
4432                let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
4433                    .await
4434                    .unwrap();
4435                let bounds = journal.bounds();
4436                let outcome = if bounds == (5..11) {
4437                    for pos in bounds {
4438                        assert_eq!(journal.read(pos).await.unwrap(), test_digest(pos));
4439                    }
4440                    1
4441                } else {
4442                    assert_eq!(bounds, 0..0, "seed {seed} reopened with {bounds:?}");
4443                    2
4444                };
4445
4446                // A retried destroy completes and leaves nothing behind.
4447                journal.destroy().await.unwrap();
4448                let journal = Journal::<_, Digest>::init(context.child("third"), cfg)
4449                    .await
4450                    .unwrap();
4451                assert_eq!(journal.bounds(), 0..0);
4452                outcome
4453            });
4454            match outcome {
4455                1 => unchanged += 1,
4456                2 => emptied += 1,
4457                _ => {}
4458            }
4459        }
4460        assert!(
4461            unchanged > 0 && emptied > 0,
4462            "unchanged {unchanged} emptied {emptied}"
4463        );
4464    }
4465
4466    /// Simulate a crash after recovery persists metadata but before the truncate repair completes.
4467    /// The stale blobs beyond the repair point still exist. The next init must succeed: it
4468    /// re-derives the same size from blob lengths, and the persisted watermark is still within
4469    /// the recovered size.
4470    #[test_traced]
4471    fn test_fixed_journal_crash_during_recovery_repair() {
4472        let executor = deterministic::Runner::default();
4473        executor.start(|context| async move {
4474            let cfg = test_cfg(NZU64!(5));
4475            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
4476                .await
4477                .unwrap();
4478
4479            // Fill 3 blobs (0..15), sync everything.
4480            for i in 0..15u64 {
4481                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
4482            }
4483            let mut journal = journal.sync().await.unwrap();
4484            assert_eq!(journal.0.recovery_watermark(), 15);
4485
4486            // Persist the recovered metadata (watermark=9) as init does before applying the
4487            // truncate repair. This simulates a crash after metadata sync but before the repair
4488            // removes stale blobs.
4489            journal.0.checkpoint = journal
4490                .0
4491                .checkpoint
4492                .persist(cfg.items_per_blob.get(), 0, 9)
4493                .await
4494                .unwrap();
4495            drop(journal);
4496
4497            // Shorten blob 1 to simulate a short non-tail blob. Recovery will compute
4498            // size=9 (blob 0 full + 4 items in blob 1) and generate a repair.
4499            {
4500                let cache_ref = CacheRef::new(PAGE_SIZE, PAGE_CACHE_SIZE);
4501                let (blob, blob_size) = context
4502                    .open(&blob_partition(&cfg), &1u64.to_be_bytes())
4503                    .await
4504                    .expect("failed to open blob 1");
4505                let mut append = PagedRecovery::open(blob, blob_size, 2048, cache_ref)
4506                    .await
4507                    .expect("failed to wrap blob 1");
4508                append = append
4509                    .truncate(4 * Digest::SIZE as u64)
4510                    .await
4511                    .expect("failed to shorten blob 1");
4512                append
4513                    .sync()
4514                    .await
4515                    .expect("failed to sync shortened blob 1");
4516            }
4517
4518            // Blobs 2 (and the empty tail at 3) still exist. Init must succeed and the
4519            // truncate must remove the stale blobs.
4520            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
4521                .await
4522                .expect("init should succeed after crash during recovery repair");
4523            assert_eq!(journal.bounds(), 0..9);
4524            assert_eq!(journal.0.recovery_watermark(), 9);
4525            assert_eq!(journal.read(8).await.unwrap(), test_digest(8));
4526            assert!(matches!(
4527                journal.read(9).await,
4528                Err(Error::ItemOutOfRange(9))
4529            ));
4530            assert_eq!(
4531                journal.test_newest_blob(),
4532                Some(1),
4533                "stale blobs beyond the repair point should be removed"
4534            );
4535
4536            journal.destroy().await.unwrap();
4537        });
4538    }
4539
4540    #[test_traced]
4541    fn test_fixed_journal_recover_accepts_clean_short_tail() {
4542        let executor = deterministic::Runner::default();
4543        executor.start(|context| async move {
4544            let cfg = test_cfg(NZU64!(5));
4545
4546            // Set up via the public API: 5 items in blob 0 (full) + 2 items in blob 1
4547            // (partial), then sync and drop.
4548            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
4549                .await
4550                .unwrap();
4551            for i in 0..7 {
4552                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
4553            }
4554            journal.sync().await.unwrap();
4555
4556            // Reopen and verify the size is exactly 7 with no repair (a clean short tail).
4557            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
4558                .await
4559                .unwrap();
4560            assert_eq!(journal.size(), 7);
4561            // Blobs 0 and 1 exist and we can read every position.
4562            for i in 0..7u64 {
4563                assert_eq!(journal.read(i).await.unwrap(), test_digest(i));
4564            }
4565            journal.destroy().await.unwrap();
4566        });
4567    }
4568
4569    #[test_traced]
4570    fn test_fixed_journal_recover_accepts_clean_empty_tail() {
4571        let executor = deterministic::Runner::default();
4572        executor.start(|context| async move {
4573            let cfg = test_cfg(NZU64!(5));
4574
4575            // Set up via the public API: 5 items in blob 0 (full); rolling over implicitly
4576            // creates an empty blob 1 as the tail. Sync and drop.
4577            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
4578                .await
4579                .unwrap();
4580            for i in 0..5 {
4581                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
4582            }
4583            journal.sync().await.unwrap();
4584
4585            // Reopen: blob 0 is full, blob 1 is the empty tail. Size = 5, no repair.
4586            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
4587                .await
4588                .unwrap();
4589            assert_eq!(journal.size(), 5);
4590            for i in 0..5u64 {
4591                assert_eq!(journal.read(i).await.unwrap(), test_digest(i));
4592            }
4593            assert_eq!(journal.test_newest_blob(), Some(1));
4594            journal.destroy().await.unwrap();
4595        });
4596    }
4597
4598    #[test_traced]
4599    fn test_fixed_journal_recover_sparse_blob_ids_repairs_at_gap() {
4600        let executor = deterministic::Runner::default();
4601        executor.start(|context| async move {
4602            let cfg = test_cfg(NZU64!(1));
4603            let blob_partition = blob_partition(&cfg);
4604
4605            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
4606                .await
4607                .unwrap();
4608            (journal, _) = journal.append(&test_digest(0)).await.unwrap();
4609            let journal = journal.sync().await.unwrap();
4610            drop(journal);
4611
4612            // Add a far-future blob directly. Recovery should inspect actual blob ids and
4613            // repair at the first missing boundary instead of walking the entire numeric range.
4614            let cache_ref = CacheRef::new(PAGE_SIZE, PAGE_CACHE_SIZE);
4615            let (blob, blob_size) = context
4616                .open(&blob_partition, &u64::MAX.to_be_bytes())
4617                .await
4618                .unwrap();
4619            let mut append = Writer::new(blob, blob_size, 2048, cache_ref).await.unwrap();
4620            let extra = test_digest(999);
4621            (append, _) = append.append(extra.as_ref()).await.unwrap();
4622            append = append.sync().await.unwrap();
4623            drop(append);
4624
4625            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
4626                .await
4627                .unwrap();
4628            assert_eq!(journal.bounds(), 0..1);
4629            assert_eq!(journal.read(0).await.unwrap(), test_digest(0));
4630            assert!(matches!(
4631                journal.read(1).await,
4632                Err(Error::ItemOutOfRange(1))
4633            ));
4634            assert_eq!(journal.test_newest_blob(), Some(1));
4635
4636            journal.destroy().await.unwrap();
4637        });
4638    }
4639
4640    #[test_traced]
4641    fn test_fixed_journal_recover_fallback_truncates_after_short_oldest_blob() {
4642        let executor = deterministic::Runner::default();
4643        executor.start(|context| async move {
4644            let cfg = test_cfg(NZU64!(5));
4645            let mut journal =
4646                Journal::<_, Digest>::init_at_size(context.child("first"), cfg.clone(), 7)
4647                    .await
4648                    .expect("failed to initialize journal at size");
4649
4650            for i in 0..8u64 {
4651                (journal, _) = journal
4652                    .append(&test_digest(100 + i))
4653                    .await
4654                    .expect("failed to append data");
4655            }
4656            let journal = journal.sync().await.expect("failed to sync journal");
4657            assert_eq!(journal.bounds(), 7..15);
4658
4659            let journal = journal
4660                .test_set_recovery_watermark(6)
4661                .await
4662                .expect("failed to sync lower recovery watermark");
4663            drop(journal);
4664
4665            let (blob, size) = context
4666                .open(&blob_partition(&cfg), &1u64.to_be_bytes())
4667                .await
4668                .expect("failed to open oldest blob");
4669            blob.resize(size - 1).await.expect("failed to corrupt blob");
4670            blob.sync().await.expect("failed to sync blob");
4671
4672            drop(blob);
4673            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
4674                .await
4675                .expect("failed to recover journal");
4676            assert_eq!(journal.bounds(), 7..9);
4677            assert_eq!(journal.read(7).await.unwrap(), test_digest(100));
4678            assert_eq!(journal.read(8).await.unwrap(), test_digest(101));
4679            assert!(matches!(
4680                journal.read(9).await,
4681                Err(Error::ItemOutOfRange(9))
4682            ));
4683            assert_eq!(journal.test_oldest_blob(), Some(1));
4684            assert_eq!(journal.test_newest_blob(), Some(1));
4685
4686            journal.destroy().await.unwrap();
4687        });
4688    }
4689
4690    #[test_traced]
4691    fn test_fixed_journal_stale_pruning_metadata_preserves_watermark() {
4692        let executor = deterministic::Runner::default();
4693        executor.start(|context| async move {
4694            let cfg = test_cfg(NZU64!(5));
4695            let mut journal =
4696                Journal::<_, Digest>::init_at_size(context.child("first"), cfg.clone(), 7)
4697                    .await
4698                    .expect("failed to initialize journal at size");
4699
4700            for i in 0..10u64 {
4701                (journal, _) = journal
4702                    .append(&test_digest(i))
4703                    .await
4704                    .expect("failed to append data");
4705            }
4706            let journal = journal.sync().await.expect("failed to sync journal");
4707            assert_eq!(journal.bounds(), 7..17);
4708
4709            // Stage the stale forward-looking watermark while the journal is alive (so we go
4710            // through the public metadata path), then drop and corrupt the underlying blob.
4711            let journal = journal
4712                .test_set_recovery_watermark(12)
4713                .await
4714                .expect("failed to sync recovery watermark");
4715            drop(journal);
4716
4717            // Shorten blob 2 to two items through paged recovery so the on-disk logical view
4718            // matches the staged watermark of 12.
4719            {
4720                let cache_ref = CacheRef::new(PAGE_SIZE, PAGE_CACHE_SIZE);
4721                let (blob, blob_size) = context
4722                    .open(&blob_partition(&cfg), &2u64.to_be_bytes())
4723                    .await
4724                    .expect("failed to open blob 2");
4725                let mut append = PagedRecovery::open(blob, blob_size, 2048, cache_ref)
4726                    .await
4727                    .expect("failed to wrap blob 2");
4728                append = append
4729                    .truncate(2 * Digest::SIZE as u64)
4730                    .await
4731                    .expect("failed to shorten anchored blob");
4732                append.sync().await.expect("failed to sync blob 2");
4733            }
4734
4735            // Remove the checkpoint's oldest blob so the boundary hint of 7 is stale. The
4736            // watermark is preserved because length-based recovery ends at the same point.
4737            context
4738                .remove(&blob_partition(&cfg), Some(&1u64.to_be_bytes()))
4739                .await
4740                .expect("failed to remove stale oldest blob");
4741
4742            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
4743                .await
4744                .expect("failed to recover journal");
4745            assert_eq!(journal.bounds(), 10..12);
4746            assert_eq!(journal.0.recovery_watermark(), 12);
4747            assert_eq!(journal.read(10).await.unwrap(), test_digest(3));
4748            assert_eq!(journal.read(11).await.unwrap(), test_digest(4));
4749            assert!(matches!(
4750                journal.read(12).await,
4751                Err(Error::ItemOutOfRange(12))
4752            ));
4753
4754            journal.destroy().await.unwrap();
4755        });
4756    }
4757
4758    #[test_traced]
4759    fn test_fixed_journal_stale_pruning_metadata_without_watermark_walks_lengths() {
4760        let executor = deterministic::Runner::default();
4761        executor.start(|context| async move {
4762            let cfg = test_cfg(NZU64!(5));
4763            let mut journal =
4764                Journal::<_, Digest>::init_at_size(context.child("first"), cfg.clone(), 7)
4765                    .await
4766                    .expect("failed to initialize journal at size");
4767
4768            for i in 0..10u64 {
4769                (journal, _) = journal
4770                    .append(&test_digest(i))
4771                    .await
4772                    .expect("failed to append data");
4773            }
4774            let mut journal = journal.sync().await.expect("failed to sync journal");
4775            assert_eq!(journal.bounds(), 7..17);
4776
4777            {
4778                journal.0.checkpoint.set_watermark(None);
4779                journal.0.checkpoint = journal
4780                    .0
4781                    .checkpoint
4782                    .sync()
4783                    .await
4784                    .expect("failed to remove recovery watermark");
4785            }
4786            drop(journal);
4787
4788            // Remove the checkpoint's oldest blob so the boundary hint of 7 is stale. Without a
4789            // recovery watermark, recovery must still walk lengths from the recovered blob boundary.
4790            context
4791                .remove(&blob_partition(&cfg), Some(&1u64.to_be_bytes()))
4792                .await
4793                .expect("failed to remove stale oldest blob");
4794
4795            let mut journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
4796                .await
4797                .expect("failed to recover journal");
4798            assert_eq!(journal.bounds(), 10..17);
4799            // No watermark: watermark at the tail blob start, not size.
4800            assert_eq!(journal.0.recovery_watermark(), 15);
4801            assert_eq!(journal.read(10).await.unwrap(), test_digest(3));
4802            assert_eq!(journal.read(16).await.unwrap(), test_digest(9));
4803
4804            // After sync, watermark advances to the full recovered size.
4805            journal = journal.sync().await.expect("failed to sync");
4806            assert_eq!(journal.0.recovery_watermark(), 17);
4807
4808            journal.destroy().await.unwrap();
4809        });
4810    }
4811
4812    /// A boundary hint ahead of the oldest blob is not a reachable crash state: prune removes
4813    /// blobs before sync persists the checkpoint, and clear_to_size stages a clear intent for
4814    /// atomicity. Verify it is rejected as corruption.
4815    #[test_traced]
4816    fn test_fixed_journal_boundary_hint_ahead_of_blobs_is_corruption() {
4817        let executor = deterministic::Runner::default();
4818        executor.start(|context| async move {
4819            let cfg = test_cfg(NZU64!(5));
4820            let mut journal =
4821                Journal::<_, Digest>::init_at_size(context.child("first"), cfg.clone(), 3)
4822                    .await
4823                    .unwrap();
4824
4825            // Append 12 items (positions 3..15) spanning blobs 0, 1, 2.
4826            for i in 0..12u64 {
4827                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
4828            }
4829            let mut journal = journal.sync().await.unwrap();
4830            assert_eq!(journal.bounds(), 3..15);
4831
4832            // Set the boundary hint to 8 (blob 1) and lower the watermark so it won't
4833            // independently trigger the watermark > size corruption check. Then remove blob 1's
4834            // blob so blob 0 is the oldest. The boundary hint now references a blob ahead
4835            // of the oldest blob, which is the corruption we're testing.
4836            {
4837                journal.0.checkpoint.set_boundary_hint(8);
4838                journal.0.checkpoint.set_watermark(Some(3));
4839                journal.0.checkpoint = journal.0.checkpoint.sync().await.unwrap();
4840            }
4841            drop(journal);
4842
4843            context
4844                .remove(&blob_partition(&cfg), Some(&1u64.to_be_bytes()))
4845                .await
4846                .unwrap();
4847
4848            let result = Journal::<_, Digest>::init(context.child("second"), cfg.clone()).await;
4849            assert!(matches!(result, Err(Error::Corruption(_))));
4850        });
4851    }
4852
4853    /// A mid-blob boundary hint with no blobs is not a reachable crash state (see comment in
4854    /// `recover_bounds`). Verify it is rejected as corruption rather than silently recovering empty.
4855    #[test_traced]
4856    fn test_fixed_journal_boundary_hint_with_no_blobs_is_corruption() {
4857        let executor = deterministic::Runner::default();
4858        executor.start(|context| async move {
4859            let cfg = test_cfg(NZU64!(5));
4860            let mut journal =
4861                Journal::<_, Digest>::init_at_size(context.child("first"), cfg.clone(), 7)
4862                    .await
4863                    .unwrap();
4864
4865            for i in 0..3u64 {
4866                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
4867            }
4868            let journal = journal.sync().await.unwrap();
4869            drop(journal);
4870
4871            // Remove all blobs but leave the checkpoint (with a boundary hint of 7) intact.
4872            for name in scan_partition(&context, &blob_partition(&cfg)).await {
4873                context
4874                    .remove(&blob_partition(&cfg), Some(&name))
4875                    .await
4876                    .unwrap();
4877            }
4878
4879            let result = Journal::<_, Digest>::init(context.child("second"), cfg.clone()).await;
4880            assert!(matches!(result, Err(Error::Corruption(_))));
4881        });
4882    }
4883
4884    #[test_traced]
4885    fn test_fixed_journal_legacy_recovery_installs_watermark() {
4886        let executor = deterministic::Runner::default();
4887        executor.start(|context| async move {
4888            let cfg = test_cfg(NZU64!(5));
4889            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
4890                .await
4891                .expect("failed to initialize journal");
4892
4893            for i in 0..12u64 {
4894                (journal, _) = journal
4895                    .append(&test_digest(i))
4896                    .await
4897                    .expect("failed to append data");
4898            }
4899            let mut journal = journal.sync().await.expect("failed to sync journal");
4900
4901            {
4902                journal.0.checkpoint.set_watermark(None);
4903                journal.0.checkpoint = journal
4904                    .0
4905                    .checkpoint
4906                    .sync()
4907                    .await
4908                    .expect("failed to remove recovery watermark");
4909            }
4910            drop(journal);
4911
4912            // Legacy recovery sets watermark to the tail blob start, not size, so sync must fsync
4913            // the tail before advancing the watermark.
4914            let mut journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
4915                .await
4916                .expect("failed to recover legacy journal");
4917            assert_eq!(journal.bounds(), 0..12);
4918            assert_eq!(journal.0.recovery_watermark(), 10);
4919
4920            // After sync, the watermark advances to the full size.
4921            journal = journal
4922                .sync()
4923                .await
4924                .expect("failed to sync after legacy recovery");
4925            assert_eq!(journal.0.recovery_watermark(), 12);
4926
4927            journal.destroy().await.unwrap();
4928        });
4929    }
4930
4931    #[test_traced]
4932    fn test_fixed_journal_commit_does_not_advance_recovery_watermark() {
4933        let executor = deterministic::Runner::default();
4934        executor.start(|context| async move {
4935            let cfg = test_cfg(NZU64!(5));
4936            let mut journal = Journal::<_, Digest>::init(context.child("journal"), cfg.clone())
4937                .await
4938                .unwrap();
4939
4940            (journal, _) = journal.append(&test_digest(0)).await.unwrap();
4941            let mut journal = journal.sync().await.unwrap();
4942            assert_eq!(journal.0.recovery_watermark(), 1);
4943
4944            (journal, _) = journal.append(&test_digest(1)).await.unwrap();
4945            let mut journal = journal.commit().await.unwrap();
4946            assert_eq!(
4947                journal.0.recovery_watermark(),
4948                1,
4949                "commit must make new data durable without advancing the recovery watermark",
4950            );
4951
4952            journal = journal.sync().await.unwrap();
4953            assert_eq!(journal.0.recovery_watermark(), 2);
4954            journal.destroy().await.unwrap();
4955        });
4956    }
4957
4958    #[test_traced]
4959    fn test_fixed_journal_prune_to_blob_boundary_removes_pruning_metadata() {
4960        let executor = deterministic::Runner::default();
4961        executor.start(|context| async move {
4962            let cfg = test_cfg(NZU64!(5));
4963            let mut journal =
4964                Journal::<_, Digest>::init_at_size(context.child("first"), cfg.clone(), 7)
4965                    .await
4966                    .expect("failed to initialize journal at size");
4967
4968            for i in 0..8u64 {
4969                (journal, _) = journal
4970                    .append(&test_digest(i))
4971                    .await
4972                    .expect("failed to append data");
4973            }
4974            let mut journal = journal.sync().await.expect("failed to sync journal");
4975            assert_eq!(journal.bounds(), 7..15);
4976
4977            (journal, _) = journal.prune(10).await.expect("failed to prune journal");
4978            let journal = journal.sync().await.expect("failed to sync pruned journal");
4979            assert_eq!(journal.bounds(), 10..15);
4980            drop(journal);
4981
4982            let checkpoint = Checkpoint::open(context.child("metadata"), &cfg.partition)
4983                .await
4984                .expect("failed to reopen checkpoint");
4985            assert!(checkpoint.boundary_hint().is_none());
4986            drop(checkpoint);
4987
4988            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
4989                .await
4990                .expect("failed to reopen journal");
4991            assert_eq!(journal.bounds(), 10..15);
4992            assert_eq!(journal.read(10).await.unwrap(), test_digest(3));
4993            journal.destroy().await.unwrap();
4994        });
4995    }
4996
4997    #[test_traced]
4998    fn test_fixed_journal_recover_rejects_overlong_blob() {
4999        let executor = deterministic::Runner::default();
5000        executor.start(|context| async move {
5001            let cfg = test_cfg(NZU64!(5));
5002            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
5003                .await
5004                .expect("failed to initialize journal");
5005
5006            for i in 0..5u64 {
5007                (journal, _) = journal
5008                    .append(&test_digest(i))
5009                    .await
5010                    .expect("failed to append data");
5011            }
5012            let journal = journal.sync().await.expect("failed to sync journal");
5013            drop(journal);
5014
5015            // Inject an extra item into blob 0 at the blob level so its length exceeds
5016            // items_per_blob, which `recover_bounds` validates and rejects as Corruption.
5017            {
5018                let extra = test_digest(99);
5019                let cache_ref = CacheRef::new(PAGE_SIZE, PAGE_CACHE_SIZE);
5020                let (blob, blob_size) = context
5021                    .open(&blob_partition(&cfg), &0u64.to_be_bytes())
5022                    .await
5023                    .expect("failed to open blob 0");
5024                let mut append = Writer::new(blob, blob_size, 2048, cache_ref)
5025                    .await
5026                    .expect("failed to wrap blob 0");
5027                (append, _) = append
5028                    .append(extra.as_ref())
5029                    .await
5030                    .expect("failed to append extra item");
5031                append.sync().await.expect("failed to sync corrupted blob");
5032            }
5033
5034            let result = Journal::<_, Digest>::init(context.child("second"), cfg.clone()).await;
5035            assert!(matches!(result, Err(Error::Corruption(_))));
5036
5037            // A bound does not excuse the overlong blob, whether it cuts inside the blob or at
5038            // its boundary.
5039            for cap in [3, 5] {
5040                let result =
5041                    Journal::<_, Digest>::init_at_most(context.child("capped"), cfg.clone(), cap)
5042                        .await;
5043                assert!(matches!(result, Err(Error::Corruption(_))), "cap {cap}");
5044            }
5045        });
5046    }
5047
5048    #[test_traced("DEBUG")]
5049    fn test_fixed_journal_recover_from_unwritten_data() {
5050        let executor = deterministic::Runner::default();
5051        executor.start(|context| async move {
5052            // Initialize the journal, allowing a max of 10 items per blob.
5053            let cfg = test_cfg(NZU64!(10));
5054            let mut journal = Journal::init(context.child("first"), cfg.clone())
5055                .await
5056                .expect("failed to initialize journal");
5057
5058            // Add only a single item
5059            (journal, _) = journal
5060                .append(&test_digest(0))
5061                .await
5062                .expect("failed to append data");
5063            assert_eq!(journal.size(), 1);
5064            let journal = journal.sync().await.expect("Failed to sync journal");
5065            drop(journal);
5066
5067            // Manually extend the blob to simulate a failure where the file was extended, but no
5068            // bytes were written due to failure.
5069            let (blob, size) = context
5070                .open(&blob_partition(&cfg), &0u64.to_be_bytes())
5071                .await
5072                .expect("Failed to open blob");
5073            blob.write_at(
5074                size,
5075                vec![0u8; PAGE_SIZE.get() as usize * 3],
5076                WriteOptions::SYNC,
5077            )
5078            .await
5079            .expect("Failed to extend blob");
5080
5081            // Re-initialize the journal to simulate a restart
5082            drop(blob);
5083            let mut journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
5084                .await
5085                .expect("Failed to re-initialize journal");
5086
5087            // The zero-filled pages are detected as invalid (bad checksum) and truncated.
5088            // No items should be lost since we called sync before the corruption.
5089            assert_eq!(journal.size(), 1);
5090
5091            // Make sure journal still works for appending.
5092            (journal, _) = journal
5093                .append(&test_digest(1))
5094                .await
5095                .expect("failed to append data");
5096
5097            journal.destroy().await.unwrap();
5098        });
5099    }
5100
5101    #[test_traced]
5102    fn test_fixed_journal_init_at_most() {
5103        let executor = deterministic::Runner::default();
5104        executor.start(|context| async move {
5105            // Initialize the journal, allowing a max of 2 items per blob.
5106            let cfg = test_cfg(NZU64!(2));
5107            let journal: Journal<_, Digest> = Journal::init(context.child("first"), cfg.clone())
5108                .await
5109                .expect("failed to initialize journal");
5110            let journal = {
5111                _ = journal.sync().await.unwrap();
5112                Journal::<_, Digest>::init_at_most(context.child("cap"), cfg.clone(), 0).await
5113            }
5114            .unwrap();
5115            drop(journal);
5116            let journal =
5117                Journal::<_, Digest>::init_at_most(context.child("above"), cfg.clone(), 1)
5118                    .await
5119                    .unwrap();
5120            assert_eq!(journal.size(), 0);
5121            drop(journal);
5122            let mut journal: Journal<_, Digest> =
5123                Journal::init(context.child("reopen"), cfg.clone())
5124                    .await
5125                    .expect("failed to re-initialize journal");
5126
5127            // Append an item to the journal
5128            (journal, _) = journal
5129                .append(&test_digest(0))
5130                .await
5131                .expect("failed to append data 0");
5132            assert_eq!(journal.size(), 1);
5133            journal = {
5134                _ = journal.sync().await.unwrap();
5135                Journal::<_, Digest>::init_at_most(context.child("cap"), cfg.clone(), 1).await
5136            }
5137            .unwrap(); // should be no-op
5138            journal = {
5139                _ = journal.sync().await.unwrap();
5140                Journal::<_, Digest>::init_at_most(context.child("cap"), cfg.clone(), 0).await
5141            }
5142            .unwrap();
5143            assert_eq!(journal.size(), 0);
5144
5145            // append 7 items
5146            for i in 0..7 {
5147                let pos;
5148                (journal, pos) = journal
5149                    .append(&test_digest(i))
5150                    .await
5151                    .expect("failed to append data");
5152                assert_eq!(pos, i);
5153            }
5154            assert_eq!(journal.size(), 7);
5155
5156            // Open the prefix ending at item 4.
5157            journal = {
5158                _ = journal.sync().await.unwrap();
5159                Journal::<_, Digest>::init_at_most(context.child("cap"), cfg.clone(), 4).await
5160            }
5161            .unwrap();
5162            assert_eq!(journal.size(), 4);
5163
5164            // Open the empty prefix.
5165            journal = {
5166                _ = journal.sync().await.unwrap();
5167                Journal::<_, Digest>::init_at_most(context.child("cap"), cfg.clone(), 0).await
5168            }
5169            .unwrap();
5170            assert_eq!(journal.size(), 0);
5171
5172            // Repeatedly append 100 items and reopen 49 items earlier.
5173            for _ in 0..10 {
5174                for i in 0..100 {
5175                    (journal, _) = journal
5176                        .append(&test_digest(i))
5177                        .await
5178                        .expect("failed to append data");
5179                }
5180                let size = journal.size();
5181                journal = {
5182                    _ = journal.sync().await.unwrap();
5183                    Journal::<_, Digest>::init_at_most(context.child("cap"), cfg.clone(), size - 49)
5184                        .await
5185                }
5186                .unwrap();
5187            }
5188            const ITEMS_REMAINING: u64 = 10 * (100 - 49);
5189            assert_eq!(journal.size(), ITEMS_REMAINING);
5190
5191            let journal = journal.sync().await.expect("Failed to sync journal");
5192            drop(journal);
5193
5194            // Repeat with a different blob size (3 items per blob)
5195            let mut cfg = test_cfg(NZU64!(3));
5196            cfg.partition = "test-partition-2".into();
5197            let mut journal = Journal::init(context.child("second"), cfg.clone())
5198                .await
5199                .expect("failed to initialize journal");
5200            for _ in 0..10 {
5201                for i in 0..100 {
5202                    (journal, _) = journal
5203                        .append(&test_digest(i))
5204                        .await
5205                        .expect("failed to append data");
5206                }
5207                let size = journal.size();
5208                journal = {
5209                    _ = journal.sync().await.unwrap();
5210                    Journal::<_, Digest>::init_at_most(context.child("cap"), cfg.clone(), size - 49)
5211                        .await
5212                }
5213                .unwrap();
5214            }
5215            assert_eq!(journal.size(), ITEMS_REMAINING);
5216
5217            journal.sync().await.expect("Failed to sync journal");
5218
5219            // Make sure re-opened journal is as expected
5220            let mut journal: Journal<_, Digest> =
5221                Journal::init(context.child("third"), cfg.clone())
5222                    .await
5223                    .expect("failed to re-initialize journal");
5224            assert_eq!(journal.size(), 10 * (100 - 49));
5225
5226            // Make sure truncating works after pruning
5227            (journal, _) = journal.prune(300).await.expect("pruning failed");
5228            assert_eq!(journal.size(), ITEMS_REMAINING);
5229
5230            // Truncating to the prune point should work. Always remain in the journal.
5231            journal = {
5232                _ = journal.sync().await.unwrap();
5233                Journal::<_, Digest>::init_at_most(context.child("cap"), cfg.clone(), 300).await
5234            }
5235            .unwrap();
5236            let bounds = journal.bounds();
5237            assert_eq!(bounds.end, 300);
5238            assert!(bounds.is_empty());
5239
5240            // Truncating prior to our prune point should fail.
5241            assert!(matches!(
5242                {
5243                    _ = journal.sync().await.unwrap();
5244                    Journal::<_, Digest>::init_at_most(context.child("cap"), cfg.clone(), 299).await
5245                },
5246                Err(Error::ItemPruned(299))
5247            ));
5248        });
5249    }
5250
5251    #[test_traced]
5252    fn test_fixed_journal_init_at_most_commit_reopen() {
5253        let executor = deterministic::Runner::default();
5254        executor.start(|context| async move {
5255            let cfg = test_cfg(NZU64!(5));
5256            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
5257                .await
5258                .expect("failed to initialize journal");
5259
5260            for i in 0..12u64 {
5261                (journal, _) = journal
5262                    .append(&test_digest(i))
5263                    .await
5264                    .expect("failed to append data");
5265            }
5266            let journal = journal.sync().await.expect("failed to sync journal");
5267
5268            let journal = {
5269                _ = journal.sync().await.unwrap();
5270                Journal::<_, Digest>::init_at_most(context.child("cap"), cfg.clone(), 7).await
5271            }
5272            .expect("failed to initialize journal prefix");
5273            journal.commit().await.expect("failed to commit journal");
5274
5275            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
5276                .await
5277                .expect("failed to re-initialize journal");
5278            assert_eq!(journal.bounds(), 0..7);
5279            for i in 0..7u64 {
5280                assert_eq!(journal.read(i).await.unwrap(), test_digest(i));
5281            }
5282            assert!(matches!(
5283                journal.read(7).await,
5284                Err(Error::ItemOutOfRange(7))
5285            ));
5286
5287            journal.destroy().await.unwrap();
5288        });
5289    }
5290
5291    #[test_traced]
5292    fn test_fixed_journal_init_at_most_persists_lower_watermark() {
5293        let executor = deterministic::Runner::default();
5294        executor.start(|context| async move {
5295            let cfg = test_cfg(NZU64!(5));
5296            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
5297                .await
5298                .expect("failed to initialize journal");
5299
5300            for i in 0..12u64 {
5301                (journal, _) = journal
5302                    .append(&test_digest(i))
5303                    .await
5304                    .expect("failed to append data");
5305            }
5306            let journal = journal.sync().await.expect("failed to sync journal");
5307            {
5308                _ = journal.sync().await.unwrap();
5309                Journal::<_, Digest>::init_at_most(context.child("cap"), cfg.clone(), 7).await
5310            }
5311            .expect("failed to truncate journal");
5312
5313            let checkpoint = Checkpoint::open(context.child("metadata"), &cfg.partition)
5314                .await
5315                .expect("failed to reopen checkpoint");
5316            let persisted_watermark = checkpoint
5317                .watermark()
5318                .expect("missing recovery watermark after truncate");
5319            assert_eq!(persisted_watermark, 7);
5320            drop(checkpoint);
5321
5322            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
5323                .await
5324                .expect("failed to re-initialize journal");
5325            journal.destroy().await.unwrap();
5326        });
5327    }
5328
5329    /// Reopen at a page-aligned bound, append over the freed pages, then crash with the appends
5330    /// retained and any unsynced resize lost. Recovery must not join the new pages with the
5331    /// discarded suffix.
5332    #[test_traced]
5333    fn test_fixed_journal_init_at_most_truncation_survives_crash() {
5334        // Two digests per page make the four-item bound page aligned. The two-page write buffer
5335        // floor sends a five-item append down the direct path: four items land in two full pages
5336        // and the fifth stays buffered, so the old pages behind them are never rewritten.
5337        const PAGE_SIZE: NonZeroU16 = NZU16!(64);
5338        const BOUND: u64 = 4;
5339
5340        fn cfg() -> Config {
5341            Config {
5342                partition: "init-at-most-truncation-crash".into(),
5343                items_per_blob: NZU64!(100),
5344                page_cache: CacheRef::new(PAGE_SIZE, PAGE_CACHE_SIZE),
5345                write_buffer: NZUsize!(1),
5346                replay_buffer: NZUsize!(2048),
5347            }
5348        }
5349        let appended: Vec<Digest> = (100..105u64).map(test_digest).collect();
5350
5351        let executor = deterministic::Runner::default();
5352        let (appended, checkpoint) = executor.start_and_recover(|context| async move {
5353            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg())
5354                .await
5355                .unwrap();
5356            for i in 0..12u64 {
5357                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
5358            }
5359            let journal = journal.sync().await.unwrap();
5360            drop(journal);
5361
5362            // Keep unsynced writes and drop unsynced resizes at the crash.
5363            *context.storage_fault_config().write() = deterministic::FaultConfig {
5364                write_rate: Some(deterministic::WriteConfig {
5365                    failure_rate: probability!(0.0),
5366                    retention_rate: probability!(1.0),
5367                    mode: deterministic::PartialWriteMode::Prefix,
5368                }),
5369                resize_rate: Some(deterministic::ResizeConfig {
5370                    failure_rate: probability!(0.0),
5371                    partial_rate: probability!(0.0),
5372                }),
5373                ..Default::default()
5374            };
5375            let journal = Journal::<_, Digest>::init_at_most(context.child("cap"), cfg(), BOUND)
5376                .await
5377                .unwrap();
5378            assert_eq!(journal.bounds(), 0..BOUND);
5379            let (journal, _) = journal.append_many(Many::Flat(&appended)).await.unwrap();
5380            drop(journal);
5381            appended
5382        });
5383
5384        deterministic::Runner::from(checkpoint).start(|context| async move {
5385            *context.storage_fault_config().write() = deterministic::FaultConfig::default();
5386            let journal = Journal::<_, Digest>::init(context.child("recover"), cfg())
5387                .await
5388                .unwrap();
5389            let size = journal.size();
5390            assert!(
5391                (BOUND..=BOUND + appended.len() as u64).contains(&size),
5392                "recovered size {size} is not a prefix of the new history"
5393            );
5394            for pos in 0..BOUND {
5395                assert_eq!(journal.read(pos).await.unwrap(), test_digest(pos));
5396            }
5397            for pos in BOUND..size {
5398                assert_eq!(
5399                    journal.read(pos).await.unwrap(),
5400                    appended[(pos - BOUND) as usize],
5401                    "content at retained position {pos} was never written there"
5402                );
5403            }
5404            journal.destroy().await.unwrap();
5405        });
5406    }
5407
5408    /// Repair a torn tail at a page-aligned item boundary during init, append over the freed
5409    /// pages, then crash with the appends retained and any unsynced resize lost. Recovery must
5410    /// not join the new pages with the discarded suffix. The default fault arm drops the appends
5411    /// too, so recovery must land exactly on the repaired size.
5412    #[test_traced]
5413    fn test_fixed_journal_repair_truncation_survives_crash() {
5414        // Two digests per page make the four-item repair target page aligned. The two-page write
5415        // buffer floor sends a five-item append down the direct path: four items land in two full
5416        // pages and the fifth stays buffered, so the old pages behind them are never rewritten.
5417        const PAGE_SIZE: NonZeroU16 = NZU16!(64);
5418        const REPAIRED: u64 = 4;
5419
5420        fn cfg() -> Config {
5421            Config {
5422                partition: "repair-truncation-crash".into(),
5423                items_per_blob: NZU64!(100),
5424                page_cache: CacheRef::new(PAGE_SIZE, PAGE_CACHE_SIZE),
5425                write_buffer: NZUsize!(1),
5426                replay_buffer: NZUsize!(2048),
5427            }
5428        }
5429
5430        for retained in [true, false] {
5431            let appended: Vec<Digest> = (100..105u64).map(test_digest).collect();
5432
5433            let executor = deterministic::Runner::default();
5434            let (appended, checkpoint) = executor.start_and_recover(|context| async move {
5435                let cfg = cfg();
5436                let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
5437                    .await
5438                    .unwrap();
5439                for i in 0..12u64 {
5440                    (journal, _) = journal.append(&test_digest(i)).await.unwrap();
5441                }
5442
5443                // Commit leaves the watermark behind, so init scans the whole blob.
5444                let journal = journal.commit().await.unwrap();
5445                drop(journal);
5446
5447                // Tear page 2 of the six-page blob. Init stops at the hole and repairs the blob
5448                // to the four items on pages 0 and 1, leaving pages 3 to 5 behind the target.
5449                corrupt_page(
5450                    &context,
5451                    &blob_partition(&cfg),
5452                    &0u64.to_be_bytes(),
5453                    2,
5454                    PAGE_SIZE.get() as u64,
5455                )
5456                .await;
5457
5458                // Keep unsynced writes and drop unsynced resizes at the crash. The default
5459                // drops both.
5460                *context.storage_fault_config().write() = if retained {
5461                    deterministic::FaultConfig {
5462                        write_rate: Some(deterministic::WriteConfig {
5463                            failure_rate: probability!(0.0),
5464                            retention_rate: probability!(1.0),
5465                            mode: deterministic::PartialWriteMode::Prefix,
5466                        }),
5467                        resize_rate: Some(deterministic::ResizeConfig {
5468                            failure_rate: probability!(0.0),
5469                            partial_rate: probability!(0.0),
5470                        }),
5471                        ..Default::default()
5472                    }
5473                } else {
5474                    deterministic::FaultConfig::default()
5475                };
5476                let journal = Journal::<_, Digest>::init(context.child("repair"), cfg)
5477                    .await
5478                    .unwrap();
5479                assert_eq!(journal.bounds(), 0..REPAIRED);
5480                let (journal, _) = journal.append_many(Many::Flat(&appended)).await.unwrap();
5481                drop(journal);
5482                appended
5483            });
5484
5485            deterministic::Runner::from(checkpoint).start(|context| async move {
5486                *context.storage_fault_config().write() = deterministic::FaultConfig::default();
5487                let journal = Journal::<_, Digest>::init(context.child("recover"), cfg())
5488                    .await
5489                    .unwrap();
5490                let size = journal.size();
5491                let kept = if retained { appended.len() as u64 } else { 0 };
5492                assert!(
5493                    (REPAIRED..=REPAIRED + kept).contains(&size),
5494                    "recovered size {size} is not a prefix of the new history"
5495                );
5496                for pos in 0..REPAIRED {
5497                    assert_eq!(journal.read(pos).await.unwrap(), test_digest(pos));
5498                }
5499                for pos in REPAIRED..size {
5500                    assert_eq!(
5501                        journal.read(pos).await.unwrap(),
5502                        appended[(pos - REPAIRED) as usize],
5503                        "content at retained position {pos} was never written there"
5504                    );
5505                }
5506                journal.destroy().await.unwrap();
5507            });
5508        }
5509    }
5510
5511    #[test_traced]
5512    fn test_fixed_journal_recover_after_watermark_lowered_before_truncate() {
5513        let executor = deterministic::Runner::default();
5514        executor.start(|context| async move {
5515            let cfg = test_cfg(NZU64!(5));
5516            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
5517                .await
5518                .expect("failed to initialize journal");
5519
5520            for i in 0..12u64 {
5521                (journal, _) = journal
5522                    .append(&test_digest(i))
5523                    .await
5524                    .expect("failed to append data");
5525            }
5526            let journal = journal.sync().await.expect("failed to sync journal");
5527
5528            let journal = journal
5529                .test_set_recovery_watermark(7)
5530                .await
5531                .expect("failed to lower recovery watermark");
5532            drop(journal);
5533
5534            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
5535                .await
5536                .expect("failed to recover journal");
5537            assert_eq!(journal.bounds(), 0..12);
5538            assert_eq!(journal.0.recovery_watermark(), 7);
5539            assert_eq!(journal.read(11).await.unwrap(), test_digest(11));
5540            journal.destroy().await.unwrap();
5541        });
5542    }
5543
5544    #[test_traced]
5545    fn test_fixed_journal_truncate_append_commit_reopen() {
5546        let executor = deterministic::Runner::default();
5547        executor.start(|context| async move {
5548            let cfg = test_cfg(NZU64!(5));
5549            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
5550                .await
5551                .expect("failed to initialize journal");
5552
5553            for i in 0..12u64 {
5554                (journal, _) = journal
5555                    .append(&test_digest(i))
5556                    .await
5557                    .expect("failed to append data");
5558            }
5559            let journal = journal.sync().await.expect("failed to sync journal");
5560
5561            let mut journal = {
5562                _ = journal.sync().await.unwrap();
5563                Journal::<_, Digest>::init_at_most(context.child("cap"), cfg.clone(), 7).await
5564            }
5565            .expect("failed to truncate journal");
5566            for i in 0..3u64 {
5567                (journal, _) = journal
5568                    .append(&test_digest(100 + i))
5569                    .await
5570                    .expect("failed to append data");
5571            }
5572            journal.commit().await.expect("failed to commit journal");
5573
5574            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
5575                .await
5576                .expect("failed to re-initialize journal");
5577            assert_eq!(journal.bounds(), 0..10);
5578            assert_eq!(journal.0.recovery_watermark(), 7);
5579            for i in 0..7u64 {
5580                assert_eq!(journal.read(i).await.unwrap(), test_digest(i));
5581            }
5582            for i in 0..3u64 {
5583                assert_eq!(journal.read(7 + i).await.unwrap(), test_digest(100 + i));
5584            }
5585            assert!(matches!(
5586                journal.read(10).await,
5587                Err(Error::ItemOutOfRange(10))
5588            ));
5589
5590            journal.destroy().await.unwrap();
5591        });
5592    }
5593
5594    #[test_traced]
5595    fn test_fixed_recovery_preserves_rolled_predecessors_without_commit() {
5596        let executor = deterministic::Runner::default();
5597        executor.start(|context| async move {
5598            let cfg = test_cfg(NZU64!(1));
5599            let mut journal = Journal::<_, Digest>::init(context.child("journal"), cfg.clone())
5600                .await
5601                .unwrap();
5602
5603            // Persist a prefix, then append across multiple blob boundaries without calling
5604            // commit/sync. Rollover starts predecessor syncs, so the filled predecessor blobs are
5605            // recoverable even though the recovery watermark is not advanced.
5606            let appended;
5607            (journal, appended) = journal.append(&test_digest(10)).await.unwrap();
5608            assert_eq!(appended, 0);
5609            journal = journal.sync().await.unwrap();
5610            let appended;
5611            (journal, appended) = journal.append(&test_digest(20)).await.unwrap();
5612            assert_eq!(appended, 1);
5613            let appended;
5614            (journal, appended) = journal.append(&test_digest(30)).await.unwrap();
5615            assert_eq!(appended, 2);
5616            drop(journal);
5617
5618            let blobs = scan_partition(&context, &blob_partition(&cfg)).await;
5619            assert!(
5620                blobs.len() > 2,
5621                "expected multiple empty trailing blobs, got {}",
5622                blobs.len()
5623            );
5624
5625            let journal = Journal::<_, Digest>::init(context.child("recovered"), cfg.clone())
5626                .await
5627                .unwrap();
5628            assert_eq!(journal.bounds(), 0..3);
5629            assert_eq!(journal.read(0).await.unwrap(), test_digest(10));
5630            assert_eq!(journal.read(1).await.unwrap(), test_digest(20));
5631            assert_eq!(journal.read(2).await.unwrap(), test_digest(30));
5632            drop(journal);
5633
5634            // Recovery should preserve the filled predecessors plus the empty tail.
5635            let blobs = scan_partition(&context, &blob_partition(&cfg)).await;
5636            assert_eq!(blobs.len(), 4);
5637
5638            let mut journal = Journal::<_, Digest>::init(context.child("recovered"), cfg.clone())
5639                .await
5640                .unwrap();
5641            let appended;
5642            (journal, appended) = journal.append(&test_digest(42)).await.unwrap();
5643            assert_eq!(appended, 3);
5644            assert_eq!(journal.read(3).await.unwrap(), test_digest(42));
5645            journal.destroy().await.unwrap();
5646        });
5647    }
5648
5649    #[test_traced]
5650    fn test_fixed_recovery_preserves_first_rollover_without_commit() {
5651        let executor = deterministic::Runner::default();
5652        executor.start(|context| async move {
5653            let cfg = test_cfg(NZU64!(1));
5654            let mut journal = Journal::<_, Digest>::init(context.child("journal"), cfg.clone())
5655                .await
5656                .unwrap();
5657
5658            // Append across multiple blob boundaries without ever calling commit/sync. Rollover
5659            // starts predecessor syncs, so filled predecessors remain recoverable.
5660            let appended;
5661            (journal, appended) = journal.append(&test_digest(10)).await.unwrap();
5662            assert_eq!(appended, 0);
5663            let appended;
5664            (journal, appended) = journal.append(&test_digest(20)).await.unwrap();
5665            assert_eq!(appended, 1);
5666            drop(journal);
5667
5668            let blobs = scan_partition(&context, &blob_partition(&cfg)).await;
5669            assert!(
5670                blobs.len() > 1,
5671                "expected multiple empty blobs, got {}",
5672                blobs.len()
5673            );
5674
5675            let journal = Journal::<_, Digest>::init(context.child("recovered"), cfg.clone())
5676                .await
5677                .unwrap();
5678            assert_eq!(journal.bounds(), 0..2);
5679            drop(journal);
5680
5681            // Recovery should preserve the filled predecessors plus the empty tail.
5682            let blobs = scan_partition(&context, &blob_partition(&cfg)).await;
5683            assert_eq!(blobs.len(), 3);
5684
5685            let mut journal = Journal::<_, Digest>::init(context.child("recovered"), cfg.clone())
5686                .await
5687                .unwrap();
5688            let appended;
5689            (journal, appended) = journal.append(&test_digest(42)).await.unwrap();
5690            assert_eq!(appended, 2);
5691            assert_eq!(journal.read(2).await.unwrap(), test_digest(42));
5692            journal.destroy().await.unwrap();
5693        });
5694    }
5695
5696    /// Recovery establishes a contiguous valid page prefix before truncating to whole items.
5697    #[test_traced]
5698    fn test_fixed_recovery_validates_pages_before_trailing_bytes() {
5699        let executor = deterministic::Runner::default();
5700        executor.start(|context| async move {
5701            const LOGICAL_PAGE_SIZE: u64 = 5;
5702
5703            let cfg = Config {
5704                partition: "fixed-validate-before-tail-trim".into(),
5705                items_per_blob: NZU64!(10),
5706                page_cache: CacheRef::new(NZU16!(LOGICAL_PAGE_SIZE as u16), NZUsize!(4)),
5707                write_buffer: NZUsize!(128),
5708                replay_buffer: NZUsize!(128),
5709            };
5710            let partition = blob_partition(&cfg);
5711            let (blob, size) = context.open(&partition, &0u64.to_be_bytes()).await.unwrap();
5712            let mut writer = PagedRecovery::open(blob, size, 128, cfg.page_cache.clone())
5713                .await
5714                .unwrap();
5715            let values = [11u64, 22, 33, 44];
5716            let mut bytes = Vec::new();
5717            for value in values {
5718                bytes.extend_from_slice(&value.to_be_bytes());
5719            }
5720            (writer, _) = writer.append(&bytes).await.unwrap();
5721            writer = writer.truncate(30).await.unwrap();
5722            writer = writer.sync().await.unwrap();
5723            drop(writer);
5724
5725            // Five-byte integrity pages crossed by eight-byte journal items:
5726            //
5727            // pages: [0..5) [5..10) [10..15) [15..20) [20..25) [25..30)
5728            // state:    ok      ok       ok       ok       torn      ok
5729            // items: [0......8) [8.......16) [16......24) [24..30 tail)
5730            //
5731            // Backward sizing stops at valid page 5 and reports 30 logical bytes. Rounding that
5732            // size to a whole item selects 24, inside torn page 4. `Recovery::truncate(24)` must
5733            // read page 4 to preserve bytes 20..24 and rewrite its partial-page checksum, so it
5734            // cannot perform that truncation. Forward validation instead stops at 20, rounds down
5735            // to 16, and safely resizes within valid page 3.
5736            corrupt_page(
5737                &context,
5738                &partition,
5739                &0u64.to_be_bytes(),
5740                4,
5741                LOGICAL_PAGE_SIZE,
5742            )
5743            .await;
5744
5745            let journal = Journal::<_, u64>::init(context.child("recover"), cfg)
5746                .await
5747                .unwrap();
5748            assert_eq!(journal.bounds(), 0..2);
5749            assert_eq!(journal.read(0).await.unwrap(), 11);
5750            assert_eq!(journal.read(1).await.unwrap(), 22);
5751            journal.destroy().await.unwrap();
5752        });
5753    }
5754
5755    /// The oldest retained blob can begin after its natural start. Watermark validation must
5756    /// measure acknowledged bytes from that retained start before repairing a partial item.
5757    #[test_traced]
5758    fn test_fixed_recovery_watermark_uses_retained_blob_start() {
5759        let executor = deterministic::Runner::default();
5760        executor.start(|context| async move {
5761            let cfg = test_cfg(NZU64!(10));
5762            let mut journal =
5763                Journal::<_, u64>::init_at_size(context.child("first"), cfg.clone(), 7)
5764                    .await
5765                    .unwrap();
5766            for (offset, value) in [11u64, 22].into_iter().enumerate() {
5767                let position;
5768                (journal, position) = journal.append(&value).await.unwrap();
5769                assert_eq!(position, 7 + offset as u64);
5770            }
5771            journal = journal.sync().await.unwrap();
5772            drop(journal);
5773
5774            let partition = blob_partition(&cfg);
5775            let (blob, size) = context.open(&partition, &0u64.to_be_bytes()).await.unwrap();
5776            let mut writer =
5777                PagedRecovery::open(blob, size, cfg.write_buffer.get(), cfg.page_cache.clone())
5778                    .await
5779                    .unwrap();
5780            assert_eq!(writer.size(), 16);
5781            (writer, _) = writer.append(&[0; 4]).await.unwrap();
5782            assert_eq!(writer.size(), 20);
5783            writer = writer.sync().await.unwrap();
5784            drop(writer);
5785
5786            let journal = Journal::<_, u64>::init(context.child("recover"), cfg)
5787                .await
5788                .unwrap();
5789            assert_eq!(journal.bounds(), 7..9);
5790            assert_eq!(journal.read(7).await.unwrap(), 11);
5791            assert_eq!(journal.read(8).await.unwrap(), 22);
5792            journal.destroy().await.unwrap();
5793        });
5794    }
5795
5796    /// A torn item write can persist a partial item on the tail with every page valid. The
5797    /// suspect scan owns the whole-item floor: recovery must truncate the fragment and leave
5798    /// the tail aligned for future appends.
5799    #[test_traced]
5800    fn test_fixed_recovery_truncates_partial_item_tail() {
5801        let executor = deterministic::Runner::default();
5802        executor.start(|context| async move {
5803            let cfg = test_cfg(NZU64!(10));
5804            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
5805                .await
5806                .unwrap();
5807            for value in [11u64, 22, 33] {
5808                (journal, _) = journal.append(&value).await.unwrap();
5809            }
5810            journal = journal.sync().await.unwrap();
5811            drop(journal);
5812
5813            // Persist three bytes of a fourth item directly on the tail blob: every page stays
5814            // valid, only the item boundary is torn.
5815            let partition = blob_partition(&cfg);
5816            let (blob, size) = context.open(&partition, &0u64.to_be_bytes()).await.unwrap();
5817            let mut writer =
5818                Writer::new(blob, size, cfg.write_buffer.get(), cfg.page_cache.clone())
5819                    .await
5820                    .unwrap();
5821            assert_eq!(writer.size(), 24);
5822            (writer, _) = writer.append(&44u64.to_be_bytes()[..3]).await.unwrap();
5823            writer = writer.sync().await.unwrap();
5824            drop(writer);
5825
5826            // Recovery floors to whole items and the journal appends aligned afterward.
5827            let mut journal = Journal::<_, u64>::init(context.child("recover"), cfg)
5828                .await
5829                .unwrap();
5830            assert_eq!(journal.bounds(), 0..3);
5831            assert_eq!(journal.read(2).await.unwrap(), 33);
5832            let position;
5833            (journal, position) = journal.append(&44u64).await.unwrap();
5834            assert_eq!(position, 3);
5835            let journal = journal.sync().await.unwrap();
5836            assert_eq!(journal.read(3).await.unwrap(), 44);
5837            journal.destroy().await.unwrap();
5838        });
5839    }
5840
5841    /// A hole strictly between a mid-blob watermark and the blob's end truncates to whole
5842    /// items above the acknowledged prefix: acknowledged < recoverable floor < size, so the
5843    /// scan must start at the watermark rather than zero or the blob's end.
5844    #[test_traced]
5845    fn test_fixed_recovery_truncates_above_mid_blob_watermark() {
5846        let executor = deterministic::Runner::default();
5847        executor.start(|context| async move {
5848            const LOGICAL_PAGE_SIZE: u64 = 5;
5849            let cfg = Config {
5850                partition: "fixed-truncate-above-watermark".into(),
5851                items_per_blob: NZU64!(10),
5852                page_cache: CacheRef::new(NZU16!(LOGICAL_PAGE_SIZE as u16), NZUsize!(4)),
5853                write_buffer: NZUsize!(128),
5854                replay_buffer: NZUsize!(128),
5855            };
5856            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
5857                .await
5858                .unwrap();
5859            for value in [11u64, 22] {
5860                (journal, _) = journal.append(&value).await.unwrap();
5861            }
5862            journal.sync().await.unwrap();
5863
5864            // Persist two more items past the watermark, then tear one page beneath the
5865            // acknowledged prefix (the proof must skip it) and one strictly above it:
5866            // acknowledged (16) < recoverable floor (24) < size (32).
5867            let partition = blob_partition(&cfg);
5868            let (blob, size) = context.open(&partition, &0u64.to_be_bytes()).await.unwrap();
5869            let mut writer = Writer::new(blob, size, 128, cfg.page_cache.clone())
5870                .await
5871                .unwrap();
5872            assert_eq!(writer.size(), 16);
5873            let mut bytes = Vec::new();
5874            for value in [33u64, 44] {
5875                bytes.extend_from_slice(&value.to_be_bytes());
5876            }
5877            (writer, _) = writer.append(&bytes).await.unwrap();
5878            writer = writer.sync().await.unwrap();
5879            drop(writer);
5880            corrupt_page(
5881                &context,
5882                &partition,
5883                &0u64.to_be_bytes(),
5884                1,
5885                LOGICAL_PAGE_SIZE,
5886            )
5887            .await;
5888            corrupt_page(
5889                &context,
5890                &partition,
5891                &0u64.to_be_bytes(),
5892                5,
5893                LOGICAL_PAGE_SIZE,
5894            )
5895            .await;
5896
5897            // The acknowledged tear is adopted (its items fail lazily at read) while the hole
5898            // above the watermark truncates the unacknowledged fourth item.
5899            let journal = Journal::<_, u64>::init(context.child("recover"), cfg)
5900                .await
5901                .unwrap();
5902            assert_eq!(journal.bounds(), 0..3);
5903            assert!(journal.read(0).await.is_err());
5904            assert!(journal.read(1).await.is_err());
5905            assert_eq!(journal.read(2).await.unwrap(), 33);
5906            journal.destroy().await.unwrap();
5907        });
5908    }
5909
5910    /// A crash during the rollover fsync can persist a valid last page above a lost interior
5911    /// page, which `Writer::new`'s backward scan cannot see. Recovery must forward-validate the
5912    /// suspect blob and truncate at the hole.
5913    #[test_traced]
5914    fn test_fixed_recovery_truncates_torn_interior_page() {
5915        let executor = deterministic::Runner::default();
5916        executor.start(|context| async move {
5917            let cfg = test_cfg(NZU64!(10));
5918            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
5919                .await
5920                .unwrap();
5921            for i in 0..15u64 {
5922                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
5923            }
5924            journal.commit().await.unwrap();
5925
5926            // Blob 0 holds 10 items (320 bytes) across 8 pages; tear page 3.
5927            corrupt_page(
5928                &context,
5929                &blob_partition(&cfg),
5930                &0u64.to_be_bytes(),
5931                3,
5932                PAGE_SIZE.get() as u64,
5933            )
5934            .await;
5935
5936            // Pages 0-2 hold 132 bytes = 4 chunk-aligned items; the gap makes blob 1 unreachable.
5937            let mut journal = Journal::<_, Digest>::init(context.child("second"), cfg)
5938                .await
5939                .unwrap();
5940            assert_eq!(journal.bounds(), 0..4);
5941            for i in 0..4u64 {
5942                assert_eq!(journal.read(i).await.unwrap(), test_digest(i));
5943            }
5944            let appended;
5945            (journal, appended) = journal.append(&test_digest(42)).await.unwrap();
5946            assert_eq!(appended, 4);
5947            journal.destroy().await.unwrap();
5948        });
5949    }
5950
5951    /// A torn interior page in the tail blob truncates the append frontier without disturbing
5952    /// its full predecessors.
5953    #[test_traced]
5954    fn test_fixed_recovery_truncates_torn_interior_page_in_tail() {
5955        let executor = deterministic::Runner::default();
5956        executor.start(|context| async move {
5957            let cfg = test_cfg(NZU64!(10));
5958            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
5959                .await
5960                .unwrap();
5961            for i in 0..15u64 {
5962                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
5963            }
5964            journal.commit().await.unwrap();
5965
5966            // Blob 1 holds 5 items (160 bytes) across 4 pages; tear page 1.
5967            corrupt_page(
5968                &context,
5969                &blob_partition(&cfg),
5970                &1u64.to_be_bytes(),
5971                1,
5972                PAGE_SIZE.get() as u64,
5973            )
5974            .await;
5975
5976            // Blob 1 keeps page 0 only: 44 bytes = 1 chunk-aligned item after blob 0's 10.
5977            let mut journal = Journal::<_, Digest>::init(context.child("second"), cfg)
5978                .await
5979                .unwrap();
5980            assert_eq!(journal.bounds(), 0..11);
5981            for i in 0..11u64 {
5982                assert_eq!(journal.read(i).await.unwrap(), test_digest(i));
5983            }
5984            let appended;
5985            (journal, appended) = journal.append(&test_digest(42)).await.unwrap();
5986            assert_eq!(appended, 11);
5987            journal.destroy().await.unwrap();
5988        });
5989    }
5990
5991    /// A bounded open whose cap lies inside a torn page. One byte of the page's rewrite by an
5992    /// unsynced flush was lost while the pages after it landed, so only its shorter durable
5993    /// checksum slot validates. Recovery must truncate at the hole before serving the retained
5994    /// prefix, since reads before publication go through the page cache, which refuses a short
5995    /// interior page.
5996    #[test_traced]
5997    fn test_fixed_bounded_recovery_repairs_torn_rewrite_at_cap() {
5998        let executor = deterministic::Runner::default();
5999        executor.start(|context| async move {
6000            const PAGE: u64 = 100;
6001            let cfg = Config {
6002                partition: "torn-rewrite-at-cap".into(),
6003                items_per_blob: NZU64!(64),
6004                page_cache: CacheRef::new(NZU16!(PAGE as u16), NZUsize!(3)),
6005                write_buffer: NZUsize!(2048),
6006                replay_buffer: NZUsize!(2048),
6007            };
6008            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
6009                .await
6010                .unwrap();
6011            for i in 0..33u64 {
6012                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
6013            }
6014
6015            // Page 10 holds the acknowledged prefix's last 56 bytes, and the watermark is 33.
6016            journal = journal.sync().await.unwrap();
6017            for i in 33..38u64 {
6018                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
6019            }
6020
6021            // Write pages 10..12 without advancing the watermark, then lose one byte of page 10's
6022            // rewrite beyond its durable prefix.
6023            journal.commit().await.unwrap();
6024            tear_page_rewrite(
6025                &context,
6026                &blob_partition(&cfg),
6027                &0u64.to_be_bytes(),
6028                10,
6029                60,
6030                PAGE,
6031            )
6032            .await;
6033
6034            // The cap's last item ends inside page 10's durable prefix.
6035            let checkpoint = Checkpoint::open(context.child("meta"), &cfg.partition)
6036                .await
6037                .unwrap();
6038            let recovery = Recovery::<_, Digest>::open(
6039                context.child("capped"),
6040                cfg.clone(),
6041                checkpoint,
6042                Some(32),
6043            )
6044            .await
6045            .unwrap();
6046            assert_eq!(recovery.bounds, 0..32);
6047            assert_eq!(recovery.item(31).await.unwrap(), test_digest(31));
6048            assert_eq!(
6049                authenticated::BackingRecovery::last_matching(&recovery, 32, |_| true)
6050                    .await
6051                    .unwrap(),
6052                32
6053            );
6054            let journal = Journal(Box::new(recovery.publish(32).await.unwrap()));
6055            assert_eq!(journal.bounds(), 0..32);
6056            assert_eq!(journal.read(31).await.unwrap(), test_digest(31));
6057            journal.destroy().await.unwrap();
6058        });
6059    }
6060
6061    /// A torn page beneath the recovery watermark is external corruption, not a crash artifact,
6062    /// since the watermark only advances after the covering fsync completes. An unbounded open
6063    /// never re-reads acknowledged pages, so it adopts the journal unchanged and the damage
6064    /// surfaces as a read error on the affected items.
6065    #[test_traced]
6066    fn test_fixed_recovery_adopts_torn_page_below_watermark() {
6067        let executor = deterministic::Runner::default();
6068        executor.start(|context| async move {
6069            let cfg = test_cfg(NZU64!(10));
6070            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
6071                .await
6072                .unwrap();
6073            for i in 0..15u64 {
6074                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
6075            }
6076            journal.sync().await.unwrap();
6077
6078            corrupt_page(
6079                &context,
6080                &blob_partition(&cfg),
6081                &0u64.to_be_bytes(),
6082                3,
6083                PAGE_SIZE.get() as u64,
6084            )
6085            .await;
6086            let size_before = context
6087                .logical_blob(&blob_partition(&cfg), &0u64.to_be_bytes())
6088                .unwrap()
6089                .len();
6090
6091            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
6092                .await
6093                .expect("acknowledged damage must not fail recovery");
6094
6095            // Adoption must not mutate the torn blob. Items on the torn page fail lazily at
6096            // read while every item beyond the damaged blob remains readable.
6097            let size_after = context
6098                .logical_blob(&blob_partition(&cfg), &0u64.to_be_bytes())
6099                .unwrap()
6100                .len();
6101            assert_eq!(
6102                size_after, size_before,
6103                "adoption must preserve the evidence"
6104            );
6105            let mut damaged = 0;
6106            for i in 0..10u64 {
6107                match journal.read(i).await {
6108                    Ok(item) => assert_eq!(item, test_digest(i)),
6109                    Err(_) => damaged += 1,
6110                }
6111            }
6112            assert!(damaged > 0, "the torn page must surface as read errors");
6113            for i in 10..15u64 {
6114                assert_eq!(journal.read(i).await.unwrap(), test_digest(i));
6115            }
6116            drop(journal);
6117
6118            // A retry adopts the same state without mutating it.
6119            let _ = Journal::<_, Digest>::init(context.child("third"), cfg.clone())
6120                .await
6121                .unwrap();
6122            let size_retry = context
6123                .logical_blob(&blob_partition(&cfg), &0u64.to_be_bytes())
6124                .unwrap()
6125                .len();
6126            assert_eq!(size_retry, size_before);
6127        });
6128    }
6129
6130    /// Blobs wholly below the floor's blob are skipped by the interior-hole scan: a torn
6131    /// page in a fully acknowledged blob is adopted at init and surfaces as read errors on
6132    /// the affected items. The floor blob's covered prefix is pinned separately by the
6133    /// mid-blob adoption test.
6134    #[test_traced]
6135    fn test_fixed_recovery_skips_watermark_covered_blobs() {
6136        let executor = deterministic::Runner::default();
6137        executor.start(|context| async move {
6138            let cfg = test_cfg(NZU64!(10));
6139            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
6140                .await
6141                .unwrap();
6142            for i in 0..15u64 {
6143                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
6144            }
6145            journal.sync().await.unwrap();
6146
6147            // Tear an interior page of the fully acknowledged blob 0: recovery must adopt the
6148            // blob without scanning it, or it would truncate acknowledged items.
6149            corrupt_page(
6150                &context,
6151                &blob_partition(&cfg),
6152                &0u64.to_be_bytes(),
6153                2,
6154                u64::from(PAGE_SIZE.get()),
6155            )
6156            .await;
6157
6158            let journal = Journal::<_, Digest>::init(context.child("second"), cfg)
6159                .await
6160                .unwrap();
6161            assert_eq!(journal.bounds(), 0..15);
6162            assert_eq!(journal.read(0).await.unwrap(), test_digest(0));
6163            assert!(journal.read(2).await.is_err());
6164            assert_eq!(journal.read(14).await.unwrap(), test_digest(14));
6165            journal.destroy().await.unwrap();
6166        });
6167    }
6168
6169    /// A torn page below a mid-blob watermark is adopted without truncating the blob's
6170    /// acknowledged prefix, and the damage surfaces as read errors on the affected items.
6171    #[test_traced]
6172    fn test_fixed_recovery_adopts_torn_page_below_mid_blob_watermark() {
6173        let executor = deterministic::Runner::default();
6174        executor.start(|context| async move {
6175            let cfg = test_cfg(NZU64!(10));
6176            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
6177                .await
6178                .unwrap();
6179            for i in 0..15u64 {
6180                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
6181            }
6182            // The watermark (15) sits mid-blob: blob 1 holds 5 items (160 bytes) across 4 pages.
6183            journal.sync().await.unwrap();
6184
6185            // Tear page 1 of blob 1, beneath the acknowledged bytes ending at 160.
6186            corrupt_page(
6187                &context,
6188                &blob_partition(&cfg),
6189                &1u64.to_be_bytes(),
6190                1,
6191                PAGE_SIZE.get() as u64,
6192            )
6193            .await;
6194            let (_, size_before) = context
6195                .open(&blob_partition(&cfg), &1u64.to_be_bytes())
6196                .await
6197                .unwrap();
6198
6199            for child in ["second", "retry"] {
6200                let journal = Journal::<_, Digest>::init(context.child(child), cfg.clone())
6201                    .await
6202                    .expect("acknowledged damage must not fail recovery");
6203                let mut damaged = 0;
6204                for i in 10..15u64 {
6205                    match journal.read(i).await {
6206                        Ok(item) => assert_eq!(item, test_digest(i)),
6207                        Err(_) => damaged += 1,
6208                    }
6209                }
6210                assert!(damaged > 0, "the torn page must surface as read errors");
6211                for i in 0..10u64 {
6212                    assert_eq!(journal.read(i).await.unwrap(), test_digest(i));
6213                }
6214            }
6215
6216            // Adoption must not truncate the blob's acknowledged prefix.
6217            let (_, size_after) = context
6218                .open(&blob_partition(&cfg), &1u64.to_be_bytes())
6219                .await
6220                .unwrap();
6221            assert_eq!(size_after, size_before);
6222        });
6223    }
6224
6225    /// A torn page containing the watermark's acknowledged boundary is the one sub-watermark
6226    /// shape recovery still reads: the blob no longer backs its acknowledged items, so init
6227    /// fails loudly. Opening may first trim the torn page as an ordinary crash tail, but
6228    /// retries fail identically without mutating further.
6229    #[test_traced]
6230    fn test_fixed_recovery_rejects_torn_boundary_page() {
6231        let executor = deterministic::Runner::default();
6232        executor.start(|context| async move {
6233            let cfg = test_cfg(NZU64!(10));
6234            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
6235                .await
6236                .unwrap();
6237            for i in 0..15u64 {
6238                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
6239            }
6240
6241            // The watermark (15) acknowledges 160 bytes of blob 1, ending inside page 3.
6242            journal.sync().await.unwrap();
6243
6244            // Tear page 3 of blob 1: the boundary page recovery must still validate.
6245            let physical_page_size = PAGE_SIZE.get() as u64 + 12;
6246            let offset = 3 * physical_page_size + 5;
6247            let (blob, _) = context
6248                .open(&blob_partition(&cfg), &1u64.to_be_bytes())
6249                .await
6250                .unwrap();
6251            let byte = blob
6252                .read_at(offset, 1, commonware_runtime::ReadOptions::default())
6253                .await
6254                .unwrap()
6255                .coalesce();
6256            blob.write_at(
6257                offset,
6258                vec![byte.as_ref()[0] ^ 0xFF],
6259                WriteOptions::default(),
6260            )
6261            .await
6262            .unwrap();
6263            blob.sync().await.unwrap();
6264            drop(blob);
6265
6266            let result = Journal::<_, Digest>::init(context.child("second"), cfg.clone()).await;
6267            assert!(matches!(result, Err(Error::Corruption(_))));
6268            let (_, size_mid) = context
6269                .open(&blob_partition(&cfg), &1u64.to_be_bytes())
6270                .await
6271                .unwrap();
6272
6273            // A retry fails identically and mutates nothing further.
6274            let result = Journal::<_, Digest>::init(context.child("retry"), cfg.clone()).await;
6275            assert!(matches!(result, Err(Error::Corruption(_))));
6276            let (_, size_after) = context
6277                .open(&blob_partition(&cfg), &1u64.to_be_bytes())
6278                .await
6279                .unwrap();
6280            assert_eq!(size_after, size_mid);
6281        });
6282    }
6283
6284    /// Test that recovery preserves exactly the durable contiguous prefix when the tail blob's
6285    /// items were never synced: blobs 0 and 1 are durable, blob 2's items were only buffered.
6286    #[test_traced]
6287    fn test_fixed_recovery_unsynced_tail_keeps_contiguous_prefix() {
6288        let executor = deterministic::Runner::default();
6289        executor.start(|context| async move {
6290            let cfg = test_cfg(NZU64!(10));
6291            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
6292                .await
6293                .unwrap();
6294
6295            // Fill blobs 0 and 1 and partially fill blob 2 (positions 20..25). Rollover has
6296            // already made blobs 0 and 1 durable; blob 2's items sit in the write buffer only.
6297            for i in 0..25u64 {
6298                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
6299            }
6300
6301            // Wait for blobs 0 and 1 to become durable, then drop without flushing
6302            // blob 2's buffered items.
6303            {
6304                journal = journal.test_sync_blob(0).await.unwrap();
6305                journal = journal.test_sync_blob(1).await.unwrap();
6306            }
6307            drop(journal);
6308
6309            // The durable data is exactly the contiguous prefix: blobs 0 and 1 hold items and
6310            // blob 2 is an empty trailing blob.
6311            let names = scan_partition(&context, &blob_partition(&cfg)).await;
6312            assert_eq!(names.len(), 3);
6313            for (blob, name) in names.iter().enumerate() {
6314                let (_, size) = context.open(&blob_partition(&cfg), name).await.unwrap();
6315                if blob < 2 {
6316                    assert!(size > 0, "blob {blob} should be durable");
6317                } else {
6318                    assert_eq!(size, 0, "blob {blob} should be empty");
6319                }
6320            }
6321
6322            // Recovery preserves exactly the contiguous prefix 0..20.
6323            let mut journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
6324                .await
6325                .unwrap();
6326            assert_eq!(journal.bounds(), 0..20);
6327            for i in 0..20u64 {
6328                assert_eq!(journal.read(i).await.unwrap(), test_digest(i));
6329            }
6330            assert!(matches!(
6331                journal.read(20).await,
6332                Err(Error::ItemOutOfRange(20))
6333            ));
6334
6335            // Appends resume cleanly from the recovered boundary.
6336            let appended;
6337            (journal, appended) = journal.append(&test_digest(999)).await.unwrap();
6338            assert_eq!(appended, 20);
6339            assert_eq!(journal.read(20).await.unwrap(), test_digest(999));
6340
6341            journal.destroy().await.unwrap();
6342        });
6343    }
6344
6345    /// Test that a durable blob above the sync watermark, sitting beyond an empty intermediate
6346    /// blob, is rolled back to the contiguous boundary during recovery.
6347    ///
6348    /// Since #3790 removed the append-time sync when crossing blob boundaries, a process crash can
6349    /// leave a later blob incidentally durable while an earlier blob stayed buffered and was
6350    /// lost, producing a physical gap. Length-based recovery walks blobs from oldest and
6351    /// truncates at the first short non-tail blob, so the post-gap blob is discarded and only
6352    /// the synced prefix survives.
6353    #[test_traced]
6354    fn test_fixed_recovery_rolls_back_durable_blob_after_gap() {
6355        let executor = deterministic::Runner::default();
6356        executor.start(|context| async move {
6357            let cfg = test_cfg(NZU64!(10));
6358            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
6359                .await
6360                .unwrap();
6361
6362            // Durably commit blob 0 (positions 0..10), advancing the recovery watermark to 10.
6363            for i in 0..10u64 {
6364                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
6365            }
6366            journal = journal.sync().await.unwrap();
6367
6368            // Append blob 1 and part of blob 2 without committing. Then corrupt blob 1 back to
6369            // empty while keeping blob 2 durable, creating an external gap recovery must reject.
6370            for i in 10..28u64 {
6371                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
6372            }
6373            {
6374                journal = journal.test_sync_blob(2).await.unwrap();
6375            }
6376            drop(journal);
6377            let (blob, _) = context
6378                .open(&blob_partition(&cfg), &1u64.to_be_bytes())
6379                .await
6380                .unwrap();
6381            blob.resize(0).await.unwrap();
6382            blob.sync().await.unwrap();
6383            drop(blob);
6384
6385            // Durable state: blob 0 (10 items), blob 1 (empty gap), blob 2 (8 items).
6386            let names = scan_partition(&context, &blob_partition(&cfg)).await;
6387            assert_eq!(names.len(), 3);
6388            let mut sizes = Vec::new();
6389            for name in &names {
6390                let (_, size) = context.open(&blob_partition(&cfg), name).await.unwrap();
6391                sizes.push(size);
6392            }
6393            assert!(sizes[0] > 0, "blob 0 should be durable");
6394            assert_eq!(sizes[1], 0, "blob 1 should be the gap");
6395            assert!(sizes[2] > 0, "blob 2 should be incidentally durable");
6396
6397            // Recovery rolls back to the watermark boundary: only the synced prefix survives and the
6398            // gapped blob 2 is truncated away.
6399            let mut journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
6400                .await
6401                .unwrap();
6402            assert_eq!(journal.bounds(), 0..10);
6403            for i in 0..10u64 {
6404                assert_eq!(journal.read(i).await.unwrap(), test_digest(i));
6405            }
6406            assert!(matches!(
6407                journal.read(10).await,
6408                Err(Error::ItemOutOfRange(10))
6409            ));
6410
6411            // The orphaned blob 2 is gone; the truncated blob 1 remains as the recovered tail.
6412            let names = scan_partition(&context, &blob_partition(&cfg)).await;
6413            assert_eq!(names.len(), 2);
6414
6415            // Appends resume cleanly from the recovered boundary.
6416            let appended;
6417            (journal, appended) = journal.append(&test_digest(999)).await.unwrap();
6418            assert_eq!(appended, 10);
6419            assert_eq!(journal.read(10).await.unwrap(), test_digest(999));
6420
6421            journal.destroy().await.unwrap();
6422        });
6423    }
6424
6425    /// A crash right after pruning must not lose retained items that were appended but never
6426    /// synced. Blob removal is durable, so prune makes all data durable first: otherwise the
6427    /// unsynced tail would vanish with the crash and recovery would truncate the journal to
6428    /// empty even though the removal survived.
6429    #[test_traced]
6430    fn test_fixed_recovery_prune_crash_retains_unsynced_tail() {
6431        let executor = deterministic::Runner::default();
6432        executor.start(|context| async move {
6433            let cfg = test_cfg(NZU64!(10));
6434            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
6435                .await
6436                .unwrap();
6437
6438            // Durably persist blob 0 (positions 0..10), then append positions 10..25 across
6439            // blobs 1 and 2 without syncing.
6440            for i in 0..10u64 {
6441                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
6442            }
6443            journal = journal.sync().await.unwrap();
6444            for i in 10..25u64 {
6445                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
6446            }
6447
6448            // Prune away blob 0, then crash before any sync.
6449            let (journal, pruned) = journal.prune(10).await.unwrap();
6450            assert!(pruned);
6451            drop(journal);
6452
6453            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
6454                .await
6455                .unwrap();
6456            assert_eq!(journal.bounds(), 10..25);
6457            for i in 10..25u64 {
6458                assert_eq!(journal.read(i).await.unwrap(), test_digest(i));
6459            }
6460            journal.destroy().await.unwrap();
6461        });
6462    }
6463
6464    /// Test recovery when the oldest blob is empty but a newer blob still holds durable items.
6465    ///
6466    /// This is the fixed-journal analog of the variable-journal empty-oldest-blob gap bug. A
6467    /// contiguous journal can only populate a later blob after filling the earlier one, so an
6468    /// empty oldest blob with a populated newer blob is an orphaned gap. Length-based recovery
6469    /// walks from the oldest blob, finds it short (empty), and truncates everything from there,
6470    /// aligning the journal to empty without panicking.
6471    #[test_traced]
6472    fn test_fixed_recovery_empty_oldest_blob_orphaned_newer_blob() {
6473        let executor = deterministic::Runner::default();
6474        executor.start(|context| async move {
6475            let cfg = test_cfg(NZU64!(10));
6476
6477            // Durably persist blobs 0 and 1 (positions 0..20).
6478            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
6479                .await
6480                .unwrap();
6481            for i in 0..20u64 {
6482                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
6483            }
6484            let journal = journal.sync().await.unwrap();
6485            drop(journal);
6486
6487            // Empty the oldest blob (external corruption). The watermark (20) now exceeds the
6488            // recoverable size (0), which is corruption.
6489            let (blob0, size0) = context
6490                .open(&blob_partition(&cfg), &0u64.to_be_bytes())
6491                .await
6492                .unwrap();
6493            assert!(size0 > 0);
6494            blob0.resize(0).await.unwrap();
6495            blob0.sync().await.unwrap();
6496
6497            drop(blob0);
6498            let result = Journal::<_, Digest>::init(context.child("second"), cfg.clone()).await;
6499            assert!(matches!(result, Err(Error::Corruption(_))));
6500        });
6501    }
6502
6503    /// A config that stores one item per blob under the given partition.
6504    fn single_item_per_blob_config() -> Config {
6505        Config {
6506            partition: "single-item-per-blob".into(),
6507            items_per_blob: NZU64!(1),
6508            page_cache: CacheRef::new(PAGE_SIZE, PAGE_CACHE_SIZE),
6509            write_buffer: NZUsize!(2048),
6510            replay_buffer: NZUsize!(2048),
6511        }
6512    }
6513
6514    /// With one item per blob, the tail blob is empty after every sync. Appends, tail reads,
6515    /// pruning, appends past the prune, and a restart all keep every retained position readable
6516    /// and the bounds intact.
6517    #[test_traced]
6518    fn test_single_item_per_blob() {
6519        let executor = deterministic::Runner::default();
6520        executor.start(|context| async move {
6521            let cfg = single_item_per_blob_config();
6522
6523            // A fresh journal starts empty, and its first synced item is readable at the tail.
6524            let mut journal = Journal::init(context.child("first"), cfg.clone())
6525                .await
6526                .expect("failed to initialize journal");
6527
6528            // Verify empty state
6529            let bounds = journal.bounds();
6530            assert_eq!(bounds.end, 0);
6531            assert!(bounds.is_empty());
6532
6533            // Append 1 item
6534            let pos;
6535            (journal, pos) = journal
6536                .append(&test_digest(0))
6537                .await
6538                .expect("failed to append");
6539            assert_eq!(pos, 0);
6540            assert_eq!(journal.size(), 1);
6541
6542            // Sync
6543            journal = journal.sync().await.expect("failed to sync");
6544
6545            // Read from size() - 1
6546            let value = journal
6547                .read(journal.size() - 1)
6548                .await
6549                .expect("failed to read");
6550            assert_eq!(value, test_digest(0));
6551
6552            // Each further append fills its own blob, and the tail stays readable at size() - 1.
6553            for i in 1..10u64 {
6554                let pos;
6555                (journal, pos) = journal
6556                    .append(&test_digest(i))
6557                    .await
6558                    .expect("failed to append");
6559                assert_eq!(pos, i);
6560                assert_eq!(journal.size(), i + 1);
6561
6562                // Verify we can read the just-appended item at size() - 1
6563                let value = journal
6564                    .read(journal.size() - 1)
6565                    .await
6566                    .expect("failed to read");
6567                assert_eq!(value, test_digest(i));
6568            }
6569
6570            // Verify all items can be read
6571            for i in 0..10u64 {
6572                assert_eq!(journal.read(i).await.unwrap(), test_digest(i));
6573            }
6574
6575            // Pruning the first five positions keeps the size but moves the start. Reads below the
6576            // start report ItemPruned while the tail stays readable.
6577            journal = journal.sync().await.expect("failed to sync");
6578            (journal, _) = journal.prune(5).await.expect("failed to prune");
6579
6580            // Size should still be 10
6581            assert_eq!(journal.size(), 10);
6582
6583            // bounds.start should be 5
6584            assert_eq!(journal.bounds().start, 5);
6585
6586            // Reading from size() - 1 (position 9) should still work
6587            let value = journal
6588                .read(journal.size() - 1)
6589                .await
6590                .expect("failed to read");
6591            assert_eq!(value, test_digest(9));
6592
6593            // Reading from pruned positions should return ItemPruned
6594            for i in 0..5 {
6595                assert!(matches!(journal.read(i).await, Err(Error::ItemPruned(_))));
6596            }
6597
6598            // Reading from retained positions should work
6599            for i in 5..10u64 {
6600                assert_eq!(journal.read(i).await.unwrap(), test_digest(i));
6601            }
6602
6603            // Appends after the prune continue at the old size, each readable at the tail.
6604            for i in 10..15u64 {
6605                let pos;
6606                (journal, pos) = journal
6607                    .append(&test_digest(i))
6608                    .await
6609                    .expect("failed to append");
6610                assert_eq!(pos, i);
6611
6612                // Verify we can read from size() - 1
6613                let value = journal
6614                    .read(journal.size() - 1)
6615                    .await
6616                    .expect("failed to read");
6617                assert_eq!(value, test_digest(i));
6618            }
6619
6620            // A reopen recovers the size and pruned start, so every retained position reads back.
6621            journal.sync().await.expect("failed to sync");
6622            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
6623                .await
6624                .expect("failed to re-initialize journal");
6625
6626            // Verify size is preserved
6627            assert_eq!(journal.size(), 15);
6628
6629            // Verify bounds.start is preserved
6630            assert_eq!(journal.bounds().start, 5);
6631
6632            // Reading from size() - 1 should work after restart
6633            let value = journal
6634                .read(journal.size() - 1)
6635                .await
6636                .expect("failed to read");
6637            assert_eq!(value, test_digest(14));
6638
6639            // Reading all retained positions should work
6640            for i in 5..15u64 {
6641                assert_eq!(journal.read(i).await.unwrap(), test_digest(i));
6642            }
6643
6644            journal.destroy().await.expect("failed to destroy journal");
6645
6646            // Destroy removes the journal state, so the same config reopens empty and accepts new
6647            // writes.
6648            let mut journal = Journal::<_, Digest>::init(context.child("after_destroy"), cfg)
6649                .await
6650                .expect("failed to re-initialize journal");
6651            assert!(journal.bounds().is_empty());
6652            assert_eq!(journal.size(), 0);
6653            for i in 0..10u64 {
6654                (journal, _) = journal.append(&test_digest(i + 1000)).await.unwrap();
6655            }
6656            journal = journal.sync().await.unwrap();
6657            assert_eq!(journal.bounds().end, 10);
6658            for i in 0..10u64 {
6659                assert_eq!(journal.read(i).await.unwrap(), test_digest(i + 1000));
6660            }
6661            journal.destroy().await.expect("failed to destroy journal");
6662        });
6663    }
6664
6665    /// A one-item-per-blob journal that is pruned and reopened keeps its bounds and reads every
6666    /// retained position, including the tail.
6667    #[test_traced]
6668    fn test_single_item_per_blob_restart_after_prune() {
6669        let executor = deterministic::Runner::default();
6670        executor.start(|context| async move {
6671            let cfg = single_item_per_blob_config();
6672
6673            let mut journal = Journal::init(context.child("third"), cfg.clone())
6674                .await
6675                .expect("failed to initialize journal");
6676
6677            // Append 10 items (positions 0-9)
6678            for i in 0..10u64 {
6679                (journal, _) = journal.append(&test_digest(i + 100)).await.unwrap();
6680            }
6681
6682            // Prune to position 5 (removes positions 0-4)
6683            (journal, _) = journal.prune(5).await.unwrap();
6684            let bounds = journal.bounds();
6685            assert_eq!(bounds.end, 10);
6686            assert_eq!(bounds.start, 5);
6687
6688            // Sync and restart
6689            journal.sync().await.unwrap();
6690
6691            // Re-open journal
6692            let journal = Journal::<_, Digest>::init(context.child("fourth"), cfg.clone())
6693                .await
6694                .expect("failed to re-initialize journal");
6695
6696            // Verify state after restart
6697            let bounds = journal.bounds();
6698            assert_eq!(bounds.end, 10);
6699            assert_eq!(bounds.start, 5);
6700
6701            // Reading from size() - 1 (position 9) should work
6702            let value = journal.read(journal.size() - 1).await.unwrap();
6703            assert_eq!(value, test_digest(109));
6704
6705            // Verify all retained positions (5-9) work
6706            for i in 5..10u64 {
6707                assert_eq!(journal.read(i).await.unwrap(), test_digest(i + 100));
6708            }
6709
6710            journal.destroy().await.expect("failed to destroy journal");
6711
6712            // Destroy removes the journal state, so the same config reopens empty and accepts new
6713            // writes.
6714            let mut journal = Journal::<_, Digest>::init(context.child("after_destroy"), cfg)
6715                .await
6716                .expect("failed to re-initialize journal");
6717            assert!(journal.bounds().is_empty());
6718            assert_eq!(journal.size(), 0);
6719            for i in 0..10u64 {
6720                (journal, _) = journal.append(&test_digest(i + 1000)).await.unwrap();
6721            }
6722            journal = journal.sync().await.unwrap();
6723            assert_eq!(journal.bounds().end, 10);
6724            for i in 0..10u64 {
6725                assert_eq!(journal.read(i).await.unwrap(), test_digest(i + 1000));
6726            }
6727            journal.destroy().await.expect("failed to destroy journal");
6728        });
6729    }
6730
6731    /// Pruning every item of a one-item-per-blob journal keeps its size, empties its bounds, and
6732    /// reports the tail position as pruned until a new append.
6733    #[test_traced]
6734    fn test_single_item_per_blob_prune_all() {
6735        let executor = deterministic::Runner::default();
6736        executor.start(|context| async move {
6737            let cfg = single_item_per_blob_config();
6738
6739            let mut journal = Journal::init(context.child("storage"), cfg.clone())
6740                .await
6741                .expect("failed to initialize journal");
6742
6743            for i in 0..5u64 {
6744                (journal, _) = journal.append(&test_digest(i + 200)).await.unwrap();
6745            }
6746            journal = journal.sync().await.unwrap();
6747
6748            // Prune all items
6749            (journal, _) = journal.prune(5).await.unwrap();
6750            let bounds = journal.bounds();
6751            assert_eq!(bounds.end, 5); // Size unchanged
6752            assert!(bounds.is_empty()); // All pruned
6753
6754            // size() - 1 = 4, but position 4 is pruned
6755            let result = journal.read(journal.size() - 1).await;
6756            assert!(matches!(result, Err(Error::ItemPruned(4))));
6757
6758            // After appending, reading works again
6759            (journal, _) = journal.append(&test_digest(205)).await.unwrap();
6760            assert_eq!(journal.bounds().start, 5);
6761            assert_eq!(
6762                journal.read(journal.size() - 1).await.unwrap(),
6763                test_digest(205)
6764            );
6765
6766            journal.destroy().await.expect("failed to destroy journal");
6767        });
6768    }
6769
6770    #[test_traced]
6771    fn test_fixed_journal_init_at_size_zero() {
6772        let executor = deterministic::Runner::default();
6773        executor.start(|context| async move {
6774            let cfg = test_cfg(NZU64!(5));
6775            let mut journal =
6776                Journal::<_, Digest>::init_at_size(context.child("storage"), cfg.clone(), 0)
6777                    .await
6778                    .unwrap();
6779
6780            let bounds = journal.bounds();
6781            assert_eq!(bounds.end, 0);
6782            assert!(bounds.is_empty());
6783
6784            // Next append should get position 0
6785            let pos;
6786            (journal, pos) = journal.append(&test_digest(100)).await.unwrap();
6787            assert_eq!(pos, 0);
6788            assert_eq!(journal.size(), 1);
6789            assert_eq!(journal.read(0).await.unwrap(), test_digest(100));
6790
6791            journal.destroy().await.unwrap();
6792        });
6793    }
6794
6795    #[test_traced]
6796    fn test_fixed_journal_init_at_max_size_rejected() {
6797        let executor = deterministic::Runner::default();
6798        executor.start(|context| async move {
6799            let mut cfg = test_cfg(NZU64!(1));
6800            cfg.partition = "max-size-rejected".into();
6801
6802            // A journal sized at `u64::MAX` could never accept an append, so init rejects it.
6803            assert!(matches!(
6804                Journal::<_, Digest>::init_at_size(context.child("storage"), cfg, u64::MAX).await,
6805                Err(Error::SizeOverflow)
6806            ));
6807        });
6808    }
6809
6810    #[test_traced]
6811    fn test_fixed_journal_append_size_overflow() {
6812        let executor = deterministic::Runner::default();
6813        executor.start(|context| async move {
6814            let mut cfg = test_cfg(NZU64!(1));
6815            cfg.partition = "append-size-overflow".into();
6816
6817            // Initialize one item shy of the maximum size.
6818            let mut journal =
6819                Journal::<_, Digest>::init_at_size(context.child("near_max"), cfg, u64::MAX - 1)
6820                    .await
6821                    .unwrap();
6822
6823            // The first append fills the last representable position.
6824            let appended;
6825            (journal, appended) = journal.append(&test_digest(7)).await.unwrap();
6826            assert_eq!(appended, u64::MAX - 1);
6827            assert_eq!(journal.size(), u64::MAX);
6828
6829            // The next append would overflow the size; it must return an error rather than
6830            // panicking.
6831            assert!(matches!(
6832                journal.append(&test_digest(8)).await,
6833                Err(Error::SizeOverflow)
6834            ));
6835        });
6836    }
6837
6838    #[test_traced]
6839    fn test_fixed_journal_replay_near_max_size() {
6840        let executor = deterministic::Runner::default();
6841        executor.start(|context| async move {
6842            let mut cfg = test_cfg(NZU64!(10));
6843            cfg.partition = "replay-near-max-size".into();
6844
6845            let mut journal =
6846                Journal::<_, Digest>::init_at_size(context.child("near_max"), cfg, u64::MAX - 1)
6847                    .await
6848                    .unwrap();
6849            let expected = test_digest(7);
6850            let appended;
6851            (journal, appended) = journal.append(&expected).await.unwrap();
6852            assert_eq!(appended, u64::MAX - 1);
6853
6854            {
6855                let reader;
6856                (journal, reader) = journal.snapshot().await.unwrap();
6857                let stream = replay_items(
6858                    &reader,
6859                    u64::MAX - 1..reader.bounds().end,
6860                    NZUsize!(1024),
6861                    ReadOptions::default(),
6862                )
6863                .await
6864                .unwrap();
6865                pin_mut!(stream);
6866                let (pos, item) = stream.next().await.unwrap().unwrap();
6867                assert_eq!(pos, u64::MAX - 1);
6868                assert_eq!(item, expected);
6869                assert!(stream.next().await.is_none());
6870            }
6871
6872            journal.destroy().await.unwrap();
6873        });
6874    }
6875
6876    #[test_traced]
6877    fn test_fixed_journal_init_at_size_blob_boundary() {
6878        let executor = deterministic::Runner::default();
6879        executor.start(|context| async move {
6880            let cfg = test_cfg(NZU64!(5));
6881
6882            // Initialize at position 10 (exactly at blob 2 boundary with items_per_blob=5)
6883            let mut journal =
6884                Journal::<_, Digest>::init_at_size(context.child("storage"), cfg.clone(), 10)
6885                    .await
6886                    .unwrap();
6887
6888            let bounds = journal.bounds();
6889            assert_eq!(bounds.end, 10);
6890            assert!(bounds.is_empty());
6891
6892            // Next append should get position 10
6893            let pos;
6894            (journal, pos) = journal.append(&test_digest(1000)).await.unwrap();
6895            assert_eq!(pos, 10);
6896            assert_eq!(journal.size(), 11);
6897            assert_eq!(journal.read(10).await.unwrap(), test_digest(1000));
6898
6899            // Can continue appending
6900            let pos;
6901            (journal, pos) = journal.append(&test_digest(1001)).await.unwrap();
6902            assert_eq!(pos, 11);
6903            assert_eq!(journal.read(11).await.unwrap(), test_digest(1001));
6904
6905            journal.destroy().await.unwrap();
6906        });
6907    }
6908
6909    #[test_traced]
6910    fn test_fixed_journal_init_at_size_mid_blob() {
6911        let executor = deterministic::Runner::default();
6912        executor.start(|context| async move {
6913            let cfg = test_cfg(NZU64!(5));
6914
6915            // Initialize at position 7 (middle of blob 1 with items_per_blob=5)
6916            let mut journal =
6917                Journal::<_, Digest>::init_at_size(context.child("storage"), cfg.clone(), 7)
6918                    .await
6919                    .unwrap();
6920
6921            let bounds = journal.bounds();
6922            assert_eq!(bounds.end, 7);
6923            // No data exists yet after init_at_size
6924            assert!(bounds.is_empty());
6925
6926            // Reading before bounds.start should return ItemPruned
6927            assert!(matches!(journal.read(5).await, Err(Error::ItemPruned(5))));
6928            assert!(matches!(journal.read(6).await, Err(Error::ItemPruned(6))));
6929
6930            // Next append should get position 7
6931            let pos;
6932            (journal, pos) = journal.append(&test_digest(700)).await.unwrap();
6933            assert_eq!(pos, 7);
6934            assert_eq!(journal.size(), 8);
6935            assert_eq!(journal.read(7).await.unwrap(), test_digest(700));
6936            // Now bounds.start should be 7 (first data position)
6937            assert_eq!(journal.bounds().start, 7);
6938
6939            journal.destroy().await.unwrap();
6940        });
6941    }
6942
6943    #[test_traced]
6944    fn test_fixed_journal_append_many_after_mid_blob_start() {
6945        let executor = deterministic::Runner::default();
6946        executor.start(|context| async move {
6947            let cfg = test_cfg(NZU64!(100));
6948            let mut journal =
6949                Journal::<_, Digest>::init_at_size(context.child("first"), cfg.clone(), 150)
6950                    .await
6951                    .unwrap();
6952
6953            let items: Vec<_> = (0..100u64).map(|i| test_digest(1500 + i)).collect();
6954            let last;
6955            (journal, last) = journal.append_many(Many::Flat(&items)).await.unwrap();
6956            assert_eq!(last, 249);
6957            assert_eq!(journal.bounds(), 150..250);
6958
6959            for (position, index) in [(150, 0), (199, 49), (200, 50), (249, 99)] {
6960                assert_eq!(
6961                    journal.read(position).await.unwrap(),
6962                    items[index],
6963                    "item at position {position} did not match"
6964                );
6965            }
6966
6967            journal.sync().await.unwrap();
6968
6969            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
6970                .await
6971                .unwrap();
6972            assert_eq!(journal.bounds(), 150..250);
6973            for (position, index) in [(150, 0), (199, 49), (200, 50), (249, 99)] {
6974                assert_eq!(
6975                    journal.read(position).await.unwrap(),
6976                    items[index],
6977                    "item at position {position} did not match after reopen"
6978                );
6979            }
6980
6981            journal.destroy().await.unwrap();
6982        });
6983    }
6984
6985    #[test_traced]
6986    fn test_fixed_journal_init_at_size_persistence() {
6987        let executor = deterministic::Runner::default();
6988        executor.start(|context| async move {
6989            let cfg = test_cfg(NZU64!(5));
6990
6991            // Initialize at position 15
6992            let mut journal =
6993                Journal::<_, Digest>::init_at_size(context.child("first"), cfg.clone(), 15)
6994                    .await
6995                    .unwrap();
6996
6997            // Append some items
6998            for i in 0..5u64 {
6999                let pos;
7000                (journal, pos) = journal.append(&test_digest(1500 + i)).await.unwrap();
7001                assert_eq!(pos, 15 + i);
7002            }
7003
7004            assert_eq!(journal.size(), 20);
7005
7006            // Sync and reopen
7007            journal.sync().await.unwrap();
7008
7009            let mut journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
7010                .await
7011                .unwrap();
7012
7013            // Size and data should be preserved
7014            let bounds = journal.bounds();
7015            assert_eq!(bounds.end, 20);
7016            assert_eq!(bounds.start, 15);
7017
7018            // Verify data
7019            for i in 0..5u64 {
7020                assert_eq!(journal.read(15 + i).await.unwrap(), test_digest(1500 + i));
7021            }
7022
7023            // Can continue appending
7024            let pos;
7025            (journal, pos) = journal.append(&test_digest(9999)).await.unwrap();
7026            assert_eq!(pos, 20);
7027            assert_eq!(journal.read(20).await.unwrap(), test_digest(9999));
7028
7029            journal.destroy().await.unwrap();
7030        });
7031    }
7032
7033    #[test_traced]
7034    fn test_fixed_journal_init_at_size_persistence_without_data() {
7035        let executor = deterministic::Runner::default();
7036        executor.start(|context| async move {
7037            let cfg = test_cfg(NZU64!(5));
7038
7039            // Initialize at position 15
7040            let journal =
7041                Journal::<_, Digest>::init_at_size(context.child("first"), cfg.clone(), 15)
7042                    .await
7043                    .unwrap();
7044
7045            let bounds = journal.bounds();
7046            assert_eq!(bounds.end, 15);
7047            assert!(bounds.is_empty());
7048
7049            // Drop without writing any data
7050            drop(journal);
7051
7052            // Reopen and verify size persisted
7053            let mut journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
7054                .await
7055                .unwrap();
7056
7057            let bounds = journal.bounds();
7058            assert_eq!(bounds.end, 15);
7059            assert!(bounds.is_empty());
7060
7061            // Can append starting at position 15
7062            let pos;
7063            (journal, pos) = journal.append(&test_digest(1500)).await.unwrap();
7064            assert_eq!(pos, 15);
7065            assert_eq!(journal.read(15).await.unwrap(), test_digest(1500));
7066
7067            journal.destroy().await.unwrap();
7068        });
7069    }
7070
7071    #[test_traced]
7072    fn test_fixed_journal_init_at_size_large_offset() {
7073        let executor = deterministic::Runner::default();
7074        executor.start(|context| async move {
7075            let cfg = test_cfg(NZU64!(5));
7076
7077            // Initialize at a large position (position 1000)
7078            let mut journal =
7079                Journal::<_, Digest>::init_at_size(context.child("storage"), cfg.clone(), 1000)
7080                    .await
7081                    .unwrap();
7082
7083            let bounds = journal.bounds();
7084            assert_eq!(bounds.end, 1000);
7085            assert!(bounds.is_empty());
7086
7087            // Next append should get position 1000
7088            let pos;
7089            (journal, pos) = journal.append(&test_digest(100000)).await.unwrap();
7090            assert_eq!(pos, 1000);
7091            assert_eq!(journal.read(1000).await.unwrap(), test_digest(100000));
7092
7093            journal.destroy().await.unwrap();
7094        });
7095    }
7096
7097    #[test_traced]
7098    fn test_fixed_journal_init_at_size_prune_and_append() {
7099        let executor = deterministic::Runner::default();
7100        executor.start(|context| async move {
7101            let cfg = test_cfg(NZU64!(5));
7102
7103            // Initialize at position 20
7104            let mut journal =
7105                Journal::<_, Digest>::init_at_size(context.child("storage"), cfg.clone(), 20)
7106                    .await
7107                    .unwrap();
7108
7109            // Append items 20-29
7110            for i in 0..10u64 {
7111                (journal, _) = journal.append(&test_digest(2000 + i)).await.unwrap();
7112            }
7113
7114            assert_eq!(journal.size(), 30);
7115
7116            // Prune to position 25
7117            (journal, _) = journal.prune(25).await.unwrap();
7118
7119            let bounds = journal.bounds();
7120            assert_eq!(bounds.end, 30);
7121            assert_eq!(bounds.start, 25);
7122
7123            // Verify remaining items are readable
7124            for i in 25..30u64 {
7125                assert_eq!(journal.read(i).await.unwrap(), test_digest(2000 + (i - 20)));
7126            }
7127
7128            // Continue appending
7129            let pos;
7130            (journal, pos) = journal.append(&test_digest(3000)).await.unwrap();
7131            assert_eq!(pos, 30);
7132
7133            journal.destroy().await.unwrap();
7134        });
7135    }
7136
7137    #[test_traced]
7138    fn test_fixed_journal_clear_to_size() {
7139        let executor = deterministic::Runner::default();
7140        executor.start(|context| async move {
7141            let cfg = test_cfg(NZU64!(10));
7142            let mut journal = Journal::init(context.child("journal"), cfg.clone())
7143                .await
7144                .expect("failed to initialize journal");
7145
7146            // Append 25 items (positions 0-24, spanning 3 blobs)
7147            for i in 0..25u64 {
7148                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
7149            }
7150            assert_eq!(journal.size(), 25);
7151            journal = journal.sync().await.unwrap();
7152
7153            // Clear to position 100, effectively resetting the journal
7154            journal.0 = journal.0.clear_to_size(100).await.unwrap();
7155            assert_eq!(journal.size(), 100);
7156
7157            // Old positions should fail
7158            for i in 0..25 {
7159                assert!(matches!(journal.read(i).await, Err(Error::ItemPruned(_))));
7160            }
7161
7162            // Verify size persists after restart without writing any data
7163            drop(journal);
7164            let mut journal =
7165                Journal::<_, Digest>::init(context.child("journal_after_clear"), cfg.clone())
7166                    .await
7167                    .expect("failed to re-initialize journal after clear");
7168            assert_eq!(journal.size(), 100);
7169
7170            // Append new data starting at position 100
7171            for i in 100..105u64 {
7172                let pos;
7173                (journal, pos) = journal.append(&test_digest(i)).await.unwrap();
7174                assert_eq!(pos, i);
7175            }
7176            assert_eq!(journal.size(), 105);
7177
7178            // New positions should be readable
7179            for i in 100..105u64 {
7180                assert_eq!(journal.read(i).await.unwrap(), test_digest(i));
7181            }
7182
7183            // Sync and re-init to verify persistence
7184            journal.sync().await.unwrap();
7185
7186            let journal = Journal::<_, Digest>::init(context.child("journal_reopened"), cfg)
7187                .await
7188                .expect("failed to re-initialize journal");
7189
7190            assert_eq!(journal.size(), 105);
7191            for i in 100..105u64 {
7192                assert_eq!(journal.read(i).await.unwrap(), test_digest(i));
7193            }
7194
7195            journal.destroy().await.unwrap();
7196        });
7197    }
7198
7199    #[test_traced]
7200    fn test_fixed_journal_clear_to_size_rejects_max() {
7201        // `clear_to_size` must reject `u64::MAX` like `init_at_size`: such a journal could never
7202        // accept an append.
7203        let executor = deterministic::Runner::default();
7204        executor.start(|context| async move {
7205            let cfg = test_cfg(NZU64!(10));
7206            let journal = Journal::<_, Digest>::init(context.child("journal"), cfg.clone())
7207                .await
7208                .unwrap();
7209            assert!(matches!(
7210                journal.0.clear_to_size(u64::MAX).await,
7211                Err(Error::SizeOverflow)
7212            ));
7213            // The failed clear consumed the journal. Reopen to exercise the staging path.
7214            let journal = Journal::<_, Digest>::init(context.child("journal_intent"), cfg)
7215                .await
7216                .unwrap();
7217            assert!(matches!(
7218                journal.0.stage_clear_intent(u64::MAX).await,
7219                Err(Error::SizeOverflow)
7220            ));
7221        });
7222    }
7223
7224    #[test_traced]
7225    fn test_fixed_journal_clear_to_size_rejects_backward_without_mutation() {
7226        let executor = deterministic::Runner::default();
7227        executor.start(|context| async move {
7228            let cfg = test_cfg(NZU64!(10));
7229            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
7230                .await
7231                .unwrap();
7232            for i in 0..2 {
7233                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
7234            }
7235            let journal = journal.sync().await.unwrap();
7236
7237            assert!(matches!(
7238                journal.0.clear_to_size(1).await,
7239                Err(Error::ItemOutOfRange(1))
7240            ));
7241
7242            let journal = Journal::<_, Digest>::init(context.child("after_clear"), cfg.clone())
7243                .await
7244                .unwrap();
7245            assert_eq!(journal.bounds(), 0..2);
7246            assert_eq!(journal.read(0).await.unwrap(), test_digest(0));
7247            assert_eq!(journal.read(1).await.unwrap(), test_digest(1));
7248            assert!(matches!(
7249                journal.0.stage_clear_intent(1).await,
7250                Err(Error::ItemOutOfRange(1))
7251            ));
7252
7253            let journal = Journal::<_, Digest>::init(context.child("after_intent"), cfg)
7254                .await
7255                .unwrap();
7256            assert_eq!(journal.bounds(), 0..2);
7257            assert_eq!(journal.read(0).await.unwrap(), test_digest(0));
7258            assert_eq!(journal.read(1).await.unwrap(), test_digest(1));
7259            journal.destroy().await.unwrap();
7260        });
7261    }
7262
7263    #[test_traced]
7264    fn test_fixed_journal_sync_crash_meta_none_boundary_aligned() {
7265        // Old meta = None (aligned), new boundary = aligned.
7266        let executor = deterministic::Runner::default();
7267        executor.start(|context| async move {
7268            let cfg = test_cfg(NZU64!(5));
7269            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
7270                .await
7271                .unwrap();
7272
7273            for i in 0..5u64 {
7274                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
7275            }
7276            journal.commit().await.unwrap();
7277
7278            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
7279                .await
7280                .unwrap();
7281            let bounds = journal.bounds();
7282            assert_eq!(bounds.start, 0);
7283            assert_eq!(bounds.end, 5);
7284            journal.destroy().await.unwrap();
7285        });
7286    }
7287
7288    #[test_traced]
7289    fn test_fixed_journal_missing_metadata_with_short_blob_is_corruption() {
7290        // Clearing all metadata leaves no watermark. Recovery falls back to the blob boundary
7291        // and finds a short non-tail blob, violating the legacy rollover-sync invariant.
7292        let executor = deterministic::Runner::default();
7293        executor.start(|context| async move {
7294            let cfg = test_cfg(NZU64!(5));
7295            let mut journal =
7296                Journal::<_, Digest>::init_at_size(context.child("first"), cfg.clone(), 7)
7297                    .await
7298                    .unwrap();
7299            for i in 0..3u64 {
7300                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
7301            }
7302            let mut journal = journal.sync().await.unwrap();
7303
7304            // Simulate metadata deletion (corruption).
7305            journal.0.checkpoint.clear();
7306            journal.0.checkpoint = journal.0.checkpoint.sync().await.unwrap();
7307            drop(journal);
7308
7309            let result = Journal::<_, Digest>::init(context.child("second"), cfg.clone()).await;
7310            assert!(matches!(result, Err(Error::Corruption(_))));
7311        });
7312    }
7313
7314    #[test_traced]
7315    fn test_fixed_journal_sync_crash_meta_mid_boundary_unchanged() {
7316        // Old meta = Some(mid), new boundary = mid-blob (same value).
7317        let executor = deterministic::Runner::default();
7318        executor.start(|context| async move {
7319            let cfg = test_cfg(NZU64!(5));
7320            let mut journal =
7321                Journal::<_, Digest>::init_at_size(context.child("first"), cfg.clone(), 7)
7322                    .await
7323                    .unwrap();
7324            for i in 0..3u64 {
7325                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
7326            }
7327            journal.commit().await.unwrap();
7328
7329            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
7330                .await
7331                .unwrap();
7332            let bounds = journal.bounds();
7333            assert_eq!(bounds.start, 7);
7334            assert_eq!(bounds.end, 10);
7335            journal.destroy().await.unwrap();
7336        });
7337    }
7338    #[test_traced]
7339    fn test_fixed_journal_sync_crash_meta_mid_to_aligned_becomes_stale() {
7340        // Old meta = Some(mid), new boundary = aligned.
7341        let executor = deterministic::Runner::default();
7342        executor.start(|context| async move {
7343            let cfg = test_cfg(NZU64!(5));
7344            let mut journal =
7345                Journal::<_, Digest>::init_at_size(context.child("first"), cfg.clone(), 7)
7346                    .await
7347                    .unwrap();
7348            for i in 0..10u64 {
7349                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
7350            }
7351            assert_eq!(journal.size(), 17);
7352            (journal, _) = journal.prune(10).await.unwrap();
7353
7354            journal.commit().await.unwrap();
7355
7356            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
7357                .await
7358                .unwrap();
7359            let bounds = journal.bounds();
7360            assert_eq!(bounds.start, 10);
7361            assert_eq!(bounds.end, 17);
7362            journal.destroy().await.unwrap();
7363        });
7364    }
7365
7366    #[test_traced]
7367    fn test_fixed_journal_prune_does_not_move_boundary_backwards() {
7368        // Pruning to a position earlier than pruning_boundary (within the same blob)
7369        // should not move the boundary backwards.
7370        let executor = deterministic::Runner::default();
7371        executor.start(|context| async move {
7372            let cfg = test_cfg(NZU64!(5));
7373            // init_at_size(7) sets pruning_boundary = 7 (mid-blob in blob 1)
7374            let mut journal =
7375                Journal::<_, Digest>::init_at_size(context.child("first"), cfg.clone(), 7)
7376                    .await
7377                    .unwrap();
7378            // Append 5 items at positions 7-11, filling blob 1 and part of blob 2
7379            for i in 0..5u64 {
7380                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
7381            }
7382            // Prune to position 5 (blob 1 start) should NOT move boundary back from 7 to 5
7383            let (journal, _) = journal.prune(5).await.unwrap();
7384            assert_eq!(journal.bounds().start, 7);
7385            journal.destroy().await.unwrap();
7386        });
7387    }
7388
7389    /// A prune that returns true has made every pre-prune item durable: a crash immediately
7390    /// after must recover the full pre-prune size even when every unsynced write is lost.
7391    #[test_traced]
7392    fn test_fixed_journal_prune_durability_survives_crash() {
7393        let executor = deterministic::Runner::default();
7394        let (_, checkpoint) = executor.start_and_recover(|context| async move {
7395            let cfg = test_cfg(NZU64!(3));
7396            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg)
7397                .await
7398                .unwrap();
7399
7400            // Fill two blobs plus an unsynced tail, then prune into blob 1. The crash
7401            // drops every write not covered by a completed sync, so the prune's internal
7402            // sync is the only durability point covering these items.
7403            for i in 0..8u64 {
7404                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
7405            }
7406            let (journal, pruned) = journal.prune(3).await.unwrap();
7407            assert!(pruned);
7408            drop(journal);
7409        });
7410
7411        deterministic::Runner::from(checkpoint).start(|context| async move {
7412            let cfg = test_cfg(NZU64!(3));
7413            let journal = Journal::<_, Digest>::init(context.child("recover"), cfg)
7414                .await
7415                .unwrap();
7416            assert_eq!(
7417                journal.bounds(),
7418                3..8,
7419                "pruned journal lost acknowledged items"
7420            );
7421            for i in 3..8u64 {
7422                assert_eq!(journal.read(i).await.unwrap(), test_digest(i));
7423            }
7424            journal.destroy().await.unwrap();
7425        });
7426    }
7427
7428    /// Prune makes all data durable before removing blobs, so a commit issued right after must
7429    /// not attempt to sync the removed blobs.
7430    #[test_traced]
7431    fn test_fixed_journal_commit_after_prune() {
7432        let executor = deterministic::Runner::default();
7433        executor.start(|context| async move {
7434            let cfg = test_cfg(NZU64!(5));
7435            let mut journal = Journal::<_, Digest>::init(context.child("journal"), cfg.clone())
7436                .await
7437                .unwrap();
7438
7439            for i in 0..12 {
7440                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
7441            }
7442
7443            let (mut journal, _) = journal.prune(5).await.unwrap();
7444            journal = journal
7445                .commit()
7446                .await
7447                .expect("commit should not try to sync pruned blobs");
7448            assert_eq!(journal.bounds(), 5..12);
7449            journal.destroy().await.unwrap();
7450        });
7451    }
7452
7453    #[test_traced]
7454    fn test_fixed_journal_replay_after_init_at_size_spanning_blobs() {
7455        // Test replay when first blob begins mid-blob: init_at_size creates a journal
7456        // where pruning_boundary is mid-blob, then we append across multiple blobs.
7457        let executor = deterministic::Runner::default();
7458        executor.start(|context| async move {
7459            let cfg = test_cfg(NZU64!(5));
7460
7461            // Initialize at position 7 (mid-blob with items_per_blob=5)
7462            // Blob 1 (positions 5-9) begins mid-blob: only positions 7, 8, 9 have data
7463            let mut journal =
7464                Journal::<_, Digest>::init_at_size(context.child("storage"), cfg.clone(), 7)
7465                    .await
7466                    .unwrap();
7467
7468            // Append 13 items (positions 7-19), spanning blobs 1, 2, 3
7469            for i in 0..13u64 {
7470                let pos;
7471                (journal, pos) = journal.append(&test_digest(100 + i)).await.unwrap();
7472                assert_eq!(pos, 7 + i);
7473            }
7474            assert_eq!(journal.size(), 20);
7475            journal = journal.sync().await.unwrap();
7476
7477            // Replay from pruning_boundary
7478            {
7479                let reader;
7480                (journal, reader) = journal.snapshot().await.unwrap();
7481                let stream = replay_items(
7482                    &reader,
7483                    7..reader.bounds().end,
7484                    NZUsize!(1024),
7485                    ReadOptions::default(),
7486                )
7487                .await
7488                .expect("failed to replay");
7489                pin_mut!(stream);
7490                let mut items: Vec<(u64, Digest)> = Vec::new();
7491                while let Some(result) = stream.next().await {
7492                    items.push(result.expect("replay item failed"));
7493                }
7494
7495                // Should get all 13 items with correct logical positions
7496                assert_eq!(items.len(), 13);
7497                for (i, (pos, item)) in items.iter().enumerate() {
7498                    assert_eq!(*pos, 7 + i as u64);
7499                    assert_eq!(*item, test_digest(100 + i as u64));
7500                }
7501            }
7502
7503            // Replay from mid-stream (position 12)
7504            {
7505                let reader;
7506                (journal, reader) = journal.snapshot().await.unwrap();
7507                let stream = replay_items(
7508                    &reader,
7509                    12..reader.bounds().end,
7510                    NZUsize!(1024),
7511                    ReadOptions::default(),
7512                )
7513                .await
7514                .expect("failed to replay from mid-stream");
7515                pin_mut!(stream);
7516                let mut items: Vec<(u64, Digest)> = Vec::new();
7517                while let Some(result) = stream.next().await {
7518                    items.push(result.expect("replay item failed"));
7519                }
7520
7521                // Should get items from position 12 onwards
7522                assert_eq!(items.len(), 8);
7523                for (i, (pos, item)) in items.iter().enumerate() {
7524                    assert_eq!(*pos, 12 + i as u64);
7525                    assert_eq!(*item, test_digest(100 + 5 + i as u64));
7526                }
7527            }
7528
7529            journal.destroy().await.unwrap();
7530        });
7531    }
7532
7533    #[test_traced]
7534    fn test_fixed_journal_init_at_most_before_retained_start() {
7535        // A bound below the retained start must be rejected.
7536        let executor = deterministic::Runner::default();
7537        executor.start(|context| async move {
7538            let cfg = test_cfg(NZU64!(5));
7539
7540            let mut journal =
7541                Journal::<_, Digest>::init_at_size(context.child("storage"), cfg.clone(), 10)
7542                    .await
7543                    .unwrap();
7544
7545            // Append a few items (positions 10, 11, 12)
7546            for i in 0..3u64 {
7547                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
7548            }
7549            assert_eq!(journal.size(), 13);
7550
7551            // A bound of 11 retains one item.
7552            journal = {
7553                _ = journal.sync().await.unwrap();
7554                Journal::<_, Digest>::init_at_most(context.child("cap"), cfg.clone(), 11).await
7555            }
7556            .unwrap();
7557            assert_eq!(journal.size(), 11);
7558
7559            // The retained start is a valid empty prefix.
7560            journal = {
7561                _ = journal.sync().await.unwrap();
7562                Journal::<_, Digest>::init_at_most(context.child("cap"), cfg.clone(), 10).await
7563            }
7564            .unwrap();
7565            assert_eq!(journal.size(), 10);
7566
7567            // Truncate to before pruning_boundary should fail
7568            let result = {
7569                _ = journal.sync().await.unwrap();
7570                Journal::<_, Digest>::init_at_most(context.child("cap"), cfg.clone(), 9).await
7571            };
7572            assert!(matches!(result, Err(Error::ItemPruned(9))));
7573        });
7574    }
7575
7576    #[test_traced]
7577    fn test_fixed_journal_init_at_size_crash_scenarios() {
7578        let executor = deterministic::Runner::default();
7579        executor.start(|context| async move {
7580            let cfg = test_cfg(NZU64!(5));
7581
7582            // Setup: Create a journal with some data and mid-blob metadata
7583            let mut journal =
7584                Journal::<_, Digest>::init_at_size(context.child("first"), cfg.clone(), 7)
7585                    .await
7586                    .unwrap();
7587            for i in 0..5u64 {
7588                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
7589            }
7590            let journal = journal.sync().await.unwrap();
7591            drop(journal);
7592
7593            // Crash Scenario 1: after clear intent is synced and blobs are removed, but before
7594            // the new tail blob is created.
7595            let blob_part = blob_partition(&cfg);
7596            let mut checkpoint = Checkpoint::open(context.child("intent_meta"), &cfg.partition)
7597                .await
7598                .unwrap();
7599            checkpoint.set_clear_target(12);
7600            let checkpoint = checkpoint.sync().await.unwrap();
7601            drop(checkpoint);
7602            context.remove(&blob_part, None).await.unwrap();
7603
7604            // Recovery should complete the interrupted init_at_size(12).
7605            let journal = Journal::<_, Digest>::init(
7606                context.child("crash").with_attribute("index", 1),
7607                cfg.clone(),
7608            )
7609            .await
7610            .expect("init failed after clear crash");
7611            let bounds = journal.bounds();
7612            assert_eq!(bounds.end, 12);
7613            assert_eq!(bounds.start, 12);
7614            drop(journal);
7615
7616            // Restore metadata for next scenario (it might have been removed by init)
7617            let mut checkpoint = Checkpoint::open(context.child("restore_meta"), &cfg.partition)
7618                .await
7619                .unwrap();
7620            checkpoint.set_boundary_hint(7);
7621            checkpoint.set_clear_target(2);
7622            let checkpoint = checkpoint.sync().await.unwrap();
7623            drop(checkpoint);
7624
7625            // Crash Scenario 2: after the new tail blob is created, but before final metadata
7626            // replaces the clear intent.
7627            let (blob, _) = context.open(&blob_part, &0u64.to_be_bytes()).await.unwrap();
7628            blob.sync().await.unwrap(); // Ensure it exists
7629            drop(blob);
7630
7631            // Recovery should complete the interrupted init_at_size(2).
7632            let journal = Journal::<_, Digest>::init(
7633                context.child("crash").with_attribute("index", 2),
7634                cfg.clone(),
7635            )
7636            .await
7637            .expect("init failed after create crash");
7638
7639            let bounds = journal.bounds();
7640            assert_eq!(bounds.start, 2);
7641            assert_eq!(bounds.end, 2);
7642            journal.destroy().await.unwrap();
7643        });
7644    }
7645
7646    #[test_traced]
7647    fn test_fixed_journal_clear_to_size_crash_scenarios() {
7648        let executor = deterministic::Runner::default();
7649        executor.start(|context| async move {
7650            let cfg = test_cfg(NZU64!(5));
7651
7652            // Setup: Init at 12 (Blob 2, offset 2)
7653            // Metadata = 12
7654            let journal =
7655                Journal::<_, Digest>::init_at_size(context.child("first"), cfg.clone(), 12)
7656                    .await
7657                    .unwrap();
7658            let journal = journal.sync().await.unwrap();
7659            drop(journal);
7660
7661            // Crash Scenario: clear_to_size(2) after the intent is synced and blob 0 is created,
7662            // but before final metadata replaces the clear intent.
7663
7664            let blob_part = blob_partition(&cfg);
7665            let mut checkpoint = Checkpoint::open(context.child("meta"), &cfg.partition)
7666                .await
7667                .unwrap();
7668            checkpoint.set_clear_target(2);
7669            let checkpoint = checkpoint.sync().await.unwrap();
7670            drop(checkpoint);
7671
7672            context.remove(&blob_part, None).await.unwrap();
7673
7674            let (blob, _) = context.open(&blob_part, &0u64.to_be_bytes()).await.unwrap();
7675            blob.sync().await.unwrap();
7676
7677            let journal = Journal::<_, Digest>::init(context.child("crash_clear"), cfg.clone())
7678                .await
7679                .expect("init failed after clear_to_size crash");
7680
7681            let bounds = journal.bounds();
7682            assert_eq!(bounds.start, 2);
7683            assert_eq!(bounds.end, 2);
7684            journal.destroy().await.unwrap();
7685        });
7686    }
7687
7688    #[test_traced]
7689    fn test_fixed_journal_clear_to_size_crash_after_intent_before_blobs() {
7690        let executor = deterministic::Runner::default();
7691        executor.start(|context| async move {
7692            let cfg = test_cfg(NZU64!(5));
7693            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
7694                .await
7695                .unwrap();
7696            for i in 0..12u64 {
7697                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
7698            }
7699            journal = journal.sync().await.unwrap();
7700
7701            drop(journal);
7702            let mut checkpoint = Checkpoint::open(context.child("meta"), &cfg.partition)
7703                .await
7704                .unwrap();
7705            checkpoint.set_clear_target(100);
7706            let checkpoint = checkpoint.sync().await.unwrap();
7707            drop(checkpoint);
7708
7709            let mut journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
7710                .await
7711                .expect("init failed after clear intent crash");
7712            assert_eq!(journal.bounds(), 100..100);
7713            let pos;
7714            (journal, pos) = journal.append(&test_digest(100)).await.unwrap();
7715            assert_eq!(pos, 100);
7716            journal.destroy().await.unwrap();
7717        });
7718    }
7719
7720    #[test_traced]
7721    fn test_fixed_journal_clear_intent_skips_corrupt_stale_blobs() {
7722        let executor = deterministic::Runner::default();
7723        executor.start(|context| async move {
7724            let cfg = test_cfg(NZU64!(5));
7725            let blob_part = blob_partition(&cfg);
7726            let mut checkpoint = Checkpoint::open(context.child("meta"), &cfg.partition)
7727                .await
7728                .unwrap();
7729            checkpoint.set_clear_target(12);
7730            let checkpoint = checkpoint.sync().await.unwrap();
7731            drop(checkpoint);
7732
7733            // This name would fail `Partition::open_many` if init tried to parse stale blobs before
7734            // honoring the clear intent.
7735            let (blob, _) = context.open(&blob_part, b"not-u64").await.unwrap();
7736            blob.write_at(0, vec![1, 2, 3], WriteOptions::SYNC)
7737                .await
7738                .unwrap();
7739            drop(blob);
7740
7741            let journal = Journal::<_, Digest>::init(context.child("recover"), cfg.clone())
7742                .await
7743                .expect("clear intent should discard stale corrupt blobs before blob parsing");
7744            assert_eq!(journal.bounds(), 12..12);
7745            assert_eq!(journal.0.recovery_watermark(), 12);
7746            journal.destroy().await.unwrap();
7747        });
7748    }
7749
7750    #[test_traced]
7751    fn test_fixed_journal_clear_to_size_crash_after_mid_blob_intent_with_old_blobs_present() {
7752        let executor = deterministic::Runner::default();
7753        executor.start(|context| async move {
7754            let cfg = test_cfg(NZU64!(10));
7755            let mut journal =
7756                Journal::<_, Digest>::init_at_size(context.child("first"), cfg.clone(), 10)
7757                    .await
7758                    .unwrap();
7759
7760            for i in 0..6u64 {
7761                let pos;
7762                (journal, pos) = journal.append(&test_digest(i)).await.unwrap();
7763                assert_eq!(pos, 10 + i);
7764            }
7765            journal = journal.sync().await.unwrap();
7766
7767            drop(journal);
7768            let mut checkpoint = Checkpoint::open(context.child("meta"), &cfg.partition)
7769                .await
7770                .unwrap();
7771            checkpoint.set_clear_target(15);
7772            let checkpoint = checkpoint.sync().await.unwrap();
7773            drop(checkpoint);
7774
7775            let journal = Journal::<_, Digest>::init(context.child("second"), cfg.clone())
7776                .await
7777                .expect("init failed after mid-blob clear intent crash");
7778            assert_eq!(journal.bounds(), 15..15);
7779            drop(journal);
7780
7781            let mut journal = Journal::<_, Digest>::init(context.child("third"), cfg.clone())
7782                .await
7783                .expect("init failed after completing mid-blob clear intent");
7784            assert_eq!(journal.bounds(), 15..15);
7785            assert!(matches!(journal.read(14).await, Err(Error::ItemPruned(14))));
7786            let pos;
7787            (journal, pos) = journal.append(&test_digest(100)).await.unwrap();
7788            assert_eq!(pos, 15);
7789            assert_eq!(journal.read(15).await.unwrap(), test_digest(100));
7790            journal.destroy().await.unwrap();
7791        });
7792    }
7793
7794    #[rstest::rstest]
7795    #[case::lower(7)]
7796    #[case::same(50)]
7797    #[case::higher(60)]
7798    #[test_traced]
7799    fn test_fixed_journal_init_sync_after_interrupted_clear(#[case] start: u64) {
7800        // State sync is the first open after a clear crashed with its intent staged. The cases
7801        // start below, at, and above the staged target 50.
7802        let executor = deterministic::Runner::default();
7803        executor.start(|context| async move {
7804            let cfg = test_cfg(NZU64!(10));
7805
7806            // Sync 30 items across three blobs.
7807            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
7808                .await
7809                .unwrap();
7810            for i in 0..30u64 {
7811                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
7812            }
7813            journal = journal.sync().await.unwrap();
7814            drop(journal);
7815
7816            // Stage a clear to 50 and crash before any blob is removed. The blobs still hold all
7817            // 30 synced items.
7818            Journal::<_, Digest>::test_stage_clear(context.child("intent"), &cfg.partition, 50)
7819                .await
7820                .unwrap();
7821
7822            // A staged clear spans only its target. Any other start replaces the intent without
7823            // recovering the stale blobs. A start at 50 completes it.
7824            let mut journal = authenticated::init_sync::<_, Journal<_, Digest>>(
7825                context.child("sync"),
7826                cfg.clone(),
7827                start..start + 20,
7828            )
7829            .await
7830            .expect("init_sync must complete or replace the staged clear");
7831            assert_eq!(journal.bounds(), start..start);
7832
7833            // Appends begin at the sync start. Digests of 100 onward differ from every stale item,
7834            // so the reads below tell new items from stale ones.
7835            for i in 0..3u64 {
7836                let pos;
7837                (journal, pos) = journal.append(&test_digest(100 + i)).await.unwrap();
7838                assert_eq!(pos, start + i);
7839            }
7840            journal = journal.sync().await.unwrap();
7841            drop(journal);
7842
7843            // Reopen with an ordinary init. A surviving intent would clear the new items, and stale
7844            // blobs would surface below the start or in place of the new items.
7845            let journal = Journal::<_, Digest>::init(context.child("reopen"), cfg.clone())
7846                .await
7847                .unwrap();
7848            assert_eq!(journal.bounds(), start..start + 3);
7849
7850            // For start 7, the pruned position 6 held a stale item before the clear.
7851            assert!(matches!(
7852                journal.read(start - 1).await,
7853                Err(Error::ItemPruned(pos)) if pos == start - 1
7854            ));
7855            for i in 0..3u64 {
7856                assert_eq!(journal.read(start + i).await.unwrap(), test_digest(100 + i));
7857            }
7858            journal.destroy().await.unwrap();
7859        });
7860    }
7861
7862    #[test_traced]
7863    fn test_fixed_journal_rejects_watermark_with_aligned_empty_tail() {
7864        // Watermark beyond the recovered size with an aligned pruning boundary.
7865        let executor = deterministic::Runner::default();
7866        executor.start(|context| async move {
7867            let cfg = test_cfg(NZU64!(5));
7868
7869            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
7870                .await
7871                .unwrap();
7872            for i in 0..10u64 {
7873                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
7874            }
7875            let journal = journal.sync().await.unwrap();
7876            drop(journal);
7877
7878            // Remove all blobs and create a single empty blob 1, leaving
7879            // recovery_watermark=10 in metadata.
7880            let blob_part = blob_partition(&cfg);
7881            context.remove(&blob_part, None).await.unwrap();
7882            let (blob, _) = context.open(&blob_part, &1u64.to_be_bytes()).await.unwrap();
7883            blob.sync().await.unwrap();
7884
7885            drop(blob);
7886            let result = Journal::<_, Digest>::init(context.child("crash"), cfg.clone()).await;
7887            assert!(matches!(result, Err(Error::Corruption(_))));
7888        });
7889    }
7890
7891    #[test_traced]
7892    fn test_fixed_journal_rejects_far_watermark_with_aligned_empty_tail() {
7893        // Same as above but the watermark is multiple blobs past the empty tail.
7894        let executor = deterministic::Runner::default();
7895        executor.start(|context| async move {
7896            let cfg = test_cfg(NZU64!(5));
7897
7898            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
7899                .await
7900                .unwrap();
7901            for i in 0..10u64 {
7902                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
7903            }
7904            let journal = journal.sync().await.unwrap();
7905            drop(journal);
7906
7907            // Remove all blobs and create a single empty blob 0, leaving
7908            // recovery_watermark=10 in metadata.
7909            let blob_part = blob_partition(&cfg);
7910            context.remove(&blob_part, None).await.unwrap();
7911            let (blob, _) = context.open(&blob_part, &0u64.to_be_bytes()).await.unwrap();
7912            blob.sync().await.unwrap();
7913
7914            drop(blob);
7915            let result = Journal::<_, Digest>::init(context.child("crash"), cfg.clone()).await;
7916            assert!(matches!(result, Err(Error::Corruption(_))));
7917        });
7918    }
7919
7920    #[test_traced]
7921    fn test_read_many_empty() {
7922        let executor = deterministic::Runner::default();
7923        executor.start(|context| async move {
7924            let cfg = test_cfg(NZU64!(10));
7925            let journal = Journal::<_, Digest>::init(context.child("j"), cfg)
7926                .await
7927                .unwrap();
7928
7929            let (journal, reader) = journal.snapshot().await.unwrap();
7930            let items = reader.read_many(&[]).await.unwrap();
7931            assert!(items.is_empty());
7932
7933            journal.destroy().await.unwrap();
7934        });
7935    }
7936
7937    #[test_traced]
7938    fn test_read_many_single_blob() {
7939        // All positions within one blob.
7940        let executor = deterministic::Runner::default();
7941        executor.start(|context| async move {
7942            let cfg = test_cfg(NZU64!(10));
7943            let mut journal = Journal::init(context.child("j"), cfg).await.unwrap();
7944
7945            for i in 0..5u64 {
7946                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
7947            }
7948            assert_eq!(journal.size(), 5);
7949
7950            let (journal, reader) = journal.snapshot().await.unwrap();
7951            let items = reader.read_many(&[0, 2, 4]).await.unwrap();
7952            assert_eq!(items, vec![test_digest(0), test_digest(2), test_digest(4)]);
7953
7954            journal.destroy().await.unwrap();
7955        });
7956    }
7957
7958    #[test_traced]
7959    #[should_panic(expected = "positions must be strictly increasing")]
7960    fn test_read_many_rejects_unsorted_positions() {
7961        let executor = deterministic::Runner::default();
7962        executor.start(|context| async move {
7963            let cfg = test_cfg(NZU64!(10));
7964            let mut journal = Journal::init(context.child("j"), cfg).await.unwrap();
7965            for i in 0..5u64 {
7966                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
7967            }
7968
7969            let (_journal, reader) = journal.snapshot().await.unwrap();
7970            let _ = reader.read_many(&[2, 1]).await;
7971        });
7972    }
7973
7974    #[test_traced]
7975    #[should_panic(expected = "positions must be strictly increasing")]
7976    fn test_read_many_rejects_duplicate_positions() {
7977        // Duplicates are not strictly increasing either.
7978        let executor = deterministic::Runner::default();
7979        executor.start(|context| async move {
7980            let cfg = test_cfg(NZU64!(10));
7981            let mut journal = Journal::init(context.child("j"), cfg).await.unwrap();
7982            for i in 0..5u64 {
7983                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
7984            }
7985
7986            let (_journal, reader) = journal.snapshot().await.unwrap();
7987            let _ = reader.read_many(&[1, 1]).await;
7988        });
7989    }
7990
7991    #[test_traced]
7992    fn test_read_many_across_blobs() {
7993        // Positions spanning multiple blobs (items_per_blob=3).
7994        let executor = deterministic::Runner::default();
7995        executor.start(|context| async move {
7996            let cfg = test_cfg(NZU64!(3));
7997            let mut journal = Journal::init(context.child("j"), cfg).await.unwrap();
7998
7999            for i in 0..9u64 {
8000                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
8001            }
8002            assert_eq!(journal.size(), 9);
8003            // Blobs: [0,1,2], [3,4,5], [6,7,8]
8004
8005            let (journal, reader) = journal.snapshot().await.unwrap();
8006            let items = reader.read_many(&[1, 4, 7]).await.unwrap();
8007            assert_eq!(items, vec![test_digest(1), test_digest(4), test_digest(7)]);
8008
8009            journal.destroy().await.unwrap();
8010        });
8011    }
8012
8013    #[test_traced]
8014    fn test_read_many_after_prune() {
8015        // Read from positions that survive pruning.
8016        let executor = deterministic::Runner::default();
8017        executor.start(|context| async move {
8018            let cfg = test_cfg(NZU64!(3));
8019            let mut journal = Journal::init(context.child("j"), cfg).await.unwrap();
8020
8021            for i in 0..9u64 {
8022                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
8023            }
8024            assert_eq!(journal.size(), 9);
8025            journal = journal.sync().await.unwrap();
8026
8027            // Prune first blob [0,1,2].
8028            (journal, _) = journal.prune(3).await.unwrap();
8029            assert_eq!(journal.bounds(), 3..9);
8030
8031            let (journal, reader) = journal.snapshot().await.unwrap();
8032            let items = reader.read_many(&[3, 5, 8]).await.unwrap();
8033            assert_eq!(items, vec![test_digest(3), test_digest(5), test_digest(8)]);
8034
8035            // Pruned position should error.
8036            let (journal, reader) = journal.snapshot().await.unwrap();
8037            let err = reader.read_many(&[1]).await.unwrap_err();
8038            assert!(matches!(err, Error::ItemPruned(1)));
8039
8040            journal.destroy().await.unwrap();
8041        });
8042    }
8043
8044    #[test_traced]
8045    fn test_read_many_out_of_range() {
8046        let executor = deterministic::Runner::default();
8047        executor.start(|context| async move {
8048            let cfg = test_cfg(NZU64!(10));
8049            let mut journal = Journal::init(context.child("j"), cfg).await.unwrap();
8050
8051            for i in 0..3u64 {
8052                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
8053            }
8054            assert_eq!(journal.size(), 3);
8055
8056            let (journal, reader) = journal.snapshot().await.unwrap();
8057            let err = reader.read_many(&[0, 5]).await.unwrap_err();
8058            assert!(matches!(err, Error::ItemOutOfRange(5)));
8059
8060            journal.destroy().await.unwrap();
8061        });
8062    }
8063
8064    #[test_traced]
8065    fn test_read_many_matches_read() {
8066        // Verify batch read matches individual reads across blobs.
8067        let executor = deterministic::Runner::default();
8068        executor.start(|context| async move {
8069            let cfg = test_cfg(NZU64!(4));
8070            let mut journal = Journal::init(context.child("j"), cfg).await.unwrap();
8071
8072            for i in 0..20u64 {
8073                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
8074            }
8075            assert_eq!(journal.size(), 20);
8076            journal = journal.sync().await.unwrap();
8077
8078            let positions: Vec<u64> = (0..20).collect();
8079            let reader;
8080            (journal, reader) = journal.snapshot().await.unwrap();
8081            let batch = reader.read_many(&positions).await.unwrap();
8082
8083            for &pos in &positions {
8084                let single = reader.read(pos).await.unwrap();
8085                assert_eq!(batch[pos as usize], single);
8086            }
8087            drop(reader);
8088
8089            journal.destroy().await.unwrap();
8090        });
8091    }
8092
8093    #[test_traced]
8094    fn test_bounded_initialization_cached_reads() {
8095        deterministic::Runner::default().start(|context| async move {
8096            let cfg = test_cfg(NZU64!(4));
8097            let mut journal = Journal::<_, Digest>::init(context.child("seed"), cfg.clone())
8098                .await
8099                .unwrap();
8100            for i in 0..20 {
8101                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
8102            }
8103            _ = journal.sync().await.unwrap();
8104            let journal = Journal::<_, Digest>::init_at_most(context.child("bounded"), cfg, 18)
8105                .await
8106                .unwrap();
8107            let (journal, reader) = journal.snapshot().await.unwrap();
8108            assert_eq!(reader.read(17).await.unwrap(), test_digest(17));
8109            let served = reader.try_read_many_sync(&[17, 18]);
8110            assert_eq!(served[0], Some(test_digest(17)));
8111            assert!(served[1].is_none());
8112            drop(reader);
8113            journal.destroy().await.unwrap();
8114        });
8115    }
8116
8117    #[test_traced]
8118    fn test_try_read_many_sync_matches_read_many() {
8119        // Cached positions are served synchronously and match the async batched read.
8120        // Positions that fail validation are misses rather than errors.
8121        let executor = deterministic::Runner::default();
8122        executor.start(|context| async move {
8123            let cfg = test_cfg(NZU64!(4));
8124            let page_cache = cfg.page_cache.clone();
8125            let mut journal = Journal::init(context.child("j"), cfg.clone())
8126                .await
8127                .unwrap();
8128
8129            for i in 0..20u64 {
8130                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
8131            }
8132            journal = journal.sync().await.unwrap();
8133
8134            let positions: Vec<u64> = (0..20).collect();
8135            let reader;
8136            (journal, reader) = journal.snapshot().await.unwrap();
8137            let expected = reader.read_many(&positions).await.unwrap();
8138
8139            // Every synchronously served item must match the async read. Positions the
8140            // 3-page test cache cannot hold are misses, never wrong values.
8141            let served = reader.try_read_many_sync(&positions);
8142            assert_eq!(served.len(), positions.len());
8143            for (item, expected) in served.iter().zip(&expected) {
8144                if let Some(item) = item {
8145                    assert_eq!(item, expected);
8146                }
8147            }
8148
8149            // Consume tail-page history, then fill the cache with unrelated single-page reads.
8150            // The synchronous-hit checks must hold without favorable prior admission state.
8151            page_cache.clear();
8152            reader
8153                .read_many(&(16..20).collect::<Vec<_>>())
8154                .await
8155                .unwrap();
8156            page_cache.clear();
8157            for pos in [0, 4, 8] {
8158                reader.read(pos).await.unwrap();
8159            }
8160
8161            // Positions 18 and 19 span one cached page and the in-memory tail, so warming them
8162            // makes both available synchronously.
8163            let tail: Vec<u64> = (18..20).collect();
8164            reader.read_many(&tail).await.unwrap();
8165            let served = reader.try_read_many_sync(&tail);
8166            for (item, pos) in served.iter().zip(&tail) {
8167                assert_eq!(
8168                    item.as_ref().expect("warmed position is served"),
8169                    &expected[*pos as usize]
8170                );
8171            }
8172
8173            // A long-evicted position is a miss.
8174            assert!(reader.try_read_many_sync(&[0])[0].is_none());
8175
8176            // An out-of-range position is a miss, not an error, and does not poison the
8177            // valid position grouped before it.
8178            let served = reader.try_read_many_sync(&[19, 20]);
8179            assert!(served[0].is_some());
8180            assert!(served[1].is_none());
8181            drop(served);
8182            drop(reader);
8183
8184            // After a truncate the journal's end is not blob-aligned, so an out-of-range
8185            // position can share a blob with a valid one. Validation trims the batch
8186            // instead of poisoning the shared group.
8187            journal = {
8188                _ = journal.sync().await.unwrap();
8189                Journal::<_, Digest>::init_at_most(context.child("cap"), cfg.clone(), 18).await
8190            }
8191            .unwrap();
8192            let reader;
8193            (journal, reader) = journal.snapshot().await.unwrap();
8194            reader.read_many(&[17]).await.unwrap();
8195            let served = reader.try_read_many_sync(&[17, 18]);
8196            assert!(served[0].is_some());
8197            assert!(served[1].is_none());
8198            drop(served);
8199            drop(reader);
8200
8201            // A pruned position is trimmed from the prefix rather than reaching offset
8202            // derivation, and the valid single-page remainder is still served.
8203            (journal, _) = journal.prune(8).await.unwrap();
8204            let reader;
8205            (journal, reader) = journal.snapshot().await.unwrap();
8206            reader.read_many(&[8]).await.unwrap();
8207            let served = reader.try_read_many_sync(&[3, 8]);
8208            assert!(served[0].is_none());
8209            assert!(served[1].is_some());
8210            drop(served);
8211            drop(reader);
8212
8213            journal.destroy().await.unwrap();
8214        });
8215    }
8216
8217    #[test_traced]
8218    fn test_probe_then_read_many_matches_read_many() {
8219        // A probe completed by one batched read over its declined positions returns the same
8220        // items as read_many, cold and warm.
8221        let executor = deterministic::Runner::default();
8222        executor.start(|context| async move {
8223            let cfg = test_cfg(NZU64!(4));
8224            let mut journal = Journal::init(context.child("j"), cfg).await.unwrap();
8225
8226            for i in 0..20u64 {
8227                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
8228            }
8229            journal = journal.sync().await.unwrap();
8230
8231            let positions: Vec<u64> = (0..20).collect();
8232            let reader;
8233            (journal, reader) = journal.snapshot().await.unwrap();
8234            let expected: Vec<_> = (0..20).map(test_digest).collect();
8235            for _ in 0..2 {
8236                let mut served = reader.try_read_many_sync(&positions);
8237                let misses: Vec<u64> = positions
8238                    .iter()
8239                    .zip(&served)
8240                    .filter_map(|(&pos, item)| item.is_none().then_some(pos))
8241                    .collect();
8242                let mut fetched = reader.read_many(&misses).await.unwrap().into_iter();
8243                for item in served.iter_mut().filter(|item| item.is_none()) {
8244                    *item = fetched.next();
8245                }
8246                let completed: Vec<_> = served.into_iter().map(Option::unwrap).collect();
8247                assert_eq!(completed, expected);
8248            }
8249            assert_eq!(reader.read_many(&positions).await.unwrap(), expected);
8250            drop(reader);
8251
8252            journal.destroy().await.unwrap();
8253        });
8254    }
8255
8256    #[test_traced]
8257    fn test_fixed_journal_metrics() {
8258        let executor = deterministic::Runner::default();
8259        executor.start(|context| async move {
8260            let cfg = test_cfg(NZU64!(2));
8261            let mut journal =
8262                Journal::<_, Digest>::init(context.child("fixed_metrics"), cfg.clone())
8263                    .await
8264                    .unwrap();
8265
8266            let items: Vec<_> = (0..5).map(test_digest).collect();
8267            (journal, _) = journal.append_many(Many::Flat(&items)).await.unwrap();
8268            (journal, _) = journal.append(&test_digest(5)).await.unwrap();
8269            journal = journal.commit().await.unwrap();
8270            journal = journal.sync().await.unwrap();
8271            let handle;
8272            (journal, handle) = journal.start_sync().await.unwrap();
8273            handle.await.unwrap();
8274            let (journal, reader) = journal.snapshot().await.unwrap();
8275            reader.read(0).await.unwrap();
8276            let (journal, reader) = journal.snapshot().await.unwrap();
8277            reader.try_read_sync(0).unwrap();
8278            let (journal, reader) = journal.snapshot().await.unwrap();
8279            reader.read_many(&[1, 2, 4]).await.unwrap();
8280            let (journal, _) = journal.prune(2).await.unwrap();
8281
8282            let buffer = context.encode();
8283            for expected in [
8284                "fixed_metrics_size 6",
8285                "fixed_metrics_pruning_boundary 2",
8286                "fixed_metrics_retained 4",
8287                "fixed_metrics_tail_items 2",
8288                "fixed_metrics_append_calls_total 1",
8289                "fixed_metrics_append_many_calls_total 1",
8290                "fixed_metrics_read_calls_total 1",
8291                "fixed_metrics_read_many_calls_total 1",
8292                "fixed_metrics_items_read_total 5",
8293                "fixed_metrics_start_sync_calls_total 1",
8294                "fixed_metrics_commit_calls_total 1",
8295                "fixed_metrics_sync_calls_total 1",
8296                "fixed_metrics_append_duration_count 1",
8297                "fixed_metrics_append_many_duration_count 1",
8298                "fixed_metrics_read_duration_count 0",
8299                "fixed_metrics_read_many_duration_count 1",
8300                "fixed_metrics_commit_duration_count 1",
8301                "fixed_metrics_sync_duration_count 1",
8302                "fixed_metrics_cache_hits_total",
8303                "fixed_metrics_cache_misses_total",
8304                "fixed_metrics_blobs_tracked",
8305            ] {
8306                assert!(buffer.contains(expected), "{expected}\n{buffer}");
8307            }
8308
8309            journal.destroy().await.unwrap();
8310        });
8311    }
8312    /// A snapshot's bounds and contents are frozen across appends and rolls.
8313    #[test_traced]
8314    fn test_snapshot_frozen_across_roll() {
8315        let executor = deterministic::Runner::default();
8316        executor.start(|context| async move {
8317            let cfg = test_cfg(NZU64!(5));
8318            let mut journal = Journal::<_, Digest>::init(context.child("j"), cfg)
8319                .await
8320                .unwrap();
8321            for i in 0..7u64 {
8322                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
8323            }
8324
8325            let snapshot;
8326            (journal, snapshot) = journal.snapshot().await.unwrap();
8327            assert_eq!(snapshot.bounds(), 0..7);
8328
8329            // Appending past the blob boundary rolls the snapshot's tail blob into
8330            // history; the snapshot keeps reading it through its own handle.
8331            for i in 7..23u64 {
8332                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
8333            }
8334            assert_eq!(snapshot.bounds(), 0..7);
8335            for i in 0..7u64 {
8336                assert_eq!(snapshot.read(i).await.unwrap(), test_digest(i));
8337            }
8338            assert!(matches!(
8339                snapshot.read(7).await,
8340                Err(Error::ItemOutOfRange(7))
8341            ));
8342
8343            let fresh;
8344            (journal, fresh) = journal.snapshot().await.unwrap();
8345            assert_eq!(fresh.bounds(), 0..23);
8346            assert_eq!(fresh.read(22).await.unwrap(), test_digest(22));
8347
8348            drop(snapshot);
8349            drop(fresh);
8350            journal.destroy().await.unwrap();
8351        });
8352    }
8353
8354    /// A snapshot taken before a prune keeps reading the pruned range; later snapshots observe
8355    /// the new boundary.
8356    #[test_traced]
8357    fn test_prune_under_snapshot() {
8358        let executor = deterministic::Runner::default();
8359        executor.start(|context| async move {
8360            let cfg = test_cfg(NZU64!(5));
8361            let mut journal = Journal::<_, Digest>::init(context.child("j"), cfg)
8362                .await
8363                .unwrap();
8364            for i in 0..17u64 {
8365                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
8366            }
8367            journal = journal.sync().await.unwrap();
8368
8369            let snapshot;
8370            (journal, snapshot) = journal.snapshot().await.unwrap();
8371            let pruned;
8372            (journal, pruned) = journal.prune(12).await.unwrap();
8373            assert!(pruned);
8374
8375            // The straggler reads the pruned range through its own handles.
8376            assert_eq!(snapshot.bounds(), 0..17);
8377            for i in 0..17u64 {
8378                assert_eq!(snapshot.read(i).await.unwrap(), test_digest(i));
8379            }
8380
8381            let fresh;
8382            (journal, fresh) = journal.snapshot().await.unwrap();
8383            assert_eq!(fresh.bounds(), 10..17);
8384            assert!(matches!(fresh.read(3).await, Err(Error::ItemPruned(3))));
8385
8386            drop(snapshot);
8387            drop(fresh);
8388            journal.destroy().await.unwrap();
8389        });
8390    }
8391
8392    /// Every snapshot shipped to a concurrent task is fully readable while the writer keeps
8393    /// appending and rolling.
8394    #[test_traced]
8395    fn test_snapshots_readable_during_concurrent_appends() {
8396        let executor = deterministic::Runner::default();
8397        executor.start(|context| async move {
8398            let cfg = test_cfg(NZU64!(5));
8399            let mut journal = Journal::<_, Digest>::init(context.child("j"), cfg)
8400                .await
8401                .unwrap();
8402
8403            let (mut tx, mut rx) =
8404                futures::channel::mpsc::channel::<Reader<'static, Context, Digest>>(8);
8405            let validator = context.child("validator").spawn(|_| async move {
8406                let mut validated = 0usize;
8407                while let Some(snapshot) = rx.next().await {
8408                    let bounds = snapshot.bounds();
8409                    for i in bounds.clone() {
8410                        assert_eq!(snapshot.read(i).await.unwrap(), test_digest(i));
8411                    }
8412                    validated += (bounds.end - bounds.start) as usize;
8413                }
8414                validated
8415            });
8416
8417            for i in 0..40u64 {
8418                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
8419                if i % 7 == 0 {
8420                    let snapshot;
8421                    (journal, snapshot) = journal.snapshot().await.unwrap();
8422                    if tx.try_send(snapshot).is_err() {
8423                        break;
8424                    }
8425                }
8426            }
8427            drop(tx);
8428            assert!(validator.await.unwrap() > 0);
8429
8430            journal.destroy().await.unwrap();
8431        });
8432    }
8433
8434    /// A snapshot taken before rolls and a prune replays its full frozen range, streaming its
8435    /// then-tail blob through the snapshot's own handle.
8436    #[test_traced]
8437    fn test_replay_from_stale_snapshot() {
8438        let executor = deterministic::Runner::default();
8439        executor.start(|context| async move {
8440            let cfg = test_cfg(NZU64!(5));
8441            let mut journal = Journal::<_, Digest>::init(context.child("j"), cfg)
8442                .await
8443                .unwrap();
8444            for i in 0..7u64 {
8445                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
8446            }
8447
8448            // Positions 5..7 live in the snapshot's tail blob.
8449            let snapshot;
8450            (journal, snapshot) = journal.snapshot().await.unwrap();
8451            assert_eq!(snapshot.bounds(), 0..7);
8452
8453            // Roll the snapshot's tail into history, then prune both of its blobs away.
8454            for i in 7..23u64 {
8455                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
8456            }
8457            let pruned;
8458            (journal, pruned) = journal.prune(12).await.unwrap();
8459            assert!(pruned);
8460
8461            {
8462                let stream = replay_items(
8463                    &snapshot,
8464                    0..snapshot.bounds().end,
8465                    NZUsize!(1024),
8466                    ReadOptions::default(),
8467                )
8468                .await
8469                .unwrap();
8470                pin_mut!(stream);
8471                let mut expected = 0u64;
8472                while let Some(result) = stream.next().await {
8473                    let (pos, item) = result.unwrap();
8474                    assert_eq!(pos, expected);
8475                    assert_eq!(item, test_digest(pos));
8476                    expected += 1;
8477                }
8478                assert_eq!(expected, 7);
8479            }
8480
8481            drop(snapshot);
8482            journal.destroy().await.unwrap();
8483        });
8484    }
8485
8486    #[test_traced]
8487    fn test_read_many_sparse_sections_and_hit_accounting() {
8488        // Verify the batched read path is byte-identical to per-item reads across multiple
8489        // blobs, with a mid-blob pruning boundary, a sparse subset of positions, and
8490        // exact hit/miss accounting over a mixed cached/uncached batch.
8491        let executor = deterministic::Runner::default();
8492        executor.start(|context| async move {
8493            let mut cfg = test_cfg(NZU64!(8));
8494            // Keep the whole batch resident so hit accounting is stable. Otherwise, the batch may
8495            // evict a page that a later per-item probe still expects to hit.
8496            cfg.page_cache = CacheRef::new(PAGE_SIZE, NZUsize!(16));
8497            let mut journal = Journal::init(context.child("j"), cfg).await.unwrap();
8498
8499            for i in 0..50u64 {
8500                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
8501            }
8502            journal = journal.sync().await.unwrap();
8503            // Prune mid-blob so first_in_blob differs from the blob start.
8504            (journal, _) = journal.prune(11).await.unwrap();
8505
8506            let reader;
8507            (journal, reader) = journal.snapshot().await.unwrap();
8508
8509            // Sparse subset spanning multiple blobs, including the pruning boundary.
8510            // `try_read_sync` probes do not populate the cache, so the cached subset is
8511            // whatever the append path left resident; derive the expected hit count from
8512            // probes so the batch read's hit/miss accounting is asserted exactly.
8513            let positions: Vec<u64> = vec![11, 12, 19, 20, 23, 31, 40, 47, 49];
8514            let expected_hits = positions
8515                .iter()
8516                .filter(|&&pos| reader.try_read_sync(pos).is_some())
8517                .count() as u64;
8518            let before = context.encode();
8519            let batch = reader.read_many(&positions).await.unwrap();
8520            let after = context.encode();
8521            assert_eq!(batch.len(), positions.len());
8522            assert_eq!(
8523                counter(&after, "cache_hits") - counter(&before, "cache_hits"),
8524                expected_hits,
8525                "batch read hit count should match the cached subset"
8526            );
8527            assert_eq!(
8528                counter(&after, "cache_misses") - counter(&before, "cache_misses"),
8529                positions.len() as u64 - expected_hits,
8530                "batch read miss count should cover the rest"
8531            );
8532            for (i, &pos) in positions.iter().enumerate() {
8533                let single = reader.read(pos).await.unwrap();
8534                assert_eq!(batch[i], single);
8535                assert_eq!(batch[i], test_digest(pos));
8536            }
8537
8538            // Full contiguous range over retained items.
8539            let all: Vec<u64> = (11..50).collect();
8540            let batch = reader.read_many(&all).await.unwrap();
8541            for (i, &pos) in all.iter().enumerate() {
8542                assert_eq!(batch[i], reader.read(pos).await.unwrap());
8543            }
8544            drop(reader);
8545
8546            journal.destroy().await.unwrap();
8547        });
8548    }
8549
8550    #[test_traced]
8551    fn test_read_many_cold_blob_groups() {
8552        // Read a batch whose positions are all cache misses spread over four blobs, so the whole
8553        // batch is served by per-blob group reads into disjoint slices of the shared output
8554        // buffer. Each digest encodes its position, so a group read into the wrong slice fails
8555        // the value assertions.
8556        let executor = deterministic::Runner::default();
8557        executor.start(|context| async move {
8558            let mut cfg = test_cfg(NZU64!(8));
8559            cfg.page_cache = CacheRef::new(PAGE_SIZE, NZUsize!(32));
8560            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
8561                .await
8562                .unwrap();
8563            for i in 0..40u64 {
8564                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
8565            }
8566            let journal = journal.sync().await.unwrap();
8567            drop(journal);
8568
8569            // Reopen with a fresh page cache so every full page is cold. The positions avoid
8570            // each blob's trailing bytes, which sealed blobs keep in memory and always serve
8571            // synchronously.
8572            cfg.page_cache = CacheRef::new(PAGE_SIZE, NZUsize!(32));
8573            let mut journal = Journal::<_, Digest>::init(context.child("second"), cfg)
8574                .await
8575                .unwrap();
8576            let reader;
8577            (journal, reader) = journal.snapshot().await.unwrap();
8578            let positions = [1, 5, 10, 14, 17, 22, 25, 30];
8579            for pos in positions {
8580                assert!(reader.try_read_sync(pos).is_none(), "position {pos}");
8581            }
8582
8583            // Every item requires a blob read, split into four groups of two.
8584            let before = context.encode();
8585            let batch = reader.read_many(&positions).await.unwrap();
8586            let after = context.encode();
8587            assert_eq!(
8588                counter(&after, "second_cache_misses") - counter(&before, "second_cache_misses"),
8589                positions.len() as u64
8590            );
8591            assert_eq!(
8592                counter(&after, "second_cache_hits"),
8593                counter(&before, "second_cache_hits")
8594            );
8595            for (i, &pos) in positions.iter().enumerate() {
8596                assert_eq!(batch[i], test_digest(pos), "position {pos}");
8597            }
8598
8599            // The first pass cached every page it faulted, so a second pass is all hits and must
8600            // return the same items.
8601            let before = context.encode();
8602            let batch = reader.read_many(&positions).await.unwrap();
8603            let after = context.encode();
8604            assert_eq!(
8605                counter(&after, "second_cache_hits") - counter(&before, "second_cache_hits"),
8606                positions.len() as u64
8607            );
8608            for (i, &pos) in positions.iter().enumerate() {
8609                assert_eq!(batch[i], test_digest(pos), "position {pos}");
8610            }
8611            drop(reader);
8612
8613            journal.destroy().await.unwrap();
8614        });
8615    }
8616
8617    #[test_traced]
8618    fn test_fixed_journal_read_miss_timed() {
8619        // Reads served from storage record a read_duration sample; cache hits do not.
8620        let executor = deterministic::Runner::default();
8621        executor.start(|context| async move {
8622            let mut journal =
8623                Journal::<_, Digest>::init(context.child("miss"), test_cfg(NZU64!(2)))
8624                    .await
8625                    .unwrap();
8626            for i in 0..20 {
8627                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
8628            }
8629            journal = journal.sync().await.unwrap();
8630
8631            // The page cache cannot hold every page, so some position must be cold.
8632            let reader;
8633            (journal, reader) = journal.snapshot().await.unwrap();
8634            let pos = (0..20)
8635                .find(|&pos| reader.try_read_sync(pos).is_none())
8636                .expect("some position should be cold");
8637            assert_eq!(reader.read(pos).await.unwrap(), test_digest(pos));
8638            drop(reader);
8639
8640            let buffer = context.encode();
8641            assert!(buffer.contains("miss_read_duration_count 1"), "{buffer}");
8642
8643            journal.destroy().await.unwrap();
8644        });
8645    }
8646
8647    /// A failed watermark advance that nobody observed surfaces at destroy before any data blob
8648    /// is removed, so the journal reopens with every durable item.
8649    #[test_traced]
8650    fn test_fixed_destroy_observes_failed_watermark_sync_before_removal() {
8651        let executor = deterministic::Runner::default();
8652        executor.start(|context| async move {
8653            let pending = PendingSyncs::default();
8654            let cfg = test_cfg(NZU64!(100));
8655            let data_partition = blob_partition(&cfg);
8656            let mut journal = Box::new(
8657                Inner::<_, u64>::init(
8658                    DelayedSyncContext {
8659                        inner: context.child("journal"),
8660                        pending: pending.clone(),
8661                    },
8662                    cfg.clone(),
8663                )
8664                .await
8665                .unwrap(),
8666            );
8667            (journal, _) = journal.append_many(Many::Flat(&[1, 2, 3])).await.unwrap();
8668            journal = drive_pending_syncs(&pending, journal.sync()).await.unwrap();
8669
8670            // Make item 4 durable without advancing the watermark, then start the advance to 4,
8671            // drop its handle, and fail its checkpoint sync, the only sync that advance starts.
8672            (journal, _) = journal.append(&4).await.unwrap();
8673            let (journal, handle) = journal.start_sync().await.unwrap();
8674            release_pending_syncs(&pending);
8675            handle.await.unwrap();
8676            let (journal, handle) = journal.start_sync().await.unwrap();
8677            drop(handle);
8678            assert_eq!(pending.lock().len(), 1);
8679            fail_pending_syncs(&pending);
8680
8681            assert!(journal.destroy().await.is_err());
8682            assert_eq!(
8683                scan_partition(&context, &data_partition).await,
8684                vec![0u64.to_be_bytes().to_vec()]
8685            );
8686            let journal = Inner::<_, u64>::init(context.child("second"), cfg)
8687                .await
8688                .unwrap();
8689            assert_eq!(journal.size(), 4);
8690            journal.destroy().await.unwrap();
8691        });
8692    }
8693
8694    /// A bounded open whose cap lies below the watermark, both inside a torn page, repairs the
8695    /// page at the end of its durable prefix. The acknowledged items survive, the persisted
8696    /// watermark is untouched, and a later open with a larger cap or none recovers all of them.
8697    #[test_traced]
8698    fn test_fixed_bounded_recovery_below_watermark_keeps_acknowledged_items() {
8699        let executor = deterministic::Runner::default();
8700        executor.start(|context| async move {
8701            const PAGE: u64 = 100;
8702            let cfg = Config {
8703                partition: "torn-rewrite-below-watermark".into(),
8704                items_per_blob: NZU64!(64),
8705                page_cache: CacheRef::new(NZU16!(PAGE as u16), NZUsize!(3)),
8706                write_buffer: NZUsize!(2048),
8707                replay_buffer: NZUsize!(2048),
8708            };
8709            let mut journal = Journal::<_, Digest>::init(context.child("first"), cfg.clone())
8710                .await
8711                .unwrap();
8712            for i in 0..33u64 {
8713                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
8714            }
8715            journal = journal.sync().await.unwrap();
8716            for i in 33..38u64 {
8717                (journal, _) = journal.append(&test_digest(i)).await.unwrap();
8718            }
8719            journal.commit().await.unwrap();
8720            tear_page_rewrite(
8721                &context,
8722                &blob_partition(&cfg),
8723                &0u64.to_be_bytes(),
8724                10,
8725                60,
8726                PAGE,
8727            )
8728            .await;
8729
8730            // Cap 32 and watermark 33 both end inside torn page 10.
8731            let checkpoint = Checkpoint::open(context.child("meta"), &cfg.partition)
8732                .await
8733                .unwrap();
8734            let recovery = Recovery::<_, Digest>::open(
8735                context.child("capped"),
8736                cfg.clone(),
8737                checkpoint,
8738                Some(32),
8739            )
8740            .await
8741            .unwrap();
8742            assert_eq!(recovery.bounds, 0..32);
8743            assert_eq!(recovery.item(31).await.unwrap(), test_digest(31));
8744            drop(recovery);
8745            assert_eq!(
8746                Journal::<_, Digest>::persisted_watermark(
8747                    context.child("watermark"),
8748                    &cfg.partition
8749                )
8750                .await
8751                .unwrap(),
8752                Some(33)
8753            );
8754
8755            // A larger cap and no cap both recover the 33 acknowledged items.
8756            let checkpoint = Checkpoint::open(context.child("wider_meta"), &cfg.partition)
8757                .await
8758                .unwrap();
8759            let recovery = Recovery::<_, Digest>::open(
8760                context.child("wider"),
8761                cfg.clone(),
8762                checkpoint,
8763                Some(36),
8764            )
8765            .await
8766            .unwrap();
8767            assert_eq!(recovery.bounds, 0..33);
8768            assert_eq!(recovery.item(32).await.unwrap(), test_digest(32));
8769            drop(recovery);
8770            let journal = Journal::<_, Digest>::init(context.child("unbounded"), cfg)
8771                .await
8772                .unwrap();
8773            assert_eq!(journal.bounds(), 0..33);
8774            for i in 0..33u64 {
8775                assert_eq!(journal.read(i).await.unwrap(), test_digest(i));
8776            }
8777            journal.destroy().await.unwrap();
8778        });
8779    }
8780}