Skip to main content

turbo_persistence/
static_sorted_file.rs

1use std::{
2    cmp::Ordering,
3    hash::BuildHasherDefault,
4    io,
5    marker::PhantomData,
6    ops::{Deref, Range},
7    path::Path,
8    sync::{
9        Arc,
10        atomic::{AtomicU64, Ordering as AtomicOrdering},
11    },
12};
13
14use anyhow::{Context, Result, bail, ensure};
15use fs_err::File;
16#[cfg(feature = "mmap")]
17use memmap2::Mmap;
18use quick_cache::{Lifecycle, sync::GuardResult};
19use rustc_hash::FxHasher;
20use smallvec::SmallVec;
21use zerocopy::IntoBytes;
22
23#[cfg(feature = "mmap")]
24use crate::mmap_helper::advise_mmap_for_persistence;
25use crate::{
26    AccessMode, Compression, QueryKey,
27    arc_bytes::ArcBytes,
28    be,
29    compression::checksum_block,
30    constants::MAX_INLINE_VALUE_SIZE,
31    lookup_entry::{IterValue, LookupEntry, LookupValue},
32    rc_bytes::RcBytes,
33    shared_bytes::SharedBytes,
34    static_sorted_file_builder::{
35        BLOCK_HEADER_SIZE, INDEX_BLOCK_ENTRY_SIZE, INDEX_BLOCK_HEADER_SIZE,
36    },
37};
38
39/// The block header for an index block.
40pub const BLOCK_TYPE_INDEX: u8 = 0;
41/// The block header for a key block with 8-byte hash per entry.
42pub const BLOCK_TYPE_KEY_WITH_HASH: u8 = 1;
43/// The block header for a key block without hash. Entries are ordered by key.
44pub const BLOCK_TYPE_KEY_NO_HASH: u8 = 2;
45/// The block header for a fixed-size key block with 8-byte hash per entry.
46pub const BLOCK_TYPE_FIXED_KEY_WITH_HASH: u8 = 3;
47/// The block header for a fixed-size key block without hash. Entries are ordered by key.
48pub const BLOCK_TYPE_FIXED_KEY_NO_HASH: u8 = 4;
49
50/// Whether a key block stores a hash per entry, and therefore what order its entries are in.
51#[derive(Clone, Copy, PartialEq, Eq, Debug)]
52pub enum KeyBlockLayout {
53    /// 8-byte hash stored ahead of each key; entries sorted by `(hash, key)`.
54    HashThenKey,
55    /// No hash stored; entries sorted by key.
56    KeyOnly,
57}
58
59impl KeyBlockLayout {
60    /// Bytes each entry spends on its stored hash: 8, or 0 when the hash is omitted.
61    #[inline]
62    pub fn hash_len(self) -> u8 {
63        match self {
64            KeyBlockLayout::HashThenKey => size_of::<u64>() as u8,
65            KeyBlockLayout::KeyOnly => 0,
66        }
67    }
68
69    /// The on-disk block type byte for this layout, for `fixed`-size or variable-size entries.
70    #[inline]
71    pub fn block_type(self, fixed: bool) -> u8 {
72        match (self, fixed) {
73            (KeyBlockLayout::HashThenKey, false) => BLOCK_TYPE_KEY_WITH_HASH,
74            (KeyBlockLayout::KeyOnly, false) => BLOCK_TYPE_KEY_NO_HASH,
75            (KeyBlockLayout::HashThenKey, true) => BLOCK_TYPE_FIXED_KEY_WITH_HASH,
76            (KeyBlockLayout::KeyOnly, true) => BLOCK_TYPE_FIXED_KEY_NO_HASH,
77        }
78    }
79
80    /// Decodes a key block's type byte into its layout, plus whether entries are fixed-size.
81    /// Returns `None` for a byte that is not a key block type.
82    #[inline]
83    pub fn from_block_type(block_type: u8) -> Option<(Self, bool)> {
84        match block_type {
85            BLOCK_TYPE_KEY_WITH_HASH => Some((KeyBlockLayout::HashThenKey, false)),
86            BLOCK_TYPE_KEY_NO_HASH => Some((KeyBlockLayout::KeyOnly, false)),
87            BLOCK_TYPE_FIXED_KEY_WITH_HASH => Some((KeyBlockLayout::HashThenKey, true)),
88            BLOCK_TYPE_FIXED_KEY_NO_HASH => Some((KeyBlockLayout::KeyOnly, true)),
89            _ => None,
90        }
91    }
92}
93
94/// Written in a fixed-size key block header's value type field when entries share a value size but
95/// not a value type. Each entry then carries its own type byte ahead of its value.
96pub const FIXED_KEY_BLOCK_MIXED_VALUE_TYPE: u8 = 4;
97
98/// The tag for a small-sized value.
99pub const KEY_BLOCK_ENTRY_TYPE_SMALL: u8 = 0;
100/// The tag for the blob value.
101pub const KEY_BLOCK_ENTRY_TYPE_BLOB: u8 = 1;
102/// The tag for the deleted value. This is a *key* tombstone: it deletes every value for the key.
103pub const KEY_BLOCK_ENTRY_TYPE_KEY_DELETED: u8 = 2;
104/// The tag for a medium-sized value.
105pub const KEY_BLOCK_ENTRY_TYPE_MEDIUM: u8 = 3;
106/// The minimum tag for inline values. The actual size is (tag - INLINE_MIN).
107pub const KEY_BLOCK_ENTRY_TYPE_INLINE_MIN: u8 = 8;
108/// The minimum tag for a key-value tombstone, which deletes only the one value it carries and
109/// leaves other values for the same key intact. Only meaningful for
110/// [`FamilyKind::MultiValue`][crate::FamilyKind::MultiValue] families.
111///
112/// This mirrors the inline value range: the deleted value is stored inline in the key block and
113/// its size is (tag - KEY_VALUE_DELETED_MIN). Only inline-sized values can be deleted this way —
114/// a tombstone for a larger value would have to store a second copy of it, costing more than the
115/// value it reclaims.
116pub const KEY_BLOCK_ENTRY_TYPE_KEY_VALUE_DELETED_MIN: u8 =
117    KEY_BLOCK_ENTRY_TYPE_INLINE_MIN + MAX_INLINE_VALUE_SIZE as u8 + 1;
118
119/// Size of one variable-size key block offset table entry when the block stores no hash:
120/// 1 byte entry type packed into the top of a 3-byte in-block position.
121pub const KEY_BLOCK_TABLE_ENTRY_SIZE_NO_HASH: usize = 4;
122/// Size of one variable-size key block offset table entry when the block stores a hash: the key's
123/// 8-byte hash followed by the type/position word.
124///
125/// The hash lives in the table rather than beside the key so that a binary search reads only this
126/// dense array — `compare_hash_key` compares the hash first and reaches for the key only when two
127/// hashes are equal, so the payload is touched once on a match and never on a miss. Total bytes are
128/// unchanged: the table grows by 8 per entry and the payload shrinks by the same.
129pub const KEY_BLOCK_TABLE_ENTRY_SIZE_WITH_HASH: usize =
130    KEY_BLOCK_TABLE_ENTRY_SIZE_NO_HASH + size_of::<u64>();
131
132/// Bytes per offset table entry for a variable-size key block with the given hash length.
133#[inline(always)]
134pub fn key_block_table_stride(hash_len: u8) -> usize {
135    KEY_BLOCK_TABLE_ENTRY_SIZE_NO_HASH + hash_len as usize
136}
137
138/// Encoded size of a small value reference: 2B block index + 2B size + 4B offset.
139pub(crate) const SMALL_VALUE_REF_SIZE: usize = 8;
140/// Encoded size of a medium value reference: 2B block index.
141pub(crate) const MEDIUM_VALUE_REF_SIZE: usize = 2;
142/// Encoded size of a blob value reference: 4B blob id.
143pub(crate) const BLOB_VALUE_REF_SIZE: usize = 4;
144/// Encoded size of a deleted (tombstone) value reference.
145pub(crate) const KEY_DELETED_REF_SIZE: usize = 0;
146
147// Static assertion: both the inline range and the key-value tombstone range that follows it must
148// fit in the key type byte. The tombstone range starts after the inline range and is the same
149// width, so the tombstone range's top is the binding constraint.
150const _: () = assert!(
151    MAX_INLINE_VALUE_SIZE <= (u8::MAX - KEY_BLOCK_ENTRY_TYPE_KEY_VALUE_DELETED_MIN) as usize,
152    "MAX_INLINE_VALUE_SIZE exceeds what can be encoded in key type byte"
153);
154
155/// The result of a lookup operation.
156pub enum SstLookupResult {
157    /// One or more values were found.
158    Found(SmallVec<[LookupValue; 1]>),
159    /// The key was not found.
160    NotFound,
161}
162
163impl From<LookupValue> for SstLookupResult {
164    fn from(value: LookupValue) -> Self {
165        SstLookupResult::Found(smallvec::smallvec![value])
166    }
167}
168
169#[derive(Clone, Default)]
170pub struct BlockWeighter;
171
172impl quick_cache::Weighter<(u32, u16), ArcBytes> for BlockWeighter {
173    fn weight(&self, _key: &(u32, u16), val: &ArcBytes) -> u64 {
174        if val.is_mmap_backed() {
175            // Mmap-backed blocks bypass the cache (served directly from mmap),
176            // so this branch should never be reached.
177            debug_assert!(
178                !val.is_mmap_backed(),
179                "mmap-backed block should not be inserted into BlockCache"
180            );
181            64
182        } else {
183            val.len() as u64 + 8
184        }
185    }
186}
187
188/// Lifecycle hooks for the block cache that prevent eviction of entries
189/// still referenced outside the cache (i.e., with `Arc` strong count > 1).
190#[derive(Clone, Default)]
191pub struct BlockCacheLifecycle;
192
193impl Lifecycle<(u32, u16), ArcBytes> for BlockCacheLifecycle {
194    type RequestState = ();
195
196    #[inline]
197    fn is_pinned(&self, _key: &(u32, u16), val: &ArcBytes) -> bool {
198        val.is_shared_arc()
199    }
200
201    #[inline]
202    fn begin_request(&self) -> Self::RequestState {}
203
204    #[inline]
205    fn on_evict(&self, _state: &mut Self::RequestState, _key: (u32, u16), _val: ArcBytes) {}
206}
207
208pub type BlockCache = quick_cache::sync::Cache<
209    (u32, u16),
210    ArcBytes,
211    BlockWeighter,
212    BuildHasherDefault<FxHasher>,
213    BlockCacheLifecycle,
214>;
215
216/// Trait abstracting value block reading for `handle_key_match_generic`.
217trait ValueBlockCache<B: SharedBytes> {
218    fn get_or_read(
219        self,
220        meta: &StaticSortedFileMetaData,
221        block_index: u16,
222        compression: Compression,
223    ) -> Result<B>;
224    fn read_uncached(
225        self,
226        meta: &StaticSortedFileMetaData,
227        block_index: u16,
228        compression: Compression,
229    ) -> Result<B>;
230}
231
232/// Bundles the lookup backing with the shared block cache and per-file CRC bitmap.
233#[derive(Clone, Copy)]
234struct ArcBlockCacheReader<'a> {
235    backing: &'a StaticSortedFileBacking,
236    block_offsets: &'a BlockOffsets,
237    cache: &'a BlockCache,
238    verified_blocks: &'a [AtomicU64],
239}
240
241impl ValueBlockCache<ArcBytes> for ArcBlockCacheReader<'_> {
242    fn get_or_read(
243        self,
244        meta: &StaticSortedFileMetaData,
245        block_index: u16,
246        compression: Compression,
247    ) -> Result<ArcBytes> {
248        // A value block's bytes are returned to the caller of `get`, so this one must own its
249        // handle. For an uncompressed mmap block that is the mmap refcount; for a compressed or
250        // file-backed one the cache entry's.
251        Ok(get_or_read_block(
252            self.backing,
253            self.block_offsets,
254            meta,
255            block_index,
256            self.cache,
257            self.verified_blocks,
258            compression,
259        )?
260        .into_owned(self.backing))
261    }
262
263    fn read_uncached(
264        self,
265        meta: &StaticSortedFileMetaData,
266        block_index: u16,
267        compression: Compression,
268    ) -> Result<ArcBytes> {
269        read_block_lookup(
270            self.backing,
271            self.block_offsets,
272            meta,
273            block_index,
274            compression,
275        )
276    }
277}
278
279/// Iteration-path reader with a lightweight single-entry cache for sequential reads.
280struct RcBlockCacheReader<'a> {
281    file: &'a File,
282    block_offsets: &'a BlockOffsets,
283    cache: &'a mut Option<(u16, RcBytes)>,
284}
285
286impl ValueBlockCache<RcBytes> for RcBlockCacheReader<'_> {
287    fn get_or_read(
288        self,
289        meta: &StaticSortedFileMetaData,
290        block_index: u16,
291        compression: Compression,
292    ) -> Result<RcBytes> {
293        if let Some((idx, block)) = self.cache.as_ref()
294            && *idx == block_index
295        {
296            return Ok(block.clone());
297        }
298        let block = read_block_iter(
299            self.file,
300            self.block_offsets,
301            meta,
302            block_index,
303            compression,
304        )?;
305        *self.cache = Some((block_index, block.clone()));
306        Ok(block)
307    }
308
309    fn read_uncached(
310        self,
311        meta: &StaticSortedFileMetaData,
312        block_index: u16,
313        compression: Compression,
314    ) -> Result<RcBytes> {
315        read_block_iter(
316            self.file,
317            self.block_offsets,
318            meta,
319            block_index,
320            compression,
321        )
322    }
323}
324
325#[derive(Clone, Copy, Debug)]
326pub struct StaticSortedFileMetaData {
327    /// The sequence number of this file.
328    pub sequence_number: u32,
329    /// The number of blocks in the SST file.
330    pub block_count: u16,
331}
332
333/// An SST's block-offset table, read and validated once when the file is opened.
334///
335/// [`BlockOffsets::read`] checks that every block is at least [`BLOCK_HEADER_SIZE`] bytes long and
336/// ends at or before the start of the table, so [`BlockOffsets::block_range`] needs no further
337/// checks.
338#[derive(Debug)]
339struct BlockOffsets(Box<[u32]>);
340
341impl BlockOffsets {
342    /// Reads the block-offset table of `file` (`file_len` bytes long) with one positional read,
343    /// then decodes and validates it in place in a single pass.
344    fn read(file: &File, meta: &StaticSortedFileMetaData, file_len: usize) -> Result<Self> {
345        let block_count = meta.block_count as usize;
346        let table_start = file_len
347            .checked_sub(block_count * size_of::<u32>())
348            .with_context(|| {
349                format!(
350                    "{:08}.sst is too short ({file_len} bytes) for its block-offset table of \
351                     {block_count} blocks",
352                    meta.sequence_number
353                )
354            })?;
355        let mut offsets = vec![0u32; block_count].into_boxed_slice();
356        pread(file, offsets.as_mut_bytes(), table_start as u64).with_context(|| {
357            format!(
358                "Failed to read the block-offset table of {:08}.sst",
359                meta.sequence_number
360            )
361        })?;
362        let mut start = 0;
363        for (index, offset) in offsets.iter_mut().enumerate() {
364            *offset = u32::from_be(*offset);
365            let end = *offset as usize;
366            ensure!(
367                end.checked_sub(start)
368                    .is_some_and(|len| len >= BLOCK_HEADER_SIZE),
369                "block {index} of {:08}.sst spans {start}..{end}, shorter than its \
370                 {BLOCK_HEADER_SIZE}-byte header",
371                meta.sequence_number
372            );
373            start = end;
374        }
375        ensure!(
376            start <= table_start,
377            "the blocks of {:08}.sst end at {start}, past the start of its block-offset table at \
378             {table_start}",
379            meta.sequence_number
380        );
381        Ok(Self(offsets))
382    }
383
384    /// The byte range of block `block_index` in the file, header included.
385    ///
386    /// Only the index can be wrong here; everything else was checked by [`BlockOffsets::read`].
387    #[inline]
388    fn block_range(&self, block_index: u16) -> Result<Range<usize>> {
389        let index = block_index as usize;
390        let Some(&end) = self.0.get(index) else {
391            bail!(
392                "block index {block_index} out of bounds ({} blocks)",
393                self.0.len()
394            );
395        };
396        let start = if index == 0 {
397            0
398        } else {
399            self.0[index - 1] as usize
400        };
401        Ok(start..end as usize)
402    }
403}
404
405enum StaticSortedFileBacking {
406    #[cfg(feature = "mmap")]
407    Mmap(Arc<Mmap>),
408    File {
409        file: Arc<File>,
410    },
411}
412
413/// An SST file accessed through mmap or positional file reads.
414pub struct StaticSortedFile {
415    /// The meta file of this file.
416    meta: StaticSortedFileMetaData,
417    backing: StaticSortedFileBacking,
418    block_offsets: BlockOffsets,
419    /// One bit per block, set when that block's CRC has been verified at least once.
420    /// Uncompressed (mmap-backed) blocks bypass the `BlockCache`, so without this
421    /// bitmap the CRC would be re-computed on every access. `Relaxed` ordering
422    /// suffices: racing first-time verifications are idempotent.
423    verified_blocks: Box<[AtomicU64]>,
424    compression: Compression,
425    /// The index block, parsed once at open time.
426    index: IndexBlock,
427}
428
429/// The index block of an SST file, read into memory and validated once when the file is opened.
430///
431/// Every lookup binary searches this one block, so everything that does not depend on the queried
432/// hash is done here instead of per lookup: reading the block, verifying its CRC, checking the
433/// block type, reading the first-child index, and splitting the entry array off the header. What
434/// remains in [`StaticSortedFile::lookup_index_block`] is the search itself.
435struct IndexBlock {
436    /// The `(hash, block index)` entry array that follows the 3-byte header.
437    entries: Box<[[u8; INDEX_BLOCK_ENTRY_SIZE]]>,
438    /// Block index for hashes below the first entry's hash.
439    first_block: u16,
440}
441
442impl IndexBlock {
443    /// Reads the index block with [`read_index_block`] and validates its layout.
444    fn read(
445        file: &File,
446        block_offsets: &BlockOffsets,
447        meta: &StaticSortedFileMetaData,
448    ) -> Result<Self> {
449        let data: ArcBytes = read_index_block(file, block_offsets, meta)?;
450        let block_index = meta.block_count - 1;
451        ensure!(
452            data.len() >= INDEX_BLOCK_HEADER_SIZE,
453            "index block {} of {:08}.sst is too short ({} bytes)",
454            block_index,
455            meta.sequence_number,
456            data.len()
457        );
458        let first_block = be::read_u16(&data[1..]);
459        let (entries, rest) = data[INDEX_BLOCK_HEADER_SIZE..].as_chunks::<INDEX_BLOCK_ENTRY_SIZE>();
460        ensure!(
461            rest.is_empty(),
462            "index block {} of {:08}.sst has {} trailing bytes past its last entry",
463            block_index,
464            meta.sequence_number,
465            rest.len()
466        );
467        Ok(Self {
468            entries: entries.into(),
469            first_block,
470        })
471    }
472}
473
474/// Reads an SST's index block, which is always its last block, with one positional read.
475///
476/// Checks that the block is uncompressed (index blocks always are), that its CRC matches, and that
477/// it starts with the index block type. Returns the block data after the block header.
478fn read_index_block<B: SharedBytes>(
479    file: &File,
480    block_offsets: &BlockOffsets,
481    meta: &StaticSortedFileMetaData,
482) -> Result<B> {
483    ensure!(
484        meta.block_count > 0,
485        "{:08}.sst has no blocks, so no index block",
486        meta.sequence_number
487    );
488    let block_index = meta.block_count - 1;
489    let range = block_range(block_offsets, meta, block_index)?;
490    let (uncompressed_length, checksum, data) =
491        pread_block::<B>(file, range).with_context(|| {
492            format!(
493                "Failed to read index block {} from {:08}.sst",
494                block_index, meta.sequence_number
495            )
496        })?;
497    ensure!(
498        uncompressed_length == 0,
499        "index block {} of {:08}.sst is compressed, but index blocks are always written \
500         uncompressed",
501        block_index,
502        meta.sequence_number
503    );
504    // Verified here rather than through `verified_blocks`: this is the one and only read of this
505    // block's bytes.
506    verify_checksum(meta, &data, checksum, block_index)?;
507    ensure!(
508        data.first() == Some(&BLOCK_TYPE_INDEX),
509        "block {} of {:08}.sst is the last block but not an index block (type {:?})",
510        block_index,
511        meta.sequence_number,
512        data.first()
513    );
514    Ok(data)
515}
516
517impl StaticSortedFile {
518    /// Opens an SST file using the configured access mode.
519    ///
520    /// The block-offset table and index blocks are eagerly read and validated. Key and value blocks
521    /// stay lazy, read on demand.
522    pub fn open(
523        db_path: &Path,
524        meta: StaticSortedFileMetaData,
525        compression: Compression,
526        access_mode: AccessMode,
527    ) -> Result<Self> {
528        let filename = format!("{:08}.sst", meta.sequence_number);
529        let path = db_path.join(&filename);
530        let file = File::open(&path)?;
531        let file_len: usize = file.metadata()?.len().try_into()?;
532        let block_offsets = BlockOffsets::read(&file, &meta, file_len)?;
533        let index = IndexBlock::read(&file, &block_offsets, &meta)?;
534        let backing = match access_mode {
535            #[cfg(feature = "mmap")]
536            AccessMode::Mmap => {
537                let mmap = unsafe { Mmap::map(file.file()) }.with_context(|| {
538                    format!(
539                        "Failed to mmap SST file {} ({file_len} bytes)",
540                        path.display(),
541                    )
542                })?;
543                ensure!(
544                    mmap.len() == file_len,
545                    "{filename} changed size while being opened ({file_len} -> {} bytes)",
546                    mmap.len()
547                );
548                // Lookups touch only a few blocks of most SSTs, so `Random` stops the kernel from
549                // reading around every fault. In exchange, `get_raw_block_slice` asks for a
550                // multi-page block with `WillNeed` before its first full read. Those hints need the
551                // page size; without it, keep the default advice so read-around still prefetches.
552                #[cfg(unix)]
553                if page_size().is_some() {
554                    mmap.advise(memmap2::Advice::Random)?;
555                }
556                advise_mmap_for_persistence(&mmap)?;
557                StaticSortedFileBacking::Mmap(Arc::new(mmap))
558            }
559            AccessMode::File => StaticSortedFileBacking::File {
560                file: Arc::new(file),
561            },
562        };
563        let bitmap_words = (meta.block_count as usize).div_ceil(u64::BITS as usize);
564        let verified_blocks = (0..bitmap_words)
565            .map(|_| AtomicU64::new(0))
566            .collect::<Box<[_]>>();
567
568        Ok(Self {
569            meta,
570            backing,
571            block_offsets,
572            verified_blocks,
573            compression,
574            index,
575        })
576    }
577
578    /// The index block's entry array: `(8-byte hash, 2-byte block index)` pairs, sorted by hash.
579    #[inline]
580    fn index_entries(&self) -> &[[u8; INDEX_BLOCK_ENTRY_SIZE]] {
581        &self.index.entries
582    }
583
584    /// Looks up a key in this file.
585    ///
586    /// If `FIND_ALL` is false, returns after finding the first match.
587    /// If `FIND_ALL` is true, returns all entries with the same key (useful for
588    /// keyspaces where keys are hashes and collisions are possible).
589    pub fn lookup<K: QueryKey, const FIND_ALL: bool>(
590        &self,
591        key_hash: u64,
592        key: &K,
593        key_block_cache: &BlockCache,
594        value_block_cache: &BlockCache,
595    ) -> Result<SstLookupResult> {
596        // The index block was resolved, verified and parsed at open time.
597        let key_block_index = self.lookup_index_block(key_hash);
598
599        // Borrowed, not owned: the search only reads the block, and any value it returns is
600        // either copied inline or points into a *value* block, so nothing outlives this call.
601        let key_block = get_or_read_block(
602            &self.backing,
603            &self.block_offsets,
604            &self.meta,
605            key_block_index,
606            key_block_cache,
607            &self.verified_blocks,
608            self.compression,
609        )?;
610        let key_block = key_block.as_slice();
611
612        let reader = ArcBlockCacheReader {
613            backing: &self.backing,
614            block_offsets: &self.block_offsets,
615            cache: value_block_cache,
616            verified_blocks: &self.verified_blocks,
617        };
618        let block_type = be::read_u8(key_block);
619        match KeyBlockLayout::from_block_type(block_type) {
620            Some((layout, false)) => self
621                .lookup_variable_key_block::<K, FIND_ALL>(key_block, key_hash, key, layout, reader),
622            Some((layout, true)) => {
623                self.lookup_fixed_key_block::<K, FIND_ALL>(key_block, key_hash, key, layout, reader)
624            }
625            None => {
626                bail!("Invalid block type");
627            }
628        }
629    }
630
631    /// Finds the key block that would hold `hash`.
632    ///
633    /// Entry `i`'s hash is the lowest hash in the block it names, so a hash below the first entry
634    /// belongs to `first_block` and any other hash belongs to its predecessor entry's block.
635    /// Everything that does not depend on `hash` was resolved by [`IndexBlock::read`] at open
636    /// time, so this is the binary search and nothing else.
637    #[inline]
638    fn lookup_index_block(&self, hash: u64) -> u16 {
639        let entries = self.index_entries();
640        match entries.binary_search_by(|entry| be::read_u64(entry).cmp(&hash)) {
641            Ok(i) => be::read_u16(&entries[i][size_of::<u64>()..]),
642            Err(0) => self.index.first_block,
643            Err(i) => be::read_u16(&entries[i - 1][size_of::<u64>()..]),
644        }
645    }
646
647    /// Looks up a key in a key block and the value in a value block.
648    ///
649    /// If `FIND_ALL` is false, returns after finding the first match.
650    /// If `FIND_ALL` is true, collects all entries with the same key.
651    fn lookup_variable_key_block<K: QueryKey, const FIND_ALL: bool>(
652        &self,
653        block: &[u8],
654        key_hash: u64,
655        key: &K,
656        layout: KeyBlockLayout,
657        reader: ArcBlockCacheReader<'_>,
658    ) -> Result<SstLookupResult> {
659        let hash_len = layout.hash_len();
660        ensure!(block.len() >= 4, "key block too short");
661        let entry_count = be::read_u24(&block[1..]) as usize;
662        let data = &block[4..];
663        let table_len = entry_count * key_block_table_stride(hash_len);
664        ensure!(
665            data.len() >= table_len,
666            "key block too short for {entry_count} entries"
667        );
668        let offsets = &data[..table_len];
669        let entries = &data[table_len..];
670
671        self.lookup_block_inner::<K, FIND_ALL>(entry_count, key_hash, key, layout, reader, |i| {
672            get_key_entry(offsets, entries, entry_count, i, hash_len)
673        })
674    }
675
676    /// Looks up a key in a fixed-size key block.
677    ///
678    /// Fixed-size key blocks store entries at predictable offsets (no offset table),
679    /// enabling direct indexing during binary search.
680    fn lookup_fixed_key_block<K: QueryKey, const FIND_ALL: bool>(
681        &self,
682        block: &[u8],
683        key_hash: u64,
684        key: &K,
685        layout: KeyBlockLayout,
686        reader: ArcBlockCacheReader<'_>,
687    ) -> Result<SstLookupResult> {
688        ensure!(block.len() >= 6, "fixed key block too short");
689        let entry_count = be::read_u24(&block[1..]) as usize;
690        let key_size = be::read_u8(&block[4..]) as usize;
691        let header_type = be::read_u8(&block[5..]);
692        let FixedValueLayout {
693            value_type,
694            val_size,
695            header_size,
696        } = fixed_value_layout(block, header_type)?;
697        let regions = FixedRegions::new(entry_count, layout, key_size, val_size);
698        let entries = &block[header_size..];
699        ensure!(
700            entries.len() == regions.total_len(entry_count),
701            "fixed key block for {entry_count} entries is the wrong size"
702        );
703
704        self.lookup_block_inner::<K, FIND_ALL>(entry_count, key_hash, key, layout, reader, |i| {
705            get_fixed_key_entry(entries, i, regions, value_type)
706        })
707    }
708
709    /// Shared binary search + collection logic for both key block variants.
710    ///
711    /// The `get_entry` closure abstracts over the difference between variable-size
712    /// key blocks (offset table lookup) and fixed-size key blocks (stride-based indexing).
713    fn lookup_block_inner<'a, K: QueryKey, const FIND_ALL: bool>(
714        &self,
715        entry_count: usize,
716        key_hash: u64,
717        key: &K,
718        layout: KeyBlockLayout,
719        reader: ArcBlockCacheReader<'_>,
720        get_entry: impl Fn(usize) -> Result<GetKeyEntryResult<'a>>,
721    ) -> Result<SstLookupResult> {
722        let mut l = 0;
723        let mut r = entry_count;
724        // binary search for a matching key
725        while l < r {
726            let m = (l + r) / 2;
727            let GetKeyEntryResult {
728                hash: mid_hash,
729                key: mid_key,
730                ty,
731                val,
732            } = get_entry(m)?;
733
734            let comparison = compare_hash_key(layout, mid_hash, mid_key, key_hash, key);
735
736            match comparison {
737                Ordering::Less => r = m,
738                Ordering::Equal => {
739                    if !FIND_ALL {
740                        // SingleValue mode: each key has exactly one entry
741                        // this is enforced when writing
742                        let result = self.handle_key_match(ty, val, reader)?;
743                        return Ok(SstLookupResult::Found(SmallVec::from_buf([result])));
744                    }
745                    // FIND_ALL (MultiValue) mode: collect all values for this key.
746                    // Within a key group, key-value tombstones sort first and key tombstones
747                    // last. We scan backward to find the start of the key group, then forward to
748                    // collect all entries.
749                    let mut results = SmallVec::new();
750                    for i in (l..m).rev() {
751                        let GetKeyEntryResult {
752                            hash,
753                            key: entry_key,
754                            ty,
755                            val,
756                        } = get_entry(i)?;
757                        if !entry_matches_key(layout, hash, entry_key, key_hash, key) {
758                            break;
759                        }
760                        results.push(self.handle_key_match(ty, val, reader)?);
761                    }
762                    // Restore on-disk order: callers depend on both ends of the key group, with
763                    // key-value tombstones preceding the values they filter and a key tombstone
764                    // landing last.
765                    results.reverse();
766
767                    // Add the entry at `m`
768                    results.push(self.handle_key_match(ty, val, reader)?);
769                    for i in (m + 1)..r {
770                        let GetKeyEntryResult {
771                            hash,
772                            key: entry_key,
773                            ty,
774                            val,
775                        } = get_entry(i)?;
776                        if !entry_matches_key(layout, hash, entry_key, key_hash, key) {
777                            break;
778                        }
779                        results.push(self.handle_key_match(ty, val, reader)?);
780                    }
781                    return Ok(SstLookupResult::Found(results));
782                }
783                Ordering::Greater => l = m + 1,
784            }
785        }
786
787        Ok(SstLookupResult::NotFound)
788    }
789
790    /// Handles a key match by looking up the value.
791    fn handle_key_match(
792        &self,
793        ty: u8,
794        val: &[u8],
795        reader: ArcBlockCacheReader<'_>,
796    ) -> Result<LookupValue> {
797        handle_key_match_generic(&self.meta, ty, val, self.compression, reader)
798    }
799}
800
801/// A block obtained from the backing store or the block cache.
802///
803/// An uncompressed mmap block is borrowed straight out of the mmap. Only that borrow is needed to
804/// search a key block, and taking it instead of an [`ArcBytes`] avoids touching the file's `mmap`
805/// refcount — a single counter shared by every reader of the file, so the most contended one on
806/// the read path. Anything that had to be decompressed or read into memory comes back owned, but
807/// its refcount belongs to one cache entry rather than the whole file.
808enum BlockRef<'l> {
809    /// Borrowed from the memory-mapped file.
810    #[cfg(feature = "mmap")]
811    Mmap(&'l [u8]),
812    /// Owned, and shared with the block cache. The zero-sized marker keeps the backing borrow
813    /// lifetime represented in builds where the mmap variant is disabled.
814    Cached(ArcBytes, PhantomData<&'l ()>),
815}
816
817impl BlockRef<'_> {
818    #[inline]
819    fn as_slice(&self) -> &[u8] {
820        match self {
821            #[cfg(feature = "mmap")]
822            BlockRef::Mmap(data) => data,
823            BlockRef::Cached(block, _) => block,
824        }
825    }
826
827    /// Promotes to an owned handle, taking a refcount for the mmap case.
828    ///
829    /// Only needed by callers that hand the bytes to something outliving the lookup.
830    #[inline]
831    #[cfg_attr(not(feature = "mmap"), allow(unused_variables))]
832    fn into_owned(self, backing: &StaticSortedFileBacking) -> ArcBytes {
833        match self {
834            #[cfg(feature = "mmap")]
835            BlockRef::Mmap(data) => {
836                let StaticSortedFileBacking::Mmap(mmap) = backing else {
837                    // `get_or_read_block` only borrows from an mmap backing.
838                    unreachable!("mmap-borrowed block with a file backing")
839                };
840                // SAFETY: the borrow came from this mmap, via `get_or_read_block`.
841                unsafe { ArcBytes::from_mmap(mmap, data) }
842            }
843            BlockRef::Cached(block, _) => block,
844        }
845    }
846}
847
848/// Gets a block from the cache, or reads it from the mmap and inserts it.
849///
850/// Reads the block header exactly once via `get_raw_block_slice`, which locates the block in the
851/// validated `block_offsets`. Uncompressed blocks bypass
852/// the cache and are borrowed from the mmap; their CRC is verified at most
853/// once per file open, tracked by `verified_blocks`. Compressed blocks are
854/// looked up in `cache`; on a miss they are decompressed, CRC-verified, and
855/// inserted. File-backed blocks always go through the cache, including
856/// uncompressed ones, since there is nothing to borrow from.
857fn get_or_read_block<'l>(
858    backing: &'l StaticSortedFileBacking,
859    block_offsets: &BlockOffsets,
860    meta: &StaticSortedFileMetaData,
861    block_index: u16,
862    cache: &BlockCache,
863    verified_blocks: &[AtomicU64],
864    compression: Compression,
865) -> Result<BlockRef<'l>> {
866    // `verified_blocks` tracks which mmap-backed blocks already passed their checksum. There is
867    // no mmap backing without the feature, so the bitmap is unused there.
868    #[cfg(not(feature = "mmap"))]
869    let _ = verified_blocks;
870    #[cfg(feature = "mmap")]
871    let mmap_block = if let StaticSortedFileBacking::Mmap(mmap) = backing {
872        // An unverified block is about to be read in full for the first time in this open (CRC
873        // verification and, if compressed, decompression), so hint it. A verified block is either
874        // in the block cache or touches only the bytes a lookup needs.
875        let will_need = !is_block_verified(block_index, verified_blocks);
876        let (uncompressed_length, checksum, block_data) =
877            get_raw_block_slice(mmap, block_offsets, meta, block_index, will_need).with_context(
878                || {
879                    format!(
880                        "Failed to read raw block {} from {:08}.sst",
881                        block_index, meta.sequence_number
882                    )
883                },
884            )?;
885
886        if uncompressed_length == 0 {
887            // Uncompressed: borrow directly from the mmap, taking no refcount.
888            // Verify CRC only once per file open.
889            verify_checksum_once(meta, block_data, checksum, block_index, verified_blocks)?;
890            return Ok(BlockRef::Mmap(block_data));
891        }
892        Some((uncompressed_length, checksum, block_data))
893    } else {
894        None
895    };
896    // Without an mmap backing there is never a zero-copy block to borrow, so every block goes
897    // through the decompress/cache path below.
898    #[cfg(not(feature = "mmap"))]
899    let mmap_block: Option<(u32, u32, &[u8])> = None;
900
901    // Compressed: check cache; decompress and insert on miss.
902    // File-backed blocks use the same cache, including uncompressed ones.
903    Ok(BlockRef::Cached(
904        match cache.get_value_or_guard(&(meta.sequence_number, block_index), None) {
905            GuardResult::Value(block) => block,
906            GuardResult::Guard(guard) => {
907                let (uncompressed_length, checksum, block_data) = match mmap_block {
908                    Some((uncompressed_length, checksum, block_data)) => (
909                        uncompressed_length,
910                        checksum,
911                        RawBlock::Borrowed(block_data),
912                    ),
913                    None => get_raw_block(backing, block_offsets, meta, block_index)?,
914                };
915                // A cached block may have been evicted, so re-reading still
916                // benefits from the bitmap to skip redundant CRC verification.
917                match backing {
918                    #[cfg(feature = "mmap")]
919                    StaticSortedFileBacking::Mmap(_) => verify_checksum_once(
920                        meta,
921                        &block_data,
922                        checksum,
923                        block_index,
924                        verified_blocks,
925                    )?,
926                    StaticSortedFileBacking::File { .. } => {
927                        verify_checksum(meta, &block_data, checksum, block_index)?
928                    }
929                }
930                let block = if uncompressed_length == 0 {
931                    block_data.into_arc_bytes()
932                } else {
933                    ArcBytes::from_decompressed(compression, uncompressed_length, &block_data)
934                        .with_context(|| {
935                            format!(
936                                "Failed to decompress block {} from {:08}.sst ({} bytes \
937                                 uncompressed)",
938                                block_index, meta.sequence_number, uncompressed_length
939                            )
940                        })?
941                };
942                let _ = guard.insert(block.clone());
943                block
944            }
945            GuardResult::Timeout => unreachable!(),
946        },
947        PhantomData,
948    ))
949}
950
951/// Asks the kernel to read `range` of the mapping ahead, if it spans more than one page.
952///
953/// Lookup mappings have `Random` advice, so without this hint the kernel faults in each page of a
954/// multi-page block separately. Readahead is best effort, and single-page ranges gain nothing.
955#[cfg(feature = "mmap")]
956fn will_need_range(mmap: &Mmap, range: Range<usize>) {
957    #[cfg(unix)]
958    if let Some(page_size) = page_size()
959        && spans_multiple_pages(&range, page_size)
960    {
961        #[cfg(test)]
962        will_need_log::record(range.clone());
963        let _ = mmap.advise_range(memmap2::Advice::WillNeed, range.start, range.len());
964    }
965    #[cfg(not(unix))]
966    let _ = (mmap, range);
967}
968
969/// Whether the non-empty `range` crosses a page boundary.
970#[cfg(all(feature = "mmap", unix))]
971fn spans_multiple_pages(range: &Range<usize>, page_size: usize) -> bool {
972    debug_assert!(!range.is_empty());
973    range.start / page_size != (range.end - 1) / page_size
974}
975
976/// The system page size, queried once. `None` if it can't be determined, in which case block hints
977/// are skipped.
978#[cfg(all(feature = "mmap", unix))]
979fn page_size() -> Option<usize> {
980    static PAGE_SIZE: std::sync::OnceLock<Option<usize>> = std::sync::OnceLock::new();
981    *PAGE_SIZE.get_or_init(|| {
982        // SAFETY: `sysconf` has no preconditions.
983        let size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
984        usize::try_from(size).ok().filter(|&size| size > 0)
985    })
986}
987
988/// Records the ranges passed to `MADV_WILLNEED` on this thread, for tests.
989#[cfg(all(test, feature = "mmap", unix))]
990pub(crate) mod will_need_log {
991    use std::{cell::RefCell, ops::Range};
992
993    thread_local! {
994        static LOG: RefCell<Vec<Range<usize>>> = const { RefCell::new(Vec::new()) };
995    }
996
997    pub(crate) fn record(range: Range<usize>) {
998        LOG.with(|log| log.borrow_mut().push(range));
999    }
1000
1001    /// Returns and clears this thread's recorded ranges.
1002    pub(crate) fn take() -> Vec<Range<usize>> {
1003        LOG.with(|log| std::mem::take(&mut *log.borrow_mut()))
1004    }
1005}
1006
1007/// Gets the raw block slice directly from a memory-mapped file.
1008/// Returns `(uncompressed_length, checksum, block_data)`.
1009///
1010/// With `will_need`, the caller is about to read the whole block, so the block (header included)
1011/// is requested with `WillNeed` before any of its bytes is read; see [`will_need_range`].
1012#[cfg(feature = "mmap")]
1013fn get_raw_block_slice<'a>(
1014    mmap: &'a Mmap,
1015    block_offsets: &BlockOffsets,
1016    meta: &StaticSortedFileMetaData,
1017    block_index: u16,
1018    will_need: bool,
1019) -> Result<(u32, u32, &'a [u8])> {
1020    let range = block_range(block_offsets, meta, block_index)?;
1021    if will_need {
1022        will_need_range(mmap, range.clone());
1023    }
1024    let uncompressed_length = be::read_u32(&mmap[range.start..]);
1025    let checksum = be::read_u32(&mmap[range.start + 4..]);
1026    let block = &mmap[range.start + BLOCK_HEADER_SIZE..range.end];
1027    Ok((uncompressed_length, checksum, block))
1028}
1029
1030/// Looks up block `block_index` in `block_offsets`, naming the file in the error.
1031fn block_range(
1032    block_offsets: &BlockOffsets,
1033    meta: &StaticSortedFileMetaData,
1034    block_index: u16,
1035) -> Result<Range<usize>> {
1036    block_offsets
1037        .block_range(block_index)
1038        .with_context(|| format!("Corrupted file {:08}.sst", meta.sequence_number))
1039}
1040
1041/// Reads a block, header included, at `range` with a positional read.
1042/// Returns `(uncompressed_length, checksum, block_data)`.
1043///
1044/// The block is read straight into the uninitialized allocation of the result and the header is
1045/// sliced off, so it is neither zeroed before the read nor copied after it.
1046fn pread_block<B: SharedBytes>(file: &File, range: Range<usize>) -> Result<(u32, u32, B)> {
1047    let bytes = B::read_filled(range.len(), |buf| pread_buf(file, buf, range.start as u64))?;
1048    let uncompressed_length = be::read_u32(&bytes);
1049    let checksum = be::read_u32(&bytes[4..]);
1050    let len = bytes.len();
1051    Ok((
1052        uncompressed_length,
1053        checksum,
1054        bytes.slice(BLOCK_HEADER_SIZE..len),
1055    ))
1056}
1057
1058/// The on-disk bytes of a block after its header, still compressed unless the uncompressed length
1059/// in the header is 0.
1060enum RawBlock<'a> {
1061    /// Borrowed from the mmap.
1062    #[cfg_attr(not(feature = "mmap"), allow(dead_code))]
1063    Borrowed(&'a [u8]),
1064    /// Read from the file. The allocation also holds the header, which is sliced off.
1065    Owned(ArcBytes),
1066}
1067
1068impl RawBlock<'_> {
1069    /// Returns the block as `ArcBytes`. Copies borrowed data, owned data is returned as is.
1070    fn into_arc_bytes(self) -> ArcBytes {
1071        match self {
1072            RawBlock::Borrowed(block) => ArcBytes::from(Arc::<[u8]>::from(block)),
1073            RawBlock::Owned(block) => block,
1074        }
1075    }
1076}
1077
1078impl Deref for RawBlock<'_> {
1079    type Target = [u8];
1080
1081    fn deref(&self) -> &[u8] {
1082        match self {
1083            RawBlock::Borrowed(block) => block,
1084            RawBlock::Owned(block) => block,
1085        }
1086    }
1087}
1088
1089/// Reads a raw block from either backing. Mmap data remains borrowed; file data is owned.
1090fn get_raw_block<'a>(
1091    backing: &'a StaticSortedFileBacking,
1092    block_offsets: &BlockOffsets,
1093    meta: &StaticSortedFileMetaData,
1094    block_index: u16,
1095) -> Result<(u32, u32, RawBlock<'a>)> {
1096    match backing {
1097        #[cfg(feature = "mmap")]
1098        StaticSortedFileBacking::Mmap(mmap) => {
1099            // Every caller with an mmap backing reads the whole block: medium value blocks, which
1100            // bypass the block cache, on every read.
1101            let (uncompressed_length, checksum, block) =
1102                get_raw_block_slice(mmap, block_offsets, meta, block_index, true)?;
1103            Ok((uncompressed_length, checksum, RawBlock::Borrowed(block)))
1104        }
1105        StaticSortedFileBacking::File { file } => {
1106            let range = block_range(block_offsets, meta, block_index)?;
1107            let (uncompressed_length, checksum, block) = pread_block(file, range)?;
1108            Ok((uncompressed_length, checksum, RawBlock::Owned(block)))
1109        }
1110    }
1111}
1112
1113/// Reads exactly `buf.len()` bytes of `file` at `offset`. Errors name the file.
1114fn pread(file: &File, buf: &mut [u8], offset: u64) -> io::Result<()> {
1115    pread_buf(file, io::BorrowedBuf::from(buf).unfilled(), offset)
1116}
1117
1118/// Reads exactly the capacity of `buf`, which may be uninitialized, from `file` at `offset`.
1119/// Errors name the file.
1120fn pread_buf(file: &File, buf: io::BorrowedCursor<'_, u8>, offset: u64) -> io::Result<()> {
1121    // `fs_err` has no `BorrowedCursor` reads, so add the path here.
1122    let with_path = |err: io::Error| {
1123        io::Error::new(
1124            err.kind(),
1125            format!("{err} reading {}", file.path().display()),
1126        )
1127    };
1128    #[cfg(unix)]
1129    {
1130        use std::os::unix::fs::FileExt;
1131        file.file()
1132            .read_buf_exact_at(buf, offset)
1133            .map_err(with_path)
1134    }
1135    #[cfg(windows)]
1136    {
1137        use std::os::windows::fs::FileExt;
1138        file.file()
1139            .seek_read_buf_exact(buf, offset)
1140            .map_err(with_path)
1141    }
1142    #[cfg(target_os = "wasi")]
1143    {
1144        // WASI has no exact variant, so loop like `read_buf_exact_at` does on unix.
1145        use std::os::wasi::fs::FileExt;
1146        let mut buf = buf;
1147        let mut offset = offset;
1148        while buf.capacity() > 0 {
1149            let written = buf.written();
1150            match file.file().read_buf_at(buf.reborrow(), offset) {
1151                Ok(()) => {}
1152                Err(err) if err.kind() == io::ErrorKind::Interrupted => {}
1153                Err(err) => return Err(with_path(err)),
1154            }
1155            let read = buf.written() - written;
1156            if read == 0 {
1157                return Err(with_path(io::Error::new(
1158                    io::ErrorKind::UnexpectedEof,
1159                    "unexpected EOF",
1160                )));
1161            }
1162            offset += read as u64;
1163        }
1164        Ok(())
1165    }
1166}
1167
1168/// Verifies the CRC32 checksum of on-disk block data. Returns an error on mismatch.
1169fn verify_checksum(
1170    meta: &StaticSortedFileMetaData,
1171    data: &[u8],
1172    expected: u32,
1173    block_index: u16,
1174) -> Result<()> {
1175    let actual = checksum_block(data);
1176    if actual != expected {
1177        bail!(
1178            "Cache corruption detected: checksum mismatch in block {} of {:08}.sst (expected \
1179             {:08x}, got {:08x})",
1180            block_index,
1181            meta.sequence_number,
1182            expected,
1183            actual
1184        );
1185    }
1186    Ok(())
1187}
1188
1189/// Whether block `block_index`'s CRC has already been verified in this open. An out-of-range index
1190/// counts as unverified; reading the block reports the error.
1191#[cfg(feature = "mmap")]
1192fn is_block_verified(block_index: u16, verified_blocks: &[AtomicU64]) -> bool {
1193    let word_idx = block_index as usize / u64::BITS as usize;
1194    let bit = 1u64 << (block_index as usize % u64::BITS as usize);
1195    verified_blocks
1196        .get(word_idx)
1197        .is_some_and(|word| word.load(AtomicOrdering::Relaxed) & bit != 0)
1198}
1199
1200/// Verifies a block's CRC using the `verified_blocks` bitmap to avoid redundant
1201/// work. In practice each block is verified once, but concurrent first-time
1202/// accesses may race and verify the same block more than once — this is harmless
1203/// since the check is deterministic and idempotent. Verification failures are
1204/// *not* recorded in the bitmap, so a corrupted block will be re-checked (and
1205/// fail again) on every access.
1206#[cfg_attr(not(feature = "mmap"), allow(dead_code))]
1207fn verify_checksum_once(
1208    meta: &StaticSortedFileMetaData,
1209    data: &[u8],
1210    expected: u32,
1211    block_index: u16,
1212    verified_blocks: &[AtomicU64],
1213) -> Result<()> {
1214    let word_idx = block_index as usize / u64::BITS as usize;
1215    let bit = 1u64 << (block_index as usize % u64::BITS as usize);
1216    if verified_blocks[word_idx].load(AtomicOrdering::Relaxed) & bit != 0 {
1217        return Ok(());
1218    }
1219    verify_checksum(meta, data, expected, block_index)?;
1220    verified_blocks[word_idx].fetch_or(bit, AtomicOrdering::Relaxed);
1221    Ok(())
1222}
1223
1224/// Reads a lookup block, decompresses it if needed, and verifies its checksum.
1225fn read_block_lookup(
1226    backing: &StaticSortedFileBacking,
1227    block_offsets: &BlockOffsets,
1228    meta: &StaticSortedFileMetaData,
1229    block_index: u16,
1230    compression: Compression,
1231) -> Result<ArcBytes> {
1232    let (uncompressed_length, checksum, block) =
1233        get_raw_block(backing, block_offsets, meta, block_index)?;
1234    verify_checksum(meta, &block, checksum, block_index)?;
1235    if uncompressed_length == 0 {
1236        return match (backing, block) {
1237            #[cfg(feature = "mmap")]
1238            (StaticSortedFileBacking::Mmap(mmap), RawBlock::Borrowed(block)) => {
1239                // SAFETY: block points into mmap.
1240                Ok(unsafe { ArcBytes::from_mmap(mmap, block) })
1241            }
1242            (_, block) => Ok(block.into_arc_bytes()),
1243        };
1244    }
1245    ArcBytes::from_decompressed(compression, uncompressed_length, &block).with_context(|| {
1246        format!(
1247            "Failed to decompress block {} from {:08}.sst ({} bytes uncompressed)",
1248            block_index, meta.sequence_number, uncompressed_length
1249        )
1250    })
1251}
1252
1253/// Returns `(uncompressed_length, checksum, block)` wrapping the raw on-disk data as
1254/// `RcBytes` for the single-threaded iteration path.
1255fn get_raw_block_iter(
1256    file: &File,
1257    block_offsets: &BlockOffsets,
1258    meta: &StaticSortedFileMetaData,
1259    block_index: u16,
1260) -> Result<(u32, u32, RcBytes)> {
1261    let range = block_range(block_offsets, meta, block_index)?;
1262    pread_block(file, range)
1263}
1264
1265/// Reads an iteration block, decompresses it if needed, and verifies its checksum.
1266fn read_block_iter(
1267    file: &File,
1268    block_offsets: &BlockOffsets,
1269    meta: &StaticSortedFileMetaData,
1270    block_index: u16,
1271    compression: Compression,
1272) -> Result<RcBytes> {
1273    let (uncompressed_length, checksum, block) =
1274        get_raw_block_iter(file, block_offsets, meta, block_index)?;
1275    verify_checksum(meta, &block, checksum, block_index)?;
1276    if uncompressed_length == 0 {
1277        return Ok(block);
1278    }
1279    RcBytes::from_decompressed(compression, uncompressed_length, &block).with_context(|| {
1280        format!(
1281            "Failed to decompress block {} from {:08}.sst ({} bytes uncompressed)",
1282            block_index, meta.sequence_number, uncompressed_length
1283        )
1284    })
1285}
1286
1287/// Handles a key match by resolving the value reference. Generic over byte type.
1288fn handle_key_match_generic<B: SharedBytes>(
1289    meta: &StaticSortedFileMetaData,
1290    ty: u8,
1291    val: &[u8],
1292    compression: Compression,
1293    reader: impl ValueBlockCache<B>,
1294) -> Result<LookupValue<B>> {
1295    Ok(match ty {
1296        KEY_BLOCK_ENTRY_TYPE_SMALL => {
1297            let block = be::read_u16(val);
1298            let size = be::read_u16(&val[2..]) as usize;
1299            let position = be::read_u32(&val[4..]) as usize;
1300            let value = reader
1301                .get_or_read(meta, block, compression)?
1302                .slice(position..position + size);
1303            LookupValue::Slice { value }
1304        }
1305        KEY_BLOCK_ENTRY_TYPE_MEDIUM => {
1306            let block = be::read_u16(val);
1307            let value = reader.read_uncached(meta, block, compression)?;
1308            LookupValue::Slice { value }
1309        }
1310        KEY_BLOCK_ENTRY_TYPE_BLOB => {
1311            let sequence_number = be::read_u32(val);
1312            LookupValue::Blob { sequence_number }
1313        }
1314        KEY_BLOCK_ENTRY_TYPE_KEY_DELETED => LookupValue::KeyDeleted,
1315        // Must precede the inline arm: both are open-ended and the tombstone range sits above it.
1316        ty if ty >= KEY_BLOCK_ENTRY_TYPE_KEY_VALUE_DELETED_MIN => {
1317            let value = B::from_inline(val);
1318            LookupValue::KeyValueDeleted { value }
1319        }
1320        _ => {
1321            // Inline value — val is already the correct slice
1322            let value = B::from_inline(val);
1323            LookupValue::Slice { value }
1324        }
1325    })
1326}
1327
1328/// An iterator over all entries in a SST file in sorted order.
1329pub struct StaticSortedFileIter {
1330    /// The SST file. Iteration reads each block with `pread` into an owned buffer, regardless of
1331    /// the database's [`AccessMode`]. Iteration is used by compaction, which merges whole
1332    /// files: through a mmap every page it touches would stay mapped and count towards the
1333    /// process memory until the kernel reclaims it, and `MADV_SEQUENTIAL` doesn't make it drop
1334    /// them as the scan advances. Reading into owned buffers bounds the memory of an open
1335    /// iterator to the blocks it currently holds. Blocks are decompressed into owned buffers
1336    /// anyway, so a mmap would only save the copy of uncompressed and medium blocks.
1337    file: File,
1338    block_offsets: BlockOffsets,
1339    /// Metadata (sequence number, block count) needed for block access.
1340    meta: StaticSortedFileMetaData,
1341
1342    /// The root index block entries (body bytes starting after the type byte).
1343    /// SST files have exactly one index level.
1344    index_entries: RcBytes,
1345    /// Total key block references in the index block (first_child + boundary entries).
1346    num_index_entries: usize,
1347    /// Next index entry to read from the index block.
1348    index_pos: usize,
1349    current_key_block: CurrentKeyBlock,
1350    /// Single-entry value block cache. Within a key block, entries reference
1351    /// value blocks sequentially and don't revisit earlier blocks, so caching
1352    /// just the current one avoids redundant decompression.
1353    value_block_cache: Option<(u16, RcBytes)>,
1354    compression: Compression,
1355}
1356
1357enum CurrentKeyBlockKind {
1358    /// Variable-size entries with an offset table for random access.
1359    Variable { offsets: RcBytes },
1360    /// Fixed-size entries with uniform key size and value size (no offset table).
1361    Fixed {
1362        /// The type shared by every entry, or `None` if each entry carries its own type byte.
1363        value_type: Option<u8>,
1364        regions: FixedRegions,
1365    },
1366}
1367
1368impl CurrentKeyBlockKind {
1369    /// Decodes entry `index`, dispatching on the block's entry layout.
1370    ///
1371    /// The result borrows from `entries` and, for a variable block storing hashes, from the offset
1372    /// table held by `self` — hence the shared lifetime.
1373    fn entry<'l>(
1374        &'l self,
1375        entries: &'l [u8],
1376        entry_count: u32,
1377        index: usize,
1378        hash_len: u8,
1379    ) -> Result<GetKeyEntryResult<'l>> {
1380        match self {
1381            CurrentKeyBlockKind::Variable { offsets } => {
1382                get_key_entry(offsets, entries, entry_count as usize, index, hash_len)
1383            }
1384            CurrentKeyBlockKind::Fixed {
1385                value_type,
1386                regions,
1387            } => get_fixed_key_entry(entries, index, *regions, *value_type),
1388        }
1389    }
1390}
1391
1392/// One entry of a [`CurrentKeyBlock::hash_order`] plan: the key's hash and the index of the entry
1393/// it was computed from.
1394struct HashOrderEntry {
1395    hash: u64,
1396    entry_index: u32,
1397}
1398
1399struct CurrentKeyBlock {
1400    kind: CurrentKeyBlockKind,
1401    /// Whether entries carry a hash, and so what order they are stored in.
1402    layout: KeyBlockLayout,
1403    entries: RcBytes,
1404    /// Number of entries in this key block (max ~819 per 16 KiB block).
1405    entry_count: u32,
1406    /// Current iteration position. Indexes `hash_order` when that is present, and the block's
1407    /// entries directly otherwise.
1408    index: u32,
1409    /// Iteration plan for a [`KeyBlockLayout::KeyOnly`] block, in `(hash, key)` order.
1410    /// `None` for [`KeyBlockLayout::HashThenKey`], whose entries are already stored in that order.
1411    hash_order: Option<Vec<HashOrderEntry>>,
1412}
1413
1414impl Iterator for StaticSortedFileIter {
1415    type Item = Result<LookupEntry>;
1416
1417    fn next(&mut self) -> Option<Self::Item> {
1418        self.next_internal().transpose()
1419    }
1420}
1421
1422impl StaticSortedFileIter {
1423    /// Opens an SST file for sequential iteration. Blocks are always read with `pread`, not
1424    /// through a mmap, see the `file` field.
1425    pub fn open(
1426        db_path: &Path,
1427        meta: StaticSortedFileMetaData,
1428        compression: Compression,
1429    ) -> Result<Self> {
1430        let filename = format!("{:08}.sst", meta.sequence_number);
1431        let path = db_path.join(&filename);
1432        let file = File::open(&path)?;
1433        let file_len: usize = file.metadata()?.len().try_into()?;
1434        let block_offsets = BlockOffsets::read(&file, &meta, file_len)?;
1435        let index_block = read_index_block(&file, &block_offsets, &meta)
1436            .with_context(|| format!("Unable to open static sorted file {filename}"))?;
1437        Self::new(file, block_offsets, index_block, meta, compression)
1438            .with_context(|| format!("Unable to open static sorted file {filename}"))
1439    }
1440
1441    /// `index_block` is the verified index block data from [`read_index_block`].
1442    fn new(
1443        file: File,
1444        block_offsets: BlockOffsets,
1445        index_block: RcBytes,
1446        meta: StaticSortedFileMetaData,
1447        compression: Compression,
1448    ) -> Result<Self> {
1449        let block = index_block;
1450        let block_len = block.len();
1451        ensure!(block_len >= 3, "index block too short");
1452        let index_entries = block.slice(1..block_len);
1453        let first_child = be::read_u16(&index_entries);
1454        // Index block body layout: [first_child: u16] [hash: u64, block: u16]*
1455        // Compute total key block references (first_child + N boundary entries)
1456        // using ceil division: (body_len - sizeof(first_child) + ENTRY_SIZE - 1) / ENTRY_SIZE + 1
1457        // simplified to (body_len + ENTRY_SIZE - 2) / ENTRY_SIZE
1458        let num_index_entries: usize = (index_entries.len() + INDEX_BLOCK_ENTRY_SIZE
1459            - size_of::<u16>())
1460            / INDEX_BLOCK_ENTRY_SIZE;
1461
1462        let current_key_block =
1463            Self::parse_key_block(&file, &block_offsets, &meta, first_child, compression)?;
1464        Ok(StaticSortedFileIter {
1465            file,
1466            block_offsets,
1467            meta,
1468            index_entries,
1469            num_index_entries,
1470            index_pos: 1,
1471            current_key_block,
1472            value_block_cache: None,
1473            compression,
1474        })
1475    }
1476
1477    /// Parses a key block at the given index, returning `RcBytes`-backed data.
1478    fn parse_key_block(
1479        file: &File,
1480        block_offsets: &BlockOffsets,
1481        meta: &StaticSortedFileMetaData,
1482        block_index: u16,
1483        compression: Compression,
1484    ) -> Result<CurrentKeyBlock> {
1485        let block = read_block_iter(file, block_offsets, meta, block_index, compression)?;
1486        let data = &*block;
1487        ensure!(data.len() >= 4, "key block too short");
1488        let block_type = data[0];
1489        let entry_count = be::read_u24(&data[1..]);
1490        let block_len = block.len();
1491        let Some((layout, fixed)) = KeyBlockLayout::from_block_type(block_type) else {
1492            bail!("Invalid key block type: {block_type}");
1493        };
1494        let hash_len = layout.hash_len();
1495
1496        let (kind, entries) = if fixed {
1497            ensure!(data.len() >= 6, "fixed key block too short");
1498            // In fixed blocks the size of the keys (<=32) is stored immediately after the block len
1499            // (retrieved above)
1500            let key_size = data[4] as usize;
1501            let FixedValueLayout {
1502                value_type,
1503                val_size,
1504                header_size,
1505            } = fixed_value_layout(data, data[5])?;
1506            let regions = FixedRegions::new(entry_count as usize, layout, key_size, val_size);
1507            let entries = block.slice(header_size..block_len);
1508            ensure!(
1509                entries.len() == regions.total_len(entry_count as usize),
1510                "fixed key block for {entry_count} entries is the wrong size"
1511            );
1512            (
1513                CurrentKeyBlockKind::Fixed {
1514                    value_type,
1515                    regions,
1516                },
1517                entries,
1518            )
1519        } else {
1520            let offset_table_begin = 4usize;
1521            let offset_table_end = 4 + (entry_count as usize) * key_block_table_stride(hash_len);
1522            ensure!(
1523                block_len >= offset_table_end,
1524                "key block too short for {entry_count} entries"
1525            );
1526            let offsets = block.clone().slice(offset_table_begin..offset_table_end);
1527            let entries = block.slice(offset_table_end..block_len);
1528            (CurrentKeyBlockKind::Variable { offsets }, entries)
1529        };
1530
1531        // Compute the hash order if needed
1532        let hash_order = match layout {
1533            KeyBlockLayout::HashThenKey => None,
1534            KeyBlockLayout::KeyOnly => Some(hash_order_for_block(entry_count, |i| {
1535                kind.entry(&entries, entry_count, i, hash_len)
1536            })?),
1537        };
1538
1539        Ok(CurrentKeyBlock {
1540            kind,
1541            layout,
1542            entries,
1543            entry_count,
1544            index: 0,
1545            hash_order,
1546        })
1547    }
1548
1549    /// Gets the next entry in the file and moves the cursor.
1550    fn next_internal(&mut self) -> Result<Option<LookupEntry>> {
1551        loop {
1552            let kb = &mut self.current_key_block;
1553            if kb.index < kb.entry_count {
1554                let (precomputed_hash, index) = match &kb.hash_order {
1555                    None => (None, kb.index as usize),
1556                    Some(hash_order) => {
1557                        let HashOrderEntry { hash, entry_index } = hash_order[kb.index as usize];
1558                        (Some(hash), entry_index as usize)
1559                    }
1560                };
1561                let GetKeyEntryResult { hash, key, ty, val } =
1562                    kb.kind
1563                        .entry(&kb.entries, kb.entry_count, index, kb.layout.hash_len())?;
1564                let full_hash = match precomputed_hash {
1565                    Some(hash) => hash,
1566                    None => be::read_u64(hash),
1567                };
1568                let value = if ty == KEY_BLOCK_ENTRY_TYPE_MEDIUM {
1569                    let block = be::read_u16(val);
1570                    let (uncompressed_size, checksum, block) =
1571                        get_raw_block_iter(&self.file, &self.block_offsets, &self.meta, block)?;
1572                    IterValue::Medium {
1573                        uncompressed_size,
1574                        checksum,
1575                        block,
1576                    }
1577                } else {
1578                    handle_key_match_generic(
1579                        &self.meta,
1580                        ty,
1581                        val,
1582                        self.compression,
1583                        RcBlockCacheReader {
1584                            file: &self.file,
1585                            block_offsets: &self.block_offsets,
1586                            cache: &mut self.value_block_cache,
1587                        },
1588                    )?
1589                    .into()
1590                };
1591                let entry = LookupEntry {
1592                    hash: full_hash,
1593                    key: unsafe { kb.entries.slice_from_subslice(key) },
1594                    value,
1595                };
1596                kb.index += 1;
1597                return Ok(Some(entry));
1598            }
1599            if self.index_pos < self.num_index_entries {
1600                let base = self.index_pos * INDEX_BLOCK_ENTRY_SIZE;
1601                let block_index = be::read_u16(&self.index_entries[base..]);
1602                self.index_pos += 1;
1603                self.current_key_block = Self::parse_key_block(
1604                    &self.file,
1605                    &self.block_offsets,
1606                    &self.meta,
1607                    block_index,
1608                    self.compression,
1609                )?;
1610            } else {
1611                return Ok(None);
1612            }
1613        }
1614    }
1615}
1616
1617struct GetKeyEntryResult<'l> {
1618    hash: &'l [u8],
1619    key: &'l [u8],
1620    ty: u8,
1621    val: &'l [u8],
1622}
1623
1624/// Computes `(key hash, entry index)` for every entry of a no-hash key block, in `(hash, key)`
1625/// order.
1626fn hash_order_for_block<'l>(
1627    entry_count: u32,
1628    get_entry: impl Fn(usize) -> Result<GetKeyEntryResult<'l>>,
1629) -> Result<Vec<HashOrderEntry>> {
1630    let mut order = Vec::with_capacity(entry_count as usize);
1631    for entry_index in 0..entry_count {
1632        let key = get_entry(entry_index as usize)?.key;
1633        order.push(HashOrderEntry {
1634            hash: crate::key::hash_key(&key),
1635            entry_index,
1636        });
1637    }
1638    // Stable sort by hash, stability is important to preserve the original hash order
1639    // This keeps tombstones in their correct relative positions.
1640    order.sort_by_key(|entry| entry.hash);
1641    Ok(order)
1642}
1643
1644/// Compares a query against an entry, returning the ordering of the query relative to the entry in
1645/// the block's own sort order.
1646fn compare_hash_key<K: QueryKey>(
1647    layout: KeyBlockLayout,
1648    entry_hash: &[u8],
1649    entry_key: &[u8],
1650    full_hash: u64,
1651    query_key: &K,
1652) -> Ordering {
1653    match layout {
1654        KeyBlockLayout::KeyOnly => {
1655            debug_assert!(entry_hash.is_empty(), "KeyOnly entries carry no hash");
1656            query_key.cmp(entry_key)
1657        }
1658        KeyBlockLayout::HashThenKey => match full_hash.to_be_bytes()[..].cmp(entry_hash) {
1659            Ordering::Equal => query_key.cmp(entry_key),
1660            ord => ord,
1661        },
1662    }
1663}
1664
1665/// Checks whether a query key names the same entry, used to walk a key group outward from a hit.
1666fn entry_matches_key<K: QueryKey>(
1667    layout: KeyBlockLayout,
1668    entry_hash: &[u8],
1669    entry_key: &[u8],
1670    full_hash: u64,
1671    query_key: &K,
1672) -> bool {
1673    match layout {
1674        KeyBlockLayout::KeyOnly => {
1675            debug_assert!(entry_hash.is_empty(), "KeyOnly entries carry no hash");
1676            query_key.eq(entry_key)
1677        }
1678        KeyBlockLayout::HashThenKey => {
1679            full_hash.to_be_bytes()[..] == *entry_hash && query_key.eq(entry_key)
1680        }
1681    }
1682}
1683
1684/// Returns the byte size of the value portion for a given key block entry type.
1685///
1686/// The type byte comes from the file, so the two open-ended ranges are bounded here rather than
1687/// trusted: the writer only ever emits sizes up to [`MAX_INLINE_VALUE_SIZE`], and a value that
1688/// large is what lets a lookup return it inline. Rejecting an over-large tag keeps that a total
1689/// function — `B::from_inline` would otherwise be handed more bytes than it can hold.
1690fn entry_val_size(ty: u8) -> Result<usize> {
1691    match ty {
1692        KEY_BLOCK_ENTRY_TYPE_SMALL => Ok(SMALL_VALUE_REF_SIZE),
1693        KEY_BLOCK_ENTRY_TYPE_MEDIUM => Ok(MEDIUM_VALUE_REF_SIZE),
1694        KEY_BLOCK_ENTRY_TYPE_BLOB => Ok(BLOB_VALUE_REF_SIZE),
1695        KEY_BLOCK_ENTRY_TYPE_KEY_DELETED => Ok(KEY_DELETED_REF_SIZE),
1696        // Must precede the inline arm: both are open-ended and the tombstone range sits above it.
1697        ty if ty >= KEY_BLOCK_ENTRY_TYPE_KEY_VALUE_DELETED_MIN => {
1698            let size = (ty - KEY_BLOCK_ENTRY_TYPE_KEY_VALUE_DELETED_MIN) as usize;
1699            ensure!(
1700                size <= MAX_INLINE_VALUE_SIZE,
1701                "key-value tombstone type {ty} claims a {size} byte value, over the \
1702                 {MAX_INLINE_VALUE_SIZE} byte maximum"
1703            );
1704            Ok(size)
1705        }
1706        ty if ty >= KEY_BLOCK_ENTRY_TYPE_INLINE_MIN => {
1707            let size = (ty - KEY_BLOCK_ENTRY_TYPE_INLINE_MIN) as usize;
1708            ensure!(
1709                size <= MAX_INLINE_VALUE_SIZE,
1710                "inline value type {ty} claims a {size} byte value, over the \
1711                 {MAX_INLINE_VALUE_SIZE} byte maximum"
1712            );
1713            Ok(size)
1714        }
1715        _ => bail!("Invalid key block entry type: {ty}"),
1716    }
1717}
1718
1719/// Reads the type and start offset from an offset table entry.
1720///
1721/// The trailing 4 bytes of every entry pack 1 byte of type into the top of a 3-byte BE offset.
1722/// `HashThenKey` entries carry the key's 8-byte hash ahead of that word — see
1723/// [`KEY_BLOCK_TABLE_ENTRY_SIZE_WITH_HASH`].
1724#[inline(always)]
1725fn read_offset_entry(
1726    offsets: &[u8],
1727    index: usize,
1728    table_stride: usize,
1729    hash_len: u8,
1730) -> (u8, usize) {
1731    // The offset word is last, so skip any hash that precedes it.
1732    let base = index * table_stride + (hash_len as usize);
1733    let word = be::read_u32(&offsets[base..]);
1734    let ty = (word >> 24) as u8;
1735    let offset = (word & 0x00FF_FFFF) as usize;
1736    (ty, offset)
1737}
1738
1739/// Reads a key entry from a key block.
1740fn get_key_entry<'l>(
1741    offsets: &'l [u8],
1742    entries: &'l [u8],
1743    entry_count: usize,
1744    index: usize,
1745    hash_len: u8,
1746) -> Result<GetKeyEntryResult<'l>> {
1747    let table_stride = key_block_table_stride(hash_len);
1748    let (ty, start) = read_offset_entry(offsets, index, table_stride, hash_len);
1749    let end = if index == entry_count - 1 {
1750        entries.len()
1751    } else {
1752        let (_, next_start) = read_offset_entry(offsets, index + 1, table_stride, hash_len);
1753        next_start
1754    };
1755    // Hoisted into the table, so the search never reaches into the payload; empty for `KeyOnly`.
1756    let hash = &offsets[index * table_stride..index * table_stride + hash_len as usize];
1757    let val_size = entry_val_size(ty)?;
1758    Ok(GetKeyEntryResult {
1759        hash,
1760        key: &entries[start..end - val_size],
1761        ty,
1762        val: &entries[end - val_size..end],
1763    })
1764}
1765
1766/// Reads a key entry from a fixed-size key block by direct indexing.
1767///
1768/// All entries have the same key size and value type, so positions are computed
1769/// arithmetically with no offset table indirection.
1770/// How a fixed-size key block encodes its entry values, decoded from the block header.
1771struct FixedValueLayout {
1772    /// The type shared by every entry, or `None` if each entry carries its own type byte.
1773    value_type: Option<u8>,
1774    /// Value bytes per entry, including any per-entry type byte.
1775    val_size: usize,
1776    /// Total header size, which the entry data follows.
1777    header_size: usize,
1778}
1779
1780/// Decodes the value layout from a fixed-size key block header.
1781fn fixed_value_layout(block: &[u8], header_type: u8) -> Result<FixedValueLayout> {
1782    if header_type == FIXED_KEY_BLOCK_MIXED_VALUE_TYPE {
1783        // Mixed-type block: the value size follows the header's type byte, and each entry
1784        // carries its own type.
1785        ensure!(block.len() >= 7, "mixed-type fixed key block too short");
1786        // Validate the value footprint byte
1787        let value_footprint = be::read_u8(&block[6..]) as usize;
1788        ensure!(
1789            value_footprint <= MAX_INLINE_VALUE_SIZE,
1790            "mixed-type fixed key block claims a {value_footprint} byte value footprint, over the \
1791             {MAX_INLINE_VALUE_SIZE} byte maximum"
1792        );
1793        Ok(FixedValueLayout {
1794            value_type: None,
1795            // +1 for the per-entry type byte, which is part of the stride.
1796            val_size: value_footprint + 1,
1797            header_size: 7,
1798        })
1799    } else {
1800        Ok(FixedValueLayout {
1801            value_type: Some(header_type),
1802            val_size: entry_val_size(header_type)?,
1803            header_size: 6,
1804        })
1805    }
1806}
1807
1808/// Where the two regions of a fixed-size key block sit, computed once per block.
1809///
1810/// A fixed block stores the bytes the binary search probes in a dense leading region and everything
1811/// else in a trailing region at the same entry index, so a probe touches one small stride rather
1812/// than a full interleaved entry.
1813///
1814/// This is the single owner of that geometry: the reader, the writer, and `sst_inspect` all derive
1815/// their offsets from here, so a change to which bytes go in the search region is made once. It is
1816/// the fixed-block counterpart to [`key_block_table_stride`] for variable-size blocks.
1817#[derive(Clone, Copy)]
1818pub struct FixedRegions {
1819    /// Which bytes the search region holds: the hash (`HashThenKey`) or the key (`KeyOnly`).
1820    layout: KeyBlockLayout,
1821    /// Bytes per entry in the search region.
1822    pub search_stride: usize,
1823    /// Offset of the tail region, relative to the start of the entry data.
1824    pub tail_start: usize,
1825    /// Bytes per entry in the tail region.
1826    pub tail_stride: usize,
1827    key_size: usize,
1828}
1829
1830impl FixedRegions {
1831    /// `val_size` is the tail's per-entry value footprint: the value bytes plus the per-entry type
1832    /// byte of a mixed-type block. `fixed_value_layout` already folds that byte in; a caller
1833    /// computing it from a block header must add it itself.
1834    pub fn new(
1835        entry_count: usize,
1836        layout: KeyBlockLayout,
1837        key_size: usize,
1838        val_size: usize,
1839    ) -> Self {
1840        // `HashThenKey` searches the hashes and keeps the key with the value; `KeyOnly` has no
1841        // hash, so the key itself is the search region.
1842        let (search_stride, tail_stride) = match layout {
1843            KeyBlockLayout::HashThenKey => (layout.hash_len() as usize, key_size + val_size),
1844            KeyBlockLayout::KeyOnly => (key_size, val_size),
1845        };
1846        Self {
1847            layout,
1848            search_stride,
1849            tail_start: entry_count * search_stride,
1850            tail_stride,
1851            key_size,
1852        }
1853    }
1854
1855    /// Bytes of a tail entry that precede its value: the key for `HashThenKey`, nothing for
1856    /// `KeyOnly`, which keeps its key in the search region.
1857    pub fn tail_key_size(&self) -> usize {
1858        match self.layout {
1859            KeyBlockLayout::HashThenKey => self.key_size,
1860            KeyBlockLayout::KeyOnly => 0,
1861        }
1862    }
1863
1864    /// Total entry-data length implied by these regions, for bounds checking.
1865    pub fn total_len(&self, entry_count: usize) -> usize {
1866        self.tail_start + entry_count * self.tail_stride
1867    }
1868}
1869
1870fn get_fixed_key_entry<'l>(
1871    entries: &'l [u8],
1872    index: usize,
1873    regions: FixedRegions,
1874    value_type: Option<u8>,
1875) -> Result<GetKeyEntryResult<'l>> {
1876    let FixedRegions {
1877        layout,
1878        search_stride,
1879        tail_start,
1880        tail_stride,
1881        key_size,
1882    } = regions;
1883    // The search region holds only what the binary search compares first: the hash for
1884    // `HashThenKey` blocks, the key for `KeyOnly` blocks. Everything else lives in the tail region
1885    // at the same entry index.
1886    let search = index * search_stride;
1887    let tail = tail_start + index * tail_stride;
1888    let (hash, key, tail_rest) = match layout {
1889        KeyBlockLayout::HashThenKey => (
1890            &entries[search..search + search_stride],
1891            &entries[tail..tail + key_size],
1892            tail + key_size,
1893        ),
1894        KeyBlockLayout::KeyOnly => (&entries[..0], &entries[search..search + key_size], tail),
1895    };
1896    // In a mixed-type block the entry's type byte precedes its value in the tail region.
1897    let (ty, val_start) = match value_type {
1898        Some(ty) => (ty, tail_rest),
1899        None => (be::read_u8(&entries[tail_rest..]), tail_rest + 1),
1900    };
1901    Ok(GetKeyEntryResult {
1902        hash,
1903        key,
1904        ty,
1905        val: &entries[val_start..tail + tail_stride],
1906    })
1907}
1908
1909#[cfg(test)]
1910mod tests {
1911    use super::*;
1912
1913    /// `block_type` and `from_block_type` must be inverses over every layout and both entry
1914    /// sizings. This is what lets the writer and the readers agree: the writer picks a variant and
1915    /// encodes it, and each reader decodes the same variant back.
1916    #[test]
1917    fn block_type_round_trips() {
1918        for layout in [KeyBlockLayout::HashThenKey, KeyBlockLayout::KeyOnly] {
1919            for fixed in [false, true] {
1920                let byte = layout.block_type(fixed);
1921                assert_eq!(
1922                    KeyBlockLayout::from_block_type(byte),
1923                    Some((layout, fixed)),
1924                    "{layout:?} (fixed={fixed}) encoded as {byte} did not round-trip"
1925                );
1926            }
1927        }
1928    }
1929
1930    /// The four key block types must be distinct, and must not collide with the index block type —
1931    /// a collision would silently route a key block into the index decoder or vice versa.
1932    #[test]
1933    fn block_types_are_distinct() {
1934        let mut seen = vec![BLOCK_TYPE_INDEX];
1935        for layout in [KeyBlockLayout::HashThenKey, KeyBlockLayout::KeyOnly] {
1936            for fixed in [false, true] {
1937                let byte = layout.block_type(fixed);
1938                assert!(!seen.contains(&byte), "block type {byte} is used twice");
1939                seen.push(byte);
1940            }
1941        }
1942        assert!(KeyBlockLayout::from_block_type(BLOCK_TYPE_INDEX).is_none());
1943    }
1944
1945    /// Only `HashThenKey` stores hash bytes, and it stores exactly a `u64` of them. `get_key_entry`
1946    /// and `get_fixed_key_entry` both slice the entry using this length.
1947    #[test]
1948    fn hash_len_matches_layout() {
1949        assert_eq!(
1950            KeyBlockLayout::HashThenKey.hash_len() as usize,
1951            size_of::<u64>()
1952        );
1953        assert_eq!(KeyBlockLayout::KeyOnly.hash_len(), 0);
1954    }
1955
1956    /// Only ranges that cross a page boundary are hinted, including a small block that straddles
1957    /// one, for both 4 KiB and 16 KiB pages.
1958    #[cfg(all(feature = "mmap", unix))]
1959    #[test]
1960    fn spans_multiple_pages_detects_page_crossings() {
1961        for page in [4096, 16384] {
1962            assert!(!spans_multiple_pages(&(0..page), page), "one full page");
1963            assert!(
1964                !spans_multiple_pages(&(page..page + 100), page),
1965                "inside a page"
1966            );
1967            assert!(spans_multiple_pages(&(0..page + 1), page), "one byte over");
1968            assert!(
1969                spans_multiple_pages(&(page - 8..page + 8), page),
1970                "straddles a boundary"
1971            );
1972            assert!(
1973                spans_multiple_pages(&(page / 2..3 * page), page),
1974                "several pages"
1975            );
1976        }
1977        // A 5 KiB block is one page at 16 KiB but two at 4 KiB.
1978        assert!(spans_multiple_pages(&(0..5120), 4096));
1979        assert!(!spans_multiple_pages(&(0..5120), 16384));
1980    }
1981
1982    /// Reads the block-offset table of a file with `data_len` bytes of blocks followed by a table
1983    /// holding `ends`.
1984    fn read_offsets(ends: &[u32], data_len: usize) -> Result<BlockOffsets> {
1985        let mut bytes = vec![0u8; data_len];
1986        for end in ends {
1987            bytes.extend_from_slice(&end.to_be_bytes());
1988        }
1989        read_offsets_from(&bytes, ends.len())
1990    }
1991
1992    /// Reads the block-offset table of `block_count` blocks from a file holding `bytes`.
1993    fn read_offsets_from(bytes: &[u8], block_count: usize) -> Result<BlockOffsets> {
1994        let dir = tempfile::tempdir()?;
1995        let path = dir.path().join("00000007.sst");
1996        fs_err::write(&path, bytes)?;
1997        let file = File::open(&path)?;
1998        let meta = StaticSortedFileMetaData {
1999            sequence_number: 7,
2000            block_count: block_count.try_into()?,
2001        };
2002        BlockOffsets::read(&file, &meta, bytes.len())
2003    }
2004
2005    #[test]
2006    fn block_offsets_ranges_use_previous_end_as_start() -> Result<()> {
2007        // A header-only first block, and offsets wider than one byte to exercise the big-endian
2008        // decode.
2009        let offsets = read_offsets(&[8, 300, 70_000], 70_000)?;
2010        assert_eq!(offsets.block_range(0)?, 0..8);
2011        assert_eq!(offsets.block_range(1)?, 8..300);
2012        assert_eq!(offsets.block_range(2)?, 300..70_000);
2013        assert!(offsets.block_range(3).is_err());
2014        Ok(())
2015    }
2016
2017    #[test]
2018    fn block_offsets_reject_file_shorter_than_table() {
2019        let err = read_offsets_from(&[0; 8], 3).unwrap_err();
2020        assert!(err.to_string().contains("too short"), "{err:#}");
2021    }
2022
2023    #[test]
2024    fn block_offsets_reject_block_shorter_than_header() {
2025        let err = read_offsets(&[12, 16, 40], 40).unwrap_err();
2026        assert!(err.to_string().contains("block 1"), "{err:#}");
2027    }
2028
2029    #[test]
2030    fn block_offsets_reject_non_monotonic_offsets() {
2031        let err = read_offsets(&[20, 12, 40], 40).unwrap_err();
2032        assert!(err.to_string().contains("block 1"), "{err:#}");
2033    }
2034
2035    /// A failed table read names the file, because `pread` goes through `fs_err`.
2036    #[test]
2037    fn block_offsets_read_error_names_the_file() -> Result<()> {
2038        let dir = tempfile::tempdir()?;
2039        let path = dir.path().join("00000007.sst");
2040        fs_err::write(&path, [0u8; 8])?;
2041        let file = File::open(&path)?;
2042        let meta = StaticSortedFileMetaData {
2043            sequence_number: 7,
2044            block_count: 1,
2045        };
2046        // Claim the file is longer than it is, so the size check passes but the read hits EOF.
2047        let err = BlockOffsets::read(&file, &meta, 16).unwrap_err();
2048        let message = format!("{err:#}");
2049        assert!(message.contains(&*path.to_string_lossy()), "{message}");
2050        Ok(())
2051    }
2052
2053    #[test]
2054    fn block_offsets_reject_blocks_past_the_table() {
2055        let err = read_offsets(&[12, 40, 60], 52).unwrap_err();
2056        assert!(err.to_string().contains("past"), "{err:#}");
2057    }
2058}