diff --git a/internal/cache/ttl.go b/internal/cache/ttl.go new file mode 100644 index 0000000..4e45928 --- /dev/null +++ b/internal/cache/ttl.go @@ -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() +} diff --git a/internal/cache/ttl_test.go b/internal/cache/ttl_test.go new file mode 100644 index 0000000..c0fca1b --- /dev/null +++ b/internal/cache/ttl_test.go @@ -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() +}