Concurrency-safe in-memory TTL cache for User
Implement a concurrency-safe in-memory cache for the User type below, keyed by User.ID (a string), where every entry expires after a fixed TTL.
Requirements:
Setstores a user and (re)sets its expiry to now + TTL.Get(id)returns(User, true)only for a present, unexpired entry; else(User{}, false).- Concurrency-safe for
SetandGetacross goroutines — no data race on the map. - Expired entries must be reclaimed even if never read again — lazy eviction alone leaks untouched keys, so add a background sweep.
Extend the skeleton freely (fields, helpers, a constructor sweep-interval param, a Close to stop the sweeper without leaking its goroutine).
type User struct {
ID string
Name string
}
type Cache struct {
// your fields here
}
func NewCache(ttl time.Duration) *Cache {
// your code here
}
func (c *Cache) Set(u User) {
// your code here
}
func (c *Cache) Get(id string) (User, bool) {
// your code here
}
Write the implementation.
Store entries in a map keyed by User.ID, each with an expiry timestamp, guarded by a sync.RWMutex. Set writes the value and now+ttl under Lock; Get takes RLock and treats an entry whose expiry is past as a miss. A background cleaner goroutine sweeps expired keys so untouched entries don't linger; a Close method stops it from leaking.
- ✗Returning a stale entry on
Getbecause the expiry timestamp is never checked on read - ✗Reading or writing the map without the lock, assuming Go maps are safe under concurrency
- ✗Never evicting expired keys, so the map grows unbounded even though
Getreports misses
- →How would you run the cleaner periodically without leaking its goroutine when the cache is gone?
- →Why prefer
RWMutexoverMutexhere, and when would that choice stop paying off?
A concurrency-safe TTL cache
A single map holds both the value and the expiry in one entry (so two parallel maps cannot drift out of sync). A sync.RWMutex admits many concurrent Get calls under RLock and serializes writes under Lock.
package main
import (
"sync"
"time"
)
type User struct {
ID string
Name string
}
type entry struct {
user User
expiresAt time.Time
}
type Cache struct {
mu sync.RWMutex
data map[string]entry
ttl time.Duration
stop chan struct{}
}
func NewCache(ttl time.Duration) *Cache {
c := &Cache{
data: make(map[string]entry),
ttl: ttl,
stop: make(chan struct{}),
}
go c.janitor(ttl) // sweep at the TTL cadence
return c
}
// Set writes the value and resets its time-to-live.
func (c *Cache) Set(u User) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[u.ID] = entry{user: u, expiresAt: time.Now().Add(c.ttl)}
}
// Get returns (User, true) only for a fresh entry.
func (c *Cache) Get(id string) (User, bool) {
c.mu.RLock()
e, ok := c.data[id]
c.mu.RUnlock()
if !ok || time.Now().After(e.expiresAt) {
return User{}, false // missing key or expired
}
return e.user, true
}
// Close stops the background janitor so its goroutine does not leak.
func (c *Cache) Close() {
close(c.stop)
}
// janitor periodically removes expired entries until the cache is closed.
func (c *Cache) janitor(interval time.Duration) {
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-t.C:
c.cleanup()
case <-c.stop:
return
}
}
}
// cleanup removes every expired entry in one pass under Lock.
func (c *Cache) cleanup() {
now := time.Now()
c.mu.Lock()
defer c.mu.Unlock()
for id, e := range c.data {
if now.After(e.expiresAt) {
delete(c.data, id)
}
}
}
Three correctness invariants:
- Expiry is checked on read.
Getcomparestime.Now()againstexpiresAtand returns a miss when expired — even if the background cleaner has not deleted the key yet. - Every access is locked. Writes (
Set,cleanup) takeLock; reads (Get) takeRLock. Unsynchronized concurrent access to a Go map is a data race and triggersfatal error: concurrent map .... - Eviction is mandatory and the goroutine must not leak. A lazy miss on
Getis not enough: a key that is never requested again would linger in the map forever. SojanitorcallscleanupunderLockon atime.Ticker, andClosecloses thestopchannel — theselectobserves it and returns, so the goroutine does not leak once the cache is gone.