TTL Caches — Hands-on Tasks¶
Practical exercises from easy to hard. Each task says what to build, what success looks like, and a hint or expected outcome. Solution sketches are at the end. Every snippet compiles against Go 1.22+ unless otherwise noted.
Easy¶
Task 1 — Minimal TTL cache with map + sync.RWMutex¶
Build a generic-free Cache type that stores string -> []byte with a per-entry TTL passed at insertion time. The API:
type Cache struct { /* ... */ }
func New() *Cache
func (c *Cache) Set(key string, value []byte, ttl time.Duration)
func (c *Cache) Get(key string) ([]byte, bool)
func (c *Cache) Delete(key string)
Requirements:
- Concurrent
Getfrom many goroutines must not block each other. - A
Getof an expired key returns(nil, false)and removes the entry lazily (no background sweeper yet). - Use
time.Now()once per call; do not call it twice for the sameGet.
Goal. Establish the baseline you will optimise away in later tasks. You should already feel two design tensions: the RWMutex is upgraded to a write lock on lazy delete, and time.Now() is monotonic so freezing the clock for tests is awkward.
Success criteria.
- A unit test inserts 100 keys with TTL of 50 ms, sleeps 100 ms, then reads them all and expects every
Getto returnfalse. go test -raceis clean.
Task 2 — Inject a clock interface¶
Refactor Task 1 to take a Clock interface in the constructor:
Provide a real implementation and a FakeClock whose Now() returns whatever the test sets via Advance(d time.Duration). Rewrite the expiration test to use FakeClock instead of time.Sleep.
Goal. Make every later test deterministic and millisecond-fast. This decoupling is non-negotiable for any cache you ship to production; flaky tests caused by real sleeps are a primary smell of "we built the wrong abstraction".
Hints.
- Store
expiresAt time.Timeon each entry, computed at insert. - The cache only ever asks the clock; it never compares two clocks. Do not mix
time.Now()withclock.Now().
Task 3 — Lazy expiration semantics under Get¶
Take the cache from Task 2 and answer the following with a test for each:
- If two goroutines call
Getsimultaneously on the same expired key, only one of them performs the lazy delete; both see(nil, false). - If one goroutine calls
Get(k)on an expired entry while another is callingSet(k, ...)with a fresh value, the new value wins and is not deleted by the lazy path. - A
Getthat races withDeletedoes not panic and does not resurrect the entry.
Goal. Force yourself to think about the "double-check upgrade" pattern: take the RLock, see the entry is expired, drop the read lock, take the write lock, re-check, delete only if still expired and still the same generation.
Hint. Tag each entry with a monotonically increasing gen uint64. The lazy deleter captures the gen during the read phase and only deletes if it still matches under the write lock.
Task 4 — Background sweeper, version 1¶
Add a background goroutine that periodically scans the entire map and removes expired entries.
func New(sweep time.Duration) *Cache // start a sweep loop
func (c *Cache) Close() error // stop it cleanly
Requirements:
Closemust not leak the goroutine; verify withgo.uber.org/goleak.VerifyNone(t).- The sweeper holds the lock for the entire scan in this naive version — that is fine for now; you will fix it in later tasks.
Closeis idempotent: calling it twice is safe.
Goal. Practice the "spawn + cancel + wait" lifecycle that every long-running cache needs.
Common bugs you will hit.
- Forgetting to
wg.Add(1)beforego sweep()— a race betweenCloseand the goroutine's first scheduled instant. - Selecting on
time.Afterinside the loop without stopping the timer — see the famoustime.Afterleak.
Task 5 — Bounded sweep cost: scan a slice per tick¶
The sweeper from Task 4 takes the write lock for the entire scan. With a million entries, latency spikes to tens of milliseconds. Rewrite the sweeper so each tick processes at most N entries (e.g. 1000), then releases the lock.
Implement a "scan cursor": remember where the previous tick stopped, and resume from there on the next tick. The cursor wraps around to the start of the map when it reaches the end.
Hint. Go maps are unordered, so you cannot index into them. The pragmatic approach is to snapshot keys into a slice once per full pass, walk it in chunks, and re-snapshot when the chunk index passes the slice length.
Success criteria. With 1 000 000 entries, a single sweep tick locks the cache for less than 1 ms on a typical laptop.
Medium¶
Task 6 — sync.Map + min-heap of expirations¶
Replace the map + RWMutex with sync.Map for the hot read path, and add a separate min-heap of (expiresAt, key) pairs guarded by its own mutex. The sweeper pops from the heap until the top entry is in the future.
The API stays the same. Compare:
- Read-heavy workload: 100 readers, 1 writer. Measure ops/sec.
- Write-heavy workload: 100 writers, 1 reader. Measure ops/sec.
- Read-write balanced: 50/50.
Goal. Internalise why sync.Map is read-optimised and when the heap design wins.
Hint. container/heap is the standard fit. Define type pq []item and implement heap.Interface. The mutex on the heap is short-held — Push/Pop are O(log n).
Subtlety. When Set overwrites an existing key, the heap still contains a stale entry pointing at the old expiration. Two ways to handle:
- Generation counter on each item, and the sweeper skips items whose generation no longer matches.
- Eager heap fix: remove the old item before pushing the new one, which is O(n) without a secondary index.
The generation approach is almost always right.
Task 7 — Jittered TTL to spread expiration¶
You have a workload that inserts 10 000 keys in a tight loop, all with TTL = 5 minutes. Five minutes later, all 10 000 keys expire in the same tick, and the sweeper hammers the lock.
Modify Set to accept a TTL range (min, max time.Duration) and pick a uniform random value inside it. Verify the next-tick eviction count distributes evenly across the next second instead of all hitting one tick.
Goal. Understand jitter as the standard antidote to synchronized expiry.
Hint. Use math/rand/v2.Int64N (Go 1.22+). It is goroutine-safe and avoids the global mutex of the old math/rand.
Observe. Plot the eviction-count histogram over time before and after jitter. The "before" picture is a single tall bar; the "after" is a flat band.
Task 8 — Integrate golang.org/x/sync/singleflight¶
Add a GetOrLoad method:
func (c *Cache) GetOrLoad(
ctx context.Context,
key string,
ttl time.Duration,
load func(ctx context.Context) ([]byte, error),
) ([]byte, error)
If the key is in cache and unexpired, return it. Otherwise call load, store the result, and return it. Under concurrent misses on the same key, only one load runs; all callers receive the same result.
Use singleflight.Group.Do. Test with 1000 concurrent GetOrLoad on a cold key — load must be called exactly once.
Goal. Eliminate the thundering herd on cold or freshly-expired keys.
Trap. Do not hold the cache mutex across the load call. Releasing the lock before singleflight.Do is essential; otherwise you serialise the entire cache on one slow origin.
Task 9 — Sharded cache¶
Wrap the cache in a sharding layer: N (e.g. 256) independent cache instances, with each key routed to a shard via fnv64a(key) % N. The outer API is identical.
Requirements:
- Each shard has its own mutex and its own sweeper goroutine.
- A close on the outer cache stops every shard cleanly.
- Benchmark single-shard vs 256-shard at 100 concurrent writers. Expect a roughly 20-50x throughput improvement when contention was the bottleneck.
Goal. See how horizontal partitioning is the single most effective technique for scaling a contended map.
Hints.
- Power-of-two shard count lets you use
hash & (N-1)instead of modulo. fnv.New64a()allocates; prefer the inline fnv loop orxxhashfor hot paths.
Task 10 — Observability: hit, miss, expired-on-read counters¶
Add an Stats method returning a struct with at least:
type Stats struct {
Hits uint64
Misses uint64
ExpiredOnRead uint64
LazyDeletes uint64
SweepDeletes uint64
Sets uint64
Size int
}
All counters must be updated with atomic.AddUint64; do not hold any lock just to update a counter.
Wire the counters into a prometheus.Counter and a prometheus.Gauge (size) via the prometheus/client_golang library. Verify hit ratio is reported as Hits / (Hits + Misses) from the /metrics endpoint.
Goal. Practice the rule: every concurrent data structure ships with metrics or it is broken in production.
Task 11 — Graceful shutdown with in-flight loaders¶
Combine Tasks 8 and 9. On Close, the cache must:
- Stop accepting new
SetandGetOrLoad(return a "closed" error). - Wait for in-flight
loadcallbacks to finish, with a deadline fromcontext.Context. - Stop all sweepers.
- Drain all stats.
Add a test that closes the cache while 100 goroutines are mid-GetOrLoad. None of them should be left hanging; all should observe either the cached value or ErrClosed.
Goal. Model the production reality where caches outlive most other state and shutdown ordering matters.
Hint. A sync.WaitGroup tracks in-flight loads; Close decrements after wg.Wait() under a deadline.
Task 12 — Active eviction under memory pressure¶
Add a max-size cap (in entries, e.g. 100 000). When Set would push the size past the cap, evict the entry with the soonest expiresAt, even if it is still alive. (You will replace this with LRU + admission in Task 17.)
Requirements:
- The eviction is O(log n) using the existing min-heap from Task 6.
- An evicted-due-to-size counter joins
Stats.
Goal. Distinguish expiry (entry's TTL hit) from eviction (capacity hit). The two have different metrics and different mental models for capacity planning.
Hard¶
Task 13 — Per-shard sweeper budget¶
Each shard's sweeper currently runs every 1 s with a budget of 1000 entries. Under load, one shard may have a million expired entries while another has none; the busy shard never catches up.
Implement an adaptive budget: if the heap top is more than sweepInterval behind real time, the sweeper runs again immediately without sleeping, until it catches up or hits a max work-per-second cap.
Add a metric sweep_lag_seconds (gauge) reporting now - heap.top.expiresAt if positive, else 0.
Goal. Build a self-regulating sweeper, not a fixed-rate one.
Task 14 — Eliminate the sweeper entirely (pure lazy)¶
Some workloads (e.g. very-short-TTL session caches) make active sweeping a net loss: the sweeper churns through entries that would have been hit anyway in the next millisecond. Build a pure-lazy variant.
The challenge: with no sweeper, expired-but-unread entries accumulate forever, leaking memory. Solve it via a probabilistic sweep on Set:
- On every
Set, with probability1/N, scan a fixed K random entries and lazily delete any that are expired.
This is Redis's strategy for the same problem. Validate with a benchmark that under a 50/50 read/write workload, the memory footprint stabilises rather than growing without bound.
Goal. Internalise that "no background goroutine" is a valid and sometimes superior design.
Hint. math/rand/v2.IntN(len(keys)) over a snapshot. Picking random keys from a map requires a secondary slice — a useful exercise in trade-offs.
Task 15 — Lock-free read path with atomic.Pointer¶
For a read-mostly workload, the RWMutex on the shard is still the bottleneck under contention because every reader does a CAS-like RUnlock. Build a variant where:
- The shard's map is an
atomic.Pointer[map[string]*entry]— readers load it, do their lookup, done. - Writers take a mutex, copy the map, mutate the copy, and
Storethe new pointer.
This is COW (copy-on-write). Measure: at 1 reader / 1 writer, expect the COW variant to be roughly the same; at 100 readers / 1 writer, expect it to be 5-10x faster.
Goal. See where COW shines and where it dies (write-heavy workloads suffer because every write reallocates the entire map).
Trap. Lazy deletion now requires the writer mutex, so reads that find expired entries should defer the delete to a queue consumed by the writer.
Task 16 — Multi-tier (L1 in-process, L2 Redis)¶
Wrap your in-process cache as L1; add a Redis client as L2. The GetOrLoad flow becomes:
- Check L1. Hit? Return.
- Check L2 (
GET key). Hit? Populate L1 with remaining TTL, return. singleflightthe loader; on success, populate both L1 and L2.
Requirements:
- L1 TTL is
min(originalTTL, 30 * time.Second)to bound staleness. - L2 TTL is the original.
- Failures to write L2 do not fail the request — log and continue.
- Add
stats.L1Hits,stats.L2Hits,stats.L2Errors.
Goal. Implement the multi-tier pattern every backend eventually grows into.
Hint. Use github.com/redis/go-redis/v9. Avoid GET + TTL (two round trips); use a single Lua EVAL returning [value, pttl] in one call.
Task 17 — Add an admission policy (TinyLFU sketch)¶
Under capacity pressure, evicting the soonest-to-expire entry is dumb: it may be your hottest key. Implement TinyLFU admission:
- Maintain a count-min sketch of access frequencies (size ~10x cap, 4 hash functions, 4-bit counters).
- On a
Setthat would evict, compare the incoming key's frequency to the current victim's frequency. If incoming is higher, evict the victim; otherwise reject theSet(or queue it as a "window" admission).
This is the core of ristretto. Implement the count-min sketch with 4-bit counters packed into []uint64.
Goal. See why admission policies, not just eviction policies, are state-of-the-art for caches.
Reference. Read the TinyLFU paper, TinyLFU: A Highly Efficient Cache Admission Policy (2017), before starting.
Task 18 — GC-aware large cache via byte arenas¶
When the cache holds millions of pointers (entries with key string, value []byte), every GC cycle scans them all. Build a variant where:
- Values are stored as
[]byteslices into a small set of large pre-allocated arenas (e.g. 8 × 256 MB[]byte). - The map stores
(arenaIdx uint8, offset uint32, length uint32)instead of a pointer to a value.
This is the bigcache strategy. Measure GC pause before and after with runtime.ReadMemStats and the gctrace env var; expect a 10-100x reduction in GC time.
Goal. Understand that "the GC is your enemy at scale" is a real engineering pressure, and how byte arenas defeat it.
Trap. Arenas grow forever unless you compact. Implement a ring-buffer per arena so new writes overwrite the oldest expired entries.
Task 19 — Race-condition Whodunnit¶
This shard implementation has a real race condition. Find it, explain it, fix it without changing the API. Run go test -race to confirm.
type shard struct {
mu sync.Mutex
entries map[string]*entry
}
type entry struct {
value []byte
expiresAt time.Time
}
func (s *shard) Get(key string) ([]byte, bool) {
s.mu.Lock()
e, ok := s.entries[key]
s.mu.Unlock()
if !ok { return nil, false }
if time.Now().After(e.expiresAt) {
s.mu.Lock()
delete(s.entries, key)
s.mu.Unlock()
return nil, false
}
return e.value, true
}
func (s *shard) Set(key string, value []byte, ttl time.Duration) {
s.mu.Lock()
s.entries[key] = &entry{value: value, expiresAt: time.Now().Add(ttl)}
s.mu.Unlock()
}
Hint. Look at the double-checked lazy delete. Between Unlock and the second Lock in Get, another goroutine may Set a fresh value with the same key. The delete then wipes the fresh value.
Task 20 — Implement RoundTrip cache for HTTP¶
Wrap your TTL cache as an http.RoundTripper that caches GET responses for the Cache-Control: max-age=N duration, with the URL as key.
- Use
singleflightkeyed byreq.URL.String()so concurrent identical GETs share one origin call. - Respect
no-store,private, andmust-revalidate. - Add
Vary: Accept-Encodinghandling: cache key includes the chosen encoding. - A test verifies the second call to the same URL hits cache and never opens a connection.
Goal. Bring everything together — TTL, singleflight, shards, observability — in the shape that almost every Go service eventually needs.
Solution Sketches¶
Task 1¶
package ttlcache
import (
"sync"
"time"
)
type entry struct {
value []byte
expiresAt time.Time
}
type Cache struct {
mu sync.RWMutex
data map[string]entry
}
func New() *Cache {
return &Cache{data: make(map[string]entry)}
}
func (c *Cache) Set(key string, value []byte, ttl time.Duration) {
c.mu.Lock()
c.data[key] = entry{value: value, expiresAt: time.Now().Add(ttl)}
c.mu.Unlock()
}
func (c *Cache) Get(key string) ([]byte, bool) {
now := time.Now()
c.mu.RLock()
e, ok := c.data[key]
c.mu.RUnlock()
if !ok {
return nil, false
}
if now.After(e.expiresAt) {
c.mu.Lock()
// re-check under write lock
if e2, ok2 := c.data[key]; ok2 && now.After(e2.expiresAt) {
delete(c.data, key)
}
c.mu.Unlock()
return nil, false
}
return e.value, true
}
func (c *Cache) Delete(key string) {
c.mu.Lock()
delete(c.data, key)
c.mu.Unlock()
}
Task 2¶
type Clock interface {
Now() time.Time
}
type realClock struct{}
func (realClock) Now() time.Time { return time.Now() }
type FakeClock struct {
mu sync.Mutex
now time.Time
}
func (f *FakeClock) Now() time.Time {
f.mu.Lock()
defer f.mu.Unlock()
return f.now
}
func (f *FakeClock) Advance(d time.Duration) {
f.mu.Lock()
f.now = f.now.Add(d)
f.mu.Unlock()
}
type Cache struct {
mu sync.RWMutex
data map[string]entry
clock Clock
}
func New(clock Clock) *Cache {
if clock == nil {
clock = realClock{}
}
return &Cache{data: make(map[string]entry), clock: clock}
}
Task 4¶
type Cache struct {
mu sync.RWMutex
data map[string]entry
clock Clock
quit chan struct{}
done chan struct{}
once sync.Once
}
func New(clock Clock, sweep time.Duration) *Cache {
c := &Cache{
data: make(map[string]entry),
clock: clock,
quit: make(chan struct{}),
done: make(chan struct{}),
}
go c.sweepLoop(sweep)
return c
}
func (c *Cache) sweepLoop(d time.Duration) {
defer close(c.done)
t := time.NewTicker(d)
defer t.Stop()
for {
select {
case <-c.quit:
return
case <-t.C:
c.sweep()
}
}
}
func (c *Cache) sweep() {
now := c.clock.Now()
c.mu.Lock()
for k, e := range c.data {
if now.After(e.expiresAt) {
delete(c.data, k)
}
}
c.mu.Unlock()
}
func (c *Cache) Close() error {
c.once.Do(func() {
close(c.quit)
<-c.done
})
return nil
}
Task 6¶
type item struct {
key string
expiresAt time.Time
gen uint64
index int // heap index; -1 if removed
}
type pq []*item
func (p pq) Len() int { return len(p) }
func (p pq) Less(i, j int) bool { return p[i].expiresAt.Before(p[j].expiresAt) }
func (p pq) Swap(i, j int) {
p[i], p[j] = p[j], p[i]
p[i].index = i
p[j].index = j
}
func (p *pq) Push(x any) {
it := x.(*item)
it.index = len(*p)
*p = append(*p, it)
}
func (p *pq) Pop() any {
old := *p
n := len(old)
it := old[n-1]
old[n-1] = nil
it.index = -1
*p = old[:n-1]
return it
}
type entry struct {
value []byte
gen uint64
// expiresAt also lives in the heap item
expiresAt time.Time
}
type Cache struct {
data sync.Map // map[string]*entry
hmu sync.Mutex
h pq
}
func (c *Cache) Set(key string, value []byte, ttl time.Duration) {
exp := time.Now().Add(ttl)
gen := nextGen()
c.data.Store(key, &entry{value: value, gen: gen, expiresAt: exp})
c.hmu.Lock()
heap.Push(&c.h, &item{key: key, expiresAt: exp, gen: gen})
c.hmu.Unlock()
}
func (c *Cache) sweep() {
now := time.Now()
for {
c.hmu.Lock()
if c.h.Len() == 0 {
c.hmu.Unlock()
return
}
top := c.h[0]
if top.expiresAt.After(now) {
c.hmu.Unlock()
return
}
heap.Pop(&c.h)
c.hmu.Unlock()
v, ok := c.data.Load(top.key)
if !ok {
continue
}
e := v.(*entry)
// skip stale heap items
if e.gen != top.gen {
continue
}
c.data.Delete(top.key)
}
}
Task 8¶
import "golang.org/x/sync/singleflight"
type Cache struct {
/* ... */
sf singleflight.Group
}
func (c *Cache) GetOrLoad(
ctx context.Context,
key string,
ttl time.Duration,
load func(ctx context.Context) ([]byte, error),
) ([]byte, error) {
if v, ok := c.Get(key); ok {
return v, nil
}
v, err, _ := c.sf.Do(key, func() (any, error) {
// re-check after acquiring single-flight: another goroutine may have populated
if v, ok := c.Get(key); ok {
return v, nil
}
result, err := load(ctx)
if err != nil {
return nil, err
}
c.Set(key, result, ttl)
return result, nil
})
if err != nil {
return nil, err
}
return v.([]byte), nil
}
Task 9¶
const numShards = 256
type Sharded struct {
shards [numShards]*Cache
}
func NewSharded() *Sharded {
var s Sharded
for i := range s.shards {
s.shards[i] = New(realClock{}, time.Second)
}
return &s
}
func (s *Sharded) shard(key string) *Cache {
h := fnv64a(key)
return s.shards[h&(numShards-1)]
}
func fnv64a(s string) uint64 {
const (
offset64 uint64 = 14695981039346656037
prime64 uint64 = 1099511628211
)
h := offset64
for i := 0; i < len(s); i++ {
h ^= uint64(s[i])
h *= prime64
}
return h
}
func (s *Sharded) Get(key string) ([]byte, bool) { return s.shard(key).Get(key) }
func (s *Sharded) Set(key string, v []byte, ttl time.Duration) {
s.shard(key).Set(key, v, ttl)
}
func (s *Sharded) Close() error {
for _, sh := range s.shards {
_ = sh.Close()
}
return nil
}
Task 14¶
func (c *Cache) maybeProbeSweep() {
if rand.IntN(probeFreq) != 0 { // e.g. probeFreq = 100
return
}
now := time.Now()
c.mu.Lock()
defer c.mu.Unlock()
n := 0
for k, e := range c.data {
if now.After(e.expiresAt) {
delete(c.data, k)
}
n++
if n >= probeK { // e.g. probeK = 20
return
}
}
}
func (c *Cache) Set(key string, value []byte, ttl time.Duration) {
c.mu.Lock()
c.data[key] = entry{value: value, expiresAt: time.Now().Add(ttl)}
c.mu.Unlock()
c.maybeProbeSweep()
}
Task 15¶
type shard struct {
snap atomic.Pointer[map[string]*entry]
mu sync.Mutex // serialises writers and gc of expired
queue chan string // expired-but-not-yet-deleted keys
}
func newShard() *shard {
s := &shard{queue: make(chan string, 1024)}
m := make(map[string]*entry)
s.snap.Store(&m)
go s.writer()
return s
}
func (s *shard) Get(key string) ([]byte, bool) {
m := *s.snap.Load()
e, ok := m[key]
if !ok {
return nil, false
}
if time.Now().After(e.expiresAt) {
select { case s.queue <- key: default: }
return nil, false
}
return e.value, true
}
func (s *shard) Set(key string, v []byte, ttl time.Duration) {
s.mu.Lock()
old := *s.snap.Load()
next := make(map[string]*entry, len(old)+1)
for k, e := range old {
next[k] = e
}
next[key] = &entry{value: v, expiresAt: time.Now().Add(ttl)}
s.snap.Store(&next)
s.mu.Unlock()
}
func (s *shard) writer() {
for key := range s.queue {
s.mu.Lock()
old := *s.snap.Load()
if e, ok := old[key]; ok && time.Now().After(e.expiresAt) {
next := make(map[string]*entry, len(old))
for k, v := range old {
if k == key { continue }
next[k] = v
}
s.snap.Store(&next)
}
s.mu.Unlock()
}
}
Task 19¶
The race: in Get, between s.mu.Unlock() and the second s.mu.Lock(), another goroutine can Set(key, ...) with a fresh value. The second Lock then deletes the fresh entry. Fix:
func (s *shard) Get(key string) ([]byte, bool) {
s.mu.Lock()
e, ok := s.entries[key]
if !ok {
s.mu.Unlock()
return nil, false
}
if time.Now().After(e.expiresAt) {
// delete only if it is still the same pointer
if cur, stillThere := s.entries[key]; stillThere && cur == e {
delete(s.entries, key)
}
s.mu.Unlock()
return nil, false
}
val := e.value
s.mu.Unlock()
return val, true
}
The cur == e pointer-identity check makes the delete safe under the same lock, with no second lock acquisition. Or simply hold the lock through both the read and the delete decision — the entire critical section is small.
Task 20¶
type cachingRT struct {
base http.RoundTripper
cache *Sharded
sf singleflight.Group
}
func (c *cachingRT) RoundTrip(req *http.Request) (*http.Response, error) {
if req.Method != http.MethodGet {
return c.base.RoundTrip(req)
}
key := req.URL.String() + "|enc=" + req.Header.Get("Accept-Encoding")
if body, ok := c.cache.Get(key); ok {
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewReader(body)),
Header: http.Header{"X-Cache": []string{"HIT"}},
}, nil
}
v, err, _ := c.sf.Do(key, func() (any, error) {
resp, err := c.base.RoundTrip(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
ttl := parseMaxAge(resp.Header.Get("Cache-Control"))
if ttl > 0 && resp.StatusCode == 200 {
c.cache.Set(key, body, ttl)
}
return body, nil
})
if err != nil {
return nil, err
}
body := v.([]byte)
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewReader(body)),
Header: http.Header{"X-Cache": []string{"MISS"}},
}, nil
}
func parseMaxAge(cc string) time.Duration {
// pragmatic: parse "max-age=N"
for _, part := range strings.Split(cc, ",") {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, "max-age=") {
n, err := strconv.Atoi(strings.TrimPrefix(part, "max-age="))
if err == nil && n > 0 {
return time.Duration(n) * time.Second
}
}
}
return 0
}
Wrap-up¶
You should now have, in your own codebase:
- A baseline TTL cache with lazy and active eviction.
- A clock-injected variant that tests deterministically in microseconds.
- A sharded, observable, single-flight-guarded cache that survives a hot-key stampede.
- A copy-on-write reader path for read-heavy workloads.
- A multi-tier L1/L2 cache wiring up Redis.
- The byte-arena variant that hides millions of entries from the GC.
The next file, find-bug.md, takes the same primitives and shows what they look like when they go wrong. The file after that, optimize.md, takes them and shows how to make them faster.