Skip to main content

graph_explorer_core/
cache.rs

1//! Cache that lets the synchronous `DataProvider` serve data arriving
2//! asynchronously: a miss queues the key and reports `pending`, and the host
3//! fills it in later. Pure — no I/O, no wasm — so it is host-testable.
4
5use std::cell::RefCell;
6use std::collections::HashMap;
7use std::rc::Rc;
8use crate::{Cursor, DataProvider, Graph, NeighborResult, NodeId, QueryParams};
9
10pub type CacheKey = (NodeId, Option<Cursor>);
11
12/// `InFlight` is tagged with the epoch of the attempt that installed it, so a
13/// settle (`fill`/`fail`) can be matched to the attempt that produced it. Two
14/// outstanding attempts for one key are possible after a cancel (the key
15/// carries no slot afterwards, so `mark_wanted` queues it again while the
16/// first fetch is still outstanding) and are otherwise indistinguishable.
17#[derive(Debug, Clone, PartialEq)]
18enum Slot { InFlight(u64), Failed(String) }
19
20/// Ready results plus in-flight/failed bookkeeping.
21#[derive(Default)]
22pub struct NeighborCache {
23    ready: HashMap<String, NeighborResult>,
24    slots: HashMap<String, Slot>,
25    wanted: Vec<CacheKey>,
26    dirty: bool,
27    /// Structured keys + messages for failures not yet reported to the host.
28    /// Kept separately because `key_str` is lossy and cannot be reversed.
29    failures: Vec<(CacheKey, String)>,
30    /// Keys filled since the host last drained them, so growth is incremental
31    /// rather than a re-walk of every ready result on each dirty tick.
32    new_arrivals: Vec<CacheKey>,
33    /// Monotonic counter; the next `take_wanted` epoch is `next_epoch + 1`.
34    next_epoch: u64,
35    /// Highest epoch that has settled for a key. The `InFlight` slot is gone
36    /// by the time a late settle arrives, so without this there is nothing
37    /// left to compare against, and an older attempt's answer would overwrite
38    /// a newer one -- or resurrect a key the newer attempt had recorded as
39    /// failed.
40    settled: HashMap<String, u64>,
41    /// Epoch floor installed by `reset`: attempts drawn before it can never
42    /// settle. `None` until the first reset, so a cache that has never been
43    /// reset imposes no floor at all.
44    discard_before: Option<u64>,
45}
46
47/// `Cursor` is not reversible from a flattened string, so keys are flattened
48/// to one string. Node ids arrive from the network and are not validated, so
49/// the id is length-prefixed: that makes the encoding unambiguous even if an
50/// id itself contains \u{1}. The cursor half is further tagged with a
51/// presence discriminant (`0`/`1`) so an absent cursor (`None`) never
52/// collides with a present-but-empty one (`Some(Cursor(String::new()))`) --
53/// without it, this flattening would disagree with `CacheKey`'s derived
54/// `Hash`/`Eq` (which does distinguish the two), and a host indexing
55/// `HashMap<CacheKey, AbortController>` by cache key could hold two entries
56/// where this cache holds one slot -- a cancel would then tear down the wrong
57/// fetch.
58fn key_str(k: &CacheKey) -> String {
59    match &k.1 {
60        Some(Cursor(c)) => format!("{}\u{1}{}\u{1}1{}", k.0.len(), k.0, c),
61        None => format!("{}\u{1}{}\u{1}0", k.0.len(), k.0),
62    }
63}
64
65impl NeighborCache {
66    pub fn new() -> Self { Self::default() }
67
68    pub fn get(&self, k: &CacheKey) -> Option<&NeighborResult> { self.ready.get(&key_str(k)) }
69
70    pub fn error(&self, k: &CacheKey) -> Option<&str> {
71        match self.slots.get(&key_str(k)) {
72            Some(Slot::Failed(e)) => Some(e.as_str()),
73            _ => None,
74        }
75    }
76
77    /// Queue a miss for fetching. Idempotent: a key already ready, in flight,
78    /// failed, or already queued is not queued again — so holding a navigation
79    /// key cannot stampede the backend and a failure cannot retry-loop.
80    pub fn mark_wanted(&mut self, k: CacheKey) {
81        let ks = key_str(&k);
82        if self.ready.contains_key(&ks) || self.slots.contains_key(&ks) { return; }
83        if self.wanted.iter().any(|w| key_str(w) == ks) { return; }
84        self.wanted.push(k);
85    }
86
87    /// Drain queued keys, marking each in flight under a fresh epoch. The
88    /// epoch identifies THIS attempt: the host must hand it back to
89    /// `fill`/`fail` so a settle can be matched to the attempt that produced
90    /// it. Without that, two outstanding attempts for one key (possible after
91    /// a cancel, since the key carries no slot and re-queues) are
92    /// indistinguishable.
93    pub fn take_wanted(&mut self) -> Vec<(CacheKey, u64)> {
94        let out: Vec<CacheKey> = self.wanted.drain(..).collect();
95        out.into_iter()
96            .map(|k| {
97                self.next_epoch += 1;
98                self.slots.insert(key_str(&k), Slot::InFlight(self.next_epoch));
99                (k, self.next_epoch)
100            })
101            .collect()
102    }
103
104    /// Whether a settle for `epoch` has been overtaken: either a newer attempt
105    /// is still in flight, or an attempt at least as new has already settled.
106    /// `>=`, not `>`: a settle whose epoch equals the last settled one is a
107    /// duplicate delivery of the very same attempt, not a fresh answer, so it
108    /// is stale too -- otherwise a redelivered `fill` would re-store the same
109    /// data and re-report it via `take_new_arrivals` as if it were new.
110    fn is_stale(&self, ks: &str, epoch: u64) -> bool {
111        // Drawn before a `reset`, so it answers a question about a dataset
112        // this cache no longer describes. Checked first: `reset` wipes both
113        // `slots` and `settled`, so neither of the checks below has anything
114        // left to catch such an attempt with.
115        if matches!(self.discard_before, Some(floor) if epoch <= floor) {
116            return true;
117        }
118        if let Some(Slot::InFlight(e)) = self.slots.get(ks) {
119            if *e > epoch {
120                return true;
121            }
122        }
123        matches!(self.settled.get(ks), Some(s) if *s >= epoch)
124    }
125
126    /// Store a received result for the attempt tagged `epoch`. `pending` is a
127    /// client-side concept — it means "this placeholder stands in for a fetch
128    /// we have not received yet" — so a value decoded off the wire is never
129    /// trusted: a host or proxy echoing `{"pending":true}` would otherwise
130    /// land a permanent cache HIT that reports pending, wedging the view on a
131    /// spinner that can never resolve.
132    pub fn fill(&mut self, k: CacheKey, epoch: u64, mut res: NeighborResult) {
133        let ks = key_str(&k);
134        if self.is_stale(&ks, epoch) { return; }
135        // A newer attempt for this key is outstanding, or one at least as new
136        // has already settled; either way this answer loses. An attempt with
137        // NO slot and nothing newer settled (cancelled, nothing since) still
138        // stores: the bytes arrived anyway, and data keyed by node id is
139        // always valid, so revisiting the node is a hit rather than a
140        // refetch.
141        res.pending = false;
142        self.slots.remove(&ks);
143        self.ready.insert(ks.clone(), res);
144        self.settled.insert(ks, epoch);
145        self.new_arrivals.push(k);
146        self.dirty = true;
147    }
148
149    /// Record a failure for the attempt tagged `epoch`.
150    pub fn fail(&mut self, k: CacheKey, epoch: u64, err: String) {
151        let ks = key_str(&k);
152        // Only the attempt currently in flight may record a failure. A
153        // cancelled attempt has no slot and a superseded one has a newer
154        // epoch; either way this rejection is stale. Recording it would
155        // install a `Slot::Failed` that `mark_wanted` refuses to re-queue,
156        // making the node a permanent dead end for a cancellation we ourselves
157        // requested.
158        if !matches!(self.slots.get(&ks), Some(Slot::InFlight(e)) if *e == epoch) {
159            return;
160        }
161        self.slots.insert(ks.clone(), Slot::Failed(err.clone()));
162        // Recorded so a later, even-more-stale success (from an attempt older
163        // than this failure) cannot fall through `fill`'s guard and erase it --
164        // that would be a second automatic path around `clear_failures`.
165        self.settled.insert(ks, epoch);
166        self.failures.push((k, err));
167        self.dirty = true;
168    }
169
170    /// Cancel a fetch. A key still queued is simply dequeued; a key in flight
171    /// loses its slot, so `is_loading()` goes quiet at once and the stale
172    /// settle is discarded by the epoch check in `fill`/`fail`. Cancellation
173    /// is not sticky: the key carries no slot afterwards, so `mark_wanted`
174    /// queues it again.
175    pub fn cancel(&mut self, k: &CacheKey) {
176        let ks = key_str(k);
177        self.wanted.retain(|w| key_str(w) != ks);
178        // ONLY an in-flight attempt is cancellable. Removing a `Slot::Failed`
179        // would backdoor the "failures are never cleared automatically"
180        // invariant and let a down backend be hammered.
181        if matches!(self.slots.get(&ks), Some(Slot::InFlight(_))) {
182            self.slots.remove(&ks);
183        }
184    }
185
186    /// Whether a fetch for `k` is currently outstanding. Hosts holding
187    /// per-fetch resources (an `AbortController`, say) use this to prune.
188    pub fn is_in_flight(&self, k: &CacheKey) -> bool {
189        matches!(self.slots.get(&key_str(k)), Some(Slot::InFlight(_)))
190    }
191
192    /// True once since the last call; consumed by the renderer.
193    pub fn take_dirty(&mut self) -> bool { std::mem::take(&mut self.dirty) }
194
195    /// Drain failures that have not yet been reported. The `Slot::Failed`
196    /// record remains, so a failed key still does not auto-retry.
197    pub fn take_failures(&mut self) -> Vec<(CacheKey, String)> {
198        std::mem::take(&mut self.failures)
199    }
200
201    /// Drain the keys filled since the last call, so a host can absorb only
202    /// what actually arrived instead of re-walking every ready result.
203    pub fn take_new_arrivals(&mut self) -> Vec<CacheKey> {
204        std::mem::take(&mut self.new_arrivals)
205    }
206
207    /// Forget every recorded failure, so those keys can be queued again by a
208    /// subsequent `mark_wanted`. Ready and in-flight entries are untouched.
209    /// Failures are never cleared automatically — that would hammer a down
210    /// backend — so this is the hook behind an explicit host "retry".
211    pub fn clear_failures(&mut self) {
212        self.slots.retain(|_, s| !matches!(s, Slot::Failed(_)));
213    }
214
215    /// Forget everything: entries, in-flight slots, the queue, the dirty flag,
216    /// undrained failures and arrivals, and the settled high-water map.
217    ///
218    /// A `load` REPLACES the dataset, so every cached neighborhood now
219    /// describes a graph that no longer exists. Keeping them would serve the
220    /// old graph's neighbors for any id the new one happens to reuse — and id
221    /// reuse is the norm rather than the exception for generated or
222    /// synthetically-keyed datasets, where `w0`/`c0` mean something different
223    /// in each. Recorded failures are wrong for exactly the same reason, and
224    /// worse for being sticky: a node that failed under the old dataset would
225    /// be a permanent dead end under the new one.
226    ///
227    /// `next_epoch` deliberately does NOT restart. Fetches issued for the old
228    /// dataset are still on the wire, and rewinding the counter would let one
229    /// of them settle under an epoch a post-reset attempt also claims. Instead
230    /// the counter's current value becomes an epoch floor: every attempt drawn
231    /// before this reset is at or below it, and `is_stale` discards it on
232    /// arrival. Without that floor, clearing `slots` and `settled` would
233    /// actually WIDEN the hole — both of `fill`'s other guards read those maps,
234    /// so an old-dataset answer would land unopposed.
235    pub fn reset(&mut self) {
236        self.ready.clear();
237        self.slots.clear();
238        self.wanted.clear();
239        self.dirty = false;
240        self.failures.clear();
241        self.new_arrivals.clear();
242        self.settled.clear();
243        self.discard_before = Some(self.next_epoch);
244    }
245
246    pub fn is_loading(&self) -> bool {
247        !self.wanted.is_empty() || self.slots.values().any(|s| matches!(s, Slot::InFlight(_)))
248    }
249
250    /// Every result received so far. Used to grow the known graph (the
251    /// traditional view starts at the seed and expands as neighborhoods land).
252    pub fn ready_results(&self) -> impl Iterator<Item = &NeighborResult> { self.ready.values() }
253}
254
255/// A `DataProvider` whose neighbor lookups are served from a `NeighborCache`.
256/// A miss returns a `pending` result and queues the key; the host fetches it
257/// and calls `NeighborCache::fill` later, at which point the view rebuilds.
258pub struct CachingProvider {
259    base: Graph,
260    cache: Rc<RefCell<NeighborCache>>,
261}
262
263impl CachingProvider {
264    pub fn new(base: Graph, cache: Rc<RefCell<NeighborCache>>) -> Self {
265        Self { base, cache }
266    }
267
268    pub fn cache(&self) -> Rc<RefCell<NeighborCache>> { self.cache.clone() }
269}
270
271impl DataProvider for CachingProvider {
272    fn load(&self) -> Graph { self.base.clone() }
273
274    fn neighbors(&self, focus: &NodeId, params: &QueryParams) -> NeighborResult {
275        let k: CacheKey = (focus.clone(), params.cursor.clone());
276        if let Some(hit) = self.cache.borrow().get(&k) {
277            return hit.clone();
278        }
279        // A failed key must NOT look pending: `mark_wanted` refuses to re-queue
280        // it, so a pending placeholder here would spin forever. Report a
281        // resolved-but-empty neighborhood (a dead end) and queue nothing; the
282        // host offers a retry, which clears the failure and lets it queue again.
283        if self.cache.borrow().error(&k).is_some() {
284            return NeighborResult {
285                nodes: vec![], edges: vec![], aggregates: vec![], next: None, pending: false,
286            };
287        }
288        self.cache.borrow_mut().mark_wanted(k);
289        NeighborResult {
290            nodes: vec![], edges: vec![], aggregates: vec![], next: None, pending: true,
291        }
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use crate::Node;
299
300    fn key(id: &str) -> CacheKey { (id.to_string(), None) }
301
302    fn result_with(id: &str) -> NeighborResult {
303        NeighborResult {
304            nodes: vec![Node { id: id.into(), label: None, attrs: Default::default() }],
305            edges: vec![], aggregates: vec![], next: None, pending: false,
306        }
307    }
308
309    #[test]
310    fn miss_queues_the_key_and_reports_loading() {
311        let mut c = NeighborCache::new();
312        assert!(c.get(&key("a")).is_none());
313        c.mark_wanted(key("a"));
314        assert!(c.is_loading());
315        assert_eq!(c.take_wanted(), vec![(key("a"), 1)]);
316    }
317
318    #[test]
319    fn in_flight_key_is_not_queued_twice() {
320        let mut c = NeighborCache::new();
321        c.mark_wanted(key("a"));
322        c.mark_wanted(key("a"));            // repeated/held navigation
323        assert_eq!(c.take_wanted().len(), 1);
324        c.mark_wanted(key("a"));            // still in flight
325        assert!(c.take_wanted().is_empty(), "in-flight key must not re-queue");
326    }
327
328    #[test]
329    fn fill_stores_result_clears_pending_and_sets_dirty() {
330        let mut c = NeighborCache::new();
331        c.mark_wanted(key("a"));
332        let epoch = c.take_wanted()[0].1;
333        c.fill(key("a"), epoch, result_with("a"));
334        assert!(c.take_dirty());
335        assert!(!c.take_dirty(), "dirty is consumed");
336        assert!(!c.is_loading());
337        assert_eq!(c.get(&key("a")).unwrap().nodes[0].id, "a");
338    }
339
340    #[test]
341    fn failure_is_recorded_and_does_not_retry_loop() {
342        let mut c = NeighborCache::new();
343        c.mark_wanted(key("a"));
344        let epoch = c.take_wanted()[0].1;
345        c.fail(key("a"), epoch, "boom".into());
346        assert!(c.take_dirty());
347        assert!(!c.is_loading());
348        assert_eq!(c.error(&key("a")), Some("boom"));
349        c.mark_wanted(key("a"));
350        assert!(c.take_wanted().is_empty(), "failed key must not auto-retry");
351    }
352
353    #[test]
354    fn distinct_cursors_are_distinct_cache_entries() {
355        let mut c = NeighborCache::new();
356        let k2 = ("a".to_string(), Some(Cursor("off:8".into())));
357        c.fill(key("a"), 0, result_with("a"));
358        assert!(c.get(&k2).is_none(), "cursor is part of the key");
359    }
360
361    #[test]
362    fn ready_results_yields_every_filled_entry() {
363        let mut c = NeighborCache::new();
364        c.fill(key("a"), 0, result_with("n1"));
365        c.fill(("b".to_string(), Some(Cursor("off:8".into()))), 0, result_with("n2"));
366        let mut ids: Vec<String> =
367            c.ready_results().map(|r| r.nodes[0].id.clone()).collect();
368        ids.sort();
369        assert_eq!(ids, vec!["n1".to_string(), "n2".to_string()]);
370    }
371
372    #[test]
373    fn provider_returns_pending_on_miss_and_queues_it() {
374        let cache = Rc::new(RefCell::new(NeighborCache::new()));
375        let p = CachingProvider::new(Graph::default(), cache.clone());
376        let r = p.neighbors(&"a".to_string(), &QueryParams { limit: 8, cursor: None });
377        assert!(r.pending);
378        assert!(r.nodes.is_empty());
379        assert_eq!(cache.borrow_mut().take_wanted(), vec![(key("a"), 1)]);
380    }
381
382    #[test]
383    fn provider_returns_cached_result_once_filled() {
384        let cache = Rc::new(RefCell::new(NeighborCache::new()));
385        let p = CachingProvider::new(Graph::default(), cache.clone());
386        cache.borrow_mut().fill(key("a"), 0, result_with("n1"));
387        let r = p.neighbors(&"a".to_string(), &QueryParams { limit: 8, cursor: None });
388        assert!(!r.pending);
389        assert_eq!(r.nodes[0].id, "n1");
390    }
391
392    #[test]
393    fn provider_does_not_queue_a_hit() {
394        let cache = Rc::new(RefCell::new(NeighborCache::new()));
395        let p = CachingProvider::new(Graph::default(), cache.clone());
396        cache.borrow_mut().fill(key("a"), 0, result_with("n1"));
397        let _ = p.neighbors(&"a".to_string(), &QueryParams { limit: 8, cursor: None });
398        assert!(cache.borrow_mut().take_wanted().is_empty());
399    }
400
401    #[test]
402    fn provider_reports_a_failed_key_as_resolved_and_empty_not_pending() {
403        // A pending placeholder here would spin forever: `mark_wanted` refuses
404        // to re-queue a failed key, so nothing would ever resolve it.
405        let cache = Rc::new(RefCell::new(NeighborCache::new()));
406        let p = CachingProvider::new(Graph::default(), cache.clone());
407        cache.borrow_mut().mark_wanted(key("a"));
408        let epoch = cache.borrow_mut().take_wanted()[0].1;
409        cache.borrow_mut().fail(key("a"), epoch, "boom".into());
410
411        let r = p.neighbors(&"a".to_string(), &QueryParams { limit: 8, cursor: None });
412        assert!(!r.pending, "a failed key must not look pending");
413        assert!(r.nodes.is_empty());
414        assert!(r.next.is_none());
415        assert!(cache.borrow_mut().take_wanted().is_empty(), "must not queue a failed key");
416    }
417
418    #[test]
419    fn clear_failures_lets_a_failed_key_be_queued_again() {
420        let mut c = NeighborCache::new();
421        c.mark_wanted(key("a"));
422        let epoch = c.take_wanted()[0].1;
423        c.fail(key("a"), epoch, "boom".into());
424        c.mark_wanted(key("a"));
425        assert!(c.take_wanted().is_empty(), "no auto-retry before an explicit clear");
426
427        c.clear_failures();
428        assert_eq!(c.error(&key("a")), None);
429        c.mark_wanted(key("a"));
430        let requeued = c.take_wanted();
431        assert_eq!(requeued.len(), 1, "retry re-queues the key");
432        assert_eq!(requeued[0].0, key("a"));
433    }
434
435    #[test]
436    fn clear_failures_leaves_ready_and_in_flight_alone() {
437        let mut c = NeighborCache::new();
438        c.fill(key("ready"), 0, result_with("n1"));
439
440        c.mark_wanted(key("inflight"));
441        let _ = c.take_wanted();
442
443        c.mark_wanted(key("bad"));
444        let epoch = c.take_wanted()[0].1;
445        c.fail(key("bad"), epoch, "boom".into());
446
447        c.clear_failures();
448        assert!(c.get(&key("ready")).is_some(), "ready entries survive");
449        assert!(c.is_loading(), "the in-flight slot survives");
450        c.mark_wanted(key("inflight"));
451        assert!(c.take_wanted().is_empty(), "still in flight, so still not re-queued");
452    }
453
454    #[test]
455    fn fill_never_trusts_pending_from_the_wire() {
456        let cache = Rc::new(RefCell::new(NeighborCache::new()));
457        let mut res = result_with("n1");
458        res.pending = true; // a proxy/host echoing `{"pending":true}`
459        cache.borrow_mut().fill(key("a"), 0, res);
460        assert!(!cache.borrow().get(&key("a")).unwrap().pending, "pending is client-side only");
461
462        // otherwise this hit would report pending forever, and `mark_wanted`
463        // would skip the key because it IS ready.
464        let p = CachingProvider::new(Graph::default(), cache.clone());
465        let r = p.neighbors(&"a".to_string(), &QueryParams { limit: 8, cursor: None });
466        assert!(!r.pending);
467        assert_eq!(r.nodes[0].id, "n1");
468    }
469
470    #[test]
471    fn new_arrivals_drain_once_and_report_each_filled_key() {
472        let mut c = NeighborCache::new();
473        let k2 = ("b".to_string(), Some(Cursor("off:8".into())));
474        c.fill(key("a"), 0, result_with("n1"));
475        c.fill(k2.clone(), 0, result_with("n2"));
476
477        let mut drained = c.take_new_arrivals();
478        drained.sort_by(|x, y| x.0.cmp(&y.0));
479        assert_eq!(drained, vec![key("a"), k2]);
480        assert!(c.take_new_arrivals().is_empty(), "draining consumes the arrivals");
481    }
482
483    #[test]
484    fn ids_containing_the_separator_do_not_collide_with_cursor_keys() {
485        // ("a\u{1}b", None) and ("a", Some("b")) both flattened to the same
486        // string before the key encoding was length-prefixed.
487        let mut c = NeighborCache::new();
488        let k1 = ("a\u{1}b".to_string(), None);
489        let k2 = ("a".to_string(), Some(Cursor("b".into())));
490        c.fill(k1.clone(), 0, result_with("from-k1"));
491        c.fill(k2.clone(), 0, result_with("from-k2"));
492        assert_eq!(c.get(&k1).unwrap().nodes[0].id, "from-k1");
493        assert_eq!(c.get(&k2).unwrap().nodes[0].id, "from-k2");
494    }
495
496    #[test]
497    fn failures_are_drainable_once() {
498        let mut c = NeighborCache::new();
499        c.mark_wanted(key("a"));
500        let epoch = c.take_wanted()[0].1;
501        c.fail(key("a"), epoch, "boom".into());
502
503        let drained = c.take_failures();
504        assert_eq!(drained.len(), 1);
505        assert_eq!(drained[0].0, key("a"), "structured key is preserved");
506        assert_eq!(drained[0].1, "boom");
507
508        assert!(c.take_failures().is_empty(), "draining consumes the failures");
509        // the failure is still recorded, so the key does not auto-retry
510        assert_eq!(c.error(&key("a")), Some("boom"));
511        c.mark_wanted(key("a"));
512        assert!(c.take_wanted().is_empty());
513    }
514
515    #[test]
516    fn failure_with_a_cursor_keeps_its_cursor_in_the_drained_key() {
517        let mut c = NeighborCache::new();
518        let k = ("a".to_string(), Some(Cursor("grp:X:0".into())));
519        c.mark_wanted(k.clone());
520        let epoch = c.take_wanted()[0].1;
521        c.fail(k.clone(), epoch, "nope".into());
522        let drained = c.take_failures();
523        assert_eq!(drained[0].0, k);
524    }
525
526    #[test]
527    fn cancel_clears_the_in_flight_slot_and_stops_reporting_loading() {
528        let mut c = NeighborCache::new();
529        c.mark_wanted(key("a"));
530        let _ = c.take_wanted();
531        assert!(c.is_loading());
532
533        c.cancel(&key("a"));
534        assert!(!c.is_loading(), "a cancelled fetch must not keep the spinner up");
535        assert!(!c.is_in_flight(&key("a")));
536    }
537
538    #[test]
539    fn fail_after_cancel_records_nothing() {
540        // An aborted fetch rejects its promise. Recording that as a failure
541        // would install a Slot::Failed, which mark_wanted refuses to re-queue —
542        // the node would be a permanent dead end for a cancellation we asked for.
543        let mut c = NeighborCache::new();
544        c.mark_wanted(key("a"));
545        let epoch = c.take_wanted()[0].1;
546        c.cancel(&key("a"));
547
548        c.fail(key("a"), epoch, "AbortError".into());
549        assert_eq!(c.error(&key("a")), None, "no failure is recorded");
550        assert!(c.take_failures().is_empty(), "nothing is reported to the host");
551        assert!(!c.take_dirty(), "a cancellation is not a repaint-worthy change");
552    }
553
554    #[test]
555    fn a_cancelled_key_can_be_requested_again() {
556        let mut c = NeighborCache::new();
557        c.mark_wanted(key("a"));
558        let epoch = c.take_wanted()[0].1;
559        c.cancel(&key("a"));
560        c.fail(key("a"), epoch, "AbortError".into());
561
562        c.mark_wanted(key("a"));
563        let requeued = c.take_wanted();
564        assert_eq!(requeued.len(), 1, "cancellation is not sticky");
565        assert_eq!(requeued[0].0, key("a"));
566    }
567
568    #[test]
569    fn fill_after_cancel_still_stores_the_result() {
570        // The bytes arrived anyway. Data keyed by node id is always valid, so
571        // keep it — revisiting the node is then a hit with no refetch. There
572        // is no separate "cancelled" flag left lying around to swallow
573        // anything later (see `genuine_failure_on_a_re_requested_attempt_
574        // after_cancel_is_recorded` for that guarantee on a re-fetched key).
575        let mut c = NeighborCache::new();
576        c.mark_wanted(key("a"));
577        let epoch = c.take_wanted()[0].1;
578        c.cancel(&key("a"));
579
580        c.fill(key("a"), epoch, result_with("n1"));
581        assert_eq!(c.get(&key("a")).unwrap().nodes[0].id, "n1");
582        assert!(!c.is_in_flight(&key("a")));
583    }
584
585    #[test]
586    fn cancel_drops_a_key_queued_but_not_yet_drained() {
587        let mut c = NeighborCache::new();
588        c.mark_wanted(key("a"));
589        c.cancel(&key("a"));
590        assert!(c.take_wanted().is_empty(), "a queued key is dequeued, not fetched");
591        assert!(!c.is_loading());
592    }
593
594    #[test]
595    fn cancel_of_an_unknown_key_is_a_no_op() {
596        let mut c = NeighborCache::new();
597        c.cancel(&key("ghost"));
598        assert!(!c.is_loading());
599        // and it must not arm the swallow-the-next-failure behaviour
600        c.mark_wanted(key("ghost"));
601        let epoch = c.take_wanted()[0].1;
602        c.fail(key("ghost"), epoch, "boom".into());
603        assert_eq!(c.error(&key("ghost")), Some("boom"));
604    }
605
606    #[test]
607    fn is_in_flight_is_true_only_between_take_wanted_and_settle() {
608        let mut c = NeighborCache::new();
609        c.mark_wanted(key("a"));
610        assert!(!c.is_in_flight(&key("a")), "queued is not yet in flight");
611        let epoch = c.take_wanted()[0].1;
612        assert!(c.is_in_flight(&key("a")));
613        c.fill(key("a"), epoch, result_with("n1"));
614        assert!(!c.is_in_flight(&key("a")));
615    }
616
617    #[test]
618    fn cache_keys_are_hashable_so_hosts_can_index_by_them() {
619        // graph-explorer-wasm keys its AbortController map by CacheKey.
620        use std::collections::HashMap;
621        let mut m: HashMap<CacheKey, u32> = HashMap::new();
622        m.insert(key("a"), 1);
623        m.insert(("a".to_string(), Some(Cursor("off:8".into()))), 2);
624        assert_eq!(m.get(&key("a")), Some(&1));
625        assert_eq!(m.len(), 2, "the cursor is part of the identity");
626    }
627
628    // --- epoch-tagged slots: bugs found in code review of the first cut ---
629
630    #[test]
631    fn cancel_requeue_cancel_does_not_poison_the_key() {
632        // Two outstanding attempts for the same key must not collide: a
633        // `HashSet<String>` "cancelled" mark could only remember one, so the
634        // second attempt's own (also-discarded) rejection would wrongly
635        // record a Slot::Failed and permanently dead-end the node. The epoch
636        // tag distinguishes them.
637        let mut c = NeighborCache::new();
638        c.mark_wanted(key("a"));
639        let epoch1 = c.take_wanted()[0].1; // P1
640        c.cancel(&key("a"));
641
642        c.mark_wanted(key("a"));
643        let epoch2 = c.take_wanted()[0].1; // P2, a fresh attempt
644        c.cancel(&key("a"));
645
646        c.fail(key("a"), epoch1, "AbortError".into()); // P1's rejection: stale
647        c.fail(key("a"), epoch2, "AbortError".into()); // P2's rejection: also stale
648
649        assert_eq!(c.error(&key("a")), None, "neither cancelled attempt records a failure");
650        c.mark_wanted(key("a"));
651        assert_eq!(c.take_wanted().len(), 1, "the key is not poisoned and can be requested again");
652    }
653
654    #[test]
655    fn genuine_failure_on_a_re_requested_attempt_after_cancel_is_recorded() {
656        // The swallow-path must not blindly return before touching `slots`:
657        // that would orphan an InFlight slot from an earlier cancelled
658        // attempt and wedge `is_loading()` true forever once the live
659        // attempt's own failure arrives.
660        let mut c = NeighborCache::new();
661        c.mark_wanted(key("a"));
662        let epoch1 = c.take_wanted()[0].1; // P1
663        c.cancel(&key("a"));
664
665        c.mark_wanted(key("a"));
666        let epoch2 = c.take_wanted()[0].1; // P2, the live attempt
667        assert!(c.is_loading());
668
669        c.fail(key("a"), epoch1, "boom".into()); // P1's rejection: stale, discarded
670        assert!(c.is_loading(), "P2 is still outstanding");
671        assert_eq!(c.error(&key("a")), None);
672
673        c.fail(key("a"), epoch2, "boom".into()); // P2's own, genuine failure
674        assert!(!c.is_loading(), "no orphaned slot: is_loading must go quiet");
675        assert_eq!(c.error(&key("a")), Some("boom"));
676    }
677
678    #[test]
679    fn cancel_of_an_already_failed_key_leaves_the_failure_intact() {
680        // Cancelling must be gated on `Slot::InFlight`, not on "any slot at
681        // all" — otherwise cancel is a backdoor around "failures are never
682        // cleared automatically", letting a down backend be hammered.
683        let mut c = NeighborCache::new();
684        c.mark_wanted(key("a"));
685        let epoch = c.take_wanted()[0].1;
686        c.fail(key("a"), epoch, "boom".into());
687
688        c.cancel(&key("a")); // nothing in flight to cancel
689
690        assert_eq!(c.error(&key("a")), Some("boom"), "cancel must not erase a recorded failure");
691        c.mark_wanted(key("a"));
692        assert!(c.take_wanted().is_empty(), "must not re-queue a failed key via cancel's back door");
693    }
694
695    #[test]
696    fn stale_fill_from_a_superseded_attempt_is_discarded() {
697        let mut c = NeighborCache::new();
698        c.mark_wanted(key("a"));
699        let epoch1 = c.take_wanted()[0].1; // P1
700        c.cancel(&key("a"));
701
702        c.mark_wanted(key("a"));
703        let epoch2 = c.take_wanted()[0].1; // P2, now the attempt of record
704
705        c.fill(key("a"), epoch1, result_with("stale")); // P1's late answer
706        assert!(c.get(&key("a")).is_none(), "a stale fill must not overwrite the live attempt");
707        assert!(c.is_in_flight(&key("a")), "P2 is still outstanding");
708
709        c.fill(key("a"), epoch2, result_with("live"));
710        assert_eq!(c.get(&key("a")).unwrap().nodes[0].id, "live");
711    }
712
713    #[test]
714    fn cancel_of_a_ready_key_is_a_no_op() {
715        let mut c = NeighborCache::new();
716        c.fill(key("a"), 0, result_with("n1"));
717        c.cancel(&key("a"));
718        assert_eq!(c.get(&key("a")).unwrap().nodes[0].id, "n1", "a ready result is untouched by cancel");
719        c.mark_wanted(key("a"));
720        assert!(c.take_wanted().is_empty(), "a ready key is still a hit, not re-queued");
721    }
722
723    #[test]
724    fn absent_cursor_and_empty_cursor_are_distinct_cache_entries() {
725        let mut c = NeighborCache::new();
726        let empty_cursor_key = ("a".to_string(), Some(Cursor(String::new())));
727        c.fill(key("a"), 0, result_with("no-cursor"));
728        c.fill(empty_cursor_key.clone(), 0, result_with("empty-cursor"));
729
730        assert_eq!(c.get(&key("a")).unwrap().nodes[0].id, "no-cursor");
731        assert_eq!(c.get(&empty_cursor_key).unwrap().nodes[0].id, "empty-cursor");
732    }
733
734    // --- `settled`: fill's guard has no memory once the slot is gone ---
735
736    #[test]
737    fn late_stale_success_does_not_overwrite_a_newer_settled_success() {
738        // `fill`'s guard used to compare only against a live `InFlight` slot.
739        // Once P2 settles and removes the slot, P1's late success fell
740        // through unguarded and stomped the fresher answer.
741        let mut c = NeighborCache::new();
742        c.mark_wanted(key("b"));
743        let epoch1 = c.take_wanted()[0].1; // P1
744        c.cancel(&key("b"));
745
746        c.mark_wanted(key("b"));
747        let epoch2 = c.take_wanted()[0].1; // P2
748
749        c.fill(key("b"), epoch2, result_with("new")); // the live attempt settles first
750        let _ = c.take_new_arrivals();
751
752        c.fill(key("b"), epoch1, result_with("old")); // P1's late, stale success
753        assert_eq!(
754            c.get(&key("b")).unwrap().nodes[0].id,
755            "new",
756            "a stale success must not overwrite a newer one"
757        );
758        assert!(
759            c.take_new_arrivals().is_empty(),
760            "a discarded stale fill must not be reported as a new arrival"
761        );
762    }
763
764    #[test]
765    fn late_stale_success_does_not_erase_a_recorded_failure() {
766        // Same gap, different victim: a stale `fill` falling through the
767        // guard would remove a `Slot::Failed` that a newer attempt recorded
768        // and store data in its place -- a second automatic path around
769        // `clear_failures`, resurrecting a key the host was already told
770        // had failed.
771        let mut c = NeighborCache::new();
772        c.mark_wanted(key("a"));
773        let epoch1 = c.take_wanted()[0].1; // P1
774        c.cancel(&key("a"));
775
776        c.mark_wanted(key("a"));
777        let epoch2 = c.take_wanted()[0].1; // P2, the live attempt
778
779        c.fail(key("a"), epoch2, "boom".into()); // P2's genuine failure
780        let _ = c.take_failures();
781        assert_eq!(c.error(&key("a")), Some("boom"));
782
783        c.fill(key("a"), epoch1, result_with("late")); // P1's late, stale success
784        assert_eq!(
785            c.error(&key("a")),
786            Some("boom"),
787            "a stale success must not erase a recorded failure"
788        );
789        assert!(c.get(&key("a")).is_none(), "the failed key must not also look like a hit");
790    }
791
792    #[test]
793    fn fill_after_cancel_with_nothing_settled_since_still_stores() {
794        // The deliberate "keep the bytes" case must survive the new guard:
795        // nothing newer is in flight and nothing has settled since, so this
796        // settle is not stale even though its slot is long gone.
797        let mut c = NeighborCache::new();
798        c.mark_wanted(key("a"));
799        let epoch = c.take_wanted()[0].1;
800        c.cancel(&key("a"));
801
802        c.fill(key("a"), epoch, result_with("n1"));
803        assert_eq!(c.get(&key("a")).unwrap().nodes[0].id, "n1");
804    }
805
806    // --- `reset`: a load replaces the dataset ---
807
808    #[test]
809    fn reset_clears_everything_the_cache_holds() {
810        let mut c = NeighborCache::new();
811
812        // a ready entry
813        c.mark_wanted(key("ready"));
814        let e_ready = c.take_wanted()[0].1;
815        c.fill(key("ready"), e_ready, result_with("n1"));
816        // a failure
817        c.mark_wanted(key("bad"));
818        let e_bad = c.take_wanted()[0].1;
819        c.fail(key("bad"), e_bad, "boom".into());
820        // an in-flight slot, and a key still only queued
821        c.mark_wanted(key("inflight"));
822        let _ = c.take_wanted();
823        c.mark_wanted(key("queued"));
824
825        c.reset();
826
827        assert!(c.get(&key("ready")).is_none(), "ready entries describe the old graph");
828        assert_eq!(c.error(&key("bad")), None, "failures are not carried across a load");
829        assert!(!c.is_loading(), "no in-flight slots and nothing queued survive");
830        assert!(!c.take_dirty(), "the dirty flag is cleared");
831        assert!(c.take_failures().is_empty(), "undrained failures are dropped");
832        assert!(c.take_new_arrivals().is_empty(), "undrained arrivals are dropped");
833        assert_eq!(c.ready_results().count(), 0);
834    }
835
836    #[test]
837    fn a_key_settled_before_a_reset_can_be_requested_again() {
838        // The whole point: the new dataset reuses the id, so the node must be
839        // a miss that fetches — not a hit serving the old graph's neighbors,
840        // and not a `settled` high-water mark blocking the fresh attempt.
841        let mut c = NeighborCache::new();
842        c.mark_wanted(key("w0"));
843        let epoch = c.take_wanted()[0].1;
844        c.fill(key("w0"), epoch, result_with("old-neighbor"));
845        assert!(c.get(&key("w0")).is_some());
846
847        c.reset();
848
849        c.mark_wanted(key("w0"));
850        let requeued = c.take_wanted();
851        assert_eq!(requeued.len(), 1, "a reused id must fetch again, not hit");
852        assert_eq!(requeued[0].0, key("w0"));
853
854        c.fill(key("w0"), requeued[0].1, result_with("new-neighbor"));
855        assert_eq!(c.get(&key("w0")).unwrap().nodes[0].id, "new-neighbor");
856    }
857
858    #[test]
859    fn a_key_that_failed_before_a_reset_is_not_a_permanent_dead_end() {
860        let mut c = NeighborCache::new();
861        c.mark_wanted(key("w0"));
862        let epoch = c.take_wanted()[0].1;
863        c.fail(key("w0"), epoch, "boom".into());
864        c.mark_wanted(key("w0"));
865        assert!(c.take_wanted().is_empty(), "failed keys do not auto-retry pre-reset");
866
867        c.reset();
868
869        c.mark_wanted(key("w0"));
870        assert_eq!(c.take_wanted().len(), 1, "the new dataset's node is fetchable");
871    }
872
873    #[test]
874    fn a_fetch_outstanding_across_a_reset_cannot_settle_into_the_new_dataset() {
875        // The abort is best-effort — a request already past the wire settles
876        // anyway. Its answer describes the OLD graph, so it must be discarded
877        // even though `reset` wiped the slot and `settled` entry that `fill`'s
878        // other guards would have caught it with.
879        let mut c = NeighborCache::new();
880        c.mark_wanted(key("w0"));
881        let old_epoch = c.take_wanted()[0].1;
882
883        c.reset();
884
885        c.fill(key("w0"), old_epoch, result_with("old-neighbor"));
886        assert!(c.get(&key("w0")).is_none(), "an old-dataset answer must not land");
887        assert!(c.take_new_arrivals().is_empty(), "nor be reported as an arrival");
888
889        c.fail(key("w0"), old_epoch, "boom".into());
890        assert_eq!(c.error(&key("w0")), None, "nor poison the reused id with its failure");
891    }
892
893    #[test]
894    fn epochs_keep_climbing_across_a_reset() {
895        // Rewinding the counter would let a pre-reset attempt settle under an
896        // epoch a post-reset attempt also claims, making the two
897        // indistinguishable.
898        let mut c = NeighborCache::new();
899        c.mark_wanted(key("a"));
900        let before = c.take_wanted()[0].1;
901
902        c.reset();
903
904        c.mark_wanted(key("a"));
905        let after = c.take_wanted()[0].1;
906        assert!(after > before, "epochs are monotonic across a reset ({after} > {before})");
907    }
908
909    #[test]
910    fn reset_on_a_fresh_cache_imposes_no_floor_on_later_attempts() {
911        // `reset` records the counter's CURRENT value, so a reset before any
912        // fetch has been drawn must not disqualify the fetches that follow.
913        let mut c = NeighborCache::new();
914        c.reset();
915
916        c.mark_wanted(key("a"));
917        let epoch = c.take_wanted()[0].1;
918        c.fill(key("a"), epoch, result_with("n1"));
919        assert_eq!(c.get(&key("a")).unwrap().nodes[0].id, "n1");
920    }
921
922    #[test]
923    fn duplicate_fill_with_the_same_epoch_is_discarded() {
924        // A redelivered settle of the very same attempt is not a fresh
925        // answer: it must not re-store (harmless here, since the data is
926        // identical) or re-report via `take_new_arrivals` as if new.
927        let mut c = NeighborCache::new();
928        c.mark_wanted(key("a"));
929        let epoch = c.take_wanted()[0].1;
930        c.fill(key("a"), epoch, result_with("first"));
931        let _ = c.take_new_arrivals();
932
933        c.fill(key("a"), epoch, result_with("duplicate"));
934        assert_eq!(
935            c.get(&key("a")).unwrap().nodes[0].id,
936            "first",
937            "a duplicate delivery of the same attempt does not overwrite"
938        );
939        assert!(
940            c.take_new_arrivals().is_empty(),
941            "a duplicate settle is not reported as a new arrival"
942        );
943    }
944}