Skip to main content

commonware_storage/journal/contiguous/
fixed.rs

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