Skip to main content

commonware_runtime/utils/buffer/paged/
sealed.rs

1//! Read-only counterpart to [`super::Writer`]: an immutable, page-cache-backed read handle for
2//! a blob whose logical content will no longer change.
3//!
4//! # Sealing and durability
5//!
6//! [`super::Writer::seal`] consumes the write handle and returns a [`Sealed`] handle without
7//! fsyncing the underlying blob. Buffered logical bytes are flushed to the blob (so subsequent
8//! reads observe them), but a crash before [`Sealed::sync`] may lose the most recently sealed
9//! bytes. Callers that need durability must invoke [`Sealed::sync`] (typically driven from a
10//! higher-level commit path).
11//!
12//! # Cheap sharing
13//!
14//! [`Sealed`] is `Clone` and shares its state via `Arc<SealedInner>`. Clones do not coordinate via
15//! any lock; they share the underlying [`Blob`] handle (which provides its own synchronization)
16//! and the page cache.
17
18use super::{read::PageReader, view::View, CacheRef, Replay, CHECKSUM_SIZE};
19use crate::{Blob, Error, IoBuf, IoBufMut, IoBufs};
20use std::{
21    num::{NonZeroU16, NonZeroUsize},
22    sync::Arc,
23};
24
25/// An immutable, page-cache-backed read handle for a [Blob]. The read-only counterpart to
26/// [`super::Writer`].
27pub struct Sealed<B: Blob> {
28    inner: Arc<SealedInner<B>>,
29}
30
31impl<B: Blob> Clone for Sealed<B> {
32    fn clone(&self) -> Self {
33        Self {
34            inner: self.inner.clone(),
35        }
36    }
37}
38
39struct SealedInner<B: Blob> {
40    /// The underlying blob being wrapped.
41    blob: B,
42
43    /// Size of the sealed view, in bytes.
44    size: u64,
45
46    /// Logical bytes of the partial last page, if the blob ends in one. Bytes at offsets
47    /// `[size - partial_page.len(), size)` come from here; bytes below come from full pages on the
48    /// blob (via the page cache).
49    partial_page: Option<IoBuf>,
50
51    /// Reference to the page cache used for reads of full pages.
52    cache_ref: CacheRef,
53
54    /// Page-cache id. [`super::Writer::seal`] preserves the writer id so hot full pages remain
55    /// valid across the transition. [`super::Writer::snapshot`] uses a fresh id because the writer
56    /// can keep mutating its own cache namespace.
57    id: u64,
58}
59
60impl<B: Blob> Sealed<B> {
61    /// Construct a [`Sealed`] from already-validated parts. Invoked by [`super::Writer::seal`].
62    pub(super) fn new(
63        blob: B,
64        size: u64,
65        partial_page: Option<IoBuf>,
66        cache_ref: CacheRef,
67        id: u64,
68    ) -> Self {
69        Self {
70            inner: Arc::new(SealedInner {
71                blob,
72                size,
73                partial_page,
74                cache_ref,
75                id,
76            }),
77        }
78    }
79
80    /// Returns the size of the blob.
81    pub fn size(&self) -> u64 {
82        self.inner.size
83    }
84
85    /// Make pending bytes on the underlying blob durable. Idempotent.
86    pub async fn sync(&self) -> Result<(), Error> {
87        self.inner.blob.sync().await
88    }
89
90    /// Logical offset at which the partial-page bytes begin. Equal to `size` when there is no
91    /// partial page.
92    fn partial_offset(&self) -> u64 {
93        self.inner.size
94            - self
95                .inner
96                .partial_page
97                .as_ref()
98                .map_or(0, |p| p.len() as u64)
99    }
100
101    /// Returns a borrowed view over this blob.
102    fn view(&self) -> View<'_, B> {
103        View {
104            blob: &self.inner.blob,
105            cache_ref: &self.inner.cache_ref,
106            id: self.inner.id,
107            size: self.inner.size,
108            tail_offset: self.partial_offset(),
109            tail: self
110                .inner
111                .partial_page
112                .as_ref()
113                .map_or(&[][..], |p| p.as_ref()),
114        }
115    }
116
117    /// Read exactly `len` immutable bytes starting at `offset`.
118    pub async fn read_at(&self, offset: u64, len: usize) -> Result<IoBufs, Error> {
119        self.view().read_at(offset, len).await
120    }
121
122    /// Read into `buf` if it can be done synchronously without I/O. Returns `true` only if all
123    /// `buf.len()` bytes were satisfied from the page cache and/or the in-memory tail. When `false`
124    /// is returned, the contents of `buf` are unspecified.
125    pub fn try_read_sync_into(&self, buf: &mut [u8], offset: u64) -> bool {
126        self.view().try_read_sync_into(buf, offset)
127    }
128
129    /// Reads bytes starting at `offset` into `buf`.
130    pub async fn read_into(&self, buf: &mut [u8], offset: u64) -> Result<(), Error> {
131        self.view().read_into(buf, offset).await
132    }
133
134    /// Reads up to `len` bytes starting at `offset`, but only as many as are available.
135    ///
136    /// Returns the buffer (truncated to actual bytes read) and the number of bytes read. Returns
137    /// an error if no bytes are available at the given offset.
138    pub async fn read_up_to(
139        &self,
140        offset: u64,
141        len: usize,
142        bufs: impl Into<IoBufMut> + Send,
143    ) -> Result<(IoBufMut, usize), Error> {
144        self.view().read_up_to(offset, len, bufs).await
145    }
146
147    /// Read multiple fixed-size items at sorted byte offsets into a contiguous caller buffer.
148    ///
149    /// `buf` must be exactly `offsets.len() * item_size` bytes. All offsets must be sorted,
150    /// non-overlapping, and within bounds.
151    ///
152    /// Returns the number of items fully served without a blob read (from the in-memory tail and the
153    /// page cache). The remaining items required at least one blob read.
154    pub async fn read_many_into(
155        &self,
156        buf: &mut [u8],
157        offsets: &[u64],
158        item_size: NonZeroUsize,
159    ) -> Result<usize, Error> {
160        self.view().read_many_into(buf, offsets, item_size).await
161    }
162
163    /// Like [`Self::read_many_into`], but synchronous and cache-only. Returns the indices of
164    /// items that require a blob read. Their slots in `buf` hold unspecified bytes.
165    pub fn try_read_many_sync_into(
166        &self,
167        buf: &mut [u8],
168        offsets: &[u64],
169        item_size: NonZeroUsize,
170    ) -> Vec<usize> {
171        self.view().try_read_many_sync_into(buf, offsets, item_size)
172    }
173
174    /// Like [`Self::try_read_many_sync_into`], but for variable-length `(offset, len)` ranges:
175    /// `buf` holds one slot per range, back to back.
176    pub fn try_read_ranges_sync_into(&self, buf: &mut [u8], ranges: &[(u64, usize)]) -> Vec<usize> {
177        self.view().try_read_ranges_sync_into(buf, ranges)
178    }
179
180    /// Returns a [Replay] for sequentially reading all logical bytes of the sealed view.
181    ///
182    /// Sealed values have no write buffer to flush, so unlike [`super::Writer::replay`] this method
183    /// is not async.
184    pub fn replay(&self, buffer_size: NonZeroUsize) -> Result<Replay<B>, Error> {
185        let logical_page_size = self.inner.cache_ref.page_size();
186        let logical_page_size_nz =
187            NonZeroU16::new(logical_page_size as u16).expect("page_size is non-zero");
188        let physical_page_size = logical_page_size
189            .checked_add(CHECKSUM_SIZE)
190            .ok_or(Error::OffsetOverflow)?;
191        let prefetch_pages = (buffer_size.get() / physical_page_size as usize).max(1);
192
193        let partial_len = self
194            .inner
195            .partial_page
196            .as_ref()
197            .map_or(0, |p| p.len() as u64);
198        let full_pages = (self.inner.size - partial_len) / logical_page_size;
199        let pages = full_pages + u64::from(partial_len > 0);
200        let physical_blob_size = physical_page_size
201            .checked_mul(pages)
202            .ok_or(Error::OffsetOverflow)?;
203        let logical_blob_size = self.inner.size;
204
205        let reader = PageReader::new(
206            self.inner.blob.clone(),
207            physical_blob_size,
208            logical_blob_size,
209            prefetch_pages,
210            logical_page_size_nz,
211        );
212        Ok(Replay::new(reader))
213    }
214
215    /// Page-cache id used for reads. Exposed for tests that verify the id is preserved across
216    /// [`super::Writer::seal`].
217    #[cfg(test)]
218    pub(super) fn cache_id(&self) -> u64 {
219        self.inner.id
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use crate::{
227        buffer::{paged::Writer, tests::SyncTrackingBlob},
228        deterministic, Buf, Runner as _, Storage as _,
229    };
230    use commonware_macros::test_traced;
231    use commonware_utils::{NZUsize, NZU16};
232
233    const PAGE_SIZE: NonZeroU16 = NZU16!(103); // janky page size to test alignment
234    const BUFFER_SIZE: usize = PAGE_SIZE.get() as usize * 2;
235
236    /// Seal a [Writer] and assert no fsync (full or range) occurred during the seal itself.
237    #[test_traced("DEBUG")]
238    fn test_seal_no_fsync() {
239        let executor = deterministic::Runner::default();
240        executor.start(|context: deterministic::Context| async move {
241            let blob = SyncTrackingBlob::new();
242            let cache_ref =
243                super::CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(BUFFER_SIZE));
244            let mut append = Writer::new(blob.clone(), 0, BUFFER_SIZE, cache_ref)
245                .await
246                .unwrap();
247
248            // Append some data crossing several pages but don't sync.
249            let data: Vec<u8> = (0u8..=255).cycle().take(300).collect();
250            append.append(&data).await.unwrap();
251
252            let (_durable_before, _writes_before, full_before, range_before) = blob.snapshot();
253
254            // Seal -- this must flush logical bytes to the blob but NOT fsync.
255            let sealed = append.seal().await.unwrap();
256
257            let (_durable_after, _writes_after, full_after, range_after) = blob.snapshot();
258            assert_eq!(full_after, full_before, "seal must not invoke Blob::sync");
259            assert_eq!(
260                range_after, range_before,
261                "seal must not invoke Blob::write_at_sync"
262            );
263
264            assert_eq!(sealed.size(), 300);
265        });
266    }
267
268    /// Sealing consumes the unique write handle; outstanding readers remain valid and agree
269    /// with the sealed view.
270    #[test_traced("DEBUG")]
271    fn test_seal_succeeds_with_readers() {
272        let executor = deterministic::Runner::default();
273        executor.start(|context: deterministic::Context| async move {
274            let (blob, blob_size) = context.open("test_partition", b"readers").await.unwrap();
275            let cache_ref =
276                super::CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(BUFFER_SIZE));
277            let mut writer = Writer::new(blob, blob_size, BUFFER_SIZE, cache_ref)
278                .await
279                .unwrap();
280            writer.append(b"hello world").await.unwrap();
281
282            // A snapshot captures the buffered bytes as an owned, frozen read handle.
283            let reader = writer.snapshot().await.unwrap();
284            let reader_clone = reader.clone();
285            assert_eq!(reader.size(), 11);
286
287            // Seal succeeds while snapshots exist.
288            let sealed = writer.seal().await.unwrap();
289            assert_eq!(sealed.size(), 11);
290
291            // Both snapshot handles keep reading the frozen state and agree with the sealed view.
292            for r in [&reader, &reader_clone] {
293                assert_eq!(r.size(), 11);
294                let via_reader = r.read_at(0, 11).await.unwrap().coalesce();
295                let via_sealed = sealed.read_at(0, 11).await.unwrap().coalesce();
296                assert_eq!(via_reader.as_ref(), b"hello world");
297                assert_eq!(via_sealed.as_ref(), via_reader.as_ref());
298            }
299        });
300    }
301
302    /// A reader created before sealing reads full pages and the partial page after the seal,
303    /// from both the page cache and the blob.
304    #[test_traced("DEBUG")]
305    fn test_reader_full_pages_after_seal() {
306        let executor = deterministic::Runner::default();
307        executor.start(|context: deterministic::Context| async move {
308            let (blob, blob_size) = context.open("test_partition", b"rdr_pages").await.unwrap();
309            // A single-page cache forces most full-page reads to miss and hit the blob.
310            let cache_ref = super::CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(1));
311            let mut writer = Writer::new(blob, blob_size, BUFFER_SIZE, cache_ref)
312                .await
313                .unwrap();
314
315            let page_size = PAGE_SIZE.get() as usize;
316            let total = page_size * 3 + 7;
317            let data: Vec<u8> = (0u8..=255).cycle().take(total).collect();
318            writer.append(&data).await.unwrap();
319
320            let reader = writer.snapshot().await.unwrap();
321            let sealed = writer.seal().await.unwrap();
322            assert_eq!(reader.size(), total as u64);
323
324            // Full range, a page-straddling range, and the partial page, each compared
325            // against the sealed view.
326            let cases = [
327                (0u64, total),
328                (page_size as u64 - 3, 6),
329                ((page_size * 3) as u64, 7),
330            ];
331            for (offset, len) in cases {
332                let via_reader = reader.read_at(offset, len).await.unwrap().coalesce();
333                let via_sealed = sealed.read_at(offset, len).await.unwrap().coalesce();
334                assert_eq!(
335                    via_reader.as_ref(),
336                    &data[offset as usize..offset as usize + len]
337                );
338                assert_eq!(via_sealed.as_ref(), via_reader.as_ref());
339            }
340        });
341    }
342
343    /// `Sealed::sync` forwards to the underlying blob's sync, making prior writes durable.
344    #[test_traced("DEBUG")]
345    fn test_sealed_sync_makes_blob_durable() {
346        let executor = deterministic::Runner::default();
347        executor.start(|context: deterministic::Context| async move {
348            let blob = SyncTrackingBlob::new();
349            let cache_ref =
350                super::CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(BUFFER_SIZE));
351            let mut append = Writer::new(blob.clone(), 0, BUFFER_SIZE, cache_ref)
352                .await
353                .unwrap();
354
355            // Write data with no fsync.
356            let data: Vec<u8> = (0u8..=255).cycle().take(300).collect();
357            append.append(&data).await.unwrap();
358            let sealed = append.seal().await.unwrap();
359
360            let (durable_before, _, full_before, _) = blob.snapshot();
361            assert!(
362                durable_before.is_empty(),
363                "no bytes should be durable before Sealed::sync"
364            );
365
366            sealed.sync().await.unwrap();
367
368            let (durable_after, _, full_after, _) = blob.snapshot();
369            assert_eq!(
370                full_after,
371                full_before + 1,
372                "Sealed::sync must invoke Blob::sync exactly once"
373            );
374            assert!(
375                !durable_after.is_empty(),
376                "blob bytes must be durable after Sealed::sync"
377            );
378        });
379    }
380
381    /// Sealing preserves the originating [Writer]'s page-cache id.
382    #[test_traced("DEBUG")]
383    fn test_seal_preserves_cache_id() {
384        let executor = deterministic::Runner::default();
385        executor.start(|context: deterministic::Context| async move {
386            let (blob, blob_size) = context.open("test_partition", b"cache_id").await.unwrap();
387            let cache_ref =
388                super::CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(BUFFER_SIZE));
389            let append = Writer::new(blob, blob_size, BUFFER_SIZE, cache_ref)
390                .await
391                .unwrap();
392            let append_id = append.cache_id();
393            let sealed = append.seal().await.unwrap();
394            assert_eq!(sealed.cache_id(), append_id);
395        });
396    }
397
398    /// Sealing an empty blob yields an empty sealed view.
399    #[test_traced("DEBUG")]
400    fn test_seal_empty_blob() {
401        let executor = deterministic::Runner::default();
402        executor.start(|context: deterministic::Context| async move {
403            let (blob, blob_size) = context.open("test_partition", b"empty").await.unwrap();
404            let cache_ref =
405                super::CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(BUFFER_SIZE));
406            let append = Writer::new(blob, blob_size, BUFFER_SIZE, cache_ref)
407                .await
408                .unwrap();
409            let sealed = append.seal().await.unwrap();
410
411            assert_eq!(sealed.size(), 0);
412
413            // Out-of-bounds reads error.
414            let mut buf = [0u8; 1];
415            let err = sealed.read_into(&mut buf, 0).await.unwrap_err();
416            assert!(matches!(err, Error::BlobInsufficientLength));
417        });
418    }
419
420    /// Sealing a blob whose size is exactly a page-multiple has no partial page.
421    #[test_traced("DEBUG")]
422    fn test_seal_full_pages_only() {
423        let executor = deterministic::Runner::default();
424        executor.start(|context: deterministic::Context| async move {
425            let (blob, blob_size) = context.open("test_partition", b"full").await.unwrap();
426            let cache_ref =
427                super::CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(BUFFER_SIZE));
428            let mut append = Writer::new(blob, blob_size, BUFFER_SIZE, cache_ref)
429                .await
430                .unwrap();
431
432            // Append exactly two pages.
433            let page_size = PAGE_SIZE.get() as usize;
434            let data: Vec<u8> = (0u8..=255).cycle().take(page_size * 2).collect();
435            append.append(&data).await.unwrap();
436            let sealed = append.seal().await.unwrap();
437
438            assert_eq!(sealed.size(), data.len() as u64);
439
440            // Read everything back.
441            let mut buf = vec![0u8; data.len()];
442            sealed.read_into(&mut buf, 0).await.unwrap();
443            assert_eq!(buf, data);
444        });
445    }
446
447    /// Sealing a blob whose size is smaller than one page yields only a partial page.
448    #[test_traced("DEBUG")]
449    fn test_seal_partial_only() {
450        let executor = deterministic::Runner::default();
451        executor.start(|context: deterministic::Context| async move {
452            let (blob, blob_size) = context.open("test_partition", b"partial").await.unwrap();
453            let cache_ref =
454                super::CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(BUFFER_SIZE));
455            let mut append = Writer::new(blob, blob_size, BUFFER_SIZE, cache_ref)
456                .await
457                .unwrap();
458
459            // Append fewer than one page of data.
460            let data: Vec<u8> = (0u8..=50).collect();
461            append.append(&data).await.unwrap();
462            let sealed = append.seal().await.unwrap();
463
464            assert_eq!(sealed.size(), data.len() as u64);
465
466            let mut buf = vec![0u8; data.len()];
467            sealed.read_into(&mut buf, 0).await.unwrap();
468            assert_eq!(buf, data);
469        });
470    }
471
472    /// Reads that straddle the partial-page boundary stitch together cache and partial bytes.
473    #[test_traced("DEBUG")]
474    fn test_seal_full_plus_partial_straddle() {
475        let executor = deterministic::Runner::default();
476        executor.start(|context: deterministic::Context| async move {
477            let (blob, blob_size) = context.open("test_partition", b"straddle").await.unwrap();
478            let cache_ref =
479                super::CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(BUFFER_SIZE));
480            let mut append = Writer::new(blob, blob_size, BUFFER_SIZE, cache_ref)
481                .await
482                .unwrap();
483
484            // One full page + a partial.
485            let page_size = PAGE_SIZE.get() as usize;
486            let total = page_size + 17;
487            let data: Vec<u8> = (0u8..=255).cycle().take(total).collect();
488            append.append(&data).await.unwrap();
489            let sealed = append.seal().await.unwrap();
490
491            assert_eq!(sealed.size(), total as u64);
492
493            // Straddle read: 5 bytes before the boundary and 10 after.
494            let off = (page_size - 5) as u64;
495            let len = 15usize;
496            let mut buf = vec![0u8; len];
497            sealed.read_into(&mut buf, off).await.unwrap();
498            assert_eq!(buf, data[page_size - 5..page_size - 5 + len]);
499
500            // Read fully within partial.
501            let off = page_size as u64;
502            let mut buf = vec![0u8; 10];
503            sealed.read_into(&mut buf, off).await.unwrap();
504            assert_eq!(buf, data[page_size..page_size + 10]);
505
506            // Read fully within first full page.
507            let mut buf = vec![0u8; 20];
508            sealed.read_into(&mut buf, 0).await.unwrap();
509            assert_eq!(buf, data[..20]);
510        });
511    }
512
513    /// `Sealed::read_at` exposes the same data as `read_into`.
514    #[test_traced("DEBUG")]
515    fn test_sealed_read_at() {
516        let executor = deterministic::Runner::default();
517        executor.start(|context: deterministic::Context| async move {
518            let (blob, blob_size) = context.open("test_partition", b"read_at").await.unwrap();
519            let cache_ref =
520                super::CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(BUFFER_SIZE));
521            let mut append = Writer::new(blob, blob_size, BUFFER_SIZE, cache_ref)
522                .await
523                .unwrap();
524
525            let data: Vec<u8> = (0u8..=255).cycle().take(250).collect();
526            append.append(&data).await.unwrap();
527            let sealed = append.seal().await.unwrap();
528
529            let bufs = sealed.read_at(0, data.len()).await.unwrap();
530            let coalesced = bufs.coalesce();
531            assert_eq!(coalesced.as_ref(), data.as_slice());
532        });
533    }
534
535    /// `Sealed::read_many_into` returns items at sorted, possibly straddling, offsets.
536    #[test_traced("DEBUG")]
537    fn test_sealed_read_many_into() {
538        let executor = deterministic::Runner::default();
539        executor.start(|context: deterministic::Context| async move {
540            let (blob, blob_size) = context.open("test_partition", b"rmany").await.unwrap();
541            let cache_ref =
542                super::CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(BUFFER_SIZE));
543            let mut append = Writer::new(blob, blob_size, BUFFER_SIZE, cache_ref)
544                .await
545                .unwrap();
546
547            // Two pages worth so reads exercise both cache and partial.
548            let page_size = PAGE_SIZE.get() as usize;
549            let total = page_size + 50;
550            let data: Vec<u8> = (0u8..=255).cycle().take(total).collect();
551            append.append(&data).await.unwrap();
552            let sealed = append.seal().await.unwrap();
553
554            // 4-byte items at three positions: pure cache, straddling boundary, pure partial.
555            let offsets = [0u64, (page_size - 2) as u64, (page_size + 10) as u64];
556            let item_size = 4usize;
557            let mut out = vec![0u8; offsets.len() * item_size];
558            sealed
559                .read_many_into(&mut out, &offsets, NZUsize!(item_size))
560                .await
561                .unwrap();
562
563            for (i, &off) in offsets.iter().enumerate() {
564                assert_eq!(
565                    &out[i * item_size..(i + 1) * item_size],
566                    &data[off as usize..off as usize + item_size],
567                );
568            }
569        });
570    }
571
572    /// `Sealed::try_read_many_sync_into` serves cached pages and the in-memory tail, and maps
573    /// missed ranges (including straddling prefixes) back to item indices.
574    #[test_traced("DEBUG")]
575    fn test_sealed_try_read_many_sync_into() {
576        let executor = deterministic::Runner::default();
577        executor.start(|context: deterministic::Context| async move {
578            let (blob, blob_size) = context.open("test_partition", b"rmany_sync").await.unwrap();
579            // Capacity of one page makes hit/miss behavior deterministic: the cache holds
580            // exactly the last page touched.
581            let cache_ref = super::CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(1));
582            let mut append = Writer::new(blob, blob_size, BUFFER_SIZE, cache_ref)
583                .await
584                .unwrap();
585
586            // Two full pages plus a partial tail page held in memory by the sealed view.
587            let page_size = PAGE_SIZE.get() as usize;
588            let total = page_size * 2 + 50;
589            let data: Vec<u8> = (0u8..=255).cycle().take(total).collect();
590            append.append(&data).await.unwrap();
591            let sealed = append.seal().await.unwrap();
592
593            // Items: page 0, page 1, straddling page 1 and the tail, pure tail.
594            let offsets = [
595                0u64,
596                page_size as u64,
597                (page_size * 2 - 2) as u64,
598                (page_size * 2 + 10) as u64,
599            ];
600            let item_size = 4usize;
601            let check = |out: &[u8], indices: &[usize]| {
602                for &i in indices {
603                    let off = offsets[i] as usize;
604                    assert_eq!(
605                        &out[i * item_size..(i + 1) * item_size],
606                        &data[off..off + item_size],
607                    );
608                }
609            };
610
611            // With only page 0 cached, items touching page 1 are misses. The tail item is
612            // served from the sealed view's in-memory bytes.
613            sealed.read_at(0, page_size).await.unwrap();
614            let mut out = vec![0u8; offsets.len() * item_size];
615            let misses = sealed.try_read_many_sync_into(&mut out, &offsets, NZUsize!(item_size));
616            assert_eq!(misses, vec![1, 2]);
617            check(&out, &[0, 3]);
618
619            // With only page 1 cached, item 0 becomes the miss and the straddler is served.
620            sealed.read_at(page_size as u64, page_size).await.unwrap();
621            let mut out = vec![0u8; offsets.len() * item_size];
622            let misses = sealed.try_read_many_sync_into(&mut out, &offsets, NZUsize!(item_size));
623            assert_eq!(misses, vec![0]);
624            check(&out, &[1, 2, 3]);
625        });
626    }
627
628    /// `Sealed::try_read_ranges_sync_into` serves cached pages and the in-memory tail for
629    /// variable-length ranges, and maps missed ranges back to range indices, including when a
630    /// zero-length range shares its offset with the missed range that follows it.
631    #[test_traced("DEBUG")]
632    fn test_sealed_try_read_ranges_sync_into() {
633        let executor = deterministic::Runner::default();
634        executor.start(|context: deterministic::Context| async move {
635            let (blob, blob_size) = context
636                .open("test_partition", b"rranges_sync")
637                .await
638                .unwrap();
639            // Capacity of one page makes hit/miss behavior deterministic: the cache holds
640            // exactly the last page touched.
641            let cache_ref = super::CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(1));
642            let mut append = Writer::new(blob, blob_size, BUFFER_SIZE, cache_ref)
643                .await
644                .unwrap();
645
646            // Two full pages plus a partial tail page held in memory by the sealed view.
647            let page_size = PAGE_SIZE.get() as usize;
648            let total = page_size * 2 + 50;
649            let data: Vec<u8> = (0u8..=255).cycle().take(total).collect();
650            append.append(&data).await.unwrap();
651            let sealed = append.seal().await.unwrap();
652
653            // Ranges: page 0, a zero-length range sharing its offset with the page 1 range
654            // that follows it, page 1, straddling page 1 and the tail, pure tail.
655            let ranges = [
656                (0u64, 3usize),
657                (page_size as u64 + 5, 0),
658                (page_size as u64 + 5, 7),
659                ((page_size * 2 - 2) as u64, 4),
660                ((page_size * 2 + 10) as u64, 4),
661            ];
662            let total_len: usize = ranges.iter().map(|&(_, len)| len).sum();
663            let check = |out: &[u8], indices: &[usize]| {
664                let mut start = 0;
665                for (i, &(off, len)) in ranges.iter().enumerate() {
666                    if indices.contains(&i) {
667                        let off = off as usize;
668                        assert_eq!(&out[start..start + len], &data[off..off + len]);
669                    }
670                    start += len;
671                }
672            };
673
674            // With only page 0 cached, the page 1 range and the straddler's prefix miss. The
675            // zero-length range never misses. The tail range is served in memory.
676            sealed.read_at(0, page_size).await.unwrap();
677            let mut out = vec![0u8; total_len];
678            let misses = sealed.try_read_ranges_sync_into(&mut out, &ranges);
679            assert_eq!(misses, vec![2, 3]);
680            check(&out, &[0, 4]);
681
682            // With only page 1 cached, range 0 becomes the miss and the rest are served.
683            sealed.read_at(page_size as u64, page_size).await.unwrap();
684            let mut out = vec![0u8; total_len];
685            let misses = sealed.try_read_ranges_sync_into(&mut out, &ranges);
686            assert_eq!(misses, vec![0]);
687            check(&out, &[1, 2, 3, 4]);
688        });
689    }
690
691    /// `Sealed::read_many_into` falls back to blob reads for full-page cache misses.
692    #[test_traced("DEBUG")]
693    fn test_sealed_read_many_into_cache_miss() {
694        let executor = deterministic::Runner::default();
695        executor.start(|context: deterministic::Context| async move {
696            let (blob, blob_size) = context.open("test_partition", b"rmany_miss").await.unwrap();
697            let cache_ref = super::CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(1));
698            let mut append = Writer::new(blob, blob_size, BUFFER_SIZE, cache_ref)
699                .await
700                .unwrap();
701
702            let page_size = PAGE_SIZE.get() as usize;
703            let data: Vec<u8> = (0u8..=255).cycle().take(page_size * 2).collect();
704            append.append(&data).await.unwrap();
705            let sealed = append.seal().await.unwrap();
706
707            let offsets = [0u64, page_size as u64];
708            let item_size = 4usize;
709            let mut out = vec![0u8; offsets.len() * item_size];
710            sealed
711                .read_many_into(&mut out, &offsets, NZUsize!(item_size))
712                .await
713                .unwrap();
714
715            for (i, &off) in offsets.iter().enumerate() {
716                assert_eq!(
717                    &out[i * item_size..(i + 1) * item_size],
718                    &data[off as usize..off as usize + item_size],
719                );
720            }
721        });
722    }
723
724    #[test_traced("DEBUG")]
725    #[should_panic(expected = "ranges must be sorted and non-overlapping")]
726    fn test_sealed_read_many_into_rejects_unsorted_offsets() {
727        let executor = deterministic::Runner::default();
728        executor.start(|context: deterministic::Context| async move {
729            let (blob, blob_size) = context.open("test_partition", b"rmany_bad").await.unwrap();
730            let cache_ref =
731                super::CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(BUFFER_SIZE));
732            let mut append = Writer::new(blob, blob_size, BUFFER_SIZE, cache_ref)
733                .await
734                .unwrap();
735            append.append(&[7; 32]).await.unwrap();
736            let sealed = append.seal().await.unwrap();
737
738            let mut out = vec![0u8; 8];
739            let _ = sealed.read_many_into(&mut out, &[8, 4], NZUsize!(4)).await;
740        });
741    }
742
743    /// `Sealed::read_many_into` validates all caller-provided offsets before reading.
744    #[test_traced("DEBUG")]
745    fn test_sealed_read_many_into_rejects_invalid_offsets() {
746        let executor = deterministic::Runner::default();
747        executor.start(|context: deterministic::Context| async move {
748            let (blob, blob_size) = context.open("test_partition", b"rmany_bad").await.unwrap();
749            let cache_ref =
750                super::CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(BUFFER_SIZE));
751            let mut append = Writer::new(blob, blob_size, BUFFER_SIZE, cache_ref)
752                .await
753                .unwrap();
754            append.append(&[7; 32]).await.unwrap();
755            let sealed = append.seal().await.unwrap();
756
757            let mut out = vec![0u8; 8];
758            let err = sealed
759                .read_many_into(&mut out, &[u64::MAX - 1, 8], NZUsize!(4))
760                .await
761                .unwrap_err();
762            assert!(matches!(err, Error::OffsetOverflow));
763
764            let err = sealed
765                .read_many_into(&mut out, &[28, 32], NZUsize!(4))
766                .await
767                .unwrap_err();
768            assert!(matches!(err, Error::BlobInsufficientLength));
769        });
770    }
771
772    /// `try_read_sync_into` succeeds when bytes come purely from the in-memory partial page.
773    #[test_traced("DEBUG")]
774    fn test_sealed_try_read_sync_partial() {
775        let executor = deterministic::Runner::default();
776        executor.start(|context: deterministic::Context| async move {
777            let (blob, blob_size) = context
778                .open("test_partition", b"trs_partial")
779                .await
780                .unwrap();
781            let cache_ref =
782                super::CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(BUFFER_SIZE));
783            let mut append = Writer::new(blob, blob_size, BUFFER_SIZE, cache_ref)
784                .await
785                .unwrap();
786
787            let page_size = PAGE_SIZE.get() as usize;
788            let total = page_size + 30;
789            let data: Vec<u8> = (0u8..=255).cycle().take(total).collect();
790            append.append(&data).await.unwrap();
791            let sealed = append.seal().await.unwrap();
792
793            // Read fully within partial.
794            let mut buf = vec![0u8; 10];
795            assert!(sealed.try_read_sync_into(&mut buf, page_size as u64));
796            assert_eq!(buf, data[page_size..page_size + 10]);
797
798            // Out of bounds returns false.
799            let mut buf = vec![0u8; 10];
800            assert!(!sealed.try_read_sync_into(&mut buf, total as u64));
801        });
802    }
803
804    /// `try_read_sync_into` can stitch a cached full-page prefix to in-memory partial bytes.
805    #[test_traced("DEBUG")]
806    fn test_sealed_try_read_sync_straddles_cached_and_partial() {
807        let executor = deterministic::Runner::default();
808        executor.start(|context: deterministic::Context| async move {
809            let (blob, blob_size) = context
810                .open("test_partition", b"trs_straddle")
811                .await
812                .unwrap();
813            let cache_ref =
814                super::CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(BUFFER_SIZE));
815            let mut append = Writer::new(blob, blob_size, BUFFER_SIZE, cache_ref)
816                .await
817                .unwrap();
818
819            let page_size = PAGE_SIZE.get() as usize;
820            let total = page_size + 30;
821            let data: Vec<u8> = (0u8..=255).cycle().take(total).collect();
822            append.append(&data).await.unwrap();
823            let sealed = append.seal().await.unwrap();
824
825            let mut buf = vec![0u8; 12];
826            assert!(sealed.try_read_sync_into(&mut buf, (page_size - 4) as u64));
827            assert_eq!(buf, data[page_size - 4..page_size + 8]);
828        });
829    }
830
831    /// Synchronous reads past the sealed size are rejected.
832    #[test_traced("DEBUG")]
833    fn test_sealed_try_read_sync_out_of_bounds() {
834        let executor = deterministic::Runner::default();
835        executor.start(|context: deterministic::Context| async move {
836            let (blob, blob_size) = context.open("test_partition", b"trs_fail").await.unwrap();
837            let cache_ref =
838                super::CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(BUFFER_SIZE));
839            let mut append = Writer::new(blob, blob_size, BUFFER_SIZE, cache_ref)
840                .await
841                .unwrap();
842
843            let page_size = PAGE_SIZE.get() as usize;
844            let data: Vec<u8> = (0u8..=255).cycle().take(page_size + 5).collect();
845            append.append(&data).await.unwrap();
846            let sealed = append.seal().await.unwrap();
847
848            let mut buf = vec![9u8; 10];
849            assert!(!sealed.try_read_sync_into(&mut buf, data.len() as u64));
850        });
851    }
852
853    /// `Sealed::replay` streams all logical bytes including the partial page.
854    #[test_traced("DEBUG")]
855    fn test_sealed_replay() {
856        let executor = deterministic::Runner::default();
857        executor.start(|context: deterministic::Context| async move {
858            let (blob, blob_size) = context.open("test_partition", b"replay").await.unwrap();
859            let cache_ref =
860                super::CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(BUFFER_SIZE));
861            let mut append = Writer::new(blob, blob_size, BUFFER_SIZE, cache_ref)
862                .await
863                .unwrap();
864
865            // Two pages + a partial, synced so the bytes are on disk before sealing.
866            let page_size = PAGE_SIZE.get() as usize;
867            let total = page_size * 2 + 25;
868            let data: Vec<u8> = (0u8..=255).cycle().take(total).collect();
869            append.append(&data).await.unwrap();
870            append.sync().await.unwrap();
871            let sealed = append.seal().await.unwrap();
872
873            let mut replay = sealed.replay(NZUsize!(BUFFER_SIZE)).unwrap();
874            assert_eq!(replay.blob_size(), total as u64);
875
876            // Drain all logical bytes.
877            let mut out = Vec::with_capacity(total);
878            while replay.ensure(1).await.unwrap() {
879                let chunk = replay.chunk();
880                let copy_len = chunk.len();
881                out.extend_from_slice(chunk);
882                replay.advance(copy_len);
883            }
884            assert_eq!(out, data);
885        });
886    }
887
888    /// Replaying a snapshot must stop at the snapshot's logical boundary, even if the live writer
889    /// later extends the same physical page.
890    #[test_traced("DEBUG")]
891    fn test_snapshot_replay_stays_frozen_after_writer_growth() {
892        let executor = deterministic::Runner::default();
893        executor.start(|context: deterministic::Context| async move {
894            let (blob, blob_size) = context
895                .open("test_partition", b"snapshot_replay_growth")
896                .await
897                .unwrap();
898            let cache_ref =
899                super::CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(BUFFER_SIZE));
900            let mut writer = Writer::new(blob, blob_size, BUFFER_SIZE, cache_ref)
901                .await
902                .unwrap();
903
904            let page_size = PAGE_SIZE.get() as usize;
905            let mut original = vec![0xAA; page_size];
906            original.extend_from_slice(b"old");
907            writer.append(&original).await.unwrap();
908            writer.sync().await.unwrap();
909
910            let snapshot = writer.snapshot().await.unwrap();
911            let snapshot_bytes = snapshot
912                .read_at(0, snapshot.size() as usize)
913                .await
914                .unwrap()
915                .coalesce();
916            let mut replay = snapshot.replay(NZUsize!(BUFFER_SIZE)).unwrap();
917            assert_eq!(replay.blob_size(), original.len() as u64);
918
919            writer.append(b"newtail").await.unwrap();
920            writer.sync().await.unwrap();
921
922            let mut out = Vec::new();
923            while replay.ensure(1).await.unwrap() {
924                let chunk = replay.chunk();
925                let copy_len = chunk.len();
926                out.extend_from_slice(chunk);
927                replay.advance(copy_len);
928            }
929
930            assert_eq!(out.as_slice(), snapshot_bytes.as_ref());
931            assert_eq!(out, original);
932        });
933    }
934
935    /// `Sealed::replay` works without a prior `Append::sync` because `Append::seal` writes bytes
936    /// to the blob without fsyncing.
937    #[test_traced("DEBUG")]
938    fn test_seal_replay_without_sync() {
939        let executor = deterministic::Runner::default();
940        executor.start(|context: deterministic::Context| async move {
941            let blob = SyncTrackingBlob::new();
942            let cache_ref =
943                super::CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(BUFFER_SIZE));
944            let mut append = Writer::new(blob.clone(), 0, BUFFER_SIZE, cache_ref)
945                .await
946                .unwrap();
947
948            let page_size = PAGE_SIZE.get() as usize;
949            let total = page_size * 2 + 25;
950            let data: Vec<u8> = (0u8..=255).cycle().take(total).collect();
951            append.append(&data).await.unwrap();
952            // Seal without a prior append sync.
953            let sealed = append.seal().await.unwrap();
954
955            // Seal must not have fsynced.
956            let (_durable, _writes, full_syncs, range_syncs) = blob.snapshot();
957            assert_eq!(full_syncs, 0, "seal must not invoke Blob::sync");
958            assert_eq!(range_syncs, 0, "seal must not invoke Blob::write_at_sync");
959
960            // Replay must observe all bytes even though they were never fsynced.
961            let mut replay = sealed.replay(NZUsize!(BUFFER_SIZE)).unwrap();
962            assert_eq!(replay.blob_size(), total as u64);
963
964            let mut out = Vec::with_capacity(total);
965            while replay.ensure(1).await.unwrap() {
966                let chunk = replay.chunk();
967                let copy_len = chunk.len();
968                out.extend_from_slice(chunk);
969                replay.advance(copy_len);
970            }
971            assert_eq!(out, data);
972        });
973    }
974
975    /// Bytes made durable via `Sealed::sync` can be reopened through the paged blob format.
976    #[test_traced("DEBUG")]
977    fn test_sealed_sync_reopens() {
978        let executor = deterministic::Runner::default();
979        executor.start(|context: deterministic::Context| async move {
980            let cache_ref =
981                super::CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(BUFFER_SIZE));
982            let data: Vec<u8> = (0u8..=255)
983                .cycle()
984                .take(PAGE_SIZE.get() as usize + 17)
985                .collect();
986            {
987                let (blob, blob_size) = context.open("test_partition", b"reopen").await.unwrap();
988                let mut append = Writer::new(blob, blob_size, BUFFER_SIZE, cache_ref.clone())
989                    .await
990                    .unwrap();
991                append.append(&data).await.unwrap();
992                let sealed = append.seal().await.unwrap();
993                sealed.sync().await.unwrap();
994            }
995
996            let (blob, blob_size) = context.open("test_partition", b"reopen").await.unwrap();
997            let append = Writer::new(blob, blob_size, BUFFER_SIZE, cache_ref)
998                .await
999                .unwrap();
1000            let mut buf = vec![0; data.len()];
1001            append.read_into(&mut buf, 0).await.unwrap();
1002            assert_eq!(buf, data);
1003        });
1004    }
1005
1006    /// Sealing a recovered, already-synced partial page must not rewrite it.
1007    #[test_traced("DEBUG")]
1008    fn test_seal_recovered_synced_partial_page_no_write() {
1009        let executor = deterministic::Runner::default();
1010        executor.start(|context: deterministic::Context| async move {
1011            let blob = SyncTrackingBlob::new();
1012            let cache_ref =
1013                super::CacheRef::from_pooler(&context, PAGE_SIZE, NZUsize!(BUFFER_SIZE));
1014            let data: Vec<u8> = (0u8..=255)
1015                .cycle()
1016                .take(PAGE_SIZE.get() as usize - 17)
1017                .collect();
1018
1019            {
1020                let mut writer = Writer::new(blob.clone(), 0, BUFFER_SIZE, cache_ref.clone())
1021                    .await
1022                    .unwrap();
1023                writer.append(&data).await.unwrap();
1024                writer.sync().await.unwrap();
1025            }
1026
1027            let (_, writes, full_syncs, range_syncs) = blob.snapshot();
1028            let mut recovered = Writer::new(blob.clone(), blob.size(), BUFFER_SIZE, cache_ref)
1029                .await
1030                .unwrap();
1031            assert_eq!(recovered.size(), data.len() as u64);
1032
1033            recovered.sync().await.unwrap();
1034            let (_, writes_after_sync, full_after_sync, range_after_sync) = blob.snapshot();
1035            assert_eq!(
1036                writes_after_sync, writes,
1037                "syncing an unchanged recovered partial page must not rewrite it"
1038            );
1039            assert_eq!(full_after_sync, full_syncs + 1);
1040            assert_eq!(range_after_sync, range_syncs);
1041
1042            let sealed = recovered.seal().await.unwrap();
1043            let (_, writes_after_seal, full_after_seal, range_after_seal) = blob.snapshot();
1044            assert_eq!(
1045                writes_after_seal, writes_after_sync,
1046                "sealing an unchanged recovered partial page must not rewrite it"
1047            );
1048            assert_eq!(full_after_seal, full_after_sync);
1049            assert_eq!(range_after_seal, range_after_sync);
1050
1051            let read = sealed.read_at(0, data.len()).await.unwrap().coalesce();
1052            assert_eq!(read.as_ref(), data.as_slice());
1053        });
1054    }
1055}