Cache-Aside Pattern — FusionCache L1/L2
Definition
Section titled “Definition”The Cache-Aside pattern loads data into the cache on demand: on a miss, data is retrieved from the source (DB), stored in cache, then returned. Subsequent accesses are served from the cache.
Granit uses FusionCache (L1 in-process + L2 Redis) with native stampede protection, fail-safe (stale-on-error), factory timeouts, and a Redis backplane for cross-pod L1 invalidation.
Diagram
Section titled “Diagram”flowchart TD
REQ[GetOrSetAsync] --> L1{L1 Memory Cache}
L1 -->|hit| RET[Return value]
L1 -->|miss| L2{L2 Redis Cache}
L2 -->|hit| SET1[Store in L1] --> RET
L2 -->|miss| LOCK[Native stampede protection]
LOCK --> FAC[Execute factory<br/>= DB query]
FAC --> SET2[Store in L1 + L2]
SET2 --> RET
FAC -.->|factory fails| FS{Fail-safe?}
FS -->|stale value exists| STALE[Return stale value] --> RET
FS -->|no stale value| ERR[Propagate error]
Implementation in Granit
Section titled “Implementation in Granit”| Component | File | Role |
|---|---|---|
IFusionCache | FusionCache library | Cache-aside with native stampede protection, fail-safe, and backplane |
FeatureChecker | src/Granit.Features/Internal/FeatureChecker.cs | FusionCache for feature resolution |
CachedLocalizationOverrideStore | src/Granit.Localization/Internal/CachedLocalizationOverrideStore.cs | FusionCache for localization overrides |
Anti-stampede
Section titled “Anti-stampede”FusionCache provides native stampede protection: when 100 simultaneous requests
have a cache miss, only one executes the factory. The other 99 wait and receive
the result once the factory completes. No manual SemaphoreSlim or
double-check locking is needed.
Per-tenant keys
Section titled “Per-tenant keys”In FeatureChecker, cache keys include the tenant:
t:{tenantId}:{featureName}. Invalidation via ExpireAsync targets only the
affected tenant and marks entries as stale (fail-safe fallback) rather than
deleting them.
Rationale
Section titled “Rationale”| Problem | Solution |
|---|---|
| Feature resolution too slow (DB query on every request) | L1 cache (nanoseconds) + L2 Redis (microseconds) |
| Stampede on cache miss (100 requests = 100 DB queries) | FusionCache native stampede protection |
| Factory failure during cache miss | Fail-safe returns stale data instead of 500 error |
| Sensitive data in Redis cache | EncryptingFusionCacheSerializer (AES-256-GCM) |
Usage example
Section titled “Usage example”// Cache-aside is transparent to the callerIFusionCache cache = serviceProvider .GetRequiredService<IFusionCache>();
PatientDto patient = await cache.GetOrSetAsync( $"patient:{patientId}", async (ctx, ct) => await LoadPatientFromDbAsync(patientId, ct), cancellationToken: cancellationToken);// 1st call -> DB + stores in cache// 2nd call -> returned from cache (L1 or L2)