chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
626
internal/readmodelcache/cache_test.go
Normal file
626
internal/readmodelcache/cache_test.go
Normal file
|
|
@ -0,0 +1,626 @@
|
|||
package readmodelcache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeClock 让 TTL 行为可确定地推进,无需 sleep。
|
||||
type fakeClock struct {
|
||||
mu sync.Mutex
|
||||
t time.Time
|
||||
}
|
||||
|
||||
func newFakeClock() *fakeClock { return &fakeClock{t: time.Unix(1_700_000_000, 0)} }
|
||||
|
||||
func (f *fakeClock) now() time.Time {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.t
|
||||
}
|
||||
|
||||
func (f *fakeClock) advance(d time.Duration) {
|
||||
f.mu.Lock()
|
||||
f.t = f.t.Add(d)
|
||||
f.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestGetOrLoadSingleflightsConcurrentMiss(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
c := New[int, string](Config[int, string]{MaxEntries: 16})
|
||||
|
||||
var calls atomic.Int32
|
||||
release := make(chan struct{})
|
||||
started := make(chan struct{})
|
||||
var once sync.Once
|
||||
load := func() (string, error) {
|
||||
calls.Add(1)
|
||||
once.Do(func() { close(started) })
|
||||
<-release
|
||||
return "v", nil
|
||||
}
|
||||
|
||||
const goroutines = 16
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
errs := make(chan error, goroutines)
|
||||
for i := 0; i < goroutines; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
v, err := c.GetOrLoad(ctx, 42, load)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
if v != "v" {
|
||||
errs <- fmt.Errorf("value = %q, want v", v)
|
||||
}
|
||||
}()
|
||||
}
|
||||
<-started
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
close(release)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Fatalf("load called %d times, want 1 (singleflight)", got)
|
||||
}
|
||||
// 命中:不再 load。
|
||||
if v, err := c.GetOrLoad(ctx, 42, func() (string, error) { return "miss", nil }); err != nil || v != "v" {
|
||||
t.Fatalf("cached GetOrLoad = %q,%v want v,<nil>", v, err)
|
||||
}
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Fatalf("cache hit re-loaded: calls=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEpochGuardRejectsStaleWriteback 证明 epoch 守卫堵住 lost-update:一次锁外 load
|
||||
// 期间到达的 Invalidate 不得被这次 load 的(已陈旧)结果覆盖;在飞读者最终拿到的是
|
||||
// 失效后重载的新值,且缓存未被陈旧值污染。
|
||||
func TestEpochGuardRejectsStaleWriteback(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
c := New[int, string](Config[int, string]{MaxEntries: 16})
|
||||
|
||||
var seq atomic.Int32 // 第 1 次 load 返回 stale,失效后第 2 次返回 fresh
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var once sync.Once
|
||||
load := func() (string, error) {
|
||||
n := seq.Add(1)
|
||||
if n == 1 {
|
||||
once.Do(func() { close(started) })
|
||||
<-release // 第一次 load 阻塞,模拟跨越一次失效
|
||||
return "stale", nil
|
||||
}
|
||||
return "fresh", nil
|
||||
}
|
||||
|
||||
resCh := make(chan string, 1)
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
v, err := c.GetOrLoad(ctx, 7, load)
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
resCh <- v
|
||||
}()
|
||||
|
||||
<-started
|
||||
// 失效在 load 期间到达(此刻缓存里还没有 key 7,delete 是 no-op,但 epoch++)。
|
||||
c.Invalidate(7)
|
||||
close(release) // 放行第一次 load:它返回 "stale",epoch 守卫必须拒绝写回。
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
t.Fatalf("GetOrLoad: %v", err)
|
||||
case v := <-resCh:
|
||||
if v != "fresh" {
|
||||
t.Fatalf("in-flight reader got %q, want fresh (stale must not win)", v)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timeout waiting for in-flight reader")
|
||||
}
|
||||
|
||||
// 缓存现在应持有 fresh,且后续读不再 load。
|
||||
callsBefore := seq.Load()
|
||||
if v, err := c.GetOrLoad(ctx, 7, load); err != nil || v != "fresh" {
|
||||
t.Fatalf("post-race GetOrLoad = %q,%v want fresh,<nil>", v, err)
|
||||
}
|
||||
if seq.Load() != callsBefore {
|
||||
t.Fatalf("cache poisoned/evicted: reloaded (seq %d -> %d)", callsBefore, seq.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLRUEvictsOldestNotWholeMap(t *testing.T) {
|
||||
c := New[int, int](Config[int, int]{MaxEntries: 3})
|
||||
for i := 1; i <= 3; i++ {
|
||||
mustLoad(t, c, i, i*10)
|
||||
}
|
||||
// 插入第 4 个:应只驱逐最旧的(key 1),而不是整表清空。
|
||||
mustLoad(t, c, 4, 40)
|
||||
if c.Len() != 3 {
|
||||
t.Fatalf("Len = %d, want 3 (single-entry eviction, not whole-map flush)", c.Len())
|
||||
}
|
||||
if _, ok := c.Peek(1); ok {
|
||||
t.Fatal("key 1 should have been evicted as oldest")
|
||||
}
|
||||
for _, k := range []int{2, 3, 4} {
|
||||
if v, ok := c.Peek(k); !ok || v != k*10 {
|
||||
t.Fatalf("key %d evicted/lost (whole-map flush?): v=%d ok=%v", k, v, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLRUTouchOnGet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
c := New[int, int](Config[int, int]{MaxEntries: 2})
|
||||
mustLoad(t, c, 1, 10)
|
||||
mustLoad(t, c, 2, 20)
|
||||
// 触碰 key 1 使其变最近使用。
|
||||
if v, err := c.GetOrLoad(ctx, 1, failLoad[int](t)); err != nil || v != 10 {
|
||||
t.Fatalf("touch GetOrLoad(1) = %d,%v", v, err)
|
||||
}
|
||||
// 插入 key 3:应驱逐 key 2(最久未用),保留 key 1。
|
||||
mustLoad(t, c, 3, 30)
|
||||
if _, ok := c.Peek(2); ok {
|
||||
t.Fatal("key 2 should have been evicted (LRU), key 1 was touched")
|
||||
}
|
||||
if _, ok := c.Peek(1); !ok {
|
||||
t.Fatal("key 1 was touched and must survive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionGateReloadsOnHashChange(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
c := New[int, string](Config[int, string]{MaxEntries: 16})
|
||||
|
||||
var calls atomic.Int32
|
||||
load := func(tag string) func() (string, error) {
|
||||
return func() (string, error) { calls.Add(1); return tag, nil }
|
||||
}
|
||||
// hash 100 装入。
|
||||
if v, _ := c.GetOrLoadVersioned(ctx, 1, 100, load("h100")); v != "h100" {
|
||||
t.Fatalf("first load = %q", v)
|
||||
}
|
||||
// 同 hash:命中,不 load。
|
||||
if v, _ := c.GetOrLoadVersioned(ctx, 1, 100, failLoad[string](t)); v != "h100" {
|
||||
t.Fatalf("same-hash hit = %q", v)
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Fatalf("same-hash re-loaded: calls=%d", calls.Load())
|
||||
}
|
||||
// hash 改变:miss,重载。
|
||||
if v, _ := c.GetOrLoadVersioned(ctx, 1, 200, load("h200")); v != "h200" {
|
||||
t.Fatalf("hash-change reload = %q", v)
|
||||
}
|
||||
if calls.Load() != 2 {
|
||||
t.Fatalf("hash change should reload once more: calls=%d", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTTLExpiryIsPerKeyAndDoesNotBumpEpoch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := newFakeClock()
|
||||
c := New[int, int](Config[int, int]{MaxEntries: 16, TTL: time.Minute})
|
||||
c.now = clock.now
|
||||
|
||||
mustLoad(t, c, 1, 10)
|
||||
epochBefore := c.cacheEpoch()
|
||||
|
||||
clock.advance(2 * time.Minute) // 越过 TTL
|
||||
|
||||
var reloaded atomic.Int32
|
||||
v, err := c.GetOrLoad(ctx, 1, func() (int, error) { reloaded.Add(1); return 11, nil })
|
||||
if err != nil || v != 11 {
|
||||
t.Fatalf("expired GetOrLoad = %d,%v want 11", v, err)
|
||||
}
|
||||
if reloaded.Load() != 1 {
|
||||
t.Fatal("expired entry should have reloaded")
|
||||
}
|
||||
if c.cacheEpoch() != epochBefore {
|
||||
t.Fatalf("TTL expiry bumped epoch %d -> %d (would thrash in-flight loads)", epochBefore, c.cacheEpoch())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateWhereFanout(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
type key struct{ channel, user int64 }
|
||||
c := New[key, int](Config[key, int]{MaxEntries: 64})
|
||||
put := func(ch, u int64) {
|
||||
if _, err := c.GetOrLoad(ctx, key{ch, u}, func() (int, error) { return 1, nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
put(10, 1)
|
||||
put(10, 2)
|
||||
put(20, 1)
|
||||
epochBefore := c.cacheEpoch()
|
||||
|
||||
c.InvalidateWhere(func(k key) bool { return k.channel == 10 })
|
||||
|
||||
if _, ok := c.Peek(key{10, 1}); ok {
|
||||
t.Fatal("channel 10 user 1 should be invalidated")
|
||||
}
|
||||
if _, ok := c.Peek(key{10, 2}); ok {
|
||||
t.Fatal("channel 10 user 2 should be invalidated")
|
||||
}
|
||||
if _, ok := c.Peek(key{20, 1}); !ok {
|
||||
t.Fatal("channel 20 must survive a channel-10 fanout invalidate")
|
||||
}
|
||||
if c.cacheEpoch() == epochBefore {
|
||||
t.Fatal("InvalidateWhere must bump epoch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneIsolatesCallerFromCache(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
c := New[int, []int](Config[int, []int]{
|
||||
MaxEntries: 16,
|
||||
Clone: func(v []int) []int { return append([]int(nil), v...) },
|
||||
})
|
||||
orig := []int{1, 2, 3}
|
||||
got, err := c.GetOrLoad(ctx, 1, func() ([]int, error) { return orig, nil })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got[0] = 999 // 突变返回值
|
||||
orig[1] = 888 // 突变 load 来源
|
||||
again, _ := c.Peek(1)
|
||||
if again[0] != 1 || again[1] != 2 || again[2] != 3 {
|
||||
t.Fatalf("cache entry mutated through aliasing: %v", again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlushClearsAndBumpsEpoch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
c := New[int, int](Config[int, int]{MaxEntries: 16})
|
||||
for i := 0; i < 5; i++ {
|
||||
mustLoad(t, c, i, i)
|
||||
}
|
||||
epochBefore := c.cacheEpoch()
|
||||
c.Flush()
|
||||
if c.Len() != 0 {
|
||||
t.Fatalf("Len after flush = %d, want 0", c.Len())
|
||||
}
|
||||
if c.cacheEpoch() == epochBefore {
|
||||
t.Fatal("Flush must bump epoch")
|
||||
}
|
||||
_ = ctx
|
||||
}
|
||||
|
||||
func TestNilCacheBypassesToLoad(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var c *Cache[int, string] // New 在 MaxEntries<=0 时返回 nil
|
||||
if got := New[int, string](Config[int, string]{MaxEntries: 0}); got != nil {
|
||||
t.Fatalf("New(MaxEntries:0) = %v, want nil", got)
|
||||
}
|
||||
var calls atomic.Int32
|
||||
v, err := c.GetOrLoad(ctx, 1, func() (string, error) { calls.Add(1); return "x", nil })
|
||||
if err != nil || v != "x" {
|
||||
t.Fatalf("nil cache GetOrLoad = %q,%v", v, err)
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Fatalf("nil cache should call load exactly once: %d", calls.Load())
|
||||
}
|
||||
// 其它方法对 nil 安全。
|
||||
c.Invalidate(1)
|
||||
c.InvalidateWhere(func(int) bool { return true })
|
||||
c.Flush()
|
||||
c.Store(1, "y")
|
||||
if _, ok := c.Peek(1); ok {
|
||||
t.Fatal("nil cache Peek must miss")
|
||||
}
|
||||
if c.Len() != 0 {
|
||||
t.Fatal("nil cache Len must be 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreIfEpochRejectsStaleExternalBuild(t *testing.T) {
|
||||
// 外部构建模式:LoadEpoch 快照 → 构建期间发生失效 → StoreIfEpoch 必须拒绝写回。
|
||||
c := New[int, string](Config[int, string]{MaxEntries: 16})
|
||||
loadEpoch := c.LoadEpoch()
|
||||
c.Invalidate(7) // 构建期间的失效(epoch++)
|
||||
c.StoreIfEpoch(7, "stale", loadEpoch)
|
||||
if _, ok := c.Peek(7); ok {
|
||||
t.Fatal("StoreIfEpoch 在 epoch 变更后必须拒绝写回")
|
||||
}
|
||||
// 无并发失效时,StoreIfEpoch 正常写入。
|
||||
fresh := c.LoadEpoch()
|
||||
c.StoreIfEpoch(7, "fresh", fresh)
|
||||
if v, ok := c.Peek(7); !ok || v != "fresh" {
|
||||
t.Fatalf("StoreIfEpoch 在 epoch 未变时应写入: %q ok=%v", v, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// batchVal 模拟带负缓存的值(found=false 表示「查过但不存在」)。
|
||||
type batchVal struct {
|
||||
n int
|
||||
found bool
|
||||
}
|
||||
|
||||
func TestGetOrLoadBatchCachesHitsMissesAndNegatives(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
c := New[int, batchVal](Config[int, batchVal]{MaxEntries: 64})
|
||||
noVersion := func(int) (int64, bool) { return 0, true }
|
||||
|
||||
var loadCalls atomic.Int32
|
||||
var lastMissing atomic.Int32
|
||||
load := func(_ context.Context, missing []int) (map[int]batchVal, error) {
|
||||
loadCalls.Add(1)
|
||||
lastMissing.Store(int32(len(missing)))
|
||||
out := make(map[int]batchVal, len(missing))
|
||||
for _, k := range missing {
|
||||
// 偶数存在,奇数为负结果(仍须返回,以便缓存负结果)。
|
||||
out[k] = batchVal{n: k * 10, found: k%2 == 0}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// 首次:全 miss,一次批量 load。
|
||||
got, err := c.GetOrLoadBatch(ctx, []int{1, 2, 3, 4}, noVersion, load)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got[2].n != 20 || !got[2].found || got[3].found {
|
||||
t.Fatalf("batch result wrong: %+v", got)
|
||||
}
|
||||
if loadCalls.Load() != 1 || lastMissing.Load() != 4 {
|
||||
t.Fatalf("first batch: calls=%d missing=%d, want 1/4", loadCalls.Load(), lastMissing.Load())
|
||||
}
|
||||
|
||||
// 再查(含一个新键 6):仅 6 是 miss,负结果(1/3)也已缓存不再 load。
|
||||
got2, err := c.GetOrLoadBatch(ctx, []int{1, 2, 3, 4, 6}, noVersion, load)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loadCalls.Load() != 2 || lastMissing.Load() != 1 {
|
||||
t.Fatalf("second batch should load only key 6: calls=%d missing=%d", loadCalls.Load(), lastMissing.Load())
|
||||
}
|
||||
if got2[6].n != 60 || !got2[6].found {
|
||||
t.Fatalf("key 6 = %+v, want 60/found", got2[6])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrLoadBatchVersionGateReloadsOnHashChange(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
c := New[int, batchVal](Config[int, batchVal]{MaxEntries: 64})
|
||||
var hash atomic.Int64
|
||||
hash.Store(100)
|
||||
versionOf := func(int) (int64, bool) { return hash.Load(), true }
|
||||
|
||||
var loadCalls atomic.Int32
|
||||
load := func(_ context.Context, missing []int) (map[int]batchVal, error) {
|
||||
loadCalls.Add(1)
|
||||
out := make(map[int]batchVal, len(missing))
|
||||
for _, k := range missing {
|
||||
out[k] = batchVal{n: int(hash.Load()), found: true}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if _, err := c.GetOrLoadBatch(ctx, []int{1}, versionOf, load); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 同 hash:命中,不 load。
|
||||
if _, err := c.GetOrLoadBatch(ctx, []int{1}, versionOf, load); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loadCalls.Load() != 1 {
|
||||
t.Fatalf("same-hash batch re-loaded: calls=%d", loadCalls.Load())
|
||||
}
|
||||
// hash 改变:版本闸门 miss,重载。
|
||||
hash.Store(200)
|
||||
got, err := c.GetOrLoadBatch(ctx, []int{1}, versionOf, load)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loadCalls.Load() != 2 || got[1].n != 200 {
|
||||
t.Fatalf("hash change should reload: calls=%d got=%+v", loadCalls.Load(), got[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetOrLoadBatchStoresUnderLookupHashNotStoreHash 钉住版本闸门修复:写回必须用查阶段
|
||||
// 快照的 hash,而非写回时重算的 hash。这里在 loadMissing(恰好在查与写之间执行)里把版本
|
||||
// 从 100 改成 200——若写回误用新 hash 200,则按版本 100 加载的数据会被当作 200 的新数据命中。
|
||||
func TestGetOrLoadBatchStoresUnderLookupHashNotStoreHash(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
c := New[int, batchVal](Config[int, batchVal]{MaxEntries: 64})
|
||||
var gen atomic.Int64
|
||||
gen.Store(100)
|
||||
versionOf := func(int) (int64, bool) { return gen.Load(), true }
|
||||
|
||||
var loadCalls atomic.Int32
|
||||
load := func(_ context.Context, missing []int) (map[int]batchVal, error) {
|
||||
loadCalls.Add(1)
|
||||
gen.Store(200) // 版本在「查(@100,miss)」与「写回」之间变更
|
||||
out := make(map[int]batchVal, len(missing))
|
||||
for _, k := range missing {
|
||||
out[k] = batchVal{n: k, found: true}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// 首批:查@100 miss → load(把 gen 改到 200)→ 写回须用快照的 100,而非 200。
|
||||
if _, err := c.GetOrLoadBatch(ctx, []int{1}, versionOf, load); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loadCalls.Load() != 1 {
|
||||
t.Fatalf("first batch load calls = %d, want 1", loadCalls.Load())
|
||||
}
|
||||
// 现在 currentHash=200。若 entry 误以 200 戳入(bug),这次会命中陈旧数据、不重载;
|
||||
// 修复后 entry 是 100 != 200 → miss → 重载。
|
||||
if _, err := c.GetOrLoadBatch(ctx, []int{1}, versionOf, load); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loadCalls.Load() != 2 {
|
||||
t.Fatalf("version changed during first load; second read must reload (loadCalls=%d, want 2) — stale-as-fresh version-gate bypass", loadCalls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrLoadBatchBypassesNonCacheable(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
c := New[int, batchVal](Config[int, batchVal]{MaxEntries: 64})
|
||||
// key 7 不可缓存(version 缺失),其余纯 TTL 缓存。
|
||||
versionOf := func(k int) (int64, bool) { return 0, k != 7 }
|
||||
var loadCalls atomic.Int32
|
||||
load := func(_ context.Context, missing []int) (map[int]batchVal, error) {
|
||||
loadCalls.Add(1)
|
||||
out := make(map[int]batchVal, len(missing))
|
||||
for _, k := range missing {
|
||||
out[k] = batchVal{n: k, found: true}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if _, err := c.GetOrLoadBatch(ctx, []int{7, 8}, versionOf, load); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 7 不可缓存故未写回;8 已缓存。再查应只为 7 load。
|
||||
got, err := c.GetOrLoadBatch(ctx, []int{7, 8}, versionOf, load)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loadCalls.Load() != 2 {
|
||||
t.Fatalf("non-cacheable key 7 should reload every time: calls=%d", loadCalls.Load())
|
||||
}
|
||||
if _, ok := c.Peek(7); ok {
|
||||
t.Fatal("non-cacheable key 7 must not be stored")
|
||||
}
|
||||
if got[8].n != 8 {
|
||||
t.Fatalf("key 8 = %+v", got[8])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrLoadBatchRetriesOnEpochChangeDuringLoad(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
c := New[int, batchVal](Config[int, batchVal]{MaxEntries: 64})
|
||||
noVersion := func(int) (int64, bool) { return 0, true }
|
||||
|
||||
var seq atomic.Int32
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var once sync.Once
|
||||
load := func(_ context.Context, missing []int) (map[int]batchVal, error) {
|
||||
n := seq.Add(1)
|
||||
out := make(map[int]batchVal, len(missing))
|
||||
if n == 1 {
|
||||
once.Do(func() { close(started) })
|
||||
<-release // 第一趟批量 load 阻塞,期间发生失效
|
||||
for _, k := range missing {
|
||||
out[k] = batchVal{n: k, found: true} // 旧批数据
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
for _, k := range missing {
|
||||
out[k] = batchVal{n: k + 1000, found: true} // 失效后重载的新数据
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
resCh := make(chan map[int]batchVal, 1)
|
||||
go func() {
|
||||
got, _ := c.GetOrLoadBatch(ctx, []int{1}, noVersion, load)
|
||||
resCh <- got
|
||||
}()
|
||||
<-started
|
||||
c.Invalidate(1) // 批量 load 期间失效 → epoch++
|
||||
close(release)
|
||||
|
||||
got := <-resCh
|
||||
if got[1].n != 1001 {
|
||||
t.Fatalf("epoch retry should return reloaded data: got %+v, want 1001", got[1])
|
||||
}
|
||||
// 缓存里应是重载后的新值,且不再 load。
|
||||
callsBefore := seq.Load()
|
||||
if v, ok := c.Peek(1); !ok || v.n != 1001 {
|
||||
t.Fatalf("cache should hold reloaded value: %+v ok=%v", v, ok)
|
||||
}
|
||||
if seq.Load() != callsBefore {
|
||||
t.Fatalf("post-retry read hit loader: seq %d", seq.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreWarmFromListDoesNotBumpEpoch(t *testing.T) {
|
||||
c := New[int, int](Config[int, int]{MaxEntries: 16})
|
||||
epochBefore := c.cacheEpoch()
|
||||
c.Store(1, 10)
|
||||
if c.cacheEpoch() != epochBefore {
|
||||
t.Fatal("Store (warm) must not bump epoch")
|
||||
}
|
||||
if v, ok := c.Peek(1); !ok || v != 10 {
|
||||
t.Fatalf("Store then Peek = %d,%v", v, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadErrorNotCached(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
c := New[int, int](Config[int, int]{MaxEntries: 16})
|
||||
wantErr := fmt.Errorf("boom")
|
||||
if _, err := c.GetOrLoad(ctx, 1, func() (int, error) { return 0, wantErr }); err != wantErr {
|
||||
t.Fatalf("err = %v, want boom", err)
|
||||
}
|
||||
if _, ok := c.Peek(1); ok {
|
||||
t.Fatal("failed load must not be cached")
|
||||
}
|
||||
// 之后成功 load 应生效。
|
||||
if v, err := c.GetOrLoad(ctx, 1, func() (int, error) { return 5, nil }); err != nil || v != 5 {
|
||||
t.Fatalf("retry GetOrLoad = %d,%v", v, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConcurrentAccessNoPanic 在 -race 下能抓数据竞争;无 -race 时验证无 panic/死锁。
|
||||
func TestConcurrentAccessNoPanic(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
c := New[int, int](Config[int, int]{MaxEntries: 64, TTL: time.Millisecond})
|
||||
var wg sync.WaitGroup
|
||||
for g := 0; g < 24; g++ {
|
||||
wg.Add(1)
|
||||
go func(g int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 500; i++ {
|
||||
k := (g + i) % 128
|
||||
switch i % 5 {
|
||||
case 0:
|
||||
_, _ = c.GetOrLoad(ctx, k, func() (int, error) { return k, nil })
|
||||
case 1:
|
||||
_, _ = c.GetOrLoadVersioned(ctx, k, int64(i%7+1), func() (int, error) { return k, nil })
|
||||
case 2:
|
||||
c.Invalidate(k)
|
||||
case 3:
|
||||
c.InvalidateWhere(func(x int) bool { return x%3 == 0 })
|
||||
case 4:
|
||||
c.Store(k, k)
|
||||
}
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func mustLoad[K comparable, V comparable](t *testing.T, c *Cache[K, V], key K, val V) {
|
||||
t.Helper()
|
||||
v, err := c.GetOrLoad(context.Background(), key, func() (V, error) { return val, nil })
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrLoad(%v): %v", key, err)
|
||||
}
|
||||
if v != val {
|
||||
t.Fatalf("GetOrLoad(%v) = %v, want %v", key, v, val)
|
||||
}
|
||||
}
|
||||
|
||||
func failLoad[V any](t *testing.T) func() (V, error) {
|
||||
return func() (V, error) {
|
||||
t.Helper()
|
||||
t.Fatal("load should not have been called (expected cache hit)")
|
||||
var zero V
|
||||
return zero, nil
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue