Skip to main content

ferrox_core/
kv_disk.rs

1//! The disk tier for the KV prefix cache: where a block goes so that a
2//! prefix survives eviction from RAM, and a process restart.
3//!
4//! One file per block, named by its
5//! [`BlockHash`](crate::kv_block::BlockHash) and sharded into
6//! subdirectories by a hex prefix of that hash, so a machine that has
7//! cached a million prefixes never puts a million entries in one
8//! directory. The payload is the block's per-layer K and V tensors,
9//! flattened, in the cache's own dtype -- **no re-encoding, no
10//! compression**: a KV block is already dense float data, and a
11//! compressor would only spend CPU on the request path to lose.
12//!
13//! # What a reader is protected from
14//!
15//! A cache file outlives the process that wrote it, so every failure
16//! mode here is "someone else's bytes":
17//!
18//! - **A torn write.** The publish is temp-file + `fsync` + `rename`,
19//!   which is atomic within a directory on every filesystem ferrox
20//!   targets -- a reader sees the whole file or no file. But a crash
21//!   mid-`write` to the *temp* file, a truncated copy, or a partial
22//!   restore from a backup can still leave a short file lying around,
23//!   so the format records its own total length and a SHA-256 of its
24//!   body. A file that does not match is refused
25//!   ([`BlockFormatError`]), never partially deserialized.
26//! - **A different build.** The format is versioned with an explicit
27//!   readable-set; an unknown version is refused rather than guessed
28//!   at.
29//! - **A different model or config.** That is
30//!   [`kv_signature`](crate::kv_signature)'s job, and this module does
31//!   not duplicate it: a decoded file becomes an
32//!   [`UnverifiedBlock`], and only
33//!   [`UnverifiedBlock::verify`] against the reader's own expectation
34//!   produces a usable [`KvBlock`].
35//!
36//! # The write-ordering invariant
37//!
38//! A write is accepted on one thread and finished on another, so for a
39//! while a block is "in the store" without being on disk. The rule that
40//! makes that safe, and the one every step below is ordered around:
41//!
42//! > **buffer -> index -> queue.** A concurrent reader must never see
43//! > an index hit for a block that has neither a file nor a buffered
44//! > payload.
45//!
46//! So the payload is reachable *before* anything claims the block
47//! exists, and on the way out the file is published *before* the
48//! buffered copy is released. A reader holds the index lock while it
49//! consults the buffer, because "this block is not on disk yet" and
50//! "here is its payload" have to be one decision -- as two, the writer
51//! can publish and release in between and the reader finds nothing.
52//! When that invariant does break, the reader gets
53//! [`StoreError::MissingPayload`] rather than a quiet miss: a
54//! correctness bug that degrades into a cache miss is a bug nobody ever
55//! finds.
56//!
57//! The queue is bounded and **never drops**: a full queue makes the
58//! caller write the block itself ([`DiskStats::inline_writes`] counts
59//! it). Dropping writes silently would be indistinguishable from a cold
60//! cache later.
61//!
62//! # Layout
63//!
64//! ```text
65//! <root>/.tmp/<hash>.<pid>.<n>.tmp     in-progress writes
66//! <root>/<hh>/<full-hex>.kvb           published blocks (hh = shard prefix)
67//! ```
68//!
69//! # File format (version 1)
70//!
71//! ```text
72//! magic           8   b"FRXKVBLK"
73//! format_version  4   u32 LE, checked against READABLE_FORMAT_VERSIONS
74//! header_len      4   u32 LE
75//! body_len        8   u64 LE
76//! digest         32   SHA-256 over header || body
77//! header  header_len  block hash, dims, dtype, model identity
78//! body      body_len  per layer: all K elements, then all V elements
79//! ```
80//!
81//! The digest covers header and body but not the fixed prefix, so the
82//! lengths are checked against the real file size *before* anything is
83//! hashed or parsed -- a 4 GB `body_len` on a 200-byte file is rejected
84//! by arithmetic, not by allocating.
85
86use std::collections::{HashMap, VecDeque};
87use std::fs;
88use std::io::{self, Write};
89use std::path::{Path, PathBuf};
90use std::sync::atomic::{AtomicU64, Ordering};
91use std::sync::{Arc, Condvar, Mutex};
92use std::time::Instant;
93
94use sha2::{Digest, Sha256};
95
96use crate::cache::KvCache;
97use crate::kv_block::BlockHash;
98use crate::kv_signature::{
99    CacheSignature, KvBlock, KvDtype, UnverifiedBlock, BLOCK_FORMAT_VERSION,
100    READABLE_FORMAT_VERSIONS,
101};
102
103const MAGIC: &[u8; 8] = b"FRXKVBLK";
104/// magic + version + header_len + body_len + digest.
105const PREFIX_LEN: usize = 8 + 4 + 4 + 8 + 32;
106/// Extension of a published block file.
107pub const BLOCK_FILE_EXT: &str = "kvb";
108/// Subdirectory holding in-progress writes. Not a valid shard name --
109/// shard directories are lowercase hex, and `.` is not a hex digit --
110/// so it can never collide with one.
111const TMP_DIR: &str = ".tmp";
112
113const DTYPE_F32: u32 = 0;
114
115fn dtype_code(dtype: KvDtype) -> u32 {
116    match dtype {
117        KvDtype::F32 => DTYPE_F32,
118    }
119}
120
121fn dtype_from_code(code: u32) -> Option<KvDtype> {
122    match code {
123        DTYPE_F32 => Some(KvDtype::F32),
124        _ => None,
125    }
126}
127
128fn dtype_width(dtype: KvDtype) -> usize {
129    match dtype {
130        KvDtype::F32 => 4,
131    }
132}
133
134/// Why a block file was refused. Every variant means "these bytes are
135/// not a block this build can read", and none of them is recoverable by
136/// reading harder.
137#[derive(Clone, Debug, PartialEq, Eq)]
138pub enum BlockFormatError {
139    /// Shorter than the fixed prefix: there is not even a header to
140    /// check.
141    TooShort { len: usize },
142    /// Not a ferrox block file at all.
143    BadMagic,
144    /// Written by a build whose layout this one does not know.
145    UnsupportedFormat {
146        found: u32,
147        readable: &'static [u32],
148    },
149    /// The file's own declared length does not match the bytes present
150    /// -- a half-written or truncated file.
151    Truncated { expected: u64, actual: u64 },
152    /// Right length, wrong bytes: bit rot, an interrupted overwrite, or
153    /// a file that was edited.
154    ChecksumMismatch,
155    /// Structurally impossible content: a dimension of zero, a body
156    /// that cannot hold the tensors the header describes.
157    Malformed(&'static str),
158    /// A dtype code this build has no reader for.
159    UnknownDtype(u32),
160}
161
162impl std::fmt::Display for BlockFormatError {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        match self {
165            BlockFormatError::TooShort { len } => write!(
166                f,
167                "KV block file is {len} bytes, shorter than the {PREFIX_LEN}-byte header prefix"
168            ),
169            BlockFormatError::BadMagic => write!(f, "KV block file has the wrong magic"),
170            BlockFormatError::UnsupportedFormat { found, readable } => write!(
171                f,
172                "KV block file format version {found} is not readable by this build (readable: {readable:?})"
173            ),
174            BlockFormatError::Truncated { expected, actual } => write!(
175                f,
176                "KV block file declares {expected} bytes but is {actual}; refusing a torn file"
177            ),
178            BlockFormatError::ChecksumMismatch => {
179                write!(f, "KV block file failed its SHA-256 checksum")
180            }
181            BlockFormatError::Malformed(what) => {
182                write!(f, "KV block file is malformed: {what}")
183            }
184            BlockFormatError::UnknownDtype(code) => {
185                write!(f, "KV block file has unknown dtype code {code}")
186            }
187        }
188    }
189}
190
191impl std::error::Error for BlockFormatError {}
192
193/// Serializes a block. The `hash` is stored inside the file as well as
194/// in its name, so a block found under a wrong or renamed path can
195/// still be checked against the identity it claims.
196pub fn encode_block(hash: &BlockHash, block: &KvBlock) -> Vec<u8> {
197    let sig = block.signature();
198    let mut header = Vec::with_capacity(64 + sig.model.len());
199    header.extend_from_slice(hash.as_bytes());
200    header.extend_from_slice(&(sig.n_layers as u32).to_le_bytes());
201    header.extend_from_slice(&(sig.n_kv_heads as u32).to_le_bytes());
202    header.extend_from_slice(&(sig.head_dim as u32).to_le_bytes());
203    header.extend_from_slice(&(sig.tokens as u32).to_le_bytes());
204    header.extend_from_slice(&dtype_code(sig.dtype).to_le_bytes());
205    header.extend_from_slice(&(sig.model.len() as u32).to_le_bytes());
206    header.extend_from_slice(sig.model.as_bytes());
207
208    let mut body = Vec::with_capacity(body_len(sig) as usize);
209    for layer in block.layers() {
210        for value in &layer.k {
211            body.extend_from_slice(&value.to_le_bytes());
212        }
213        for value in &layer.v {
214            body.extend_from_slice(&value.to_le_bytes());
215        }
216    }
217
218    let mut digest = Sha256::new();
219    digest.update(&header);
220    digest.update(&body);
221    let digest: [u8; 32] = digest.finalize().into();
222
223    let mut out = Vec::with_capacity(PREFIX_LEN + header.len() + body.len());
224    out.extend_from_slice(MAGIC);
225    out.extend_from_slice(&BLOCK_FORMAT_VERSION.to_le_bytes());
226    out.extend_from_slice(&(header.len() as u32).to_le_bytes());
227    out.extend_from_slice(&(body.len() as u64).to_le_bytes());
228    out.extend_from_slice(&digest);
229    out.extend_from_slice(&header);
230    out.extend_from_slice(&body);
231    out
232}
233
234/// Bytes the body of a block with this signature occupies. Lets the
235/// store charge a block against its budget without serializing it
236/// first.
237fn body_len(sig: &CacheSignature) -> u64 {
238    let per_layer = sig.tokens as u64
239        * sig.n_kv_heads as u64
240        * sig.head_dim as u64
241        * dtype_width(sig.dtype) as u64;
242    // K and V.
243    per_layer * 2 * sig.n_layers as u64
244}
245
246/// Total on-disk size of a block with this signature, header included.
247pub fn encoded_len(sig: &CacheSignature) -> u64 {
248    let header = 32 + 4 * 6 + sig.model.len() as u64;
249    PREFIX_LEN as u64 + header + body_len(sig)
250}
251
252/// The identity and payload recovered from a block file. The signature
253/// is deliberately *unverified*: use
254/// [`UnverifiedBlock::verify`](crate::kv_signature::UnverifiedBlock::verify)
255/// to turn it into a block this process may use.
256#[derive(Debug)]
257pub struct DecodedBlock {
258    /// The hash the file claims to be stored under.
259    pub hash: BlockHash,
260    pub block: UnverifiedBlock,
261}
262
263/// Parses a block file. Checks, in order: length, magic, format
264/// version, declared-vs-actual size, checksum, then structure. Nothing
265/// is allocated from a length field until that length has been checked
266/// against the bytes actually present.
267pub fn decode_block(bytes: &[u8]) -> Result<DecodedBlock, BlockFormatError> {
268    if bytes.len() < PREFIX_LEN {
269        return Err(BlockFormatError::TooShort { len: bytes.len() });
270    }
271    if &bytes[..8] != MAGIC {
272        return Err(BlockFormatError::BadMagic);
273    }
274    let version = u32::from_le_bytes(bytes[8..12].try_into().unwrap());
275    if !READABLE_FORMAT_VERSIONS.contains(&version) {
276        return Err(BlockFormatError::UnsupportedFormat {
277            found: version,
278            readable: READABLE_FORMAT_VERSIONS,
279        });
280    }
281    let header_len = u32::from_le_bytes(bytes[12..16].try_into().unwrap()) as u64;
282    let body_len = u64::from_le_bytes(bytes[16..24].try_into().unwrap());
283    let declared = PREFIX_LEN as u64 + header_len + body_len;
284    if declared != bytes.len() as u64 {
285        return Err(BlockFormatError::Truncated {
286            expected: declared,
287            actual: bytes.len() as u64,
288        });
289    }
290    let digest_recorded = &bytes[24..PREFIX_LEN];
291    let mut digest = Sha256::new();
292    digest.update(&bytes[PREFIX_LEN..]);
293    let digest: [u8; 32] = digest.finalize().into();
294    if digest != digest_recorded {
295        return Err(BlockFormatError::ChecksumMismatch);
296    }
297
298    let header = &bytes[PREFIX_LEN..PREFIX_LEN + header_len as usize];
299    let body = &bytes[PREFIX_LEN + header_len as usize..];
300    if header.len() < 32 + 4 * 6 {
301        return Err(BlockFormatError::Malformed(
302            "header shorter than its fields",
303        ));
304    }
305    let mut hash = [0u8; 32];
306    hash.copy_from_slice(&header[..32]);
307    let hash = BlockHash::from_bytes(hash);
308    let field = |i: usize| u32::from_le_bytes(header[32 + i * 4..36 + i * 4].try_into().unwrap());
309    let n_layers = field(0) as usize;
310    let n_kv_heads = field(1) as usize;
311    let head_dim = field(2) as usize;
312    let tokens = field(3) as usize;
313    let dtype_code = field(4);
314    let model_len = field(5) as usize;
315    let dtype = dtype_from_code(dtype_code).ok_or(BlockFormatError::UnknownDtype(dtype_code))?;
316    if header.len() != 32 + 4 * 6 + model_len {
317        return Err(BlockFormatError::Malformed(
318            "model name length disagrees with header",
319        ));
320    }
321    let model = std::str::from_utf8(&header[32 + 4 * 6..])
322        .map_err(|_| BlockFormatError::Malformed("model name is not UTF-8"))?
323        .to_string();
324    if n_layers == 0 || n_kv_heads == 0 || head_dim == 0 {
325        return Err(BlockFormatError::Malformed(
326            "zero layers, heads, or head dim",
327        ));
328    }
329
330    let per_layer_elems = tokens
331        .checked_mul(n_kv_heads)
332        .and_then(|n| n.checked_mul(head_dim))
333        .ok_or(BlockFormatError::Malformed("layer size overflows"))?;
334    let expected_body = (per_layer_elems as u64)
335        .checked_mul(2 * n_layers as u64)
336        .and_then(|n| n.checked_mul(dtype_width(dtype) as u64))
337        .ok_or(BlockFormatError::Malformed("body size overflows"))?;
338    if expected_body != body.len() as u64 {
339        return Err(BlockFormatError::Malformed(
340            "body does not match declared dims",
341        ));
342    }
343
344    let mut layers = Vec::with_capacity(n_layers);
345    let mut offset = 0usize;
346    for _ in 0..n_layers {
347        let k = read_f32(&body[offset..offset + per_layer_elems * 4]);
348        offset += per_layer_elems * 4;
349        let v = read_f32(&body[offset..offset + per_layer_elems * 4]);
350        offset += per_layer_elems * 4;
351        let mut cache = KvCache::new(n_kv_heads, head_dim);
352        cache.k = k;
353        cache.v = v;
354        cache.seq_len = tokens;
355        layers.push(cache);
356    }
357
358    let signature = CacheSignature {
359        format_version: version,
360        model,
361        n_layers,
362        n_kv_heads,
363        head_dim,
364        dtype,
365        tokens,
366    };
367    Ok(DecodedBlock {
368        hash,
369        block: UnverifiedBlock::new(Some(signature), layers),
370    })
371}
372
373fn read_f32(bytes: &[u8]) -> Vec<f32> {
374    bytes
375        .chunks_exact(4)
376        .map(|c| f32::from_le_bytes(c.try_into().unwrap()))
377        .collect()
378}
379
380/// Something went wrong reaching the disk tier. Corruption and
381/// incompatibility are **not** here: those are misses, reported through
382/// [`DiskStats`], because a caller's only sane response to either is to
383/// recompute the prefix.
384#[derive(Clone, Debug, PartialEq, Eq)]
385pub enum StoreError {
386    Io {
387        op: &'static str,
388        path: PathBuf,
389        message: String,
390    },
391    /// The index named a block whose payload is nowhere: not on disk,
392    /// not in the write buffer. This is not a cache miss -- it is the
393    /// write-ordering invariant (buffer -> index -> queue) having been
394    /// violated, i.e. a bug in this module, and it is surfaced rather
395    /// than smoothed into a miss so a test can fail on it.
396    MissingPayload { hash: BlockHash },
397}
398
399impl std::fmt::Display for StoreError {
400    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
401        match self {
402            StoreError::Io { op, path, message } => {
403                write!(
404                    f,
405                    "KV block store failed to {op} {}: {message}",
406                    path.display()
407                )
408            }
409            StoreError::MissingPayload { hash } => write!(
410                f,
411                "KV block store index names {hash:?} but it has neither a file nor a buffered \
412                 payload; the write-ordering invariant was violated"
413            ),
414        }
415    }
416}
417
418impl std::error::Error for StoreError {}
419
420fn io_err(op: &'static str, path: &Path, err: io::Error) -> StoreError {
421    StoreError::Io {
422        op,
423        path: path.to_path_buf(),
424        message: err.to_string(),
425    }
426}
427
428/// Asks how many bytes are still free on the filesystem holding a
429/// path. `None` means "cannot tell", and the store then trusts only its
430/// configured byte budget.
431///
432/// Injectable so the budget's behaviour under a nearly-full disk is
433/// testable without actually filling one.
434pub type FreeSpaceProbe = Arc<dyn Fn(&Path) -> Option<u64> + Send + Sync>;
435
436/// Free space via `statvfs`. `f_bavail` (blocks available to an
437/// unprivileged process), not `f_bfree`, because the reserved blocks a
438/// filesystem keeps back are not ours to spend.
439#[cfg(unix)]
440#[allow(clippy::unnecessary_cast)] // field widths differ across unixes
441fn platform_free_bytes(path: &Path) -> Option<u64> {
442    use std::os::unix::ffi::OsStrExt;
443    let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
444    // SAFETY: `c_path` is a NUL-terminated path that outlives the call,
445    // and `stat` is a correctly sized, writable `statvfs`.
446    let stat = unsafe {
447        let mut stat: libc::statvfs = std::mem::zeroed();
448        if libc::statvfs(c_path.as_ptr(), &mut stat) != 0 {
449            return None;
450        }
451        stat
452    };
453    let block = if stat.f_frsize > 0 {
454        stat.f_frsize as u64
455    } else {
456        stat.f_bsize as u64
457    };
458    Some((stat.f_bavail as u64).saturating_mul(block))
459}
460
461#[cfg(not(unix))]
462fn platform_free_bytes(_path: &Path) -> Option<u64> {
463    None
464}
465
466/// A free-space reading and when it was taken. `statvfs` is a syscall
467/// per call and the answer changes slowly, so it is cached -- but only
468/// for a TTL, and any `ENOSPC` throws it away immediately, because at
469/// that moment the cached number is known to be a lie.
470struct FreeSpace {
471    checked_at: Option<Instant>,
472    bytes: Option<u64>,
473}
474
475/// How the store is sized, laid out, and how much writing it will do
476/// off the calling thread.
477#[derive(Clone)]
478pub struct DiskConfig {
479    /// Directory the store owns. Created if absent.
480    pub root: PathBuf,
481    /// Byte budget for blocks the store is accounting for. Eviction
482    /// keeps the store at or under this.
483    pub max_bytes: u64,
484    /// Hex characters of the hash used as the shard subdirectory name.
485    /// 2 gives 256 shards, which keeps directory sizes sane well past a
486    /// million blocks.
487    pub shard_chars: usize,
488    /// Writes that may be waiting for a writer thread at once. When it
489    /// is full, [`DiskKvStore::put`] writes on the calling thread
490    /// instead of dropping the block -- backpressure, not loss.
491    pub queue_capacity: usize,
492    /// Background writer threads. `0` is legitimate and means every
493    /// write happens on the thread that asked for it.
494    pub writer_threads: usize,
495    /// Background reader threads, serving prefetches and any demand
496    /// read that does not want to block its own thread. `0` means every
497    /// read happens on the thread that asked for it, and a prefetch is
498    /// a no-op.
499    pub reader_threads: usize,
500    /// Blocks that may sit in the prefetch staging area at once. A
501    /// prefetch is a hint, so this is a hard refusal rather than
502    /// backpressure: reading ahead must never be the thing that runs
503    /// the process out of memory.
504    pub prefetch_capacity: usize,
505    /// Bytes to leave free on the filesystem. The store evicts to stay
506    /// this far away from a full disk, rather than letting `ENOSPC` be
507    /// the mechanism that tells it to stop.
508    pub reserve_bytes: u64,
509    /// How long a free-space reading is trusted before it is taken
510    /// again.
511    pub free_space_ttl: std::time::Duration,
512    /// How free space is measured. Defaults to `statvfs` on unix, and
513    /// to "cannot tell" elsewhere.
514    pub free_space_probe: FreeSpaceProbe,
515}
516
517impl std::fmt::Debug for DiskConfig {
518    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
519        f.debug_struct("DiskConfig")
520            .field("root", &self.root)
521            .field("max_bytes", &self.max_bytes)
522            .field("shard_chars", &self.shard_chars)
523            .field("queue_capacity", &self.queue_capacity)
524            .field("writer_threads", &self.writer_threads)
525            .field("reader_threads", &self.reader_threads)
526            .field("prefetch_capacity", &self.prefetch_capacity)
527            .field("reserve_bytes", &self.reserve_bytes)
528            .field("free_space_ttl", &self.free_space_ttl)
529            .finish_non_exhaustive()
530    }
531}
532
533impl DiskConfig {
534    pub fn new(root: impl Into<PathBuf>) -> Self {
535        DiskConfig {
536            root: root.into(),
537            max_bytes: 1 << 30,
538            shard_chars: 2,
539            queue_capacity: 64,
540            writer_threads: 1,
541            reader_threads: 2,
542            prefetch_capacity: 64,
543            reserve_bytes: 1 << 30,
544            free_space_ttl: std::time::Duration::from_secs(2),
545            free_space_probe: Arc::new(platform_free_bytes),
546        }
547    }
548
549    pub fn with_max_bytes(mut self, max_bytes: u64) -> Self {
550        self.max_bytes = max_bytes;
551        self
552    }
553
554    pub fn with_shard_chars(mut self, shard_chars: usize) -> Self {
555        self.shard_chars = shard_chars.clamp(1, 8);
556        self
557    }
558
559    pub fn with_queue_capacity(mut self, queue_capacity: usize) -> Self {
560        self.queue_capacity = queue_capacity;
561        self
562    }
563
564    pub fn with_writer_threads(mut self, writer_threads: usize) -> Self {
565        self.writer_threads = writer_threads;
566        self
567    }
568
569    pub fn with_reader_threads(mut self, reader_threads: usize) -> Self {
570        self.reader_threads = reader_threads;
571        self
572    }
573
574    pub fn with_prefetch_capacity(mut self, prefetch_capacity: usize) -> Self {
575        self.prefetch_capacity = prefetch_capacity;
576        self
577    }
578
579    pub fn with_reserve_bytes(mut self, reserve_bytes: u64) -> Self {
580        self.reserve_bytes = reserve_bytes;
581        self
582    }
583
584    pub fn with_free_space_ttl(mut self, free_space_ttl: std::time::Duration) -> Self {
585        self.free_space_ttl = free_space_ttl;
586        self
587    }
588
589    pub fn with_free_space_probe(mut self, probe: FreeSpaceProbe) -> Self {
590        self.free_space_probe = probe;
591        self
592    }
593}
594
595#[derive(Default)]
596struct Stats {
597    writes: AtomicU64,
598    queued_writes: AtomicU64,
599    /// Writes that ran on the calling thread because the queue was
600    /// full. The plan's "count the fallbacks": a store that is
601    /// permanently inline is a store whose queue is too small or whose
602    /// disk is too slow, and that is invisible without this.
603    inline_writes: AtomicU64,
604    write_failures: AtomicU64,
605    /// Queued writes whose block was evicted (or superseded) before a
606    /// writer thread reached it.
607    write_skipped: AtomicU64,
608    write_nanos: AtomicU64,
609    /// Writes whose block was evicted while it was being written, so
610    /// the published file was withdrawn again.
611    write_raced_eviction: AtomicU64,
612    hits: AtomicU64,
613    /// Reads served from the write buffer, before the block reached
614    /// disk. These are what make the write path asynchronous *and*
615    /// immediately visible.
616    buffer_hits: AtomicU64,
617    misses: AtomicU64,
618    /// Files that failed [`decode_block`] and were quarantined.
619    corrupt: AtomicU64,
620    /// Blocks that decoded cleanly but do not match this reader's
621    /// signature expectation.
622    incompatible: AtomicU64,
623    read_nanos: AtomicU64,
624    evictions: AtomicU64,
625    evicted_bytes: AtomicU64,
626    /// Reads handed to a reader thread by [`DiskKvStore::prefetch`].
627    prefetch_issued: AtomicU64,
628    /// Prefetches refused: staging full, no reader threads, or the
629    /// block was already staged or in flight.
630    prefetch_dropped: AtomicU64,
631    /// Demand reads that found a prefetch already *finished*. This is
632    /// the one that says the read-ahead paid for itself: the request
633    /// did no I/O at all.
634    prefetch_hits: AtomicU64,
635    /// Demand reads that found a prefetch still running and waited for
636    /// it instead of issuing a second read of the same file.
637    prefetch_waits: AtomicU64,
638    /// Reads that ran on a reader thread rather than the caller's.
639    async_reads: AtomicU64,
640    /// Writes that failed because the filesystem was full. Non-zero
641    /// means the budget lost the race it exists to win.
642    enospc: AtomicU64,
643    /// Eviction passes whose ceiling came from free disk rather than
644    /// from `max_bytes`.
645    space_clamped: AtomicU64,
646}
647
648/// A snapshot of the tier's behaviour. Note the two time-valued fields:
649/// hit *rate* alone cannot tell an operator whether a disk hit was
650/// cheaper than recomputing the prefix, which is the only question that
651/// decides whether the tier is worth having.
652#[derive(Clone, Debug, Default, PartialEq, Eq)]
653pub struct DiskStats {
654    pub blocks: usize,
655    /// Everything the store has accepted, published or still buffered.
656    pub bytes: u64,
657    /// The subset that has actually reached the filesystem.
658    pub disk_bytes: u64,
659    pub queue_depth: usize,
660    pub writes: u64,
661    pub queued_writes: u64,
662    pub inline_writes: u64,
663    pub write_failures: u64,
664    pub write_skipped: u64,
665    pub write_raced_eviction: u64,
666    pub write_nanos: u64,
667    pub hits: u64,
668    pub buffer_hits: u64,
669    pub misses: u64,
670    pub corrupt: u64,
671    pub incompatible: u64,
672    pub read_nanos: u64,
673    pub evictions: u64,
674    pub evicted_bytes: u64,
675    pub prefetch_issued: u64,
676    pub prefetch_dropped: u64,
677    pub prefetch_hits: u64,
678    pub prefetch_waits: u64,
679    pub async_reads: u64,
680    pub staged_blocks: usize,
681    pub enospc: u64,
682    pub space_clamped: u64,
683    /// The ceiling eviction is currently working to: `max_bytes`, or
684    /// less when free disk says so.
685    pub effective_capacity: u64,
686}
687
688struct Entry {
689    bytes: u64,
690    last_used: u64,
691    /// False between admitting the block and its file landing under its
692    /// final name. While it is false the payload lives in the write
693    /// buffer, and a reader is served from there.
694    published: bool,
695    /// Bumped every time this hash is admitted, so a write that
696    /// finishes after its entry was evicted and re-created cannot mark
697    /// the *new* entry published, and a queued job whose block has been
698    /// superseded can tell.
699    generation: u64,
700}
701
702struct Index {
703    entries: HashMap<BlockHash, Entry>,
704    /// Everything the store has accepted, published or still buffered.
705    bytes: u64,
706    /// The subset that is actually occupying filesystem space. The
707    /// free-space budget reasons about this one: a block that has been
708    /// accepted but not written has not taken any disk yet, and
709    /// charging it twice (once here, once as space the device has
710    /// already lost) makes the ceiling oscillate.
711    disk_bytes: u64,
712    clock: u64,
713}
714
715impl Index {
716    fn touch(&mut self) -> u64 {
717        self.clock += 1;
718        self.clock
719    }
720
721    fn insert_entry(&mut self, hash: BlockHash, entry: Entry) {
722        let bytes = entry.bytes;
723        if let Some(previous) = self.entries.insert(hash, entry) {
724            self.uncharge(&previous);
725        }
726        self.bytes += bytes;
727    }
728
729    fn remove_entry(&mut self, hash: &BlockHash) -> Option<Entry> {
730        let entry = self.entries.remove(hash)?;
731        self.uncharge(&entry);
732        Some(entry)
733    }
734
735    fn uncharge(&mut self, entry: &Entry) {
736        self.bytes -= entry.bytes;
737        if entry.published {
738            self.disk_bytes -= entry.bytes;
739        }
740    }
741}
742
743/// Where a block's payload is, once the index has been consulted.
744enum Source {
745    Disk(PathBuf),
746    Buffer(Arc<KvBlock>),
747}
748
749/// A block that has been accepted but whose file is not on disk yet.
750struct Buffered {
751    generation: u64,
752    block: Arc<KvBlock>,
753}
754
755#[derive(Clone, Copy)]
756struct WriteJob {
757    hash: BlockHash,
758    generation: u64,
759}
760
761struct QueueState {
762    jobs: VecDeque<WriteJob>,
763    running: usize,
764    shutdown: bool,
765}
766
767/// A bounded queue of pending block writes.
768///
769/// Bounded, and **never lossy**: `try_push` refusing is the caller's
770/// signal to write the block itself, not to drop it. A dropped write is
771/// indistinguishable from a cache miss later, which is exactly the kind
772/// of silent degradation that makes a cache tier impossible to trust.
773struct WriteQueue {
774    state: Mutex<QueueState>,
775    ready: Condvar,
776    idle: Condvar,
777    capacity: usize,
778}
779
780impl WriteQueue {
781    fn new(capacity: usize) -> Self {
782        WriteQueue {
783            state: Mutex::new(QueueState {
784                jobs: VecDeque::new(),
785                running: 0,
786                shutdown: false,
787            }),
788            ready: Condvar::new(),
789            idle: Condvar::new(),
790            capacity: capacity.max(1),
791        }
792    }
793
794    fn lock(&self) -> std::sync::MutexGuard<'_, QueueState> {
795        self.state.lock().expect("kv disk write queue poisoned")
796    }
797
798    /// `false` means "full, or shutting down" -- write it yourself.
799    fn try_push(&self, job: WriteJob) -> bool {
800        let mut state = self.lock();
801        if state.shutdown || state.jobs.len() >= self.capacity {
802            return false;
803        }
804        state.jobs.push_back(job);
805        self.ready.notify_one();
806        true
807    }
808
809    fn pop_blocking(&self) -> Option<WriteJob> {
810        let mut state = self.lock();
811        loop {
812            if let Some(job) = state.jobs.pop_front() {
813                state.running += 1;
814                return Some(job);
815            }
816            if state.shutdown {
817                return None;
818            }
819            state = self
820                .ready
821                .wait(state)
822                .expect("kv disk write queue poisoned");
823        }
824    }
825
826    fn pop_now(&self) -> Option<WriteJob> {
827        let mut state = self.lock();
828        let job = state.jobs.pop_front()?;
829        state.running += 1;
830        Some(job)
831    }
832
833    fn finish(&self) {
834        let mut state = self.lock();
835        state.running -= 1;
836        self.idle.notify_all();
837    }
838
839    fn shutdown(&self) {
840        let mut state = self.lock();
841        state.shutdown = true;
842        self.ready.notify_all();
843    }
844
845    fn depth(&self) -> usize {
846        self.lock().jobs.len()
847    }
848}
849
850/// What a read produced. `Ok(None)` is a miss (absent, corrupt, or
851/// incompatible); `Err` is I/O, or the write-ordering invariant
852/// breaking.
853pub type ReadOutcome = Result<Option<Arc<KvBlock>>, StoreError>;
854
855/// A read in progress, or one that has finished and is waiting to be
856/// claimed. Shared between the thread that asked for it, the thread
857/// that runs it, and any later request for the same block.
858struct ReadSlot {
859    /// The signature this read was issued for. A staged result is only
860    /// reusable by a reader that wants the *same* shape -- otherwise a
861    /// prefetch issued under one config would hand its answer to a
862    /// request under another.
863    expected: CacheSignature,
864    outcome: Mutex<Option<ReadOutcome>>,
865    done: Condvar,
866}
867
868impl ReadSlot {
869    fn pending(expected: CacheSignature) -> Arc<Self> {
870        Arc::new(ReadSlot {
871            expected,
872            outcome: Mutex::new(None),
873            done: Condvar::new(),
874        })
875    }
876
877    fn ready(expected: CacheSignature, outcome: ReadOutcome) -> Arc<Self> {
878        Arc::new(ReadSlot {
879            expected,
880            outcome: Mutex::new(Some(outcome)),
881            done: Condvar::new(),
882        })
883    }
884
885    fn is_ready(&self) -> bool {
886        self.outcome
887            .lock()
888            .expect("kv disk read slot poisoned")
889            .is_some()
890    }
891
892    fn fulfil(&self, outcome: ReadOutcome) {
893        let mut slot = self.outcome.lock().expect("kv disk read slot poisoned");
894        *slot = Some(outcome);
895        self.done.notify_all();
896    }
897
898    fn wait(&self) -> ReadOutcome {
899        let mut slot = self.outcome.lock().expect("kv disk read slot poisoned");
900        loop {
901            if let Some(outcome) = slot.as_ref() {
902                return outcome.clone();
903            }
904            slot = self.done.wait(slot).expect("kv disk read slot poisoned");
905        }
906    }
907}
908
909struct ReadJob {
910    hash: BlockHash,
911    path: PathBuf,
912    slot: Arc<ReadSlot>,
913}
914
915struct ReadQueueState {
916    jobs: VecDeque<ReadJob>,
917    shutdown: bool,
918}
919
920/// Pending disk reads. Unlike the write queue this one *is* allowed to
921/// refuse work -- a prefetch is a hint, and a refused hint costs a
922/// later request one read. A refused *demand* read is not dropped
923/// either: it runs on the calling thread.
924struct ReadQueue {
925    state: Mutex<ReadQueueState>,
926    ready: Condvar,
927    capacity: usize,
928}
929
930impl ReadQueue {
931    fn new(capacity: usize) -> Self {
932        ReadQueue {
933            state: Mutex::new(ReadQueueState {
934                jobs: VecDeque::new(),
935                shutdown: false,
936            }),
937            ready: Condvar::new(),
938            capacity: capacity.max(1),
939        }
940    }
941
942    fn lock(&self) -> std::sync::MutexGuard<'_, ReadQueueState> {
943        self.state.lock().expect("kv disk read queue poisoned")
944    }
945
946    /// `demand` jumps the queue: a request that is waiting must not sit
947    /// behind speculative read-ahead.
948    fn try_push(&self, job: ReadJob, demand: bool) -> bool {
949        let mut state = self.lock();
950        if state.shutdown || state.jobs.len() >= self.capacity {
951            return false;
952        }
953        if demand {
954            state.jobs.push_front(job);
955        } else {
956            state.jobs.push_back(job);
957        }
958        self.ready.notify_one();
959        true
960    }
961
962    fn pop_blocking(&self) -> Option<ReadJob> {
963        let mut state = self.lock();
964        loop {
965            if let Some(job) = state.jobs.pop_front() {
966                return Some(job);
967            }
968            if state.shutdown {
969                return None;
970            }
971            state = self.ready.wait(state).expect("kv disk read queue poisoned");
972        }
973    }
974
975    fn shutdown(&self) {
976        let mut state = self.lock();
977        state.shutdown = true;
978        self.ready.notify_all();
979    }
980}
981
982/// A read that may not have finished yet.
983///
984/// The disk tier is asynchronous **by construction**, not as a later
985/// retrofit: [`DiskKvStore::get`] is this handle plus a `wait`, so
986/// there is no synchronous read path that a prefetch has to work
987/// around.
988pub struct ReadHandle {
989    shared: Arc<Shared>,
990    hash: BlockHash,
991    slot: Arc<ReadSlot>,
992    /// Whether this handle's slot is registered in the staging map and
993    /// should be removed once claimed.
994    staged: bool,
995}
996
997impl ReadHandle {
998    /// True if the block is already in hand -- a memory hit, a miss, or
999    /// a prefetch that has landed.
1000    pub fn is_ready(&self) -> bool {
1001        self.slot.is_ready()
1002    }
1003
1004    /// The result, without blocking. Returns `None` if the read is
1005    /// still running.
1006    pub fn try_claim(&self) -> Option<ReadOutcome> {
1007        if !self.slot.is_ready() {
1008            return None;
1009        }
1010        Some(self.claim())
1011    }
1012
1013    /// Blocks until the read finishes.
1014    pub fn wait(self) -> ReadOutcome {
1015        self.claim()
1016    }
1017
1018    fn claim(&self) -> ReadOutcome {
1019        let outcome = self.slot.wait();
1020        if self.staged {
1021            self.shared.unstage(&self.hash, &self.slot);
1022        }
1023        outcome
1024    }
1025}
1026
1027impl std::fmt::Debug for ReadHandle {
1028    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1029        f.debug_struct("ReadHandle")
1030            .field("hash", &self.hash)
1031            .field("ready", &self.is_ready())
1032            .finish()
1033    }
1034}
1035
1036#[cfg(test)]
1037type Hook = Arc<dyn Fn(&BlockHash) + Send + Sync>;
1038
1039/// Which order the write path uses. Production is
1040/// `BufferThenIndex`; the other two exist so a test can prove the
1041/// invariant test is not vacuous -- a concurrency test that passes on
1042/// broken code proves nothing.
1043#[cfg(test)]
1044#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1045enum WriteOrder {
1046    #[default]
1047    BufferThenIndex,
1048    /// Index the block before buffering it: a reader can then hit the
1049    /// index for a block with no file and no payload.
1050    IndexBeforeBuffer,
1051    /// Release the buffered payload before marking the file published:
1052    /// same hole, at the other end of the write.
1053    DropBufferBeforeMarking,
1054}
1055
1056#[cfg(test)]
1057#[derive(Default)]
1058struct Hooks {
1059    order: Mutex<WriteOrder>,
1060    /// Called after the rename and before the post-publish eviction
1061    /// re-check, so a test can evict the block in exactly the window
1062    /// the re-check exists to cover.
1063    after_rename: Mutex<Option<Hook>>,
1064    /// Called inside the window between the two steps of admission.
1065    in_put_window: Mutex<Option<Hook>>,
1066    /// Called inside the window between the two steps of publication.
1067    in_publish_window: Mutex<Option<Hook>>,
1068    /// Makes the next write fail as though the filesystem were full,
1069    /// which is the one failure that cannot be arranged for real
1070    /// without filling a real disk.
1071    fail_with_enospc: std::sync::atomic::AtomicBool,
1072}
1073
1074#[cfg(test)]
1075impl Hooks {
1076    fn fire(slot: &Mutex<Option<Hook>>, hash: &BlockHash) {
1077        // Cloned out before calling, so a hook that re-enters the store
1078        // cannot deadlock on the hook slot itself.
1079        let hook = slot.lock().expect("kv disk hook poisoned").clone();
1080        if let Some(hook) = hook {
1081            hook(hash);
1082        }
1083    }
1084}
1085
1086/// Everything the store's threads share. Deliberately holds no join
1087/// handles: the writer threads hold an `Arc<Shared>`, so a `Drop` here
1088/// that joined them could run *on* a writer thread and deadlock. The
1089/// handles live in [`DiskKvStore`], which is not `Clone`.
1090struct Shared {
1091    root: PathBuf,
1092    shard_chars: usize,
1093    max_bytes: u64,
1094    index: Mutex<Index>,
1095    /// Blocks accepted but not yet on disk.
1096    ///
1097    /// **Lock order: `index`, then `buffer`, never the reverse.** A
1098    /// reader decides "the index says this block exists" and "here is
1099    /// its payload" as one atomic act; otherwise the writer could mark
1100    /// a block published and drop its buffered copy in between, and the
1101    /// reader would find nothing.
1102    buffer: Mutex<HashMap<BlockHash, Buffered>>,
1103    queue: WriteQueue,
1104    reads: ReadQueue,
1105    /// Reads in flight or finished and unclaimed, keyed by block. One
1106    /// slot per block, so a demand read that arrives while a prefetch
1107    /// is running joins it instead of reading the same file twice.
1108    staging: Mutex<HashMap<BlockHash, Arc<ReadSlot>>>,
1109    prefetch_capacity: usize,
1110    has_readers: bool,
1111    reserve_bytes: u64,
1112    free_space_ttl: std::time::Duration,
1113    free_space_probe: FreeSpaceProbe,
1114    /// **Lock order: `index`, then `free_space`.** Eviction reads the
1115    /// budget while holding the index.
1116    free_space: Mutex<FreeSpace>,
1117    stats: Stats,
1118    seq: AtomicU64,
1119    generation: AtomicU64,
1120    #[cfg(test)]
1121    hooks: Hooks,
1122}
1123
1124/// A content-addressed block store on disk, with its own writer
1125/// threads.
1126///
1127/// Not `Clone` on purpose -- it owns the writer threads and joins them
1128/// when it drops. Share it as an `Arc<DiskKvStore>`; every method takes
1129/// `&self`.
1130pub struct DiskKvStore {
1131    shared: Arc<Shared>,
1132    writers: Vec<std::thread::JoinHandle<()>>,
1133    readers: Vec<std::thread::JoinHandle<()>>,
1134}
1135
1136impl Drop for DiskKvStore {
1137    /// Stops accepting queued work and joins the worker threads. Blocks
1138    /// already queued but not started are **not** written: they were
1139    /// never durable, and a shutdown that waits for an arbitrarily deep
1140    /// queue is worse than a cold cache. Call [`Self::flush`] first if
1141    /// they matter.
1142    fn drop(&mut self) {
1143        self.shared.queue.shutdown();
1144        self.shared.reads.shutdown();
1145        for writer in self.writers.drain(..) {
1146            let _ = writer.join();
1147        }
1148        for reader in self.readers.drain(..) {
1149            let _ = reader.join();
1150        }
1151    }
1152}
1153
1154impl DiskKvStore {
1155    /// Creates the store's directories and starts its writer threads.
1156    /// Does **not** scan `root` for pre-existing blocks: reattaching to
1157    /// a store left by a previous process is [`Self::reindex`], an
1158    /// explicit step, because it costs a directory walk and a caller
1159    /// may prefer to start cold.
1160    pub fn open(config: DiskConfig) -> Result<Self, StoreError> {
1161        let root = config.root.clone();
1162        fs::create_dir_all(&root).map_err(|e| io_err("create", &root, e))?;
1163        let tmp = root.join(TMP_DIR);
1164        fs::create_dir_all(&tmp).map_err(|e| io_err("create", &tmp, e))?;
1165        let shared = Arc::new(Shared {
1166            root,
1167            shard_chars: config.shard_chars.clamp(1, 8),
1168            max_bytes: config.max_bytes,
1169            index: Mutex::new(Index {
1170                entries: HashMap::new(),
1171                bytes: 0,
1172                disk_bytes: 0,
1173                clock: 0,
1174            }),
1175            buffer: Mutex::new(HashMap::new()),
1176            queue: WriteQueue::new(config.queue_capacity),
1177            reads: ReadQueue::new(config.queue_capacity),
1178            staging: Mutex::new(HashMap::new()),
1179            prefetch_capacity: config.prefetch_capacity,
1180            has_readers: config.reader_threads > 0,
1181            reserve_bytes: config.reserve_bytes,
1182            free_space_ttl: config.free_space_ttl,
1183            free_space_probe: Arc::clone(&config.free_space_probe),
1184            free_space: Mutex::new(FreeSpace {
1185                checked_at: None,
1186                bytes: None,
1187            }),
1188            stats: Stats::default(),
1189            seq: AtomicU64::new(0),
1190            generation: AtomicU64::new(0),
1191            #[cfg(test)]
1192            hooks: Hooks::default(),
1193        });
1194        let mut writers = Vec::with_capacity(config.writer_threads);
1195        for n in 0..config.writer_threads {
1196            let shared = Arc::clone(&shared);
1197            let handle = std::thread::Builder::new()
1198                .name(format!("ferrox-kv-write-{n}"))
1199                .spawn(move || {
1200                    while let Some(job) = shared.queue.pop_blocking() {
1201                        shared.run_job(job);
1202                        shared.queue.finish();
1203                    }
1204                })
1205                .map_err(|e| io_err("spawn writer for", &config.root, e))?;
1206            writers.push(handle);
1207        }
1208        let mut readers = Vec::with_capacity(config.reader_threads);
1209        for n in 0..config.reader_threads {
1210            let shared = Arc::clone(&shared);
1211            let handle = std::thread::Builder::new()
1212                .name(format!("ferrox-kv-read-{n}"))
1213                .spawn(move || {
1214                    while let Some(job) = shared.reads.pop_blocking() {
1215                        shared.stats.async_reads.fetch_add(1, Ordering::Relaxed);
1216                        let outcome = shared.read_timed(&job.path, &job.hash, &job.slot.expected);
1217                        job.slot.fulfil(outcome);
1218                    }
1219                })
1220                .map_err(|e| io_err("spawn reader for", &config.root, e))?;
1221            readers.push(handle);
1222        }
1223        Ok(DiskKvStore {
1224            shared,
1225            writers,
1226            readers,
1227        })
1228    }
1229
1230    pub fn root(&self) -> &Path {
1231        &self.shared.root
1232    }
1233
1234    /// Accepts a block: buffered, indexed, then queued. Returns as soon
1235    /// as the block is *visible* -- a reader can have it immediately,
1236    /// whether or not it has reached disk.
1237    ///
1238    /// If the write queue is full the block is written on this thread
1239    /// rather than dropped, and the fallback is counted.
1240    pub fn put(&self, hash: BlockHash, block: KvBlock) -> Result<(), StoreError> {
1241        self.shared.put(hash, block, false)
1242    }
1243
1244    /// Like [`Self::put`], but always writes on the calling thread.
1245    pub fn put_blocking(&self, hash: BlockHash, block: KvBlock) -> Result<(), StoreError> {
1246        self.shared.put(hash, block, true)
1247    }
1248
1249    /// Runs every pending write to completion, on this thread if no
1250    /// writer thread gets there first. Returns once the queue is empty
1251    /// and nothing is in flight.
1252    pub fn flush(&self) {
1253        loop {
1254            if let Some(job) = self.shared.queue.pop_now() {
1255                self.shared.run_job(job);
1256                self.shared.queue.finish();
1257                continue;
1258            }
1259            let state = self.shared.queue.lock();
1260            if state.jobs.is_empty() && state.running == 0 {
1261                return;
1262            }
1263            // Timed, so a store with no writer threads and a job pushed
1264            // by another thread cannot park here forever.
1265            let _ = self
1266                .shared
1267                .queue
1268                .idle
1269                .wait_timeout(state, std::time::Duration::from_millis(1));
1270        }
1271    }
1272
1273    /// Looks a block up, verifying it against `expected` before
1274    /// returning it. Blocks until the answer is in hand.
1275    ///
1276    /// This is exactly [`Self::read_async`] plus a wait -- the disk
1277    /// tier has no separate synchronous read path for a prefetch to
1278    /// have to work around later.
1279    pub fn get(&self, hash: &BlockHash, expected: &CacheSignature) -> ReadOutcome {
1280        self.shared.read_async(hash, expected, true).wait()
1281    }
1282
1283    /// Starts a read and returns immediately. The handle can be polled
1284    /// with [`ReadHandle::try_claim`] or waited on.
1285    pub fn read_async(&self, hash: &BlockHash, expected: &CacheSignature) -> ReadHandle {
1286        self.shared.read_async(hash, expected, true)
1287    }
1288
1289    /// Reads `hashes` ahead of anyone asking for them, on the reader
1290    /// threads, and returns without waiting.
1291    ///
1292    /// Intended for a prefix chain the moment its hashes are known:
1293    /// every block the request is about to want, read while the tokens
1294    /// before it are still being processed. Blocks already staged, in
1295    /// flight, or in memory are skipped; so is everything past
1296    /// `prefetch_capacity`, because reading ahead must never be the
1297    /// thing that exhausts memory.
1298    pub fn prefetch(&self, hashes: &[BlockHash], expected: &CacheSignature) {
1299        self.shared.prefetch(hashes, expected);
1300    }
1301
1302    /// Drops any staged read-ahead results. A caller that abandons a
1303    /// request it prefetched for should say so rather than leave the
1304    /// blocks occupying staging until something else needs the room.
1305    pub fn clear_prefetch(&self) {
1306        self.shared
1307            .staging
1308            .lock()
1309            .expect("kv disk staging poisoned")
1310            .clear();
1311    }
1312
1313    /// Adopts the blocks already under `root`, as a restart would.
1314    pub fn reindex(&self) -> Result<usize, StoreError> {
1315        self.shared.reindex()
1316    }
1317
1318    /// Removes a block from the index, the write buffer, and the disk.
1319    pub fn remove(&self, hash: &BlockHash) {
1320        self.shared.quarantine(hash);
1321    }
1322
1323    /// True if the index holds an entry for `hash` -- on disk or still
1324    /// buffered. Says nothing about whether its contents will verify.
1325    pub fn contains(&self, hash: &BlockHash) -> bool {
1326        let index = self.shared.index.lock().expect("kv disk index poisoned");
1327        index.entries.contains_key(hash)
1328    }
1329
1330    /// Byte ceiling an operator configured.
1331    pub fn capacity(&self) -> u64 {
1332        self.shared.max_bytes
1333    }
1334
1335    /// The ceiling eviction is actually working to right now: the
1336    /// configured one, or less when free disk says so.
1337    pub fn effective_capacity(&self) -> u64 {
1338        let used = {
1339            let index = self.shared.index.lock().expect("kv disk index poisoned");
1340            index.bytes
1341        };
1342        self.shared.effective_capacity(used)
1343    }
1344
1345    /// Where a block's file lives (or would). Useful to an operator
1346    /// tracing one block; the file may not exist yet, or at all.
1347    pub fn block_path(&self, hash: &BlockHash) -> PathBuf {
1348        self.shared.block_path(hash)
1349    }
1350
1351    pub fn stats(&self) -> DiskStats {
1352        self.shared.stats()
1353    }
1354}
1355
1356impl Shared {
1357    fn next_generation(&self) -> u64 {
1358        self.generation.fetch_add(1, Ordering::SeqCst) + 1
1359    }
1360
1361    /// The write path, in the order the plan requires:
1362    ///
1363    /// 1. **buffer** -- the payload is reachable before anything claims
1364    ///    it exists;
1365    /// 2. **index** -- now it is claimed to exist, and is subject to
1366    ///    eviction and budget accounting;
1367    /// 3. **queue** -- only now does the write get scheduled.
1368    ///
1369    /// Reversing 1 and 2 lets a reader hit the index for a block with
1370    /// no file and no payload. Reversing 2 and 3 would be harmless but
1371    /// pointless: a queued write whose block is not indexed cannot be
1372    /// evicted or accounted for.
1373    fn put(&self, hash: BlockHash, block: KvBlock, inline: bool) -> Result<(), StoreError> {
1374        let bytes = encoded_len(block.signature());
1375        let block = Arc::new(block);
1376        let generation = self.next_generation();
1377
1378        #[cfg(test)]
1379        let index_first = *self.hooks.order.lock().expect("kv disk hook poisoned")
1380            == WriteOrder::IndexBeforeBuffer;
1381        #[cfg(not(test))]
1382        let index_first = false;
1383
1384        if index_first {
1385            self.reserve(hash, bytes, generation);
1386            #[cfg(test)]
1387            Hooks::fire(&self.hooks.in_put_window, &hash);
1388            self.buffer_block(hash, generation, Arc::clone(&block));
1389        } else {
1390            self.buffer_block(hash, generation, Arc::clone(&block));
1391            #[cfg(test)]
1392            Hooks::fire(&self.hooks.in_put_window, &hash);
1393            self.reserve(hash, bytes, generation);
1394        }
1395
1396        let job = WriteJob { hash, generation };
1397        if !inline && self.queue.try_push(job) {
1398            self.stats.queued_writes.fetch_add(1, Ordering::Relaxed);
1399            return Ok(());
1400        }
1401        if !inline {
1402            self.stats.inline_writes.fetch_add(1, Ordering::Relaxed);
1403        }
1404        self.run_write(job, block)
1405    }
1406
1407    fn buffer_block(&self, hash: BlockHash, generation: u64, block: Arc<KvBlock>) {
1408        self.buffer
1409            .lock()
1410            .expect("kv disk buffer poisoned")
1411            .insert(hash, Buffered { generation, block });
1412    }
1413
1414    /// Drops a buffered payload, but only if it is still the one this
1415    /// generation put there -- a newer `put` for the same hash owns the
1416    /// slot now.
1417    fn release_buffer(&self, hash: &BlockHash, generation: u64) {
1418        let mut buffer = self.buffer.lock().expect("kv disk buffer poisoned");
1419        if buffer.get(hash).is_some_and(|b| b.generation == generation) {
1420            buffer.remove(hash);
1421        }
1422    }
1423
1424    fn buffered(&self, hash: &BlockHash, generation: u64) -> Option<Arc<KvBlock>> {
1425        let buffer = self.buffer.lock().expect("kv disk buffer poisoned");
1426        buffer
1427            .get(hash)
1428            .filter(|b| b.generation == generation)
1429            .map(|b| Arc::clone(&b.block))
1430    }
1431
1432    /// Runs a queued write. A block whose buffered payload is gone was
1433    /// evicted or superseded while it waited, and is skipped rather
1434    /// than resurrected.
1435    fn run_job(&self, job: WriteJob) {
1436        match self.buffered(&job.hash, job.generation) {
1437            Some(block) => {
1438                let _ = self.run_write(job, block);
1439            }
1440            None => {
1441                self.stats.write_skipped.fetch_add(1, Ordering::Relaxed);
1442            }
1443        }
1444    }
1445
1446    fn run_write(&self, job: WriteJob, block: Arc<KvBlock>) -> Result<(), StoreError> {
1447        let started = Instant::now();
1448        let result = self.write_and_publish(&job.hash, &block, job.generation);
1449        self.stats
1450            .write_nanos
1451            .fetch_add(started.elapsed().as_nanos() as u64, Ordering::Relaxed);
1452        match result {
1453            Ok(()) => {
1454                self.stats.writes.fetch_add(1, Ordering::Relaxed);
1455                Ok(())
1456            }
1457            Err(err) => {
1458                self.stats.write_failures.fetch_add(1, Ordering::Relaxed);
1459                self.abandon(&job.hash, job.generation);
1460                Err(err)
1461            }
1462        }
1463    }
1464
1465    /// Reserves (or re-reserves) an index entry, charges its bytes, and
1466    /// evicts whatever that pushes over budget.
1467    fn reserve(&self, hash: BlockHash, bytes: u64, generation: u64) {
1468        let mut index = self.index.lock().expect("kv disk index poisoned");
1469        let last_used = index.touch();
1470        index.insert_entry(
1471            hash,
1472            Entry {
1473                bytes,
1474                last_used,
1475                published: false,
1476                generation,
1477            },
1478        );
1479        let victims = self.collect_victims(&mut index, Some(&hash));
1480        drop(index);
1481        self.discard(victims);
1482    }
1483
1484    /// Drops an entry that will never be published (a failed write).
1485    /// Index first, then the buffer: an entry that is gone from the
1486    /// index is unreachable, so no reader can be looking for the
1487    /// payload we are about to free.
1488    fn abandon(&self, hash: &BlockHash, generation: u64) {
1489        {
1490            let mut index = self.index.lock().expect("kv disk index poisoned");
1491            if index
1492                .entries
1493                .get(hash)
1494                .is_some_and(|e| e.generation == generation)
1495            {
1496                index.remove_entry(hash);
1497            }
1498        }
1499        self.release_buffer(hash, generation);
1500    }
1501
1502    fn write_and_publish(
1503        &self,
1504        hash: &BlockHash,
1505        block: &KvBlock,
1506        generation: u64,
1507    ) -> Result<(), StoreError> {
1508        let bytes = encode_block(hash, block);
1509        let final_path = self.block_path(hash);
1510        let shard = final_path.parent().expect("block path has a parent");
1511        fs::create_dir_all(shard).map_err(|e| io_err("create", shard, e))?;
1512        let tmp_path = self.tmp_path(hash);
1513        {
1514            let mut file =
1515                fs::File::create(&tmp_path).map_err(|e| io_err("create", &tmp_path, e))?;
1516            #[cfg(test)]
1517            let written = if self.hooks.fail_with_enospc.load(Ordering::Relaxed) {
1518                Err(io::Error::from(io::ErrorKind::StorageFull))
1519            } else {
1520                file.write_all(&bytes)
1521            };
1522            #[cfg(not(test))]
1523            let written = file.write_all(&bytes);
1524            if let Err(e) = written {
1525                let _ = fs::remove_file(&tmp_path);
1526                self.note_if_enospc(&e);
1527                return Err(io_err("write", &tmp_path, e));
1528            }
1529            // Without this the rename can be durable while the contents
1530            // are not, which is exactly how a zero-length "published"
1531            // block file appears after a power loss.
1532            if let Err(e) = file.sync_all() {
1533                let _ = fs::remove_file(&tmp_path);
1534                self.note_if_enospc(&e);
1535                return Err(io_err("sync", &tmp_path, e));
1536            }
1537        }
1538        fs::rename(&tmp_path, &final_path).map_err(|e| {
1539            let _ = fs::remove_file(&tmp_path);
1540            io_err("publish", &final_path, e)
1541        })?;
1542
1543        #[cfg(test)]
1544        Hooks::fire(&self.hooks.after_rename, hash);
1545
1546        #[cfg(test)]
1547        let drop_buffer_first = *self.hooks.order.lock().expect("kv disk hook poisoned")
1548            == WriteOrder::DropBufferBeforeMarking;
1549        #[cfg(not(test))]
1550        let drop_buffer_first = false;
1551
1552        // Mark published, *then* release the buffered payload. In
1553        // between, a reader either sees "published" and reads the file
1554        // (which exists) or sees "buffered" and reads the buffer (which
1555        // still holds it). Releasing first opens a window where neither
1556        // is true.
1557        let survived = if drop_buffer_first {
1558            self.release_buffer(hash, generation);
1559            #[cfg(test)]
1560            Hooks::fire(&self.hooks.in_publish_window, hash);
1561            self.mark_published(hash, generation)
1562        } else {
1563            let survived = self.mark_published(hash, generation);
1564            #[cfg(test)]
1565            Hooks::fire(&self.hooks.in_publish_window, hash);
1566            self.release_buffer(hash, generation);
1567            survived
1568        };
1569
1570        if !survived {
1571            // Evicted (or superseded) mid-write. The eviction already
1572            // released this entry's bytes and could not delete a file
1573            // that did not exist yet, so the file is ours to withdraw.
1574            let _ = fs::remove_file(&final_path);
1575            self.stats
1576                .write_raced_eviction
1577                .fetch_add(1, Ordering::Relaxed);
1578        }
1579        Ok(())
1580    }
1581
1582    fn mark_published(&self, hash: &BlockHash, generation: u64) -> bool {
1583        let mut index = self.index.lock().expect("kv disk index poisoned");
1584        match index.entries.get_mut(hash) {
1585            Some(entry) if entry.generation == generation => {
1586                if !entry.published {
1587                    entry.published = true;
1588                    let bytes = entry.bytes;
1589                    index.disk_bytes += bytes;
1590                }
1591                true
1592            }
1593            _ => false,
1594        }
1595    }
1596
1597    /// Where a block's payload currently is, decided under the index
1598    /// lock. `Ok(None)` is an outright miss.
1599    fn source(&self, hash: &BlockHash) -> Result<Option<Source>, StoreError> {
1600        let mut index = self.index.lock().expect("kv disk index poisoned");
1601        let clock = index.clock + 1;
1602        let Some(entry) = index.entries.get_mut(hash) else {
1603            return Ok(None);
1604        };
1605        entry.last_used = clock;
1606        let published = entry.published;
1607        index.clock = clock;
1608        if published {
1609            return Ok(Some(Source::Disk(self.block_path(hash))));
1610        }
1611        // The index lock is deliberately still held: "not published"
1612        // and "here is the buffered payload" must be one decision. Two
1613        // decisions leave a gap for the writer to publish and release
1614        // in between.
1615        let buffered = self
1616            .buffer
1617            .lock()
1618            .expect("kv disk buffer poisoned")
1619            .get(hash)
1620            .map(|b| Arc::clone(&b.block));
1621        match buffered {
1622            Some(block) => Ok(Some(Source::Buffer(block))),
1623            None => Err(StoreError::MissingPayload { hash: *hash }),
1624        }
1625    }
1626
1627    /// Begins a read.
1628    ///
1629    /// `Ok(None)` in the eventual outcome covers every "you will have
1630    /// to recompute this" case -- absent, corrupt on disk, or built for
1631    /// a different config -- because they are the same answer to the
1632    /// caller. The counters in [`DiskStats`] tell them apart. `Err` is
1633    /// I/O that failed, or the write-ordering invariant breaking.
1634    ///
1635    /// Anything answerable from memory (a miss, or a block still in the
1636    /// write buffer) is answered here and comes back already ready; no
1637    /// thread is involved. Only a real file read is dispatched, and a
1638    /// `demand` read jumps ahead of queued prefetches -- or, if the
1639    /// queue is full, runs on this thread rather than queueing behind
1640    /// speculative work.
1641    fn read_async(
1642        self: &Arc<Self>,
1643        hash: &BlockHash,
1644        expected: &CacheSignature,
1645        demand: bool,
1646    ) -> ReadHandle {
1647        // An in-flight or finished read for the same block and the same
1648        // expectation is the answer -- never a second read of the same
1649        // file.
1650        let staged = {
1651            let staging = self.staging.lock().expect("kv disk staging poisoned");
1652            staging
1653                .get(hash)
1654                .filter(|slot| &slot.expected == expected)
1655                .map(Arc::clone)
1656        };
1657        if let Some(slot) = staged {
1658            if demand {
1659                let counter = if slot.is_ready() {
1660                    &self.stats.prefetch_hits
1661                } else {
1662                    &self.stats.prefetch_waits
1663                };
1664                counter.fetch_add(1, Ordering::Relaxed);
1665            }
1666            return self.handle(*hash, slot, true);
1667        }
1668
1669        let ready = |outcome: ReadOutcome| ReadHandle {
1670            shared: Arc::clone(self),
1671            hash: *hash,
1672            slot: ReadSlot::ready(expected.clone(), outcome),
1673            staged: false,
1674        };
1675
1676        let path = match self.source(hash) {
1677            Err(err) => return ready(Err(err)),
1678            Ok(None) => {
1679                self.stats.misses.fetch_add(1, Ordering::Relaxed);
1680                return ready(Ok(None));
1681            }
1682            Ok(Some(Source::Buffer(block))) => {
1683                if block.signature() != expected {
1684                    self.stats.incompatible.fetch_add(1, Ordering::Relaxed);
1685                    return ready(Ok(None));
1686                }
1687                self.stats.buffer_hits.fetch_add(1, Ordering::Relaxed);
1688                return ready(Ok(Some(block)));
1689            }
1690            Ok(Some(Source::Disk(path))) => path,
1691        };
1692
1693        let slot = ReadSlot::pending(expected.clone());
1694        let dispatched = self.has_readers && {
1695            self.staging
1696                .lock()
1697                .expect("kv disk staging poisoned")
1698                .insert(*hash, Arc::clone(&slot));
1699            let job = ReadJob {
1700                hash: *hash,
1701                path: path.clone(),
1702                slot: Arc::clone(&slot),
1703            };
1704            let pushed = self.reads.try_push(job, demand);
1705            if !pushed {
1706                self.unstage(hash, &slot);
1707            }
1708            pushed
1709        };
1710        if dispatched {
1711            return self.handle(*hash, slot, true);
1712        }
1713        slot.fulfil(self.read_timed(&path, hash, expected));
1714        self.handle(*hash, slot, false)
1715    }
1716
1717    fn handle(self: &Arc<Self>, hash: BlockHash, slot: Arc<ReadSlot>, staged: bool) -> ReadHandle {
1718        ReadHandle {
1719            shared: Arc::clone(self),
1720            hash,
1721            slot,
1722            staged,
1723        }
1724    }
1725
1726    /// Removes a staging entry, but only if it is still the slot the
1727    /// claimant was holding -- a newer read for the same block owns the
1728    /// entry now.
1729    fn unstage(&self, hash: &BlockHash, slot: &Arc<ReadSlot>) {
1730        let mut staging = self.staging.lock().expect("kv disk staging poisoned");
1731        if staging.get(hash).is_some_and(|s| Arc::ptr_eq(s, slot)) {
1732            staging.remove(hash);
1733        }
1734    }
1735
1736    fn prefetch(self: &Arc<Self>, hashes: &[BlockHash], expected: &CacheSignature) {
1737        if !self.has_readers {
1738            self.stats
1739                .prefetch_dropped
1740                .fetch_add(hashes.len() as u64, Ordering::Relaxed);
1741            return;
1742        }
1743        for hash in hashes {
1744            let room = {
1745                let staging = self.staging.lock().expect("kv disk staging poisoned");
1746                !staging.contains_key(hash) && staging.len() < self.prefetch_capacity
1747            };
1748            if !room {
1749                self.stats.prefetch_dropped.fetch_add(1, Ordering::Relaxed);
1750                continue;
1751            }
1752            let handle = self.read_async(hash, expected, false);
1753            if handle.staged {
1754                self.stats.prefetch_issued.fetch_add(1, Ordering::Relaxed);
1755            } else {
1756                // Answered from memory, or the read queue was full and
1757                // it ran here. Either way there is nothing staged to
1758                // claim later, so drop the answer: a prefetch is a
1759                // hint, and re-reading is cheaper than holding blocks
1760                // nobody asked for.
1761                self.stats.prefetch_dropped.fetch_add(1, Ordering::Relaxed);
1762            }
1763            // Dropped unclaimed on purpose: a staged slot stays in
1764            // staging for whoever asks for the block next, which is the
1765            // entire point of reading it early.
1766            drop(handle);
1767        }
1768    }
1769
1770    /// The ceiling eviction works to, given how many bytes the store
1771    /// currently holds.
1772    ///
1773    /// `max_bytes` is what an operator asked for; free disk is what the
1774    /// machine can actually give. The store may occupy what it already
1775    /// occupies plus whatever the filesystem still has, less a reserve:
1776    ///
1777    /// ```text
1778    /// effective = min(max_bytes, used + free - reserve)
1779    /// ```
1780    ///
1781    /// As free space falls below the reserve the ceiling drops below
1782    /// `used`, so the next write evicts instead of pushing the
1783    /// filesystem to `ENOSPC`. That is the whole point: the cache
1784    /// should give the disk back before the disk takes it back.
1785    fn effective_capacity(&self, used: u64) -> u64 {
1786        let Some(free) = self.free_bytes() else {
1787            return self.max_bytes;
1788        };
1789        // `free - reserve` is deliberately signed. Clamping it at zero
1790        // would make the ceiling equal `used` exactly when the disk is
1791        // fullest -- the store would sit still and let the filesystem
1792        // do the refusing, which is the failure this whole budget
1793        // exists to avoid.
1794        let headroom = free as i128 - self.reserve_bytes as i128;
1795        let allowed = (used as i128 + headroom).max(0) as u64;
1796        self.max_bytes.min(allowed)
1797    }
1798
1799    /// Free space, re-measured at most once per TTL. A `statvfs` per
1800    /// block write would be a syscall on the write path for a number
1801    /// that moves slowly.
1802    fn free_bytes(&self) -> Option<u64> {
1803        let mut cache = self.free_space.lock().expect("kv disk free space poisoned");
1804        if let Some(checked_at) = cache.checked_at {
1805            if checked_at.elapsed() < self.free_space_ttl {
1806                return cache.bytes;
1807            }
1808        }
1809        let bytes = (self.free_space_probe)(&self.root);
1810        cache.checked_at = Some(Instant::now());
1811        cache.bytes = bytes;
1812        bytes
1813    }
1814
1815    /// The filesystem said "full". Whatever the cached free-space
1816    /// reading says, it is wrong *now*, so it is thrown away rather
1817    /// than left to expire -- and an eviction pass runs immediately, so
1818    /// the next write has somewhere to go.
1819    fn note_enospc(&self) {
1820        self.stats.enospc.fetch_add(1, Ordering::Relaxed);
1821        {
1822            let mut cache = self.free_space.lock().expect("kv disk free space poisoned");
1823            cache.checked_at = None;
1824            cache.bytes = None;
1825        }
1826        let victims = {
1827            let mut index = self.index.lock().expect("kv disk index poisoned");
1828            self.collect_victims(&mut index, None)
1829        };
1830        self.discard(victims);
1831    }
1832
1833    /// Recognises the one I/O error the budget exists to prevent.
1834    fn note_if_enospc(&self, err: &io::Error) {
1835        if err.kind() == io::ErrorKind::StorageFull {
1836            self.note_enospc();
1837        }
1838    }
1839
1840    fn read_timed(&self, path: &Path, hash: &BlockHash, expected: &CacheSignature) -> ReadOutcome {
1841        let started = Instant::now();
1842        let outcome = self.read_verified(path, hash, expected);
1843        self.stats
1844            .read_nanos
1845            .fetch_add(started.elapsed().as_nanos() as u64, Ordering::Relaxed);
1846        outcome
1847    }
1848
1849    fn read_verified(
1850        &self,
1851        path: &Path,
1852        hash: &BlockHash,
1853        expected: &CacheSignature,
1854    ) -> Result<Option<Arc<KvBlock>>, StoreError> {
1855        let bytes = match fs::read(path) {
1856            Ok(bytes) => bytes,
1857            Err(e) if e.kind() == io::ErrorKind::NotFound => {
1858                // The file vanished under us -- an eviction between the
1859                // index lookup and the open, or an external cleaner.
1860                self.stats.misses.fetch_add(1, Ordering::Relaxed);
1861                self.drop_entry(hash);
1862                return Ok(None);
1863            }
1864            Err(e) => return Err(io_err("read", path, e)),
1865        };
1866        let decoded = match decode_block(&bytes) {
1867            Ok(decoded) => decoded,
1868            Err(_) => {
1869                self.stats.corrupt.fetch_add(1, Ordering::Relaxed);
1870                self.quarantine(hash);
1871                return Ok(None);
1872            }
1873        };
1874        if &decoded.hash != hash {
1875            // The file under this name is some other block. Treat it
1876            // exactly like corruption: the name is the identity.
1877            self.stats.corrupt.fetch_add(1, Ordering::Relaxed);
1878            self.quarantine(hash);
1879            return Ok(None);
1880        }
1881        match decoded.block.verify(expected) {
1882            Ok(block) => {
1883                self.stats.hits.fetch_add(1, Ordering::Relaxed);
1884                Ok(Some(Arc::new(block)))
1885            }
1886            Err(_) => {
1887                self.stats.incompatible.fetch_add(1, Ordering::Relaxed);
1888                Ok(None)
1889            }
1890        }
1891    }
1892
1893    fn quarantine(&self, hash: &BlockHash) {
1894        self.drop_entry(hash);
1895        self.buffer
1896            .lock()
1897            .expect("kv disk buffer poisoned")
1898            .remove(hash);
1899        let _ = fs::remove_file(self.block_path(hash));
1900    }
1901
1902    fn drop_entry(&self, hash: &BlockHash) {
1903        let mut index = self.index.lock().expect("kv disk index poisoned");
1904        index.remove_entry(hash);
1905    }
1906
1907    fn reindex(&self) -> Result<usize, StoreError> {
1908        let tmp = self.root.join(TMP_DIR);
1909        if let Ok(entries) = fs::read_dir(&tmp) {
1910            for entry in entries.flatten() {
1911                let _ = fs::remove_file(entry.path());
1912            }
1913        }
1914        let mut found = Vec::new();
1915        let shards = fs::read_dir(&self.root).map_err(|e| io_err("read", &self.root, e))?;
1916        for shard in shards.flatten() {
1917            if !shard.file_type().map(|t| t.is_dir()).unwrap_or(false) {
1918                continue;
1919            }
1920            if shard.file_name() == TMP_DIR {
1921                continue;
1922            }
1923            let Ok(files) = fs::read_dir(shard.path()) else {
1924                continue;
1925            };
1926            for file in files.flatten() {
1927                let path = file.path();
1928                if path.extension().and_then(|e| e.to_str()) != Some(BLOCK_FILE_EXT) {
1929                    continue;
1930                }
1931                let Some(hash) = path
1932                    .file_stem()
1933                    .and_then(|s| s.to_str())
1934                    .and_then(parse_hex_hash)
1935                else {
1936                    continue;
1937                };
1938                let Ok(meta) = file.metadata() else { continue };
1939                found.push((hash, meta.len()));
1940            }
1941        }
1942        let mut adopted = 0;
1943        let mut index = self.index.lock().expect("kv disk index poisoned");
1944        for (hash, bytes) in found {
1945            if index.entries.contains_key(&hash) {
1946                continue;
1947            }
1948            let last_used = index.touch();
1949            index.insert_entry(
1950                hash,
1951                Entry {
1952                    bytes,
1953                    last_used,
1954                    published: true,
1955                    generation: self.next_generation(),
1956                },
1957            );
1958            index.disk_bytes += bytes;
1959            adopted += 1;
1960        }
1961        let victims = self.collect_victims(&mut index, None);
1962        drop(index);
1963        self.discard(victims);
1964        Ok(adopted)
1965    }
1966
1967    /// Picks least-recently-used entries until the store fits its
1968    /// budget, removing them from the index and returning them for
1969    /// disposal. Index first, payload second -- an entry that is gone
1970    /// from the index is unreachable, whereas a file deleted while the
1971    /// index still points at it would be a hit that fails to open.
1972    fn collect_victims(&self, index: &mut Index, protect: Option<&BlockHash>) -> Vec<Victim> {
1973        // Computed once per pass, against the pre-eviction size. It has
1974        // to be: the ceiling is a function of how much the store
1975        // already occupies, so re-evaluating it as blocks leave would
1976        // make eviction chase its own tail down to empty.
1977        let budget = self.effective_capacity(index.disk_bytes);
1978        if budget < self.max_bytes {
1979            self.stats.space_clamped.fetch_add(1, Ordering::Relaxed);
1980        }
1981        if index.bytes <= budget {
1982            return Vec::new();
1983        }
1984        let mut candidates: Vec<(u64, BlockHash)> = index
1985            .entries
1986            .iter()
1987            .filter(|(hash, _)| Some(*hash) != protect)
1988            .map(|(hash, entry)| (entry.last_used, *hash))
1989            .collect();
1990        candidates.sort_unstable();
1991        let mut victims = Vec::new();
1992        for (_, hash) in candidates {
1993            if index.bytes <= budget {
1994                break;
1995            }
1996            if let Some(entry) = index.remove_entry(&hash) {
1997                self.stats.evictions.fetch_add(1, Ordering::Relaxed);
1998                self.stats
1999                    .evicted_bytes
2000                    .fetch_add(entry.bytes, Ordering::Relaxed);
2001                victims.push(Victim {
2002                    hash,
2003                    published: entry.published,
2004                });
2005            }
2006        }
2007        victims
2008    }
2009
2010    /// Frees what eviction removed from the index: the buffered payload
2011    /// and, if it got that far, the file.
2012    fn discard(&self, victims: Vec<Victim>) {
2013        if victims.is_empty() {
2014            return;
2015        }
2016        {
2017            let mut buffer = self.buffer.lock().expect("kv disk buffer poisoned");
2018            for victim in &victims {
2019                buffer.remove(&victim.hash);
2020            }
2021        }
2022        for victim in victims {
2023            if victim.published {
2024                let _ = fs::remove_file(self.block_path(&victim.hash));
2025            }
2026        }
2027    }
2028
2029    fn stats(&self) -> DiskStats {
2030        let index = self.index.lock().expect("kv disk index poisoned");
2031        let stats = &self.stats;
2032        DiskStats {
2033            blocks: index.entries.len(),
2034            bytes: index.bytes,
2035            queue_depth: self.queue.depth(),
2036            writes: stats.writes.load(Ordering::Relaxed),
2037            queued_writes: stats.queued_writes.load(Ordering::Relaxed),
2038            inline_writes: stats.inline_writes.load(Ordering::Relaxed),
2039            write_failures: stats.write_failures.load(Ordering::Relaxed),
2040            write_skipped: stats.write_skipped.load(Ordering::Relaxed),
2041            write_raced_eviction: stats.write_raced_eviction.load(Ordering::Relaxed),
2042            write_nanos: stats.write_nanos.load(Ordering::Relaxed),
2043            hits: stats.hits.load(Ordering::Relaxed),
2044            buffer_hits: stats.buffer_hits.load(Ordering::Relaxed),
2045            misses: stats.misses.load(Ordering::Relaxed),
2046            corrupt: stats.corrupt.load(Ordering::Relaxed),
2047            incompatible: stats.incompatible.load(Ordering::Relaxed),
2048            read_nanos: stats.read_nanos.load(Ordering::Relaxed),
2049            evictions: stats.evictions.load(Ordering::Relaxed),
2050            evicted_bytes: stats.evicted_bytes.load(Ordering::Relaxed),
2051            prefetch_issued: stats.prefetch_issued.load(Ordering::Relaxed),
2052            prefetch_dropped: stats.prefetch_dropped.load(Ordering::Relaxed),
2053            prefetch_hits: stats.prefetch_hits.load(Ordering::Relaxed),
2054            prefetch_waits: stats.prefetch_waits.load(Ordering::Relaxed),
2055            async_reads: stats.async_reads.load(Ordering::Relaxed),
2056            staged_blocks: self.staging.lock().expect("kv disk staging poisoned").len(),
2057            enospc: stats.enospc.load(Ordering::Relaxed),
2058            space_clamped: stats.space_clamped.load(Ordering::Relaxed),
2059            effective_capacity: self.effective_capacity(index.disk_bytes),
2060            disk_bytes: index.disk_bytes,
2061        }
2062    }
2063
2064    fn block_path(&self, hash: &BlockHash) -> PathBuf {
2065        self.root
2066            .join(hash.shard_prefix(self.shard_chars))
2067            .join(format!("{}.{BLOCK_FILE_EXT}", hash.to_hex()))
2068    }
2069
2070    fn tmp_path(&self, hash: &BlockHash) -> PathBuf {
2071        let n = self.seq.fetch_add(1, Ordering::Relaxed);
2072        self.root.join(TMP_DIR).join(format!(
2073            "{}.{}.{n}.tmp",
2074            hash.shard_prefix(16),
2075            std::process::id()
2076        ))
2077    }
2078}
2079
2080/// An entry eviction has already removed from the index, awaiting
2081/// disposal of its payload.
2082struct Victim {
2083    hash: BlockHash,
2084    published: bool,
2085}
2086
2087fn parse_hex_hash(text: &str) -> Option<BlockHash> {
2088    if text.len() != 64 {
2089        return None;
2090    }
2091    let mut out = [0u8; 32];
2092    for (i, byte) in out.iter_mut().enumerate() {
2093        let hi = text.as_bytes()[i * 2] as char;
2094        let lo = text.as_bytes()[i * 2 + 1] as char;
2095        *byte = ((hi.to_digit(16)? << 4) | lo.to_digit(16)?) as u8;
2096    }
2097    Some(BlockHash::from_bytes(out))
2098}
2099
2100#[cfg(test)]
2101mod tests {
2102    use super::*;
2103    use crate::kv_block::BlockHasher;
2104    use std::sync::atomic::AtomicUsize;
2105
2106    /// A throwaway directory that removes itself, so a failing test
2107    /// does not leave block files behind. `std::env::temp_dir` plus the
2108    /// pid and a counter: the workspace has no `tempfile` dependency
2109    /// and this needs eight lines.
2110    struct TempDir(PathBuf);
2111
2112    impl TempDir {
2113        fn new(tag: &str) -> Self {
2114            static N: AtomicU64 = AtomicU64::new(0);
2115            let path = std::env::temp_dir().join(format!(
2116                "ferrox-kvdisk-{tag}-{}-{}",
2117                std::process::id(),
2118                N.fetch_add(1, Ordering::Relaxed)
2119            ));
2120            let _ = fs::remove_dir_all(&path);
2121            fs::create_dir_all(&path).expect("temp dir");
2122            TempDir(path)
2123        }
2124
2125        fn path(&self) -> &Path {
2126            &self.0
2127        }
2128    }
2129
2130    impl Drop for TempDir {
2131        fn drop(&mut self) {
2132            let _ = fs::remove_dir_all(&self.0);
2133        }
2134    }
2135
2136    fn layer(n_kv_heads: usize, head_dim: usize, tokens: usize, fill: f32) -> KvCache {
2137        let mut cache = KvCache::new(n_kv_heads, head_dim);
2138        for t in 0..tokens {
2139            let k = vec![fill + t as f32; n_kv_heads * head_dim];
2140            let v = vec![fill - t as f32; n_kv_heads * head_dim];
2141            cache.push(&k, &v).expect("unpooled push cannot fail");
2142        }
2143        cache
2144    }
2145
2146    fn block(model: &str, n_layers: usize, tokens: usize, fill: f32) -> KvBlock {
2147        let layers = (0..n_layers)
2148            .map(|l| layer(2, 4, tokens, fill + l as f32 * 100.0))
2149            .collect();
2150        KvBlock::stamp(model, layers).expect("stamp")
2151    }
2152
2153    fn expected(model: &str, n_layers: usize, tokens: usize) -> CacheSignature {
2154        CacheSignature::expected(model, n_layers, 2, 4, tokens)
2155    }
2156
2157    fn hash(n: usize) -> BlockHash {
2158        BlockHasher::new("model-a", &[] as &[&str]).chain(&[n, n + 1], 2)[0]
2159    }
2160
2161    /// A free-space probe that reports a terabyte. Tests must not
2162    /// depend on how full the machine's real disk happens to be -- that
2163    /// is exactly the variable the budget tests below control on
2164    /// purpose.
2165    fn plenty() -> FreeSpaceProbe {
2166        Arc::new(|_: &Path| Some(1 << 40))
2167    }
2168
2169    /// Default test store: everything on the calling thread, so a test
2170    /// that does not care about the writer pool never races it.
2171    fn store(dir: &TempDir, max_bytes: u64) -> DiskKvStore {
2172        DiskKvStore::open(
2173            DiskConfig::new(dir.path())
2174                .with_max_bytes(max_bytes)
2175                .with_writer_threads(0)
2176                .with_free_space_probe(plenty()),
2177        )
2178        .expect("open")
2179    }
2180
2181    fn put_now(store: &DiskKvStore, hash: BlockHash, block: KvBlock) {
2182        store.put_blocking(hash, block).expect("put");
2183    }
2184
2185    #[test]
2186    fn a_block_round_trips_through_a_file() {
2187        let dir = TempDir::new("roundtrip");
2188        let store = store(&dir, 1 << 20);
2189        let h = hash(1);
2190        let written = block("model-a", 3, 4, 1.0);
2191        let copy = block("model-a", 3, 4, 1.0);
2192        put_now(&store, h, written);
2193
2194        let read = store
2195            .get(&h, &expected("model-a", 3, 4))
2196            .expect("get")
2197            .expect("the block just written must be found");
2198        assert_eq!(read.layers().len(), 3);
2199        for (a, b) in read.layers().iter().zip(copy.layers()) {
2200            assert_eq!(a.k, b.k);
2201            assert_eq!(a.v, b.v);
2202            assert_eq!(a.seq_len, b.seq_len);
2203        }
2204        let stats = store.stats();
2205        assert_eq!(stats.hits, 1);
2206        assert_eq!(stats.writes, 1);
2207        assert_eq!(stats.blocks, 1);
2208        assert!(stats.read_nanos > 0, "a read must be timed");
2209        assert!(stats.write_nanos > 0, "a write must be timed");
2210    }
2211
2212    #[test]
2213    fn the_accounted_size_is_the_real_file_size() {
2214        let dir = TempDir::new("size");
2215        let store = store(&dir, 1 << 20);
2216        let h = hash(2);
2217        let written = block("model-a", 2, 8, 0.25);
2218        let predicted = encoded_len(written.signature());
2219        put_now(&store, h, written);
2220        let on_disk = fs::metadata(store.block_path(&h)).expect("stat").len();
2221        assert_eq!(
2222            predicted, on_disk,
2223            "the budget charges what the file really costs"
2224        );
2225        assert_eq!(store.stats().bytes, on_disk);
2226    }
2227
2228    #[test]
2229    fn blocks_are_sharded_by_hash_prefix() {
2230        let dir = TempDir::new("shard");
2231        let store = DiskKvStore::open(
2232            DiskConfig::new(dir.path())
2233                .with_shard_chars(2)
2234                .with_writer_threads(0)
2235                .with_free_space_probe(plenty()),
2236        )
2237        .expect("open");
2238        let h = hash(3);
2239        put_now(&store, h, block("model-a", 1, 2, 1.0));
2240        let path = store.block_path(&h);
2241        assert_eq!(
2242            path.parent()
2243                .unwrap()
2244                .file_name()
2245                .unwrap()
2246                .to_str()
2247                .unwrap(),
2248            &h.to_hex()[..2]
2249        );
2250        assert!(path.exists());
2251    }
2252
2253    /// The crash-safety case, stated as the failure it prevents: a file
2254    /// cut short must be *refused*, not parsed into whatever the
2255    /// remaining bytes happen to say.
2256    #[test]
2257    fn a_truncated_file_is_refused_at_every_cut_point() {
2258        let h = hash(4);
2259        let bytes = encode_block(&h, &block("model-a", 2, 4, 3.0));
2260        assert!(bytes.len() > PREFIX_LEN + 16);
2261
2262        // Cut inside the fixed prefix: not even a header to read.
2263        let err = decode_block(&bytes[..PREFIX_LEN - 1]).expect_err("short file");
2264        assert_eq!(
2265            err,
2266            BlockFormatError::TooShort {
2267                len: PREFIX_LEN - 1
2268            }
2269        );
2270
2271        // Cut inside the body: the declared length no longer matches.
2272        for cut in [PREFIX_LEN, PREFIX_LEN + 8, bytes.len() - 4, bytes.len() - 1] {
2273            let err = decode_block(&bytes[..cut]).expect_err("truncated file");
2274            assert_eq!(
2275                err,
2276                BlockFormatError::Truncated {
2277                    expected: bytes.len() as u64,
2278                    actual: cut as u64,
2279                },
2280                "a file cut at {cut} must be refused"
2281            );
2282        }
2283
2284        // A file that is the right length but has been altered.
2285        let mut flipped = bytes.clone();
2286        let last = flipped.len() - 1;
2287        flipped[last] ^= 0xff;
2288        assert_eq!(
2289            decode_block(&flipped).expect_err("altered file"),
2290            BlockFormatError::ChecksumMismatch
2291        );
2292
2293        // And a file that is not one of ours at all.
2294        let mut alien = bytes;
2295        alien[0] = b'X';
2296        assert_eq!(
2297            decode_block(&alien).expect_err("foreign file"),
2298            BlockFormatError::BadMagic
2299        );
2300    }
2301
2302    /// Truncation through the whole store, not just the decoder: a torn
2303    /// file is a miss and is removed, so the next request recomputes
2304    /// instead of tripping over it forever.
2305    #[test]
2306    fn a_torn_file_on_disk_is_a_miss_and_is_quarantined() {
2307        let dir = TempDir::new("torn");
2308        let store = store(&dir, 1 << 20);
2309        let h = hash(5);
2310        put_now(&store, h, block("model-a", 2, 4, 1.0));
2311        let path = store.block_path(&h);
2312
2313        // Simulate a write that died half way.
2314        let full = fs::read(&path).expect("read back");
2315        fs::write(&path, &full[..full.len() / 2]).expect("truncate");
2316
2317        let got = store.get(&h, &expected("model-a", 2, 4)).expect("get");
2318        assert!(got.is_none(), "a torn block must not be returned");
2319        assert_eq!(store.stats().corrupt, 1);
2320        assert!(!path.exists(), "a torn block must not be left to trip over");
2321        assert!(!store.contains(&h));
2322    }
2323
2324    #[test]
2325    fn an_unreadable_format_version_is_refused() {
2326        let h = hash(6);
2327        let mut bytes = encode_block(&h, &block("model-a", 1, 2, 1.0));
2328        bytes[8..12].copy_from_slice(&99u32.to_le_bytes());
2329        // Re-checksum so the version is the only thing wrong.
2330        let mut digest = Sha256::new();
2331        digest.update(&bytes[PREFIX_LEN..]);
2332        let digest: [u8; 32] = digest.finalize().into();
2333        bytes[24..PREFIX_LEN].copy_from_slice(&digest);
2334        assert_eq!(
2335            decode_block(&bytes).expect_err("unknown version"),
2336            BlockFormatError::UnsupportedFormat {
2337                found: 99,
2338                readable: READABLE_FORMAT_VERSIONS,
2339            }
2340        );
2341    }
2342
2343    /// The signature discipline is not re-implemented here, but it is
2344    /// enforced here: a block written under one config must not be
2345    /// handed to a reader expecting another.
2346    #[test]
2347    fn a_block_from_a_different_config_is_a_miss_not_a_hit() {
2348        let dir = TempDir::new("config");
2349        let store = store(&dir, 1 << 20);
2350        let h = hash(7);
2351        put_now(&store, h, block("model-a", 2, 4, 1.0));
2352
2353        assert!(store
2354            .get(&h, &expected("model-b", 2, 4))
2355            .expect("get")
2356            .is_none());
2357        assert!(store
2358            .get(&h, &CacheSignature::expected("model-a", 2, 8, 4, 4))
2359            .expect("get")
2360            .is_none());
2361        assert_eq!(store.stats().incompatible, 2);
2362        assert_eq!(store.stats().hits, 0);
2363        // Still readable by a reader that does match: an incompatible
2364        // read is not destructive.
2365        assert!(store
2366            .get(&h, &expected("model-a", 2, 4))
2367            .expect("get")
2368            .is_some());
2369    }
2370
2371    /// A file whose name says one block and whose contents say another
2372    /// is treated as corruption. The name is the identity; a store that
2373    /// trusted the contents instead would serve a prefix under the
2374    /// wrong hash, which is the silent-wrong-answer case the hashing
2375    /// exists to prevent.
2376    #[test]
2377    fn a_file_stored_under_the_wrong_name_is_rejected() {
2378        let dir = TempDir::new("misfiled");
2379        let store = store(&dir, 1 << 20);
2380        let (a, b) = (hash(8), hash(9));
2381        put_now(&store, a, block("model-a", 1, 2, 1.0));
2382        put_now(&store, b, block("model-a", 1, 2, 2.0));
2383        // Put b's bytes under a's name.
2384        let bytes = fs::read(store.block_path(&b)).expect("read b");
2385        fs::write(store.block_path(&a), bytes).expect("misfile");
2386
2387        assert!(store
2388            .get(&a, &expected("model-a", 1, 2))
2389            .expect("get")
2390            .is_none());
2391        assert_eq!(store.stats().corrupt, 1);
2392    }
2393
2394    #[test]
2395    fn eviction_keeps_the_store_inside_its_budget() {
2396        let dir = TempDir::new("evict");
2397        let one = encoded_len(block("model-a", 1, 4, 1.0).signature());
2398        // Room for two blocks and change, never three.
2399        let store = store(&dir, one * 2 + 8);
2400        let hashes: Vec<BlockHash> = (0..4).map(|i| hash(20 + i)).collect();
2401        for (i, h) in hashes.iter().enumerate() {
2402            put_now(&store, *h, block("model-a", 1, 4, i as f32));
2403            assert!(
2404                store.stats().bytes <= store.capacity(),
2405                "the store must never sit over budget"
2406            );
2407        }
2408        let stats = store.stats();
2409        assert_eq!(stats.blocks, 2);
2410        assert_eq!(stats.evictions, 2);
2411        assert!(stats.evicted_bytes >= one * 2);
2412        // The two oldest are gone, from the index and from the disk.
2413        for h in &hashes[..2] {
2414            assert!(!store.contains(h));
2415            assert!(
2416                !store.block_path(h).exists(),
2417                "an evicted file must be deleted"
2418            );
2419        }
2420        for h in &hashes[2..] {
2421            assert!(store.contains(h));
2422        }
2423    }
2424
2425    #[test]
2426    fn a_read_makes_a_block_the_least_likely_eviction_victim() {
2427        let dir = TempDir::new("lru");
2428        let one = encoded_len(block("model-a", 1, 4, 1.0).signature());
2429        let store = store(&dir, one * 2 + 8);
2430        let (a, b, c) = (hash(30), hash(31), hash(32));
2431        put_now(&store, a, block("model-a", 1, 4, 1.0));
2432        put_now(&store, b, block("model-a", 1, 4, 2.0));
2433        // Touch `a`, so `b` is now the oldest.
2434        assert!(store.get(&a, &expected("model-a", 1, 4)).unwrap().is_some());
2435        put_now(&store, c, block("model-a", 1, 4, 3.0));
2436
2437        assert!(store.contains(&a), "a recently read block must survive");
2438        assert!(!store.contains(&b));
2439        assert!(store.contains(&c));
2440    }
2441
2442    /// The post-rename re-check. The block is evicted in the window
2443    /// between the rename and the index update -- exactly the window
2444    /// the re-check exists for. Without it the file stays on disk
2445    /// forever with nothing accounting for its bytes, and the store
2446    /// drifts over budget one raced write at a time.
2447    #[test]
2448    fn a_block_evicted_mid_write_does_not_leave_its_file_behind() {
2449        let dir = TempDir::new("raced");
2450        let store = store(&dir, 1 << 20);
2451        let h = hash(40);
2452        {
2453            let evicting = Arc::clone(&store.shared);
2454            let mut hook = store
2455                .shared
2456                .hooks
2457                .after_rename
2458                .lock()
2459                .expect("hook lock poisoned");
2460            *hook = Some(Arc::new(move |hash: &BlockHash| {
2461                // Whoever evicts cannot delete a file that does not
2462                // exist yet; the writer must notice and withdraw it.
2463                evicting.drop_entry(hash);
2464            }));
2465        }
2466        put_now(&store, h, block("model-a", 1, 4, 1.0));
2467
2468        assert!(
2469            !store.block_path(&h).exists(),
2470            "a file published for an entry that no longer exists must be withdrawn"
2471        );
2472        assert!(!store.contains(&h));
2473        let stats = store.stats();
2474        assert_eq!(stats.write_raced_eviction, 1);
2475        assert_eq!(stats.bytes, 0, "no bytes may be left unaccounted");
2476    }
2477
2478    #[test]
2479    fn no_temp_files_survive_a_successful_write() {
2480        let dir = TempDir::new("tmp");
2481        let store = store(&dir, 1 << 20);
2482        for i in 0..4 {
2483            put_now(&store, hash(50 + i), block("model-a", 1, 2, i as f32));
2484        }
2485        let leftovers: Vec<_> = fs::read_dir(dir.path().join(TMP_DIR))
2486            .expect("tmp dir")
2487            .flatten()
2488            .collect();
2489        assert!(
2490            leftovers.is_empty(),
2491            "temp files must not accumulate: {leftovers:?}"
2492        );
2493    }
2494
2495    /// Survival across a restart is the whole point of the tier: a
2496    /// second store opened on the same directory finds what the first
2497    /// one wrote, and sweeps away temp files that never published.
2498    #[test]
2499    fn a_new_store_reattaches_to_what_the_previous_one_published() {
2500        let dir = TempDir::new("restart");
2501        let h = hash(60);
2502        {
2503            let store = store(&dir, 1 << 20);
2504            put_now(&store, h, block("model-a", 2, 4, 7.0));
2505        }
2506        // A write that died before publishing.
2507        let orphan = dir.path().join(TMP_DIR).join("dead.tmp");
2508        fs::write(&orphan, b"half a block").expect("orphan");
2509
2510        let reopened = store(&dir, 1 << 20);
2511        assert!(
2512            !reopened.contains(&h),
2513            "reattaching must be an explicit step, not a side effect of open()"
2514        );
2515        assert_eq!(reopened.reindex().expect("reindex"), 1);
2516        assert!(reopened.contains(&h));
2517        assert!(!orphan.exists(), "an unpublished temp file must be swept");
2518
2519        let read = reopened
2520            .get(&h, &expected("model-a", 2, 4))
2521            .expect("get")
2522            .expect("a block written before the restart must still be readable");
2523        assert_eq!(read.tokens(), 4);
2524    }
2525
2526    #[test]
2527    fn reindex_evicts_down_to_the_budget() {
2528        let dir = TempDir::new("reindex-evict");
2529        let one = encoded_len(block("model-a", 1, 4, 1.0).signature());
2530        {
2531            let store = store(&dir, 1 << 20);
2532            for i in 0..4 {
2533                put_now(&store, hash(70 + i), block("model-a", 1, 4, i as f32));
2534            }
2535        }
2536        let small = store(&dir, one * 2 + 8);
2537        small.reindex().expect("reindex");
2538        let stats = small.stats();
2539        assert_eq!(stats.blocks, 2, "a shrunken budget must bind on restart");
2540        assert!(stats.bytes <= small.capacity());
2541    }
2542
2543    #[test]
2544    fn an_absent_block_is_a_plain_miss() {
2545        let dir = TempDir::new("miss");
2546        let store = store(&dir, 1 << 20);
2547        assert!(store
2548            .get(&hash(80), &expected("model-a", 1, 2))
2549            .expect("get")
2550            .is_none());
2551        assert_eq!(store.stats().misses, 1);
2552        assert_eq!(store.stats().corrupt, 0);
2553    }
2554
2555    /// A hit whose file has been deleted behind the store's back (an
2556    /// external cleaner, a `rm -rf` on the shard) is a miss, and the
2557    /// stale entry is dropped rather than left to fail forever.
2558    #[test]
2559    fn a_file_deleted_behind_the_stores_back_is_a_miss() {
2560        let dir = TempDir::new("vanished");
2561        let store = store(&dir, 1 << 20);
2562        let h = hash(90);
2563        put_now(&store, h, block("model-a", 1, 2, 1.0));
2564        fs::remove_file(store.block_path(&h)).expect("remove");
2565        assert!(store
2566            .get(&h, &expected("model-a", 1, 2))
2567            .expect("get")
2568            .is_none());
2569        assert!(!store.contains(&h));
2570        assert_eq!(store.stats().bytes, 0);
2571    }
2572
2573    #[test]
2574    fn rewriting_a_block_does_not_double_charge_it() {
2575        let dir = TempDir::new("rewrite");
2576        let store = store(&dir, 1 << 20);
2577        let h = hash(100);
2578        put_now(&store, h, block("model-a", 1, 4, 1.0));
2579        let once = store.stats().bytes;
2580        put_now(&store, h, block("model-a", 1, 4, 1.0));
2581        assert_eq!(store.stats().bytes, once);
2582        assert_eq!(store.stats().blocks, 1);
2583    }
2584
2585    #[test]
2586    fn hex_names_round_trip() {
2587        let h = hash(110);
2588        assert_eq!(parse_hex_hash(&h.to_hex()), Some(h));
2589        assert_eq!(parse_hex_hash("nothex"), None);
2590        assert_eq!(parse_hex_hash(&"z".repeat(64)), None);
2591    }
2592
2593    // ---------------------------------------------------------------
2594    // Write ordering: buffer -> index -> queue
2595    // ---------------------------------------------------------------
2596
2597    /// Runs one `put` with a reader firing inside the window between
2598    /// the two ordered steps of the write path, and reports what the
2599    /// reader saw. Deterministic on purpose: a sleep-and-hope
2600    /// concurrency test that passes tells you nothing about a window it
2601    /// may simply have missed.
2602    ///
2603    /// Returns `(missing_payload_errors, served)`.
2604    fn probe_window(order: WriteOrder, publish_window: bool) -> (usize, usize) {
2605        let dir = TempDir::new("ordering");
2606        let store = store(&dir, 1 << 20);
2607        *store.shared.hooks.order.lock().unwrap() = order;
2608
2609        let violations = Arc::new(AtomicUsize::new(0));
2610        let served = Arc::new(AtomicUsize::new(0));
2611        let reader = Arc::clone(&store.shared);
2612        let v = Arc::clone(&violations);
2613        let s = Arc::clone(&served);
2614        let hook: Hook = Arc::new(move |hash: &BlockHash| {
2615            match reader
2616                .read_async(hash, &expected("model-a", 1, 4), true)
2617                .wait()
2618            {
2619                Ok(Some(_)) => {
2620                    s.fetch_add(1, Ordering::Relaxed);
2621                }
2622                // Not indexed yet: an honest miss, the reader simply
2623                // recomputes.
2624                Ok(None) => {}
2625                Err(StoreError::MissingPayload { .. }) => {
2626                    v.fetch_add(1, Ordering::Relaxed);
2627                }
2628                Err(other) => panic!("unexpected store error: {other}"),
2629            }
2630        });
2631        let slot = if publish_window {
2632            &store.shared.hooks.in_publish_window
2633        } else {
2634            &store.shared.hooks.in_put_window
2635        };
2636        *slot.lock().unwrap() = Some(hook);
2637
2638        put_now(&store, hash(200), block("model-a", 1, 4, 1.0));
2639        (
2640            violations.load(Ordering::Relaxed),
2641            served.load(Ordering::Relaxed),
2642        )
2643    }
2644
2645    /// The invariant. A reader that looks inside either window -- after
2646    /// the block is buffered but before it is indexed, and after it is
2647    /// published but before the buffer is released -- either misses
2648    /// cleanly or gets the block. It never gets an index hit with
2649    /// nothing behind it.
2650    #[test]
2651    fn a_reader_never_sees_an_index_hit_with_no_payload() {
2652        let (violations, _) = probe_window(WriteOrder::BufferThenIndex, false);
2653        assert_eq!(violations, 0, "admission window must be safe");
2654
2655        let (violations, served) = probe_window(WriteOrder::BufferThenIndex, true);
2656        assert_eq!(violations, 0, "publication window must be safe");
2657        assert_eq!(
2658            served, 1,
2659            "the reader must actually have reached the block, or this test proves nothing"
2660        );
2661    }
2662
2663    /// The proof that the test above is not vacuous: with the two steps
2664    /// of admission reversed -- index first, buffer second, which is
2665    /// the natural way to write it -- the very same reader hits an
2666    /// index entry for a block with no file and no payload.
2667    #[test]
2668    fn indexing_before_buffering_is_caught() {
2669        let (violations, _) = probe_window(WriteOrder::IndexBeforeBuffer, false);
2670        assert_eq!(
2671            violations, 1,
2672            "index-then-buffer must be detected as an invariant violation"
2673        );
2674    }
2675
2676    /// The other end of the write, and the subtler half: releasing the
2677    /// buffered payload before marking the file published leaves the
2678    /// same gap.
2679    #[test]
2680    fn releasing_the_buffer_before_publishing_is_caught() {
2681        let (violations, _) = probe_window(WriteOrder::DropBufferBeforeMarking, true);
2682        assert_eq!(
2683            violations, 1,
2684            "release-then-mark must be detected as an invariant violation"
2685        );
2686    }
2687
2688    /// The same invariant under real concurrency rather than a hook:
2689    /// writers queueing blocks while readers hammer them. This one can
2690    /// only ever *sample* the windows, which is why the deterministic
2691    /// probes above exist -- but it also exercises the writer threads,
2692    /// the queue, and eviction all at once.
2693    #[test]
2694    fn concurrent_readers_never_see_an_index_hit_with_no_payload() {
2695        let dir = TempDir::new("concurrent");
2696        let one = encoded_len(block("model-a", 1, 4, 1.0).signature());
2697        let store = Arc::new(
2698            DiskKvStore::open(
2699                DiskConfig::new(dir.path())
2700                    // Tight enough that eviction runs constantly.
2701                    .with_max_bytes(one * 8)
2702                    .with_queue_capacity(4)
2703                    .with_writer_threads(2)
2704                    .with_free_space_probe(plenty()),
2705            )
2706            .expect("open"),
2707        );
2708        let hashes: Vec<BlockHash> = (0..16).map(|i| hash(300 + i)).collect();
2709        let violations = Arc::new(AtomicUsize::new(0));
2710        let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
2711
2712        let readers: Vec<_> = (0..4)
2713            .map(|_| {
2714                let store = Arc::clone(&store);
2715                let hashes = hashes.clone();
2716                let violations = Arc::clone(&violations);
2717                let stop = Arc::clone(&stop);
2718                std::thread::spawn(move || {
2719                    let want = expected("model-a", 1, 4);
2720                    while !stop.load(Ordering::Relaxed) {
2721                        for h in &hashes {
2722                            match store.get(h, &want) {
2723                                Ok(_) => {}
2724                                Err(StoreError::MissingPayload { .. }) => {
2725                                    violations.fetch_add(1, Ordering::Relaxed);
2726                                }
2727                                Err(other) => panic!("unexpected store error: {other}"),
2728                            }
2729                        }
2730                    }
2731                })
2732            })
2733            .collect();
2734
2735        for round in 0..4 {
2736            for (i, h) in hashes.iter().enumerate() {
2737                store
2738                    .put(*h, block("model-a", 1, 4, (round * 16 + i) as f32))
2739                    .expect("put");
2740            }
2741        }
2742        store.flush();
2743        stop.store(true, Ordering::Relaxed);
2744        for reader in readers {
2745            reader.join().expect("reader thread");
2746        }
2747
2748        assert_eq!(
2749            violations.load(Ordering::Relaxed),
2750            0,
2751            "no reader may ever see an index hit with no payload"
2752        );
2753        let stats = store.stats();
2754        assert!(
2755            stats.buffer_hits > 0,
2756            "readers must have caught blocks still in the write buffer, \
2757             or this test never entered the window"
2758        );
2759        assert!(stats.evictions > 0, "the budget must have bound");
2760        assert!(stats.bytes <= store.capacity());
2761    }
2762
2763    /// A block is readable the instant `put` returns, before any writer
2764    /// thread has touched it. This is what makes the queue safe to use
2765    /// on the request path.
2766    #[test]
2767    fn a_queued_block_is_readable_before_it_reaches_disk() {
2768        let dir = TempDir::new("buffered");
2769        // No writer threads: nothing can publish until we flush.
2770        let store = store(&dir, 1 << 20);
2771        let h = hash(400);
2772        store.put(h, block("model-a", 1, 4, 1.0)).expect("put");
2773
2774        assert!(
2775            !store.block_path(&h).exists(),
2776            "nothing has been written yet"
2777        );
2778        let got = store
2779            .get(&h, &expected("model-a", 1, 4))
2780            .expect("get")
2781            .expect("a queued block must be readable immediately");
2782        assert_eq!(got.tokens(), 4);
2783        assert_eq!(store.stats().buffer_hits, 1);
2784
2785        store.flush();
2786        assert!(store.block_path(&h).exists(), "flush must publish it");
2787        assert!(store
2788            .get(&h, &expected("model-a", 1, 4))
2789            .expect("get")
2790            .is_some());
2791        assert_eq!(store.stats().hits, 1, "and now it comes off the disk");
2792    }
2793
2794    /// Backpressure, not loss: when the queue is full the block is
2795    /// written on the calling thread. Nothing is dropped, and the
2796    /// fallback is counted so an operator can see a queue that is too
2797    /// small.
2798    #[test]
2799    fn a_full_queue_writes_inline_rather_than_dropping_the_block() {
2800        let dir = TempDir::new("backpressure");
2801        let store = DiskKvStore::open(
2802            DiskConfig::new(dir.path())
2803                .with_queue_capacity(2)
2804                // Nothing drains the queue, so it stays full.
2805                .with_writer_threads(0)
2806                .with_free_space_probe(plenty()),
2807        )
2808        .expect("open");
2809
2810        let hashes: Vec<BlockHash> = (0..5).map(|i| hash(500 + i)).collect();
2811        for (i, h) in hashes.iter().enumerate() {
2812            store
2813                .put(*h, block("model-a", 1, 4, i as f32))
2814                .expect("put");
2815        }
2816        let stats = store.stats();
2817        assert_eq!(stats.queued_writes, 2, "the queue holds exactly its cap");
2818        assert_eq!(stats.inline_writes, 3, "the rest fall back to this thread");
2819        assert_eq!(stats.writes, 3, "and the fallbacks really wrote");
2820
2821        // Every block is readable regardless of which path it took --
2822        // the point of "fall back" instead of "drop".
2823        let want = expected("model-a", 1, 4);
2824        for h in &hashes {
2825            assert!(
2826                store.get(h, &want).expect("get").is_some(),
2827                "no block may be lost to a full queue"
2828            );
2829        }
2830        store.flush();
2831        for h in &hashes {
2832            assert!(store.block_path(h).exists(), "flush publishes the rest");
2833        }
2834    }
2835
2836    /// A queued write whose block was evicted before a writer reached
2837    /// it is skipped, not resurrected: eviction has already released
2838    /// its bytes, so writing it would put the store over budget with a
2839    /// file nothing accounts for.
2840    #[test]
2841    fn a_queued_write_evicted_before_it_runs_is_skipped() {
2842        let dir = TempDir::new("skipped");
2843        let store = store(&dir, 1 << 20);
2844        let h = hash(600);
2845        store.put(h, block("model-a", 1, 4, 1.0)).expect("put");
2846        store.remove(&h);
2847        store.flush();
2848
2849        let stats = store.stats();
2850        assert_eq!(stats.write_skipped, 1);
2851        assert_eq!(stats.writes, 0);
2852        assert!(!store.block_path(&h).exists());
2853        assert_eq!(stats.bytes, 0);
2854    }
2855
2856    /// A second `put` for the same hash supersedes the first: the
2857    /// queued job for the older generation finds a payload that is no
2858    /// longer its own and skips, rather than overwriting the newer
2859    /// block with the older one.
2860    #[test]
2861    fn a_superseded_queued_write_does_not_overwrite_the_newer_block() {
2862        let dir = TempDir::new("superseded");
2863        let store = store(&dir, 1 << 20);
2864        let h = hash(700);
2865        store.put(h, block("model-a", 1, 4, 1.0)).expect("put");
2866        store.put(h, block("model-a", 1, 4, 9.0)).expect("put");
2867        store.flush();
2868
2869        let got = store
2870            .get(&h, &expected("model-a", 1, 4))
2871            .expect("get")
2872            .expect("hit");
2873        assert_eq!(
2874            got.layers()[0].k[0],
2875            9.0,
2876            "the newer block must win, not whichever write ran last"
2877        );
2878        assert_eq!(store.stats().write_skipped, 1);
2879        assert_eq!(store.stats().blocks, 1);
2880    }
2881
2882    // ---------------------------------------------------------------
2883    // Asynchronous and prefetched reads
2884    // ---------------------------------------------------------------
2885
2886    /// A store that reads on its own threads, writes on the caller's
2887    /// (so a test's writes are done when `put_blocking` returns).
2888    fn reading_store(dir: &TempDir, readers: usize) -> DiskKvStore {
2889        DiskKvStore::open(
2890            DiskConfig::new(dir.path())
2891                .with_writer_threads(0)
2892                .with_reader_threads(readers)
2893                .with_free_space_probe(plenty()),
2894        )
2895        .expect("open")
2896    }
2897
2898    /// Blocks until a prefetch for `hash` has landed in staging.
2899    fn wait_staged(store: &DiskKvStore, hash: &BlockHash) {
2900        for _ in 0..2000 {
2901            let ready = store
2902                .shared
2903                .staging
2904                .lock()
2905                .unwrap()
2906                .get(hash)
2907                .map(|slot| slot.is_ready())
2908                .unwrap_or(false);
2909            if ready {
2910                return;
2911            }
2912            std::thread::sleep(std::time::Duration::from_millis(1));
2913        }
2914        panic!("prefetch never completed");
2915    }
2916
2917    /// The point of prefetching, proved the only way it can be: the
2918    /// block file is **deleted** after the prefetch and before the
2919    /// request. The request still gets its block, so the read
2920    /// demonstrably happened before it was asked for -- not on its
2921    /// thread, not on its clock.
2922    #[test]
2923    fn a_prefetched_block_is_already_read_when_the_request_arrives() {
2924        let dir = TempDir::new("prefetch");
2925        let store = reading_store(&dir, 1);
2926        let h = hash(800);
2927        put_now(&store, h, block("model-a", 2, 4, 5.0));
2928        let want = expected("model-a", 2, 4);
2929
2930        store.prefetch(&[h], &want);
2931        wait_staged(&store, &h);
2932        fs::remove_file(store.block_path(&h)).expect("remove");
2933
2934        let got = store
2935            .get(&h, &want)
2936            .expect("get")
2937            .expect("the prefetch already had it");
2938        assert_eq!(got.tokens(), 4);
2939        assert_eq!(got.layers()[0].k[0], 5.0);
2940
2941        let stats = store.stats();
2942        assert_eq!(
2943            stats.prefetch_hits, 1,
2944            "the request must have found it ready"
2945        );
2946        assert_eq!(
2947            stats.hits, 1,
2948            "and the file must have been read exactly once"
2949        );
2950        assert_eq!(stats.async_reads, 1, "on a reader thread, not the caller's");
2951        assert_eq!(stats.staged_blocks, 0, "a claimed read leaves staging");
2952    }
2953
2954    /// A whole prefix chain read ahead in one call, which is how a
2955    /// prefix cache would use this: the request's blocks are known
2956    /// before the request needs them.
2957    #[test]
2958    fn a_whole_chain_can_be_read_ahead_in_one_call() {
2959        let dir = TempDir::new("chain");
2960        let store = reading_store(&dir, 2);
2961        let hashes: Vec<BlockHash> = (0..4).map(|i| hash(810 + i)).collect();
2962        for (i, h) in hashes.iter().enumerate() {
2963            put_now(&store, *h, block("model-a", 1, 4, i as f32));
2964        }
2965        let want = expected("model-a", 1, 4);
2966
2967        store.prefetch(&hashes, &want);
2968        for h in &hashes {
2969            wait_staged(&store, h);
2970            fs::remove_file(store.block_path(h)).expect("remove");
2971        }
2972        for (i, h) in hashes.iter().enumerate() {
2973            let got = store.get(h, &want).expect("get").expect("read ahead");
2974            assert_eq!(got.layers()[0].k[0], i as f32);
2975        }
2976        let stats = store.stats();
2977        assert_eq!(stats.prefetch_issued, 4);
2978        assert_eq!(stats.prefetch_hits, 4);
2979        assert_eq!(stats.hits, 4, "four blocks, four reads, none repeated");
2980    }
2981
2982    /// Whether or not the prefetch has landed, the request never reads
2983    /// the same file twice: it joins the read in flight.
2984    #[test]
2985    fn a_request_joins_a_read_already_running_rather_than_repeating_it() {
2986        let dir = TempDir::new("join");
2987        let store = reading_store(&dir, 1);
2988        let h = hash(820);
2989        put_now(&store, h, block("model-a", 1, 4, 1.0));
2990        let want = expected("model-a", 1, 4);
2991
2992        store.prefetch(&[h], &want);
2993        // Deliberately no wait: this races the reader thread, and the
2994        // assertion holds either way.
2995        let got = store.get(&h, &want).expect("get").expect("hit");
2996        assert_eq!(got.tokens(), 4);
2997        let stats = store.stats();
2998        assert_eq!(
2999            stats.prefetch_hits + stats.prefetch_waits,
3000            1,
3001            "the request either found the read done or waited for it"
3002        );
3003        assert_eq!(stats.hits, 1, "one physical read, whichever way it went");
3004    }
3005
3006    /// A staged read belongs to the shape it was issued for. Handing a
3007    /// prefetch's answer to a request that wants a different layout
3008    /// would defeat the whole signature discipline, so the staged slot
3009    /// is only reused on an exact match.
3010    #[test]
3011    fn a_staged_read_is_not_reused_by_a_reader_that_wants_another_shape() {
3012        let dir = TempDir::new("staged-shape");
3013        let store = reading_store(&dir, 1);
3014        let h = hash(830);
3015        put_now(&store, h, block("model-a", 1, 4, 1.0));
3016
3017        store.prefetch(&[h], &expected("model-a", 1, 4));
3018        wait_staged(&store, &h);
3019
3020        let got = store.get(&h, &expected("model-b", 1, 4)).expect("get");
3021        assert!(got.is_none(), "a different model must not be served");
3022        assert_eq!(store.stats().incompatible, 1);
3023        assert_eq!(
3024            store.stats().prefetch_hits,
3025            0,
3026            "the staged answer was for another expectation and must not be claimed"
3027        );
3028    }
3029
3030    /// Reading ahead is a hint and must never be the thing that
3031    /// exhausts memory: past the staging cap, prefetches are refused
3032    /// and counted rather than queued.
3033    #[test]
3034    fn prefetching_is_bounded() {
3035        let dir = TempDir::new("prefetch-bound");
3036        let store = DiskKvStore::open(
3037            DiskConfig::new(dir.path())
3038                .with_writer_threads(0)
3039                .with_reader_threads(1)
3040                .with_prefetch_capacity(2)
3041                .with_free_space_probe(plenty()),
3042        )
3043        .expect("open");
3044        let hashes: Vec<BlockHash> = (0..6).map(|i| hash(840 + i)).collect();
3045        for (i, h) in hashes.iter().enumerate() {
3046            put_now(&store, *h, block("model-a", 1, 4, i as f32));
3047        }
3048
3049        store.prefetch(&hashes, &expected("model-a", 1, 4));
3050        let stats = store.stats();
3051        assert!(
3052            stats.staged_blocks <= 2,
3053            "staging must respect its cap, got {}",
3054            stats.staged_blocks
3055        );
3056        assert!(
3057            stats.prefetch_dropped >= 4,
3058            "the refusals must be visible, got {}",
3059            stats.prefetch_dropped
3060        );
3061
3062        // Refusing a hint costs a read, never an answer.
3063        let want = expected("model-a", 1, 4);
3064        for (i, h) in hashes.iter().enumerate() {
3065            let got = store.get(h, &want).expect("get").expect("hit");
3066            assert_eq!(got.layers()[0].k[0], i as f32);
3067        }
3068    }
3069
3070    /// With no reader threads every read happens on the calling thread
3071    /// and a prefetch is an honest no-op -- counted, not pretended.
3072    #[test]
3073    fn without_reader_threads_reads_run_on_the_caller() {
3074        let dir = TempDir::new("no-readers");
3075        let store = reading_store(&dir, 0);
3076        let h = hash(850);
3077        put_now(&store, h, block("model-a", 1, 4, 1.0));
3078        let want = expected("model-a", 1, 4);
3079
3080        store.prefetch(&[h], &want);
3081        assert_eq!(store.stats().prefetch_dropped, 1);
3082        assert_eq!(store.stats().staged_blocks, 0);
3083
3084        assert!(store.get(&h, &want).expect("get").is_some());
3085        let stats = store.stats();
3086        assert_eq!(stats.hits, 1);
3087        assert_eq!(stats.async_reads, 0);
3088    }
3089
3090    /// The handle is pollable: a caller that has other work to do can
3091    /// start the read, do the work, and collect it.
3092    #[test]
3093    fn a_read_handle_can_be_polled_to_completion() {
3094        let dir = TempDir::new("handle");
3095        let store = reading_store(&dir, 1);
3096        let h = hash(860);
3097        put_now(&store, h, block("model-a", 1, 4, 2.0));
3098        let want = expected("model-a", 1, 4);
3099
3100        let handle = store.read_async(&h, &want);
3101        for _ in 0..2000 {
3102            if let Some(outcome) = handle.try_claim() {
3103                let got = outcome.expect("read").expect("hit");
3104                assert_eq!(got.layers()[0].k[0], 2.0);
3105                assert_eq!(store.stats().staged_blocks, 0);
3106                return;
3107            }
3108            std::thread::sleep(std::time::Duration::from_millis(1));
3109        }
3110        panic!("read never completed");
3111    }
3112
3113    /// A miss needs no thread at all: it is decided under the index
3114    /// lock and comes back already answered.
3115    #[test]
3116    fn a_miss_is_answered_without_dispatching_a_read() {
3117        let dir = TempDir::new("ready-miss");
3118        let store = reading_store(&dir, 1);
3119        let handle = store.read_async(&hash(870), &expected("model-a", 1, 4));
3120        assert!(handle.is_ready(), "a miss must not cost a thread hop");
3121        assert!(handle.wait().expect("read").is_none());
3122        assert_eq!(store.stats().async_reads, 0);
3123    }
3124
3125    /// Abandoning a request drops what was read for it, rather than
3126    /// leaving the blocks parked in staging.
3127    #[test]
3128    fn clearing_the_prefetch_releases_staged_blocks() {
3129        let dir = TempDir::new("clear");
3130        let store = reading_store(&dir, 1);
3131        let h = hash(880);
3132        put_now(&store, h, block("model-a", 1, 4, 1.0));
3133        store.prefetch(&[h], &expected("model-a", 1, 4));
3134        wait_staged(&store, &h);
3135        assert_eq!(store.stats().staged_blocks, 1);
3136        store.clear_prefetch();
3137        assert_eq!(store.stats().staged_blocks, 0);
3138    }
3139
3140    // ---------------------------------------------------------------
3141    // The disk budget
3142    // ---------------------------------------------------------------
3143
3144    /// A store whose free-space reading the test controls.
3145    fn budgeted_store(
3146        dir: &TempDir,
3147        max_bytes: u64,
3148        reserve: u64,
3149        ttl: std::time::Duration,
3150        probe: FreeSpaceProbe,
3151    ) -> DiskKvStore {
3152        DiskKvStore::open(
3153            DiskConfig::new(dir.path())
3154                .with_max_bytes(max_bytes)
3155                .with_reserve_bytes(reserve)
3156                .with_free_space_ttl(ttl)
3157                .with_free_space_probe(probe)
3158                .with_writer_threads(0)
3159                .with_reader_threads(0),
3160        )
3161        .expect("open")
3162    }
3163
3164    /// Bytes really occupying the store's directory. The device model
3165    /// below is driven by this rather than by the store's own
3166    /// accounting, so the test cannot pass by agreeing with itself.
3167    fn dir_bytes(root: &Path) -> u64 {
3168        let mut total = 0;
3169        let Ok(entries) = fs::read_dir(root) else {
3170            return 0;
3171        };
3172        for entry in entries.flatten() {
3173            let path = entry.path();
3174            if path.is_dir() {
3175                total += dir_bytes(&path);
3176            } else if let Ok(meta) = entry.metadata() {
3177                total += meta.len();
3178            }
3179        }
3180        total
3181    }
3182
3183    /// The budget the plan asks for: the configured ceiling is an upper
3184    /// bound, and real free disk can lower it. When the filesystem
3185    /// fills up under a store that is nowhere near its byte budget,
3186    /// eviction is what fires -- not `ENOSPC`.
3187    ///
3188    /// The probe models a real device: free space is what the modelled
3189    /// device has left after the store's actual files, so evicting
3190    /// really does give space back and the ceiling has a fixed point
3191    /// instead of chasing itself down to empty.
3192    #[test]
3193    fn the_ceiling_falls_when_the_filesystem_fills_up() {
3194        let dir = TempDir::new("budget");
3195        let one = encoded_len(block("model-a", 1, 4, 1.0).signature());
3196        let device = Arc::new(AtomicU64::new(1 << 40));
3197        let probe: FreeSpaceProbe = {
3198            let device = Arc::clone(&device);
3199            let root = dir.path().to_path_buf();
3200            Arc::new(move |_: &Path| {
3201                Some(
3202                    device
3203                        .load(Ordering::Relaxed)
3204                        .saturating_sub(dir_bytes(&root)),
3205                )
3206            })
3207        };
3208        // Room for a hundred blocks by byte budget; two blocks' worth
3209        // of headroom demanded on the device.
3210        let reserve = one * 2;
3211        let store = budgeted_store(&dir, one * 100, reserve, std::time::Duration::ZERO, probe);
3212
3213        for i in 0..4 {
3214            put_now(&store, hash(900 + i), block("model-a", 1, 4, i as f32));
3215        }
3216        assert_eq!(store.stats().blocks, 4);
3217        assert_eq!(store.stats().evictions, 0, "nothing binds yet");
3218        assert_eq!(
3219            store.effective_capacity(),
3220            store.capacity(),
3221            "with a terabyte free the configured budget is the ceiling"
3222        );
3223
3224        // The device turns out to be small -- or something else on it
3225        // grew. Six blocks total, of which two must stay free.
3226        device.store(one * 6, Ordering::Relaxed);
3227        assert_eq!(
3228            store.effective_capacity(),
3229            one * 4,
3230            "the ceiling must follow the device down to total - reserve"
3231        );
3232
3233        for i in 0..6 {
3234            put_now(&store, hash(910 + i), block("model-a", 1, 4, i as f32));
3235            let on_disk = dir_bytes(dir.path());
3236            assert!(
3237                on_disk + reserve <= one * 6,
3238                "the store must hand the device its reserve back before the \
3239                 filesystem has to: {on_disk} bytes used of {}, {reserve} reserved",
3240                one * 6
3241            );
3242        }
3243
3244        let stats = store.stats();
3245        assert_eq!(stats.blocks, 4, "settled at total - reserve");
3246        assert!(stats.evictions >= 6, "got {}", stats.evictions);
3247        assert!(stats.space_clamped > 0, "the clamp must be visible");
3248        assert!(
3249            stats.bytes < one * 100,
3250            "far under the configured budget it never reached"
3251        );
3252        assert_eq!(
3253            stats.disk_bytes,
3254            dir_bytes(dir.path()),
3255            "the store's idea of its disk footprint must be the real one"
3256        );
3257    }
3258
3259    /// `statvfs` is a syscall, and free space moves slowly. It is read
3260    /// at most once per TTL rather than once per block written.
3261    #[test]
3262    fn the_free_space_reading_is_cached_for_its_ttl() {
3263        let dir = TempDir::new("ttl");
3264        let calls = Arc::new(AtomicUsize::new(0));
3265        let probe: FreeSpaceProbe = {
3266            let calls = Arc::clone(&calls);
3267            Arc::new(move |_: &Path| {
3268                calls.fetch_add(1, Ordering::Relaxed);
3269                Some(1 << 40)
3270            })
3271        };
3272        let store = budgeted_store(&dir, 1 << 20, 0, std::time::Duration::from_secs(60), probe);
3273
3274        for _ in 0..5 {
3275            store.effective_capacity();
3276        }
3277        for i in 0..3 {
3278            put_now(&store, hash(920 + i), block("model-a", 1, 4, i as f32));
3279        }
3280        assert_eq!(
3281            calls.load(Ordering::Relaxed),
3282            1,
3283            "a TTL'd reading must not be re-taken per operation"
3284        );
3285    }
3286
3287    /// The invalidation that matters: at the moment the filesystem says
3288    /// "full", the cached reading is known to be wrong, so it is thrown
3289    /// away rather than left to expire. And the store does not keep an
3290    /// index entry for a block it could not write.
3291    #[test]
3292    fn enospc_throws_away_the_cached_free_space() {
3293        let dir = TempDir::new("enospc");
3294        let calls = Arc::new(AtomicUsize::new(0));
3295        let probe: FreeSpaceProbe = {
3296            let calls = Arc::clone(&calls);
3297            Arc::new(move |_: &Path| {
3298                calls.fetch_add(1, Ordering::Relaxed);
3299                Some(1 << 40)
3300            })
3301        };
3302        let store = budgeted_store(
3303            &dir,
3304            1 << 20,
3305            0,
3306            // Long enough that nothing but the ENOSPC can re-take it.
3307            std::time::Duration::from_secs(3600),
3308            probe,
3309        );
3310        let h = hash(930);
3311        put_now(&store, h, block("model-a", 1, 4, 1.0));
3312        store.effective_capacity();
3313        assert_eq!(calls.load(Ordering::Relaxed), 1);
3314
3315        store
3316            .shared
3317            .hooks
3318            .fail_with_enospc
3319            .store(true, Ordering::Relaxed);
3320        let full = hash(931);
3321        let err = store
3322            .put_blocking(full, block("model-a", 1, 4, 2.0))
3323            .expect_err("a full filesystem must be reported, not swallowed");
3324        assert!(matches!(err, StoreError::Io { .. }), "{err}");
3325
3326        let stats = store.stats();
3327        assert_eq!(stats.enospc, 1);
3328        assert!(
3329            calls.load(Ordering::Relaxed) > 1,
3330            "ENOSPC must invalidate the cached reading immediately"
3331        );
3332        assert!(
3333            !store.contains(&full),
3334            "a block that could not be written must not be indexed"
3335        );
3336        assert_eq!(stats.write_failures, 1);
3337        let leftovers: Vec<_> = fs::read_dir(dir.path().join(TMP_DIR))
3338            .expect("tmp dir")
3339            .flatten()
3340            .collect();
3341        assert!(
3342            leftovers.is_empty(),
3343            "a failed write must clean up after itself: {leftovers:?}"
3344        );
3345
3346        // The block written before the failure is untouched.
3347        assert!(store
3348            .get(&h, &expected("model-a", 1, 4))
3349            .expect("get")
3350            .is_some());
3351
3352        // And once there is room again, writing resumes.
3353        store
3354            .shared
3355            .hooks
3356            .fail_with_enospc
3357            .store(false, Ordering::Relaxed);
3358        put_now(&store, full, block("model-a", 1, 4, 2.0));
3359        assert!(store.contains(&full));
3360    }
3361
3362    /// A filesystem that cannot be measured is not treated as full, and
3363    /// not treated as infinite either: the configured budget is simply
3364    /// the only ceiling.
3365    #[test]
3366    fn an_unmeasurable_filesystem_falls_back_to_the_configured_budget() {
3367        let dir = TempDir::new("unknowable");
3368        let store = budgeted_store(
3369            &dir,
3370            1 << 20,
3371            1 << 30,
3372            std::time::Duration::ZERO,
3373            Arc::new(|_: &Path| None),
3374        );
3375        assert_eq!(store.effective_capacity(), 1 << 20);
3376        for i in 0..3 {
3377            put_now(&store, hash(940 + i), block("model-a", 1, 4, i as f32));
3378        }
3379        assert_eq!(store.stats().blocks, 3);
3380        assert_eq!(store.stats().evictions, 0);
3381    }
3382
3383    /// The real probe, exercised: it is `unsafe` FFI, and a binding
3384    /// that silently returned nonsense would disable the whole budget
3385    /// without failing anything else.
3386    #[test]
3387    #[cfg(unix)]
3388    fn the_platform_probe_measures_a_real_filesystem() {
3389        let dir = TempDir::new("statvfs");
3390        let free = platform_free_bytes(dir.path()).expect("statvfs on a directory that exists");
3391        assert!(free > 0, "a writable temp dir with zero bytes free?");
3392        assert!(
3393            platform_free_bytes(&dir.path().join("no-such-dir")).is_none(),
3394            "a path that does not exist cannot report free space"
3395        );
3396    }
3397}