Skip to main content

nodedb_graph/
sharded.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Tenant-partitioned CSR index.
4//!
5//! The graph engine multiplexes many tenants onto one per-core `CsrIndex`.
6//! Historically that multiplexing was done by prepending `"{tid}:"` to every
7//! node key before handing it to the CSR — a lexical scheme that required
8//! every API boundary (results, traversal output, algorithm emit) to strip
9//! the prefix on the way back out. The heuristic strippers were fragile
10//! (mangled any id that happened to match the shape) and the strip was
11//! opt-in (forgetting it leaked internal keys to clients).
12//!
13//! `ShardedCsrIndex` replaces that scheme with structural partitioning:
14//! one independent `CsrIndex` per tenant, keyed in a `HashMap<TenantId, _>`.
15//! Node keys inside a partition are the raw user-visible names — no prefix,
16//! no strip, no heuristic. Cross-tenant access is not a question of auth
17//! checks; it is structurally impossible because partitions don't share
18//! key space.
19//!
20//! ## Invariants
21//!
22//! - **One tenant per partition.** Every `CsrIndex` held in `partitions`
23//!   contains only one tenant's nodes and edges. Mixing is a programming
24//!   bug, not a runtime condition.
25//! - **No key prefixing.** Callers pass user-visible names directly; the
26//!   partition never sees a scoped form.
27//! - **Partition lifecycle is explicit.** `get_or_create` constructs on
28//!   first use; `drop_partition` removes atomically. No auto-eviction at
29//!   this layer (belongs to a future pool-management concern).
30//! - **Data Plane shape preserved.** This type is `!Send` in the same way
31//!   `CsrIndex` is (via `Cell<u32>` access counters). It is owned by a
32//!   single Data Plane core.
33
34use std::collections::HashMap;
35use std::collections::hash_map::{Entry, Iter, IterMut};
36
37use nodedb_mem::ScopedMemory;
38use nodedb_types::{DatabaseId, TenantId};
39
40use crate::GraphError;
41use crate::csr::CsrIndex;
42
43/// Per-tenant partitioned CSR index.
44///
45/// Holds one `CsrIndex` per tenant on the owning Data Plane core.
46/// Handlers resolve the caller's tenant to a partition up front; all
47/// downstream graph operations (algorithms, MATCH, traversal) run
48/// against that single partition and cannot reach any other tenant's
49/// state.
50pub struct ShardedCsrIndex {
51    partitions: HashMap<(DatabaseId, TenantId), CsrIndex>,
52}
53
54impl ShardedCsrIndex {
55    /// Construct an empty sharded index with no partitions.
56    pub fn new() -> Self {
57        Self {
58            partitions: HashMap::new(),
59        }
60    }
61
62    /// Shared access to a tenant's partition, if it exists.
63    ///
64    /// Returns `None` if the tenant has never inserted any graph data.
65    /// Callers expecting a read-only view of a possibly-empty partition
66    /// should treat `None` as "empty" rather than as an error.
67    pub fn partition(&self, db: DatabaseId, tid: TenantId) -> Option<&CsrIndex> {
68        self.partitions.get(&(db, tid))
69    }
70
71    /// Mutable access to a tenant's partition, if it exists.
72    pub fn partition_mut(&mut self, db: DatabaseId, tid: TenantId) -> Option<&mut CsrIndex> {
73        self.partitions.get_mut(&(db, tid))
74    }
75
76    /// Mutable access to a tenant's partition, creating an empty one
77    /// bound to `memory` on first use.
78    ///
79    /// This is the canonical write-path entry point: insertion handlers
80    /// call this once to resolve the partition, then operate on the
81    /// returned `&mut CsrIndex` exactly as they would on a standalone
82    /// instance.
83    pub fn get_or_create(
84        &mut self,
85        db: DatabaseId,
86        tid: TenantId,
87        memory: ScopedMemory,
88    ) -> &mut CsrIndex {
89        self.partitions
90            .entry((db, tid))
91            .or_insert_with(|| CsrIndex::new(memory))
92    }
93
94    /// Drop a tenant's entire graph state.
95    ///
96    /// Returns `true` if a partition existed and was removed, `false` if
97    /// the tenant had no graph state. Used by tenant-purge flows — O(1)
98    /// structural deletion replaces the former "range-scan and erase
99    /// every key with the `{tid}:` prefix" approach, which was both
100    /// slow and coupled to the lexical encoding.
101    pub fn drop_partition(&mut self, db: DatabaseId, tid: TenantId) -> bool {
102        self.partitions.remove(&(db, tid)).is_some()
103    }
104
105    /// Collection-scoped in-memory reclaim.
106    ///
107    /// The CSR is collection-agnostic in memory: a tenant's partition holds
108    /// edges from **all** collections in a single adjacency structure. There
109    /// is no per-collection sub-partition to drop. As a result this method is
110    /// intentionally a no-op — collection-scoped edge removal from persistent
111    /// storage is handled by [`EdgeStore::purge_collection`], and the stale
112    /// in-memory CSR state is eliminated on the next tenant `drop_partition`
113    /// call (triggered by tenant deletion) or on server restart (CSR is
114    /// rebuilt from the now-clean EdgeStore).
115    pub fn drop_collection(&mut self, _db: DatabaseId, _tid: TenantId, _collection: &str) {
116        // Intentional no-op. See doc comment above.
117    }
118
119    /// Whether a partition exists for the tenant.
120    pub fn contains_partition(&self, db: DatabaseId, tid: TenantId) -> bool {
121        self.partitions.contains_key(&(db, tid))
122    }
123
124    /// Number of tenants with graph state on this core.
125    pub fn partition_count(&self) -> usize {
126        self.partitions.len()
127    }
128
129    /// Iterate all (tenant, partition) pairs. Used for checkpointing,
130    /// memory accounting, and administrative views that genuinely
131    /// need to see every tenant's state.
132    pub fn iter(&self) -> Iter<'_, (DatabaseId, TenantId), CsrIndex> {
133        self.partitions.iter()
134    }
135
136    /// Mutable iteration over all partitions. Used by `compact()` and
137    /// similar maintenance passes that apply per-partition without
138    /// needing tenant routing.
139    pub fn iter_mut(&mut self) -> IterMut<'_, (DatabaseId, TenantId), CsrIndex> {
140        self.partitions.iter_mut()
141    }
142
143    /// Compact every partition. Mirrors `CsrIndex::compact` but across
144    /// the full set — maintenance handlers call this once per core.
145    ///
146    /// # Errors
147    ///
148    /// Returns the first [`GraphError::MemoryBudget`] encountered if any
149    /// partition has a memory governor installed and the budget is exhausted.
150    /// Partitions already compacted before the error are not rolled back.
151    pub fn compact_all(&mut self) -> Result<(), GraphError> {
152        for (_tid, part) in self.iter_mut() {
153            part.compact()?;
154        }
155        Ok(())
156    }
157
158    /// Replace an existing partition (or install a new one) with the
159    /// given `CsrIndex`. Used by the rebuild path — after rebuilding a
160    /// tenant's CSR from persistent edge storage, this installs it
161    /// atomically.
162    pub fn install_partition(&mut self, db: DatabaseId, tid: TenantId, csr: CsrIndex) {
163        self.partitions.insert((db, tid), csr);
164    }
165
166    /// Access or create a partition via the `Entry` API, for cases that
167    /// need conditional initialization with a non-default constructor.
168    pub fn entry(
169        &mut self,
170        db: DatabaseId,
171        tid: TenantId,
172    ) -> Entry<'_, (DatabaseId, TenantId), CsrIndex> {
173        self.partitions.entry((db, tid))
174    }
175}
176
177impl Default for ShardedCsrIndex {
178    fn default() -> Self {
179        Self::new()
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::test_support::test_memory;
187
188    fn tid(n: u64) -> TenantId {
189        TenantId::new(n)
190    }
191
192    const DB: DatabaseId = DatabaseId::DEFAULT;
193
194    #[test]
195    fn empty_sharded_has_no_partitions() {
196        let sharded = ShardedCsrIndex::new();
197        assert_eq!(sharded.partition_count(), 0);
198        assert!(!sharded.contains_partition(DB, tid(1)));
199        assert!(sharded.partition(DB, tid(1)).is_none());
200    }
201
202    #[test]
203    fn get_or_create_installs_empty_partition() {
204        let mut sharded = ShardedCsrIndex::new();
205        let part = sharded.get_or_create(DB, tid(7), test_memory());
206        assert_eq!(part.node_count(), 0);
207        assert!(sharded.contains_partition(DB, tid(7)));
208        assert_eq!(sharded.partition_count(), 1);
209    }
210
211    #[test]
212    fn partitions_are_isolated_by_tenant() {
213        // Two tenants insert identically-named nodes. Neither can see
214        // the other's graph state — the test encodes the core
215        // architectural guarantee of Option C: isolation by partition,
216        // not by key prefix.
217        let mut sharded = ShardedCsrIndex::new();
218
219        sharded
220            .get_or_create(DB, tid(1), test_memory())
221            .add_edge("alice", "knows", "bob")
222            .unwrap();
223        sharded
224            .get_or_create(DB, tid(2), test_memory())
225            .add_edge("alice", "knows", "carol")
226            .unwrap();
227
228        let p1 = sharded.partition(DB, tid(1)).unwrap();
229        let p2 = sharded.partition(DB, tid(2)).unwrap();
230
231        // Tenant 1 sees alice→bob; no carol.
232        assert!(p1.contains_node("alice"));
233        assert!(p1.contains_node("bob"));
234        assert!(!p1.contains_node("carol"));
235
236        // Tenant 2 sees alice→carol; no bob.
237        assert!(p2.contains_node("alice"));
238        assert!(p2.contains_node("carol"));
239        assert!(!p2.contains_node("bob"));
240
241        // `alice` exists in both partitions but refers to two different
242        // logical nodes — no collision, no ambiguity, no lexical prefix.
243    }
244
245    #[test]
246    fn node_names_are_unprefixed() {
247        let mut sharded = ShardedCsrIndex::new();
248        sharded
249            .get_or_create(DB, tid(42), test_memory())
250            .add_edge("alice", "knows", "bob")
251            .unwrap();
252        sharded
253            .get_or_create(DB, tid(42), test_memory())
254            .compact()
255            .expect("test governor ceiling covers this reservation");
256
257        let part = sharded.partition(DB, tid(42)).unwrap();
258        let alice_id = part.node_id("alice").expect("alice must be present");
259        // The stored name is exactly what the caller inserted — never
260        // `"42:alice"`. Structural partitioning keeps shard ids out of
261        // user-visible keys.
262        assert_eq!(part.node_name(alice_id), "alice");
263    }
264
265    #[test]
266    fn drop_partition_removes_tenant_state() {
267        let mut sharded = ShardedCsrIndex::new();
268        sharded
269            .get_or_create(DB, tid(1), test_memory())
270            .add_edge("a", "l", "b")
271            .unwrap();
272        assert!(sharded.contains_partition(DB, tid(1)));
273
274        assert!(sharded.drop_partition(DB, tid(1)));
275        assert!(!sharded.contains_partition(DB, tid(1)));
276        assert_eq!(sharded.partition_count(), 0);
277
278        // Second drop is a no-op, not an error.
279        assert!(!sharded.drop_partition(DB, tid(1)));
280    }
281
282    #[test]
283    fn drop_partition_does_not_touch_other_tenants() {
284        let mut sharded = ShardedCsrIndex::new();
285        sharded
286            .get_or_create(DB, tid(1), test_memory())
287            .add_edge("a", "l", "b")
288            .unwrap();
289        sharded
290            .get_or_create(DB, tid(2), test_memory())
291            .add_edge("c", "l", "d")
292            .unwrap();
293
294        sharded.drop_partition(DB, tid(1));
295        assert!(!sharded.contains_partition(DB, tid(1)));
296        assert!(sharded.contains_partition(DB, tid(2)));
297        assert!(sharded.partition(DB, tid(2)).unwrap().contains_node("c"));
298    }
299
300    #[test]
301    fn install_partition_replaces_existing() {
302        let mut sharded = ShardedCsrIndex::new();
303        sharded
304            .get_or_create(DB, tid(1), test_memory())
305            .add_edge("old", "l", "value")
306            .unwrap();
307
308        let mut replacement = CsrIndex::new(test_memory());
309        replacement.add_edge("new", "l", "value").unwrap();
310        sharded.install_partition(DB, tid(1), replacement);
311
312        let part = sharded.partition(DB, tid(1)).unwrap();
313        assert!(part.contains_node("new"));
314        assert!(!part.contains_node("old"));
315    }
316
317    #[test]
318    fn compact_all_applies_to_every_partition() {
319        let mut sharded = ShardedCsrIndex::new();
320        for t in 1..=3 {
321            sharded
322                .get_or_create(DB, tid(t), test_memory())
323                .add_edge("a", "l", "b")
324                .unwrap();
325        }
326        // Pre-compact: edges live in the write buffer. `compact_all`
327        // merges them into the dense CSR arrays for every partition.
328        sharded
329            .compact_all()
330            .expect("test governor ceiling covers this reservation");
331        for t in 1..=3 {
332            let part = sharded.partition(DB, tid(t)).unwrap();
333            assert_eq!(part.edge_count(), 1);
334            assert_eq!(part.node_count(), 2);
335        }
336    }
337}