db-11 step 02 — LRU cache with write-back
Goal
Add the in-memory page cache on top of step 01. Bounded capacity,
LRU eviction, write-back on eviction, dirty bit per frame. After
this step the disk is touched only on cache misses, evictions, and
flush().
Tasks
- Define
Frame { pid: u32, data: Vec<u8>, dirty: bool, prev, next }whereprev/nextare indices into aVec<Frame>(Rust) or*list.Element(Go) orstd::list<Frame>::iterator(C++). - Add to
Pager:capacity: usize— set atopen().frames: Vec<Frame>— the storage backing the LRU chain.free: Vec<usize>— reusable indices after eviction.map: HashMap<u32, usize>— pid → frame index.head, tail: Option<usize>— MRU and LRU ends.hits, misses: u64— accounting.
- Helpers:
promote(idx)— unlink frame from current position, insert at head. No-op if already at head.unlink(idx)— remove frame from the list.evict_tail()— pop the LRU frame; if dirty,pwritebefore reusing the slot.admit(pid, data, dirty)— if at capacity,evict_tailfirst; allocate a frame (fromfreeor push new); insert at head; updatemap.
- Rewrite
read(pid):- if
map[pid]exists: promote,hits += 1, clone, return. - else:
misses += 1,pread,admit(pid, data, dirty=false), clone, return.
- if
- Rewrite
write(pid, bytes):- if
map[pid]exists: overwrite data, setdirty = true, promote. - else:
admit(pid, bytes, dirty=true)— no pread.
- if
- Rewrite
flush():- collect all
(pid, frame_idx)wheredirty == true. - sort by
pidascending. - for each,
pwriteatpid * page_size, setdirty = false. - rewrite page 0 with current
num_pages. fsync.
- collect all
Acceptance
Inline unit tests:
cache_hit_does_not_pread— write then read twice; second read produces a cache hit (cache_hits >= 1).eviction_writes_back_dirty— fill cache + 1, evict the oldest frame, drop the pager, reopen, read the evicted pid, bytes match the value written before eviction.eviction_skips_clean_pages— fill cache with only-reads, evict, reopen: file size unchanged (no spurious writes).flush_is_idempotent— flush twice in a row, file bytes identical, both succeed.hits_misses_accounting— for a known sequence of operations,cache_hits + cache_missesequals the number of read calls (writes that hit the cache are not counted as reads).
All three green in Rust, Go, and C++.
Discussion prompts
- Why does
writeon a miss not do apread? When could this be wrong? (Answer: never, as long as the caller writes the whole page. Partial-page writes would need read-modify-write.) - Why sort dirty pages by
pidbefore writing them out? - What is the worst-case eviction cost, and how could
evict_tailamortize fsyncs across many evictions?