Skip to main content

Module flight

Module flight 

Source
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 (reliability feature, native, backend implements LockableBackend — 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§

SingleFlight
Guard for a single-flight fill, returned by CacheKit::single_flight.