Phase 5b slice A. Generic TTL cache that replaces the mechanically identical dashboardCache + timelineCache in slices B/C. - TTLCache[V] over map[string]entry[V] with sync.RWMutex. - Get / Set / Invalidate(key) / InvalidateAll. - Lazy expiry — a Get past the deadline removes the entry; no sweeper goroutine (matches today's behaviour and stays simple at single-user scale). - Nil receiver is safe across all four methods — same defensive shape the existing per-package caches use. Tests cover empty Get, Set+Get, expiry on miss, overwrite, keyed-Invalidate isolation, InvalidateAll, nil receiver, pointer payload behaviour, and a -race-flag concurrent-access probe across 8 workers × 200 ops. No web/mcp wiring yet — slices B/C migrate the callers. Same Go linker DCE caveat as 5a slice A applies (strings | grep alone won't fire on this slice). Task: t-projax-5b-cache
89 lines
2.2 KiB
Go
89 lines
2.2 KiB
Go
// Package cache provides a tiny generic TTL cache used by the projax web
|
|
// surface. Before Phase 5b each web-side cache (dashboardCache,
|
|
// timelineCache) defined its own copy of the same shape: map + mutex +
|
|
// per-entry expiry + invalidation. Generics let us collapse them.
|
|
package cache
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// TTLCache is a concurrency-safe map keyed by string, holding values of
|
|
// type V for up to TTL each. Expiry is lazy — a Get past the deadline
|
|
// removes the entry and reports miss. No sweeper goroutine; at projax's
|
|
// single-user scale the map stays tiny.
|
|
type TTLCache[V any] struct {
|
|
ttl time.Duration
|
|
mu sync.RWMutex
|
|
rows map[string]entry[V]
|
|
}
|
|
|
|
type entry[V any] struct {
|
|
value V
|
|
expires time.Time
|
|
}
|
|
|
|
// NewTTL builds a TTLCache with the given entry lifetime.
|
|
func NewTTL[V any](ttl time.Duration) *TTLCache[V] {
|
|
return &TTLCache[V]{ttl: ttl, rows: map[string]entry[V]{}}
|
|
}
|
|
|
|
// Get returns the cached value for key. The second result is true iff the
|
|
// entry is present AND has not yet expired. On expiry the entry is
|
|
// removed so the next miss path doesn't keep finding stale data.
|
|
func (c *TTLCache[V]) Get(key string) (V, bool) {
|
|
var zero V
|
|
if c == nil {
|
|
return zero, false
|
|
}
|
|
// Fast path: optimistic read under RLock.
|
|
c.mu.RLock()
|
|
e, ok := c.rows[key]
|
|
c.mu.RUnlock()
|
|
if !ok {
|
|
return zero, false
|
|
}
|
|
if time.Now().Before(e.expires) {
|
|
return e.value, true
|
|
}
|
|
// Expired — drop it.
|
|
c.mu.Lock()
|
|
if e2, ok := c.rows[key]; ok && !time.Now().Before(e2.expires) {
|
|
delete(c.rows, key)
|
|
}
|
|
c.mu.Unlock()
|
|
return zero, false
|
|
}
|
|
|
|
// Set inserts or overwrites the entry for key.
|
|
func (c *TTLCache[V]) Set(key string, v V) {
|
|
if c == nil {
|
|
return
|
|
}
|
|
c.mu.Lock()
|
|
c.rows[key] = entry[V]{value: v, expires: time.Now().Add(c.ttl)}
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
// Invalidate removes a single key. No-op if the key was absent.
|
|
func (c *TTLCache[V]) Invalidate(key string) {
|
|
if c == nil {
|
|
return
|
|
}
|
|
c.mu.Lock()
|
|
delete(c.rows, key)
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
// InvalidateAll wipes every entry. Used by writeback handlers that may
|
|
// have changed content under any filter key.
|
|
func (c *TTLCache[V]) InvalidateAll() {
|
|
if c == nil {
|
|
return
|
|
}
|
|
c.mu.Lock()
|
|
c.rows = map[string]entry[V]{}
|
|
c.mu.Unlock()
|
|
}
|