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)
This commit is contained in:
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -131,3 +133,74 @@ func TestRedisCacheDocker(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
171
modules/cooler/pull_test.go
Normal file
171
modules/cooler/pull_test.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package cooler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// pullRace sets key to want, lets pullers goroutines Pull it at once behind
|
||||
// a start barrier and returns how many saw found=true. Any error, a winner
|
||||
// with the wrong bytes or a loser with a value fails the test.
|
||||
func pullRace(t *testing.T, c Cache, key string, want []byte, pullers int) int32 {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
if err := c.Set(ctx, key, 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 && !bytes.Equal(v, want):
|
||||
errs <- fmt.Errorf("winner got %q, want %q", v, want)
|
||||
case ok:
|
||||
winners.Add(1)
|
||||
case v != nil:
|
||||
errs <- fmt.Errorf("a loser got the value %q", v)
|
||||
}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return winners.Load()
|
||||
}
|
||||
|
||||
// TestPullConcurrent: 64 concurrent Pulls of one key yield exactly one
|
||||
// found=true with the stored bytes, and the key is gone afterwards (D-08),
|
||||
// on the memory driver and on Redis (miniredis), 20 rounds each.
|
||||
func TestPullConcurrent(t *testing.T) {
|
||||
const pullers, rounds = 64, 20
|
||||
run := func(t *testing.T, c Cache) {
|
||||
t.Helper()
|
||||
for round := range rounds {
|
||||
key := "code-" + strconv.Itoa(round)
|
||||
want := []byte("one-time-value-" + strconv.Itoa(round))
|
||||
if n := pullRace(t, c, key, want, pullers); n != 1 {
|
||||
t.Fatalf("round %d: %d of %d Pulls found the value, want exactly 1", round, n, pullers)
|
||||
}
|
||||
if v, ok, err := c.Get(t.Context(), key); err != nil || ok || v != nil {
|
||||
t.Fatalf("round %d: Get after the race = %q %v %v, want nil false nil", round, v, ok, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Run("memory", func(t *testing.T) {
|
||||
run(t, newMemory(t, "acme:", DefaultMaxEntries))
|
||||
})
|
||||
t.Run("miniredis", func(t *testing.T) {
|
||||
_, client := miniClient(t)
|
||||
run(t, NewRedisCache(client, "acme:"))
|
||||
})
|
||||
}
|
||||
|
||||
// TestPullExpired: an expired value is never returned and is gone after the
|
||||
// Pull, on both drivers.
|
||||
func TestPullExpired(t *testing.T) {
|
||||
t.Run("memory", func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
m := newMemory(t, "acme:", DefaultMaxEntries)
|
||||
if err := m.Set(ctx, "short", []byte("x"), 30*time.Millisecond); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
if v, ok, err := m.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)
|
||||
}
|
||||
if _, ok := m.c.GetIfPresent(m.key("short")); ok {
|
||||
t.Fatal("the expired entry is still stored after Pull")
|
||||
}
|
||||
// An entry stored already past its expiry (the eviction has not run
|
||||
// yet) is refused and removed by Pull itself.
|
||||
m.c.Set(m.key("stale"), memEntry{val: []byte("old"), expiresAt: time.Now().Add(-time.Second)})
|
||||
if v, ok, err := m.Pull(ctx, "stale"); err != nil || ok || v != nil {
|
||||
t.Fatalf("Pull of a stale entry = %q %v %v, want nil false nil", v, ok, err)
|
||||
}
|
||||
if _, ok := m.c.GetIfPresent(m.key("stale")); ok {
|
||||
t.Fatal("Pull left the stale entry stored")
|
||||
}
|
||||
if v, ok, err := m.Get(ctx, "stale"); err != nil || ok || v != nil {
|
||||
t.Fatalf("Get after Pull = %q %v %v", v, ok, err)
|
||||
}
|
||||
})
|
||||
t.Run("miniredis", func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
mr, client := miniClient(t)
|
||||
c := NewRedisCache(client, "acme:")
|
||||
if err := c.Set(ctx, "short", []byte("x"), time.Second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mr.FastForward(2 * time.Second)
|
||||
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)
|
||||
}
|
||||
if mr.Exists("acme:cache:short") {
|
||||
t.Fatal("the expired key still exists after Pull")
|
||||
}
|
||||
// A live key with a TTL is returned once and removed.
|
||||
if err := c.Set(ctx, "live", []byte("y"), time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v, ok, err := c.Pull(ctx, "live"); err != nil || !ok || string(v) != "y" {
|
||||
t.Fatalf("Pull of a live key = %q %v %v", v, ok, err)
|
||||
}
|
||||
if mr.Exists("acme:cache:live") {
|
||||
t.Fatal("Pull did not remove the live key")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPullPrefixIsolation: two applications with different prefixes share
|
||||
// one Redis; Pull on one removes only its own <prefix>cache:<key>, never the
|
||||
// other's key nor a key outside its cache namespace.
|
||||
func TestPullPrefixIsolation(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
mr, client := miniClient(t)
|
||||
first := NewRedisCache(client, "first:")
|
||||
second := NewRedisCache(client, "second:")
|
||||
for _, c := range []Cache{first, second} {
|
||||
if err := c.Set(ctx, "code", []byte("v"), time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := mr.Set("first:rl:code", "keep"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v, ok, err := first.Pull(ctx, "code"); err != nil || !ok || string(v) != "v" {
|
||||
t.Fatalf("first Pull = %q %v %v", v, ok, err)
|
||||
}
|
||||
if mr.Exists("first:cache:code") {
|
||||
t.Fatal("Pull did not remove first:cache:code")
|
||||
}
|
||||
for _, k := range []string{"second:cache:code", "first:rl:code"} {
|
||||
if !mr.Exists(k) {
|
||||
t.Fatalf("Pull on the first prefix removed %s", k)
|
||||
}
|
||||
}
|
||||
if v, ok, err := second.Pull(ctx, "code"); err != nil || !ok || string(v) != "v" {
|
||||
t.Fatalf("second Pull = %q %v %v", v, ok, err)
|
||||
}
|
||||
if v, ok, err := first.Pull(ctx, "code"); err != nil || ok || v != nil {
|
||||
t.Fatalf("first Pull again = %q %v %v, want nil false nil", v, ok, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user