Expand description
Cold-miss single-flight: dedup concurrent fills of the same key. Cold-miss single-flight: dedup concurrent fills of the same key.
Under metered-misses pricing a stampede is literally billable — N tasks
missing the same key at once means N backend misses and N executions of
the wrapped function. CacheKit::single_flight
collapses that to one:
- In-process (always available): a per-key async mutex. The first task through becomes the leader and computes; concurrent tasks queue behind it and re-check the cache once the leader finishes.
- Cross-process (
reliabilityfeature, native, backend implementsLockableBackend— CachekitIO and Redis do): the leader additionally takes a distributed fill lock. If another process already holds it, this process polls the cache for the other side’s fill instead of recomputing, and computes anyway once the lock timeout, measured from its own lock attempt, has passed: by then the other side’s lock has expired (fail-open — a stampede beats unavailability).
Once a leader’s fill is stored, the #[cachekit] macro sends its unlock
from a task spawned on the ambient tokio runtime and returns without
waiting for it. That needs l1 and a tokio runtime, and never happens
under unsync; there, and whenever the fill was not stored, the caller
awaits the unlock. If the backend has not answered the spawned unlock by
the next lock attempt on that key from this client or its clones (still
in flight, or its runtime idle or dropped), that attempt sends the unlock
itself first, so a re-miss does not contest its own lock unless that
unlock fails too. If the runtime is dropped or left idle right after the
call, the unlock waits for that next attempt, and until then the lease
stays held server-side for up to its 5 s timeout: another process that
misses the key inside that window polls for it, as it would behind any
slow leader.
The #[cachekit] macro wires this in automatically around its miss path.
Its stale-while-revalidate refresh takes the same locks but never waits
for another worker’s fill: if another worker holds either one, or the lock
call fails, the refresh stands down without polling or re-reading the
cache, and the stale copy keeps being served. A cold miss queued behind a
holder that found the distributed lock contested and let go without a
fill (a refresh that stood down, a cancelled or failed poll) contests that
lock itself before computing, since another process may be filling.
Manual usage follows the same shape:
if let Some(_v) = cache.get::<String>("expensive").await? {
return Ok(());
}
let mut flight = cache.single_flight("expensive").await;
while flight.wait_for_fill().await {
if let Some(_v) = cache.get::<String>("expensive").await? {
flight.release().await; // another worker filled it
return Ok(());
}
}
let value = "computed".to_owned(); // expensive work — runs once
cache.set("expensive", &value).await?;
flight.release().await;Structs§
- Single
Flight - Guard for a single-flight fill, returned by
CacheKit::single_flight.