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}