Skip to main content

fs_ext4/
block_cache.rs

1//! Buffer cache wrapping a `BlockDevice`.
2//!
3//! `CachedDevice` is the single source of truth for block contents
4//! within a mount session. Mirrors Linux's buffer-cache role for
5//! journaled filesystems: reads are served from the cache, writes
6//! update the cache, and the cache holds journaled-but-not-yet-
7//! checkpointed bytes so that subsequent reads see them before the
8//! data area on disk catches up.
9//!
10//! Design:
11//! - **LRU clean entries** — `entries` holds blocks read from disk
12//!   or written through `write_at`. LRU-evictable; the disk has the
13//!   same bytes so eviction is safe.
14//! - **Pinned entries** — `pinned` holds blocks whose bytes only
15//!   exist in this map and the journal log on disk; the data area on
16//!   disk still has the pre-commit content. Pinned entries are
17//!   NEVER evicted, since evicting them would lose the only
18//!   in-memory copy and the next allocator scan would re-read stale
19//!   bytes from disk. `Filesystem::commit_block_buffer` populates
20//!   `pinned` after a successful journal commit; the
21//!   `Filesystem::replay_journal_if_dirty` hook calls `unpin_all`
22//!   when the journal has been checkpointed.
23//! - **Write-through update** — `write_at` UPDATES the cache (not
24//!   invalidates) and forwards to the inner device. This keeps the
25//!   cache consistent with disk for direct writes (e.g.
26//!   `write_inode_raw`) and means a read-after-write is satisfied
27//!   from the cache without bouncing to disk.
28//! - **Block-aligned reads only.** Multi-block reads bypass the
29//!   cache and pass through.
30//! - **Crash safety unchanged.** Pinned bytes are also persisted in
31//!   the journal log (the caller invoked `populate_cache` after a
32//!   journal commit); on crash, replay applies them. Clean LRU
33//!   entries match disk by construction.
34//! - **No external LRU crate** — hand-rolled to avoid pulling in
35//!   GPL/LGPL deps and to keep the cache logic auditable.
36
37use crate::block_io::BlockDevice;
38use crate::error::Result;
39use std::collections::HashMap;
40use std::sync::Mutex;
41
42/// Inner cache state held under a Mutex on `CachedDevice`.
43///
44/// Two maps:
45/// - `entries`: clean blocks (LRU-evictable; disk has the same bytes).
46/// - `pinned`: blocks whose bytes only exist here and in the journal
47///   log on disk. NEVER evicted until `unpin_all`.
48///
49/// Read order: `pinned` → `entries` → inner device.
50/// Write through `write_at`: updates `entries` only; pinned stays.
51/// Populate via `populate`: inserts into `pinned`.
52struct CacheState {
53    capacity: usize,
54    entries: HashMap<u64, (Vec<u8>, u64)>,
55    pinned: HashMap<u64, Vec<u8>>,
56    next_seq: u64,
57    hits: u64,
58    misses: u64,
59}
60
61impl CacheState {
62    fn new(capacity: usize) -> Self {
63        Self {
64            capacity,
65            entries: HashMap::with_capacity(capacity.min(1024)),
66            pinned: HashMap::new(),
67            next_seq: 0,
68            hits: 0,
69            misses: 0,
70        }
71    }
72
73    fn next_seq(&mut self) -> u64 {
74        let s = self.next_seq;
75        self.next_seq = s.wrapping_add(1);
76        s
77    }
78
79    /// Look up `block`. Pinned wins over LRU. On miss: None.
80    fn get(&mut self, block: u64) -> Option<Vec<u8>> {
81        if let Some(bytes) = self.pinned.get(&block) {
82            self.hits += 1;
83            return Some(bytes.clone());
84        }
85        let seq = self.next_seq();
86        if let Some(slot) = self.entries.get_mut(&block) {
87            slot.1 = seq;
88            self.hits += 1;
89            Some(slot.0.clone())
90        } else {
91            self.misses += 1;
92            None
93        }
94    }
95
96    /// Insert/update a clean LRU entry. Evicts the LRU victim if full.
97    /// Pinned entries are never considered for eviction.
98    fn put(&mut self, block: u64, bytes: Vec<u8>) {
99        // If the block is already pinned, replace its pinned bytes
100        // (a journaled write superseded by a direct write — unlikely
101        // in practice but kept consistent).
102        if let std::collections::hash_map::Entry::Occupied(mut e) = self.pinned.entry(block) {
103            e.insert(bytes);
104            return;
105        }
106        if self.entries.len() >= self.capacity {
107            if let Some((&victim, _)) = self.entries.iter().min_by_key(|(_, (_, seq))| *seq) {
108                self.entries.remove(&victim);
109            }
110        }
111        let seq = self.next_seq();
112        self.entries.insert(block, (bytes, seq));
113    }
114
115    /// Stash a block whose bytes live only here and in the journal
116    /// log. Will not be evicted until `unpin_all`. If the block was
117    /// in the LRU, the LRU entry is dropped — pinned takes priority.
118    fn pin(&mut self, block: u64, bytes: Vec<u8>) {
119        self.entries.remove(&block);
120        self.pinned.insert(block, bytes);
121    }
122
123    /// Move all pinned entries into the LRU (now safe to evict).
124    fn unpin_all(&mut self) {
125        // Drain pinned. Each entry becomes an LRU candidate;
126        // capacity-bound eviction kicks in if the LRU was already full.
127        let drained: Vec<(u64, Vec<u8>)> = self.pinned.drain().collect();
128        for (block, bytes) in drained {
129            if self.entries.len() >= self.capacity {
130                if let Some((&victim, _)) = self.entries.iter().min_by_key(|(_, (_, seq))| *seq) {
131                    self.entries.remove(&victim);
132                }
133            }
134            let seq = self.next_seq();
135            self.entries.insert(block, (bytes, seq));
136        }
137    }
138}
139
140/// LRU-cached BlockDevice. Pass-through for is_writable + size_bytes;
141/// caches block-aligned reads, invalidates on writes.
142pub struct CachedDevice {
143    inner: std::sync::Arc<dyn BlockDevice>,
144    block_size: u32,
145    state: Mutex<CacheState>,
146}
147
148impl CachedDevice {
149    /// Wrap `inner` with an LRU of `capacity` blocks. Pick `capacity`
150    /// based on workload: 64 blocks (256 KiB at 4 KiB) is a reasonable
151    /// default for general use; bigger directory walks benefit from 256+.
152    pub fn new(inner: std::sync::Arc<dyn BlockDevice>, block_size: u32, capacity: usize) -> Self {
153        Self {
154            inner,
155            block_size,
156            state: Mutex::new(CacheState::new(capacity.max(1))),
157        }
158    }
159
160    /// Snapshot (hits, misses) — useful for benchmarks and tests.
161    pub fn stats(&self) -> (u64, u64) {
162        let s = self.state.lock().expect("cache mutex poisoned");
163        (s.hits, s.misses)
164    }
165}
166
167impl BlockDevice for CachedDevice {
168    fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<()> {
169        let bs = self.block_size as u64;
170        let block = offset / bs;
171        let off_in_block = (offset % bs) as usize;
172        let len = buf.len();
173
174        // Block-aligned single-block read: cache fast path.
175        if off_in_block + len <= bs as usize {
176            // Try the cache first.
177            {
178                let mut state = self.state.lock().expect("cache mutex poisoned");
179                if let Some(blk) = state.get(block) {
180                    buf.copy_from_slice(&blk[off_in_block..off_in_block + len]);
181                    return Ok(());
182                }
183            }
184            // Miss: read the whole block from the inner device, then cache.
185            let mut blk = vec![0u8; bs as usize];
186            self.inner.read_at(block * bs, &mut blk)?;
187            buf.copy_from_slice(&blk[off_in_block..off_in_block + len]);
188            let mut state = self.state.lock().expect("cache mutex poisoned");
189            state.put(block, blk);
190            return Ok(());
191        }
192
193        // Multi-block read (rare): bypass the cache, pass through.
194        self.inner.read_at(offset, buf)
195    }
196
197    fn size_bytes(&self) -> u64 {
198        self.inner.size_bytes()
199    }
200
201    fn write_at(&self, offset: u64, buf: &[u8]) -> Result<()> {
202        let bs = self.block_size as u64;
203        // Forward to disk first; if the inner write fails, the cache
204        // still reflects whatever was there before — no partial update.
205        self.inner.write_at(offset, buf)?;
206
207        // Single-block, block-aligned writes update the cache directly
208        // (write-through). Multi-block / unaligned writes fall through
209        // to invalidation since we don't have the full block image
210        // for each affected block.
211        let off_in_block = (offset % bs) as usize;
212        let len = buf.len();
213        if off_in_block == 0 && len == bs as usize {
214            let block = offset / bs;
215            let mut state = self.state.lock().expect("cache mutex poisoned");
216            state.put(block, buf.to_vec());
217            return Ok(());
218        }
219
220        // Unaligned / multi-block write. Two layers to handle:
221        //
222        // - PINNED blocks hold post-commit-pre-checkpoint journaled
223        //   bytes that don't yet exist on disk. We MUST NOT
224        //   invalidate them — the on-disk data area still has the
225        //   pre-commit version, so dropping the pinned image would
226        //   make the next read serve stale bytes for the unwritten
227        //   portion. Instead, overlay the new sub-block bytes onto
228        //   the pinned image so reads see (journaled bytes for the
229        //   unwritten portion + new bytes for the written portion).
230        //
231        // - LRU entries (clean, not pinned) can be dropped: we just
232        //   wrote to disk above, so the disk holds the merged truth
233        //   (old bytes for unwritten portion + new bytes for written
234        //   portion). The next read fetches that merged image.
235        //
236        // The only sub-block write site in the crate today is
237        // `write_inode_raw` (writes a single inode at its offset
238        // within a 4 KiB inode-table block); other direct writes
239        // (`set_block_run_used`, BGD blocks, indirect blocks) are
240        // full-block-aligned and take the fast path above.
241        let first_block = offset / bs;
242        let last_block = (offset + buf.len() as u64).saturating_sub(1) / bs;
243        let buf_end_byte = offset + buf.len() as u64;
244        let mut state = self.state.lock().expect("cache mutex poisoned");
245        for b in first_block..=last_block {
246            let block_start = b * bs;
247            let block_end = block_start + bs;
248            let write_start = offset.max(block_start);
249            let write_end = buf_end_byte.min(block_end);
250            let in_block_off = (write_start - block_start) as usize;
251            let in_block_end = (write_end - block_start) as usize;
252            let buf_start = (write_start - offset) as usize;
253            let buf_end = (write_end - offset) as usize;
254
255            if let Some(img) = state.pinned.get_mut(&b) {
256                img[in_block_off..in_block_end].copy_from_slice(&buf[buf_start..buf_end]);
257            }
258            state.entries.remove(&b);
259        }
260        Ok(())
261    }
262
263    fn flush(&self) -> Result<()> {
264        self.inner.flush()
265    }
266
267    fn is_writable(&self) -> bool {
268        self.inner.is_writable()
269    }
270
271    fn populate_cache(&self, block: u64, bytes: Vec<u8>) {
272        let mut state = self.state.lock().expect("cache mutex poisoned");
273        state.pin(block, bytes);
274    }
275
276    fn unpin_all(&self) {
277        let mut state = self.state.lock().expect("cache mutex poisoned");
278        state.unpin_all();
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use std::sync::Arc;
286
287    /// Counts every read/write so we can prove the cache eliminates them.
288    struct CountingDevice {
289        bytes: Mutex<Vec<u8>>,
290        reads: std::sync::atomic::AtomicU64,
291        writes: std::sync::atomic::AtomicU64,
292        writable: bool,
293    }
294
295    impl CountingDevice {
296        fn new(size: usize, writable: bool) -> Arc<Self> {
297            Arc::new(Self {
298                bytes: Mutex::new(vec![0u8; size]),
299                reads: std::sync::atomic::AtomicU64::new(0),
300                writes: std::sync::atomic::AtomicU64::new(0),
301                writable,
302            })
303        }
304        fn reads(&self) -> u64 {
305            self.reads.load(std::sync::atomic::Ordering::SeqCst)
306        }
307        fn writes(&self) -> u64 {
308            self.writes.load(std::sync::atomic::Ordering::SeqCst)
309        }
310    }
311
312    impl BlockDevice for CountingDevice {
313        fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<()> {
314            self.reads.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
315            let b = self.bytes.lock().unwrap();
316            let off = offset as usize;
317            buf.copy_from_slice(&b[off..off + buf.len()]);
318            Ok(())
319        }
320        fn size_bytes(&self) -> u64 {
321            self.bytes.lock().unwrap().len() as u64
322        }
323        fn write_at(&self, offset: u64, buf: &[u8]) -> Result<()> {
324            self.writes
325                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
326            let mut b = self.bytes.lock().unwrap();
327            let off = offset as usize;
328            b[off..off + buf.len()].copy_from_slice(buf);
329            Ok(())
330        }
331        fn is_writable(&self) -> bool {
332            self.writable
333        }
334    }
335
336    #[test]
337    fn second_read_of_same_block_is_a_cache_hit() {
338        let inner = CountingDevice::new(4096 * 16, false);
339        let cached = CachedDevice::new(inner.clone(), 4096, 8);
340        let mut buf = vec![0u8; 100];
341        cached.read_at(0, &mut buf).unwrap();
342        cached.read_at(0, &mut buf).unwrap();
343        cached.read_at(50, &mut buf).unwrap(); // still in same block 0
344        assert_eq!(inner.reads(), 1, "cache should serve all 3 from one read");
345        let (hits, misses) = cached.stats();
346        assert_eq!(hits, 2);
347        assert_eq!(misses, 1);
348    }
349
350    #[test]
351    fn unaligned_write_merges_into_pinned_block() {
352        // Pinned blocks hold post-commit-pre-checkpoint journaled
353        // bytes that don't yet exist on disk. A sub-block write must
354        // NOT invalidate the pinned image — that would let later
355        // reads serve stale data-area bytes for the untouched portion.
356        // The cache must overlay the new sub-block bytes onto the
357        // pinned image, preserving everything else.
358        let inner = CountingDevice::new(4096 * 16, true);
359        let cached = CachedDevice::new(inner.clone(), 4096, 8);
360        // Pin block 5 with a synthetic post-commit image. Disk
361        // (CountingDevice) still holds zeros — the pre-commit version.
362        let mut journaled = vec![0u8; 4096];
363        journaled[0] = 0xAA;
364        journaled[3000] = 0xBB;
365        cached.populate_cache(5, journaled);
366        // Sub-block direct write touching bytes 100..200 of block 5.
367        cached.write_at(5 * 4096 + 100, &[0xCCu8; 100]).unwrap();
368        // Read back the full block via the cache.
369        let mut buf = vec![0u8; 4096];
370        for i in 0..4096 {
371            cached
372                .read_at(5 * 4096 + i as u64, &mut buf[i..i + 1])
373                .unwrap();
374        }
375        assert_eq!(buf[0], 0xAA, "pinned journaled byte 0 must survive");
376        assert_eq!(buf[100], 0xCC, "new sub-block bytes must be visible");
377        assert_eq!(buf[199], 0xCC);
378        assert_eq!(buf[200], 0x00, "untouched portion stays at journaled value");
379        assert_eq!(
380            buf[3000], 0xBB,
381            "pinned bytes outside the write window survive"
382        );
383    }
384
385    #[test]
386    fn unaligned_write_invalidates_cache_entry() {
387        // Partial-block writes don't have a full block image to
388        // update the cache with, so we drop the affected entries and
389        // the next read pays the cost of a fresh disk read.
390        let inner = CountingDevice::new(4096 * 16, true);
391        let cached = CachedDevice::new(inner.clone(), 4096, 8);
392        let mut buf = vec![0u8; 100];
393        cached.read_at(0, &mut buf).unwrap();
394        cached.write_at(0, &[42u8; 100]).unwrap(); // unaligned: 100 < 4096
395        cached.read_at(0, &mut buf).unwrap();
396        assert_eq!(inner.reads(), 2);
397        assert_eq!(buf[0], 42, "post-write read should see the new bytes");
398        // The cache is write-through, not write-back: the bytes reach
399        // the device now, exactly once. Nothing asserted this before —
400        // `CountingDevice::writes()` existed and had no caller, which is
401        // what the crate-wide `allow(dead_code)` was hiding.
402        assert_eq!(
403            inner.writes(),
404            1,
405            "an unaligned write must reach the device once, not be held in cache"
406        );
407    }
408
409    #[test]
410    fn aligned_write_updates_cache_entry() {
411        // Full-block, block-aligned writes go straight into the cache
412        // (write-through). The next read is served from cache without
413        // touching the inner device — read-after-write coherence with
414        // zero extra device reads.
415        let inner = CountingDevice::new(4096 * 16, true);
416        let cached = CachedDevice::new(inner.clone(), 4096, 8);
417        // Prime the cache with a read.
418        let mut buf = vec![0u8; 100];
419        cached.read_at(0, &mut buf).unwrap();
420        assert_eq!(inner.reads(), 1);
421        // Full-block write — cache should hold the new bytes.
422        let new_block = vec![0xCDu8; 4096];
423        cached.write_at(0, &new_block).unwrap();
424        // Read back: served from cache, no additional device read.
425        let mut readback = vec![0u8; 100];
426        cached.read_at(0, &mut readback).unwrap();
427        assert_eq!(inner.reads(), 1, "aligned write-through skips disk read");
428        assert_eq!(readback[0], 0xCD);
429        // Verify the inner device also got the bytes.
430        let mut from_disk = vec![0u8; 100];
431        inner.read_at(0, &mut from_disk).unwrap();
432        assert_eq!(from_disk[0], 0xCD);
433    }
434
435    #[test]
436    fn populate_cache_pins_entry_against_lru() {
437        // Pinned entries hold journaled-but-not-checkpointed bytes —
438        // they're the only in-memory copy, so eviction would lose
439        // data. Verify they survive even when the LRU is hammered.
440        let inner = CountingDevice::new(4096 * 16, false);
441        let cached = CachedDevice::new(inner.clone(), 4096, 2); // tiny capacity
442                                                                // Pin block 7 with synthetic journaled bytes.
443        cached.populate_cache(7, vec![0xAAu8; 4096]);
444        // Saturate the LRU with reads of other blocks.
445        let mut throwaway = vec![0u8; 8];
446        for blk in 0..5u64 {
447            cached.read_at(blk * 4096, &mut throwaway).unwrap();
448        }
449        // Block 7 must still be served from the pin, NOT from disk
450        // (which has zeros).
451        let mut buf = vec![0u8; 8];
452        cached.read_at(7 * 4096, &mut buf).unwrap();
453        assert_eq!(buf, vec![0xAA; 8],
454                   "pinned entry must survive LRU pressure — otherwise journaled writes vanish before checkpoint");
455    }
456
457    #[test]
458    fn unpin_all_lets_pinned_entries_evict_normally() {
459        // After journal replay, pinned bytes are also on disk —
460        // unpin_all moves them into the LRU where they can be evicted
461        // under normal pressure, freeing memory.
462        let inner = CountingDevice::new(4096 * 16, false);
463        let cached = CachedDevice::new(inner.clone(), 4096, 1); // capacity=1
464        cached.populate_cache(3, vec![0xBBu8; 4096]);
465        cached.unpin_all();
466        // Now read another block — block 3 should be evicted under
467        // capacity pressure.
468        let mut throwaway = vec![0u8; 8];
469        cached.read_at(0, &mut throwaway).unwrap();
470        // Re-read block 3 — should miss (evicted), serve from disk
471        // (which is zeros — `unpin_all` is purely an in-memory
472        // re-classification, the cache still hands out the pinned
473        // bytes that are STILL there until eviction picks them).
474        // Actually since capacity=1 and we just read block 0, block 3
475        // got evicted; the next read of block 3 misses the cache and
476        // hits the inner device. Inner has zeros (no journal replay
477        // really happened here — this test only exercises the
478        // pin/unpin state machine).
479        let mut buf3 = vec![0u8; 8];
480        cached.read_at(3 * 4096, &mut buf3).unwrap();
481        assert_eq!(
482            buf3,
483            vec![0; 8],
484            "after unpin + LRU eviction, the inner device's bytes win"
485        );
486    }
487
488    #[test]
489    fn multi_block_read_bypasses_cache() {
490        let inner = CountingDevice::new(4096 * 16, false);
491        let cached = CachedDevice::new(inner.clone(), 4096, 8);
492        let mut buf = vec![0u8; 8000]; // spans blocks 0 + 1
493        cached.read_at(0, &mut buf).unwrap();
494        // Cache wasn't populated → second multi-block read goes to disk again.
495        cached.read_at(0, &mut buf).unwrap();
496        assert_eq!(inner.reads(), 2);
497        let (hits, misses) = cached.stats();
498        assert_eq!(hits, 0);
499        assert_eq!(misses, 0, "multi-block reads bypass entirely");
500    }
501
502    #[test]
503    fn lru_evicts_oldest_when_capacity_exceeded() {
504        let inner = CountingDevice::new(4096 * 16, false);
505        let cached = CachedDevice::new(inner.clone(), 4096, 2); // capacity=2
506        let mut buf = vec![0u8; 8];
507        // Read blocks 0, 1, 2 — block 0 should be evicted.
508        for blk in 0..3u64 {
509            cached.read_at(blk * 4096, &mut buf).unwrap();
510        }
511        // Re-read block 0 → should miss (evicted).
512        cached.read_at(0, &mut buf).unwrap();
513        // Re-read block 2 → should hit (most recent).
514        cached.read_at(2 * 4096, &mut buf).unwrap();
515        let (hits, misses) = cached.stats();
516        assert_eq!(misses, 4, "blocks 0,1,2 + re-read of 0 (evicted)");
517        assert_eq!(hits, 1, "re-read of 2 still cached");
518    }
519
520    #[test]
521    fn lru_keeps_recently_touched_block_alive() {
522        // With capacity=2, reading [0, 1, 0, 2] keeps block 0 alive
523        // because it was touched between 1 and 2 — block 1 should be
524        // the eviction victim, not 0.
525        let inner = CountingDevice::new(4096 * 16, false);
526        let cached = CachedDevice::new(inner.clone(), 4096, 2);
527        let mut buf = vec![0u8; 8];
528        cached.read_at(0, &mut buf).unwrap(); // miss → cache
529        cached.read_at(4096, &mut buf).unwrap(); // miss → cache
530        cached.read_at(0, &mut buf).unwrap(); // hit, bumps recency
531        cached.read_at(2 * 4096, &mut buf).unwrap(); // miss → evicts block 1
532        cached.read_at(0, &mut buf).unwrap(); // hit (still cached)
533        let (hits, misses) = cached.stats();
534        assert_eq!(hits, 2);
535        assert_eq!(misses, 3);
536        // Verify block 1 was the eviction victim, not 0.
537        cached.read_at(4096, &mut buf).unwrap(); // should miss
538        let (_, m_after) = cached.stats();
539        assert_eq!(m_after, 4, "block 1 was evicted, re-read is a miss");
540    }
541}