Skip to content

Commit 20698b6

Browse files
committed
feat(gms): add host-tier storage foundation
Signed-off-by: Maksim Khadkevich <mkhadkevich@nvidia.com>
1 parent ba7ba24 commit 20698b6

10 files changed

Lines changed: 5549 additions & 0 deletions

lib/gms_kv_ring/daemon/backend.py

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
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

Comments
 (0)