Skip to main content

rust_mc_status/core/
cache.rs

1//! Response cache with TTL, LRU eviction, and in-flight deduplication.
2//!
3//! ## Overview
4//!
5//! [`ResponseCache`] stores the full [`ServerStatus`] result for each
6//! `(protocol, address)` cache key with a configurable TTL.  On a cache hit
7//! the stored status is returned instantly with `latency = 0.0` and
8//! `cached = true`.
9//!
10//! The cache is **disabled by default** (`TTL = Duration::ZERO`).  Enable it
11//! via [`McClientBuilder::response_cache`](crate::McClientBuilder::response_cache).
12//!
13//! ## In-flight deduplication (thundering herd protection)
14//!
15//! When many tasks concurrently miss the cache for the same key, only **one**
16//! real ping is performed.  The rest subscribe via a `tokio::sync::broadcast`
17//! channel and receive the result as soon as it arrives.
18//!
19//! ```text
20//! task-1  ──► cache miss ──► starts real ping ──────────────► insert + broadcast
21//! task-2  ──► cache miss ──► sees in-flight   ──► subscribe ──────────────────────► receive
22//! task-3  ──► cache miss ──► sees in-flight   ──► subscribe ──────────────────────► receive
23//! ```
24//!
25//! The caller must call [`insert`](ResponseCache::insert) or
26//! [`insert_error`](ResponseCache::insert_error) after the ping completes —
27//! this broadcasts the result to all subscribers and removes the in-flight
28//! entry.  If the sender is dropped without broadcasting, subscribers receive
29//! `Err(RecvError::Closed)` and fall through to a direct ping.
30
31use std::num::NonZeroUsize;
32use std::sync::Arc;
33use std::time::{Duration, SystemTime};
34
35use lru::LruCache;
36use tokio::sync::{RwLock, broadcast};
37
38use crate::error::McError;
39use crate::models::ServerStatus;
40
41/// Default LRU capacity when [`McClientBuilder::response_cache_ttl`](crate::McClientBuilder::response_cache_ttl)
42/// is used without an explicit size.
43pub(crate) const DEFAULT_RESPONSE_CACHE_SIZE: usize = 256;
44
45/// Maximum number of concurrent subscribers per in-flight key.
46///
47/// If more than 64 tasks simultaneously miss for the same key, excess
48/// subscribers will receive a `RecvError::Closed` and fall through to a
49/// direct ping.  This is a very conservative limit — real workloads
50/// rarely see more than a handful of concurrent misses.
51const INFLIGHT_CHANNEL_CAPACITY: usize = 64;
52
53// ─── Internal entry ───────────────────────────────────────────────────────────
54
55#[derive(Clone)]
56struct Entry {
57    status:    ServerStatus,
58    /// Wall-clock time when this entry was stored.  Used to compute TTL expiry.
59    stored_at: SystemTime,
60}
61
62// ─── ResponseCache ────────────────────────────────────────────────────────────
63
64/// Response cache: LRU + TTL + in-flight deduplication.
65///
66/// Cheaply `Clone`-able — all clones share the same underlying `Arc` state.
67///
68/// A TTL of [`Duration::ZERO`] disables the cache entirely — [`get`](Self::get)
69/// always returns `None` and [`insert`](Self::insert) is a no-op.
70#[derive(Clone)]
71pub struct ResponseCache {
72    /// Completed results indexed by cache key.
73    entries: Arc<RwLock<LruCache<String, Entry>>>,
74    /// In-flight keys mapped to their broadcast sender.
75    ///
76    /// A key is present here while a real ping is in progress.
77    /// Removed when the ping completes (via [`insert`](Self::insert) or
78    /// [`insert_error`](Self::insert_error)).
79    inflight: Arc<RwLock<std::collections::HashMap<
80        String,
81        broadcast::Sender<Result<ServerStatus, String>>,
82    >>>,
83    /// Cache TTL.  `Duration::ZERO` = disabled.
84    ttl: Duration,
85}
86
87impl ResponseCache {
88    /// Create a disabled cache (`TTL = Duration::ZERO`).
89    ///
90    /// All operations are no-ops.  No memory is allocated for entries.
91    pub fn disabled() -> Self {
92        Self::new(Duration::ZERO, 1)
93    }
94
95    /// Create an enabled cache with the given `ttl` and LRU `capacity`.
96    ///
97    /// Entries older than `ttl` are treated as expired on next access.
98    /// When the cache is full the least-recently-used entry is evicted.
99    pub fn new(ttl: Duration, capacity: usize) -> Self {
100        let cap = NonZeroUsize::new(capacity.max(1))
101            .expect("response cache capacity must be > 0");
102        Self {
103            entries:  Arc::new(RwLock::new(LruCache::new(cap))),
104            inflight: Arc::new(RwLock::new(std::collections::HashMap::new())),
105            ttl,
106        }
107    }
108
109    /// `true` when the cache is active (TTL > 0).
110    pub fn is_enabled(&self) -> bool { !self.ttl.is_zero() }
111
112    /// Current TTL setting.
113    pub fn ttl(&self) -> Duration { self.ttl }
114
115    /// Try to return a non-expired cached [`ServerStatus`] for `key`.
116    ///
117    /// Returns `None` when:
118    /// - the cache is disabled,
119    /// - no entry exists for `key`, or
120    /// - the entry has expired (age ≥ TTL).
121    ///
122    /// On a hit, the returned status has `cached = true` and `latency = 0.0`.
123    pub async fn get(&self, key: &str) -> Option<ServerStatus> {
124        if !self.is_enabled() { return None; }
125        let mut cache = self.entries.write().await;
126        let entry     = cache.get(key)?;
127        if entry.stored_at.elapsed().unwrap_or(Duration::MAX) >= self.ttl {
128            return None;
129        }
130        let mut status = entry.status.clone();
131        status.cached  = true;
132        status.latency = 0.0;
133        Some(status)
134    }
135
136    /// Store a successful [`ServerStatus`] under `key` and broadcast it to
137    /// any tasks waiting on the in-flight channel for this key.
138    ///
139    /// If the cache is disabled this is a no-op (but the broadcast still fires
140    /// if an in-flight entry exists, releasing any waiting subscribers).
141    pub async fn insert(&self, key: String, status: ServerStatus) {
142        if !self.is_enabled() { return; }
143
144        // Store in LRU first so subscribers can immediately read from cache
145        {
146            let entry = Entry { status: status.clone(), stored_at: SystemTime::now() };
147            self.entries.write().await.put(key.clone(), entry);
148        }
149
150        // Notify in-flight subscribers and free the slot
151        let sender = self.inflight.write().await.remove(&key);
152        if let Some(tx) = sender {
153            let _ = tx.send(Ok(status)); // ignore Err (no subscribers) — that's fine
154        }
155    }
156
157    /// Notify in-flight subscribers of a failure for `key`, then free the slot.
158    ///
159    /// The error message is broadcast as a `String` so subscribers can surface
160    /// a meaningful error without cloning the original `McError`.
161    pub async fn insert_error(&self, key: &str, err: &McError) {
162        let sender = self.inflight.write().await.remove(key);
163        if let Some(tx) = sender {
164            let _ = tx.send(Err(err.to_string()));
165        }
166    }
167
168    /// Attempt to join an existing in-flight request for `key`, or register
169    /// this task as the designated pinger.
170    ///
171    /// Returns:
172    /// - `Some(receiver)` — another task is already pinging; wait on this
173    ///   channel for the result.
174    /// - `None` — this task is first; it **must** call [`insert`](Self::insert)
175    ///   or [`insert_error`](Self::insert_error) when the ping completes.
176    ///
177    /// Always returns `None` when the cache is disabled.
178    pub async fn join_or_register(
179        &self,
180        key: &str,
181    ) -> Option<broadcast::Receiver<Result<ServerStatus, String>>> {
182        if !self.is_enabled() { return None; }
183        let mut inflight = self.inflight.write().await;
184        if let Some(tx) = inflight.get(key) {
185            // Subscribe to the existing in-flight ping
186            return Some(tx.subscribe());
187        }
188        // Register as the pinger
189        let (tx, _) = broadcast::channel(INFLIGHT_CHANNEL_CAPACITY);
190        inflight.insert(key.to_string(), tx);
191        None
192    }
193
194    /// Clear all cached entries and all in-flight registrations.
195    pub async fn clear(&self) {
196        self.entries.write().await.clear();
197        self.inflight.write().await.clear();
198    }
199
200    /// Number of entries currently in the LRU (includes expired entries that
201    /// have not yet been accessed and evicted).
202    pub async fn len(&self) -> usize {
203        self.entries.read().await.len()
204    }
205}