Overview
Redis is the dominant application-tier cache. Per redis.io/docs/latest/commands/expire/ (opens in new window), keys get TTLs via EXPIRE, PEXPIRE, or EXPIREAT - with Redis 7+ flags NX / XX / GT / LT controlling conditional expiry.
This skill wraps test patterns against a real Redis instance (via testcontainers or a dedicated test cluster) - not a mock. Mocks lose the eviction-policy + TTL-tick + pub-sub behaviours that real bugs hide in.
When to use
Authoring
Install
pip install redis testcontainers # Python
npm install --save-dev ioredis testcontainers # NodeReal-Redis test fixture (Python)
import pytest
import redis
from testcontainers.redis import RedisContainer
@pytest.fixture(scope="session")
def redis_url():
with RedisContainer("redis:7-alpine") as r:
yield r.get_connection_url()
@pytest.fixture
def r(redis_url):
client = redis.from_url(redis_url, decode_responses=True)
yield client
client.flushdb() # Reset between testsBasic TTL tests
Per redis.io (opens in new window):
def test_expire_sets_ttl(r):
r.set("k", "v")
assert r.expire("k", 60) == 1
ttl = r.ttl("k")
assert 58 <= ttl <= 60
def test_ttl_minus_2_when_key_absent(r):
assert r.ttl("nonexistent") == -2 # per redis docs
def test_ttl_minus_1_when_no_expiry(r):
r.set("k", "v")
assert r.ttl("k") == -1 # exists, no TTL
def test_set_clears_existing_ttl(r):
r.set("k", "v", ex=60)
assert r.ttl("k") > 0
r.set("k", "v2") # overwrite clears TTL per redis docs
assert r.ttl("k") == -1Conditional expire (NX / XX / GT / LT)
def test_expire_nx_only_when_no_ttl(r):
r.set("k", "v")
assert r.expire("k", 60, nx=True) == 1
assert r.expire("k", 120, nx=True) == 0 # already has TTL
assert r.ttl("k") <= 60
def test_expire_gt_only_extends(r):
r.set("k", "v", ex=60)
assert r.expire("k", 120, gt=True) == 1 # 120 > 60
assert r.expire("k", 30, gt=True) == 0 # 30 < 120Cache-aside write-then-invalidate
Per references/cache-coherence-patterns.md cache-aside pattern:
def test_write_invalidates_cache(r, db):
db.users.insert({"id": "u1", "name": "alice"})
r.set("user:u1", '{"id":"u1","name":"alice"}', ex=300)
# Update via the app's write path
update_user_via_app("u1", name="bob") # must DEL cache
cached = r.get("user:u1")
assert cached is None, "App should have invalidated cache on write"Eviction-policy tests
Under memory pressure with maxmemory-policy allkeys-lru:
def test_lru_evicts_oldest_under_pressure(r):
r.config_set("maxmemory", "1mb")
r.config_set("maxmemory-policy", "allkeys-lru")
big_value = "x" * 100_000 # 100 KB
# Fill cache
for i in range(20):
r.set(f"key:{i}", big_value)
# Touch key:0 to make it recently-used
r.get("key:0")
# Add more → should evict middle keys, not key:0
for i in range(20, 30):
r.set(f"key:{i}", big_value)
assert r.exists("key:0") # Recently touched → kept
assert not r.exists("key:5") # Not touched → evictedCache stampede mitigation
import concurrent.futures, threading
def test_lock_prevents_stampede(r):
call_count = threading.Lock()
counter = [0]
def recompute_once():
with call_count: counter[0] += 1
return "computed"
def cached_get(key):
val = r.get(key)
if val: return val
lock_acquired = r.set(f"lock:{key}", "1", nx=True, ex=30)
if lock_acquired:
val = recompute_once()
r.set(key, val, ex=300)
r.delete(f"lock:{key}")
return val
# Wait for the lock holder (simplified)
for _ in range(50):
val = r.get(key)
if val: return val
__import__("time").sleep(0.01)
return None
with concurrent.futures.ThreadPoolExecutor(max_workers=100) as ex:
results = list(ex.map(lambda _: cached_get("hot"), range(100)))
assert counter[0] == 1, f"Stampede: recomputed {counter[0]} times"
assert all(r == "computed" for r in results)Tenant-namespacing tests
Per cross-tenant-data-leak-tests Test 10:
def test_tenant_namespaced_keys(r, cache):
cache.set("user:1", "tenant_a_data", tenant_id="A")
cache.set("user:1", "tenant_b_data", tenant_id="B")
# Real Redis state should have separate keys
assert r.get("tenant:A:user:1") == "tenant_a_data"
assert r.get("tenant:B:user:1") == "tenant_b_data"
# The application-layer get must respect tenant
assert cache.get("user:1", tenant_id="A") == "tenant_a_data"
assert cache.get("user:1", tenant_id="B") == "tenant_b_data"Running
pytest tests/redis/ -vtestcontainers boots Redis per session; per-test flushdb resets state.
Pub-sub invalidation across nodes
For multi-node cache invalidation (e.g., Redis Sentinel or a pub-sub fan-out):
def test_pubsub_invalidation(r):
pubsub = r.pubsub()
pubsub.subscribe("invalidate")
r.set("k", "v", ex=300)
r.publish("invalidate", "k")
msg = next(m for m in pubsub.listen() if m["type"] == "message")
assert msg["data"] == "k"
# Other nodes would now `r.delete(msg['data'])`Parsing results
Redis returns simple types: int (1/0 success codes), string (the value), or None (key absent). Assertions are direct.
For TTL: positive int = remaining seconds, -1 = no TTL, -2 = key absent (per redis.io docs).
CI integration
jobs:
redis-tests:
runs-on: ubuntu-latest
services:
redis:
image: redis:7-alpine
options: --health-cmd "redis-cli ping" --health-interval 10s
ports: [6379]
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v5
- run: pip install -e ".[test]"
- run: pytest tests/redis/ --tb=short
env:
REDIS_URL: redis://localhost:6379Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Mocking the Redis client | Misses TTL-tick, eviction, pub-sub | Use testcontainers / real Redis |
Hardcoded test sleep time.sleep(60) to test TTL | Slow + flaky | Set tiny TTL (ms via pexpire) |
| Asserting on exact TTL value | Race vs Redis tick | Range assertion (e.g., 58 <= ttl <= 60) |
Tests don't FLUSHDB between | Cross-test pollution | Per-test or per-class flush |
SET k v EX 0 | Immediate deletion per redis docs | Use positive TTL or PERSIST |
| Cache-aside without explicit invalidation test | Logic bug merged | Cover write → cache-state |
| Tests skip eviction-policy | Memory-pressure bugs hide | Test maxmemory + policy explicitly |
KEYS * in test setup | O(N) blocks Redis; flakes under parallel test load | SCAN or per-test isolated DB |
Limitations
References
Overview
Keeping cached values consistent with their source of truth across tiers (browser, CDN, reverse-proxy, application, data store). Wrong coherence shows as stale data; wrong invalidation shows as cache stampedes per stampede.md (opens in new window). A reference used by the per-tier test skills.
When to read this
How to use this reference
The five-tier stack
| Tier | Where | Common TTL | Invalidation |
|---|---|---|---|
| Browser | Cache-Control: private | minutes-hours | TTL only (or Service Worker code) |
| CDN | Cloudflare / Fastly / CloudFront / Akamai | seconds-days | Purge API or surrogate-key tag |
| Reverse proxy | Varnish, nginx | seconds-hours | VCL purge / nginx cache_purge |
| Application | Redis / Memcached / in-process | seconds-minutes | Direct delete / pub-sub broadcast |
| Data store | Postgres query cache, RDS read replicas | seconds | Replication-driven |
A coherence bug at any tier surfaces at the user. The test surface is layered; each tier needs its own coherence tests.
Cache-writing patterns
For application-tier caches (Redis):
| Pattern | Flow | When |
|---|---|---|
| Cache-aside (lazy load) | Read miss → read source → populate → return; Write → invalidate cache | Read-heavy, eventual consistency OK |
| Write-through | Write → write source → write cache (synchronous) | Strong consistency, latency tolerable |
| Write-back | Write → write cache → async write to source | Burst writes; data-loss risk on cache crash |
| Write-around | Write → write source (skip cache); reads do cache-aside | Write-heavy with rare re-reads |
| Refresh-ahead | Background refresh before TTL expires | Predictable read patterns; hot keys |
Invalidation strategies
| Strategy | Mechanism | Trade-off |
|---|---|---|
| TTL-only | Just let it expire | Simple; possibly-stale window = TTL |
| Event-driven purge | Source-of-truth update fires a delete | Coupling; firehose at high write rate |
| Surrogate keys (Fastly, Varnish) | Tag responses; purge by tag | Group-invalidation; coordination cost |
| Version-tagged URLs | /api/users?_v=42; new version = new key | Immutable cache; full deploy per change |
| Soft purge | Mark stale, keep serving until refresh | Used by stale-while-revalidate per stale-while-revalidate.md (opens in new window) |
Worked example: a multi-tenant dashboard endpoint
Scenario: /api/users serves per-tenant dashboard data, is read-heavy, and must never leak one tenant's rows to another. Walk the four decisions from How to use this reference, then the test.
Coherence test (the browser-tier "write → reload → see old" case):
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Cache-Control: public on per-user data | Shared cache leaks data | Use private for user-specific |
Missing Vary: Authorization | Cross-tenant leak | Add to Vary or set private |
s-maxage longer than session lifetime | Logged-out users see another user's data | Match TTL to security window |
| TTL but no purge | Stale-window = TTL even for urgent updates | Implement purge API + use surrogate keys |
ETag generated per-request from now() | Defeats the validation | Stable ETag from content hash |
no-cache instead of no-store for sensitive data | Browser still stores; just revalidates | no-store, no-cache, must-revalidate, private |
| Browser TTL = CDN TTL = origin TTL | Multi-tier amplifies staleness instead of layering it | Origin lowest, CDN longer, browser shortest |
| Cache-aside without write-then-invalidate | Reads see pre-write state for TTL window | Always invalidate on write |
Vary: * | Disables shared cache entirely | Use specific headers |
| Single Cache-Control for HTML + JSON + assets | One-size doesn't fit; HTML often short, assets long | Per-route directives |
Deep references
The contract layer and the audit-and-test surface live in companion references so this file stays a decision surface:
Limitations
References
Cross-tier coherence problems and the per-tier test surface
View source (opens in a new tab) (opens in new window)Deep reference for cache-coherence-patterns.md (opens in new window). Consult when auditing an existing multi-tier cache for coherence bugs and when deciding what to test at each tier.
Cross-tier coherence problems
A coherence bug at any tier surfaces at the user; the failure usually lives in the seam between two tiers, not inside one.
| Problem | Where | Detection |
|---|---|---|
| Browser caches stale page after server purge | Browser ignores must-revalidate, or no must-revalidate | E2E test: write → reload → see old |
| CDN serves stale after origin update | Purge didn't propagate or s-maxage too long | E2E: write → purge → read at CDN edge |
| Different Vary at browser vs CDN | CDN strips headers; cache keys diverge | Header-comparison test |
| Layered TTL inversion | s-maxage < max-age → CDN refreshes more often than browser; browser eventually outpaces CDN | Audit the TTL stack |
Vary: Cookie without normalised cookies | Tracker cookies fragment cache; near-zero hit rate | Inspect Vary; normalise |
| Tenant-scoped data with shared Vary | Cross-tenant leak per cross-tenant-data-leak-tests | Add Authorization to Vary or use private |
Testable behaviours by tier
Each tier needs its own coherence tests; the categories below map to the per-tier test skills that consume this reference.
| Tier | Test categories |
|---|---|
| Browser | Cache-Control respected (max-age, no-cache, must-revalidate); ETag round-trip; Vary honoured |
| CDN | Edge hit/miss vs origin; purge API works end-to-end; s-maxage overrides max-age |
| Reverse proxy | VCL purge (varnish-test-vtc-syntax); grace-mode behaviour |
| Application | Cache-aside write-then-invalidate; key collisions |
| Data store | Replication lag (separate concern; out of scope here) |
Deep reference for cache-coherence-patterns.md (opens in new window). Consult when designing the Cache-Control, Vary, and ETag contract that the browser and CDN tiers enforce.
Per www.rfc-editor.org/rfc/rfc9111.html (opens in new window):
Response directives (server → cache)
| Directive | RFC ref | Meaning |
|---|---|---|
max-age=N | §5.2.2.1 | "The response is to be considered stale after its age is greater than the specified number of seconds." |
s-maxage=N | §5.2.2.10 | "For a shared cache, the maximum age specified by this directive overrides... max-age." |
no-cache | §5.2.2.4 | "The response MUST NOT be used to satisfy any other request without forwarding it for validation." |
no-store | §5.2.2.5 | "A cache MUST NOT store any part of either the immediate request or the response." |
must-revalidate | §5.2.2.2 | "Once the response has become stale, a cache MUST NOT reuse that response... until it has been successfully validated." |
private | §5.2.2.7 | "A shared cache MUST NOT store the response (intended for a single user)." |
public | §5.2.2.9 | "A cache MAY store the response even if it would otherwise be prohibited." |
immutable | RFC 8246 | Response body will not change for the lifetime of the URL. Browsers skip revalidation. |
Per RFC 9111 §4.2.4: "A cache MUST NOT generate a stale response unless it is disconnected or doing so is explicitly permitted by the client or origin server." This is the formal basis for stale-while-revalidate per stale-while-revalidate.md (opens in new window).
Vary - the cache key
Per RFC 9111 §4.1: "When a cache receives a request that can be satisfied by a stored response and that stored response contains a Vary header field, the cache MUST NOT use that stored response without revalidation unless all the presented request header fields nominated by that Vary field value match those fields in the original request."
Practical: Vary: Accept-Encoding, Authorization means "separate cache entries per (Accept-Encoding, Authorization) combination." Missing Vary: Authorization is the canonical cross-tenant cache leak per cross-tenant-data-leak-tests Test 10.
ETag + If-None-Match revalidation
Per RFC 9111 §4.3.1: "Another validator is the entity tag given in an ETag field. One or more entity tags can be used in an If-None-Match header field for response validation."
Pattern: server returns ETag: "abc123"; client sends If-None-Match: "abc123"; server returns 304 Not Modified or 200 OK with new ETag. Bandwidth-efficient but doesn't help latency (still a round-trip).
Sources
stale-while-revalidate and stale-if-error (RFC 5861)
View source (opens in a new tab) (opens in new window)stale-while-revalidate (SWR) and stale-if-error (SIE) are Cache-Control extensions defined in RFC 5861 (opens in new window), widely implemented by browsers, CDNs (Cloudflare, Fastly, CloudFront), and reverse proxies (Varnish via grace).
| Directive | When stale-serve happens | Revalidation |
|---|---|---|
stale-while-revalidate=N | Up to N seconds after max-age expires | Background async; client sees stale |
stale-if-error=N | Origin returns 5xx, up to N seconds after max-age | Client sees stale instead of the 5xx |
stale-while-revalidate lifecycle
Per RFC 5861 §3 (opens in new window): "caches MAY serve the response in which it appears after it becomes stale, up to the indicated number of seconds." Syntax: Cache-Control: max-age=60, stale-while-revalidate=300.
Failed-revalidation behaviour differs per vendor (Cloudflare keeps serving stale until the window expires; Fastly surfaces 5xx sooner; Varnish is VCL-configurable) - test the actual vendor.
stale-if-error
Per RFC 5861 §4 (opens in new window): a stale response "MAY be used to satisfy the request, regardless of other freshness information" on origin 500/502/503/504. Composition:
Cache-Control: max-age=60, stale-while-revalidate=300, stale-if-error=864001-minute freshness, 5-minute background-refresh grace, 1-day serve-stale grace if the origin is down.
Interaction with must-revalidate
Per RFC 9111, must-revalidate forbids serving stale after expiry. It and SWR are mutually exclusive in spirit; most caches honour the strictest (must-revalidate wins). For SWR / SIE to work, don't add must-revalidate.
Per-vendor support
| Cache | SWR | SIE | Caveat |
|---|---|---|---|
| Cloudflare | Yes | Yes | Honours response + request directives |
| Fastly | Yes (Surrogate-Control or Cache-Control) | Yes | Stale-on-error more aggressive |
| CloudFront | Yes (since 2022) | Yes | SIE needs origin error caching policy |
| Varnish | grace in VCL | stale-if-error | See varnish-test-vtc-syntax |
| nginx | proxy_cache_use_stale updating | ... error timeout | Different keyword |
| Browsers | Yes | Yes | Per-tab behaviour varies; test |
| Service Workers | Manual (Workbox SWR strategy) | n/a | Code-level implementation |
Testable behaviours
| Behaviour | Test |
|---|---|
| SWR serves stale within window | max-age=1, SWR=300; wait 5s; request → stale + async revalidate |
| SWR triggers exactly one revalidation | Origin sees one revalidate after the stale response returned |
| SWR window enforced | Wait > max-age + SWR; next request blocks on origin |
| SIE serves stale on 5xx | Origin down; request within SIE window → 200 with stale data |
| SIE window enforced | Origin down beyond window → user sees 5xx |
| must-revalidate wins over SWR | Both set → no stale served |
| Stampede mitigation under load | N=1000 concurrent at t=max-age+1s → origin sees 1-2 revalidates |
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
must-revalidate, stale-while-revalidate=300 | Contradictory; SWR silently ignored | Drop must-revalidate |
SWR on private user data without private | Stale exposure risks | Pair with private deliberately |
| SWR=0 | No grace; equivalent to omitting | Use ≥30s |
| SWR window >> max-age (×10+) | Stale for most of the lifetime | Keep proportionate |
| SIE without an origin-5xx alarm | "Site looks fine" while origin is down for days | Pair SIE with monitoring |
Limitations
References
The three families named in stampede.md (opens in new window), in full, per Wikipedia's cache-stampede article. That file summarises each in one line and keeps the XFetch formula; this file carries the implementations, drawbacks, and the XFetch variable table.
1. Locking
Upon cache miss, processes attempt to acquire a lock for that key. Only the lock holder recomputes; others either wait, return "not found," or use a stale value.
def get(key):
val = cache.get(key)
if val is not None and not val.stale:
return val
if cache.acquire_lock(key, ttl=30):
try:
val = recompute(key)
cache.set(key, val, ttl=300)
return val
finally:
cache.release_lock(key)
else:
# Another process is recomputing; serve stale or wait
return val or wait_then_get(key)Drawbacks per Wikipedia: "complex implementation handling edge cases like process failures and race conditions." Lock holder crashing → cache empty for the lock TTL.
Mitigation: short-TTL locks with periodic refresh while recomputing.
2. External recomputation
A separate process recomputes the cache periodically or near expiry, decoupled from the request path. Per Wikipedia: "triggered when values approach expiration, periodically, or on cache miss."
# Cron / scheduled job
def refresh_hot_keys():
for key in HOT_KEYS:
val = recompute(key)
cache.set(key, val, ttl=600)When it fits: static cache keys ("homepage data," "top-10 products"). Hot keys are knowable in advance. The recompute schedule overlaps the cache TTL.
Drawback: doesn't help with unknown / user-specific hot keys; needs separate infrastructure.
3. Probabilistic early expiration (XFetch)
Each requester independently decides - with rising probability as the value ages - to refresh before formal expiry. Per Wikipedia, the canonical formula:
if (!value || (time() - delta * beta * log(rand(0,1))) >= expiry)
recompute_and_cache(key)
else
return valueWhere:
| Variable | Meaning |
|---|---|
delta | Time to recompute the value (scales the probability distribution) |
beta | Tuning parameter (default 1; >1 favours earlier refresh) |
log(rand(0,1)) | Always negative; magnitude controls the early-refresh probability |
time() | Wall-clock or monotonic time |
expiry | Absolute expiry time stored alongside the value |
The "exponential distribution" of refresh decisions means most requesters use the cached value; only a few do early refresh. Per Wikipedia: "setting beta=1 works well in practice."
Implementation:
import math, random, time
def get_xfetch(key):
entry = cache.get(key) # contains {value, expiry, delta}
if not entry:
val, delta = measure_recompute(key)
expiry = time.time() + 300
cache.set(key, {"value": val, "expiry": expiry, "delta": delta}, ttl=300)
return val
now = time.time()
rand = max(random.random(), 1e-10)
if now - entry["delta"] * 1.0 * math.log(rand) >= entry["expiry"]:
# Early refresh
val, delta = measure_recompute(key)
expiry = now + 300
cache.set(key, {"value": val, "expiry": expiry, "delta": delta}, ttl=300)
return val
return entry["value"]The delta (recompute cost) is measured during refresh and stored. Expensive-to-recompute values get earlier refresh attempts.
Cache stampede (thundering herd) - phenomena and mitigations
View source (opens in a new tab) (opens in new window)A cache stampede ("dog-piling") occurs when a cached value expires under high load - many requesters simultaneously detect the miss, all recompute, all write back. Per en.wikipedia.org/wiki/Cache_stampede (opens in new window), the pathological state is "congestion collapse, preventing the resource from being recached and maintaining zero cache hit rates."
Symptoms in production
| Signal | Interpretation |
|---|---|
| DB / upstream latency spikes at cache-key TTL boundaries | Stampede on key expiry |
| Cache hit rate drops near zero, recovers slowly | Congestion collapse |
| Load spikes synchronised with cron / scheduled jobs | Multiple processes invalidating + recomputing |
| Recompute-cost-vs-traffic ratio > 0.1 | Hot key - stampede risk |
The three mitigation families
Per the Wikipedia article; full code, drawbacks, and the XFetch variable table are in stampede-mitigations.md (opens in new window).
if (!value || (time() - delta * beta * log(rand(0,1))) >= expiry)
recompute_and_cache(key)
else
return valuePer Wikipedia, "setting beta=1 works well in practice." Measure delta (recompute cost) during refresh and store it beside value and expiry.
Choosing and combining
Choose by key knowability: XFetch for unknown / user-specific keys, external recompute for known-hot keys, locking as a backstop. The strongest setups layer them:
| Layer | Strategy |
|---|---|
| Cache backend | TTL + stale-while-revalidate (stale-while-revalidate.md (opens in new window)) |
| App logic | XFetch on read for hot keys |
| Operations | External recompute for known-hot keys |
| Safety net | Distributed lock (Redis SET NX EX) |
Worked example
A homepage "top-10 products" aggregate under one key with ttl=300 expires at the traffic peak; ~1,200 concurrent misses hit the database and the hit rate collapses for ~40s. The key is known and hot, so external recomputation fits: a cron refreshes it every 240s, with XFetch on read as a backstop for a missed cron run. The load test below then asserts the upstream sees <=5 recomputes, down from ~1,000.
Testable behaviours
| Behaviour | Test |
|---|---|
| Lock holds under contention | N concurrent gets on a missing key → 1 recompute, N-1 wait/stale |
| XFetch probability rises near expiry | Statistical: fraction refreshing early within target band |
| External recompute fires before TTL | Write source → wait → assert cache reflects new value |
| Stampede absent under load | N=1000 concurrent on cold key → upstream sees 1-5 recomputes |
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| No mitigation at all | Stampede inevitable under traffic | Pick at least one family |
| Lock without TTL | Lock-holder crash → deadlock | TTL on locks |
XFetch with very high beta (10+) | Everyone refreshes constantly | beta=1; tune via load test |
| External recompute without monitoring | Failed cron → stampedes return silently | Alarm on cache-miss-rate spike |
Hot key with must-revalidate | Forced revalidation = forced stampede at TTL | SWR or grace mode |
| Mitigation tested only at low load | Passes at 10 RPS, fails at 1000 | Production-equivalent concurrency |