Day 58 — caching

Updated

July 30, 2026

Day 58 — caching

Stage VI · ~3h
Goal: Implement a cache-aside layer behind a small interface—in-memory with TTL, optional Redis—plus invalidation rules that won’t lie to users.

Why this day exists

Databases and upstream HTTP are expensive. Caching helps when:

  • Reads dominate
  • Slightly stale data is acceptable or invalidation is correct
  • You measure hit rate later (Stage VII metrics)

Caching also hurts when:

  • You cache without TTL or invalidation
  • You cache user-specific data under global keys
  • Stampede thunders on expiry
  • You treat cache as source of truth

Today you build a correct-by-default cache-aside path you can disable with a Noop implementation.


Theory 1 — Cache-aside (lazy loading)

read(key):
  if val, ok := cache.Get(key); ok { return val }
  val = db.Load(key)
  cache.Set(key, val, ttl)
  return val

write(key, val):
  db.Save(key, val)
  cache.Delete(key)   // or Set updated

Why delete on write? Avoid dual-write inconsistency when DB succeeds and cache set fails (or vice versa). Re-populate on next read. Write-through (update cache in the write path) is valid but harder to keep consistent under partial failures.


Theory 2 — Interface first

type Cache interface {
    Get(ctx context.Context, key string) (string, bool, error)
    Set(ctx context.Context, key, val string, ttl time.Duration) error
    Delete(ctx context.Context, key string) error
}

Implementations:

  1. MemoryCachemap + sync.RWMutex + expiry
  2. RedisCache — optional; skip if no Redis
  3. NoopCache — always miss (tests/feature flag)
type NoopCache struct{}

func (NoopCache) Get(context.Context, string) (string, bool, error) {
    return "", false, nil
}
func (NoopCache) Set(context.Context, string, string, time.Duration) error { return nil }
func (NoopCache) Delete(context.Context, string) error                    { return nil }

Theory 3 — Memory implementation sketch

type entry struct {
    val string
    exp time.Time
}

type MemoryCache struct {
    mu   sync.RWMutex
    data map[string]entry
}

func NewMemoryCache() *MemoryCache {
    return &MemoryCache{data: make(map[string]entry)}
}

func (c *MemoryCache) Get(_ context.Context, key string) (string, bool, error) {
    c.mu.RLock()
    e, ok := c.data[key]
    c.mu.RUnlock()
    if !ok || time.Now().After(e.exp) {
        if ok {
            _ = c.Delete(context.Background(), key)
        }
        return "", false, nil
    }
    return e.val, true, nil
}

func (c *MemoryCache) Set(_ context.Context, key, val string, ttl time.Duration) error {
    c.mu.Lock()
    c.data[key] = entry{val: val, exp: time.Now().Add(ttl)}
    c.mu.Unlock()
    return nil
}

func (c *MemoryCache) Delete(_ context.Context, key string) error {
    c.mu.Lock()
    delete(c.data, key)
    c.mu.Unlock()
    return nil
}

Janitor

Optional goroutine ticking every minute to drop expired keys (or lazy delete only). Bound map size (max entries + eviction) for production; lab can document unbounded risk.

func (c *MemoryCache) Janitor(stop <-chan struct{}, every time.Duration) {
    t := time.NewTicker(every)
    defer t.Stop()
    for {
        select {
        case <-stop:
            return
        case <-t.C:
            now := time.Now()
            c.mu.Lock()
            for k, e := range c.data {
                if now.After(e.exp) {
                    delete(c.data, k)
                }
            }
            c.mu.Unlock()
        }
    }
}

Theory 4 — Key design

item:v1:{id}
user:v1:{id}:items

Include version prefix so you can bust all keys by deploying a new version. Include tenant/user when data is private:

item:v1:{userID}:{id}

Never use raw user input without sanitizing separators. Prefer fixed prefixes + opaque ids.


Theory 5 — Stampede control (awareness)

When popular key expires, N requests hit DB. Mitigations:

  • Singleflight (golang.org/x/sync/singleflight)
  • Probabilistic early expiration
  • Lock per key
var g singleflight.Group

