From 671d388ca154792e8f0a32f6b71b677cc87e8254 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 21 Oct 2026 00:45:33 +0200 Subject: [PATCH] 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 cache: - TestPullDocker: the same on redis:7-alpine (GETDEL) --- modules/cooler/docker_test.go | 73 +++++++++++++++ modules/cooler/pull_test.go | 171 ++++++++++++++++++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 modules/cooler/pull_test.go diff --git a/modules/cooler/docker_test.go b/modules/cooler/docker_test.go index 1b808d28..7e64929b 100644 --- a/modules/cooler/docker_test.go +++ b/modules/cooler/docker_test.go @@ -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) + } + } +} diff --git a/modules/cooler/pull_test.go b/modules/cooler/pull_test.go new file mode 100644 index 00000000..07855285 --- /dev/null +++ b/modules/cooler/pull_test.go @@ -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 cache:, 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) + } +}