224 lines
4.9 KiB
Go
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())
|
|
}
|
|
}
|