Skip to main content

pingora_cache/eviction/
async_lru.rs

1// Copyright 2026 Cloudflare, Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Async LRU eviction manager.
16//!
17//! [`Manager`] wraps [`AsyncLru`] and implements the [`EvictionManager`]
18//! trait. Eviction happens asynchronously inside the actor tasks, so
19//! [`EvictionManager::admit`] and [`EvictionManager::increment_weight`]
20//! **always return empty `Vec`s** — callers should not expect synchronous
21//! eviction results from these methods. Evicted items are instead handled
22//! by the [`AsyncEvictionCallback`] provided at construction time.
23
24use super::{CacheEntryKey, CacheEntryKeyRef, EvictionManager};
25#[cfg(test)]
26use crate::key::CompactCacheKey;
27
28use async_trait::async_trait;
29use pingora_core::server::ShutdownWatch;
30use pingora_error::{ErrorType::*, OrErr, Result};
31use pingora_lru::{
32    async_lru::{hash_key, AsyncEvictionCallback, AsyncLru},
33    persistence,
34};
35use serde::de::SeqAccess;
36
37use std::sync::Arc;
38use std::time::SystemTime;
39
40/// Async LRU eviction manager.
41///
42/// (Nearly) drop-in replacement for [`super::lru::Manager`] that uses actor-based
43/// shards instead of `RwLock`-based shards. Eviction is handled
44/// asynchronously by dedicated eviction worker tasks.
45///
46/// Construct via the builder pattern: call [`Manager::builder`] with the
47/// required arguments (weight limit, [`AsyncEvictionCallback`], and
48/// [`ShutdownWatch`]), then chain optional setters before calling
49/// [`ManagerBuilder::build`].
50pub struct Manager<const N: usize> {
51    lru: Arc<AsyncLru<CacheEntryKey, N>>,
52}
53
54impl<const N: usize> Manager<N> {
55    /// Construct from a pre-built [`AsyncLru`].
56    pub fn from_lru(lru: Arc<AsyncLru<CacheEntryKey, N>>) -> Self {
57        Manager { lru }
58    }
59}
60
61/// Builder for [`Manager`].
62///
63/// # Required
64/// - `weight_limit` — maximum total weight before eviction
65/// - `eviction_cb` — callback invoked for each evicted `(key, weight)` pair
66/// - `shutdown` — watch receiver that signals actors to stop
67///
68/// # Example
69/// ```ignore
70/// let manager = Manager::<32>::builder(limit, eviction_cb, shutdown_rx, runtime_handle)
71///     .capacity(10_000)
72///     .build();
73/// ```
74pub struct ManagerBuilder<C, const N: usize> {
75    weight_limit: usize,
76    eviction_cb: Arc<C>,
77    shutdown: ShutdownWatch,
78    runtime: tokio::runtime::Handle,
79    capacity: usize,
80    len_watermark: Option<usize>,
81    num_eviction_workers: usize,
82}
83
84impl<C, const N: usize> ManagerBuilder<C, N>
85where
86    C: AsyncEvictionCallback<CacheEntryKey>,
87{
88    /// Set the estimated per-shard capacity for preallocation.
89    pub fn capacity(mut self, capacity: usize) -> Self {
90        self.capacity = capacity;
91        self
92    }
93
94    /// Set an optional item-count watermark.
95    pub fn len_watermark(mut self, watermark: usize) -> Self {
96        self.len_watermark = Some(watermark);
97        self
98    }
99
100    /// Set the number of concurrent eviction worker tasks.
101    /// Defaults to `N` (one per shard).
102    pub fn num_eviction_workers(mut self, n: usize) -> Self {
103        self.num_eviction_workers = n;
104        self
105    }
106
107    /// Build the [`Manager`], spawning shard actors and eviction workers.
108    ///
109    /// Must be called within a tokio runtime context.
110    pub fn build(self) -> Manager<N> {
111        let mut lru_builder = AsyncLru::builder(
112            self.weight_limit,
113            self.eviction_cb,
114            self.shutdown,
115            self.runtime,
116        )
117        .capacity(self.capacity)
118        .num_eviction_workers(self.num_eviction_workers);
119        if let Some(w) = self.len_watermark {
120            lru_builder = lru_builder.len_watermark(w);
121        }
122        Manager {
123            lru: Arc::new(lru_builder.build()),
124        }
125    }
126}
127
128impl<const N: usize> Manager<N> {
129    /// Create a builder for constructing a [`Manager`].
130    ///
131    /// This is the **only** way to construct a `Manager`. All four
132    /// arguments are required:
133    ///
134    /// - `weight_limit` — maximum total weight before eviction.
135    /// - `eviction_cb` — callback invoked for every evicted `(key, weight)`.
136    /// - `shutdown` — [`ShutdownWatch`] that signals actors to stop.
137    /// - `runtime` — [`tokio::runtime::Handle`] on which internal tasks
138    ///   are spawned.
139    pub fn builder<C>(
140        weight_limit: usize,
141        eviction_cb: Arc<C>,
142        shutdown: ShutdownWatch,
143        runtime: tokio::runtime::Handle,
144    ) -> ManagerBuilder<C, N>
145    where
146        C: AsyncEvictionCallback<CacheEntryKey>,
147    {
148        ManagerBuilder {
149            weight_limit,
150            eviction_cb,
151            shutdown,
152            runtime,
153            capacity: 0,
154            len_watermark: None,
155            num_eviction_workers: N.max(1),
156        }
157    }
158
159    /// Return the total number of shards (`N`).
160    pub fn shards(&self) -> usize {
161        self.lru.shards()
162    }
163
164    /// Query the exact weight of a specific shard via request/response to its
165    /// actor. Returns `None` if `shard >= N`.
166    pub async fn shard_weight(&self, shard: usize) -> Option<usize> {
167        self.lru.shard_weight(shard).await
168    }
169
170    /// Get the number of items in a specific shard. Lock-free, best-effort
171    /// (`Relaxed` atomic load).
172    pub fn shard_len(&self, shard: usize) -> usize {
173        self.lru.shard_len(shard)
174    }
175
176    /// Compute the shard index for a given cache entry using the same hash
177    /// function used internally by the LRU.
178    pub fn get_shard_for_key(&self, key: &CacheEntryKey) -> usize {
179        (hash_key(key) % N as u64) as usize
180    }
181
182    /// Peek at the least-recently-used key in the given shard. Async —
183    /// sends a request/response message to the shard actor. Returns `None`
184    /// if `shard >= N` or the shard is empty.
185    pub async fn peek_lru(&self, shard: usize) -> Option<CacheEntryKey> {
186        self.lru.peek_lru(shard).await.map(|(key, _weight)| key)
187    }
188
189    /// Peek the weight of a cache entry without promoting it. Lock-free.
190    /// Returns `None` if the entry is absent.
191    pub fn peek_weight(&self, item: &CacheEntryKey) -> Option<usize> {
192        self.lru.peek_weight(item)
193    }
194
195    /// Serialize the contents of a single shard to MessagePack bytes.
196    ///
197    /// Snapshots the shard via the actor (cloning all keys), then serializes
198    /// the `(key, weight)` pairs as a msgpack sequence.
199    pub async fn serialize_shard(&self, shard: usize) -> Result<Vec<u8>> {
200        use rmp_serde::encode::Serializer;
201        use serde::ser::SerializeSeq;
202        use serde::ser::Serializer as _;
203
204        let items = self.lru.snapshot_shard(shard).await.unwrap_or_default();
205        let mut ser = Serializer::new(vec![]);
206        let mut seq = ser
207            .serialize_seq(Some(items.len()))
208            .or_err(InternalError, "fail to serialize node")?;
209        for item in &items {
210            seq.serialize_element(item)
211                .or_err(InternalError, "when serializing LRU element")?;
212        }
213        seq.end().or_err(InternalError, "when serializing LRU")?;
214        Ok(ser.into_inner())
215    }
216
217    /// Deserialize a shard buffer into a list of `(key, weight)` pairs.
218    fn deserialize_shard(buf: &[u8]) -> Result<Vec<(CacheEntryKey, usize)>> {
219        use rmp_serde::decode::Deserializer;
220        use serde::de::Deserializer as _;
221
222        let mut de = Deserializer::new(buf);
223        let visitor = CollectItems;
224        de.deserialize_seq(visitor)
225            .or_err(InternalError, "when deserializing async LRU")
226    }
227
228    /// Insert deserialized items into the LRU at the tail.
229    fn load_shard(&self, items: Vec<(CacheEntryKey, usize)>) {
230        for (key, weight) in items {
231            self.lru.insert_tail(key, weight);
232        }
233    }
234}
235
236struct CollectItems;
237
238impl<'de> serde::de::Visitor<'de> for CollectItems {
239    type Value = Vec<(CacheEntryKey, usize)>;
240
241    fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
242        formatter.write_str("array of (key, weight) tuples")
243    }
244
245    fn visit_seq<A>(self, mut seq: A) -> std::result::Result<Self::Value, A::Error>
246    where
247        A: SeqAccess<'de>,
248    {
249        let mut items = Vec::with_capacity(seq.size_hint().unwrap_or(0));
250        while let Some(item) = seq.next_element::<(CacheEntryKey, usize)>()? {
251            items.push(item);
252        }
253        Ok(items)
254    }
255}
256
257const FILE_NAME: &str = "lru.data";
258
259#[async_trait]
260impl<const N: usize> EvictionManager for Manager<N> {
261    /// Total weight across all shards. Lock-free.
262    fn total_size(&self) -> usize {
263        self.lru.weight()
264    }
265    /// Total item count across all shards. Lock-free.
266    fn total_items(&self) -> usize {
267        self.lru.len()
268    }
269    /// Accumulated weight of all evicted items since construction. Lock-free.
270    fn evicted_size(&self) -> usize {
271        self.lru.evicted_weight()
272    }
273    /// Accumulated count of all evicted items since construction. Lock-free.
274    fn evicted_items(&self) -> usize {
275        self.lru.evicted_len()
276    }
277
278    /// Admit a cache key with the given size. Fire-and-forget.
279    ///
280    /// **Always returns `vec![]`** — eviction is handled asynchronously by
281    /// the eviction workers and delivered via the [`AsyncEvictionCallback`].
282    fn admit(
283        &self,
284        item: CacheEntryKey,
285        size: usize,
286        _fresh_until: SystemTime,
287    ) -> Vec<CacheEntryKey> {
288        self.lru.admit(item, size);
289        vec![]
290    }
291
292    /// Increment a cache key's weight, admitting it if needed. Fire-and-forget.
293    ///
294    /// **Always returns `vec![]`** — eviction is handled asynchronously by
295    /// the eviction workers and delivered via the [`AsyncEvictionCallback`].
296    fn increment_weight(
297        &self,
298        item: &CacheEntryKey,
299        delta: usize,
300        max_weight: Option<usize>,
301    ) -> Vec<CacheEntryKey> {
302        self.lru.increment_weight(item, delta, max_weight);
303        vec![]
304    }
305
306    /// Remove a cache key from the LRU. Fire-and-forget — enqueued on the
307    /// shard's unbounded channel, so the message is never dropped (it fails
308    /// only if the actor is gone, i.e. during shutdown).
309    fn remove(&self, item: CacheEntryKeyRef<'_>) {
310        self.lru.remove_by_hash(hash_key(&item));
311    }
312
313    /// Record an access to a cache key. If the key already exists it is
314    /// promoted to the head of the LRU; otherwise it is admitted with the
315    /// given `size`. Returns `true` if the key was already present (promoted),
316    /// `false` if it was newly admitted.
317    fn access(&self, item: &CacheEntryKey, size: usize, _fresh_until: SystemTime) -> bool {
318        if self.lru.promote(item) {
319            true
320        } else {
321            self.lru.admit(item.clone(), size);
322            false
323        }
324    }
325
326    /// Check whether a cache key exists in the LRU without promoting it.
327    /// Lock-free.
328    fn peek(&self, item: &CacheEntryKey) -> bool {
329        self.lru.peek(item)
330    }
331
332    /// Persist all shards sequentially to the given directory.
333    ///
334    /// Each shard is snapshotted via the actor, serialized to MessagePack,
335    /// and written to `{dir_path}/lru.data.{shard_index}` using atomic rename.
336    async fn save(&self, dir_path: &str) -> Result<()> {
337        persistence::save_shards_async(dir_path, FILE_NAME, N, |i| self.serialize_shard(i))
338            .await
339            .or_err(InternalError, "failed to save async LRU")?;
340        Ok(())
341    }
342
343    /// Load the current manager's shard files from the given directory.
344    ///
345    /// Each file is deserialized from MessagePack, and the resulting items are
346    /// inserted at the tail of the LRU.
347    async fn load(&self, dir_path: &str) -> Result<()> {
348        persistence::load_shards(dir_path, FILE_NAME, N, |_i, data| {
349            Self::deserialize_shard(data).map(|items| self.load_shard(items))
350        })
351        .await;
352        Ok(())
353    }
354}
355
356#[cfg(test)]
357impl<const N: usize> Manager<N> {
358    fn increment_weight(
359        &self,
360        item: &CompactCacheKey,
361        delta: usize,
362        max_weight: Option<usize>,
363    ) -> Vec<CompactCacheKey> {
364        EvictionManager::increment_weight(
365            self,
366            &CacheEntryKey::key_only(item.clone()),
367            delta,
368            max_weight,
369        )
370        .into_iter()
371        .map(CacheEntryKey::into_key)
372        .collect()
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use crate::key::CacheKey;
380
381    #[tokio::test(flavor = "multi_thread")]
382    async fn increment_weight_admits_missing_key() {
383        let (_shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
384        let manager = Manager::<1>::builder(
385            100,
386            Arc::new(|_key: CacheEntryKey, _weight| async {}),
387            shutdown_rx,
388            tokio::runtime::Handle::current(),
389        )
390        .build();
391        let key = CacheKey::new("missing", "1").to_compact();
392
393        manager.increment_weight(&key, 7, Some(10));
394        manager.shard_weight(0).await;
395
396        assert_eq!(manager.peek_weight(&CacheEntryKey::key_only(key)), Some(7));
397        assert_eq!(manager.total_items(), 1);
398    }
399
400    #[tokio::test(flavor = "multi_thread")]
401    async fn remove_requires_complete_identity() {
402        let (_shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
403        let manager = Manager::<1>::builder(
404            100,
405            Arc::new(|_key: CacheEntryKey, _weight| async {}),
406            shutdown_rx,
407            tokio::runtime::Handle::current(),
408        )
409        .build();
410        let key = CacheKey::new("identified", "1").to_compact();
411        let id = crate::eviction::CacheEntryId::new(42);
412
413        let _ = EvictionManager::admit(
414            &manager,
415            CacheEntryKey::identified(key.clone(), id),
416            1,
417            SystemTime::now(),
418        );
419        // A shard query waits for the actor to process preceding messages on its FIFO channel.
420        manager.shard_weight(0).await;
421
422        EvictionManager::remove(&manager, CacheEntryKeyRef::from_entry_id(&key, None));
423        manager.shard_weight(0).await;
424        assert_eq!(manager.total_size(), 1);
425
426        EvictionManager::remove(&manager, CacheEntryKeyRef::from_entry_id(&key, Some(id)));
427        manager.shard_weight(0).await;
428        assert_eq!(manager.total_size(), 0);
429    }
430
431    #[tokio::test(flavor = "multi_thread")]
432    async fn save_returns_error_when_all_shards_fail() {
433        let dir = std::env::temp_dir().join(format!(
434            "pingora-async-lru-total-save-failure-{}-{}",
435            std::process::id(),
436            rand::random::<u32>()
437        ));
438        std::fs::create_dir_all(&dir).unwrap();
439        for shard in 0..2 {
440            // Renaming a temporary file over a directory fails.
441            std::fs::create_dir(dir.join(format!("{FILE_NAME}.{shard}"))).unwrap();
442        }
443
444        let (_shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
445        let manager = Manager::<2>::builder(
446            100,
447            Arc::new(|_key: CacheEntryKey, _weight| async {}),
448            shutdown_rx,
449            tokio::runtime::Handle::current(),
450        )
451        .build();
452
453        let error = manager.save(dir.to_str().unwrap()).await.unwrap_err();
454        assert!(
455            error.to_string().contains("failed to save async LRU"),
456            "unexpected error: {error}"
457        );
458
459        std::fs::remove_dir_all(dir).unwrap();
460    }
461}