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 cache:. 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) } }) }