Skip to main content

turbo_persistence/
static_sorted_file.rs

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