Files
pages/internal/cache/cache_test.go
T
2026-08-15 07:13:00 +00:00

224 lines
4.9 KiB
Go

package cache
import (
"fmt"
"sync"
"testing"
"time"
)
// clock lets the TTL tests run without sleeping.
type clock struct {
mu sync.Mutex
t time.Time
}
func (c *clock) now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.t
}
func (c *clock) advance(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.t = c.t.Add(d)
}
func newTestCache[K comparable, V any](t *testing.T, max int, ttl time.Duration) (*Cache[K, V], *clock) {
t.Helper()
c := New[K, V](max, ttl)
clk := &clock{t: time.Unix(1_700_000_000, 0)}
c.now = clk.now
return c, clk
}
func TestGetPut(t *testing.T) {
c, _ := newTestCache[string, int](t, 4, time.Minute)
if _, ok := c.Get("missing"); ok {
t.Error("empty cache returned a hit")
}
c.Put("a", 1)
if v, ok := c.Get("a"); !ok || v != 1 {
t.Errorf("Get(a) = %v, %v", v, ok)
}
c.Put("a", 2)
if v, _ := c.Get("a"); v != 2 {
t.Errorf("Put did not overwrite: %v", v)
}
if c.Len() != 1 {
t.Errorf("Len = %d, want 1", c.Len())
}
c.Delete("a")
if _, ok := c.Get("a"); ok {
t.Error("deleted key still present")
}
}
func TestTTLExpiry(t *testing.T) {
c, clk := newTestCache[string, int](t, 4, time.Minute)
c.Put("a", 1)
clk.advance(59 * time.Second)
if _, ok := c.Get("a"); !ok {
t.Error("entry expired early")
}
clk.advance(time.Second)
if _, ok := c.Get("a"); ok {
t.Error("entry outlived its TTL")
}
if c.Len() != 0 {
t.Errorf("expired entry not dropped: Len = %d", c.Len())
}
}
// The bound is the whole point: the rate limiter's key space is whatever
// addresses show up, so an unbounded map would be a memory DoS.
func TestEvictsLeastRecentlyUsed(t *testing.T) {
c, _ := newTestCache[string, int](t, 3, time.Minute)
c.Put("a", 1)
c.Put("b", 2)
c.Put("c", 3)
// Touching "a" makes "b" the least recently used.
if _, ok := c.Get("a"); !ok {
t.Fatal("a missing")
}
c.Put("d", 4)
if c.Len() != 3 {
t.Errorf("Len = %d, want 3", c.Len())
}
if _, ok := c.Get("b"); ok {
t.Error("b should have been evicted")
}
for _, k := range []string{"a", "c", "d"} {
if _, ok := c.Get(k); !ok {
t.Errorf("%s should have survived", k)
}
}
}
func TestEvictionKeepsListConsistent(t *testing.T) {
c, _ := newTestCache[int, int](t, 8, time.Minute)
for i := 0; i < 1000; i++ {
c.Put(i, i)
if i%3 == 0 {
c.Get(i / 2)
}
if i%7 == 0 {
c.Delete(i - 1)
}
if c.Len() > 8 {
t.Fatalf("cap exceeded at i=%d: Len = %d", i, c.Len())
}
}
// Every surviving node must be reachable from both ends, or a later
// eviction would corrupt the list rather than free anything.
c.mu.Lock()
defer c.mu.Unlock()
forward := 0
for n := c.head; n != nil; n = n.next {
forward++
if forward > 100 {
t.Fatal("forward walk did not terminate (cycle in the list)")
}
}
backward := 0
for n := c.tail; n != nil; n = n.prev {
backward++
if backward > 100 {
t.Fatal("backward walk did not terminate (cycle in the list)")
}
}
if forward != len(c.m) || backward != len(c.m) {
t.Errorf("list holds %d/%d nodes, map holds %d", forward, backward, len(c.m))
}
}
func TestGetOrCreate(t *testing.T) {
c, clk := newTestCache[string, *int](t, 4, time.Minute)
calls := 0
newVal := func() *int {
calls++
v := calls
return &v
}
first := c.GetOrCreate("a", newVal)
second := c.GetOrCreate("a", newVal)
if first != second {
t.Error("GetOrCreate must return the same value for a live entry")
}
if calls != 1 {
t.Errorf("newVal called %d times, want 1", calls)
}
clk.advance(2 * time.Minute)
third := c.GetOrCreate("a", newVal)
if third == first {
t.Error("an expired entry must be replaced")
}
if calls != 2 {
t.Errorf("newVal called %d times, want 2", calls)
}
}
// Two callers racing on the same key must end up sharing one value — the rate
// limiter relies on this to avoid handing every request its own token bucket.
func TestGetOrCreateIsAtomic(t *testing.T) {
c := New[string, *int](64, time.Minute)
const goroutines = 32
var wg sync.WaitGroup
got := make([]*int, goroutines)
start := make(chan struct{})
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start
got[i] = c.GetOrCreate("shared", func() *int { v := 0; return &v })
}(i)
}
close(start)
wg.Wait()
for i := 1; i < goroutines; i++ {
if got[i] != got[0] {
t.Fatalf("goroutine %d got a different value", i)
}
}
}
func TestConcurrentUse(t *testing.T) {
c := New[string, int](16, time.Minute)
var wg sync.WaitGroup
for g := 0; g < 8; g++ {
wg.Add(1)
go func(g int) {
defer wg.Done()
for i := 0; i < 500; i++ {
k := fmt.Sprintf("k%d", i%40)
c.Put(k, i)
c.Get(k)
if i%10 == 0 {
c.Delete(k)
}
c.GetOrCreate(k, func() int { return g })
}
}(g)
}
wg.Wait()
if c.Len() > 16 {
t.Errorf("Len = %d, want <= 16", c.Len())
}
}
func TestNewClampsMax(t *testing.T) {
c := New[string, int](0, time.Minute)
c.Put("a", 1)
c.Put("b", 2)
if c.Len() != 1 {
t.Errorf("Len = %d, want 1", c.Len())
}
}