|
| 1 | +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. |
| 2 | +# SPDX-License-Identifier: Apache-2.0 |
| 3 | + |
| 4 | +"""Storage-backend abstraction. |
| 5 | +
|
| 6 | +The daemon's storage tier is a pluggable thing: today's `StorageTier` |
| 7 | +writes local files; tomorrow's `NixlBackend` ships bytes over a |
| 8 | +NIXL transport (POSIX plugin for disk, OBJ plugin for S3) and a |
| 9 | +`MooncakeBackend` uses the Mooncake transfer engine. |
| 10 | +
|
| 11 | +All backends implement the same `StorageBackend` interface so the |
| 12 | +daemon shell, the engine RPCs, and the sweeper can treat them |
| 13 | +uniformly. The slot dataclass `StorageSlot` is the abstract record; |
| 14 | +backends are free to subclass it to attach backend-specific resource |
| 15 | +handles (e.g., a file path for the local backend, an object key for |
| 16 | +NIXL OBJ). |
| 17 | +
|
| 18 | +Crash semantics: a backend method may raise. The daemon wraps backend |
| 19 | +calls in a `BackendSupervisor` (see `daemon/supervisor.py`) that |
| 20 | +catches Python exceptions, increments a metric, and re-creates the |
| 21 | +backend instance after N consecutive failures. A C-level SIGSEGV |
| 22 | +from a transport library will still kill the whole daemon — surviving |
| 23 | +that requires subprocess workers, which is a separate evolution.""" |
| 24 | + |
| 25 | +from __future__ import annotations |
| 26 | + |
| 27 | +from abc import ABC, abstractmethod |
| 28 | +from dataclasses import dataclass |
| 29 | +from typing import Optional |
| 30 | + |
| 31 | + |
| 32 | +@dataclass |
| 33 | +class StorageSlot: |
| 34 | + """Abstract record for one offloaded block. Backends MAY subclass |
| 35 | + to attach resource handles (file path, NIXL region id, S3 key); |
| 36 | + this base shape carries only what the cleanup primitives use.""" |
| 37 | + |
| 38 | + size: int |
| 39 | + # CRC32 (IEEE) of the payload bytes. Stored at demote, verified |
| 40 | + # on promote. Single source of truth across tier hops. |
| 41 | + crc: int |
| 42 | + # Last-write time, seconds since epoch. Set by demote() and |
| 43 | + # backend reconcile(). Drives LRU + TTL eviction without |
| 44 | + # per-slot stat() syscalls. |
| 45 | + mtime: float = 0.0 |
| 46 | + |
| 47 | + |
| 48 | +class StorageBackend(ABC): |
| 49 | + """Pluggable backend for the storage tier. |
| 50 | +
|
| 51 | + Lifecycle: |
| 52 | + - Backends are constructed at daemon startup. The constructor |
| 53 | + SHOULD probe its runtime deps and raise a clear error if |
| 54 | + they're missing (typically via `cls.is_available()` first). |
| 55 | + - `reconcile()` is called at construction to rebuild the index |
| 56 | + from durable state (disk files / remote registry / etc). |
| 57 | + - The daemon calls `demote/promote/get/release_*` during |
| 58 | + normal operation. |
| 59 | + - Cleanup primitives (`prune_older_than`, `enforce_byte_quota`, |
| 60 | + `enforce_per_engine_byte_quota`) are driven by the sweeper |
| 61 | + thread and operator-driven RPCs. |
| 62 | +
|
| 63 | + Slot identity is (engine_id, layer, offset). The backend owns the |
| 64 | + mapping from key → backend-specific resource.""" |
| 65 | + |
| 66 | + #: Short identifier exported via /metrics + logs. Override. |
| 67 | + name: str = "abstract" |
| 68 | + |
| 69 | + @classmethod |
| 70 | + def is_available(cls) -> bool: |
| 71 | + """Return True iff this backend's runtime dependencies are |
| 72 | + importable + usable in the current process. Default: True |
| 73 | + (no deps). Backends that wrap external libs override this to |
| 74 | + probe their imports lazily — so a daemon shipping with no |
| 75 | + NIXL installed still imports cleanly.""" |
| 76 | + return True |
| 77 | + |
| 78 | + # ----- core slot lifecycle ----- |
| 79 | + |
| 80 | + @abstractmethod |
| 81 | + def demote( |
| 82 | + self, |
| 83 | + engine_id: str, |
| 84 | + layer: int, |
| 85 | + offset: int, |
| 86 | + host_ptr: int, |
| 87 | + size: int, |
| 88 | + crc: int, |
| 89 | + ) -> StorageSlot: |
| 90 | + """Write `size` bytes from pinned-host `host_ptr` into durable |
| 91 | + backend storage, stamped with the provided CRC. Returns the |
| 92 | + slot record (subclass-specific resource handle attached). |
| 93 | +
|
| 94 | + Caller MUST guarantee the source bytes are stable for the |
| 95 | + duration of the call (typically by having synced the engine's |
| 96 | + evict stream before calling demote).""" |
| 97 | + |
| 98 | + @abstractmethod |
| 99 | + def promote( |
| 100 | + self, |
| 101 | + engine_id: str, |
| 102 | + layer: int, |
| 103 | + offset: int, |
| 104 | + dest_host_ptr: int, |
| 105 | + max_size: int, |
| 106 | + ) -> Optional[int]: |
| 107 | + """Read the backend's stored bytes into pinned-host |
| 108 | + `dest_host_ptr` and verify integrity. Returns the verified |
| 109 | + CRC on success. Returns None if: |
| 110 | + - the slot is unknown |
| 111 | + - reading the backend failed |
| 112 | + - the on-backend size doesn't fit in max_size |
| 113 | + - the CRC verify failed (at-rest corruption) |
| 114 | + Implementations MUST be safe to call concurrently with |
| 115 | + demote() of other keys.""" |
| 116 | + |
| 117 | + @abstractmethod |
| 118 | + def get( |
| 119 | + self, |
| 120 | + engine_id: str, |
| 121 | + layer: int, |
| 122 | + offset: int, |
| 123 | + ) -> Optional[StorageSlot]: |
| 124 | + """Index lookup. Cheap, in-memory.""" |
| 125 | + |
| 126 | + @abstractmethod |
| 127 | + def release_slot( |
| 128 | + self, |
| 129 | + engine_id: str, |
| 130 | + layer: int, |
| 131 | + offset: int, |
| 132 | + ) -> bool: |
| 133 | + """Drop the slot and free its backend resource. Idempotent.""" |
| 134 | + |
| 135 | + @abstractmethod |
| 136 | + def release_engine(self, engine_id: str) -> int: |
| 137 | + """Free every slot for `engine_id`. Returns count released.""" |
| 138 | + |
| 139 | + # ----- introspection / observability ----- |
| 140 | + |
| 141 | + @abstractmethod |
| 142 | + def n_slots(self) -> int: |
| 143 | + ... |
| 144 | + |
| 145 | + @abstractmethod |
| 146 | + def total_bytes(self) -> int: |
| 147 | + ... |
| 148 | + |
| 149 | + @abstractmethod |
| 150 | + def bytes_by_engine(self) -> dict[str, int]: |
| 151 | + ... |
| 152 | + |
| 153 | + @abstractmethod |
| 154 | + def stats(self) -> dict: |
| 155 | + ... |
| 156 | + |
| 157 | + # ----- cleanup primitives (driven by sweeper + RPCs) ----- |
| 158 | + |
| 159 | + @abstractmethod |
| 160 | + def prune_older_than( |
| 161 | + self, |
| 162 | + max_age_seconds: float, |
| 163 | + now: Optional[float] = None, |
| 164 | + ) -> int: |
| 165 | + ... |
| 166 | + |
| 167 | + @abstractmethod |
| 168 | + def enforce_byte_quota(self, max_bytes: int) -> int: |
| 169 | + ... |
| 170 | + |
| 171 | + @abstractmethod |
| 172 | + def enforce_per_engine_byte_quota( |
| 173 | + self, |
| 174 | + max_bytes_per_engine: int, |
| 175 | + ) -> int: |
| 176 | + ... |
| 177 | + |
| 178 | + # ----- optional: key snapshot for scrub ----- |
| 179 | + |
| 180 | + def snapshot_keys(self) -> list[tuple[str, int, int]]: |
| 181 | + """Return a copy of the current slot-key list. Used by the |
| 182 | + daemon's background backend-scrub thread, which doesn't |
| 183 | + want to hold the backend's lock across per-slot read + |
| 184 | + CRC compute (slow when reading large slots from disk). |
| 185 | +
|
| 186 | + Default implementation reads `self._slots` under |
| 187 | + `self._lock` if both attributes exist — true for every |
| 188 | + first-party backend (Local, NIXL, Mooncake). Backends that |
| 189 | + don't follow this convention return an empty list, opting |
| 190 | + out of scrub support.""" |
| 191 | + slots = getattr(self, "_slots", None) |
| 192 | + if slots is None: |
| 193 | + return [] |
| 194 | + lock = getattr(self, "_lock", None) |
| 195 | + if lock is None: |
| 196 | + return list(slots.keys()) |
| 197 | + with lock: |
| 198 | + return list(slots.keys()) |
| 199 | + |
| 200 | + # ----- optional GPU-direct data plane (GDS-style backends) ----- |
| 201 | + |
| 202 | + def supports_gpu_direct(self) -> bool: |
| 203 | + """True iff the backend has `demote_from_gpu` / |
| 204 | + `promote_into_gpu` data-plane methods (i.e., it can accept a |
| 205 | + GPU pointer as source/dest and skip CPU staging). Default |
| 206 | + False — most backends route through pinned host.""" |
| 207 | + return False |
| 208 | + |
| 209 | + # ----- optional sidecar manifest (mooncake-style backends) ----- |
| 210 | + |
| 211 | + def manifest_size_bytes(self) -> int: |
| 212 | + """Bytes on disk for any sidecar metadata file the backend |
| 213 | + maintains (e.g., MooncakeBackend's JSONL manifest). Default 0 |
| 214 | + — backends that walk durable storage on reconcile (Local, |
| 215 | + NIXL POSIX) don't have a sidecar.""" |
| 216 | + return 0 |
| 217 | + |
| 218 | + def compact_manifest(self) -> int: |
| 219 | + """Rewrite the sidecar manifest to drop dead records. |
| 220 | + Returns bytes freed. Default 0 (no-op) — only backends that |
| 221 | + maintain an append-only manifest implement this.""" |
| 222 | + return 0 |
| 223 | + |
| 224 | + def _sweep_once( |
| 225 | + self, |
| 226 | + max_age_seconds: Optional[float] = None, |
| 227 | + max_bytes: Optional[int] = None, |
| 228 | + max_bytes_per_engine: Optional[int] = None, |
| 229 | + ) -> tuple[int, int, int]: |
| 230 | + """Run one cleanup pass. |
| 231 | + Returns (ttl_evicted, per_engine_evicted, global_quota_evicted). |
| 232 | +
|
| 233 | + Order: TTL → per-engine → global. Each phase sees state from |
| 234 | + the prior. Backends may override if they want a different |
| 235 | + order, but the default works for any in-memory `_slots` shape.""" |
| 236 | + ttl_n = 0 |
| 237 | + per_eng_n = 0 |
| 238 | + global_n = 0 |
| 239 | + if max_age_seconds is not None: |
| 240 | + ttl_n = self.prune_older_than(float(max_age_seconds)) |
| 241 | + if max_bytes_per_engine is not None: |
| 242 | + per_eng_n = self.enforce_per_engine_byte_quota( |
| 243 | + int(max_bytes_per_engine), |
| 244 | + ) |
| 245 | + if max_bytes is not None: |
| 246 | + global_n = self.enforce_byte_quota(int(max_bytes)) |
| 247 | + return ttl_n, per_eng_n, global_n |
0 commit comments