Skip to main content

aurum_core/runtime/
singleflight.rs

1//! Per-key singleflight loading (JOE-1597).
2
3use crate::error::{ProviderError, Result};
4use std::collections::HashMap;
5use std::panic::{catch_unwind, AssertUnwindSafe};
6use std::sync::{Arc, Condvar, Mutex};
7use std::time::{Duration, Instant};
8
9/// Identity for a loadable model/session.
10#[derive(Debug, Clone, PartialEq, Eq, Hash)]
11pub struct LoadKey {
12    pub kind: &'static str,
13    pub id: String,
14    pub path: String,
15}
16
17impl LoadKey {
18    pub fn stt(id: impl Into<String>, path: impl Into<String>) -> Self {
19        Self {
20            kind: "stt",
21            id: id.into(),
22            path: path.into(),
23        }
24    }
25
26    pub fn tts(id: impl Into<String>, path: impl Into<String>) -> Self {
27        Self {
28            kind: "tts",
29            id: id.into(),
30            path: path.into(),
31        }
32    }
33}
34
35enum SlotState<T> {
36    Loading { waiters: usize },
37    Ready(Arc<T>),
38    Failed { message: String, at: Instant },
39}
40
41/// Coalesce concurrent loads for the same key.
42pub struct Singleflight<T> {
43    inner: Mutex<HashMap<LoadKey, SlotState<T>>>,
44    cv: Condvar,
45    /// How long to keep a failed result before allowing retry.
46    fail_ttl: Duration,
47}
48
49impl<T> Singleflight<T> {
50    pub fn new(fail_ttl: Duration) -> Self {
51        Self {
52            inner: Mutex::new(HashMap::new()),
53            cv: Condvar::new(),
54            fail_ttl,
55        }
56    }
57
58    pub fn get_ready(&self, key: &LoadKey) -> Option<Arc<T>> {
59        let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
60        match guard.get(key) {
61            Some(SlotState::Ready(v)) => Some(Arc::clone(v)),
62            _ => None,
63        }
64    }
65
66    pub fn contains_ready(&self, key: &LoadKey) -> bool {
67        self.get_ready(key).is_some()
68    }
69
70    pub fn invalidate(&self, key: &LoadKey) {
71        let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
72        guard.remove(key);
73        self.cv.notify_all();
74    }
75
76    pub fn clear(&self) {
77        let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
78        guard.clear();
79        self.cv.notify_all();
80    }
81
82    pub fn ready_count(&self) -> usize {
83        let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
84        guard
85            .values()
86            .filter(|s| matches!(s, SlotState::Ready(_)))
87            .count()
88    }
89
90    /// Complete a coalesced load without retaining a permanent Ready slot
91    /// (JOE-1646). The caller must have already published the value into the
92    /// authoritative registry. Waiters wake, see no Loading slot, and re-query
93    /// the registry.
94    pub fn finish_load_published(&self, key: &LoadKey) {
95        let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
96        guard.remove(key);
97        self.cv.notify_all();
98    }
99
100    /// Mark a failed coalesced load.
101    pub fn finish_load_failed(&self, key: &LoadKey, message: String) {
102        let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
103        guard.insert(
104            key.clone(),
105            SlotState::Failed {
106                message,
107                at: Instant::now(),
108            },
109        );
110        self.cv.notify_all();
111    }
112
113    /// Begin a coalesced load for registry-owned residency (JOE-1646).
114    ///
115    /// - [`BeginLoad::Leader`]: this caller must load, insert into the registry,
116    ///   then call [`Self::finish_load_published`] or [`Self::finish_load_failed`].
117    /// - [`BeginLoad::WaitDone`]: a concurrent load finished; re-check the registry.
118    /// - [`BeginLoad::Failed`]: recent failure still in TTL window.
119    pub fn begin_or_wait(&self, key: &LoadKey) -> BeginLoad {
120        let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
121        loop {
122            if let Some(SlotState::Failed { at, .. }) = guard.get(key) {
123                if at.elapsed() > self.fail_ttl {
124                    guard.remove(key);
125                }
126            }
127            match guard.get_mut(key) {
128                Some(SlotState::Ready(_)) => {
129                    // Treat completed slot as done; clear and let caller re-query.
130                    guard.remove(key);
131                    self.cv.notify_all();
132                    return BeginLoad::WaitDone;
133                }
134                Some(SlotState::Failed { message, .. }) => {
135                    return BeginLoad::Failed(message.clone());
136                }
137                Some(SlotState::Loading { waiters }) => {
138                    *waiters += 1;
139                    guard = self.cv.wait(guard).unwrap_or_else(|e| e.into_inner());
140                    // After wake: loading finished (published or failed).
141                    // Loop to observe Failed or absence → WaitDone.
142                    if !matches!(guard.get(key), Some(SlotState::Loading { .. })) {
143                        if let Some(SlotState::Failed { message, .. }) = guard.get(key) {
144                            return BeginLoad::Failed(message.clone());
145                        }
146                        return BeginLoad::WaitDone;
147                    }
148                }
149                None => {
150                    guard.insert(key.clone(), SlotState::Loading { waiters: 0 });
151                    return BeginLoad::Leader;
152                }
153            }
154        }
155    }
156
157    /// Acquire leadership for `key` and return a panic-safe [`LeaderGuard`].
158    ///
159    /// `Ok(None)` means another load finished — re-check the registry.
160    /// `Err` is a cached failure message still within TTL.
161    pub fn begin_or_wait_guard(
162        &self,
163        key: LoadKey,
164    ) -> std::result::Result<Option<LeaderGuard<'_, T>>, String> {
165        match self.begin_or_wait(&key) {
166            BeginLoad::Leader => Ok(Some(LeaderGuard {
167                flight: self,
168                key,
169                finished: false,
170            })),
171            BeginLoad::WaitDone => Ok(None),
172            BeginLoad::Failed(m) => Err(m),
173        }
174    }
175}
176
177/// Result of [`Singleflight::begin_or_wait`].
178#[derive(Debug)]
179pub enum BeginLoad {
180    /// This caller is responsible for loading and finishing the slot.
181    Leader,
182    /// A concurrent load completed; re-check the registry (or retry).
183    WaitDone,
184    /// A recent failure is still cached.
185    Failed(String),
186}
187
188/// RAII leader for registry-publication loads (JOE-1646).
189///
190/// If dropped without [`LeaderGuard::success`] or [`LeaderGuard::fail`], the key is
191/// marked Failed so waiters are not stuck in Loading forever (including panic unwind).
192pub struct LeaderGuard<'a, T> {
193    flight: &'a Singleflight<T>,
194    key: LoadKey,
195    finished: bool,
196}
197
198impl<'a, T> LeaderGuard<'a, T> {
199    pub fn key(&self) -> &LoadKey {
200        &self.key
201    }
202
203    pub fn success(mut self) {
204        self.flight.finish_load_published(&self.key);
205        self.finished = true;
206    }
207
208    pub fn fail(mut self, message: impl Into<String>) {
209        self.flight.finish_load_failed(&self.key, message.into());
210        self.finished = true;
211    }
212}
213
214impl<T> Drop for LeaderGuard<'_, T> {
215    fn drop(&mut self) {
216        if !self.finished {
217            self.flight.finish_load_failed(
218                &self.key,
219                "loader panicked or abandoned before publish".into(),
220            );
221        }
222    }
223}
224
225impl<T> Default for Singleflight<T> {
226    fn default() -> Self {
227        Self::new(Duration::from_secs(2))
228    }
229}
230
231impl<T: Send + Sync + 'static> Singleflight<T> {
232    /// Load `key` using `loader` exactly once for concurrent callers.
233    ///
234    /// The registry mutex is **not** held during `loader`.
235    /// Leader panics are converted to a failed slot so waiters never hang.
236    pub fn get_or_load<F>(&self, key: LoadKey, loader: F) -> Result<Arc<T>>
237    where
238        F: FnOnce() -> Result<T>,
239    {
240        // Local role without nesting T in an enum (avoids E0401).
241        let mut leader = false;
242        let early: Option<Result<Arc<T>>> = {
243            let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
244            loop {
245                // Drop stale failures so retries can proceed.
246                if let Some(SlotState::Failed { at, .. }) = guard.get(&key) {
247                    if at.elapsed() > self.fail_ttl {
248                        guard.remove(&key);
249                    }
250                }
251
252                match guard.get_mut(&key) {
253                    Some(SlotState::Ready(v)) => {
254                        break Some(Ok(Arc::clone(v)));
255                    }
256                    Some(SlotState::Failed { message, .. }) => {
257                        break Some(Err(ProviderError::ModelLoad {
258                            model: key.id.clone(),
259                            reason: message.clone(),
260                        }
261                        .into()));
262                    }
263                    Some(SlotState::Loading { waiters }) => {
264                        *waiters += 1;
265                        guard = self.cv.wait(guard).unwrap_or_else(|e| e.into_inner());
266                        // Re-check after wake.
267                    }
268                    None => {
269                        guard.insert(key.clone(), SlotState::Loading { waiters: 0 });
270                        leader = true;
271                        break None;
272                    }
273                }
274            }
275        };
276
277        if let Some(r) = early {
278            return r;
279        }
280        debug_assert!(leader);
281
282        // Catch panics so the key is never stuck in Loading.
283        let result = match catch_unwind(AssertUnwindSafe(loader)) {
284            Ok(r) => r,
285            Err(_) => Err(ProviderError::ModelLoad {
286                model: key.id.clone(),
287                reason: "loader panicked".into(),
288            }
289            .into()),
290        };
291        let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
292        match result {
293            Ok(value) => {
294                let arc = Arc::new(value);
295                guard.insert(key, SlotState::Ready(Arc::clone(&arc)));
296                self.cv.notify_all();
297                Ok(arc)
298            }
299            Err(e) => {
300                let message = e.to_string();
301                guard.insert(
302                    key,
303                    SlotState::Failed {
304                        message,
305                        at: Instant::now(),
306                    },
307                );
308                self.cv.notify_all();
309                Err(e)
310            }
311        }
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use std::sync::atomic::{AtomicUsize, Ordering};
319    use std::thread;
320
321    #[test]
322    fn single_loader_for_many_waiters() {
323        let sf = Arc::new(Singleflight::<u32>::default());
324        let loads = Arc::new(AtomicUsize::new(0));
325        let key = LoadKey::stt("m1", "/tmp/m1");
326        let mut handles = vec![];
327        for _ in 0..16 {
328            let sf = Arc::clone(&sf);
329            let loads = Arc::clone(&loads);
330            let key = key.clone();
331            handles.push(thread::spawn(move || {
332                sf.get_or_load(key, || {
333                    loads.fetch_add(1, Ordering::SeqCst);
334                    thread::sleep(Duration::from_millis(30));
335                    Ok(42)
336                })
337                .unwrap()
338            }));
339        }
340        for h in handles {
341            assert_eq!(*h.join().unwrap(), 42);
342        }
343        assert_eq!(loads.load(Ordering::SeqCst), 1);
344    }
345
346    #[test]
347    fn failure_delivered_to_waiters() {
348        let sf = Arc::new(Singleflight::<u32>::new(Duration::from_secs(10)));
349        let key = LoadKey::stt("bad", "/tmp/bad");
350        let sf2 = Arc::clone(&sf);
351        let key2 = key.clone();
352        let leader = thread::spawn(move || {
353            sf2.get_or_load(key2, || {
354                thread::sleep(Duration::from_millis(20));
355                Err(ProviderError::ModelLoad {
356                    model: "bad".into(),
357                    reason: "boom".into(),
358                }
359                .into())
360            })
361        });
362        thread::sleep(Duration::from_millis(5));
363        let waiter = sf.get_or_load(key, || Ok(1));
364        assert!(leader.join().unwrap().is_err());
365        assert!(waiter.is_err());
366    }
367
368    #[test]
369    fn panic_does_not_stick_loading() {
370        let sf = Singleflight::<u32>::new(Duration::from_millis(50));
371        let key = LoadKey::stt("panic", "/tmp/p");
372        let err = sf.get_or_load(key.clone(), || panic!("boom"));
373        assert!(err.is_err());
374        // After fail_ttl, a new load can proceed.
375        thread::sleep(Duration::from_millis(60));
376        let v = sf.get_or_load(key, || Ok(7)).unwrap();
377        assert_eq!(*v, 7);
378    }
379
380    #[test]
381    fn leader_guard_drop_unblocks_waiters() {
382        // Long enough fail_ttl that scheduling noise cannot expire Failed mid-test.
383        let sf = Arc::new(Singleflight::<u32>::new(Duration::from_millis(500)));
384        let key = LoadKey::stt("abandon", "/tmp/a");
385        let held = Arc::new(std::sync::Barrier::new(2));
386        let sf2 = Arc::clone(&sf);
387        let key2 = key.clone();
388        let held2 = Arc::clone(&held);
389        let leader = thread::spawn(move || {
390            let g = sf2.begin_or_wait_guard(key2).unwrap().expect("leader");
391            // Publish that Loading is held before any waiter starts (avoids race where
392            // the waiter becomes Leader first on a loaded CI runner).
393            held2.wait();
394            // Simulate panic/abandon: drop guard without success/fail.
395            drop(g);
396        });
397        held.wait();
398        let waiter = thread::spawn({
399            let sf = Arc::clone(&sf);
400            let key = key.clone();
401            move || sf.begin_or_wait(&key)
402        });
403        // Let the waiter park on Loading before the leader abandons.
404        thread::sleep(Duration::from_millis(30));
405        leader.join().unwrap();
406        match waiter.join().unwrap() {
407            BeginLoad::Failed(m) => assert!(m.contains("abandon") || m.contains("panic")),
408            other => panic!("expected Failed after abandon, got {other:?}"),
409        }
410        // After TTL a new leader can proceed and publish successfully.
411        thread::sleep(Duration::from_millis(520));
412        let g = sf
413            .begin_or_wait_guard(key.clone())
414            .unwrap()
415            .expect("leader2");
416        g.success();
417        // Key removed after publish — next caller becomes leader again (or WaitDone
418        // if a concurrent waiter saw the wake). Either is fine; not Loading.
419        match sf.begin_or_wait(&key) {
420            BeginLoad::Leader | BeginLoad::WaitDone => {}
421            BeginLoad::Failed(m) => panic!("unexpected Failed: {m}"),
422        }
423    }
424}