func (r *CachedItemRepo) Get(ctx context.Context, id string) (Item, error) {
    key := "item:v1:" + id
    if raw, ok, err := r.cache.Get(ctx, key); err != nil {
        return Item{}, err
    } else if ok {
        var it Item
        if err := json.Unmarshal([]byte(raw), &it); err == nil {
            return it, nil
        }
    }
    v, err, _ := g.Do(key, func() (any, error) {
        it, err := r.inner.Get(ctx, id)
        if err != nil {
            return Item{}, err
        }
        b, _ := json.Marshal(it)
        _ = r.cache.Set(ctx, key, string(b), r.ttl)
        return it, nil
    })
    if err != nil {
        return Item{}, err
    }
    return v.(Item), nil
}

Lab stretch: prove concurrent Gets coalesce to one load.


Theory 6 — HTTP caching vs app cache

Layer Mechanism
Client/CDN Cache-Control, ETag (Day 56 R)
App memory/Redis Cache interface today
DB Materialized views / query cache (DBA)

Do not confuse Cache-Control: private personal data with public CDN caching. App caches are not a substitute for HTTP cache headers when browsers/CDNs are the consumers.

Negative caching

Caching “not found” can protect the DB from hammering missing keys—use a short TTL and never confuse it with a real item payload (store a sentinel or separate negative map).


Worked example — repo with cache-aside

type CachedItemRepo struct {
    inner ItemReaderWriter // interface to real store
    cache Cache
    ttl   time.Duration
    log   *slog.Logger
}

func (r *CachedItemRepo) Get(ctx context.Context, id string) (Item, error) {
    key := "item:v1:" + id
    if raw, ok, err := r.cache.Get(ctx, key); err != nil {
        return Item{}, err
    } else if ok {
        var it Item
        if err := json.Unmarshal([]byte(raw), &it); err == nil {
            r.log.Debug("cache hit", "key", key)
            return it, nil
        }
    }
    it, err := r.inner.Get(ctx, id)
    if err != nil {
        return it, err
    }
    b, err := json.Marshal(it)
    if err == nil {
        _ = r.cache.Set(ctx, key, string(b), r.ttl)
    }
    return it, nil
}

func (r *CachedItemRepo) Update(ctx context.Context, it Item) error {
    if err := r.inner.Update(ctx, it); err != nil {
        return err
    }
    return r.cache.Delete(ctx, "item:v1:"+it.ID)
}

Lab 1 — Setup

mkdir -p ~/lab/90daysofx/01-go/day58
cd ~/lab/90daysofx/01-go/day58
go mod init example.com/day58

Package layout: cache/, store/, optional api/ wiring.


Lab 2 — MemoryCache tests

  1. Set/Get hit
  2. Expiry miss after TTL
  3. Delete removes key
  4. Concurrent Set/Get with -race

Use short TTLs (20 * time.Millisecond) + time.Sleep carefully or inject a clock interface for purity.

go test ./cache/ -race -count=1

Lab 3 — Wire into API

GET /v1/items/{id} goes through cached repo. Manually:

  1. Get (miss → DB)
  2. Get again (hit—log optional cache_hit=true)
  3. Update/Delete invalidates
  4. Get loads fresh

Optional counter metrics later (Day 69); for now slog Debug is enough.


Lab 4 — Optional Redis

If Redis available:

rdb := redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"})
// GET / SET with expiration / DEL — implement Cache

Skip with build tag or runtime detect—document “Redis not used” if skipped. Never fail unit tests because Redis is down.


Lab 5 — singleflight stretch

Prove with a slow DB fake that 20 concurrent Gets cause ~1 load:

var loads atomic.Int64
// inner.Get sleeps 50ms and increments loads
// 20 goroutines call Cached Get same id
// assert loads == 1 (or very close) with singleflight

Common gotchas

Gotcha Fix
Cache forever TTL always
No invalidation on write Delete/Set
Caching errors/404 forever Don’t cache misses or short negative TTL deliberately
Global keys for private data Namespace by user
Huge values Bound size
Ignoring ctx in Redis Use context variants
Race on map Mutex
Caching mutable pointers shared with callers Marshal copies / return new structs

Checkpoint

  • Cache interface + Memory impl
  • Cache-aside Get + invalidate on write
  • Tests for TTL and race
  • Key naming scheme documented
  • Optional Redis or explicit skip note
  • Know when not to cache

Commit

git add .
git commit -m "day58: cache-aside interface and memory TTL cache"

Write three personal gotchas before continuing.


Tomorrow

Day 59 — WebSockets or SSE: pick one realtime pattern; push live updates from your service.