Skip to main content

qualia_core_db/query/
graph_index.rs

1//! Revision-cached graph index — the per-cell index lifecycle (#22 step 3).
2//!
3//! Building a `QuinIndex` over the daemon-graph snapshot on every resolve would be O(n)
4//! per call. Instead we memoize it by `daemon_graph::graph_revision()`: the index is
5//! rebuilt LAZILY only when the graph has actually changed, so resolution is O(1)
6//! amortized. The cache is host-side (separate from the 42 MB SlgArena).
7//!
8//! "Per-cell" in the Fractal-Shard model means one cache per 512 MB cell; for the single
9//! daemon graph today this is that one cache. Streaming a huge graph via BIDX/demand-
10//! paging — so the index need not copy the whole snapshot — remains future work.
11
12#[cfg(not(target_arch = "wasm32"))]
13use crate::indexing::QuinIndex;
14use std::sync::RwLock;
15
16/// Memoizes a value `T` by a monotonically-advancing `revision`, rebuilding via `build`
17/// only when the supplied revision differs from the cached one.
18pub struct RevisionCache<T> {
19    inner: RwLock<Option<(u64, T)>>,
20}
21
22impl<T> RevisionCache<T> {
23    pub const fn new() -> Self {
24        Self {
25            inner: RwLock::new(None),
26        }
27    }
28
29    /// Run `f` against the value cached at `revision`, building it via `build` first if
30    /// the cache is empty or stale. `build` runs at most once per distinct revision, and
31    /// only when a caller actually needs the value (lazy).
32    pub fn with<R>(&self, revision: u64, build: impl FnOnce() -> T, f: impl FnOnce(&T) -> R) -> R {
33        // Fast path: cache present and fresh.
34        {
35            let guard = self.inner.read().unwrap();
36            if let Some((rev, value)) = guard.as_ref() {
37                if *rev == revision {
38                    return f(value);
39                }
40            }
41        }
42        // Slow path: (re)build and store, then serve.
43        let value = build();
44        {
45            let mut w = self.inner.write().unwrap();
46            *w = Some((revision, value));
47        }
48        let guard = self.inner.read().unwrap();
49        let (_, value) = guard.as_ref().expect("cache just populated");
50        f(value)
51    }
52}
53
54impl<T> Default for RevisionCache<T> {
55    fn default() -> Self {
56        Self::new()
57    }
58}
59
60#[cfg(not(target_arch = "wasm32"))]
61static GRAPH_INDEX: RevisionCache<QuinIndex> = RevisionCache::new();
62
63/// Run `f` against a `QuinIndex` over the current daemon graph, rebuilt only when the
64/// graph revision has changed since the last build. This is what `graph_resolve` (and
65/// other index consumers) route through, so a burst of resolves between graph changes
66/// shares a single O(n) build.
67// Routes through `daemon_graph` (the native daemon's in-memory graph), which does not exist on
68// wasm32; the only caller (mcp_tool_impls) is native-only too.
69#[cfg(not(target_arch = "wasm32"))]
70pub fn with_graph_index<R>(f: impl FnOnce(&QuinIndex) -> R) -> R {
71    let revision = crate::daemon_graph::graph_revision();
72    GRAPH_INDEX.with(
73        revision,
74        || {
75            let guard = crate::daemon_graph::graph_read_guard();
76            QuinIndex::from_slice(guard.as_slice())
77        },
78        f,
79    )
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use std::sync::atomic::{AtomicUsize, Ordering};
86
87    #[test]
88    fn rebuilds_only_on_revision_change() {
89        let cache: RevisionCache<usize> = RevisionCache::new();
90        let builds = AtomicUsize::new(0);
91
92        // First touch at rev 1 builds.
93        let v = cache.with(
94            1,
95            || {
96                builds.fetch_add(1, Ordering::SeqCst);
97                42
98            },
99            |v| *v,
100        );
101        assert_eq!(v, 42);
102        // Same rev: served from cache, no rebuild.
103        cache.with(
104            1,
105            || {
106                builds.fetch_add(1, Ordering::SeqCst);
107                0
108            },
109            |_| (),
110        );
111        assert_eq!(
112            builds.load(Ordering::SeqCst),
113            1,
114            "same revision must not rebuild"
115        );
116
117        // New rev: rebuild.
118        cache.with(
119            2,
120            || {
121                builds.fetch_add(1, Ordering::SeqCst);
122                99
123            },
124            |_| (),
125        );
126        assert_eq!(
127            builds.load(Ordering::SeqCst),
128            2,
129            "changed revision must rebuild"
130        );
131        // And stays cached at the new rev.
132        cache.with(
133            2,
134            || {
135                builds.fetch_add(1, Ordering::SeqCst);
136                0
137            },
138            |_| (),
139        );
140        assert_eq!(builds.load(Ordering::SeqCst), 2);
141    }
142}