- 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)
207 lines
6.1 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|