Skip to content

Commit a6cfd75

Browse files
committed
Add cache stampede protection documentation
Document the recommended pattern for preventing cache stampedes using CacheLockProvider with the existing cache-aside pattern in both the caching guide and the Foundatio agent skill gotchas.
1 parent d24afcb commit a6cfd75

2 files changed

Lines changed: 58 additions & 0 deletions

File tree

.agents/skills/foundatio/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,7 @@ public class OrderServiceTests : TestLoggerBase
302302
- **Dispose streams and locks**: `ILock` is `IAsyncDisposable` -- use `await using`. Streams from `GetFileStreamAsync` are `IDisposable` -- use `using var`.
303303
- **Cache TTL floor**: Expiration values below 5ms are treated as already-expired and the key is silently removed. If you compute TTL dynamically (e.g., `expiresAt - now`), guard against near-zero values.
304304
- **Cache `GetAsync` returns `CacheValue<T>`**: Check `result.HasValue` before accessing `result.Value`. A missing key returns `HasValue = false`, not an exception.
305+
- **Cache stampede (thundering herd)**: The cache-aside pattern (`Get` -> miss -> load -> `Set`) is vulnerable to stampedes when a popular key expires and many callers regenerate simultaneously. Use `CacheLockProvider` to serialize regeneration: acquire a lock keyed on the cache key, double-check the cache after acquiring, and only then call the backing store. See the [Cache Stampede Protection](https://foundatio.readthedocs.io/guide/caching.html#cache-stampede-protection) docs for the full pattern.
305306
- **Queue auto-complete**: `QueueJobBase<T>` auto-completes entries based on `JobResult` by default. Set `AutoComplete = false` only when you need manual `CompleteAsync()`/`AbandonAsync()` control. Manual `DequeueAsync` does NOT auto-complete.
306307
- **JobWithLockBase vs manual locking**: Use `JobWithLockBase` when the entire run must be single-instance (leader election). Use manual `ILockProvider.AcquireAsync` inside `JobBase` for finer-grained locking within a job.
307308
- **JobContext.RenewLockAsync**: Call in long-running jobs (both `JobBase` and `QueueJobBase`) to prevent lock expiration mid-processing.

docs/guide/caching.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,63 @@ public async Task<User> GetUserAsync(int userId)
579579
}
580580
```
581581

582+
### Cache Stampede Protection
583+
584+
The cache-aside pattern above is vulnerable to **cache stampedes** (also called the thundering herd problem). When a popular key expires, many concurrent requests all see a cache miss simultaneously and each independently loads the same data from the backing store. For an expensive query that takes two seconds to run, 50 concurrent callers means 50 identical database queries instead of one.
585+
586+
Use [`CacheLockProvider`](/guide/locks) to serialize cache regeneration so only one caller loads the data while others wait:
587+
588+
```csharp
589+
public class ProductService
590+
{
591+
private readonly ICacheClient _cache;
592+
private readonly ILockProvider _locker;
593+
594+
public ProductService(ICacheClient cache, ILockProvider locker)
595+
{
596+
_cache = cache;
597+
_locker = locker;
598+
}
599+
600+
public async Task<Product?> GetProductAsync(int productId, CancellationToken ct)
601+
{
602+
var cacheKey = $"product:{productId}";
603+
604+
var cached = await _cache.GetAsync<Product>(cacheKey);
605+
if (cached.HasValue)
606+
return cached.Value;
607+
608+
// Only one caller regenerates; others wait for the lock then re-check cache.
609+
await using var lck = await _locker.AcquireAsync(
610+
$"cache-load:{cacheKey}",
611+
timeUntilExpires: TimeSpan.FromSeconds(30),
612+
cancellationToken: ct);
613+
614+
if (lck is null)
615+
return null; // Could not acquire -- caller decides how to handle
616+
617+
// Double-check: another caller may have populated cache while we waited.
618+
cached = await _cache.GetAsync<Product>(cacheKey);
619+
if (cached.HasValue)
620+
return cached.Value;
621+
622+
var product = await _database.GetProductAsync(productId);
623+
await _cache.SetAsync(cacheKey, product, TimeSpan.FromMinutes(30));
624+
return product;
625+
}
626+
}
627+
```
628+
629+
The key points of this pattern:
630+
631+
1. **Lock on the cache key.** Use a lock name derived from the cache key (e.g., `cache-load:product:42`) so different keys are loaded concurrently while the same key is serialized.
632+
2. **Double-check after acquiring.** Another caller may have populated the cache while you were waiting for the lock. Always re-read before loading from the backing store.
633+
3. **Lock expiration as a safety net.** Set `timeUntilExpires` to a value longer than the expected load time. If the loader crashes, the lock auto-expires and the next caller retries.
634+
635+
::: tip CacheLockProvider + IMessageBus
636+
When `CacheLockProvider` is configured with an `IMessageBus`, waiting callers are notified instantly via pub/sub when the lock is released. Without a message bus, lock release falls back to polling. For stampede protection where multiple callers are blocked on the same lock, the message bus significantly reduces wait time.
637+
:::
638+
582639
### Atomic Operations
583640

584641
Use conditional operations for race-safe updates:

0 commit comments

Comments
 (0)