Files
summercms/modules/cooler/memory_test.go
Jakub Zych 78ba385793 test(19.3-05): cooler caches, rate-limit parity, Install and after-commit purge
- miniredis v2.40.0 as a test-only dependency (approved at the Task 4 check)
- memory and Redis cache suites, Flush limited to <prefix>cache:
- one parity table through surf.MemoryStore, miniredis and real Redis
- Install, FromApp and site_cache validation; Postgres after-commit purge
- gate: cooler names in the go and docker stages
2026-10-15 19:49:34 +02:00

337 lines
10 KiB
Go

package cooler
import (
"context"
"errors"
"log/slog"
"strconv"
"strings"
"sync"
"testing"
"time"
)
func newMemory(t *testing.T, prefix string, maxEntries int) *memoryCache {
t.Helper()
c, err := NewMemoryCache(prefix, maxEntries)
if err != nil {
t.Fatal(err)
}
m, ok := c.(*memoryCache)
if !ok {
t.Fatalf("NewMemoryCache returned %T", c)
}
return m
}
func TestMemoryCache(t *testing.T) {
ctx := t.Context()
m := newMemory(t, "acme:", DefaultMaxEntries)
if v, ok, err := m.Get(ctx, "missing"); v != nil || ok || err != nil {
t.Fatalf("Get(missing) = %q %v %v", v, ok, err)
}
if ok, err := m.Has(ctx, "missing"); ok || err != nil {
t.Fatalf("Has(missing) = %v %v", ok, err)
}
if err := m.Forget(ctx, "missing"); err != nil {
t.Fatalf("Forget(missing) = %v", err)
}
// Keys live under <prefix>cache:<key>.
if err := m.Set(ctx, "greeting", []byte("hello"), time.Minute); err != nil {
t.Fatal(err)
}
if _, ok := m.c.GetIfPresent("acme:cache:greeting"); !ok {
t.Fatal("Set did not store acme:cache:greeting")
}
if v, ok, err := m.Get(ctx, "greeting"); err != nil || !ok || string(v) != "hello" {
t.Fatalf("Get = %q %v %v", v, ok, err)
}
// ttl 0 and a negative ttl keep the value without expiry.
for _, ttl := range []time.Duration{0, -time.Second} {
key := "forever" + ttl.String()
if err := m.Set(ctx, key, []byte("x"), ttl); err != nil {
t.Fatal(err)
}
e, ok := m.c.GetIfPresent(m.key(key))
if !ok || !e.expiresAt.IsZero() {
t.Fatalf("ttl %s: entry %+v found=%v, want no expiry", ttl, e, ok)
}
}
// A nil value is stored and read back as found.
if err := m.Set(ctx, "nil", nil, 0); err != nil {
t.Fatal(err)
}
if _, ok, err := m.Get(ctx, "nil"); !ok || err != nil {
t.Fatalf("Get(nil value) found=%v err=%v", ok, err)
}
// Copy-on-set and copy-on-get.
in := []byte("abc")
_ = m.Set(ctx, "copy", in, 0)
in[0] = 'X'
out, _, _ := m.Get(ctx, "copy")
out[1] = 'Y'
if again, _, _ := m.Get(ctx, "copy"); string(again) != "abc" {
t.Fatalf("stored value mutated: %q", again)
}
// Increment: missing starts at 0, existing integer grows, a non-integer fails.
if n, err := m.Increment(ctx, "hits", 5); err != nil || n != 5 {
t.Fatalf("Increment(missing) = %d %v", n, err)
}
if n, err := m.Increment(ctx, "hits", -2); err != nil || n != 3 {
t.Fatalf("Increment(-2) = %d %v", n, err)
}
if n, err := m.Increment(ctx, "greeting", 1); err == nil || n != 0 || !strings.Contains(err.Error(), "greeting") {
t.Fatalf("Increment(non-integer) = %d %v, want an error naming the key", n, err)
}
if v, _, _ := m.Get(ctx, "greeting"); string(v) != "hello" {
t.Fatalf("a failed Increment changed the value: %q", v)
}
// Remember stores the computed value; an error from fn is returned.
calls := 0
fn := func(context.Context) ([]byte, error) { calls++; return []byte("computed"), nil }
for range 3 {
if v, err := m.Remember(ctx, "remembered", time.Minute, fn); err != nil || string(v) != "computed" {
t.Fatalf("Remember = %q %v", v, err)
}
}
if calls != 1 {
t.Fatalf("callback ran %d times, want 1", calls)
}
boom := errors.New("boom")
if v, err := m.Remember(ctx, "failing", time.Minute, func(context.Context) ([]byte, error) { return nil, boom }); v != nil || !errors.Is(err, boom) {
t.Fatalf("Remember(failing fn) = %q %v", v, err)
}
if ok, _ := m.Has(ctx, "failing"); ok {
t.Fatal("a failing callback stored a value")
}
if _, err := m.Remember(ctx, "nilfn", time.Minute, nil); err == nil {
t.Fatal("Remember with a nil callback must fail")
}
// Forget and Flush.
if err := m.Forget(ctx, "greeting"); err != nil {
t.Fatal(err)
}
if ok, _ := m.Has(ctx, "greeting"); ok {
t.Fatal("Forget left the key")
}
if err := m.Flush(ctx); err != nil {
t.Fatal(err)
}
for _, k := range []string{"hits", "remembered", "copy", "forever0s"} {
if ok, _ := m.Has(ctx, k); ok {
t.Fatalf("Flush left %q", k)
}
}
if _, err := NewMemoryCache("acme:", -1); err == nil || !strings.Contains(err.Error(), CacheMaxEntriesKey) {
t.Fatalf("NewMemoryCache(-1) = %v, want an error naming %s", err, CacheMaxEntriesKey)
}
}
func TestMemoryCacheTTL(t *testing.T) {
ctx := t.Context()
m := newMemory(t, "acme:", DefaultMaxEntries)
if err := m.Set(ctx, "short", []byte("x"), 40*time.Millisecond); err != nil {
t.Fatal(err)
}
if ok, _ := m.Has(ctx, "short"); !ok {
t.Fatal("a fresh 40 ms entry is missing")
}
time.Sleep(80 * time.Millisecond)
if ok, _ := m.Has(ctx, "short"); ok {
t.Fatal("a 40 ms entry outlived 80 ms")
}
if _, ok, _ := m.Get(ctx, "short"); ok {
t.Fatal("Get returned an expired entry")
}
// Increment on an existing key keeps its expiry, as INCRBY does.
if err := m.Set(ctx, "counter", []byte("5"), 60*time.Millisecond); err != nil {
t.Fatal(err)
}
before, _ := m.c.GetIfPresent(m.key("counter"))
if n, err := m.Increment(ctx, "counter", 1); err != nil || n != 6 {
t.Fatalf("Increment = %d %v", n, err)
}
after, _ := m.c.GetIfPresent(m.key("counter"))
if !after.expiresAt.Equal(before.expiresAt) {
t.Fatalf("Increment moved the expiry from %v to %v", before.expiresAt, after.expiresAt)
}
time.Sleep(100 * time.Millisecond)
if ok, _ := m.Has(ctx, "counter"); ok {
t.Fatal("an incremented entry outlived its TTL")
}
// An expired key restarts at 0 without a TTL.
if n, err := m.Increment(ctx, "counter", 2); err != nil || n != 2 {
t.Fatalf("Increment after expiry = %d %v, want 2", n, err)
}
if e, _ := m.c.GetIfPresent(m.key("counter")); !e.expiresAt.IsZero() {
t.Fatalf("a restarted counter has expiry %v, want none", e.expiresAt)
}
}
func TestMemoryCacheBounded(t *testing.T) {
ctx := t.Context()
const limit = 50
m := newMemory(t, "acme:", limit)
for i := range 5000 {
if err := m.Set(ctx, "k"+strconv.Itoa(i), []byte("v"), 0); err != nil {
t.Fatal(err)
}
}
m.c.CleanUp()
if n := m.c.EstimatedSize(); n > limit {
t.Fatalf("cache holds %d entries, bound is %d", n, limit)
}
live := 0
for i := range 5000 {
if ok, _ := m.Has(ctx, "k"+strconv.Itoa(i)); ok {
live++
}
}
if live == 0 || live > limit {
t.Fatalf("%d live keys, want between 1 and %d", live, limit)
}
}
func TestMemoryIncrementConcurrent(t *testing.T) {
ctx := t.Context()
m := newMemory(t, "acme:", DefaultMaxEntries)
if err := m.Set(ctx, "views", []byte("0"), time.Hour); err != nil {
t.Fatal(err)
}
before, _ := m.c.GetIfPresent(m.key("views"))
const workers, each = 100, 10
var wg sync.WaitGroup
start := make(chan struct{})
errs := make(chan error, workers*each)
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
<-start
for range each {
if _, err := m.Increment(ctx, "views", 1); err != nil {
errs <- err
}
}
}()
}
close(start)
wg.Wait()
close(errs)
for err := range errs {
t.Fatal(err)
}
v, ok, err := m.Get(ctx, "views")
if err != nil || !ok || string(v) != strconv.Itoa(workers*each) {
t.Fatalf("views = %q %v %v, want %d", v, ok, err, workers*each)
}
after, _ := m.c.GetIfPresent(m.key("views"))
if !after.expiresAt.Equal(before.expiresAt) {
t.Fatalf("concurrent increments moved the expiry from %v to %v", before.expiresAt, after.expiresAt)
}
}
// failingCache is a Cache whose reads and writes fail as configured.
type failingCache struct {
Cache
getErr, setErr error
sets int
}
func (f *failingCache) Get(context.Context, string) ([]byte, bool, error) {
return nil, false, f.getErr
}
func (f *failingCache) Set(context.Context, string, []byte, time.Duration) error {
f.sets++
return f.setErr
}
func TestRememberDegrades(t *testing.T) {
ctx := t.Context()
value := func(context.Context) ([]byte, error) { return []byte("fresh"), nil }
t.Run("a failing read computes, skips the write and warns once", func(t *testing.T) {
buf := &syncBuffer{}
w := newWarner(slog.New(slog.NewTextHandler(buf, nil)))
f := &failingCache{getErr: errors.New("connection refused")}
for range 3 {
v, err := remember(ctx, f, w, "k", time.Minute, value)
if err != nil || string(v) != "fresh" {
t.Fatalf("remember = %q %v", v, err)
}
}
if f.sets != 0 {
t.Fatalf("a failed read still wrote %d times", f.sets)
}
lines := buf.lines()
if len(lines) != 1 || !strings.Contains(lines[0], "level=WARN") || !strings.Contains(lines[0], "cache read failed") {
t.Fatalf("log = %q, want one read warning", lines)
}
})
t.Run("a failing write returns the value and warns once", func(t *testing.T) {
buf := &syncBuffer{}
w := newWarner(slog.New(slog.NewTextHandler(buf, nil)))
f := &failingCache{setErr: errors.New("read-only replica")}
for range 3 {
v, err := remember(ctx, f, w, "k", time.Minute, value)
if err != nil || string(v) != "fresh" {
t.Fatalf("remember = %q %v", v, err)
}
}
if f.sets != 3 {
t.Fatalf("%d writes, want 3", f.sets)
}
lines := buf.lines()
if len(lines) != 1 || !strings.Contains(lines[0], "cache write failed") {
t.Fatalf("log = %q, want one write warning", lines)
}
})
t.Run("an error from the callback is returned", func(t *testing.T) {
boom := errors.New("boom")
f := &failingCache{getErr: errors.New("down")}
w := newWarner(slog.New(slog.NewTextHandler(&syncBuffer{}, nil)))
if v, err := remember(ctx, f, w, "k", time.Minute, func(context.Context) ([]byte, error) { return nil, boom }); v != nil || !errors.Is(err, boom) {
t.Fatalf("remember = %q %v", v, err)
}
})
t.Run("a Redis cache whose server is gone still returns the value", func(t *testing.T) {
buf := &syncBuffer{}
client, err := OpenRedis(ctx, "redis://"+closedAddr(t)+"/0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = client.Close() })
c := NewRedisCache(client, "acme:")
setLogger(c, slog.New(slog.NewTextHandler(buf, nil)))
for range 2 {
// The caller's deadline bounds go-redis's dial retries.
cctx, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
v, err := c.Remember(cctx, "k", time.Minute, value)
cancel()
if err != nil || string(v) != "fresh" {
t.Fatalf("Remember = %q %v", v, err)
}
}
if lines := buf.lines(); len(lines) != 1 || !strings.Contains(lines[0], "cache read failed") {
t.Fatalf("log = %q, want one read warning", lines)
}
})
}