Skip to main content

cgn_core/
prefix.rs

1//! Prefix-aware lookup index used by the router.
2//!
3//! Each chunk hash (32 bytes, BLAKE3 over `TOKENS_PER_CHUNK` token ids — see
4//! [`crate::hash`]) is mapped to the set of nodes that currently hold that
5//! chunk, with TTL and last-seen timestamps for staleness control.
6//!
7//! The index is concurrent-safe and read-mostly. It is rebuilt incrementally
8//! from etcd / gossip events; the router queries it on every request to
9//! compute KV-overlap scores.
10
11use std::time::{Duration, Instant};
12
13use dashmap::DashMap;
14use parking_lot::RwLock;
15
16/// Per-node entry stored under each prefix.
17#[derive(Debug, Clone)]
18pub struct NodeEntry {
19    pub node_id: String,
20    pub last_seen: Instant,
21}
22
23/// Concurrent prefix → nodes index. The internal map is `DashMap` keyed by
24/// the 32-byte digest. Per-prefix node lists are guarded by a `RwLock` to
25/// avoid lock contention on hot keys.
26#[derive(Default)]
27pub struct PrefixIndex {
28    /// digest → list of nodes that own this chunk.
29    inner: DashMap<[u8; 32], RwLock<Vec<NodeEntry>>>,
30    /// Entries older than this are garbage-collected on access.
31    ttl: Duration,
32}
33
34impl PrefixIndex {
35    pub fn new(ttl: Duration) -> Self {
36        Self {
37            inner: DashMap::new(),
38            ttl,
39        }
40    }
41
42    /// Record that `node_id` currently holds `digest`.
43    pub fn insert(&self, digest: [u8; 32], node_id: &str) {
44        let now = Instant::now();
45        let entry = self.inner.entry(digest).or_default();
46        let mut v = entry.write();
47        if let Some(e) = v.iter_mut().find(|e| e.node_id == node_id) {
48            e.last_seen = now;
49        } else {
50            v.push(NodeEntry {
51                node_id: node_id.to_string(),
52                last_seen: now,
53            });
54        }
55    }
56
57    /// Drop a node from the index entirely (invoked on graceful drain).
58    pub fn forget_node(&self, node_id: &str) {
59        for mut e in self.inner.iter_mut() {
60            e.value_mut().write().retain(|n| n.node_id != node_id);
61        }
62        // Lazy purge of empty entries.
63        self.inner.retain(|_, v| !v.read().is_empty());
64    }
65
66    /// Look up the live nodes that currently hold `digest`.
67    pub fn lookup(&self, digest: &[u8; 32]) -> Vec<String> {
68        let Some(entry) = self.inner.get(digest) else {
69            return Vec::new();
70        };
71        let now = Instant::now();
72        let g = entry.read();
73        g.iter()
74            .filter(|n| now.duration_since(n.last_seen) < self.ttl)
75            .map(|n| n.node_id.clone())
76            .collect()
77    }
78
79    /// Compute, for every node, how many of `digests` it holds. Returns a
80    /// hash map of `node_id -> count`. The router uses this to score nodes
81    /// by *content* overlap.
82    ///
83    /// This counts every matching digest regardless of position. Use
84    /// [`Self::longest_prefix_overlap`] when the digests are
85    /// sequence-chained (`hash_seq_chunks`) and you want the actual
86    /// length of the cached prefix the node can serve without a prefill.
87    pub fn overlap(&self, digests: &[[u8; 32]]) -> std::collections::HashMap<String, usize> {
88        let mut out: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
89        let now = Instant::now();
90        for d in digests {
91            if let Some(entry) = self.inner.get(d) {
92                let g = entry.read();
93                for n in g.iter() {
94                    if now.duration_since(n.last_seen) < self.ttl {
95                        *out.entry(n.node_id.clone()).or_insert(0) += 1;
96                    }
97                }
98            }
99        }
100        out
101    }
102
103    /// For each node, report the length of the longest contiguous prefix
104    /// of `digests` (starting at index 0) that the node holds.
105    ///
106    /// `digests` MUST be sequence-chained — i.e. produced by
107    /// [`crate::hash::hash_seq_chunks`] — otherwise the result is
108    /// meaningless. With sequence-chained digests, "node X holds chunks
109    /// `0..K` in the right order" reduces to "node X has digest_i for
110    /// every i in `0..K`", because the chained hash already encodes
111    /// positional dependency.
112    ///
113    /// Returns `node_id -> prefix_length_in_chunks` for every node that
114    /// holds at least chunk 0. Nodes with no prefix overlap are absent
115    /// from the map.
116    pub fn longest_prefix_overlap(
117        &self,
118        digests: &[[u8; 32]],
119    ) -> std::collections::HashMap<String, usize> {
120        let mut out: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
121        if digests.is_empty() {
122            return out;
123        }
124        let now = Instant::now();
125
126        // Seed with nodes holding chunk 0.
127        let first = match self.inner.get(&digests[0]) {
128            Some(e) => e,
129            None => return out,
130        };
131        let mut active: std::collections::HashSet<String> = first
132            .read()
133            .iter()
134            .filter(|n| now.duration_since(n.last_seen) < self.ttl)
135            .map(|n| n.node_id.clone())
136            .collect();
137        for n in &active {
138            out.insert(n.clone(), 1);
139        }
140        drop(first);
141
142        // Walk forward, intersecting the active set with the holders of
143        // each successive chunk. Once a node drops out it cannot rejoin.
144        for (i, d) in digests.iter().enumerate().skip(1) {
145            if active.is_empty() {
146                break;
147            }
148            let holders: std::collections::HashSet<String> = match self.inner.get(d) {
149                Some(e) => e
150                    .read()
151                    .iter()
152                    .filter(|n| now.duration_since(n.last_seen) < self.ttl)
153                    .map(|n| n.node_id.clone())
154                    .collect(),
155                None => Default::default(),
156            };
157            active.retain(|n| holders.contains(n));
158            for n in &active {
159                out.insert(n.clone(), i + 1);
160            }
161        }
162        out
163    }
164
165    /// Total tracked digests.
166    pub fn len(&self) -> usize {
167        self.inner.len()
168    }
169    pub fn is_empty(&self) -> bool {
170        self.inner.is_empty()
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    fn d(byte: u8) -> [u8; 32] {
179        let mut x = [0u8; 32];
180        x[0] = byte;
181        x
182    }
183
184    #[test]
185    fn insert_and_lookup() {
186        let ix = PrefixIndex::new(Duration::from_secs(60));
187        ix.insert(d(1), "node-a");
188        ix.insert(d(1), "node-b");
189        ix.insert(d(2), "node-a");
190        assert_eq!(ix.len(), 2);
191
192        let mut nodes = ix.lookup(&d(1));
193        nodes.sort();
194        assert_eq!(nodes, vec!["node-a".to_string(), "node-b".to_string()]);
195    }
196
197    #[test]
198    fn forget_node_purges_entries() {
199        let ix = PrefixIndex::new(Duration::from_secs(60));
200        ix.insert(d(1), "node-a");
201        ix.insert(d(2), "node-a");
202        ix.forget_node("node-a");
203        assert!(ix.is_empty());
204    }
205
206    #[test]
207    fn overlap_counts() {
208        let ix = PrefixIndex::new(Duration::from_secs(60));
209        ix.insert(d(1), "n1");
210        ix.insert(d(2), "n1");
211        ix.insert(d(3), "n2");
212        let counts = ix.overlap(&[d(1), d(2), d(3), d(4)]);
213        assert_eq!(counts.get("n1").copied(), Some(2));
214        assert_eq!(counts.get("n2").copied(), Some(1));
215    }
216
217    #[test]
218    fn longest_prefix_overlap_walks_in_order() {
219        // n1 holds chunks 0, 1, 2 → prefix length 3.
220        // n2 holds chunks 0, 2    → prefix length 1 (gap at chunk 1).
221        // n3 holds nothing useful → absent from map.
222        let ix = PrefixIndex::new(Duration::from_secs(60));
223        ix.insert(d(1), "n1");
224        ix.insert(d(2), "n1");
225        ix.insert(d(3), "n1");
226        ix.insert(d(1), "n2");
227        ix.insert(d(3), "n2");
228        ix.insert(d(7), "n3");
229
230        let req = [d(1), d(2), d(3), d(4)];
231        let lp = ix.longest_prefix_overlap(&req);
232        assert_eq!(lp.get("n1").copied(), Some(3));
233        assert_eq!(lp.get("n2").copied(), Some(1));
234        assert!(!lp.contains_key("n3"));
235    }
236
237    #[test]
238    fn longest_prefix_overlap_empty_when_no_chunk0() {
239        let ix = PrefixIndex::new(Duration::from_secs(60));
240        ix.insert(d(2), "n1");
241        ix.insert(d(3), "n1");
242        let lp = ix.longest_prefix_overlap(&[d(1), d(2), d(3)]);
243        assert!(lp.is_empty(), "no node holds chunk 0 → no prefix overlap");
244    }
245}