1use 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";
104const PREFIX_LEN: usize = 8 + 4 + 4 + 8 + 32;
106pub const BLOCK_FILE_EXT: &str = "kvb";
108const 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#[derive(Clone, Debug, PartialEq, Eq)]
138pub enum BlockFormatError {
139 TooShort { len: usize },
142 BadMagic,
144 UnsupportedFormat {
146 found: u32,
147 readable: &'static [u32],
148 },
149 Truncated { expected: u64, actual: u64 },
152 ChecksumMismatch,
155 Malformed(&'static str),
158 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
193pub 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
234fn 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 per_layer * 2 * sig.n_layers as u64
244}
245
246pub 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#[derive(Debug)]
257pub struct DecodedBlock {
258 pub hash: BlockHash,
260 pub block: UnverifiedBlock,
261}
262
263pub 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#[derive(Clone, Debug, PartialEq, Eq)]
385pub enum StoreError {
386 Io {
387 op: &'static str,
388 path: PathBuf,
389 message: String,
390 },
391 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
428pub type FreeSpaceProbe = Arc<dyn Fn(&Path) -> Option<u64> + Send + Sync>;
435
436#[cfg(unix)]
440#[allow(clippy::unnecessary_cast)] fn 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 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
466struct FreeSpace {
471 checked_at: Option<Instant>,
472 bytes: Option<u64>,
473}
474
475#[derive(Clone)]
478pub struct DiskConfig {
479 pub root: PathBuf,
481 pub max_bytes: u64,
484 pub shard_chars: usize,
488 pub queue_capacity: usize,
492 pub writer_threads: usize,
495 pub reader_threads: usize,
500 pub prefetch_capacity: usize,
505 pub reserve_bytes: u64,
509 pub free_space_ttl: std::time::Duration,
512 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 inline_writes: AtomicU64,
604 write_failures: AtomicU64,
605 write_skipped: AtomicU64,
608 write_nanos: AtomicU64,
609 write_raced_eviction: AtomicU64,
612 hits: AtomicU64,
613 buffer_hits: AtomicU64,
617 misses: AtomicU64,
618 corrupt: AtomicU64,
620 incompatible: AtomicU64,
623 read_nanos: AtomicU64,
624 evictions: AtomicU64,
625 evicted_bytes: AtomicU64,
626 prefetch_issued: AtomicU64,
628 prefetch_dropped: AtomicU64,
631 prefetch_hits: AtomicU64,
635 prefetch_waits: AtomicU64,
638 async_reads: AtomicU64,
640 enospc: AtomicU64,
643 space_clamped: AtomicU64,
646}
647
648#[derive(Clone, Debug, Default, PartialEq, Eq)]
653pub struct DiskStats {
654 pub blocks: usize,
655 pub bytes: u64,
657 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 pub effective_capacity: u64,
686}
687
688struct Entry {
689 bytes: u64,
690 last_used: u64,
691 published: bool,
695 generation: u64,
700}
701
702struct Index {
703 entries: HashMap<BlockHash, Entry>,
704 bytes: u64,
706 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
743enum Source {
745 Disk(PathBuf),
746 Buffer(Arc<KvBlock>),
747}
748
749struct 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
767struct 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 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
850pub type ReadOutcome = Result<Option<Arc<KvBlock>>, StoreError>;
854
855struct ReadSlot {
859 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
920struct 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 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
982pub struct ReadHandle {
989 shared: Arc<Shared>,
990 hash: BlockHash,
991 slot: Arc<ReadSlot>,
992 staged: bool,
995}
996
997impl ReadHandle {
998 pub fn is_ready(&self) -> bool {
1001 self.slot.is_ready()
1002 }
1003
1004 pub fn try_claim(&self) -> Option<ReadOutcome> {
1007 if !self.slot.is_ready() {
1008 return None;
1009 }
1010 Some(self.claim())
1011 }
1012
1013 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#[cfg(test)]
1044#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1045enum WriteOrder {
1046 #[default]
1047 BufferThenIndex,
1048 IndexBeforeBuffer,
1051 DropBufferBeforeMarking,
1054}
1055
1056#[cfg(test)]
1057#[derive(Default)]
1058struct Hooks {
1059 order: Mutex<WriteOrder>,
1060 after_rename: Mutex<Option<Hook>>,
1064 in_put_window: Mutex<Option<Hook>>,
1066 in_publish_window: Mutex<Option<Hook>>,
1068 fail_with_enospc: std::sync::atomic::AtomicBool,
1072}
1073
1074#[cfg(test)]
1075impl Hooks {
1076 fn fire(slot: &Mutex<Option<Hook>>, hash: &BlockHash) {
1077 let hook = slot.lock().expect("kv disk hook poisoned").clone();
1080 if let Some(hook) = hook {
1081 hook(hash);
1082 }
1083 }
1084}
1085
1086struct Shared {
1091 root: PathBuf,
1092 shard_chars: usize,
1093 max_bytes: u64,
1094 index: Mutex<Index>,
1095 buffer: Mutex<HashMap<BlockHash, Buffered>>,
1103 queue: WriteQueue,
1104 reads: ReadQueue,
1105 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 free_space: Mutex<FreeSpace>,
1117 stats: Stats,
1118 seq: AtomicU64,
1119 generation: AtomicU64,
1120 #[cfg(test)]
1121 hooks: Hooks,
1122}
1123
1124pub 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 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 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 pub fn put(&self, hash: BlockHash, block: KvBlock) -> Result<(), StoreError> {
1241 self.shared.put(hash, block, false)
1242 }
1243
1244 pub fn put_blocking(&self, hash: BlockHash, block: KvBlock) -> Result<(), StoreError> {
1246 self.shared.put(hash, block, true)
1247 }
1248
1249 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 let _ = self
1266 .shared
1267 .queue
1268 .idle
1269 .wait_timeout(state, std::time::Duration::from_millis(1));
1270 }
1271 }
1272
1273 pub fn get(&self, hash: &BlockHash, expected: &CacheSignature) -> ReadOutcome {
1280 self.shared.read_async(hash, expected, true).wait()
1281 }
1282
1283 pub fn read_async(&self, hash: &BlockHash, expected: &CacheSignature) -> ReadHandle {
1286 self.shared.read_async(hash, expected, true)
1287 }
1288
1289 pub fn prefetch(&self, hashes: &[BlockHash], expected: &CacheSignature) {
1299 self.shared.prefetch(hashes, expected);
1300 }
1301
1302 pub fn clear_prefetch(&self) {
1306 self.shared
1307 .staging
1308 .lock()
1309 .expect("kv disk staging poisoned")
1310 .clear();
1311 }
1312
1313 pub fn reindex(&self) -> Result<usize, StoreError> {
1315 self.shared.reindex()
1316 }
1317
1318 pub fn remove(&self, hash: &BlockHash) {
1320 self.shared.quarantine(hash);
1321 }
1322
1323 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 pub fn capacity(&self) -> u64 {
1332 self.shared.max_bytes
1333 }
1334
1335 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 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 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 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 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 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 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 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 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 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 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 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 fn read_async(
1642 self: &Arc<Self>,
1643 hash: &BlockHash,
1644 expected: &CacheSignature,
1645 demand: bool,
1646 ) -> ReadHandle {
1647 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 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 self.stats.prefetch_dropped.fetch_add(1, Ordering::Relaxed);
1762 }
1763 drop(handle);
1767 }
1768 }
1769
1770 fn effective_capacity(&self, used: u64) -> u64 {
1786 let Some(free) = self.free_bytes() else {
1787 return self.max_bytes;
1788 };
1789 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 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 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 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 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 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 fn collect_victims(&self, index: &mut Index, protect: Option<&BlockHash>) -> Vec<Victim> {
1973 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 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
2080struct 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 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 fn plenty() -> FreeSpaceProbe {
2166 Arc::new(|_: &Path| Some(1 << 40))
2167 }
2168
2169 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 #[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 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 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 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 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 #[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 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 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 #[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 assert!(store
2366 .get(&h, &expected("model-a", 2, 4))
2367 .expect("get")
2368 .is_some());
2369 }
2370
2371 #[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 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 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 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 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 #[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 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 #[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 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 #[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 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 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 #[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 #[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 #[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 #[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 .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 #[test]
2767 fn a_queued_block_is_readable_before_it_reaches_disk() {
2768 let dir = TempDir::new("buffered");
2769 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 #[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 .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 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 #[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 #[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 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 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 #[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 #[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 #[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 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 #[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 #[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 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 #[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 #[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 #[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 #[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 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 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 #[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 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 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 #[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 #[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 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 assert!(store
3348 .get(&h, &expected("model-a", 1, 4))
3349 .expect("get")
3350 .is_some());
3351
3352 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 #[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 #[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}