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