atlas_tier/residency.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! [`Residency`] — the policy core: the page table over a bounded hot
4//! [`SlotArena`] and an unbounded cold [`SwapStore`].
5
6use std::collections::{HashMap, VecDeque};
7
8use anyhow::{Result, bail};
9
10use crate::aligned::PageAlignedBuf;
11use crate::traits::{SlotArena, SwapStats, SwapStore};
12
13/// Where a key's blob currently lives.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15enum Loc {
16 /// Arena slot handed out for an in-flight PUT; pinned (not evictable) until
17 /// `commit`. Holds the caller's about-to-be-written bytes.
18 Reserved(usize),
19 /// Live in an arena slot (RDMA-readable now).
20 Resident(usize),
21 /// Spilled to a disk record; a GET faults it back into a slot.
22 OnDisk(usize),
23}
24
25/// The page table: `key → Loc` over a bounded [`SlotArena`] (hot) backed by an
26/// unbounded [`SwapStore`] (cold), with LRU eviction of resident slots to disk.
27pub struct Residency<A: SlotArena, S: SwapStore> {
28 arena: A,
29 swap: S,
30 blob_bytes: usize,
31 map: HashMap<u64, Loc>,
32 /// Free arena slot indices (LIFO reuse).
33 free_slots: Vec<usize>,
34 /// Resident keys, front = coldest (LRU eviction victim). Reserved keys are
35 /// NOT in here (pinned).
36 lru: VecDeque<u64>,
37 /// On-disk keys, front = coldest — the disk-cap eviction victim. Every
38 /// `OnDisk` entry is exactly once in here (a bounded two-level LRU:
39 /// RAM `lru` above disk `disk_lru`).
40 disk_lru: VecDeque<u64>,
41 /// Max simultaneous on-disk records (the disk cap / blob_bytes). 0 =
42 /// unbounded. When full, the coldest on-disk snapshot is dropped to make
43 /// room — a later GET for it misses and the model recomputes (correct
44 /// degradation, keeps the swap file bounded).
45 max_disk_slots: usize,
46 /// Free disk record indices (reused before growing the high-water mark).
47 free_disk: Vec<usize>,
48 next_disk: usize,
49 /// Reusable scratch for a single blob move (spill/fault), sized once and
50 /// **4 KiB-aligned**: [`crate::DirectSwapFile`] bounces any unaligned buffer
51 /// through an internal aligned copy, so a plain `Vec` here charged every
52 /// O_DIRECT write AND read a wasted full-record memcpy (~66 MB/snapshot).
53 scratch: PageAlignedBuf,
54 /// Read-pins: `key → active reader count`. A GET hands the client an arena
55 /// offset it then one-sided-RDMA-READs; the peer drops the residency lock
56 /// before that read, so a concurrent allocation on another connection could pick
57 /// the slot as an eviction victim and reuse it mid-read (torn restore). A
58 /// pinned key is held OUT of `lru` (like a `Reserved` slot) so
59 /// `evict_coldest_to_disk` can never choose it. Ref-counted for concurrent
60 /// readers of the same key. Invariant: `key ∈ lru ⟺ Resident AND unpinned`.
61 read_pins: HashMap<u64, u32>,
62 stats: SwapStats,
63}
64
65impl<A: SlotArena, S: SwapStore> Residency<A, S> {
66 /// Unbounded disk tier (no cap). Prefer [`Residency::new_capped`] in
67 /// production.
68 pub fn new(arena: A, swap: S) -> Result<Self> {
69 Self::new_capped(arena, swap, 0)
70 }
71
72 /// `max_disk_slots` bounds the on-disk record count (0 = unbounded). When
73 /// full, spilling evicts the coldest on-disk snapshot (dropped → later GET
74 /// misses → recompute), keeping the swap file at ≤ `max_disk_slots` records.
75 pub fn new_capped(arena: A, swap: S, max_disk_slots: usize) -> Result<Self> {
76 let blob_bytes = arena.slot_bytes();
77 if blob_bytes == 0 {
78 bail!("Residency: slot_bytes must be > 0");
79 }
80 if swap.record_bytes() != blob_bytes {
81 bail!(
82 "Residency: arena slot ({}) and swap record ({}) sizes differ",
83 blob_bytes,
84 swap.record_bytes()
85 );
86 }
87 let n = arena.num_slots();
88 if n == 0 {
89 bail!("Residency: arena must have >= 1 slot");
90 }
91 Ok(Self {
92 arena,
93 swap,
94 blob_bytes,
95 map: HashMap::new(),
96 free_slots: (0..n).rev().collect(),
97 lru: VecDeque::new(),
98 disk_lru: VecDeque::new(),
99 max_disk_slots,
100 free_disk: Vec::new(),
101 next_disk: 0,
102 scratch: PageAlignedBuf::new(blob_bytes),
103 read_pins: HashMap::new(),
104 stats: SwapStats::default(),
105 })
106 }
107
108 pub fn blob_bytes(&self) -> usize {
109 self.blob_bytes
110 }
111 /// Scratch address — the tripwire for the O_DIRECT alignment requirement.
112 #[doc(hidden)]
113 pub fn scratch_addr(&self) -> usize {
114 self.scratch.as_slice().as_ptr() as usize
115 }
116 pub fn stats(&self) -> &SwapStats {
117 &self.stats
118 }
119 pub fn resident_count(&self) -> usize {
120 self.lru.len()
121 }
122 pub fn total_keys(&self) -> usize {
123 self.map.len()
124 }
125 /// Live on-disk records right now (≤ `max_disk_slots` when capped).
126 pub fn disk_count(&self) -> usize {
127 self.disk_lru.len()
128 }
129 /// Disk-record high-water mark — the honest **swap-file size** in records,
130 /// which `disk_count` is not: [`crate::DirectSwapFile`] does not override
131 /// `SwapStore::discard_record` (the default no-op), so a freed record's
132 /// blocks are never punched back out of the file. The file is bounded
133 /// solely by index reuse through `free_disk` up to `next_disk`, so an
134 /// operator sizing a partition must budget `disk_high_water × blob_bytes`.
135 /// Reaches `max_disk_slots + 1` under a cap: [`Residency::locate`]'s
136 /// `OnDisk` arm pulls the faulting key out of `disk_lru` while its record
137 /// is still live, so `make_disk_room` counts one fewer than exist.
138 pub fn disk_high_water(&self) -> usize {
139 self.next_disk
140 }
141 /// The configured on-disk record cap (0 = unbounded).
142 pub fn max_disk_slots(&self) -> usize {
143 self.max_disk_slots
144 }
145
146 /// Direct arena access for the data plane. The peer writes the slots its
147 /// clients one-sided-RDMA into/out of; in-process consumers should prefer
148 /// [`Residency::put_blob`] / [`Residency::get_blob`].
149 pub fn arena(&self) -> &A {
150 &self.arena
151 }
152 pub fn arena_mut(&mut self) -> &mut A {
153 &mut self.arena
154 }
155
156 /// Byte offset of an arena slot (what the client RDMA-reads/writes).
157 pub fn slot_offset(&self, slot: usize) -> u64 {
158 (slot as u64) * (self.blob_bytes as u64)
159 }
160
161 // ─────────────────────────── control-plane ops ───────────────────────────
162
163 /// PUT step 1 — reserve an arena slot for `key`. Evicts the coldest resident
164 /// slot to disk if the arena is full (never rejects). The caller then
165 /// RDMA-WRITEs the blob into `slot_offset(slot)` and calls `commit(key)`.
166 /// Re-PUT of a live key reuses its current slot (idempotent overwrite).
167 pub fn alloc(&mut self, key: u64) -> Result<usize> {
168 self.stats.puts += 1;
169 // Overwrite-in-place: a key already resident/reserved keeps its slot.
170 match self.map.get(&key).copied() {
171 Some(Loc::Resident(slot)) => {
172 self.lru_remove(key); // pin during the rewrite
173 self.map.insert(key, Loc::Reserved(slot));
174 return Ok(slot);
175 }
176 Some(Loc::Reserved(slot)) => return Ok(slot),
177 Some(Loc::OnDisk(disk_slot)) => {
178 // Rewriting a spilled key: take a slot FIRST, only then reclaim
179 // its disk record. The order is load-bearing, not stylistic.
180 //
181 // `acquire_slot` can spill a victim, and that spill's
182 // `write_record` can fail — ENOSPC on the swap file is the
183 // canonical trigger. Freeing the record before that fallible
184 // step and then bailing would leave `disk_slot` on `free_disk`
185 // while `map[key]` still reads `OnDisk(disk_slot)`: a retry of
186 // the PUT (the natural response to a transient ENOSPC) re-enters
187 // this arm, or a `remove` takes its own `OnDisk` arm, and the
188 // index is DOUBLE-PUSHED onto the free list. The two pops then
189 // hand one record to two different keys, and the loser's GET
190 // reads the winner's bytes while `get_blob` reports `Ok(true)` —
191 // silent cross-request corruption (one sequence's whole SSM
192 // snapshot restored into another's), never a surfaced error.
193 //
194 // Self-pin out of `disk_lru` before acquiring for the same
195 // reason `locate`'s `OnDisk` arm does: otherwise the capped
196 // `make_disk_room` inside that spill could pick THIS key as the
197 // coldest on-disk victim and free its record behind our back —
198 // the same double-push by a second route.
199 self.disk_lru_remove(key);
200 let slot = match self.acquire_slot() {
201 Ok(s) => s,
202 Err(e) => {
203 // Un-pin: the key is untouched, still OnDisk, and its
204 // record is still exclusively its own.
205 self.disk_lru.push_front(key);
206 return Err(e);
207 }
208 };
209 // Slot secured; releasing the record now can no longer be undone
210 // by a later failure, so it is freed exactly once.
211 self.free_disk.push(disk_slot);
212 self.swap.discard_record(disk_slot);
213 self.map.insert(key, Loc::Reserved(slot));
214 return Ok(slot);
215 }
216 None => {}
217 }
218 let slot = self.acquire_slot()?;
219 self.map.insert(key, Loc::Reserved(slot));
220 Ok(slot)
221 }
222
223 /// PUT step 2 — the client's RDMA-WRITE into the reserved slot has landed;
224 /// mark `key` resident (and hottest in the LRU).
225 pub fn commit(&mut self, key: u64) -> Result<()> {
226 match self.map.get(&key).copied() {
227 Some(Loc::Reserved(slot)) => {
228 self.map.insert(key, Loc::Resident(slot));
229 // Maintain the `in-lru ⟺ Resident AND unpinned` invariant: if a
230 // reader pinned this key while the re-PUT was in flight, leave it
231 // out of the LRU — `unpin_read` re-adds it when the last reader
232 // releases.
233 if !self.read_pins.contains_key(&key) {
234 self.lru.push_back(key); // hottest
235 }
236 Ok(())
237 }
238 Some(Loc::Resident(_)) => Ok(()), // already committed — idempotent
239 _ => bail!("commit({key:#x}): no reserved slot (alloc not called / evicted)"),
240 }
241 }
242
243 /// GET — ensure `key` is resident and return its arena slot (offset via
244 /// `slot_offset`). Faults from disk into a slot if it was spilled (evicting
245 /// a victim to make room). `Ok(None)` = unknown key (caller recomputes).
246 pub fn locate(&mut self, key: u64) -> Result<Option<usize>> {
247 self.stats.gets += 1;
248 match self.map.get(&key).copied() {
249 Some(Loc::Resident(slot)) => {
250 self.stats.resident_hits += 1;
251 // A concurrently-pinned key is held out of `lru`; touching it
252 // would re-insert it (breaking the invariant + making it an
253 // eviction victim while still being read). The caller pins right
254 // after this returns, so unpinned hits get refreshed here and
255 // pinned ones stay out.
256 if !self.read_pins.contains_key(&key) {
257 self.lru_touch(key);
258 }
259 Ok(Some(slot))
260 }
261 Some(Loc::Reserved(slot)) => {
262 // A GET racing an uncommitted PUT: the bytes are (being) written
263 // by the same caller; hand back the slot.
264 Ok(Some(slot))
265 }
266 Some(Loc::OnDisk(disk_slot)) => {
267 // Pin against `acquire_slot`'s spill+make_disk_room evicting THIS
268 // key (it is still OnDisk until the fault below completes).
269 self.disk_lru_remove(key);
270 let slot = match self.acquire_slot() {
271 Ok(s) => s,
272 Err(e) => {
273 self.disk_lru.push_front(key); // un-pin (still on disk)
274 return Err(e);
275 }
276 };
277 // scratch is exclusive to one move at a time (control loop is
278 // single-threaded per connection); read disk → arena slot.
279 let mut buf = std::mem::take(&mut self.scratch);
280 let r = self
281 .swap
282 .read_record(disk_slot, buf.as_mut_slice())
283 .and_then(|_| self.arena.write_slot(slot, buf.as_slice()));
284 // ALWAYS put the scratch back before an early return. The
285 // `write_slot` arm used to bail with `?` while `scratch` was
286 // moved out, leaving a ZERO-LENGTH scratch: every later
287 // spill/fault then failed its `len == slot_bytes` check and the
288 // residency stayed wedged for the life of the process.
289 self.scratch = buf;
290 if let Err(e) = r {
291 self.free_slots.push(slot);
292 self.disk_lru.push_front(key); // still on disk; re-pin (cold)
293 return Err(e);
294 }
295 self.free_disk.push(disk_slot);
296 self.swap.discard_record(disk_slot);
297 self.map.insert(key, Loc::Resident(slot));
298 self.lru.push_back(key);
299 self.stats.faults_from_disk += 1;
300 Ok(Some(slot))
301 }
302 None => {
303 self.stats.get_miss += 1;
304 Ok(None)
305 }
306 }
307 }
308
309 /// Drop `key` entirely, reclaiming its arena slot or disk record.
310 pub fn remove(&mut self, key: u64) {
311 match self.map.remove(&key) {
312 Some(Loc::Resident(slot)) | Some(Loc::Reserved(slot)) => {
313 self.lru_remove(key);
314 self.free_slots.push(slot);
315 }
316 Some(Loc::OnDisk(disk_slot)) => {
317 self.disk_lru_remove(key);
318 self.free_disk.push(disk_slot);
319 self.swap.discard_record(disk_slot);
320 }
321 None => {}
322 }
323 }
324
325 /// Read-pin `key` so its resident slot cannot be chosen as an eviction
326 /// victim while a client's one-sided RDMA READ of it is in flight (the
327 /// GET→RDMA-read race: the peer replies with the offset and drops the lock
328 /// before the client reads, so a concurrent allocation could otherwise
329 /// spill+reuse the slot). Ref-counted for concurrent readers; the first pin
330 /// removes the key from `lru` (like a `Reserved` slot). No-op unless the key
331 /// is currently `Resident`.
332 pub fn pin_read(&mut self, key: u64) {
333 if !matches!(self.map.get(&key), Some(Loc::Resident(_))) {
334 return;
335 }
336 let n = self.read_pins.get(&key).copied().unwrap_or(0);
337 if n == 0 {
338 self.lru_remove(key); // exclude from eviction victims while read
339 }
340 self.read_pins.insert(key, n + 1);
341 }
342
343 /// Release one read-pin taken by [`Residency::pin_read`]. When the last
344 /// reader releases, the key rejoins `lru` as hottest (it was just read).
345 /// No-op if the key holds no pin. Robust to the key having been removed
346 /// while pinned: only re-adds to `lru` if still `Resident` and not already
347 /// present.
348 pub fn unpin_read(&mut self, key: u64) {
349 let Some(n) = self.read_pins.get_mut(&key) else {
350 return;
351 };
352 *n -= 1;
353 if *n == 0 {
354 self.read_pins.remove(&key);
355 if matches!(self.map.get(&key), Some(Loc::Resident(_))) && !self.lru.contains(&key) {
356 self.lru.push_back(key); // hottest — just accessed
357 }
358 }
359 }
360
361 /// Active read-pin count (test/introspection).
362 pub fn read_pin_count(&self, key: u64) -> u32 {
363 self.read_pins.get(&key).copied().unwrap_or(0)
364 }
365
366 // ───────────────────── in-process one-shot helpers ─────────────────────
367
368 /// One-shot in-process PUT: reserve a slot, copy `bytes` into it, commit.
369 /// Same NEVER-reject chain as the two-phase peer path (alloc → spill the
370 /// coldest resident → drop the coldest on-disk key when capped). For
371 /// consumers whose data plane is a memcpy rather than a client RDMA-WRITE.
372 pub fn put_blob(&mut self, key: u64, bytes: &[u8]) -> Result<()> {
373 if bytes.len() != self.blob_bytes {
374 bail!(
375 "put_blob({key:#x}): {} bytes, expected {}",
376 bytes.len(),
377 self.blob_bytes
378 );
379 }
380 let slot = self.alloc(key)?;
381 if let Err(e) = self.arena.write_slot(slot, bytes) {
382 // Roll back the reservation so the slot is not stranded Reserved
383 // (a later GET must miss cleanly, never read a torn slot).
384 self.remove(key);
385 return Err(e);
386 }
387 self.commit(key)
388 }
389
390 /// One-shot in-process GET: fault `key` in (if spilled) and copy its blob
391 /// into `out`. `Ok(false)` = unknown key (caller recomputes).
392 pub fn get_blob(&mut self, key: u64, out: &mut [u8]) -> Result<bool> {
393 if out.len() != self.blob_bytes {
394 bail!(
395 "get_blob({key:#x}): {} bytes, expected {}",
396 out.len(),
397 self.blob_bytes
398 );
399 }
400 match self.locate(key)? {
401 Some(slot) => {
402 self.arena.read_slot(slot, out)?;
403 Ok(true)
404 }
405 None => Ok(false),
406 }
407 }
408
409 // ─────────────────────────── internals ───────────────────────────
410
411 /// A free arena slot, spilling the coldest resident slot to disk if none.
412 fn acquire_slot(&mut self) -> Result<usize> {
413 if let Some(s) = self.free_slots.pop() {
414 return Ok(s);
415 }
416 self.evict_coldest_to_disk()
417 }
418
419 /// Spill the LRU-coldest RESIDENT key to a disk record and return its freed
420 /// arena slot. Reserved (pinned) keys are never victims.
421 fn evict_coldest_to_disk(&mut self) -> Result<usize> {
422 let Some(victim) = self.lru.pop_front() else {
423 bail!(
424 "Residency: arena exhausted — all {} slots reserved (uncommitted \
425 PUTs) or read-pinned (in-flight RDMA READs)",
426 self.arena.num_slots()
427 );
428 };
429 let slot = match self.map.get(&victim).copied() {
430 Some(Loc::Resident(slot)) => slot,
431 other => bail!("LRU/map desync: victim {victim:#x} is {other:?}, expected Resident"),
432 };
433 // Bound the disk tier: drop the coldest on-disk snapshot(s) if at cap
434 // BEFORE claiming a disk slot for this spill.
435 self.make_disk_room();
436 let disk_slot = self.alloc_disk_slot();
437 let mut buf = std::mem::take(&mut self.scratch);
438 let res = self
439 .arena
440 .read_slot(slot, buf.as_mut_slice())
441 .and_then(|_| self.swap.write_record(disk_slot, buf.as_slice()));
442 self.scratch = buf;
443 if let Err(e) = res {
444 // Roll back: victim stays resident, disk slot returns to the pool.
445 self.free_disk.push(disk_slot);
446 self.lru.push_front(victim);
447 return Err(e);
448 }
449 self.map.insert(victim, Loc::OnDisk(disk_slot));
450 self.disk_lru.push_back(victim); // warmest on-disk entry
451 self.stats.spills_to_disk += 1;
452 Ok(slot)
453 }
454
455 /// Evict the coldest on-disk snapshot(s) until there is room for one more
456 /// under `max_disk_slots` (no-op when unbounded). A dropped snapshot's key
457 /// leaves the map entirely → a later GET misses → the model recomputes.
458 fn make_disk_room(&mut self) {
459 if self.max_disk_slots == 0 {
460 return;
461 }
462 while self.disk_lru.len() >= self.max_disk_slots {
463 let Some(cold) = self.disk_lru.pop_front() else {
464 break;
465 };
466 if let Some(Loc::OnDisk(ds)) = self.map.remove(&cold) {
467 self.free_disk.push(ds);
468 self.swap.discard_record(ds);
469 self.stats.disk_evictions += 1;
470 }
471 }
472 }
473
474 fn alloc_disk_slot(&mut self) -> usize {
475 if let Some(d) = self.free_disk.pop() {
476 d
477 } else {
478 let d = self.next_disk;
479 self.next_disk += 1;
480 d
481 }
482 }
483
484 fn disk_lru_remove(&mut self, key: u64) {
485 if let Some(pos) = self.disk_lru.iter().position(|&k| k == key) {
486 self.disk_lru.remove(pos);
487 }
488 }
489
490 fn lru_touch(&mut self, key: u64) {
491 self.lru_remove(key);
492 self.lru.push_back(key);
493 }
494
495 fn lru_remove(&mut self, key: u64) {
496 if let Some(pos) = self.lru.iter().position(|&k| k == key) {
497 self.lru.remove(pos);
498 }
499 }
500}