metrale_storage/tier/residency.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: [`Residency`], the page table over a bounded hot [`SlotArena`]
4//! and a cold [`SwapStore`].
5//!
6//! Owner: storage (tier).
7//! Invariants, between calls:
8//! - A key is in `lru` if and only if it is `Resident` and holds no read-pin.
9//! - Every `OnDisk` key is in `disk_lru` exactly once.
10
11use std::collections::{HashMap, VecDeque};
12
13use anyhow::{Result, bail};
14
15use crate::tier::aligned::PageAlignedBuf;
16use crate::tier::traits::{SlotArena, SwapStats, SwapStore};
17
18/// 2026-09-25: Where a key's blob is.
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20enum Loc {
21 /// 2026-09-25: An arena slot handed out by `alloc` and not yet committed.
22 /// It is not in `lru`, so it is never an eviction victim.
23 Reserved(usize),
24 /// 2026-09-25: Committed in an arena slot.
25 Resident(usize),
26 /// 2026-09-25: Spilled to a store record; `locate` faults it back into a
27 /// slot.
28 OnDisk(usize),
29}
30
31/// 2026-09-25: The page table from a `u64` key to a [`SlotArena`] slot or a
32/// [`SwapStore`] record, evicting the least recently used resident to the store
33/// when the arena is full.
34pub struct Residency<A: SlotArena, S: SwapStore> {
35 arena: A,
36 swap: S,
37 blob_bytes: usize,
38 map: HashMap<u64, Loc>,
39 /// 2026-09-25: Free arena slots, reused last-in first-out.
40 free_slots: Vec<usize>,
41 /// 2026-09-25: Unpinned resident keys, coldest first: the front is the next
42 /// eviction victim.
43 lru: VecDeque<u64>,
44 /// 2026-09-25: On-disk keys, coldest first: the front is the next record the
45 /// disk cap drops.
46 disk_lru: VecDeque<u64>,
47 /// 2026-09-25: The on-disk record cap, 0 for none. At the cap a spill first
48 /// drops the coldest on-disk key from the map, so a later get of it misses.
49 max_disk_slots: usize,
50 /// 2026-09-25: Free record indices, reused before `next_disk` grows.
51 free_disk: Vec<usize>,
52 next_disk: usize,
53 /// 2026-09-25: The buffer every spill and fault-in moves a blob through,
54 /// `blob_bytes` long and 4 KiB-aligned: the unix
55 /// [`DirectSwapFile`](crate::tier::DirectSwapFile) copies a buffer that is
56 /// not page-aligned through its own bounce buffer first.
57 scratch: PageAlignedBuf,
58 /// 2026-09-25: Read-pins, key to reader count. A peer hands a client the
59 /// slot offset for a one-sided RDMA read that happens after the lock is
60 /// released; a pinned key stays out of `lru`, so `evict_coldest_to_disk`
61 /// cannot reuse its slot during that read.
62 read_pins: HashMap<u64, u32>,
63 stats: SwapStats,
64}
65
66impl<A: SlotArena, S: SwapStore> Residency<A, S> {
67 /// 2026-09-25: [`Residency::new_capped`] with no disk cap.
68 pub fn new(arena: A, swap: S) -> Result<Self> {
69 Self::new_capped(arena, swap, 0)
70 }
71
72 /// 2026-09-25: A residency over `arena` and `swap` with at most
73 /// `max_disk_slots` live on-disk records (0: no cap). The file can still
74 /// reach `max_disk_slots + 1` records (see [`Residency::disk_high_water`]).
75 ///
76 /// # Errors
77 /// `slot_bytes()` is 0, differs from `swap.record_bytes()`, or the arena has
78 /// no slot.
79 pub fn new_capped(arena: A, swap: S, max_disk_slots: usize) -> Result<Self> {
80 let blob_bytes = arena.slot_bytes();
81 if blob_bytes == 0 {
82 bail!("Residency: slot_bytes must be > 0");
83 }
84 if swap.record_bytes() != blob_bytes {
85 bail!(
86 "Residency: arena slot ({}) and swap record ({}) sizes differ",
87 blob_bytes,
88 swap.record_bytes()
89 );
90 }
91 let n = arena.num_slots();
92 if n == 0 {
93 bail!("Residency: arena must have >= 1 slot");
94 }
95 Ok(Self {
96 arena,
97 swap,
98 blob_bytes,
99 map: HashMap::new(),
100 free_slots: (0..n).rev().collect(),
101 lru: VecDeque::new(),
102 disk_lru: VecDeque::new(),
103 max_disk_slots,
104 free_disk: Vec::new(),
105 next_disk: 0,
106 scratch: PageAlignedBuf::new(blob_bytes),
107 read_pins: HashMap::new(),
108 stats: SwapStats::default(),
109 })
110 }
111
112 pub fn blob_bytes(&self) -> usize {
113 self.blob_bytes
114 }
115 /// 2026-09-25: Address of the scratch buffer, for the alignment test.
116 #[doc(hidden)]
117 pub fn scratch_addr(&self) -> usize {
118 self.scratch.as_slice().as_ptr() as usize
119 }
120 pub fn stats(&self) -> &SwapStats {
121 &self.stats
122 }
123 pub fn resident_count(&self) -> usize {
124 self.lru.len()
125 }
126 pub fn total_keys(&self) -> usize {
127 self.map.len()
128 }
129 /// 2026-09-25: Keys on disk now; at most `max_disk_slots` when capped.
130 pub fn disk_count(&self) -> usize {
131 self.disk_lru.len()
132 }
133 /// 2026-09-25: The highest record index ever allocated, plus one: the swap
134 /// file's size in records. [`DirectSwapFile`](crate::tier::DirectSwapFile)
135 /// keeps the default no-op `discard_record`, so its file never shrinks and
136 /// only index reuse through `free_disk` bounds it. Under a cap this reaches
137 /// `max_disk_slots + 1`: [`Residency::locate`]'s `OnDisk` arm takes the
138 /// faulting key out of `disk_lru` while its record is still live, so
139 /// `make_disk_room` counts one fewer record than exist.
140 pub fn disk_high_water(&self) -> usize {
141 self.next_disk
142 }
143 /// 2026-09-25: The on-disk record cap, 0 for none.
144 pub fn max_disk_slots(&self) -> usize {
145 self.max_disk_slots
146 }
147
148 /// 2026-09-25: The arena itself, for callers that move the bytes of a slot
149 /// they got from [`Residency::alloc`] or [`Residency::locate`];
150 /// [`Residency::put_blob`] and [`Residency::get_blob`] do both steps.
151 pub fn arena(&self) -> &A {
152 &self.arena
153 }
154 pub fn arena_mut(&mut self) -> &mut A {
155 &mut self.arena
156 }
157
158 /// 2026-09-25: Byte offset of `slot` in the arena: `slot * blob_bytes`.
159 pub fn slot_offset(&self, slot: usize) -> u64 {
160 (slot as u64) * (self.blob_bytes as u64)
161 }
162
163 /// 2026-09-25: Put, step 1: reserve an arena slot for `key`, spilling the
164 /// coldest unpinned resident to the store when no slot is free. The caller
165 /// writes the blob into the slot and then calls [`Residency::commit`]. A
166 /// resident or reserved key keeps its slot; an on-disk key gets a new slot
167 /// and its record is freed.
168 ///
169 /// # Errors
170 /// Every slot is reserved or read-pinned, or the spill's arena read or
171 /// store write fails. On error the key keeps its previous location.
172 pub fn alloc(&mut self, key: u64) -> Result<usize> {
173 self.stats.puts += 1;
174 match self.map.get(&key).copied() {
175 Some(Loc::Resident(slot)) => {
176 self.lru_remove(key);
177 self.map.insert(key, Loc::Reserved(slot));
178 return Ok(slot);
179 }
180 Some(Loc::Reserved(slot)) => return Ok(slot),
181 Some(Loc::OnDisk(disk_slot)) => {
182 // 2026-09-25: The slot is acquired before the key's record is
183 // freed. `acquire_slot` can fail in its spill (a store write
184 // error such as ENOSPC); had the record already been freed, it
185 // would sit on `free_disk` while `map[key]` still says
186 // `OnDisk(disk_slot)`, and a retried put or a `remove` would free
187 // it a second time, handing one record to two keys.
188 //
189 // The key also leaves `disk_lru` first, so `make_disk_room` in
190 // that spill cannot pick it and free its record the same way.
191 self.disk_lru_remove(key);
192 let slot = match self.acquire_slot() {
193 Ok(s) => s,
194 Err(e) => {
195 // 2026-09-25: The key is still `OnDisk` with its own
196 // record; put it back at the cold end.
197 self.disk_lru.push_front(key);
198 return Err(e);
199 }
200 };
201 self.free_disk.push(disk_slot);
202 self.swap.discard_record(disk_slot);
203 self.map.insert(key, Loc::Reserved(slot));
204 return Ok(slot);
205 }
206 None => {}
207 }
208 let slot = self.acquire_slot()?;
209 self.map.insert(key, Loc::Reserved(slot));
210 Ok(slot)
211 }
212
213 /// 2026-09-25: Put, step 2: the blob is in the reserved slot; mark `key`
214 /// resident and, unless read-pinned, the hottest in `lru`. Committing a
215 /// resident key does nothing.
216 ///
217 /// # Errors
218 /// `key` is neither reserved nor resident.
219 pub fn commit(&mut self, key: u64) -> Result<()> {
220 match self.map.get(&key).copied() {
221 Some(Loc::Reserved(slot)) => {
222 self.map.insert(key, Loc::Resident(slot));
223 // 2026-09-25: A key pinned while its re-put was in flight stays
224 // out of `lru`; `unpin_read` adds it back.
225 if !self.read_pins.contains_key(&key) {
226 self.lru.push_back(key);
227 }
228 Ok(())
229 }
230 Some(Loc::Resident(_)) => Ok(()),
231 _ => bail!("commit({key:#x}): no reserved slot (alloc not called / evicted)"),
232 }
233 }
234
235 /// 2026-09-25: Get: the arena slot holding `key`, faulting an on-disk key
236 /// back into a slot (which may spill another key). `Ok(None)` for an unknown
237 /// key. A reserved key returns its slot, whose bytes the caller has not
238 /// committed.
239 ///
240 /// # Errors
241 /// No slot can be acquired, or the store read or arena write fails; the key
242 /// then stays on disk and the acquired slot is freed.
243 pub fn locate(&mut self, key: u64) -> Result<Option<usize>> {
244 self.stats.gets += 1;
245 match self.map.get(&key).copied() {
246 Some(Loc::Resident(slot)) => {
247 self.stats.resident_hits += 1;
248 // 2026-09-25: A pinned key is not in `lru`, and touching it would
249 // put it back there while it is being read.
250 if !self.read_pins.contains_key(&key) {
251 self.lru_touch(key);
252 }
253 Ok(Some(slot))
254 }
255 Some(Loc::Reserved(slot)) => {
256 // 2026-09-25: Not yet committed; the slot is returned as it is.
257 Ok(Some(slot))
258 }
259 Some(Loc::OnDisk(disk_slot)) => {
260 // 2026-09-25: Out of `disk_lru` while the fault runs, so the
261 // `make_disk_room` in `acquire_slot`'s spill cannot drop it.
262 self.disk_lru_remove(key);
263 let slot = match self.acquire_slot() {
264 Ok(s) => s,
265 Err(e) => {
266 self.disk_lru.push_front(key);
267 return Err(e);
268 }
269 };
270 // 2026-09-25: `&mut self` makes this the only move using
271 // `scratch`.
272 let mut buf = std::mem::take(&mut self.scratch);
273 let r = self
274 .swap
275 .read_record(disk_slot, buf.as_mut_slice())
276 .and_then(|_| self.arena.write_slot(slot, buf.as_slice()));
277 // 2026-09-25: `scratch` goes back before any return: while it is
278 // taken, `self.scratch` is the empty default, and a later move
279 // through it would fail its length check.
280 self.scratch = buf;
281 if let Err(e) = r {
282 self.free_slots.push(slot);
283 self.disk_lru.push_front(key);
284 return Err(e);
285 }
286 self.free_disk.push(disk_slot);
287 self.swap.discard_record(disk_slot);
288 self.map.insert(key, Loc::Resident(slot));
289 self.lru.push_back(key);
290 self.stats.faults_from_disk += 1;
291 Ok(Some(slot))
292 }
293 None => {
294 self.stats.get_miss += 1;
295 Ok(None)
296 }
297 }
298 }
299
300 /// 2026-09-25: Forget `key`, freeing its arena slot or store record. A read
301 /// pin on it stays until `unpin_read`.
302 pub fn remove(&mut self, key: u64) {
303 match self.map.remove(&key) {
304 Some(Loc::Resident(slot)) | Some(Loc::Reserved(slot)) => {
305 self.lru_remove(key);
306 self.free_slots.push(slot);
307 }
308 Some(Loc::OnDisk(disk_slot)) => {
309 self.disk_lru_remove(key);
310 self.free_disk.push(disk_slot);
311 self.swap.discard_record(disk_slot);
312 }
313 None => {}
314 }
315 }
316
317 /// 2026-09-25: Read-pin `key` so its slot is not an eviction victim while a
318 /// client reads it. Counted per reader; the first pin takes the key out of
319 /// `lru`. Does nothing unless `key` is resident.
320 pub fn pin_read(&mut self, key: u64) {
321 if !matches!(self.map.get(&key), Some(Loc::Resident(_))) {
322 return;
323 }
324 let n = self.read_pins.get(&key).copied().unwrap_or(0);
325 if n == 0 {
326 self.lru_remove(key);
327 }
328 self.read_pins.insert(key, n + 1);
329 }
330
331 /// 2026-09-25: Release one pin taken by [`Residency::pin_read`]. After the
332 /// last one, a key that is still resident rejoins `lru` as the hottest. Does
333 /// nothing for a key with no pin.
334 pub fn unpin_read(&mut self, key: u64) {
335 let Some(n) = self.read_pins.get_mut(&key) else {
336 return;
337 };
338 *n -= 1;
339 if *n == 0 {
340 self.read_pins.remove(&key);
341 if matches!(self.map.get(&key), Some(Loc::Resident(_))) && !self.lru.contains(&key) {
342 self.lru.push_back(key);
343 }
344 }
345 }
346
347 /// 2026-09-25: The number of read-pins on `key`.
348 pub fn read_pin_count(&self, key: u64) -> u32 {
349 self.read_pins.get(&key).copied().unwrap_or(0)
350 }
351
352 /// 2026-09-25: Put in one call: [`Residency::alloc`], copy `bytes` into the
353 /// slot, [`Residency::commit`].
354 ///
355 /// # Errors
356 /// `bytes` is not `blob_bytes` long, `alloc` fails, or the arena write fails,
357 /// after which `key` is removed.
358 pub fn put_blob(&mut self, key: u64, bytes: &[u8]) -> Result<()> {
359 if bytes.len() != self.blob_bytes {
360 bail!(
361 "put_blob({key:#x}): {} bytes, expected {}",
362 bytes.len(),
363 self.blob_bytes
364 );
365 }
366 let slot = self.alloc(key)?;
367 if let Err(e) = self.arena.write_slot(slot, bytes) {
368 self.remove(key);
369 return Err(e);
370 }
371 self.commit(key)
372 }
373
374 /// 2026-09-25: Get in one call: [`Residency::locate`], then copy the slot
375 /// into `out`. `Ok(false)` for an unknown key.
376 ///
377 /// # Errors
378 /// `out` is not `blob_bytes` long, or `locate` or the arena read fails.
379 pub fn get_blob(&mut self, key: u64, out: &mut [u8]) -> Result<bool> {
380 if out.len() != self.blob_bytes {
381 bail!(
382 "get_blob({key:#x}): {} bytes, expected {}",
383 out.len(),
384 self.blob_bytes
385 );
386 }
387 match self.locate(key)? {
388 Some(slot) => {
389 self.arena.read_slot(slot, out)?;
390 Ok(true)
391 }
392 None => Ok(false),
393 }
394 }
395
396 /// 2026-09-25: A free arena slot, or the slot of the coldest unpinned
397 /// resident after spilling it.
398 fn acquire_slot(&mut self) -> Result<usize> {
399 if let Some(s) = self.free_slots.pop() {
400 return Ok(s);
401 }
402 self.evict_coldest_to_disk()
403 }
404
405 /// 2026-09-25: Spill the front of `lru` to a store record and return its
406 /// slot. Reserved and read-pinned keys are not in `lru`. On a failed store
407 /// write the victim stays resident at the cold end.
408 fn evict_coldest_to_disk(&mut self) -> Result<usize> {
409 let Some(victim) = self.lru.pop_front() else {
410 bail!(
411 "Residency: arena exhausted — all {} slots reserved (uncommitted \
412 PUTs) or read-pinned (in-flight RDMA READs)",
413 self.arena.num_slots()
414 );
415 };
416 let slot = match self.map.get(&victim).copied() {
417 Some(Loc::Resident(slot)) => slot,
418 other => bail!("LRU/map desync: victim {victim:#x} is {other:?}, expected Resident"),
419 };
420 self.make_disk_room();
421 let disk_slot = self.alloc_disk_slot();
422 let mut buf = std::mem::take(&mut self.scratch);
423 let res = self
424 .arena
425 .read_slot(slot, buf.as_mut_slice())
426 .and_then(|_| self.swap.write_record(disk_slot, buf.as_slice()));
427 self.scratch = buf;
428 if let Err(e) = res {
429 self.free_disk.push(disk_slot);
430 self.lru.push_front(victim);
431 return Err(e);
432 }
433 self.map.insert(victim, Loc::OnDisk(disk_slot));
434 self.disk_lru.push_back(victim);
435 self.stats.spills_to_disk += 1;
436 Ok(slot)
437 }
438
439 /// 2026-09-25: Drop the coldest on-disk keys until `disk_lru` holds fewer
440 /// than `max_disk_slots` (nothing without a cap). A dropped key leaves the
441 /// map, so a later get of it misses.
442 fn make_disk_room(&mut self) {
443 if self.max_disk_slots == 0 {
444 return;
445 }
446 while self.disk_lru.len() >= self.max_disk_slots {
447 let Some(cold) = self.disk_lru.pop_front() else {
448 break;
449 };
450 if let Some(Loc::OnDisk(ds)) = self.map.remove(&cold) {
451 self.free_disk.push(ds);
452 self.swap.discard_record(ds);
453 self.stats.disk_evictions += 1;
454 }
455 }
456 }
457
458 fn alloc_disk_slot(&mut self) -> usize {
459 if let Some(d) = self.free_disk.pop() {
460 d
461 } else {
462 let d = self.next_disk;
463 self.next_disk += 1;
464 d
465 }
466 }
467
468 fn disk_lru_remove(&mut self, key: u64) {
469 if let Some(pos) = self.disk_lru.iter().position(|&k| k == key) {
470 self.disk_lru.remove(pos);
471 }
472 }
473
474 fn lru_touch(&mut self, key: u64) {
475 self.lru_remove(key);
476 self.lru.push_back(key);
477 }
478
479 fn lru_remove(&mut self, key: u64) {
480 if let Some(pos) = self.lru.iter().position(|&k| k == key) {
481 self.lru.remove(pos);
482 }
483 }
484}