Skip to main content

aa_cache/
l1.rs

1//! [`L1Cache`] — a `DashMap`-backed, TTL'd, cache-aside wrapper over a store.
2
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6
7use aa_core::storage::Result;
8use dashmap::mapref::entry::Entry;
9use dashmap::DashMap;
10use tokio::sync::Notify;
11
12use crate::cached_value::CachedValue;
13use crate::source::CacheSource;
14
15/// In-process L1 cache that fronts a [`CacheSource`] with a [`DashMap`].
16///
17/// `get` serves fresh keys from memory and falls back to the wrapped store on a
18/// miss or once an entry's TTL elapses, repopulating the cache on the way out
19/// (cache-aside). Concurrent misses for the same key collapse to a single
20/// `load` call (stampede protection), so a burst of cold lookups never fans out
21/// into N backend round-trips.
22pub struct L1Cache<S: CacheSource> {
23    inner: S,
24    entries: Arc<DashMap<S::Key, CachedValue<S::Value>>>,
25    inflight: Arc<DashMap<S::Key, Arc<Notify>>>,
26    /// Monotonic invalidation counter, bumped by every [`invalidate`](Self::invalidate).
27    /// A leader snapshots it before loading and refuses to cache its result if the
28    /// counter moved during the load window, so a push-invalidation that races an
29    /// in-flight load is never silently lost (see AAASM-3985).
30    epoch: AtomicU64,
31    ttl: Duration,
32    /// Upper bound on the number of live entries. Once an insert pushes the map
33    /// past this, [`enforce_capacity`](Self::enforce_capacity) drops expired
34    /// entries first, then the oldest-by-insertion entries, so the cache can
35    /// never grow without bound (AAASM-3997).
36    max_entries: usize,
37}
38
39/// Default entry ceiling for [`L1Cache::new`]. Large enough to be a no-op for
40/// realistic agent populations, small enough to bound memory if a caller never
41/// invalidates and the key space is effectively unbounded.
42const DEFAULT_MAX_ENTRIES: usize = 100_000;
43
44impl<S: CacheSource> L1Cache<S> {
45    /// Wrap `inner`, expiring cached entries `ttl` after insertion, with the
46    /// default entry ceiling ([`DEFAULT_MAX_ENTRIES`]).
47    pub fn new(inner: S, ttl: Duration) -> Self {
48        Self::with_max_entries(inner, ttl, DEFAULT_MAX_ENTRIES)
49    }
50
51    /// Like [`new`](Self::new) but with an explicit maximum live-entry count.
52    ///
53    /// `max_entries` is clamped to at least 1 so the cache always retains the
54    /// entry it just loaded.
55    pub fn with_max_entries(inner: S, ttl: Duration, max_entries: usize) -> Self {
56        Self {
57            inner,
58            entries: Arc::new(DashMap::new()),
59            inflight: Arc::new(DashMap::new()),
60            epoch: AtomicU64::new(0),
61            ttl,
62            max_entries: max_entries.max(1),
63        }
64    }
65
66    /// Borrow the wrapped store.
67    pub fn inner(&self) -> &S {
68        &self.inner
69    }
70
71    /// Number of entries currently held (including any past their TTL but not
72    /// yet evicted). Intended for diagnostics, not control flow.
73    #[must_use]
74    pub fn len(&self) -> usize {
75        self.entries.len()
76    }
77
78    /// Whether the cache holds no entries.
79    #[must_use]
80    pub fn is_empty(&self) -> bool {
81        self.entries.is_empty()
82    }
83
84    /// Drop every cached entry.
85    pub fn clear(&self) {
86        self.entries.clear();
87    }
88
89    /// Drop the cached entry for `key`; returns whether one was present.
90    ///
91    /// This is the hook the Epic C push-invalidation channel calls when the
92    /// Gateway reports that an agent's policy changed: the next `get` reloads
93    /// from the source of truth rather than serving a stale entry.
94    pub fn invalidate(&self, key: &S::Key) -> bool {
95        // Bump the epoch *before* removing. A concurrent leader load that
96        // snapshotted the old epoch will fail its post-load check and discard
97        // its now-stale value; and because the leader commits its insert under
98        // the same shard lock that `remove` takes, the bump-then-remove here is
99        // ordered against the check-then-insert there — the eviction can't be
100        // lost to a racing insert (AAASM-3985).
101        self.epoch.fetch_add(1, Ordering::AcqRel);
102        self.entries.remove(key).is_some()
103    }
104
105    /// Bound the live-entry count at [`max_entries`](Self::max_entries),
106    /// evicting when an insert pushes the map over the ceiling (AAASM-3997).
107    ///
108    /// Eviction is a pure size-management concern, independent of the
109    /// invalidation epoch: removing a cached entry is always safe (the worst
110    /// case is a subsequent miss that reloads from the source of truth), so this
111    /// never races the epoch/guarded-insert logic. It must be called *without*
112    /// holding any `entries` shard guard to avoid a DashMap self-deadlock.
113    fn enforce_capacity(&self) {
114        if self.entries.len() <= self.max_entries {
115            return;
116        }
117        // Cheap first pass: drop entries already past their TTL (they are misses
118        // anyway) before resorting to age-based eviction of live entries.
119        self.entries.retain(|_, value| !value.is_expired(self.ttl));
120        let len = self.entries.len();
121        if len <= self.max_entries {
122            return;
123        }
124        // Still over budget: evict the oldest-by-insertion entries. Snapshot the
125        // (inserted_at, key) pairs, sort ascending, and remove the excess.
126        let mut stamped: Vec<(Instant, S::Key)> = self
127            .entries
128            .iter()
129            .map(|entry| (entry.value().inserted_at, entry.key().clone()))
130            .collect();
131        stamped.sort_by_key(|(inserted_at, _)| *inserted_at);
132        for (_, key) in stamped.into_iter().take(len - self.max_entries) {
133            self.entries.remove(&key);
134        }
135    }
136
137    /// Return a fresh (non-expired) cached value for `key`, if present.
138    fn fresh(&self, key: &S::Key) -> Option<S::Value> {
139        let entry = self.entries.get(key)?;
140        if entry.is_expired(self.ttl) {
141            None
142        } else {
143            Some(entry.value.clone())
144        }
145    }
146
147    /// Fetch the value for `key`, serving from cache when fresh.
148    ///
149    /// Cache-aside: a hit clones out of the `DashMap`; a miss (or an expired
150    /// entry) loads from the wrapped store, populates the cache, and returns.
151    ///
152    /// Stampede protection: the first caller to miss a key becomes the *leader*
153    /// and performs the single `load`; concurrent callers become *followers*,
154    /// wait on a shared [`Notify`], then re-read the now-populated cache. The
155    /// inner store therefore sees exactly one call per key per miss window.
156    pub async fn get(&self, key: S::Key) -> Result<S::Value> {
157        loop {
158            // Fast path: a fresh cache hit needs no coordination.
159            if let Some(value) = self.fresh(&key) {
160                return Ok(value);
161            }
162
163            // Miss: claim leadership for this key, or grab the in-flight signal.
164            let follower = match self.inflight.entry(key.clone()) {
165                Entry::Vacant(slot) => {
166                    slot.insert(Arc::new(Notify::new()));
167                    None
168                }
169                Entry::Occupied(slot) => Some(slot.get().clone()),
170            };
171
172            match follower {
173                // Leader: load once, populate, then wake every waiter.
174                None => {
175                    // Snapshot the invalidation epoch before the load so a push
176                    // `invalidate` that lands mid-load is detected below.
177                    let epoch_before = self.epoch.load(Ordering::Acquire);
178                    let result = self.inner.load(&key).await;
179                    let mut inserted = false;
180                    if let Ok(ref value) = result {
181                        // Commit under the key's shard lock, and only if no
182                        // invalidation raced the load. Holding the entry guard
183                        // serializes this check-and-insert against `invalidate`'s
184                        // `remove`, so a concurrent eviction is never lost: either
185                        // we observe the bumped epoch and skip the insert, or the
186                        // remove runs after us and drops the entry we just wrote.
187                        // The guard is confined to this inner block so it is
188                        // dropped before `enforce_capacity` touches the map.
189                        {
190                            let entry = self.entries.entry(key.clone());
191                            if self.epoch.load(Ordering::Acquire) == epoch_before {
192                                match entry {
193                                    Entry::Occupied(mut occupied) => {
194                                        occupied.insert(CachedValue::new(value.clone()));
195                                    }
196                                    Entry::Vacant(vacant) => {
197                                        vacant.insert(CachedValue::new(value.clone()));
198                                    }
199                                }
200                                inserted = true;
201                            }
202                        }
203                    }
204                    // Bound the cache size once the shard guard is released
205                    // (AAASM-3997). Only meaningful after a real insert.
206                    if inserted {
207                        self.enforce_capacity();
208                    }
209                    if let Some((_, notify)) = self.inflight.remove(&key) {
210                        notify.notify_waiters();
211                    }
212                    return result;
213                }
214                // Follower: wait for the leader, then retry the loop.
215                Some(notify) => {
216                    let waiter = notify.notified();
217                    tokio::pin!(waiter);
218                    // Register before re-checking the cache so the leader's
219                    // notification can't be missed (tokio::sync::Notify pattern):
220                    // the leader always populates `entries` before notifying, so
221                    // either the re-check sees the value or the wait is woken.
222                    waiter.as_mut().enable();
223                    if let Some(value) = self.fresh(&key) {
224                        return Ok(value);
225                    }
226                    waiter.await;
227                }
228            }
229        }
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use std::time::Duration;
236
237    use aa_core::storage::AgentId;
238
239    use crate::testing::{sample_policy, MemoryPolicyStore};
240    use crate::L1Cache;
241
242    fn agent(seed: u8) -> AgentId {
243        AgentId::from_bytes([seed; 16])
244    }
245
246    #[tokio::test]
247    async fn miss_populates_then_serves_from_cache() {
248        let id = agent(1);
249        let store = MemoryPolicyStore::with_policy(id, sample_policy(1));
250        let cache = L1Cache::new(store, Duration::from_secs(60));
251
252        // First get is a miss: hits the store and populates the cache.
253        let first = cache.get(id).await.expect("policy present");
254        assert_eq!(first.version, 1);
255        assert_eq!(cache.inner().call_count(), 1);
256        assert_eq!(cache.len(), 1);
257
258        // Second get is a hit: served from memory, the store is not touched again.
259        let second = cache.get(id).await.expect("policy present");
260        assert_eq!(second.version, 1);
261        assert_eq!(cache.inner().call_count(), 1);
262    }
263
264    #[tokio::test]
265    async fn expired_entry_is_treated_as_a_miss() {
266        let id = agent(2);
267        let store = MemoryPolicyStore::with_policy(id, sample_policy(1));
268        let cache = L1Cache::new(store, Duration::from_millis(20));
269
270        cache.get(id).await.expect("policy present");
271        assert_eq!(cache.inner().call_count(), 1);
272
273        // Let the entry age past its TTL; the next get must reload from the store.
274        tokio::time::sleep(Duration::from_millis(40)).await;
275        cache.get(id).await.expect("policy present");
276        assert_eq!(cache.inner().call_count(), 2);
277    }
278
279    #[tokio::test]
280    async fn invalidate_evicts_the_cached_entry() {
281        let id = agent(3);
282        let store = MemoryPolicyStore::with_policy(id, sample_policy(1));
283        let cache = L1Cache::new(store, Duration::from_secs(60));
284
285        cache.get(id).await.expect("policy present");
286        assert_eq!(cache.len(), 1);
287
288        // Invalidate removes the entry and reports it was present.
289        assert!(cache.invalidate(&id));
290        assert_eq!(cache.len(), 0);
291
292        // Invalidating the now-absent key reports nothing was removed.
293        assert!(!cache.invalidate(&id));
294
295        // The next get is a fresh miss that reloads from the store.
296        cache.get(id).await.expect("policy present");
297        assert_eq!(cache.inner().call_count(), 2);
298    }
299
300    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
301    async fn invalidate_during_load_is_not_lost() {
302        use std::sync::Arc;
303
304        let id = agent(5);
305        // A 50ms inner delay holds the leader inside `load` long enough for the
306        // racing `invalidate` below to land in the middle of the load window.
307        let store = MemoryPolicyStore::with_policy(id, sample_policy(1)).with_delay(Duration::from_millis(50));
308        let cache = Arc::new(L1Cache::new(store, Duration::from_secs(60)));
309
310        // Leader begins a miss and is now sleeping inside `load`.
311        let leader = {
312            let cache = Arc::clone(&cache);
313            tokio::spawn(async move { cache.get(id).await })
314        };
315
316        // Let the leader enter `load`, then invalidate while it is in flight.
317        // `entries` is still empty, so the old code's `remove` was a silent
318        // no-op and the leader would go on to cache the value it is loading.
319        tokio::time::sleep(Duration::from_millis(20)).await;
320        assert!(!cache.invalidate(&id));
321
322        // The leader still returns the value it loaded, but must NOT have cached
323        // it: the invalidation raced its load and takes precedence.
324        leader.await.expect("task joined").expect("policy present");
325        assert_eq!(
326            cache.len(),
327            0,
328            "stale value must not be cached after a racing invalidate"
329        );
330        assert_eq!(cache.inner().call_count(), 1);
331
332        // Because nothing was cached, the next get is a fresh miss that reloads
333        // from the source of truth rather than serving the stale entry.
334        cache.get(id).await.expect("policy present");
335        assert_eq!(cache.inner().call_count(), 2);
336        assert_eq!(cache.len(), 1);
337    }
338
339    #[tokio::test]
340    async fn cache_is_bounded_by_max_entries() {
341        // AAASM-3997: without a bound the L1 map grew once per distinct key and
342        // never shrank. With a ceiling of 2, loading 5 distinct keys must leave
343        // at most 2 resident, and the most-recently loaded key stays cached.
344        let mut store = MemoryPolicyStore::new();
345        for seed in 0..5u8 {
346            store.insert(agent(seed), sample_policy(u32::from(seed)));
347        }
348        let cache = L1Cache::with_max_entries(store, Duration::from_secs(60), 2);
349
350        for seed in 0..5u8 {
351            cache.get(agent(seed)).await.expect("policy present");
352        }
353
354        assert!(cache.len() <= 2, "cache grew past its ceiling: {} entries", cache.len());
355
356        // The newest key was loaded last, so it survived eviction: serving it is
357        // a hit that does not touch the store again.
358        let calls_before = cache.inner().call_count();
359        cache.get(agent(4)).await.expect("policy present");
360        assert_eq!(
361            cache.inner().call_count(),
362            calls_before,
363            "the most-recently loaded key should still be cached"
364        );
365    }
366
367    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
368    async fn concurrent_misses_collapse_to_one_load() {
369        use std::sync::Arc;
370
371        let id = agent(4);
372        // A 50ms inner delay holds the leader long enough for all followers to
373        // pile up behind it before it finishes loading.
374        let store = MemoryPolicyStore::with_policy(id, sample_policy(7)).with_delay(Duration::from_millis(50));
375        let cache = Arc::new(L1Cache::new(store, Duration::from_secs(60)));
376
377        // Fire 100 concurrent gets for the same cold key.
378        let mut handles = Vec::with_capacity(100);
379        for _ in 0..100 {
380            let cache = Arc::clone(&cache);
381            handles.push(tokio::spawn(async move { cache.get(id).await }));
382        }
383        for handle in handles {
384            let policy = handle.await.expect("task joined").expect("policy present");
385            assert_eq!(policy.version, 7);
386        }
387
388        // Every miss collapsed onto a single inner load.
389        assert_eq!(cache.inner().call_count(), 1);
390    }
391}