cachekit/flight.rs
1//! Cold-miss single-flight: dedup concurrent fills of the same key.
2//!
3//! Under metered-misses pricing a stampede is literally billable — N tasks
4//! missing the same key at once means N backend misses and N executions of
5//! the wrapped function. [`CacheKit::single_flight`](crate::CacheKit::single_flight)
6//! collapses that to one:
7//!
8//! - **In-process** (always available): a per-key async mutex. The first
9//! task through becomes the *leader* and computes; concurrent tasks queue
10//! behind it and re-check the cache once the leader finishes.
11//! - **Cross-process** (`reliability` feature, native, backend implements
12//! `LockableBackend` — CachekitIO and Redis do): the leader additionally
13//! takes a distributed fill lock. If another process already holds it,
14//! this process polls the cache for the other side's fill instead of
15//! recomputing, and computes anyway once the poll budget is exhausted
16//! (fail-open — a stampede beats unavailability).
17//!
18//! The `#[cachekit]` macro wires this in automatically around its miss path.
19//! Manual usage follows the same shape:
20//!
21//! ```no_run
22//! # async fn example(cache: &cachekit::CacheKit) -> Result<(), cachekit::CachekitError> {
23//! if let Some(_v) = cache.get::<String>("expensive").await? {
24//! return Ok(());
25//! }
26//! let mut flight = cache.single_flight("expensive").await;
27//! while flight.wait_for_fill().await {
28//! if let Some(_v) = cache.get::<String>("expensive").await? {
29//! flight.release().await; // another worker filled it
30//! return Ok(());
31//! }
32//! }
33//! let value = "computed".to_owned(); // expensive work — runs once
34//! cache.set("expensive", &value).await?;
35//! flight.release().await;
36//! # Ok(())
37//! # }
38//! ```
39
40use std::collections::HashMap;
41use std::sync::{Arc, Mutex, PoisonError, Weak};
42
43#[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
44use std::sync::atomic::{AtomicU64, Ordering};
45
46use crate::client::SharedBackend;
47
48/// Above this many live entries, dead map slots are swept opportunistically.
49const SWEEP_THRESHOLD: usize = 128;
50
51/// How long a distributed fill lock is held server-side before auto-expiry.
52///
53/// Also the cross-process suppression ceiling: a fill that runs longer than
54/// this loses its lock mid-compute and another process may recompute
55/// concurrently (fail-open by design — release is owner-checked, so an
56/// expired lock is never wrongfully deleted). Workloads whose fills
57/// routinely approach 5 s keep in-process dedup but should not rely on
58/// cross-process suppression.
59/// ponytail: fixed TTL, no heartbeat — add lock renewal if slow fills matter.
60#[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
61const FILL_LOCK_TIMEOUT_MS: u64 = 5_000;
62
63/// Poll cadence while waiting for another process's fill.
64#[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
65const FILL_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(100);
66
67/// Poll budget: 50 × 100 ms ≈ the fill lock timeout.
68#[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
69const FILL_POLL_BUDGET: u32 = 50;
70
71// ── FlightMap ────────────────────────────────────────────────────────────────
72
73/// Per-key async mutexes for in-process fill dedup. Weak entries let finished
74/// flights drop their state without an explicit removal protocol.
75#[derive(Default)]
76pub(crate) struct FlightMap {
77 entries: Mutex<HashMap<String, Weak<tokio::sync::Mutex<()>>>>,
78}
79
80impl FlightMap {
81 fn handle(&self, key: &str) -> Arc<tokio::sync::Mutex<()>> {
82 let mut map = self.entries.lock().unwrap_or_else(PoisonError::into_inner);
83 // ponytail: O(n) sweep once the map grows; a doubly-indexed structure
84 // is not worth it until someone caches millions of distinct cold keys.
85 if map.len() > SWEEP_THRESHOLD {
86 map.retain(|_, w| w.strong_count() > 0);
87 }
88 if let Some(existing) = map.get(key).and_then(Weak::upgrade) {
89 return existing;
90 }
91 let fresh = Arc::new(tokio::sync::Mutex::new(()));
92 map.insert(key.to_owned(), Arc::downgrade(&fresh));
93 fresh
94 }
95}
96
97// ── SWR mutation ordering ────────────────────────────────────────────────────
98
99/// State shared by a stale-read token and same-key mutations while that token
100/// is alive. The weak map can discard idle keys without losing correctness:
101/// an outstanding token itself keeps this state alive.
102#[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
103pub(crate) struct MutationState {
104 lock: Arc<tokio::sync::Mutex<()>>,
105 version: AtomicU64,
106}
107
108#[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
109impl MutationState {
110 fn new() -> Self {
111 Self {
112 lock: Arc::new(tokio::sync::Mutex::new(())),
113 version: AtomicU64::new(0),
114 }
115 }
116
117 pub(crate) fn version(&self) -> u64 {
118 self.version.load(Ordering::Acquire)
119 }
120}
121
122/// Per-key mutation versions for conditional SWR commits. Cloned clients
123/// share this map. Each entry is weak so unrelated keys do not accumulate.
124#[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
125#[derive(Default)]
126pub(crate) struct MutationMap {
127 entries: Mutex<HashMap<String, Weak<MutationState>>>,
128}
129
130#[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
131impl MutationMap {
132 pub(crate) fn state(&self, key: &str) -> Arc<MutationState> {
133 let mut map = self.entries.lock().unwrap_or_else(PoisonError::into_inner);
134 if map.len() > SWEEP_THRESHOLD {
135 map.retain(|_, weak| weak.strong_count() > 0);
136 }
137 if let Some(existing) = map.get(key).and_then(Weak::upgrade) {
138 return existing;
139 }
140 let fresh = Arc::new(MutationState::new());
141 map.insert(key.to_owned(), Arc::downgrade(&fresh));
142 fresh
143 }
144
145 pub(crate) async fn lock(&self, key: &str) -> MutationGuard {
146 let state = self.state(key);
147 let lock = Arc::clone(&state.lock).lock_owned().await;
148 MutationGuard { state, _lock: lock }
149 }
150}
151
152/// Holds same-key ordering across an L2 mutation and its L1 update.
153#[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
154pub(crate) struct MutationGuard {
155 state: Arc<MutationState>,
156 _lock: tokio::sync::OwnedMutexGuard<()>,
157}
158
159#[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
160impl MutationGuard {
161 pub(crate) fn snapshot(&self) -> (Arc<MutationState>, u64) {
162 (Arc::clone(&self.state), self.state.version())
163 }
164
165 pub(crate) fn is_current(&self, state: &Arc<MutationState>, version: u64) -> bool {
166 Arc::ptr_eq(&self.state, state) && self.state.version() == version
167 }
168
169 pub(crate) fn advance(&self) {
170 self.state.version.fetch_add(1, Ordering::Release);
171 }
172}
173
174// ── SingleFlight guard ───────────────────────────────────────────────────────
175
176enum Role {
177 /// First worker in: compute without re-checking (a re-check would be a
178 /// second billable miss under metered-misses pricing).
179 Leader,
180 /// Queued behind a local leader that has since finished: re-check the
181 /// cache once — the leader's fill is in L1 — then compute if it missed.
182 LocalFollower { rechecked: bool },
183 /// Another *process* holds the distributed fill lock: poll the cache for
184 /// its fill, then compute anyway when the budget runs out (fail-open).
185 #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
186 RemoteContested { polls_left: u32 },
187}
188
189/// Guard for a single-flight fill, returned by
190/// [`CacheKit::single_flight`](crate::CacheKit::single_flight).
191///
192/// Holds the per-key in-process lock for its whole lifetime, and the
193/// distributed fill lock (if one was acquired) until [`Self::release`].
194/// Dropping without `release` is safe: the in-process lock frees immediately
195/// and a distributed lock expires server-side after its timeout.
196pub struct SingleFlight {
197 _local: tokio::sync::OwnedMutexGuard<()>,
198 role: Role,
199 #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
200 dist: Option<DistLock>,
201}
202
203#[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
204struct DistLock {
205 backend: SharedBackend,
206 full_key: String,
207 lock_id: String,
208}
209
210impl SingleFlight {
211 /// `true` while another worker may still be filling this key — re-check
212 /// the cache after every `true` before computing yourself:
213 ///
214 /// - Leader: immediately `false` (compute, don't re-read your own miss).
215 /// - Queued behind a local leader: `true` exactly once.
216 /// - Contested cross-process: sleeps one poll interval per call, `true`
217 /// until the poll budget is spent.
218 pub async fn wait_for_fill(&mut self) -> bool {
219 match &mut self.role {
220 Role::Leader => false,
221 Role::LocalFollower { rechecked } => {
222 let first = !*rechecked;
223 *rechecked = true;
224 first
225 }
226 #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
227 Role::RemoteContested { polls_left } => {
228 if *polls_left == 0 {
229 return false;
230 }
231 *polls_left -= 1;
232 tokio::time::sleep(FILL_POLL_INTERVAL).await;
233 true
234 }
235 }
236 }
237
238 /// Release the flight. Best-effort: frees the distributed fill lock (if
239 /// held) so other processes stop waiting early; errors are ignored — the
240 /// lock expires server-side regardless.
241 pub async fn release(self) {
242 #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
243 if let Some(dist) = self.dist {
244 if let Some(lockable) = dist.backend.as_lockable() {
245 let _ = lockable.release_lock(&dist.full_key, &dist.lock_id).await;
246 }
247 }
248 }
249
250 pub(crate) async fn acquire(map: &FlightMap, backend: &SharedBackend, full_key: &str) -> Self {
251 let handle = map.handle(full_key);
252 match Arc::clone(&handle).try_lock_owned() {
253 Ok(local) => Self::lead(local, backend, full_key).await,
254 Err(_) => {
255 // Contended: a local leader is filling. Queue behind it.
256 let local = handle.lock_owned().await;
257 Self {
258 _local: local,
259 role: Role::LocalFollower { rechecked: false },
260 #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
261 dist: None,
262 }
263 }
264 }
265 }
266
267 /// Local leader: attempt cross-process suppression via the backend's
268 /// distributed lock, when available. Lock-infrastructure errors fail
269 /// open to a plain leader — suppression is an optimisation, never an
270 /// availability dependency.
271 #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
272 async fn lead(
273 local: tokio::sync::OwnedMutexGuard<()>,
274 backend: &SharedBackend,
275 full_key: &str,
276 ) -> Self {
277 let (role, dist) = match backend.as_lockable() {
278 Some(lockable) => match lockable.acquire_lock(full_key, FILL_LOCK_TIMEOUT_MS).await {
279 Ok(Some(lock_id)) => (
280 Role::Leader,
281 Some(DistLock {
282 backend: backend.clone(),
283 full_key: full_key.to_owned(),
284 lock_id,
285 }),
286 ),
287 Ok(None) => (
288 Role::RemoteContested {
289 polls_left: FILL_POLL_BUDGET,
290 },
291 None,
292 ),
293 Err(_) => (Role::Leader, None),
294 },
295 None => (Role::Leader, None),
296 };
297 Self {
298 _local: local,
299 role,
300 dist,
301 }
302 }
303
304 #[cfg(not(all(feature = "reliability", not(target_arch = "wasm32"))))]
305 async fn lead(
306 local: tokio::sync::OwnedMutexGuard<()>,
307 _backend: &SharedBackend,
308 _full_key: &str,
309 ) -> Self {
310 Self {
311 _local: local,
312 role: Role::Leader,
313 }
314 }
315}