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
14#[commonware_macros::stability(ALPHA)]
15use super::checkpoint::Checkpoint;
16use super::{
17    Batch, Contiguous, Decoded, Many, Mutable, blob_first_position,
18    blobs::{Blob, Blobs, Partition, Replay as BlobReplay, Writable},
19    fixed,
20    metrics::Metrics,
21    position_to_blob,
22};
23#[commonware_macros::stability(ALPHA)]
24use crate::journal::authenticated;
25use crate::{
26    Context, SyncCompletion,
27    journal::{
28        Error,
29        durability::Barrier,
30        frame::{
31            FrameInfo, Limited, decode_item, decode_length_prefix, encode_frame_into, find_frame,
32            read_frame_at,
33        },
34    },
35};
36use bytes::{Bytes, BytesMut};
37use commonware_codec::{Codec, CodecShared, Copying, varint::MAX_U32_VARINT_SIZE};
38use commonware_macros::boxed;
39use commonware_runtime::{
40    Blob as RBlob, Buf, Handle, IoBuf, ReadOptions,
41    buffer::paged::{CacheRef, Recovery as PagedRecovery, Replay},
42};
43use commonware_utils::{MAX_ALLOCATION_BYTES, futures::try_join_all};
44use futures::{FutureExt as _, Stream, future::try_join};
45#[cfg(test)]
46use std::future::pending;
47use std::{
48    collections::BTreeMap,
49    iter::once,
50    marker::PhantomData,
51    num::{NonZeroU64, NonZeroUsize},
52    ops::Range,
53    sync::Arc,
54};
55use tracing::warn;
56
57/// Suffix appended to the base partition name for the data blobs.
58const DATA_SUFFIX: &str = "_data";
59
60/// Suffix appended to the base partition name for the offsets journal.
61const OFFSETS_SUFFIX: &str = "_offsets";
62
63/// Unused capacity a compressed [PreparedAppend] may retain before compaction is considered.
64///
65/// In-place compression reserves `ZSTD_compressBound` of each encoded item for one-pass
66/// compression. A compressible record leaves much of that reservation unused, and a caller may
67/// retain the batch across unrelated work. Compaction requires unused capacity above this floor
68/// and at least three times the stored length, borrowing zstd's `ZSTD_WORKSPACETOOLARGE_FACTOR`.
69const PREPARED_SPARE_LIMIT: usize = 64 * 1024;
70
71/// Items encoded for a deferred append, created by [`Journal::prepare_append`] and consumed by
72/// [`Journal::append_prepared`].
73pub struct PreparedAppend<V> {
74    encoded: Vec<u8>,
75    item_starts: Vec<usize>,
76    compressed: bool,
77    _marker: PhantomData<V>,
78}
79
80/// Provides an owned buffer for reading and reclaims the scratch unless retained fields share it.
81fn with_bytes<T>(scratch: &mut BytesMut, f: impl FnOnce(&Bytes) -> T) -> T {
82    // Splitting preserves reusable allocation metadata across freezing and reclamation.
83    let bytes = std::mem::take(scratch).split().freeze();
84    let result = f(&bytes);
85    if let Ok(reclaimed) = bytes.try_into_mut() {
86        *scratch = reclaimed;
87    }
88    result
89}
90
91/// Size a byte buffer without exceeding Rust's allocation limit.
92fn read_buffer_len(lengths: impl IntoIterator<Item = usize>) -> Option<usize> {
93    let total = lengths
94        .into_iter()
95        .try_fold(0usize, |total, len| total.checked_add(len))?;
96    (total <= MAX_ALLOCATION_BYTES).then_some(total)
97}
98
99/// Decode an item from a `span` that holds exactly its frame, length prefix included. Returns
100/// [Error::OffsetDataMismatch] if the prefix disagrees with `span`'s length. `blob` and `offset`
101/// locate the frame in that error.
102fn decode_frame_span<V: CodecShared>(
103    mut span: Bytes,
104    blob: u64,
105    offset: u64,
106    codec_config: &V::Cfg,
107    compressed: bool,
108) -> Result<V, Error> {
109    let expected_len = span.len();
110    let (size, varint_len) = decode_length_prefix(&mut span)?;
111    let actual_len = size.checked_add(varint_len).ok_or(Error::OffsetOverflow)?;
112    if actual_len != expected_len {
113        return Err(Error::OffsetDataMismatch {
114            section: blob,
115            offset,
116            expected_len,
117            actual_len,
118        });
119    }
120    decode_item::<V>(span, codec_config, compressed)
121}
122
123/// One step of walking varint frames over a blob's bytes during recovery.
124enum Frame {
125    /// A complete, decodable item began at byte `offset`.
126    Item { offset: u64 },
127    /// No more complete frames. `valid_size` is one past the last complete frame; `torn` reports
128    /// whether undecodable trailing bytes follow it.
129    End { valid_size: u64, torn: bool },
130}
131
132/// Scans varint frames over a blob's bytes during recovery.
133struct FrameScanner<'a, B: RBlob, V: Codec> {
134    replay: Replay<B>,
135    /// Byte offset of the next frame.
136    offset: u64,
137    codec_config: &'a V::Cfg,
138    compressed: bool,
139}
140
141impl<'a, B: RBlob, V: CodecShared> FrameScanner<'a, B, V> {
142    const fn new(replay: Replay<B>, codec_config: &'a V::Cfg, compressed: bool) -> Self {
143        Self {
144            replay,
145            offset: 0,
146            codec_config,
147            compressed,
148        }
149    }
150
151    /// Advance to the next frame.
152    ///
153    /// Incomplete trailing bytes surface as [Frame::End] with `torn` set; a complete frame whose
154    /// payload fails to decode is an error.
155    async fn next(&mut self) -> Result<Frame, Error> {
156        match self.replay.ensure(MAX_U32_VARINT_SIZE).await {
157            Ok(true) => {}
158            Ok(false) if self.replay.remaining() == 0 => {
159                return Ok(Frame::End {
160                    valid_size: self.offset,
161                    torn: false,
162                });
163            }
164            // Fewer bytes than a max-size varint remain; they may still hold a whole frame.
165            Ok(false) => {}
166            Err(err) => return Err(err.into()),
167        }
168
169        let before_remaining = self.replay.remaining();
170        let (item_size, varint_len) = match decode_length_prefix(&mut self.replay) {
171            Ok(result) => result,
172            Err(err) => {
173                // An incomplete varint at the end of the blob is trailing junk; anything else
174                // is a real decode failure.
175                if self.replay.is_exhausted() || before_remaining < MAX_U32_VARINT_SIZE {
176                    return Ok(Frame::End {
177                        valid_size: self.offset,
178                        torn: true,
179                    });
180                }
181                return Err(err);
182            }
183        };
184
185        match self.replay.ensure(item_size).await {
186            Ok(true) => {}
187            Ok(false) => {
188                return Ok(Frame::End {
189                    valid_size: self.offset,
190                    torn: true,
191                });
192            }
193            Err(err) => return Err(err.into()),
194        }
195
196        let item_offset = self.offset;
197        let next_offset = item_offset
198            .checked_add(varint_len as u64)
199            .and_then(|offset| offset.checked_add(item_size as u64))
200            .ok_or(Error::OffsetOverflow)?;
201        decode_item::<V>(
202            Limited::new(&mut self.replay, item_size),
203            self.codec_config,
204            self.compressed,
205        )?;
206        self.offset = next_offset;
207        Ok(Frame::Item {
208            offset: item_offset,
209        })
210    }
211}
212
213/// Result of scanning all frames in a blob.
214#[derive(Clone, Copy)]
215struct BlobScan {
216    /// Number of complete items.
217    items: u64,
218    /// Byte offset one past the last complete item.
219    valid_size: u64,
220    /// Whether undecodable trailing bytes follow `valid_size`.
221    torn: bool,
222}
223
224/// Replay state for one data blob in a variable-size journal.
225///
226/// Unlike fixed replay, each yielded item must first decode a varint frame length. The byte
227/// `budget` caps how much frame data this state emits in one batch.
228struct ReplayState<'a, B: RBlob, V: Codec> {
229    /// Blob index, used in corruption messages.
230    blob: u64,
231    /// Sequential logical bytes for this blob.
232    replay: BlobReplay<'a, B>,
233    /// Target maximum number of encoded bytes decoded per batch.
234    budget: u64,
235    /// Next position to yield.
236    pos: u64,
237    /// Exclusive end position within this blob.
238    end_pos: u64,
239    /// Byte offset of the next frame in this blob.
240    offset: u64,
241    /// Codec configuration for decoded items.
242    codec_config: V::Cfg,
243    /// Whether frame payloads are compressed.
244    compressed: bool,
245    _marker: PhantomData<V>,
246}
247
248impl<B: RBlob, V: CodecShared> super::BatchDecoder for ReplayState<'_, B, V> {
249    type Item = V;
250
251    /// Decode the next batch of varint-framed items from this blob.
252    async fn next(mut self) -> Option<(Decoded<V>, Self)> {
253        if self.pos == self.end_pos {
254            return None;
255        }
256
257        let start = self.pos;
258        let mut items = Vec::new();
259        let start_offset = self.offset;
260        loop {
261            if self.pos == self.end_pos {
262                break;
263            }
264
265            // Awaiting a refill costs about as much as decoding an item, so refill only when the
266            // header is not already buffered. A short read before a frame header is corruption
267            // for replay, since bounds and offsets say this item exists, so EOF here means the
268            // data blob is shorter than expected.
269            let buffered = if self.replay.remaining() < MAX_U32_VARINT_SIZE {
270                self.replay.ensure(MAX_U32_VARINT_SIZE).await
271            } else {
272                Ok(true)
273            };
274            match buffered {
275                Ok(true) => {}
276                Ok(false) if self.replay.remaining() == 0 => {
277                    let error = Error::Corruption(format!(
278                        "data blob {} ended before position {}",
279                        self.blob, self.pos
280                    ));
281                    let decoded = Decoded::failed(start, items, error);
282                    return Some((decoded, self));
283                }
284                Ok(false) => {}
285                Err(err) => {
286                    let decoded = Decoded::failed(start, items, err);
287                    return Some((decoded, self));
288                }
289            }
290
291            // Keep the initial byte count for classifying a failed header read.
292            let before_remaining = self.replay.remaining();
293            let (item_size, varint_len) = match self.replay.read_length() {
294                Ok(result) => result,
295                Err(err) => {
296                    let error =
297                        if self.replay.is_exhausted() || before_remaining < MAX_U32_VARINT_SIZE {
298                            Error::Corruption(format!(
299                                "incomplete frame header in data blob {} at offset {}",
300                                self.blob, self.offset
301                            ))
302                        } else {
303                            err
304                        };
305                    let decoded = Decoded::failed(start, items, error);
306                    return Some((decoded, self));
307                }
308            };
309
310            let buffered = if self.replay.remaining() < item_size {
311                self.replay.ensure(item_size).await
312            } else {
313                Ok(true)
314            };
315            match buffered {
316                Ok(true) => {}
317                Ok(false) => {
318                    let error = Error::Corruption(format!(
319                        "incomplete frame in data blob {} at offset {}",
320                        self.blob, self.offset
321                    ));
322                    let decoded = Decoded::failed(start, items, error);
323                    return Some((decoded, self));
324                }
325                Err(err) => {
326                    let decoded = Decoded::failed(start, items, err);
327                    return Some((decoded, self));
328                }
329            }
330
331            let next_offset = self
332                .offset
333                .checked_add(varint_len as u64)
334                .and_then(|offset| offset.checked_add(item_size as u64));
335            let Some(next_offset) = next_offset else {
336                let decoded = Decoded::failed(start, items, Error::OffsetOverflow);
337                return Some((decoded, self));
338            };
339
340            // Limit reads to this frame's payload so decoding cannot consume the next frame.
341            match self
342                .replay
343                .decode::<V>(item_size, &self.codec_config, self.compressed)
344            {
345                Ok(item) => {
346                    self.pos += 1;
347                    self.offset = next_offset;
348                    items.push(item);
349                }
350                Err(err) => {
351                    let decoded = Decoded::failed(start, items, err);
352                    return Some((decoded, self));
353                }
354            }
355
356            // Yield once the replay byte budget is reached. If fewer than MAX_U32_VARINT_SIZE
357            // bytes remain, yield as well so the next poll can refill before decoding a header.
358            if self.offset - start_offset >= self.budget
359                || self.replay.remaining() < MAX_U32_VARINT_SIZE
360            {
361                break;
362            }
363        }
364
365        Some((
366            Decoded {
367                batch: Batch { start, items },
368                error: None,
369            },
370            self,
371        ))
372    }
373}
374
375/// Configuration for a [Journal].
376#[derive(Clone)]
377pub struct Config<C> {
378    /// Base partition name. Sub-partitions will be created by appending DATA_SUFFIX and OFFSETS_SUFFIX.
379    pub partition: String,
380
381    /// The number of items to store in each blob.
382    ///
383    /// Once set, this value cannot be changed across restarts.
384    /// All non-final blobs are logically full.
385    pub items_per_section: NonZeroU64,
386
387    /// Optional zstd compression level for stored items.
388    ///
389    /// Keep the choice between `None` and `Some(_)` fixed while stored items are retained.
390    /// Only the compression level may change between initializations when compression is enabled.
391    pub compression: Option<u8>,
392
393    /// [Codec] configuration for encoding/decoding items.
394    pub codec_config: C,
395
396    /// Page cache for buffering reads from the underlying storage.
397    pub page_cache: CacheRef,
398
399    /// Write buffer size for each blob.
400    pub write_buffer: NonZeroUsize,
401
402    /// Buffer size for sequential reads during recovery.
403    pub replay_buffer: NonZeroUsize,
404}
405
406impl<C> Config<C> {
407    /// Returns the partition name for the data blobs.
408    fn data_partition(&self) -> String {
409        format!("{}{}", self.partition, DATA_SUFFIX)
410    }
411
412    /// Returns the partition name for the offsets journal.
413    fn offsets_partition(&self) -> String {
414        format!("{}{}", self.partition, OFFSETS_SUFFIX)
415    }
416
417    /// Configuration of the offsets journal.
418    fn offsets_config(&self) -> fixed::Config {
419        fixed::Config {
420            partition: self.offsets_partition(),
421            items_per_blob: self.items_per_section,
422            page_cache: self.page_cache.clone(),
423            write_buffer: self.write_buffer,
424            replay_buffer: self.replay_buffer,
425        }
426    }
427}
428
429/// The journal's state, boxed so the public [Journal] handle stays pointer-sized.
430struct Inner<E: Context, V: Codec> {
431    /// The data blobs: sealed history plus the writable tail.
432    blobs: Writable<E>,
433
434    /// Index mapping positions to byte offsets within their data blob. Its checkpoint is also
435    /// this journal's durable recovery record.
436    offsets: Box<fixed::Inner<E, u64>>,
437
438    /// The readable positions; `bounds.end` is the next append position.
439    bounds: Range<u64>,
440
441    /// Test-only: park [Self::prune] after the data-blob removal, before the offsets prune,
442    /// so tests can drop the pending future at that exact point.
443    #[cfg(test)]
444    halt_before_offsets_prune: bool,
445
446    /// The number of items per blob.
447    ///
448    /// # Invariant
449    ///
450    /// This value is immutable after initialization and must remain consistent
451    /// across restarts. Changing this value will result in data loss or corruption.
452    items_per_blob: NonZeroU64,
453
454    /// Optional compression level when encoding items.
455    compression: Option<u8>,
456
457    /// Codec configuration for decoding items.
458    codec_config: V::Cfg,
459
460    /// Journal and Reader metrics.
461    metrics: Arc<Metrics<E>>,
462
463    /// The size proven durable for both the data and offsets journals. The offsets watermark
464    /// only ever takes this joint value: a one-sided size could exceed the other journal's
465    /// surviving data after a crash, which init rejects as corruption.
466    barrier: Barrier,
467}
468
469/// A reader over a variable journal.
470pub struct Reader<'a, E: Context, V: Codec> {
471    /// The journal's data blobs.
472    data: Blobs<'a, E::Blob>,
473
474    /// The readable position range `[start, end)`.
475    bounds: Range<u64>,
476
477    /// Maps positions to byte offsets within the data blobs.
478    offsets: fixed::Reader<'a, E, u64>,
479
480    /// The number of items in each blob.
481    items_per_blob: NonZeroU64,
482
483    /// [Codec] configuration for decoding items.
484    codec_config: V::Cfg,
485
486    /// Whether items are zstd-compressed.
487    compressed: bool,
488
489    /// Journal and Reader metrics.
490    metrics: Arc<Metrics<E>>,
491}
492
493impl<'a, E: Context, V: CodecShared> Reader<'a, E, V> {
494    /// Validate a position to be read: must lie within `bounds`.
495    const fn validate_readable(&self, position: u64) -> Result<(), Error> {
496        if position >= self.bounds.end {
497            return Err(Error::ItemOutOfRange(position));
498        }
499        if position < self.bounds.start {
500            return Err(Error::ItemPruned(position));
501        }
502        Ok(())
503    }
504
505    /// Read the varint-framed item at byte `offset` via `blob`.
506    async fn read_at_offset(&self, blob: &Blob<'_, E::Blob>, offset: u64) -> Result<V, Error> {
507        read_frame_at(blob, offset, &self.codec_config, self.compressed)
508            .await
509            .map(|(_, _, item)| item)
510    }
511
512    /// Read the varint-framed item for `position` at byte `offset` from cached bytes, returning
513    /// `None` on any miss.
514    fn try_read_frame_sync(&self, position: u64, offset: u64, buf: &mut BytesMut) -> Option<V> {
515        let blob = self
516            .data
517            .get(position_to_blob(position, self.items_per_blob.get()))?;
518        let remaining = blob.size().checked_sub(offset)?;
519        let header_len = usize::try_from(remaining.min(MAX_U32_VARINT_SIZE as u64)).ok()?;
520        if header_len == 0 {
521            return None;
522        }
523
524        // Read the varint header to determine item size.
525        let mut header = [0u8; MAX_U32_VARINT_SIZE];
526        if !blob.try_read_sync_into(&mut header[..header_len], offset) {
527            return None;
528        }
529        let mut cursor = Copying(&header[..header_len]);
530        let (_, item_info) = find_frame(&mut cursor, offset).ok()?;
531
532        let (varint_len, data_len) = match item_info {
533            FrameInfo::Complete {
534                varint_len,
535                data_len,
536            } => (varint_len, data_len),
537            FrameInfo::Incomplete {
538                varint_len,
539                total_len,
540                ..
541            } => (varint_len, total_len),
542        };
543        let item_len = varint_len.checked_add(data_len)?;
544        if item_len > usize::try_from(remaining).ok()? {
545            return None;
546        }
547
548        // If the full item fits in the header read, decode directly.
549        if item_len <= header_len {
550            return decode_item::<V>(
551                Copying(&header[varint_len..varint_len + data_len]),
552                &self.codec_config,
553                self.compressed,
554            )
555            .ok();
556        }
557
558        // Otherwise try reading the full item from cache.
559        buf.resize(item_len, 0);
560        if !blob.try_read_sync_into(buf, offset) {
561            return None;
562        }
563        with_bytes(buf, |bytes| {
564            decode_item::<V>(
565                bytes.slice(varint_len..),
566                &self.codec_config,
567                self.compressed,
568            )
569            .ok()
570        })
571    }
572
573    /// Build one replay state for each data blob touched by `range`.
574    async fn replay_states(
575        &self,
576        range: Range<u64>,
577        buffer: NonZeroUsize,
578        read_options: ReadOptions,
579    ) -> Result<Vec<ReplayState<'a, E::Blob, V>>, Error> {
580        let bounds = self.bounds();
581        if range.start > range.end || range.end > bounds.end {
582            return Err(Error::ItemOutOfRange(if range.start > range.end {
583                range.start
584            } else {
585                range.end
586            }));
587        }
588        if range.start < bounds.start {
589            return Err(Error::ItemPruned(range.start));
590        }
591
592        let mut states = Vec::new();
593        if range.start < range.end {
594            // The first blob may start at a nonzero data offset; subsequent blob states always
595            // start at byte offset 0.
596            let items_per_blob = self.items_per_blob.get();
597            let start_pos = range.start;
598            let start_blob = position_to_blob(start_pos, items_per_blob);
599            let end_blob = position_to_blob(range.end - 1, items_per_blob);
600            let start_offset = self.offsets.read(start_pos).await?;
601
602            for blob in start_blob..=end_blob {
603                let blob_handle = self
604                    .data
605                    .get(blob)
606                    .expect("positions in bounds map to a retained blob");
607                let offset = if blob == start_blob { start_offset } else { 0 };
608
609                let first_pos = if blob == start_blob {
610                    start_pos
611                } else {
612                    blob_first_position(blob, items_per_blob)?
613                };
614                let end_pos = super::blob_end_position(blob, items_per_blob, range.end);
615
616                // Store codec settings in the state because the stream owns states across await
617                // points and cannot borrow `self`.
618                states.push(ReplayState::<E::Blob, V> {
619                    blob,
620                    replay: blob_handle.replay_from(offset, buffer, read_options)?,
621                    budget: buffer.get() as u64,
622                    pos: first_pos,
623                    end_pos,
624                    offset,
625                    codec_config: self.codec_config.clone(),
626                    compressed: self.compressed,
627                    _marker: PhantomData,
628                });
629            }
630        }
631
632        Ok(states)
633    }
634
635    /// Validate a batched-read request: non-empty `positions` must be strictly increasing and
636    /// fall within `bounds`.
637    fn validate_read_many(&self, positions: &[u64]) -> Result<(), Error> {
638        if positions[0] < self.bounds.start {
639            return Err(Error::ItemPruned(positions[0]));
640        }
641        let last_position = *positions.last().expect("positions is not empty");
642        if last_position >= self.bounds.end {
643            return Err(Error::ItemOutOfRange(last_position));
644        }
645        assert!(
646            positions.is_sorted_by(|a, b| a < b),
647            "positions must be strictly increasing"
648        );
649        Ok(())
650    }
651
652    /// Return `position + 1` if it is below `bounds.end` and in the same data blob. Frames in a
653    /// blob are byte-adjacent, so its offset is where `position`'s frame ends.
654    fn next_in_blob(&self, position: u64) -> Option<u64> {
655        let next = position.checked_add(1)?;
656        (next < self.bounds.end && !next.is_multiple_of(self.items_per_blob.get())).then_some(next)
657    }
658
659    /// Fill `out[i]` for each readable position whose offset and frame are already in the page
660    /// cache or the in-memory tail and whose frame decodes. Returns a `Miss` for every other
661    /// position, carrying the offsets the pass found so completion can skip those lookups.
662    fn read_many_sync_pass(&self, positions: &[u64], out: &mut [Option<V>]) -> Vec<Miss> {
663        if positions.is_empty() {
664            return Vec::new();
665        }
666
667        // A frame at position p spans [off(p), off(p + 1)), or [off(p), blob size) when p is the
668        // last readable position of its blob. Positions and their same-blob successors interleave
669        // into one strictly increasing lookup list, so one batched probe of the offsets journal
670        // looks up every offset these extents need.
671        let items_per_blob = self.items_per_blob.get();
672        let mut lookups: Vec<u64> = Vec::with_capacity(positions.len() * 2);
673        for &position in positions {
674            if lookups.last() != Some(&position) {
675                lookups.push(position);
676            }
677            lookups.extend(self.next_in_blob(position));
678        }
679        let offsets = self.offsets.probe_items(&lookups);
680
681        // Resolve each queried frame's extent for the batched cache read below. Frames with a
682        // cached start but no cached successor can use their length prefix instead. If any
683        // resolved extent is empty, unsorted, overlapping, or out of bounds, serve nothing
684        // from the batch so completion can validate all requested extents together.
685        let mut extents: Vec<(usize, u64, usize)> = Vec::with_capacity(positions.len());
686        let mut singles = Vec::new();
687        let mut lookup_idx = 0;
688        let mut next_blob_start = 0;
689        let mut size = None;
690        let mut previous_end = 0;
691        for (idx, &position) in positions.iter().enumerate() {
692            while lookups[lookup_idx] != position {
693                lookup_idx += 1;
694            }
695            if self.validate_readable(position).is_err() {
696                continue;
697            }
698            let Some(offset) = offsets[lookup_idx] else {
699                continue;
700            };
701
702            // On entering a new blob, look up its size and restart the overlap check. Positions
703            // in a blob missing from the reader stay unserved.
704            if position >= next_blob_start {
705                let blob_num = position_to_blob(position, items_per_blob);
706                next_blob_start = (blob_num + 1).saturating_mul(items_per_blob);
707                size = self.data.get(blob_num).map(|blob| blob.size());
708                previous_end = 0;
709            }
710            let Some(size) = size else {
711                continue;
712            };
713
714            // A same-blob successor is adjacent in `lookups`.
715            let end = match self.next_in_blob(position) {
716                Some(_) => offsets[lookup_idx + 1],
717                None => Some(size),
718            };
719            // Checking the start needs no successor, so it covers frames served by prefix too.
720            if offset < previous_end || end.is_some_and(|end| end <= offset || end > size) {
721                extents.clear();
722                singles.clear();
723                break;
724            }
725            let Some(end) = end else {
726                singles.push((idx, offset));
727                continue;
728            };
729            let Ok(len) = usize::try_from(end - offset) else {
730                continue;
731            };
732            previous_end = end;
733            extents.push((idx, offset, len));
734        }
735
736        let mut buf = BytesMut::new();
737        let mut hits = 0u64;
738
739        // Serve the resolved extents with one batched cache read per data blob.
740        let mut group_start = 0;
741        while group_start < extents.len() {
742            let blob_num = position_to_blob(positions[extents[group_start].0], items_per_blob);
743            let mut group_end = group_start + 1;
744            while group_end < extents.len()
745                && position_to_blob(positions[extents[group_end].0], items_per_blob) == blob_num
746            {
747                group_end += 1;
748            }
749            let group = &extents[group_start..group_end];
750            group_start = group_end;
751
752            let Some(blob) = self.data.get(blob_num) else {
753                continue;
754            };
755            let ranges: Vec<(u64, usize)> = group
756                .iter()
757                .map(|&(_, offset, len)| (offset, len))
758                .collect();
759            let Some(total) = read_buffer_len(ranges.iter().map(|&(_, len)| len)) else {
760                continue;
761            };
762            buf.resize(total, 0);
763            let missed = blob.try_read_ranges_sync_into(&mut buf, &ranges);
764            with_bytes(&mut buf, |bytes| {
765                let mut missed = missed.into_iter().peekable();
766                let mut local = 0usize;
767                for (range_idx, &(idx, offset, len)) in group.iter().enumerate() {
768                    let start = local;
769                    local += len;
770                    if missed.peek() == Some(&range_idx) {
771                        missed.next();
772                        continue;
773                    }
774
775                    // A frame that fails to decode stays unserved, so an async read re-reads it
776                    // and returns the error.
777                    let slot = bytes.slice(start..local);
778                    if let Ok(item) = decode_frame_span(
779                        slot,
780                        blob_num,
781                        offset,
782                        &self.codec_config,
783                        self.compressed,
784                    ) {
785                        out[idx] = Some(item);
786                        hits += 1;
787                    }
788                }
789            });
790        }
791        for (idx, offset) in singles {
792            if let Some(item) = self.try_read_frame_sync(positions[idx], offset, &mut buf) {
793                out[idx] = Some(item);
794                hits += 1;
795            }
796        }
797        self.metrics.cache_hits.inc_by(hits);
798        self.metrics.items_read.inc_by(hits);
799
800        // Each unserved position becomes a miss carrying the offsets found above, so completion
801        // only looks up the rest.
802        let mut lookup_idx = 0;
803        let mut misses = Vec::new();
804        for (idx, &position) in positions.iter().enumerate() {
805            if out[idx].is_some() {
806                continue;
807            }
808            while lookups[lookup_idx] != position {
809                lookup_idx += 1;
810            }
811            misses.push(Miss {
812                position,
813                offset: offsets[lookup_idx],
814                end: self
815                    .next_in_blob(position)
816                    .and_then(|_| offsets[lookup_idx + 1]),
817            });
818        }
819        misses
820    }
821}
822
823/// A position the sync pass couldn't serve.
824#[derive(Clone, Copy)]
825struct Miss {
826    /// The item's position.
827    position: u64,
828    /// Where the frame starts, if the sync pass found it.
829    offset: Option<u64>,
830    /// Where the frame ends, if the sync pass found its same-blob successor's offset. A blob's last
831    /// readable frame has no such successor, and completion ends it at the blob's size.
832    end: Option<u64>,
833}
834
835/// Complete a sync pass's item slots: read `misses` (the positions of the `None` slots, in
836/// order) and fill each slot with its item.
837async fn complete<E: Context, V: CodecShared>(
838    reader: &Reader<'_, E, V>,
839    items: Vec<Option<V>>,
840    misses: Vec<Miss>,
841) -> Result<Vec<V>, Error> {
842    if misses.is_empty() {
843        return Ok(items
844            .into_iter()
845            .map(|item| item.expect("complete probe has no misses"))
846            .collect());
847    }
848
849    let fetched = reader.fetch_misses(&misses).await?;
850    let mut fetched = fetched.into_iter();
851    Ok(items
852        .into_iter()
853        .map(|item| item.unwrap_or_else(|| fetched.next().expect("one fetched item per miss")))
854        .collect())
855}
856
857impl<E: Context, V: CodecShared> Reader<'_, E, V> {
858    /// Run the sync pass over strictly increasing `positions`, returning one item slot per
859    /// position and the misses.
860    fn probe_parts(&self, positions: &[u64]) -> (Vec<Option<V>>, Vec<Miss>) {
861        let mut items: Vec<Option<V>> = (0..positions.len()).map(|_| None).collect();
862        let misses = self.read_many_sync_pass(positions, &mut items);
863        (items, misses)
864    }
865
866    /// Read the items for `misses` (strictly increasing by position) and return them in order.
867    /// Offsets the sync pass didn't find come from one batched read of the offsets journal. Each
868    /// data blob's frames then come from one batched read, and the blobs are read concurrently.
869    async fn fetch_misses(&self, misses: &[Miss]) -> Result<Vec<V>, Error> {
870        if misses.is_empty() {
871            return Ok(Vec::new());
872        }
873
874        // Validate before consulting the offsets journal so a probe-declined out-of-bounds
875        // position surfaces as a range error rather than corruption.
876        for miss in misses {
877            self.validate_readable(miss.position)?;
878        }
879
880        // List the positions whose offsets the sync pass didn't find: the miss itself for where
881        // its frame starts, and its same-blob successor for where it ends. Misses are in
882        // increasing order, so the list is sorted, and `dedup` makes it strictly increasing by
883        // dropping a successor that is also the next miss.
884        let mut lookups = Vec::new();
885        for miss in misses {
886            if miss.offset.is_none() {
887                lookups.push(miss.position);
888            }
889            if miss.end.is_none() {
890                lookups.extend(self.next_in_blob(miss.position));
891            }
892        }
893        lookups.dedup();
894
895        // Range errors from the offsets journal are corruption. The misses were validated above
896        // and every successor is below `bounds.end`, so the offsets journal must have them all.
897        let fetched = self
898            .offsets
899            .read_many_inner(&lookups)
900            .await
901            .map_err(|e| match e {
902                Error::ItemOutOfRange(e) | Error::ItemPruned(e) => {
903                    Error::Corruption(format!("blob/item should be found, but got: {e}"))
904                }
905                other => other,
906            })?;
907
908        // The grouping below requests offsets in the order `lookups` was built, so a cursor that
909        // only moves forward finds each one.
910        let mut cursor = 0;
911        let mut lookup = |position: u64| {
912            while lookups[cursor] < position {
913                cursor += 1;
914            }
915            assert_eq!(
916                lookups[cursor], position,
917                "every requested position must be in `lookups`"
918            );
919            fetched[cursor]
920        };
921
922        // Group misses by data blob. A frame ends at its same-blob successor's offset, or at the
923        // blob's size when it is the blob's last readable frame, so every frame joins the blob's
924        // batched read.
925        let items_per_blob = self.items_per_blob.get();
926        let mut groups = Vec::new();
927        for misses in misses.chunk_by(|a, b| {
928            position_to_blob(a.position, items_per_blob)
929                == position_to_blob(b.position, items_per_blob)
930        }) {
931            let blob_num = position_to_blob(misses[0].position, items_per_blob);
932            let blob = self
933                .data
934                .get(blob_num)
935                .expect("positions in bounds map to a retained blob");
936            let size = blob.size();
937            let mut ranges = Vec::with_capacity(misses.len());
938            for miss in misses {
939                let offset = miss.offset.unwrap_or_else(|| lookup(miss.position));
940                let end = miss
941                    .end
942                    .or_else(|| self.next_in_blob(miss.position).map(&mut lookup))
943                    .unwrap_or(size);
944
945                // Within a blob, a consistent journal's offsets strictly increase with position
946                // and stay inside the blob, so reject any other offsets as corruption before they
947                // reach the batched read. That read panics on unsorted or overlapping ranges and
948                // sizes its buffer from them. An empty extent holds no frame to decode, and a
949                // reversed one would underflow its length.
950                let previous_end = ranges
951                    .last()
952                    .map_or(0, |&(offset, len)| offset + len as u64);
953                if end <= offset || offset < previous_end || end > size {
954                    return Err(Error::Corruption(format!(
955                        "inconsistent offsets in blob {blob_num} at position {}",
956                        miss.position
957                    )));
958                }
959                let len = usize::try_from(end - offset).map_err(|_| Error::OffsetOverflow)?;
960                ranges.push((offset, len));
961            }
962            groups.push((blob_num, blob, ranges));
963        }
964        let groups = try_join_all(
965            groups
966                .into_iter()
967                .map(|(blob_num, blob, ranges)| self.read_frames(blob_num, blob, ranges)),
968        )
969        .await?;
970
971        self.metrics.cache_misses.inc_by(misses.len() as u64);
972        self.metrics.items_read.inc_by(misses.len() as u64);
973        Ok(groups.into_iter().flatten().collect())
974    }
975
976    /// Read the frames at `ranges` (sorted, non-overlapping, and inside `blob`) with one batched
977    /// read and return their decoded items in `ranges` order. `blob_num` only names the blob in
978    /// errors.
979    async fn read_frames(
980        &self,
981        blob_num: u64,
982        blob: Blob<'_, E::Blob>,
983        ranges: Vec<(u64, usize)>,
984    ) -> Result<Vec<V>, Error> {
985        // The checked total bounds both the read buffer and every merged span.
986        let total =
987            read_buffer_len(ranges.iter().map(|&(_, len)| len)).ok_or(Error::OffsetOverflow)?;
988        let mut spans: Vec<(u64, usize)> = Vec::with_capacity(ranges.len());
989        for &(offset, len) in &ranges {
990            match spans.last_mut() {
991                Some((start, span)) if *start + *span as u64 == offset => *span += len,
992                _ => spans.push((offset, len)),
993            }
994        }
995        let mut buf = vec![0u8; total];
996        blob.read_ranges_into(&mut buf, &spans).await?;
997        let mut bytes = Bytes::from(buf);
998        let mut items = Vec::with_capacity(ranges.len());
999        for &(offset, len) in &ranges {
1000            let span = bytes.split_to(len);
1001            items.push(decode_frame_span(
1002                span,
1003                blob_num,
1004                offset,
1005                &self.codec_config,
1006                self.compressed,
1007            )?);
1008        }
1009        Ok(items)
1010    }
1011}
1012
1013impl<E: Context, V: CodecShared> super::Contiguous for Reader<'_, E, V> {
1014    type Item = V;
1015
1016    fn bounds(&self) -> Range<u64> {
1017        self.bounds.clone()
1018    }
1019
1020    async fn read(&self, position: u64) -> Result<V, Error> {
1021        self.metrics.read_calls.inc();
1022        self.validate_readable(position)?;
1023
1024        // Probe the offsets journal once, serving from the page cache synchronously when
1025        // possible. On a data-frame miss the resolved offset is reused by the async path so the
1026        // offsets journal is not consulted twice.
1027        let cached_offset = self.offsets.try_read_sync(position);
1028        if let Some(offset) = cached_offset
1029            && let Some(item) = self.try_read_frame_sync(position, offset, &mut BytesMut::new())
1030        {
1031            self.metrics.cache_hits.inc();
1032            self.metrics.items_read.inc();
1033            return Ok(item);
1034        }
1035
1036        let _timer = self.metrics.read_timer();
1037        let offset = match cached_offset {
1038            Some(offset) => offset,
1039            None => self.offsets.read_miss(position).await?,
1040        };
1041        let blob = self
1042            .data
1043            .get(position_to_blob(position, self.items_per_blob.get()))
1044            .expect("position in bounds maps to a retained blob");
1045        self.metrics.cache_misses.inc();
1046        let item = self.read_at_offset(&blob, offset).await?;
1047        self.metrics.items_read.inc();
1048        Ok(item)
1049    }
1050
1051    async fn read_many(&self, positions: &[u64]) -> Result<Vec<V>, Error> {
1052        if positions.is_empty() {
1053            return Ok(Vec::new());
1054        }
1055        let _timer = self.metrics.read_many_timer();
1056        self.metrics.read_many_calls.inc();
1057        self.validate_read_many(positions)?;
1058        let (items, misses) = self.probe_parts(positions);
1059        complete(self, items, misses).await
1060    }
1061
1062    fn try_read_sync(&self, position: u64) -> Option<V> {
1063        self.validate_readable(position).ok()?;
1064        let offset = self.offsets.try_read_sync(position)?;
1065        let item = self.try_read_frame_sync(position, offset, &mut BytesMut::new())?;
1066        self.metrics.cache_hits.inc();
1067        self.metrics.items_read.inc();
1068        Some(item)
1069    }
1070
1071    fn try_read_many_sync(&self, positions: &[u64]) -> Vec<Option<V>> {
1072        assert!(
1073            positions.is_sorted_by(|a, b| a < b),
1074            "positions must be strictly increasing"
1075        );
1076        let mut items: Vec<Option<V>> = (0..positions.len()).map(|_| None).collect();
1077        self.read_many_sync_pass(positions, &mut items);
1078        items
1079    }
1080
1081    async fn replay(
1082        &self,
1083        range: Range<u64>,
1084        buffer: NonZeroUsize,
1085        read_options: ReadOptions,
1086    ) -> Result<impl Stream<Item = Result<Batch<V>, Error>> + Send, Error> {
1087        let states = self.replay_states(range, buffer, read_options).await?;
1088
1089        Ok(super::replay_stream_from_states(states))
1090    }
1091}
1092
1093/// Variable data and derived offsets owned exclusively by initialization.
1094pub struct Recovery<E: Context, V: CodecShared> {
1095    /// Runtime context for partition resets and the published journal's metrics.
1096    context: E,
1097
1098    /// Frame encoding, blob layout, and I/O settings used during recovery and live access.
1099    cfg: Config<V::Cfg>,
1100
1101    /// Data-blob partition held under recovery ownership until publication.
1102    partition: Partition<E>,
1103
1104    /// Opened data blobs keyed by physical index, including suffix candidates awaiting repair.
1105    pending: BTreeMap<u64, PagedRecovery<E::Blob>>,
1106
1107    /// Data blob indices excluded by the cap. When no lower blob was opened, their oldest index
1108    /// still constrains the retained start; publication removes them.
1109    discarded: Vec<u64>,
1110
1111    /// Validated frame counts and byte ends keyed by blob index, reused during publication.
1112    recovered_scans: BTreeMap<u64, BlobScan>,
1113
1114    /// Inspection rebuilds retained entries at or above the watermark from validated frames.
1115    /// Together with acknowledged entries, these cover every position in `bounds`.
1116    offsets: Box<fixed::Recovery<E, u64>>,
1117
1118    /// Selected logical item range; its end is exclusive.
1119    bounds: Range<u64>,
1120
1121    /// Whether recovery was opened with an explicit cap; bounded inspection rejects retained
1122    /// offsets when no physical data blob exists.
1123    bounded: bool,
1124
1125    /// When set, `publish` parks right after deleting discarded data blobs so tests can crash
1126    /// at that exact point.
1127    #[cfg(test)]
1128    halt_after_data_removal: bool,
1129}
1130
1131impl<E: Context, V: CodecShared> Recovery<E, V> {
1132    /// Inspect retained frames before choosing a prefix to publish.
1133    #[boxed]
1134    async fn open(context: E, cfg: Config<V::Cfg>, max_size: Option<u64>) -> Result<Self, Error> {
1135        let items_per_blob = cfg.items_per_section.get();
1136        let data_partition = cfg.data_partition();
1137        let data_context = context.child("data");
1138
1139        // If a prior `init_at_size`/`clear_to_size` crashed mid-reset, the offsets journal
1140        // carries a staged clear. `init_cleared` discards the data partition before finishing
1141        // that reset so stale data is never replayed past the reset size.
1142        let offsets = fixed::Recovery::<E, u64>::init_cleared(
1143            context.child("offsets"),
1144            cfg.offsets_config(),
1145            max_size,
1146            || Partition::<E>::remove_all(&data_context, &data_partition),
1147        )
1148        .await?;
1149
1150        let partition = Partition::new(
1151            data_context,
1152            data_partition,
1153            cfg.page_cache.clone(),
1154            cfg.write_buffer,
1155        );
1156        let (mut pending, discarded) = match max_size {
1157            Some(size) => partition.open_bounded(size, items_per_blob).await?,
1158            None => (partition.open_all().await?, Vec::new()),
1159        };
1160
1161        // Acknowledged floor: every position below it was covered by a completed data fsync
1162        // (the pruning boundary covers a watermark gone stale after a prune).
1163        let floor = offsets.recovery_watermark().max(offsets.pruning_boundary());
1164        let floor_blob = position_to_blob(floor, items_per_blob);
1165
1166        // Check the two newest blobs for interior holes. Only they can hold non-durable data
1167        // (each rollover fsyncs the just-sealed blob and awaits the previous rollover's fsync),
1168        // and a crash during an in-flight fsync can lose an interior page while later pages
1169        // survive. `PagedRecovery::open` sizes a blob by its last valid page, so it cannot see
1170        // such a hole. A hole is truncated immediately, whatever the opening cap. The offsets
1171        // watermark advances only after the covering data fsync, so every acknowledged frame
1172        // lies below the hole, and reads before publication go through the page cache, which
1173        // refuses a short interior page.
1174        let suspects: Vec<u64> = pending.keys().rev().take(2).copied().collect();
1175        for blob in suspects {
1176            // Completed barriers cover blobs before the floor. Frame inspection and terminal
1177            // validation retain their separate checks of the data extent.
1178            if blob < floor_blob {
1179                continue;
1180            }
1181
1182            // The floor's blob is scanned from the front: offset rebuild replays it from its
1183            // start regardless, and a torn acknowledged page is clearer as corruption here.
1184            let writer = pending.get(&blob).expect("suspect blob is present");
1185            let valid = writer
1186                .recoverable_prefix_len(0, cfg.replay_buffer, ReadOptions::default())
1187                .await?;
1188            let size = writer.size();
1189            if valid == size {
1190                continue;
1191            }
1192
1193            // The floor's blob must retain its acknowledged prefix: a cut at or below the last
1194            // acknowledged frame's start lost acknowledged data (a cut inside that frame is
1195            // truncated, then rejected by replay in `align`). A floor at the blob boundary or
1196            // below the offsets pruning boundary acknowledges nothing here.
1197            if blob == floor_blob
1198                && floor > blob_first_position(blob, items_per_blob)?
1199                && floor > offsets.pruning_boundary()
1200                && valid <= offsets.item(floor - 1).await?
1201            {
1202                return Err(Error::Corruption(format!(
1203                    "blob {blob} no longer backs acknowledged items: well-formed prefix {valid} \
1204                     of size {size}"
1205                )));
1206            }
1207            warn!(blob, valid, size, "truncating to last well-formed page");
1208            let writer = pending.remove(&blob).expect("suspect blob is present");
1209            pending.insert(blob, writer.truncate(valid).await?);
1210        }
1211
1212        Self {
1213            context,
1214            cfg,
1215            partition,
1216            pending,
1217            discarded,
1218            recovered_scans: BTreeMap::new(),
1219            offsets: Box::new(offsets),
1220            bounds: 0..0,
1221            bounded: max_size.is_some(),
1222            #[cfg(test)]
1223            halt_after_data_removal: false,
1224        }
1225        .inspect(max_size.unwrap_or(u64::MAX))
1226        .await
1227    }
1228
1229    /// Positions stored items may occupy, from the offsets checkpoint and blob names without
1230    /// opening data or offsets blobs.
1231    ///
1232    /// When a data blob exists, the start matches the one inspection derives from the offsets
1233    /// boundary and the oldest data blob, and the end is one past the newest data blob's last
1234    /// possible position, so inspection may recover less. Otherwise the range is empty at the
1235    /// offsets start. A staged clear yields an empty range at its target.
1236    ///
1237    /// Returns [Error::Corruption] when the offsets watermark acknowledges items above the
1238    /// retained start but no data blob exists.
1239    #[commonware_macros::stability(ALPHA)]
1240    pub(crate) async fn span(context: E, cfg: &Config<V::Cfg>) -> Result<Range<u64>, Error> {
1241        let per_blob = cfg.items_per_section.get();
1242        let offsets_context = context.child("offsets");
1243        let offsets_cfg = cfg.offsets_config();
1244        let checkpoint =
1245            Checkpoint::open(offsets_context.child("meta"), &offsets_cfg.partition).await?;
1246        if let Some(target) = checkpoint.clear_target() {
1247            return Ok(target..target);
1248        }
1249        let offsets =
1250            fixed::Recovery::<E, u64>::span(&offsets_context, &offsets_cfg, &checkpoint).await?;
1251        let names = Partition::<E>::scan_names(&context, &cfg.data_partition()).await?;
1252        let data = Partition::<E>::indices(names)?;
1253        let (Some(&oldest), Some(&newest)) = (data.first(), data.last()) else {
1254            if checkpoint
1255                .watermark()
1256                .is_some_and(|watermark| watermark > offsets.start)
1257            {
1258                return Err(Error::Corruption(
1259                    "retained offsets have no data blobs".into(),
1260                ));
1261            }
1262            return Ok(offsets.start..offsets.start);
1263        };
1264        let start = offsets.start.max(blob_first_position(oldest, per_blob)?);
1265        let end = blob_first_position(newest, per_blob)?.saturating_add(per_blob);
1266        Ok(start..end)
1267    }
1268
1269    /// Reopen the journal without an initialization bound.
1270    ///
1271    /// A bounded open removes no data blob and defers logical data truncation to publication,
1272    /// except when it completes a staged clear. Before then it trims only bytes beyond the
1273    /// page-validated prefix of a data or offsets blob, which covers every acknowledged item
1274    /// under the interrupted-write model, and derived offsets that a reopen rebuilds from the
1275    /// data, so the reopen recovers every retained item.
1276    #[commonware_macros::stability(ALPHA)]
1277    pub(crate) async fn unbounded(self) -> Result<Self, Error> {
1278        let Self {
1279            context,
1280            cfg,
1281            partition,
1282            pending,
1283            offsets,
1284            ..
1285        } = self;
1286
1287        // Reopening a blob fails while a handle from this view is alive, and the reopen derives
1288        // this view's children again. Release every handle and partition first.
1289        drop(pending);
1290        drop(offsets);
1291        drop(partition);
1292        Self::open(context, cfg, None).await
1293    }
1294
1295    /// An empty journal at `size` over `offsets`, which a completed reset left empty at `size`.
1296    ///
1297    /// The caller removes the data partition under the staged offsets reset, before it
1298    /// completes, so stale data never outlives the reset.
1299    #[commonware_macros::stability(ALPHA)]
1300    fn cleared(
1301        context: E,
1302        cfg: Config<V::Cfg>,
1303        offsets: fixed::Recovery<E, u64>,
1304        size: u64,
1305    ) -> Self {
1306        let partition = Partition::new(
1307            context.child("data"),
1308            cfg.data_partition(),
1309            cfg.page_cache.clone(),
1310            cfg.write_buffer,
1311        );
1312        Self {
1313            context,
1314            cfg,
1315            partition,
1316            pending: BTreeMap::new(),
1317            discarded: Vec::new(),
1318            recovered_scans: BTreeMap::new(),
1319            offsets: Box::new(offsets),
1320            bounds: size..size,
1321            bounded: false,
1322            #[cfg(test)]
1323            halt_after_data_removal: false,
1324        }
1325    }
1326
1327    /// Scan only the recovery suffix, stopping before decoding discarded frames.
1328    async fn inspect(mut self, ceiling: u64) -> Result<Self, Error> {
1329        // Reconcile the logical retained start from offsets metadata and data blob names before
1330        // decoding any frame.
1331        let per_blob = self.cfg.items_per_section.get();
1332        let offsets_start = self.offsets.pruning_boundary();
1333        let oldest = self
1334            .pending
1335            .keys()
1336            .next()
1337            .copied()
1338            .or_else(|| self.discarded.first().copied());
1339        let Some(oldest) = oldest else {
1340            let end = self.offsets.size();
1341            if self.bounded && end > offsets_start {
1342                return Err(Error::Corruption(
1343                    "retained offsets have no data blobs".into(),
1344                ));
1345            }
1346            self.bounds = end..end;
1347            return Ok(self);
1348        };
1349        let data_start = blob_first_position(oldest, per_blob)?;
1350        let start = offsets_start.max(data_start);
1351        if ceiling < start {
1352            return Err(Error::ItemPruned(ceiling));
1353        }
1354        if self.offsets.size() < data_start {
1355            return Err(Error::Corruption(
1356                "offsets end precedes retained data".into(),
1357            ));
1358        }
1359        if position_to_blob(offsets_start, per_blob) > oldest {
1360            return Err(Error::Corruption(
1361                "offsets start precedes no retained data blob".into(),
1362            ));
1363        }
1364        if ceiling == start {
1365            self.bounds = start..start;
1366            return Ok(self);
1367        }
1368        let anchor = self.offsets.recovery_watermark().max(start);
1369        let mut blob = position_to_blob(anchor, per_blob);
1370
1371        // Bounds must not acknowledge missing blobs below the replay anchor.
1372        let mut expected = position_to_blob(start, per_blob);
1373        for (&retained, _) in self.pending.range(expected..blob) {
1374            if retained != expected {
1375                return Err(Error::Corruption(format!(
1376                    "missing acknowledged data blob {expected}"
1377                )));
1378            }
1379            expected += 1;
1380        }
1381        if expected != blob
1382            || (anchor > blob_first_position(blob, per_blob)? && !self.pending.contains_key(&blob))
1383        {
1384            return Err(Error::Corruption(format!(
1385                "missing acknowledged data blob {expected}"
1386            )));
1387        }
1388
1389        // Data owns the derived suffix. Rebuild it without advancing the watermark so an
1390        // interrupted inspection can replay the same source frames on its next attempt. An
1391        // anchor at the ceiling appends nothing, so publication alone trims the offsets. The
1392        // offsets open clamps the watermark to the ceiling, so the anchor never exceeds it.
1393        let offsets_end = self.offsets.size();
1394        if anchor < ceiling {
1395            self.offsets = self.offsets.truncate(anchor).await?;
1396        }
1397        let mut end = anchor;
1398        while let Some(writer) = self.pending.remove(&blob) {
1399            // Scan one capacity-bounded blob from its retained start and rebuild every derived
1400            // offset at or above the recovery anchor.
1401            let first = blob_first_position(blob, per_blob)?.max(start);
1402            let limit = super::blob_end_position(blob, per_blob, ceiling);
1403            let physical_size = writer.size();
1404            let (writer, replay) = writer
1405                .replay(self.cfg.replay_buffer, ReadOptions::default())
1406                .await?;
1407            self.pending.insert(blob, writer);
1408            let mut scanner = FrameScanner::<E::Blob, V>::new(
1409                replay,
1410                &self.cfg.codec_config,
1411                self.cfg.compression.is_some(),
1412            );
1413            let mut pos = first;
1414            while pos < limit {
1415                match scanner.next().await? {
1416                    Frame::Item { offset } => {
1417                        if pos >= anchor {
1418                            self.offsets = self.offsets.append(&offset).await?;
1419                        }
1420                        pos = pos.checked_add(1).ok_or(Error::OffsetOverflow)?;
1421                    }
1422                    Frame::End { .. } => break,
1423                }
1424            }
1425            if pos < anchor {
1426                let message = if self
1427                    .pending
1428                    .keys()
1429                    .next_back()
1430                    .is_some_and(|&last| last > blob)
1431                {
1432                    format!("data blobs shorter than offsets recovery watermark {anchor}")
1433                } else {
1434                    format!(
1435                        "offsets recovery watermark {anchor} exceeds retained data end {pos} (offsets bounds {}..{})",
1436                        self.offsets.pruning_boundary(),
1437                        offsets_end
1438                    )
1439                };
1440                return Err(Error::Corruption(message));
1441            }
1442            end = pos;
1443
1444            // A complete frame beyond a full blob is corruption. A short blob or the requested
1445            // ceiling ends the selected contiguous prefix.
1446            if pos == limit && pos != ceiling && matches!(scanner.next().await?, Frame::Item { .. })
1447            {
1448                return Err(Error::Corruption(format!(
1449                    "blob {blob} exceeds its item capacity"
1450                )));
1451            }
1452            self.recovered_scans.insert(
1453                blob,
1454                BlobScan {
1455                    items: pos - first,
1456                    valid_size: scanner.offset,
1457                    torn: scanner.offset < physical_size,
1458                },
1459            );
1460            if pos < limit || pos == ceiling {
1461                break;
1462            }
1463            blob = blob.checked_add(1).ok_or(Error::OffsetOverflow)?;
1464        }
1465        self.bounds = start..end;
1466        Ok(self)
1467    }
1468
1469    /// Resolve a terminal byte offset without depending on a discarded offset entry.
1470    async fn terminal_offset(&self, size: u64) -> Result<u64, Error> {
1471        let per_blob = self.cfg.items_per_section.get();
1472        let blob = position_to_blob(size, per_blob);
1473        let first = blob_first_position(blob, per_blob)?.max(self.bounds.start);
1474        if size == first {
1475            return Ok(0);
1476        }
1477        if size == self.bounds.end
1478            && let Some(scan) = self.recovered_scans.get(&blob)
1479        {
1480            return Ok(scan.valid_size);
1481        }
1482        self.offsets.item(size).await
1483    }
1484
1485    /// Repair both partitions and publish a live handle once the selected prefix is durable.
1486    async fn publish(mut self, max_size: u64) -> Result<Inner<E, V>, Error> {
1487        let size = max_size.min(self.bounds.end);
1488        if size < self.bounds.start {
1489            return Err(Error::ItemPruned(size));
1490        }
1491        let per_blob = self.cfg.items_per_section.get();
1492
1493        // Offsets authorize releasing data. Select their prefix and lower the watermark before
1494        // discarding any data that inspection made available to the caller.
1495        let terminal = self.terminal_offset(size).await?;
1496        self.offsets = self.offsets.truncate(size).await?;
1497
1498        // Establish the retained start in the offsets before deleting discarded data. When no
1499        // data blob survives, the offsets are the only durable witness of the floor, and a retry
1500        // would otherwise find retained offsets with no data blobs.
1501        (self.offsets, _) = self.offsets.prune(self.bounds.start).await?;
1502
1503        // Data outside the selected offset prefix must be removed, including partial tails
1504        // and whole blobs excluded by inspection.
1505        let tail = position_to_blob(size, per_blob);
1506
1507        // Blobs below the tail keep their scanned prefix, and the tail keeps the selected
1508        // terminal offset.
1509        let retained = || {
1510            self.recovered_scans
1511                .range(..tail)
1512                .map(|(&blob, scan)| (blob, scan.valid_size))
1513                .chain(once((tail, terminal)))
1514        };
1515        let repair_data = !self.discarded.is_empty()
1516            || self
1517                .pending
1518                .keys()
1519                .next_back()
1520                .is_some_and(|&blob| blob > tail)
1521            || retained().any(|(blob, bytes)| {
1522                self.pending
1523                    .get(&blob)
1524                    .is_some_and(|writer| bytes < writer.size())
1525            });
1526        if repair_data {
1527            // Delete excluded blobs newest-first so every crash leaves a contiguous prefix.
1528            for blob in self.discarded.into_iter().rev() {
1529                self.partition.remove(blob).await?;
1530            }
1531            Inner::<E, V>::remove_blobs_after(&self.partition, &mut self.pending, tail).await?;
1532            #[cfg(test)]
1533            if self.halt_after_data_removal {
1534                pending::<()>().await;
1535            }
1536
1537            // Shorten each retained blob to its prefix, oldest first.
1538            for (blob, bytes) in retained() {
1539                if self
1540                    .pending
1541                    .get(&blob)
1542                    .is_some_and(|writer| bytes < writer.size())
1543                {
1544                    let writer = self.pending.remove(&blob).expect("blob is present");
1545                    self.pending.insert(blob, writer.truncate(bytes).await?);
1546                }
1547            }
1548        }
1549
1550        // Retain the selected scan results so alignment does not decode these frames again.
1551        self.recovered_scans.retain(|&blob, _| blob <= tail);
1552        for scan in self.recovered_scans.values_mut() {
1553            scan.torn = false;
1554        }
1555        if self.pending.contains_key(&tail) {
1556            let first = blob_first_position(tail, per_blob)?.max(self.bounds.start);
1557            self.recovered_scans.insert(
1558                tail,
1559                BlobScan {
1560                    items: size - first,
1561                    valid_size: terminal,
1562                    torn: false,
1563                },
1564            );
1565        }
1566
1567        // Align offsets with the selected data prefix before sealing its blobs for live access.
1568        let (offsets, bounds) = Inner::<E, V>::align(
1569            &self.partition,
1570            &mut self.pending,
1571            self.offsets,
1572            per_blob,
1573            self.cfg.replay_buffer,
1574            &self.cfg.codec_config,
1575            self.cfg.compression.is_some(),
1576            &self.recovered_scans,
1577        )
1578        .await?;
1579        if bounds.end > size {
1580            return Err(Error::Corruption(
1581                "recovery exceeded its selected end".into(),
1582            ));
1583        }
1584        let tail = position_to_blob(bounds.end, per_blob);
1585        let blobs = Writable::recover(self.partition, self.pending, tail).await?;
1586        let barrier = Barrier::new(offsets.recovery_watermark());
1587
1588        // Publish the offsets watermark only after the data it authorizes is durable.
1589        let offsets = Box::new(offsets.publish(bounds.end).await?);
1590        let metrics = Metrics::new(self.context);
1591        metrics.update(bounds.end, bounds.start, per_blob);
1592        Ok(Inner {
1593            blobs,
1594            offsets,
1595            bounds,
1596            #[cfg(test)]
1597            halt_before_offsets_prune: false,
1598            items_per_blob: self.cfg.items_per_section,
1599            compression: self.cfg.compression,
1600            codec_config: self.cfg.codec_config,
1601            metrics: Arc::new(metrics),
1602            barrier,
1603        })
1604    }
1605}
1606
1607impl<E: Context, V: CodecShared> Inner<E, V> {
1608    /// See [Journal::init].
1609    #[boxed]
1610    pub(crate) async fn init(context: E, cfg: Config<V::Cfg>) -> Result<Self, Error> {
1611        Recovery::<E, V>::open(context, cfg, None)
1612            .await?
1613            .publish(u64::MAX)
1614            .await
1615    }
1616
1617    /// See [Journal::init_at_size].
1618    #[commonware_macros::stability(ALPHA)]
1619    pub(crate) async fn init_at_size(
1620        context: E,
1621        cfg: Config<V::Cfg>,
1622        size: u64,
1623    ) -> Result<Self, Error> {
1624        let offsets_partition = cfg.offsets_partition();
1625        let offsets_context = context.child("offsets");
1626
1627        // Fail before writing intent if the offsets blob partitions are already inconsistent.
1628        Partition::select(&offsets_context, &offsets_partition).await?;
1629
1630        // A journal sized at `u64::MAX` can never accept an append (the successor size
1631        // overflows), so reject it before opening the checkpoint.
1632        if size == u64::MAX {
1633            return Err(Error::SizeOverflow);
1634        }
1635        let checkpoint =
1636            Checkpoint::open(offsets_context.child("meta"), &offsets_partition).await?;
1637
1638        // Remove the data partition under the staged offsets reset, before it completes. A crash
1639        // after staging leaves an intent that the next open completes.
1640        let data_partition = cfg.data_partition();
1641        let offsets = fixed::Recovery::<E, u64>::open_cleared(
1642            offsets_context,
1643            cfg.offsets_config(),
1644            checkpoint,
1645            size,
1646            || Partition::<E>::remove_all(&context, &data_partition),
1647        )
1648        .await?;
1649        Recovery::<E, V>::cleared(context, cfg, offsets, size)
1650            .publish(u64::MAX)
1651            .await
1652    }
1653
1654    /// See [Journal::append].
1655    pub(crate) async fn append(self: Box<Self>, item: &V) -> Result<(Box<Self>, u64), Error> {
1656        let _timer = self.metrics.append_timer();
1657        self.metrics.append_calls.inc();
1658        self.append_many_inner(Many::Flat(std::slice::from_ref(item)))
1659            .await
1660    }
1661
1662    /// See [Journal::append_many].
1663    pub(crate) async fn append_many(
1664        self: Box<Self>,
1665        items: Many<'_, V>,
1666    ) -> Result<(Box<Self>, u64), Error> {
1667        let _timer = self.metrics.append_many_timer();
1668        self.metrics.append_many_calls.inc();
1669        self.append_many_inner(items).await
1670    }
1671
1672    async fn append_many_inner(
1673        self: Box<Self>,
1674        items: Many<'_, V>,
1675    ) -> Result<(Box<Self>, u64), Error> {
1676        let prepared = self.prepare_append::<false>(items)?;
1677        self.write_encoded(prepared).await
1678    }
1679
1680    /// Encode a batch, optionally compacting unused capacity for a deferred append according to
1681    /// [PREPARED_SPARE_LIMIT].
1682    pub(crate) fn prepare_append<const COMPACT: bool>(
1683        &self,
1684        items: Many<'_, V>,
1685    ) -> Result<PreparedAppend<V>, Error> {
1686        let mut encoded = Vec::new();
1687        let mut item_starts = Vec::with_capacity(items.len());
1688        let mut encode = |item: &V| {
1689            item_starts.push(encoded.len());
1690            encode_frame_into(self.compression, item, &mut encoded)
1691        };
1692        match items {
1693            Many::Flat(items) => {
1694                for item in items {
1695                    encode(item)?;
1696                }
1697            }
1698            Many::Nested(nested_items) => {
1699                for items in nested_items {
1700                    for item in *items {
1701                        encode(item)?;
1702                    }
1703                }
1704            }
1705        }
1706
1707        // Deferred batches (COMPACT) may be held across unrelated work, so shrink a buffer
1708        // that compression left mostly unused (see PREPARED_SPARE_LIMIT). Do it here, while
1709        // the buffer is still a local. Doing it on the returned PreparedAppend in
1710        // Journal::prepare_append adds a struct copy to every call, including uncompressed ones.
1711        if COMPACT && self.compression.is_some() {
1712            let (len, capacity) = (encoded.len(), encoded.capacity());
1713            if capacity - len > PREPARED_SPARE_LIMIT && len <= capacity / 4 {
1714                encoded.shrink_to_fit();
1715            }
1716        }
1717
1718        Ok(PreparedAppend {
1719            encoded,
1720            item_starts,
1721            compressed: self.compression.is_some(),
1722            _marker: PhantomData,
1723        })
1724    }
1725
1726    /// See [Journal::append_prepared].
1727    pub(crate) async fn append_prepared(
1728        self: Box<Self>,
1729        prepared: PreparedAppend<V>,
1730    ) -> Result<(Box<Self>, u64), Error> {
1731        let _timer = self.metrics.append_prepared_timer();
1732        self.metrics.append_prepared_calls.inc();
1733        self.write_encoded(prepared).await
1734    }
1735
1736    // Write pre-encoded items; shared by all append paths. Records no call metrics.
1737    async fn write_encoded(
1738        mut self: Box<Self>,
1739        prepared: PreparedAppend<V>,
1740    ) -> Result<(Box<Self>, u64), Error> {
1741        let PreparedAppend {
1742            encoded,
1743            item_starts,
1744            compressed,
1745            ..
1746        } = prepared;
1747        let items_count = item_starts.len();
1748        if items_count == 0 {
1749            return Err(Error::EmptyAppend);
1750        }
1751        if compressed != self.compression.is_some() {
1752            return Err(Error::InvalidConfiguration(
1753                "prepared append compression setting does not match journal".into(),
1754            ));
1755        }
1756        let encoded = IoBuf::from(encoded);
1757
1758        // Reject the append before writing anything (to either the data blobs or offsets
1759        // journal) if it would push the size past `u64::MAX`.
1760        self.bounds
1761            .end
1762            .checked_add(items_count as u64)
1763            .ok_or(Error::SizeOverflow)?;
1764
1765        // Split the batch at blob boundaries so data and its offsets rotate together.
1766        let items_per_blob = self.items_per_blob.get();
1767        let mut written = 0;
1768        while written < items_count {
1769            let batch_count = super::batch_count_to_blob_boundary(
1770                self.bounds.end,
1771                items_count - written,
1772                items_per_blob,
1773            );
1774            let batch_start = item_starts[written];
1775            let batch_end = item_starts
1776                .get(written + batch_count)
1777                .copied()
1778                .unwrap_or(encoded.len());
1779
1780            // Buffer fitting batches in place and write large batches directly as whole pages.
1781            // The returned offset locates this batch's first byte in the tail blob.
1782            let base_offset = match self
1783                .blobs
1784                .try_append(&encoded.as_ref()[batch_start..batch_end])
1785            {
1786                Some(offset) => offset,
1787                None => {
1788                    let (blobs, offset) = self
1789                        .blobs
1790                        .append_owned(encoded.slice(batch_start..batch_end))
1791                        .await?;
1792                    self.blobs = blobs;
1793                    offset
1794                }
1795            };
1796
1797            // Convert batch-relative starts into absolute offsets within the data blob.
1798            let absolute_offsets = item_starts[written..written + batch_count]
1799                .iter()
1800                .map(|&start| {
1801                    base_offset
1802                        .checked_add((start - batch_start) as u64)
1803                        .ok_or(Error::OffsetOverflow)
1804                })
1805                .collect::<Result<Vec<u64>, _>>()?;
1806
1807            // Append the offsets for this blob batch to the offsets journal.
1808            let (offsets, last_offsets_pos) = self
1809                .offsets
1810                .append_many(Many::Flat(&absolute_offsets))
1811                .await?;
1812            self.offsets = offsets;
1813            assert_eq!(last_offsets_pos, self.bounds.end + batch_count as u64 - 1);
1814
1815            self.bounds.end += batch_count as u64;
1816            written += batch_count;
1817
1818            // Seal the just-filled tail, start syncing it, and open the next blob as the new tail.
1819            if self.bounds.end.is_multiple_of(items_per_blob) {
1820                self.blobs = self.blobs.seal_tail().await?;
1821            }
1822        }
1823
1824        self.metrics.update(
1825            self.bounds.end,
1826            self.bounds.start,
1827            self.items_per_blob.get(),
1828        );
1829        let position = self.bounds.end - 1;
1830        Ok((self, position))
1831    }
1832
1833    /// See [Journal::snapshot].
1834    pub(crate) async fn snapshot(
1835        mut self: Box<Self>,
1836    ) -> Result<(Box<Self>, Reader<'static, E, V>), Error> {
1837        let (blobs, data) = self.blobs.snapshot().await?;
1838        self.blobs = blobs;
1839        let (offsets, offsets_reader) = self.offsets.snapshot().await?;
1840        self.offsets = offsets;
1841        let reader = Reader {
1842            data,
1843            bounds: self.bounds.clone(),
1844            offsets: offsets_reader,
1845            items_per_blob: self.items_per_blob,
1846            codec_config: self.codec_config.clone(),
1847            compressed: self.compression.is_some(),
1848            metrics: self.metrics.clone(),
1849        };
1850        Ok((self, reader))
1851    }
1852
1853    /// A reader borrowing the journal's live state.
1854    fn reader(&self) -> Reader<'_, E, V> {
1855        Reader {
1856            data: self.blobs.reader(),
1857            bounds: self.bounds.clone(),
1858            offsets: self.offsets.reader(),
1859            items_per_blob: self.items_per_blob,
1860            codec_config: self.codec_config.clone(),
1861            compressed: self.compression.is_some(),
1862            metrics: self.metrics.clone(),
1863        }
1864    }
1865
1866    /// Return the total number of items in the journal, irrespective of pruning. The next value
1867    /// appended to the journal will be at this position.
1868    pub const fn size(&self) -> u64 {
1869        self.bounds.end
1870    }
1871
1872    /// See [Journal::prune].
1873    pub(crate) async fn prune(
1874        mut self: Box<Self>,
1875        min_position: u64,
1876    ) -> Result<(Box<Self>, bool), Error> {
1877        let items_per_blob = self.items_per_blob.get();
1878
1879        // Calculate the blob that would contain min_position, capped to the tail (which is
1880        // guaranteed to exist by our invariant).
1881        let target_blob = position_to_blob(min_position, items_per_blob);
1882        let tail_blob = position_to_blob(self.bounds.end, items_per_blob);
1883        let min_blob = target_blob.min(tail_blob);
1884
1885        if min_blob <= self.blobs.oldest_blob_index() {
1886            return Ok((self, false));
1887        }
1888
1889        let new_boundary = blob_first_position(min_blob, items_per_blob)?;
1890
1891        // Make all data durable before removing any: the prune target may be justified by an
1892        // appended-but-unflushed item (e.g. a consumer's commit record), and removals are
1893        // durable, so pruning without this sync could leave a recovered journal whose
1894        // surviving items no longer justify its boundary. The sync also covers unsynced
1895        // survivors above the boundary: removal may be interrupted, and recovery truncates at
1896        // the first torn item, so an unsynced survivor could discard every synced blob behind
1897        // it. Offsets entries for retained items must survive the same crash: recovery rebuilds
1898        // offsets that end behind the surviving data's end by replaying data, but offsets that
1899        // end behind its start are unrecoverable because the data needed to rebuild the missing
1900        // entries is about to be removed. Data is flushed first, matching the ordering every
1901        // other durability path maintains.
1902        let (blobs, data_sync) = self.blobs.start_sync().await?;
1903        self.blobs = blobs;
1904        data_sync.await?;
1905        self.offsets = self.offsets.commit().await?;
1906        self.barrier.mark_durable(self.bounds.end);
1907
1908        self.blobs = self.blobs.prune(min_blob).await?;
1909        self.bounds.start = new_boundary;
1910
1911        #[cfg(test)]
1912        if self.halt_before_offsets_prune {
1913            std::future::pending::<()>().await;
1914        }
1915
1916        // Prune data before offsets so a crash leaves offsets behind, which init repairs by
1917        // pruning offsets to match.
1918        let (offsets, _) = self.offsets.prune(new_boundary).await?;
1919        self.offsets = offsets;
1920        self.metrics.update(
1921            self.bounds.end,
1922            self.bounds.start,
1923            self.items_per_blob.get(),
1924        );
1925
1926        Ok((self, true))
1927    }
1928
1929    /// See [Journal::start_sync].
1930    pub(crate) async fn start_sync(mut self: Box<Self>) -> Result<(Box<Self>, Handle<()>), Error> {
1931        self.metrics.start_sync_calls.inc();
1932
1933        // Start independent durability barriers for the data and its offsets.
1934        let ((blobs, data), (offsets_journal, offsets)) =
1935            try_join(self.blobs.start_sync(), self.offsets.start_data_sync()).await?;
1936        self.blobs = blobs;
1937
1938        // Publish only the watermark already proved by a completed combined barrier.
1939        let size = self.barrier.boundary();
1940        let (offsets_journal, watermark_handle) =
1941            offsets_journal.start_watermark_sync(size).await?;
1942        self.offsets = offsets_journal;
1943
1944        // Record this prefix so the barrier advances once both data and offsets are durable.
1945        let journal_completion: SyncCompletion =
1946            async move { try_join(data, offsets).await.map(|_| ()) }
1947                .boxed()
1948                .shared();
1949        self.barrier
1950            .record(self.bounds.end, journal_completion.clone());
1951
1952        // The caller observes both the new durability barrier and watermark publication.
1953        let handle = Handle::from_future(async move {
1954            journal_completion.await?;
1955            watermark_handle.await
1956        });
1957        Ok((self, handle))
1958    }
1959
1960    /// See [Journal::commit].
1961    pub(crate) async fn commit(mut self: Box<Self>) -> Result<Box<Self>, Error> {
1962        let _timer = self.metrics.commit_timer();
1963        self.metrics.commit_calls.inc();
1964        let (blobs, handle) = self.blobs.start_sync().await?;
1965        self.blobs = blobs;
1966        handle.await?;
1967        Ok(self)
1968    }
1969
1970    /// See [Journal::sync].
1971    pub(crate) async fn sync(mut self: Box<Self>) -> Result<Box<Self>, Error> {
1972        let _timer = self.metrics.sync_timer();
1973        self.metrics.sync_calls.inc();
1974
1975        // Make data durable before the offsets journal can acknowledge its prefix.
1976        let size = self.bounds.end;
1977        let (blobs, handle) = self.blobs.start_sync().await?;
1978        self.blobs = blobs;
1979        handle.await?;
1980        self.offsets = self.offsets.sync().await?;
1981        self.barrier.mark_durable(size);
1982        Ok(self)
1983    }
1984
1985    /// See [Journal::destroy].
1986    pub(crate) async fn destroy(self) -> Result<(), Error> {
1987        // A failed offsets sync must surface before any data is removed, since the surviving
1988        // offsets journal would otherwise acknowledge items the removed blobs no longer back.
1989        let offsets = self.offsets.wait_for_sync().await?;
1990        self.blobs.destroy().await?;
1991        offsets.destroy().await
1992    }
1993
1994    /// Clear all data and advance the journal to a new starting position.
1995    ///
1996    /// Unlike `destroy`, this keeps the journal alive so it can be reused.
1997    /// After clearing, the journal will behave as if initialized with `init_at_size(new_size)`.
1998    /// The offsets reset intent is staged before the data blobs are cleared so recovery can
1999    /// complete the requested reset if a crash interrupts the operation.
2000    /// Returns [Error::ItemOutOfRange] if `new_size` is below the current end.
2001    #[commonware_macros::stability(ALPHA)]
2002    pub(crate) async fn clear_to_size(
2003        mut self: Box<Self>,
2004        new_size: u64,
2005    ) -> Result<Box<Self>, Error> {
2006        if new_size < self.bounds.end {
2007            return Err(Error::ItemOutOfRange(new_size));
2008        }
2009
2010        // Stage in offsets first so a crash mid-clear leaves an intent that recovery completes.
2011        // `clear_to_size` re-stages the same target idempotently before completing.
2012        self.offsets = self.offsets.stage_clear_intent(new_size).await?;
2013        self.blobs = self
2014            .blobs
2015            .clear(position_to_blob(new_size, self.items_per_blob.get()))
2016            .await?;
2017        self.offsets = self.offsets.clear_to_size(new_size).await?;
2018
2019        self.bounds = new_size..new_size;
2020        self.barrier = Barrier::new(new_size);
2021        self.metrics.update(
2022            self.bounds.end,
2023            self.bounds.start,
2024            self.items_per_blob.get(),
2025        );
2026        Ok(self)
2027    }
2028
2029    /// Scan every frame in `writer`, returning the writer with the item count and valid prefix.
2030    async fn scan_blob(
2031        writer: PagedRecovery<E::Blob>,
2032        buffer: NonZeroUsize,
2033        codec_config: &V::Cfg,
2034        compressed: bool,
2035    ) -> Result<(PagedRecovery<E::Blob>, BlobScan), Error> {
2036        let (writer, replay) = writer.replay(buffer, ReadOptions::default()).await?;
2037        let mut scanner = FrameScanner::<E::Blob, V>::new(replay, codec_config, compressed);
2038        let mut items = 0u64;
2039        loop {
2040            match scanner.next().await? {
2041                Frame::Item { .. } => items += 1,
2042                Frame::End { valid_size, torn } => {
2043                    let scan = BlobScan {
2044                        items,
2045                        valid_size,
2046                        torn,
2047                    };
2048                    return Ok((writer, scan));
2049                }
2050            }
2051        }
2052    }
2053
2054    /// Align the offsets journal and data blobs to be consistent in case a crash occurred on a
2055    /// previous run and left them in an inconsistent state.
2056    ///
2057    /// Inspection supplies the rebuilt offsets and scan results for the selected data prefix.
2058    /// Final blob-index contiguity is enforced by [Writable::recover]. Repairs mutate `pending`
2059    /// in place, and the data must be durable before publishing the offsets watermark.
2060    ///
2061    /// Returns the recovered bounds (`pruning_boundary..size`).
2062    #[allow(clippy::too_many_arguments)]
2063    async fn align(
2064        partition: &Partition<E>,
2065        pending: &mut BTreeMap<u64, PagedRecovery<E::Blob>>,
2066        mut offsets: Box<fixed::Recovery<E, u64>>,
2067        items_per_blob: u64,
2068        buffer: NonZeroUsize,
2069        codec_config: &V::Cfg,
2070        compressed: bool,
2071        scans: &BTreeMap<u64, BlobScan>,
2072    ) -> Result<(Box<fixed::Recovery<E, u64>>, Range<u64>), Error> {
2073        // Find the newest item-bearing blob, truncating torn trailing bytes along the way (the
2074        // first invalid frame is the end of the journal).
2075        let scanned: Vec<u64> = pending.keys().rev().copied().collect();
2076        let mut items_in_newest = 0;
2077        let mut newest_blob = None;
2078        for &blob in &scanned {
2079            let writer = pending.remove(&blob).expect("blob came from pending");
2080            let (mut writer, scan) = match scans.get(&blob) {
2081                Some(scan) => (writer, *scan),
2082                None => Self::scan_blob(writer, buffer, codec_config, compressed).await?,
2083            };
2084            if scan.items > items_per_blob {
2085                return Err(Error::Corruption(format!(
2086                    "blob {blob} has too many items: expected at most {items_per_blob}, got {}",
2087                    scan.items
2088                )));
2089            }
2090            if scan.torn {
2091                warn!(
2092                    blob,
2093                    new_size = scan.valid_size,
2094                    "crash repair: truncating trailing bytes"
2095                );
2096                writer = writer.truncate(scan.valid_size).await?;
2097            }
2098            pending.insert(blob, writer);
2099            if scan.items > 0 {
2100                items_in_newest = scan.items;
2101                newest_blob = Some(blob);
2102                break;
2103            }
2104        }
2105
2106        // The tail blob (where the next append lands) is one past the newest full blob, the
2107        // newest partial blob, or the oldest blob when empty (resolved by `align_empty`). Any
2108        // blob above it is an empty crash artifact; the loop below removes them.
2109        let tail_blob = match newest_blob {
2110            Some(blob) if items_in_newest == items_per_blob => blob.saturating_add(1),
2111            Some(blob) => blob,
2112            None => pending.keys().next().copied().unwrap_or(0),
2113        };
2114        for &blob in &scanned {
2115            if blob <= tail_blob {
2116                break;
2117            }
2118            warn!(blob, "crash repair: removing empty trailing data blob");
2119            pending.remove(&blob);
2120            partition.remove(blob).await?;
2121        }
2122
2123        let Some(newest_blob) = newest_blob else {
2124            return Self::align_empty(partition, pending, offsets, items_per_blob).await;
2125        };
2126
2127        // Align pruning state at the blob level. After alignment, the offsets journal starts in
2128        // the oldest data blob; a mid-blob offsets start (from `init_at_size`) is valid within
2129        // the same blob.
2130        let oldest_blob = *pending.keys().next().expect("pending is non-empty");
2131        let data_oldest_pos = blob_first_position(oldest_blob, items_per_blob)?;
2132        {
2133            let offsets_bounds = offsets.pruning_boundary()..offsets.size();
2134
2135            // The offsets journal ending before the oldest retained data blob represents an
2136            // impossible state under normal crash/prune sequences, indicating external corruption.
2137            if offsets_bounds.end < data_oldest_pos {
2138                return Err(Error::Corruption(format!(
2139                    "offsets journal size {} is behind data oldest position {data_oldest_pos}",
2140                    offsets_bounds.end
2141                )));
2142            }
2143            let offsets_start_blob = position_to_blob(offsets_bounds.start, items_per_blob);
2144            match offsets_start_blob.cmp(&oldest_blob) {
2145                std::cmp::Ordering::Less => {
2146                    warn!("crash repair: pruning offsets journal to {data_oldest_pos}");
2147                    let (pruned, _) = offsets.prune(data_oldest_pos).await?;
2148                    offsets = pruned;
2149                }
2150                std::cmp::Ordering::Equal => {}
2151                std::cmp::Ordering::Greater => {
2152                    // Prune always removes data before offsets, so offsets should never be
2153                    // ahead by a blob.
2154                    return Err(Error::Corruption(format!(
2155                        "offsets start blob {offsets_start_blob} ahead of \
2156                         oldest data blob {oldest_blob}"
2157                    )));
2158                }
2159            }
2160        }
2161
2162        // Re-fetch bounds since prune may have been called above.
2163        let offsets_bounds = offsets.pruning_boundary()..offsets.size();
2164
2165        // The newest item-bearing blob bounds how far recovery can possibly go. If it is also
2166        // the oldest retained blob, its logical start may be a mid-blob pruning boundary.
2167        let data_size = blob_first_position(newest_blob, items_per_blob)?
2168            .max(offsets_bounds.start)
2169            .checked_add(items_in_newest)
2170            .ok_or(Error::OffsetOverflow)?;
2171        let data_sync_start = Self::recovery_anchor(&offsets, &offsets_bounds, data_size)?;
2172
2173        // Final invariant checks. These hold by construction after alignment, but the inputs are
2174        // recovered from disk, so a violation means corruption rather than a logic bug.
2175        let pruning_boundary = {
2176            let offsets_bounds = offsets.pruning_boundary()..offsets.size();
2177            if offsets_bounds.end != data_size {
2178                return Err(Error::Corruption(format!(
2179                    "recovered offsets end {} does not match data size {data_size}",
2180                    offsets_bounds.end
2181                )));
2182            }
2183
2184            // Recovery can truncate the data back to empty (e.g. an empty oldest blob preceded
2185            // the only populated blob, so no contiguous data-backed prefix exists). In that case
2186            // there is no oldest blob to anchor against; otherwise offsets and data must start in
2187            // the same blob.
2188            if !offsets_bounds.is_empty()
2189                && position_to_blob(offsets_bounds.start, items_per_blob) != oldest_blob
2190            {
2191                return Err(Error::Corruption(format!(
2192                    "recovered offsets and data start in different blobs: {} != {oldest_blob}",
2193                    position_to_blob(offsets_bounds.start, items_per_blob)
2194                )));
2195            }
2196
2197            // Return bounds.start from offsets as the true boundary.
2198            offsets_bounds.start
2199        };
2200
2201        // Make retained data durable before advancing the rebuilt offsets' watermark.
2202        Self::sync_data_range(pending, data_sync_start, data_size, items_per_blob).await?;
2203        let offsets = offsets.sync().await?;
2204        Ok((offsets, pruning_boundary..data_size))
2205    }
2206
2207    /// Reconcile a data partition holding no items against the offsets journal.
2208    ///
2209    /// At most one empty blob remains in `pending`. It is the tail of an empty journal after
2210    /// a clean restart or an interrupted truncation, prune-all, or first append.
2211    async fn align_empty(
2212        partition: &Partition<E>,
2213        pending: &mut BTreeMap<u64, PagedRecovery<E::Blob>>,
2214        mut offsets: Box<fixed::Recovery<E, u64>>,
2215        items_per_blob: u64,
2216    ) -> Result<(Box<fixed::Recovery<E, u64>>, Range<u64>), Error> {
2217        let offsets_bounds = offsets.pruning_boundary()..offsets.size();
2218
2219        let Some(&blob) = pending.keys().next() else {
2220            // No data blobs at all: a fresh partition, or a crash after pruning the data blobs
2221            // but before pruning the offsets journal. Clear (rather than prune) the offsets so
2222            // bounds collapse even when the size is mid-blob.
2223            let size = offsets_bounds.end;
2224            if !offsets_bounds.is_empty() {
2225                warn!("crash repair: clearing offsets to {size} (prune-all crash)");
2226                offsets = offsets.clear_to_size(size).await?;
2227            }
2228            return Ok((offsets, size..size));
2229        };
2230
2231        // The journal restarts at the empty blob's first position, or at the offsets journal's
2232        // mid-blob boundary within it.
2233        let blob_start = blob_first_position(blob, items_per_blob)?;
2234        let target = blob_start.max(offsets_bounds.start);
2235
2236        // Nothing to repair when the blob is the tail of an already-aligned empty journal.
2237        let aligned = position_to_blob(target, items_per_blob) == blob;
2238        if aligned && offsets_bounds == (target..target) {
2239            return Ok((offsets, target..target));
2240        }
2241
2242        // Otherwise reconcile both sides to `target`: drop the blob unless it is the tail at
2243        // `target` (recovery reopens the tail), and collapse the offsets bounds.
2244        if !aligned {
2245            warn!(blob, "crash repair: removing empty data blob");
2246            pending.remove(&blob);
2247            partition.remove(blob).await?;
2248        }
2249        warn!("crash repair: clearing offsets to {target} (empty data)");
2250        let offsets = offsets.clear_to_size(target).await?;
2251        Ok((offsets, target..target))
2252    }
2253
2254    /// Choose the position to rebuild offsets from. A watermark below the pruning boundary is
2255    /// stale after a prune, while a watermark beyond retained data indicates corruption.
2256    fn recovery_anchor(
2257        offsets: &fixed::Recovery<E, u64>,
2258        offsets_bounds: &Range<u64>,
2259        retained_data_end_bound: u64,
2260    ) -> Result<u64, Error> {
2261        let recovery_watermark = offsets.recovery_watermark();
2262        if recovery_watermark < offsets_bounds.start {
2263            warn!(
2264                recovery_watermark,
2265                start = offsets_bounds.start,
2266                end = offsets_bounds.end,
2267                retained_data_end_bound,
2268                "crash repair: offsets recovery watermark is unusable, rebuilding from offsets start"
2269            );
2270            return Ok(offsets_bounds.start);
2271        }
2272        if recovery_watermark > retained_data_end_bound {
2273            return Err(Error::Corruption(format!(
2274                "offsets recovery watermark {recovery_watermark} exceeds retained data end \
2275                 {retained_data_end_bound} (offsets bounds {}..{})",
2276                offsets_bounds.start, offsets_bounds.end
2277            )));
2278        }
2279        Ok(recovery_watermark)
2280    }
2281
2282    /// Sync data blobs backing rebuilt offsets before the offsets are made durable.
2283    async fn sync_data_range(
2284        pending: &mut BTreeMap<u64, PagedRecovery<E::Blob>>,
2285        start_position: u64,
2286        end_position: u64,
2287        items_per_blob: u64,
2288    ) -> Result<(), Error> {
2289        if start_position >= end_position {
2290            return Ok(());
2291        }
2292
2293        // Own writers with sync work and restore them only after every sync succeeds.
2294        let start_blob = position_to_blob(start_position, items_per_blob);
2295        let end_blob = position_to_blob(end_position - 1, items_per_blob);
2296        let futures: Vec<_> = pending
2297            .extract_if(start_blob..=end_blob, |_, writer| writer.needs_sync())
2298            .map(|(blob, writer)| async move { writer.sync().await.map(|writer| (blob, writer)) })
2299            .collect();
2300        let synced = try_join_all(futures).await?;
2301        pending.extend(synced);
2302        Ok(())
2303    }
2304
2305    /// Remove every blob newer than `blob`, newest-first so a crash leaves a contiguous prefix.
2306    async fn remove_blobs_after(
2307        partition: &Partition<E>,
2308        pending: &mut BTreeMap<u64, PagedRecovery<E::Blob>>,
2309        blob: u64,
2310    ) -> Result<(), Error> {
2311        while let Some((&newest, _)) = pending.last_key_value() {
2312            if newest <= blob {
2313                break;
2314            }
2315            drop(pending.remove(&newest));
2316            partition.remove(newest).await?;
2317        }
2318        Ok(())
2319    }
2320}
2321
2322/// A contiguous journal with variable-size entries.
2323///
2324/// This journal manages blob assignment automatically, allowing callers to append items
2325/// sequentially without manually tracking blob indexes.
2326///
2327/// # Repair
2328///
2329/// Like
2330/// [sqlite](https://github.com/sqlite/sqlite/blob/8658a8df59f00ec8fcfea336a2a6a4b5ef79d2ee/src/wal.c#L1504-L1505)
2331/// and
2332/// [rocksdb](https://github.com/facebook/rocksdb/blob/0c533e61bc6d89fdf1295e8e0bcee4edb3aef401/include/rocksdb/options.h#L441-L445),
2333/// the first invalid data read above the recovery watermark's acknowledged prefix is considered
2334/// the new end of the journal (and the underlying blob is truncated to the last valid item).
2335/// Data at or below that prefix is never repaired: a blob that no longer backs its acknowledged
2336/// items fails init, and other damage there surfaces as a read error. Repair is performed during
2337/// init. Incomplete trailing frames are repaired as torn writes. Complete frames whose payloads
2338/// fail to decode are treated as corruption.
2339///
2340/// # Invariants
2341///
2342/// ## 1. Data Blobs are the Source of Truth
2343///
2344/// The data blobs are always the source of truth. The offsets journal is an index that may
2345/// temporarily diverge during crashes. Divergences are automatically aligned during init():
2346/// * If offsets are behind data after the recovery watermark: rebuild missing offsets by replaying
2347///   data from the recovery anchor.
2348/// * If offsets are ahead of the retained data prefix but the data still reaches the recovery
2349///   watermark, truncate offsets to match the data-backed size. Retained data ending before the
2350///   watermark is corruption because acknowledged data is missing.
2351/// * If offsets.bounds().start < the oldest data blob's start: prune offsets to match (this can
2352///   happen if we crash after pruning the data blobs but before pruning the offsets journal).
2353///
2354/// Offsets may start after the data's blob-aligned start when both are in the same blob, as in a
2355/// mid-blob `init_at_size`. Offsets starting in a later blob imply corruption because we
2356/// always prune the data blobs before the offsets journal.
2357///
2358/// ## 2. Offsets Recovery Watermark
2359///
2360/// The offsets journal's recovery watermark records a durable lower bound on the journal size and
2361/// a preferred point for replaying data to rebuild offset entries after a crash. Fixed-journal
2362/// recovery rejects watermarks beyond the recovered offsets size as corruption. A watermark below
2363/// the recovered offsets start is stale after a prune, so init falls back to the offsets start. If
2364/// retained data exists but ends before the watermark, init returns corruption because acknowledged
2365/// data is missing. If no retained data exists, init reconciles both sides to an empty journal.
2366/// Replay after a valid anchor stops at the first short data blob and truncates newer blobs so the
2367/// recovered journal remains a contiguous prefix.
2368///
2369/// Mutating functions consume the journal and return it only on success: an error (or a dropped
2370/// future) destroys the handle.
2371pub struct Journal<E: Context, V: Codec>(Box<Inner<E, V>>);
2372
2373impl<E: Context, V: CodecShared> std::fmt::Debug for Journal<E, V> {
2374    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2375        f.debug_struct("Journal")
2376            .field("bounds", &super::Contiguous::bounds(self))
2377            .finish_non_exhaustive()
2378    }
2379}
2380
2381impl<E: Context, V: CodecShared> Journal<E, V> {
2382    /// Initialize a contiguous variable journal.
2383    ///
2384    /// # Crash Recovery
2385    ///
2386    /// The data blobs are the source of truth. If the offsets journal is inconsistent
2387    /// it will be updated to match the data blobs.
2388    pub async fn init(context: E, cfg: Config<V::Cfg>) -> Result<Self, Error> {
2389        Ok(Self(Box::new(Inner::init(context, cfg).await?)))
2390    }
2391
2392    /// Open at most `max_size` items, including pruned positions.
2393    ///
2394    /// The discarded suffix is durably removed before success. Subsequent appends may exceed
2395    /// the cap. A cap below the retained start returns [Error::ItemPruned].
2396    pub async fn init_at_most(
2397        context: E,
2398        cfg: Config<V::Cfg>,
2399        max_size: u64,
2400    ) -> Result<Self, Error> {
2401        let recovery = Recovery::<E, V>::open(context, cfg, Some(max_size)).await?;
2402        Ok(Self(Box::new(recovery.publish(max_size).await?)))
2403    }
2404
2405    /// Initialize an empty [Journal] at the given logical `size`.
2406    ///
2407    /// This discards any existing data and offsets. The offsets reset intent is staged before the
2408    /// data partition is cleared so recovery can complete the requested reset if a crash
2409    /// interrupts the operation.
2410    ///
2411    /// Returns a journal with journal.bounds() == Range{start: size, end: size}
2412    /// and next append at position `size`.
2413    ///
2414    /// # Errors
2415    ///
2416    /// Returns [Error::SizeOverflow] if `size` is `u64::MAX`.
2417    #[commonware_macros::stability(ALPHA)]
2418    pub async fn init_at_size(context: E, cfg: Config<V::Cfg>, size: u64) -> Result<Self, Error> {
2419        Ok(Self(Box::new(
2420            Inner::init_at_size(context, cfg, size).await?,
2421        )))
2422    }
2423
2424    /// Discard all items and reposition the journal at or beyond its current end.
2425    #[commonware_macros::stability(ALPHA)]
2426    pub(crate) async fn clear_to_size(mut self, new_size: u64) -> Result<Self, Error> {
2427        self.0 = self.0.clear_to_size(new_size).await?;
2428        Ok(self)
2429    }
2430
2431    /// Append a new item to the journal, returning its position.
2432    ///
2433    /// The position returned is a stable, consecutively increasing value starting from 0.
2434    /// This position remains constant after pruning.
2435    ///
2436    /// # Errors
2437    ///
2438    /// Returns an error if the underlying storage operation fails or if the item cannot
2439    /// be encoded.
2440    pub async fn append(mut self, item: &V) -> Result<(Self, u64), Error> {
2441        let (inner, position) = self.0.append(item).await?;
2442        self.0 = inner;
2443        Ok((self, position))
2444    }
2445
2446    /// Append items to the journal, returning the position of the last item appended.
2447    ///
2448    /// Returns [Error::EmptyAppend] if items is empty.
2449    pub async fn append_many(mut self, items: Many<'_, V>) -> Result<(Self, u64), Error> {
2450        let (inner, position) = self.0.append_many(items).await?;
2451        self.0 = inner;
2452        Ok((self, position))
2453    }
2454
2455    /// Encode `items` into a buffer that can be appended later with [`Self::append_prepared`].
2456    ///
2457    /// This lets callers serialize borrowed items synchronously, release those borrows, and
2458    /// perform the append without holding unrelated locks across journal I/O.
2459    pub fn prepare_append(&self, items: Many<'_, V>) -> Result<PreparedAppend<V>, Error> {
2460        self.0.prepare_append::<true>(items)
2461    }
2462
2463    /// Append items encoded by [`Self::prepare_append`], returning the position of the last item
2464    /// appended.
2465    ///
2466    /// Returns [Error::EmptyAppend] if `prepared` contains no items.
2467    /// Returns [Error::InvalidConfiguration] if `prepared` was encoded with different compression
2468    /// settings than this journal uses.
2469    pub async fn append_prepared(
2470        mut self,
2471        prepared: PreparedAppend<V>,
2472    ) -> Result<(Self, u64), Error> {
2473        let (inner, position) = self.0.append_prepared(prepared).await?;
2474        self.0 = inner;
2475        Ok((self, position))
2476    }
2477
2478    /// Capture an owned snapshot ([`Reader`]) over the current journal. Bounds are frozen at
2479    /// creation, and the snapshot stays readable across concurrent appends and prunes. It keeps
2480    /// the journal's blobs open, so reopening a partition that still holds one of them fails
2481    /// while the snapshot is alive.
2482    pub async fn snapshot(mut self) -> Result<(Self, Reader<'static, E, V>), Error> {
2483        let (inner, reader) = self.0.snapshot().await?;
2484        self.0 = inner;
2485        Ok((self, reader))
2486    }
2487
2488    /// Return the total number of items in the journal, irrespective of pruning. The next value
2489    /// appended to the journal will be at this position.
2490    pub fn size(&self) -> u64 {
2491        self.0.size()
2492    }
2493
2494    /// Prune items at positions strictly less than `min_position`.
2495    ///
2496    /// Returns `true` if any data was pruned, `false` otherwise.
2497    ///
2498    /// # Errors
2499    ///
2500    /// Returns an error if the underlying storage operation fails.
2501    pub async fn prune(mut self, min_position: u64) -> Result<(Self, bool), Error> {
2502        let (inner, pruned) = self.0.prune(min_position).await?;
2503        self.0 = inner;
2504        Ok((self, pruned))
2505    }
2506
2507    /// Persist data blobs so committed data survives a crash.
2508    ///
2509    /// Does not advance the recovery watermark, so reopen may replay entries above it.
2510    pub async fn commit(mut self) -> Result<Self, Error> {
2511        self.0 = self.0.commit().await?;
2512        Ok(self)
2513    }
2514
2515    /// Begin durably persisting the current state of the journal.
2516    ///
2517    /// Awaiting the returned [Handle] guarantees state appended before this call survives a
2518    /// crash. Also tries to advance the recovery watermark to the previous proven durable
2519    /// size, bounding startup recovery. Only `sync()` guarantees a current watermark.
2520    ///
2521    /// At most one data sync and one watermark sync are in flight at a time: this call waits for
2522    /// the prior call's syncs before starting new ones. It does not wait for a pending rollover
2523    /// fsync: the returned handle joins it, so an earlier call's handle may still be pending when
2524    /// this call returns. Reads proceed while the returned handle is pending, and appends proceed
2525    /// while they fit in the write buffer.
2526    ///
2527    /// Flush errors are returned directly. Dropping the handle does not cancel the sync or lose its
2528    /// failure: a later operation reports it, no later than the next [Self::sync].
2529    pub async fn start_sync(mut self) -> Result<(Self, Handle<()>), Error> {
2530        let (inner, handle) = self.0.start_sync().await?;
2531        self.0 = inner;
2532        Ok((self, handle))
2533    }
2534
2535    /// Persist data blobs and all metadata for both the data and offsets journals.
2536    pub async fn sync(mut self) -> Result<Self, Error> {
2537        self.0 = self.0.sync().await?;
2538        Ok(self)
2539    }
2540
2541    /// Remove any underlying blobs created by the journal.
2542    ///
2543    /// This destroys both the data blobs and the offsets journal. A started sync that failed is
2544    /// reported before anything is removed.
2545    ///
2546    /// # Crash Safety
2547    ///
2548    /// This operation is intended for final teardown and is not crash-safe. If interrupted,
2549    /// reopening the same partitions may observe partially removed state. Use [Self::init_at_size]
2550    /// for a recoverable reset.
2551    pub async fn destroy(self) -> Result<(), Error> {
2552        self.0.destroy().await
2553    }
2554}
2555
2556impl<E: Context, V: CodecShared> Contiguous for Journal<E, V> {
2557    type Item = V;
2558
2559    fn bounds(&self) -> Range<u64> {
2560        self.0.bounds.clone()
2561    }
2562
2563    async fn read(&self, position: u64) -> Result<V, Error> {
2564        self.0.reader().read(position).await
2565    }
2566
2567    async fn read_many(&self, positions: &[u64]) -> Result<Vec<V>, Error> {
2568        self.0.reader().read_many(positions).await
2569    }
2570
2571    fn try_read_sync(&self, position: u64) -> Option<V> {
2572        self.0.reader().try_read_sync(position)
2573    }
2574
2575    fn try_read_many_sync(&self, positions: &[u64]) -> Vec<Option<V>> {
2576        self.0.reader().try_read_many_sync(positions)
2577    }
2578
2579    async fn replay(
2580        &self,
2581        range: Range<u64>,
2582        buffer: NonZeroUsize,
2583        read_options: ReadOptions,
2584    ) -> Result<impl Stream<Item = Result<Batch<V>, Error>> + Send, Error> {
2585        let reader = self.0.reader();
2586        let states = reader.replay_states(range, buffer, read_options).await?;
2587
2588        Ok(super::replay_stream_from_states(states))
2589    }
2590}
2591
2592impl<E: Context, V: CodecShared> Mutable for Journal<E, V> {
2593    async fn append(self, item: &Self::Item) -> Result<(Self, u64), Error> {
2594        Self::append(self, item).await
2595    }
2596
2597    async fn append_many(self, items: Many<'_, Self::Item>) -> Result<(Self, u64), Error> {
2598        Self::append_many(self, items).await
2599    }
2600
2601    async fn prune(self, min_position: u64) -> Result<(Self, bool), Error> {
2602        Self::prune(self, min_position).await
2603    }
2604
2605    async fn start_sync(self) -> Result<(Self, Handle<()>), Error> {
2606        Self::start_sync(self).await
2607    }
2608
2609    async fn commit(self) -> Result<Self, Error> {
2610        Self::commit(self).await
2611    }
2612
2613    async fn sync(self) -> Result<Self, Error> {
2614        Self::sync(self).await
2615    }
2616
2617    async fn destroy(self) -> Result<(), Error> {
2618        Self::destroy(self).await
2619    }
2620}
2621
2622#[commonware_macros::stability(ALPHA)]
2623impl<E: Context, V: CodecShared> authenticated::BackingRecovery for Recovery<E, V> {
2624    type Journal = Journal<E, V>;
2625
2626    fn bounds(&self) -> Range<u64> {
2627        self.bounds.clone()
2628    }
2629
2630    /// Read recovery data through the acknowledged and rebuilt offsets.
2631    async fn read(&self, pos: u64) -> Result<V, Error> {
2632        if pos < self.bounds.start {
2633            return Err(Error::ItemPruned(pos));
2634        }
2635        if pos >= self.bounds.end {
2636            return Err(Error::ItemOutOfRange(pos));
2637        }
2638        let per_blob = self.cfg.items_per_section.get();
2639        let blob = position_to_blob(pos, per_blob);
2640        let writer = self
2641            .pending
2642            .get(&blob)
2643            .ok_or_else(|| Error::Corruption(format!("missing recovery data blob {blob}")))?;
2644        let offset = self.offsets.item(pos).await?;
2645        read_frame_at::<V>(
2646            writer,
2647            offset,
2648            &self.cfg.codec_config,
2649            self.cfg.compression.is_some(),
2650        )
2651        .await
2652        .map(|(_, _, item)| item)
2653    }
2654
2655    async fn reset(mut self, size: u64) -> Result<Self, Error> {
2656        self.pending.clear();
2657        let partition = self.cfg.data_partition();
2658        self.offsets = self
2659            .offsets
2660            .clear_to_size_cleared(size, || {
2661                Partition::<E>::remove_all(&self.context, &partition)
2662            })
2663            .await?;
2664        self.discarded.clear();
2665        self.recovered_scans.clear();
2666        self.bounds = size..size;
2667        Ok(self)
2668    }
2669
2670    async fn finish(self, size: u64) -> Result<Self::Journal, Error> {
2671        Ok(Journal(Box::new(Self::publish(self, size).await?)))
2672    }
2673}
2674
2675#[commonware_macros::stability(ALPHA)]
2676impl<E: Context, V: CodecShared> authenticated::Backing<E> for Journal<E, V> {
2677    type Recovery = Recovery<E, V>;
2678
2679    async fn recover(
2680        context: E,
2681        cfg: Self::Config,
2682        max_size: Option<u64>,
2683    ) -> Result<Self::Recovery, Error> {
2684        Recovery::open(context, cfg, max_size).await
2685    }
2686
2687    async fn span(context: E, cfg: &Self::Config) -> Result<Range<u64>, Error> {
2688        Recovery::<E, V>::span(context, cfg).await
2689    }
2690
2691    async fn clear(context: E, cfg: Self::Config, size: u64) -> Result<Self::Recovery, Error> {
2692        let offsets_partition = cfg.offsets_partition();
2693        let offsets_context = context.child("offsets");
2694        let checkpoint =
2695            Checkpoint::open(offsets_context.child("meta"), &offsets_partition).await?;
2696
2697        // A staged clear already owns the offsets blob partitions. Otherwise fail before writing
2698        // intent if they are inconsistent.
2699        if checkpoint.clear_target().is_none() {
2700            Partition::select(&offsets_context, &offsets_partition).await?;
2701        }
2702
2703        // Remove the data partition under the staged offsets reset, before it completes. A crash
2704        // after staging leaves an intent that the next open completes.
2705        let data_partition = cfg.data_partition();
2706        let offsets = fixed::Recovery::<E, u64>::open_cleared(
2707            offsets_context,
2708            cfg.offsets_config(),
2709            checkpoint,
2710            size,
2711            || Partition::<E>::remove_all(&context, &data_partition),
2712        )
2713        .await?;
2714        Ok(Recovery::cleared(context, cfg, offsets, size))
2715    }
2716
2717    type Config = Config<V::Cfg>;
2718}
2719
2720#[cfg(test)]
2721impl<E: Context, V: CodecShared> Journal<E, V> {
2722    /// Test helper: Prune the data blobs directly (simulates crash scenario).
2723    pub(crate) async fn test_prune_data(mut self, min_blob: u64) -> Result<(Self, bool), Error> {
2724        let min_blob = min_blob.min(self.0.blobs.tail_blob_index());
2725        if min_blob <= self.0.blobs.oldest_blob_index() {
2726            return Ok((self, false));
2727        }
2728        self.0.blobs = self.0.blobs.prune(min_blob).await?;
2729        Ok((self, true))
2730    }
2731
2732    /// Test helper: Prune the internal offsets journal directly (simulates crash scenario).
2733    pub(crate) async fn test_prune_offsets(mut self, position: u64) -> Result<(Self, bool), Error> {
2734        let (offsets, pruned) = self.0.offsets.prune(position).await?;
2735        self.0.offsets = offsets;
2736        Ok((self, pruned))
2737    }
2738
2739    /// Test helper: Set and persist the offsets recovery watermark directly.
2740    pub(crate) async fn test_set_offsets_recovery_watermark(
2741        mut self,
2742        watermark: u64,
2743    ) -> Result<Self, Error> {
2744        self.0.offsets = self
2745            .0
2746            .offsets
2747            .test_set_recovery_watermark(watermark)
2748            .await?;
2749        Ok(self)
2750    }
2751
2752    /// Test helper: Get the size of the internal offsets journal.
2753    pub(crate) fn test_offsets_size(&self) -> u64 {
2754        self.0.offsets.size()
2755    }
2756
2757    /// Test helper: Append directly to the data blobs without indexing (simulates crash
2758    /// scenario). The target must be the tail blob or a newer one (created as an orphan).
2759    pub(crate) async fn test_append_data(
2760        mut self,
2761        blob: u64,
2762        item: V,
2763    ) -> Result<(Self, u64, u32), Error> {
2764        let mut encoded = Vec::new();
2765        encode_frame_into(self.0.compression, &item, &mut encoded)?;
2766        let item_len = encoded.len() as u32;
2767
2768        let tail_blob = self.0.blobs.tail_blob_index();
2769        if blob == tail_blob {
2770            let (blobs, offset) = self.0.blobs.append_owned(encoded.into()).await?;
2771            self.0.blobs = blobs;
2772            return Ok((self, offset, item_len));
2773        }
2774        assert!(blob > tail_blob, "cannot append to a sealed blob");
2775        let writer = self.0.blobs.open_blob(blob).await?;
2776        let (writer, offset) = writer.append(&encoded).await?;
2777        writer.sync().await?;
2778        Ok((self, offset, item_len))
2779    }
2780
2781    /// Test helper: Sync one data blob.
2782    pub(crate) async fn test_sync_data_blob(mut self, blob: u64) -> Result<Self, Error> {
2783        self.0.blobs = self.0.blobs.sync_blob(blob).await?;
2784        Ok(self)
2785    }
2786
2787    /// Test helper: Reopen a shorter prefix through recovery, releasing the previous owner.
2788    pub(crate) async fn test_truncate(mut self, cap: u64) -> Result<Self, Error> {
2789        self.0 = self.0.test_truncate(cap).await?;
2790        Ok(self)
2791    }
2792
2793    /// Test helper. Truncate the internal offsets journal directly (simulates crash scenario).
2794    pub(crate) async fn test_truncate_offsets(mut self, position: u64) -> Result<Self, Error> {
2795        self.0.offsets = self.0.offsets.test_truncate(position).await?;
2796        Ok(self)
2797    }
2798
2799    /// Drop the journal and truncate only its data, leaving stale offsets for recovery tests.
2800    async fn test_truncate_data(mut self, position: u64) -> Result<Partition<E>, Error> {
2801        let offset = self.0.offsets.reader().read(position).await?;
2802        let blob = position_to_blob(position, self.0.items_per_blob.get());
2803        let (context, name, cache, buffer) = self.0.blobs.test_configuration();
2804        let (blobs, sync) = self.0.blobs.start_sync().await?;
2805        self.0.blobs = blobs;
2806        sync.await?;
2807        drop(self);
2808        let partition = Partition::new(context, name, cache, buffer);
2809        let mut pending = partition.open_all().await?;
2810        Inner::<E, V>::remove_blobs_after(&partition, &mut pending, blob).await?;
2811        pending
2812            .remove(&blob)
2813            .expect("retained data blob")
2814            .truncate(offset)
2815            .await?;
2816        Ok(partition)
2817    }
2818}
2819
2820#[cfg(test)]
2821impl<E: crate::Context, V: CodecShared> Inner<E, V> {
2822    /// Reopen a shorter prefix for recovery/fault fixtures, releasing the previous owner.
2823    async fn test_truncate(self: Box<Self>, cap: u64) -> Result<Box<Self>, Error> {
2824        let (context, name, page_cache, write_buffer) = self.blobs.test_configuration();
2825        let cfg = Config {
2826            partition: name.strip_suffix(DATA_SUFFIX).unwrap().to_owned(),
2827            items_per_section: self.items_per_blob,
2828            page_cache,
2829            write_buffer,
2830            replay_buffer: write_buffer,
2831            compression: self.compression,
2832            codec_config: self.codec_config.clone(),
2833        };
2834        _ = self.sync().await?;
2835        Ok(Box::new(
2836            Recovery::<E, V>::open(context, cfg, Some(cap))
2837                .await?
2838                .publish(cap)
2839                .await?,
2840        ))
2841    }
2842}
2843
2844#[cfg(test)]
2845mod tests {
2846    use super::*;
2847    use crate::{
2848        journal::{
2849            authenticated::{self, BackingRecovery as _},
2850            contiguous::{
2851                checkpoint::Checkpoint,
2852                tests::{replay_items, run_contiguous_tests, tear_page_rewrite},
2853            },
2854        },
2855        utils::codec::View,
2856    };
2857    use commonware_macros::test_traced;
2858    use commonware_runtime::{
2859        Metrics as _, ReadOptions, Runner, Spawner as _, Storage, Supervisor as _, WriteOptions,
2860        buffer::paged::{CacheRef, Writer, corrupt_page},
2861        deterministic,
2862        mocks::{
2863            DelayedSyncContext, PendingSyncs, RecordingContext, drive_pending_syncs,
2864            fail_pending_syncs, next_pending_sync, release_pending_syncs,
2865        },
2866    };
2867    use commonware_utils::{NZU16, NZU64, NZUsize, probability, sequence::FixedBytes};
2868    use futures::StreamExt as _;
2869    use rand::Rng as _;
2870    use std::num::NonZeroU16;
2871
2872    // Use some jank sizes to exercise boundary conditions.
2873    const PAGE_SIZE: NonZeroU16 = NZU16!(101);
2874    const PAGE_CACHE_SIZE: usize = 2;
2875    // Larger page sizes for tests that need more buffer space.
2876    const LARGE_PAGE_SIZE: NonZeroU16 = NZU16!(1024);
2877    const SMALL_PAGE_SIZE: NonZeroU16 = NZU16!(512);
2878
2879    fn initialization_cfg(partition: &str, items_per_section: u64) -> Config<()> {
2880        Config {
2881            partition: partition.into(),
2882            items_per_section: NonZeroU64::new(items_per_section).unwrap(),
2883            compression: None,
2884            codec_config: (),
2885            page_cache: CacheRef::new(NZU16!(16), NZUsize!(4)),
2886            write_buffer: NZUsize!(1),
2887            replay_buffer: NZUsize!(256),
2888        }
2889    }
2890
2891    #[test]
2892    fn test_bounded_inspection_preserves_acknowledged_offsets_until_publication() {
2893        deterministic::Runner::default().start(|context| async move {
2894            // Acknowledge 20 items across four full sections, so the offsets watermark is 20.
2895            let cfg = initialization_cfg("inspection-preserves-offsets", 5);
2896            let offsets_partition = cfg.offsets_partition();
2897            let mut journal = Journal::<_, u64>::init(context.child("seed"), cfg.clone())
2898                .await
2899                .unwrap();
2900            for item in 0..20u64 {
2901                (journal, _) = journal.append(&item).await.unwrap();
2902            }
2903            drop(journal.sync().await.unwrap());
2904            assert_eq!(
2905                fixed::Journal::<_, u64>::persisted_watermark(
2906                    context.child("synced"),
2907                    &offsets_partition
2908                )
2909                .await
2910                .unwrap(),
2911                Some(20)
2912            );
2913
2914            // The cap clamps the recovery anchor to the ceiling, below the watermark, so inspection
2915            // rebuilds no offsets. It must leave the acknowledged offsets and their watermark for
2916            // publication to trim. Comparing offsets blob names before and after the open detects
2917            // any blob it removed.
2918            let offsets_blobs = format!("{offsets_partition}-blobs");
2919            let offsets = context.scan(&offsets_blobs).await.unwrap();
2920            let pending = Recovery::<_, u64>::open(context.child("bounded"), cfg.clone(), Some(10))
2921                .await
2922                .unwrap();
2923            assert_eq!(pending.bounds, 0..10);
2924            drop(pending);
2925            assert_eq!(context.scan(&offsets_blobs).await.unwrap(), offsets);
2926            assert_eq!(
2927                fixed::Journal::<_, u64>::persisted_watermark(
2928                    context.child("inspected"),
2929                    &offsets_partition
2930                )
2931                .await
2932                .unwrap(),
2933                Some(20)
2934            );
2935
2936            // Abandoning the unpublished view leaves the full acknowledged history to the next
2937            // ordinary open.
2938            let journal = Journal::<_, u64>::init(context.child("retry"), cfg)
2939                .await
2940                .unwrap();
2941            assert_eq!(journal.bounds(), 0..20);
2942            for item in 0..20 {
2943                assert_eq!(journal.read(item).await.unwrap(), item);
2944            }
2945            drop(journal);
2946
2947            // Publishing the full history keeps the offsets watermark at the acknowledged end.
2948            assert_eq!(
2949                fixed::Journal::<_, u64>::persisted_watermark(
2950                    context.child("reopened"),
2951                    &offsets_partition
2952                )
2953                .await
2954                .unwrap(),
2955                Some(20)
2956            );
2957        });
2958    }
2959
2960    #[test]
2961    fn test_bounded_inspection_preserves_data_above_watermark_until_publication() {
2962        deterministic::Runner::default().start(|context| async move {
2963            // Acknowledge 7 items with a full sync, so the offsets watermark is 7.
2964            let cfg = initialization_cfg("inspection-preserves-data", 5);
2965            let offsets_partition = cfg.offsets_partition();
2966            let data_partition = cfg.data_partition();
2967            let mut journal = Journal::<_, u64>::init(context.child("seed"), cfg.clone())
2968                .await
2969                .unwrap();
2970            for item in 0..7u64 {
2971                (journal, _) = journal.append(&item).await.unwrap();
2972            }
2973            journal = journal.sync().await.unwrap();
2974
2975            // Make 13 more items durable through start_sync, then crash before the next sync.
2976            // The watermark only advances to the previously proven size, so it stays at 7 while
2977            // data and offsets reach 20 across four full sections and an empty tail.
2978            for item in 7..20u64 {
2979                (journal, _) = journal.append(&item).await.unwrap();
2980            }
2981            let (journal, handle) = journal.start_sync().await.unwrap();
2982            handle.await.unwrap();
2983            drop(journal);
2984            assert_eq!(
2985                fixed::Journal::<_, u64>::persisted_watermark(
2986                    context.child("lagged"),
2987                    &offsets_partition
2988                )
2989                .await
2990                .unwrap(),
2991                Some(7)
2992            );
2993
2994            // Record every data blob with its size. Comparing them after the open detects a
2995            // removed blob or a cut that drops a physical page, and the reads after the retry
2996            // catch any lost item.
2997            let mut data = Vec::new();
2998            for name in context.scan(&data_partition).await.unwrap() {
2999                let (_, size) = context.open(&data_partition, &name).await.unwrap();
3000                data.push((name, size));
3001            }
3002            data.sort();
3003            assert_eq!(data.len(), 5);
3004
3005            // The cap lies above the watermark, so inspection rebuilds offsets 7..12 from data.
3006            // Blobs 3 and 4 start past the cap and blob 2 holds items past it. Publication owns
3007            // removing and truncating that data, so an open that never publishes must leave it.
3008            // Inspection also keeps the watermark at 7, so a retry replays the same frames.
3009            let pending = Recovery::<_, u64>::open(context.child("bounded"), cfg.clone(), Some(12))
3010                .await
3011                .unwrap();
3012            assert_eq!(pending.bounds, 0..12);
3013            assert_eq!(pending.discarded, vec![3, 4]);
3014            drop(pending);
3015            let mut inspected = Vec::new();
3016            for name in context.scan(&data_partition).await.unwrap() {
3017                let (_, size) = context.open(&data_partition, &name).await.unwrap();
3018                inspected.push((name, size));
3019            }
3020            inspected.sort();
3021            assert_eq!(inspected, data);
3022            assert_eq!(
3023                fixed::Journal::<_, u64>::persisted_watermark(
3024                    context.child("inspected"),
3025                    &offsets_partition
3026                )
3027                .await
3028                .unwrap(),
3029                Some(7)
3030            );
3031
3032            // Abandoning the unpublished view leaves every durable item to the next ordinary
3033            // open.
3034            let journal = Journal::<_, u64>::init(context.child("retry"), cfg)
3035                .await
3036                .unwrap();
3037            assert_eq!(journal.bounds(), 0..20);
3038            for item in 0..20 {
3039                assert_eq!(journal.read(item).await.unwrap(), item);
3040            }
3041            drop(journal);
3042
3043            // Publishing the full history advances the offsets watermark to the recovered end.
3044            assert_eq!(
3045                fixed::Journal::<_, u64>::persisted_watermark(
3046                    context.child("reopened"),
3047                    &offsets_partition
3048                )
3049                .await
3050                .unwrap(),
3051                Some(20)
3052            );
3053        });
3054    }
3055
3056    #[test]
3057    fn test_recovery_failure_must_not_clear_durable_data() {
3058        deterministic::Runner::default().start(|context| async move {
3059            let config = initialization_cfg("initialization-recovery-clear", 20);
3060            let mut journal =
3061                Journal::<_, u64>::init_at_size(context.child("seed"), config.clone(), 20)
3062                    .await
3063                    .unwrap();
3064            for value in 0..8 {
3065                (journal, _) = journal.append(&value).await.unwrap();
3066            }
3067            let (journal, handle) = journal.start_sync().await.unwrap();
3068            handle.await.unwrap();
3069            drop(journal);
3070
3071            // An empty trailing data blob requires recovery to remove it.
3072            let (blob, _) = context
3073                .open(&config.data_partition(), &2u64.to_be_bytes())
3074                .await
3075                .unwrap();
3076            drop(blob);
3077            *context.storage_fault_config().write() = deterministic::FaultConfig {
3078                remove_rate: Some(probability!(1.0)),
3079                ..Default::default()
3080            };
3081
3082            let result =
3083                Journal::<_, u64>::init(context.child("interrupted"), config.clone()).await;
3084            assert!(matches!(result, Err(Error::Runtime(_))), "{result:?}");
3085            *context.storage_fault_config().write() = deterministic::FaultConfig::default();
3086            let journal = Journal::<_, u64>::init(context.child("retry"), config)
3087                .await
3088                .unwrap();
3089            assert_eq!(
3090                journal.bounds(),
3091                20..28,
3092                "ordinary recovery must retain committed data after retry"
3093            );
3094            for pos in 20..28 {
3095                assert_eq!(journal.read(pos).await.unwrap(), pos - 20);
3096            }
3097        });
3098    }
3099
3100    #[test]
3101    fn test_sync_rejects_missing_acknowledged_data() {
3102        deterministic::Runner::default().start(|context| async move {
3103            // Persist a data prefix and its acknowledged offsets.
3104            let config = initialization_cfg("initialization-missing-anchor", 5);
3105            let mut journal = Journal::<_, u64>::init(context.child("seed"), config.clone())
3106                .await
3107                .unwrap();
3108            for value in 0..20 {
3109                (journal, _) = journal.append(&value).await.unwrap();
3110            }
3111            _ = journal.sync().await.unwrap();
3112
3113            // Remove acknowledged data while leaving its offsets and watermark intact.
3114            for section in 1u64..=4 {
3115                context
3116                    .remove(
3117                        "initialization-missing-anchor_data",
3118                        Some(&section.to_be_bytes()),
3119                    )
3120                    .await
3121                    .unwrap();
3122            }
3123
3124            // The watermark makes this missing data fatal during recovery.
3125            let result = <Journal<_, u64> as authenticated::Backing<_>>::recover(
3126                context.child("sync"),
3127                config,
3128                Some(40),
3129            )
3130            .await;
3131            let error = result.err().expect("missing acknowledged data must fail");
3132            assert!(
3133                matches!(error, Error::Corruption(_)),
3134                "must reject missing data instead of authorizing a reset: {error}"
3135            );
3136        });
3137    }
3138
3139    #[test]
3140    fn test_init_sync_rejects_acknowledged_offsets_without_data() {
3141        deterministic::Runner::default().start(|context| async move {
3142            // Acknowledge 20 items so the offsets watermark exceeds the retained start.
3143            let config = initialization_cfg("initialization-offsets-without-data", 5);
3144            let mut journal = Journal::<_, u64>::init(context.child("seed"), config.clone())
3145                .await
3146                .unwrap();
3147            for value in 0..20u64 {
3148                (journal, _) = journal.append(&value).await.unwrap();
3149            }
3150            drop(journal.sync().await.unwrap());
3151
3152            // Record the offsets blob names to show below that nothing is cleared.
3153            let offsets_blobs = format!("{}-blobs", config.offsets_partition());
3154            let offsets = context.scan(&offsets_blobs).await.unwrap();
3155
3156            // Remove every data blob with no clear staged. No crash cut reaches this state, so
3157            // this covers the corruption policy for external damage.
3158            context
3159                .remove(&config.data_partition(), None)
3160                .await
3161                .unwrap();
3162
3163            // Without data blobs the span would collapse to the offsets start 0. Start 7 lies
3164            // inside the acknowledged range but an empty span at 0 does not cover it, so init_sync
3165            // would clear the acknowledged items. The span must report corruption instead.
3166            let result = authenticated::init_sync::<_, Journal<_, u64>>(
3167                context.child("sync"),
3168                config.clone(),
3169                7..27,
3170            )
3171            .await;
3172            assert!(
3173                matches!(
3174                    &result,
3175                    Err(Error::Corruption(message))
3176                        if message == "retained offsets have no data blobs"
3177                ),
3178                "{result:?}"
3179            );
3180
3181            // Nothing was cleared. The offsets blobs and the acknowledged watermark remain.
3182            assert_eq!(context.scan(&offsets_blobs).await.unwrap(), offsets);
3183            assert_eq!(
3184                fixed::Journal::<_, u64>::persisted_watermark(
3185                    context.child("checkpoint"),
3186                    &config.offsets_partition()
3187                )
3188                .await
3189                .unwrap(),
3190                Some(20)
3191            );
3192        });
3193    }
3194
3195    struct Counted(u64);
3196
3197    impl commonware_codec::Write for Counted {
3198        fn write(&self, buf: &mut impl bytes::BufMut) {
3199            commonware_codec::Write::write(&self.0, buf);
3200        }
3201    }
3202
3203    impl commonware_codec::FixedSize for Counted {
3204        const SIZE: usize = 8;
3205    }
3206
3207    impl commonware_codec::Read for Counted {
3208        type Cfg = Arc<std::sync::atomic::AtomicUsize>;
3209
3210        fn read_cfg(
3211            buf: &mut impl commonware_codec::Buf,
3212            count: &Self::Cfg,
3213        ) -> Result<Self, commonware_codec::Error> {
3214            count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3215            Ok(Self(u64::read_cfg(buf, &())?))
3216        }
3217    }
3218
3219    #[test]
3220    fn test_bounded_empty_prefix_at_maximum_position() {
3221        deterministic::Runner::default().start(|context| async move {
3222            let cfg = Config {
3223                partition: "maximum-empty-prefix".into(),
3224                items_per_section: NZU64!(1),
3225                compression: None,
3226                codec_config: (),
3227                page_cache: CacheRef::new(PAGE_SIZE, NZUsize!(8)),
3228                write_buffer: NZUsize!(1024),
3229                replay_buffer: NZUsize!(1024),
3230            };
3231            let mut journal =
3232                Journal::<_, u64>::init_at_size(context.child("seed"), cfg.clone(), u64::MAX - 1)
3233                    .await
3234                    .unwrap();
3235            (journal, _) = journal.append(&99).await.unwrap();
3236            (journal, _) = journal.prune(u64::MAX).await.unwrap();
3237            _ = journal.sync().await.unwrap();
3238            let journal =
3239                Journal::<_, u64>::init_at_most(context.child("capped"), cfg.clone(), u64::MAX)
3240                    .await
3241                    .unwrap();
3242            assert_eq!(journal.bounds(), u64::MAX..u64::MAX);
3243            drop(journal);
3244            let journal = Journal::<_, u64>::init(context.child("ordinary"), cfg)
3245                .await
3246                .unwrap();
3247            assert_eq!(journal.bounds(), u64::MAX..u64::MAX);
3248        });
3249    }
3250
3251    fn check_bounded_empty_publish_retry(cap: Option<u64>) {
3252        fn config() -> Config<()> {
3253            Config {
3254                partition: "bounded-empty-publish-floor".into(),
3255                items_per_section: NZU64!(10),
3256                compression: None,
3257                codec_config: (),
3258                page_cache: CacheRef::new(NZU16!(16), NZUsize!(16)),
3259                write_buffer: NZUsize!(32),
3260                replay_buffer: NZUsize!(128),
3261            }
3262        }
3263
3264        // First crash: the data blob below the prune target is gone while the offsets still
3265        // start at zero.
3266        let ((), checkpoint) =
3267            deterministic::Runner::default().start_and_recover(|context| async move {
3268                let cfg = config();
3269                let mut journal = Journal::<_, u64>::init(context.child("seed"), cfg.clone())
3270                    .await
3271                    .unwrap();
3272                for value in 0..15u64 {
3273                    (journal, _) = journal.append(&value).await.unwrap();
3274                }
3275                journal = journal.sync().await.unwrap();
3276                journal.0.halt_before_offsets_prune = true;
3277                {
3278                    let prune = journal.prune(10);
3279                    futures::pin_mut!(prune);
3280                    assert!(futures::poll!(prune.as_mut()).is_pending());
3281                }
3282                assert_eq!(
3283                    context.scan(&cfg.data_partition()).await.unwrap(),
3284                    vec![1u64.to_be_bytes().to_vec()]
3285                );
3286            });
3287
3288        // Second crash: a bounded publication of the empty prefix at the prune target stops right
3289        // after deleting the final data blob, before anything else is staged.
3290        let ((), checkpoint) =
3291            deterministic::Runner::from(checkpoint).start_and_recover(|context| async move {
3292                let cfg = config();
3293                let mut recovery =
3294                    Recovery::<_, u64>::open(context.child("recover"), cfg.clone(), Some(10))
3295                        .await
3296                        .unwrap();
3297                assert_eq!(recovery.bounds, 10..10);
3298                assert_eq!(recovery.offsets.bounds(), 0..10);
3299                assert_eq!(recovery.discarded, vec![1]);
3300                assert!(recovery.pending.is_empty());
3301                recovery.offsets = recovery.offsets.truncate(10).await.unwrap();
3302                recovery.halt_after_data_removal = true;
3303                {
3304                    let publish = recovery.publish(10);
3305                    futures::pin_mut!(publish);
3306                    assert!(futures::poll!(publish.as_mut()).is_pending());
3307                }
3308                assert!(
3309                    context
3310                        .scan(&cfg.data_partition())
3311                        .await
3312                        .unwrap()
3313                        .is_empty()
3314                );
3315            });
3316
3317        deterministic::Runner::from(checkpoint).start(move |context| async move {
3318            let cfg = config();
3319            let metadata = Checkpoint::open(context.child("checkpoint"), &cfg.offsets_partition())
3320                .await
3321                .unwrap();
3322            assert_eq!(metadata.watermark(), Some(10));
3323            assert_eq!(metadata.clear_target(), None);
3324            drop(metadata);
3325            let result = match cap {
3326                Some(cap) => {
3327                    Journal::<_, u64>::init_at_most(context.child("retry"), cfg, cap).await
3328                }
3329                None => Journal::<_, u64>::init(context.child("retry"), cfg).await,
3330            };
3331            if cap == Some(9) {
3332                assert!(matches!(result, Err(Error::ItemPruned(9))));
3333            } else {
3334                let journal = result.expect("interrupted publication must remain recoverable");
3335                assert_eq!(journal.bounds(), 10..10);
3336                let (journal, pos) = journal.append(&99).await.unwrap();
3337                assert_eq!(pos, 10);
3338                assert_eq!(journal.read(10).await.unwrap(), 99);
3339            }
3340        });
3341    }
3342
3343    #[test]
3344    fn test_bounded_empty_publish_same_cap() {
3345        check_bounded_empty_publish_retry(Some(10));
3346    }
3347
3348    #[test]
3349    fn test_bounded_empty_publish_larger_cap() {
3350        check_bounded_empty_publish_retry(Some(11));
3351    }
3352
3353    #[test]
3354    fn test_bounded_empty_publish_lower_cap() {
3355        check_bounded_empty_publish_retry(Some(9));
3356    }
3357
3358    #[test]
3359    fn test_bounded_empty_publish_ordinary() {
3360        check_bounded_empty_publish_retry(None);
3361    }
3362
3363    #[test]
3364    fn test_clean_initialization_cap_adds_no_syncs() {
3365        deterministic::Runner::default().start(|context| async move {
3366            let cfg = Config {
3367                partition: "clean-cap-syncs".into(),
3368                items_per_section: NZU64!(5),
3369                compression: None,
3370                codec_config: (),
3371                page_cache: CacheRef::new(PAGE_SIZE, NZUsize!(8)),
3372                write_buffer: NZUsize!(1024),
3373                replay_buffer: NZUsize!(1024),
3374            };
3375            let mut journal = Journal::init(context.child("seed"), cfg.clone())
3376                .await
3377                .unwrap();
3378            for value in 0..13u64 {
3379                (journal, _) = journal.append(&value).await.unwrap();
3380            }
3381            _ = journal.sync().await.unwrap();
3382            let mut baseline = None;
3383            for cap in [None, Some(13), Some(u64::MAX)] {
3384                let syncs = PendingSyncs::default();
3385                syncs.unblock();
3386                let recovery_context = DelayedSyncContext {
3387                    inner: context.child("recover"),
3388                    pending: syncs.clone(),
3389                };
3390                let journal = match cap {
3391                    Some(cap) => {
3392                        Journal::<_, u64>::init_at_most(recovery_context, cfg.clone(), cap).await
3393                    }
3394                    None => Journal::<_, u64>::init(recovery_context, cfg.clone()).await,
3395                }
3396                .unwrap();
3397                assert_eq!(journal.bounds(), 0..13);
3398                assert_eq!(syncs.starts(), *baseline.get_or_insert(syncs.starts()));
3399                drop(journal);
3400            }
3401        });
3402    }
3403
3404    #[test]
3405    fn test_selected_prefix_reuses_known_offsets() {
3406        for start in [0, 7] {
3407            for watermark in [start, 10, 13] {
3408                deterministic::Runner::default().start(|context| async move {
3409                    let count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3410                    let cfg = Config {
3411                        partition: "selected-offset".into(),
3412                        items_per_section: NZU64!(5),
3413                        compression: None,
3414                        codec_config: count.clone(),
3415                        page_cache: CacheRef::new(PAGE_SIZE, NZUsize!(8)),
3416                        write_buffer: NZUsize!(1024),
3417                        replay_buffer: NZUsize!(1024),
3418                    };
3419                    let mut journal =
3420                        Journal::init_at_size(context.child("seed"), cfg.clone(), start)
3421                            .await
3422                            .unwrap();
3423                    for value in start..13 {
3424                        (journal, _) = journal.append(&Counted(value)).await.unwrap();
3425                    }
3426                    let journal = journal.sync().await.unwrap();
3427                    drop(
3428                        journal
3429                            .test_set_offsets_recovery_watermark(watermark)
3430                            .await
3431                            .unwrap(),
3432                    );
3433                    let pending =
3434                        Recovery::<_, Counted>::open(context.child("recover"), cfg, Some(12))
3435                            .await
3436                            .unwrap();
3437                    count.store(0, std::sync::atomic::Ordering::Relaxed);
3438                    // Every selectable end comes from a blob boundary, an acknowledged offset,
3439                    // or the inspection scan. Resolving it must not decode frames again.
3440                    for size in start..=12 {
3441                        let first = (size / 5 * 5).max(start);
3442                        assert_eq!(
3443                            pending.terminal_offset(size).await.unwrap(),
3444                            (size - first) * 9
3445                        );
3446                    }
3447                    assert_eq!(count.load(std::sync::atomic::Ordering::Relaxed), 0);
3448                    for value in start..12 {
3449                        assert_eq!(pending.read(value).await.unwrap().0, value);
3450                    }
3451                    assert_eq!(
3452                        count.load(std::sync::atomic::Ordering::Relaxed),
3453                        (12 - start) as usize
3454                    );
3455                    let journal = Journal(Box::new(pending.publish(7).await.unwrap()));
3456                    assert_eq!(journal.bounds(), start..7);
3457                    if start < 7 {
3458                        assert_eq!(journal.read(6).await.unwrap().0, 6);
3459                    }
3460                });
3461            }
3462        }
3463    }
3464
3465    #[test]
3466    fn test_initialization_decodes_each_recovery_frame_once() {
3467        for (cap, watermark, expected) in [
3468            (None, 13, 3),
3469            (Some(13), 13, 3),
3470            (Some(u64::MAX), 13, 3),
3471            (Some(12), 13, 2),
3472            (None, 10, 3),
3473        ] {
3474            deterministic::Runner::default().start(|context| async move {
3475                let count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3476                let cfg = Config {
3477                    partition: "decode-once".into(),
3478                    items_per_section: NZU64!(5),
3479                    compression: None,
3480                    codec_config: count.clone(),
3481                    page_cache: CacheRef::new(PAGE_SIZE, NZUsize!(8)),
3482                    write_buffer: NZUsize!(1024),
3483                    replay_buffer: NZUsize!(1024),
3484                };
3485                let mut journal = Journal::init(context.child("seed"), cfg.clone())
3486                    .await
3487                    .unwrap();
3488                for value in 0..13 {
3489                    (journal, _) = journal.append(&Counted(value)).await.unwrap();
3490                }
3491                let journal = journal.sync().await.unwrap();
3492                drop(
3493                    journal
3494                        .test_set_offsets_recovery_watermark(watermark)
3495                        .await
3496                        .unwrap(),
3497                );
3498                count.store(0, std::sync::atomic::Ordering::Relaxed);
3499                let mut journal = match cap {
3500                    Some(cap) => {
3501                        Journal::<_, Counted>::init_at_most(context.child("recover"), cfg, cap)
3502                            .await
3503                    }
3504                    None => Journal::<_, Counted>::init(context.child("recover"), cfg).await,
3505                }
3506                .unwrap();
3507                assert_eq!(count.load(std::sync::atomic::Ordering::Relaxed), expected);
3508                let size = cap.unwrap_or(u64::MAX).min(13);
3509                assert_eq!(journal.bounds(), 0..size);
3510                for value in 0..size {
3511                    assert_eq!(journal.read(value).await.unwrap().0, value);
3512                }
3513                (journal, _) = journal.append(&Counted(99)).await.unwrap();
3514                assert_eq!(journal.read(size).await.unwrap().0, 99);
3515            });
3516        }
3517    }
3518
3519    #[test]
3520    fn test_offset_rebuild_with_partial_final_page() {
3521        const PAGE_SIZE: usize = 4096;
3522        const WRITE_BUFFER: usize = 131072;
3523        const SECTIONS: u64 = 16;
3524        const CACHE_PAGES: usize = 4;
3525
3526        fn config(items_per_section: u64) -> Config<()> {
3527            Config {
3528                partition: "offset-rebuild-buffers".into(),
3529                items_per_section: NonZeroU64::new(items_per_section).unwrap(),
3530                compression: None,
3531                codec_config: (),
3532                page_cache: CacheRef::new(NZU16!(PAGE_SIZE as u16), NZUsize!(CACHE_PAGES)),
3533                write_buffer: NZUsize!(WRITE_BUFFER),
3534                replay_buffer: NZUsize!(PAGE_SIZE),
3535            }
3536        }
3537
3538        // Exercise both page-aligned sections and sections with a final partial page.
3539        for items_per_section in [8192, 8193] {
3540            let size = SECTIONS * items_per_section;
3541
3542            let ((), checkpoint) =
3543                deterministic::Runner::default().start_and_recover(move |context| async move {
3544                    let mut journal =
3545                        Journal::<_, u64>::init(context.child("seed"), config(items_per_section))
3546                            .await
3547                            .unwrap();
3548                    for value in 0..size {
3549                        (journal, _) = journal.append(&value).await.unwrap();
3550                    }
3551                    let journal = journal.commit().await.unwrap();
3552                    drop(journal);
3553                });
3554
3555            deterministic::Runner::from(checkpoint).start(move |context| async move {
3556                let pending = Recovery::<_, u64>::open(
3557                    context.child("recover"),
3558                    config(items_per_section),
3559                    None,
3560                )
3561                .await
3562                .unwrap();
3563                assert_eq!(pending.bounds, 0..size);
3564                assert_eq!(pending.offsets.recovery_watermark(), 0);
3565
3566                let journal = Journal(Box::new(pending.publish(size).await.unwrap()));
3567                for section in 0..SECTIONS {
3568                    for pos in [
3569                        section * items_per_section,
3570                        (section + 1) * items_per_section - 1,
3571                    ] {
3572                        assert_eq!(journal.read(pos).await.unwrap(), pos);
3573                    }
3574                }
3575            });
3576        }
3577    }
3578
3579    #[test]
3580    fn test_interrupted_offset_rebuild_preserves_committed_data() {
3581        fn config() -> Config<<Counted as commonware_codec::Read>::Cfg> {
3582            Config {
3583                partition: "interrupted-offset-rebuild".into(),
3584                items_per_section: NZU64!(256),
3585                compression: None,
3586                codec_config: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
3587                page_cache: CacheRef::new(PAGE_SIZE, NZUsize!(2)),
3588                write_buffer: NZUsize!(128),
3589                replay_buffer: NZUsize!(128),
3590            }
3591        }
3592
3593        for start in [0, 7] {
3594            for retention_rate in [probability!(0.0), probability!(1.0)] {
3595                let ((), checkpoint) =
3596                    deterministic::Runner::default().start_and_recover(|context| async move {
3597                        let cfg = config();
3598                        let mut journal =
3599                            Journal::init_at_size(context.child("seed"), cfg.clone(), start)
3600                                .await
3601                                .unwrap();
3602                        for value in start..128 {
3603                            (journal, _) = journal.append(&Counted(value)).await.unwrap();
3604                        }
3605                        drop(journal.commit().await.unwrap());
3606
3607                        let count = cfg.codec_config.clone();
3608                        count.store(0, std::sync::atomic::Ordering::Relaxed);
3609                        *context.storage_fault_config().write() = deterministic::FaultConfig {
3610                            write_rate: Some(deterministic::WriteConfig {
3611                                failure_rate: probability!(1.0),
3612                                retention_rate,
3613                                mode: deterministic::PartialWriteMode::Prefix,
3614                            }),
3615                            ..Default::default()
3616                        };
3617                        assert!(
3618                            Recovery::<_, Counted>::open(context.child("faulted"), cfg, None)
3619                                .await
3620                                .is_err()
3621                        );
3622
3623                        // The fault must interrupt replay after it starts and before it reaches
3624                        // the end, so this exercises a partially rebuilt offsets suffix.
3625                        let decoded = count.load(std::sync::atomic::Ordering::Relaxed);
3626                        assert!(decoded > 0 && decoded < (128 - start) as usize);
3627                    });
3628
3629                deterministic::Runner::from(checkpoint).start(|context| async move {
3630                    *context.storage_fault_config().write() = deterministic::FaultConfig::default();
3631                    let cfg = config();
3632                    let checkpoint =
3633                        Checkpoint::open(context.child("checkpoint"), &cfg.offsets_partition())
3634                            .await
3635                            .unwrap();
3636                    assert_eq!(checkpoint.watermark(), Some(start));
3637                    assert_eq!(checkpoint.clear_target(), None);
3638                    drop(checkpoint);
3639
3640                    let journal = Journal::<_, Counted>::init(context.child("retry"), cfg)
3641                        .await
3642                        .unwrap();
3643                    assert_eq!(journal.bounds(), start..128);
3644                    for value in start..128 {
3645                        assert_eq!(journal.read(value).await.unwrap().0, value);
3646                    }
3647                });
3648            }
3649        }
3650    }
3651
3652    #[test]
3653    fn test_bounded_initialization_retries_after_storage_faults() {
3654        for start in [0, 7] {
3655            for kind in 0..3 {
3656                for numerator in [1, 3, 7, 10] {
3657                    let (succeeded, checkpoint) = deterministic::Runner::default()
3658                        .start_and_recover(|context| async move {
3659                            let cfg = Config {
3660                                partition: "capped-faults".into(),
3661                                items_per_section: NZU64!(5),
3662                                compression: None,
3663                                codec_config: (),
3664                                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(4)),
3665                                write_buffer: NZUsize!(128),
3666                                replay_buffer: NZUsize!(128),
3667                            };
3668                            let mut journal = Journal::<_, u64>::init_at_size(
3669                                context.child("seed"),
3670                                cfg.clone(),
3671                                start,
3672                            )
3673                            .await
3674                            .unwrap();
3675                            for value in start..13 {
3676                                (journal, _) = journal.append(&value).await.unwrap();
3677                            }
3678                            _ = journal.sync().await.unwrap();
3679                            let rate = commonware_utils::Probability::new(numerator, 10).unwrap();
3680                            *context.storage_fault_config().write() = deterministic::FaultConfig {
3681                                sync_rate: (kind == 0).then_some(rate),
3682                                remove_rate: (kind == 1).then_some(rate),
3683                                resize_rate: (kind == 2).then_some(deterministic::ResizeConfig {
3684                                    failure_rate: rate,
3685                                    partial_rate: rate,
3686                                }),
3687                                ..Default::default()
3688                            };
3689
3690                            // The failed initializer is consumed. Inspect only freshly opened
3691                            // storage.
3692                            Journal::<_, u64>::init_at_most(context.child("faulted"), cfg, 7)
3693                                .await
3694                                .is_ok()
3695                        });
3696                    deterministic::Runner::from(checkpoint).start(|context| async move {
3697                        *context.storage_fault_config().write() =
3698                            deterministic::FaultConfig::default();
3699                        let cfg = Config {
3700                            partition: "capped-faults".into(),
3701                            items_per_section: NZU64!(5),
3702                            compression: None,
3703                            codec_config: (),
3704                            page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(4)),
3705                            write_buffer: NZUsize!(128),
3706                            replay_buffer: NZUsize!(128),
3707                        };
3708                        if succeeded {
3709                            let journal =
3710                                Journal::<_, u64>::init(context.child("ordinary"), cfg.clone())
3711                                    .await
3712                                    .unwrap();
3713                            assert_eq!(journal.bounds(), start..7);
3714                            drop(journal);
3715                        }
3716                        let journal =
3717                            Journal::<_, u64>::init_at_most(context.child("retry"), cfg.clone(), 7)
3718                                .await
3719                                .unwrap();
3720                        assert_eq!(journal.bounds(), start..7);
3721                        for value in start..7 {
3722                            assert_eq!(journal.read(value).await.unwrap(), value);
3723                        }
3724                        drop(journal);
3725                        let mut journal =
3726                            Journal::<_, u64>::init(context.child("restart"), cfg.clone())
3727                                .await
3728                                .unwrap();
3729                        assert_eq!(journal.bounds(), start..7);
3730                        (journal, _) = journal.append(&99).await.unwrap();
3731                        _ = journal.sync().await.unwrap();
3732                        let journal = Journal::<_, u64>::init(context.child("verify"), cfg)
3733                            .await
3734                            .unwrap();
3735                        assert_eq!(journal.bounds(), start..8);
3736                        assert_eq!(journal.read(7).await.unwrap(), 99);
3737                    });
3738                }
3739            }
3740        }
3741    }
3742
3743    #[test]
3744    fn test_init_at_most_restart_and_append() {
3745        for compression in [None, Some(3)] {
3746            for start in [0, 7] {
3747                for cap in [0, 1, 5, 7, 8, 10, 13, 20, u64::MAX] {
3748                    deterministic::Runner::default().start(|context| async move {
3749                        let cfg = Config {
3750                            partition: "variable-cap".into(),
3751                            items_per_section: NZU64!(5),
3752                            compression,
3753                            codec_config: (),
3754                            page_cache: CacheRef::new(PAGE_SIZE, NZUsize!(3)),
3755                            write_buffer: NZUsize!(2048),
3756                            replay_buffer: NZUsize!(2048),
3757                        };
3758                        let mut journal = Journal::<_, u64>::init_at_size(
3759                            context.child("storage"),
3760                            cfg.clone(),
3761                            start,
3762                        )
3763                        .await
3764                        .unwrap();
3765                        for value in start..13 {
3766                            (journal, _) = journal.append(&value).await.unwrap();
3767                        }
3768                        journal = journal.sync().await.unwrap();
3769                        drop(journal);
3770
3771                        let opened = Journal::<_, u64>::init_at_most(
3772                            context.child("storage"),
3773                            cfg.clone(),
3774                            cap,
3775                        )
3776                        .await;
3777                        if cap < start {
3778                            assert!(
3779                                matches!(opened, Err(Error::ItemPruned(value)) if value == cap)
3780                            );
3781                            let journal = Journal::<_, u64>::init(context.child("storage"), cfg)
3782                                .await
3783                                .unwrap();
3784                            assert_eq!(journal.bounds(), start..13);
3785                            return;
3786                        }
3787                        let end = cap.min(13);
3788                        let journal = opened.unwrap();
3789                        assert_eq!(journal.bounds(), start..end);
3790                        for value in start..end {
3791                            assert_eq!(journal.read(value).await.unwrap(), value);
3792                        }
3793                        drop(journal);
3794
3795                        // Opening alone must durably establish the selected prefix.
3796                        let mut journal =
3797                            Journal::<_, u64>::init(context.child("storage"), cfg.clone())
3798                                .await
3799                                .unwrap();
3800                        assert_eq!(journal.bounds(), start..end);
3801                        for value in end..end + 8 {
3802                            let pos;
3803                            (journal, pos) = journal.append(&(value + 100)).await.unwrap();
3804                            assert_eq!(pos, value);
3805                        }
3806                        journal = journal.sync().await.unwrap();
3807                        drop(journal);
3808                        let journal = Journal::<_, u64>::init(context.child("storage"), cfg)
3809                            .await
3810                            .unwrap();
3811                        for value in start..end + 8 {
3812                            let expected = if value < end { value } else { value + 100 };
3813                            assert_eq!(journal.read(value).await.unwrap(), expected);
3814                        }
3815                    });
3816                }
3817            }
3818        }
3819    }
3820
3821    #[test]
3822    fn test_init_at_most_skips_discarded_blobs() {
3823        deterministic::Runner::default().start(|context| async move {
3824            let cfg = Config {
3825                partition: "variable-cap".into(),
3826                items_per_section: NZU64!(5),
3827                compression: None,
3828                codec_config: (),
3829                page_cache: CacheRef::new(PAGE_SIZE, NZUsize!(3)),
3830                write_buffer: NZUsize!(2048),
3831                replay_buffer: NZUsize!(2048),
3832            };
3833            let mut journal = Journal::<_, u64>::init(context.child("storage"), cfg.clone())
3834                .await
3835                .unwrap();
3836            for value in 0..23 {
3837                (journal, _) = journal.append(&value).await.unwrap();
3838            }
3839            journal = journal.sync().await.unwrap();
3840            drop(journal);
3841            let (blob, _) = context
3842                .open(&cfg.data_partition(), &3u64.to_be_bytes())
3843                .await
3844                .unwrap();
3845            blob.resize(1).await.unwrap();
3846            blob.sync().await.unwrap();
3847            drop(blob);
3848            let journal = Journal::<_, u64>::init_at_most(context.child("storage"), cfg.clone(), 7)
3849                .await
3850                .unwrap();
3851            assert_eq!(journal.bounds(), 0..7);
3852            drop(journal);
3853            let journal = Journal::<_, u64>::init(context.child("storage"), cfg)
3854                .await
3855                .unwrap();
3856            assert_eq!(journal.bounds(), 0..7);
3857        });
3858    }
3859
3860    #[test_traced]
3861    fn test_replay_read_options_reach_history_and_tip() {
3862        const PAGE_SIZE: NonZeroU16 = NZU16!(40);
3863
3864        let executor = deterministic::Runner::default();
3865        executor.start(|context| async move {
3866            let (context, recordings) = RecordingContext::new(context);
3867            let page_cache = CacheRef::new(PAGE_SIZE, NZUsize!(8));
3868            let cfg = Config {
3869                partition: "variable-replay-read-options".into(),
3870                items_per_section: NZU64!(20),
3871                compression: None,
3872                codec_config: (),
3873                page_cache: page_cache.clone(),
3874                write_buffer: NZUsize!(1024),
3875                replay_buffer: NZUsize!(1024),
3876            };
3877            let mut journal = Journal::<_, u64>::init(context.child("journal"), cfg)
3878                .await
3879                .unwrap();
3880
3881            // The tip section's fifteen 9-byte frames fill three full pages and a partial page.
3882            for item in 0..35 {
3883                (journal, _) = journal.append(&item).await.unwrap();
3884            }
3885            journal = journal.sync().await.unwrap();
3886
3887            // Sealed history receives the replay operation's read policy directly.
3888            page_cache.clear();
3889            {
3890                let stream = replay_items(
3891                    &journal,
3892                    0..journal.bounds().end,
3893                    NZUsize!(32),
3894                    ReadOptions::DONT_CACHE,
3895                )
3896                .await
3897                .unwrap();
3898                recordings.clear();
3899                futures::pin_mut!(stream);
3900                assert_eq!(stream.next().await.unwrap().unwrap(), (0, 0));
3901
3902                let reads = recordings.snapshot().reads;
3903                assert!(!reads.is_empty());
3904                assert!(
3905                    reads
3906                        .iter()
3907                        .all(|options| *options == ReadOptions::DONT_CACHE)
3908                );
3909            }
3910
3911            // A synced writable tip has no unwritten full page, so it is replayed like sealed
3912            // history and its full pages arrive in one read that carries the caller's policy.
3913            for options in [ReadOptions::default(), ReadOptions::DONT_CACHE] {
3914                page_cache.clear();
3915                {
3916                    let stream =
3917                        replay_items(&journal, 20..journal.bounds().end, NZUsize!(2048), options)
3918                            .await
3919                            .unwrap();
3920                    recordings.clear();
3921                    futures::pin_mut!(stream);
3922                    for item in 20..35 {
3923                        assert_eq!(stream.next().await.unwrap().unwrap(), (item, item));
3924                    }
3925                    assert!(stream.next().await.is_none());
3926                    assert_eq!(recordings.snapshot().reads, vec![options]);
3927                }
3928                recordings.clear();
3929                assert_eq!(journal.read(20).await.unwrap(), 20);
3930                assert!(!recordings.snapshot().reads.is_empty());
3931                recordings.clear();
3932                assert_eq!(journal.read(20).await.unwrap(), 20);
3933                assert!(recordings.snapshot().reads.is_empty());
3934            }
3935
3936            // Once the tip buffers an unwritten full page (without filling the section, which
3937            // would seal it), replay goes through the page cache, which faults each missing page
3938            // with DONT_CACHE regardless of the caller's policy.
3939            for item in 35..39 {
3940                (journal, _) = journal.append(&item).await.unwrap();
3941            }
3942            page_cache.clear();
3943            {
3944                let stream = replay_items(
3945                    &journal,
3946                    20..journal.bounds().end,
3947                    NZUsize!(2048),
3948                    ReadOptions::default(),
3949                )
3950                .await
3951                .unwrap();
3952                recordings.clear();
3953                futures::pin_mut!(stream);
3954                assert_eq!(stream.next().await.unwrap().unwrap(), (20, 20));
3955                assert_eq!(
3956                    recordings.snapshot().reads,
3957                    vec![ReadOptions::DONT_CACHE; 3]
3958                );
3959            }
3960
3961            journal.destroy().await.unwrap();
3962        });
3963    }
3964
3965    #[test]
3966    fn test_start_sync_keeps_predecessor_sync() {
3967        let executor = deterministic::Runner::default();
3968        executor.start(|context| async move {
3969            let cfg = Config {
3970                partition: "variable-start-sync-predecessor".into(),
3971                items_per_section: NZU64!(3),
3972                compression: None,
3973                codec_config: (),
3974                page_cache: CacheRef::new(PAGE_SIZE, NZUsize!(2)),
3975                write_buffer: NZUsize!(2048),
3976                replay_buffer: NZUsize!(2048),
3977            };
3978            let mut journal = Box::new(Inner::<_, u64>::init(context, cfg).await.unwrap());
3979
3980            (journal, _) = journal
3981                .append_many(Many::Flat(&[1, 2, 3, 4]))
3982                .await
3983                .unwrap();
3984            assert!(journal.blobs.has_tail_predecessor_sync());
3985
3986            // Handle includes predecessor; slot drains later.
3987            let (journal, handle) = journal.start_sync().await.unwrap();
3988            assert!(journal.blobs.has_tail_predecessor_sync());
3989            handle.await.unwrap();
3990            assert!(journal.blobs.has_tail_predecessor_sync());
3991
3992            journal.destroy().await.unwrap();
3993        });
3994    }
3995
3996    #[test_traced]
3997    fn test_start_sync_stages_offsets_while_data_waits() {
3998        for release_data_first in [false, true] {
3999            deterministic::Runner::default().start(|context| async move {
4000                let pending = PendingSyncs::default();
4001                let cfg = Config {
4002                    partition: "variable-concurrent-staging".into(),
4003                    items_per_section: NZU64!(100),
4004                    compression: None,
4005                    codec_config: (),
4006                    page_cache: CacheRef::new(PAGE_SIZE, NZUsize!(2)),
4007                    write_buffer: NZUsize!(2048),
4008                    replay_buffer: NZUsize!(2048),
4009                };
4010                let mut journal = Box::new(
4011                    Inner::<_, u64>::init(
4012                        DelayedSyncContext {
4013                            inner: context.child("journal"),
4014                            pending: pending.clone(),
4015                        },
4016                        cfg,
4017                    )
4018                    .await
4019                    .unwrap(),
4020                );
4021                (journal, _) = journal.append(&1).await.unwrap();
4022                let (mut journal, first) = journal.start_sync().await.unwrap();
4023                let prior_data = next_pending_sync(&pending);
4024                next_pending_sync(&pending).release.send(Ok(())).unwrap();
4025
4026                // The prior data sync gates the next data flush, while offsets can be written.
4027                (journal, _) = journal.append(&2).await.unwrap();
4028                let starts = pending.starts();
4029                let mut starting = std::pin::pin!(journal.start_sync());
4030                assert!(futures::poll!(starting.as_mut()).is_pending());
4031                assert_eq!(pending.starts(), starts + 1);
4032                prior_data.blocked.await.unwrap();
4033                let offsets = next_pending_sync(&pending);
4034                prior_data.release.send(Ok(())).unwrap();
4035                let (mut journal, handle) = starting.await.unwrap();
4036                first.await.unwrap();
4037                let data = next_pending_sync(&pending);
4038                release_pending_syncs(&pending);
4039
4040                let (first, last) = if release_data_first {
4041                    (data, offsets)
4042                } else {
4043                    (offsets, data)
4044                };
4045                first.release.send(Ok(())).unwrap();
4046                let mut handle = std::pin::pin!(handle);
4047                assert!(futures::poll!(handle.as_mut()).is_pending());
4048                assert_eq!(journal.barrier.boundary(), 1);
4049                last.release.send(Ok(())).unwrap();
4050                handle.await.unwrap();
4051                assert_eq!(journal.barrier.boundary(), 2);
4052                journal.destroy().await.unwrap();
4053            });
4054        }
4055    }
4056
4057    #[test_traced]
4058    fn test_start_sync_advances_offsets_watermark_lagged() {
4059        let executor = deterministic::Runner::default();
4060        executor.start(|context| async move {
4061            let pending = PendingSyncs::default();
4062            let cfg = Config {
4063                partition: "variable-watermark-lagged".into(),
4064                items_per_section: NZU64!(100),
4065                compression: None,
4066                codec_config: (),
4067                page_cache: CacheRef::new(PAGE_SIZE, NZUsize!(2)),
4068                write_buffer: NZUsize!(2048),
4069                replay_buffer: NZUsize!(2048),
4070            };
4071            let make = |pending: PendingSyncs| {
4072                Inner::<_, u64>::init(
4073                    DelayedSyncContext {
4074                        inner: context.child("journal"),
4075                        pending,
4076                    },
4077                    cfg.clone(),
4078                )
4079            };
4080            let mut journal = Box::new(make(pending.clone()).await.unwrap());
4081
4082            // Nothing proven while the first sync is parked: the anchor must not move.
4083            (journal, _) = journal.append_many(Many::Flat(&[1, 2, 3])).await.unwrap();
4084            let (mut journal, h1) = journal.start_sync().await.unwrap();
4085            assert_eq!(journal.offsets.recovery_watermark(), 0);
4086
4087            release_pending_syncs(&pending);
4088            drive_pending_syncs(&pending, h1).await.unwrap();
4089
4090            // The first sync is jointly proven (data and offsets), so the next call advances
4091            // the offsets watermark to its size, one interval behind the tip.
4092            (journal, _) = journal.append(&4).await.unwrap();
4093            let (journal, h2) = journal.start_sync().await.unwrap();
4094            assert_eq!(journal.offsets.recovery_watermark(), 3);
4095            drive_pending_syncs(&pending, h2).await.unwrap();
4096
4097            // The advanced anchor is durable: a reopen replays only past it, then init's align
4098            // catches the anchor up to the recovered size.
4099            pending.unblock();
4100            drop(journal);
4101            let journal = make(pending.clone()).await.unwrap();
4102            assert_eq!(journal.offsets.recovery_watermark(), 4);
4103            assert_eq!(journal.bounds, 0..4);
4104            for i in 0..4u64 {
4105                assert_eq!(journal.reader().read(i).await.unwrap(), i + 1);
4106            }
4107            journal.destroy().await.unwrap();
4108        });
4109    }
4110
4111    #[test_traced]
4112    fn test_start_sync_watermark_requires_joint_proof() {
4113        let executor = deterministic::Runner::default();
4114        executor.start(|context| async move {
4115            let pending = PendingSyncs::default();
4116            let cfg = Config {
4117                partition: "variable-watermark-joint".into(),
4118                items_per_section: NZU64!(3),
4119                compression: None,
4120                codec_config: (),
4121                page_cache: CacheRef::new(PAGE_SIZE, NZUsize!(2)),
4122                write_buffer: NZUsize!(2048),
4123                replay_buffer: NZUsize!(2048),
4124            };
4125            let mut journal = Box::new(
4126                Inner::<_, u64>::init(
4127                    DelayedSyncContext {
4128                        inner: context.child("journal"),
4129                        pending: pending.clone(),
4130                    },
4131                    cfg,
4132                )
4133                .await
4134                .unwrap(),
4135            );
4136
4137            // Filling the first section seals it in the offsets journal and then in the data
4138            // blobs, starting each sealed blob's sync. The next start_sync syncs both fresh tails.
4139            (journal, _) = journal.append_many(Many::Flat(&[1, 2, 3])).await.unwrap();
4140            let (mut journal, h1) = journal.start_sync().await.unwrap();
4141
4142            // Fail the sealed data blob's sync and let every other sync land.
4143            let offsets_sealed = next_pending_sync(&pending);
4144            let data_sealed = next_pending_sync(&pending);
4145            offsets_sealed.release.send(Ok(())).unwrap();
4146            data_sealed
4147                .release
4148                .send(Err(commonware_runtime::Error::Io(
4149                    std::io::Error::other("injected sync failure").into(),
4150                )))
4151                .unwrap();
4152            release_pending_syncs(&pending);
4153            assert!(h1.await.is_err());
4154
4155            // Offsets durability alone proves nothing. The next call succeeds because both tail
4156            // syncs landed, but it must not advance the anchor, which would point recovery past
4157            // the surviving data.
4158            assert_eq!(journal.barrier.boundary(), 0);
4159            let (journal, h2) = journal.start_sync().await.unwrap();
4160            assert_eq!(journal.offsets.recovery_watermark(), 0);
4161            assert!(h2.await.is_err());
4162        });
4163    }
4164
4165    #[test]
4166    fn test_truncate_truncates_durable_size() {
4167        let executor = deterministic::Runner::default();
4168        executor.start(|context| async move {
4169            let pending = PendingSyncs::default();
4170            let cfg = Config {
4171                partition: "variable-rewind-truncate".into(),
4172                items_per_section: NZU64!(100),
4173                compression: None,
4174                codec_config: (),
4175                page_cache: CacheRef::new(PAGE_SIZE, NZUsize!(2)),
4176                write_buffer: NZUsize!(2048),
4177                replay_buffer: NZUsize!(2048),
4178            };
4179            let mut journal = Box::new(
4180                Inner::<_, u64>::init(
4181                    DelayedSyncContext {
4182                        inner: context.child("journal"),
4183                        pending: pending.clone(),
4184                    },
4185                    cfg,
4186                )
4187                .await
4188                .unwrap(),
4189            );
4190
4191            (journal, _) = journal.append_many(Many::Flat(&[1, 2, 3])).await.unwrap();
4192            let journal = drive_pending_syncs(&pending, journal.sync()).await.unwrap();
4193            assert_eq!(journal.offsets.recovery_watermark(), 3);
4194
4195            // Truncate discards the joint proof for position 2. Re-append it, then fail the data
4196            // sync while the offsets sync lands. The boundary must not trust the stale proof.
4197            let mut journal = drive_pending_syncs(&pending, journal.test_truncate(2))
4198                .await
4199                .unwrap();
4200            (journal, _) = journal.append(&9).await.unwrap();
4201            let (mut journal, h1) = journal.start_sync().await.unwrap();
4202            let data = next_pending_sync(&pending);
4203            release_pending_syncs(&pending);
4204            data.release
4205                .send(Err(commonware_runtime::Error::Io(
4206                    std::io::Error::other("injected sync failure").into(),
4207                )))
4208                .unwrap();
4209            assert!(h1.await.is_err());
4210
4211            // The boundary stays at the truncate point, and the next call returns the retained
4212            // failure.
4213            assert_eq!(journal.barrier.boundary(), 2);
4214            assert_eq!(journal.offsets.recovery_watermark(), 2);
4215            assert!(journal.start_sync().await.is_err());
4216        });
4217    }
4218
4219    #[test]
4220    fn test_start_sync_watermark_advance_deferred_failure() {
4221        let executor = deterministic::Runner::default();
4222        executor.start(|context| async move {
4223            let pending = PendingSyncs::default();
4224            let cfg = Config {
4225                partition: "variable-watermark-deferred".into(),
4226                items_per_section: NZU64!(100),
4227                compression: None,
4228                codec_config: (),
4229                page_cache: CacheRef::new(PAGE_SIZE, NZUsize!(2)),
4230                write_buffer: NZUsize!(2048),
4231                replay_buffer: NZUsize!(2048),
4232            };
4233            let mut journal = Box::new(
4234                Inner::<_, u64>::init(
4235                    DelayedSyncContext {
4236                        inner: context.child("journal"),
4237                        pending: pending.clone(),
4238                    },
4239                    cfg,
4240                )
4241                .await
4242                .unwrap(),
4243            );
4244
4245            (journal, _) = journal.append_many(Many::Flat(&[1, 2, 3])).await.unwrap();
4246            let (journal, h1) = journal.start_sync().await.unwrap();
4247            release_pending_syncs(&pending);
4248            h1.await.unwrap();
4249
4250            // Only the advance's offsets-checkpoint fsync is in flight: its failure surfaces
4251            // on the journal handle even though the data is durable.
4252            let (mut journal, h2) = journal.start_sync().await.unwrap();
4253            fail_pending_syncs(&pending);
4254            assert!(h2.await.is_err());
4255
4256            // The checkpoint store retained the failure: commit (which does not write the
4257            // checkpoint) still succeeds, and the next operation that does fails.
4258            (journal, _) = journal.append(&4).await.unwrap();
4259            let journal = drive_pending_syncs(&pending, journal.commit())
4260                .await
4261                .unwrap();
4262            assert!(drive_pending_syncs(&pending, journal.sync()).await.is_err());
4263        });
4264    }
4265
4266    /// A failed flush inside `start_sync` returns an error.
4267    #[test_traced]
4268    fn test_variable_start_sync_flush_failure_returns_err() {
4269        let executor = deterministic::Runner::default();
4270        executor.start(|context| async move {
4271            let cfg = Config {
4272                partition: "variable-start-sync-flush-failure".into(),
4273                items_per_section: NZU64!(3),
4274                compression: None,
4275                codec_config: (),
4276                page_cache: CacheRef::new(PAGE_SIZE, NZUsize!(2)),
4277                write_buffer: NZUsize!(2048),
4278                replay_buffer: NZUsize!(2048),
4279            };
4280            let mut journal = Box::new(
4281                Inner::<_, u64>::init(context.child("journal"), cfg)
4282                    .await
4283                    .unwrap(),
4284            );
4285
4286            // Buffer an item so start_sync must flush it.
4287            (journal, _) = journal.append(&0).await.unwrap();
4288
4289            // The flush inside start_sync fails.
4290            *context.storage_fault_config().write() = deterministic::FaultConfig {
4291                write_rate: Some(deterministic::WriteConfig {
4292                    failure_rate: probability!(1.0),
4293                    retention_rate: probability!(0.0),
4294                    mode: deterministic::PartialWriteMode::Prefix,
4295                }),
4296                ..Default::default()
4297            };
4298            assert!(matches!(journal.start_sync().await, Err(Error::Runtime(_))));
4299        });
4300    }
4301
4302    /// A failed data tail sync whose handle was dropped surfaces at the next rollover.
4303    #[test_traced]
4304    fn test_variable_dropped_failed_start_sync_surfaces_after_rollover() {
4305        let executor = deterministic::Runner::default();
4306        executor.start(|context| async move {
4307            let pending = PendingSyncs::default();
4308            let cfg = Config {
4309                partition: "variable-dropped-failed-sync".into(),
4310                items_per_section: NZU64!(3),
4311                compression: None,
4312                codec_config: (),
4313                page_cache: CacheRef::new(PAGE_SIZE, NZUsize!(2)),
4314                write_buffer: NZUsize!(2048),
4315                replay_buffer: NZUsize!(2048),
4316            };
4317            let mut journal = Box::new(
4318                Inner::<_, u64>::init(
4319                    DelayedSyncContext {
4320                        inner: context.child("journal"),
4321                        pending: pending.clone(),
4322                    },
4323                    cfg,
4324                )
4325                .await
4326                .unwrap(),
4327            );
4328
4329            // Buffer an item and drop the started sync's handle unobserved. Fail the data tail
4330            // sync and let the offsets sync land.
4331            (journal, _) = journal.append(&0).await.unwrap();
4332            let (journal, handle) = journal.start_sync().await.unwrap();
4333            drop(handle);
4334            next_pending_sync(&pending)
4335                .release
4336                .send(Err(commonware_runtime::Error::Io(
4337                    std::io::Error::other("injected sync failure").into(),
4338                )))
4339                .unwrap();
4340            release_pending_syncs(&pending);
4341
4342            // Appending through the blob boundary must surface the failure.
4343            assert!(matches!(
4344                journal.append_many(Many::Flat(&[1, 2, 3])).await,
4345                Err(Error::Runtime(_))
4346            ));
4347        });
4348    }
4349
4350    /// A failed offsets sync whose handle was dropped is reported by the next start_sync.
4351    #[test_traced]
4352    fn test_variable_dropped_failed_offsets_sync_fails_next_start_sync() {
4353        let executor = deterministic::Runner::default();
4354        executor.start(|context| async move {
4355            let pending = PendingSyncs::default();
4356            let cfg = Config {
4357                partition: "variable-dropped-failed-offsets-sync".into(),
4358                items_per_section: NZU64!(3),
4359                compression: None,
4360                codec_config: (),
4361                page_cache: CacheRef::new(PAGE_SIZE, NZUsize!(2)),
4362                write_buffer: NZUsize!(2048),
4363                replay_buffer: NZUsize!(2048),
4364            };
4365            let mut journal = Box::new(
4366                Inner::<_, u64>::init(
4367                    DelayedSyncContext {
4368                        inner: context.child("journal"),
4369                        pending: pending.clone(),
4370                    },
4371                    cfg,
4372                )
4373                .await
4374                .unwrap(),
4375            );
4376
4377            // Buffer an item and drop the started sync's handle unobserved. Let the data tail
4378            // sync land and fail the offsets sync.
4379            (journal, _) = journal.append(&0).await.unwrap();
4380            let (journal, handle) = journal.start_sync().await.unwrap();
4381            drop(handle);
4382            next_pending_sync(&pending).release.send(Ok(())).unwrap();
4383            next_pending_sync(&pending)
4384                .release
4385                .send(Err(commonware_runtime::Error::Io(
4386                    std::io::Error::other("injected sync failure").into(),
4387                )))
4388                .unwrap();
4389            release_pending_syncs(&pending);
4390
4391            // The data sync landed, so commit succeeds.
4392            let journal = journal.commit().await.unwrap();
4393
4394            // The next start_sync observes the failed offsets sync before starting new syncs.
4395            assert!(matches!(journal.start_sync().await, Err(Error::Runtime(_))));
4396        });
4397    }
4398
4399    /// Extract a metric counter's value from encoded metrics output.
4400    fn counter(buffer: &str, name: &str) -> u64 {
4401        buffer
4402            .lines()
4403            .find(|l| l.contains(name) && !l.starts_with('#'))
4404            .and_then(|l| l.split_whitespace().last())
4405            .and_then(|v| v.parse().ok())
4406            .expect("counter missing")
4407    }
4408
4409    #[test_traced]
4410    fn test_variable_init_syncs_adopted_data_before_offsets_watermark_advance() {
4411        let executor = deterministic::Runner::default();
4412        executor.start(|context| async move {
4413            let cfg = Config {
4414                partition: "init-adopted-variable".into(),
4415                items_per_section: NZU64!(10),
4416                compression: None,
4417                codec_config: (),
4418                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
4419                write_buffer: NZUsize!(1024),
4420                replay_buffer: NZUsize!(1024),
4421            };
4422
4423            let mut journal =
4424                Journal::<_, FixedBytes<32>>::init(context.child("first"), cfg.clone())
4425                    .await
4426                    .unwrap();
4427            (journal, _) = journal.append(&FixedBytes::new([1; 32])).await.unwrap();
4428            (journal, _) = journal.append(&FixedBytes::new([2; 32])).await.unwrap();
4429            let journal = journal.sync().await.unwrap();
4430            // Simulate the state left by a crash after item 2's data became visible to recovery,
4431            // but before the offsets journal's recovery watermark advanced past item 1.
4432            let journal = journal
4433                .test_set_offsets_recovery_watermark(1)
4434                .await
4435                .unwrap();
4436            drop(journal);
4437
4438            // Regression: init used to rebuild and sync offsets through item 2 without first
4439            // syncing the adopted data range they point at. A sync fault scoped only to the data
4440            // partition would therefore be missed. With the fix, init must sync data before the
4441            // rebuilt offsets become durable, so this reopen fails.
4442            let data_partition = format!("{}{}", cfg.partition, DATA_SUFFIX);
4443            let context = commonware_runtime::mocks::SyncFaultContext {
4444                inner: context,
4445                fail_partition: data_partition,
4446            };
4447            assert!(
4448                Journal::<_, FixedBytes<32>>::init(context.child("second"), cfg.clone())
4449                    .await
4450                    .is_err(),
4451                "init must sync adopted data before advancing rebuilt offsets"
4452            );
4453        });
4454    }
4455
4456    #[test_traced]
4457    fn test_variable_append_many_compressed() {
4458        let executor = deterministic::Runner::default();
4459        executor.start(|context| async move {
4460            let cfg = Config {
4461                partition: "append-many-compressed".into(),
4462                items_per_section: NZU64!(3),
4463                compression: Some(1),
4464                codec_config: (),
4465                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
4466                write_buffer: NZUsize!(1024),
4467                replay_buffer: NZUsize!(1024),
4468            };
4469            let mut journal = Journal::<_, FixedBytes<32>>::init(context.child("journal"), cfg)
4470                .await
4471                .unwrap();
4472            let items = [
4473                FixedBytes::new([0; 32]),
4474                FixedBytes::new([1; 32]),
4475                FixedBytes::new([2; 32]),
4476                FixedBytes::new([3; 32]),
4477                FixedBytes::new([4; 32]),
4478            ];
4479
4480            let last;
4481            (journal, last) = journal.append_many(Many::Flat(&items)).await.unwrap();
4482            assert_eq!(last, 4);
4483            for (pos, item) in items.iter().enumerate() {
4484                assert_eq!(journal.read(pos as u64).await.unwrap(), *item);
4485            }
4486
4487            journal.destroy().await.unwrap();
4488        });
4489    }
4490
4491    #[test_traced]
4492    fn test_variable_prepared_compressed_capacity() {
4493        let executor = deterministic::Runner::default();
4494        executor.start(|mut context| async move {
4495            let cfg = Config {
4496                partition: "prepared-compressed-capacity".into(),
4497                items_per_section: NZU64!(1024),
4498                compression: Some(3),
4499                codec_config: (..).into(),
4500                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
4501                write_buffer: NZUsize!(1024),
4502                replay_buffer: NZUsize!(1024),
4503            };
4504            let journal = Journal::<_, Bytes>::init(context.child("journal"), cfg)
4505                .await
4506                .unwrap();
4507
4508            // A highly compressible large record, small records whose buffer grows
4509            // geometrically, and small records followed by a large one.
4510            let large = Bytes::from(vec![0xAB; 1 << 20]);
4511            let small: Vec<_> = (0..4096)
4512                .map(|_| {
4513                    let mut record = vec![0; 64];
4514                    context.fill_bytes(&mut record);
4515                    Bytes::from(record)
4516                })
4517                .collect();
4518            let mixed: Vec<_> = small[..64].iter().cloned().chain([large.clone()]).collect();
4519
4520            // Immediate appends retain capacity even when a deferred batch would be compacted.
4521            let immediate = journal
4522                .0
4523                .prepare_append::<false>(Many::Flat(std::slice::from_ref(&large)))
4524                .unwrap();
4525            let len = immediate.encoded.len();
4526            let spare = immediate.encoded.capacity() - len;
4527            assert!(spare > PREPARED_SPARE_LIMIT.max(len.saturating_mul(3)));
4528
4529            // Compaction must not change the frames.
4530            for batch in [vec![large], small, mixed] {
4531                let prepared = journal.prepare_append(Many::Flat(&batch)).unwrap();
4532                let mut expected = Vec::new();
4533                let mut starts = Vec::new();
4534                for record in &batch {
4535                    starts.push(expected.len());
4536                    let mut frame = Vec::new();
4537                    encode_frame_into(Some(3), record, &mut frame).unwrap();
4538                    expected.extend_from_slice(&frame);
4539                }
4540                assert_eq!(prepared.encoded, expected);
4541                assert_eq!(prepared.item_starts, starts);
4542
4543                let len = prepared.encoded.len();
4544                let spare = prepared.encoded.capacity() - len;
4545                assert!(
4546                    spare <= PREPARED_SPARE_LIMIT.max(len.saturating_mul(3)),
4547                    "{spare} unused bytes for {len} encoded bytes"
4548                );
4549            }
4550
4551            journal.destroy().await.unwrap();
4552        });
4553    }
4554
4555    #[test_traced]
4556    fn test_variable_compressed_frames_cross_pages() {
4557        let executor = deterministic::Runner::default();
4558        executor.start(|mut context| async move {
4559            let cfg = Config {
4560                partition: "compressed-cross-pages".into(),
4561                items_per_section: NZU64!(3),
4562                compression: Some(3),
4563                codec_config: ((..=4096).into(), ()),
4564                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
4565                write_buffer: NZUsize!(1024),
4566                replay_buffer: NZUsize!(64),
4567            };
4568
4569            // Random items stay larger than a page after compression, so their frames span
4570            // checksummed page boundaries. Repeated items compress to a few bytes.
4571            let items: Vec<Vec<u8>> = (0..10)
4572                .map(|i| {
4573                    let mut item = vec![i as u8; 1500 + i * 97];
4574                    if i % 3 != 2 {
4575                        context.fill_bytes(&mut item);
4576                    }
4577                    item
4578                })
4579                .collect();
4580            let mut journal = Journal::<_, Vec<u8>>::init(context.child("first"), cfg.clone())
4581                .await
4582                .unwrap();
4583            for item in &items {
4584                (journal, _) = journal.append(item).await.unwrap();
4585            }
4586            journal = journal.sync().await.unwrap();
4587            drop(journal);
4588
4589            // Reopen with an empty cache so reads decode frames from the blobs. Sealed sections
4590            // replay pages directly, and the tail replays through the writer.
4591            let cfg = Config {
4592                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
4593                ..cfg
4594            };
4595            let journal = Journal::<_, Vec<u8>>::init(context.child("second"), cfg)
4596                .await
4597                .unwrap();
4598            {
4599                let stream = replay_items(
4600                    &journal,
4601                    0..journal.bounds().end,
4602                    NZUsize!(64),
4603                    ReadOptions::default(),
4604                )
4605                .await
4606                .unwrap();
4607                futures::pin_mut!(stream);
4608                let mut expected = 0u64;
4609                while let Some(result) = stream.next().await {
4610                    let (pos, item) = result.unwrap();
4611                    assert_eq!(pos, expected);
4612                    assert_eq!(item, items[pos as usize]);
4613                    expected += 1;
4614                }
4615                assert_eq!(expected, items.len() as u64);
4616            }
4617            for (pos, item) in items.iter().enumerate() {
4618                assert_eq!(&journal.read(pos as u64).await.unwrap(), item);
4619            }
4620            let positions: Vec<u64> = (0..items.len() as u64).collect();
4621            assert_eq!(journal.read_many(&positions).await.unwrap(), items);
4622            journal.destroy().await.unwrap();
4623        });
4624    }
4625
4626    #[test_traced]
4627    fn test_variable_append_many_exceeding_write_buffer_reopens_across_sections() {
4628        let executor = deterministic::Runner::default();
4629        executor.start(|context| async move {
4630            let cfg = Config {
4631                partition: "append-many-exceeds-buffer".into(),
4632                items_per_section: NZU64!(5),
4633                compression: None,
4634                codec_config: (),
4635                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
4636                write_buffer: NZUsize!(512),
4637                replay_buffer: NZUsize!(512),
4638            };
4639            let items = (0..13)
4640                .map(|i| FixedBytes::new([i as u8; 300]))
4641                .collect::<Vec<_>>();
4642
4643            let mut journal =
4644                Journal::<_, FixedBytes<300>>::init(context.child("first"), cfg.clone())
4645                    .await
4646                    .unwrap();
4647            let appended;
4648            (journal, appended) = journal.append_many(Many::Flat(&items)).await.unwrap();
4649            assert_eq!(appended, 12);
4650            journal.sync().await.unwrap();
4651
4652            let journal = Journal::<_, FixedBytes<300>>::init(context.child("second"), cfg)
4653                .await
4654                .unwrap();
4655            assert_eq!(journal.bounds(), 0..13);
4656            for (pos, item) in items.iter().enumerate() {
4657                assert_eq!(journal.read(pos as u64).await.unwrap(), *item);
4658            }
4659            assert_eq!(
4660                journal.read_many(&[0, 4, 5, 9, 10, 12]).await.unwrap(),
4661                vec![
4662                    items[0].clone(),
4663                    items[4].clone(),
4664                    items[5].clone(),
4665                    items[9].clone(),
4666                    items[10].clone(),
4667                    items[12].clone(),
4668                ]
4669            );
4670
4671            journal.destroy().await.unwrap();
4672        });
4673    }
4674
4675    #[test_traced]
4676    fn test_variable_init_at_max_size_rejected() {
4677        let executor = deterministic::Runner::default();
4678        executor.start(|context| async move {
4679            let cfg = Config {
4680                partition: "init-at-max".into(),
4681                items_per_section: NZU64!(5),
4682                compression: None,
4683                codec_config: (),
4684                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
4685                write_buffer: NZUsize!(1024),
4686                replay_buffer: NZUsize!(1024),
4687            };
4688
4689            // init_at_size rejects a maximal size before opening the offsets checkpoint.
4690            assert!(matches!(
4691                Journal::<_, u64>::init_at_size(context.child("max"), cfg, u64::MAX).await,
4692                Err(Error::SizeOverflow)
4693            ));
4694        });
4695    }
4696
4697    #[test_traced]
4698    fn test_variable_append_size_overflow() {
4699        let executor = deterministic::Runner::default();
4700        executor.start(|context| async move {
4701            let cfg = Config {
4702                partition: "append-size-overflow".into(),
4703                items_per_section: NZU64!(5),
4704                compression: None,
4705                codec_config: (),
4706                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
4707                write_buffer: NZUsize!(1024),
4708                replay_buffer: NZUsize!(1024),
4709            };
4710
4711            // Initialize one item shy of the maximum size.
4712            let mut journal =
4713                Journal::<_, u64>::init_at_size(context.child("near_max"), cfg, u64::MAX - 1)
4714                    .await
4715                    .unwrap();
4716
4717            // The first append fills the last representable position.
4718            let appended;
4719            (journal, appended) = journal.append(&7).await.unwrap();
4720            assert_eq!(appended, u64::MAX - 1);
4721            assert_eq!(journal.size(), u64::MAX);
4722
4723            // The next append would overflow the size; it must return a recoverable error
4724            // rather than panicking.
4725            assert!(matches!(journal.append(&8).await, Err(Error::SizeOverflow)));
4726        });
4727    }
4728
4729    #[test_traced]
4730    fn test_variable_replay_near_max_size() {
4731        let executor = deterministic::Runner::default();
4732        executor.start(|context| async move {
4733            let cfg = Config {
4734                partition: "replay-near-max-size".into(),
4735                items_per_section: NZU64!(10),
4736                compression: None,
4737                codec_config: (),
4738                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
4739                write_buffer: NZUsize!(1024),
4740                replay_buffer: NZUsize!(1024),
4741            };
4742
4743            let mut journal =
4744                Journal::<_, u64>::init_at_size(context.child("near_max"), cfg, u64::MAX - 1)
4745                    .await
4746                    .unwrap();
4747            let appended;
4748            (journal, appended) = journal.append(&7).await.unwrap();
4749            assert_eq!(appended, u64::MAX - 1);
4750
4751            {
4752                let reader;
4753                (journal, reader) = journal.snapshot().await.unwrap();
4754                let stream = replay_items(
4755                    &reader,
4756                    u64::MAX - 1..reader.bounds().end,
4757                    NZUsize!(20),
4758                    ReadOptions::default(),
4759                )
4760                .await
4761                .unwrap();
4762                futures::pin_mut!(stream);
4763                let (pos, item) = stream.next().await.unwrap().unwrap();
4764                assert_eq!(pos, u64::MAX - 1);
4765                assert_eq!(item, 7);
4766                assert!(stream.next().await.is_none());
4767            }
4768
4769            journal.destroy().await.unwrap();
4770        });
4771    }
4772
4773    #[test_traced]
4774    fn test_variable_sync_read_preserves_byte_fields() {
4775        let executor = deterministic::Runner::default();
4776        executor.start(|context| async move {
4777            let cfg = Config {
4778                partition: "sync-read-ownership".into(),
4779                items_per_section: NZU64!(5),
4780                compression: None,
4781                codec_config: ((..).into(), ()),
4782                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(8)),
4783                write_buffer: NZUsize!(1024),
4784                replay_buffer: NZUsize!(1024),
4785            };
4786            let value = vec![View::new(1), View::new(2)];
4787            let mut journal = Journal::<_, Vec<View>>::init(context, cfg).await.unwrap();
4788            (journal, _) = journal.append(&value).await.unwrap();
4789            journal = journal.sync().await.unwrap();
4790            let (journal, reader) = journal.snapshot().await.unwrap();
4791            drop(reader.read(0).await.unwrap());
4792            let offset = reader.offsets.try_read_sync(0).unwrap();
4793            let decoded = reader
4794                .try_read_frame_sync(0, offset, &mut BytesMut::new())
4795                .unwrap();
4796            drop(reader);
4797            journal.destroy().await.unwrap();
4798            assert_eq!(decoded.len(), 2);
4799            for (field, expected) in decoded.iter().zip([1u64, 2]) {
4800                assert_eq!(field.bytes.as_ref(), &expected.to_be_bytes());
4801                field.assert_shared();
4802            }
4803        });
4804    }
4805
4806    #[test_traced]
4807    fn test_variable_read_many_buffer_len() {
4808        assert_eq!(read_buffer_len([]), Some(0));
4809        assert_eq!(read_buffer_len([0, 302, 0, 302]), Some(604));
4810        assert_eq!(
4811            read_buffer_len([MAX_ALLOCATION_BYTES - 1, 1]),
4812            Some(MAX_ALLOCATION_BYTES)
4813        );
4814        assert_eq!(read_buffer_len([MAX_ALLOCATION_BYTES, 1]), None);
4815        assert_eq!(
4816            read_buffer_len([MAX_ALLOCATION_BYTES, MAX_ALLOCATION_BYTES, 2]),
4817            None
4818        );
4819    }
4820
4821    #[test_traced]
4822    fn test_variable_read_many_preserves_byte_fields() {
4823        deterministic::Runner::default().start(|context| async move {
4824            let page_cache = CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(8));
4825            let cfg = Config {
4826                partition: "read-many-ownership".into(),
4827                items_per_section: NZU64!(5),
4828                compression: None,
4829                codec_config: ((..).into(), ()),
4830                page_cache: page_cache.clone(),
4831                write_buffer: NZUsize!(1024),
4832                replay_buffer: NZUsize!(1024),
4833            };
4834            let values: Vec<Vec<View>> = (0..6)
4835                .map(|position| {
4836                    (0..40)
4837                        .map(|field| View::new(position * 40 + field))
4838                        .collect()
4839                })
4840                .collect();
4841            let mut frame = Vec::new();
4842            encode_frame_into(None, &values[0], &mut frame).unwrap();
4843            let frame_len = frame.len();
4844            let mut journal = Journal::<_, Vec<View>>::init(context, cfg).await.unwrap();
4845            for value in &values {
4846                (journal, _) = journal.append(value).await.unwrap();
4847            }
4848            journal = journal.sync().await.unwrap();
4849            let (journal, reader) = journal.snapshot().await.unwrap();
4850            let positions = [0, 1, 2];
4851
4852            // These frames occupy sealed pages, so clearing the cache forces the async batch.
4853            page_cache.clear();
4854            assert!(
4855                reader
4856                    .try_read_many_sync(&positions)
4857                    .iter()
4858                    .all(Option::is_none)
4859            );
4860            let cold = reader.read_many(&positions).await.unwrap();
4861            let probed: Vec<_> = reader
4862                .try_read_many_sync(&positions)
4863                .into_iter()
4864                .map(|item| item.expect("cached frame is served"))
4865                .collect();
4866            let warm = reader.read_many(&positions).await.unwrap();
4867            drop(reader);
4868            journal.destroy().await.unwrap();
4869
4870            // Every frame has a successor in its blob, so all three cold items decode from one
4871            // batch buffer, and the cached items from one cache read. In both cases adjacent
4872            // items sit back to back in that buffer.
4873            for (decoded, shared) in [(cold, 3), (probed, 3), (warm, 3)] {
4874                assert_eq!(decoded.len(), positions.len());
4875                for (item, expected) in decoded.iter().zip(&values) {
4876                    assert_eq!(item.len(), expected.len());
4877                    for (field, expected) in item.iter().zip(expected) {
4878                        assert_eq!(field.bytes, expected.bytes);
4879                    }
4880                }
4881                for field in decoded[..shared].iter().flatten() {
4882                    field.assert_shared();
4883                }
4884                for pair in decoded.windows(2) {
4885                    assert_eq!(
4886                        pair[1][0].bytes.as_ptr(),
4887                        pair[0][0].bytes.as_ptr().wrapping_add(frame_len)
4888                    );
4889                }
4890            }
4891        });
4892    }
4893
4894    #[test]
4895    fn test_variable_frame_span_preserves_byte_fields() {
4896        let fields = vec![Bytes::from_static(b"hello"), Bytes::from_static(b"world")];
4897        let mut frame = Vec::new();
4898        encode_frame_into(None, &fields, &mut frame).unwrap();
4899        let source = Bytes::from(frame);
4900        let range = source.as_ptr_range();
4901        let decoded =
4902            decode_frame_span::<Vec<Bytes>>(source, 0, 0, &((..).into(), (..).into()), false)
4903                .unwrap();
4904        assert_eq!(decoded, fields);
4905        assert!(decoded.iter().all(|field| range.contains(&field.as_ptr())));
4906    }
4907
4908    #[test_traced]
4909    fn test_variable_try_read_many_sync_matches_read_many() {
4910        // Cached positions are served synchronously and match the async batched read.
4911        // Positions that fail validation are misses rather than errors.
4912        let executor = deterministic::Runner::default();
4913        executor.start(|context| async move {
4914            let cfg = Config {
4915                partition: "read-many-sync".into(),
4916                items_per_section: NZU64!(5),
4917                compression: None,
4918                codec_config: (),
4919                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(64)),
4920                write_buffer: NZUsize!(1024),
4921                replay_buffer: NZUsize!(1024),
4922            };
4923            let items = (0..13)
4924                .map(|i| FixedBytes::new([i as u8; 300]))
4925                .collect::<Vec<_>>();
4926            let mut journal = Journal::<_, FixedBytes<300>>::init(context.child("j"), cfg)
4927                .await
4928                .unwrap();
4929            (journal, _) = journal.append_many(Many::Flat(&items)).await.unwrap();
4930            journal = journal.sync().await.unwrap();
4931
4932            let positions: Vec<u64> = (0..items.len() as u64).collect();
4933            let reader;
4934            (journal, reader) = journal.snapshot().await.unwrap();
4935            // Warm both the offsets and data page caches, then expect every position to be
4936            // served synchronously.
4937            let expected = reader.read_many(&positions).await.unwrap();
4938            let served = reader.try_read_many_sync(&positions);
4939            assert_eq!(served.len(), positions.len());
4940            for (item, expected) in served.iter().zip(&expected) {
4941                assert_eq!(item.as_ref().expect("cached position is served"), expected);
4942            }
4943
4944            // Blob-final frames need no successor lookup. Invalid positions are misses
4945            // without affecting valid positions in the same offsets blob.
4946            let positions = [0, 4, 6, 9, 11, 12, 13, u64::MAX];
4947            let before = context.encode();
4948            let served = reader.try_read_many_sync(&positions);
4949            assert_eq!(served.len(), positions.len());
4950            for (&position, item) in positions.iter().zip(&served) {
4951                if position < items.len() as u64 {
4952                    assert_eq!(item.as_ref(), Some(&items[position as usize]));
4953                } else {
4954                    assert!(item.is_none());
4955                }
4956            }
4957            let after = context.encode();
4958            assert_eq!(
4959                counter(&after, "offsets_items_read_total")
4960                    - counter(&before, "offsets_items_read_total"),
4961                8
4962            );
4963            assert_eq!(
4964                counter(&after, "j_cache_hits") - counter(&before, "j_cache_hits"),
4965                6
4966            );
4967            drop(served);
4968            drop(reader);
4969
4970            journal.destroy().await.unwrap();
4971        });
4972    }
4973
4974    #[test_traced]
4975    #[should_panic(expected = "positions must be strictly increasing")]
4976    fn test_variable_read_many_rejects_unsorted_positions() {
4977        let executor = deterministic::Runner::default();
4978        executor.start(|context| async move {
4979            let cfg = Config {
4980                partition: "read-many-unsorted".into(),
4981                items_per_section: NZU64!(5),
4982                compression: None,
4983                codec_config: (),
4984                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
4985                write_buffer: NZUsize!(1024),
4986                replay_buffer: NZUsize!(1024),
4987            };
4988            let mut journal = Journal::<_, u64>::init(context.child("j"), cfg)
4989                .await
4990                .unwrap();
4991            for i in 0..5u64 {
4992                (journal, _) = journal.append(&(i * 100)).await.unwrap();
4993            }
4994            journal = journal.sync().await.unwrap();
4995
4996            let (_journal, reader) = journal.snapshot().await.unwrap();
4997            let _ = reader.read_many(&[2, 1]).await;
4998        });
4999    }
5000
5001    #[test_traced]
5002    #[should_panic(expected = "positions must be strictly increasing")]
5003    fn test_variable_read_many_rejects_duplicate_positions() {
5004        // Duplicates are not strictly increasing either.
5005        let executor = deterministic::Runner::default();
5006        executor.start(|context| async move {
5007            let cfg = Config {
5008                partition: "read-many-duplicate".into(),
5009                items_per_section: NZU64!(5),
5010                compression: None,
5011                codec_config: (),
5012                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
5013                write_buffer: NZUsize!(1024),
5014                replay_buffer: NZUsize!(1024),
5015            };
5016            let mut journal = Journal::<_, u64>::init(context.child("j"), cfg)
5017                .await
5018                .unwrap();
5019            for i in 0..5u64 {
5020                (journal, _) = journal.append(&(i * 100)).await.unwrap();
5021            }
5022            journal = journal.sync().await.unwrap();
5023
5024            let (_journal, reader) = journal.snapshot().await.unwrap();
5025            let _ = reader.read_many(&[1, 1]).await;
5026        });
5027    }
5028
5029    #[test_traced]
5030    fn test_variable_probe_then_read_many_matches_read_many() {
5031        // A probe completed by one batched read over its declined positions returns the same
5032        // items as read_many, cold and warm.
5033        let executor = deterministic::Runner::default();
5034        executor.start(|context| async move {
5035            let cfg = Config {
5036                partition: "read-many-probe-complete".into(),
5037                items_per_section: NZU64!(5),
5038                compression: None,
5039                codec_config: (),
5040                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
5041                write_buffer: NZUsize!(1024),
5042                replay_buffer: NZUsize!(1024),
5043            };
5044            let mut journal = Journal::<_, u64>::init(context.child("j"), cfg)
5045                .await
5046                .unwrap();
5047            for i in 0..12u64 {
5048                (journal, _) = journal.append(&(i * 100)).await.unwrap();
5049            }
5050            journal = journal.sync().await.unwrap();
5051
5052            let positions: Vec<u64> = (0..12).collect();
5053            let expected: Vec<u64> = (0..12).map(|i| i * 100).collect();
5054            let reader;
5055            (journal, reader) = journal.snapshot().await.unwrap();
5056            for _ in 0..2 {
5057                let mut served = reader.try_read_many_sync(&positions);
5058                let misses: Vec<u64> = positions
5059                    .iter()
5060                    .zip(&served)
5061                    .filter_map(|(&pos, item)| item.is_none().then_some(pos))
5062                    .collect();
5063                let mut fetched = reader.read_many(&misses).await.unwrap().into_iter();
5064                for item in served.iter_mut().filter(|item| item.is_none()) {
5065                    *item = fetched.next();
5066                }
5067                let completed: Vec<_> = served.into_iter().map(Option::unwrap).collect();
5068                assert_eq!(completed, expected);
5069            }
5070            assert_eq!(reader.read_many(&positions).await.unwrap(), expected);
5071            drop(reader);
5072
5073            journal.destroy().await.unwrap();
5074        });
5075    }
5076
5077    #[test_traced]
5078    fn test_variable_read_many_reuses_probed_offset() {
5079        // read_many's sync pass resolves frame offsets even when the frame itself misses. The
5080        // completion must reuse them instead of consulting the offsets journal again.
5081        let executor = deterministic::Runner::default();
5082        executor.start(|context| async move {
5083            // Sections of 128 make section 0's offsets exactly fill two flushed pages, so a
5084            // section-0 offset lookup can genuinely miss (a sealed blob serves a partial
5085            // trailing page from memory, never the page cache).
5086            let cfg = Config {
5087                partition: "read-many-offset-reuse".into(),
5088                items_per_section: NZU64!(128),
5089                compression: None,
5090                codec_config: (),
5091                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(4)),
5092                write_buffer: NZUsize!(1024),
5093                replay_buffer: NZUsize!(1024),
5094            };
5095            let appended = (0..140)
5096                .map(|i| FixedBytes::new([i as u8; 300]))
5097                .collect::<Vec<_>>();
5098            let mut journal = Journal::<_, FixedBytes<300>>::init(context.child("j"), cfg)
5099                .await
5100                .unwrap();
5101            (journal, _) = journal.append_many(Many::Flat(&appended)).await.unwrap();
5102            journal = journal.sync().await.unwrap();
5103            let reader;
5104            (journal, reader) = journal.snapshot().await.unwrap();
5105
5106            // Position 0's frame page already left the 4-page cache during the writes. Its
5107            // single Small slot evicts each flushed page when the next one arrives. Churn the
5108            // cache with section-1 frames (five data pages), then warm the offsets page shared
5109            // by positions 0..64 without touching position 0's frame page (302-byte frames put
5110            // frame 0 in page 0 and frame 4 in page 2).
5111            let (items, _) = reader.probe_parts(&[0]);
5112            assert!(items[0].is_none());
5113            reader
5114                .read_many(&(128..136).collect::<Vec<u64>>())
5115                .await
5116                .unwrap();
5117            reader.read(4).await.unwrap();
5118
5119            // The sync pass resolves position 0's offset but cannot serve its frame.
5120            let (items, misses) = reader.probe_parts(&[0]);
5121            assert!(items[0].is_none());
5122            assert_eq!(misses[0].offset, Some(0));
5123
5124            // read_many consults the offsets journal only in its sync pass (position 0 and
5125            // its successor, both cache hits): completion reuses the carried offset rather
5126            // than resolving it again.
5127            let before = context.encode();
5128            assert_eq!(
5129                reader.read_many(&[0]).await.unwrap(),
5130                vec![appended[0].clone()]
5131            );
5132            let after = context.encode();
5133            assert_eq!(
5134                counter(&after, "offsets_items_read_total"),
5135                counter(&before, "offsets_items_read_total") + 2
5136            );
5137            drop(reader);
5138
5139            journal.destroy().await.unwrap();
5140        });
5141    }
5142
5143    #[test_traced]
5144    fn test_variable_read_many_reuses_probed_successor() {
5145        // A frame's extent comes from two offsets entries. When the sync pass finds only the
5146        // successor's (its own page is cold while the next page is warm), completion must keep
5147        // it and fetch just the start.
5148        let executor = deterministic::Runner::default();
5149        executor.start(|context| async move {
5150            // Sections of 128 fill two whole offsets pages that the cache can evict, and a
5151            // 512-byte page holds 64 offsets, so positions 63 and 64 sit on different pages.
5152            let page_cache = CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(16));
5153            let cfg = Config {
5154                partition: "read-many-successor-reuse".into(),
5155                items_per_section: NZU64!(128),
5156                compression: None,
5157                codec_config: (),
5158                page_cache: page_cache.clone(),
5159                write_buffer: NZUsize!(1024),
5160                replay_buffer: NZUsize!(1024),
5161            };
5162            let appended = (0..128)
5163                .map(|i| FixedBytes::new([i as u8; 300]))
5164                .collect::<Vec<_>>();
5165            let mut journal = Journal::<_, FixedBytes<300>>::init(context.child("j"), cfg)
5166                .await
5167                .unwrap();
5168            (journal, _) = journal.append_many(Many::Flat(&appended)).await.unwrap();
5169            let reader;
5170            (journal, reader) = journal.snapshot().await.unwrap();
5171
5172            // Warm only the offsets page holding position 64, leaving position 63's offset and
5173            // frame cold.
5174            page_cache.clear();
5175            reader.offsets.read(64).await.unwrap();
5176            assert!(reader.offsets.try_read_sync(63).is_none());
5177            assert!(reader.offsets.try_read_sync(64).is_some());
5178            let (items, misses) = reader.probe_parts(&[63]);
5179            assert!(items[0].is_none());
5180            assert!(misses[0].offset.is_none());
5181            assert!(misses[0].end.is_some());
5182
5183            // The sync pass reads the successor's offset and completion reads only the start.
5184            let before = context.encode();
5185            assert_eq!(
5186                reader.read_many(&[63]).await.unwrap(),
5187                vec![appended[63].clone()]
5188            );
5189            let after = context.encode();
5190            assert_eq!(
5191                counter(&after, "offsets_items_read_total"),
5192                counter(&before, "offsets_items_read_total") + 2
5193            );
5194            drop(reader);
5195
5196            journal.destroy().await.unwrap();
5197        });
5198    }
5199
5200    #[test_traced]
5201    fn test_variable_read_many_cached_frame_with_cold_successor() {
5202        let executor = deterministic::Runner::default();
5203        executor.start(|context| async move {
5204            let (context, recordings) = RecordingContext::new(context);
5205            let page_cache = CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(16));
5206            let cfg = Config {
5207                partition: "read-many-cached-frame-cold-successor".into(),
5208                items_per_section: NZU64!(128),
5209                compression: None,
5210                codec_config: (),
5211                page_cache: page_cache.clone(),
5212                write_buffer: NZUsize!(1024),
5213                replay_buffer: NZUsize!(1024),
5214            };
5215            let appended = (0..128)
5216                .map(|i| FixedBytes::new([i as u8; 300]))
5217                .collect::<Vec<_>>();
5218            let mut journal = Journal::<_, FixedBytes<300>>::init(context.child("j"), cfg)
5219                .await
5220                .unwrap();
5221            (journal, _) = journal.append_many(Many::Flat(&appended)).await.unwrap();
5222            let reader;
5223            (journal, reader) = journal.snapshot().await.unwrap();
5224
5225            // Position 62 spans both data pages needed by frames 62 and 63. Its offset page
5226            // holds entries 0 through 63, so reading it leaves position 64's offset cold.
5227            page_cache.clear();
5228            assert_eq!(reader.read(62).await.unwrap(), appended[62]);
5229            assert!(reader.offsets.try_read_sync(63).is_some());
5230            assert!(reader.offsets.try_read_sync(64).is_none());
5231            assert_eq!(reader.try_read_sync(63), Some(appended[63].clone()));
5232
5233            recordings.clear();
5234            let before = context.encode();
5235            let positions = [62, 63];
5236            let expected = &appended[62..64];
5237            assert_eq!(
5238                reader.try_read_many_sync(&positions),
5239                expected.iter().cloned().map(Some).collect::<Vec<_>>()
5240            );
5241            assert_eq!(reader.read_many(&positions).await.unwrap(), expected);
5242            let after = context.encode();
5243            assert_eq!(
5244                counter(&after, "j_cache_hits") - counter(&before, "j_cache_hits"),
5245                4
5246            );
5247            let recorded = recordings.snapshot();
5248            assert!(recorded.reads.is_empty());
5249            assert!(recorded.batches.is_empty());
5250            assert!(reader.offsets.try_read_sync(64).is_none());
5251            drop(reader);
5252
5253            journal.destroy().await.unwrap();
5254        });
5255    }
5256
5257    #[test_traced]
5258    fn test_variable_read_many_last_frame_uses_blob_size() {
5259        // A blob's last readable frame has no successor offset, so its extent ends at the
5260        // reader's blob size. A snapshot keeps the size it was taken at while the journal grows,
5261        // and a reopen capped at four items ends the blob after frame 3.
5262        let executor = deterministic::Runner::default();
5263        executor.start(|context| async move {
5264            let page_cache = CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(16));
5265            let cfg = Config {
5266                partition: "read-many-last-frame".into(),
5267                items_per_section: NZU64!(128),
5268                compression: None,
5269                codec_config: (),
5270                page_cache: page_cache.clone(),
5271                write_buffer: NZUsize!(1024),
5272                replay_buffer: NZUsize!(1024),
5273            };
5274            let appended = (0..6)
5275                .map(|i| FixedBytes::new([i as u8; 300]))
5276                .collect::<Vec<_>>();
5277            let mut journal = Journal::<_, FixedBytes<300>>::init(context.child("j"), cfg.clone())
5278                .await
5279                .unwrap();
5280            (journal, _) = journal
5281                .append_many(Many::Flat(&appended[..4]))
5282                .await
5283                .unwrap();
5284            let reader;
5285            (journal, reader) = journal.snapshot().await.unwrap();
5286            (journal, _) = journal
5287                .append_many(Many::Flat(&appended[4..]))
5288                .await
5289                .unwrap();
5290
5291            // Frame 3 straddles cold page 1 and the snapshot's in-memory partial page, so its read
5292            // goes through the batched read. Its extent ends at the blob size the snapshot was
5293            // taken at.
5294            page_cache.clear();
5295            assert_eq!(reader.try_read_many_sync(&[2, 3]), vec![None, None]);
5296            assert_eq!(reader.read_many(&[2, 3]).await.unwrap(), appended[2..4]);
5297            assert_eq!(
5298                reader.try_read_many_sync(&[3]),
5299                vec![Some(appended[3].clone())]
5300            );
5301            drop(reader);
5302            journal = journal.sync().await.unwrap();
5303            drop(journal);
5304
5305            // A reopen capped at four items discards frames 4 and 5, so frame 3 again ends at the
5306            // blob's size.
5307            let mut journal =
5308                Journal::<_, FixedBytes<300>>::init_at_most(context.child("j"), cfg, 4)
5309                    .await
5310                    .unwrap();
5311            assert_eq!(journal.bounds(), 0..4);
5312            let reader;
5313            (journal, reader) = journal.snapshot().await.unwrap();
5314            page_cache.clear();
5315            assert_eq!(reader.try_read_many_sync(&[3]), vec![None]);
5316            assert_eq!(reader.read_many(&[3]).await.unwrap(), appended[3..4]);
5317            drop(reader);
5318
5319            journal.destroy().await.unwrap();
5320        });
5321    }
5322
5323    #[test_traced]
5324    fn test_variable_read_many_rejects_inconsistent_offsets() {
5325        // Offsets that run backwards, overlap, or point past the blob must never reach the batched
5326        // read. The sync pass serves nothing from a batch that holds one, and completion reports it
5327        // as `Error::Corruption` once it sees every frame's extent. A successor offset that only
5328        // disagrees with the frame's own length prefix is caught when the frame decodes.
5329        let executor = deterministic::Runner::default();
5330        executor.start(|context| async move {
5331            let page_cache = CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(16));
5332            let cfg = Config {
5333                partition: "read-many-inconsistent".into(),
5334                items_per_section: NZU64!(128),
5335                compression: None,
5336                codec_config: (),
5337                page_cache: page_cache.clone(),
5338                write_buffer: NZUsize!(1024),
5339                replay_buffer: NZUsize!(1024),
5340            };
5341            let appended = (0..4)
5342                .map(|i| FixedBytes::new([i as u8; 300]))
5343                .collect::<Vec<_>>();
5344            let mut journal = Journal::<_, FixedBytes<300>>::init(context.child("j"), cfg)
5345                .await
5346                .unwrap();
5347            (journal, _) = journal.append_many(Many::Flat(&appended)).await.unwrap();
5348            journal = journal.sync().await.unwrap();
5349
5350            // Pair the real data with an offsets journal of the test's choosing. Frames are
5351            // 302 bytes, so the consistent offsets are 0, 302, 604 and 906.
5352            let tampered = |label: &'static str, offsets: &'static [u64]| {
5353                let context = context.child(label);
5354                async move {
5355                    let offsets_cfg = fixed::Config {
5356                        partition: format!("read-many-inconsistent-{label}"),
5357                        items_per_blob: NZU64!(128),
5358                        page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(16)),
5359                        write_buffer: NZUsize!(1024),
5360                        replay_buffer: NZUsize!(1024),
5361                    };
5362                    let mut offsets_journal = fixed::Journal::<_, u64>::init(context, offsets_cfg)
5363                        .await
5364                        .unwrap();
5365                    (offsets_journal, _) = offsets_journal
5366                        .append_many(Many::Flat(offsets))
5367                        .await
5368                        .unwrap();
5369                    let (_, offsets) = offsets_journal.snapshot().await.unwrap();
5370                    offsets
5371                }
5372            };
5373            let reader = |offsets| Reader::<_, FixedBytes<300>> {
5374                data: journal.0.blobs.reader(),
5375                bounds: 0..4,
5376                offsets,
5377                items_per_blob: NZU64!(128),
5378                codec_config: (),
5379                compressed: false,
5380                metrics: journal.0.metrics.clone(),
5381            };
5382
5383            // Frame 1 would end at offset 100, before it starts, and frame 2 would start inside
5384            // frame 0. The sync pass serves none of frames 0 to 2, and completion reports
5385            // corruption. Without frame 1 in the request, completion rejects the overlap alone.
5386            let backwards = reader(tampered("backwards", &[0, 302, 100, 906]).await);
5387            assert_eq!(
5388                backwards.try_read_many_sync(&[0, 1, 2]),
5389                vec![None, None, None]
5390            );
5391            assert!(matches!(
5392                backwards.read_many(&[0, 1, 2]).await,
5393                Err(Error::Corruption(_))
5394            ));
5395            page_cache.clear();
5396            assert!(matches!(
5397                backwards.read_many(&[0, 2]).await,
5398                Err(Error::Corruption(_))
5399            ));
5400
5401            // Frame 2 claims frame 0's bytes. The overlap must be reported whether frame 0 is cold
5402            // or cached, and a cached frame 0 is not served from a batch that holds the overlap.
5403            let aliased = reader(tampered("aliased", &[0, 302, 0, 302]).await);
5404            page_cache.clear();
5405            assert!(matches!(
5406                aliased.read_many(&[0, 2]).await,
5407                Err(Error::Corruption(_))
5408            ));
5409            assert_eq!(aliased.read_many(&[0]).await.unwrap(), appended[0..1]);
5410            assert_eq!(aliased.try_read_many_sync(&[0, 2]), vec![None, None]);
5411            assert!(matches!(
5412                aliased.read_many(&[0, 2]).await,
5413                Err(Error::Corruption(_))
5414            ));
5415
5416            // Frame 2's successor offset lies far past the blob.
5417            let oversized = reader(tampered("oversized", &[0, 302, 604, u64::MAX / 2]).await);
5418            assert_eq!(oversized.try_read_many_sync(&[2]), vec![None]);
5419            assert!(matches!(
5420                oversized.read_many(&[2]).await,
5421                Err(Error::Corruption(_))
5422            ));
5423
5424            // Frame 1's successor offset is one byte short of the frame. Only the frame's own
5425            // length prefix reveals that.
5426            let short = reader(tampered("short", &[0, 302, 603, 906]).await);
5427            assert_eq!(short.try_read_many_sync(&[1]), vec![None]);
5428            assert!(matches!(
5429                short.read_many(&[1]).await,
5430                Err(Error::OffsetDataMismatch {
5431                    section: 0,
5432                    offset: 302,
5433                    expected_len: 301,
5434                    actual_len: 302,
5435                })
5436            ));
5437            drop(backwards);
5438            drop(aliased);
5439            drop(oversized);
5440            drop(short);
5441
5442            journal.destroy().await.unwrap();
5443        });
5444    }
5445
5446    #[test_traced]
5447    fn test_variable_read_many_rejects_overlapping_prefix_frame() {
5448        // A frame whose successor offset is cold is served by its length prefix. Its start must
5449        // still be checked against the earlier frames of its blob, or it could decode another
5450        // item's bytes.
5451        let executor = deterministic::Runner::default();
5452        executor.start(|context| async move {
5453            let page_cache = CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(16));
5454            let cfg = Config {
5455                partition: "read-many-overlapping-prefix".into(),
5456                items_per_section: NZU64!(128),
5457                compression: None,
5458                codec_config: (),
5459                page_cache: page_cache.clone(),
5460                write_buffer: NZUsize!(1024),
5461                replay_buffer: NZUsize!(1024),
5462            };
5463            let appended = (0..128)
5464                .map(|i| FixedBytes::new([i as u8; 300]))
5465                .collect::<Vec<_>>();
5466            let mut journal = Journal::<_, FixedBytes<300>>::init(context.child("j"), cfg)
5467                .await
5468                .unwrap();
5469            (journal, _) = journal.append_many(Many::Flat(&appended)).await.unwrap();
5470            journal = journal.sync().await.unwrap();
5471
5472            // Frames are 302 bytes. Point frame 63 at frame 0's bytes.
5473            let mut offsets: Vec<u64> = (0..128).map(|i| i * 302).collect();
5474            offsets[63] = 0;
5475            let mut offsets_journal = fixed::Journal::<_, u64>::init(
5476                context.child("offsets"),
5477                fixed::Config {
5478                    partition: "read-many-overlapping-prefix-offsets".into(),
5479                    items_per_blob: NZU64!(128),
5480                    page_cache: page_cache.clone(),
5481                    write_buffer: NZUsize!(1024),
5482                    replay_buffer: NZUsize!(1024),
5483                },
5484            )
5485            .await
5486            .unwrap();
5487            (offsets_journal, _) = offsets_journal
5488                .append_many(Many::Flat(&offsets))
5489                .await
5490                .unwrap();
5491            let (_, offsets) = offsets_journal.snapshot().await.unwrap();
5492            let reader = Reader::<_, FixedBytes<300>> {
5493                data: journal.0.blobs.reader(),
5494                bounds: 0..128,
5495                offsets,
5496                items_per_blob: NZU64!(128),
5497                codec_config: (),
5498                compressed: false,
5499                metrics: journal.0.metrics.clone(),
5500            };
5501
5502            // Cache frame 0 and the offsets page holding entries 0 to 63, leaving entry 64 cold,
5503            // so frame 63 has a cached start but no cached end.
5504            page_cache.clear();
5505            assert_eq!(reader.read(0).await.unwrap(), appended[0]);
5506            assert!(reader.offsets.try_read_sync(63).is_some());
5507            assert!(reader.offsets.try_read_sync(64).is_none());
5508
5509            assert_eq!(reader.try_read_many_sync(&[0, 63]), vec![None, None]);
5510            assert!(matches!(
5511                reader.read_many(&[0, 63]).await,
5512                Err(Error::Corruption(_))
5513            ));
5514            drop(reader);
5515
5516            journal.destroy().await.unwrap();
5517        });
5518    }
5519
5520    #[test_traced]
5521    fn test_variable_read_many_batches_cold_misses() {
5522        // Cold frames in one data blob, its last readable frame included, must be read with one
5523        // batched blob read of the pages they need and no single-page reads.
5524        let executor = deterministic::Runner::default();
5525        executor.start(|context| async move {
5526            let (context, recordings) = RecordingContext::new(context);
5527            let page_cache = CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(16));
5528            let cfg = Config {
5529                partition: "read-many-batches".into(),
5530                items_per_section: NZU64!(5),
5531                compression: None,
5532                codec_config: (),
5533                page_cache: page_cache.clone(),
5534                write_buffer: NZUsize!(1024),
5535                replay_buffer: NZUsize!(1024),
5536            };
5537            let appended = (0..4)
5538                .map(|i| FixedBytes::new([i as u8; 300]))
5539                .collect::<Vec<_>>();
5540            let mut journal = Journal::<_, FixedBytes<300>>::init(context.child("j"), cfg)
5541                .await
5542                .unwrap();
5543            (journal, _) = journal.append_many(Many::Flat(&appended)).await.unwrap();
5544            journal = journal.sync().await.unwrap();
5545            let reader;
5546            (journal, reader) = journal.snapshot().await.unwrap();
5547
5548            // Frame 0 lies in page 0. Frame 3 is the last readable frame and straddles page 1 and
5549            // the in-memory partial page. With both pages cold and the offsets warm, the only blob
5550            // access must be one batched read of those two pages.
5551            page_cache.clear();
5552            reader.offsets.read_many_inner(&[0, 1, 3]).await.unwrap();
5553            recordings.clear();
5554            assert_eq!(
5555                reader.read_many(&[0, 3]).await.unwrap(),
5556                vec![appended[0].clone(), appended[3].clone()]
5557            );
5558            let snapshot = recordings.snapshot();
5559            assert_eq!(snapshot.batches, vec![2]);
5560            assert_eq!(snapshot.reads.len(), 2);
5561            drop(reader);
5562
5563            journal.destroy().await.unwrap();
5564        });
5565    }
5566
5567    #[test_traced]
5568    fn test_variable_read_many_consecutive_after_reopen() {
5569        let executor = deterministic::Runner::default();
5570        executor.start(|context| async move {
5571            let cfg = Config {
5572                partition: "read-many-consecutive-after-reopen".into(),
5573                items_per_section: NZU64!(20),
5574                compression: None,
5575                codec_config: (),
5576                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
5577                write_buffer: NZUsize!(1024),
5578                replay_buffer: NZUsize!(1024),
5579            };
5580
5581            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
5582                .await
5583                .unwrap();
5584            for i in 0..20u64 {
5585                (journal, _) = journal.append(&(i * 100)).await.unwrap();
5586            }
5587            let journal = journal.sync().await.unwrap();
5588            drop(journal);
5589
5590            let cfg = Config {
5591                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
5592                ..cfg
5593            };
5594            let mut journal = Journal::<_, u64>::init(context.child("second"), cfg)
5595                .await
5596                .unwrap();
5597            let reader;
5598            (journal, reader) = journal.snapshot().await.unwrap();
5599            let positions: Vec<u64> = (3..10).collect();
5600            let items = reader.read_many(&positions).await.unwrap();
5601            assert_eq!(items, vec![300, 400, 500, 600, 700, 800, 900]);
5602            drop(reader);
5603
5604            journal.destroy().await.unwrap();
5605        });
5606    }
5607
5608    #[test_traced]
5609    fn test_variable_read_many_scattered_single_runs_across_blobs() {
5610        // Read a batch where cache hits are interleaved with non-consecutive misses spanning
5611        // several blobs. Each blob's misses are fetched in one batched read, and every item
5612        // must land in the correct result slot between the cached items. Every item's payload
5613        // encodes its position, so a wrong frame boundary or a misplaced result fails the value
5614        // assertions.
5615        let executor = deterministic::Runner::default();
5616        executor.start(|context| async move {
5617            let cfg = Config {
5618                partition: "read-many-scattered-runs".into(),
5619                items_per_section: NZU64!(5),
5620                compression: None,
5621                codec_config: (),
5622                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(16)),
5623                write_buffer: NZUsize!(1024),
5624                replay_buffer: NZUsize!(1024),
5625            };
5626
5627            // Each item's frame is 302 bytes, so a 5-item blob spans two full 512-byte pages plus
5628            // a partial page. Full pages are served through the page cache and go cold on reopen,
5629            // which is what lets this test stage misses at all.
5630            let items = (0..30)
5631                .map(|i| FixedBytes::new([i as u8; 300]))
5632                .collect::<Vec<_>>();
5633            let mut journal =
5634                Journal::<_, FixedBytes<300>>::init(context.child("first"), cfg.clone())
5635                    .await
5636                    .unwrap();
5637            for item in &items {
5638                (journal, _) = journal.append(item).await.unwrap();
5639            }
5640            let journal = journal.sync().await.unwrap();
5641            drop(journal);
5642
5643            // Reopen with a fresh page cache so sealed-blob full pages are cold.
5644            let cfg = Config {
5645                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(16)),
5646                ..cfg
5647            };
5648            let mut journal = Journal::<_, FixedBytes<300>>::init(context.child("second"), cfg)
5649                .await
5650                .unwrap();
5651            let reader;
5652            (journal, reader) = journal.snapshot().await.unwrap();
5653
5654            // Warm blobs 1 (positions 5..10) and 3 (positions 15..20) with one read each. The
5655            // faulted pages cover the neighboring positions asserted below.
5656            reader.read(6).await.unwrap();
5657            reader.read(16).await.unwrap();
5658
5659            // Prove the interleave the batch will see: warm-blob positions are sync hits, and the
5660            // scattered positions in blobs 0, 2, and 4 are misses. The hit pass in read_many runs
5661            // before any miss I/O, so this is exactly the hit/miss split the call resolves.
5662            for hit in [5, 6, 15, 17] {
5663                assert!(reader.try_read_sync(hit).is_some(), "position {hit}");
5664            }
5665            let misses = [0, 3, 10, 12, 20, 21, 23];
5666            for miss in misses {
5667                assert!(reader.try_read_sync(miss).is_none(), "position {miss}");
5668            }
5669
5670            // The misses form five single frames and one adjacent pair ([20, 21]) across three
5671            // blobs, with hits interleaved between them.
5672            let positions = [0, 3, 5, 6, 10, 12, 15, 17, 20, 21, 23];
5673            let expected: Vec<_> = positions
5674                .iter()
5675                .map(|&p| items[p as usize].clone())
5676                .collect();
5677            let before = context.encode();
5678            assert_eq!(reader.read_many(&positions).await.unwrap(), expected);
5679
5680            // The batch's hit/miss accounting must match the staged interleave exactly.
5681            let after = context.encode();
5682            assert_eq!(
5683                counter(&after, "second_cache_hits") - counter(&before, "second_cache_hits"),
5684                4
5685            );
5686            assert_eq!(
5687                counter(&after, "second_cache_misses") - counter(&before, "second_cache_misses"),
5688                misses.len() as u64
5689            );
5690
5691            // A second pass serves the now-cached positions through the hit path and must agree.
5692            let before = context.encode();
5693            assert_eq!(reader.read_many(&positions).await.unwrap(), expected);
5694            let after = context.encode();
5695            assert_eq!(
5696                counter(&after, "second_cache_hits") - counter(&before, "second_cache_hits"),
5697                positions.len() as u64
5698            );
5699            assert_eq!(
5700                counter(&after, "second_cache_misses"),
5701                counter(&before, "second_cache_misses")
5702            );
5703            drop(reader);
5704
5705            journal.destroy().await.unwrap();
5706        });
5707    }
5708
5709    #[test_traced]
5710    fn test_variable_read_many_cold_offsets_across_blobs() {
5711        // Completion looks up every start and end the sync pass did not find. Adjacent cold
5712        // positions share a lookup (p's end is p + 1's start), so the list must be deduplicated
5713        // before the offsets journal's batched read. The batch mixes adjacent and scattered
5714        // positions, blob-final frames (127, 255) and the journal's final frame (257) across
5715        // three blobs, with every item's payload encoding its position.
5716        let executor = deterministic::Runner::default();
5717        executor.start(|context| async move {
5718            let page_cache = CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(256));
5719            let cfg = Config {
5720                partition: "read-many-cold-offsets".into(),
5721                items_per_section: NZU64!(128),
5722                compression: None,
5723                codec_config: (),
5724                page_cache: page_cache.clone(),
5725                write_buffer: NZUsize!(1024),
5726                replay_buffer: NZUsize!(1024),
5727            };
5728            let appended = (0..258u64)
5729                .map(|p| {
5730                    let mut bytes = [p as u8; 300];
5731                    bytes[..8].copy_from_slice(&p.to_be_bytes());
5732                    FixedBytes::new(bytes)
5733                })
5734                .collect::<Vec<_>>();
5735            let mut journal = Journal::<_, FixedBytes<300>>::init(context.child("j"), cfg)
5736                .await
5737                .unwrap();
5738            (journal, _) = journal.append_many(Many::Flat(&appended)).await.unwrap();
5739            let reader;
5740            (journal, reader) = journal.snapshot().await.unwrap();
5741            let positions = [0, 1, 3, 62, 63, 64, 65, 126, 127, 128, 129, 255, 256, 257];
5742            let expected: Vec<_> = positions
5743                .iter()
5744                .map(|&p| appended[p as usize].clone())
5745                .collect();
5746
5747            // With the cache cold, only the tail blob's offsets (held in memory) are found.
5748            page_cache.clear();
5749            let (items, misses) = reader.probe_parts(&positions);
5750            assert!(items.iter().all(Option::is_none));
5751            let miss = |p: u64| misses.iter().find(|miss| miss.position == p).unwrap();
5752            assert!(miss(0).offset.is_none() && miss(0).end.is_none());
5753            assert!(miss(255).offset.is_none() && miss(255).end.is_none());
5754            assert!(miss(257).offset.is_some() && miss(257).end.is_none());
5755            assert_eq!(complete(&reader, items, misses).await.unwrap(), expected);
5756
5757            // With only the offsets page for positions 64 to 127 warm, position 63 knows its end
5758            // but not its start, 64 to 126 know both, and 127 knows only its start because it
5759            // ends its blob.
5760            page_cache.clear();
5761            reader.offsets.read(64).await.unwrap();
5762            let (items, misses) = reader.probe_parts(&positions);
5763            assert!(items.iter().all(Option::is_none));
5764            let miss = |p: u64| misses.iter().find(|miss| miss.position == p).unwrap();
5765            assert!(miss(63).offset.is_none() && miss(63).end.is_some());
5766            assert!(miss(64).offset.is_some() && miss(64).end.is_some());
5767            assert!(miss(127).offset.is_some() && miss(127).end.is_none());
5768            assert_eq!(complete(&reader, items, misses).await.unwrap(), expected);
5769            drop(reader);
5770
5771            journal.destroy().await.unwrap();
5772        });
5773    }
5774
5775    /// Test that complete offsets partition loss after pruning is detected as unrecoverable.
5776    ///
5777    /// When the offsets partition is completely lost and the data has been pruned, we cannot
5778    /// rebuild the index with correct position alignment (would require creating placeholder blobs).
5779    /// This is a genuine external failure that should be detected and reported clearly.
5780    #[test_traced]
5781    fn test_variable_offsets_partition_loss_after_prune_unrecoverable() {
5782        let executor = deterministic::Runner::default();
5783        executor.start(|context| async move {
5784            let cfg = Config {
5785                partition: "offsets-loss-after-prune".into(),
5786                items_per_section: NZU64!(10),
5787                compression: None,
5788                codec_config: (),
5789                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
5790                write_buffer: NZUsize!(1024),
5791                replay_buffer: NZUsize!(1024),
5792            };
5793
5794            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
5795                .await
5796                .unwrap();
5797
5798            // Append 40 items across 4 blobs (0-3)
5799            for i in 0..40u64 {
5800                (journal, _) = journal.append(&(i * 100)).await.unwrap();
5801            }
5802
5803            // Prune to position 20 (removes blobs 0-1, keeps blobs 2-3)
5804            let (journal, _) = journal.prune(20).await.unwrap();
5805            let bounds = journal.bounds();
5806            assert_eq!(bounds.start, 20);
5807            assert_eq!(bounds.end, 40);
5808
5809            let journal = journal.sync().await.unwrap();
5810            drop(journal);
5811
5812            // Remove both the offsets data partition and its metadata partition
5813            context
5814                .remove(&format!("{}-blobs", cfg.offsets_partition()), None)
5815                .await
5816                .expect("Failed to remove offsets blobs partition");
5817            context
5818                .remove(&format!("{}-metadata", cfg.offsets_partition()), None)
5819                .await
5820                .expect("Failed to remove offsets metadata partition");
5821
5822            // Without the offsets partition the pruned prefix cannot be reconciled, so init reports
5823            // corruption.
5824            let result = Journal::<_, u64>::init(context.child("second"), cfg.clone()).await;
5825            assert!(matches!(result, Err(Error::Corruption(_))));
5826        });
5827    }
5828
5829    /// Test that init aligns state when data is pruned/lost but offsets survives.
5830    ///
5831    /// This handles both:
5832    /// 1. Crash during prune-all (data pruned, offsets not yet)
5833    /// 2. External data partition loss
5834    ///
5835    /// In both cases, we align by pruning offsets to match.
5836    #[test_traced]
5837    fn test_variable_align_data_offsets_mismatch() {
5838        let executor = deterministic::Runner::default();
5839        executor.start(|context| async move {
5840            let cfg = Config {
5841                partition: "data-loss-test".into(),
5842                items_per_section: NZU64!(10),
5843                compression: None,
5844                codec_config: (),
5845                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
5846                write_buffer: NZUsize!(1024),
5847                replay_buffer: NZUsize!(1024),
5848            };
5849
5850            let mut variable = Journal::<_, u64>::init(context.child("first"), cfg.clone())
5851                .await
5852                .unwrap();
5853
5854            // Append 20 items across 2 blobs
5855            for i in 0..20u64 {
5856                (variable, _) = variable.append(&(i * 100)).await.unwrap();
5857            }
5858
5859            let variable = variable.sync().await.unwrap();
5860            drop(variable);
5861
5862            // Lose the data partition while the offsets still describe every item.
5863            context
5864                .remove(&cfg.data_partition(), None)
5865                .await
5866                .expect("Failed to remove data partition");
5867
5868            // Init aligns the offsets to the missing data, so the size survives but every item is
5869            // pruned.
5870            let mut journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
5871                .await
5872                .expect("Should align offsets to match empty data");
5873
5874            // Size should be preserved
5875            assert_eq!(journal.size(), 20);
5876
5877            // But no items remain (both journals pruned)
5878            assert!(journal.bounds().is_empty());
5879
5880            // All reads should fail with ItemPruned
5881            for i in 0..20 {
5882                assert!(matches!(
5883                    journal.read(i).await,
5884                    Err(crate::journal::Error::ItemPruned(_))
5885                ));
5886            }
5887
5888            // Can append new data starting at position 20
5889            let pos;
5890            (journal, pos) = journal.append(&999).await.unwrap();
5891            assert_eq!(pos, 20);
5892            assert_eq!(journal.read(20).await.unwrap(), 999);
5893
5894            journal.destroy().await.unwrap();
5895        });
5896    }
5897
5898    /// Replay yields exactly the requested positions, spanning blob boundaries, and
5899    /// validates the range against `bounds()`.
5900    #[test_traced]
5901    fn test_variable_replay_range() {
5902        let executor = deterministic::Runner::default();
5903        executor.start(|context| async move {
5904            let cfg = Config {
5905                partition: "replay-range".into(),
5906                items_per_section: NZU64!(10),
5907                compression: None,
5908                codec_config: (),
5909                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
5910                write_buffer: NZUsize!(1024),
5911                replay_buffer: NZUsize!(1024),
5912            };
5913            let mut journal = Journal::<_, u64>::init(context, cfg).await.unwrap();
5914
5915            // Append 40 items across 4 blobs.
5916            for i in 0..40u64 {
5917                (journal, _) = journal.append(&(i * 100)).await.unwrap();
5918            }
5919
5920            // A mid-journal range crossing blob boundaries yields exactly [start, end).
5921            {
5922                let stream = replay_items(&journal, 7..25, NZUsize!(20), ReadOptions::default())
5923                    .await
5924                    .unwrap();
5925                futures::pin_mut!(stream);
5926                for i in 7..25u64 {
5927                    let (pos, item) = stream.next().await.unwrap().unwrap();
5928                    assert_eq!(pos, i);
5929                    assert_eq!(item, i * 100);
5930                }
5931                assert!(stream.next().await.is_none());
5932            }
5933
5934            // An empty range yields an empty stream.
5935            {
5936                let stream = replay_items(&journal, 5..5, NZUsize!(20), ReadOptions::default())
5937                    .await
5938                    .unwrap();
5939                futures::pin_mut!(stream);
5940                assert!(stream.next().await.is_none());
5941            }
5942
5943            // A range past the journal's end is rejected.
5944            let res = replay_items(&journal, 0..41, NZUsize!(20), ReadOptions::default())
5945                .await
5946                .map(|_| ());
5947            assert!(matches!(
5948                res,
5949                Err(crate::journal::Error::ItemOutOfRange(41))
5950            ));
5951
5952            // An inverted range is rejected.
5953            #[allow(clippy::reversed_empty_ranges)]
5954            let res = replay_items(&journal, 10..5, NZUsize!(20), ReadOptions::default())
5955                .await
5956                .map(|_| ());
5957            assert!(matches!(
5958                res,
5959                Err(crate::journal::Error::ItemOutOfRange(10))
5960            ));
5961
5962            journal.destroy().await.unwrap();
5963        });
5964    }
5965
5966    /// Test replay behavior for variable-length items.
5967    #[test_traced]
5968    fn test_variable_replay() {
5969        let executor = deterministic::Runner::default();
5970        executor.start(|context| async move {
5971            let cfg = Config {
5972                partition: "replay".into(),
5973                items_per_section: NZU64!(10),
5974                compression: None,
5975                codec_config: (),
5976                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
5977                write_buffer: NZUsize!(1024),
5978                replay_buffer: NZUsize!(1024),
5979            };
5980
5981            // Initialize journal
5982            let mut journal = Journal::<_, u64>::init(context, cfg).await.unwrap();
5983
5984            // Append 40 items across 4 blobs (0-3)
5985            for i in 0..40u64 {
5986                (journal, _) = journal.append(&(i * 100)).await.unwrap();
5987            }
5988
5989            // Test 1: Full replay
5990            {
5991                let reader;
5992                (journal, reader) = journal.snapshot().await.unwrap();
5993                let stream = replay_items(
5994                    &reader,
5995                    0..reader.bounds().end,
5996                    NZUsize!(20),
5997                    ReadOptions::default(),
5998                )
5999                .await
6000                .unwrap();
6001                futures::pin_mut!(stream);
6002                for i in 0..40u64 {
6003                    let (pos, item) = stream.next().await.unwrap().unwrap();
6004                    assert_eq!(pos, i);
6005                    assert_eq!(item, i * 100);
6006                }
6007                assert!(stream.next().await.is_none());
6008            }
6009
6010            // Test 2: Partial replay from middle of blob
6011            {
6012                let reader;
6013                (journal, reader) = journal.snapshot().await.unwrap();
6014                let stream = replay_items(
6015                    &reader,
6016                    15..reader.bounds().end,
6017                    NZUsize!(20),
6018                    ReadOptions::default(),
6019                )
6020                .await
6021                .unwrap();
6022                futures::pin_mut!(stream);
6023                for i in 15..40u64 {
6024                    let (pos, item) = stream.next().await.unwrap().unwrap();
6025                    assert_eq!(pos, i);
6026                    assert_eq!(item, i * 100);
6027                }
6028                assert!(stream.next().await.is_none());
6029            }
6030
6031            // Test 3: Partial replay from blob boundary
6032            {
6033                let reader;
6034                (journal, reader) = journal.snapshot().await.unwrap();
6035                let stream = replay_items(
6036                    &reader,
6037                    20..reader.bounds().end,
6038                    NZUsize!(20),
6039                    ReadOptions::default(),
6040                )
6041                .await
6042                .unwrap();
6043                futures::pin_mut!(stream);
6044                for i in 20..40u64 {
6045                    let (pos, item) = stream.next().await.unwrap().unwrap();
6046                    assert_eq!(pos, i);
6047                    assert_eq!(item, i * 100);
6048                }
6049                assert!(stream.next().await.is_none());
6050            }
6051
6052            // Test 4: Prune and verify replay from pruned
6053            (journal, _) = journal.prune(20).await.unwrap();
6054            {
6055                let reader;
6056                (journal, reader) = journal.snapshot().await.unwrap();
6057                let res = replay_items(
6058                    &reader,
6059                    0..reader.bounds().end,
6060                    NZUsize!(20),
6061                    ReadOptions::default(),
6062                )
6063                .await;
6064                assert!(matches!(res, Err(crate::journal::Error::ItemPruned(_))));
6065            }
6066            {
6067                let reader;
6068                (journal, reader) = journal.snapshot().await.unwrap();
6069                let res = replay_items(
6070                    &reader,
6071                    19..reader.bounds().end,
6072                    NZUsize!(20),
6073                    ReadOptions::default(),
6074                )
6075                .await;
6076                assert!(matches!(res, Err(crate::journal::Error::ItemPruned(_))));
6077            }
6078
6079            // Test 5: Replay from exactly at pruning boundary after prune
6080            {
6081                let reader;
6082                (journal, reader) = journal.snapshot().await.unwrap();
6083                let stream = replay_items(
6084                    &reader,
6085                    20..reader.bounds().end,
6086                    NZUsize!(20),
6087                    ReadOptions::default(),
6088                )
6089                .await
6090                .unwrap();
6091                futures::pin_mut!(stream);
6092                for i in 20..40u64 {
6093                    let (pos, item) = stream.next().await.unwrap().unwrap();
6094                    assert_eq!(pos, i);
6095                    assert_eq!(item, i * 100);
6096                }
6097                assert!(stream.next().await.is_none());
6098            }
6099
6100            // Test 6: Replay from the end
6101            {
6102                let reader;
6103                (journal, reader) = journal.snapshot().await.unwrap();
6104                let stream = replay_items(
6105                    &reader,
6106                    40..reader.bounds().end,
6107                    NZUsize!(20),
6108                    ReadOptions::default(),
6109                )
6110                .await
6111                .unwrap();
6112                futures::pin_mut!(stream);
6113                assert!(stream.next().await.is_none());
6114            }
6115
6116            // Test 7: Replay beyond the end (should error)
6117            {
6118                let reader;
6119                (journal, reader) = journal.snapshot().await.unwrap();
6120                let res = replay_items(
6121                    &reader,
6122                    41..reader.bounds().end,
6123                    NZUsize!(20),
6124                    ReadOptions::default(),
6125                )
6126                .await;
6127                assert!(matches!(
6128                    res,
6129                    Err(crate::journal::Error::ItemOutOfRange(41))
6130                ));
6131            }
6132
6133            journal.destroy().await.unwrap();
6134        });
6135    }
6136
6137    #[test_traced]
6138    fn test_variable_replay_stops_after_error() {
6139        let executor = deterministic::Runner::default();
6140        executor.start(|context| async move {
6141            let cfg = Config {
6142                partition: "replay-stops-after-error".into(),
6143                items_per_section: NZU64!(10),
6144                compression: None,
6145                codec_config: (),
6146                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
6147                write_buffer: NZUsize!(1024),
6148                replay_buffer: NZUsize!(1024),
6149            };
6150
6151            let mut journal = Journal::<_, u64>::init(context.child("journal"), cfg.clone())
6152                .await
6153                .unwrap();
6154            for i in 0..30u64 {
6155                (journal, _) = journal.append(&(i * 100)).await.unwrap();
6156            }
6157            let journal = journal.sync().await.unwrap();
6158            drop(journal);
6159
6160            let (blob, _) = context
6161                .open(&cfg.data_partition(), &1u64.to_be_bytes())
6162                .await
6163                .unwrap();
6164            blob.write_at(0, vec![0xFF; 1], WriteOptions::SYNC)
6165                .await
6166                .unwrap();
6167
6168            drop(blob);
6169            {
6170                let cache = CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10));
6171                let mut writers = Vec::new();
6172                for blob_index in 0..3u64 {
6173                    let (blob, size) = context
6174                        .open(&cfg.data_partition(), &blob_index.to_be_bytes())
6175                        .await
6176                        .unwrap();
6177                    writers.push(
6178                        Writer::new(blob, size, cfg.write_buffer.get(), cache.clone())
6179                            .await
6180                            .unwrap(),
6181                    );
6182                }
6183
6184                let mut states = Vec::new();
6185                for (blob_index, writer) in writers.iter().enumerate() {
6186                    let blob = blob_index as u64;
6187                    states.push(ReplayState::<_, u64> {
6188                        blob,
6189                        replay: Blob::Writer(writer)
6190                            .replay_from(0, NZUsize!(1024), ReadOptions::default())
6191                            .unwrap(),
6192                        budget: 1024,
6193                        pos: blob * 10,
6194                        end_pos: (blob + 1) * 10,
6195                        offset: 0,
6196                        codec_config: (),
6197                        compressed: false,
6198                        _marker: PhantomData,
6199                    });
6200                }
6201
6202                // The intact first blob is yielded in full, the corrupt second blob ends the
6203                // stream, and the intact third blob is never reached.
6204                let stream = crate::journal::contiguous::replay_stream_from_states(states);
6205                futures::pin_mut!(stream);
6206
6207                let mut next = 0u64;
6208                while next < 10 {
6209                    let batch = stream.next().await.unwrap().unwrap();
6210                    assert_eq!(batch.start, next);
6211                    assert!(!batch.items.is_empty());
6212                    next = batch.start + batch.items.len() as u64;
6213                    for (position, item) in batch {
6214                        assert_eq!(item, position * 100);
6215                    }
6216                }
6217                assert_eq!(next, 10);
6218                assert!(matches!(
6219                    stream.next().await.unwrap(),
6220                    Err(Error::Corruption(_))
6221                ));
6222                assert!(stream.next().await.is_none());
6223            }
6224        });
6225    }
6226
6227    #[test_traced]
6228    fn test_variable_contiguous() {
6229        let executor = deterministic::Runner::default();
6230        executor.start(|context| async move {
6231            run_contiguous_tests(move |test_name: String, idx: usize, cap: Option<u64>| {
6232                let label = test_name.replace('-', "_");
6233                let context = context
6234                    .child("test")
6235                    .with_attribute("name", &label)
6236                    .with_attribute("index", idx);
6237                async move {
6238                    let cfg = Config {
6239                        partition: format!("generic-test-{test_name}"),
6240                        items_per_section: NZU64!(10),
6241                        compression: None,
6242                        codec_config: (),
6243                        page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
6244                        write_buffer: NZUsize!(1024),
6245                        replay_buffer: NZUsize!(1024),
6246                    };
6247                    match cap {
6248                        Some(size) => Journal::<_, u64>::init_at_most(context, cfg, size).await,
6249                        None => Journal::<_, u64>::init(context, cfg).await,
6250                    }
6251                }
6252                .boxed()
6253            })
6254            .await;
6255        });
6256    }
6257
6258    /// A prune that returns true has made every pre-prune item durable: a crash immediately
6259    /// after must recover the full pre-prune size even when every unsynced write is lost.
6260    #[test_traced]
6261    fn test_variable_prune_durability_survives_crash() {
6262        fn cfg() -> Config<()> {
6263            Config {
6264                partition: "variable-prune-durability".into(),
6265                items_per_section: NZU64!(3),
6266                compression: None,
6267                codec_config: (),
6268                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
6269                write_buffer: NZUsize!(1024),
6270                replay_buffer: NZUsize!(1024),
6271            }
6272        }
6273
6274        let executor = deterministic::Runner::default();
6275        let (_, checkpoint) = executor.start_and_recover(|context| async move {
6276            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg())
6277                .await
6278                .unwrap();
6279
6280            // Fill two sections plus an unsynced tail, then prune into section 1. The crash
6281            // drops every write not covered by a completed sync, so the prune's internal
6282            // sync is the only durability point covering these items.
6283            for i in 0..8u64 {
6284                (journal, _) = journal.append(&(i * 100)).await.unwrap();
6285            }
6286            let (journal, pruned) = journal.prune(3).await.unwrap();
6287            assert!(pruned);
6288            drop(journal);
6289        });
6290
6291        deterministic::Runner::from(checkpoint).start(|context| async move {
6292            let journal = Journal::<_, u64>::init(context.child("recover"), cfg())
6293                .await
6294                .unwrap();
6295            assert_eq!(
6296                journal.bounds(),
6297                3..8,
6298                "pruned journal lost acknowledged items"
6299            );
6300            for i in 3..8u64 {
6301                assert_eq!(journal.read(i).await.unwrap(), i * 100);
6302            }
6303            journal.destroy().await.unwrap();
6304        });
6305    }
6306
6307    /// Test multiple sequential prunes with Variable-specific guarantees.
6308    #[test_traced]
6309    fn test_variable_multiple_sequential_prunes() {
6310        let executor = deterministic::Runner::default();
6311        executor.start(|context| async move {
6312            let cfg = Config {
6313                partition: "sequential-prunes".into(),
6314                items_per_section: NZU64!(10),
6315                compression: None,
6316                codec_config: (),
6317                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
6318                write_buffer: NZUsize!(1024),
6319                replay_buffer: NZUsize!(1024),
6320            };
6321
6322            let mut journal = Journal::<_, u64>::init(context, cfg).await.unwrap();
6323
6324            // Append items across 4 blobs: [0-9], [10-19], [20-29], [30-39]
6325            for i in 0..40u64 {
6326                (journal, _) = journal.append(&(i * 100)).await.unwrap();
6327            }
6328
6329            // Initial state: all items accessible
6330            let bounds = journal.bounds();
6331            assert_eq!(bounds.start, 0);
6332            assert_eq!(bounds.end, 40);
6333
6334            // First prune: remove blob 0 (positions 0-9)
6335            let pruned;
6336            (journal, pruned) = journal.prune(10).await.unwrap();
6337            assert!(pruned);
6338
6339            // Variable-specific guarantee: oldest is EXACTLY at blob boundary
6340            assert_eq!(journal.bounds().start, 10);
6341
6342            // Items 0-9 should be pruned, 10+ should be accessible
6343            assert!(matches!(
6344                journal.read(0).await,
6345                Err(crate::journal::Error::ItemPruned(_))
6346            ));
6347            assert_eq!(journal.read(10).await.unwrap(), 1000);
6348            assert_eq!(journal.read(19).await.unwrap(), 1900);
6349
6350            // Second prune: remove blob 1 (positions 10-19)
6351            let pruned;
6352            (journal, pruned) = journal.prune(20).await.unwrap();
6353            assert!(pruned);
6354
6355            // Variable-specific guarantee: oldest is EXACTLY at blob boundary
6356            assert_eq!(journal.bounds().start, 20);
6357
6358            // Items 0-19 should be pruned, 20+ should be accessible
6359            assert!(matches!(
6360                journal.read(10).await,
6361                Err(crate::journal::Error::ItemPruned(_))
6362            ));
6363            assert!(matches!(
6364                journal.read(19).await,
6365                Err(crate::journal::Error::ItemPruned(_))
6366            ));
6367            assert_eq!(journal.read(20).await.unwrap(), 2000);
6368            assert_eq!(journal.read(29).await.unwrap(), 2900);
6369
6370            // Third prune: remove blob 2 (positions 20-29)
6371            let pruned;
6372            (journal, pruned) = journal.prune(30).await.unwrap();
6373            assert!(pruned);
6374
6375            // Variable-specific guarantee: oldest is EXACTLY at blob boundary
6376            assert_eq!(journal.bounds().start, 30);
6377
6378            // Items 0-29 should be pruned, 30+ should be accessible
6379            assert!(matches!(
6380                journal.read(20).await,
6381                Err(crate::journal::Error::ItemPruned(_))
6382            ));
6383            assert!(matches!(
6384                journal.read(29).await,
6385                Err(crate::journal::Error::ItemPruned(_))
6386            ));
6387            assert_eq!(journal.read(30).await.unwrap(), 3000);
6388            assert_eq!(journal.read(39).await.unwrap(), 3900);
6389
6390            // Size should still be 40 (pruning doesn't affect size)
6391            assert_eq!(journal.size(), 40);
6392
6393            journal.destroy().await.unwrap();
6394        });
6395    }
6396
6397    /// Test that pruning all data and re-initializing preserves positions.
6398    #[test_traced]
6399    fn test_variable_prune_all_then_reinit() {
6400        let executor = deterministic::Runner::default();
6401        executor.start(|context| async move {
6402            let cfg = Config {
6403                partition: "prune-all-reinit".into(),
6404                items_per_section: NZU64!(10),
6405                compression: None,
6406                codec_config: (),
6407                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
6408                write_buffer: NZUsize!(1024),
6409                replay_buffer: NZUsize!(1024),
6410            };
6411
6412            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
6413                .await
6414                .unwrap();
6415
6416            // Fill the journal so pruning everything leaves a nonzero size with nothing to read.
6417            for i in 0..100u64 {
6418                (journal, _) = journal.append(&(i * 100)).await.unwrap();
6419            }
6420
6421            let bounds = journal.bounds();
6422            assert_eq!(bounds.end, 100);
6423            assert_eq!(bounds.start, 0);
6424
6425            // Pruning everything keeps the size at 100 and leaves no readable position.
6426            let pruned;
6427            (journal, pruned) = journal.prune(100).await.unwrap();
6428            assert!(pruned);
6429
6430            // All data is pruned, so no items remain
6431            let bounds = journal.bounds();
6432            assert_eq!(bounds.end, 100);
6433            assert!(bounds.is_empty());
6434
6435            // All reads should fail with ItemPruned
6436            for i in 0..100 {
6437                assert!(matches!(
6438                    journal.read(i).await,
6439                    Err(crate::journal::Error::ItemPruned(_))
6440                ));
6441            }
6442
6443            // A reopen must recover the size and the empty bounds without any data blobs.
6444            journal.sync().await.unwrap();
6445            let mut journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
6446                .await
6447                .unwrap();
6448
6449            // Size should be preserved, but no items remain
6450            let bounds = journal.bounds();
6451            assert_eq!(bounds.end, 100);
6452            assert!(bounds.is_empty());
6453
6454            // All reads should still fail
6455            for i in 0..100 {
6456                assert!(matches!(
6457                    journal.read(i).await,
6458                    Err(crate::journal::Error::ItemPruned(_))
6459                ));
6460            }
6461
6462            // The next append continues at position 100, which becomes the only retained item.
6463            (journal, _) = journal.append(&10000).await.unwrap();
6464            let bounds = journal.bounds();
6465            assert_eq!(bounds.end, 101);
6466            // Now we have one item at position 100
6467            assert_eq!(bounds.start, 100);
6468
6469            // Can read the new item
6470            assert_eq!(journal.read(100).await.unwrap(), 10000);
6471
6472            // Old positions still fail
6473            assert!(matches!(
6474                journal.read(99).await,
6475                Err(crate::journal::Error::ItemPruned(_))
6476            ));
6477
6478            journal.destroy().await.unwrap();
6479        });
6480    }
6481
6482    /// Test recovery from crash after data blobs pruned but before offsets journal.
6483    #[test_traced]
6484    fn test_variable_recovery_prune_crash_offsets_behind() {
6485        let executor = deterministic::Runner::default();
6486        executor.start(|context| async move {
6487            let cfg = Config {
6488                partition: "recovery-prune-crash".into(),
6489                items_per_section: NZU64!(10),
6490                compression: None,
6491                codec_config: (),
6492                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
6493                write_buffer: NZUsize!(1024),
6494                replay_buffer: NZUsize!(1024),
6495            };
6496
6497            let mut variable = Journal::<_, u64>::init(context.child("first"), cfg.clone())
6498                .await
6499                .unwrap();
6500
6501            // Append 40 items across 4 blobs to both journals
6502            for i in 0..40u64 {
6503                (variable, _) = variable.append(&(i * 100)).await.unwrap();
6504            }
6505
6506            // Prune to position 10 normally (both data and offsets journals pruned)
6507            let (mut variable, _) = variable.prune(10).await.unwrap();
6508            assert_eq!(variable.bounds().start, 10);
6509
6510            // Persist data pruning to position 20 while offsets still retain positions 10-19.
6511            (variable, _) = variable.test_prune_data(2).await.unwrap();
6512            variable.sync().await.unwrap();
6513
6514            // Reopen to reconcile the offsets with the retained data.
6515            let (recovery_context, recordings) = RecordingContext::new(context.child("second"));
6516            let variable = Journal::<_, u64>::init(recovery_context, cfg.clone())
6517                .await
6518                .unwrap();
6519
6520            // Loading the checkpoint retains encoded mirrors of both metadata blobs, so both
6521            // reads request DONT_CACHE.
6522            let reads = recordings.snapshot().reads;
6523            assert!(reads.len() > 2);
6524            let (metadata_reads, recovery_reads) = reads.split_at(2);
6525            assert_eq!(metadata_reads, [ReadOptions::DONT_CACHE; 2]);
6526
6527            // Data-page validation uses the default options so alignment can reuse those pages
6528            // during recovery.
6529            assert!(
6530                recovery_reads
6531                    .iter()
6532                    .all(|options| *options == ReadOptions::default())
6533            );
6534
6535            // Init should auto-repair: offsets journal pruned to match data blobs
6536            let bounds = variable.bounds();
6537            assert_eq!(bounds.start, 20);
6538            assert_eq!(bounds.end, 40);
6539
6540            // Reads before position 20 should fail (pruned from both journals)
6541            assert!(matches!(
6542                variable.read(10).await,
6543                Err(crate::journal::Error::ItemPruned(_))
6544            ));
6545
6546            // Reads at position 20+ should succeed
6547            assert_eq!(variable.read(20).await.unwrap(), 2000);
6548            assert_eq!(variable.read(39).await.unwrap(), 3900);
6549
6550            variable.destroy().await.unwrap();
6551        });
6552    }
6553
6554    /// A crash after data pruning but before offsets pruning must remain recoverable even when
6555    /// the last durable offsets end is below the new data boundary.
6556    #[test_traced]
6557    fn test_variable_recovery_prune_crash_offsets_end_behind() {
6558        let executor = deterministic::Runner::default();
6559        executor.start(|context| async move {
6560            let cfg = Config {
6561                partition: "recovery-prune-offsets-end-behind".into(),
6562                items_per_section: NZU64!(10),
6563                compression: None,
6564                codec_config: (),
6565                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
6566                write_buffer: NZUsize!(1024),
6567                replay_buffer: NZUsize!(1024),
6568            };
6569
6570            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
6571                .await
6572                .unwrap();
6573
6574            // Persist offsets only through position 7, then append enough unsynced items for a
6575            // prune to advance the data boundary beyond that durable offsets end.
6576            for i in 0..7u64 {
6577                (journal, _) = journal.append(&(i * 100)).await.unwrap();
6578            }
6579            let mut journal = journal.sync().await.unwrap();
6580            for i in 7..12u64 {
6581                (journal, _) = journal.append(&(i * 100)).await.unwrap();
6582            }
6583
6584            // Drop the production prune future while it is parked after the data-blob
6585            // removal, before offsets.prune has made the appended offsets durable: a
6586            // genuine cancellation at that await.
6587            journal.0.halt_before_offsets_prune = true;
6588            {
6589                let fut = journal.prune(10);
6590                futures::pin_mut!(fut);
6591                assert!(
6592                    futures::poll!(fut.as_mut()).is_pending(),
6593                    "prune must park before offsets.prune"
6594                );
6595            }
6596
6597            let journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
6598                .await
6599                .expect("prune crash must leave a recoverable journal");
6600            assert_eq!(journal.bounds(), 10..12);
6601            for i in 10..12u64 {
6602                assert_eq!(journal.read(i).await.unwrap(), i * 100);
6603            }
6604            journal.destroy().await.unwrap();
6605        });
6606    }
6607
6608    /// Test recovery detects corruption when offsets journal pruned ahead of data blobs.
6609    ///
6610    /// Simulates an impossible state (offsets journal pruned more than data blobs) which
6611    /// should never happen due to write ordering. Verifies that init() returns corruption error.
6612    #[test_traced]
6613    fn test_variable_recovery_offsets_ahead_corruption() {
6614        let executor = deterministic::Runner::default();
6615        executor.start(|context| async move {
6616            let cfg = Config {
6617                partition: "recovery-offsets-ahead".into(),
6618                items_per_section: NZU64!(10),
6619                compression: None,
6620                codec_config: (),
6621                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
6622                write_buffer: NZUsize!(1024),
6623                replay_buffer: NZUsize!(1024),
6624            };
6625
6626            let mut variable = Journal::<_, u64>::init(context.child("first"), cfg.clone())
6627                .await
6628                .unwrap();
6629
6630            // Append 40 items across 4 blobs to both journals
6631            for i in 0..40u64 {
6632                (variable, _) = variable.append(&(i * 100)).await.unwrap();
6633            }
6634
6635            // Put offsets ahead of the data floor. Normal pruning cannot produce this state.
6636            let (mut variable, _) = variable.test_prune_offsets(20).await.unwrap();
6637            (variable, _) = variable.test_prune_data(1).await.unwrap();
6638
6639            let variable = variable.sync().await.unwrap();
6640            drop(variable);
6641
6642            // Recovery must reject the contradictory floors.
6643            let result = Journal::<_, u64>::init(context.child("second"), cfg.clone()).await;
6644            assert!(matches!(result, Err(Error::Corruption(_))));
6645        });
6646    }
6647
6648    /// Offsets journal is empty but in a different blob than data. This is an impossible state:
6649    /// both journals are always created in the same blob by init or init_at_size.
6650    #[test_traced]
6651    fn test_variable_recovery_offsets_empty_different_blob_is_corruption() {
6652        let executor = deterministic::Runner::default();
6653        executor.start(|context| async move {
6654            let cfg = Config {
6655                partition: "offsets-empty-diff-blob".into(),
6656                items_per_section: NZU64!(10),
6657                compression: None,
6658                codec_config: (),
6659                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
6660                write_buffer: NZUsize!(1024),
6661                replay_buffer: NZUsize!(1024),
6662            };
6663
6664            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
6665                .await
6666                .unwrap();
6667
6668            for i in 0..15u64 {
6669                (journal, _) = journal.append(&(i * 100)).await.unwrap();
6670            }
6671            let mut journal = journal.sync().await.unwrap();
6672
6673            // Clear offsets to blob 2 (position 20) while data starts at blob 0.
6674            // This puts them in different blobs with offsets empty (bounds 20..20).
6675            journal.0.offsets = journal.0.offsets.clear_to_size(20).await.unwrap();
6676            drop(journal);
6677
6678            let result = Journal::<_, u64>::init(context.child("second"), cfg.clone()).await;
6679            assert!(matches!(result, Err(Error::Corruption(_))));
6680        });
6681    }
6682
6683    /// Offsets journal ends before data oldest position (offsets_bounds.end < data_oldest_pos).
6684    /// This is an impossible/corrupted state.
6685    #[test_traced]
6686    fn test_variable_recovery_offsets_end_behind_data_oldest_is_corruption() {
6687        let executor = deterministic::Runner::default();
6688        executor.start(|context| async move {
6689            let cfg = Config {
6690                partition: "offsets-end-behind-data-oldest".into(),
6691                items_per_section: NZU64!(10),
6692                compression: None,
6693                codec_config: (),
6694                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
6695                write_buffer: NZUsize!(1024),
6696                replay_buffer: NZUsize!(1024),
6697            };
6698
6699            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
6700                .await
6701                .unwrap();
6702
6703            for i in 0..15u64 {
6704                (journal, _) = journal.append(&(i * 100)).await.unwrap();
6705            }
6706            let mut journal = journal.sync().await.unwrap();
6707
6708            // Prune data to blob 1 (position 10), but truncate offsets to 5. The offsets range
6709            // then ends before the oldest retained data position.
6710            (journal, _) = journal.test_prune_data(1).await.unwrap();
6711            let journal = journal.test_truncate_offsets(5).await.unwrap();
6712            drop(journal);
6713
6714            let result = Journal::<_, u64>::init(context.child("second"), cfg.clone()).await;
6715            assert!(matches!(result, Err(Error::Corruption(_))));
6716        });
6717    }
6718
6719    /// A bound equal to the retained start must still reject offsets that end before the oldest
6720    /// data blob, as the unbounded open does.
6721    #[test_traced]
6722    fn test_variable_bounded_recovery_offsets_end_behind_data_oldest_is_corruption() {
6723        let executor = deterministic::Runner::default();
6724        executor.start(|context| async move {
6725            let cfg = Config {
6726                partition: "bounded-offsets-end-behind-data-oldest".into(),
6727                items_per_section: NZU64!(10),
6728                compression: None,
6729                codec_config: (),
6730                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
6731                write_buffer: NZUsize!(1024),
6732                replay_buffer: NZUsize!(1024),
6733            };
6734
6735            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
6736                .await
6737                .unwrap();
6738            for i in 0..15u64 {
6739                (journal, _) = journal.append(&(i * 100)).await.unwrap();
6740            }
6741            let mut journal = journal.sync().await.unwrap();
6742
6743            // Prune data to blob 1 (position 10) but rewind offsets to 5, so the retained start is
6744            // 10 while the offsets end at 5.
6745            (journal, _) = journal.test_prune_data(1).await.unwrap();
6746            let journal = journal.test_truncate_offsets(5).await.unwrap();
6747            drop(journal);
6748
6749            let result = Journal::<_, u64>::init_at_most(context.child("second"), cfg, 10).await;
6750            assert!(matches!(result, Err(Error::Corruption(_))), "{result:?}");
6751        });
6752    }
6753
6754    /// Offsets start is mid-blob ahead of data's blob-aligned start, but in the same
6755    /// blob. This is the valid state left by init_at_size.
6756    #[test_traced]
6757    fn test_variable_recovery_offsets_start_mid_blob_ahead_of_data() {
6758        let executor = deterministic::Runner::default();
6759        executor.start(|context| async move {
6760            let cfg = Config {
6761                partition: "offsets-mid-blob-ahead".into(),
6762                items_per_section: NZU64!(10),
6763                compression: None,
6764                codec_config: (),
6765                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
6766                write_buffer: NZUsize!(1024),
6767                replay_buffer: NZUsize!(1024),
6768            };
6769
6770            // init_at_size(7) creates offsets starting at position 7 (mid-blob 0), while
6771            // data's first blob is blob 0 (position 0). offsets.start > data_oldest_pos
6772            // but same blob.
6773            let mut journal =
6774                Journal::<_, u64>::init_at_size(context.child("first"), cfg.clone(), 7)
6775                    .await
6776                    .unwrap();
6777            for i in 0..5u64 {
6778                (journal, _) = journal.append(&(i * 100)).await.unwrap();
6779            }
6780            journal.sync().await.unwrap();
6781
6782            let journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
6783                .await
6784                .unwrap();
6785            assert_eq!(journal.bounds(), 7..12);
6786            assert_eq!(journal.read(7).await.unwrap(), 0);
6787            assert_eq!(journal.read(11).await.unwrap(), 400);
6788            journal.destroy().await.unwrap();
6789        });
6790    }
6791
6792    /// The offsets recovery watermark is below the offsets pruning boundary. This can happen if
6793    /// prune moved the boundary forward but sync (which advances the watermark) didn't run.
6794    /// Recovery falls back to rebuilding from the offsets start.
6795    #[test_traced]
6796    fn test_variable_recovery_watermark_below_offsets_start() {
6797        let executor = deterministic::Runner::default();
6798        executor.start(|context| async move {
6799            let cfg = Config {
6800                partition: "watermark-below-start".into(),
6801                items_per_section: NZU64!(10),
6802                compression: None,
6803                codec_config: (),
6804                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
6805                write_buffer: NZUsize!(1024),
6806                replay_buffer: NZUsize!(1024),
6807            };
6808
6809            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
6810                .await
6811                .unwrap();
6812            for i in 0..25u64 {
6813                (journal, _) = journal.append(&(i * 100)).await.unwrap();
6814            }
6815            let journal = journal.sync().await.unwrap();
6816
6817            // Prune to blob 1 (position 10), then set watermark below the new start.
6818            let (journal, _) = journal.prune(10).await.unwrap();
6819            let journal = journal
6820                .test_set_offsets_recovery_watermark(5)
6821                .await
6822                .unwrap();
6823            drop(journal);
6824
6825            // Recovery detects stale watermark and rebuilds from offsets start.
6826            let journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
6827                .await
6828                .unwrap();
6829            assert_eq!(journal.bounds(), 10..25);
6830            assert_eq!(journal.read(10).await.unwrap(), 1000);
6831            assert_eq!(journal.read(24).await.unwrap(), 2400);
6832            journal.destroy().await.unwrap();
6833        });
6834    }
6835
6836    /// Test recovery from crash after appending to data blobs but before appending to offsets journal.
6837    #[test_traced]
6838    fn test_variable_recovery_append_crash_offsets_behind() {
6839        let executor = deterministic::Runner::default();
6840        executor.start(|context| async move {
6841            let cfg = Config {
6842                partition: "recovery-append-crash".into(),
6843                items_per_section: NZU64!(10),
6844                compression: None,
6845                codec_config: (),
6846                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
6847                write_buffer: NZUsize!(1024),
6848                replay_buffer: NZUsize!(1024),
6849            };
6850
6851            let mut variable = Journal::<_, u64>::init(context.child("first"), cfg.clone())
6852                .await
6853                .unwrap();
6854
6855            // Append 15 items to both journals (fills blob 0, partial blob 1)
6856            for i in 0..15u64 {
6857                (variable, _) = variable.append(&(i * 100)).await.unwrap();
6858            }
6859
6860            assert_eq!(variable.size(), 15);
6861
6862            // Manually append 5 more items directly to data blobs only
6863            for i in 15..20u64 {
6864                (variable, _, _) = variable.test_append_data(1, i * 100).await.unwrap();
6865            }
6866            // Offsets journal still has only 15 entries
6867
6868            // A reopen rebuilds the missing offsets by replaying the data blobs.
6869            variable.sync().await.unwrap();
6870            let variable = Journal::<_, u64>::init(context.child("second"), cfg.clone())
6871                .await
6872                .unwrap();
6873
6874            // Init should rebuild offsets journal from data blobs replay
6875            let bounds = variable.bounds();
6876            assert_eq!(bounds.end, 20);
6877            assert_eq!(bounds.start, 0);
6878
6879            // All items should be readable from both journals
6880            for i in 0..20u64 {
6881                assert_eq!(variable.read(i).await.unwrap(), i * 100);
6882            }
6883
6884            // Offsets journal should be fully rebuilt to match data blobs
6885            assert_eq!(variable.test_offsets_size(), 20);
6886
6887            variable.destroy().await.unwrap();
6888        });
6889    }
6890
6891    #[test_traced]
6892    fn test_variable_recovery_rejects_overlong_data_blob() {
6893        let executor = deterministic::Runner::default();
6894        executor.start(|context| async move {
6895            let cfg = Config {
6896                partition: "recovery-overlong-data-blob".into(),
6897                items_per_section: NZU64!(10),
6898                compression: None,
6899                codec_config: (),
6900                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
6901                write_buffer: NZUsize!(1024),
6902                replay_buffer: NZUsize!(1024),
6903            };
6904
6905            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
6906                .await
6907                .unwrap();
6908
6909            for i in 0..11u64 {
6910                (journal, _, _) = journal.test_append_data(0, i * 100).await.unwrap();
6911            }
6912            let (blobs, sync) = journal.0.blobs.start_sync().await.unwrap();
6913            journal.0.blobs = blobs;
6914            sync.await.unwrap();
6915            drop(journal);
6916
6917            let result = Journal::<_, u64>::init(context.child("second"), cfg.clone()).await;
6918            assert!(matches!(result, Err(Error::Corruption(_))));
6919        });
6920    }
6921
6922    /// Over-capacity non-newest data blob detected during offset rebuild replay.
6923    /// The preflight check (`items_in_newest`) only validates the newest blob. This test
6924    /// overfills blob 0, adds a valid blob 1, and leaves offsets empty so rebuild_offsets
6925    /// replays from blob 0 and hits the over-capacity branch.
6926    #[test_traced]
6927    fn test_variable_recovery_rejects_over_capacity_non_newest_blob() {
6928        let executor = deterministic::Runner::default();
6929        executor.start(|context| async move {
6930            let cfg = Config {
6931                partition: "recovery-over-capacity-non-newest".into(),
6932                items_per_section: NZU64!(10),
6933                compression: None,
6934                codec_config: (),
6935                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
6936                write_buffer: NZUsize!(1024),
6937                replay_buffer: NZUsize!(1024),
6938            };
6939
6940            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
6941                .await
6942                .unwrap();
6943
6944            // Overfill blob 0 with 11 items (capacity is 10).
6945            for i in 0..11u64 {
6946                (journal, _, _) = journal.test_append_data(0, i * 100).await.unwrap();
6947            }
6948            // Sync blob 0 so the data survives reopen, then add one valid item in blob 1
6949            // (synced on creation) so blob 0 is not the newest.
6950            let (blobs, sync) = journal.0.blobs.start_sync().await.unwrap();
6951            journal.0.blobs = blobs;
6952            sync.await.unwrap();
6953            (journal, _, _) = journal.test_append_data(1, 9999).await.unwrap();
6954            // Offsets is empty, so rebuild replays from blob 0.
6955            drop(journal);
6956
6957            let result = Journal::<_, u64>::init(context.child("second"), cfg.clone()).await;
6958            assert!(matches!(result, Err(Error::Corruption(_))));
6959        });
6960    }
6961
6962    #[test_traced]
6963    fn test_variable_recovery_preserves_rolled_predecessors_without_commit() {
6964        let executor = deterministic::Runner::default();
6965        executor.start(|context| async move {
6966            let cfg = Config::<()> {
6967                partition: "recovery-empty-data-tail".into(),
6968                items_per_section: NZU64!(1),
6969                compression: None,
6970                codec_config: (),
6971                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
6972                write_buffer: NZUsize!(1024),
6973                replay_buffer: NZUsize!(1024),
6974            };
6975            let mut journal = Journal::<_, u64>::init(context.child("journal"), cfg.clone())
6976                .await
6977                .unwrap();
6978
6979            // First persist a prefix, then append across multiple blob boundaries without calling
6980            // commit/sync. Rollover starts predecessor syncs, so the filled predecessor blobs are
6981            // recoverable even though the offsets watermark is not advanced.
6982            let appended;
6983            (journal, appended) = journal.append(&10).await.unwrap();
6984            assert_eq!(appended, 0);
6985            journal = journal.sync().await.unwrap();
6986            let appended;
6987            (journal, appended) = journal.append(&20).await.unwrap();
6988            assert_eq!(appended, 1);
6989            let appended;
6990            (journal, appended) = journal.append(&30).await.unwrap();
6991            assert_eq!(appended, 2);
6992            drop(journal);
6993
6994            let data_partition = cfg.data_partition();
6995            let mut data_blobs = context.scan(&data_partition).await.unwrap();
6996            data_blobs.sort();
6997            assert_eq!(data_blobs.len(), 4);
6998            for name in &data_blobs[..3] {
6999                let (_, size) = context.open(&data_partition, name).await.unwrap();
7000                assert!(size > 0);
7001            }
7002            let (_, size) = context.open(&data_partition, &data_blobs[3]).await.unwrap();
7003            assert_eq!(size, 0);
7004
7005            // Recovery should preserve the filled predecessors plus the empty tail.
7006            let cfg = Config {
7007                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
7008                ..cfg
7009            };
7010            let mut journal = Journal::<_, u64>::init(context.child("recovered"), cfg.clone())
7011                .await
7012                .unwrap();
7013            assert_eq!(journal.bounds(), 0..3);
7014            assert_eq!(journal.read(0).await.unwrap(), 10);
7015            assert_eq!(journal.read(1).await.unwrap(), 20);
7016            assert_eq!(journal.read(2).await.unwrap(), 30);
7017            let appended;
7018            (journal, appended) = journal.append(&42).await.unwrap();
7019            assert_eq!(appended, 3);
7020            assert_eq!(journal.read(3).await.unwrap(), 42);
7021            drop(journal);
7022
7023            // Recovery should preserve the filled predecessors plus the fresh empty tail.
7024            let data_blobs = context.scan(&cfg.data_partition()).await.unwrap();
7025            assert_eq!(data_blobs.len(), 5);
7026
7027            let journal = Journal::<_, u64>::init(context.child("recovered"), cfg)
7028                .await
7029                .unwrap();
7030            journal.destroy().await.unwrap();
7031        });
7032    }
7033
7034    #[test_traced]
7035    fn test_variable_recovery_preserves_first_rollover_without_commit() {
7036        let executor = deterministic::Runner::default();
7037        executor.start(|context| async move {
7038            let cfg = Config::<()> {
7039                partition: "recovery-empty-data-no-items".into(),
7040                items_per_section: NZU64!(1),
7041                compression: None,
7042                codec_config: (),
7043                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
7044                write_buffer: NZUsize!(1024),
7045                replay_buffer: NZUsize!(1024),
7046            };
7047            let mut journal = Journal::<_, u64>::init(context.child("journal"), cfg.clone())
7048                .await
7049                .unwrap();
7050
7051            // Append across multiple blob boundaries without ever calling commit/sync. Rollover
7052            // starts predecessor syncs, so filled predecessors remain recoverable.
7053            let appended;
7054            (journal, appended) = journal.append(&10).await.unwrap();
7055            assert_eq!(appended, 0);
7056            let appended;
7057            (journal, appended) = journal.append(&20).await.unwrap();
7058            assert_eq!(appended, 1);
7059            drop(journal);
7060
7061            let data_partition = cfg.data_partition();
7062            let mut data_blobs = context.scan(&data_partition).await.unwrap();
7063            data_blobs.sort();
7064            assert_eq!(data_blobs.len(), 3);
7065            for name in &data_blobs[..2] {
7066                let (_, size) = context.open(&data_partition, name).await.unwrap();
7067                assert!(size > 0);
7068            }
7069            let (_, size) = context.open(&data_partition, &data_blobs[2]).await.unwrap();
7070            assert_eq!(size, 0);
7071
7072            let cfg = Config {
7073                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
7074                ..cfg
7075            };
7076            let mut journal = Journal::<_, u64>::init(context.child("recovered"), cfg)
7077                .await
7078                .unwrap();
7079            assert_eq!(journal.bounds(), 0..2);
7080            let appended;
7081            (journal, appended) = journal.append(&42).await.unwrap();
7082            assert_eq!(appended, 2);
7083            assert_eq!(journal.read(2).await.unwrap(), 42);
7084            journal.destroy().await.unwrap();
7085        });
7086    }
7087
7088    /// A crash during the rollover fsync can persist a valid last page above a lost interior
7089    /// page, which `Writer::new`'s backward scan cannot see. Recovery must truncate the suspect
7090    /// blob at the hole instead of failing on an unreadable page.
7091    #[test_traced]
7092    fn test_variable_recovery_truncates_torn_interior_page() {
7093        let executor = deterministic::Runner::default();
7094        executor.start(|context| async move {
7095            let cfg = Config::<()> {
7096                partition: "variable-torn-interior".into(),
7097                items_per_section: NZU64!(30),
7098                compression: None,
7099                codec_config: (),
7100                page_cache: CacheRef::new(NZU16!(64), NZUsize!(10)),
7101                write_buffer: NZUsize!(2048),
7102                replay_buffer: NZUsize!(2048),
7103            };
7104            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
7105                .await
7106                .unwrap();
7107            for i in 0..33u64 {
7108                (journal, _) = journal.append(&(i * 100)).await.unwrap();
7109            }
7110            journal.commit().await.unwrap();
7111
7112            // Data blob 0 holds 30 9-byte frames (270 bytes) across 5 pages; tear page 2.
7113            corrupt_page(&context, &cfg.data_partition(), &0u64.to_be_bytes(), 2, 64).await;
7114
7115            // Pages 0-1 hold 128 bytes = 14 whole frames; the 2 leftover bytes are torn junk
7116            // and the gap makes blob 1 unreachable.
7117            let mut journal = Journal::<_, u64>::init(context.child("second"), cfg)
7118                .await
7119                .unwrap();
7120            assert_eq!(journal.bounds(), 0..14);
7121            for i in 0..14u64 {
7122                assert_eq!(journal.read(i).await.unwrap(), i * 100);
7123            }
7124            let appended;
7125            (journal, appended) = journal.append(&4242).await.unwrap();
7126            assert_eq!(appended, 14);
7127            journal.destroy().await.unwrap();
7128        });
7129    }
7130
7131    /// A torn interior page in the tail data blob truncates the append frontier without
7132    /// disturbing its full predecessors.
7133    #[test_traced]
7134    fn test_variable_recovery_truncates_torn_interior_page_in_tail() {
7135        let executor = deterministic::Runner::default();
7136        executor.start(|context| async move {
7137            let cfg = Config::<()> {
7138                partition: "variable-torn-interior-tail".into(),
7139                items_per_section: NZU64!(30),
7140                compression: None,
7141                codec_config: (),
7142                page_cache: CacheRef::new(NZU16!(64), NZUsize!(10)),
7143                write_buffer: NZUsize!(2048),
7144                replay_buffer: NZUsize!(2048),
7145            };
7146            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
7147                .await
7148                .unwrap();
7149            for i in 0..50u64 {
7150                (journal, _) = journal.append(&(i * 100)).await.unwrap();
7151            }
7152            journal.commit().await.unwrap();
7153
7154            // Data blob 1 holds 20 9-byte frames (180 bytes) across 3 pages; tear page 1.
7155            corrupt_page(&context, &cfg.data_partition(), &1u64.to_be_bytes(), 1, 64).await;
7156
7157            // Blob 1 keeps page 0 only: 64 bytes = 7 whole frames after blob 0's 30.
7158            let mut journal = Journal::<_, u64>::init(context.child("second"), cfg)
7159                .await
7160                .unwrap();
7161            assert_eq!(journal.bounds(), 0..37);
7162            for i in 0..37u64 {
7163                assert_eq!(journal.read(i).await.unwrap(), i * 100);
7164            }
7165            let appended;
7166            (journal, appended) = journal.append(&4242).await.unwrap();
7167            assert_eq!(appended, 37);
7168            journal.destroy().await.unwrap();
7169        });
7170    }
7171
7172    /// Configuration for the torn-rewrite tests. Its 32-byte items are 33-byte frames, so 33
7173    /// frames leave 89 acknowledged bytes in data page 10, and 33 8-byte offsets leave 64 in
7174    /// offsets page 2.
7175    fn torn_rewrite_cfg(partition: &str) -> Config<()> {
7176        Config {
7177            partition: partition.into(),
7178            items_per_section: NZU64!(64),
7179            compression: None,
7180            codec_config: (),
7181            page_cache: CacheRef::new(NZU16!(100), NZUsize!(3)),
7182            write_buffer: NZUsize!(2048),
7183            replay_buffer: NZUsize!(2048),
7184        }
7185    }
7186
7187    /// Acknowledge 33 items, append five more, and make their pages durable without advancing
7188    /// the watermark, modeling the flush a crash interrupts.
7189    async fn torn_rewrite_journal(
7190        context: &deterministic::Context,
7191        cfg: &Config<()>,
7192    ) -> Journal<deterministic::Context, FixedBytes<32>> {
7193        let mut journal = Journal::<_, FixedBytes<32>>::init(context.child("seed"), cfg.clone())
7194            .await
7195            .unwrap();
7196        for i in 0..33u64 {
7197            (journal, _) = journal
7198                .append(&FixedBytes::new([i as u8; 32]))
7199                .await
7200                .unwrap();
7201        }
7202        journal = journal.sync().await.unwrap();
7203        for i in 33..38u64 {
7204            (journal, _) = journal
7205                .append(&FixedBytes::new([i as u8; 32]))
7206                .await
7207                .unwrap();
7208        }
7209        let (journal, handle) = journal.start_sync().await.unwrap();
7210        handle.await.unwrap();
7211        journal
7212    }
7213
7214    /// A bounded open repairs a torn data page before serving reads, since reads before
7215    /// publication go through the page cache, which refuses a short interior page.
7216    #[test_traced]
7217    fn test_variable_bounded_recovery_reads_torn_rewrite_before_publish() {
7218        let executor = deterministic::Runner::default();
7219        executor.start(|context| async move {
7220            let cfg = torn_rewrite_cfg("variable-torn-rewrite-read");
7221            let journal = torn_rewrite_journal(&context, &cfg).await;
7222            drop(journal);
7223
7224            // Lose one byte of data page 10's rewrite beyond its 89 durable bytes.
7225            tear_page_rewrite(
7226                &context,
7227                &cfg.data_partition(),
7228                &0u64.to_be_bytes(),
7229                10,
7230                95,
7231                100,
7232            )
7233            .await;
7234
7235            let recovery =
7236                Recovery::<_, FixedBytes<32>>::open(context.child("capped"), cfg.clone(), Some(33))
7237                    .await
7238                    .unwrap();
7239            assert_eq!(recovery.bounds(), 0..33);
7240            assert_eq!(
7241                recovery.read(32).await.unwrap(),
7242                FixedBytes::new([32u8; 32])
7243            );
7244            assert_eq!(recovery.last_matching(33, |_| true).await.unwrap(), 33);
7245            let journal = recovery.finish(33).await.unwrap();
7246            assert_eq!(journal.bounds(), 0..33);
7247            assert_eq!(journal.read(32).await.unwrap(), FixedBytes::new([32u8; 32]));
7248            journal.destroy().await.unwrap();
7249        });
7250    }
7251
7252    /// A bounded open repairs torn rewrites in both blobs. The offsets journal's bounded open
7253    /// truncates its torn page even though the page's durable prefix reaches the cap, so the
7254    /// acknowledged-floor check can read the last acknowledged offset.
7255    #[test_traced]
7256    fn test_variable_bounded_open_repairs_torn_rewrites() {
7257        let executor = deterministic::Runner::default();
7258        executor.start(|context| async move {
7259            let cfg = torn_rewrite_cfg("variable-torn-rewrites");
7260            let journal = torn_rewrite_journal(&context, &cfg).await;
7261            drop(journal);
7262
7263            // Lose one new byte of each blob's rewritten page, in data page 10 beyond its 89
7264            // durable bytes and in offsets page 2 beyond its 64.
7265            tear_page_rewrite(
7266                &context,
7267                &cfg.data_partition(),
7268                &0u64.to_be_bytes(),
7269                10,
7270                95,
7271                100,
7272            )
7273            .await;
7274            tear_page_rewrite(
7275                &context,
7276                &format!("{}-blobs", cfg.offsets_config().partition),
7277                &0u64.to_be_bytes(),
7278                2,
7279                70,
7280                100,
7281            )
7282            .await;
7283
7284            let journal =
7285                Journal::<_, FixedBytes<32>>::init_at_most(context.child("capped"), cfg, 33)
7286                    .await
7287                    .unwrap();
7288            assert_eq!(journal.bounds(), 0..33);
7289            for i in 0..33u64 {
7290                assert_eq!(
7291                    journal.read(i).await.unwrap(),
7292                    FixedBytes::new([i as u8; 32])
7293                );
7294            }
7295            journal.destroy().await.unwrap();
7296        });
7297    }
7298
7299    /// A torn page beneath the offsets recovery watermark is external corruption, not a crash
7300    /// artifact: the watermark only advances after the covering data fsync completes. Recovery
7301    /// never re-reads blobs wholly below the floor's blob, so it adopts the journal unchanged
7302    /// and the damage surfaces as read errors on the affected items.
7303    #[test_traced]
7304    fn test_variable_recovery_adopts_torn_page_below_watermark() {
7305        let executor = deterministic::Runner::default();
7306        executor.start(|context| async move {
7307            let cfg = Config::<()> {
7308                partition: "variable-torn-below-watermark".into(),
7309                items_per_section: NZU64!(30),
7310                compression: None,
7311                codec_config: (),
7312                page_cache: CacheRef::new(NZU16!(64), NZUsize!(10)),
7313                write_buffer: NZUsize!(2048),
7314                replay_buffer: NZUsize!(2048),
7315            };
7316            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
7317                .await
7318                .unwrap();
7319            for i in 0..33u64 {
7320                (journal, _) = journal.append(&(i * 100)).await.unwrap();
7321            }
7322            // Unlike the torn-interior tests above, advance the offsets watermark past blob 0.
7323            journal.sync().await.unwrap();
7324
7325            // Data blob 0 holds 30 9-byte frames (270 bytes) across 5 pages; tear page 2.
7326            corrupt_page(&context, &cfg.data_partition(), &0u64.to_be_bytes(), 2, 64).await;
7327            let size_before = context
7328                .logical_blob(&cfg.data_partition(), &0u64.to_be_bytes())
7329                .unwrap()
7330                .len();
7331
7332            // The watermark (33) anchors recovery in blob 1 and every blob-0 page had its
7333            // covering fsync complete, so recovery never revisits blob 0: the journal is
7334            // adopted unchanged and the damage surfaces as read errors on affected items.
7335            let journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
7336                .await
7337                .expect("acknowledged damage must not fail recovery");
7338            let size_after = context
7339                .logical_blob(&cfg.data_partition(), &0u64.to_be_bytes())
7340                .unwrap()
7341                .len();
7342            assert_eq!(
7343                size_after, size_before,
7344                "adoption must preserve the evidence"
7345            );
7346            let mut damaged = 0;
7347            for i in 0..30u64 {
7348                match journal.read(i).await {
7349                    Ok(item) => assert_eq!(item, i * 100),
7350                    Err(_) => damaged += 1,
7351                }
7352            }
7353            assert!(damaged > 0, "the torn page must surface as read errors");
7354            for i in 30..33u64 {
7355                assert_eq!(journal.read(i).await.unwrap(), i * 100);
7356            }
7357            drop(journal);
7358
7359            // A retry adopts the same state without mutating it.
7360            let _ = Journal::<_, u64>::init(context.child("third"), cfg.clone())
7361                .await
7362                .unwrap();
7363            let size_retry = context
7364                .logical_blob(&cfg.data_partition(), &0u64.to_be_bytes())
7365                .unwrap()
7366                .len();
7367            assert_eq!(size_retry, size_before);
7368        });
7369    }
7370
7371    /// A blob cleanly shortened beneath the watermark is out-of-model damage to acknowledged
7372    /// data. Recovery adopts the watermark without re-reading acknowledged blobs, so init
7373    /// succeeds and the missing items surface as read errors while the surviving prefix and
7374    /// every other blob stay readable.
7375    #[test_traced]
7376    fn test_variable_recovery_adopts_shortened_blob_below_watermark() {
7377        let executor = deterministic::Runner::default();
7378        executor.start(|context| async move {
7379            let cfg = Config::<()> {
7380                partition: "variable-short-below-watermark".into(),
7381                items_per_section: NZU64!(30),
7382                compression: None,
7383                codec_config: (),
7384                page_cache: CacheRef::new(NZU16!(64), NZUsize!(10)),
7385                write_buffer: NZUsize!(2048),
7386                replay_buffer: NZUsize!(2048),
7387            };
7388            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
7389                .await
7390                .unwrap();
7391            for i in 0..33u64 {
7392                (journal, _) = journal.append(&(i * 100)).await.unwrap();
7393            }
7394            journal.sync().await.unwrap();
7395
7396            // Cut blob 0 to two whole physical pages (128 logical bytes = 14 whole frames), so
7397            // every surviving page stays well-formed.
7398            let physical_page_size = 64 + 12;
7399            let (blob, size) = context
7400                .open(&cfg.data_partition(), &0u64.to_be_bytes())
7401                .await
7402                .unwrap();
7403            assert!(size > 2 * physical_page_size);
7404            blob.resize(2 * physical_page_size).await.unwrap();
7405            blob.sync().await.unwrap();
7406
7407            drop(blob);
7408            let journal = Journal::<_, u64>::init(context.child("second"), cfg)
7409                .await
7410                .unwrap();
7411            assert_eq!(journal.size(), 33);
7412            for i in 0..14u64 {
7413                assert_eq!(journal.read(i).await.unwrap(), i * 100);
7414            }
7415            assert!(journal.read(20).await.is_err());
7416            for i in 30..33u64 {
7417                assert_eq!(journal.read(i).await.unwrap(), i * 100);
7418            }
7419        });
7420    }
7421
7422    /// An externally shortened blob below the watermark is adopted (not re-read) even when it
7423    /// is older than the two blobs the interior-hole scan reads, and its missing items surface
7424    /// as read errors.
7425    #[test_traced]
7426    fn test_variable_recovery_adopts_shortened_old_blob_below_watermark() {
7427        let executor = deterministic::Runner::default();
7428        executor.start(|context| async move {
7429            let cfg = Config::<()> {
7430                partition: "variable-short-old-below-watermark".into(),
7431                items_per_section: NZU64!(10),
7432                compression: None,
7433                codec_config: (),
7434                page_cache: CacheRef::new(NZU16!(64), NZUsize!(10)),
7435                write_buffer: NZUsize!(2048),
7436                replay_buffer: NZUsize!(2048),
7437            };
7438            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
7439                .await
7440                .unwrap();
7441            for i in 0..25u64 {
7442                (journal, _) = journal.append(&(i * 100)).await.unwrap();
7443            }
7444            journal.sync().await.unwrap();
7445
7446            // Blob 0 holds 10 9-byte frames (90 bytes) across 2 pages, and is older than both
7447            // scan suspects (blobs 1 and 2). Cut it to one whole physical page (64 logical
7448            // bytes = 7 whole frames), leaving the surviving page well-formed.
7449            let physical_page_size = 64 + 12;
7450            let (blob, size) = context
7451                .open(&cfg.data_partition(), &0u64.to_be_bytes())
7452                .await
7453                .unwrap();
7454            assert!(size > physical_page_size);
7455            blob.resize(physical_page_size).await.unwrap();
7456            blob.sync().await.unwrap();
7457
7458            drop(blob);
7459            let journal = Journal::<_, u64>::init(context.child("second"), cfg)
7460                .await
7461                .unwrap();
7462            assert_eq!(journal.size(), 25);
7463            for i in 0..7u64 {
7464                assert_eq!(journal.read(i).await.unwrap(), i * 100);
7465            }
7466            assert!(journal.read(8).await.is_err());
7467            for i in 10..25u64 {
7468                assert_eq!(journal.read(i).await.unwrap(), i * 100);
7469            }
7470        });
7471    }
7472
7473    /// A torn page below a mid-blob watermark is rejected without truncating the blob's
7474    /// acknowledged prefix, and retries fail identically.
7475    #[test_traced]
7476    fn test_variable_recovery_rejects_torn_page_below_mid_blob_watermark() {
7477        let executor = deterministic::Runner::default();
7478        executor.start(|context| async move {
7479            let cfg = Config::<()> {
7480                partition: "variable-torn-mid-blob-watermark".into(),
7481                items_per_section: NZU64!(30),
7482                compression: None,
7483                codec_config: (),
7484                page_cache: CacheRef::new(NZU16!(64), NZUsize!(10)),
7485                write_buffer: NZUsize!(2048),
7486                replay_buffer: NZUsize!(2048),
7487            };
7488            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
7489                .await
7490                .unwrap();
7491            for i in 0..20u64 {
7492                (journal, _) = journal.append(&(i * 100)).await.unwrap();
7493            }
7494            // The watermark (20) sits mid-blob: blob 0 holds 20 9-byte frames across 3 pages.
7495            journal.sync().await.unwrap();
7496
7497            // Tear page 1 (bytes 64..128), beneath the acknowledged frames ending at byte 180.
7498            corrupt_page(&context, &cfg.data_partition(), &0u64.to_be_bytes(), 1, 64).await;
7499            let (_, size_before) = context
7500                .open(&cfg.data_partition(), &0u64.to_be_bytes())
7501                .await
7502                .unwrap();
7503
7504            for child in ["second", "retry"] {
7505                let result = Journal::<_, u64>::init(context.child(child), cfg.clone()).await;
7506                assert!(matches!(result, Err(Error::Corruption(_))));
7507            }
7508
7509            // The rejection must not truncate the blob's acknowledged prefix.
7510            let (_, size_after) = context
7511                .open(&cfg.data_partition(), &0u64.to_be_bytes())
7512                .await
7513                .unwrap();
7514            assert_eq!(size_after, size_before);
7515        });
7516    }
7517
7518    /// Test that a durable data blob above the sync watermark, sitting beyond an empty
7519    /// intermediate blob, is rolled back to the contiguous boundary during recovery.
7520    ///
7521    /// This constructs an external physical gap by emptying blob 1 while leaving blob 2 durable.
7522    /// Recovery anchors at the durable watermark and replays the data forward with a strict
7523    /// blob-contiguity check, so the post-gap blob is truncated and recovery returns only the
7524    /// synced prefix.
7525    #[test_traced]
7526    fn test_variable_recovery_rolls_back_durable_blob_after_gap() {
7527        let executor = deterministic::Runner::default();
7528        executor.start(|context| async move {
7529            let cfg = Config {
7530                partition: "recovery-rollback-after-gap".into(),
7531                items_per_section: NZU64!(10),
7532                compression: None,
7533                codec_config: (),
7534                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
7535                write_buffer: NZUsize!(1024),
7536                replay_buffer: NZUsize!(1024),
7537            };
7538
7539            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
7540                .await
7541                .unwrap();
7542
7543            // Durably commit blob 0 (positions 0..10), advancing the recovery watermark to 10.
7544            for i in 0..10u64 {
7545                (journal, _) = journal.append(&(i * 100)).await.unwrap();
7546            }
7547            journal = journal.sync().await.unwrap();
7548
7549            // Append blobs 1 and 2 without committing. Then corrupt blob 1 back to empty while
7550            // keeping blob 2 durable, creating a gap recovery must reject.
7551            for i in 10..30u64 {
7552                (journal, _) = journal.append(&(i * 100)).await.unwrap();
7553            }
7554            journal = journal.test_sync_data_blob(2).await.unwrap();
7555            drop(journal);
7556            let data_partition = cfg.data_partition();
7557            let (blob, _) = context
7558                .open(&data_partition, &1u64.to_be_bytes())
7559                .await
7560                .unwrap();
7561            blob.resize(0).await.unwrap();
7562            blob.sync().await.unwrap();
7563            drop(blob);
7564
7565            // Durable state: blob 0 (10 items), blob 1 (empty, lost), blob 2 (10
7566            // items), blob 3 (the empty tail).
7567            let mut names = context.scan(&data_partition).await.unwrap();
7568            names.sort();
7569            assert_eq!(names.len(), 4);
7570            let sizes = {
7571                let mut sizes = Vec::new();
7572                for name in &names {
7573                    let (_, size) = context.open(&data_partition, name).await.unwrap();
7574                    sizes.push(size);
7575                }
7576                sizes
7577            };
7578            assert!(sizes[0] > 0, "blob 0 should be durable");
7579            assert_eq!(sizes[1], 0, "blob 1 should be the gap");
7580            assert!(sizes[2] > 0, "blob 2 should be incidentally durable");
7581            assert_eq!(sizes[3], 0, "blob 3 should be the empty tail");
7582
7583            // Recovery rolls back to the watermark boundary: only the synced prefix survives and the
7584            // gapped blob 2 is truncated away.
7585            let mut journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
7586                .await
7587                .unwrap();
7588            assert_eq!(journal.bounds(), 0..10);
7589            for i in 0..10u64 {
7590                assert_eq!(journal.read(i).await.unwrap(), i * 100);
7591            }
7592            assert!(matches!(
7593                journal.read(10).await,
7594                Err(Error::ItemOutOfRange(10))
7595            ));
7596
7597            // The orphaned blob 2 is gone. The repair truncates blob 1 in place, so its
7598            // emptied blob remains as the recovered tail.
7599            let data_blobs = context.scan(&cfg.data_partition()).await.unwrap();
7600            assert_eq!(data_blobs.len(), 2);
7601
7602            // Appends resume cleanly from the recovered boundary.
7603            let appended;
7604            (journal, appended) = journal.append(&1234).await.unwrap();
7605            assert_eq!(appended, 10);
7606            assert_eq!(journal.read(10).await.unwrap(), 1234);
7607
7608            journal.destroy().await.unwrap();
7609        });
7610    }
7611
7612    /// Test recovery when the oldest data blob is empty but a newer blob still holds
7613    /// durable items and the offsets journal is gone.
7614    ///
7615    /// A contiguous journal can only populate a later blob after filling the earlier one, so an
7616    /// empty oldest blob with a populated newer blob is an orphaned gap. Replaying from the
7617    /// empty oldest blob immediately yields the newer blob's items, which are "ahead" of the
7618    /// expected blob, so recovery truncates everything past the gap and aligns the journal to
7619    /// empty. This regresses a bad invariant that asserted offsets must be non-empty after
7620    /// alignment.
7621    #[test_traced]
7622    fn test_variable_recovery_empty_oldest_blob_orphaned_newer_blob() {
7623        let executor = deterministic::Runner::default();
7624        executor.start(|context| async move {
7625            let cfg = Config {
7626                partition: "recovery-empty-oldest-blob".into(),
7627                items_per_section: NZU64!(10),
7628                compression: None,
7629                codec_config: (),
7630                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
7631                write_buffer: NZUsize!(1024),
7632                replay_buffer: NZUsize!(1024),
7633            };
7634
7635            // Durably persist blobs 0 and 1 (positions 0..20).
7636            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
7637                .await
7638                .unwrap();
7639            for i in 0..20u64 {
7640                (journal, _) = journal.append(&(i * 100)).await.unwrap();
7641            }
7642            let journal = journal.sync().await.unwrap();
7643            drop(journal);
7644
7645            // Empty the oldest data blob in place, leaving blob 1's items orphaned past the
7646            // gap, then drop the offsets journal so recovery rebuilds from the data alone.
7647            let data_partition = cfg.data_partition();
7648            let mut names = context.scan(&data_partition).await.unwrap();
7649            names.sort();
7650            assert_eq!(names.len(), 3);
7651            let (blob0, size0) = context.open(&data_partition, &names[0]).await.unwrap();
7652            assert!(size0 > 0, "blob 0 should start durable");
7653            blob0.resize(0).await.unwrap();
7654            blob0.sync().await.unwrap();
7655            context
7656                .remove(&format!("{}-blobs", cfg.offsets_partition()), None)
7657                .await
7658                .unwrap();
7659            context
7660                .remove(&format!("{}-metadata", cfg.offsets_partition()), None)
7661                .await
7662                .unwrap();
7663
7664            // Recovery aligns to an empty journal instead of panicking.
7665            drop(blob0);
7666            let mut journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
7667                .await
7668                .unwrap();
7669            assert_eq!(journal.bounds(), 0..0);
7670            assert!(matches!(
7671                journal.read(0).await,
7672                Err(Error::ItemOutOfRange(0))
7673            ));
7674
7675            // The orphaned newer blob is truncated away and appends resume from position 0.
7676            let appended;
7677            (journal, appended) = journal.append(&42).await.unwrap();
7678            assert_eq!(appended, 0);
7679            assert_eq!(journal.read(0).await.unwrap(), 42);
7680            let data_blobs = context.scan(&cfg.data_partition()).await.unwrap();
7681            assert_eq!(
7682                data_blobs.len(),
7683                1,
7684                "orphaned newer blob should be truncated away"
7685            );
7686
7687            journal.destroy().await.unwrap();
7688        });
7689    }
7690
7691    /// Test recovery when the oldest data blob ends at a clean page boundary but is still short.
7692    ///
7693    /// No trailing bytes are repaired in this case: the data blob simply contains fewer complete
7694    /// items than its capacity. Replaying from the start must still detect the jump to the newer
7695    /// blob and truncate it instead of skipping missing logical positions.
7696    #[test_traced]
7697    fn test_variable_recovery_clean_short_oldest_blob_orphaned_newer_blob() {
7698        let executor = deterministic::Runner::default();
7699        executor.start(|context| async move {
7700            let cfg = Config {
7701                partition: "recovery-clean-short-oldest-blob".into(),
7702                items_per_section: NZU64!(64),
7703                compression: None,
7704                codec_config: (),
7705                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
7706                write_buffer: NZUsize!(1024),
7707                replay_buffer: NZUsize!(1024),
7708            };
7709
7710            // Build two durable data blobs. Blob 1 is only reachable if replay incorrectly
7711            // skips the missing tail of blob 0.
7712            let mut journal =
7713                Journal::<_, FixedBytes<31>>::init(context.child("first"), cfg.clone())
7714                    .await
7715                    .unwrap();
7716            for i in 0..128u8 {
7717                (journal, _) = journal.append(&FixedBytes::new([i; 31])).await.unwrap();
7718            }
7719            let journal = journal.sync().await.unwrap();
7720            drop(journal);
7721
7722            let physical_page_size = LARGE_PAGE_SIZE.get() as u64 + 12;
7723            let items_in_page = LARGE_PAGE_SIZE.get() as u64 / 32;
7724            assert!(items_in_page < cfg.items_per_section.get());
7725
7726            let data_partition = cfg.data_partition();
7727            let mut names = context.scan(&data_partition).await.unwrap();
7728            names.sort();
7729            assert_eq!(names.len(), 3);
7730
7731            // Truncate at a valid physical page boundary. This leaves a clean short data blob,
7732            // not trailing corruption.
7733            let (blob0, size0) = context.open(&data_partition, &names[0]).await.unwrap();
7734            assert!(size0 > physical_page_size);
7735            blob0.resize(physical_page_size).await.unwrap();
7736            blob0.sync().await.unwrap();
7737
7738            // Remove offsets so recovery must rebuild by replaying data and checking blob
7739            // continuity.
7740            context
7741                .remove(&format!("{}-blobs", cfg.offsets_partition()), None)
7742                .await
7743                .unwrap();
7744            context
7745                .remove(&format!("{}-metadata", cfg.offsets_partition()), None)
7746                .await
7747                .unwrap();
7748
7749            // Recovery must stop at the short non-tail blob rather than accepting blob 1's
7750            // items as later logical positions.
7751            drop(blob0);
7752            let mut journal =
7753                Journal::<_, FixedBytes<31>>::init(context.child("second"), cfg.clone())
7754                    .await
7755                    .unwrap();
7756            assert_eq!(journal.bounds(), 0..items_in_page);
7757            assert_eq!(
7758                journal.read(items_in_page - 1).await.unwrap(),
7759                FixedBytes::new([(items_in_page - 1) as u8; 31])
7760            );
7761            assert!(matches!(
7762                journal.read(items_in_page).await,
7763                Err(Error::ItemOutOfRange(pos)) if pos == items_in_page
7764            ));
7765
7766            let data_blobs = context.scan(&cfg.data_partition()).await.unwrap();
7767            assert_eq!(
7768                data_blobs.len(),
7769                1,
7770                "orphaned newer blob should be truncated away"
7771            );
7772
7773            // Appends resume directly after the recovered prefix.
7774            let pos;
7775            (journal, pos) = journal.append(&FixedBytes::new([42; 31])).await.unwrap();
7776            assert_eq!(pos, items_in_page);
7777            assert_eq!(
7778                journal.read(items_in_page).await.unwrap(),
7779                FixedBytes::new([42; 31])
7780            );
7781
7782            journal.destroy().await.unwrap();
7783        });
7784    }
7785
7786    /// Test that recovery preserves exactly the durable contiguous prefix when the tail blob's
7787    /// items and the offsets journal were never synced: blobs 0 and 1 are durable, blob 2's
7788    /// items were only buffered.
7789    #[test_traced]
7790    fn test_variable_recovery_unsynced_tail_keeps_contiguous_prefix() {
7791        let executor = deterministic::Runner::default();
7792        executor.start(|context| async move {
7793            let cfg = Config {
7794                partition: "recovery-partial-sync-loop".into(),
7795                items_per_section: NZU64!(10),
7796                compression: None,
7797                codec_config: (),
7798                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
7799                write_buffer: NZUsize!(1024),
7800                replay_buffer: NZUsize!(1024),
7801            };
7802
7803            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
7804                .await
7805                .unwrap();
7806
7807            // Fill blobs 0 and 1 and partially fill blob 2 (positions 20..25). Rollover has
7808            // already made blobs 0 and 1 durable; blob 2's items sit in the write buffer only.
7809            for i in 0..25u64 {
7810                (journal, _) = journal.append(&(i * 100)).await.unwrap();
7811            }
7812
7813            // Wait for blobs 0 and 1 to become durable, then drop without flushing
7814            // blob 2 or the offsets journal.
7815            journal = journal.test_sync_data_blob(0).await.unwrap();
7816            journal = journal.test_sync_data_blob(1).await.unwrap();
7817            drop(journal);
7818
7819            // The durable data is exactly the contiguous prefix: blobs 0 and 1 hold items,
7820            // blob 2 is an empty trailing blob, and offsets never synced.
7821            let data_partition = cfg.data_partition();
7822            let mut names = context.scan(&data_partition).await.unwrap();
7823            names.sort();
7824            assert_eq!(names.len(), 3);
7825            for (blob, name) in names.iter().enumerate() {
7826                let (_, size) = context.open(&data_partition, name).await.unwrap();
7827                if blob < 2 {
7828                    assert!(size > 0, "blob {blob} should be durable");
7829                } else {
7830                    assert_eq!(size, 0, "blob {blob} should be empty");
7831                }
7832            }
7833
7834            // Recovery trims the empty trailing blob, rebuilds offsets from the durable data, and
7835            // exposes exactly the contiguous prefix 0..20.
7836            let mut journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
7837                .await
7838                .unwrap();
7839            assert_eq!(journal.bounds(), 0..20);
7840            for i in 0..20u64 {
7841                assert_eq!(journal.read(i).await.unwrap(), i * 100);
7842            }
7843            assert!(matches!(
7844                journal.read(20).await,
7845                Err(Error::ItemOutOfRange(20))
7846            ));
7847
7848            // The empty trailing blob is adopted as the tail; appends continue from the
7849            // recovered end.
7850            let data_blobs = context.scan(&cfg.data_partition()).await.unwrap();
7851            assert_eq!(data_blobs.len(), 3);
7852            let appended;
7853            (journal, appended) = journal.append(&2000).await.unwrap();
7854            assert_eq!(appended, 20);
7855            assert_eq!(journal.read(20).await.unwrap(), 2000);
7856
7857            journal.destroy().await.unwrap();
7858        });
7859    }
7860
7861    /// Test recovery from multiple prune operations with crash.
7862    #[test_traced]
7863    fn test_variable_recovery_multiple_prunes_crash() {
7864        let executor = deterministic::Runner::default();
7865        executor.start(|context| async move {
7866            let cfg = Config {
7867                partition: "recovery-multiple-prunes".into(),
7868                items_per_section: NZU64!(10),
7869                compression: None,
7870                codec_config: (),
7871                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
7872                write_buffer: NZUsize!(1024),
7873                replay_buffer: NZUsize!(1024),
7874            };
7875
7876            let mut variable = Journal::<_, u64>::init(context.child("first"), cfg.clone())
7877                .await
7878                .unwrap();
7879
7880            // Append 50 items across 5 blobs to both journals
7881            for i in 0..50u64 {
7882                (variable, _) = variable.append(&(i * 100)).await.unwrap();
7883            }
7884
7885            // Prune to position 10 normally (both data and offsets journals pruned)
7886            let (mut variable, _) = variable.prune(10).await.unwrap();
7887            assert_eq!(variable.bounds().start, 10);
7888
7889            // Persist data pruning to position 30 while offsets still start at position 10.
7890            (variable, _) = variable.test_prune_data(3).await.unwrap();
7891            variable.sync().await.unwrap();
7892
7893            // Reopen to reconcile the offsets with the retained data.
7894            let variable = Journal::<_, u64>::init(context.child("second"), cfg.clone())
7895                .await
7896                .unwrap();
7897
7898            // Init should auto-repair: offsets journal pruned to match data blobs
7899            let bounds = variable.bounds();
7900            assert_eq!(bounds.start, 30);
7901            assert_eq!(bounds.end, 50);
7902
7903            // Reads before position 30 should fail (pruned from both journals)
7904            assert!(matches!(
7905                variable.read(10).await,
7906                Err(crate::journal::Error::ItemPruned(_))
7907            ));
7908            assert!(matches!(
7909                variable.read(20).await,
7910                Err(crate::journal::Error::ItemPruned(_))
7911            ));
7912
7913            // Reads at position 30+ should succeed
7914            assert_eq!(variable.read(30).await.unwrap(), 3000);
7915            assert_eq!(variable.read(49).await.unwrap(), 4900);
7916
7917            variable.destroy().await.unwrap();
7918        });
7919    }
7920
7921    /// Test recovery when the offsets journal is behind the data blobs.
7922    ///
7923    /// This creates a situation where offsets are missing while the data blobs still contain
7924    /// items across multiple blobs. Verifies that init() rebuilds the offsets suffix across all
7925    /// remaining data blobs.
7926    #[test_traced]
7927    fn test_variable_recovery_offsets_behind_data_multi_blob() {
7928        let executor = deterministic::Runner::default();
7929        executor.start(|context| async move {
7930            let cfg = Config {
7931                partition: "recovery-rewind-crash".into(),
7932                items_per_section: NZU64!(10),
7933                compression: None,
7934                codec_config: (),
7935                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
7936                write_buffer: NZUsize!(1024),
7937                replay_buffer: NZUsize!(1024),
7938            };
7939
7940            let mut variable = Journal::<_, u64>::init(context.child("first"), cfg.clone())
7941                .await
7942                .unwrap();
7943
7944            // Append 25 items across 3 blobs (blob 0: 0-9, blob 1: 10-19, blob 2: 20-24)
7945            for i in 0..25u64 {
7946                (variable, _) = variable.append(&(i * 100)).await.unwrap();
7947            }
7948
7949            assert_eq!(variable.size(), 25);
7950
7951            // Keep offsets for positions 0-4, while data still contains all 25 items.
7952            let variable = variable.test_truncate_offsets(5).await.unwrap();
7953
7954            // A reopen rebuilds the truncated offsets from every data blob.
7955            variable.sync().await.unwrap();
7956            let mut variable = Journal::<_, u64>::init(context.child("second"), cfg.clone())
7957                .await
7958                .unwrap();
7959
7960            // Init should rebuild offsets[5-24] from data blobs across all 3 blobs
7961            let bounds = variable.bounds();
7962            assert_eq!(bounds.end, 25);
7963            assert_eq!(bounds.start, 0);
7964
7965            // All items should be readable, with offsets rebuilt correctly across all blobs
7966            for i in 0..25u64 {
7967                assert_eq!(variable.read(i).await.unwrap(), i * 100);
7968            }
7969
7970            // Verify offsets journal fully rebuilt
7971            assert_eq!(variable.test_offsets_size(), 25);
7972
7973            // Verify next append gets position 25
7974            let pos;
7975            (variable, pos) = variable.append(&2500).await.unwrap();
7976            assert_eq!(pos, 25);
7977            assert_eq!(variable.read(25).await.unwrap(), 2500);
7978
7979            variable.destroy().await.unwrap();
7980        });
7981    }
7982
7983    #[test_traced]
7984    fn test_variable_recovery_rejects_watermark_beyond_retained_data() {
7985        let executor = deterministic::Runner::default();
7986        executor.start(|context| async move {
7987            let cfg = Config {
7988                partition: "recovery-anchor-too-far".into(),
7989                items_per_section: NZU64!(10),
7990                compression: None,
7991                codec_config: (),
7992                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
7993                write_buffer: NZUsize!(1024),
7994                replay_buffer: NZUsize!(1024),
7995            };
7996
7997            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
7998                .await
7999                .unwrap();
8000
8001            for i in 0..20u64 {
8002                (journal, _) = journal.append(&(i * 100)).await.unwrap();
8003            }
8004            let journal = journal.sync().await.unwrap();
8005
8006            // The offsets watermark is in-bounds, but vouches for acknowledged data that no
8007            // longer exists.
8008            let journal = journal
8009                .test_set_offsets_recovery_watermark(15)
8010                .await
8011                .unwrap();
8012            journal.test_truncate_data(12).await.unwrap();
8013
8014            // Missing acknowledged data remains an error on every recovery attempt.
8015            for child in ["second", "retry"] {
8016                match Journal::<_, u64>::init(context.child(child), cfg.clone()).await {
8017                    Err(Error::Corruption(message)) => assert!(message.starts_with(
8018                        "offsets recovery watermark 15 exceeds retained data end 12 ("
8019                    )),
8020                    Err(error) => panic!("unexpected error: {error}"),
8021                    Ok(_) => panic!("missing acknowledged data was accepted"),
8022                }
8023            }
8024        });
8025    }
8026
8027    #[test_traced]
8028    fn test_variable_recovery_rejects_missing_data_below_watermark() {
8029        let executor = deterministic::Runner::default();
8030        executor.start(|context| async move {
8031            let cfg = Config {
8032                partition: "recovery-short-middle-retry".into(),
8033                items_per_section: NZU64!(10),
8034                compression: None,
8035                codec_config: (),
8036                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
8037                write_buffer: NZUsize!(1024),
8038                replay_buffer: NZUsize!(1024),
8039            };
8040
8041            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
8042                .await
8043                .unwrap();
8044
8045            for i in 0..30u64 {
8046                (journal, _) = journal.append(&(i * 100)).await.unwrap();
8047            }
8048            let journal = journal.sync().await.unwrap();
8049
8050            // Keep the offsets watermark in bounds and within the retained data end bound, but make
8051            // the data blob that contains the watermark too short to reach it.
8052            let journal = journal
8053                .test_set_offsets_recovery_watermark(15)
8054                .await
8055                .unwrap();
8056            let partition = journal.test_truncate_data(12).await.unwrap();
8057            let mut orphan = partition.open_recovery(2).await.unwrap();
8058            let mut encoded = Vec::new();
8059            encode_frame_into(None, &9999u64, &mut encoded).unwrap();
8060            (orphan, _) = orphan.append(&encoded).await.unwrap();
8061            orphan = orphan.sync().await.unwrap();
8062            drop(orphan);
8063
8064            // Rebuilding from watermark 15 cannot skip five items in blob 1 because only
8065            // positions 10 and 11 survive. Recovery must fail without deleting the orphaned
8066            // blob 2, and the same evidence must remain visible on retry.
8067            for child in ["second", "retry"] {
8068                match Journal::<_, u64>::init(context.child(child), cfg.clone()).await {
8069                    Err(Error::Corruption(message)) => assert_eq!(
8070                        message,
8071                        "data blobs shorter than offsets recovery watermark 15"
8072                    ),
8073                    Err(error) => panic!("unexpected error: {error}"),
8074                    Ok(_) => panic!("missing acknowledged data was accepted"),
8075                }
8076            }
8077
8078            let data_blobs = context.scan(&cfg.data_partition()).await.unwrap();
8079            assert_eq!(
8080                data_blobs.len(),
8081                3,
8082                "corruption evidence should not be removed"
8083            );
8084        });
8085    }
8086
8087    /// Reopen at a page-aligned bound, append over the freed data pages, then crash with the
8088    /// appends retained and any unsynced resize lost. Recovery must not join the new frames with
8089    /// the discarded suffix.
8090    #[test_traced]
8091    fn test_variable_init_at_most_truncation_survives_crash() {
8092        // A u64 frame is nine bytes, so four frames fill a 36-byte page and the eight-item bound
8093        // is page aligned. The two-page write buffer floor sends a nine-item append down the
8094        // direct path: eight items land in two full pages and the ninth stays buffered, so the
8095        // old pages behind them are never rewritten.
8096        const PAGE_SIZE: NonZeroU16 = NZU16!(36);
8097        const BOUND: u64 = 8;
8098
8099        fn cfg() -> Config<()> {
8100            Config {
8101                partition: "variable-init-at-most-truncation-crash".into(),
8102                items_per_section: NZU64!(100),
8103                compression: None,
8104                codec_config: (),
8105                page_cache: CacheRef::new(PAGE_SIZE, NZUsize!(3)),
8106                write_buffer: NZUsize!(1),
8107                replay_buffer: NZUsize!(2048),
8108            }
8109        }
8110        let appended: Vec<u64> = (100..109).collect();
8111
8112        let executor = deterministic::Runner::default();
8113        let (appended, checkpoint) = executor.start_and_recover(|context| async move {
8114            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg())
8115                .await
8116                .unwrap();
8117            for value in 0..24u64 {
8118                (journal, _) = journal.append(&value).await.unwrap();
8119            }
8120            let journal = journal.sync().await.unwrap();
8121            drop(journal);
8122
8123            // Keep unsynced writes and drop unsynced resizes at the crash.
8124            *context.storage_fault_config().write() = deterministic::FaultConfig {
8125                write_rate: Some(deterministic::WriteConfig {
8126                    failure_rate: probability!(0.0),
8127                    retention_rate: probability!(1.0),
8128                    mode: deterministic::PartialWriteMode::Prefix,
8129                }),
8130                resize_rate: Some(deterministic::ResizeConfig {
8131                    failure_rate: probability!(0.0),
8132                    partial_rate: probability!(0.0),
8133                }),
8134                ..Default::default()
8135            };
8136            let journal = Journal::<_, u64>::init_at_most(context.child("cap"), cfg(), BOUND)
8137                .await
8138                .unwrap();
8139            assert_eq!(journal.bounds(), 0..BOUND);
8140            let (journal, _) = journal.append_many(Many::Flat(&appended)).await.unwrap();
8141            drop(journal);
8142            appended
8143        });
8144
8145        deterministic::Runner::from(checkpoint).start(|context| async move {
8146            *context.storage_fault_config().write() = deterministic::FaultConfig::default();
8147            let journal = Journal::<_, u64>::init(context.child("recover"), cfg())
8148                .await
8149                .unwrap();
8150            let bounds = journal.bounds();
8151            assert_eq!(bounds.start, 0);
8152            assert!(
8153                (BOUND..=BOUND + appended.len() as u64).contains(&bounds.end),
8154                "recovered size {} is not a prefix of the new history",
8155                bounds.end
8156            );
8157            for pos in 0..BOUND {
8158                assert_eq!(journal.read(pos).await.unwrap(), pos);
8159            }
8160            for pos in BOUND..bounds.end {
8161                assert_eq!(
8162                    journal.read(pos).await.unwrap(),
8163                    appended[(pos - BOUND) as usize],
8164                    "content at retained position {pos} was never written there"
8165                );
8166            }
8167            journal.destroy().await.unwrap();
8168        });
8169    }
8170
8171    #[test_traced]
8172    fn test_variable_init_at_most_commit_reopen() {
8173        let executor = deterministic::Runner::default();
8174        executor.start(|context| async move {
8175            let cfg = Config {
8176                partition: "rewind-commit-reopen".into(),
8177                items_per_section: NZU64!(10),
8178                compression: None,
8179                codec_config: (),
8180                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
8181                write_buffer: NZUsize!(1024),
8182                replay_buffer: NZUsize!(1024),
8183            };
8184
8185            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
8186                .await
8187                .unwrap();
8188
8189            for i in 0..25u64 {
8190                (journal, _) = journal.append(&(i * 100)).await.unwrap();
8191            }
8192            let journal = journal.sync().await.unwrap();
8193
8194            let journal = {
8195                _ = journal.sync().await.unwrap();
8196                Journal::<_, u64>::init_at_most(context.child("cap"), cfg.clone(), 12).await
8197            }
8198            .unwrap();
8199            journal.commit().await.unwrap();
8200
8201            let journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
8202                .await
8203                .unwrap();
8204            assert_eq!(journal.bounds(), 0..12);
8205            for i in 0..12u64 {
8206                assert_eq!(journal.read(i).await.unwrap(), i * 100);
8207            }
8208            assert!(matches!(
8209                journal.read(12).await,
8210                Err(Error::ItemOutOfRange(12))
8211            ));
8212
8213            journal.destroy().await.unwrap();
8214        });
8215    }
8216
8217    #[test_traced]
8218    fn test_variable_recovery_rejects_synced_data_truncate_to_boundary() {
8219        let executor = deterministic::Runner::default();
8220        executor.start(|context| async move {
8221            let cfg = Config {
8222                partition: "recovery-boundary-data-rewind".into(),
8223                items_per_section: NZU64!(10),
8224                compression: None,
8225                codec_config: (),
8226                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
8227                write_buffer: NZUsize!(1024),
8228                replay_buffer: NZUsize!(1024),
8229            };
8230
8231            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
8232                .await
8233                .unwrap();
8234
8235            for i in 0..20u64 {
8236                (journal, _) = journal.append(&(i * 100)).await.unwrap();
8237            }
8238            let journal = journal.sync().await.unwrap();
8239            journal.test_truncate_data(10).await.unwrap();
8240
8241            // The watermark proves positions through 20 were acknowledged. Losing the second
8242            // blob is corruption, including when the surviving data ends exactly at a boundary.
8243            // The recovery anchor compares the watermark against the retained data end (both
8244            // already in hand) and rejects the truncate.
8245            for child in ["second", "retry"] {
8246                match Journal::<_, u64>::init(context.child(child), cfg.clone()).await {
8247                    Err(Error::Corruption(message)) => assert_eq!(
8248                        message,
8249                        "offsets recovery watermark 20 exceeds retained data end 10 (offsets \
8250                         bounds 0..20)"
8251                    ),
8252                    Err(error) => panic!("unexpected error: {error}"),
8253                    Ok(_) => panic!("missing acknowledged data was accepted"),
8254                }
8255            }
8256        });
8257    }
8258
8259    #[test_traced]
8260    fn test_variable_recovery_truncates_short_data_blob_after_anchor() {
8261        let executor = deterministic::Runner::default();
8262        executor.start(|context| async move {
8263            let cfg = Config {
8264                partition: "recovery-short-blob-after-anchor".into(),
8265                items_per_section: NZU64!(10),
8266                compression: None,
8267                codec_config: (),
8268                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
8269                write_buffer: NZUsize!(1024),
8270                replay_buffer: NZUsize!(1024),
8271            };
8272
8273            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
8274                .await
8275                .unwrap();
8276
8277            for i in 0..25u64 {
8278                (journal, _) = journal.append(&(i * 100)).await.unwrap();
8279            }
8280            journal = journal.sync().await.unwrap();
8281
8282            // Simulate a crash after the previous recovery checkpoint where blob 1 was only
8283            // partly durable but blob 2 was present. Recovery should keep the contiguous prefix
8284            // and discard blob 2 rather than treating the blob jump as hard corruption.
8285            let journal = journal
8286                .test_set_offsets_recovery_watermark(10)
8287                .await
8288                .unwrap();
8289            let offset = journal.0.offsets.reader().read(12).await.unwrap();
8290            drop(journal);
8291
8292            // Truncate blob 1 in place (keeping blob 2) by reopening its blob directly.
8293            let (blob, size) = context
8294                .open(&cfg.data_partition(), &1u64.to_be_bytes())
8295                .await
8296                .unwrap();
8297            let mut writer = PagedRecovery::open(blob, size, 1024, cfg.page_cache.clone())
8298                .await
8299                .unwrap();
8300            writer = writer.truncate(offset).await.unwrap();
8301            writer = writer.sync().await.unwrap();
8302            drop(writer);
8303
8304            let journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
8305                .await
8306                .unwrap();
8307            assert_eq!(journal.bounds(), 0..12);
8308            assert_eq!(journal.test_offsets_size(), 12);
8309            for i in 0..12u64 {
8310                assert_eq!(journal.read(i).await.unwrap(), i * 100);
8311            }
8312            assert!(matches!(
8313                journal.read(12).await,
8314                Err(Error::ItemOutOfRange(12))
8315            ));
8316
8317            journal.destroy().await.unwrap();
8318        });
8319    }
8320
8321    #[test_traced]
8322    fn test_variable_init_persists_offsets_trailing_item_repair() {
8323        let executor = deterministic::Runner::default();
8324        let ((offsets_blob_partition, expected_size), checkpoint) =
8325            executor.start_and_recover(|context| async move {
8326                let cfg = Config {
8327                    partition: "offsets-init-repair-sync".into(),
8328                    items_per_section: NZU64!(10),
8329                    compression: None,
8330                    codec_config: (),
8331                    page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
8332                    write_buffer: NZUsize!(1024),
8333                    replay_buffer: NZUsize!(1024),
8334                };
8335                let offsets_blob_partition = format!("{}-blobs", cfg.offsets_partition());
8336                let expected_size = 2 * std::mem::size_of::<u64>() as u64;
8337
8338                let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
8339                    .await
8340                    .unwrap();
8341                (journal, _) = journal.append(&10).await.unwrap();
8342                (journal, _) = journal.append(&20).await.unwrap();
8343                let journal = journal.sync().await.unwrap();
8344                drop(journal);
8345
8346                let (blob, raw_size) = context
8347                    .open(&offsets_blob_partition, &0u64.to_be_bytes())
8348                    .await
8349                    .unwrap();
8350                let mut append = Writer::new(
8351                    blob,
8352                    raw_size,
8353                    2048,
8354                    CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
8355                )
8356                .await
8357                .unwrap();
8358                assert_eq!(append.size(), expected_size);
8359                (append, _) = append.append(&[0]).await.unwrap();
8360                append = append.sync().await.unwrap();
8361                drop(append);
8362
8363                let journal = Journal::<_, u64>::init(context.child("second"), cfg)
8364                    .await
8365                    .unwrap();
8366                assert_eq!(journal.bounds(), 0..2);
8367                drop(journal);
8368
8369                (offsets_blob_partition, expected_size)
8370            });
8371
8372        deterministic::Runner::from(checkpoint).start(move |context| async move {
8373            let (blob, raw_size) = context
8374                .open(&offsets_blob_partition, &0u64.to_be_bytes())
8375                .await
8376                .unwrap();
8377            let append = Writer::new(
8378                blob,
8379                raw_size,
8380                2048,
8381                CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
8382            )
8383            .await
8384            .unwrap();
8385            assert_eq!(append.size(), expected_size);
8386        });
8387    }
8388
8389    #[test_traced]
8390    fn test_variable_init_persists_data_tail_repair() {
8391        let executor = deterministic::Runner::default();
8392        let ((data_partition, expected_size), checkpoint) =
8393            executor.start_and_recover(|context| async move {
8394                let cfg = Config {
8395                    partition: "data-init-repair-sync".into(),
8396                    items_per_section: NZU64!(10),
8397                    compression: None,
8398                    codec_config: (),
8399                    page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
8400                    write_buffer: NZUsize!(1024),
8401                    replay_buffer: NZUsize!(1024),
8402                };
8403                let data_partition = cfg.data_partition();
8404
8405                let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
8406                    .await
8407                    .unwrap();
8408                (journal, _) = journal.append(&10).await.unwrap();
8409                (journal, _) = journal.append(&20).await.unwrap();
8410                let journal = journal.sync().await.unwrap();
8411                drop(journal);
8412
8413                let (blob, raw_size) = context
8414                    .open(&data_partition, &0u64.to_be_bytes())
8415                    .await
8416                    .unwrap();
8417                let mut append = Writer::new(
8418                    blob,
8419                    raw_size,
8420                    2048,
8421                    CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
8422                )
8423                .await
8424                .unwrap();
8425                let expected_size = append.size();
8426                (append, _) = append.append(&[0xFF, 0xFF]).await.unwrap();
8427                append = append.sync().await.unwrap();
8428                drop(append);
8429
8430                let journal = Journal::<_, u64>::init(context.child("second"), cfg)
8431                    .await
8432                    .unwrap();
8433                assert_eq!(journal.bounds(), 0..2);
8434                drop(journal);
8435
8436                (data_partition, expected_size)
8437            });
8438
8439        deterministic::Runner::from(checkpoint).start(move |context| async move {
8440            let (blob, raw_size) = context
8441                .open(&data_partition, &0u64.to_be_bytes())
8442                .await
8443                .unwrap();
8444            let append = Writer::new(
8445                blob,
8446                raw_size,
8447                2048,
8448                CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
8449            )
8450            .await
8451            .unwrap();
8452            assert_eq!(append.size(), expected_size);
8453        });
8454    }
8455
8456    /// Test recovery from crash after data sync but before offsets sync when journal was
8457    /// previously emptied by pruning.
8458    #[test_traced]
8459    fn test_variable_recovery_empty_offsets_after_prune_and_append() {
8460        let executor = deterministic::Runner::default();
8461        executor.start(|context| async move {
8462            let cfg = Config {
8463                partition: "recovery-empty-after-prune".into(),
8464                items_per_section: NZU64!(10),
8465                compression: None,
8466                codec_config: (),
8467                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
8468                write_buffer: NZUsize!(1024),
8469                replay_buffer: NZUsize!(1024),
8470            };
8471
8472            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
8473                .await
8474                .unwrap();
8475
8476            // Append 10 items (positions 0-9), fills blob 0
8477            for i in 0..10u64 {
8478                (journal, _) = journal.append(&(i * 100)).await.unwrap();
8479            }
8480            let bounds = journal.bounds();
8481            assert_eq!(bounds.end, 10);
8482            assert_eq!(bounds.start, 0);
8483
8484            // Pruning the only blob leaves an empty journal whose size is still 10.
8485            (journal, _) = journal.prune(10).await.unwrap();
8486            let bounds = journal.bounds();
8487            assert_eq!(bounds.end, 10);
8488            assert!(bounds.is_empty()); // Empty!
8489
8490            // Manually append to data blobs only (bypassing Variable's append logic)
8491            // This simulates the case where data was synced but offsets wasn't
8492            for i in 10..20u64 {
8493                (journal, _, _) = journal.test_append_data(1, i * 100).await.unwrap();
8494            }
8495            // Sync the data blobs (blob 1)
8496            let (blobs, sync) = journal.0.blobs.start_sync().await.unwrap();
8497            journal.0.blobs = blobs;
8498            sync.await.unwrap();
8499
8500            // Leave the offsets journal unsynced to simulate a crash before `offsets.sync()`.
8501
8502            // Close without syncing offsets
8503            drop(journal);
8504
8505            // A reopen must recover the unsynced offsets from the data appended after the prune.
8506            let journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
8507                .await
8508                .expect("Should recover from crash after data sync but before offsets sync");
8509
8510            // All data should be recovered
8511            let bounds = journal.bounds();
8512            assert_eq!(bounds.end, 20);
8513            assert_eq!(bounds.start, 10);
8514
8515            // All items from position 10-19 should be readable
8516            for i in 10..20u64 {
8517                assert_eq!(journal.read(i).await.unwrap(), i * 100);
8518            }
8519
8520            // Items 0-9 should be pruned
8521            for i in 0..10 {
8522                assert!(matches!(journal.read(i).await, Err(Error::ItemPruned(_))));
8523            }
8524
8525            journal.destroy().await.unwrap();
8526        });
8527    }
8528
8529    /// Test that offsets index is rebuilt from data after sync writes data but not offsets.
8530    #[test_traced]
8531    fn test_variable_concurrent_sync_recovery() {
8532        let executor = deterministic::Runner::default();
8533        executor.start(|context| async move {
8534            let cfg = Config {
8535                partition: "concurrent-sync-recovery".into(),
8536                items_per_section: NZU64!(10),
8537                compression: None,
8538                codec_config: (),
8539                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
8540                write_buffer: NZUsize!(1024),
8541                replay_buffer: NZUsize!(1024),
8542            };
8543
8544            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
8545                .await
8546                .unwrap();
8547
8548            // Append items across a blob boundary
8549            for i in 0..15u64 {
8550                (journal, _) = journal.append(&(i * 100)).await.unwrap();
8551            }
8552
8553            // Manually sync only data to simulate crash during concurrent sync
8554            let journal = journal.commit().await.unwrap();
8555
8556            // Simulate a crash (offsets not synced)
8557            drop(journal);
8558
8559            let journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
8560                .await
8561                .unwrap();
8562
8563            // Data should be intact and offsets rebuilt
8564            assert_eq!(journal.size(), 15);
8565            for i in 0..15u64 {
8566                assert_eq!(journal.read(i).await.unwrap(), i * 100);
8567            }
8568
8569            journal.destroy().await.unwrap();
8570        });
8571    }
8572
8573    #[test_traced]
8574    fn test_variable_recovery_from_mid_blob_durable_anchor() {
8575        let executor = deterministic::Runner::default();
8576        executor.start(|context| async move {
8577            let cfg = Config {
8578                partition: "mid-blob-durable-anchor".into(),
8579                items_per_section: NZU64!(5),
8580                compression: None,
8581                codec_config: (),
8582                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
8583                write_buffer: NZUsize!(1024),
8584                replay_buffer: NZUsize!(1024),
8585            };
8586
8587            let mut journal =
8588                Journal::<_, u64>::init_at_size(context.child("first"), cfg.clone(), 7)
8589                    .await
8590                    .unwrap();
8591            let appended;
8592            (journal, appended) = journal.append(&700).await.unwrap();
8593            assert_eq!(appended, 7);
8594            journal = journal.sync().await.unwrap();
8595
8596            for i in 1..6u64 {
8597                let appended;
8598                (journal, appended) = journal.append(&(700 + i)).await.unwrap();
8599                assert_eq!(appended, 7 + i);
8600            }
8601            journal.commit().await.unwrap();
8602
8603            let journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
8604                .await
8605                .unwrap();
8606            assert_eq!(journal.bounds(), 7..13);
8607            for i in 0..6u64 {
8608                assert_eq!(journal.read(7 + i).await.unwrap(), 700 + i);
8609            }
8610
8611            journal.destroy().await.unwrap();
8612        });
8613    }
8614
8615    #[test_traced]
8616    fn test_init_at_size_rejects_conflicting_offsets_partitions() {
8617        let executor = deterministic::Runner::default();
8618        executor.start(|context| async move {
8619            let cfg = Config {
8620                partition: "init-at-size-conflicting-offsets".into(),
8621                items_per_section: NZU64!(5),
8622                compression: None,
8623                codec_config: (),
8624                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
8625                write_buffer: NZUsize!(1024),
8626                replay_buffer: NZUsize!(1024),
8627            };
8628            let legacy_partition = cfg.offsets_partition();
8629            let blobs_partition = format!("{legacy_partition}-blobs");
8630
8631            for partition in [&legacy_partition, &blobs_partition] {
8632                let (blob, _) = context.open(partition, &0u64.to_be_bytes()).await.unwrap();
8633                blob.write_at(0, vec![0], WriteOptions::SYNC).await.unwrap();
8634            }
8635
8636            let result = Journal::<_, u64>::init_at_size(context.child("storage"), cfg, 7).await;
8637            assert!(matches!(result, Err(Error::Corruption(_))));
8638
8639            // The consistency check must fail before staging a reset, which would erase the
8640            // conflicting partitions and their corruption evidence.
8641            assert_eq!(context.scan(&legacy_partition).await.unwrap().len(), 1);
8642            assert_eq!(context.scan(&blobs_partition).await.unwrap().len(), 1);
8643        });
8644    }
8645
8646    #[test_traced]
8647    fn test_init_at_size_zero() {
8648        let executor = deterministic::Runner::default();
8649        executor.start(|context| async move {
8650            let cfg = Config {
8651                partition: "init-at-size-zero".into(),
8652                items_per_section: NZU64!(5),
8653                compression: None,
8654                codec_config: (),
8655                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
8656                write_buffer: NZUsize!(1024),
8657                replay_buffer: NZUsize!(1024),
8658            };
8659
8660            let mut journal =
8661                Journal::<_, u64>::init_at_size(context.child("storage"), cfg.clone(), 0)
8662                    .await
8663                    .unwrap();
8664
8665            // Size should be 0
8666            assert_eq!(journal.size(), 0);
8667
8668            // No oldest retained position (empty journal)
8669            assert!(journal.bounds().is_empty());
8670
8671            // Next append should get position 0
8672            let pos;
8673            (journal, pos) = journal.append(&100).await.unwrap();
8674            assert_eq!(pos, 0);
8675            assert_eq!(journal.size(), 1);
8676            assert_eq!(journal.read(0).await.unwrap(), 100);
8677
8678            journal.destroy().await.unwrap();
8679        });
8680    }
8681
8682    #[test_traced]
8683    fn test_init_at_size_blob_boundary() {
8684        let executor = deterministic::Runner::default();
8685        executor.start(|context| async move {
8686            let cfg = Config {
8687                partition: "init-at-size-boundary".into(),
8688                items_per_section: NZU64!(5),
8689                compression: None,
8690                codec_config: (),
8691                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
8692                write_buffer: NZUsize!(1024),
8693                replay_buffer: NZUsize!(1024),
8694            };
8695
8696            // Initialize at position 10 (exactly at blob 1 boundary with items_per_section=5)
8697            let mut journal =
8698                Journal::<_, u64>::init_at_size(context.child("storage"), cfg.clone(), 10)
8699                    .await
8700                    .unwrap();
8701
8702            // Size should be 10
8703            let bounds = journal.bounds();
8704            assert_eq!(bounds.end, 10);
8705
8706            // No data yet, so no oldest retained position
8707            assert!(bounds.is_empty());
8708
8709            // Next append should get position 10
8710            let pos;
8711            (journal, pos) = journal.append(&1000).await.unwrap();
8712            assert_eq!(pos, 10);
8713            assert_eq!(journal.size(), 11);
8714            assert_eq!(journal.read(10).await.unwrap(), 1000);
8715
8716            // Can continue appending
8717            let pos;
8718            (journal, pos) = journal.append(&1001).await.unwrap();
8719            assert_eq!(pos, 11);
8720            assert_eq!(journal.read(11).await.unwrap(), 1001);
8721
8722            journal.destroy().await.unwrap();
8723        });
8724    }
8725
8726    #[test_traced]
8727    fn test_init_at_size_mid_blob() {
8728        let executor = deterministic::Runner::default();
8729        executor.start(|context| async move {
8730            let cfg = Config {
8731                partition: "init-at-size-mid".into(),
8732                items_per_section: NZU64!(5),
8733                compression: None,
8734                codec_config: (),
8735                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
8736                write_buffer: NZUsize!(1024),
8737                replay_buffer: NZUsize!(1024),
8738            };
8739
8740            // Initialize at position 7 (middle of blob 1 with items_per_section=5)
8741            let mut journal =
8742                Journal::<_, u64>::init_at_size(context.child("storage"), cfg.clone(), 7)
8743                    .await
8744                    .unwrap();
8745
8746            // Size should be 7
8747            let bounds = journal.bounds();
8748            assert_eq!(bounds.end, 7);
8749
8750            // No data yet, so no oldest retained position
8751            assert!(bounds.is_empty());
8752
8753            // Next append should get position 7
8754            let pos;
8755            (journal, pos) = journal.append(&700).await.unwrap();
8756            assert_eq!(pos, 7);
8757            assert_eq!(journal.size(), 8);
8758            assert_eq!(journal.read(7).await.unwrap(), 700);
8759
8760            journal.destroy().await.unwrap();
8761        });
8762    }
8763
8764    #[test_traced]
8765    fn test_init_at_size_persistence() {
8766        let executor = deterministic::Runner::default();
8767        executor.start(|context| async move {
8768            let cfg = Config {
8769                partition: "init-at-size-persist".into(),
8770                items_per_section: NZU64!(5),
8771                compression: None,
8772                codec_config: (),
8773                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
8774                write_buffer: NZUsize!(1024),
8775                replay_buffer: NZUsize!(1024),
8776            };
8777
8778            // Initialize at position 15
8779            let mut journal =
8780                Journal::<_, u64>::init_at_size(context.child("first"), cfg.clone(), 15)
8781                    .await
8782                    .unwrap();
8783
8784            // Append some items
8785            for i in 0..5u64 {
8786                let pos;
8787                (journal, pos) = journal.append(&(1500 + i)).await.unwrap();
8788                assert_eq!(pos, 15 + i);
8789            }
8790
8791            assert_eq!(journal.size(), 20);
8792
8793            // Sync and reopen
8794            journal.sync().await.unwrap();
8795
8796            let mut journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
8797                .await
8798                .unwrap();
8799
8800            // Size and data should be preserved
8801            let bounds = journal.bounds();
8802            assert_eq!(bounds.end, 20);
8803            assert_eq!(bounds.start, 15);
8804
8805            // Verify data
8806            for i in 0..5u64 {
8807                assert_eq!(journal.read(15 + i).await.unwrap(), 1500 + i);
8808            }
8809
8810            // Can continue appending
8811            let pos;
8812            (journal, pos) = journal.append(&9999).await.unwrap();
8813            assert_eq!(pos, 20);
8814            assert_eq!(journal.read(20).await.unwrap(), 9999);
8815
8816            journal.destroy().await.unwrap();
8817        });
8818    }
8819
8820    #[test_traced]
8821    fn test_init_at_size_persistence_without_data() {
8822        let executor = deterministic::Runner::default();
8823        executor.start(|context| async move {
8824            let cfg = Config {
8825                partition: "init-at-size-persist-empty".into(),
8826                items_per_section: NZU64!(5),
8827                compression: None,
8828                codec_config: (),
8829                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
8830                write_buffer: NZUsize!(1024),
8831                replay_buffer: NZUsize!(1024),
8832            };
8833
8834            // Initialize at position 15
8835            let journal = Journal::<_, u64>::init_at_size(context.child("first"), cfg.clone(), 15)
8836                .await
8837                .unwrap();
8838
8839            let bounds = journal.bounds();
8840            assert_eq!(bounds.end, 15);
8841            assert!(bounds.is_empty());
8842
8843            // Drop without writing any data
8844            drop(journal);
8845
8846            // Reopen and verify size persisted
8847            let mut journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
8848                .await
8849                .unwrap();
8850
8851            let bounds = journal.bounds();
8852            assert_eq!(bounds.end, 15);
8853            assert!(bounds.is_empty());
8854
8855            // Can append starting at position 15
8856            let pos;
8857            (journal, pos) = journal.append(&1500).await.unwrap();
8858            assert_eq!(pos, 15);
8859            assert_eq!(journal.read(15).await.unwrap(), 1500);
8860
8861            journal.destroy().await.unwrap();
8862        });
8863    }
8864
8865    #[test_traced]
8866    fn test_init_at_size_clears_existing_data() {
8867        let executor = deterministic::Runner::default();
8868        executor.start(|context| async move {
8869            let cfg = Config {
8870                partition: "init-at-size-clears-existing".into(),
8871                items_per_section: NZU64!(5),
8872                compression: None,
8873                codec_config: (),
8874                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
8875                write_buffer: NZUsize!(1024),
8876                replay_buffer: NZUsize!(1024),
8877            };
8878
8879            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
8880                .await
8881                .unwrap();
8882            for i in 0..12u64 {
8883                (journal, _) = journal.append(&(100 + i)).await.unwrap();
8884            }
8885            journal.sync().await.unwrap();
8886
8887            let mut journal =
8888                Journal::<_, u64>::init_at_size(context.child("reset"), cfg.clone(), 7)
8889                    .await
8890                    .unwrap();
8891            assert_eq!(journal.bounds(), 7..7);
8892            let appended;
8893            (journal, appended) = journal.append(&700).await.unwrap();
8894            assert_eq!(appended, 7);
8895            journal.sync().await.unwrap();
8896
8897            let journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
8898                .await
8899                .unwrap();
8900            assert_eq!(journal.bounds(), 7..8);
8901            assert_eq!(journal.read(7).await.unwrap(), 700);
8902            assert!(matches!(journal.read(6).await, Err(Error::ItemPruned(6))));
8903            assert!(matches!(
8904                journal.read(8).await,
8905                Err(Error::ItemOutOfRange(8))
8906            ));
8907
8908            journal.destroy().await.unwrap();
8909        });
8910    }
8911
8912    #[test_traced]
8913    fn test_init_at_size_stages_reset_before_clearing_data() {
8914        let partition = "init-at-size-stage-before-clear-failure".to_string();
8915        let executor = deterministic::Runner::default();
8916        let ((), checkpoint) = executor.start_and_recover({
8917            let partition = partition.clone();
8918            |context| async move {
8919                let cfg = Config {
8920                    partition,
8921                    items_per_section: NZU64!(5),
8922                    compression: None,
8923                    codec_config: (),
8924                    page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
8925                    write_buffer: NZUsize!(1024),
8926                    replay_buffer: NZUsize!(1024),
8927                };
8928
8929                let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
8930                    .await
8931                    .unwrap();
8932                for i in 0..12u64 {
8933                    (journal, _) = journal.append(&(100 + i)).await.unwrap();
8934                }
8935                let journal = journal.sync().await.unwrap();
8936                drop(journal);
8937
8938                *context.storage_fault_config().write() = deterministic::FaultConfig {
8939                    sync_rate: Some(probability!(1.0)),
8940                    ..Default::default()
8941                };
8942                assert!(
8943                    Journal::<_, u64>::init_at_size(context.child("reset"), cfg, 7)
8944                        .await
8945                        .is_err()
8946                );
8947            }
8948        });
8949
8950        deterministic::Runner::from(checkpoint).start(move |context| async move {
8951            *context.storage_fault_config().write() = deterministic::FaultConfig::default();
8952            let cfg = Config {
8953                partition,
8954                items_per_section: NZU64!(5),
8955                compression: None,
8956                codec_config: (),
8957                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
8958                write_buffer: NZUsize!(1024),
8959                replay_buffer: NZUsize!(1024),
8960            };
8961
8962            let journal = Journal::<_, u64>::init(context.child("recover"), cfg.clone())
8963                .await
8964                .unwrap();
8965            assert_eq!(journal.bounds(), 0..12);
8966            for i in 0..12u64 {
8967                assert_eq!(journal.read(i).await.unwrap(), 100 + i);
8968            }
8969
8970            journal.destroy().await.unwrap();
8971        });
8972    }
8973
8974    #[test_traced]
8975    fn test_clear_to_size_stages_reset_before_clearing_data() {
8976        let partition = "clear-to-size-stage-before-clear-failure".to_string();
8977        let executor = deterministic::Runner::default();
8978        let ((), checkpoint) = executor.start_and_recover({
8979            let partition = partition.clone();
8980            |context| async move {
8981                let cfg = Config {
8982                    partition,
8983                    items_per_section: NZU64!(5),
8984                    compression: None,
8985                    codec_config: (),
8986                    page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
8987                    write_buffer: NZUsize!(1024),
8988                    replay_buffer: NZUsize!(1024),
8989                };
8990
8991                let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
8992                    .await
8993                    .unwrap();
8994                for i in 0..12u64 {
8995                    (journal, _) = journal.append(&(100 + i)).await.unwrap();
8996                }
8997                let journal = journal.sync().await.unwrap();
8998
8999                // Fail the offsets metadata sync inside `stage_clear_intent` so `clear_to_size`
9000                // aborts before any data is cleared. The reset intent never becomes durable.
9001                *context.storage_fault_config().write() = deterministic::FaultConfig {
9002                    sync_rate: Some(probability!(1.0)),
9003                    ..Default::default()
9004                };
9005                assert!(journal.0.clear_to_size(17).await.is_err());
9006            }
9007        });
9008
9009        deterministic::Runner::from(checkpoint).start(move |context| async move {
9010            *context.storage_fault_config().write() = deterministic::FaultConfig::default();
9011            let cfg = Config {
9012                partition,
9013                items_per_section: NZU64!(5),
9014                compression: None,
9015                codec_config: (),
9016                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
9017                write_buffer: NZUsize!(1024),
9018                replay_buffer: NZUsize!(1024),
9019            };
9020
9021            let journal = Journal::<_, u64>::init(context.child("recover"), cfg.clone())
9022                .await
9023                .unwrap();
9024            assert_eq!(journal.bounds(), 0..12);
9025            for i in 0..12u64 {
9026                assert_eq!(journal.read(i).await.unwrap(), 100 + i);
9027            }
9028
9029            journal.destroy().await.unwrap();
9030        });
9031    }
9032
9033    #[test_traced]
9034    fn test_clear_to_size_crash_after_staging_completes_on_init() {
9035        let partition = "clear-to-size-crash-after-staging".to_string();
9036        let executor = deterministic::Runner::default();
9037        let ((), checkpoint) = executor.start_and_recover({
9038            let partition = partition.clone();
9039            |context| async move {
9040                let cfg = Config {
9041                    partition,
9042                    items_per_section: NZU64!(5),
9043                    compression: None,
9044                    codec_config: (),
9045                    page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
9046                    write_buffer: NZUsize!(1024),
9047                    replay_buffer: NZUsize!(1024),
9048                };
9049
9050                let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
9051                    .await
9052                    .unwrap();
9053                for i in 0..12u64 {
9054                    (journal, _) = journal.append(&(100 + i)).await.unwrap();
9055                }
9056                journal = journal.sync().await.unwrap();
9057
9058                // Let `stage_clear_intent` (a metadata sync) persist the reset intent, but fail the
9059                // subsequent `data.clear()` (a blob remove) so `clear_to_size` aborts after the
9060                // intent is durable but before the data is cleared.
9061                *context.storage_fault_config().write() = deterministic::FaultConfig {
9062                    remove_rate: Some(probability!(1.0)),
9063                    ..Default::default()
9064                };
9065                assert!(journal.0.clear_to_size(17).await.is_err());
9066            }
9067        });
9068
9069        deterministic::Runner::from(checkpoint).start(move |context| async move {
9070            *context.storage_fault_config().write() = deterministic::FaultConfig::default();
9071            let cfg = Config {
9072                partition,
9073                items_per_section: NZU64!(5),
9074                compression: None,
9075                codec_config: (),
9076                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
9077                write_buffer: NZUsize!(1024),
9078                replay_buffer: NZUsize!(1024),
9079            };
9080
9081            // `init` finds the staged intent, discards the stale data, and completes the reset.
9082            let mut journal = Journal::<_, u64>::init(context.child("recover"), cfg.clone())
9083                .await
9084                .unwrap();
9085            assert_eq!(journal.bounds(), 17..17);
9086            let appended;
9087            (journal, appended) = journal.append(&700).await.unwrap();
9088            assert_eq!(appended, 17);
9089            journal.sync().await.unwrap();
9090
9091            // Reopen: the completed reset persists and no stale data was replayed.
9092            let journal = Journal::<_, u64>::init(context.child("reopen"), cfg.clone())
9093                .await
9094                .unwrap();
9095            assert_eq!(journal.bounds(), 17..18);
9096            assert_eq!(journal.read(17).await.unwrap(), 700);
9097
9098            journal.destroy().await.unwrap();
9099        });
9100    }
9101
9102    #[test_traced]
9103    fn test_init_at_size_recovers_staged_reset_crash_points() {
9104        let executor = deterministic::Runner::default();
9105        executor.start(|context| async move {
9106            for (index, clear_data) in [false, true].into_iter().enumerate() {
9107                let cfg = Config {
9108                    partition: format!("init-at-size-staged-reset-crash-{index}"),
9109                    items_per_section: NZU64!(5),
9110                    compression: None,
9111                    codec_config: (),
9112                    page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
9113                    write_buffer: NZUsize!(1024),
9114                    replay_buffer: NZUsize!(1024),
9115                };
9116
9117                let mut journal = Journal::<_, u64>::init(
9118                    context.child("first").with_attribute("index", index),
9119                    cfg.clone(),
9120                )
9121                .await
9122                .unwrap();
9123                for i in 0..12u64 {
9124                    (journal, _) = journal.append(&(100 + i)).await.unwrap();
9125                }
9126                let journal = journal.sync().await.unwrap();
9127                drop(journal);
9128
9129                let offsets_cfg = fixed::Config {
9130                    partition: cfg.offsets_partition(),
9131                    items_per_blob: cfg.items_per_section,
9132                    page_cache: cfg.page_cache.clone(),
9133                    write_buffer: cfg.write_buffer,
9134                    replay_buffer: cfg.replay_buffer,
9135                };
9136                // Simulate a crash mid-`init_at_size`: stage a clear intent in the offsets
9137                // checkpoint but leave data untouched (clear_data=false) or also clear data
9138                // (clear_data=true) so we cover both crash points.
9139                let intent_ctx = context.child("intent").with_attribute("index", index);
9140                fixed::Journal::<_, u64>::test_stage_clear(
9141                    intent_ctx.child("meta"),
9142                    &offsets_cfg.partition,
9143                    7,
9144                )
9145                .await
9146                .unwrap();
9147
9148                if clear_data {
9149                    Partition::<deterministic::Context>::remove_all(
9150                        &context,
9151                        &cfg.data_partition(),
9152                    )
9153                    .await
9154                    .unwrap();
9155                }
9156
9157                let mut journal = Journal::<_, u64>::init(
9158                    context.child("recover").with_attribute("index", index),
9159                    cfg.clone(),
9160                )
9161                .await
9162                .unwrap();
9163                assert_eq!(journal.bounds(), 7..7);
9164                let appended;
9165                (journal, appended) = journal.append(&700).await.unwrap();
9166                assert_eq!(appended, 7);
9167                journal.sync().await.unwrap();
9168
9169                let journal = Journal::<_, u64>::init(
9170                    context.child("reopen").with_attribute("index", index),
9171                    cfg.clone(),
9172                )
9173                .await
9174                .unwrap();
9175                assert_eq!(journal.bounds(), 7..8);
9176                assert_eq!(journal.read(7).await.unwrap(), 700);
9177
9178                journal.destroy().await.unwrap();
9179            }
9180        });
9181    }
9182
9183    #[test_traced]
9184    fn test_init_at_size_overwrites_pending_clear_target() {
9185        let executor = deterministic::Runner::default();
9186        executor.start(|context| async move {
9187            let cfg = Config {
9188                partition: "init-at-size-overwrites-pending-target".into(),
9189                items_per_section: NZU64!(5),
9190                compression: None,
9191                codec_config: (),
9192                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
9193                write_buffer: NZUsize!(1024),
9194                replay_buffer: NZUsize!(1024),
9195            };
9196
9197            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
9198                .await
9199                .unwrap();
9200            for i in 0..12u64 {
9201                (journal, _) = journal.append(&(100 + i)).await.unwrap();
9202            }
9203            let journal = journal.sync().await.unwrap();
9204            drop(journal);
9205
9206            // Simulate a prior `clear_to_size(5)` that crashed after staging its intent: the offsets
9207            // checkpoint carries a clear target of 5 while the data blobs still holds all 12 items.
9208            let offsets_cfg = fixed::Config {
9209                partition: cfg.offsets_partition(),
9210                items_per_blob: cfg.items_per_section,
9211                page_cache: cfg.page_cache.clone(),
9212                write_buffer: cfg.write_buffer,
9213                replay_buffer: cfg.replay_buffer,
9214            };
9215            let stale_ctx = context.child("stale");
9216            fixed::Journal::<_, u64>::test_stage_clear(
9217                stale_ctx.child("meta"),
9218                &offsets_cfg.partition,
9219                5,
9220            )
9221            .await
9222            .unwrap();
9223
9224            // init_at_size(10) overwrites the pending target of 5 and resets to 10.
9225            let mut journal =
9226                Journal::<_, u64>::init_at_size(context.child("reset"), cfg.clone(), 10)
9227                    .await
9228                    .unwrap();
9229            assert_eq!(journal.bounds(), 10..10);
9230            let appended;
9231            (journal, appended) = journal.append(&700).await.unwrap();
9232            assert_eq!(appended, 10);
9233            journal.sync().await.unwrap();
9234
9235            // Reopen: target 10 (not 5) persisted and no stale data was replayed.
9236            let journal = Journal::<_, u64>::init(context.child("reopen"), cfg.clone())
9237                .await
9238                .unwrap();
9239            assert_eq!(journal.bounds(), 10..11);
9240            assert_eq!(journal.read(10).await.unwrap(), 700);
9241
9242            journal.destroy().await.unwrap();
9243        });
9244    }
9245
9246    #[test_traced]
9247    fn test_init_at_size_discards_same_blob_stale_data() {
9248        let executor = deterministic::Runner::default();
9249        executor.start(|context| async move {
9250            let cfg = Config {
9251                partition: "init-at-size-discards-same-blob-stale-data".into(),
9252                items_per_section: NZU64!(5),
9253                compression: None,
9254                codec_config: (),
9255                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
9256                write_buffer: NZUsize!(1024),
9257                replay_buffer: NZUsize!(1024),
9258            };
9259
9260            let mut journal =
9261                Journal::<_, u64>::init_at_size(context.child("first"), cfg.clone(), 5)
9262                    .await
9263                    .unwrap();
9264            for i in 0..4u64 {
9265                let appended;
9266                (journal, appended) = journal.append(&(500 + i)).await.unwrap();
9267                assert_eq!(appended, 5 + i);
9268            }
9269            let journal = journal.sync().await.unwrap();
9270            drop(journal);
9271
9272            Journal::<_, u64>::init_at_size(context.child("reset"), cfg.clone(), 7)
9273                .await
9274                .unwrap();
9275
9276            let mut journal = Journal::<_, u64>::init(context.child("after_reset"), cfg.clone())
9277                .await
9278                .unwrap();
9279            assert_eq!(journal.bounds(), 7..7);
9280            assert!(matches!(
9281                journal.read(7).await,
9282                Err(Error::ItemOutOfRange(7))
9283            ));
9284
9285            let appended;
9286            (journal, appended) = journal.append(&700).await.unwrap();
9287            assert_eq!(appended, 7);
9288            journal.sync().await.unwrap();
9289
9290            let journal = Journal::<_, u64>::init(context.child("after_append"), cfg.clone())
9291                .await
9292                .unwrap();
9293            assert_eq!(journal.bounds(), 7..8);
9294            assert_eq!(journal.read(7).await.unwrap(), 700);
9295            assert!(matches!(
9296                journal.read(8).await,
9297                Err(Error::ItemOutOfRange(8))
9298            ));
9299
9300            journal.destroy().await.unwrap();
9301        });
9302    }
9303
9304    /// Test init_at_size with mid-blob value persists correctly across restart.
9305    #[test_traced]
9306    fn test_init_at_size_mid_blob_persistence() {
9307        let executor = deterministic::Runner::default();
9308        executor.start(|context| async move {
9309            let cfg = Config {
9310                partition: "init-at-size-mid-blob".into(),
9311                items_per_section: NZU64!(5),
9312                compression: None,
9313                codec_config: (),
9314                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
9315                write_buffer: NZUsize!(1024),
9316                replay_buffer: NZUsize!(1024),
9317            };
9318
9319            // Initialize at position 7 (mid-blob, 7 % 5 = 2)
9320            let mut journal =
9321                Journal::<_, u64>::init_at_size(context.child("first"), cfg.clone(), 7)
9322                    .await
9323                    .unwrap();
9324
9325            // Append 3 items at positions 7, 8, 9 (fills rest of blob 1)
9326            for i in 0..3u64 {
9327                let pos;
9328                (journal, pos) = journal.append(&(700 + i)).await.unwrap();
9329                assert_eq!(pos, 7 + i);
9330            }
9331
9332            let bounds = journal.bounds();
9333            assert_eq!(bounds.end, 10);
9334            assert_eq!(bounds.start, 7);
9335
9336            // Sync and reopen
9337            journal.sync().await.unwrap();
9338
9339            // Reopen
9340            let journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
9341                .await
9342                .unwrap();
9343
9344            // Size and bounds.start should be preserved correctly
9345            let bounds = journal.bounds();
9346            assert_eq!(bounds.end, 10);
9347            assert_eq!(bounds.start, 7);
9348
9349            // Verify data
9350            for i in 0..3u64 {
9351                assert_eq!(journal.read(7 + i).await.unwrap(), 700 + i);
9352            }
9353
9354            // Positions before 7 should be pruned
9355            assert!(matches!(journal.read(6).await, Err(Error::ItemPruned(6))));
9356
9357            journal.destroy().await.unwrap();
9358        });
9359    }
9360
9361    /// Test init_at_size mid-blob with data spanning multiple blobs.
9362    #[test_traced]
9363    fn test_init_at_size_mid_blob_multi_blob_persistence() {
9364        let executor = deterministic::Runner::default();
9365        executor.start(|context| async move {
9366            let cfg = Config {
9367                partition: "init-at-size-multi-blob".into(),
9368                items_per_section: NZU64!(5),
9369                compression: None,
9370                codec_config: (),
9371                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
9372                write_buffer: NZUsize!(1024),
9373                replay_buffer: NZUsize!(1024),
9374            };
9375
9376            // Initialize at position 7 (mid-blob)
9377            let mut journal =
9378                Journal::<_, u64>::init_at_size(context.child("first"), cfg.clone(), 7)
9379                    .await
9380                    .unwrap();
9381
9382            // Append 8 items: positions 7-14 (blob 1: 3 items, blob 2: 5 items)
9383            for i in 0..8u64 {
9384                let pos;
9385                (journal, pos) = journal.append(&(700 + i)).await.unwrap();
9386                assert_eq!(pos, 7 + i);
9387            }
9388
9389            let bounds = journal.bounds();
9390            assert_eq!(bounds.end, 15);
9391            assert_eq!(bounds.start, 7);
9392
9393            // Sync and reopen
9394            journal.sync().await.unwrap();
9395
9396            // Reopen
9397            let journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
9398                .await
9399                .unwrap();
9400
9401            // Verify state preserved
9402            let bounds = journal.bounds();
9403            assert_eq!(bounds.end, 15);
9404            assert_eq!(bounds.start, 7);
9405
9406            // Verify all data
9407            for i in 0..8u64 {
9408                assert_eq!(journal.read(7 + i).await.unwrap(), 700 + i);
9409            }
9410
9411            journal.destroy().await.unwrap();
9412        });
9413    }
9414
9415    /// Regression test: data-empty crash repair must preserve mid-blob pruning boundary.
9416    #[test_traced]
9417    fn test_align_journals_data_empty_mid_blob_pruning_boundary() {
9418        let executor = deterministic::Runner::default();
9419        executor.start(|context| async move {
9420            let cfg = Config {
9421                partition: "align-journals-mid-blob-pruning-boundary".into(),
9422                items_per_section: NZU64!(5),
9423                compression: None,
9424                codec_config: (),
9425                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
9426                write_buffer: NZUsize!(1024),
9427                replay_buffer: NZUsize!(1024),
9428            };
9429
9430            // Phase 1: Create data and offsets, then simulate data-only pruning crash.
9431            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
9432                .await
9433                .unwrap();
9434            for i in 0..7u64 {
9435                (journal, _) = journal.append(&(100 + i)).await.unwrap();
9436            }
9437            journal = journal.sync().await.unwrap();
9438
9439            // Simulate crash after data was cleared but before offsets were pruned.
9440            drop(journal);
9441            Partition::<deterministic::Context>::remove_all(&context, &cfg.data_partition())
9442                .await
9443                .unwrap();
9444
9445            // Phase 2: Init triggers data-empty repair and should treat journal as fully pruned at size 7.
9446            let mut journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
9447                .await
9448                .unwrap();
9449            let bounds = journal.bounds();
9450            assert_eq!(bounds.end, 7);
9451            assert!(bounds.is_empty());
9452
9453            // Append one item at position 7.
9454            let pos;
9455            (journal, pos) = journal.append(&777).await.unwrap();
9456            assert_eq!(pos, 7);
9457            assert_eq!(journal.size(), 8);
9458            assert_eq!(journal.read(7).await.unwrap(), 777);
9459
9460            // Sync only the data blobs to simulate a crash before offsets are synced.
9461            let (blobs, sync) = journal.0.blobs.start_sync().await.unwrap();
9462            journal.0.blobs = blobs;
9463            sync.await.unwrap();
9464            drop(journal);
9465
9466            // Phase 3: Reopen and verify we did not lose the appended item.
9467            let journal = Journal::<_, u64>::init(context.child("third"), cfg.clone())
9468                .await
9469                .unwrap();
9470            let bounds = journal.bounds();
9471            assert_eq!(bounds.end, 8);
9472            assert_eq!(bounds.start, 7);
9473            assert_eq!(journal.read(7).await.unwrap(), 777);
9474
9475            journal.destroy().await.unwrap();
9476        });
9477    }
9478
9479    /// Test crash recovery: init_at_size + append + crash with data synced but offsets not.
9480    #[test_traced]
9481    fn test_init_at_size_crash_data_synced_offsets_not() {
9482        let executor = deterministic::Runner::default();
9483        executor.start(|context| async move {
9484            let cfg = Config {
9485                partition: "init-at-size-crash-recovery".into(),
9486                items_per_section: NZU64!(5),
9487                compression: None,
9488                codec_config: (),
9489                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
9490                write_buffer: NZUsize!(1024),
9491                replay_buffer: NZUsize!(1024),
9492            };
9493
9494            // Initialize at position 7 (mid-blob)
9495            let mut journal =
9496                Journal::<_, u64>::init_at_size(context.child("first"), cfg.clone(), 7)
9497                    .await
9498                    .unwrap();
9499
9500            // Append 3 items
9501            for i in 0..3u64 {
9502                (journal, _) = journal.append(&(700 + i)).await.unwrap();
9503            }
9504
9505            // Sync only the data blobs, not offsets (simulate crash). The appended items
9506            // live in (sealed) blob 1.
9507            journal = journal.test_sync_data_blob(1).await.unwrap();
9508
9509            // Don't sync offsets. This simulates a crash after the data write but before the
9510            // offsets write.
9511            drop(journal);
9512
9513            // Reopening should recover by rebuilding offsets from data
9514            let journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
9515                .await
9516                .unwrap();
9517
9518            // Verify recovery
9519            let bounds = journal.bounds();
9520            assert_eq!(bounds.end, 10);
9521            assert_eq!(bounds.start, 7);
9522
9523            // Verify data is accessible
9524            for i in 0..3u64 {
9525                assert_eq!(journal.read(7 + i).await.unwrap(), 700 + i);
9526            }
9527
9528            journal.destroy().await.unwrap();
9529        });
9530    }
9531
9532    #[test_traced]
9533    fn test_prune_does_not_move_oldest_retained_backwards() {
9534        let executor = deterministic::Runner::default();
9535        executor.start(|context| async move {
9536            let cfg = Config {
9537                partition: "prune-no-backwards".into(),
9538                items_per_section: NZU64!(5),
9539                compression: None,
9540                codec_config: (),
9541                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
9542                write_buffer: NZUsize!(1024),
9543                replay_buffer: NZUsize!(1024),
9544            };
9545
9546            let mut journal =
9547                Journal::<_, u64>::init_at_size(context.child("first"), cfg.clone(), 7)
9548                    .await
9549                    .unwrap();
9550
9551            // Append a few items at positions 7..9
9552            for i in 0..3u64 {
9553                let pos;
9554                (journal, pos) = journal.append(&(700 + i)).await.unwrap();
9555                assert_eq!(pos, 7 + i);
9556            }
9557            assert_eq!(journal.bounds().start, 7);
9558
9559            // Prune to a position within the same blob should not move bounds.start backwards.
9560            (journal, _) = journal.prune(8).await.unwrap();
9561            assert_eq!(journal.bounds().start, 7);
9562            assert!(matches!(journal.read(6).await, Err(Error::ItemPruned(6))));
9563            assert_eq!(journal.read(7).await.unwrap(), 700);
9564
9565            journal.destroy().await.unwrap();
9566        });
9567    }
9568
9569    #[test_traced]
9570    fn test_variable_recovery_near_max_data_synced_offsets_not() {
9571        let executor = deterministic::Runner::default();
9572        executor.start(|context| async move {
9573            let cfg = Config {
9574                partition: "near-max-data-synced-offsets-not".into(),
9575                items_per_section: NZU64!(10),
9576                compression: None,
9577                codec_config: (),
9578                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
9579                write_buffer: NZUsize!(1024),
9580                replay_buffer: NZUsize!(1024),
9581            };
9582
9583            let mut journal =
9584                Journal::<_, u64>::init_at_size(context.child("first"), cfg.clone(), u64::MAX - 1)
9585                    .await
9586                    .unwrap();
9587            let appended;
9588            (journal, appended) = journal.append(&7).await.unwrap();
9589            assert_eq!(appended, u64::MAX - 1);
9590            journal = journal
9591                .test_sync_data_blob(position_to_blob(u64::MAX - 1, cfg.items_per_section.get()))
9592                .await
9593                .unwrap();
9594            drop(journal);
9595
9596            let journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
9597                .await
9598                .unwrap();
9599            assert_eq!(journal.bounds(), (u64::MAX - 1)..u64::MAX);
9600            assert_eq!(journal.read(u64::MAX - 1).await.unwrap(), 7);
9601
9602            journal.destroy().await.unwrap();
9603        });
9604    }
9605
9606    #[test_traced]
9607    fn test_init_at_size_large_offset() {
9608        let executor = deterministic::Runner::default();
9609        executor.start(|context| async move {
9610            let cfg = Config {
9611                partition: "init-at-size-large".into(),
9612                items_per_section: NZU64!(5),
9613                compression: None,
9614                codec_config: (),
9615                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
9616                write_buffer: NZUsize!(1024),
9617                replay_buffer: NZUsize!(1024),
9618            };
9619
9620            // Initialize at a large position (position 1000)
9621            let mut journal =
9622                Journal::<_, u64>::init_at_size(context.child("storage"), cfg.clone(), 1000)
9623                    .await
9624                    .unwrap();
9625
9626            let bounds = journal.bounds();
9627            assert_eq!(bounds.end, 1000);
9628            // No data yet, so no oldest retained position
9629            assert!(bounds.is_empty());
9630
9631            // Next append should get position 1000
9632            let pos;
9633            (journal, pos) = journal.append(&100000).await.unwrap();
9634            assert_eq!(pos, 1000);
9635            assert_eq!(journal.read(1000).await.unwrap(), 100000);
9636
9637            journal.destroy().await.unwrap();
9638        });
9639    }
9640
9641    #[test_traced]
9642    fn test_init_at_size_prune_and_append() {
9643        let executor = deterministic::Runner::default();
9644        executor.start(|context| async move {
9645            let cfg = Config {
9646                partition: "init-at-size-prune".into(),
9647                items_per_section: NZU64!(5),
9648                compression: None,
9649                codec_config: (),
9650                page_cache: CacheRef::new(SMALL_PAGE_SIZE, NZUsize!(2)),
9651                write_buffer: NZUsize!(1024),
9652                replay_buffer: NZUsize!(1024),
9653            };
9654
9655            // Initialize at position 20
9656            let mut journal =
9657                Journal::<_, u64>::init_at_size(context.child("storage"), cfg.clone(), 20)
9658                    .await
9659                    .unwrap();
9660
9661            // Append items 20-29
9662            for i in 0..10u64 {
9663                (journal, _) = journal.append(&(2000 + i)).await.unwrap();
9664            }
9665
9666            assert_eq!(journal.size(), 30);
9667
9668            // Prune to position 25
9669            (journal, _) = journal.prune(25).await.unwrap();
9670
9671            let bounds = journal.bounds();
9672            assert_eq!(bounds.end, 30);
9673            assert_eq!(bounds.start, 25);
9674
9675            // Verify remaining items are readable
9676            for i in 25..30u64 {
9677                assert_eq!(journal.read(i).await.unwrap(), 2000 + (i - 20));
9678            }
9679
9680            // Continue appending
9681            let pos;
9682            (journal, pos) = journal.append(&3000).await.unwrap();
9683            assert_eq!(pos, 30);
9684
9685            journal.destroy().await.unwrap();
9686        });
9687    }
9688
9689    /// Test `init_sync` when there is no existing data on disk.
9690    #[test_traced]
9691    fn test_init_sync_no_existing_data() {
9692        let executor = deterministic::Runner::default();
9693        executor.start(|context| async move {
9694            let cfg = Config {
9695                partition: "test-fresh-start".into(),
9696                items_per_section: NZU64!(5),
9697                compression: None,
9698                codec_config: (),
9699                write_buffer: NZUsize!(1024),
9700                replay_buffer: NZUsize!(1024),
9701                page_cache: CacheRef::new(PAGE_SIZE, NZUsize!(PAGE_CACHE_SIZE)),
9702            };
9703
9704            // Initialize journal with sync boundaries when no existing data exists
9705            let lower_bound = 10;
9706            let upper_bound = 26;
9707            let mut journal = authenticated::init_sync::<_, Journal<_, u64>>(
9708                context.child("storage"),
9709                cfg.clone(),
9710                lower_bound..upper_bound,
9711            )
9712            .await
9713            .expect("Failed to initialize journal with sync boundaries");
9714
9715            let bounds = journal.bounds();
9716            assert_eq!(bounds.end, lower_bound);
9717            assert!(bounds.is_empty());
9718
9719            // Append items using the contiguous API
9720            let pos1;
9721            (journal, pos1) = journal.append(&42u64).await.unwrap();
9722            assert_eq!(pos1, lower_bound);
9723            assert_eq!(journal.read(pos1).await.unwrap(), 42u64);
9724
9725            let pos2;
9726            (journal, pos2) = journal.append(&43u64).await.unwrap();
9727            assert_eq!(pos2, lower_bound + 1);
9728            assert_eq!(journal.read(pos2).await.unwrap(), 43u64);
9729
9730            journal.destroy().await.unwrap();
9731        });
9732    }
9733
9734    /// Test `init_sync` when there is existing data that overlaps with the sync target range.
9735    #[test_traced]
9736    fn test_init_sync_existing_data_overlap() {
9737        let executor = deterministic::Runner::default();
9738        executor.start(|context| async move {
9739            let cfg = Config {
9740                partition: "test-overlap".into(),
9741                items_per_section: NZU64!(5),
9742                compression: None,
9743                codec_config: (),
9744                write_buffer: NZUsize!(1024),
9745                replay_buffer: NZUsize!(1024),
9746                page_cache: CacheRef::new(PAGE_SIZE, NZUsize!(PAGE_CACHE_SIZE)),
9747            };
9748
9749            // Create initial journal with data in multiple blobs
9750            let mut journal =
9751                Journal::<deterministic::Context, u64>::init(context.child("storage"), cfg.clone())
9752                    .await
9753                    .expect("Failed to create initial journal");
9754
9755            // Add data at positions 0-19 (blobs 0-3 with items_per_section=5)
9756            for i in 0..20u64 {
9757                (journal, _) = journal.append(&(i * 100)).await.unwrap();
9758            }
9759            let journal = journal.sync().await.unwrap();
9760            drop(journal);
9761
9762            // Initialize with sync boundaries that overlap with existing data
9763            // lower_bound: 8 (blob 1), upper_bound: 31 (last location 30, blob 6)
9764            let lower_bound = 8;
9765            let upper_bound = 31;
9766            let mut journal = authenticated::init_sync::<_, Journal<_, u64>>(
9767                context.child("storage"),
9768                cfg.clone(),
9769                lower_bound..upper_bound,
9770            )
9771            .await
9772            .expect("Failed to initialize journal with overlap");
9773
9774            assert_eq!(journal.size(), 20);
9775
9776            // Verify oldest retained is pruned to lower_bound's blob boundary (5)
9777            assert_eq!(journal.bounds().start, 5); // Blob 1 starts at position 5
9778
9779            // Verify data integrity: positions before 5 are pruned
9780            assert!(matches!(journal.read(0).await, Err(Error::ItemPruned(_))));
9781            assert!(matches!(journal.read(4).await, Err(Error::ItemPruned(_))));
9782
9783            // Positions 5-19 should be accessible
9784            assert_eq!(journal.read(5).await.unwrap(), 500);
9785            assert_eq!(journal.read(8).await.unwrap(), 800);
9786            assert_eq!(journal.read(19).await.unwrap(), 1900);
9787
9788            // Position 20+ should not exist yet
9789            assert!(matches!(
9790                journal.read(20).await,
9791                Err(Error::ItemOutOfRange(_))
9792            ));
9793
9794            // Assert journal can accept new items
9795            let pos;
9796            (journal, pos) = journal.append(&999).await.unwrap();
9797            assert_eq!(pos, 20);
9798            assert_eq!(journal.read(20).await.unwrap(), 999);
9799
9800            journal.destroy().await.unwrap();
9801        });
9802    }
9803
9804    /// Test `init_sync` with invalid parameters.
9805    #[should_panic]
9806    #[test_traced]
9807    fn test_init_sync_invalid_parameters() {
9808        let executor = deterministic::Runner::default();
9809        executor.start(|context| async move {
9810            let cfg = Config {
9811                partition: "test-invalid".into(),
9812                items_per_section: NZU64!(5),
9813                compression: None,
9814                codec_config: (),
9815                write_buffer: NZUsize!(1024),
9816                replay_buffer: NZUsize!(1024),
9817                page_cache: CacheRef::new(PAGE_SIZE, NZUsize!(PAGE_CACHE_SIZE)),
9818            };
9819
9820            #[allow(clippy::reversed_empty_ranges)]
9821            let _result = authenticated::init_sync::<_, Journal<_, u64>>(
9822                context.child("storage"),
9823                cfg,
9824                10..5, // invalid range: lower > upper
9825            )
9826            .await;
9827        });
9828    }
9829
9830    /// Test `init_sync` when existing data exactly matches the sync range.
9831    #[test_traced]
9832    fn test_init_sync_existing_data_exact_match() {
9833        let executor = deterministic::Runner::default();
9834        executor.start(|context| async move {
9835            let items_per_section = NZU64!(5);
9836            let cfg = Config {
9837                partition: "test-exact-match".into(),
9838                items_per_section,
9839                compression: None,
9840                codec_config: (),
9841                write_buffer: NZUsize!(1024),
9842                replay_buffer: NZUsize!(1024),
9843                page_cache: CacheRef::new(PAGE_SIZE, NZUsize!(PAGE_CACHE_SIZE)),
9844            };
9845
9846            // Create initial journal with data exactly matching sync range
9847            let mut journal =
9848                Journal::<deterministic::Context, u64>::init(context.child("storage"), cfg.clone())
9849                    .await
9850                    .expect("Failed to create initial journal");
9851
9852            // Add data at positions 0-19 (blobs 0-3 with items_per_section=5)
9853            for i in 0..20u64 {
9854                (journal, _) = journal.append(&(i * 100)).await.unwrap();
9855            }
9856            let journal = journal.sync().await.unwrap();
9857            drop(journal);
9858
9859            // Initialize with sync boundaries that exactly match existing data
9860            let lower_bound = 5; // blob 1
9861            let upper_bound = 20; // blob 3
9862            let mut journal = authenticated::init_sync::<_, Journal<_, u64>>(
9863                context.child("storage"),
9864                cfg.clone(),
9865                lower_bound..upper_bound,
9866            )
9867            .await
9868            .expect("Failed to initialize journal with exact match");
9869
9870            assert_eq!(journal.size(), 20);
9871
9872            // Verify pruning to lower bound (blob 1 boundary = position 5)
9873            assert_eq!(journal.bounds().start, 5); // Blob 1 starts at position 5
9874
9875            // Verify positions before 5 are pruned
9876            assert!(matches!(journal.read(0).await, Err(Error::ItemPruned(_))));
9877            assert!(matches!(journal.read(4).await, Err(Error::ItemPruned(_))));
9878
9879            // Positions 5-19 should be accessible
9880            assert_eq!(journal.read(5).await.unwrap(), 500);
9881            assert_eq!(journal.read(10).await.unwrap(), 1000);
9882            assert_eq!(journal.read(19).await.unwrap(), 1900);
9883
9884            // Position 20+ should not exist yet
9885            assert!(matches!(
9886                journal.read(20).await,
9887                Err(Error::ItemOutOfRange(_))
9888            ));
9889
9890            // Assert journal can accept new operations
9891            let pos;
9892            (journal, pos) = journal.append(&999).await.unwrap();
9893            assert_eq!(pos, 20);
9894            assert_eq!(journal.read(20).await.unwrap(), 999);
9895
9896            journal.destroy().await.unwrap();
9897        });
9898    }
9899
9900    /// Test `init_sync` truncates data that exceeds the sync target range.
9901    #[test_traced]
9902    fn test_init_sync_truncates_data_exceeding_upper_bound() {
9903        let executor = deterministic::Runner::default();
9904        executor.start(|context| async move {
9905            let items_per_section = NZU64!(5);
9906            let cfg = Config {
9907                partition: "test-unexpected-data".into(),
9908                items_per_section,
9909                compression: None,
9910                codec_config: (),
9911                write_buffer: NZUsize!(1024),
9912                replay_buffer: NZUsize!(1024),
9913                page_cache: CacheRef::new(PAGE_SIZE, NZUsize!(PAGE_CACHE_SIZE)),
9914            };
9915
9916            // Create initial journal with data beyond sync range
9917            let mut journal =
9918                Journal::<deterministic::Context, u64>::init(context.child("initial"), cfg.clone())
9919                    .await
9920                    .expect("Failed to create initial journal");
9921
9922            // Add data at positions 0-29 (blobs 0-5 with items_per_section=5)
9923            for i in 0..30u64 {
9924                (journal, _) = journal.append(&(i * 1000)).await.unwrap();
9925            }
9926            let journal = journal.sync().await.unwrap();
9927            drop(journal);
9928
9929            // Initialize with sync boundaries that are exceeded by existing data.
9930            let lower_bound = 8; // blob 1
9931            let upper_bound = 20;
9932            let journal = authenticated::init_sync::<_, Journal<_, u64>>(
9933                context.child("sync"),
9934                cfg.clone(),
9935                lower_bound..upper_bound,
9936            )
9937            .await
9938            .expect("Failed to truncate journal to the older sync range");
9939
9940            assert_eq!(journal.bounds(), 5..upper_bound);
9941            for i in lower_bound..upper_bound {
9942                assert_eq!(journal.read(i).await.unwrap(), i * 1000);
9943            }
9944            journal.destroy().await.unwrap();
9945        });
9946    }
9947
9948    /// Test `init_sync` repairs an empty journal recovered at a stale position beyond the range.
9949    #[test_traced]
9950    fn test_init_sync_empty_stale_position_beyond_upper_bound() {
9951        let executor = deterministic::Runner::default();
9952        executor.start(|context| async move {
9953            let cfg = Config {
9954                partition: "test-empty-stale-position".into(),
9955                items_per_section: NZU64!(5),
9956                compression: None,
9957                codec_config: (),
9958                write_buffer: NZUsize!(1024),
9959                replay_buffer: NZUsize!(1024),
9960                page_cache: CacheRef::new(PAGE_SIZE, NZUsize!(PAGE_CACHE_SIZE)),
9961            };
9962
9963            let stale_size = 30;
9964            let journal = Journal::<deterministic::Context, u64>::init_at_size(
9965                context.child("first"),
9966                cfg.clone(),
9967                stale_size,
9968            )
9969            .await
9970            .expect("Failed to create stale empty journal");
9971            assert_eq!(journal.size(), stale_size);
9972            assert!(journal.bounds().is_empty());
9973            drop(journal);
9974
9975            let lower_bound = 10;
9976            let upper_bound = 26;
9977            let mut journal = authenticated::init_sync::<_, Journal<_, u64>>(
9978                context.child("second"),
9979                cfg.clone(),
9980                lower_bound..upper_bound,
9981            )
9982            .await
9983            .expect("Failed to repair stale empty journal");
9984
9985            assert_eq!(journal.size(), lower_bound);
9986            assert!(journal.bounds().is_empty());
9987
9988            let pos;
9989            (journal, pos) = journal.append(&999).await.unwrap();
9990            assert_eq!(pos, lower_bound);
9991            assert_eq!(journal.read(pos).await.unwrap(), 999);
9992
9993            journal.destroy().await.unwrap();
9994        });
9995    }
9996
9997    /// Test `init_sync` repairs an empty journal recovered after a `clear_to_size` crash.
9998    #[test_traced]
9999    fn test_init_sync_recovers_from_stale_clear_to_size() {
10000        let executor = deterministic::Runner::default();
10001        executor.start(|context| async move {
10002            let cfg = Config {
10003                partition: "test-stale-clear-to-size".into(),
10004                items_per_section: NZU64!(5),
10005                compression: None,
10006                codec_config: (),
10007                write_buffer: NZUsize!(1024),
10008                replay_buffer: NZUsize!(1024),
10009                page_cache: CacheRef::new(PAGE_SIZE, NZUsize!(PAGE_CACHE_SIZE)),
10010            };
10011
10012            let journal = Journal::<deterministic::Context, u64>::init_at_size(
10013                context.child("first"),
10014                cfg.clone(),
10015                9,
10016            )
10017            .await
10018            .expect("Failed to create stale empty journal");
10019            let journal = journal.sync().await.unwrap();
10020            drop(journal);
10021
10022            // Simulate clear_to_size(7) crashing after clearing data, but before offsets were
10023            // re-cleared. Recovery will initially see the old empty offsets boundary at 9.
10024            match context.remove(&cfg.data_partition(), None).await {
10025                Ok(()) | Err(commonware_runtime::Error::PartitionMissing(_)) => {}
10026                Err(error) => panic!("failed to clear data partition: {error}"),
10027            }
10028
10029            let lower_bound = 7;
10030            let upper_bound = 20;
10031            let journal = authenticated::init_sync::<_, Journal<_, u64>>(
10032                context.child("second"),
10033                cfg.clone(),
10034                lower_bound..upper_bound,
10035            )
10036            .await
10037            .expect("Failed to repair stale empty journal");
10038
10039            assert_eq!(journal.size(), lower_bound);
10040            let bounds = journal.bounds();
10041            assert!(bounds.is_empty());
10042            assert_eq!(bounds.start, lower_bound);
10043
10044            journal.destroy().await.unwrap();
10045        });
10046    }
10047
10048    /// Test `init_sync` as the first open after a clear crashed with its intent staged.
10049    #[rstest::rstest]
10050    fn test_init_sync_after_interrupted_clear(
10051        #[values(7, 50, 60)] start: u64,
10052        #[values(false, true)] removed: bool,
10053    ) {
10054        deterministic::Runner::default().start(|context| async move {
10055            let cfg = Config {
10056                partition: "test-interrupted-clear".into(),
10057                items_per_section: NZU64!(5),
10058                compression: None,
10059                codec_config: (),
10060                write_buffer: NZUsize!(1024),
10061                replay_buffer: NZUsize!(1024),
10062                page_cache: CacheRef::new(PAGE_SIZE, NZUsize!(PAGE_CACHE_SIZE)),
10063            };
10064
10065            // Persist 30 items across six sections.
10066            let mut journal = Journal::<_, u64>::init(context.child("seed"), cfg.clone())
10067                .await
10068                .unwrap();
10069            for value in 0..30u64 {
10070                (journal, _) = journal.append(&value).await.unwrap();
10071            }
10072            drop(journal.sync().await.unwrap());
10073
10074            // Crash a clear to 50 after it durably staged its intent, either before or after it
10075            // removed the data partition. Staging writes only the offsets checkpoint, so the
10076            // offsets blobs survive in both cuts.
10077            fixed::Journal::<_, u64>::test_stage_clear(
10078                context.child("intent"),
10079                &cfg.offsets_partition(),
10080                50,
10081            )
10082            .await
10083            .unwrap();
10084            if removed {
10085                context.remove(&cfg.data_partition(), None).await.unwrap();
10086            }
10087
10088            // No ordinary open consumes the intent first. The span of a staged clear is empty at
10089            // its target, so only a start of 50 calls `recover`, which completes the staged clear.
10090            // Starts 7 and 60 call `clear`, which replaces the intent with a clear to the start.
10091            let mut journal = authenticated::init_sync::<_, Journal<_, u64>>(
10092                context.child("sync"),
10093                cfg.clone(),
10094                start..start + 20,
10095            )
10096            .await
10097            .unwrap();
10098            assert_eq!(journal.bounds(), start..start);
10099
10100            // The reset journal appends from the sync start. Values from 1000 differ from every
10101            // stale value, so the reads below tell new items from stale ones.
10102            for value in 0..3u64 {
10103                let pos;
10104                (journal, pos) = journal.append(&(1000 + value)).await.unwrap();
10105                assert_eq!(pos, start + value);
10106            }
10107            drop(journal.sync().await.unwrap());
10108
10109            // Reopening retains only the new items.
10110            let journal = Journal::<_, u64>::init(context.child("reopen"), cfg)
10111                .await
10112                .unwrap();
10113            assert_eq!(journal.bounds(), start..start + 3);
10114            for value in 0..3u64 {
10115                assert_eq!(journal.read(start + value).await.unwrap(), 1000 + value);
10116            }
10117
10118            // Positions below the start are pruned and positions past the new items are out of
10119            // range, so no stale item from before the clear is readable.
10120            for pos in 0..30 {
10121                let result = journal.read(pos).await;
10122                if pos < start {
10123                    assert!(
10124                        matches!(result, Err(Error::ItemPruned(_))),
10125                        "{pos}: {result:?}"
10126                    );
10127                } else if pos >= start + 3 {
10128                    assert!(
10129                        matches!(result, Err(Error::ItemOutOfRange(_))),
10130                        "{pos}: {result:?}"
10131                    );
10132                }
10133            }
10134
10135            journal.destroy().await.unwrap();
10136        });
10137    }
10138
10139    /// Test `init_sync` when all existing data is stale (before lower bound).
10140    #[test_traced]
10141    fn test_init_sync_existing_data_stale() {
10142        let executor = deterministic::Runner::default();
10143        executor.start(|context| async move {
10144            let items_per_section = NZU64!(5);
10145            let cfg = Config {
10146                partition: "test-stale".into(),
10147                items_per_section,
10148                compression: None,
10149                codec_config: (),
10150                write_buffer: NZUsize!(1024),
10151                replay_buffer: NZUsize!(1024),
10152                page_cache: CacheRef::new(PAGE_SIZE, NZUsize!(PAGE_CACHE_SIZE)),
10153            };
10154
10155            // Create initial journal with stale data
10156            let mut journal =
10157                Journal::<deterministic::Context, u64>::init(context.child("first"), cfg.clone())
10158                    .await
10159                    .expect("Failed to create initial journal");
10160
10161            // Add data at positions 0-9 (blobs 0-1 with items_per_section=5)
10162            for i in 0..10u64 {
10163                (journal, _) = journal.append(&(i * 100)).await.unwrap();
10164            }
10165            let journal = journal.sync().await.unwrap();
10166            drop(journal);
10167
10168            // Initialize with sync boundaries beyond all existing data
10169            let lower_bound = 15; // blob 3
10170            let upper_bound = 26; // last element in blob 5
10171            let journal = authenticated::init_sync::<_, Journal<_, u64>>(
10172                context.child("second"),
10173                cfg.clone(),
10174                lower_bound..upper_bound,
10175            )
10176            .await
10177            .expect("Failed to initialize journal with stale data");
10178
10179            assert_eq!(journal.size(), 15);
10180
10181            // Verify fresh journal (all old data destroyed, starts at position 15)
10182            assert!(journal.bounds().is_empty());
10183
10184            // Verify old positions don't exist
10185            assert!(matches!(journal.read(0).await, Err(Error::ItemPruned(_))));
10186            assert!(matches!(journal.read(9).await, Err(Error::ItemPruned(_))));
10187            assert!(matches!(journal.read(14).await, Err(Error::ItemPruned(_))));
10188
10189            journal.destroy().await.unwrap();
10190        });
10191    }
10192
10193    /// Test `init_sync` with blob boundary edge cases.
10194    #[test_traced]
10195    fn test_init_sync_blob_boundaries() {
10196        let executor = deterministic::Runner::default();
10197        executor.start(|context| async move {
10198            let items_per_section = NZU64!(5);
10199            let cfg = Config {
10200                partition: "test-boundaries".into(),
10201                items_per_section,
10202                compression: None,
10203                codec_config: (),
10204                write_buffer: NZUsize!(1024),
10205                replay_buffer: NZUsize!(1024),
10206                page_cache: CacheRef::new(PAGE_SIZE, NZUsize!(PAGE_CACHE_SIZE)),
10207            };
10208
10209            // Create journal with data at blob boundaries
10210            let mut journal =
10211                Journal::<deterministic::Context, u64>::init(context.child("storage"), cfg.clone())
10212                    .await
10213                    .expect("Failed to create initial journal");
10214
10215            // Add data at positions 0-24 (blobs 0-4 with items_per_section=5)
10216            for i in 0..25u64 {
10217                (journal, _) = journal.append(&(i * 100)).await.unwrap();
10218            }
10219            let journal = journal.sync().await.unwrap();
10220            drop(journal);
10221
10222            // Test sync boundaries exactly at blob boundaries
10223            let lower_bound = 15; // Exactly at blob boundary (15/5 = 3)
10224            let upper_bound = 25; // Last element exactly at blob boundary (24/5 = 4)
10225            let mut journal = authenticated::init_sync::<_, Journal<_, u64>>(
10226                context.child("storage"),
10227                cfg.clone(),
10228                lower_bound..upper_bound,
10229            )
10230            .await
10231            .expect("Failed to initialize journal at boundaries");
10232
10233            assert_eq!(journal.size(), 25);
10234
10235            // Verify oldest retained is at blob 3 boundary (position 15)
10236            assert_eq!(journal.bounds().start, 15);
10237
10238            // Verify positions before 15 are pruned
10239            assert!(matches!(journal.read(0).await, Err(Error::ItemPruned(_))));
10240            assert!(matches!(journal.read(14).await, Err(Error::ItemPruned(_))));
10241
10242            // Verify positions 15-24 are accessible
10243            assert_eq!(journal.read(15).await.unwrap(), 1500);
10244            assert_eq!(journal.read(20).await.unwrap(), 2000);
10245            assert_eq!(journal.read(24).await.unwrap(), 2400);
10246
10247            // Position 25+ should not exist yet
10248            assert!(matches!(
10249                journal.read(25).await,
10250                Err(Error::ItemOutOfRange(_))
10251            ));
10252
10253            // Assert journal can accept new operations
10254            let pos;
10255            (journal, pos) = journal.append(&999).await.unwrap();
10256            assert_eq!(pos, 25);
10257            assert_eq!(journal.read(25).await.unwrap(), 999);
10258
10259            journal.destroy().await.unwrap();
10260        });
10261    }
10262
10263    /// Test `init_sync` when range.start and range.end-1 are in the same blob.
10264    #[test_traced]
10265    fn test_init_sync_same_blob_bounds() {
10266        let executor = deterministic::Runner::default();
10267        executor.start(|context| async move {
10268            let items_per_section = NZU64!(5);
10269            let cfg = Config {
10270                partition: "test-same-blob".into(),
10271                items_per_section,
10272                compression: None,
10273                codec_config: (),
10274                write_buffer: NZUsize!(1024),
10275                replay_buffer: NZUsize!(1024),
10276                page_cache: CacheRef::new(PAGE_SIZE, NZUsize!(PAGE_CACHE_SIZE)),
10277            };
10278
10279            // Create journal with data in multiple blobs
10280            let mut journal =
10281                Journal::<deterministic::Context, u64>::init(context.child("storage"), cfg.clone())
10282                    .await
10283                    .expect("Failed to create initial journal");
10284
10285            // Add data at positions 0-14 (blobs 0-2 with items_per_section=5)
10286            for i in 0..15u64 {
10287                (journal, _) = journal.append(&(i * 100)).await.unwrap();
10288            }
10289            let journal = journal.sync().await.unwrap();
10290            drop(journal);
10291
10292            // Test sync boundaries within the same blob
10293            let lower_bound = 10; // operation 10 (blob 2: 10/5 = 2)
10294            let upper_bound = 15; // Last operation 14 (blob 2: 14/5 = 2)
10295            let mut journal = authenticated::init_sync::<_, Journal<_, u64>>(
10296                context.child("storage"),
10297                cfg.clone(),
10298                lower_bound..upper_bound,
10299            )
10300            .await
10301            .expect("Failed to initialize journal with same-blob bounds");
10302
10303            assert_eq!(journal.size(), 15);
10304
10305            // Both operations are in blob 2, so blobs 0, 1 should be pruned, blob 2 retained
10306            // Oldest retained position should be at blob 2 boundary (position 10)
10307            assert_eq!(journal.bounds().start, 10);
10308
10309            // Verify positions before 10 are pruned
10310            assert!(matches!(journal.read(0).await, Err(Error::ItemPruned(_))));
10311            assert!(matches!(journal.read(9).await, Err(Error::ItemPruned(_))));
10312
10313            // Verify positions 10-14 are accessible
10314            assert_eq!(journal.read(10).await.unwrap(), 1000);
10315            assert_eq!(journal.read(11).await.unwrap(), 1100);
10316            assert_eq!(journal.read(14).await.unwrap(), 1400);
10317
10318            // Position 15+ should not exist yet
10319            assert!(matches!(
10320                journal.read(15).await,
10321                Err(Error::ItemOutOfRange(_))
10322            ));
10323
10324            // Assert journal can accept new operations
10325            let pos;
10326            (journal, pos) = journal.append(&999).await.unwrap();
10327            assert_eq!(pos, 15);
10328            assert_eq!(journal.read(15).await.unwrap(), 999);
10329
10330            journal.destroy().await.unwrap();
10331        });
10332    }
10333
10334    /// A config that stores one item per section under the given partition.
10335    fn single_item_per_section_config() -> Config<()> {
10336        Config {
10337            partition: "single-item-per-blob".into(),
10338            items_per_section: NZU64!(1),
10339            compression: None,
10340            codec_config: (),
10341            page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
10342            write_buffer: NZUsize!(1024),
10343            replay_buffer: NZUsize!(1024),
10344        }
10345    }
10346
10347    /// With one item per section, appends, tail reads, pruning, appends past the prune, and a
10348    /// restart all keep every retained position readable and the bounds intact.
10349    #[test_traced]
10350    fn test_single_item_per_blob() {
10351        let executor = deterministic::Runner::default();
10352        executor.start(|context| async move {
10353            let cfg = single_item_per_section_config();
10354
10355            // A fresh journal starts empty, and its first synced item is readable at the tail.
10356            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
10357                .await
10358                .unwrap();
10359
10360            // Verify empty state
10361            let bounds = journal.bounds();
10362            assert_eq!(bounds.end, 0);
10363            assert!(bounds.is_empty());
10364
10365            // Append 1 item (value = position * 100, so position 0 has value 0)
10366            let pos;
10367            (journal, pos) = journal.append(&0).await.unwrap();
10368            assert_eq!(pos, 0);
10369            assert_eq!(journal.size(), 1);
10370
10371            // Sync
10372            journal = journal.sync().await.unwrap();
10373
10374            // Read from size() - 1
10375            let value = journal.read(journal.size() - 1).await.unwrap();
10376            assert_eq!(value, 0);
10377
10378            // Each further append fills its own section, and the tail stays readable at size() - 1.
10379            for i in 1..10u64 {
10380                let pos;
10381                (journal, pos) = journal.append(&(i * 100)).await.unwrap();
10382                assert_eq!(pos, i);
10383                assert_eq!(journal.size(), i + 1);
10384
10385                // Verify we can read the just-appended item at size() - 1
10386                let value = journal.read(journal.size() - 1).await.unwrap();
10387                assert_eq!(value, i * 100);
10388            }
10389
10390            // Verify all items can be read
10391            for i in 0..10u64 {
10392                assert_eq!(journal.read(i).await.unwrap(), i * 100);
10393            }
10394
10395            // Pruning the first five positions keeps the size but moves the start. Reads below the
10396            // start report ItemPruned while the tail stays readable.
10397            journal = journal.sync().await.unwrap();
10398            let pruned;
10399            (journal, pruned) = journal.prune(5).await.unwrap();
10400            assert!(pruned);
10401
10402            // Size should still be 10
10403            assert_eq!(journal.size(), 10);
10404
10405            // bounds.start should be 5
10406            assert_eq!(journal.bounds().start, 5);
10407
10408            // Reading from bounds.end - 1 (position 9) should still work
10409            let value = journal.read(journal.size() - 1).await.unwrap();
10410            assert_eq!(value, 900);
10411
10412            // Reading from pruned positions should return ItemPruned
10413            for i in 0..5 {
10414                assert!(matches!(
10415                    journal.read(i).await,
10416                    Err(crate::journal::Error::ItemPruned(_))
10417                ));
10418            }
10419
10420            // Reading from retained positions should work
10421            for i in 5..10u64 {
10422                assert_eq!(journal.read(i).await.unwrap(), i * 100);
10423            }
10424
10425            // Appends after the prune continue at the old size, each readable at the tail.
10426            for i in 10..15u64 {
10427                let pos;
10428                (journal, pos) = journal.append(&(i * 100)).await.unwrap();
10429                assert_eq!(pos, i);
10430
10431                // Verify we can read from size() - 1
10432                let value = journal.read(journal.size() - 1).await.unwrap();
10433                assert_eq!(value, i * 100);
10434            }
10435
10436            // A reopen recovers the size and pruned start, so every retained position reads back.
10437            journal.sync().await.unwrap();
10438            let journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
10439                .await
10440                .unwrap();
10441
10442            // Verify size is preserved
10443            assert_eq!(journal.size(), 15);
10444
10445            // Verify bounds.start is preserved
10446            assert_eq!(journal.bounds().start, 5);
10447
10448            // Reading from bounds.end - 1 should work after restart
10449            let value = journal.read(journal.size() - 1).await.unwrap();
10450            assert_eq!(value, 1400);
10451
10452            // Reading all retained positions should work
10453            for i in 5..15u64 {
10454                assert_eq!(journal.read(i).await.unwrap(), i * 100);
10455            }
10456
10457            journal.destroy().await.unwrap();
10458
10459            // Destroy removes the journal state, so the same config reopens empty and accepts new
10460            // writes.
10461            let mut journal = Journal::<_, u64>::init(context.child("after_destroy"), cfg)
10462                .await
10463                .unwrap();
10464            assert!(journal.bounds().is_empty());
10465            assert_eq!(journal.size(), 0);
10466            for i in 0..10u64 {
10467                (journal, _) = journal.append(&((i + 1000) * 100)).await.unwrap();
10468            }
10469            journal = journal.sync().await.unwrap();
10470            assert_eq!(journal.bounds().end, 10);
10471            for i in 0..10u64 {
10472                assert_eq!(journal.read(i).await.unwrap(), (i + 1000) * 100);
10473            }
10474            journal.destroy().await.unwrap();
10475        });
10476    }
10477
10478    /// A one-item-per-section journal that is pruned and reopened keeps its bounds and reads every
10479    /// retained position, including the tail. Reading the tail after a prune and restart once
10480    /// failed for this configuration.
10481    #[test_traced]
10482    fn test_single_item_per_blob_restart_after_prune() {
10483        let executor = deterministic::Runner::default();
10484        executor.start(|context| async move {
10485            let cfg = single_item_per_section_config();
10486
10487            let mut journal = Journal::<_, u64>::init(context.child("third"), cfg.clone())
10488                .await
10489                .unwrap();
10490
10491            // Append 10 items (positions 0-9)
10492            for i in 0..10u64 {
10493                (journal, _) = journal.append(&(i * 1000)).await.unwrap();
10494            }
10495
10496            // Prune to position 5 (removes positions 0-4)
10497            (journal, _) = journal.prune(5).await.unwrap();
10498            let bounds = journal.bounds();
10499            assert_eq!(bounds.end, 10);
10500            assert_eq!(bounds.start, 5);
10501
10502            // Sync and restart
10503            journal.sync().await.unwrap();
10504
10505            // Re-open journal
10506            let journal = Journal::<_, u64>::init(context.child("fourth"), cfg.clone())
10507                .await
10508                .unwrap();
10509
10510            // Verify state after restart
10511            let bounds = journal.bounds();
10512            assert_eq!(bounds.end, 10);
10513            assert_eq!(bounds.start, 5);
10514
10515            // KEY TEST: Reading from bounds.end - 1 (position 9) should work
10516            let value = journal.read(journal.size() - 1).await.unwrap();
10517            assert_eq!(value, 9000);
10518
10519            // Verify all retained positions (5-9) work
10520            for i in 5..10u64 {
10521                assert_eq!(journal.read(i).await.unwrap(), i * 1000);
10522            }
10523
10524            journal.destroy().await.unwrap();
10525
10526            // Destroy removes the journal state, so the same config reopens empty and accepts new
10527            // writes.
10528            let mut journal = Journal::<_, u64>::init(context.child("after_destroy"), cfg)
10529                .await
10530                .unwrap();
10531            assert!(journal.bounds().is_empty());
10532            assert_eq!(journal.size(), 0);
10533            for i in 0..10u64 {
10534                (journal, _) = journal.append(&((i + 1000) * 1000)).await.unwrap();
10535            }
10536            journal = journal.sync().await.unwrap();
10537            assert_eq!(journal.bounds().end, 10);
10538            for i in 0..10u64 {
10539                assert_eq!(journal.read(i).await.unwrap(), (i + 1000) * 1000);
10540            }
10541            journal.destroy().await.unwrap();
10542        });
10543    }
10544
10545    /// Pruning every item of a one-item-per-section journal keeps its size, empties its bounds, and
10546    /// reports the tail position as pruned until a new append.
10547    #[test_traced]
10548    fn test_single_item_per_blob_prune_all() {
10549        let executor = deterministic::Runner::default();
10550        executor.start(|context| async move {
10551            let cfg = single_item_per_section_config();
10552
10553            // Callers must check bounds().is_empty() before reading once everything is pruned.
10554            let mut journal = Journal::<_, u64>::init(context.child("fifth"), cfg.clone())
10555                .await
10556                .unwrap();
10557
10558            for i in 0..5u64 {
10559                (journal, _) = journal.append(&(i * 100)).await.unwrap();
10560            }
10561            journal = journal.sync().await.unwrap();
10562
10563            // Prune all items
10564            (journal, _) = journal.prune(5).await.unwrap();
10565            let bounds = journal.bounds();
10566            assert_eq!(bounds.end, 5); // Size unchanged
10567            assert!(bounds.is_empty()); // All pruned
10568
10569            // bounds.end - 1 = 4, but position 4 is pruned
10570            let result = journal.read(journal.size() - 1).await;
10571            assert!(matches!(result, Err(crate::journal::Error::ItemPruned(4))));
10572
10573            // After appending, reading works again
10574            (journal, _) = journal.append(&500).await.unwrap();
10575            let bounds = journal.bounds();
10576            assert_eq!(bounds.start, 5);
10577            assert_eq!(journal.read(bounds.end - 1).await.unwrap(), 500);
10578
10579            journal.destroy().await.unwrap();
10580        });
10581    }
10582
10583    #[test_traced]
10584    fn test_variable_journal_clear_to_size() {
10585        let executor = deterministic::Runner::default();
10586        executor.start(|context| async move {
10587            let cfg = Config {
10588                partition: "clear-test".into(),
10589                items_per_section: NZU64!(10),
10590                compression: None,
10591                codec_config: (),
10592                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
10593                write_buffer: NZUsize!(1024),
10594                replay_buffer: NZUsize!(1024),
10595            };
10596
10597            let mut journal = Journal::<_, u64>::init(context.child("journal"), cfg.clone())
10598                .await
10599                .unwrap();
10600
10601            // Append 25 items (spanning multiple blobs)
10602            for i in 0..25u64 {
10603                (journal, _) = journal.append(&(i * 100)).await.unwrap();
10604            }
10605            let bounds = journal.bounds();
10606            assert_eq!(bounds.end, 25);
10607            assert_eq!(bounds.start, 0);
10608            journal = journal.sync().await.unwrap();
10609
10610            // Clear to position 100, effectively resetting the journal
10611            journal.0 = journal.0.clear_to_size(100).await.unwrap();
10612            let bounds = journal.bounds();
10613            assert_eq!(bounds.end, 100);
10614            assert!(bounds.is_empty());
10615
10616            // Old positions should fail
10617            for i in 0..25 {
10618                assert!(matches!(
10619                    journal.read(i).await,
10620                    Err(crate::journal::Error::ItemPruned(_))
10621                ));
10622            }
10623
10624            // Verify size persists after restart without writing any data
10625            drop(journal);
10626            let mut journal =
10627                Journal::<_, u64>::init(context.child("journal_after_clear"), cfg.clone())
10628                    .await
10629                    .unwrap();
10630            let bounds = journal.bounds();
10631            assert_eq!(bounds.end, 100);
10632            assert!(bounds.is_empty());
10633
10634            // Append new data starting at position 100
10635            for i in 100..105u64 {
10636                let pos;
10637                (journal, pos) = journal.append(&(i * 100)).await.unwrap();
10638                assert_eq!(pos, i);
10639            }
10640            let bounds = journal.bounds();
10641            assert_eq!(bounds.end, 105);
10642            assert_eq!(bounds.start, 100);
10643
10644            // New positions should be readable
10645            for i in 100..105u64 {
10646                assert_eq!(journal.read(i).await.unwrap(), i * 100);
10647            }
10648
10649            // Sync and re-init to verify persistence
10650            journal.sync().await.unwrap();
10651
10652            let journal = Journal::<_, u64>::init(context.child("journal_reopened"), cfg)
10653                .await
10654                .unwrap();
10655
10656            let bounds = journal.bounds();
10657            assert_eq!(bounds.end, 105);
10658            assert_eq!(bounds.start, 100);
10659            for i in 100..105u64 {
10660                assert_eq!(journal.read(i).await.unwrap(), i * 100);
10661            }
10662
10663            journal.destroy().await.unwrap();
10664        });
10665    }
10666
10667    #[test_traced]
10668    fn test_variable_journal_clear_to_size_rejects_backward_without_mutation() {
10669        let executor = deterministic::Runner::default();
10670        executor.start(|context| async move {
10671            let cfg = Config {
10672                partition: "clear-to-size-backward".into(),
10673                items_per_section: NZU64!(5),
10674                compression: None,
10675                codec_config: (),
10676                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
10677                write_buffer: NZUsize!(1024),
10678                replay_buffer: NZUsize!(1024),
10679            };
10680            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
10681                .await
10682                .unwrap();
10683            for i in 0..2 {
10684                (journal, _) = journal.append(&(100 + i)).await.unwrap();
10685            }
10686            let journal = journal.sync().await.unwrap();
10687
10688            assert!(matches!(
10689                journal.0.clear_to_size(1).await,
10690                Err(Error::ItemOutOfRange(1))
10691            ));
10692
10693            let journal = Journal::<_, u64>::init(context.child("reopen"), cfg)
10694                .await
10695                .unwrap();
10696            assert_eq!(journal.bounds(), 0..2);
10697            assert_eq!(journal.read(0).await.unwrap(), 100);
10698            assert_eq!(journal.read(1).await.unwrap(), 101);
10699            journal.destroy().await.unwrap();
10700        });
10701    }
10702
10703    #[test_traced]
10704    fn test_variable_journal_metrics() {
10705        let executor = deterministic::Runner::default();
10706        executor.start(|context| async move {
10707            let cfg = Config {
10708                partition: "metrics".into(),
10709                items_per_section: NZU64!(2),
10710                compression: None,
10711                codec_config: (),
10712                page_cache: CacheRef::new(PAGE_SIZE, NZUsize!(10)),
10713                write_buffer: NZUsize!(1024),
10714                replay_buffer: NZUsize!(1024),
10715            };
10716            let mut journal = Journal::<_, u64>::init(context.child("variable_metrics"), cfg)
10717                .await
10718                .unwrap();
10719
10720            let items = [0, 1, 2, 3, 4];
10721            (journal, _) = journal.append_many(Many::Flat(&items)).await.unwrap();
10722            (journal, _) = journal.append(&5).await.unwrap();
10723            let reader;
10724            (journal, reader) = journal.snapshot().await.unwrap();
10725            reader.read(0).await.unwrap();
10726            reader.read_many(&[1, 2]).await.unwrap();
10727            reader.try_read_sync(3).unwrap();
10728            drop(reader);
10729            journal = journal.commit().await.unwrap();
10730            journal = journal.sync().await.unwrap();
10731            let handle;
10732            (journal, handle) = journal.start_sync().await.unwrap();
10733            handle.await.unwrap();
10734            (journal, _) = journal.prune(2).await.unwrap();
10735
10736            let buffer = context.encode();
10737            for expected in [
10738                "variable_metrics_size 6",
10739                "variable_metrics_pruning_boundary 2",
10740                "variable_metrics_retained 4",
10741                "variable_metrics_tail_items 2",
10742                "variable_metrics_append_calls_total 1",
10743                "variable_metrics_append_many_calls_total 1",
10744                "variable_metrics_read_calls_total 1",
10745                "variable_metrics_read_many_calls_total 1",
10746                "variable_metrics_items_read_total 4",
10747                "variable_metrics_start_sync_calls_total 1",
10748                "variable_metrics_commit_calls_total 1",
10749                "variable_metrics_sync_calls_total 1",
10750                "variable_metrics_append_duration_count 1",
10751                "variable_metrics_append_many_duration_count 1",
10752                "variable_metrics_read_duration_count 0",
10753                "variable_metrics_read_many_duration_count 1",
10754                "variable_metrics_commit_duration_count 1",
10755                "variable_metrics_sync_duration_count 1",
10756                "variable_metrics_cache_hits_total 4",
10757                "variable_metrics_cache_misses_total 0",
10758                "variable_metrics_data_tracked",
10759                "variable_metrics_offsets_size 6",
10760                "variable_metrics_offsets_blobs_tracked",
10761            ] {
10762                assert!(buffer.contains(expected), "{expected}\n{buffer}");
10763            }
10764
10765            journal.destroy().await.unwrap();
10766        });
10767    }
10768
10769    #[test_traced]
10770    fn test_variable_journal_read_miss_timed() {
10771        // Reads served from storage record a read_duration sample; cache hits do not.
10772        let executor = deterministic::Runner::default();
10773        executor.start(|context| async move {
10774            // Blobs span multiple full pages so their data must go through the (evictable)
10775            // page cache rather than staying resident in each blob's partial tail page.
10776            let cfg = Config {
10777                partition: "miss".into(),
10778                items_per_section: NZU64!(50),
10779                compression: None,
10780                codec_config: (),
10781                page_cache: CacheRef::new(PAGE_SIZE, NZUsize!(10)),
10782                write_buffer: NZUsize!(1024),
10783                replay_buffer: NZUsize!(1024),
10784            };
10785            let mut journal = Journal::<_, u64>::init(context.child("miss"), cfg)
10786                .await
10787                .unwrap();
10788            for i in 0..200u64 {
10789                (journal, _) = journal.append(&i).await.unwrap();
10790            }
10791            journal = journal.sync().await.unwrap();
10792
10793            // The page cache cannot hold every page, so some position must be cold.
10794            let reader;
10795            (journal, reader) = journal.snapshot().await.unwrap();
10796            let pos = (0..200)
10797                .find(|&pos| reader.try_read_sync(pos).is_none())
10798                .expect("some position should be cold");
10799            assert_eq!(reader.read(pos).await.unwrap(), pos);
10800            drop(reader);
10801
10802            let buffer = context.encode();
10803            assert!(buffer.contains("miss_read_duration_count 1"), "{buffer}");
10804
10805            journal.destroy().await.unwrap();
10806        });
10807    }
10808
10809    #[test_traced]
10810    fn test_variable_snapshot_frozen_across_roll() {
10811        let executor = deterministic::Runner::default();
10812        executor.start(|context| async move {
10813            let cfg = Config {
10814                partition: "snapshot-frozen".into(),
10815                items_per_section: NZU64!(5),
10816                compression: None,
10817                codec_config: (),
10818                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
10819                write_buffer: NZUsize!(1024),
10820                replay_buffer: NZUsize!(1024),
10821            };
10822            let mut journal = Journal::<_, u64>::init(context.child("j"), cfg)
10823                .await
10824                .unwrap();
10825            for i in 0..7u64 {
10826                (journal, _) = journal.append(&(i * 100)).await.unwrap();
10827            }
10828
10829            let snapshot;
10830            (journal, snapshot) = journal.snapshot().await.unwrap();
10831            assert_eq!(snapshot.bounds(), 0..7);
10832
10833            // Appending past the blob boundary rolls the snapshot's tail blob into
10834            // history; the snapshot keeps reading it through its own handle.
10835            for i in 7..23u64 {
10836                (journal, _) = journal.append(&(i * 100)).await.unwrap();
10837            }
10838            assert_eq!(snapshot.bounds(), 0..7);
10839            for i in 0..7u64 {
10840                assert_eq!(snapshot.read(i).await.unwrap(), i * 100);
10841            }
10842            assert!(matches!(
10843                snapshot.read(7).await,
10844                Err(Error::ItemOutOfRange(7))
10845            ));
10846
10847            let fresh;
10848            (journal, fresh) = journal.snapshot().await.unwrap();
10849            assert_eq!(fresh.bounds(), 0..23);
10850            assert_eq!(fresh.read(22).await.unwrap(), 2200);
10851
10852            drop(snapshot);
10853            drop(fresh);
10854            journal.destroy().await.unwrap();
10855        });
10856    }
10857
10858    #[test_traced]
10859    fn test_variable_prune_under_snapshot() {
10860        let executor = deterministic::Runner::default();
10861        executor.start(|context| async move {
10862            let cfg = Config {
10863                partition: "snapshot-prune".into(),
10864                items_per_section: NZU64!(5),
10865                compression: Some(3),
10866                codec_config: (),
10867                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
10868                write_buffer: NZUsize!(1024),
10869                replay_buffer: NZUsize!(1024),
10870            };
10871            let mut journal = Journal::<_, u64>::init(context.child("j"), cfg)
10872                .await
10873                .unwrap();
10874            for i in 0..17u64 {
10875                (journal, _) = journal.append(&(i * 100)).await.unwrap();
10876            }
10877            journal = journal.sync().await.unwrap();
10878
10879            let snapshot;
10880            (journal, snapshot) = journal.snapshot().await.unwrap();
10881            let pruned;
10882            (journal, pruned) = journal.prune(12).await.unwrap();
10883            assert!(pruned);
10884
10885            // The straggler reads the pruned range through its own handles.
10886            assert_eq!(snapshot.bounds(), 0..17);
10887            for i in 0..17u64 {
10888                assert_eq!(snapshot.read(i).await.unwrap(), i * 100);
10889            }
10890            assert_eq!(
10891                snapshot.read_many(&[1, 2, 3, 11, 16]).await.unwrap(),
10892                vec![100, 200, 300, 1100, 1600]
10893            );
10894
10895            let fresh;
10896            (journal, fresh) = journal.snapshot().await.unwrap();
10897            assert_eq!(fresh.bounds(), 10..17);
10898            assert!(matches!(fresh.read(3).await, Err(Error::ItemPruned(3))));
10899
10900            drop(snapshot);
10901            drop(fresh);
10902            journal.destroy().await.unwrap();
10903        });
10904    }
10905
10906    #[test_traced]
10907    fn test_variable_snapshots_readable_during_concurrent_appends() {
10908        let executor = deterministic::Runner::default();
10909        executor.start(|context| async move {
10910            let cfg = Config {
10911                partition: "snapshot-concurrent".into(),
10912                items_per_section: NZU64!(5),
10913                compression: None,
10914                codec_config: (),
10915                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
10916                write_buffer: NZUsize!(1024),
10917                replay_buffer: NZUsize!(1024),
10918            };
10919            let mut journal = Journal::<_, u64>::init(context.child("j"), cfg)
10920                .await
10921                .unwrap();
10922
10923            let (mut tx, mut rx) =
10924                futures::channel::mpsc::channel::<Reader<'static, deterministic::Context, u64>>(8);
10925            let validator = context.child("validator").spawn(|_| async move {
10926                let mut validated = 0usize;
10927                while let Some(snapshot) = rx.next().await {
10928                    let bounds = snapshot.bounds();
10929                    for i in bounds.clone() {
10930                        assert_eq!(snapshot.read(i).await.unwrap(), i * 100);
10931                    }
10932                    validated += (bounds.end - bounds.start) as usize;
10933                }
10934                validated
10935            });
10936
10937            for i in 0..40u64 {
10938                (journal, _) = journal.append(&(i * 100)).await.unwrap();
10939                if i % 7 == 0 {
10940                    let snapshot;
10941                    (journal, snapshot) = journal.snapshot().await.unwrap();
10942                    if tx.try_send(snapshot).is_err() {
10943                        break;
10944                    }
10945                }
10946            }
10947            drop(tx);
10948            assert!(validator.await.unwrap() > 0);
10949
10950            journal.destroy().await.unwrap();
10951        });
10952    }
10953
10954    #[test_traced]
10955    fn test_variable_replay_from_stale_snapshot() {
10956        let executor = deterministic::Runner::default();
10957        executor.start(|context| async move {
10958            let cfg = Config {
10959                partition: "snapshot-replay".into(),
10960                items_per_section: NZU64!(5),
10961                compression: None,
10962                codec_config: (),
10963                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
10964                write_buffer: NZUsize!(1024),
10965                replay_buffer: NZUsize!(1024),
10966            };
10967            let mut journal = Journal::<_, u64>::init(context.child("j"), cfg)
10968                .await
10969                .unwrap();
10970            for i in 0..7u64 {
10971                (journal, _) = journal.append(&(i * 100)).await.unwrap();
10972            }
10973
10974            // Positions 5..7 live in the snapshot's tail blob.
10975            let snapshot;
10976            (journal, snapshot) = journal.snapshot().await.unwrap();
10977            assert_eq!(snapshot.bounds(), 0..7);
10978
10979            // Roll the snapshot's tail into history, then prune both of its blobs away.
10980            for i in 7..23u64 {
10981                (journal, _) = journal.append(&(i * 100)).await.unwrap();
10982            }
10983            let pruned;
10984            (journal, pruned) = journal.prune(12).await.unwrap();
10985            assert!(pruned);
10986
10987            {
10988                let stream = replay_items(
10989                    &snapshot,
10990                    0..snapshot.bounds().end,
10991                    NZUsize!(1024),
10992                    ReadOptions::default(),
10993                )
10994                .await
10995                .unwrap();
10996                futures::pin_mut!(stream);
10997                let mut expected = 0u64;
10998                while let Some(result) = stream.next().await {
10999                    let (pos, item) = result.unwrap();
11000                    assert_eq!(pos, expected);
11001                    assert_eq!(item, pos * 100);
11002                    expected += 1;
11003                }
11004                assert_eq!(expected, 7);
11005            }
11006
11007            drop(snapshot);
11008            journal.destroy().await.unwrap();
11009        });
11010    }
11011
11012    /// A journal written before eager tail creation can end with a full newest blob and no
11013    /// successor file; recovery opens the missing tail.
11014    #[test_traced]
11015    fn test_variable_recovery_full_newest_blob_without_successor() {
11016        let executor = deterministic::Runner::default();
11017        executor.start(|context| async move {
11018            let cfg = Config {
11019                partition: "recovery-full-newest-no-successor".into(),
11020                items_per_section: NZU64!(10),
11021                compression: None,
11022                codec_config: (),
11023                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
11024                write_buffer: NZUsize!(1024),
11025                replay_buffer: NZUsize!(1024),
11026            };
11027
11028            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
11029                .await
11030                .unwrap();
11031            for i in 0..10u64 {
11032                (journal, _) = journal.append(&(i * 100)).await.unwrap();
11033            }
11034            let journal = journal.sync().await.unwrap();
11035            drop(journal);
11036
11037            // Remove the empty tail blob, leaving only the full blob 0 (the layout written
11038            // before eager tail creation).
11039            let data_partition = cfg.data_partition();
11040            context
11041                .remove(&data_partition, Some(&1u64.to_be_bytes()))
11042                .await
11043                .unwrap();
11044
11045            let mut journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
11046                .await
11047                .unwrap();
11048            assert_eq!(journal.bounds(), 0..10);
11049            for i in 0..10u64 {
11050                assert_eq!(journal.read(i).await.unwrap(), i * 100);
11051            }
11052            let appended;
11053            (journal, appended) = journal.append(&1000).await.unwrap();
11054            assert_eq!(appended, 10);
11055            assert_eq!(journal.read(10).await.unwrap(), 1000);
11056
11057            journal.destroy().await.unwrap();
11058        });
11059    }
11060
11061    /// A crash after initialization lowered and truncated the offsets journal but before the data
11062    /// was truncated leaves offsets behind data. Recovery rebuilds the offsets suffix, restoring
11063    /// the pre-truncate state with every item readable.
11064    #[test_traced]
11065    fn test_variable_truncate_crash_before_data_truncation() {
11066        let executor = deterministic::Runner::default();
11067        executor.start(|context| async move {
11068            let cfg = Config {
11069                partition: "rewind-crash-offsets-only".into(),
11070                items_per_section: NZU64!(10),
11071                compression: None,
11072                codec_config: (),
11073                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
11074                write_buffer: NZUsize!(1024),
11075                replay_buffer: NZUsize!(1024),
11076            };
11077
11078            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
11079                .await
11080                .unwrap();
11081            for i in 0..25u64 {
11082                (journal, _) = journal.append(&(i * 100)).await.unwrap();
11083            }
11084            let journal = journal.sync().await.unwrap();
11085
11086            // Initialization truncates offsets (lowering the watermark) before the data. Simulate a
11087            // crash in between.
11088            let journal = journal.test_truncate_offsets(12).await.unwrap();
11089            drop(journal);
11090
11091            let journal = Journal::<_, u64>::init(context.child("second"), cfg.clone())
11092                .await
11093                .unwrap();
11094            assert_eq!(journal.bounds(), 0..25);
11095            for i in 0..25u64 {
11096                assert_eq!(journal.read(i).await.unwrap(), i * 100);
11097            }
11098
11099            journal.destroy().await.unwrap();
11100        });
11101    }
11102
11103    /// Retained data blobs must be contiguous; recovery rejects a missing interior blob.
11104    #[test_traced]
11105    fn test_variable_recovery_rejects_gap_in_retained_blobs() {
11106        let executor = deterministic::Runner::default();
11107        executor.start(|context| async move {
11108            let cfg = Config {
11109                partition: "recovery-gap-in-retained-blobs".into(),
11110                items_per_section: NZU64!(10),
11111                compression: None,
11112                codec_config: (),
11113                page_cache: CacheRef::new(LARGE_PAGE_SIZE, NZUsize!(10)),
11114                write_buffer: NZUsize!(1024),
11115                replay_buffer: NZUsize!(1024),
11116            };
11117
11118            let mut journal = Journal::<_, u64>::init(context.child("first"), cfg.clone())
11119                .await
11120                .unwrap();
11121            for i in 0..25u64 {
11122                (journal, _) = journal.append(&(i * 100)).await.unwrap();
11123            }
11124            let journal = journal.sync().await.unwrap();
11125            drop(journal);
11126
11127            // Remove blob 1, leaving a gap in the retained data blobs.
11128            context
11129                .remove(&cfg.data_partition(), Some(&1u64.to_be_bytes()))
11130                .await
11131                .unwrap();
11132
11133            let result = Journal::<_, u64>::init(context.child("second"), cfg.clone()).await;
11134            assert!(matches!(result, Err(Error::Corruption(_))));
11135        });
11136    }
11137
11138    /// A failed offsets watermark advance that nobody observed surfaces at destroy before any
11139    /// data blob is removed, so the journal reopens with every durable item.
11140    #[test_traced]
11141    fn test_variable_destroy_observes_failed_watermark_sync_before_removal() {
11142        let executor = deterministic::Runner::default();
11143        executor.start(|context| async move {
11144            let pending = PendingSyncs::default();
11145            let cfg = initialization_cfg("destroy-failed-watermark", 100);
11146            let data_partition = cfg.data_partition();
11147            let mut journal = Journal::<_, u64>::init(
11148                DelayedSyncContext {
11149                    inner: context.child("journal"),
11150                    pending: pending.clone(),
11151                },
11152                cfg.clone(),
11153            )
11154            .await
11155            .unwrap();
11156            for item in 1..=3u64 {
11157                (journal, _) = journal.append(&item).await.unwrap();
11158            }
11159            journal = drive_pending_syncs(&pending, journal.sync()).await.unwrap();
11160
11161            // Make item 4 durable without advancing the watermark, then start the advance to 4,
11162            // drop its handle, and fail its checkpoint sync, the only sync that advance starts.
11163            (journal, _) = journal.append(&4).await.unwrap();
11164            let (journal, handle) = journal.start_sync().await.unwrap();
11165            release_pending_syncs(&pending);
11166            handle.await.unwrap();
11167            let (journal, handle) = journal.start_sync().await.unwrap();
11168            drop(handle);
11169            assert_eq!(pending.lock().len(), 1);
11170            fail_pending_syncs(&pending);
11171
11172            assert!(journal.destroy().await.is_err());
11173            assert!(
11174                context
11175                    .scan(&data_partition)
11176                    .await
11177                    .is_ok_and(|names| names.len() == 1)
11178            );
11179            let journal = Journal::<_, u64>::init(context.child("second"), cfg)
11180                .await
11181                .unwrap();
11182            assert_eq!(journal.bounds(), 0..4);
11183            journal.destroy().await.unwrap();
11184        });
11185    }
11186
11187    /// A failed offsets blob sync that nobody observed surfaces at destroy before any data blob
11188    /// is removed, so the journal reopens with every durable item.
11189    #[test_traced]
11190    fn test_variable_destroy_observes_failed_offsets_blob_sync_before_removal() {
11191        let executor = deterministic::Runner::default();
11192        executor.start(|context| async move {
11193            let pending = PendingSyncs::default();
11194            let cfg = initialization_cfg("destroy-failed-offsets-blob", 100);
11195            let data_partition = cfg.data_partition();
11196            let mut journal = Journal::<_, u64>::init(
11197                DelayedSyncContext {
11198                    inner: context.child("journal"),
11199                    pending: pending.clone(),
11200                },
11201                cfg.clone(),
11202            )
11203            .await
11204            .unwrap();
11205            for item in 1..=3u64 {
11206                (journal, _) = journal.append(&item).await.unwrap();
11207            }
11208            journal = drive_pending_syncs(&pending, journal.sync()).await.unwrap();
11209
11210            // A commit makes item 4's data durable while leaving its offset unsynced and the
11211            // watermark at 3, so the next advance starts only the offsets blob sync. Fail it.
11212            (journal, _) = journal.append(&4).await.unwrap();
11213            journal = drive_pending_syncs(&pending, journal.commit())
11214                .await
11215                .unwrap();
11216            let (journal, handle) = journal.start_sync().await.unwrap();
11217            drop(handle);
11218            assert_eq!(pending.lock().len(), 1);
11219            fail_pending_syncs(&pending);
11220
11221            assert!(journal.destroy().await.is_err());
11222            assert!(
11223                context
11224                    .scan(&data_partition)
11225                    .await
11226                    .is_ok_and(|names| names.len() == 1)
11227            );
11228            let journal = Journal::<_, u64>::init(context.child("second"), cfg)
11229                .await
11230                .unwrap();
11231            assert_eq!(journal.bounds(), 0..4);
11232            journal.destroy().await.unwrap();
11233        });
11234    }
11235
11236    /// A bounded open whose cap lies in a page below the watermark's page truncates the torn
11237    /// data page at once, since the data scan covers the whole blob and the page's durable slot
11238    /// holds every acknowledged frame, but repairs the offsets journal only through the cap's
11239    /// page. The torn rewrite of the watermark's offsets page stays for a later open, and
11240    /// widening the view recovers every acknowledged item.
11241    #[test_traced]
11242    fn test_variable_bounded_open_below_torn_watermark_page_widens() {
11243        let executor = deterministic::Runner::default();
11244        executor.start(|context| async move {
11245            let cfg = torn_rewrite_cfg("variable-torn-watermark-page");
11246            let journal = torn_rewrite_journal(&context, &cfg).await;
11247            drop(journal);
11248
11249            // Lose one new byte of each blob's rewritten page, as in the repair tests above.
11250            tear_page_rewrite(
11251                &context,
11252                &cfg.data_partition(),
11253                &0u64.to_be_bytes(),
11254                10,
11255                95,
11256                100,
11257            )
11258            .await;
11259            let offsets_partition = format!("{}-blobs", cfg.offsets_config().partition);
11260            tear_page_rewrite(
11261                &context,
11262                &offsets_partition,
11263                &0u64.to_be_bytes(),
11264                2,
11265                70,
11266                100,
11267            )
11268            .await;
11269
11270            // Cap 10 ends in offsets page 0, while the watermark's last offset is in page 2.
11271            let recovery =
11272                Recovery::<_, FixedBytes<32>>::open(context.child("capped"), cfg.clone(), Some(10))
11273                    .await
11274                    .unwrap();
11275            assert_eq!(recovery.bounds(), 0..10);
11276            assert_eq!(recovery.read(9).await.unwrap(), FixedBytes::new([9u8; 32]));
11277
11278            // The data blob ends at page 10's 89 durable bytes, while offsets page 2 still
11279            // validates only its 64 durable bytes with page 3 behind it.
11280            let physical = (100 + commonware_runtime::buffer::paged::CHECKSUM_SIZE) as usize;
11281            assert_eq!(
11282                context
11283                    .logical_blob(&cfg.data_partition(), &0u64.to_be_bytes())
11284                    .unwrap()
11285                    .len(),
11286                11 * physical
11287            );
11288            let bytes = context
11289                .logical_blob(&offsets_partition, &0u64.to_be_bytes())
11290                .unwrap();
11291            assert!(bytes.len() >= 4 * physical);
11292            assert_eq!(
11293                commonware_runtime::buffer::paged::page_len(
11294                    &bytes[2 * physical..3 * physical],
11295                    100
11296                ),
11297                Some(64)
11298            );
11299
11300            let recovery = recovery.unbounded().await.unwrap();
11301            assert_eq!(recovery.bounds(), 0..33);
11302            for i in 0..33u64 {
11303                assert_eq!(
11304                    recovery.read(i).await.unwrap(),
11305                    FixedBytes::new([i as u8; 32])
11306                );
11307            }
11308        });
11309    }
11310}