Files
summercms/modules/cooler/docker_test.go
Jakub Zych 671d388ca1 test(cooler): Pull concurrency, expiry and prefix isolation
- TestPullConcurrent: 64 concurrent Pulls of one key, one winner, 20 rounds,
  on memory and miniredis
- TestPullExpired: an expired value is never returned and is gone afterwards
- TestPullPrefixIsolation: Pull touches only its own <prefix>cache:<key>
- TestPullDocker: the same on redis:7-alpine (GETDEL)
2026-10-21 00:45:33 +02:00

207 lines
6.1 KiB
Go

package cooler_test
import (
"context"
"fmt"
"strconv"
"sync"
"sync/atomic"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/cooler"
"github.com/redis/go-redis/v9"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
// realRedis starts redis:7-alpine for the test and returns a client from
// OpenRedis. It skips under -short.
func realRedis(t *testing.T) *redis.Client {
t.Helper()
if testing.Short() {
t.Skip("requires testcontainers redis")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
ctr, err := testcontainers.Run(ctx, "redis:7-alpine",
testcontainers.WithExposedPorts("6379/tcp"),
testcontainers.WithWaitStrategy(wait.ForListeningPort("6379/tcp")),
)
if err != nil {
t.Fatalf("redis: testcontainers: %v", err)
}
t.Cleanup(func() { _ = testcontainers.TerminateContainer(ctr) })
host, err := ctr.Host(ctx)
if err != nil {
t.Fatal(err)
}
port, err := ctr.MappedPort(ctx, "6379/tcp")
if err != nil {
t.Fatal(err)
}
client, err := cooler.OpenRedis(ctx, fmt.Sprintf("redis://%s:%s/0", host, port.Port()))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = client.Close() })
if err := client.Ping(ctx).Err(); err != nil {
t.Fatalf("redis ping: %v", err)
}
return client
}
func TestRateLimitParityDocker(t *testing.T) {
client := realRedis(t)
store := cooler.NewRateLimitStore(client, "acme:", nil)
runParity(t, parityStore{name: "redis", store: store, advance: sleepFor})
if got := countAdmitted(cooler.NewRateLimitStore(client, "burst:", nil), 50, 10); got != 10 {
t.Fatalf("real Redis admitted %d of 50, want 10", got)
}
ctx := t.Context()
if v, err := client.Get(ctx, "burst:rl:burst").Result(); err != nil || v != "10" {
t.Fatalf("burst counter = %q %v, want 10", v, err)
}
if ttl, err := client.PTTL(ctx, "burst:rl:burst").Result(); err != nil || ttl <= 0 || ttl > time.Minute {
t.Fatalf("burst window TTL = %s %v", ttl, err)
}
}
func TestRedisCacheDocker(t *testing.T) {
client := realRedis(t)
ctx := t.Context()
c := cooler.NewRedisCache(client, "acme:")
if err := c.Set(ctx, "greeting", []byte("hello"), 150*time.Millisecond); err != nil {
t.Fatal(err)
}
if v, ok, err := c.Get(ctx, "greeting"); err != nil || !ok || string(v) != "hello" {
t.Fatalf("Get = %q %v %v", v, ok, err)
}
if ttl, _ := client.PTTL(ctx, "acme:cache:greeting").Result(); ttl <= 0 || ttl > 150*time.Millisecond {
t.Fatalf("PTTL = %s, want (0, 150ms]", ttl)
}
time.Sleep(250 * time.Millisecond)
if ok, _ := c.Has(ctx, "greeting"); ok {
t.Fatal("the key outlived its TTL")
}
if err := c.Set(ctx, "views", []byte("1"), time.Minute); err != nil {
t.Fatal(err)
}
if n, err := c.Increment(ctx, "views", 2); err != nil || n != 3 {
t.Fatalf("Increment = %d %v", n, err)
}
if ttl, _ := client.PTTL(ctx, "acme:cache:views").Result(); ttl <= 50*time.Second {
t.Fatalf("Increment dropped the TTL: %s", ttl)
}
calls := 0
for range 2 {
v, err := c.Remember(ctx, "remembered", time.Minute, func(context.Context) ([]byte, error) { calls++; return []byte("computed"), nil })
if err != nil || string(v) != "computed" {
t.Fatalf("Remember = %q %v", v, err)
}
}
if calls != 1 {
t.Fatalf("Remember called the callback %d times", calls)
}
// Flush walks SCAN in batches: more keys than one batch, and the keys
// outside <prefix>cache: survive.
pipe := client.Pipeline()
for i := range 1200 {
pipe.Set(ctx, "acme:cache:bulk"+strconv.Itoa(i), "v", 0)
}
survivors := []string{"acme:rl:login|192.0.2.1", "other:cache:greeting", "acme:cachex"}
for _, k := range survivors {
pipe.Set(ctx, k, "keep", 0)
}
if _, err := pipe.Exec(ctx); err != nil {
t.Fatal(err)
}
if err := c.Flush(ctx); err != nil {
t.Fatal(err)
}
left, err := client.Keys(ctx, "acme:cache:*").Result()
if err != nil || len(left) != 0 {
t.Fatalf("Flush left %d cache keys (%v)", len(left), err)
}
for _, k := range survivors {
if n, err := client.Exists(ctx, k).Result(); err != nil || n != 1 {
t.Fatalf("Flush removed %q", k)
}
}
}
// TestPullDocker runs cooler.Cache.Pull against redis:7-alpine (GETDEL
// needs Redis 6.2 or newer): a value is found once, a second Pull finds
// nothing, an expired key is never returned, and 64 concurrent Pulls of one
// key have exactly one winner, 10 rounds (D-08).
func TestPullDocker(t *testing.T) {
client := realRedis(t)
ctx := t.Context()
c := cooler.NewRedisCache(client, "acme:")
if err := c.Set(ctx, "code", []byte("v"), time.Minute); err != nil {
t.Fatal(err)
}
if v, ok, err := c.Pull(ctx, "code"); err != nil || !ok || string(v) != "v" {
t.Fatalf("first Pull = %q %v %v", v, ok, err)
}
if v, ok, err := c.Pull(ctx, "code"); err != nil || ok || v != nil {
t.Fatalf("second Pull = %q %v %v, want nil false nil", v, ok, err)
}
if n, err := client.Exists(ctx, "acme:cache:code").Result(); err != nil || n != 0 {
t.Fatalf("the key survived Pull (%d, %v)", n, err)
}
if err := c.Set(ctx, "short", []byte("x"), 100*time.Millisecond); err != nil {
t.Fatal(err)
}
time.Sleep(200 * time.Millisecond)
if v, ok, err := c.Pull(ctx, "short"); err != nil || ok || v != nil {
t.Fatalf("Pull of an expired key = %q %v %v, want nil false nil", v, ok, err)
}
const pullers, rounds = 64, 10
for round := range rounds {
key := "race-" + strconv.Itoa(round)
want := "one-time-" + strconv.Itoa(round)
if err := c.Set(ctx, key, []byte(want), time.Minute); err != nil {
t.Fatal(err)
}
var (
wg sync.WaitGroup
winners atomic.Int32
)
start := make(chan struct{})
errs := make(chan error, pullers)
for range pullers {
wg.Add(1)
go func() {
defer wg.Done()
<-start
v, ok, err := c.Pull(ctx, key)
switch {
case err != nil:
errs <- err
case ok && string(v) != want:
errs <- fmt.Errorf("winner got %q, want %q", v, want)
case ok:
winners.Add(1)
}
}()
}
close(start)
wg.Wait()
close(errs)
for err := range errs {
t.Fatal(err)
}
if n := winners.Load(); n != 1 {
t.Fatalf("round %d: %d of %d Pulls found the value, want exactly 1", round, n, pullers)
}
}
}