Skip to main content

commonware_storage/journal/contiguous/
variable.rs

1//! Position-based journal for variable-length items.
2//!
3//! The data blobs are the source of truth. The offsets journal provides indexed access and records
4//! the preferred recovery point for replaying data to rebuild offset entries.
5
6use super::{
7    blob_first_position,
8    blobs::{Blob, Blobs, Partition, Replay as BlobReplay, Writable},
9    fixed,
10    metrics::Metrics,
11    position_to_blob, Contiguous, Many, Mutable,
12};
13#[commonware_macros::stability(ALPHA)]
14use crate::{journal::authenticated, merkle};
15use crate::{
16    journal::{
17        frame::{
18            decode_item, decode_length_prefix, encode_frame_into, find_frame, read_frame_at,
19            FrameInfo,
20        },
21        Error,
22    },
23    Context,
24};
25use commonware_codec::{varint::MAX_U32_VARINT_SIZE, Codec, CodecShared};
26use commonware_macros::boxed;
27use commonware_runtime::{
28    buffer::paged::{CacheRef, Replay, Writer},
29    Blob as RBlob, Buf, IoBuf,
30};
31use commonware_utils::NZUsize;
32use futures::{future::try_join_all, Stream};
33use std::{
34    collections::BTreeMap,
35    io::Cursor,
36    marker::PhantomData,
37    num::{NonZeroU64, NonZeroUsize},
38    ops::Range,
39    sync::Arc,
40};
41#[commonware_macros::stability(ALPHA)]
42use tracing::debug;
43use tracing::warn;
44
45/// Items encoded for a deferred append, created by [`Journal::prepare_append`] and consumed by
46/// [`Journal::append_prepared`].
47pub struct PreparedAppend<V> {
48    encoded: Vec<u8>,
49    item_starts: Vec<usize>,
50    compressed: bool,
51    _marker: PhantomData<V>,
52}
53
54/// Buffer size used when replaying data blobs during recovery.
55const REPLAY_BUFFER_SIZE: NonZeroUsize = NZUsize!(1024);
56
57/// Suffix appended to the base partition name for the data blobs.
58const DATA_SUFFIX: &str = "_data";
59
60/// Suffix appended to the base partition name for the offsets journal.
61const OFFSETS_SUFFIX: &str = "_offsets";
62
63/// Decode one varint-framed item from the head of `bytes`, whose encoded length must be exactly
64/// `frame_len` (the gap to the next frame's offset). Returns `None` on any mismatch or decode
65/// failure. The async read path reports such errors.
66fn decode_frame_from_span<V: CodecShared>(
67    bytes: &[u8],
68    frame_len: usize,
69    codec_config: &V::Cfg,
70    compressed: bool,
71) -> Option<V> {
72    let mut cursor = Cursor::new(bytes);
73    let (size, varint_len) = decode_length_prefix(&mut cursor).ok()?;
74    let actual_len = size.checked_add(varint_len)?;
75    if actual_len != frame_len || frame_len > bytes.len() {
76        return None;
77    }
78    decode_item::<V>(&bytes[varint_len..frame_len], codec_config, compressed).ok()
79}
80
81/// One step of walking varint frames over a blob's bytes during recovery.
82enum Frame {
83    /// A complete, decodable item began at byte `offset`.
84    Item { offset: u64 },
85    /// No more complete frames. `valid_size` is one past the last complete frame; `torn` reports
86    /// whether undecodable trailing bytes follow it.
87    End { valid_size: u64, torn: bool },
88}
89
90/// Scans varint frames over a blob's bytes during recovery.
91struct FrameScanner<'a, B: RBlob, V: Codec> {
92    replay: Replay<B>,
93    /// Byte offset of the next frame.
94    offset: u64,
95    codec_config: &'a V::Cfg,
96    compressed: bool,
97}
98
99impl<'a, B: RBlob, V: CodecShared> FrameScanner<'a, B, V> {
100    const fn new(replay: Replay<B>, codec_config: &'a V::Cfg, compressed: bool) -> Self {
101        Self {
102            replay,
103            offset: 0,
104            codec_config,
105            compressed,
106        }
107    }
108
109    /// Advance to the next frame.
110    ///
111    /// Incomplete trailing bytes surface as [Frame::End] with `torn` set; a complete frame whose
112    /// payload fails to decode is an error.
113    async fn next(&mut self) -> Result<Frame, Error> {
114        match self.replay.ensure(MAX_U32_VARINT_SIZE).await {
115            Ok(true) => {}
116            Ok(false) if self.replay.remaining() == 0 => {
117                return Ok(Frame::End {
118                    valid_size: self.offset,
119                    torn: false,
120                });
121            }
122            // Fewer bytes than a max-size varint remain; they may still hold a whole frame.
123            Ok(false) => {}
124            Err(err) => return Err(err.into()),
125        }
126
127        let before_remaining = self.replay.remaining();
128        let (item_size, varint_len) = match decode_length_prefix(&mut self.replay) {
129            Ok(result) => result,
130            Err(err) => {
131                // An incomplete varint at the end of the blob is trailing junk; anything else
132                // is a real decode failure.
133                if self.replay.is_exhausted() || before_remaining < MAX_U32_VARINT_SIZE {
134                    return Ok(Frame::End {
135                        valid_size: self.offset,
136                        torn: true,
137                    });
138                }
139                return Err(err);
140            }
141        };
142
143        match self.replay.ensure(item_size).await {
144            Ok(true) => {}
145            Ok(false) => {
146                return Ok(Frame::End {
147                    valid_size: self.offset,
148                    torn: true,
149                });
150            }
151            Err(err) => return Err(err.into()),
152        }
153
154        let item_offset = self.offset;
155        let next_offset = item_offset
156            .checked_add(varint_len as u64)
157            .and_then(|offset| offset.checked_add(item_size as u64))
158            .ok_or(Error::OffsetOverflow)?;
159        decode_item::<V>(
160            (&mut self.replay).take(item_size),
161            self.codec_config,
162            self.compressed,
163        )?;
164        self.offset = next_offset;
165        Ok(Frame::Item {
166            offset: item_offset,
167        })
168    }
169}
170
171/// Result of scanning all frames in a blob.
172struct BlobScan {
173    /// Number of complete items.
174    items: u64,
175    /// Byte offset one past the last complete item.
176    valid_size: u64,
177    /// Whether undecodable trailing bytes follow `valid_size`.
178    torn: bool,
179}
180
181/// Replay state for one data blob in a variable-size journal.
182///
183/// Unlike fixed replay, each yielded item must first decode a varint frame length. The byte
184/// `budget` caps how much frame data this state emits in one stream batch.
185struct ReplayState<'a, B: RBlob, V: Codec> {
186    /// Blob index, used in corruption messages.
187    blob: u64,
188    /// Sequential logical bytes for this blob.
189    replay: BlobReplay<'a, B>,
190    /// Target maximum number of encoded bytes decoded per batch.
191    budget: u64,
192    /// Next position to yield.
193    pos: u64,
194    /// Exclusive end position within this blob.
195    end_pos: u64,
196    /// Byte offset of the next frame in this blob.
197    offset: u64,
198    /// Codec configuration for decoded items.
199    codec_config: V::Cfg,
200    /// Whether frame payloads are compressed.
201    compressed: bool,
202    _marker: PhantomData<V>,
203}
204
205impl<B: RBlob, V: CodecShared> super::ReplayBatchState for ReplayState<'_, B, V> {
206    type Item = V;
207
208    /// Decode the next batch of varint-framed items from this blob.
209    async fn next_batch(mut self) -> Option<(Vec<Result<(u64, V), Error>>, Self)> {
210        if self.pos == self.end_pos {
211            return None;
212        }
213
214        let mut batch = Vec::new();
215        let mut consumed = 0u64;
216        loop {
217            if self.pos == self.end_pos {
218                return (!batch.is_empty()).then_some((batch, self));
219            }
220
221            // A short read before a frame header is corruption for replay: bounds and offsets say
222            // this item exists, so EOF here means the data blob is shorter than expected.
223            match self.replay.ensure(MAX_U32_VARINT_SIZE).await {
224                Ok(true) => {}
225                Ok(false) if self.replay.remaining() == 0 => {
226                    batch.push(Err(Error::Corruption(format!(
227                        "data blob {} ended before position {}",
228                        self.blob, self.pos
229                    ))));
230                    self.pos = self.end_pos;
231                    return Some((batch, self));
232                }
233                Ok(false) => {}
234                Err(err) => {
235                    batch.push(Err(err));
236                    self.pos = self.end_pos;
237                    return Some((batch, self));
238                }
239            }
240
241            let before_remaining = self.replay.remaining();
242            let (item_size, varint_len) = match decode_length_prefix(&mut self.replay) {
243                Ok(result) => result,
244                Err(err) => {
245                    if self.replay.is_exhausted() || before_remaining < MAX_U32_VARINT_SIZE {
246                        batch.push(Err(Error::Corruption(format!(
247                            "incomplete frame header in data blob {} at offset {}",
248                            self.blob, self.offset
249                        ))));
250                    } else {
251                        batch.push(Err(err));
252                    }
253                    self.pos = self.end_pos;
254                    return Some((batch, self));
255                }
256            };
257
258            match self.replay.ensure(item_size).await {
259                Ok(true) => {}
260                Ok(false) => {
261                    batch.push(Err(Error::Corruption(format!(
262                        "incomplete frame in data blob {} at offset {}",
263                        self.blob, self.offset
264                    ))));
265                    self.pos = self.end_pos;
266                    return Some((batch, self));
267                }
268                Err(err) => {
269                    batch.push(Err(err));
270                    self.pos = self.end_pos;
271                    return Some((batch, self));
272                }
273            }
274
275            let next_offset = self
276                .offset
277                .checked_add(varint_len as u64)
278                .and_then(|offset| offset.checked_add(item_size as u64));
279            let Some(next_offset) = next_offset else {
280                batch.push(Err(Error::OffsetOverflow));
281                self.pos = self.end_pos;
282                return Some((batch, self));
283            };
284            let item_len = next_offset - self.offset;
285
286            // `take(item_size)` advances past exactly the payload bytes after the header was
287            // consumed by `decode_length_prefix`.
288            match decode_item::<V>(
289                (&mut self.replay).take(item_size),
290                &self.codec_config,
291                self.compressed,
292            ) {
293                Ok(item) => {
294                    let pos = self.pos;
295                    let Some(next_pos) = self.pos.checked_add(1) else {
296                        batch.push(Err(Error::OffsetOverflow));
297                        self.pos = self.end_pos;
298                        return Some((batch, self));
299                    };
300                    self.pos = next_pos;
301                    self.offset = next_offset;
302                    consumed = match consumed.checked_add(item_len) {
303                        Some(consumed) => consumed,
304                        None => {
305                            batch.push(Err(Error::OffsetOverflow));
306                            self.pos = self.end_pos;
307                            return Some((batch, self));
308                        }
309                    };
310                    batch.push(Ok((pos, item)));
311                }
312                Err(err) => {
313                    batch.push(Err(err));
314                    self.pos = self.end_pos;
315                    return Some((batch, self));
316                }
317            }
318
319            // Yield once the replay byte budget is reached. If fewer than MAX_U32_VARINT_SIZE
320            // bytes remain, yield as well so the next poll can refill before decoding a header.
321            if consumed >= self.budget {
322                return Some((batch, self));
323            }
324            if self.replay.remaining() < MAX_U32_VARINT_SIZE {
325                return Some((batch, self));
326            }
327        }
328    }
329}
330
331/// Configuration for a [Journal].
332#[derive(Clone)]
333pub struct Config<C> {
334    /// Base partition name. Sub-partitions will be created by appending DATA_SUFFIX and OFFSETS_SUFFIX.
335    pub partition: String,
336
337    /// The number of items to store in each blob.
338    ///
339    /// Once set, this value cannot be changed across restarts.
340    /// All non-final blobs are logically full.
341    pub items_per_section: NonZeroU64,
342
343    /// Optional compression level for stored items.
344    pub compression: Option<u8>,
345
346    /// [Codec] configuration for encoding/decoding items.
347    pub codec_config: C,
348
349    /// Page cache for buffering reads from the underlying storage.
350    pub page_cache: CacheRef,
351
352    /// Write buffer size for each blob.
353    pub write_buffer: NonZeroUsize,
354}
355
356impl<C> Config<C> {
357    /// Returns the partition name for the data blobs.
358    fn data_partition(&self) -> String {
359        format!("{}{}", self.partition, DATA_SUFFIX)
360    }
361
362    /// Returns the partition name for the offsets journal.
363    fn offsets_partition(&self) -> String {
364        format!("{}{}", self.partition, OFFSETS_SUFFIX)
365    }
366}
367
368/// A contiguous journal with variable-size entries.
369///
370/// This journal manages blob assignment automatically, allowing callers to append items
371/// sequentially without manually tracking blob indexes.
372///
373/// # Repair
374///
375/// Like
376/// [sqlite](https://github.com/sqlite/sqlite/blob/8658a8df59f00ec8fcfea336a2a6a4b5ef79d2ee/src/wal.c#L1504-L1505)
377/// and
378/// [rocksdb](https://github.com/facebook/rocksdb/blob/0c533e61bc6d89fdf1295e8e0bcee4edb3aef401/include/rocksdb/options.h#L441-L445),
379/// the first invalid data read will be considered the new end of the journal (and the underlying
380/// blob will be truncated to the last valid item). Repair is performed during init.
381/// Incomplete trailing frames are repaired as torn writes; complete frames whose payloads fail to
382/// decode are treated as corruption.
383///
384/// # Invariants
385///
386/// ## 1. Data Blobs are the Source of Truth
387///
388/// The data blobs are always the source of truth. The offsets journal is an index that may
389/// temporarily diverge during crashes. Divergences are automatically aligned during init():
390/// * If offsets are behind data after the recovery watermark: rebuild missing offsets by replaying
391///   data from the recovery anchor.
392/// * If offsets are ahead of the retained data prefix but the data still reaches the recovery
393///   watermark: rewind offsets to match the data-backed size. Retained data ending before the
394///   watermark is corruption because acknowledged data is missing.
395/// * If offsets.bounds().start < the oldest data blob's start: prune offsets to match (this can
396///   happen if we crash after pruning the data blobs but before pruning the offsets journal).
397///
398/// Offsets may start after the data's blob-aligned start when both are in the same blob, as in a
399/// mid-blob `init_at_size`. Offsets starting in a later blob imply corruption because we
400/// always prune the data blobs before the offsets journal.
401///
402/// ## 2. Offsets Recovery Watermark
403///
404/// The offsets journal's recovery watermark records a durable lower bound on the journal size and
405/// a preferred point for replaying data to rebuild offset entries after a crash. Fixed-journal
406/// recovery rejects watermarks beyond the recovered offsets size as corruption. A watermark below
407/// the recovered offsets start is stale after a prune, so init falls back to the offsets start. If
408/// retained data exists but ends before the watermark, init returns corruption because acknowledged
409/// data is missing. If no retained data exists, init reconciles both sides to an empty journal.
410/// Replay after a valid anchor stops at the first short data blob and truncates newer blobs so the
411/// recovered journal remains a contiguous prefix.
412pub struct Journal<E: Context, V: Codec> {
413    /// The data blobs: sealed history plus the writable tail.
414    blobs: Writable<E>,
415
416    /// Index mapping positions to byte offsets within their data blob. Its checkpoint is also
417    /// this journal's durable recovery record.
418    offsets: fixed::Journal<E, u64>,
419
420    /// The readable positions; `bounds.end` is the next append position.
421    bounds: Range<u64>,
422
423    /// Earliest data blob modified since the last `commit()` or `sync()`.
424    dirty_from_blob: Option<u64>,
425
426    /// Test-only: park [Self::prune] after the data-blob removal, before the offsets prune,
427    /// so tests can drop the pending future at that exact point.
428    #[cfg(test)]
429    halt_before_offsets_prune: bool,
430
431    /// The number of items per blob.
432    ///
433    /// # Invariant
434    ///
435    /// This value is immutable after initialization and must remain consistent
436    /// across restarts. Changing this value will result in data loss or corruption.
437    items_per_blob: NonZeroU64,
438
439    /// Optional compression level when encoding items.
440    compression: Option<u8>,
441
442    /// Codec configuration for decoding items.
443    codec_config: V::Cfg,
444
445    /// Journal and Reader metrics.
446    metrics: Arc<Metrics<E>>,
447}
448
449/// A reader over a variable journal.
450pub struct Reader<'a, E: Context, V: Codec> {
451    /// The journal's data blobs.
452    data: Blobs<'a, E::Blob>,
453
454    /// The readable position range `[start, end)`.
455    bounds: Range<u64>,
456
457    /// Maps positions to byte offsets within the data blobs.
458    offsets: fixed::Reader<'a, E, u64>,
459
460    /// The number of items in each blob.
461    items_per_blob: NonZeroU64,
462
463    /// [Codec] configuration for decoding items.
464    codec_config: V::Cfg,
465
466    /// Whether items are zstd-compressed.
467    compressed: bool,
468
469    /// Journal and Reader metrics.
470    metrics: Arc<Metrics<E>>,
471}
472
473impl<'a, E: Context, V: CodecShared> Reader<'a, E, V> {
474    /// Validate a position to be read: must lie within `bounds`.
475    const fn validate_readable(&self, position: u64) -> Result<(), Error> {
476        if position >= self.bounds.end {
477            return Err(Error::ItemOutOfRange(position));
478        }
479        if position < self.bounds.start {
480            return Err(Error::ItemPruned(position));
481        }
482        Ok(())
483    }
484
485    /// Read the varint-framed item at byte `offset` via `blob`.
486    async fn read_at_offset(&self, blob: &Blob<'_, E::Blob>, offset: u64) -> Result<V, Error> {
487        read_frame_at(blob, offset, &self.codec_config, self.compressed)
488            .await
489            .map(|(_, _, item)| item)
490    }
491
492    /// Read consecutive items in one blob. `offsets` must be strictly increasing byte offsets of
493    /// byte-adjacent frames.
494    ///
495    /// Returns [Error::OffsetDataMismatch] if the on-disk varint at any offset reports a size
496    /// inconsistent with the gap to the next offset, or [Error::Corruption] if the offsets are not
497    /// strictly increasing.
498    async fn read_consecutive(
499        &self,
500        blob_handle: &Blob<'_, E::Blob>,
501        blob: u64,
502        offsets: &[u64],
503    ) -> Result<Vec<V>, Error> {
504        // Trivial spans take the single-item path; there is nothing to batch.
505        if offsets.len() <= 1 {
506            let mut items = Vec::with_capacity(offsets.len());
507            for &offset in offsets {
508                items.push(self.read_at_offset(blob_handle, offset).await?);
509            }
510            return Ok(items);
511        }
512
513        for window in offsets.windows(2) {
514            if window[1] <= window[0] {
515                return Err(Error::Corruption(format!(
516                    "non-increasing offsets in blob {blob}: {} >= {}",
517                    window[0], window[1]
518                )));
519            }
520        }
521
522        // Read the byte span covering every item but the last in one operation; the last item's
523        // length is unknown, so it goes through the single-item path.
524        let start = offsets[0];
525        let end = offsets[offsets.len() - 1];
526        let range_len = usize::try_from(end - start).map_err(|_| Error::OffsetOverflow)?;
527        let bytes = blob_handle.read_at(start, range_len).await?.coalesce();
528        let bytes = bytes.as_ref();
529
530        let mut items = Vec::with_capacity(offsets.len());
531        let mut local_offset = 0usize;
532        for window in offsets.windows(2) {
533            let offset = window[0];
534            let next_offset = window[1];
535            let item_len =
536                usize::try_from(next_offset - offset).map_err(|_| Error::OffsetOverflow)?;
537
538            let mut cursor = Cursor::new(&bytes[local_offset..]);
539            let (size, varint_len) = decode_length_prefix(&mut cursor)?;
540            let actual_len = size.checked_add(varint_len).ok_or(Error::OffsetOverflow)?;
541            if actual_len != item_len {
542                return Err(Error::OffsetDataMismatch {
543                    section: blob,
544                    offset,
545                    expected_len: item_len,
546                    actual_len,
547                });
548            }
549
550            // Validation above guarantees strictly increasing offsets, so `data_end` never
551            // exceeds `range_len` and these additions stay in bounds.
552            let data_start = local_offset
553                .checked_add(varint_len)
554                .ok_or(Error::OffsetOverflow)?;
555            let data_end = local_offset
556                .checked_add(item_len)
557                .ok_or(Error::OffsetOverflow)?;
558            items.push(decode_item::<V>(
559                &bytes[data_start..data_end],
560                &self.codec_config,
561                self.compressed,
562            )?);
563
564            local_offset = data_end;
565        }
566
567        items.push(self.read_at_offset(blob_handle, end).await?);
568        Ok(items)
569    }
570
571    /// Read the varint-framed item for `position` at byte `offset` from cached bytes, returning
572    /// `None` on any miss.
573    fn try_read_frame_sync(&self, position: u64, offset: u64, buf: &mut Vec<u8>) -> Option<V> {
574        let blob = self
575            .data
576            .get(position_to_blob(position, self.items_per_blob.get()))?;
577        let remaining = blob.size().checked_sub(offset)?;
578        let header_len = usize::try_from(remaining.min(MAX_U32_VARINT_SIZE as u64)).ok()?;
579        if header_len == 0 {
580            return None;
581        }
582
583        // Read the varint header to determine item size.
584        let mut header = [0u8; MAX_U32_VARINT_SIZE];
585        if !blob.try_read_sync_into(&mut header[..header_len], offset) {
586            return None;
587        }
588        let mut cursor = Cursor::new(&header[..header_len]);
589        let (_, item_info) = find_frame(&mut cursor, offset).ok()?;
590
591        let (varint_len, data_len) = match item_info {
592            FrameInfo::Complete {
593                varint_len,
594                data_len,
595            } => (varint_len, data_len),
596            FrameInfo::Incomplete {
597                varint_len,
598                total_len,
599                ..
600            } => (varint_len, total_len),
601        };
602        let item_len = varint_len.checked_add(data_len)?;
603        if item_len > usize::try_from(remaining).ok()? {
604            return None;
605        }
606
607        // If the full item fits in the header read, decode directly.
608        if item_len <= header_len {
609            return decode_item::<V>(
610                &header[varint_len..varint_len + data_len],
611                &self.codec_config,
612                self.compressed,
613            )
614            .ok();
615        }
616
617        // Otherwise try reading the full item from cache.
618        buf.resize(item_len, 0);
619        if !blob.try_read_sync_into(buf, offset) {
620            return None;
621        }
622        decode_item::<V>(
623            &buf[varint_len..varint_len + data_len],
624            &self.codec_config,
625            self.compressed,
626        )
627        .ok()
628    }
629
630    /// Build one replay state for each data blob touched by `[start_pos, bounds.end)`.
631    async fn replay_states(
632        &self,
633        start_pos: u64,
634        buffer: NonZeroUsize,
635    ) -> Result<Vec<ReplayState<'a, E::Blob, V>>, Error> {
636        let bounds = self.bounds();
637        if start_pos > bounds.end {
638            return Err(Error::ItemOutOfRange(start_pos));
639        }
640        if start_pos < bounds.start {
641            return Err(Error::ItemPruned(start_pos));
642        }
643
644        let mut states = Vec::new();
645        if start_pos < bounds.end {
646            // The first blob may start at a nonzero data offset; subsequent blob states always
647            // start at byte offset 0.
648            let items_per_blob = self.items_per_blob.get();
649            let start_blob = position_to_blob(start_pos, items_per_blob);
650            let end_blob = position_to_blob(bounds.end - 1, items_per_blob);
651            let start_offset = self.offsets.read(start_pos).await?;
652
653            for blob in start_blob..=end_blob {
654                let blob_handle = self
655                    .data
656                    .get(blob)
657                    .expect("positions in bounds map to a retained blob");
658                let offset = if blob == start_blob { start_offset } else { 0 };
659
660                let first_pos = if blob == start_blob {
661                    start_pos
662                } else {
663                    blob_first_position(blob, items_per_blob)?
664                };
665                let end_pos = super::blob_end_position(blob, items_per_blob, bounds.end);
666
667                // Store codec settings in the state because the stream owns states across await
668                // points and cannot borrow `self`.
669                states.push(ReplayState::<E::Blob, V> {
670                    blob,
671                    replay: blob_handle.replay_from(offset, buffer)?,
672                    budget: buffer.get() as u64,
673                    pos: first_pos,
674                    end_pos,
675                    offset,
676                    codec_config: self.codec_config.clone(),
677                    compressed: self.compressed,
678                    _marker: PhantomData,
679                });
680            }
681        }
682
683        Ok(states)
684    }
685
686    /// Validate a batched-read request: non-empty `positions` must be strictly increasing and
687    /// fall within `bounds`.
688    fn validate_read_many(&self, positions: &[u64]) -> Result<(), Error> {
689        if positions[0] < self.bounds.start {
690            return Err(Error::ItemPruned(positions[0]));
691        }
692        let last_position = *positions.last().expect("positions is not empty");
693        if last_position >= self.bounds.end {
694            return Err(Error::ItemOutOfRange(last_position));
695        }
696        assert!(
697            positions.is_sorted_by(|a, b| a < b),
698            "positions must be strictly increasing"
699        );
700        Ok(())
701    }
702
703    /// Read `miss_positions` from storage and fill their slots in `result`. `miss_offsets[i]`
704    /// is the byte offset of `miss_positions[i]`'s frame, and `miss_indices[i]` is the `result`
705    /// slot for `miss_positions[i]` (identity when `None`).
706    async fn read_misses(
707        &self,
708        result: &mut [Option<V>],
709        miss_indices: Option<&[usize]>,
710        miss_positions: &[u64],
711        miss_offsets: &[u64],
712    ) -> Result<(), Error> {
713        // Group runs of consecutive positions that fall into the same blob, then read all runs
714        // concurrently.
715        let items_per_blob = self.items_per_blob.get();
716        let mut runs = Vec::new();
717        let mut group_start = 0;
718        while group_start < miss_positions.len() {
719            let blob = position_to_blob(miss_positions[group_start], items_per_blob);
720            let mut group_end = group_start + 1;
721            while group_end < miss_positions.len()
722                && position_to_blob(miss_positions[group_end], items_per_blob) == blob
723            {
724                group_end += 1;
725            }
726
727            let blob_handle = self
728                .data
729                .get(blob)
730                .expect("positions in bounds map to a retained blob");
731            // Consecutive positions are byte-adjacent frames, so each next offset gives the
732            // previous frame's encoded length.
733            let mut run_start = group_start;
734            while run_start < group_end {
735                let mut run_end = run_start + 1;
736                while run_end < group_end
737                    && miss_positions[run_end - 1].checked_add(1) == Some(miss_positions[run_end])
738                {
739                    run_end += 1;
740                }
741                runs.push((run_start, run_end, blob, blob_handle.clone()));
742                run_start = run_end;
743            }
744            group_start = group_end;
745        }
746
747        let run_items = try_join_all(runs.iter().map(|(run_start, run_end, blob, handle)| {
748            self.read_consecutive(handle, *blob, &miss_offsets[*run_start..*run_end])
749        }))
750        .await?;
751        for ((run_start, _, _, _), items) in runs.iter().zip(run_items) {
752            for (k, item) in items.into_iter().enumerate() {
753                let slot = miss_indices.map_or(run_start + k, |indices| indices[run_start + k]);
754                result[slot] = Some(item);
755            }
756        }
757
758        Ok(())
759    }
760
761    /// One synchronous batched pass over `positions`, filling `out[i]` for every frame served
762    /// entirely from the page cache. Returns the per-position frame offsets resolved along the
763    /// way: `Some(offset)` whenever the offsets journal served position `i` synchronously, even
764    /// if the data frame itself missed (callers reuse these offsets so the offsets journal is
765    /// not consulted twice).
766    fn read_many_sync_pass(&self, positions: &[u64], out: &mut [Option<V>]) -> Vec<Option<u64>> {
767        let mut resolved: Vec<Option<u64>> = vec![None; positions.len()];
768        if positions.is_empty() {
769            return resolved;
770        }
771
772        // A frame at position p spans [off(p), off(p + 1)), so one batched pass over the
773        // offsets journal resolves every queried frame's extent. Positions and their in-bounds
774        // successors interleave into one strictly increasing lookup list. The journal's last
775        // frame has no successor and takes the per-frame path below.
776        let mut lookups: Vec<u64> = Vec::with_capacity(positions.len() * 2);
777        for &position in positions {
778            if lookups.last() != Some(&position) {
779                lookups.push(position);
780            }
781            match position.checked_add(1) {
782                Some(next) if next < self.bounds.end => lookups.push(next),
783                _ => {}
784            }
785        }
786        let offsets = self.offsets.probe_items(&lookups);
787
788        // Split queried frames into known extents (served below by one batched cache read per
789        // data blob) and unknown extents (the last frame of a blob or of the journal, served by
790        // the per-frame path). Frames whose offset lookup missed stay `None`.
791        let items_per_blob = self.items_per_blob.get();
792        let mut extents: Vec<(usize, u64, usize)> = Vec::with_capacity(positions.len());
793        let mut singles: Vec<(usize, u64)> = Vec::new();
794        let mut lookup_idx = 0;
795        for (idx, &position) in positions.iter().enumerate() {
796            while lookups[lookup_idx] != position {
797                lookup_idx += 1;
798            }
799            if self.validate_readable(position).is_err() {
800                continue;
801            }
802            let Some(offset) = offsets[lookup_idx] else {
803                continue;
804            };
805            resolved[idx] = Some(offset);
806
807            // The successor lookup is adjacent in `lookups` whenever it was pushed (in
808            // bounds). A cross-blob successor's offset is in a different data blob and does
809            // not bound this frame.
810            let next = position + 1;
811            let next_offset = if next < self.bounds.end
812                && position_to_blob(position, items_per_blob)
813                    == position_to_blob(next, items_per_blob)
814            {
815                offsets[lookup_idx + 1]
816            } else {
817                None
818            };
819            match next_offset {
820                Some(next) if next > offset => {
821                    extents.push((idx, offset, (next - offset) as usize))
822                }
823                _ => singles.push((idx, offset)),
824            }
825        }
826
827        let mut buf = Vec::new();
828        let mut hits = 0u64;
829
830        // Serve known-extent frames: one batched cache read per data blob group.
831        let mut group_start = 0;
832        while group_start < extents.len() {
833            let blob_num = position_to_blob(positions[extents[group_start].0], items_per_blob);
834            let mut group_end = group_start + 1;
835            while group_end < extents.len()
836                && position_to_blob(positions[extents[group_end].0], items_per_blob) == blob_num
837            {
838                group_end += 1;
839            }
840            let group = &extents[group_start..group_end];
841            group_start = group_end;
842
843            let Some(blob) = self.data.get(blob_num) else {
844                continue;
845            };
846            let ranges: Vec<(u64, usize)> = group
847                .iter()
848                .map(|&(_, offset, len)| (offset, len))
849                .collect();
850            let total: usize = ranges.iter().map(|&(_, len)| len).sum();
851            buf.resize(total, 0);
852            let missed = blob.try_read_ranges_sync_into(&mut buf, &ranges);
853            let mut missed = missed.into_iter().peekable();
854            let mut local = 0usize;
855            for (range_idx, &(idx, _, len)) in group.iter().enumerate() {
856                let slot = &buf[local..local + len];
857                local += len;
858                if missed.peek() == Some(&range_idx) {
859                    missed.next();
860                    continue;
861                }
862                if let Some(item) =
863                    decode_frame_from_span(slot, len, &self.codec_config, self.compressed)
864                {
865                    out[idx] = Some(item);
866                    hits += 1;
867                }
868            }
869        }
870
871        // Per-frame path for frames whose extent is unknown.
872        let mut frame_buf = Vec::new();
873        for (idx, offset) in singles {
874            if let Some(item) = self.try_read_frame_sync(positions[idx], offset, &mut frame_buf) {
875                out[idx] = Some(item);
876                hits += 1;
877            }
878        }
879        self.metrics.cache_hits.inc_by(hits);
880        self.metrics.items_read.inc_by(hits);
881        resolved
882    }
883}
884
885/// A position the sync pass could not serve, carrying the frame offset the pass resolved from
886/// the offsets journal along the way (when it did).
887#[derive(Clone, Copy)]
888struct Miss {
889    position: u64,
890    offset: Option<u64>,
891}
892
893/// Complete a sync pass's item slots: read `misses` (the positions of the `None` slots, in
894/// order) and fill each slot with its item.
895async fn complete<E: Context, V: CodecShared>(
896    reader: &Reader<'_, E, V>,
897    items: Vec<Option<V>>,
898    misses: Vec<Miss>,
899) -> Result<Vec<V>, Error> {
900    if misses.is_empty() {
901        return Ok(items
902            .into_iter()
903            .map(|item| item.expect("complete probe has no misses"))
904            .collect());
905    }
906
907    let fetched = reader.fetch_misses(&misses).await?;
908    let mut fetched = fetched.into_iter();
909    Ok(items
910        .into_iter()
911        .map(|item| item.unwrap_or_else(|| fetched.next().expect("one fetched item per miss")))
912        .collect())
913}
914
915impl<E: Context, V: CodecShared> Reader<'_, E, V> {
916    /// One probe pass over strictly increasing `positions`: one item slot per position plus
917    /// the misses, each carrying any frame offset the pass resolved.
918    fn probe_parts(&self, positions: &[u64]) -> (Vec<Option<V>>, Vec<Miss>) {
919        let mut items: Vec<Option<V>> = (0..positions.len()).map(|_| None).collect();
920        let resolved = self.read_many_sync_pass(positions, &mut items);
921        let misses = positions
922            .iter()
923            .zip(&items)
924            .zip(resolved)
925            .filter_map(|((&position, item), offset)| {
926                item.is_none().then_some(Miss { position, offset })
927            })
928            .collect();
929        (items, misses)
930    }
931
932    /// Complete probe misses (strictly increasing by position): resolve outstanding offsets
933    /// without the offsets journal's cache pass (those positions just missed it) and read the
934    /// frames with one batched pass per blob run. Returns one item per miss, in order.
935    async fn fetch_misses(&self, misses: &[Miss]) -> Result<Vec<V>, Error> {
936        if misses.is_empty() {
937            return Ok(Vec::new());
938        }
939
940        // Validate before consulting the offsets journal so a probe-declined out-of-bounds
941        // position surfaces as a range error rather than corruption.
942        for miss in misses {
943            self.validate_readable(miss.position)?;
944        }
945
946        let unresolved: Vec<u64> = misses
947            .iter()
948            .filter(|miss| miss.offset.is_none())
949            .map(|miss| miss.position)
950            .collect();
951
952        // Range errors from the offsets journal are corruption: the positions were already
953        // validated against `bounds`, so the offsets journal must have them.
954        let fetched = self
955            .offsets
956            .read_many_inner(&unresolved)
957            .await
958            .map_err(|e| match e {
959                Error::ItemOutOfRange(e) | Error::ItemPruned(e) => {
960                    Error::Corruption(format!("blob/item should be found, but got: {e}"))
961                }
962                other => other,
963            })?;
964        let mut fetched = fetched.into_iter();
965        let offsets: Vec<u64> = misses
966            .iter()
967            .map(|miss| {
968                miss.offset.unwrap_or_else(|| {
969                    fetched
970                        .next()
971                        .expect("one fetched offset per unresolved miss")
972                })
973            })
974            .collect();
975        let positions: Vec<u64> = misses.iter().map(|miss| miss.position).collect();
976
977        let mut result: Vec<Option<V>> = (0..misses.len()).map(|_| None).collect();
978        self.read_misses(&mut result, None, &positions, &offsets)
979            .await?;
980        self.metrics.cache_misses.inc_by(positions.len() as u64);
981        self.metrics.items_read.inc_by(positions.len() as u64);
982        Ok(result
983            .into_iter()
984            .map(|item| item.expect("read_misses fills every slot"))
985            .collect())
986    }
987}
988
989impl<E: Context, V: CodecShared> super::Contiguous for Reader<'_, E, V> {
990    type Item = V;
991
992    fn bounds(&self) -> Range<u64> {
993        self.bounds.clone()
994    }
995
996    async fn read(&self, position: u64) -> Result<V, Error> {
997        self.metrics.read_calls.inc();
998        self.validate_readable(position)?;
999
1000        // Probe the offsets journal once, serving from the page cache synchronously when
1001        // possible. On a data-frame miss the resolved offset is reused by the async path so the
1002        // offsets journal is not consulted twice.
1003        let cached_offset = self.offsets.try_read_sync(position);
1004        if let Some(offset) = cached_offset {
1005            let mut buf = Vec::new();
1006            if let Some(item) = self.try_read_frame_sync(position, offset, &mut buf) {
1007                self.metrics.cache_hits.inc();
1008                self.metrics.items_read.inc();
1009                return Ok(item);
1010            }
1011        }
1012
1013        let _timer = self.metrics.read_timer();
1014        let offset = match cached_offset {
1015            Some(offset) => offset,
1016            None => self.offsets.read(position).await?,
1017        };
1018        let blob = self
1019            .data
1020            .get(position_to_blob(position, self.items_per_blob.get()))
1021            .expect("position in bounds maps to a retained blob");
1022        self.metrics.cache_misses.inc();
1023        let item = self.read_at_offset(&blob, offset).await?;
1024        self.metrics.items_read.inc();
1025        Ok(item)
1026    }
1027
1028    async fn read_many(&self, positions: &[u64]) -> Result<Vec<V>, Error> {
1029        if positions.is_empty() {
1030            return Ok(Vec::new());
1031        }
1032        let _timer = self.metrics.read_many_timer();
1033        self.metrics.read_many_calls.inc();
1034        self.validate_read_many(positions)?;
1035        let (items, misses) = self.probe_parts(positions);
1036        complete(self, items, misses).await
1037    }
1038
1039    fn try_read_sync(&self, position: u64) -> Option<V> {
1040        self.validate_readable(position).ok()?;
1041        let offset = self.offsets.try_read_sync(position)?;
1042        let mut buf = Vec::new();
1043        let item = self.try_read_frame_sync(position, offset, &mut buf)?;
1044        self.metrics.cache_hits.inc();
1045        self.metrics.items_read.inc();
1046        Some(item)
1047    }
1048
1049    fn try_read_many_sync(&self, positions: &[u64]) -> Vec<Option<V>> {
1050        assert!(
1051            positions.is_sorted_by(|a, b| a < b),
1052            "positions must be strictly increasing"
1053        );
1054        let mut items: Vec<Option<V>> = (0..positions.len()).map(|_| None).collect();
1055        self.read_many_sync_pass(positions, &mut items);
1056        items
1057    }
1058
1059    async fn replay(
1060        &self,
1061        start_pos: u64,
1062        buffer: NonZeroUsize,
1063    ) -> Result<impl Stream<Item = Result<(u64, V), Error>> + Send, Error> {
1064        let states = self.replay_states(start_pos, buffer).await?;
1065
1066        Ok(super::replay_stream_from_states(states))
1067    }
1068}
1069
1070impl<E: Context, V: CodecShared> Journal<E, V> {
1071    /// Mark all data blobs from `blob` onward as dirty.
1072    fn mark_dirty_from(&mut self, blob: u64) {
1073        self.dirty_from_blob = Some(
1074            self.dirty_from_blob
1075                .map_or(blob, |existing| existing.min(blob)),
1076        );
1077    }
1078
1079    /// Initialize a contiguous variable journal.
1080    ///
1081    /// # Crash Recovery
1082    ///
1083    /// The data blobs are the source of truth. If the offsets journal is inconsistent
1084    /// it will be updated to match the data blobs.
1085    #[boxed]
1086    pub async fn init(context: E, cfg: Config<V::Cfg>) -> Result<Self, Error> {
1087        let items_per_blob = cfg.items_per_section.get();
1088        let data_partition = cfg.data_partition();
1089        let data_context = context.child("data");
1090
1091        // If a prior `init_at_size`/`clear_to_size` crashed mid-reset, the offsets journal
1092        // carries a staged clear. `init_cleared` discards the data partition before finishing
1093        // that reset so stale data is never replayed past the reset size.
1094        let mut offsets = fixed::Journal::<E, u64>::init_cleared(
1095            context.child("offsets"),
1096            fixed::Config {
1097                partition: cfg.offsets_partition(),
1098                items_per_blob: cfg.items_per_section,
1099                page_cache: cfg.page_cache.clone(),
1100                write_buffer: cfg.write_buffer,
1101            },
1102            || Partition::<E>::remove_all(&data_context, &data_partition),
1103        )
1104        .await?;
1105
1106        let partition = Partition::new(
1107            data_context,
1108            data_partition,
1109            cfg.page_cache,
1110            cfg.write_buffer,
1111        );
1112        let mut pending = partition.open_all().await?;
1113
1114        // Validate and align the offsets journal to match the data blobs.
1115        let bounds = Self::align(
1116            &partition,
1117            &mut pending,
1118            &mut offsets,
1119            items_per_blob,
1120            &cfg.codec_config,
1121            cfg.compression.is_some(),
1122        )
1123        .await?;
1124
1125        // Seal every blob below the tail and assemble the blobs.
1126        let tail_blob = position_to_blob(bounds.end, items_per_blob);
1127        let blobs = Writable::recover(partition, pending, tail_blob).await?;
1128
1129        // `align` synced any repaired or adopted data before `offsets.sync()`.
1130        // `Writable::recover` only seals already-recovered writers; sealing an unchanged recovered
1131        // partial page emits no new write, so init has no dirty data to carry forward.
1132
1133        let metrics = Metrics::new(context);
1134        metrics.update(bounds.end, bounds.start, items_per_blob);
1135
1136        Ok(Self {
1137            blobs,
1138            offsets,
1139            bounds,
1140            dirty_from_blob: None,
1141            #[cfg(test)]
1142            halt_before_offsets_prune: false,
1143            items_per_blob: cfg.items_per_section,
1144            compression: cfg.compression,
1145            codec_config: cfg.codec_config,
1146            metrics: Arc::new(metrics),
1147        })
1148    }
1149
1150    /// Initialize an empty [Journal] at the given logical `size`.
1151    ///
1152    /// This discards any existing data and offsets. The offsets reset intent is staged before the
1153    /// data partition is cleared so recovery can complete the requested reset if a crash
1154    /// interrupts the operation.
1155    ///
1156    /// Returns a journal with journal.bounds() == Range{start: size, end: size}
1157    /// and next append at position `size`.
1158    #[commonware_macros::stability(ALPHA)]
1159    pub async fn init_at_size(context: E, cfg: Config<V::Cfg>, size: u64) -> Result<Self, Error> {
1160        let items_per_blob = cfg.items_per_section.get();
1161        let data_partition = cfg.data_partition();
1162        let data_context = context.child("data");
1163        let offsets_partition = cfg.offsets_partition();
1164        let offsets_context = context.child("offsets");
1165
1166        // Fail before writing intent if the offsets blob partitions are already inconsistent.
1167        Partition::select(&offsets_context, &offsets_partition).await?;
1168
1169        // `init_at_size_cleared` durably stages the offsets reset, clears the data partition,
1170        // then completes the reset. A crash at any point leaves a staged clear that the next
1171        // `init` (via `init_cleared`) finishes, so stale data can never outlive the reset.
1172        let offsets = fixed::Journal::<E, u64>::init_at_size_cleared(
1173            offsets_context,
1174            fixed::Config {
1175                partition: offsets_partition,
1176                items_per_blob: cfg.items_per_section,
1177                page_cache: cfg.page_cache.clone(),
1178                write_buffer: cfg.write_buffer,
1179            },
1180            size,
1181            || Partition::<E>::remove_all(&data_context, &data_partition),
1182        )
1183        .await?;
1184
1185        let partition = Partition::new(
1186            data_context,
1187            data_partition,
1188            cfg.page_cache,
1189            cfg.write_buffer,
1190        );
1191        let blobs = Writable::recover(
1192            partition,
1193            BTreeMap::new(),
1194            position_to_blob(size, items_per_blob),
1195        )
1196        .await?;
1197
1198        let metrics = Metrics::new(context);
1199        metrics.update(size, size, items_per_blob);
1200
1201        Ok(Self {
1202            blobs,
1203            offsets,
1204            bounds: size..size,
1205            dirty_from_blob: None,
1206            #[cfg(test)]
1207            halt_before_offsets_prune: false,
1208            items_per_blob: cfg.items_per_section,
1209            compression: cfg.compression,
1210            codec_config: cfg.codec_config,
1211            metrics: Arc::new(metrics),
1212        })
1213    }
1214
1215    /// Initialize a [Journal] for use in state sync.
1216    ///
1217    /// The bounds are item locations (not blob indexes). This function prepares the
1218    /// on-disk journal so that subsequent appends go to the correct physical location for the
1219    /// requested range.
1220    ///
1221    /// Behavior by existing on-disk state:
1222    /// - Fresh (no data): returns an empty journal, resetting to `range.start` if needed.
1223    /// - Stale (all data strictly before `range.start`): resets to `range.start` using the
1224    ///   crash-safe clear path and returns an empty journal.
1225    /// - Overlap within [`range.start`, `range.end`]:
1226    ///   - Prunes toward `range.start` (blob-aligned, so some items before
1227    ///     `range.start` may be retained)
1228    /// - Data beyond `range.end`: returns [Error::ItemOutOfRange].
1229    ///
1230    /// # Arguments
1231    /// - `context`: storage context
1232    /// - `cfg`: journal configuration
1233    /// - `range`: range of item locations to retain
1234    ///
1235    /// # Returns
1236    /// A contiguous journal ready for sync operations. The journal's size will be within the range.
1237    ///
1238    /// # Errors
1239    /// Returns [Error::ItemOutOfRange] if existing data extends beyond `range.end`.
1240    #[commonware_macros::stability(ALPHA)]
1241    pub(crate) async fn init_sync(
1242        context: E,
1243        cfg: Config<V::Cfg>,
1244        range: Range<u64>,
1245    ) -> Result<Self, Error> {
1246        assert!(!range.is_empty(), "range must not be empty");
1247
1248        debug!(
1249            range.start,
1250            range.end,
1251            items_per_blob = cfg.items_per_section.get(),
1252            "initializing contiguous variable journal for sync"
1253        );
1254
1255        // Initialize contiguous journal
1256        let mut journal = Self::init(context.child("journal"), cfg.clone()).await?;
1257
1258        let size = journal.size();
1259
1260        // No existing data - reset to sync range start if needed
1261        if size == 0 {
1262            if range.start == 0 {
1263                debug!("no existing journal data, returning empty journal");
1264                return Ok(journal);
1265            } else {
1266                debug!(
1267                    range.start,
1268                    "no existing journal data, resetting to sync range start"
1269                );
1270                journal.clear_to_size(range.start).await?;
1271                return Ok(journal);
1272            }
1273        }
1274
1275        // After a same-blob crash during a previous clear_to_size, the journal may recover to a
1276        // stale position ahead of the requested start.
1277        let bounds = journal.bounds.clone();
1278        if bounds.is_empty() && bounds.start > range.start {
1279            journal.clear_to_size(range.start).await?;
1280            return Ok(journal);
1281        }
1282
1283        // Check if data exceeds the sync range
1284        if size > range.end {
1285            return Err(Error::ItemOutOfRange(size));
1286        }
1287
1288        // If all existing data is before our sync range, reset to range start
1289        if size <= range.start {
1290            debug!(
1291                size,
1292                range.start, "existing journal data is stale, resetting to start position"
1293            );
1294            journal.clear_to_size(range.start).await?;
1295            return Ok(journal);
1296        }
1297
1298        // Prune to lower bound if needed
1299        if !bounds.is_empty() && bounds.start < range.start {
1300            debug!(
1301                oldest_pos = bounds.start,
1302                range.start, "pruning journal to sync range start"
1303            );
1304            journal.prune(range.start).await?;
1305        }
1306
1307        Ok(journal)
1308    }
1309
1310    /// Rewind the journal to the given size, discarding items from the end.
1311    ///
1312    /// After rewinding to size N, the journal will contain exactly N items, and the next append
1313    /// will receive position N.
1314    ///
1315    /// # Errors
1316    ///
1317    /// Returns [Error::InvalidRewind] if `size` is larger than current size.
1318    /// Returns [Error::ItemPruned] if `size` is smaller than the pruning boundary.
1319    /// # Warning
1320    ///
1321    /// - This operation is not guaranteed to survive restarts until `commit` or `sync` is called.
1322    /// - Readers returned by [`snapshot`](Self::snapshot) may observe unspecified contents if this
1323    ///   rewind truncates into their range.
1324    pub async fn rewind(&mut self, size: u64) -> Result<(), Error> {
1325        match size.cmp(&self.bounds.end) {
1326            std::cmp::Ordering::Greater => return Err(Error::InvalidRewind(size)),
1327            std::cmp::Ordering::Equal => return Ok(()),
1328            std::cmp::Ordering::Less => {}
1329        }
1330
1331        // Rewind never updates the pruning boundary.
1332        if size < self.bounds.start {
1333            return Err(Error::ItemPruned(size));
1334        }
1335
1336        let discard_blob = position_to_blob(size, self.items_per_blob.get());
1337
1338        // The byte offset of the first discarded item is the data truncation point.
1339        let discard_offset = self.offsets.read(size).await?;
1340
1341        // Rewind offsets before data. Rewinding the offsets journal persists a lowered recovery
1342        // watermark before any state moves backward, so a crash anywhere in this sequence leaves
1343        // offsets at or behind the data, a shape init repairs by rebuilding offsets from the
1344        // data. Truncating data first would leave a window where a crash strands a short blob
1345        // below a watermark that recovery trusts, permanently hiding the missing items.
1346        self.offsets.rewind(size).await?;
1347
1348        if discard_blob == self.blobs.tail_blob_index() {
1349            self.blobs.rewind_tail(discard_offset).await?;
1350        } else {
1351            self.blobs
1352                .rewind_into_sealed(discard_blob, discard_offset)
1353                .await?;
1354        }
1355
1356        self.bounds.end = size;
1357        self.mark_dirty_from(discard_blob);
1358        self.metrics.update(
1359            self.bounds.end,
1360            self.bounds.start,
1361            self.items_per_blob.get(),
1362        );
1363
1364        Ok(())
1365    }
1366
1367    /// Append a new item to the journal, returning its position.
1368    ///
1369    /// The position returned is a stable, consecutively increasing value starting from 0.
1370    /// This position remains constant after pruning.
1371    ///
1372    /// # Errors
1373    ///
1374    /// Returns an error if the underlying storage operation fails or if the item cannot
1375    /// be encoded.
1376    pub async fn append(&mut self, item: &V) -> Result<u64, Error> {
1377        let _timer = self.metrics.append_timer();
1378        self.metrics.append_calls.inc();
1379        self.append_many_inner(Many::Flat(std::slice::from_ref(item)))
1380            .await
1381    }
1382
1383    /// Append items to the journal, returning the position of the last item appended.
1384    ///
1385    /// Returns [Error::EmptyAppend] if items is empty.
1386    pub async fn append_many<'a>(&'a mut self, items: Many<'a, V>) -> Result<u64, Error> {
1387        let _timer = self.metrics.append_many_timer();
1388        self.metrics.append_many_calls.inc();
1389        self.append_many_inner(items).await
1390    }
1391
1392    async fn append_many_inner<'a>(&'a mut self, items: Many<'a, V>) -> Result<u64, Error> {
1393        self.write_encoded(self.prepare_append(items)?).await
1394    }
1395
1396    /// Encode `items` into a buffer that can be appended later with [`Self::append_prepared`].
1397    ///
1398    /// This lets callers serialize borrowed items synchronously, release those borrows, and
1399    /// perform the append without holding unrelated locks across journal I/O.
1400    pub fn prepare_append(&self, items: Many<'_, V>) -> Result<PreparedAppend<V>, Error> {
1401        let mut encoded = Vec::new();
1402        let mut item_starts = Vec::with_capacity(items.len());
1403        let mut encode = |item: &V| {
1404            item_starts.push(encoded.len());
1405            encode_frame_into(self.compression, item, &mut encoded)
1406        };
1407        match items {
1408            Many::Flat(items) => {
1409                for item in items {
1410                    encode(item)?;
1411                }
1412            }
1413            Many::Nested(nested_items) => {
1414                for items in nested_items {
1415                    for item in *items {
1416                        encode(item)?;
1417                    }
1418                }
1419            }
1420        }
1421        Ok(PreparedAppend {
1422            encoded,
1423            item_starts,
1424            compressed: self.compression.is_some(),
1425            _marker: PhantomData,
1426        })
1427    }
1428
1429    /// Append items encoded by [`Self::prepare_append`], returning the position of the last item
1430    /// appended.
1431    ///
1432    /// Returns [Error::EmptyAppend] if `prepared` contains no items.
1433    /// Returns [Error::InvalidConfiguration] if `prepared` was encoded with different compression
1434    /// settings than this journal uses.
1435    pub async fn append_prepared(&mut self, prepared: PreparedAppend<V>) -> Result<u64, Error> {
1436        let _timer = self.metrics.append_prepared_timer();
1437        self.metrics.append_prepared_calls.inc();
1438        self.write_encoded(prepared).await
1439    }
1440
1441    // Write pre-encoded items; shared by all append paths. Records no call metrics.
1442    async fn write_encoded(&mut self, prepared: PreparedAppend<V>) -> Result<u64, Error> {
1443        let PreparedAppend {
1444            encoded,
1445            item_starts,
1446            compressed,
1447            ..
1448        } = prepared;
1449        let items_count = item_starts.len();
1450        if items_count == 0 {
1451            return Err(Error::EmptyAppend);
1452        }
1453        if compressed != self.compression.is_some() {
1454            return Err(Error::InvalidConfiguration(
1455                "prepared append compression setting does not match journal".into(),
1456            ));
1457        }
1458        let encoded = IoBuf::from(encoded);
1459
1460        // Reject the append before writing anything (to either the data blobs or offsets
1461        // journal) if it would push the size past `u64::MAX`.
1462        self.bounds
1463            .end
1464            .checked_add(items_count as u64)
1465            .ok_or(Error::SizeOverflow)?;
1466
1467        let items_per_blob = self.items_per_blob.get();
1468        self.mark_dirty_from(position_to_blob(self.bounds.end, items_per_blob));
1469        let mut written = 0;
1470        while written < items_count {
1471            let batch_count = super::batch_count_to_blob_boundary(
1472                self.bounds.end,
1473                items_count - written,
1474                items_per_blob,
1475            );
1476            let batch_start = item_starts[written];
1477            let batch_end = item_starts
1478                .get(written + batch_count)
1479                .copied()
1480                .unwrap_or(encoded.len());
1481
1482            // Append pre-encoded data to the tail, then convert relative item starts into
1483            // absolute offsets. A large batch is written whole-page-direct to the blob; the
1484            // returned offset is where this batch's first byte was written.
1485            let base_offset = self
1486                .blobs
1487                .tail_writer()
1488                .append_owned(encoded.slice(batch_start..batch_end))
1489                .await
1490                .map_err(Error::Runtime)?;
1491
1492            let absolute_offsets = item_starts[written..written + batch_count]
1493                .iter()
1494                .map(|&start| {
1495                    base_offset
1496                        .checked_add((start - batch_start) as u64)
1497                        .ok_or(Error::OffsetOverflow)
1498                })
1499                .collect::<Result<Vec<u64>, _>>()?;
1500
1501            // Append the offsets for this blob batch to the offsets journal.
1502            let last_offsets_pos = self
1503                .offsets
1504                .append_many(Many::Flat(&absolute_offsets))
1505                .await?;
1506            assert_eq!(last_offsets_pos, self.bounds.end + batch_count as u64 - 1);
1507
1508            self.bounds.end += batch_count as u64;
1509            written += batch_count;
1510
1511            // Seal the just-filled tail and open the next blob as the new tail. This does not
1512            // fsync the old blob; dirty tracking still covers it until commit/sync.
1513            if self.bounds.end.is_multiple_of(items_per_blob) {
1514                self.blobs.seal_tail().await?;
1515            }
1516        }
1517
1518        self.metrics.update(
1519            self.bounds.end,
1520            self.bounds.start,
1521            self.items_per_blob.get(),
1522        );
1523        Ok(self.bounds.end - 1)
1524    }
1525
1526    /// Capture an owned snapshot ([`Reader`]) over the current journal. Bounds are frozen at
1527    /// creation, and the snapshot stays readable across concurrent appends and prunes.
1528    ///
1529    /// If the journal later rewinds into the returned reader's range, subsequent reads
1530    /// from that range may observe unspecified contents.
1531    pub async fn snapshot(&mut self) -> Result<Reader<'static, E, V>, Error> {
1532        Ok(Reader {
1533            data: self.blobs.snapshot().await?,
1534            bounds: self.bounds.clone(),
1535            offsets: self.offsets.snapshot().await?,
1536            items_per_blob: self.items_per_blob,
1537            codec_config: self.codec_config.clone(),
1538            compressed: self.compression.is_some(),
1539            metrics: self.metrics.clone(),
1540        })
1541    }
1542
1543    /// A reader borrowing the journal's live state.
1544    fn reader(&self) -> Reader<'_, E, V> {
1545        Reader {
1546            data: self.blobs.reader(),
1547            bounds: self.bounds.clone(),
1548            offsets: self.offsets.reader(),
1549            items_per_blob: self.items_per_blob,
1550            codec_config: self.codec_config.clone(),
1551            compressed: self.compression.is_some(),
1552            metrics: self.metrics.clone(),
1553        }
1554    }
1555
1556    /// Return the total number of items in the journal, irrespective of pruning. The next value
1557    /// appended to the journal will be at this position.
1558    pub const fn size(&self) -> u64 {
1559        self.bounds.end
1560    }
1561
1562    /// Prune items at positions strictly less than `min_position`.
1563    ///
1564    /// Returns `true` if any data was pruned, `false` otherwise.
1565    ///
1566    /// # Errors
1567    ///
1568    /// Returns an error if the underlying storage operation fails.
1569    pub async fn prune(&mut self, min_position: u64) -> Result<bool, Error> {
1570        let items_per_blob = self.items_per_blob.get();
1571
1572        // Calculate the blob that would contain min_position, capped to the tail (which is
1573        // guaranteed to exist by our invariant).
1574        let target_blob = position_to_blob(min_position, items_per_blob);
1575        let tail_blob = position_to_blob(self.bounds.end, items_per_blob);
1576        let min_blob = target_blob.min(tail_blob);
1577
1578        if min_blob <= self.blobs.oldest_blob_index() {
1579            return Ok(false);
1580        }
1581
1582        let new_boundary = blob_first_position(min_blob, items_per_blob)?;
1583
1584        // Make all dirty blobs durable before removing any: the prune target may be
1585        // justified by an appended-but-unflushed item (e.g. a consumer's commit record), and
1586        // removals are durable, so pruning without this barrier could leave a recovered
1587        // journal whose surviving items no longer justify its boundary. Dirty blobs below the
1588        // prune point are flushed too: removal may be interrupted, and recovery truncates at
1589        // the first torn item, so an unsynced survivor below the boundary could discard
1590        // every synced blob behind it. Offsets entries for retained items must survive the
1591        // same crash: recovery rebuilds offsets that end behind the surviving data's end by
1592        // replaying data, but offsets that end behind its start are unrecoverable because
1593        // the data needed to rebuild the missing entries is about to be removed. Data is
1594        // flushed first, matching the ordering every other durability path maintains.
1595        self.flush_dirty_data().await?;
1596        self.dirty_from_blob = None;
1597        self.offsets.commit().await?;
1598
1599        self.blobs.prune(min_blob).await?;
1600        self.bounds.start = new_boundary;
1601
1602        #[cfg(test)]
1603        if self.halt_before_offsets_prune {
1604            std::future::pending::<()>().await;
1605        }
1606
1607        // Prune data before offsets so a crash leaves offsets behind, which init repairs by
1608        // pruning offsets to match.
1609        self.offsets.prune(new_boundary).await?;
1610        self.metrics.update(
1611            self.bounds.end,
1612            self.bounds.start,
1613            self.items_per_blob.get(),
1614        );
1615
1616        Ok(true)
1617    }
1618
1619    /// Make dirty data blobs durable.
1620    async fn flush_dirty_data(&mut self) -> Result<(), Error> {
1621        let Some(start_blob) = self.dirty_from_blob else {
1622            return Ok(());
1623        };
1624        self.blobs.sync_from(start_blob).await
1625    }
1626
1627    /// Persist dirty data blobs so committed data survives a crash.
1628    ///
1629    /// Does not advance the recovery watermark, so reopen may need to replay entries beyond
1630    /// the previous `sync()`.
1631    pub async fn commit(&mut self) -> Result<(), Error> {
1632        let _timer = self.metrics.commit_timer();
1633        self.metrics.commit_calls.inc();
1634        self.flush_dirty_data().await?;
1635        self.dirty_from_blob = None;
1636        Ok(())
1637    }
1638
1639    /// Persist dirty data blobs and all metadata for both the data and offsets journals.
1640    pub async fn sync(&mut self) -> Result<(), Error> {
1641        let _timer = self.metrics.sync_timer();
1642        self.metrics.sync_calls.inc();
1643        self.flush_dirty_data().await?;
1644        self.offsets.sync().await?;
1645        self.dirty_from_blob = None;
1646        Ok(())
1647    }
1648
1649    /// Remove any underlying blobs created by the journal.
1650    ///
1651    /// This destroys both the data blobs and the offsets journal.
1652    ///
1653    /// # Crash Safety
1654    ///
1655    /// This operation is intended for final teardown and is not crash-safe. If interrupted,
1656    /// reopening the same partitions may observe partially removed state. Use [Self::init_at_size]
1657    /// for a recoverable reset.
1658    pub async fn destroy(self) -> Result<(), Error> {
1659        self.blobs.destroy().await?;
1660        self.offsets.destroy().await
1661    }
1662
1663    /// Clear all data and reset the journal to a new starting position.
1664    ///
1665    /// Unlike `destroy`, this keeps the journal alive so it can be reused.
1666    /// After clearing, the journal will behave as if initialized with `init_at_size(new_size)`.
1667    /// The offsets reset intent is staged before the data blobs are cleared so recovery can
1668    /// complete the requested reset if a crash interrupts the operation.
1669    #[commonware_macros::stability(ALPHA)]
1670    pub(crate) async fn clear_to_size(&mut self, new_size: u64) -> Result<(), Error> {
1671        // Stage in offsets first so a crash mid-clear leaves an intent that recovery completes.
1672        // `clear_to_size` re-stages the same target idempotently before completing.
1673        self.offsets.stage_clear_intent(new_size).await?;
1674        self.blobs
1675            .clear(position_to_blob(new_size, self.items_per_blob.get()))
1676            .await?;
1677        self.offsets.clear_to_size(new_size).await?;
1678
1679        self.bounds = new_size..new_size;
1680        self.dirty_from_blob = None;
1681        self.metrics.update(
1682            self.bounds.end,
1683            self.bounds.start,
1684            self.items_per_blob.get(),
1685        );
1686        Ok(())
1687    }
1688
1689    /// Scan every frame in `writer`, returning the item count and valid prefix.
1690    async fn scan_blob(
1691        writer: &mut Writer<E::Blob>,
1692        codec_config: &V::Cfg,
1693        compressed: bool,
1694    ) -> Result<BlobScan, Error> {
1695        let replay = writer
1696            .replay(REPLAY_BUFFER_SIZE)
1697            .await
1698            .map_err(Error::Runtime)?;
1699        let mut scanner = FrameScanner::<E::Blob, V>::new(replay, codec_config, compressed);
1700        let mut items = 0u64;
1701        loop {
1702            match scanner.next().await? {
1703                Frame::Item { .. } => items += 1,
1704                Frame::End { valid_size, torn } => {
1705                    return Ok(BlobScan {
1706                        items,
1707                        valid_size,
1708                        torn,
1709                    })
1710                }
1711            }
1712        }
1713    }
1714
1715    /// Align the offsets journal and data blobs to be consistent in case a crash occurred on a
1716    /// previous run and left them in an inconsistent state.
1717    ///
1718    /// The data blobs are the source of truth. This function replays them as needed to verify or
1719    /// rebuild the offsets suffix, then fixes any mismatches. Final blob-index contiguity is
1720    /// enforced by [Writable::recover]. Repairs mutate `pending` in place.
1721    ///
1722    /// Returns the recovered bounds (`pruning_boundary..size`).
1723    async fn align(
1724        partition: &Partition<E>,
1725        pending: &mut BTreeMap<u64, Writer<E::Blob>>,
1726        offsets: &mut fixed::Journal<E, u64>,
1727        items_per_blob: u64,
1728        codec_config: &V::Cfg,
1729        compressed: bool,
1730    ) -> Result<Range<u64>, Error> {
1731        // Find the newest item-bearing blob, truncating torn trailing bytes along the way (the
1732        // first invalid frame is the end of the journal).
1733        let scanned: Vec<u64> = pending.keys().rev().copied().collect();
1734        let mut items_in_newest = 0;
1735        let mut newest_blob = None;
1736        for &blob in &scanned {
1737            let writer = pending.get_mut(&blob).expect("blob came from pending");
1738            let scan = Self::scan_blob(writer, codec_config, compressed).await?;
1739            if scan.items > items_per_blob {
1740                return Err(Error::Corruption(format!(
1741                    "blob {blob} has too many items: expected at most {items_per_blob}, got {}",
1742                    scan.items
1743                )));
1744            }
1745            if scan.torn {
1746                warn!(
1747                    blob,
1748                    new_size = scan.valid_size,
1749                    "crash repair: truncating trailing bytes"
1750                );
1751                writer
1752                    .resize(scan.valid_size)
1753                    .await
1754                    .map_err(Error::Runtime)?;
1755                writer.sync().await.map_err(Error::Runtime)?;
1756            }
1757            if scan.items > 0 {
1758                items_in_newest = scan.items;
1759                newest_blob = Some(blob);
1760                break;
1761            }
1762        }
1763
1764        // The tail blob (where the next append lands) is one past the newest full blob, the
1765        // newest partial blob, or the oldest blob when empty (resolved by `align_empty`). Any
1766        // blob above it is an empty crash artifact; the loop below removes them.
1767        let tail_blob = match newest_blob {
1768            Some(blob) if items_in_newest == items_per_blob => blob.saturating_add(1),
1769            Some(blob) => blob,
1770            None => pending.keys().next().copied().unwrap_or(0),
1771        };
1772        for &blob in &scanned {
1773            if blob <= tail_blob {
1774                break;
1775            }
1776            warn!(blob, "crash repair: removing empty trailing data blob");
1777            pending.remove(&blob);
1778            partition.remove(blob).await?;
1779        }
1780
1781        let Some(newest_blob) = newest_blob else {
1782            return Self::align_empty(partition, pending, offsets, items_per_blob).await;
1783        };
1784
1785        // Align pruning state at the blob level. After alignment, the offsets journal starts in
1786        // the oldest data blob; a mid-blob offsets start (from `init_at_size`) is valid within
1787        // the same blob.
1788        let oldest_blob = *pending.keys().next().expect("pending is non-empty");
1789        let data_oldest_pos = blob_first_position(oldest_blob, items_per_blob)?;
1790        {
1791            let offsets_bounds = offsets.pruning_boundary()..offsets.size();
1792
1793            // The offsets journal ending before the oldest retained data blob represents an
1794            // impossible state under normal crash/prune sequences, indicating external corruption.
1795            if offsets_bounds.end < data_oldest_pos {
1796                return Err(Error::Corruption(format!(
1797                    "offsets journal size {} is behind data oldest position {data_oldest_pos}",
1798                    offsets_bounds.end
1799                )));
1800            }
1801            let offsets_start_blob = position_to_blob(offsets_bounds.start, items_per_blob);
1802            match offsets_start_blob.cmp(&oldest_blob) {
1803                std::cmp::Ordering::Less => {
1804                    warn!("crash repair: pruning offsets journal to {data_oldest_pos}");
1805                    offsets.prune(data_oldest_pos).await?;
1806                }
1807                std::cmp::Ordering::Equal => {}
1808                std::cmp::Ordering::Greater => {
1809                    // Prune always removes data before offsets, so offsets should never be
1810                    // ahead by a blob.
1811                    return Err(Error::Corruption(format!(
1812                        "offsets start blob {offsets_start_blob} ahead of \
1813                         oldest data blob {oldest_blob}"
1814                    )));
1815                }
1816            }
1817        }
1818
1819        // Re-fetch bounds since prune may have been called above.
1820        let offsets_bounds = offsets.pruning_boundary()..offsets.size();
1821
1822        // The newest item-bearing blob bounds how far recovery can possibly go. If it is also
1823        // the oldest retained blob, its logical start may be a mid-blob pruning boundary.
1824        let retained_data_end_bound = blob_first_position(newest_blob, items_per_blob)?
1825            .max(offsets_bounds.start)
1826            .checked_add(items_in_newest)
1827            .ok_or(Error::OffsetOverflow)?;
1828        let data_sync_start =
1829            Self::recovery_anchor(offsets, &offsets_bounds, retained_data_end_bound)?;
1830
1831        // Rebuild the offsets suffix by replaying data from there.
1832        let data_size = Self::rebuild_offsets_from_anchor(
1833            partition,
1834            pending,
1835            offsets,
1836            items_per_blob,
1837            data_sync_start,
1838            codec_config,
1839            compressed,
1840        )
1841        .await?;
1842
1843        // Final invariant checks. These hold by construction after alignment, but the inputs are
1844        // recovered from disk, so a violation means corruption rather than a logic bug.
1845        let pruning_boundary = {
1846            let offsets_bounds = offsets.pruning_boundary()..offsets.size();
1847            if offsets_bounds.end != data_size {
1848                return Err(Error::Corruption(format!(
1849                    "recovered offsets end {} does not match data size {data_size}",
1850                    offsets_bounds.end
1851                )));
1852            }
1853
1854            // Recovery can truncate the data back to empty (e.g. an empty oldest blob preceded
1855            // the only populated blob, so no contiguous data-backed prefix exists). In that case
1856            // there is no oldest blob to anchor against; otherwise offsets and data must start in
1857            // the same blob.
1858            if !offsets_bounds.is_empty()
1859                && position_to_blob(offsets_bounds.start, items_per_blob) != oldest_blob
1860            {
1861                return Err(Error::Corruption(format!(
1862                    "recovered offsets and data start in different blobs: {} != {oldest_blob}",
1863                    position_to_blob(offsets_bounds.start, items_per_blob)
1864                )));
1865            }
1866
1867            // Return bounds.start from offsets as the true boundary.
1868            offsets_bounds.start
1869        };
1870
1871        // Rebuilt offsets are about to become durable. First make the data they point at durable
1872        // too; on real filesystems, init may have adopted bytes that were readable but not synced.
1873        Self::sync_data_range(pending, data_sync_start, data_size, items_per_blob).await?;
1874        offsets.sync().await?;
1875        Ok(pruning_boundary..data_size)
1876    }
1877
1878    /// Reconcile a data partition holding no items against the offsets journal.
1879    ///
1880    /// At most one (empty) blob remains in `pending` here: the tail of an empty journal, which
1881    /// is legitimate after a clean restart, or the artifact of a rewind, prune-all, or
1882    /// first-append crash.
1883    async fn align_empty(
1884        partition: &Partition<E>,
1885        pending: &mut BTreeMap<u64, Writer<E::Blob>>,
1886        offsets: &mut fixed::Journal<E, u64>,
1887        items_per_blob: u64,
1888    ) -> Result<Range<u64>, Error> {
1889        let offsets_bounds = offsets.pruning_boundary()..offsets.size();
1890
1891        let Some(&blob) = pending.keys().next() else {
1892            // No data blobs at all: a fresh partition, or a crash after pruning the data blobs
1893            // but before pruning the offsets journal. Clear (rather than prune) the offsets so
1894            // bounds collapse even when the size is mid-blob.
1895            let size = offsets_bounds.end;
1896            if !offsets_bounds.is_empty() {
1897                warn!("crash repair: clearing offsets to {size} (prune-all crash)");
1898                offsets.clear_to_size(size).await?;
1899            }
1900            return Ok(size..size);
1901        };
1902
1903        // The journal restarts at the empty blob's first position, or at the offsets journal's
1904        // mid-blob boundary within it.
1905        let blob_start = blob_first_position(blob, items_per_blob)?;
1906        let target = blob_start.max(offsets_bounds.start);
1907
1908        // Nothing to repair when the blob is the tail of an already-aligned empty journal.
1909        let aligned = position_to_blob(target, items_per_blob) == blob;
1910        if aligned && offsets_bounds == (target..target) {
1911            return Ok(target..target);
1912        }
1913
1914        // Otherwise reconcile both sides to `target`: drop the blob unless it is the tail at
1915        // `target` (recovery reopens the tail), and collapse the offsets bounds.
1916        if !aligned {
1917            warn!(blob, "crash repair: removing empty data blob");
1918            pending.remove(&blob);
1919            partition.remove(blob).await?;
1920        }
1921        warn!("crash repair: clearing offsets to {target} (empty data)");
1922        offsets.clear_to_size(target).await?;
1923        Ok(target..target)
1924    }
1925
1926    /// Choose the position to rebuild offsets from. A watermark below the pruning boundary is
1927    /// stale after a prune, while a watermark beyond retained data indicates corruption.
1928    fn recovery_anchor(
1929        offsets: &fixed::Journal<E, u64>,
1930        offsets_bounds: &Range<u64>,
1931        retained_data_end_bound: u64,
1932    ) -> Result<u64, Error> {
1933        let recovery_watermark = offsets.recovery_watermark();
1934        if recovery_watermark > offsets_bounds.end {
1935            // This condition should be unreachable (fixed-journal init rejects watermark > size),
1936            // so if it were reachable it would indicate external corruption.
1937            return Err(Error::Corruption(format!(
1938                "offsets recovery watermark {recovery_watermark} exceeds offsets size {}",
1939                offsets_bounds.end
1940            )));
1941        }
1942        if recovery_watermark < offsets_bounds.start {
1943            warn!(
1944                recovery_watermark,
1945                start = offsets_bounds.start,
1946                end = offsets_bounds.end,
1947                retained_data_end_bound,
1948                "crash repair: offsets recovery watermark is unusable, rebuilding from offsets start"
1949            );
1950            return Ok(offsets_bounds.start);
1951        }
1952        if recovery_watermark > retained_data_end_bound {
1953            return Err(Error::Corruption(format!(
1954                "offsets recovery watermark {recovery_watermark} exceeds retained data end \
1955                 {retained_data_end_bound} (offsets bounds {}..{})",
1956                offsets_bounds.start, offsets_bounds.end
1957            )));
1958        }
1959        Ok(recovery_watermark)
1960    }
1961
1962    /// Sync data blobs backing rebuilt offsets before the offsets are made durable.
1963    async fn sync_data_range(
1964        pending: &mut BTreeMap<u64, Writer<E::Blob>>,
1965        start_position: u64,
1966        end_position: u64,
1967        items_per_blob: u64,
1968    ) -> Result<(), Error> {
1969        if start_position >= end_position {
1970            return Ok(());
1971        }
1972
1973        let start_blob = position_to_blob(start_position, items_per_blob);
1974        let end_blob = position_to_blob(end_position - 1, items_per_blob);
1975        futures::future::try_join_all(
1976            pending
1977                .range_mut(start_blob..=end_blob)
1978                .map(|(_, writer)| writer.sync()),
1979        )
1980        .await
1981        .map_err(Error::Runtime)?;
1982        Ok(())
1983    }
1984
1985    /// Rebuild the offsets suffix by replaying the data blobs from a recovery anchor.
1986    ///
1987    /// Returns corruption if the data does not reach the anchor. If replay finds a short blob
1988    /// after the anchor, recovery truncates newer blobs and returns the contiguous data-backed
1989    /// size.
1990    async fn rebuild_offsets_from_anchor(
1991        partition: &Partition<E>,
1992        pending: &mut BTreeMap<u64, Writer<E::Blob>>,
1993        offsets: &mut fixed::Journal<E, u64>,
1994        items_per_blob: u64,
1995        anchor: u64,
1996        codec_config: &V::Cfg,
1997        compressed: bool,
1998    ) -> Result<u64, Error> {
1999        assert!(
2000            !pending.is_empty(),
2001            "rebuild_offsets called with no data blobs"
2002        );
2003
2004        let offsets_bounds = offsets.pruning_boundary()..offsets.size();
2005        let data_too_short = || {
2006            if anchor == offsets_bounds.start {
2007                Error::Corruption(format!(
2008                    "data blobs shorter than pruning boundary {}",
2009                    offsets_bounds.start
2010                ))
2011            } else {
2012                Error::Corruption(format!(
2013                    "data blobs shorter than offsets recovery watermark {anchor}"
2014                ))
2015            }
2016        };
2017        if anchor < offsets_bounds.start || anchor > offsets_bounds.end {
2018            return Err(data_too_short());
2019        }
2020
2021        if offsets_bounds.end > anchor {
2022            offsets.rewind(anchor).await?;
2023        }
2024
2025        let start_blob = position_to_blob(anchor, items_per_blob);
2026        let first_position = offsets_bounds
2027            .start
2028            .max(blob_first_position(start_blob, items_per_blob)?);
2029
2030        // Walk blobs from the anchor's blob upward, skipping the already-indexed prefix of the
2031        // first blob, appending an offsets entry per frame after it.
2032        let mut skip = anchor - first_position;
2033        let mut size = anchor;
2034        let mut blob = start_blob;
2035        loop {
2036            let Some(writer) = pending.get_mut(&blob) else {
2037                if skip > 0 {
2038                    // The data ends before the anchor.
2039                    return Err(data_too_short());
2040                }
2041                // A missing blob ends the contiguous data-backed prefix: any newer blobs are
2042                // unreachable and removed.
2043                if pending.keys().next_back().is_some_and(|&n| n > blob) {
2044                    warn!(
2045                        blob,
2046                        size, "crash repair: truncating data after missing blob"
2047                    );
2048                    Self::remove_blobs_after(partition, pending, blob).await?;
2049                }
2050                return Ok(size);
2051            };
2052
2053            let replay = writer
2054                .replay(REPLAY_BUFFER_SIZE)
2055                .await
2056                .map_err(Error::Runtime)?;
2057            let mut scanner = FrameScanner::<E::Blob, V>::new(replay, codec_config, compressed);
2058            let blob_end_pos = super::blob_end_position(blob, items_per_blob, u64::MAX);
2059
2060            let end = loop {
2061                if size == blob_end_pos {
2062                    // The blob reached its capacity; whole trailing frames are over-capacity
2063                    // corruption, while torn trailing junk is repaired like a short blob.
2064                    match scanner.next().await? {
2065                        Frame::Item { .. } => {
2066                            return Err(Error::Corruption(format!(
2067                                "blob {blob} over capacity at logical position {size}"
2068                            )));
2069                        }
2070                        Frame::End {
2071                            valid_size,
2072                            torn: true,
2073                        } => break Some((valid_size, true)),
2074                        Frame::End { .. } => break None,
2075                    }
2076                }
2077                match scanner.next().await? {
2078                    Frame::Item { offset } => {
2079                        if skip > 0 {
2080                            skip -= 1;
2081                        } else {
2082                            offsets.append(&offset).await?;
2083                            size += 1;
2084                        }
2085                    }
2086                    Frame::End { valid_size, torn } => break Some((valid_size, torn)),
2087                }
2088            };
2089
2090            if let Some((valid_size, torn)) = end {
2091                // The blob's frames ended here (short blob, or torn junk at capacity).
2092                if skip > 0 {
2093                    // The data ends before the anchor.
2094                    return Err(data_too_short());
2095                }
2096                if torn {
2097                    warn!(
2098                        blob,
2099                        new_size = valid_size,
2100                        "crash repair: truncating trailing bytes"
2101                    );
2102                    writer.resize(valid_size).await.map_err(Error::Runtime)?;
2103                    writer.sync().await.map_err(Error::Runtime)?;
2104                }
2105                // A short blob ends the contiguous data-backed prefix: any newer blobs are
2106                // unreachable and removed.
2107                if pending.keys().next_back().is_some_and(|&n| n > blob) {
2108                    warn!(blob, size, "crash repair: truncating data after short blob");
2109                    Self::remove_blobs_after(partition, pending, blob).await?;
2110                }
2111                return Ok(size);
2112            }
2113
2114            blob = blob.checked_add(1).ok_or(Error::OffsetOverflow)?;
2115        }
2116    }
2117
2118    /// Remove every blob newer than `blob`, newest-first so a crash leaves a contiguous prefix.
2119    async fn remove_blobs_after(
2120        partition: &Partition<E>,
2121        pending: &mut BTreeMap<u64, Writer<E::Blob>>,
2122        blob: u64,
2123    ) -> Result<(), Error> {
2124        while let Some((&newest, _)) = pending.last_key_value() {
2125            if newest <= blob {
2126                break;
2127            }
2128            drop(pending.remove(&newest));
2129            partition.remove(newest).await?;
2130        }
2131        Ok(())
2132    }
2133}
2134
2135impl<E: Context, V: CodecShared> Contiguous for Journal<E, V> {
2136    type Item = V;
2137
2138    fn bounds(&self) -> Range<u64> {
2139        self.bounds.clone()
2140    }
2141
2142    async fn read(&self, position: u64) -> Result<V, Error> {
2143        self.reader().read(position).await
2144    }
2145
2146    async fn read_many(&self, positions: &[u64]) -> Result<Vec<V>, Error> {
2147        self.reader().read_many(positions).await
2148    }
2149
2150    fn try_read_sync(&self, position: u64) -> Option<V> {
2151        self.reader().try_read_sync(position)
2152    }
2153
2154    fn try_read_many_sync(&self, positions: &[u64]) -> Vec<Option<V>> {
2155        self.reader().try_read_many_sync(positions)
2156    }
2157
2158    async fn replay(
2159        &self,
2160        start_pos: u64,
2161        buffer: NonZeroUsize,
2162    ) -> Result<impl Stream<Item = Result<(u64, V), Error>> + Send, Error> {
2163        let reader = self.reader();
2164        let states = reader.replay_states(start_pos, buffer).await?;
2165
2166        Ok(super::replay_stream_from_states(states))
2167    }
2168}
2169
2170impl<E: Context, V: CodecShared> Mutable for Journal<E, V> {
2171    async fn append(&mut self, item: &Self::Item) -> Result<u64, Error> {
2172        Self::append(self, item).await
2173    }
2174
2175    async fn append_many<'a>(&'a mut self, items: Many<'a, Self::Item>) -> Result<u64, Error> {
2176        Self::append_many(self, items).await
2177    }
2178
2179    async fn prune(&mut self, min_position: u64) -> Result<bool, Error> {
2180        Self::prune(self, min_position).await
2181    }
2182
2183    async fn rewind(&mut self, size: u64) -> Result<(), Error> {
2184        Self::rewind(self, size).await
2185    }
2186
2187    async fn commit(&mut self) -> Result<(), Error> {
2188        Self::commit(self).await
2189    }
2190
2191    async fn sync(&mut self) -> Result<(), Error> {
2192        Self::sync(self).await
2193    }
2194
2195    async fn destroy(self) -> Result<(), Error> {
2196        Self::destroy(self).await
2197    }
2198}
2199
2200#[commonware_macros::stability(ALPHA)]
2201impl<E: Context, V: CodecShared> authenticated::Inner<E> for Journal<E, V> {
2202    type Config = Config<V::Cfg>;
2203
2204    async fn init<
2205        F: merkle::Family,
2206        H: commonware_cryptography::Hasher,
2207        S: commonware_parallel::Strategy,
2208    >(
2209        context: E,
2210        merkle_cfg: merkle::full::Config<S>,
2211        journal_cfg: Self::Config,
2212        rewind_predicate: fn(&V) -> bool,
2213        bagging: merkle::Bagging,
2214    ) -> Result<authenticated::Journal<F, E, Self, H, S>, authenticated::Error<F>> {
2215        authenticated::Journal::<F, E, Self, H, S>::new(
2216            context,
2217            merkle_cfg,
2218            journal_cfg,
2219            rewind_predicate,
2220            bagging,
2221        )
2222        .await
2223    }
2224}
2225
2226#[cfg(test)]
2227impl<E: Context, V: CodecShared> Journal<E, V> {
2228    /// Test helper: Prune the data blobs directly (simulates crash scenario).
2229    pub(crate) async fn test_prune_data(&mut self, min_blob: u64) -> Result<bool, Error> {
2230        let min_blob = min_blob.min(self.blobs.tail_blob_index());
2231        if min_blob <= self.blobs.oldest_blob_index() {
2232            return Ok(false);
2233        }
2234        self.blobs.prune(min_blob).await?;
2235        Ok(true)
2236    }
2237
2238    /// Test helper: Prune the internal offsets journal directly (simulates crash scenario).
2239    pub(crate) async fn test_prune_offsets(&mut self, position: u64) -> Result<bool, Error> {
2240        self.offsets.prune(position).await
2241    }
2242
2243    /// Test helper: Rewind the internal offsets journal directly (simulates crash scenario).
2244    pub(crate) async fn test_rewind_offsets(&mut self, position: u64) -> Result<(), Error> {
2245        self.offsets.rewind(position).await
2246    }
2247
2248    /// Test helper: Set and persist the offsets recovery watermark directly.
2249    pub(crate) async fn test_set_offsets_recovery_watermark(
2250        &mut self,
2251        watermark: u64,
2252    ) -> Result<(), Error> {
2253        self.offsets.test_set_recovery_watermark(watermark).await
2254    }
2255
2256    /// Test helper: Get the size of the internal offsets journal.
2257    pub(crate) fn test_offsets_size(&self) -> u64 {
2258        self.offsets.size()
2259    }
2260
2261    /// Test helper: Rewind the data blobs to the item at `position` (simulates crash scenario).
2262    pub(crate) async fn test_rewind_data_to_position(
2263        &mut self,
2264        position: u64,
2265    ) -> Result<(), Error> {
2266        let offset = self.offsets.read(position).await?;
2267        let blob = position_to_blob(position, self.items_per_blob.get());
2268        if blob == self.blobs.tail_blob_index() {
2269            self.blobs.rewind_tail(offset).await
2270        } else {
2271            self.blobs.rewind_into_sealed(blob, offset).await
2272        }
2273    }
2274
2275    /// Test helper: Append directly to the data blobs without indexing (simulates crash
2276    /// scenario). The target must be the tail blob or a newer one (created as an orphan).
2277    pub(crate) async fn test_append_data(
2278        &mut self,
2279        blob: u64,
2280        item: V,
2281    ) -> Result<(u64, u32), Error> {
2282        let mut encoded = Vec::new();
2283        encode_frame_into(self.compression, &item, &mut encoded)?;
2284        let item_len = encoded.len() as u32;
2285
2286        let tail_blob = self.blobs.tail_blob_index();
2287        if blob == tail_blob {
2288            let writer = self.blobs.tail_writer();
2289            let offset = writer.size();
2290            writer.append(&encoded).await.map_err(Error::Runtime)?;
2291            return Ok((offset, item_len));
2292        }
2293        assert!(blob > tail_blob, "cannot append to a sealed blob");
2294        let mut writer = self.blobs.open_blob(blob).await?;
2295        let offset = writer.size();
2296        writer.append(&encoded).await.map_err(Error::Runtime)?;
2297        writer.sync().await.map_err(Error::Runtime)?;
2298        Ok((offset, item_len))
2299    }
2300
2301    /// Test helper: Sync the data tail.
2302    pub(crate) async fn test_sync_data(&mut self) -> Result<(), Error> {
2303        self.blobs.sync_from(self.blobs.tail_blob_index()).await
2304    }
2305
2306    /// Test helper: Sync one data blob.
2307    pub(crate) async fn test_sync_data_blob(&mut self, blob: u64) -> Result<(), Error> {
2308        self.blobs.sync_blob(blob).await
2309    }
2310}
2311
2312#[cfg(test)]
2313mod tests {
2314    use super::*;
2315    use crate::journal::contiguous::tests::run_contiguous_tests;
2316    use commonware_macros::test_traced;
2317    use commonware_runtime::{
2318        buffer::paged::{CacheRef, Writer},
2319        deterministic, Metrics as _, Runner, Spawner as _, Storage, Supervisor as _,
2320    };
2321    use commonware_utils::{sequence::FixedBytes, NZUsize, NZU16, NZU64};
2322    use futures::{FutureExt as _, StreamExt as _};
2323    use std::num::NonZeroU16;
2324
2325    // Use some jank sizes to exercise boundary conditions.
2326    const PAGE_SIZE: NonZeroU16 = NZU16!(101);
2327    const PAGE_CACHE_SIZE: usize = 2;
2328    // Larger page sizes for tests that need more buffer space.
2329    const LARGE_PAGE_SIZE: NonZeroU16 = NZU16!(1024);
2330    const SMALL_PAGE_SIZE: NonZeroU16 = NZU16!(512);
2331
2332    /// Extract a metric counter's value from encoded metrics output.
2333    fn counter(buffer: &str, name: &str) -> u64 {
2334        buffer
2335            .lines()
2336            .find(|l| l.contains(name) && !l.starts_with('#'))
2337            .and_then(|l| l.split_whitespace().last())
2338            .and_then(|v| v.parse().ok())
2339            .expect("counter missing")
2340    }
2341
2342    #[test_traced]
2343    fn test_variable_init_syncs_adopted_data_before_offsets_watermark_advance() {
2344        let executor = deterministic::Runner::default();
2345        executor.start(|context| async move {
2346            let cfg = Config {
2347                partition: "init-adopted-variable".into(),
2348                items_per_section: NZU64!(10),
2349                compression: None,
2350                codec_config: (),
2351                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
2352                write_buffer: NZUsize!(1024),
2353            };
2354
2355            let mut journal =
2356                Journal::<_, FixedBytes<32>>::init(context.child("first"), cfg.clone())
2357                    .await
2358                    .unwrap();
2359            journal.append(&FixedBytes::new([1; 32])).await.unwrap();
2360            journal.append(&FixedBytes::new([2; 32])).await.unwrap();
2361            journal.sync().await.unwrap();
2362            // Simulate the state left by a crash after item 2's data became visible to recovery,
2363            // but before the offsets journal's recovery watermark advanced past item 1.
2364            journal
2365                .test_set_offsets_recovery_watermark(1)
2366                .await
2367                .unwrap();
2368            drop(journal);
2369
2370            // Regression: init used to rebuild and sync offsets through item 2 without first
2371            // syncing the adopted data range they point at. A sync fault scoped only to the data
2372            // partition would therefore be missed. With the fix, init must sync data before the
2373            // rebuilt offsets become durable, so this reopen fails.
2374            let data_partition = format!("{}{}", cfg.partition, DATA_SUFFIX);
2375            let context = commonware_runtime::mocks::SyncFaultContext {
2376                inner: context,
2377                fail_partition: data_partition,
2378            };
2379            assert!(
2380                Journal::<_, FixedBytes<32>>::init(context.child("second"), cfg.clone())
2381                    .await
2382                    .is_err(),
2383                "init must sync adopted data before advancing rebuilt offsets"
2384            );
2385        });
2386    }
2387
2388    #[test_traced]
2389    fn test_variable_append_many_compressed() {
2390        let executor = deterministic::Runner::default();
2391        executor.start(|context| async move {
2392            let cfg = Config {
2393                partition: "append-many-compressed".into(),
2394                items_per_section: NZU64!(3),
2395                compression: Some(1),
2396                codec_config: (),
2397                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
2398                write_buffer: NZUsize!(1024),
2399            };
2400            let mut journal = Journal::<_, FixedBytes<32>>::init(context.child("journal"), cfg)
2401                .await
2402                .unwrap();
2403            let items = [
2404                FixedBytes::new([0; 32]),
2405                FixedBytes::new([1; 32]),
2406                FixedBytes::new([2; 32]),
2407                FixedBytes::new([3; 32]),
2408                FixedBytes::new([4; 32]),
2409            ];
2410
2411            let last = journal.append_many(Many::Flat(&items)).await.unwrap();
2412            assert_eq!(last, 4);
2413            for (pos, item) in items.iter().enumerate() {
2414                assert_eq!(journal.read(pos as u64).await.unwrap(), *item);
2415            }
2416
2417            journal.destroy().await.unwrap();
2418        });
2419    }
2420
2421    #[test_traced]
2422    fn test_variable_append_many_exceeding_write_buffer_reopens_across_sections() {
2423        let executor = deterministic::Runner::default();
2424        executor.start(|context| async move {
2425            let cfg = Config {
2426                partition: "append-many-exceeds-buffer".into(),
2427                items_per_section: NZU64!(5),
2428                compression: None,
2429                codec_config: (),
2430                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
2431                write_buffer: NZUsize!(512),
2432            };
2433            let items = (0..13)
2434                .map(|i| FixedBytes::new([i as u8; 300]))
2435                .collect::<Vec<_>>();
2436
2437            let mut journal =
2438                Journal::<_, FixedBytes<300>>::init(context.child("first"), cfg.clone())
2439                    .await
2440                    .unwrap();
2441            assert_eq!(journal.append_many(Many::Flat(&items)).await.unwrap(), 12);
2442            journal.sync().await.unwrap();
2443            drop(journal);
2444
2445            let journal = Journal::<_, FixedBytes<300>>::init(context.child("second"), cfg)
2446                .await
2447                .unwrap();
2448            assert_eq!(journal.bounds(), 0..13);
2449            for (pos, item) in items.iter().enumerate() {
2450                assert_eq!(journal.read(pos as u64).await.unwrap(), *item);
2451            }
2452            assert_eq!(
2453                journal.read_many(&[0, 4, 5, 9, 10, 12]).await.unwrap(),
2454                vec![
2455                    items[0].clone(),
2456                    items[4].clone(),
2457                    items[5].clone(),
2458                    items[9].clone(),
2459                    items[10].clone(),
2460                    items[12].clone(),
2461                ]
2462            );
2463
2464            journal.destroy().await.unwrap();
2465        });
2466    }
2467
2468    #[test_traced]
2469    fn test_variable_init_at_max_size_rejected() {
2470        let executor = deterministic::Runner::default();
2471        executor.start(|context| async move {
2472            let cfg = Config {
2473                partition: "init-at-max".into(),
2474                items_per_section: NZU64!(5),
2475                compression: None,
2476                codec_config: (),
2477                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
2478                write_buffer: NZUsize!(1024),
2479            };
2480
2481            // The internal offsets journal rejects a maximal size, so init_at_size propagates it.
2482            assert!(matches!(
2483                Journal::<_, u64>::init_at_size(context.child("max"), cfg, u64::MAX).await,
2484                Err(Error::SizeOverflow)
2485            ));
2486        });
2487    }
2488
2489    #[test_traced]
2490    fn test_variable_append_size_overflow() {
2491        let executor = deterministic::Runner::default();
2492        executor.start(|context| async move {
2493            let cfg = Config {
2494                partition: "append-size-overflow".into(),
2495                items_per_section: NZU64!(5),
2496                compression: None,
2497                codec_config: (),
2498                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
2499                write_buffer: NZUsize!(1024),
2500            };
2501
2502            // Initialize one item shy of the maximum size.
2503            let mut journal =
2504                Journal::<_, u64>::init_at_size(context.child("near_max"), cfg, u64::MAX - 1)
2505                    .await
2506                    .unwrap();
2507
2508            // The first append fills the last representable position.
2509            assert_eq!(journal.append(&7).await.unwrap(), u64::MAX - 1);
2510            assert_eq!(journal.size(), u64::MAX);
2511
2512            // The next append would overflow the size; it must return a recoverable error
2513            // rather than panicking.
2514            assert!(matches!(journal.append(&8).await, Err(Error::SizeOverflow)));
2515
2516            journal.destroy().await.unwrap();
2517        });
2518    }
2519
2520    #[test_traced]
2521    fn test_variable_replay_near_max_size() {
2522        let executor = deterministic::Runner::default();
2523        executor.start(|context| async move {
2524            let cfg = Config {
2525                partition: "replay-near-max-size".into(),
2526                items_per_section: NZU64!(10),
2527                compression: None,
2528                codec_config: (),
2529                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
2530                write_buffer: NZUsize!(1024),
2531            };
2532
2533            let mut journal =
2534                Journal::<_, u64>::init_at_size(context.child("near_max"), cfg, u64::MAX - 1)
2535                    .await
2536                    .unwrap();
2537            assert_eq!(journal.append(&7).await.unwrap(), u64::MAX - 1);
2538
2539            {
2540                let reader = journal.snapshot().await.unwrap();
2541                let stream = reader.replay(u64::MAX - 1, NZUsize!(20)).await.unwrap();
2542                futures::pin_mut!(stream);
2543                let (pos, item) = stream.next().await.unwrap().unwrap();
2544                assert_eq!(pos, u64::MAX - 1);
2545                assert_eq!(item, 7);
2546                assert!(stream.next().await.is_none());
2547            }
2548
2549            journal.destroy().await.unwrap();
2550        });
2551    }
2552
2553    #[test_traced]
2554    fn test_variable_try_read_many_sync_matches_read_many() {
2555        // Cached positions are served synchronously and match the async batched read.
2556        // Positions that fail validation are misses rather than errors.
2557        let executor = deterministic::Runner::default();
2558        executor.start(|context| async move {
2559            let cfg = Config {
2560                partition: "read-many-sync".into(),
2561                items_per_section: NZU64!(5),
2562                compression: None,
2563                codec_config: (),
2564                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(64)),
2565                write_buffer: NZUsize!(1024),
2566            };
2567            let items = (0..13)
2568                .map(|i| FixedBytes::new([i as u8; 300]))
2569                .collect::<Vec<_>>();
2570            let mut journal = Journal::<_, FixedBytes<300>>::init(context.child("j"), cfg)
2571                .await
2572                .unwrap();
2573            journal.append_many(Many::Flat(&items)).await.unwrap();
2574            journal.sync().await.unwrap();
2575
2576            let positions: Vec<u64> = (0..items.len() as u64).collect();
2577            let reader = journal.snapshot().await.unwrap();
2578            // Warm both the offsets and data page caches, then expect every position to be
2579            // served synchronously.
2580            let expected = reader.read_many(&positions).await.unwrap();
2581            let served = reader.try_read_many_sync(&positions);
2582            assert_eq!(served.len(), positions.len());
2583            for (item, expected) in served.iter().zip(&expected) {
2584                assert_eq!(item.as_ref().expect("cached position is served"), expected);
2585            }
2586
2587            // An out-of-range position is a miss, not an error. Positions grouped with it
2588            // (same offsets blob) are unaffected: validation trims the out-of-range suffix
2589            // instead of poisoning the group.
2590            let served = reader.try_read_many_sync(&[9, 13]);
2591            assert!(served[0].is_some());
2592            assert!(served[1].is_none());
2593            drop(served);
2594            drop(reader);
2595
2596            journal.destroy().await.unwrap();
2597        });
2598    }
2599
2600    #[test_traced]
2601    #[should_panic(expected = "positions must be strictly increasing")]
2602    fn test_variable_read_many_rejects_unsorted_positions() {
2603        let executor = deterministic::Runner::default();
2604        executor.start(|context| async move {
2605            let cfg = Config {
2606                partition: "read-many-unsorted".into(),
2607                items_per_section: NZU64!(5),
2608                compression: None,
2609                codec_config: (),
2610                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
2611                write_buffer: NZUsize!(1024),
2612            };
2613            let mut journal = Journal::<_, u64>::init(context.child("j"), cfg)
2614                .await
2615                .unwrap();
2616            for i in 0..5u64 {
2617                journal.append(&(i * 100)).await.unwrap();
2618            }
2619            journal.sync().await.unwrap();
2620
2621            let reader = journal.snapshot().await.unwrap();
2622            let _ = reader.read_many(&[2, 1]).await;
2623        });
2624    }
2625
2626    #[test_traced]
2627    #[should_panic(expected = "positions must be strictly increasing")]
2628    fn test_variable_read_many_rejects_duplicate_positions() {
2629        // Duplicates are not strictly increasing either.
2630        let executor = deterministic::Runner::default();
2631        executor.start(|context| async move {
2632            let cfg = Config {
2633                partition: "read-many-duplicate".into(),
2634                items_per_section: NZU64!(5),
2635                compression: None,
2636                codec_config: (),
2637                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
2638                write_buffer: NZUsize!(1024),
2639            };
2640            let mut journal = Journal::<_, u64>::init(context.child("j"), cfg)
2641                .await
2642                .unwrap();
2643            for i in 0..5u64 {
2644                journal.append(&(i * 100)).await.unwrap();
2645            }
2646            journal.sync().await.unwrap();
2647
2648            let reader = journal.snapshot().await.unwrap();
2649            let _ = reader.read_many(&[1, 1]).await;
2650        });
2651    }
2652
2653    #[test_traced]
2654    fn test_variable_probe_then_read_many_matches_read_many() {
2655        // A probe completed by one batched read over its declined positions returns the same
2656        // items as read_many, cold and warm.
2657        let executor = deterministic::Runner::default();
2658        executor.start(|context| async move {
2659            let cfg = Config {
2660                partition: "read-many-probe-complete".into(),
2661                items_per_section: NZU64!(5),
2662                compression: None,
2663                codec_config: (),
2664                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
2665                write_buffer: NZUsize!(1024),
2666            };
2667            let mut journal = Journal::<_, u64>::init(context.child("j"), cfg)
2668                .await
2669                .unwrap();
2670            for i in 0..12u64 {
2671                journal.append(&(i * 100)).await.unwrap();
2672            }
2673            journal.sync().await.unwrap();
2674
2675            let positions: Vec<u64> = (0..12).collect();
2676            let expected: Vec<u64> = (0..12).map(|i| i * 100).collect();
2677            let reader = journal.snapshot().await.unwrap();
2678            for _ in 0..2 {
2679                let mut served = reader.try_read_many_sync(&positions);
2680                let misses: Vec<u64> = positions
2681                    .iter()
2682                    .zip(&served)
2683                    .filter_map(|(&pos, item)| item.is_none().then_some(pos))
2684                    .collect();
2685                let mut fetched = reader.read_many(&misses).await.unwrap().into_iter();
2686                for item in served.iter_mut().filter(|item| item.is_none()) {
2687                    *item = fetched.next();
2688                }
2689                let completed: Vec<_> = served.into_iter().map(Option::unwrap).collect();
2690                assert_eq!(completed, expected);
2691            }
2692            assert_eq!(reader.read_many(&positions).await.unwrap(), expected);
2693            drop(reader);
2694
2695            journal.destroy().await.unwrap();
2696        });
2697    }
2698
2699    #[test_traced]
2700    fn test_variable_read_many_reuses_probed_offset() {
2701        // read_many's sync pass resolves frame offsets even when the frame itself misses. The
2702        // completion must reuse them instead of consulting the offsets journal again.
2703        let executor = deterministic::Runner::default();
2704        executor.start(|context| async move {
2705            // Sections of 128 make section 0's offsets exactly fill two flushed pages, so a
2706            // section-0 offset lookup can genuinely miss (a sealed blob serves a partial
2707            // trailing page from memory, never the page cache).
2708            let cfg = Config {
2709                partition: "read-many-offset-reuse".into(),
2710                items_per_section: NZU64!(128),
2711                compression: None,
2712                codec_config: (),
2713                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(4)),
2714                write_buffer: NZUsize!(1024),
2715            };
2716            let appended = (0..140)
2717                .map(|i| FixedBytes::new([i as u8; 300]))
2718                .collect::<Vec<_>>();
2719            let mut journal = Journal::<_, FixedBytes<300>>::init(context.child("j"), cfg)
2720                .await
2721                .unwrap();
2722            journal.append_many(Many::Flat(&appended)).await.unwrap();
2723            journal.sync().await.unwrap();
2724            let reader = journal.snapshot().await.unwrap();
2725
2726            // Churn the 4-page pool with section-1 frames (five data pages) so every
2727            // section-0 page is evicted, then warm the offsets page shared by positions
2728            // 0..64 without touching position 0's frame page (302-byte frames put frame 0
2729            // in page 0 and frame 4 in page 2).
2730            reader
2731                .read_many(&(128..136).collect::<Vec<u64>>())
2732                .await
2733                .unwrap();
2734            reader.read(4).await.unwrap();
2735
2736            // The sync pass resolves position 0's offset but cannot serve its frame.
2737            let (items, misses) = reader.probe_parts(&[0]);
2738            assert!(items[0].is_none());
2739            assert_eq!(misses[0].offset, Some(0));
2740
2741            // read_many consults the offsets journal only in its sync pass (position 0 and
2742            // its successor, both cache hits): completion reuses the carried offset rather
2743            // than resolving it again.
2744            let before = context.encode();
2745            assert_eq!(
2746                reader.read_many(&[0]).await.unwrap(),
2747                vec![appended[0].clone()]
2748            );
2749            let after = context.encode();
2750            assert_eq!(
2751                counter(&after, "offsets_items_read_total"),
2752                counter(&before, "offsets_items_read_total") + 2
2753            );
2754            drop(reader);
2755
2756            journal.destroy().await.unwrap();
2757        });
2758    }
2759
2760    #[test_traced]
2761    fn test_variable_read_many_consecutive_after_reopen() {
2762        let executor = deterministic::Runner::default();
2763        executor.start(|context| async move {
2764            let cfg = Config {
2765                partition: "read-many-consecutive-after-reopen".into(),
2766                items_per_section: NZU64!(20),
2767                compression: None,
2768                codec_config: (),
2769                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
2770                write_buffer: NZUsize!(1024),
2771            };
2772
2773            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
2774                .await
2775                .unwrap();
2776            for i in 0..20u64 {
2777                journal.append(&(i * 100)).await.unwrap();
2778            }
2779            journal.sync().await.unwrap();
2780            drop(journal);
2781
2782            let cfg = Config {
2783                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
2784                ..cfg
2785            };
2786            let mut journal = Journal::<_, u64>::init(context.child("second"), cfg)
2787                .await
2788                .unwrap();
2789            let reader = journal.snapshot().await.unwrap();
2790            let positions: Vec<u64> = (3..10).collect();
2791            let items = reader.read_many(&positions).await.unwrap();
2792            assert_eq!(items, vec![300, 400, 500, 600, 700, 800, 900]);
2793            drop(reader);
2794
2795            journal.destroy().await.unwrap();
2796        });
2797    }
2798
2799    #[test_traced]
2800    fn test_variable_read_many_scattered_single_runs_across_blobs() {
2801        // Read a batch where cache hits are interleaved with non-consecutive misses spanning
2802        // several blobs. The misses split into many small runs that are fetched separately, and
2803        // each run's items must land in the correct result slots between the cached items. Every
2804        // item's payload encodes its position, so a wrong run boundary or a misplaced result
2805        // fails the value assertions.
2806        let executor = deterministic::Runner::default();
2807        executor.start(|context| async move {
2808            let cfg = Config {
2809                partition: "read-many-scattered-runs".into(),
2810                items_per_section: NZU64!(5),
2811                compression: None,
2812                codec_config: (),
2813                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(16)),
2814                write_buffer: NZUsize!(1024),
2815            };
2816
2817            // Each item's frame is 302 bytes, so a 5-item blob spans two full 512-byte pages plus
2818            // a partial page. Full pages are served through the page cache and go cold on reopen,
2819            // which is what lets this test stage misses at all.
2820            let items = (0..30)
2821                .map(|i| FixedBytes::new([i as u8; 300]))
2822                .collect::<Vec<_>>();
2823            let mut journal =
2824                Journal::<_, FixedBytes<300>>::init(context.child("first"), cfg.clone())
2825                    .await
2826                    .unwrap();
2827            for item in &items {
2828                journal.append(item).await.unwrap();
2829            }
2830            journal.sync().await.unwrap();
2831            drop(journal);
2832
2833            // Reopen with a fresh page cache so sealed-blob full pages are cold.
2834            let cfg = Config {
2835                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(16)),
2836                ..cfg
2837            };
2838            let mut journal = Journal::<_, FixedBytes<300>>::init(context.child("second"), cfg)
2839                .await
2840                .unwrap();
2841            let reader = journal.snapshot().await.unwrap();
2842
2843            // Warm blobs 1 (positions 5..10) and 3 (positions 15..20) with one read each. The
2844            // faulted pages cover the neighboring positions asserted below.
2845            reader.read(6).await.unwrap();
2846            reader.read(16).await.unwrap();
2847
2848            // Prove the interleave the batch will see: warm-blob positions are sync hits, and the
2849            // scattered positions in blobs 0, 2, and 4 are misses. The hit pass in read_many runs
2850            // before any miss I/O, so this is exactly the hit/miss split the call resolves.
2851            for hit in [5, 6, 15, 17] {
2852                assert!(reader.try_read_sync(hit).is_some(), "position {hit}");
2853            }
2854            let misses = [0, 3, 10, 12, 20, 21, 23];
2855            for miss in misses {
2856                assert!(reader.try_read_sync(miss).is_none(), "position {miss}");
2857            }
2858
2859            // The misses decompose into runs [0], [3], [10], [12], [20, 21], [23]: four single-item
2860            // runs and one consecutive pair across three blobs, with hits interleaved between them.
2861            let positions = [0, 3, 5, 6, 10, 12, 15, 17, 20, 21, 23];
2862            let expected: Vec<_> = positions
2863                .iter()
2864                .map(|&p| items[p as usize].clone())
2865                .collect();
2866            let before = context.encode();
2867            assert_eq!(reader.read_many(&positions).await.unwrap(), expected);
2868
2869            // The batch's hit/miss accounting must match the staged interleave exactly.
2870            let after = context.encode();
2871            assert_eq!(
2872                counter(&after, "second_cache_hits") - counter(&before, "second_cache_hits"),
2873                4
2874            );
2875            assert_eq!(
2876                counter(&after, "second_cache_misses") - counter(&before, "second_cache_misses"),
2877                misses.len() as u64
2878            );
2879
2880            // A second pass serves the now-cached positions through the hit path and must agree.
2881            let before = context.encode();
2882            assert_eq!(reader.read_many(&positions).await.unwrap(), expected);
2883            let after = context.encode();
2884            assert_eq!(
2885                counter(&after, "second_cache_hits") - counter(&before, "second_cache_hits"),
2886                positions.len() as u64
2887            );
2888            assert_eq!(
2889                counter(&after, "second_cache_misses"),
2890                counter(&before, "second_cache_misses")
2891            );
2892            drop(reader);
2893
2894            journal.destroy().await.unwrap();
2895        });
2896    }
2897
2898    /// Test that complete offsets partition loss after pruning is detected as unrecoverable.
2899    ///
2900    /// When the offsets partition is completely lost and the data has been pruned, we cannot
2901    /// rebuild the index with correct position alignment (would require creating placeholder blobs).
2902    /// This is a genuine external failure that should be detected and reported clearly.
2903    #[test_traced]
2904    fn test_variable_offsets_partition_loss_after_prune_unrecoverable() {
2905        let executor = deterministic::Runner::default();
2906        executor.start(|context| async move {
2907            let cfg = Config {
2908                partition: "offsets-loss-after-prune".into(),
2909                items_per_section: NZU64!(10),
2910                compression: None,
2911                codec_config: (),
2912                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
2913                write_buffer: NZUsize!(1024),
2914            };
2915
2916            // === Phase 1: Create journal with data and prune ===
2917            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
2918                .await
2919                .unwrap();
2920
2921            // Append 40 items across 4 blobs (0-3)
2922            for i in 0..40u64 {
2923                journal.append(&(i * 100)).await.unwrap();
2924            }
2925
2926            // Prune to position 20 (removes blobs 0-1, keeps blobs 2-3)
2927            journal.prune(20).await.unwrap();
2928            let bounds = journal.bounds();
2929            assert_eq!(bounds.start, 20);
2930            assert_eq!(bounds.end, 40);
2931
2932            journal.sync().await.unwrap();
2933            drop(journal);
2934
2935            // === Phase 2: Simulate complete offsets partition loss ===
2936            // Remove both the offsets data partition and its metadata partition
2937            context
2938                .remove(&format!("{}-blobs", cfg.offsets_partition()), None)
2939                .await
2940                .expect("Failed to remove offsets blobs partition");
2941            context
2942                .remove(&format!("{}-metadata", cfg.offsets_partition()), None)
2943                .await
2944                .expect("Failed to remove offsets metadata partition");
2945
2946            // === Phase 3: Verify this is detected as unrecoverable ===
2947            let result = Journal::<_, u64>::init(context.child("second"), cfg.clone()).await;
2948            assert!(matches!(result, Err(Error::Corruption(_))));
2949        });
2950    }
2951
2952    /// Test that init aligns state when data is pruned/lost but offsets survives.
2953    ///
2954    /// This handles both:
2955    /// 1. Crash during prune-all (data pruned, offsets not yet)
2956    /// 2. External data partition loss
2957    ///
2958    /// In both cases, we align by pruning offsets to match.
2959    #[test_traced]
2960    fn test_variable_align_data_offsets_mismatch() {
2961        let executor = deterministic::Runner::default();
2962        executor.start(|context| async move {
2963            let cfg = Config {
2964                partition: "data-loss-test".into(),
2965                items_per_section: NZU64!(10),
2966                compression: None,
2967                codec_config: (),
2968                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
2969                write_buffer: NZUsize!(1024),
2970            };
2971
2972            // === Setup: Create journal with data ===
2973            let mut variable = Journal::<_, u64>::init(context.child("first"), cfg.clone())
2974                .await
2975                .unwrap();
2976
2977            // Append 20 items across 2 blobs
2978            for i in 0..20u64 {
2979                variable.append(&(i * 100)).await.unwrap();
2980            }
2981
2982            variable.sync().await.unwrap();
2983            drop(variable);
2984
2985            // === Simulate data loss: Delete data partition but keep offsets ===
2986            context
2987                .remove(&cfg.data_partition(), None)
2988                .await
2989                .expect("Failed to remove data partition");
2990
2991            // === Verify init aligns the mismatch ===
2992            let mut journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
2993                .await
2994                .expect("Should align offsets to match empty data");
2995
2996            // Size should be preserved
2997            assert_eq!(journal.size(), 20);
2998
2999            // But no items remain (both journals pruned)
3000            assert!(journal.bounds().is_empty());
3001
3002            // All reads should fail with ItemPruned
3003            for i in 0..20 {
3004                assert!(matches!(
3005                    journal.read(i).await,
3006                    Err(crate::journal::Error::ItemPruned(_))
3007                ));
3008            }
3009
3010            // Can append new data starting at position 20
3011            let pos = journal.append(&999).await.unwrap();
3012            assert_eq!(pos, 20);
3013            assert_eq!(journal.read(20).await.unwrap(), 999);
3014
3015            journal.destroy().await.unwrap();
3016        });
3017    }
3018
3019    /// Test replay behavior for variable-length items.
3020    #[test_traced]
3021    fn test_variable_replay() {
3022        let executor = deterministic::Runner::default();
3023        executor.start(|context| async move {
3024            let cfg = Config {
3025                partition: "replay".into(),
3026                items_per_section: NZU64!(10),
3027                compression: None,
3028                codec_config: (),
3029                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
3030                write_buffer: NZUsize!(1024),
3031            };
3032
3033            // Initialize journal
3034            let mut journal = Journal::<_, u64>::init(context, cfg).await.unwrap();
3035
3036            // Append 40 items across 4 blobs (0-3)
3037            for i in 0..40u64 {
3038                journal.append(&(i * 100)).await.unwrap();
3039            }
3040
3041            // Test 1: Full replay
3042            {
3043                let reader = journal.snapshot().await.unwrap();
3044                let stream = reader.replay(0, NZUsize!(20)).await.unwrap();
3045                futures::pin_mut!(stream);
3046                for i in 0..40u64 {
3047                    let (pos, item) = stream.next().await.unwrap().unwrap();
3048                    assert_eq!(pos, i);
3049                    assert_eq!(item, i * 100);
3050                }
3051                assert!(stream.next().await.is_none());
3052            }
3053
3054            // Test 2: Partial replay from middle of blob
3055            {
3056                let reader = journal.snapshot().await.unwrap();
3057                let stream = reader.replay(15, NZUsize!(20)).await.unwrap();
3058                futures::pin_mut!(stream);
3059                for i in 15..40u64 {
3060                    let (pos, item) = stream.next().await.unwrap().unwrap();
3061                    assert_eq!(pos, i);
3062                    assert_eq!(item, i * 100);
3063                }
3064                assert!(stream.next().await.is_none());
3065            }
3066
3067            // Test 3: Partial replay from blob boundary
3068            {
3069                let reader = journal.snapshot().await.unwrap();
3070                let stream = reader.replay(20, NZUsize!(20)).await.unwrap();
3071                futures::pin_mut!(stream);
3072                for i in 20..40u64 {
3073                    let (pos, item) = stream.next().await.unwrap().unwrap();
3074                    assert_eq!(pos, i);
3075                    assert_eq!(item, i * 100);
3076                }
3077                assert!(stream.next().await.is_none());
3078            }
3079
3080            // Test 4: Prune and verify replay from pruned
3081            journal.prune(20).await.unwrap();
3082            {
3083                let reader = journal.snapshot().await.unwrap();
3084                let res = reader.replay(0, NZUsize!(20)).await;
3085                assert!(matches!(res, Err(crate::journal::Error::ItemPruned(_))));
3086            }
3087            {
3088                let reader = journal.snapshot().await.unwrap();
3089                let res = reader.replay(19, NZUsize!(20)).await;
3090                assert!(matches!(res, Err(crate::journal::Error::ItemPruned(_))));
3091            }
3092
3093            // Test 5: Replay from exactly at pruning boundary after prune
3094            {
3095                let reader = journal.snapshot().await.unwrap();
3096                let stream = reader.replay(20, NZUsize!(20)).await.unwrap();
3097                futures::pin_mut!(stream);
3098                for i in 20..40u64 {
3099                    let (pos, item) = stream.next().await.unwrap().unwrap();
3100                    assert_eq!(pos, i);
3101                    assert_eq!(item, i * 100);
3102                }
3103                assert!(stream.next().await.is_none());
3104            }
3105
3106            // Test 6: Replay from the end
3107            {
3108                let reader = journal.snapshot().await.unwrap();
3109                let stream = reader.replay(40, NZUsize!(20)).await.unwrap();
3110                futures::pin_mut!(stream);
3111                assert!(stream.next().await.is_none());
3112            }
3113
3114            // Test 7: Replay beyond the end (should error)
3115            {
3116                let reader = journal.snapshot().await.unwrap();
3117                let res = reader.replay(41, NZUsize!(20)).await;
3118                assert!(matches!(
3119                    res,
3120                    Err(crate::journal::Error::ItemOutOfRange(41))
3121                ));
3122            }
3123
3124            journal.destroy().await.unwrap();
3125        });
3126    }
3127
3128    #[test_traced]
3129    fn test_variable_replay_stops_after_error() {
3130        let executor = deterministic::Runner::default();
3131        executor.start(|context| async move {
3132            let cfg = Config {
3133                partition: "replay-stops-after-error".into(),
3134                items_per_section: NZU64!(10),
3135                compression: None,
3136                codec_config: (),
3137                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
3138                write_buffer: NZUsize!(1024),
3139            };
3140
3141            let mut journal = Journal::<_, u64>::init(context.child("journal"), cfg.clone())
3142                .await
3143                .unwrap();
3144            for i in 0..30u64 {
3145                journal.append(&(i * 100)).await.unwrap();
3146            }
3147            journal.sync().await.unwrap();
3148
3149            let (blob, _) = context
3150                .open(&cfg.data_partition(), &1u64.to_be_bytes())
3151                .await
3152                .unwrap();
3153            blob.write_at_sync(0, vec![0xFF; 1]).await.unwrap();
3154
3155            {
3156                let cache = CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10));
3157                let mut writers = Vec::new();
3158                for blob_index in 0..3u64 {
3159                    let (blob, size) = context
3160                        .open(&cfg.data_partition(), &blob_index.to_be_bytes())
3161                        .await
3162                        .unwrap();
3163                    writers.push(
3164                        Writer::new(blob, size, cfg.write_buffer.get(), cache.clone())
3165                            .await
3166                            .unwrap(),
3167                    );
3168                }
3169
3170                let mut states = Vec::new();
3171                for (blob_index, writer) in writers.iter().enumerate() {
3172                    let blob = blob_index as u64;
3173                    states.push(ReplayState::<_, u64> {
3174                        blob,
3175                        replay: Blob::Writer(writer).replay_from(0, NZUsize!(1024)).unwrap(),
3176                        budget: 1024,
3177                        pos: blob * 10,
3178                        end_pos: (blob + 1) * 10,
3179                        offset: 0,
3180                        codec_config: (),
3181                        compressed: false,
3182                        _marker: PhantomData,
3183                    });
3184                }
3185
3186                let stream = crate::journal::contiguous::replay_stream_from_states(states);
3187                futures::pin_mut!(stream);
3188
3189                for i in 0..10u64 {
3190                    let (pos, item) = stream.next().await.unwrap().unwrap();
3191                    assert_eq!(pos, i);
3192                    assert_eq!(item, i * 100);
3193                }
3194                assert!(matches!(
3195                    stream.next().await.unwrap(),
3196                    Err(Error::Corruption(_))
3197                ));
3198                assert!(stream.next().await.is_none());
3199            }
3200
3201            journal.destroy().await.unwrap();
3202        });
3203    }
3204
3205    #[test_traced]
3206    fn test_variable_contiguous() {
3207        let executor = deterministic::Runner::default();
3208        executor.start(|context| async move {
3209            run_contiguous_tests(move |test_name: String, idx: usize| {
3210                let label = test_name.replace('-', "_");
3211                let context = context
3212                    .child("test")
3213                    .with_attribute("name", &label)
3214                    .with_attribute("index", idx);
3215                async move {
3216                    let cfg = Config {
3217                        partition: format!("generic-test-{test_name}"),
3218                        items_per_section: NZU64!(10),
3219                        compression: None,
3220                        codec_config: (),
3221                        page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
3222                        write_buffer: NZUsize!(1024),
3223                    };
3224                    Journal::<_, u64>::init(context, cfg).await
3225                }
3226                .boxed()
3227            })
3228            .await;
3229        });
3230    }
3231
3232    /// Test multiple sequential prunes with Variable-specific guarantees.
3233    #[test_traced]
3234    fn test_variable_multiple_sequential_prunes() {
3235        let executor = deterministic::Runner::default();
3236        executor.start(|context| async move {
3237            let cfg = Config {
3238                partition: "sequential-prunes".into(),
3239                items_per_section: NZU64!(10),
3240                compression: None,
3241                codec_config: (),
3242                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
3243                write_buffer: NZUsize!(1024),
3244            };
3245
3246            let mut journal = Journal::<_, u64>::init(context, cfg).await.unwrap();
3247
3248            // Append items across 4 blobs: [0-9], [10-19], [20-29], [30-39]
3249            for i in 0..40u64 {
3250                journal.append(&(i * 100)).await.unwrap();
3251            }
3252
3253            // Initial state: all items accessible
3254            let bounds = journal.bounds();
3255            assert_eq!(bounds.start, 0);
3256            assert_eq!(bounds.end, 40);
3257
3258            // First prune: remove blob 0 (positions 0-9)
3259            let pruned = journal.prune(10).await.unwrap();
3260            assert!(pruned);
3261
3262            // Variable-specific guarantee: oldest is EXACTLY at blob boundary
3263            assert_eq!(journal.bounds().start, 10);
3264
3265            // Items 0-9 should be pruned, 10+ should be accessible
3266            assert!(matches!(
3267                journal.read(0).await,
3268                Err(crate::journal::Error::ItemPruned(_))
3269            ));
3270            assert_eq!(journal.read(10).await.unwrap(), 1000);
3271            assert_eq!(journal.read(19).await.unwrap(), 1900);
3272
3273            // Second prune: remove blob 1 (positions 10-19)
3274            let pruned = journal.prune(20).await.unwrap();
3275            assert!(pruned);
3276
3277            // Variable-specific guarantee: oldest is EXACTLY at blob boundary
3278            assert_eq!(journal.bounds().start, 20);
3279
3280            // Items 0-19 should be pruned, 20+ should be accessible
3281            assert!(matches!(
3282                journal.read(10).await,
3283                Err(crate::journal::Error::ItemPruned(_))
3284            ));
3285            assert!(matches!(
3286                journal.read(19).await,
3287                Err(crate::journal::Error::ItemPruned(_))
3288            ));
3289            assert_eq!(journal.read(20).await.unwrap(), 2000);
3290            assert_eq!(journal.read(29).await.unwrap(), 2900);
3291
3292            // Third prune: remove blob 2 (positions 20-29)
3293            let pruned = journal.prune(30).await.unwrap();
3294            assert!(pruned);
3295
3296            // Variable-specific guarantee: oldest is EXACTLY at blob boundary
3297            assert_eq!(journal.bounds().start, 30);
3298
3299            // Items 0-29 should be pruned, 30+ should be accessible
3300            assert!(matches!(
3301                journal.read(20).await,
3302                Err(crate::journal::Error::ItemPruned(_))
3303            ));
3304            assert!(matches!(
3305                journal.read(29).await,
3306                Err(crate::journal::Error::ItemPruned(_))
3307            ));
3308            assert_eq!(journal.read(30).await.unwrap(), 3000);
3309            assert_eq!(journal.read(39).await.unwrap(), 3900);
3310
3311            // Size should still be 40 (pruning doesn't affect size)
3312            assert_eq!(journal.size(), 40);
3313
3314            journal.destroy().await.unwrap();
3315        });
3316    }
3317
3318    /// Test that pruning all data and re-initializing preserves positions.
3319    #[test_traced]
3320    fn test_variable_prune_all_then_reinit() {
3321        let executor = deterministic::Runner::default();
3322        executor.start(|context| async move {
3323            let cfg = Config {
3324                partition: "prune-all-reinit".into(),
3325                items_per_section: NZU64!(10),
3326                compression: None,
3327                codec_config: (),
3328                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
3329                write_buffer: NZUsize!(1024),
3330            };
3331
3332            // === Phase 1: Create journal and append data ===
3333            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
3334                .await
3335                .unwrap();
3336
3337            for i in 0..100u64 {
3338                journal.append(&(i * 100)).await.unwrap();
3339            }
3340
3341            let bounds = journal.bounds();
3342            assert_eq!(bounds.end, 100);
3343            assert_eq!(bounds.start, 0);
3344
3345            // === Phase 2: Prune all data ===
3346            let pruned = journal.prune(100).await.unwrap();
3347            assert!(pruned);
3348
3349            // All data is pruned - no items remain
3350            let bounds = journal.bounds();
3351            assert_eq!(bounds.end, 100);
3352            assert!(bounds.is_empty());
3353
3354            // All reads should fail with ItemPruned
3355            for i in 0..100 {
3356                assert!(matches!(
3357                    journal.read(i).await,
3358                    Err(crate::journal::Error::ItemPruned(_))
3359                ));
3360            }
3361
3362            journal.sync().await.unwrap();
3363            drop(journal);
3364
3365            // === Phase 3: Re-init and verify position preserved ===
3366            let mut journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
3367                .await
3368                .unwrap();
3369
3370            // Size should be preserved, but no items remain
3371            let bounds = journal.bounds();
3372            assert_eq!(bounds.end, 100);
3373            assert!(bounds.is_empty());
3374
3375            // All reads should still fail
3376            for i in 0..100 {
3377                assert!(matches!(
3378                    journal.read(i).await,
3379                    Err(crate::journal::Error::ItemPruned(_))
3380                ));
3381            }
3382
3383            // === Phase 4: Append new data ===
3384            // Next append should get position 100
3385            journal.append(&10000).await.unwrap();
3386            let bounds = journal.bounds();
3387            assert_eq!(bounds.end, 101);
3388            // Now we have one item at position 100
3389            assert_eq!(bounds.start, 100);
3390
3391            // Can read the new item
3392            assert_eq!(journal.read(100).await.unwrap(), 10000);
3393
3394            // Old positions still fail
3395            assert!(matches!(
3396                journal.read(99).await,
3397                Err(crate::journal::Error::ItemPruned(_))
3398            ));
3399
3400            journal.destroy().await.unwrap();
3401        });
3402    }
3403
3404    /// Test recovery from crash after data blobs pruned but before offsets journal.
3405    #[test_traced]
3406    fn test_variable_recovery_prune_crash_offsets_behind() {
3407        let executor = deterministic::Runner::default();
3408        executor.start(|context| async move {
3409            // === Setup: Create Variable wrapper with data ===
3410            let cfg = Config {
3411                partition: "recovery-prune-crash".into(),
3412                items_per_section: NZU64!(10),
3413                compression: None,
3414                codec_config: (),
3415                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
3416                write_buffer: NZUsize!(1024),
3417            };
3418
3419            let mut variable = Journal::<_, u64>::init(context.child("first"), cfg.clone())
3420                .await
3421                .unwrap();
3422
3423            // Append 40 items across 4 blobs to both journals
3424            for i in 0..40u64 {
3425                variable.append(&(i * 100)).await.unwrap();
3426            }
3427
3428            // Prune to position 10 normally (both data and offsets journals pruned)
3429            variable.prune(10).await.unwrap();
3430            assert_eq!(variable.bounds().start, 10);
3431
3432            // === Simulate crash: Prune data blobs but not offsets journal ===
3433            // Manually prune data blobs to blob 2 (position 20)
3434            variable.test_prune_data(2).await.unwrap();
3435            // Offsets journal still has data from position 10-19
3436
3437            variable.sync().await.unwrap();
3438            drop(variable);
3439
3440            // === Verify recovery ===
3441            let variable = Journal::<_, u64>::init(context.child("second"), cfg.clone())
3442                .await
3443                .unwrap();
3444
3445            // Init should auto-repair: offsets journal pruned to match data blobs
3446            let bounds = variable.bounds();
3447            assert_eq!(bounds.start, 20);
3448            assert_eq!(bounds.end, 40);
3449
3450            // Reads before position 20 should fail (pruned from both journals)
3451            assert!(matches!(
3452                variable.read(10).await,
3453                Err(crate::journal::Error::ItemPruned(_))
3454            ));
3455
3456            // Reads at position 20+ should succeed
3457            assert_eq!(variable.read(20).await.unwrap(), 2000);
3458            assert_eq!(variable.read(39).await.unwrap(), 3900);
3459
3460            variable.destroy().await.unwrap();
3461        });
3462    }
3463
3464    /// A crash after data pruning but before offsets pruning must remain recoverable even when
3465    /// the last durable offsets end is below the new data boundary.
3466    #[test_traced]
3467    fn test_variable_recovery_prune_crash_offsets_end_behind() {
3468        let executor = deterministic::Runner::default();
3469        executor.start(|context| async move {
3470            let cfg = Config {
3471                partition: "recovery-prune-offsets-end-behind".into(),
3472                items_per_section: NZU64!(10),
3473                compression: None,
3474                codec_config: (),
3475                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
3476                write_buffer: NZUsize!(1024),
3477            };
3478
3479            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
3480                .await
3481                .unwrap();
3482
3483            // Persist offsets only through position 7, then append enough unsynced items for a
3484            // prune to advance the data boundary beyond that durable offsets end.
3485            for i in 0..7u64 {
3486                journal.append(&(i * 100)).await.unwrap();
3487            }
3488            journal.sync().await.unwrap();
3489            for i in 7..12u64 {
3490                journal.append(&(i * 100)).await.unwrap();
3491            }
3492
3493            // Drop the production prune future while it is parked after the data-blob
3494            // removal, before offsets.prune has made the appended offsets durable: a
3495            // genuine cancellation at that await.
3496            journal.halt_before_offsets_prune = true;
3497            {
3498                let fut = journal.prune(10);
3499                futures::pin_mut!(fut);
3500                assert!(
3501                    futures::poll!(fut.as_mut()).is_pending(),
3502                    "prune must park before offsets.prune"
3503                );
3504            }
3505            drop(journal);
3506
3507            let journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
3508                .await
3509                .expect("prune crash must leave a recoverable journal");
3510            assert_eq!(journal.bounds(), 10..12);
3511            for i in 10..12u64 {
3512                assert_eq!(journal.read(i).await.unwrap(), i * 100);
3513            }
3514            journal.destroy().await.unwrap();
3515        });
3516    }
3517
3518    /// Test recovery detects corruption when offsets journal pruned ahead of data blobs.
3519    ///
3520    /// Simulates an impossible state (offsets journal pruned more than data blobs) which
3521    /// should never happen due to write ordering. Verifies that init() returns corruption error.
3522    #[test_traced]
3523    fn test_variable_recovery_offsets_ahead_corruption() {
3524        let executor = deterministic::Runner::default();
3525        executor.start(|context| async move {
3526            // === Setup: Create Variable wrapper with data ===
3527            let cfg = Config {
3528                partition: "recovery-offsets-ahead".into(),
3529                items_per_section: NZU64!(10),
3530                compression: None,
3531                codec_config: (),
3532                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
3533                write_buffer: NZUsize!(1024),
3534            };
3535
3536            let mut variable = Journal::<_, u64>::init(context.child("first"), cfg.clone())
3537                .await
3538                .unwrap();
3539
3540            // Append 40 items across 4 blobs to both journals
3541            for i in 0..40u64 {
3542                variable.append(&(i * 100)).await.unwrap();
3543            }
3544
3545            // Prune offsets journal ahead of data blobs (impossible state)
3546            variable.test_prune_offsets(20).await.unwrap(); // Prune to position 20
3547            variable.test_prune_data(1).await.unwrap(); // Only prune data blobs to blob 1 (position 10)
3548
3549            variable.sync().await.unwrap();
3550            drop(variable);
3551
3552            // === Verify corruption detected ===
3553            let result = Journal::<_, u64>::init(context.child("second"), cfg.clone()).await;
3554            assert!(matches!(result, Err(Error::Corruption(_))));
3555        });
3556    }
3557
3558    /// Offsets journal is empty but in a different blob than data. This is an impossible state:
3559    /// both journals are always created in the same blob by init or init_at_size.
3560    #[test_traced]
3561    fn test_variable_recovery_offsets_empty_different_blob_is_corruption() {
3562        let executor = deterministic::Runner::default();
3563        executor.start(|context| async move {
3564            let cfg = Config {
3565                partition: "offsets-empty-diff-blob".into(),
3566                items_per_section: NZU64!(10),
3567                compression: None,
3568                codec_config: (),
3569                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
3570                write_buffer: NZUsize!(1024),
3571            };
3572
3573            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
3574                .await
3575                .unwrap();
3576
3577            for i in 0..15u64 {
3578                journal.append(&(i * 100)).await.unwrap();
3579            }
3580            journal.sync().await.unwrap();
3581
3582            // Clear offsets to blob 2 (position 20) while data starts at blob 0.
3583            // This puts them in different blobs with offsets empty (bounds 20..20).
3584            journal.offsets.clear_to_size(20).await.unwrap();
3585            drop(journal);
3586
3587            let result = Journal::<_, u64>::init(context.child("second"), cfg.clone()).await;
3588            assert!(matches!(result, Err(Error::Corruption(_))));
3589        });
3590    }
3591
3592    /// Offsets journal ends before data oldest position (offsets_bounds.end < data_oldest_pos).
3593    /// This is an impossible/corrupted state.
3594    #[test_traced]
3595    fn test_variable_recovery_offsets_end_behind_data_oldest_is_corruption() {
3596        let executor = deterministic::Runner::default();
3597        executor.start(|context| async move {
3598            let cfg = Config {
3599                partition: "offsets-end-behind-data-oldest".into(),
3600                items_per_section: NZU64!(10),
3601                compression: None,
3602                codec_config: (),
3603                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
3604                write_buffer: NZUsize!(1024),
3605            };
3606
3607            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
3608                .await
3609                .unwrap();
3610
3611            for i in 0..15u64 {
3612                journal.append(&(i * 100)).await.unwrap();
3613            }
3614            journal.sync().await.unwrap();
3615
3616            // Prune data to blob 1 (position 10), but rewind offsets to 5 (so offsets_bounds is 0..5).
3617            // offsets_bounds.end = 5 < data_oldest_pos = 10.
3618            journal.test_prune_data(1).await.unwrap();
3619            journal.test_rewind_offsets(5).await.unwrap();
3620            drop(journal);
3621
3622            let result = Journal::<_, u64>::init(context.child("second"), cfg.clone()).await;
3623            assert!(matches!(result, Err(Error::Corruption(_))));
3624        });
3625    }
3626
3627    /// Offsets start is mid-blob ahead of data's blob-aligned start, but in the same
3628    /// blob. This is the valid state left by init_at_size.
3629    #[test_traced]
3630    fn test_variable_recovery_offsets_start_mid_blob_ahead_of_data() {
3631        let executor = deterministic::Runner::default();
3632        executor.start(|context| async move {
3633            let cfg = Config {
3634                partition: "offsets-mid-blob-ahead".into(),
3635                items_per_section: NZU64!(10),
3636                compression: None,
3637                codec_config: (),
3638                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
3639                write_buffer: NZUsize!(1024),
3640            };
3641
3642            // init_at_size(7) creates offsets starting at position 7 (mid-blob 0), while
3643            // data's first blob is blob 0 (position 0). offsets.start > data_oldest_pos
3644            // but same blob.
3645            let mut journal =
3646                Journal::<_, u64>::init_at_size(context.child("first"), cfg.clone(), 7)
3647                    .await
3648                    .unwrap();
3649            for i in 0..5u64 {
3650                journal.append(&(i * 100)).await.unwrap();
3651            }
3652            journal.sync().await.unwrap();
3653            drop(journal);
3654
3655            let journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
3656                .await
3657                .unwrap();
3658            assert_eq!(journal.bounds(), 7..12);
3659            assert_eq!(journal.read(7).await.unwrap(), 0);
3660            assert_eq!(journal.read(11).await.unwrap(), 400);
3661            journal.destroy().await.unwrap();
3662        });
3663    }
3664
3665    /// The offsets recovery watermark is below the offsets pruning boundary. This can happen if
3666    /// prune moved the boundary forward but sync (which advances the watermark) didn't run.
3667    /// Recovery falls back to rebuilding from the offsets start.
3668    #[test_traced]
3669    fn test_variable_recovery_watermark_below_offsets_start() {
3670        let executor = deterministic::Runner::default();
3671        executor.start(|context| async move {
3672            let cfg = Config {
3673                partition: "watermark-below-start".into(),
3674                items_per_section: NZU64!(10),
3675                compression: None,
3676                codec_config: (),
3677                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
3678                write_buffer: NZUsize!(1024),
3679            };
3680
3681            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
3682                .await
3683                .unwrap();
3684            for i in 0..25u64 {
3685                journal.append(&(i * 100)).await.unwrap();
3686            }
3687            journal.sync().await.unwrap();
3688
3689            // Prune to blob 1 (position 10), then set watermark below the new start.
3690            journal.prune(10).await.unwrap();
3691            journal
3692                .test_set_offsets_recovery_watermark(5)
3693                .await
3694                .unwrap();
3695            drop(journal);
3696
3697            // Recovery detects stale watermark and rebuilds from offsets start.
3698            let journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
3699                .await
3700                .unwrap();
3701            assert_eq!(journal.bounds(), 10..25);
3702            assert_eq!(journal.read(10).await.unwrap(), 1000);
3703            assert_eq!(journal.read(24).await.unwrap(), 2400);
3704            journal.destroy().await.unwrap();
3705        });
3706    }
3707
3708    /// Test recovery from crash after appending to data blobs but before appending to offsets journal.
3709    #[test_traced]
3710    fn test_variable_recovery_append_crash_offsets_behind() {
3711        let executor = deterministic::Runner::default();
3712        executor.start(|context| async move {
3713            // === Setup: Create Variable wrapper with partial data ===
3714            let cfg = Config {
3715                partition: "recovery-append-crash".into(),
3716                items_per_section: NZU64!(10),
3717                compression: None,
3718                codec_config: (),
3719                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
3720                write_buffer: NZUsize!(1024),
3721            };
3722
3723            let mut variable = Journal::<_, u64>::init(context.child("first"), cfg.clone())
3724                .await
3725                .unwrap();
3726
3727            // Append 15 items to both journals (fills blob 0, partial blob 1)
3728            for i in 0..15u64 {
3729                variable.append(&(i * 100)).await.unwrap();
3730            }
3731
3732            assert_eq!(variable.size(), 15);
3733
3734            // Manually append 5 more items directly to data blobs only
3735            for i in 15..20u64 {
3736                variable.test_append_data(1, i * 100).await.unwrap();
3737            }
3738            // Offsets journal still has only 15 entries
3739
3740            variable.sync().await.unwrap();
3741            drop(variable);
3742
3743            // === Verify recovery ===
3744            let variable = Journal::<_, u64>::init(context.child("second"), cfg.clone())
3745                .await
3746                .unwrap();
3747
3748            // Init should rebuild offsets journal from data blobs replay
3749            let bounds = variable.bounds();
3750            assert_eq!(bounds.end, 20);
3751            assert_eq!(bounds.start, 0);
3752
3753            // All items should be readable from both journals
3754            for i in 0..20u64 {
3755                assert_eq!(variable.read(i).await.unwrap(), i * 100);
3756            }
3757
3758            // Offsets journal should be fully rebuilt to match data blobs
3759            assert_eq!(variable.test_offsets_size(), 20);
3760
3761            variable.destroy().await.unwrap();
3762        });
3763    }
3764
3765    #[test_traced]
3766    fn test_variable_recovery_rejects_overlong_data_blob() {
3767        let executor = deterministic::Runner::default();
3768        executor.start(|context| async move {
3769            let cfg = Config {
3770                partition: "recovery-overlong-data-blob".into(),
3771                items_per_section: NZU64!(10),
3772                compression: None,
3773                codec_config: (),
3774                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
3775                write_buffer: NZUsize!(1024),
3776            };
3777
3778            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
3779                .await
3780                .unwrap();
3781
3782            for i in 0..11u64 {
3783                journal.test_append_data(0, i * 100).await.unwrap();
3784            }
3785            journal.test_sync_data().await.unwrap();
3786            drop(journal);
3787
3788            let result = Journal::<_, u64>::init(context.child("second"), cfg.clone()).await;
3789            assert!(matches!(result, Err(Error::Corruption(_))));
3790        });
3791    }
3792
3793    /// Over-capacity non-newest data blob detected during offset rebuild replay.
3794    /// The preflight check (`items_in_newest`) only validates the newest blob. This test
3795    /// overfills blob 0, adds a valid blob 1, and leaves offsets empty so rebuild_offsets
3796    /// replays from blob 0 and hits the over-capacity branch.
3797    #[test_traced]
3798    fn test_variable_recovery_rejects_over_capacity_non_newest_blob() {
3799        let executor = deterministic::Runner::default();
3800        executor.start(|context| async move {
3801            let cfg = Config {
3802                partition: "recovery-over-capacity-non-newest".into(),
3803                items_per_section: NZU64!(10),
3804                compression: None,
3805                codec_config: (),
3806                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
3807                write_buffer: NZUsize!(1024),
3808            };
3809
3810            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
3811                .await
3812                .unwrap();
3813
3814            // Overfill blob 0 with 11 items (capacity is 10).
3815            for i in 0..11u64 {
3816                journal.test_append_data(0, i * 100).await.unwrap();
3817            }
3818            // Sync blob 0 so the data survives reopen, then add one valid item in blob 1
3819            // (synced on creation) so blob 0 is not the newest.
3820            journal.test_sync_data().await.unwrap();
3821            journal.test_append_data(1, 9999).await.unwrap();
3822            // Offsets is empty, so rebuild replays from blob 0.
3823            drop(journal);
3824
3825            let result = Journal::<_, u64>::init(context.child("second"), cfg.clone()).await;
3826            assert!(matches!(result, Err(Error::Corruption(_))));
3827        });
3828    }
3829
3830    #[test_traced]
3831    fn test_variable_recovery_handles_multiple_empty_data_tail_blobs() {
3832        let executor = deterministic::Runner::default();
3833        executor.start(|context| async move {
3834            let cfg = Config::<()> {
3835                partition: "recovery-empty-data-tail".into(),
3836                items_per_section: NZU64!(1),
3837                compression: None,
3838                codec_config: (),
3839                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
3840                write_buffer: NZUsize!(1024),
3841            };
3842            let mut journal = Journal::<_, u64>::init(context.child("journal"), cfg.clone())
3843                .await
3844                .unwrap();
3845
3846            // First persist a prefix, then append across multiple blob
3847            // boundaries without syncing. The unsynced item bytes are lost when
3848            // the journal is dropped, but their blob files remain visible.
3849            assert_eq!(journal.append(&10).await.unwrap(), 0);
3850            journal.sync().await.unwrap();
3851            assert_eq!(journal.append(&20).await.unwrap(), 1);
3852            assert_eq!(journal.append(&30).await.unwrap(), 2);
3853            drop(journal);
3854
3855            let data_partition = cfg.data_partition();
3856            let data_blobs = context.scan(&data_partition).await.unwrap();
3857            assert_eq!(data_blobs.len(), 4);
3858            for name in &data_blobs[1..] {
3859                let (_blob, size) = context.open(&data_partition, name).await.unwrap();
3860                assert_eq!(size, 0);
3861            }
3862
3863            // Recovery should trim only the empty trailing blobs, preserving
3864            // the durable prefix.
3865            let cfg = Config {
3866                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
3867                ..cfg
3868            };
3869            let mut journal = Journal::<_, u64>::init(context.child("recovered"), cfg.clone())
3870                .await
3871                .unwrap();
3872            assert_eq!(journal.bounds(), 0..1);
3873            assert_eq!(journal.read(0).await.unwrap(), 10);
3874            assert_eq!(journal.append(&42).await.unwrap(), 1);
3875            assert_eq!(journal.read(1).await.unwrap(), 42);
3876            drop(journal);
3877
3878            // Recovery should have removed the empty trailing blobs, leaving the durable
3879            // prefix's blob, the one written above, and the fresh empty tail.
3880            let data_blobs = context.scan(&cfg.data_partition()).await.unwrap();
3881            assert_eq!(data_blobs.len(), 3);
3882
3883            let journal = Journal::<_, u64>::init(context.child("recovered"), cfg)
3884                .await
3885                .unwrap();
3886            journal.destroy().await.unwrap();
3887        });
3888    }
3889
3890    #[test_traced]
3891    fn test_variable_recovery_handles_empty_data_with_no_durable_items() {
3892        let executor = deterministic::Runner::default();
3893        executor.start(|context| async move {
3894            let cfg = Config::<()> {
3895                partition: "recovery-empty-data-no-items".into(),
3896                items_per_section: NZU64!(1),
3897                compression: None,
3898                codec_config: (),
3899                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
3900                write_buffer: NZUsize!(1024),
3901            };
3902            let mut journal = Journal::<_, u64>::init(context.child("journal"), cfg.clone())
3903                .await
3904                .unwrap();
3905
3906            // Append across multiple blob boundaries without ever syncing. Each
3907            // append opens a fresh blob blob, but no item bytes (and no offsets)
3908            // become durable, so recovery sees multiple empty blobs and no
3909            // durable data.
3910            assert_eq!(journal.append(&10).await.unwrap(), 0);
3911            assert_eq!(journal.append(&20).await.unwrap(), 1);
3912            drop(journal);
3913
3914            let data_partition = cfg.data_partition();
3915            let data_blobs = context.scan(&data_partition).await.unwrap();
3916            assert_eq!(data_blobs.len(), 3);
3917            for name in &data_blobs {
3918                let (_blob, size) = context.open(&data_partition, name).await.unwrap();
3919                assert_eq!(size, 0);
3920            }
3921
3922            let cfg = Config {
3923                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
3924                ..cfg
3925            };
3926            let mut journal = Journal::<_, u64>::init(context.child("recovered"), cfg)
3927                .await
3928                .unwrap();
3929            assert_eq!(journal.bounds(), 0..0);
3930            assert_eq!(journal.append(&42).await.unwrap(), 0);
3931            assert_eq!(journal.read(0).await.unwrap(), 42);
3932            journal.destroy().await.unwrap();
3933        });
3934    }
3935
3936    /// Test that a durable data blob above the sync watermark, sitting beyond an empty
3937    /// intermediate blob, is rolled back to the contiguous boundary during recovery.
3938    ///
3939    /// Since #3790 removed the append-time sync when crossing blob boundaries, a process crash can
3940    /// leave a later data blob incidentally durable (its page-cache writes survived) while an
3941    /// earlier blob stayed buffered and was lost, producing a physical gap. Recovery anchors at
3942    /// the durable watermark and replays the data forward with a strict blob-contiguity check, so
3943    /// the post-gap blob is truncated and recovery returns only the synced prefix.
3944    #[test_traced]
3945    fn test_variable_recovery_rolls_back_durable_blob_after_gap() {
3946        let executor = deterministic::Runner::default();
3947        executor.start(|context| async move {
3948            let cfg = Config {
3949                partition: "recovery-rollback-after-gap".into(),
3950                items_per_section: NZU64!(10),
3951                compression: None,
3952                codec_config: (),
3953                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
3954                write_buffer: NZUsize!(1024),
3955            };
3956
3957            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
3958                .await
3959                .unwrap();
3960
3961            // Durably commit blob 0 (positions 0..10), advancing the recovery watermark to 10.
3962            for i in 0..10u64 {
3963                journal.append(&(i * 100)).await.unwrap();
3964            }
3965            journal.sync().await.unwrap();
3966
3967            // Append blobs 1 and 2 without committing. Manually sync only blob 2's data blob
3968            // to mimic its page-cache writes surviving a crash, while blob 1 stays buffered and
3969            // the offsets journal is never advanced past position 10.
3970            for i in 10..30u64 {
3971                journal.append(&(i * 100)).await.unwrap();
3972            }
3973            journal.test_sync_data_blob(2).await.unwrap();
3974            drop(journal);
3975
3976            // Durable state: blob 0 (10 items), blob 1 (empty, lost), blob 2 (10
3977            // items), blob 3 (the empty tail).
3978            let data_partition = cfg.data_partition();
3979            let mut names = context.scan(&data_partition).await.unwrap();
3980            names.sort();
3981            assert_eq!(names.len(), 4);
3982            let sizes = {
3983                let mut sizes = Vec::new();
3984                for name in &names {
3985                    let (_blob, size) = context.open(&data_partition, name).await.unwrap();
3986                    sizes.push(size);
3987                }
3988                sizes
3989            };
3990            assert!(sizes[0] > 0, "blob 0 should be durable");
3991            assert_eq!(sizes[1], 0, "blob 1 should be the gap");
3992            assert!(sizes[2] > 0, "blob 2 should be incidentally durable");
3993            assert_eq!(sizes[3], 0, "blob 3 should be the empty tail");
3994
3995            // Recovery rolls back to the watermark boundary: only the synced prefix survives and the
3996            // gapped blob 2 is truncated away.
3997            let mut journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
3998                .await
3999                .unwrap();
4000            assert_eq!(journal.bounds(), 0..10);
4001            for i in 0..10u64 {
4002                assert_eq!(journal.read(i).await.unwrap(), i * 100);
4003            }
4004            assert!(matches!(
4005                journal.read(10).await,
4006                Err(Error::ItemOutOfRange(10))
4007            ));
4008
4009            // The orphaned blob 2 is gone. The repair truncates blob 1 in place, so its
4010            // emptied blob remains as the recovered tail.
4011            let data_blobs = context.scan(&cfg.data_partition()).await.unwrap();
4012            assert_eq!(data_blobs.len(), 2);
4013
4014            // Appends resume cleanly from the recovered boundary.
4015            assert_eq!(journal.append(&1234).await.unwrap(), 10);
4016            assert_eq!(journal.read(10).await.unwrap(), 1234);
4017
4018            journal.destroy().await.unwrap();
4019        });
4020    }
4021
4022    /// Test recovery when the oldest data blob is empty but a newer blob still holds
4023    /// durable items and the offsets journal is gone.
4024    ///
4025    /// A contiguous journal can only populate a later blob after filling the earlier one, so an
4026    /// empty oldest blob with a populated newer blob is an orphaned gap. Replaying from the
4027    /// empty oldest blob immediately yields the newer blob's items, which are "ahead" of the
4028    /// expected blob, so recovery truncates everything past the gap and aligns the journal to
4029    /// empty. This regresses a bad invariant that asserted offsets must be non-empty after
4030    /// alignment.
4031    #[test_traced]
4032    fn test_variable_recovery_empty_oldest_blob_orphaned_newer_blob() {
4033        let executor = deterministic::Runner::default();
4034        executor.start(|context| async move {
4035            let cfg = Config {
4036                partition: "recovery-empty-oldest-blob".into(),
4037                items_per_section: NZU64!(10),
4038                compression: None,
4039                codec_config: (),
4040                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
4041                write_buffer: NZUsize!(1024),
4042            };
4043
4044            // Durably persist blobs 0 and 1 (positions 0..20).
4045            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
4046                .await
4047                .unwrap();
4048            for i in 0..20u64 {
4049                journal.append(&(i * 100)).await.unwrap();
4050            }
4051            journal.sync().await.unwrap();
4052            drop(journal);
4053
4054            // Empty the oldest data blob in place, leaving blob 1's items orphaned past the
4055            // gap, then drop the offsets journal so recovery rebuilds from the data alone.
4056            let data_partition = cfg.data_partition();
4057            let mut names = context.scan(&data_partition).await.unwrap();
4058            names.sort();
4059            assert_eq!(names.len(), 3);
4060            let (blob0, size0) = context.open(&data_partition, &names[0]).await.unwrap();
4061            assert!(size0 > 0, "blob 0 should start durable");
4062            blob0.resize(0).await.unwrap();
4063            blob0.sync().await.unwrap();
4064            context
4065                .remove(&format!("{}-blobs", cfg.offsets_partition()), None)
4066                .await
4067                .unwrap();
4068            context
4069                .remove(&format!("{}-metadata", cfg.offsets_partition()), None)
4070                .await
4071                .unwrap();
4072
4073            // Recovery aligns to an empty journal instead of panicking.
4074            let mut journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
4075                .await
4076                .unwrap();
4077            assert_eq!(journal.bounds(), 0..0);
4078            assert!(matches!(
4079                journal.read(0).await,
4080                Err(Error::ItemOutOfRange(0))
4081            ));
4082
4083            // The orphaned newer blob is truncated away and appends resume from position 0.
4084            assert_eq!(journal.append(&42).await.unwrap(), 0);
4085            assert_eq!(journal.read(0).await.unwrap(), 42);
4086            let data_blobs = context.scan(&cfg.data_partition()).await.unwrap();
4087            assert_eq!(
4088                data_blobs.len(),
4089                1,
4090                "orphaned newer blob should be truncated away"
4091            );
4092
4093            journal.destroy().await.unwrap();
4094        });
4095    }
4096
4097    /// Test recovery when the oldest data blob ends at a clean page boundary but is still short.
4098    ///
4099    /// No trailing bytes are repaired in this case: the data blob simply contains fewer complete
4100    /// items than its capacity. Replaying from the start must still detect the jump to the newer
4101    /// blob and truncate it instead of skipping missing logical positions.
4102    #[test_traced]
4103    fn test_variable_recovery_clean_short_oldest_blob_orphaned_newer_blob() {
4104        let executor = deterministic::Runner::default();
4105        executor.start(|context| async move {
4106            let cfg = Config {
4107                partition: "recovery-clean-short-oldest-blob".into(),
4108                items_per_section: NZU64!(64),
4109                compression: None,
4110                codec_config: (),
4111                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
4112                write_buffer: NZUsize!(1024),
4113            };
4114
4115            // Build two durable data blobs. Blob 1 is only reachable if replay incorrectly
4116            // skips the missing tail of blob 0.
4117            let mut journal =
4118                Journal::<_, FixedBytes<31>>::init(context.child("first"), cfg.clone())
4119                    .await
4120                    .unwrap();
4121            for i in 0..128u8 {
4122                journal.append(&FixedBytes::new([i; 31])).await.unwrap();
4123            }
4124            journal.sync().await.unwrap();
4125            drop(journal);
4126
4127            let physical_page_size = LARGE_PAGE_SIZE.get() as u64 + 12;
4128            let items_in_page = LARGE_PAGE_SIZE.get() as u64 / 32;
4129            assert!(items_in_page < cfg.items_per_section.get());
4130
4131            let data_partition = cfg.data_partition();
4132            let mut names = context.scan(&data_partition).await.unwrap();
4133            names.sort();
4134            assert_eq!(names.len(), 3);
4135
4136            // Truncate at a valid physical page boundary. This leaves a clean short data blob,
4137            // not trailing corruption.
4138            let (blob0, size0) = context.open(&data_partition, &names[0]).await.unwrap();
4139            assert!(size0 > physical_page_size);
4140            blob0.resize(physical_page_size).await.unwrap();
4141            blob0.sync().await.unwrap();
4142
4143            // Remove offsets so recovery must rebuild by replaying data and checking blob
4144            // continuity.
4145            context
4146                .remove(&format!("{}-blobs", cfg.offsets_partition()), None)
4147                .await
4148                .unwrap();
4149            context
4150                .remove(&format!("{}-metadata", cfg.offsets_partition()), None)
4151                .await
4152                .unwrap();
4153
4154            // Recovery must stop at the short non-tail blob rather than accepting blob 1's
4155            // items as later logical positions.
4156            let mut journal =
4157                Journal::<_, FixedBytes<31>>::init(context.child("second"), cfg.clone())
4158                    .await
4159                    .unwrap();
4160            assert_eq!(journal.bounds(), 0..items_in_page);
4161            assert_eq!(
4162                journal.read(items_in_page - 1).await.unwrap(),
4163                FixedBytes::new([(items_in_page - 1) as u8; 31])
4164            );
4165            assert!(matches!(
4166                journal.read(items_in_page).await,
4167                Err(Error::ItemOutOfRange(pos)) if pos == items_in_page
4168            ));
4169
4170            let data_blobs = context.scan(&cfg.data_partition()).await.unwrap();
4171            assert_eq!(
4172                data_blobs.len(),
4173                1,
4174                "orphaned newer blob should be truncated away"
4175            );
4176
4177            // Appends resume directly after the recovered prefix.
4178            assert_eq!(
4179                journal.append(&FixedBytes::new([42; 31])).await.unwrap(),
4180                items_in_page
4181            );
4182            assert_eq!(
4183                journal.read(items_in_page).await.unwrap(),
4184                FixedBytes::new([42; 31])
4185            );
4186
4187            journal.destroy().await.unwrap();
4188        });
4189    }
4190
4191    /// Test that a crash partway through a multi-blob sync leaves a contiguous durable prefix
4192    /// that recovery preserves.
4193    ///
4194    /// `flush_dirty_data` syncs dirty data blobs before syncing offsets. This reproduces a
4195    /// crash after blobs 0 and 1 were synced but before blob 2 and the offsets journal were,
4196    /// then asserts recovery keeps exactly the contiguous prefix 0..20.
4197    #[test_traced]
4198    fn test_variable_recovery_partial_sync_loop_keeps_contiguous_prefix() {
4199        let executor = deterministic::Runner::default();
4200        executor.start(|context| async move {
4201            let cfg = Config {
4202                partition: "recovery-partial-sync-loop".into(),
4203                items_per_section: NZU64!(10),
4204                compression: None,
4205                codec_config: (),
4206                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
4207                write_buffer: NZUsize!(1024),
4208            };
4209
4210            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
4211                .await
4212                .unwrap();
4213
4214            // Fill blobs 0 and 1 and partially fill blob 2 (positions 20..25). Nothing is
4215            // synced yet, so only the created blob files are durable, all still empty.
4216            for i in 0..25u64 {
4217                journal.append(&(i * 100)).await.unwrap();
4218            }
4219
4220            // Sync blobs 0 and 1 but not blob 2 (and not offsets), simulating a crash after
4221            // part of a multi-blob sync became durable.
4222            journal.test_sync_data_blob(0).await.unwrap();
4223            journal.test_sync_data_blob(1).await.unwrap();
4224            drop(journal);
4225
4226            // The durable data is exactly the contiguous prefix: blobs 0 and 1 hold items,
4227            // blob 2 is an empty trailing blob, and offsets never synced.
4228            let data_partition = cfg.data_partition();
4229            let mut names = context.scan(&data_partition).await.unwrap();
4230            names.sort();
4231            assert_eq!(names.len(), 3);
4232            for (blob, name) in names.iter().enumerate() {
4233                let (_blob, size) = context.open(&data_partition, name).await.unwrap();
4234                if blob < 2 {
4235                    assert!(size > 0, "blob {blob} should be durable");
4236                } else {
4237                    assert_eq!(size, 0, "blob {blob} should be empty");
4238                }
4239            }
4240
4241            // Recovery trims the empty trailing blob, rebuilds offsets from the durable data, and
4242            // exposes exactly the contiguous prefix 0..20.
4243            let mut journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
4244                .await
4245                .unwrap();
4246            assert_eq!(journal.bounds(), 0..20);
4247            for i in 0..20u64 {
4248                assert_eq!(journal.read(i).await.unwrap(), i * 100);
4249            }
4250            assert!(matches!(
4251                journal.read(20).await,
4252                Err(Error::ItemOutOfRange(20))
4253            ));
4254
4255            // The empty trailing blob is adopted as the tail; appends continue from the
4256            // recovered end.
4257            let data_blobs = context.scan(&cfg.data_partition()).await.unwrap();
4258            assert_eq!(data_blobs.len(), 3);
4259            assert_eq!(journal.append(&2000).await.unwrap(), 20);
4260            assert_eq!(journal.read(20).await.unwrap(), 2000);
4261
4262            journal.destroy().await.unwrap();
4263        });
4264    }
4265
4266    /// Test recovery from multiple prune operations with crash.
4267    #[test_traced]
4268    fn test_variable_recovery_multiple_prunes_crash() {
4269        let executor = deterministic::Runner::default();
4270        executor.start(|context| async move {
4271            // === Setup: Create Variable wrapper with data ===
4272            let cfg = Config {
4273                partition: "recovery-multiple-prunes".into(),
4274                items_per_section: NZU64!(10),
4275                compression: None,
4276                codec_config: (),
4277                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
4278                write_buffer: NZUsize!(1024),
4279            };
4280
4281            let mut variable = Journal::<_, u64>::init(context.child("first"), cfg.clone())
4282                .await
4283                .unwrap();
4284
4285            // Append 50 items across 5 blobs to both journals
4286            for i in 0..50u64 {
4287                variable.append(&(i * 100)).await.unwrap();
4288            }
4289
4290            // Prune to position 10 normally (both data and offsets journals pruned)
4291            variable.prune(10).await.unwrap();
4292            assert_eq!(variable.bounds().start, 10);
4293
4294            // === Simulate crash: Multiple prunes on data blobs, not on offsets journal ===
4295            // Manually prune data blobs to blob 3 (position 30)
4296            variable.test_prune_data(3).await.unwrap();
4297            // Offsets journal still thinks oldest is position 10
4298
4299            variable.sync().await.unwrap();
4300            drop(variable);
4301
4302            // === Verify recovery ===
4303            let variable = Journal::<_, u64>::init(context.child("second"), cfg.clone())
4304                .await
4305                .unwrap();
4306
4307            // Init should auto-repair: offsets journal pruned to match data blobs
4308            let bounds = variable.bounds();
4309            assert_eq!(bounds.start, 30);
4310            assert_eq!(bounds.end, 50);
4311
4312            // Reads before position 30 should fail (pruned from both journals)
4313            assert!(matches!(
4314                variable.read(10).await,
4315                Err(crate::journal::Error::ItemPruned(_))
4316            ));
4317            assert!(matches!(
4318                variable.read(20).await,
4319                Err(crate::journal::Error::ItemPruned(_))
4320            ));
4321
4322            // Reads at position 30+ should succeed
4323            assert_eq!(variable.read(30).await.unwrap(), 3000);
4324            assert_eq!(variable.read(49).await.unwrap(), 4900);
4325
4326            variable.destroy().await.unwrap();
4327        });
4328    }
4329
4330    /// Test recovery when the offsets journal is behind the data blobs.
4331    ///
4332    /// This creates a situation where offsets are missing while the data blobs still contain
4333    /// items across multiple blobs. Verifies that init() rebuilds the offsets suffix across all
4334    /// remaining data blobs.
4335    #[test_traced]
4336    fn test_variable_recovery_offsets_behind_data_multi_blob() {
4337        let executor = deterministic::Runner::default();
4338        executor.start(|context| async move {
4339            // === Setup: Create Variable wrapper with data across multiple blobs ===
4340            let cfg = Config {
4341                partition: "recovery-rewind-crash".into(),
4342                items_per_section: NZU64!(10),
4343                compression: None,
4344                codec_config: (),
4345                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
4346                write_buffer: NZUsize!(1024),
4347            };
4348
4349            let mut variable = Journal::<_, u64>::init(context.child("first"), cfg.clone())
4350                .await
4351                .unwrap();
4352
4353            // Append 25 items across 3 blobs (blob 0: 0-9, blob 1: 10-19, blob 2: 20-24)
4354            for i in 0..25u64 {
4355                variable.append(&(i * 100)).await.unwrap();
4356            }
4357
4358            assert_eq!(variable.size(), 25);
4359
4360            // Keep offsets for positions 0-4, while data still contains all 25 items.
4361            variable.test_rewind_offsets(5).await.unwrap();
4362
4363            variable.sync().await.unwrap();
4364            drop(variable);
4365
4366            // === Verify recovery ===
4367            let mut variable = Journal::<_, u64>::init(context.child("second"), cfg.clone())
4368                .await
4369                .unwrap();
4370
4371            // Init should rebuild offsets[5-24] from data blobs across all 3 blobs
4372            let bounds = variable.bounds();
4373            assert_eq!(bounds.end, 25);
4374            assert_eq!(bounds.start, 0);
4375
4376            // All items should be readable - offsets rebuilt correctly across all blobs
4377            for i in 0..25u64 {
4378                assert_eq!(variable.read(i).await.unwrap(), i * 100);
4379            }
4380
4381            // Verify offsets journal fully rebuilt
4382            assert_eq!(variable.test_offsets_size(), 25);
4383
4384            // Verify next append gets position 25
4385            let pos = variable.append(&2500).await.unwrap();
4386            assert_eq!(pos, 25);
4387            assert_eq!(variable.read(25).await.unwrap(), 2500);
4388
4389            variable.destroy().await.unwrap();
4390        });
4391    }
4392
4393    #[test_traced]
4394    fn test_variable_rebuild_offsets_rejects_anchor_outside_bounds() {
4395        let executor = deterministic::Runner::default();
4396        executor.start(|context| async move {
4397            let offsets_cfg = fixed::Config {
4398                partition: "rebuild-anchor-outside-offsets".into(),
4399                items_per_blob: NZU64!(10),
4400                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
4401                write_buffer: NZUsize!(1024),
4402            };
4403
4404            let partition = Partition::new(
4405                context.child("data"),
4406                "rebuild-anchor-outside-data".into(),
4407                CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
4408                NZUsize!(1024),
4409            );
4410            let mut pending = BTreeMap::new();
4411            pending.insert(0, partition.open(0).await.unwrap());
4412            let mut offsets = fixed::Journal::<_, u64>::init(context.child("offsets"), offsets_cfg)
4413                .await
4414                .unwrap();
4415
4416            let mut encoded = Vec::new();
4417            encode_frame_into(None, &100u64, &mut encoded).unwrap();
4418            pending.get_mut(&0).unwrap().append(&encoded).await.unwrap();
4419            offsets.append(&0).await.unwrap();
4420
4421            let result = Journal::<_, u64>::rebuild_offsets_from_anchor(
4422                &partition,
4423                &mut pending,
4424                &mut offsets,
4425                10,
4426                2,
4427                &(),
4428                false,
4429            )
4430            .await;
4431            assert!(matches!(result, Err(Error::Corruption(_))));
4432
4433            drop(pending);
4434            Partition::<deterministic::Context>::remove_all(
4435                &context,
4436                "rebuild-anchor-outside-data",
4437            )
4438            .await
4439            .unwrap();
4440            offsets.destroy().await.unwrap();
4441        });
4442    }
4443
4444    #[test_traced]
4445    fn test_variable_recovery_rejects_watermark_beyond_retained_data() {
4446        let executor = deterministic::Runner::default();
4447        executor.start(|context| async move {
4448            let cfg = Config {
4449                partition: "recovery-anchor-too-far".into(),
4450                items_per_section: NZU64!(10),
4451                compression: None,
4452                codec_config: (),
4453                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
4454                write_buffer: NZUsize!(1024),
4455            };
4456
4457            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
4458                .await
4459                .unwrap();
4460
4461            for i in 0..20u64 {
4462                journal.append(&(i * 100)).await.unwrap();
4463            }
4464            journal.sync().await.unwrap();
4465
4466            // The offsets watermark is in-bounds, but vouches for acknowledged data that no
4467            // longer exists.
4468            journal
4469                .test_set_offsets_recovery_watermark(15)
4470                .await
4471                .unwrap();
4472            journal.test_rewind_data_to_position(12).await.unwrap();
4473            journal.test_sync_data().await.unwrap();
4474            drop(journal);
4475
4476            // Recovery must preserve the evidence and fail consistently on retry.
4477            for child in ["second", "retry"] {
4478                match Journal::<_, u64>::init(context.child(child), cfg.clone()).await {
4479                    Err(Error::Corruption(message)) => assert_eq!(
4480                        message,
4481                        "offsets recovery watermark 15 exceeds retained data end 12 \
4482                         (offsets bounds 0..20)"
4483                    ),
4484                    Err(error) => panic!("unexpected error: {error}"),
4485                    Ok(_) => panic!("missing acknowledged data was accepted"),
4486                }
4487            }
4488        });
4489    }
4490
4491    #[test_traced]
4492    fn test_variable_recovery_rejects_missing_data_below_watermark() {
4493        let executor = deterministic::Runner::default();
4494        executor.start(|context| async move {
4495            let cfg = Config {
4496                partition: "recovery-short-middle-retry".into(),
4497                items_per_section: NZU64!(10),
4498                compression: None,
4499                codec_config: (),
4500                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
4501                write_buffer: NZUsize!(1024),
4502            };
4503
4504            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
4505                .await
4506                .unwrap();
4507
4508            for i in 0..30u64 {
4509                journal.append(&(i * 100)).await.unwrap();
4510            }
4511            journal.sync().await.unwrap();
4512
4513            // Keep the offsets watermark in bounds and within the retained data end bound, but make
4514            // the data blob that contains the watermark too short to reach it.
4515            journal
4516                .test_set_offsets_recovery_watermark(15)
4517                .await
4518                .unwrap();
4519            journal.test_rewind_data_to_position(12).await.unwrap();
4520            journal.test_sync_data().await.unwrap();
4521            journal.test_append_data(2, 9999).await.unwrap();
4522            journal.test_sync_data().await.unwrap();
4523            drop(journal);
4524
4525            // Rebuilding from watermark 15 cannot skip five items in blob 1 because only
4526            // positions 10 and 11 survive. Recovery must fail without deleting the orphaned
4527            // blob 2, and the same evidence must remain visible on retry.
4528            for child in ["second", "retry"] {
4529                match Journal::<_, u64>::init(context.child(child), cfg.clone()).await {
4530                    Err(Error::Corruption(message)) => assert_eq!(
4531                        message,
4532                        "data blobs shorter than offsets recovery watermark 15"
4533                    ),
4534                    Err(error) => panic!("unexpected error: {error}"),
4535                    Ok(_) => panic!("missing acknowledged data was accepted"),
4536                }
4537            }
4538
4539            let data_blobs = context.scan(&cfg.data_partition()).await.unwrap();
4540            assert_eq!(
4541                data_blobs.len(),
4542                3,
4543                "corruption evidence should not be removed"
4544            );
4545        });
4546    }
4547
4548    #[test_traced]
4549    fn test_variable_rewind_commit_reopen() {
4550        let executor = deterministic::Runner::default();
4551        executor.start(|context| async move {
4552            let cfg = Config {
4553                partition: "rewind-commit-reopen".into(),
4554                items_per_section: NZU64!(10),
4555                compression: None,
4556                codec_config: (),
4557                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
4558                write_buffer: NZUsize!(1024),
4559            };
4560
4561            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
4562                .await
4563                .unwrap();
4564
4565            for i in 0..25u64 {
4566                journal.append(&(i * 100)).await.unwrap();
4567            }
4568            journal.sync().await.unwrap();
4569
4570            journal.rewind(12).await.unwrap();
4571            journal.commit().await.unwrap();
4572            drop(journal);
4573
4574            let journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
4575                .await
4576                .unwrap();
4577            assert_eq!(journal.bounds(), 0..12);
4578            for i in 0..12u64 {
4579                assert_eq!(journal.read(i).await.unwrap(), i * 100);
4580            }
4581            assert!(matches!(
4582                journal.read(12).await,
4583                Err(Error::ItemOutOfRange(12))
4584            ));
4585
4586            journal.destroy().await.unwrap();
4587        });
4588    }
4589
4590    #[test_traced]
4591    fn test_variable_recovery_rejects_synced_data_rewind_to_boundary() {
4592        let executor = deterministic::Runner::default();
4593        executor.start(|context| async move {
4594            let cfg = Config {
4595                partition: "recovery-boundary-data-rewind".into(),
4596                items_per_section: NZU64!(10),
4597                compression: None,
4598                codec_config: (),
4599                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
4600                write_buffer: NZUsize!(1024),
4601            };
4602
4603            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
4604                .await
4605                .unwrap();
4606
4607            for i in 0..20u64 {
4608                journal.append(&(i * 100)).await.unwrap();
4609            }
4610            journal.sync().await.unwrap();
4611
4612            journal.test_rewind_data_to_position(10).await.unwrap();
4613            journal.test_sync_data().await.unwrap();
4614            drop(journal);
4615
4616            // The watermark proves positions through 20 were acknowledged. Losing the second
4617            // blob is corruption, including when the surviving data ends exactly at a boundary.
4618            for child in ["second", "retry"] {
4619                match Journal::<_, u64>::init(context.child(child), cfg.clone()).await {
4620                    Err(Error::Corruption(message)) => assert_eq!(
4621                        message,
4622                        "offsets recovery watermark 20 exceeds retained data end 10 \
4623                         (offsets bounds 0..20)"
4624                    ),
4625                    Err(error) => panic!("unexpected error: {error}"),
4626                    Ok(_) => panic!("missing acknowledged data was accepted"),
4627                }
4628            }
4629        });
4630    }
4631
4632    #[test_traced]
4633    fn test_variable_recovery_truncates_short_data_blob_after_anchor() {
4634        let executor = deterministic::Runner::default();
4635        executor.start(|context| async move {
4636            let cfg = Config {
4637                partition: "recovery-short-blob-after-anchor".into(),
4638                items_per_section: NZU64!(10),
4639                compression: None,
4640                codec_config: (),
4641                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
4642                write_buffer: NZUsize!(1024),
4643            };
4644
4645            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
4646                .await
4647                .unwrap();
4648
4649            for i in 0..25u64 {
4650                journal.append(&(i * 100)).await.unwrap();
4651            }
4652            journal.sync().await.unwrap();
4653
4654            // Simulate a crash after the previous recovery checkpoint where blob 1 was only
4655            // partly durable but blob 2 was present. Recovery should keep the contiguous prefix
4656            // and discard blob 2 rather than treating the blob jump as hard corruption.
4657            journal
4658                .test_set_offsets_recovery_watermark(10)
4659                .await
4660                .unwrap();
4661            let offset = {
4662                let offsets = journal.offsets.snapshot().await.unwrap();
4663                offsets.read(12).await.unwrap()
4664            };
4665            drop(journal);
4666
4667            // Truncate blob 1 in place (keeping blob 2) by reopening its blob directly.
4668            let (blob, size) = context
4669                .open(&cfg.data_partition(), &1u64.to_be_bytes())
4670                .await
4671                .unwrap();
4672            let mut writer = Writer::new(blob, size, 1024, cfg.page_cache.clone())
4673                .await
4674                .unwrap();
4675            writer.resize(offset).await.unwrap();
4676            writer.sync().await.unwrap();
4677            drop(writer);
4678
4679            let journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
4680                .await
4681                .unwrap();
4682            assert_eq!(journal.bounds(), 0..12);
4683            assert_eq!(journal.test_offsets_size(), 12);
4684            for i in 0..12u64 {
4685                assert_eq!(journal.read(i).await.unwrap(), i * 100);
4686            }
4687            assert!(matches!(
4688                journal.read(12).await,
4689                Err(Error::ItemOutOfRange(12))
4690            ));
4691
4692            journal.destroy().await.unwrap();
4693        });
4694    }
4695
4696    #[test_traced]
4697    fn test_variable_init_persists_offsets_trailing_item_repair() {
4698        let executor = deterministic::Runner::default();
4699        let ((offsets_blob_partition, expected_size), checkpoint) =
4700            executor.start_and_recover(|context| async move {
4701                let cfg = Config {
4702                    partition: "offsets-init-repair-sync".into(),
4703                    items_per_section: NZU64!(10),
4704                    compression: None,
4705                    codec_config: (),
4706                    page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
4707                    write_buffer: NZUsize!(1024),
4708                };
4709                let offsets_blob_partition = format!("{}-blobs", cfg.offsets_partition());
4710                let expected_size = 2 * std::mem::size_of::<u64>() as u64;
4711
4712                let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
4713                    .await
4714                    .unwrap();
4715                journal.append(&10).await.unwrap();
4716                journal.append(&20).await.unwrap();
4717                journal.sync().await.unwrap();
4718                drop(journal);
4719
4720                let (blob, raw_size) = context
4721                    .open(&offsets_blob_partition, &0u64.to_be_bytes())
4722                    .await
4723                    .unwrap();
4724                let mut append = Writer::new(
4725                    blob,
4726                    raw_size,
4727                    2048,
4728                    CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
4729                )
4730                .await
4731                .unwrap();
4732                assert_eq!(append.size(), expected_size);
4733                append.resize(expected_size + 1).await.unwrap();
4734                append.sync().await.unwrap();
4735                drop(append);
4736
4737                let journal = Journal::<_, u64>::init(context.child("second"), cfg)
4738                    .await
4739                    .unwrap();
4740                assert_eq!(journal.bounds(), 0..2);
4741                drop(journal);
4742
4743                (offsets_blob_partition, expected_size)
4744            });
4745
4746        deterministic::Runner::from(checkpoint).start(move |context| async move {
4747            let (blob, raw_size) = context
4748                .open(&offsets_blob_partition, &0u64.to_be_bytes())
4749                .await
4750                .unwrap();
4751            let append = Writer::new(
4752                blob,
4753                raw_size,
4754                2048,
4755                CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
4756            )
4757            .await
4758            .unwrap();
4759            assert_eq!(append.size(), expected_size);
4760        });
4761    }
4762
4763    #[test_traced]
4764    fn test_variable_init_persists_data_tail_repair() {
4765        let executor = deterministic::Runner::default();
4766        let ((data_partition, expected_size), checkpoint) =
4767            executor.start_and_recover(|context| async move {
4768                let cfg = Config {
4769                    partition: "data-init-repair-sync".into(),
4770                    items_per_section: NZU64!(10),
4771                    compression: None,
4772                    codec_config: (),
4773                    page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
4774                    write_buffer: NZUsize!(1024),
4775                };
4776                let data_partition = cfg.data_partition();
4777
4778                let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
4779                    .await
4780                    .unwrap();
4781                journal.append(&10).await.unwrap();
4782                journal.append(&20).await.unwrap();
4783                journal.sync().await.unwrap();
4784                drop(journal);
4785
4786                let (blob, raw_size) = context
4787                    .open(&data_partition, &0u64.to_be_bytes())
4788                    .await
4789                    .unwrap();
4790                let mut append = Writer::new(
4791                    blob,
4792                    raw_size,
4793                    2048,
4794                    CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
4795                )
4796                .await
4797                .unwrap();
4798                let expected_size = append.size();
4799                append.append(&[0xFF, 0xFF]).await.unwrap();
4800                append.sync().await.unwrap();
4801                drop(append);
4802
4803                let journal = Journal::<_, u64>::init(context.child("second"), cfg)
4804                    .await
4805                    .unwrap();
4806                assert_eq!(journal.bounds(), 0..2);
4807                drop(journal);
4808
4809                (data_partition, expected_size)
4810            });
4811
4812        deterministic::Runner::from(checkpoint).start(move |context| async move {
4813            let (blob, raw_size) = context
4814                .open(&data_partition, &0u64.to_be_bytes())
4815                .await
4816                .unwrap();
4817            let append = Writer::new(
4818                blob,
4819                raw_size,
4820                2048,
4821                CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
4822            )
4823            .await
4824            .unwrap();
4825            assert_eq!(append.size(), expected_size);
4826        });
4827    }
4828
4829    /// Test recovery from crash after data sync but before offsets sync when journal was
4830    /// previously emptied by pruning.
4831    #[test_traced]
4832    fn test_variable_recovery_empty_offsets_after_prune_and_append() {
4833        let executor = deterministic::Runner::default();
4834        executor.start(|context| async move {
4835            let cfg = Config {
4836                partition: "recovery-empty-after-prune".into(),
4837                items_per_section: NZU64!(10),
4838                compression: None,
4839                codec_config: (),
4840                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
4841                write_buffer: NZUsize!(1024),
4842            };
4843
4844            // === Phase 1: Create journal with one full blob ===
4845            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
4846                .await
4847                .unwrap();
4848
4849            // Append 10 items (positions 0-9), fills blob 0
4850            for i in 0..10u64 {
4851                journal.append(&(i * 100)).await.unwrap();
4852            }
4853            let bounds = journal.bounds();
4854            assert_eq!(bounds.end, 10);
4855            assert_eq!(bounds.start, 0);
4856
4857            // === Phase 2: Prune to create empty journal ===
4858            journal.prune(10).await.unwrap();
4859            let bounds = journal.bounds();
4860            assert_eq!(bounds.end, 10);
4861            assert!(bounds.is_empty()); // Empty!
4862
4863            // === Phase 3: Append directly to data blobs to simulate crash ===
4864            // Manually append to data blobs only (bypassing Variable's append logic)
4865            // This simulates the case where data was synced but offsets wasn't
4866            for i in 10..20u64 {
4867                journal.test_append_data(1, i * 100).await.unwrap();
4868            }
4869            // Sync the data blobs (blob 1)
4870            journal.test_sync_data().await.unwrap();
4871            // Do NOT sync offsets journal - simulates crash before offsets.sync()
4872
4873            // Close without syncing offsets
4874            drop(journal);
4875
4876            // === Phase 4: Verify recovery succeeds ===
4877            let journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
4878                .await
4879                .expect("Should recover from crash after data sync but before offsets sync");
4880
4881            // All data should be recovered
4882            let bounds = journal.bounds();
4883            assert_eq!(bounds.end, 20);
4884            assert_eq!(bounds.start, 10);
4885
4886            // All items from position 10-19 should be readable
4887            for i in 10..20u64 {
4888                assert_eq!(journal.read(i).await.unwrap(), i * 100);
4889            }
4890
4891            // Items 0-9 should be pruned
4892            for i in 0..10 {
4893                assert!(matches!(journal.read(i).await, Err(Error::ItemPruned(_))));
4894            }
4895
4896            journal.destroy().await.unwrap();
4897        });
4898    }
4899
4900    /// Test that offsets index is rebuilt from data after sync writes data but not offsets.
4901    #[test_traced]
4902    fn test_variable_concurrent_sync_recovery() {
4903        let executor = deterministic::Runner::default();
4904        executor.start(|context| async move {
4905            let cfg = Config {
4906                partition: "concurrent-sync-recovery".into(),
4907                items_per_section: NZU64!(10),
4908                compression: None,
4909                codec_config: (),
4910                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
4911                write_buffer: NZUsize!(1024),
4912            };
4913
4914            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
4915                .await
4916                .unwrap();
4917
4918            // Append items across a blob boundary
4919            for i in 0..15u64 {
4920                journal.append(&(i * 100)).await.unwrap();
4921            }
4922
4923            // Manually sync only data to simulate crash during concurrent sync
4924            journal.commit().await.unwrap();
4925
4926            // Simulate a crash (offsets not synced)
4927            drop(journal);
4928
4929            let journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
4930                .await
4931                .unwrap();
4932
4933            // Data should be intact and offsets rebuilt
4934            assert_eq!(journal.size(), 15);
4935            for i in 0..15u64 {
4936                assert_eq!(journal.read(i).await.unwrap(), i * 100);
4937            }
4938
4939            journal.destroy().await.unwrap();
4940        });
4941    }
4942
4943    #[test_traced]
4944    fn test_variable_recovery_from_mid_blob_durable_anchor() {
4945        let executor = deterministic::Runner::default();
4946        executor.start(|context| async move {
4947            let cfg = Config {
4948                partition: "mid-blob-durable-anchor".into(),
4949                items_per_section: NZU64!(5),
4950                compression: None,
4951                codec_config: (),
4952                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
4953                write_buffer: NZUsize!(1024),
4954            };
4955
4956            let mut journal =
4957                Journal::<_, u64>::init_at_size(context.child("first"), cfg.clone(), 7)
4958                    .await
4959                    .unwrap();
4960            assert_eq!(journal.append(&700).await.unwrap(), 7);
4961            journal.sync().await.unwrap();
4962
4963            for i in 1..6u64 {
4964                assert_eq!(journal.append(&(700 + i)).await.unwrap(), 7 + i);
4965            }
4966            journal.commit().await.unwrap();
4967            drop(journal);
4968
4969            let journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
4970                .await
4971                .unwrap();
4972            assert_eq!(journal.bounds(), 7..13);
4973            for i in 0..6u64 {
4974                assert_eq!(journal.read(7 + i).await.unwrap(), 700 + i);
4975            }
4976
4977            journal.destroy().await.unwrap();
4978        });
4979    }
4980
4981    #[test_traced]
4982    fn test_init_at_size_rejects_conflicting_offsets_partitions() {
4983        let executor = deterministic::Runner::default();
4984        executor.start(|context| async move {
4985            let cfg = Config {
4986                partition: "init-at-size-conflicting-offsets".into(),
4987                items_per_section: NZU64!(5),
4988                compression: None,
4989                codec_config: (),
4990                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
4991                write_buffer: NZUsize!(1024),
4992            };
4993            let legacy_partition = cfg.offsets_partition();
4994            let blobs_partition = format!("{legacy_partition}-blobs");
4995
4996            for partition in [&legacy_partition, &blobs_partition] {
4997                let (blob, _) = context.open(partition, &0u64.to_be_bytes()).await.unwrap();
4998                blob.write_at_sync(0, vec![0]).await.unwrap();
4999            }
5000
5001            let result = Journal::<_, u64>::init_at_size(context.child("storage"), cfg, 7).await;
5002            assert!(matches!(result, Err(Error::Corruption(_))));
5003
5004            // The consistency check must fail before staging a reset, which would erase the
5005            // conflicting partitions and their corruption evidence.
5006            assert_eq!(context.scan(&legacy_partition).await.unwrap().len(), 1);
5007            assert_eq!(context.scan(&blobs_partition).await.unwrap().len(), 1);
5008        });
5009    }
5010
5011    #[test_traced]
5012    fn test_init_at_size_zero() {
5013        let executor = deterministic::Runner::default();
5014        executor.start(|context| async move {
5015            let cfg = Config {
5016                partition: "init-at-size-zero".into(),
5017                items_per_section: NZU64!(5),
5018                compression: None,
5019                codec_config: (),
5020                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
5021                write_buffer: NZUsize!(1024),
5022            };
5023
5024            let mut journal =
5025                Journal::<_, u64>::init_at_size(context.child("storage"), cfg.clone(), 0)
5026                    .await
5027                    .unwrap();
5028
5029            // Size should be 0
5030            assert_eq!(journal.size(), 0);
5031
5032            // No oldest retained position (empty journal)
5033            assert!(journal.bounds().is_empty());
5034
5035            // Next append should get position 0
5036            let pos = journal.append(&100).await.unwrap();
5037            assert_eq!(pos, 0);
5038            assert_eq!(journal.size(), 1);
5039            assert_eq!(journal.read(0).await.unwrap(), 100);
5040
5041            journal.destroy().await.unwrap();
5042        });
5043    }
5044
5045    #[test_traced]
5046    fn test_init_at_size_blob_boundary() {
5047        let executor = deterministic::Runner::default();
5048        executor.start(|context| async move {
5049            let cfg = Config {
5050                partition: "init-at-size-boundary".into(),
5051                items_per_section: NZU64!(5),
5052                compression: None,
5053                codec_config: (),
5054                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
5055                write_buffer: NZUsize!(1024),
5056            };
5057
5058            // Initialize at position 10 (exactly at blob 1 boundary with items_per_section=5)
5059            let mut journal =
5060                Journal::<_, u64>::init_at_size(context.child("storage"), cfg.clone(), 10)
5061                    .await
5062                    .unwrap();
5063
5064            // Size should be 10
5065            let bounds = journal.bounds();
5066            assert_eq!(bounds.end, 10);
5067
5068            // No data yet, so no oldest retained position
5069            assert!(bounds.is_empty());
5070
5071            // Next append should get position 10
5072            let pos = journal.append(&1000).await.unwrap();
5073            assert_eq!(pos, 10);
5074            assert_eq!(journal.size(), 11);
5075            assert_eq!(journal.read(10).await.unwrap(), 1000);
5076
5077            // Can continue appending
5078            let pos = journal.append(&1001).await.unwrap();
5079            assert_eq!(pos, 11);
5080            assert_eq!(journal.read(11).await.unwrap(), 1001);
5081
5082            journal.destroy().await.unwrap();
5083        });
5084    }
5085
5086    #[test_traced]
5087    fn test_init_at_size_mid_blob() {
5088        let executor = deterministic::Runner::default();
5089        executor.start(|context| async move {
5090            let cfg = Config {
5091                partition: "init-at-size-mid".into(),
5092                items_per_section: NZU64!(5),
5093                compression: None,
5094                codec_config: (),
5095                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
5096                write_buffer: NZUsize!(1024),
5097            };
5098
5099            // Initialize at position 7 (middle of blob 1 with items_per_section=5)
5100            let mut journal =
5101                Journal::<_, u64>::init_at_size(context.child("storage"), cfg.clone(), 7)
5102                    .await
5103                    .unwrap();
5104
5105            // Size should be 7
5106            let bounds = journal.bounds();
5107            assert_eq!(bounds.end, 7);
5108
5109            // No data yet, so no oldest retained position
5110            assert!(bounds.is_empty());
5111
5112            // Next append should get position 7
5113            let pos = journal.append(&700).await.unwrap();
5114            assert_eq!(pos, 7);
5115            assert_eq!(journal.size(), 8);
5116            assert_eq!(journal.read(7).await.unwrap(), 700);
5117
5118            journal.destroy().await.unwrap();
5119        });
5120    }
5121
5122    #[test_traced]
5123    fn test_init_at_size_persistence() {
5124        let executor = deterministic::Runner::default();
5125        executor.start(|context| async move {
5126            let cfg = Config {
5127                partition: "init-at-size-persist".into(),
5128                items_per_section: NZU64!(5),
5129                compression: None,
5130                codec_config: (),
5131                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
5132                write_buffer: NZUsize!(1024),
5133            };
5134
5135            // Initialize at position 15
5136            let mut journal =
5137                Journal::<_, u64>::init_at_size(context.child("first"), cfg.clone(), 15)
5138                    .await
5139                    .unwrap();
5140
5141            // Append some items
5142            for i in 0..5u64 {
5143                let pos = journal.append(&(1500 + i)).await.unwrap();
5144                assert_eq!(pos, 15 + i);
5145            }
5146
5147            assert_eq!(journal.size(), 20);
5148
5149            // Sync and reopen
5150            journal.sync().await.unwrap();
5151            drop(journal);
5152
5153            let mut journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
5154                .await
5155                .unwrap();
5156
5157            // Size and data should be preserved
5158            let bounds = journal.bounds();
5159            assert_eq!(bounds.end, 20);
5160            assert_eq!(bounds.start, 15);
5161
5162            // Verify data
5163            for i in 0..5u64 {
5164                assert_eq!(journal.read(15 + i).await.unwrap(), 1500 + i);
5165            }
5166
5167            // Can continue appending
5168            let pos = journal.append(&9999).await.unwrap();
5169            assert_eq!(pos, 20);
5170            assert_eq!(journal.read(20).await.unwrap(), 9999);
5171
5172            journal.destroy().await.unwrap();
5173        });
5174    }
5175
5176    #[test_traced]
5177    fn test_init_at_size_persistence_without_data() {
5178        let executor = deterministic::Runner::default();
5179        executor.start(|context| async move {
5180            let cfg = Config {
5181                partition: "init-at-size-persist-empty".into(),
5182                items_per_section: NZU64!(5),
5183                compression: None,
5184                codec_config: (),
5185                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
5186                write_buffer: NZUsize!(1024),
5187            };
5188
5189            // Initialize at position 15
5190            let journal = Journal::<_, u64>::init_at_size(context.child("first"), cfg.clone(), 15)
5191                .await
5192                .unwrap();
5193
5194            let bounds = journal.bounds();
5195            assert_eq!(bounds.end, 15);
5196            assert!(bounds.is_empty());
5197
5198            // Drop without writing any data
5199            drop(journal);
5200
5201            // Reopen and verify size persisted
5202            let mut journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
5203                .await
5204                .unwrap();
5205
5206            let bounds = journal.bounds();
5207            assert_eq!(bounds.end, 15);
5208            assert!(bounds.is_empty());
5209
5210            // Can append starting at position 15
5211            let pos = journal.append(&1500).await.unwrap();
5212            assert_eq!(pos, 15);
5213            assert_eq!(journal.read(15).await.unwrap(), 1500);
5214
5215            journal.destroy().await.unwrap();
5216        });
5217    }
5218
5219    #[test_traced]
5220    fn test_init_at_size_clears_existing_data() {
5221        let executor = deterministic::Runner::default();
5222        executor.start(|context| async move {
5223            let cfg = Config {
5224                partition: "init-at-size-clears-existing".into(),
5225                items_per_section: NZU64!(5),
5226                compression: None,
5227                codec_config: (),
5228                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
5229                write_buffer: NZUsize!(1024),
5230            };
5231
5232            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
5233                .await
5234                .unwrap();
5235            for i in 0..12u64 {
5236                journal.append(&(100 + i)).await.unwrap();
5237            }
5238            journal.sync().await.unwrap();
5239            drop(journal);
5240
5241            let mut journal =
5242                Journal::<_, u64>::init_at_size(context.child("reset"), cfg.clone(), 7)
5243                    .await
5244                    .unwrap();
5245            assert_eq!(journal.bounds(), 7..7);
5246            assert_eq!(journal.append(&700).await.unwrap(), 7);
5247            journal.sync().await.unwrap();
5248            drop(journal);
5249
5250            let journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
5251                .await
5252                .unwrap();
5253            assert_eq!(journal.bounds(), 7..8);
5254            assert_eq!(journal.read(7).await.unwrap(), 700);
5255            assert!(matches!(journal.read(6).await, Err(Error::ItemPruned(6))));
5256            assert!(matches!(
5257                journal.read(8).await,
5258                Err(Error::ItemOutOfRange(8))
5259            ));
5260
5261            journal.destroy().await.unwrap();
5262        });
5263    }
5264
5265    #[test_traced]
5266    fn test_init_at_size_stages_reset_before_clearing_data() {
5267        let partition = "init-at-size-stage-before-clear-failure".to_string();
5268        let executor = deterministic::Runner::default();
5269        let ((), checkpoint) = executor.start_and_recover({
5270            let partition = partition.clone();
5271            |context| async move {
5272                let cfg = Config {
5273                    partition,
5274                    items_per_section: NZU64!(5),
5275                    compression: None,
5276                    codec_config: (),
5277                    page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
5278                    write_buffer: NZUsize!(1024),
5279                };
5280
5281                let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
5282                    .await
5283                    .unwrap();
5284                for i in 0..12u64 {
5285                    journal.append(&(100 + i)).await.unwrap();
5286                }
5287                journal.sync().await.unwrap();
5288                drop(journal);
5289
5290                *context.storage_fault_config().write() = deterministic::FaultConfig {
5291                    sync_rate: Some(1.0),
5292                    ..Default::default()
5293                };
5294                assert!(
5295                    Journal::<_, u64>::init_at_size(context.child("reset"), cfg, 7)
5296                        .await
5297                        .is_err()
5298                );
5299            }
5300        });
5301
5302        deterministic::Runner::from(checkpoint).start(move |context| async move {
5303            *context.storage_fault_config().write() = deterministic::FaultConfig::default();
5304            let cfg = Config {
5305                partition,
5306                items_per_section: NZU64!(5),
5307                compression: None,
5308                codec_config: (),
5309                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
5310                write_buffer: NZUsize!(1024),
5311            };
5312
5313            let journal = Journal::<_, u64>::init(context.child("recover"), cfg.clone())
5314                .await
5315                .unwrap();
5316            assert_eq!(journal.bounds(), 0..12);
5317            for i in 0..12u64 {
5318                assert_eq!(journal.read(i).await.unwrap(), 100 + i);
5319            }
5320
5321            journal.destroy().await.unwrap();
5322        });
5323    }
5324
5325    #[test_traced]
5326    fn test_clear_to_size_stages_reset_before_clearing_data() {
5327        let partition = "clear-to-size-stage-before-clear-failure".to_string();
5328        let executor = deterministic::Runner::default();
5329        let ((), checkpoint) = executor.start_and_recover({
5330            let partition = partition.clone();
5331            |context| async move {
5332                let cfg = Config {
5333                    partition,
5334                    items_per_section: NZU64!(5),
5335                    compression: None,
5336                    codec_config: (),
5337                    page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
5338                    write_buffer: NZUsize!(1024),
5339                };
5340
5341                let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
5342                    .await
5343                    .unwrap();
5344                for i in 0..12u64 {
5345                    journal.append(&(100 + i)).await.unwrap();
5346                }
5347                journal.sync().await.unwrap();
5348
5349                // Fail the offsets metadata sync inside `stage_clear_intent` so `clear_to_size`
5350                // aborts before any data is cleared. The reset intent never becomes durable.
5351                *context.storage_fault_config().write() = deterministic::FaultConfig {
5352                    sync_rate: Some(1.0),
5353                    ..Default::default()
5354                };
5355                assert!(journal.clear_to_size(7).await.is_err());
5356            }
5357        });
5358
5359        deterministic::Runner::from(checkpoint).start(move |context| async move {
5360            *context.storage_fault_config().write() = deterministic::FaultConfig::default();
5361            let cfg = Config {
5362                partition,
5363                items_per_section: NZU64!(5),
5364                compression: None,
5365                codec_config: (),
5366                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
5367                write_buffer: NZUsize!(1024),
5368            };
5369
5370            let journal = Journal::<_, u64>::init(context.child("recover"), cfg.clone())
5371                .await
5372                .unwrap();
5373            assert_eq!(journal.bounds(), 0..12);
5374            for i in 0..12u64 {
5375                assert_eq!(journal.read(i).await.unwrap(), 100 + i);
5376            }
5377
5378            journal.destroy().await.unwrap();
5379        });
5380    }
5381
5382    #[test_traced]
5383    fn test_clear_to_size_crash_after_staging_completes_on_init() {
5384        let partition = "clear-to-size-crash-after-staging".to_string();
5385        let executor = deterministic::Runner::default();
5386        let ((), checkpoint) = executor.start_and_recover({
5387            let partition = partition.clone();
5388            |context| async move {
5389                let cfg = Config {
5390                    partition,
5391                    items_per_section: NZU64!(5),
5392                    compression: None,
5393                    codec_config: (),
5394                    page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
5395                    write_buffer: NZUsize!(1024),
5396                };
5397
5398                let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
5399                    .await
5400                    .unwrap();
5401                for i in 0..12u64 {
5402                    journal.append(&(100 + i)).await.unwrap();
5403                }
5404                journal.sync().await.unwrap();
5405
5406                // Let `stage_clear_intent` (a metadata sync) persist the reset intent, but fail the
5407                // subsequent `data.clear()` (a blob remove) so `clear_to_size` aborts after the
5408                // intent is durable but before the data is cleared.
5409                *context.storage_fault_config().write() = deterministic::FaultConfig {
5410                    remove_rate: Some(1.0),
5411                    ..Default::default()
5412                };
5413                assert!(journal.clear_to_size(7).await.is_err());
5414            }
5415        });
5416
5417        deterministic::Runner::from(checkpoint).start(move |context| async move {
5418            *context.storage_fault_config().write() = deterministic::FaultConfig::default();
5419            let cfg = Config {
5420                partition,
5421                items_per_section: NZU64!(5),
5422                compression: None,
5423                codec_config: (),
5424                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
5425                write_buffer: NZUsize!(1024),
5426            };
5427
5428            // `init` finds the staged intent, discards the stale data, and completes the reset.
5429            let mut journal = Journal::<_, u64>::init(context.child("recover"), cfg.clone())
5430                .await
5431                .unwrap();
5432            assert_eq!(journal.bounds(), 7..7);
5433            assert_eq!(journal.append(&700).await.unwrap(), 7);
5434            journal.sync().await.unwrap();
5435            drop(journal);
5436
5437            // Reopen: the completed reset persists and no stale data was replayed.
5438            let journal = Journal::<_, u64>::init(context.child("reopen"), cfg.clone())
5439                .await
5440                .unwrap();
5441            assert_eq!(journal.bounds(), 7..8);
5442            assert_eq!(journal.read(7).await.unwrap(), 700);
5443
5444            journal.destroy().await.unwrap();
5445        });
5446    }
5447
5448    #[test_traced]
5449    fn test_init_at_size_recovers_staged_reset_crash_points() {
5450        let executor = deterministic::Runner::default();
5451        executor.start(|context| async move {
5452            for (index, clear_data) in [false, true].into_iter().enumerate() {
5453                let cfg = Config {
5454                    partition: format!("init-at-size-staged-reset-crash-{index}"),
5455                    items_per_section: NZU64!(5),
5456                    compression: None,
5457                    codec_config: (),
5458                    page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
5459                    write_buffer: NZUsize!(1024),
5460                };
5461
5462                let mut journal = Journal::<_, u64>::init(
5463                    context.child("first").with_attribute("index", index),
5464                    cfg.clone(),
5465                )
5466                .await
5467                .unwrap();
5468                for i in 0..12u64 {
5469                    journal.append(&(100 + i)).await.unwrap();
5470                }
5471                journal.sync().await.unwrap();
5472                drop(journal);
5473
5474                let offsets_cfg = fixed::Config {
5475                    partition: cfg.offsets_partition(),
5476                    items_per_blob: cfg.items_per_section,
5477                    page_cache: cfg.page_cache.clone(),
5478                    write_buffer: cfg.write_buffer,
5479                };
5480                // Simulate a crash mid-`init_at_size`: stage a clear intent in the offsets
5481                // checkpoint but leave data untouched (clear_data=false) or also clear data
5482                // (clear_data=true) so we cover both crash points.
5483                let intent_ctx = context.child("intent").with_attribute("index", index);
5484                fixed::Journal::<_, u64>::test_stage_clear(
5485                    intent_ctx.child("meta"),
5486                    &offsets_cfg.partition,
5487                    7,
5488                )
5489                .await
5490                .unwrap();
5491
5492                if clear_data {
5493                    Partition::<deterministic::Context>::remove_all(
5494                        &context,
5495                        &cfg.data_partition(),
5496                    )
5497                    .await
5498                    .unwrap();
5499                }
5500
5501                let mut journal = Journal::<_, u64>::init(
5502                    context.child("recover").with_attribute("index", index),
5503                    cfg.clone(),
5504                )
5505                .await
5506                .unwrap();
5507                assert_eq!(journal.bounds(), 7..7);
5508                assert_eq!(journal.append(&700).await.unwrap(), 7);
5509                journal.sync().await.unwrap();
5510                drop(journal);
5511
5512                let journal = Journal::<_, u64>::init(
5513                    context.child("reopen").with_attribute("index", index),
5514                    cfg.clone(),
5515                )
5516                .await
5517                .unwrap();
5518                assert_eq!(journal.bounds(), 7..8);
5519                assert_eq!(journal.read(7).await.unwrap(), 700);
5520
5521                journal.destroy().await.unwrap();
5522            }
5523        });
5524    }
5525
5526    #[test_traced]
5527    fn test_init_at_size_overwrites_pending_clear_target() {
5528        let executor = deterministic::Runner::default();
5529        executor.start(|context| async move {
5530            let cfg = Config {
5531                partition: "init-at-size-overwrites-pending-target".into(),
5532                items_per_section: NZU64!(5),
5533                compression: None,
5534                codec_config: (),
5535                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
5536                write_buffer: NZUsize!(1024),
5537            };
5538
5539            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
5540                .await
5541                .unwrap();
5542            for i in 0..12u64 {
5543                journal.append(&(100 + i)).await.unwrap();
5544            }
5545            journal.sync().await.unwrap();
5546            drop(journal);
5547
5548            // Simulate a prior `clear_to_size(5)` that crashed after staging its intent: the offsets
5549            // checkpoint carries a clear target of 5 while the data blobs still holds all 12 items.
5550            let offsets_cfg = fixed::Config {
5551                partition: cfg.offsets_partition(),
5552                items_per_blob: cfg.items_per_section,
5553                page_cache: cfg.page_cache.clone(),
5554                write_buffer: cfg.write_buffer,
5555            };
5556            let stale_ctx = context.child("stale");
5557            fixed::Journal::<_, u64>::test_stage_clear(
5558                stale_ctx.child("meta"),
5559                &offsets_cfg.partition,
5560                5,
5561            )
5562            .await
5563            .unwrap();
5564
5565            // init_at_size(10) overwrites the pending target of 5 and resets to 10.
5566            let mut journal =
5567                Journal::<_, u64>::init_at_size(context.child("reset"), cfg.clone(), 10)
5568                    .await
5569                    .unwrap();
5570            assert_eq!(journal.bounds(), 10..10);
5571            assert_eq!(journal.append(&700).await.unwrap(), 10);
5572            journal.sync().await.unwrap();
5573            drop(journal);
5574
5575            // Reopen: target 10 (not 5) persisted and no stale data was replayed.
5576            let journal = Journal::<_, u64>::init(context.child("reopen"), cfg.clone())
5577                .await
5578                .unwrap();
5579            assert_eq!(journal.bounds(), 10..11);
5580            assert_eq!(journal.read(10).await.unwrap(), 700);
5581
5582            journal.destroy().await.unwrap();
5583        });
5584    }
5585
5586    #[test_traced]
5587    fn test_init_at_size_discards_same_blob_stale_data() {
5588        let executor = deterministic::Runner::default();
5589        executor.start(|context| async move {
5590            let cfg = Config {
5591                partition: "init-at-size-discards-same-blob-stale-data".into(),
5592                items_per_section: NZU64!(5),
5593                compression: None,
5594                codec_config: (),
5595                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
5596                write_buffer: NZUsize!(1024),
5597            };
5598
5599            let mut journal =
5600                Journal::<_, u64>::init_at_size(context.child("first"), cfg.clone(), 5)
5601                    .await
5602                    .unwrap();
5603            for i in 0..4u64 {
5604                assert_eq!(journal.append(&(500 + i)).await.unwrap(), 5 + i);
5605            }
5606            journal.sync().await.unwrap();
5607            drop(journal);
5608
5609            let journal = Journal::<_, u64>::init_at_size(context.child("reset"), cfg.clone(), 7)
5610                .await
5611                .unwrap();
5612            drop(journal);
5613
5614            let mut journal = Journal::<_, u64>::init(context.child("after_reset"), cfg.clone())
5615                .await
5616                .unwrap();
5617            assert_eq!(journal.bounds(), 7..7);
5618            assert!(matches!(
5619                journal.read(7).await,
5620                Err(Error::ItemOutOfRange(7))
5621            ));
5622
5623            assert_eq!(journal.append(&700).await.unwrap(), 7);
5624            journal.sync().await.unwrap();
5625            drop(journal);
5626
5627            let journal = Journal::<_, u64>::init(context.child("after_append"), cfg.clone())
5628                .await
5629                .unwrap();
5630            assert_eq!(journal.bounds(), 7..8);
5631            assert_eq!(journal.read(7).await.unwrap(), 700);
5632            assert!(matches!(
5633                journal.read(8).await,
5634                Err(Error::ItemOutOfRange(8))
5635            ));
5636
5637            journal.destroy().await.unwrap();
5638        });
5639    }
5640
5641    /// Test init_at_size with mid-blob value persists correctly across restart.
5642    #[test_traced]
5643    fn test_init_at_size_mid_blob_persistence() {
5644        let executor = deterministic::Runner::default();
5645        executor.start(|context| async move {
5646            let cfg = Config {
5647                partition: "init-at-size-mid-blob".into(),
5648                items_per_section: NZU64!(5),
5649                compression: None,
5650                codec_config: (),
5651                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
5652                write_buffer: NZUsize!(1024),
5653            };
5654
5655            // Initialize at position 7 (mid-blob, 7 % 5 = 2)
5656            let mut journal =
5657                Journal::<_, u64>::init_at_size(context.child("first"), cfg.clone(), 7)
5658                    .await
5659                    .unwrap();
5660
5661            // Append 3 items at positions 7, 8, 9 (fills rest of blob 1)
5662            for i in 0..3u64 {
5663                let pos = journal.append(&(700 + i)).await.unwrap();
5664                assert_eq!(pos, 7 + i);
5665            }
5666
5667            let bounds = journal.bounds();
5668            assert_eq!(bounds.end, 10);
5669            assert_eq!(bounds.start, 7);
5670
5671            // Sync and reopen
5672            journal.sync().await.unwrap();
5673            drop(journal);
5674
5675            // Reopen
5676            let journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
5677                .await
5678                .unwrap();
5679
5680            // Size and bounds.start should be preserved correctly
5681            let bounds = journal.bounds();
5682            assert_eq!(bounds.end, 10);
5683            assert_eq!(bounds.start, 7);
5684
5685            // Verify data
5686            for i in 0..3u64 {
5687                assert_eq!(journal.read(7 + i).await.unwrap(), 700 + i);
5688            }
5689
5690            // Positions before 7 should be pruned
5691            assert!(matches!(journal.read(6).await, Err(Error::ItemPruned(6))));
5692
5693            journal.destroy().await.unwrap();
5694        });
5695    }
5696
5697    /// Test init_at_size mid-blob with data spanning multiple blobs.
5698    #[test_traced]
5699    fn test_init_at_size_mid_blob_multi_blob_persistence() {
5700        let executor = deterministic::Runner::default();
5701        executor.start(|context| async move {
5702            let cfg = Config {
5703                partition: "init-at-size-multi-blob".into(),
5704                items_per_section: NZU64!(5),
5705                compression: None,
5706                codec_config: (),
5707                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
5708                write_buffer: NZUsize!(1024),
5709            };
5710
5711            // Initialize at position 7 (mid-blob)
5712            let mut journal =
5713                Journal::<_, u64>::init_at_size(context.child("first"), cfg.clone(), 7)
5714                    .await
5715                    .unwrap();
5716
5717            // Append 8 items: positions 7-14 (blob 1: 3 items, blob 2: 5 items)
5718            for i in 0..8u64 {
5719                let pos = journal.append(&(700 + i)).await.unwrap();
5720                assert_eq!(pos, 7 + i);
5721            }
5722
5723            let bounds = journal.bounds();
5724            assert_eq!(bounds.end, 15);
5725            assert_eq!(bounds.start, 7);
5726
5727            // Sync and reopen
5728            journal.sync().await.unwrap();
5729            drop(journal);
5730
5731            // Reopen
5732            let journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
5733                .await
5734                .unwrap();
5735
5736            // Verify state preserved
5737            let bounds = journal.bounds();
5738            assert_eq!(bounds.end, 15);
5739            assert_eq!(bounds.start, 7);
5740
5741            // Verify all data
5742            for i in 0..8u64 {
5743                assert_eq!(journal.read(7 + i).await.unwrap(), 700 + i);
5744            }
5745
5746            journal.destroy().await.unwrap();
5747        });
5748    }
5749
5750    /// Regression test: data-empty crash repair must preserve mid-blob pruning boundary.
5751    #[test_traced]
5752    fn test_align_journals_data_empty_mid_blob_pruning_boundary() {
5753        let executor = deterministic::Runner::default();
5754        executor.start(|context| async move {
5755            let cfg = Config {
5756                partition: "align-journals-mid-blob-pruning-boundary".into(),
5757                items_per_section: NZU64!(5),
5758                compression: None,
5759                codec_config: (),
5760                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
5761                write_buffer: NZUsize!(1024),
5762            };
5763
5764            // Phase 1: Create data and offsets, then simulate data-only pruning crash.
5765            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
5766                .await
5767                .unwrap();
5768            for i in 0..7u64 {
5769                journal.append(&(100 + i)).await.unwrap();
5770            }
5771            journal.sync().await.unwrap();
5772
5773            // Simulate crash after data was cleared but before offsets were pruned.
5774            drop(journal);
5775            Partition::<deterministic::Context>::remove_all(&context, &cfg.data_partition())
5776                .await
5777                .unwrap();
5778
5779            // Phase 2: Init triggers data-empty repair and should treat journal as fully pruned at size 7.
5780            let mut journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
5781                .await
5782                .unwrap();
5783            let bounds = journal.bounds();
5784            assert_eq!(bounds.end, 7);
5785            assert!(bounds.is_empty());
5786
5787            // Append one item at position 7.
5788            let pos = journal.append(&777).await.unwrap();
5789            assert_eq!(pos, 7);
5790            assert_eq!(journal.size(), 8);
5791            assert_eq!(journal.read(7).await.unwrap(), 777);
5792
5793            // Sync only the data blobs to simulate a crash before offsets are synced.
5794            journal.test_sync_data().await.unwrap();
5795            drop(journal);
5796
5797            // Phase 3: Reopen and verify we did not lose the appended item.
5798            let journal = Journal::<_, u64>::init(context.child("third"), cfg.clone())
5799                .await
5800                .unwrap();
5801            let bounds = journal.bounds();
5802            assert_eq!(bounds.end, 8);
5803            assert_eq!(bounds.start, 7);
5804            assert_eq!(journal.read(7).await.unwrap(), 777);
5805
5806            journal.destroy().await.unwrap();
5807        });
5808    }
5809
5810    /// Test crash recovery: init_at_size + append + crash with data synced but offsets not.
5811    #[test_traced]
5812    fn test_init_at_size_crash_data_synced_offsets_not() {
5813        let executor = deterministic::Runner::default();
5814        executor.start(|context| async move {
5815            let cfg = Config {
5816                partition: "init-at-size-crash-recovery".into(),
5817                items_per_section: NZU64!(5),
5818                compression: None,
5819                codec_config: (),
5820                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
5821                write_buffer: NZUsize!(1024),
5822            };
5823
5824            // Initialize at position 7 (mid-blob)
5825            let mut journal =
5826                Journal::<_, u64>::init_at_size(context.child("first"), cfg.clone(), 7)
5827                    .await
5828                    .unwrap();
5829
5830            // Append 3 items
5831            for i in 0..3u64 {
5832                journal.append(&(700 + i)).await.unwrap();
5833            }
5834
5835            // Sync only the data blobs, not offsets (simulate crash). The appended items
5836            // live in (sealed) blob 1.
5837            journal.test_sync_data_blob(1).await.unwrap();
5838            // Don't sync offsets - simulates crash after data write but before offsets write
5839            drop(journal);
5840
5841            // Reopen - should recover by rebuilding offsets from data
5842            let journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
5843                .await
5844                .unwrap();
5845
5846            // Verify recovery
5847            let bounds = journal.bounds();
5848            assert_eq!(bounds.end, 10);
5849            assert_eq!(bounds.start, 7);
5850
5851            // Verify data is accessible
5852            for i in 0..3u64 {
5853                assert_eq!(journal.read(7 + i).await.unwrap(), 700 + i);
5854            }
5855
5856            journal.destroy().await.unwrap();
5857        });
5858    }
5859
5860    #[test_traced]
5861    fn test_prune_does_not_move_oldest_retained_backwards() {
5862        let executor = deterministic::Runner::default();
5863        executor.start(|context| async move {
5864            let cfg = Config {
5865                partition: "prune-no-backwards".into(),
5866                items_per_section: NZU64!(5),
5867                compression: None,
5868                codec_config: (),
5869                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
5870                write_buffer: NZUsize!(1024),
5871            };
5872
5873            let mut journal =
5874                Journal::<_, u64>::init_at_size(context.child("first"), cfg.clone(), 7)
5875                    .await
5876                    .unwrap();
5877
5878            // Append a few items at positions 7..9
5879            for i in 0..3u64 {
5880                let pos = journal.append(&(700 + i)).await.unwrap();
5881                assert_eq!(pos, 7 + i);
5882            }
5883            assert_eq!(journal.bounds().start, 7);
5884
5885            // Prune to a position within the same blob should not move bounds.start backwards.
5886            journal.prune(8).await.unwrap();
5887            assert_eq!(journal.bounds().start, 7);
5888            assert!(matches!(journal.read(6).await, Err(Error::ItemPruned(6))));
5889            assert_eq!(journal.read(7).await.unwrap(), 700);
5890
5891            journal.destroy().await.unwrap();
5892        });
5893    }
5894
5895    #[test_traced]
5896    fn test_variable_recovery_near_max_data_synced_offsets_not() {
5897        let executor = deterministic::Runner::default();
5898        executor.start(|context| async move {
5899            let cfg = Config {
5900                partition: "near-max-data-synced-offsets-not".into(),
5901                items_per_section: NZU64!(10),
5902                compression: None,
5903                codec_config: (),
5904                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
5905                write_buffer: NZUsize!(1024),
5906            };
5907
5908            let mut journal =
5909                Journal::<_, u64>::init_at_size(context.child("first"), cfg.clone(), u64::MAX - 1)
5910                    .await
5911                    .unwrap();
5912            assert_eq!(journal.append(&7).await.unwrap(), u64::MAX - 1);
5913            journal
5914                .test_sync_data_blob(position_to_blob(u64::MAX - 1, cfg.items_per_section.get()))
5915                .await
5916                .unwrap();
5917            drop(journal);
5918
5919            let journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
5920                .await
5921                .unwrap();
5922            assert_eq!(journal.bounds(), (u64::MAX - 1)..u64::MAX);
5923            assert_eq!(journal.read(u64::MAX - 1).await.unwrap(), 7);
5924
5925            journal.destroy().await.unwrap();
5926        });
5927    }
5928
5929    #[test_traced]
5930    fn test_init_at_size_large_offset() {
5931        let executor = deterministic::Runner::default();
5932        executor.start(|context| async move {
5933            let cfg = Config {
5934                partition: "init-at-size-large".into(),
5935                items_per_section: NZU64!(5),
5936                compression: None,
5937                codec_config: (),
5938                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
5939                write_buffer: NZUsize!(1024),
5940            };
5941
5942            // Initialize at a large position (position 1000)
5943            let mut journal =
5944                Journal::<_, u64>::init_at_size(context.child("storage"), cfg.clone(), 1000)
5945                    .await
5946                    .unwrap();
5947
5948            let bounds = journal.bounds();
5949            assert_eq!(bounds.end, 1000);
5950            // No data yet, so no oldest retained position
5951            assert!(bounds.is_empty());
5952
5953            // Next append should get position 1000
5954            let pos = journal.append(&100000).await.unwrap();
5955            assert_eq!(pos, 1000);
5956            assert_eq!(journal.read(1000).await.unwrap(), 100000);
5957
5958            journal.destroy().await.unwrap();
5959        });
5960    }
5961
5962    #[test_traced]
5963    fn test_init_at_size_prune_and_append() {
5964        let executor = deterministic::Runner::default();
5965        executor.start(|context| async move {
5966            let cfg = Config {
5967                partition: "init-at-size-prune".into(),
5968                items_per_section: NZU64!(5),
5969                compression: None,
5970                codec_config: (),
5971                page_cache: CacheRef::from_pooler(&context, SMALL_PAGE_SIZE, NZUsize!(2)),
5972                write_buffer: NZUsize!(1024),
5973            };
5974
5975            // Initialize at position 20
5976            let mut journal =
5977                Journal::<_, u64>::init_at_size(context.child("storage"), cfg.clone(), 20)
5978                    .await
5979                    .unwrap();
5980
5981            // Append items 20-29
5982            for i in 0..10u64 {
5983                journal.append(&(2000 + i)).await.unwrap();
5984            }
5985
5986            assert_eq!(journal.size(), 30);
5987
5988            // Prune to position 25
5989            journal.prune(25).await.unwrap();
5990
5991            let bounds = journal.bounds();
5992            assert_eq!(bounds.end, 30);
5993            assert_eq!(bounds.start, 25);
5994
5995            // Verify remaining items are readable
5996            for i in 25..30u64 {
5997                assert_eq!(journal.read(i).await.unwrap(), 2000 + (i - 20));
5998            }
5999
6000            // Continue appending
6001            let pos = journal.append(&3000).await.unwrap();
6002            assert_eq!(pos, 30);
6003
6004            journal.destroy().await.unwrap();
6005        });
6006    }
6007
6008    /// Test `init_sync` when there is no existing data on disk.
6009    #[test_traced]
6010    fn test_init_sync_no_existing_data() {
6011        let executor = deterministic::Runner::default();
6012        executor.start(|context| async move {
6013            let cfg = Config {
6014                partition: "test-fresh-start".into(),
6015                items_per_section: NZU64!(5),
6016                compression: None,
6017                codec_config: (),
6018                write_buffer: NZUsize!(1024),
6019                page_cache: CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(PAGE_CACHE_SIZE)),
6020            };
6021
6022            // Initialize journal with sync boundaries when no existing data exists
6023            let lower_bound = 10;
6024            let upper_bound = 26;
6025            let mut journal = Journal::init_sync(
6026                context.child("storage"),
6027                cfg.clone(),
6028                lower_bound..upper_bound,
6029            )
6030            .await
6031            .expect("Failed to initialize journal with sync boundaries");
6032
6033            let bounds = journal.bounds();
6034            assert_eq!(bounds.end, lower_bound);
6035            assert!(bounds.is_empty());
6036
6037            // Append items using the contiguous API
6038            let pos1 = journal.append(&42u64).await.unwrap();
6039            assert_eq!(pos1, lower_bound);
6040            assert_eq!(journal.read(pos1).await.unwrap(), 42u64);
6041
6042            let pos2 = journal.append(&43u64).await.unwrap();
6043            assert_eq!(pos2, lower_bound + 1);
6044            assert_eq!(journal.read(pos2).await.unwrap(), 43u64);
6045
6046            journal.destroy().await.unwrap();
6047        });
6048    }
6049
6050    /// Test `init_sync` when there is existing data that overlaps with the sync target range.
6051    #[test_traced]
6052    fn test_init_sync_existing_data_overlap() {
6053        let executor = deterministic::Runner::default();
6054        executor.start(|context| async move {
6055            let cfg = Config {
6056                partition: "test-overlap".into(),
6057                items_per_section: NZU64!(5),
6058                compression: None,
6059                codec_config: (),
6060                write_buffer: NZUsize!(1024),
6061                page_cache: CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(PAGE_CACHE_SIZE)),
6062            };
6063
6064            // Create initial journal with data in multiple blobs
6065            let mut journal =
6066                Journal::<deterministic::Context, u64>::init(context.child("storage"), cfg.clone())
6067                    .await
6068                    .expect("Failed to create initial journal");
6069
6070            // Add data at positions 0-19 (blobs 0-3 with items_per_section=5)
6071            for i in 0..20u64 {
6072                journal.append(&(i * 100)).await.unwrap();
6073            }
6074            journal.sync().await.unwrap();
6075            drop(journal);
6076
6077            // Initialize with sync boundaries that overlap with existing data
6078            // lower_bound: 8 (blob 1), upper_bound: 31 (last location 30, blob 6)
6079            let lower_bound = 8;
6080            let upper_bound = 31;
6081            let mut journal = Journal::<deterministic::Context, u64>::init_sync(
6082                context.child("storage"),
6083                cfg.clone(),
6084                lower_bound..upper_bound,
6085            )
6086            .await
6087            .expect("Failed to initialize journal with overlap");
6088
6089            assert_eq!(journal.size(), 20);
6090
6091            // Verify oldest retained is pruned to lower_bound's blob boundary (5)
6092            assert_eq!(journal.bounds().start, 5); // Blob 1 starts at position 5
6093
6094            // Verify data integrity: positions before 5 are pruned
6095            assert!(matches!(journal.read(0).await, Err(Error::ItemPruned(_))));
6096            assert!(matches!(journal.read(4).await, Err(Error::ItemPruned(_))));
6097
6098            // Positions 5-19 should be accessible
6099            assert_eq!(journal.read(5).await.unwrap(), 500);
6100            assert_eq!(journal.read(8).await.unwrap(), 800);
6101            assert_eq!(journal.read(19).await.unwrap(), 1900);
6102
6103            // Position 20+ should not exist yet
6104            assert!(matches!(
6105                journal.read(20).await,
6106                Err(Error::ItemOutOfRange(_))
6107            ));
6108
6109            // Assert journal can accept new items
6110            let pos = journal.append(&999).await.unwrap();
6111            assert_eq!(pos, 20);
6112            assert_eq!(journal.read(20).await.unwrap(), 999);
6113
6114            journal.destroy().await.unwrap();
6115        });
6116    }
6117
6118    /// Test `init_sync` with invalid parameters.
6119    #[should_panic]
6120    #[test_traced]
6121    fn test_init_sync_invalid_parameters() {
6122        let executor = deterministic::Runner::default();
6123        executor.start(|context| async move {
6124            let cfg = Config {
6125                partition: "test-invalid".into(),
6126                items_per_section: NZU64!(5),
6127                compression: None,
6128                codec_config: (),
6129                write_buffer: NZUsize!(1024),
6130                page_cache: CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(PAGE_CACHE_SIZE)),
6131            };
6132
6133            #[allow(clippy::reversed_empty_ranges)]
6134            let _result = Journal::<deterministic::Context, u64>::init_sync(
6135                context.child("storage"),
6136                cfg,
6137                10..5, // invalid range: lower > upper
6138            )
6139            .await;
6140        });
6141    }
6142
6143    /// Test `init_sync` when existing data exactly matches the sync range.
6144    #[test_traced]
6145    fn test_init_sync_existing_data_exact_match() {
6146        let executor = deterministic::Runner::default();
6147        executor.start(|context| async move {
6148            let items_per_section = NZU64!(5);
6149            let cfg = Config {
6150                partition: "test-exact-match".into(),
6151                items_per_section,
6152                compression: None,
6153                codec_config: (),
6154                write_buffer: NZUsize!(1024),
6155                page_cache: CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(PAGE_CACHE_SIZE)),
6156            };
6157
6158            // Create initial journal with data exactly matching sync range
6159            let mut journal =
6160                Journal::<deterministic::Context, u64>::init(context.child("storage"), cfg.clone())
6161                    .await
6162                    .expect("Failed to create initial journal");
6163
6164            // Add data at positions 0-19 (blobs 0-3 with items_per_section=5)
6165            for i in 0..20u64 {
6166                journal.append(&(i * 100)).await.unwrap();
6167            }
6168            journal.sync().await.unwrap();
6169            drop(journal);
6170
6171            // Initialize with sync boundaries that exactly match existing data
6172            let lower_bound = 5; // blob 1
6173            let upper_bound = 20; // blob 3
6174            let mut journal = Journal::<deterministic::Context, u64>::init_sync(
6175                context.child("storage"),
6176                cfg.clone(),
6177                lower_bound..upper_bound,
6178            )
6179            .await
6180            .expect("Failed to initialize journal with exact match");
6181
6182            assert_eq!(journal.size(), 20);
6183
6184            // Verify pruning to lower bound (blob 1 boundary = position 5)
6185            assert_eq!(journal.bounds().start, 5); // Blob 1 starts at position 5
6186
6187            // Verify positions before 5 are pruned
6188            assert!(matches!(journal.read(0).await, Err(Error::ItemPruned(_))));
6189            assert!(matches!(journal.read(4).await, Err(Error::ItemPruned(_))));
6190
6191            // Positions 5-19 should be accessible
6192            assert_eq!(journal.read(5).await.unwrap(), 500);
6193            assert_eq!(journal.read(10).await.unwrap(), 1000);
6194            assert_eq!(journal.read(19).await.unwrap(), 1900);
6195
6196            // Position 20+ should not exist yet
6197            assert!(matches!(
6198                journal.read(20).await,
6199                Err(Error::ItemOutOfRange(_))
6200            ));
6201
6202            // Assert journal can accept new operations
6203            let pos = journal.append(&999).await.unwrap();
6204            assert_eq!(pos, 20);
6205            assert_eq!(journal.read(20).await.unwrap(), 999);
6206
6207            journal.destroy().await.unwrap();
6208        });
6209    }
6210
6211    /// Test `init_sync` when existing data exceeds the sync target range.
6212    /// This tests that ItemOutOfRange is returned when existing data goes beyond the upper bound.
6213    #[test_traced]
6214    fn test_init_sync_existing_data_exceeds_upper_bound() {
6215        let executor = deterministic::Runner::default();
6216        executor.start(|context| async move {
6217            let items_per_section = NZU64!(5);
6218            let cfg = Config {
6219                partition: "test-unexpected-data".into(),
6220                items_per_section,
6221                compression: None,
6222                codec_config: (),
6223                write_buffer: NZUsize!(1024),
6224                page_cache: CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(PAGE_CACHE_SIZE)),
6225            };
6226
6227            // Create initial journal with data beyond sync range
6228            let mut journal =
6229                Journal::<deterministic::Context, u64>::init(context.child("initial"), cfg.clone())
6230                    .await
6231                    .expect("Failed to create initial journal");
6232
6233            // Add data at positions 0-29 (blobs 0-5 with items_per_section=5)
6234            for i in 0..30u64 {
6235                journal.append(&(i * 1000)).await.unwrap();
6236            }
6237            journal.sync().await.unwrap();
6238            drop(journal);
6239
6240            // Initialize with sync boundaries that are exceeded by existing data
6241            let lower_bound = 8; // blob 1
6242            for (i, upper_bound) in (9..29).enumerate() {
6243                let result = Journal::<deterministic::Context, u64>::init_sync(
6244                    context.child("sync").with_attribute("index", i),
6245                    cfg.clone(),
6246                    lower_bound..upper_bound,
6247                )
6248                .await;
6249
6250                // Should return ItemOutOfRange error since data exists beyond upper_bound
6251                assert!(matches!(result, Err(Error::ItemOutOfRange(_))));
6252            }
6253        });
6254    }
6255
6256    /// Test `init_sync` repairs an empty journal recovered at a stale position beyond the range.
6257    #[test_traced]
6258    fn test_init_sync_empty_stale_position_beyond_upper_bound() {
6259        let executor = deterministic::Runner::default();
6260        executor.start(|context| async move {
6261            let cfg = Config {
6262                partition: "test-empty-stale-position".into(),
6263                items_per_section: NZU64!(5),
6264                compression: None,
6265                codec_config: (),
6266                write_buffer: NZUsize!(1024),
6267                page_cache: CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(PAGE_CACHE_SIZE)),
6268            };
6269
6270            let stale_size = 30;
6271            let journal = Journal::<deterministic::Context, u64>::init_at_size(
6272                context.child("first"),
6273                cfg.clone(),
6274                stale_size,
6275            )
6276            .await
6277            .expect("Failed to create stale empty journal");
6278            assert_eq!(journal.size(), stale_size);
6279            assert!(journal.bounds().is_empty());
6280            drop(journal);
6281
6282            let lower_bound = 10;
6283            let upper_bound = 26;
6284            let mut journal = Journal::<deterministic::Context, u64>::init_sync(
6285                context.child("second"),
6286                cfg.clone(),
6287                lower_bound..upper_bound,
6288            )
6289            .await
6290            .expect("Failed to repair stale empty journal");
6291
6292            assert_eq!(journal.size(), lower_bound);
6293            assert!(journal.bounds().is_empty());
6294
6295            let pos = journal.append(&999).await.unwrap();
6296            assert_eq!(pos, lower_bound);
6297            assert_eq!(journal.read(pos).await.unwrap(), 999);
6298
6299            journal.destroy().await.unwrap();
6300        });
6301    }
6302
6303    /// Test `init_sync` repairs an empty journal recovered after a `clear_to_size` crash.
6304    #[test_traced]
6305    fn test_init_sync_recovers_from_stale_clear_to_size() {
6306        let executor = deterministic::Runner::default();
6307        executor.start(|context| async move {
6308            let cfg = Config {
6309                partition: "test-stale-clear-to-size".into(),
6310                items_per_section: NZU64!(5),
6311                compression: None,
6312                codec_config: (),
6313                write_buffer: NZUsize!(1024),
6314                page_cache: CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(PAGE_CACHE_SIZE)),
6315            };
6316
6317            let mut journal = Journal::<deterministic::Context, u64>::init_at_size(
6318                context.child("first"),
6319                cfg.clone(),
6320                9,
6321            )
6322            .await
6323            .expect("Failed to create stale empty journal");
6324            journal.sync().await.unwrap();
6325            drop(journal);
6326
6327            // Simulate clear_to_size(7) crashing after clearing data, but before offsets were
6328            // re-cleared. Recovery will initially see the old empty offsets boundary at 9.
6329            match context.remove(&cfg.data_partition(), None).await {
6330                Ok(()) | Err(commonware_runtime::Error::PartitionMissing(_)) => {}
6331                Err(error) => panic!("failed to clear data partition: {error}"),
6332            }
6333
6334            let lower_bound = 7;
6335            let upper_bound = 20;
6336            let journal = Journal::<deterministic::Context, u64>::init_sync(
6337                context.child("second"),
6338                cfg.clone(),
6339                lower_bound..upper_bound,
6340            )
6341            .await
6342            .expect("Failed to repair stale empty journal");
6343
6344            assert_eq!(journal.size(), lower_bound);
6345            let bounds = journal.bounds();
6346            assert!(bounds.is_empty());
6347            assert_eq!(bounds.start, lower_bound);
6348
6349            journal.destroy().await.unwrap();
6350        });
6351    }
6352
6353    /// Test `init_sync` when all existing data is stale (before lower bound).
6354    #[test_traced]
6355    fn test_init_sync_existing_data_stale() {
6356        let executor = deterministic::Runner::default();
6357        executor.start(|context| async move {
6358            let items_per_section = NZU64!(5);
6359            let cfg = Config {
6360                partition: "test-stale".into(),
6361                items_per_section,
6362                compression: None,
6363                codec_config: (),
6364                write_buffer: NZUsize!(1024),
6365                page_cache: CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(PAGE_CACHE_SIZE)),
6366            };
6367
6368            // Create initial journal with stale data
6369            let mut journal =
6370                Journal::<deterministic::Context, u64>::init(context.child("first"), cfg.clone())
6371                    .await
6372                    .expect("Failed to create initial journal");
6373
6374            // Add data at positions 0-9 (blobs 0-1 with items_per_section=5)
6375            for i in 0..10u64 {
6376                journal.append(&(i * 100)).await.unwrap();
6377            }
6378            journal.sync().await.unwrap();
6379            drop(journal);
6380
6381            // Initialize with sync boundaries beyond all existing data
6382            let lower_bound = 15; // blob 3
6383            let upper_bound = 26; // last element in blob 5
6384            let journal = Journal::<deterministic::Context, u64>::init_sync(
6385                context.child("second"),
6386                cfg.clone(),
6387                lower_bound..upper_bound,
6388            )
6389            .await
6390            .expect("Failed to initialize journal with stale data");
6391
6392            assert_eq!(journal.size(), 15);
6393
6394            // Verify fresh journal (all old data destroyed, starts at position 15)
6395            assert!(journal.bounds().is_empty());
6396
6397            // Verify old positions don't exist
6398            assert!(matches!(journal.read(0).await, Err(Error::ItemPruned(_))));
6399            assert!(matches!(journal.read(9).await, Err(Error::ItemPruned(_))));
6400            assert!(matches!(journal.read(14).await, Err(Error::ItemPruned(_))));
6401
6402            journal.destroy().await.unwrap();
6403        });
6404    }
6405
6406    /// Test `init_sync` with blob boundary edge cases.
6407    #[test_traced]
6408    fn test_init_sync_blob_boundaries() {
6409        let executor = deterministic::Runner::default();
6410        executor.start(|context| async move {
6411            let items_per_section = NZU64!(5);
6412            let cfg = Config {
6413                partition: "test-boundaries".into(),
6414                items_per_section,
6415                compression: None,
6416                codec_config: (),
6417                write_buffer: NZUsize!(1024),
6418                page_cache: CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(PAGE_CACHE_SIZE)),
6419            };
6420
6421            // Create journal with data at blob boundaries
6422            let mut journal =
6423                Journal::<deterministic::Context, u64>::init(context.child("storage"), cfg.clone())
6424                    .await
6425                    .expect("Failed to create initial journal");
6426
6427            // Add data at positions 0-24 (blobs 0-4 with items_per_section=5)
6428            for i in 0..25u64 {
6429                journal.append(&(i * 100)).await.unwrap();
6430            }
6431            journal.sync().await.unwrap();
6432            drop(journal);
6433
6434            // Test sync boundaries exactly at blob boundaries
6435            let lower_bound = 15; // Exactly at blob boundary (15/5 = 3)
6436            let upper_bound = 25; // Last element exactly at blob boundary (24/5 = 4)
6437            let mut journal = Journal::<deterministic::Context, u64>::init_sync(
6438                context.child("storage"),
6439                cfg.clone(),
6440                lower_bound..upper_bound,
6441            )
6442            .await
6443            .expect("Failed to initialize journal at boundaries");
6444
6445            assert_eq!(journal.size(), 25);
6446
6447            // Verify oldest retained is at blob 3 boundary (position 15)
6448            assert_eq!(journal.bounds().start, 15);
6449
6450            // Verify positions before 15 are pruned
6451            assert!(matches!(journal.read(0).await, Err(Error::ItemPruned(_))));
6452            assert!(matches!(journal.read(14).await, Err(Error::ItemPruned(_))));
6453
6454            // Verify positions 15-24 are accessible
6455            assert_eq!(journal.read(15).await.unwrap(), 1500);
6456            assert_eq!(journal.read(20).await.unwrap(), 2000);
6457            assert_eq!(journal.read(24).await.unwrap(), 2400);
6458
6459            // Position 25+ should not exist yet
6460            assert!(matches!(
6461                journal.read(25).await,
6462                Err(Error::ItemOutOfRange(_))
6463            ));
6464
6465            // Assert journal can accept new operations
6466            let pos = journal.append(&999).await.unwrap();
6467            assert_eq!(pos, 25);
6468            assert_eq!(journal.read(25).await.unwrap(), 999);
6469
6470            journal.destroy().await.unwrap();
6471        });
6472    }
6473
6474    /// Test `init_sync` when range.start and range.end-1 are in the same blob.
6475    #[test_traced]
6476    fn test_init_sync_same_blob_bounds() {
6477        let executor = deterministic::Runner::default();
6478        executor.start(|context| async move {
6479            let items_per_section = NZU64!(5);
6480            let cfg = Config {
6481                partition: "test-same-blob".into(),
6482                items_per_section,
6483                compression: None,
6484                codec_config: (),
6485                write_buffer: NZUsize!(1024),
6486                page_cache: CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(PAGE_CACHE_SIZE)),
6487            };
6488
6489            // Create journal with data in multiple blobs
6490            let mut journal =
6491                Journal::<deterministic::Context, u64>::init(context.child("storage"), cfg.clone())
6492                    .await
6493                    .expect("Failed to create initial journal");
6494
6495            // Add data at positions 0-14 (blobs 0-2 with items_per_section=5)
6496            for i in 0..15u64 {
6497                journal.append(&(i * 100)).await.unwrap();
6498            }
6499            journal.sync().await.unwrap();
6500            drop(journal);
6501
6502            // Test sync boundaries within the same blob
6503            let lower_bound = 10; // operation 10 (blob 2: 10/5 = 2)
6504            let upper_bound = 15; // Last operation 14 (blob 2: 14/5 = 2)
6505            let mut journal = Journal::<deterministic::Context, u64>::init_sync(
6506                context.child("storage"),
6507                cfg.clone(),
6508                lower_bound..upper_bound,
6509            )
6510            .await
6511            .expect("Failed to initialize journal with same-blob bounds");
6512
6513            assert_eq!(journal.size(), 15);
6514
6515            // Both operations are in blob 2, so blobs 0, 1 should be pruned, blob 2 retained
6516            // Oldest retained position should be at blob 2 boundary (position 10)
6517            assert_eq!(journal.bounds().start, 10);
6518
6519            // Verify positions before 10 are pruned
6520            assert!(matches!(journal.read(0).await, Err(Error::ItemPruned(_))));
6521            assert!(matches!(journal.read(9).await, Err(Error::ItemPruned(_))));
6522
6523            // Verify positions 10-14 are accessible
6524            assert_eq!(journal.read(10).await.unwrap(), 1000);
6525            assert_eq!(journal.read(11).await.unwrap(), 1100);
6526            assert_eq!(journal.read(14).await.unwrap(), 1400);
6527
6528            // Position 15+ should not exist yet
6529            assert!(matches!(
6530                journal.read(15).await,
6531                Err(Error::ItemOutOfRange(_))
6532            ));
6533
6534            // Assert journal can accept new operations
6535            let pos = journal.append(&999).await.unwrap();
6536            assert_eq!(pos, 15);
6537            assert_eq!(journal.read(15).await.unwrap(), 999);
6538
6539            journal.destroy().await.unwrap();
6540        });
6541    }
6542
6543    /// Test contiguous variable journal with items_per_section=1.
6544    ///
6545    /// This is a regression test for a bug where reading from size()-1 fails
6546    /// when using items_per_section=1, particularly after pruning and restart.
6547    #[test_traced]
6548    fn test_single_item_per_blob() {
6549        let executor = deterministic::Runner::default();
6550        executor.start(|context| async move {
6551            let cfg = Config {
6552                partition: "single-item-per-blob".into(),
6553                items_per_section: NZU64!(1),
6554                compression: None,
6555                codec_config: (),
6556                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
6557                write_buffer: NZUsize!(1024),
6558            };
6559
6560            // === Test 1: Basic single item operation ===
6561            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
6562                .await
6563                .unwrap();
6564
6565            // Verify empty state
6566            let bounds = journal.bounds();
6567            assert_eq!(bounds.end, 0);
6568            assert!(bounds.is_empty());
6569
6570            // Append 1 item (value = position * 100, so position 0 has value 0)
6571            let pos = journal.append(&0).await.unwrap();
6572            assert_eq!(pos, 0);
6573            assert_eq!(journal.size(), 1);
6574
6575            // Sync
6576            journal.sync().await.unwrap();
6577
6578            // Read from size() - 1
6579            let value = journal.read(journal.size() - 1).await.unwrap();
6580            assert_eq!(value, 0);
6581
6582            // === Test 2: Multiple items with single item per blob ===
6583            for i in 1..10u64 {
6584                let pos = journal.append(&(i * 100)).await.unwrap();
6585                assert_eq!(pos, i);
6586                assert_eq!(journal.size(), i + 1);
6587
6588                // Verify we can read the just-appended item at size() - 1
6589                let value = journal.read(journal.size() - 1).await.unwrap();
6590                assert_eq!(value, i * 100);
6591            }
6592
6593            // Verify all items can be read
6594            for i in 0..10u64 {
6595                assert_eq!(journal.read(i).await.unwrap(), i * 100);
6596            }
6597
6598            journal.sync().await.unwrap();
6599
6600            // === Test 3: Pruning with single item per blob ===
6601            // Prune to position 5 (removes positions 0-4)
6602            let pruned = journal.prune(5).await.unwrap();
6603            assert!(pruned);
6604
6605            // Size should still be 10
6606            assert_eq!(journal.size(), 10);
6607
6608            // bounds.start should be 5
6609            assert_eq!(journal.bounds().start, 5);
6610
6611            // Reading from bounds.end - 1 (position 9) should still work
6612            let value = journal.read(journal.size() - 1).await.unwrap();
6613            assert_eq!(value, 900);
6614
6615            // Reading from pruned positions should return ItemPruned
6616            for i in 0..5 {
6617                assert!(matches!(
6618                    journal.read(i).await,
6619                    Err(crate::journal::Error::ItemPruned(_))
6620                ));
6621            }
6622
6623            // Reading from retained positions should work
6624            for i in 5..10u64 {
6625                assert_eq!(journal.read(i).await.unwrap(), i * 100);
6626            }
6627
6628            // Append more items after pruning
6629            for i in 10..15u64 {
6630                let pos = journal.append(&(i * 100)).await.unwrap();
6631                assert_eq!(pos, i);
6632
6633                // Verify we can read from size() - 1
6634                let value = journal.read(journal.size() - 1).await.unwrap();
6635                assert_eq!(value, i * 100);
6636            }
6637
6638            journal.sync().await.unwrap();
6639            drop(journal);
6640
6641            // === Test 4: Restart persistence with single item per blob ===
6642            let journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
6643                .await
6644                .unwrap();
6645
6646            // Verify size is preserved
6647            assert_eq!(journal.size(), 15);
6648
6649            // Verify bounds.start is preserved
6650            assert_eq!(journal.bounds().start, 5);
6651
6652            // Reading from bounds.end - 1 should work after restart
6653            let value = journal.read(journal.size() - 1).await.unwrap();
6654            assert_eq!(value, 1400);
6655
6656            // Reading all retained positions should work
6657            for i in 5..15u64 {
6658                assert_eq!(journal.read(i).await.unwrap(), i * 100);
6659            }
6660
6661            journal.destroy().await.unwrap();
6662
6663            // === Test 5: Restart after pruning with non-zero index (KEY SCENARIO) ===
6664            // Fresh journal for this test
6665            let mut journal = Journal::<_, u64>::init(context.child("third"), cfg.clone())
6666                .await
6667                .unwrap();
6668
6669            // Append 10 items (positions 0-9)
6670            for i in 0..10u64 {
6671                journal.append(&(i * 1000)).await.unwrap();
6672            }
6673
6674            // Prune to position 5 (removes positions 0-4)
6675            journal.prune(5).await.unwrap();
6676            let bounds = journal.bounds();
6677            assert_eq!(bounds.end, 10);
6678            assert_eq!(bounds.start, 5);
6679
6680            // Sync and restart
6681            journal.sync().await.unwrap();
6682            drop(journal);
6683
6684            // Re-open journal
6685            let journal = Journal::<_, u64>::init(context.child("fourth"), cfg.clone())
6686                .await
6687                .unwrap();
6688
6689            // Verify state after restart
6690            let bounds = journal.bounds();
6691            assert_eq!(bounds.end, 10);
6692            assert_eq!(bounds.start, 5);
6693
6694            // KEY TEST: Reading from bounds.end - 1 (position 9) should work
6695            let value = journal.read(journal.size() - 1).await.unwrap();
6696            assert_eq!(value, 9000);
6697
6698            // Verify all retained positions (5-9) work
6699            for i in 5..10u64 {
6700                assert_eq!(journal.read(i).await.unwrap(), i * 1000);
6701            }
6702
6703            journal.destroy().await.unwrap();
6704
6705            // === Test 6: Prune all items (edge case) ===
6706            // This tests the scenario where prune removes everything.
6707            // Callers must check bounds().is_empty() before reading.
6708            let mut journal = Journal::<_, u64>::init(context.child("fifth"), cfg.clone())
6709                .await
6710                .unwrap();
6711
6712            for i in 0..5u64 {
6713                journal.append(&(i * 100)).await.unwrap();
6714            }
6715            journal.sync().await.unwrap();
6716
6717            // Prune all items
6718            journal.prune(5).await.unwrap();
6719            let bounds = journal.bounds();
6720            assert_eq!(bounds.end, 5); // Size unchanged
6721            assert!(bounds.is_empty()); // All pruned
6722
6723            // bounds.end - 1 = 4, but position 4 is pruned
6724            let result = journal.read(journal.size() - 1).await;
6725            assert!(matches!(result, Err(crate::journal::Error::ItemPruned(4))));
6726
6727            // After appending, reading works again
6728            journal.append(&500).await.unwrap();
6729            let bounds = journal.bounds();
6730            assert_eq!(bounds.start, 5);
6731            assert_eq!(journal.read(bounds.end - 1).await.unwrap(), 500);
6732
6733            journal.destroy().await.unwrap();
6734        });
6735    }
6736
6737    #[test_traced]
6738    fn test_variable_journal_clear_to_size() {
6739        let executor = deterministic::Runner::default();
6740        executor.start(|context| async move {
6741            let cfg = Config {
6742                partition: "clear-test".into(),
6743                items_per_section: NZU64!(10),
6744                compression: None,
6745                codec_config: (),
6746                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
6747                write_buffer: NZUsize!(1024),
6748            };
6749
6750            let mut journal = Journal::<_, u64>::init(context.child("journal"), cfg.clone())
6751                .await
6752                .unwrap();
6753
6754            // Append 25 items (spanning multiple blobs)
6755            for i in 0..25u64 {
6756                journal.append(&(i * 100)).await.unwrap();
6757            }
6758            let bounds = journal.bounds();
6759            assert_eq!(bounds.end, 25);
6760            assert_eq!(bounds.start, 0);
6761            journal.sync().await.unwrap();
6762
6763            // Clear to position 100, effectively resetting the journal
6764            journal.clear_to_size(100).await.unwrap();
6765            let bounds = journal.bounds();
6766            assert_eq!(bounds.end, 100);
6767            assert!(bounds.is_empty());
6768
6769            // Old positions should fail
6770            for i in 0..25 {
6771                assert!(matches!(
6772                    journal.read(i).await,
6773                    Err(crate::journal::Error::ItemPruned(_))
6774                ));
6775            }
6776
6777            // Verify size persists after restart without writing any data
6778            drop(journal);
6779            let mut journal =
6780                Journal::<_, u64>::init(context.child("journal_after_clear"), cfg.clone())
6781                    .await
6782                    .unwrap();
6783            let bounds = journal.bounds();
6784            assert_eq!(bounds.end, 100);
6785            assert!(bounds.is_empty());
6786
6787            // Append new data starting at position 100
6788            for i in 100..105u64 {
6789                let pos = journal.append(&(i * 100)).await.unwrap();
6790                assert_eq!(pos, i);
6791            }
6792            let bounds = journal.bounds();
6793            assert_eq!(bounds.end, 105);
6794            assert_eq!(bounds.start, 100);
6795
6796            // New positions should be readable
6797            for i in 100..105u64 {
6798                assert_eq!(journal.read(i).await.unwrap(), i * 100);
6799            }
6800
6801            // Sync and re-init to verify persistence
6802            journal.sync().await.unwrap();
6803            drop(journal);
6804
6805            let journal = Journal::<_, u64>::init(context.child("journal_reopened"), cfg)
6806                .await
6807                .unwrap();
6808
6809            let bounds = journal.bounds();
6810            assert_eq!(bounds.end, 105);
6811            assert_eq!(bounds.start, 100);
6812            for i in 100..105u64 {
6813                assert_eq!(journal.read(i).await.unwrap(), i * 100);
6814            }
6815
6816            journal.destroy().await.unwrap();
6817        });
6818    }
6819
6820    #[test_traced]
6821    fn test_variable_journal_metrics() {
6822        let executor = deterministic::Runner::default();
6823        executor.start(|context| async move {
6824            let cfg = Config {
6825                partition: "metrics".into(),
6826                items_per_section: NZU64!(2),
6827                compression: None,
6828                codec_config: (),
6829                page_cache: CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(10)),
6830                write_buffer: NZUsize!(1024),
6831            };
6832            let mut journal = Journal::<_, u64>::init(context.child("variable_metrics"), cfg)
6833                .await
6834                .unwrap();
6835
6836            let items = [0, 1, 2, 3, 4];
6837            journal.append_many(Many::Flat(&items)).await.unwrap();
6838            journal.append(&5).await.unwrap();
6839            let reader = journal.snapshot().await.unwrap();
6840            reader.read(0).await.unwrap();
6841            reader.read_many(&[1, 2]).await.unwrap();
6842            reader.try_read_sync(3).unwrap();
6843            drop(reader);
6844            journal.commit().await.unwrap();
6845            journal.sync().await.unwrap();
6846            journal.prune(2).await.unwrap();
6847            journal.rewind(4).await.unwrap();
6848
6849            let buffer = context.encode();
6850            for expected in [
6851                "variable_metrics_size 4",
6852                "variable_metrics_pruning_boundary 2",
6853                "variable_metrics_retained 2",
6854                "variable_metrics_tail_items 2",
6855                "variable_metrics_append_calls_total 1",
6856                "variable_metrics_append_many_calls_total 1",
6857                "variable_metrics_read_calls_total 1",
6858                "variable_metrics_read_many_calls_total 1",
6859                "variable_metrics_items_read_total 4",
6860                "variable_metrics_commit_calls_total 1",
6861                "variable_metrics_sync_calls_total 1",
6862                "variable_metrics_append_duration_count 1",
6863                "variable_metrics_append_many_duration_count 1",
6864                "variable_metrics_read_duration_count 0",
6865                "variable_metrics_read_many_duration_count 1",
6866                "variable_metrics_commit_duration_count 1",
6867                "variable_metrics_sync_duration_count 1",
6868                "variable_metrics_cache_hits_total 4",
6869                "variable_metrics_cache_misses_total 0",
6870                "variable_metrics_data_tracked",
6871                "variable_metrics_offsets_size 4",
6872                "variable_metrics_offsets_blobs_tracked",
6873            ] {
6874                assert!(buffer.contains(expected), "{expected}\n{buffer}");
6875            }
6876
6877            journal.destroy().await.unwrap();
6878        });
6879    }
6880
6881    #[test_traced]
6882    fn test_variable_journal_read_miss_timed() {
6883        // Reads served from storage record a read_duration sample; cache hits do not.
6884        let executor = deterministic::Runner::default();
6885        executor.start(|context| async move {
6886            // Blobs span multiple full pages so their data must go through the (evictable)
6887            // page cache rather than staying resident in each blob's partial tail page.
6888            let cfg = Config {
6889                partition: "miss".into(),
6890                items_per_section: NZU64!(50),
6891                compression: None,
6892                codec_config: (),
6893                page_cache: CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(10)),
6894                write_buffer: NZUsize!(1024),
6895            };
6896            let mut journal = Journal::<_, u64>::init(context.child("miss"), cfg)
6897                .await
6898                .unwrap();
6899            for i in 0..200u64 {
6900                journal.append(&i).await.unwrap();
6901            }
6902            journal.sync().await.unwrap();
6903
6904            // The page cache cannot hold every page, so some position must be cold.
6905            let reader = journal.snapshot().await.unwrap();
6906            let pos = (0..200)
6907                .find(|&pos| reader.try_read_sync(pos).is_none())
6908                .expect("some position should be cold");
6909            assert_eq!(reader.read(pos).await.unwrap(), pos);
6910            drop(reader);
6911
6912            let buffer = context.encode();
6913            assert!(buffer.contains("miss_read_duration_count 1"), "{buffer}");
6914
6915            journal.destroy().await.unwrap();
6916        });
6917    }
6918
6919    #[test_traced]
6920    fn test_variable_snapshot_frozen_across_roll() {
6921        let executor = deterministic::Runner::default();
6922        executor.start(|context| async move {
6923            let cfg = Config {
6924                partition: "snapshot-frozen".into(),
6925                items_per_section: NZU64!(5),
6926                compression: None,
6927                codec_config: (),
6928                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
6929                write_buffer: NZUsize!(1024),
6930            };
6931            let mut journal = Journal::<_, u64>::init(context.child("j"), cfg)
6932                .await
6933                .unwrap();
6934            for i in 0..7u64 {
6935                journal.append(&(i * 100)).await.unwrap();
6936            }
6937
6938            let snapshot = journal.snapshot().await.unwrap();
6939            assert_eq!(snapshot.bounds(), 0..7);
6940
6941            // Appending past the blob boundary rolls the snapshot's tail blob into
6942            // history; the snapshot keeps reading it through its own handle.
6943            for i in 7..23u64 {
6944                journal.append(&(i * 100)).await.unwrap();
6945            }
6946            assert_eq!(snapshot.bounds(), 0..7);
6947            for i in 0..7u64 {
6948                assert_eq!(snapshot.read(i).await.unwrap(), i * 100);
6949            }
6950            assert!(matches!(
6951                snapshot.read(7).await,
6952                Err(Error::ItemOutOfRange(7))
6953            ));
6954
6955            let fresh = journal.snapshot().await.unwrap();
6956            assert_eq!(fresh.bounds(), 0..23);
6957            assert_eq!(fresh.read(22).await.unwrap(), 2200);
6958
6959            drop(snapshot);
6960            drop(fresh);
6961            journal.destroy().await.unwrap();
6962        });
6963    }
6964
6965    #[test_traced]
6966    fn test_variable_prune_under_snapshot() {
6967        let executor = deterministic::Runner::default();
6968        executor.start(|context| async move {
6969            let cfg = Config {
6970                partition: "snapshot-prune".into(),
6971                items_per_section: NZU64!(5),
6972                compression: Some(3),
6973                codec_config: (),
6974                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
6975                write_buffer: NZUsize!(1024),
6976            };
6977            let mut journal = Journal::<_, u64>::init(context.child("j"), cfg)
6978                .await
6979                .unwrap();
6980            for i in 0..17u64 {
6981                journal.append(&(i * 100)).await.unwrap();
6982            }
6983            journal.sync().await.unwrap();
6984
6985            let snapshot = journal.snapshot().await.unwrap();
6986            assert!(journal.prune(12).await.unwrap());
6987
6988            // The straggler reads the pruned range through its own handles.
6989            assert_eq!(snapshot.bounds(), 0..17);
6990            for i in 0..17u64 {
6991                assert_eq!(snapshot.read(i).await.unwrap(), i * 100);
6992            }
6993            assert_eq!(
6994                snapshot.read_many(&[1, 2, 3, 11, 16]).await.unwrap(),
6995                vec![100, 200, 300, 1100, 1600]
6996            );
6997
6998            let fresh = journal.snapshot().await.unwrap();
6999            assert_eq!(fresh.bounds(), 10..17);
7000            assert!(matches!(fresh.read(3).await, Err(Error::ItemPruned(3))));
7001
7002            drop(snapshot);
7003            drop(fresh);
7004            journal.destroy().await.unwrap();
7005        });
7006    }
7007
7008    #[test_traced]
7009    fn test_variable_snapshots_readable_during_concurrent_appends() {
7010        let executor = deterministic::Runner::seeded(7);
7011        executor.start(|context| async move {
7012            let cfg = Config {
7013                partition: "snapshot-concurrent".into(),
7014                items_per_section: NZU64!(5),
7015                compression: None,
7016                codec_config: (),
7017                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
7018                write_buffer: NZUsize!(1024),
7019            };
7020            let mut journal = Journal::<_, u64>::init(context.child("j"), cfg)
7021                .await
7022                .unwrap();
7023
7024            let (mut tx, mut rx) =
7025                futures::channel::mpsc::channel::<Reader<'static, deterministic::Context, u64>>(8);
7026            let validator = context.child("validator").spawn(|_| async move {
7027                let mut validated = 0usize;
7028                while let Some(snapshot) = rx.next().await {
7029                    let bounds = snapshot.bounds();
7030                    for i in bounds.clone() {
7031                        assert_eq!(snapshot.read(i).await.unwrap(), i * 100);
7032                    }
7033                    validated += (bounds.end - bounds.start) as usize;
7034                }
7035                validated
7036            });
7037
7038            for i in 0..40u64 {
7039                journal.append(&(i * 100)).await.unwrap();
7040                if i % 7 == 0 {
7041                    let snapshot = journal.snapshot().await.unwrap();
7042                    if tx.try_send(snapshot).is_err() {
7043                        break;
7044                    }
7045                }
7046            }
7047            drop(tx);
7048            assert!(validator.await.unwrap() > 0);
7049
7050            journal.destroy().await.unwrap();
7051        });
7052    }
7053
7054    #[test_traced]
7055    fn test_variable_replay_from_stale_snapshot() {
7056        let executor = deterministic::Runner::default();
7057        executor.start(|context| async move {
7058            let cfg = Config {
7059                partition: "snapshot-replay".into(),
7060                items_per_section: NZU64!(5),
7061                compression: None,
7062                codec_config: (),
7063                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
7064                write_buffer: NZUsize!(1024),
7065            };
7066            let mut journal = Journal::<_, u64>::init(context.child("j"), cfg)
7067                .await
7068                .unwrap();
7069            for i in 0..7u64 {
7070                journal.append(&(i * 100)).await.unwrap();
7071            }
7072
7073            // Positions 5..7 live in the snapshot's tail blob.
7074            let snapshot = journal.snapshot().await.unwrap();
7075            assert_eq!(snapshot.bounds(), 0..7);
7076
7077            // Roll the snapshot's tail into history, then prune both of its blobs away.
7078            for i in 7..23u64 {
7079                journal.append(&(i * 100)).await.unwrap();
7080            }
7081            assert!(journal.prune(12).await.unwrap());
7082
7083            {
7084                let stream = snapshot.replay(0, NZUsize!(1024)).await.unwrap();
7085                futures::pin_mut!(stream);
7086                let mut expected = 0u64;
7087                while let Some(result) = stream.next().await {
7088                    let (pos, item) = result.unwrap();
7089                    assert_eq!(pos, expected);
7090                    assert_eq!(item, pos * 100);
7091                    expected += 1;
7092                }
7093                assert_eq!(expected, 7);
7094            }
7095
7096            drop(snapshot);
7097            journal.destroy().await.unwrap();
7098        });
7099    }
7100
7101    /// A journal written before eager tail creation can end with a full newest blob and no
7102    /// successor file; recovery opens the missing tail.
7103    #[test_traced]
7104    fn test_variable_recovery_full_newest_blob_without_successor() {
7105        let executor = deterministic::Runner::default();
7106        executor.start(|context| async move {
7107            let cfg = Config {
7108                partition: "recovery-full-newest-no-successor".into(),
7109                items_per_section: NZU64!(10),
7110                compression: None,
7111                codec_config: (),
7112                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
7113                write_buffer: NZUsize!(1024),
7114            };
7115
7116            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
7117                .await
7118                .unwrap();
7119            for i in 0..10u64 {
7120                journal.append(&(i * 100)).await.unwrap();
7121            }
7122            journal.sync().await.unwrap();
7123            drop(journal);
7124
7125            // Remove the empty tail blob, leaving only the full blob 0 (the layout written
7126            // before eager tail creation).
7127            let data_partition = cfg.data_partition();
7128            context
7129                .remove(&data_partition, Some(&1u64.to_be_bytes()))
7130                .await
7131                .unwrap();
7132
7133            let mut journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
7134                .await
7135                .unwrap();
7136            assert_eq!(journal.bounds(), 0..10);
7137            for i in 0..10u64 {
7138                assert_eq!(journal.read(i).await.unwrap(), i * 100);
7139            }
7140            assert_eq!(journal.append(&1000).await.unwrap(), 10);
7141            assert_eq!(journal.read(10).await.unwrap(), 1000);
7142
7143            journal.destroy().await.unwrap();
7144        });
7145    }
7146
7147    /// A crash after `rewind` lowered and truncated the offsets journal but before the data was
7148    /// truncated leaves offsets behind data; recovery rebuilds the offsets suffix, restoring the
7149    /// pre-rewind state with every item readable.
7150    #[test_traced]
7151    fn test_variable_rewind_crash_before_data_truncation() {
7152        let executor = deterministic::Runner::default();
7153        executor.start(|context| async move {
7154            let cfg = Config {
7155                partition: "rewind-crash-offsets-only".into(),
7156                items_per_section: NZU64!(10),
7157                compression: None,
7158                codec_config: (),
7159                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
7160                write_buffer: NZUsize!(1024),
7161            };
7162
7163            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
7164                .await
7165                .unwrap();
7166            for i in 0..25u64 {
7167                journal.append(&(i * 100)).await.unwrap();
7168            }
7169            journal.sync().await.unwrap();
7170
7171            // `rewind` truncates offsets (lowering the watermark) before the data; simulate a
7172            // crash in between.
7173            journal.test_rewind_offsets(12).await.unwrap();
7174            drop(journal);
7175
7176            let journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
7177                .await
7178                .unwrap();
7179            assert_eq!(journal.bounds(), 0..25);
7180            for i in 0..25u64 {
7181                assert_eq!(journal.read(i).await.unwrap(), i * 100);
7182            }
7183
7184            journal.destroy().await.unwrap();
7185        });
7186    }
7187
7188    /// Retained data blobs must be contiguous; recovery rejects a missing interior blob.
7189    #[test_traced]
7190    fn test_variable_recovery_rejects_gap_in_retained_blobs() {
7191        let executor = deterministic::Runner::default();
7192        executor.start(|context| async move {
7193            let cfg = Config {
7194                partition: "recovery-gap-in-retained-blobs".into(),
7195                items_per_section: NZU64!(10),
7196                compression: None,
7197                codec_config: (),
7198                page_cache: CacheRef::from_pooler(&context, LARGE_PAGE_SIZE, NZUsize!(10)),
7199                write_buffer: NZUsize!(1024),
7200            };
7201
7202            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
7203                .await
7204                .unwrap();
7205            for i in 0..25u64 {
7206                journal.append(&(i * 100)).await.unwrap();
7207            }
7208            journal.sync().await.unwrap();
7209            drop(journal);
7210
7211            // Remove blob 1, leaving a gap in the retained data blobs.
7212            context
7213                .remove(&cfg.data_partition(), Some(&1u64.to_be_bytes()))
7214                .await
7215                .unwrap();
7216
7217            let result = Journal::<_, u64>::init(context.child("second"), cfg.clone()).await;
7218            assert!(matches!(result, Err(Error::Corruption(_))));
7219        });
7220    }
7221}