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                    // Legacy Ready path: treat as completed, clear and let caller
130                    // re-query registry.
131                    guard.remove(key);
132                    self.cv.notify_all();
133                    return BeginLoad::WaitDone;
134                }
135                Some(SlotState::Failed { message, .. }) => {
136                    return BeginLoad::Failed(message.clone());
137                }
138                Some(SlotState::Loading { waiters }) => {
139                    *waiters += 1;
140                    guard = self.cv.wait(guard).unwrap_or_else(|e| e.into_inner());
141                    // After wake: loading finished (published or failed).
142                    // Loop to observe Failed or absence → WaitDone.
143                    if !matches!(guard.get(key), Some(SlotState::Loading { .. })) {
144                        if let Some(SlotState::Failed { message, .. }) = guard.get(key) {
145                            return BeginLoad::Failed(message.clone());
146                        }
147                        return BeginLoad::WaitDone;
148                    }
149                }
150                None => {
151                    guard.insert(key.clone(), SlotState::Loading { waiters: 0 });
152                    return BeginLoad::Leader;
153                }
154            }
155        }
156    }
157
158    /// Acquire leadership for `key` and return a panic-safe [`LeaderGuard`].
159    ///
160    /// `Ok(None)` means another load finished — re-check the registry.
161    /// `Err` is a cached failure message still within TTL.
162    pub fn begin_or_wait_guard(
163        &self,
164        key: LoadKey,
165    ) -> std::result::Result<Option<LeaderGuard<'_, T>>, String> {
166        match self.begin_or_wait(&key) {
167            BeginLoad::Leader => Ok(Some(LeaderGuard {
168                flight: self,
169                key,
170                finished: false,
171            })),
172            BeginLoad::WaitDone => Ok(None),
173            BeginLoad::Failed(m) => Err(m),
174        }
175    }
176}
177
178/// Result of [`Singleflight::begin_or_wait`].
179#[derive(Debug)]
180pub enum BeginLoad {
181    /// This caller is responsible for loading and finishing the slot.
182    Leader,
183    /// A concurrent load completed; re-check the registry (or retry).
184    WaitDone,
185    /// A recent failure is still cached.
186    Failed(String),
187}
188
189/// RAII leader for registry-publication loads (JOE-1646).
190///
191/// If dropped without [`LeaderGuard::success`] or [`LeaderGuard::fail`], the key is
192/// marked Failed so waiters are not stuck in Loading forever (including panic unwind).
193pub struct LeaderGuard<'a, T> {
194    flight: &'a Singleflight<T>,
195    key: LoadKey,
196    finished: bool,
197}
198
199impl<'a, T> LeaderGuard<'a, T> {
200    pub fn key(&self) -> &LoadKey {
201        &self.key
202    }
203
204    pub fn success(mut self) {
205        self.flight.finish_load_published(&self.key);
206        self.finished = true;
207    }
208
209    pub fn fail(mut self, message: impl Into<String>) {
210        self.flight.finish_load_failed(&self.key, message.into());
211        self.finished = true;
212    }
213}
214
215impl<T> Drop for LeaderGuard<'_, T> {
216    fn drop(&mut self) {
217        if !self.finished {
218            self.flight.finish_load_failed(
219                &self.key,
220                "loader panicked or abandoned before publish".into(),
221            );
222        }
223    }
224}
225
226impl<T> Default for Singleflight<T> {
227    fn default() -> Self {
228        Self::new(Duration::from_secs(2))
229    }
230}
231
232impl<T: Send + Sync + 'static> Singleflight<T> {
233    /// Load `key` using `loader` exactly once for concurrent callers.
234    ///
235    /// The registry mutex is **not** held during `loader`.
236    /// Leader panics are converted to a failed slot so waiters never hang.
237    pub fn get_or_load<F>(&self, key: LoadKey, loader: F) -> Result<Arc<T>>
238    where
239        F: FnOnce() -> Result<T>,
240    {
241        // Local role without nesting T in an enum (avoids E0401).
242        let mut leader = false;
243        let early: Option<Result<Arc<T>>> = {
244            let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
245            loop {
246                // Drop stale failures so retries can proceed.
247                if let Some(SlotState::Failed { at, .. }) = guard.get(&key) {
248                    if at.elapsed() > self.fail_ttl {
249                        guard.remove(&key);
250                    }
251                }
252
253                match guard.get_mut(&key) {
254                    Some(SlotState::Ready(v)) => {
255                        break Some(Ok(Arc::clone(v)));
256                    }
257                    Some(SlotState::Failed { message, .. }) => {
258                        break Some(Err(ProviderError::ModelLoad {
259                            model: key.id.clone(),
260                            reason: message.clone(),
261                        }
262                        .into()));
263                    }
264                    Some(SlotState::Loading { waiters }) => {
265                        *waiters += 1;
266                        guard = self.cv.wait(guard).unwrap_or_else(|e| e.into_inner());
267                        // Re-check after wake.
268                    }
269                    None => {
270                        guard.insert(key.clone(), SlotState::Loading { waiters: 0 });
271                        leader = true;
272                        break None;
273                    }
274                }
275            }
276        };
277
278        if let Some(r) = early {
279            return r;
280        }
281        debug_assert!(leader);
282
283        // Catch panics so the key is never stuck in Loading.
284        let result = match catch_unwind(AssertUnwindSafe(loader)) {
285            Ok(r) => r,
286            Err(_) => Err(ProviderError::ModelLoad {
287                model: key.id.clone(),
288                reason: "loader panicked".into(),
289            }
290            .into()),
291        };
292        let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
293        match result {
294            Ok(value) => {
295                let arc = Arc::new(value);
296                guard.insert(key, SlotState::Ready(Arc::clone(&arc)));
297                self.cv.notify_all();
298                Ok(arc)
299            }
300            Err(e) => {
301                let message = e.to_string();
302                guard.insert(
303                    key,
304                    SlotState::Failed {
305                        message,
306                        at: Instant::now(),
307                    },
308                );
309                self.cv.notify_all();
310                Err(e)
311            }
312        }
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use std::sync::atomic::{AtomicUsize, Ordering};
320    use std::thread;
321
322    #[test]
323    fn single_loader_for_many_waiters() {
324        let sf = Arc::new(Singleflight::<u32>::default());
325        let loads = Arc::new(AtomicUsize::new(0));
326        let key = LoadKey::stt("m1", "/tmp/m1");
327        let mut handles = vec![];
328        for _ in 0..16 {
329            let sf = Arc::clone(&sf);
330            let loads = Arc::clone(&loads);
331            let key = key.clone();
332            handles.push(thread::spawn(move || {
333                sf.get_or_load(key, || {
334                    loads.fetch_add(1, Ordering::SeqCst);
335                    thread::sleep(Duration::from_millis(30));
336                    Ok(42)
337                })
338                .unwrap()
339            }));
340        }
341        for h in handles {
342            assert_eq!(*h.join().unwrap(), 42);
343        }
344        assert_eq!(loads.load(Ordering::SeqCst), 1);
345    }
346
347    #[test]
348    fn failure_delivered_to_waiters() {
349        let sf = Arc::new(Singleflight::<u32>::new(Duration::from_secs(10)));
350        let key = LoadKey::stt("bad", "/tmp/bad");
351        let sf2 = Arc::clone(&sf);
352        let key2 = key.clone();
353        let leader = thread::spawn(move || {
354            sf2.get_or_load(key2, || {
355                thread::sleep(Duration::from_millis(20));
356                Err(ProviderError::ModelLoad {
357                    model: "bad".into(),
358                    reason: "boom".into(),
359                }
360                .into())
361            })
362        });
363        thread::sleep(Duration::from_millis(5));
364        let waiter = sf.get_or_load(key, || Ok(1));
365        assert!(leader.join().unwrap().is_err());
366        assert!(waiter.is_err());
367    }
368
369    #[test]
370    fn panic_does_not_stick_loading() {
371        let sf = Singleflight::<u32>::new(Duration::from_millis(50));
372        let key = LoadKey::stt("panic", "/tmp/p");
373        let err = sf.get_or_load(key.clone(), || panic!("boom"));
374        assert!(err.is_err());
375        // After fail_ttl, a new load can proceed.
376        thread::sleep(Duration::from_millis(60));
377        let v = sf.get_or_load(key, || Ok(7)).unwrap();
378        assert_eq!(*v, 7);
379    }
380
381    #[test]
382    fn leader_guard_drop_unblocks_waiters() {
383        // Long enough fail_ttl that scheduling noise cannot expire Failed mid-test.
384        let sf = Arc::new(Singleflight::<u32>::new(Duration::from_millis(500)));
385        let key = LoadKey::stt("abandon", "/tmp/a");
386        let held = Arc::new(std::sync::Barrier::new(2));
387        let sf2 = Arc::clone(&sf);
388        let key2 = key.clone();
389        let held2 = Arc::clone(&held);
390        let leader = thread::spawn(move || {
391            let g = sf2.begin_or_wait_guard(key2).unwrap().expect("leader");
392            // Publish that Loading is held before any waiter starts (avoids race where
393            // the waiter becomes Leader first on a loaded CI runner).
394            held2.wait();
395            // Simulate panic/abandon: drop guard without success/fail.
396            drop(g);
397        });
398        held.wait();
399        let waiter = thread::spawn({
400            let sf = Arc::clone(&sf);
401            let key = key.clone();
402            move || sf.begin_or_wait(&key)
403        });
404        // Let the waiter park on Loading before the leader abandons.
405        thread::sleep(Duration::from_millis(30));
406        leader.join().unwrap();
407        match waiter.join().unwrap() {
408            BeginLoad::Failed(m) => assert!(m.contains("abandon") || m.contains("panic")),
409            other => panic!("expected Failed after abandon, got {other:?}"),
410        }
411        // After TTL a new leader can proceed and publish successfully.
412        thread::sleep(Duration::from_millis(520));
413        let g = sf
414            .begin_or_wait_guard(key.clone())
415            .unwrap()
416            .expect("leader2");
417        g.success();
418        // Key removed after publish — next caller becomes leader again (or WaitDone
419        // if a concurrent waiter saw the wake). Either is fine; not Loading.
420        match sf.begin_or_wait(&key) {
421            BeginLoad::Leader | BeginLoad::WaitDone => {}
422            BeginLoad::Failed(m) => panic!("unexpected Failed: {m}"),
423        }
424    }
425}