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}