Summary
CacheManager.get_or_set() documents that it "does not provide stampede protection: concurrent misses for the same key may each invoke factory". When the factory is expensive (a slow query, an upstream API with rate limits) and the key is hot, an expiry turns into N simultaneous recomputations.
Proposal
An opt-in lock around the miss path, built on CacheLock (#64):
report = await cache.get_or_set(
"report:daily",
build_report,
ttl=300,
lock_ttl=30, # enables protection; upper bound on one factory run
wait_timeout=10, # how long other callers wait for the winner
)
- On a miss, try to acquire a lock for the key (non-blocking).
- The winner re-checks the cache, runs
factory, stores the value, and releases the lock.
- Everyone else polls the cache until the value appears or
wait_timeout elapses.
- On timeout, fall back to running
factory themselves, so a crashed winner degrades to today's behaviour instead of failing requests.
Without lock_ttl the behaviour is unchanged, so this is backward compatible.
Open questions
- Polling interval and whether to back off.
- Whether the
@cache decorator's miss path should get the same option later (separate issue; it has to deal with response objects, not plain values).
Depends on: #64
Summary
CacheManager.get_or_set()documents that it "does not provide stampede protection: concurrent misses for the same key may each invokefactory". When the factory is expensive (a slow query, an upstream API with rate limits) and the key is hot, an expiry turns into N simultaneous recomputations.Proposal
An opt-in lock around the miss path, built on
CacheLock(#64):factory, stores the value, and releases the lock.wait_timeoutelapses.factorythemselves, so a crashed winner degrades to today's behaviour instead of failing requests.Without
lock_ttlthe behaviour is unchanged, so this is backward compatible.Open questions
@cachedecorator's miss path should get the same option later (separate issue; it has to deal with response objects, not plain values).Depends on: #64