Skip to main content

reddb_server/storage/index/
registry.rs

1//! Central index catalog — glues [`super::IndexBase`] implementations to
2//! the cost-based planner's [`crate::storage::query::planner::StatsProvider`]
3//! surface.
4//!
5//! Before this module, fase 1 defined the generic [`super::IndexBase`] trait
6//! and fase 3.1 added a `StatsProvider` that the planner consults for real
7//! statistics. But nothing *published* index stats into the provider — every
8//! storage component kept its indexes private. The registry is the missing
9//! glue: storages register any [`IndexBase`] trait object keyed by scope,
10//! and the registry exposes those stats through the `StatsProvider` surface.
11//!
12//! # Scopes
13//!
14//! Indexes live in three scopes so the registry can match planner queries
15//! without leaking storage-specific paths:
16//!
17//! - `Table { name, column }` — secondary indexes on table columns
18//! - `Graph { collection }`   — adjacency / property indexes on graph nodes
19//! - `Timeseries { series }`  — temporal indexes for a named series
20//!
21//! # Thread safety
22//!
23//! The registry is `Send + Sync` via `RwLock`. Reads are the hot path
24//! (planner consults per query), writes are rare (startup, schema
25//! migrations). Callers share an `Arc<IndexRegistry>`.
26
27use std::collections::HashMap;
28use std::sync::{Arc, RwLock};
29
30use super::{IndexBase, IndexStats};
31
32/// Where an index lives in the logical namespace.
33#[derive(Debug, Clone, PartialEq, Eq, Hash)]
34pub enum IndexScope {
35    /// Secondary index on a table column.
36    Table { table: String, column: String },
37    /// Index attached to a graph collection (adjacency, label, type).
38    Graph { collection: String },
39    /// Temporal / property index on a named timeseries.
40    Timeseries { series: String },
41}
42
43impl IndexScope {
44    /// Build a table-scoped key.
45    pub fn table(table: impl Into<String>, column: impl Into<String>) -> Self {
46        Self::Table {
47            table: table.into(),
48            column: column.into(),
49        }
50    }
51
52    /// Build a graph-scoped key.
53    pub fn graph(collection: impl Into<String>) -> Self {
54        Self::Graph {
55            collection: collection.into(),
56        }
57    }
58
59    /// Build a timeseries-scoped key.
60    pub fn timeseries(series: impl Into<String>) -> Self {
61        Self::Timeseries {
62            series: series.into(),
63        }
64    }
65}
66
67/// A trait-object-friendly snapshot of an index. Stored in the registry so
68/// readers get `Arc<dyn IndexBase>` without worrying about concrete types.
69pub type SharedIndex = Arc<dyn IndexBase>;
70
71/// Central index catalog.
72pub struct IndexRegistry {
73    entries: RwLock<HashMap<IndexScope, SharedIndex>>,
74}
75
76impl IndexRegistry {
77    /// Create an empty registry.
78    pub fn new() -> Self {
79        Self {
80            entries: RwLock::new(HashMap::new()),
81        }
82    }
83
84    /// Register (or replace) an index in the given scope. Returns the
85    /// previous entry if one existed, so callers can drop it deterministically.
86    pub fn register(&self, scope: IndexScope, index: SharedIndex) -> Option<SharedIndex> {
87        self.entries
88            .write()
89            .ok()
90            .and_then(|mut map| map.insert(scope, index))
91    }
92
93    /// Remove an index from the registry. Returns it if it existed.
94    pub fn unregister(&self, scope: &IndexScope) -> Option<SharedIndex> {
95        self.entries
96            .write()
97            .ok()
98            .and_then(|mut map| map.remove(scope))
99    }
100
101    /// Look up an index by scope.
102    pub fn get(&self, scope: &IndexScope) -> Option<SharedIndex> {
103        self.entries
104            .read()
105            .ok()
106            .and_then(|map| map.get(scope).cloned())
107    }
108
109    /// Convenience: fetch stats for a `(table, column)` index.
110    pub fn table_index_stats(&self, table: &str, column: &str) -> Option<IndexStats> {
111        self.get(&IndexScope::table(table, column))
112            .map(|idx| idx.stats())
113    }
114
115    /// Convenience: fetch stats for a graph collection index.
116    pub fn graph_index_stats(&self, collection: &str) -> Option<IndexStats> {
117        self.get(&IndexScope::graph(collection))
118            .map(|idx| idx.stats())
119    }
120
121    /// Convenience: fetch stats for a named timeseries index.
122    pub fn timeseries_index_stats(&self, series: &str) -> Option<IndexStats> {
123        self.get(&IndexScope::timeseries(series))
124            .map(|idx| idx.stats())
125    }
126
127    /// Total number of registered indexes across every scope.
128    pub fn len(&self) -> usize {
129        self.entries.read().map(|m| m.len()).unwrap_or(0)
130    }
131
132    /// Is the registry empty?
133    pub fn is_empty(&self) -> bool {
134        self.len() == 0
135    }
136
137    /// Iterate over every `(scope, stats)` pair currently registered.
138    /// Clones stats so callers don't hold the read lock.
139    pub fn snapshot(&self) -> Vec<(IndexScope, IndexStats)> {
140        self.entries
141            .read()
142            .map(|map| {
143                map.iter()
144                    .map(|(scope, idx)| (scope.clone(), idx.stats()))
145                    .collect()
146            })
147            .unwrap_or_default()
148    }
149
150    /// Drop every registered index.
151    pub fn clear(&self) {
152        if let Ok(mut map) = self.entries.write() {
153            map.clear();
154        }
155    }
156}
157
158impl Default for IndexRegistry {
159    fn default() -> Self {
160        Self::new()
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use crate::storage::index::{IndexBase, IndexKind, IndexStats};
168
169    struct FakeIndex {
170        name: String,
171        kind: IndexKind,
172        stats: IndexStats,
173    }
174
175    impl FakeIndex {
176        fn new(name: &str, kind: IndexKind, distinct: usize) -> Self {
177            Self {
178                name: name.to_string(),
179                kind,
180                stats: IndexStats {
181                    entries: distinct,
182                    distinct_keys: distinct,
183                    approx_bytes: 0,
184                    kind,
185                    has_bloom: false,
186                    index_correlation: 0.0,
187                },
188            }
189        }
190    }
191
192    impl IndexBase for FakeIndex {
193        fn name(&self) -> &str {
194            &self.name
195        }
196        fn kind(&self) -> IndexKind {
197            self.kind
198        }
199        fn stats(&self) -> IndexStats {
200            self.stats.clone()
201        }
202    }
203
204    fn shared(name: &str, kind: IndexKind, distinct: usize) -> SharedIndex {
205        Arc::new(FakeIndex::new(name, kind, distinct))
206    }
207
208    #[test]
209    fn register_and_lookup_table_scope() {
210        let reg = IndexRegistry::new();
211        let prev = reg.register(
212            IndexScope::table("users", "email"),
213            shared("users.email", IndexKind::Hash, 1_000_000),
214        );
215        assert!(prev.is_none());
216
217        let stats = reg.table_index_stats("users", "email").unwrap();
218        assert_eq!(stats.distinct_keys, 1_000_000);
219        assert_eq!(stats.kind, IndexKind::Hash);
220    }
221
222    #[test]
223    fn register_replaces_existing() {
224        let reg = IndexRegistry::new();
225        reg.register(
226            IndexScope::table("t", "c"),
227            shared("old", IndexKind::BTree, 10),
228        );
229        let replaced = reg.register(
230            IndexScope::table("t", "c"),
231            shared("new", IndexKind::Hash, 100),
232        );
233        assert!(replaced.is_some());
234        let stats = reg.table_index_stats("t", "c").unwrap();
235        assert_eq!(stats.distinct_keys, 100);
236        assert_eq!(stats.kind, IndexKind::Hash);
237    }
238
239    #[test]
240    fn unregister_removes_entry() {
241        let reg = IndexRegistry::new();
242        reg.register(
243            IndexScope::graph("hosts"),
244            shared("adjacency", IndexKind::GraphAdjacency, 50),
245        );
246        assert_eq!(reg.len(), 1);
247
248        let removed = reg.unregister(&IndexScope::graph("hosts"));
249        assert!(removed.is_some());
250        assert_eq!(reg.len(), 0);
251        assert!(reg.graph_index_stats("hosts").is_none());
252    }
253
254    #[test]
255    fn multi_scope_registration() {
256        let reg = IndexRegistry::new();
257        reg.register(
258            IndexScope::table("users", "id"),
259            shared("u.id", IndexKind::BTree, 10_000),
260        );
261        reg.register(
262            IndexScope::graph("social"),
263            shared("social.adj", IndexKind::GraphAdjacency, 5_000),
264        );
265        reg.register(
266            IndexScope::timeseries("cpu.idle"),
267            shared("cpu.temporal", IndexKind::Temporal, 2_000),
268        );
269
270        assert_eq!(reg.len(), 3);
271        assert!(reg.table_index_stats("users", "id").is_some());
272        assert!(reg.graph_index_stats("social").is_some());
273        assert!(reg.timeseries_index_stats("cpu.idle").is_some());
274    }
275
276    #[test]
277    fn snapshot_returns_all_entries() {
278        let reg = IndexRegistry::new();
279        reg.register(
280            IndexScope::table("a", "x"),
281            shared("a.x", IndexKind::Hash, 5),
282        );
283        reg.register(
284            IndexScope::table("a", "y"),
285            shared("a.y", IndexKind::Hash, 6),
286        );
287
288        let snap = reg.snapshot();
289        assert_eq!(snap.len(), 2);
290        let kinds: Vec<IndexKind> = snap.iter().map(|(_, s)| s.kind).collect();
291        assert!(kinds.iter().all(|k| *k == IndexKind::Hash));
292    }
293
294    #[test]
295    fn clear_drops_everything() {
296        let reg = IndexRegistry::new();
297        reg.register(
298            IndexScope::table("a", "x"),
299            shared("a.x", IndexKind::Hash, 5),
300        );
301        reg.clear();
302        assert!(reg.is_empty());
303    }
304
305    #[test]
306    fn concurrent_registration_is_safe() {
307        use std::thread;
308
309        let reg = Arc::new(IndexRegistry::new());
310        let mut handles = vec![];
311        for t in 0..4u32 {
312            let reg_c = Arc::clone(&reg);
313            handles.push(thread::spawn(move || {
314                for i in 0..50u32 {
315                    let scope = IndexScope::table(format!("t{t}"), format!("c{i}"));
316                    reg_c.register(
317                        scope,
318                        shared(&format!("t{t}.c{i}"), IndexKind::BTree, i as usize + 1),
319                    );
320                }
321            }));
322        }
323        for h in handles {
324            h.join().unwrap();
325        }
326        assert_eq!(reg.len(), 200);
327    }
328}