- 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
337 lines
10 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|