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}