Skip to main content

khive_runtime/
reference_ring.rs

1//! Recently-referenced ring: a bounded, per-`(namespace, actor)` cache of ids
2//! this actor recently touched by name, held in daemon-warm memory only.
3//!
4//! No schema, no persistence, no migration — a daemon restart empties it, and
5//! that is fine: its miss path is the hybrid-search fallback in
6//! `reference_resolution::resolve_reference`. Admission is gated by the
7//! dispatch boundary (`pack.rs::dispatch_with_identity`) under a strict rule:
8//! only by-id touches (create/get/update/delete/merge/link) admit an id.
9//! `search`/`list` result-sets never enter the ring — the anaphora signal is
10//! the sparsity, so admitting every search hit would drown "the old record"
11//! in noise.
12
13use std::collections::{HashMap, VecDeque};
14use std::sync::Mutex;
15use std::time::{Duration, Instant};
16
17use serde_json::Value;
18use uuid::Uuid;
19
20/// Default ring size per `(namespace, actor)` key.
21pub const DEFAULT_RING_CAPACITY: usize = 64;
22
23/// Default eviction age.
24pub const DEFAULT_RING_TTL: Duration = Duration::from_secs(30 * 60);
25
26/// Maximum distinct `(namespace, actor)` keys held at once. The per-key ring
27/// is capacity/TTL-bounded (above), but the outer map itself has no such
28/// bound by default — a daemon serving many transient actor ids needs an
29/// eviction rule for the map too, or it grows without limit. LRU by
30/// most-recent touch, sized generously above the 64-entry inner bound since
31/// this is the actor-fanout axis, not the per-actor recency axis.
32pub const DEFAULT_MAX_OUTER_KEYS: usize = 4096;
33
34/// One admitted id: the id itself, a best-effort display name (drawn from the
35/// dispatch result JSON — never a fresh fetch, to keep admission cheap on the
36/// Tier-1 latency budget), and the instant it was touched.
37#[derive(Clone, Debug)]
38pub struct RingEntry {
39    pub id: Uuid,
40    pub name: Option<String>,
41    pub touched_at: Instant,
42}
43
44type RingKey = (String, String);
45
46#[derive(Default)]
47struct RingState {
48    rings: HashMap<RingKey, VecDeque<RingEntry>>,
49}
50
51/// Daemon-warm, actor-scoped recently-referenced ring.
52///
53/// Privacy is structural: rings are keyed by `(namespace, actor)` and a
54/// snapshot for one actor never observes another actor's entries, even
55/// within the same namespace.
56pub struct ReferenceRing {
57    state: Mutex<RingState>,
58    capacity: usize,
59    ttl: Duration,
60    max_outer_keys: usize,
61}
62
63impl Default for ReferenceRing {
64    fn default() -> Self {
65        Self::new()
66    }
67}
68
69impl ReferenceRing {
70    pub fn new() -> Self {
71        Self::with_bounds(DEFAULT_RING_CAPACITY, DEFAULT_RING_TTL)
72    }
73
74    pub fn with_bounds(capacity: usize, ttl: Duration) -> Self {
75        Self::with_bounds_and_outer_limit(capacity, ttl, DEFAULT_MAX_OUTER_KEYS)
76    }
77
78    pub fn with_bounds_and_outer_limit(
79        capacity: usize,
80        ttl: Duration,
81        max_outer_keys: usize,
82    ) -> Self {
83        Self {
84            state: Mutex::new(RingState::default()),
85            capacity,
86            ttl,
87            max_outer_keys,
88        }
89    }
90
91    fn key(namespace: &str, actor: &str) -> RingKey {
92        (namespace.to_owned(), actor.to_owned())
93    }
94
95    /// Lock the shared state, recovering from mutex poisoning instead of
96    /// panicking: ring admission is best-effort cache maintenance and must
97    /// never turn an unrelated panic into a failure for every future caller.
98    fn lock_state(&self) -> std::sync::MutexGuard<'_, RingState> {
99        match self.state.lock() {
100            Ok(guard) => guard,
101            Err(poisoned) => {
102                tracing::warn!(
103                    "reference ring mutex poisoned by a prior panic; recovering inner state \
104                     (ring admission/lookup is best-effort and must never fail a dispatch)"
105                );
106                poisoned.into_inner()
107            }
108        }
109    }
110
111    /// Evict entries older than `ttl` from the front of `ring` (front = oldest,
112    /// since admission always pushes to the back with the current instant).
113    fn evict_stale(ring: &mut VecDeque<RingEntry>, ttl: Duration, now: Instant) {
114        while let Some(front) = ring.front() {
115            if now.duration_since(front.touched_at) > ttl {
116                ring.pop_front();
117            } else {
118                break;
119            }
120        }
121    }
122
123    /// Drop empty/aged-out outer-map keys, then if still over `max_outer_keys`
124    /// evict the least-recently-touched surviving keys until back within
125    /// budget. `exempt` (the key that triggered this admission) is never
126    /// evicted: admitting an id must never immediately evict its own ring.
127    fn prune_outer_map(
128        rings: &mut HashMap<RingKey, VecDeque<RingEntry>>,
129        ttl: Duration,
130        max_outer_keys: usize,
131        now: Instant,
132        exempt: &RingKey,
133    ) {
134        rings.retain(|_, ring| {
135            Self::evict_stale(ring, ttl, now);
136            !ring.is_empty()
137        });
138        if rings.len() <= max_outer_keys {
139            return;
140        }
141        let mut by_recency: Vec<(RingKey, Instant)> = rings
142            .iter()
143            .filter(|(k, _)| *k != exempt)
144            .filter_map(|(k, ring)| ring.back().map(|e| (k.clone(), e.touched_at)))
145            .collect();
146        by_recency.sort_by_key(|(_, touched_at)| *touched_at);
147        let overflow = rings.len().saturating_sub(max_outer_keys);
148        for (key, _) in by_recency.into_iter().take(overflow) {
149            rings.remove(&key);
150        }
151    }
152
153    /// Admit `id` into the `(namespace, actor)` ring, touching it to "now".
154    ///
155    /// Re-admitting an id already present moves it to the back (most-recent)
156    /// instead of duplicating the entry. Eviction is size-or-age, then the
157    /// outer map itself is pruned (see `prune_outer_map`) so it never grows
158    /// without bound.
159    pub fn admit(&self, namespace: &str, actor: &str, id: Uuid, name: Option<String>) {
160        let now = Instant::now();
161        let mut state = self.lock_state();
162        let key = Self::key(namespace, actor);
163        {
164            let ring = state.rings.entry(key.clone()).or_default();
165            Self::evict_stale(ring, self.ttl, now);
166            ring.retain(|e| e.id != id);
167            ring.push_back(RingEntry {
168                id,
169                name,
170                touched_at: now,
171            });
172            while ring.len() > self.capacity {
173                ring.pop_front();
174            }
175        }
176        Self::prune_outer_map(&mut state.rings, self.ttl, self.max_outer_keys, now, &key);
177    }
178
179    /// Snapshot the live (non-stale) entries for `(namespace, actor)`,
180    /// most-recently-touched first. Returns an empty vec for an unknown or
181    /// empty key: never an error, since a ring miss is always legitimate
182    /// (fresh session, restarted daemon, cross-actor query).
183    ///
184    /// Also runs `prune_outer_map` over the whole outer map, not just the
185    /// queried key: otherwise a daemon that only ever reads (never admits)
186    /// would never prune any other actor's stale key.
187    pub fn snapshot(&self, namespace: &str, actor: &str) -> Vec<RingEntry> {
188        let now = Instant::now();
189        let mut state = self.lock_state();
190        let key = Self::key(namespace, actor);
191        let snap: Vec<RingEntry> = match state.rings.get_mut(&key) {
192            Some(ring) => {
193                Self::evict_stale(ring, self.ttl, now);
194                ring.iter().rev().cloned().collect()
195            }
196            None => Vec::new(),
197        };
198        Self::prune_outer_map(&mut state.rings, self.ttl, self.max_outer_keys, now, &key);
199        snap
200    }
201}
202
203/// Display name extracted from a dispatch result's `name` field. No fallback
204/// to a note's `content`: the ring only ever admits entities, and free text
205/// must never stand in for an entity's actual name.
206fn display_name(result: &Value) -> Option<String> {
207    let name = result.get("name").and_then(Value::as_str)?;
208    let trimmed = name.trim();
209    (!trimmed.is_empty()).then(|| trimmed.to_string())
210}
211
212/// The nine closed entity kinds. Duplicated here rather than depending on
213/// khive-pack-kg's vocab, which would invert the crate dependency direction.
214const ENTITY_KINDS: [&str; 9] = [
215    "concept", "document", "dataset", "project", "person", "org", "artifact", "service", "resource",
216];
217
218fn is_entity_kind_value(v: &str) -> bool {
219    v == "entity" || ENTITY_KINDS.contains(&v)
220}
221
222/// Whether a `create`/`get`/`update`/`delete` result JSON denotes an entity
223/// (the ring only ever admits entity ids, plus `link` endpoints). No extra
224/// storage read: the discriminator is read off the shape khive-pack-kg's
225/// handlers already return.
226///
227/// - `edge`/`event` results carry an explicit top-level `"kind": "edge"` /
228///   `"kind": "event"`: never entities.
229/// - `create`/`get`/`update` return the raw storage record: entities always
230///   serialize an `entity_type` key (even as `null`), notes always serialize
231///   a `content` key: the two shapes never overlap, so key presence alone
232///   is a reliable substrate discriminator.
233/// - `delete` returns a synthetic `{deleted, id, kind}` summary carrying
234///   neither field; its `kind` is the caller-supplied request param verbatim,
235///   which may be absent/ambiguous. Absent or ambiguous `kind` never admits:
236///   a delete admission is a nice-to-have, so skipping is always the safe
237///   choice over guessing.
238fn substrate_admits_as_entity(obj: &serde_json::Map<String, Value>) -> bool {
239    if matches!(
240        obj.get("kind").and_then(Value::as_str),
241        Some("edge") | Some("event")
242    ) {
243        return false;
244    }
245    if obj.contains_key("content") {
246        return false;
247    }
248    if obj.contains_key("entity_type") {
249        return true;
250    }
251    obj.get("kind")
252        .and_then(Value::as_str)
253        .is_some_and(is_entity_kind_value)
254}
255
256/// Compute the `(id, name)` pairs a successful `verb` dispatch admits to the
257/// ring, from its already-serialized JSON `result` alone — no extra storage
258/// reads, so admission stays cheap on the Tier-1 latency budget.
259///
260/// Only singleton by-id touches admit: `create`, `get`, `update`, `delete`,
261/// `merge`, and `link` (both endpoints). Bulk shapes (`items=[...]`,
262/// `links=[...]`) are identifiable by an `attempted` count in their response
263/// and are excluded: they name multiple ids, not the one-caller-named-id
264/// semantic the ring exists to serve. `search`/`list` never reach this
265/// function — they are not in the verb match below.
266pub(crate) fn ring_admissions_for(verb: &str, result: &Value) -> Vec<(Uuid, Option<String>)> {
267    let Some(obj) = result.as_object() else {
268        return Vec::new();
269    };
270    if obj.contains_key("attempted") {
271        return Vec::new();
272    }
273    let parse_id = |key: &str| -> Option<Uuid> {
274        obj.get(key)
275            .and_then(Value::as_str)
276            .and_then(|s| Uuid::parse_str(s).ok())
277    };
278    match verb {
279        "create" | "get" | "update" | "delete" => {
280            if !substrate_admits_as_entity(obj) {
281                return Vec::new();
282            }
283            match parse_id("id") {
284                Some(id) => vec![(id, display_name(result))],
285                None => Vec::new(),
286            }
287        }
288        // merge is entity-only by construction: no substrate check needed;
289        // `MergeSummary` carries no `kind` field to check even if one were wanted.
290        "merge" => match parse_id("kept_id") {
291            Some(id) => vec![(id, None)],
292            None => Vec::new(),
293        },
294        "link" => {
295            let mut out = Vec::new();
296            if let Some(id) = parse_id("source_id") {
297                out.push((id, None));
298            }
299            if let Some(id) = parse_id("target_id") {
300                out.push((id, None));
301            }
302            out
303        }
304        _ => Vec::new(),
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use serde_json::json;
312
313    #[test]
314    fn admits_by_id_ops_and_extracts_name() {
315        let ring = ReferenceRing::new();
316        ring.admit("local", "actor:a", Uuid::nil(), Some("Alpha".into()));
317        let snap = ring.snapshot("local", "actor:a");
318        assert_eq!(snap.len(), 1);
319        assert_eq!(snap[0].id, Uuid::nil());
320        assert_eq!(snap[0].name.as_deref(), Some("Alpha"));
321    }
322
323    #[test]
324    fn ring_admissions_for_search_and_list_is_empty() {
325        let result = json!([{"id": Uuid::nil().to_string(), "name": "hit"}]);
326        assert!(ring_admissions_for("search", &result).is_empty());
327        assert!(ring_admissions_for("list", &result).is_empty());
328    }
329
330    #[test]
331    fn ring_admissions_for_get_extracts_id_and_name() {
332        let id = Uuid::new_v4();
333        // Real entity get/create/update responses always carry `entity_type`
334        // (even as `null`) — that key's presence is the entity-substrate
335        // discriminator `substrate_admits_as_entity` checks for.
336        let result = json!({"id": id.to_string(), "name": "Concept", "entity_type": null});
337        let admissions = ring_admissions_for("get", &result);
338        assert_eq!(admissions, vec![(id, Some("Concept".to_string()))]);
339    }
340
341    #[test]
342    fn ring_admissions_for_note_result_is_empty() {
343        let id = Uuid::new_v4();
344        // Real note get/create/update responses carry `content`, never
345        // `entity_type`: the ring only ever admits entity ids.
346        let result = json!({"id": id.to_string(), "name": "a note", "content": "body text"});
347        assert!(ring_admissions_for("create", &result).is_empty());
348        assert!(ring_admissions_for("get", &result).is_empty());
349        assert!(ring_admissions_for("update", &result).is_empty());
350        assert!(ring_admissions_for("delete", &result).is_empty());
351    }
352
353    #[test]
354    fn ring_admissions_for_edge_and_event_kind_is_empty() {
355        let id = Uuid::new_v4();
356        let edge_result = json!({"id": id.to_string(), "kind": "edge"});
357        assert!(ring_admissions_for("get", &edge_result).is_empty());
358        let event_result = json!({"id": id.to_string(), "kind": "event"});
359        assert!(ring_admissions_for("get", &event_result).is_empty());
360    }
361
362    #[test]
363    fn ring_admissions_for_delete_uses_echoed_kind_param() {
364        let id = Uuid::new_v4();
365        // delete's synthetic summary has neither `content` nor `entity_type`;
366        // it falls back to the caller-echoed `kind` param.
367        let entity_delete = json!({"deleted": true, "id": id.to_string(), "kind": "concept"});
368        assert_eq!(
369            ring_admissions_for("delete", &entity_delete),
370            vec![(id, None)]
371        );
372        let generic_entity_delete =
373            json!({"deleted": true, "id": id.to_string(), "kind": "entity"});
374        assert_eq!(
375            ring_admissions_for("delete", &generic_entity_delete),
376            vec![(id, None)]
377        );
378        let note_delete = json!({"deleted": true, "id": id.to_string(), "kind": "observation"});
379        assert!(ring_admissions_for("delete", &note_delete).is_empty());
380        let unspecified_delete = json!({"deleted": true, "id": id.to_string(), "kind": null});
381        assert!(ring_admissions_for("delete", &unspecified_delete).is_empty());
382    }
383
384    #[test]
385    fn display_name_never_falls_back_to_content() {
386        let result = json!({"id": Uuid::new_v4().to_string(), "content": "some note body"});
387        assert_eq!(display_name(&result), None);
388    }
389
390    #[test]
391    fn ring_admissions_for_link_extracts_both_endpoints() {
392        let source = Uuid::new_v4();
393        let target = Uuid::new_v4();
394        let result = json!({
395            "id": Uuid::new_v4().to_string(),
396            "source_id": source.to_string(),
397            "target_id": target.to_string(),
398        });
399        let admissions = ring_admissions_for("link", &result);
400        assert_eq!(admissions, vec![(source, None), (target, None)]);
401    }
402
403    #[test]
404    fn ring_admissions_for_merge_uses_kept_id() {
405        let kept = Uuid::new_v4();
406        let removed = Uuid::new_v4();
407        let result = json!({"kept_id": kept.to_string(), "removed_id": removed.to_string()});
408        let admissions = ring_admissions_for("merge", &result);
409        assert_eq!(admissions, vec![(kept, None)]);
410    }
411
412    #[test]
413    fn ring_admissions_for_bulk_shapes_is_empty() {
414        let bulk_create = json!({"attempted": 3, "created": 3});
415        assert!(ring_admissions_for("create", &bulk_create).is_empty());
416        let bulk_link = json!({"attempted": 2, "created": 2, "skipped": 0, "failed": 0});
417        assert!(ring_admissions_for("link", &bulk_link).is_empty());
418    }
419
420    #[test]
421    fn admission_bounds_by_size() {
422        let ring = ReferenceRing::with_bounds(3, DEFAULT_RING_TTL);
423        let ids: Vec<Uuid> = (0..5).map(|_| Uuid::new_v4()).collect();
424        for id in &ids {
425            ring.admit("local", "actor:a", *id, None);
426        }
427        let snap = ring.snapshot("local", "actor:a");
428        assert_eq!(snap.len(), 3);
429        // Most-recently-touched first; the two oldest (ids[0], ids[1]) were evicted.
430        assert_eq!(snap[0].id, ids[4]);
431        assert_eq!(snap[1].id, ids[3]);
432        assert_eq!(snap[2].id, ids[2]);
433    }
434
435    #[test]
436    fn admission_bounds_by_age() {
437        let ring = ReferenceRing::with_bounds(64, Duration::from_millis(20));
438        let old = Uuid::new_v4();
439        ring.admit("local", "actor:a", old, None);
440        std::thread::sleep(Duration::from_millis(40));
441        let fresh = Uuid::new_v4();
442        ring.admit("local", "actor:a", fresh, None);
443        let snap = ring.snapshot("local", "actor:a");
444        assert_eq!(snap.len(), 1);
445        assert_eq!(snap[0].id, fresh);
446    }
447
448    #[test]
449    fn actor_isolation_never_crosses_boundary() {
450        let ring = ReferenceRing::new();
451        let id_a = Uuid::new_v4();
452        ring.admit("local", "actor:a", id_a, Some("A-only".into()));
453        let snap_b = ring.snapshot("local", "actor:b");
454        assert!(snap_b.is_empty(), "actor b must never see actor a's ring");
455        let snap_a = ring.snapshot("local", "actor:a");
456        assert_eq!(snap_a.len(), 1);
457    }
458
459    #[test]
460    fn namespace_isolation_is_independent_of_actor_isolation() {
461        let ring = ReferenceRing::new();
462        let id = Uuid::new_v4();
463        ring.admit("tenant-a", "actor:a", id, None);
464        assert!(ring.snapshot("tenant-b", "actor:a").is_empty());
465        assert_eq!(ring.snapshot("tenant-a", "actor:a").len(), 1);
466    }
467
468    #[test]
469    fn re_admitting_an_id_moves_it_to_most_recent_without_duplicating() {
470        let ring = ReferenceRing::new();
471        let a = Uuid::new_v4();
472        let b = Uuid::new_v4();
473        ring.admit("local", "actor:a", a, Some("A".into()));
474        ring.admit("local", "actor:a", b, Some("B".into()));
475        ring.admit("local", "actor:a", a, Some("A-renamed".into()));
476        let snap = ring.snapshot("local", "actor:a");
477        assert_eq!(snap.len(), 2, "re-admission must not duplicate the entry");
478        assert_eq!(snap[0].id, a, "re-admitted id must be most-recent");
479        assert_eq!(snap[0].name.as_deref(), Some("A-renamed"));
480    }
481
482    #[test]
483    fn snapshot_prunes_a_key_that_ages_out_entirely() {
484        let ring = ReferenceRing::with_bounds(64, Duration::from_millis(20));
485        ring.admit("local", "actor:a", Uuid::new_v4(), None);
486        std::thread::sleep(Duration::from_millis(40));
487        // The read itself must observe (and clean up) the now-fully-stale key.
488        assert!(ring.snapshot("local", "actor:a").is_empty());
489        let state = ring.state.lock().unwrap();
490        assert!(
491            !state
492                .rings
493                .contains_key(&("local".to_string(), "actor:a".to_string())),
494            "a key whose ring emptied via TTL eviction must not linger in the outer map"
495        );
496    }
497
498    /// Regression: `snapshot` must sweep the whole outer map, not just the
499    /// queried key: otherwise a read-only daemon (one that only ever calls
500    /// `snapshot`, never `admit`) never prunes any other actor's stale key.
501    /// Two actors age out; only `actor:queried` is snapshotted, but
502    /// `actor:other`'s stale key must be removed too.
503    #[test]
504    fn snapshot_prunes_other_stale_keys_it_did_not_query() {
505        let ring = ReferenceRing::with_bounds(64, Duration::from_millis(20));
506        ring.admit("local", "actor:queried", Uuid::new_v4(), None);
507        ring.admit("local", "actor:other", Uuid::new_v4(), None);
508        std::thread::sleep(Duration::from_millis(40));
509
510        // Only `actor:queried` is ever snapshotted directly.
511        assert!(ring.snapshot("local", "actor:queried").is_empty());
512
513        let state = ring.state.lock().unwrap();
514        assert!(
515            !state
516                .rings
517                .contains_key(&("local".to_string(), "actor:other".to_string())),
518            "snapshotting one actor must also prune OTHER actors' fully-stale keys, \
519             not just the one queried"
520        );
521    }
522
523    #[test]
524    fn outer_map_evicts_least_recently_touched_keys_over_budget() {
525        let ring = ReferenceRing::with_bounds_and_outer_limit(64, DEFAULT_RING_TTL, 3);
526        // Admit four distinct actors in order; the outer-key budget is 3, so
527        // the least-recently-touched one (actor:0) must be evicted once the
528        // fourth actor is admitted.
529        for i in 0..4 {
530            ring.admit(
531                "local",
532                &format!("actor:{i}"),
533                Uuid::new_v4(),
534                Some(format!("actor {i}")),
535            );
536        }
537        assert!(
538            ring.snapshot("local", "actor:0").is_empty(),
539            "the least-recently-touched key must be evicted once the outer-key budget is exceeded"
540        );
541        for i in 1..4 {
542            assert!(
543                !ring.snapshot("local", &format!("actor:{i}")).is_empty(),
544                "actor:{i} must survive the budget eviction"
545            );
546        }
547    }
548
549    #[test]
550    fn outer_map_budget_eviction_never_evicts_the_key_just_admitted() {
551        let ring = ReferenceRing::with_bounds_and_outer_limit(64, DEFAULT_RING_TTL, 1);
552        ring.admit("local", "actor:a", Uuid::new_v4(), None);
553        // Admitting actor:b pushes the outer map to 2 keys against a budget
554        // of 1; the key that triggered this admission (actor:b) must survive
555        // even though it is, by construction, also the most-recently-touched.
556        ring.admit("local", "actor:b", Uuid::new_v4(), None);
557        assert!(!ring.snapshot("local", "actor:b").is_empty());
558    }
559
560    /// A prior panic while holding the ring's mutex must never turn a
561    /// later, unrelated `admit`/`snapshot` call into a panic too — admission
562    /// is best-effort cache maintenance riding on an already-successful
563    /// dispatch.
564    #[test]
565    fn admit_and_snapshot_recover_from_poisoned_mutex() {
566        let ring = std::sync::Arc::new(ReferenceRing::new());
567        let poison_ring = ring.clone();
568        let _ = std::thread::spawn(move || {
569            let _guard = poison_ring.state.lock().unwrap();
570            panic!("deliberately poisoning the reference ring mutex for the recovery test");
571        })
572        .join();
573
574        // Both calls must complete normally (not panic) despite the poison.
575        ring.admit(
576            "local",
577            "actor:a",
578            Uuid::new_v4(),
579            Some("post-poison".into()),
580        );
581        let snap = ring.snapshot("local", "actor:a");
582        assert_eq!(snap.len(), 1);
583        assert_eq!(snap[0].name.as_deref(), Some("post-poison"));
584    }
585}