Worked review 4: a generic cache with TTL
Cache[K, V] is a small generic, time-limited cache — Set stores a value with an expiry, Get should return false once that expiry has passed. It compiles, go vet says nothing, golangci-lint run says nothing, and it runs. It has 4 real bugs, one of which is a genuine fatal error under real concurrent use.
The program
Section titled “The program”package main
import ( "fmt" "time")
type entry[V any] struct { value V exp time.Time}
type Cache[K comparable, V any] struct { data map[K]entry[V] ttl time.Duration}
func New[K comparable, V any](ttl time.Duration) Cache[K, V] { return Cache[K, V]{data: make(map[K]entry[V]), ttl: ttl}}
func (c Cache[K, V]) Set(key K, value V) { c.data[key] = entry[V]{value: value, exp: time.Now().Add(c.ttl)}}
func (c Cache[K, V]) Get(key K) (V, bool) { e, ok := c.data[key] if !ok { var zero V return zero, false } if time.Now().After(e.exp) { delete(c.data, key) } return e.value, true}
func main() { c := New[string, int](50 * time.Millisecond) c.Set("a", 1)
v, ok := c.Get("a") fmt.Println("immediately:", v, ok)
time.Sleep(100 * time.Millisecond) v, ok = c.Get("a") fmt.Println("after ttl:", v, ok)
for i := 0; i < 5; i++ { c.Set(fmt.Sprintf("k%d", i), i) } time.Sleep(100 * time.Millisecond) fmt.Println("entries still in map after all expired, none re-Get:", len(c.data))
c2 := c c2.Set("shared", 42) sharedVal, sharedOK := c.Get("shared") fmt.Println("c (unrelated-looking copy) also sees c2's write:", sharedVal, sharedOK)} Find the bugs
Section titled “Find the bugs”c.Get("a") after the TTL has already passed still reports true. Two supposedly independent-looking cache variables see each other’s writes. Try to name at least three of the four bugs before opening the answer.
Reveal the 4 bugs
Getdeletes the expired entry but still returns it as if it were valid.if time.Now().After(e.exp) { delete(c.data, key) }runs, and then execution falls straight through to the unconditionalreturn e.value, truebelow it — there is noreturn zero, falseon the expired path. Real output:after ttl: 1 true, when it should be0 false. The entry is even correctly removed from the map (deleteon a shared map reference works fine through a value receiver) — but the stale value ships anyway on the way out.- Neither
SetnorGetsynchronizes access to the map. There is nosync.Mutexanywhere inCache. Run four goroutines callingSet/Geton the same cache concurrently and Go’s runtime — not just the race detector — catches it directly:This is not afatal error: concurrent map writes-race-only finding; unsynchronized concurrent map writes crash the process outright, with or without the race detector attached (-raceadditionally reports the exact two goroutines and lines involved). - Expired entries are never removed unless something happens to call
Geton that exact key again. The demo sets five keys, lets all of them expire, and never touches any of them again withGet—len(c.data)reports all 5 still present. A cache used asSet-mostly (write a value, look it up rarely or never again by that exact key) grows without bound; nothing ever sweeps it. NewreturnsCache[K, V]by value with value-receiver methods, so a plain assignment silently aliases instead of copying.c2 := clooks like an independent copy —Cacheis a plain struct, after all — butCache’s only real state is a map, and maps are reference types.c2.Set("shared", 42)is visible throughctoo:c (unrelated-looking copy) also sees c2's write: 42 true. Nothing here is technically incorrect Go — it is a value type whose “value” semantics are deceptive because of what it contains.
The corrected program
Section titled “The corrected program”package main
import ( "fmt" "sync" "time")
type entry[V any] struct { value V exp time.Time}
type Cache[K comparable, V any] struct { mu sync.Mutex // fixed: guards data against concurrent Set/Get data map[K]entry[V] ttl time.Duration done chan struct{}}
func New[K comparable, V any](ttl time.Duration) *Cache[K, V] { // fixed: pointer — copies now share explicitly, on purpose c := &Cache[K, V]{data: make(map[K]entry[V]), ttl: ttl, done: make(chan struct{})} go c.janitor(ttl) // fixed: proactively evict expired entries, not just on access return c}
func (c *Cache[K, V]) janitor(interval time.Duration) { t := time.NewTicker(interval) defer t.Stop() for { select { case <-t.C: c.mu.Lock() for k, e := range c.data { if time.Now().After(e.exp) { delete(c.data, k) } } c.mu.Unlock() case <-c.done: return } }}
func (c *Cache[K, V]) Close() { close(c.done) } // fixed: caller can stop the janitor goroutine
func (c *Cache[K, V]) Set(key K, value V) { c.mu.Lock() defer c.mu.Unlock() c.data[key] = entry[V]{value: value, exp: time.Now().Add(c.ttl)}}
func (c *Cache[K, V]) Get(key K) (V, bool) { c.mu.Lock() defer c.mu.Unlock() e, ok := c.data[key] if !ok || time.Now().After(e.exp) { // fixed: expired counts as not found var zero V return zero, false } return e.value, true}
func main() { c := New[string, int](50 * time.Millisecond) defer c.Close() c.Set("a", 1)
v, ok := c.Get("a") fmt.Println("immediately:", v, ok)
time.Sleep(100 * time.Millisecond) v, ok = c.Get("a") fmt.Println("after ttl:", v, ok)
for i := 0; i < 5; i++ { c.Set(fmt.Sprintf("k%d", i), i) } time.Sleep(200 * time.Millisecond) // give the janitor a couple of ticks fmt.Println("entries left after janitor sweep:", len(c.data))
c2 := c // fixed: this is now an explicit pointer copy — no surprise aliasing of a value type c2.Set("shared", 42) sharedVal, sharedOK := c.Get("shared") fmt.Println("c and c2 are the same *Cache on purpose:", sharedVal, sharedOK)} Run the corrected version: after ttl: 0 false, entries left after janitor sweep: 0 (a background janitor goroutine, stoppable via Close, sweeps expired entries on a ticker), and c and c2 are the same *Cache on purpose: 42 true — the aliasing is unchanged, but it is no longer a silent surprise: *Cache[K, V] documents in its type that copies share state, the same way every other pointer in Go does. Stress-testing this version with four goroutines calling Set/Get concurrently under go run -race reports no race and no fatal error — the version above crashed with fatal error: concurrent map writes under the identical load.