polydat_core/library/support/cache.rs
1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Race-free per-key once-init cache.
5//!
6//! Generalizes the "cached shared resource" pattern that Polydat node
7//! functions repeatedly need: a global, expensive-to-construct
8//! handle (a vectordata `TestDataGroup`, a typed reader, an HTTP
9//! client, a parsed schema, a compiled regex, …) that should be
10//! built **at most once** per unique key, regardless of how many
11//! fibers race to the access point.
12//!
13//! ## Why a dedicated type
14//!
15//! The naïve form — `Mutex<HashMap<K, V>>` with
16//! `lock → check missing → unlock → load → relock → insert` —
17//! exhibits a TOCTOU bug under concurrency: N fibers all see
18//! "missing" simultaneously, each runs the (expensive) loader,
19//! and the last writer wins. nb-rs hit exactly this in
20//! `polydat::library::vectors`: 20 fibers all opened their
21//! own `vectordata::Storage` per facet, each constructing a
22//! fresh `reqwest::blocking::Client` (≈ load-native-certs +
23//! TLS-bootstrap), driving the per-cycle reqwest cost into
24//! observable flamegraph dominance.
25//!
26//! [`OnceCache`] caches an `Arc<OnceLock<Result<V, String>>>`
27//! per key, holds the outer `Mutex` only long enough to insert
28//! the slot, then dispatches the actual loader through
29//! [`OnceLock::get_or_init`]. Concurrent callers for the same
30//! key block on the OnceLock and reuse the cached `Result` —
31//! exactly one loader run per (key, lifetime).
32//!
33//! ## Failure semantics
34//!
35//! Failed loads are **sticky**: every concurrent caller for the
36//! same key sees the same `Err` rather than triggering a retry
37//! storm. This is deliberate — the caller in nb-rs treats a load
38//! failure as a workload-config issue (missing dataset, bad URL,
39//! permission), and re-attempting per fiber wouldn't change the
40//! diagnostic. If a future caller wants retry semantics, it
41//! should clear the slot via a `purge` method (not yet
42//! exposed; add when needed).
43//!
44//! ## Hot-path cost
45//!
46//! After the first successful load, every subsequent
47//! `get_or_init` is: one outer `Mutex::lock` (for the
48//! `HashMap::entry` lookup), one `Arc::clone`, one
49//! `OnceLock::get_or_init` (returns immediately because the
50//! slot is initialized), and one `Result::clone` (the V is
51//! typically `Arc<…>` — a refcount bump). No load runs, no I/O
52//! happens. This is comparable to a plain `Mutex<HashMap>` read
53//! and not on any hot path nb-rs cares about (cycle-time reads
54//! go through pre-resolved handles, not through the cache).
55
56use std::collections::HashMap;
57use std::hash::Hash;
58use std::sync::{Arc, Mutex, OnceLock};
59
60/// The cache's backing store: each key maps to a shared,
61/// once-initialised cell holding either the cached value or the
62/// init error string.
63type CacheStore<K, V> = Mutex<HashMap<K, Arc<OnceLock<Result<V, String>>>>>;
64
65/// Per-key once-init cache. See module docs for the rationale
66/// and pattern.
67///
68/// `K` is the cache key (hashable, cloneable, equatable). `V`
69/// is the cached value type — typically `Arc<Something>` so
70/// the clone on hit is just a refcount bump.
71pub struct OnceCache<K: Eq + Hash + Clone, V: Clone> {
72 inner: CacheStore<K, V>,
73}
74
75impl<K: Eq + Hash + Clone, V: Clone> Default for OnceCache<K, V> {
76 fn default() -> Self {
77 Self::new()
78 }
79}
80
81impl<K: Eq + Hash + Clone, V: Clone> OnceCache<K, V> {
82 /// Empty cache. Use with `LazyLock`/`OnceLock` for a static.
83 pub fn new() -> Self {
84 Self {
85 inner: Mutex::new(HashMap::new()),
86 }
87 }
88
89 /// Get the value for `key`, computing it via `init` exactly
90 /// once across all concurrent callers. Subsequent callers
91 /// for the same `key` see the cached `Result` (including
92 /// errors — see module docs §"Failure semantics").
93 ///
94 /// `init` may take seconds (HTTPS download, cert load, file
95 /// open). The outer `Mutex` is only held long enough to
96 /// install the per-key slot; `init` runs without it,
97 /// guarded only by the per-key `OnceLock`.
98 pub fn get_or_init<F>(&self, key: K, init: F) -> Result<V, String>
99 where
100 F: FnOnce() -> Result<V, String>,
101 {
102 let slot: Arc<OnceLock<Result<V, String>>> = {
103 let mut map = self.inner.lock().unwrap_or_else(|e| e.into_inner());
104 map.entry(key)
105 .or_insert_with(|| Arc::new(OnceLock::new()))
106 .clone()
107 };
108 slot.get_or_init(init).clone()
109 }
110
111 /// Number of distinct keys currently in the cache. Includes
112 /// keys whose load is in progress or has failed. Diagnostic
113 /// only — the cache should not be inspected for routing or
114 /// behavior decisions.
115 pub fn len(&self) -> usize {
116 self.inner.lock().unwrap_or_else(|e| e.into_inner()).len()
117 }
118
119 /// Whether the cache holds no keys.
120 pub fn is_empty(&self) -> bool {
121 self.len() == 0
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128 use std::sync::atomic::{AtomicU32, Ordering};
129 use std::thread;
130
131 #[test]
132 fn first_caller_runs_loader_subsequent_callers_reuse_value() {
133 let cache: OnceCache<&'static str, Arc<String>> = OnceCache::new();
134 let calls = AtomicU32::new(0);
135
136 let v1 = cache
137 .get_or_init("k", || {
138 calls.fetch_add(1, Ordering::Relaxed);
139 Ok(Arc::new("loaded".into()))
140 })
141 .unwrap();
142 let v2 = cache
143 .get_or_init("k", || {
144 calls.fetch_add(1, Ordering::Relaxed);
145 Ok(Arc::new("DIFFERENT".into()))
146 })
147 .unwrap();
148
149 assert_eq!(*v1, "loaded");
150 assert_eq!(*v2, "loaded");
151 assert!(
152 Arc::ptr_eq(&v1, &v2),
153 "second caller should see the cached arc"
154 );
155 assert_eq!(calls.load(Ordering::Relaxed), 1);
156 }
157
158 #[test]
159 fn concurrent_callers_share_one_loader_run() {
160 // The TOCTOU regression test: 32 threads all race for the
161 // same key. The naïve lock-check-release-load pattern
162 // would fire the loader 32 times. `OnceCache` should
163 // fire it exactly once.
164 let cache: Arc<OnceCache<&'static str, Arc<String>>> = Arc::new(OnceCache::new());
165 let calls = Arc::new(AtomicU32::new(0));
166
167 let handles: Vec<_> = (0..32)
168 .map(|_| {
169 let cache = cache.clone();
170 let calls = calls.clone();
171 thread::spawn(move || {
172 cache.get_or_init("hot-key", || {
173 calls.fetch_add(1, Ordering::Relaxed);
174 // Tiny stall to widen the race window.
175 thread::sleep(std::time::Duration::from_millis(5));
176 Ok(Arc::new("only-once".into()))
177 })
178 })
179 })
180 .collect();
181
182 for h in handles {
183 let v = h.join().unwrap().unwrap();
184 assert_eq!(*v, "only-once");
185 }
186 assert_eq!(
187 calls.load(Ordering::Relaxed),
188 1,
189 "loader should run exactly once across all concurrent callers"
190 );
191 }
192
193 #[test]
194 fn distinct_keys_load_independently() {
195 let cache: OnceCache<u32, u32> = OnceCache::new();
196 let v1 = cache.get_or_init(1, || Ok(10)).unwrap();
197 let v2 = cache.get_or_init(2, || Ok(20)).unwrap();
198 assert_eq!(v1, 10);
199 assert_eq!(v2, 20);
200 assert_eq!(cache.len(), 2);
201 }
202
203 #[test]
204 fn failed_load_is_sticky() {
205 let cache: OnceCache<&'static str, u32> = OnceCache::new();
206 let calls = AtomicU32::new(0);
207
208 let r1 = cache.get_or_init("bad", || {
209 calls.fetch_add(1, Ordering::Relaxed);
210 Err("nope".into())
211 });
212 let r2 = cache.get_or_init("bad", || {
213 calls.fetch_add(1, Ordering::Relaxed);
214 Ok(42)
215 });
216
217 assert_eq!(r1.unwrap_err(), "nope");
218 assert_eq!(r2.unwrap_err(), "nope");
219 assert_eq!(
220 calls.load(Ordering::Relaxed),
221 1,
222 "second caller must NOT retry; the failure is sticky"
223 );
224 }
225}