Skip to main content

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}