Skip to main content

ferro_rs/memo/
mod.rs

1//! Request-scoped memoization store.
2//!
3//! A [`MemoStore`] is created fresh per HTTP request and held in the
4//! `MEMO_STORE` task-local. Any async function annotated with
5//! `#[memoize]` reads the ambient store via [`current_memo_store()`];
6//! outside a request context the function runs normally with no caching
7//! (graceful no-op, D-02).
8
9use futures::future::{BoxFuture, FutureExt, Shared};
10use std::any::Any;
11use std::collections::HashMap;
12use std::sync::{Arc, Mutex};
13
14// ── Task-local ──────────────────────────────────────────────────────────────
15
16tokio::task_local! {
17    /// Task-local slot that holds the per-request memo store.
18    ///
19    /// Entered once per request in `server.rs`; absent in background jobs,
20    /// queue workers, and tests that do not explicitly scope it.
21    pub(crate) static MEMO_STORE: Arc<MemoStore>;
22}
23
24// ── Public types ─────────────────────────────────────────────────────────────
25
26/// Type-erased awaitable slot stored in a [`MemoStore`] entry.
27///
28/// A `Shared` future whose output is an `Arc<dyn Any + Send + Sync>`.
29/// Multiple concurrent callers can await the same slot; the wrapped future
30/// runs exactly once.
31pub type MemoSlot = Shared<BoxFuture<'static, Arc<dyn Any + Send + Sync>>>;
32
33/// Key for a memoized call: callsite identity plus a hash of the arguments.
34///
35/// `callsite` is the [`std::any::TypeId`] of a per-macro-expansion
36/// zero-sized marker type, ensuring distinct call sites never share slots
37/// even when argument hashes collide.
38#[derive(Eq, PartialEq, Hash, Clone, Copy)]
39pub struct MemoKey {
40    callsite: std::any::TypeId,
41    args_hash: u64,
42}
43
44impl MemoKey {
45    /// Construct a key for the given callsite marker and hashable arguments.
46    ///
47    /// `Marker` is a zero-sized type minted by `#[memoize]` at each
48    /// expansion site. `A` is the tuple of non-receiver arguments; it must
49    /// implement [`std::hash::Hash`].
50    pub fn new<Marker: 'static, A: std::hash::Hash>(args: &A) -> Self {
51        use std::collections::hash_map::DefaultHasher;
52        use std::hash::Hasher;
53        let mut h = DefaultHasher::new();
54        args.hash(&mut h);
55        Self {
56            callsite: std::any::TypeId::of::<Marker>(),
57            args_hash: h.finish(),
58        }
59    }
60}
61
62/// Per-request memoization store.
63///
64/// Holds a map from [`MemoKey`] to a [`MemoSlot`] (a shared awaitable
65/// future). The first caller for a key inserts a pending future via
66/// [`get_or_insert`](MemoStore::get_or_insert); subsequent callers —
67/// including concurrent ones — receive a clone of the same slot and await
68/// the same underlying computation.
69///
70/// Created fresh at the start of each HTTP request and dropped when the
71/// request's task-local scope exits. No entries survive across requests.
72pub struct MemoStore {
73    entries: Mutex<HashMap<MemoKey, MemoSlot>>,
74}
75
76impl MemoStore {
77    /// Create an empty store.
78    pub fn new() -> Self {
79        Self {
80            entries: Mutex::new(HashMap::new()),
81        }
82    }
83
84    /// Return the cached slot for `key`, inserting one built by `make_fut`
85    /// if none exists.
86    ///
87    /// The mutex guard is released **before** the slot is returned to the
88    /// caller, so the caller can `.await` the slot without holding any lock.
89    /// `make_fut` is called at most once per key per store lifetime.
90    pub fn get_or_insert(
91        &self,
92        key: MemoKey,
93        make_fut: impl FnOnce() -> BoxFuture<'static, Arc<dyn Any + Send + Sync>>,
94    ) -> MemoSlot {
95        let slot = {
96            let mut map = self.entries.lock().unwrap();
97            map.entry(key)
98                .or_insert_with(|| make_fut().shared())
99                .clone()
100        }; // MutexGuard dropped here — safe to .await outside the lock
101        slot
102    }
103}
104
105impl Default for MemoStore {
106    fn default() -> Self {
107        Self::new()
108    }
109}
110
111// ── Scope helpers ─────────────────────────────────────────────────────────────
112
113/// Return the current request's memo store, if inside a request context.
114///
115/// Returns `None` outside a request scope (background jobs, queue workers,
116/// tests that do not enter a `MEMO_STORE.scope`). Never panics.
117pub fn current_memo_store() -> Option<Arc<MemoStore>> {
118    MEMO_STORE.try_with(|s| s.clone()).ok()
119}
120
121/// Create a fresh `Arc<MemoStore>` for a new request scope.
122// Used by server.rs to enter the per-request scope.
123#[allow(dead_code)]
124pub(crate) fn memo_scope() -> Arc<MemoStore> {
125    Arc::new(MemoStore::new())
126}
127
128/// Run `f` within a `MEMO_STORE` scope backed by `store`.
129// Used by server.rs to enter the per-request scope.
130#[allow(dead_code)]
131pub(crate) async fn with_memo_scope<F, R>(store: Arc<MemoStore>, f: F) -> R
132where
133    F: std::future::Future<Output = R>,
134{
135    MEMO_STORE.scope(store, f).await
136}
137
138// ── Macro-level tests (in-crate, uses #[memoize] via crate::memoize) ─────────
139
140#[cfg(test)]
141mod macro_tests;
142
143// ── Render-path integration tests (SC-3 proof, projections feature-gated) ────
144
145#[cfg(all(test, feature = "projections"))]
146mod render_path_tests;
147
148// ── Unit tests ────────────────────────────────────────────────────────────────
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use std::sync::atomic::{AtomicUsize, Ordering};
154
155    // ── hit: same key, body runs once ─────────────────────────────────────
156
157    #[tokio::test]
158    async fn hit_body_runs_once_for_same_key() {
159        let counter = Arc::new(AtomicUsize::new(0));
160        let store = Arc::new(MemoStore::new());
161
162        // Unique marker for this call site.
163        struct Marker;
164        let key = MemoKey::new::<Marker, _>(&42u32);
165
166        let c1 = counter.clone();
167        let slot1 = store.get_or_insert(key, move || {
168            Box::pin(async move {
169                c1.fetch_add(1, Ordering::SeqCst);
170                Arc::new(99u32) as Arc<dyn Any + Send + Sync>
171            })
172        });
173
174        let c2 = counter.clone();
175        let slot2 = store.get_or_insert(key, move || {
176            // This closure must NOT be called because the key already exists.
177            Box::pin(async move {
178                c2.fetch_add(100, Ordering::SeqCst);
179                Arc::new(0u32) as Arc<dyn Any + Send + Sync>
180            })
181        });
182
183        let a1 = slot1.await;
184        let a2 = slot2.await;
185
186        assert_eq!(*a1.downcast_ref::<u32>().unwrap(), 99);
187        assert_eq!(*a2.downcast_ref::<u32>().unwrap(), 99);
188        // Body ran exactly once.
189        assert_eq!(counter.load(Ordering::SeqCst), 1);
190    }
191
192    // ── miss: distinct keys each run their own body ────────────────────────
193
194    #[tokio::test]
195    async fn miss_distinct_keys_each_run_body() {
196        let counter = Arc::new(AtomicUsize::new(0));
197        let store = Arc::new(MemoStore::new());
198
199        struct MarkerA;
200        struct MarkerB;
201        let key_a = MemoKey::new::<MarkerA, _>(&1u32);
202        let key_b = MemoKey::new::<MarkerB, _>(&1u32); // same arg, different callsite
203
204        let ca = counter.clone();
205        let sa = store.get_or_insert(key_a, move || {
206            Box::pin(async move {
207                ca.fetch_add(1, Ordering::SeqCst);
208                Arc::new(10u32) as Arc<dyn Any + Send + Sync>
209            })
210        });
211
212        let cb = counter.clone();
213        let sb = store.get_or_insert(key_b, move || {
214            Box::pin(async move {
215                cb.fetch_add(1, Ordering::SeqCst);
216                Arc::new(20u32) as Arc<dyn Any + Send + Sync>
217            })
218        });
219
220        let va = sa.await;
221        let vb = sb.await;
222
223        assert_eq!(*va.downcast_ref::<u32>().unwrap(), 10);
224        assert_eq!(*vb.downcast_ref::<u32>().unwrap(), 20);
225        // Both bodies ran.
226        assert_eq!(counter.load(Ordering::SeqCst), 2);
227    }
228
229    // ── coalesce: concurrent awaits on the same key run the body once ──────
230
231    #[tokio::test]
232    async fn coalesce_concurrent_callers_run_body_once() {
233        let counter = Arc::new(AtomicUsize::new(0));
234        let store = Arc::new(MemoStore::new());
235
236        struct Marker;
237        let key = MemoKey::new::<Marker, _>(&7u32);
238
239        let c1 = counter.clone();
240        let slot1 = store.get_or_insert(key, move || {
241            Box::pin(async move {
242                c1.fetch_add(1, Ordering::SeqCst);
243                Arc::new(42u32) as Arc<dyn Any + Send + Sync>
244            })
245        });
246
247        let slot2 = store.get_or_insert(key, || {
248            // Must not be called — slot already exists.
249            Box::pin(async move { Arc::new(0u32) as Arc<dyn Any + Send + Sync> })
250        });
251
252        // Drive both concurrently.
253        let (r1, r2) = tokio::join!(slot1, slot2);
254
255        assert_eq!(*r1.downcast_ref::<u32>().unwrap(), 42);
256        assert_eq!(*r2.downcast_ref::<u32>().unwrap(), 42);
257        assert_eq!(counter.load(Ordering::SeqCst), 1);
258    }
259
260    // ── out-of-scope: no panic, returns None ─────────────────────────────
261
262    #[test]
263    fn out_of_scope_returns_none_without_panic() {
264        // No MEMO_STORE scope active — must not panic.
265        let result = current_memo_store();
266        assert!(result.is_none());
267    }
268
269    // ── Err cached: Result-returning future stores the Err for second caller
270
271    #[tokio::test]
272    async fn err_cached_result_returning_future() {
273        let counter = Arc::new(AtomicUsize::new(0));
274        let store = Arc::new(MemoStore::new());
275
276        struct Marker;
277        let key = MemoKey::new::<Marker, _>(&0u32);
278
279        let c1 = counter.clone();
280        let slot1 = store.get_or_insert(key, move || {
281            Box::pin(async move {
282                c1.fetch_add(1, Ordering::SeqCst);
283                // Store a Result::Err as the full cached value.
284                let result: Result<u32, String> = Err("boom".to_string());
285                Arc::new(result) as Arc<dyn Any + Send + Sync>
286            })
287        });
288
289        let slot2 = store.get_or_insert(key, || {
290            Box::pin(async move { Arc::new(Ok::<u32, String>(0)) as Arc<dyn Any + Send + Sync> })
291        });
292
293        let a1 = slot1.await;
294        let a2 = slot2.await;
295
296        let r1 = a1.downcast_ref::<Result<u32, String>>().unwrap();
297        let r2 = a2.downcast_ref::<Result<u32, String>>().unwrap();
298
299        assert!(r1.is_err());
300        assert_eq!(r1.as_ref().unwrap_err(), "boom");
301        assert!(r2.is_err());
302        assert_eq!(r2.as_ref().unwrap_err(), "boom");
303        // Body ran exactly once.
304        assert_eq!(counter.load(Ordering::SeqCst), 1);
305    }
306
307    // ── drop: a fresh store has no entries from a prior scope ─────────────
308
309    #[tokio::test]
310    async fn dropped_store_has_no_prior_entries() {
311        struct Marker;
312        let key = MemoKey::new::<Marker, _>(&5u32);
313
314        // First scope — populate an entry.
315        let store1 = Arc::new(MemoStore::new());
316        {
317            let slot = store1.get_or_insert(key, || {
318                Box::pin(async move { Arc::new(123u32) as Arc<dyn Any + Send + Sync> })
319            });
320            let _ = slot.await;
321        }
322        // Verify it is in store1.
323        {
324            let map = store1.entries.lock().unwrap();
325            assert!(map.contains_key(&key));
326        }
327
328        // Second scope — a fresh store must not see the prior entry.
329        let store2 = Arc::new(MemoStore::new());
330        {
331            let map = store2.entries.lock().unwrap();
332            assert!(!map.contains_key(&key));
333        }
334    }
335
336    // ── with_memo_scope helper wires current_memo_store() correctly ────────
337
338    #[tokio::test]
339    async fn with_scope_makes_current_memo_store_return_some() {
340        let store = memo_scope();
341        let result = with_memo_scope(store, async { current_memo_store() }).await;
342        assert!(result.is_some());
343    }
344}