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}