Files
kandev/apps/backend/internal/github/ttl_cache_test.go
T
2026-07-24 15:55:57 +01:00

331 lines
8.8 KiB
Go

package github
import (
"errors"
"fmt"
"sync"
"sync/atomic"
"testing"
"testing/synctest"
"time"
)
func TestTTLCache_HitAndMiss(t *testing.T) {
cache := newTTLCache()
var calls int
fetch := func() (any, error) {
calls++
return "result", nil
}
for i := 0; i < 3; i++ {
v, err := cache.doOrFetch("k", fetch)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if v != "result" {
t.Fatalf("unexpected value: %v", v)
}
}
if calls != 1 {
t.Fatalf("expected fetch called once, got %d", calls)
}
}
func TestTTLCache_Expiry(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
cache := newTTLCache()
cache.ttl = 30 * time.Second
var calls int
fetch := func() (any, error) {
calls++
return calls, nil
}
if _, err := cache.doOrFetch("k", fetch); err != nil {
t.Fatal(err)
}
time.Sleep(29 * time.Second)
if _, err := cache.doOrFetch("k", fetch); err != nil {
t.Fatal(err)
}
if calls != 1 {
t.Fatalf("expected cache hit before TTL; calls=%d", calls)
}
time.Sleep(2 * time.Second)
v, err := cache.doOrFetch("k", fetch)
if err != nil {
t.Fatal(err)
}
if calls != 2 {
t.Fatalf("expected refetch after TTL; calls=%d", calls)
}
if v != 2 {
t.Fatalf("expected fresh value 2, got %v", v)
}
})
}
func TestTTLCache_ErrorNotCached(t *testing.T) {
cache := newTTLCache()
var calls int
sentinel := errors.New("boom")
fetch := func() (any, error) {
calls++
if calls == 1 {
return nil, sentinel
}
return "ok", nil
}
if _, err := cache.doOrFetch("k", fetch); !errors.Is(err, sentinel) {
t.Fatalf("expected sentinel error, got %v", err)
}
v, err := cache.doOrFetch("k", fetch)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if v != "ok" {
t.Fatalf("unexpected value: %v", v)
}
if calls != 2 {
t.Fatalf("expected 2 fetch calls (error not cached), got %d", calls)
}
}
func TestSearchCacheKey_KeyIsolation(t *testing.T) {
if searchCacheKey("pr", "", "is:open", 1, 25) == searchCacheKey("issue", "", "is:open", 1, 25) {
t.Fatal("kind should be part of key")
}
if searchCacheKey("pr", "", "is:open", 1, 25) == searchCacheKey("pr", "", "is:open", 2, 25) {
t.Fatal("page should be part of key")
}
if searchCacheKey("pr", "", "is:open", 1, 25) == searchCacheKey("pr", "", "is:closed", 1, 25) {
t.Fatal("customQuery should be part of key")
}
// A user-controllable customQuery must not be able to collide with
// adjacent fields by embedding the separator.
collisions := [][2][5]any{
{{"pr", "a", "b", 1, 25}, {"pr", "a|b", "", 1, 25}},
{{"pr", "", "a|b|1|25", 0, 0}, {"pr", "a", "b", 1, 25}},
}
for _, c := range collisions {
a := searchCacheKey(c[0][0].(string), c[0][1].(string), c[0][2].(string), c[0][3].(int), c[0][4].(int))
b := searchCacheKey(c[1][0].(string), c[1][1].(string), c[1][2].(string), c[1][3].(int), c[1][4].(int))
if a == b {
t.Fatalf("separator collision: %q == %q", a, b)
}
}
}
func TestTTLCache_Singleflight(t *testing.T) {
cache := newTTLCache()
var calls atomic.Int32
release := make(chan struct{})
started := make(chan struct{}, 1)
fetch := func() (any, error) {
calls.Add(1)
select {
case started <- struct{}{}:
default:
}
<-release
return "v", nil
}
const n = 8
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
go func() {
defer wg.Done()
if _, err := cache.doOrFetch("k", fetch); err != nil {
t.Errorf("fetch err: %v", err)
}
}()
}
<-started
close(release)
wg.Wait()
if got := calls.Load(); got != 1 {
t.Fatalf("expected singleflight to coalesce; calls=%d", got)
}
}
// Regression: a clear() that races with an in-flight singleflight fetch must
// invalidate the late write so the cache stays empty afterward. Otherwise a
// token swap (the user-facing trigger for clear()) could surface the
// previous user's repos for up to one TTL window — the cleared entries get
// re-populated by the still-pending fetch from the old generation.
func TestTTLCache_ClearInvalidatesInFlightFetch(t *testing.T) {
cache := newTTLCache()
fetchStarted := make(chan struct{})
releaseFetch := make(chan struct{})
fetchDone := make(chan struct{})
go func() {
_, _ = cache.doOrFetch("k", func() (any, error) {
close(fetchStarted)
<-releaseFetch
return "stale", nil
})
close(fetchDone)
}()
<-fetchStarted
cache.clear()
close(releaseFetch)
<-fetchDone
if v, ok := cache.get("k"); ok {
t.Fatalf("expected cache to remain empty after clear(); got %v", v)
}
}
// Regression: a caller arriving after clear() must not join a still-in-flight
// fetch from the previous generation via singleflight's shared result. Before
// keying singleflight by generation, B's doOrFetch joined A's in-flight call
// and received A's stale value — the post-clear cache stayed empty, but the
// caller still saw old data. After the fix, B mints its own fetch under the
// new generation.
func TestTTLCache_ClearMakesPostClearCallersMintNewFetch(t *testing.T) {
cache := newTTLCache()
var calls atomic.Int32
fetchAStarted := make(chan struct{})
releaseA := make(chan struct{})
// Caller A: starts a fetch, blocks on releaseA, eventually returns "A".
resultA := make(chan any, 1)
go func() {
v, _ := cache.doOrFetch("k", func() (any, error) {
calls.Add(1)
close(fetchAStarted)
<-releaseA
return "A", nil
})
resultA <- v
}()
<-fetchAStarted
// Clear races between A's fetch starting and finishing. After this, any
// new caller must NOT receive A's value via singleflight.
cache.clear()
// Caller B arrives after clear() while A is still in flight. B's fetch
// returns "B" immediately (it does not block on releaseA), so under the
// generation-keyed singleflight B must mint its own fetch and resolve right
// away rather than joining A's still-blocked call.
resultB := make(chan any, 1)
go func() {
v, _ := cache.doOrFetch("k", func() (any, error) {
calls.Add(1)
return "B", nil
})
resultB <- v
}()
// Block on B's result with a timeout guard instead of a sleep busy-loop. If
// generation-keyed singleflight regressed (B joining A's blocked fetch), B
// would never resolve and the timeout fires deterministically.
select {
case v := <-resultB:
if v != "B" {
t.Fatalf("caller B got %v, expected 'B' (joined A's stale fetch?)", v)
}
case <-time.After(2 * time.Second):
t.Fatal("caller B never completed — generation-keyed singleflight may still be blocking")
}
// Now let A finish; its write must be dropped via the generation guard.
close(releaseA)
<-resultA
if v, ok := cache.get("k"); ok {
// We expect either empty (A's setIfCurrentGeneration was a no-op AND B
// fetched but landed under the new generation) or "B" — never "A".
if v == "A" {
t.Fatalf("stale A result leaked into cache: %v", v)
}
}
if got := calls.Load(); got < 2 {
t.Fatalf("expected at least 2 fetches (A then B); got %d", got)
}
}
func TestTTLCache_MaxSizeEviction(t *testing.T) {
cache := newTTLCache()
cache.maxSize = 3
for i := 0; i < 5; i++ {
cache.set(fmt.Sprintf("k%d", i), i)
}
if len(cache.entries) > cache.maxSize {
t.Fatalf("cache exceeded maxSize: %d", len(cache.entries))
}
}
func TestTTLCache_InvalidateIdleKeysDoesNotRetainEpochMetadata(t *testing.T) {
cache := newTTLCache()
for i := 0; i < 1000; i++ {
cache.invalidateKey(fmt.Sprintf("pr-%d", i))
}
if got := len(cache.keyEpochs); got != 0 {
t.Fatalf("idle invalidation retained %d epoch entries, want 0", got)
}
}
func TestTTLCache_InvalidateInFlightFillReclaimsEpochMetadata(t *testing.T) {
cache := newTTLCache()
oldStarted := make(chan struct{})
freshStarted := make(chan struct{})
unrelatedStarted := make(chan struct{})
releaseOld := make(chan struct{})
releaseFresh := make(chan struct{})
releaseUnrelated := make(chan struct{})
oldDone := make(chan struct{})
freshDone := make(chan struct{})
unrelatedDone := make(chan struct{})
go func() {
defer close(oldDone)
_, _ = cache.doOrFetch("affected", func() (any, error) {
close(oldStarted)
<-releaseOld
return "stale", nil
})
}()
go func() {
defer close(unrelatedDone)
_, _ = cache.doOrFetch("unrelated", func() (any, error) {
close(unrelatedStarted)
<-releaseUnrelated
return "unrelated", nil
})
}()
<-oldStarted
<-unrelatedStarted
cache.invalidateKey("affected")
go func() {
defer close(freshDone)
_, _ = cache.doOrFetch("affected", func() (any, error) {
close(freshStarted)
<-releaseFresh
return "fresh", nil
})
}()
<-freshStarted
close(releaseFresh)
<-freshDone
close(releaseOld)
close(releaseUnrelated)
<-oldDone
<-unrelatedDone
if value, ok := cache.get("affected"); !ok || value != "fresh" {
t.Fatalf("affected cache = %v, %t; want fresh, true", value, ok)
}
if value, ok := cache.get("unrelated"); !ok || value != "unrelated" {
t.Fatalf("unrelated cache = %v, %t; want unrelated, true", value, ok)
}
if got := len(cache.keyEpochs); got != 0 {
t.Fatalf("completed fills retained %d epoch entries, want 0", got)
}
}