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) } }