feat(cache): introduce internal/cache/ TTLCache[V]
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
This commit is contained in:
88
internal/cache/ttl.go
vendored
Normal file
88
internal/cache/ttl.go
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
// 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()
|
||||
}
|
||||
134
internal/cache/ttl_test.go
vendored
Normal file
134
internal/cache/ttl_test.go
vendored
Normal file
@@ -0,0 +1,134 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGetEmpty(t *testing.T) {
|
||||
c := NewTTL[int](50 * time.Millisecond)
|
||||
if v, ok := c.Get("nope"); ok || v != 0 {
|
||||
t.Fatalf("empty Get: got (%v, %v), want (0, false)", v, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetGet(t *testing.T) {
|
||||
c := NewTTL[string](50 * time.Millisecond)
|
||||
c.Set("k", "v")
|
||||
got, ok := c.Get("k")
|
||||
if !ok || got != "v" {
|
||||
t.Fatalf("got (%q, %v), want (v, true)", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAfterTTL(t *testing.T) {
|
||||
c := NewTTL[string](20 * time.Millisecond)
|
||||
c.Set("k", "v")
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if _, ok := c.Get("k"); ok {
|
||||
t.Fatalf("expected miss after TTL")
|
||||
}
|
||||
// Internal: the expired entry must be removed.
|
||||
c.mu.RLock()
|
||||
_, present := c.rows["k"]
|
||||
c.mu.RUnlock()
|
||||
if present {
|
||||
t.Fatalf("expired entry was not removed on miss")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetOverwrites(t *testing.T) {
|
||||
c := NewTTL[int](1 * time.Second)
|
||||
c.Set("k", 1)
|
||||
c.Set("k", 2)
|
||||
got, ok := c.Get("k")
|
||||
if !ok || got != 2 {
|
||||
t.Fatalf("got (%d, %v), want (2, true)", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateOnlyTargetsKey(t *testing.T) {
|
||||
c := NewTTL[int](1 * time.Second)
|
||||
c.Set("a", 1)
|
||||
c.Set("b", 2)
|
||||
c.Invalidate("a")
|
||||
if _, ok := c.Get("a"); ok {
|
||||
t.Fatalf("a should be gone")
|
||||
}
|
||||
if v, ok := c.Get("b"); !ok || v != 2 {
|
||||
t.Fatalf("b should survive Invalidate of a; got (%d, %v)", v, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateAll(t *testing.T) {
|
||||
c := NewTTL[int](1 * time.Second)
|
||||
c.Set("a", 1)
|
||||
c.Set("b", 2)
|
||||
c.InvalidateAll()
|
||||
if _, ok := c.Get("a"); ok {
|
||||
t.Fatalf("a should be gone")
|
||||
}
|
||||
if _, ok := c.Get("b"); ok {
|
||||
t.Fatalf("b should be gone")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilReceiverSafe(t *testing.T) {
|
||||
var c *TTLCache[string]
|
||||
if v, ok := c.Get("k"); ok || v != "" {
|
||||
t.Errorf("nil Get should miss, got (%q, %v)", v, ok)
|
||||
}
|
||||
c.Set("k", "v") // must not panic
|
||||
c.Invalidate("k")
|
||||
c.InvalidateAll()
|
||||
}
|
||||
|
||||
func TestPointerPayload(t *testing.T) {
|
||||
type payload struct{ N int }
|
||||
c := NewTTL[*payload](1 * time.Second)
|
||||
if v, ok := c.Get("k"); ok || v != nil {
|
||||
t.Fatalf("pointer miss should yield (nil, false), got (%v, %v)", v, ok)
|
||||
}
|
||||
c.Set("k", &payload{N: 42})
|
||||
v, ok := c.Get("k")
|
||||
if !ok || v == nil || v.N != 42 {
|
||||
t.Fatalf("got (%+v, %v), want non-nil N=42", v, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRaceCleanConcurrentAccess runs many goroutines Setting / Getting /
|
||||
// Invalidating overlapping keys. Run with `go test -race ./internal/cache/`
|
||||
// to catch any unprotected writes — the test body itself only checks
|
||||
// non-panic + final-count sanity.
|
||||
func TestRaceCleanConcurrentAccess(t *testing.T) {
|
||||
c := NewTTL[int](250 * time.Millisecond)
|
||||
const workers = 8
|
||||
const ops = 200
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func(seed int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < ops; i++ {
|
||||
key := "k" + strconv.Itoa((seed*ops+i)%5)
|
||||
switch i % 4 {
|
||||
case 0:
|
||||
c.Set(key, i)
|
||||
case 1:
|
||||
_, _ = c.Get(key)
|
||||
case 2:
|
||||
c.Invalidate(key)
|
||||
case 3:
|
||||
if i%50 == 0 {
|
||||
c.InvalidateAll()
|
||||
} else {
|
||||
_, _ = c.Get(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
Reference in New Issue
Block a user