chore: refresh gramsrv public release

This commit is contained in:
A 2026-06-30 14:37:43 +08:00
parent 75cebe8dbf
commit 70b6820474
1274 changed files with 378751 additions and 59919 deletions

View file

@ -0,0 +1,459 @@
// Package readmodelcache provides telesrv 统一的进程内 read-model 缓存原语。
//
// 它是 ~21 个手搓 read-model 缓存(map + mutex + TTL + 整表 flush + 偶尔 singleflight +
// 偶尔 epoch)收敛后的唯一实现。它**不是**通用响应缓存:Telegram 响应对 viewer 敏感,
// 可缓存单元仍是 docs/read-model-architecture.md 定义的 per-viewer 事实/投影。本类型只是
// 机制——键、TTL、版本 token 仍是各缓存自己的策略。
//
// 它替每个使用方免费保证(正是 2026-06-17 缓存审计发现的整类 bug):
// - 有界:LRU 单条驱逐,绝不整表 flush(避免 thundering-herd 重载悬崖);
// - 一致:跨越一次失效的 load 不会把陈旧值写回(epoch 守卫)——曾钉死版本脊与
// ChannelMemberCache 的 lost-update;
// - 去重:同键并发 miss 收敛为一次 load(singleflight);
// - 可选版本闸门:仅当 storedHash == currentHash 时复用缓存项。
//
// epoch 刻意是**进程内**的:它只关掉进程内 load-vs-invalidate 竞态。跨实例新鲜度由
// NOTIFY 监听器(以及尚未落地的 durable 失效层)负责,本原语不改变跨进程契约。
package readmodelcache
import (
"container/list"
"context"
"fmt"
"strconv"
"sync"
"time"
"golang.org/x/sync/singleflight"
)
// Config 配置一个 Cache 实例。MaxEntries 是强制的:不允许构造无界缓存。
type Config[K comparable, V any] struct {
// MaxEntries 是 LRU 上界。<=0 时 New 返回 nil(等价"禁用缓存",沿用各处
// New*Cache(max<=0)->nil 的惯例;所有方法对 nil 安全,退化为直接 load)。
MaxEntries int
// TTL 仅作安全兜底(漏掉的带外写)。0 = 纯事件驱动,无时间过期。
TTL time.Duration
// Clone 在 store 与返回两个边界上对值做深拷贝,隔离调用方与缓存项的别名突变。
// nil = 值本身 copy-safe(标量/扁平结构),不拷贝。
Clone func(V) V
// KeyString 生成 singleflight 键;nil 时默认 fmt.Sprint(K)。仅当 K 的 fmt 表示
// 有歧义(可能两个不同键打印相同)时才需要自定义。
KeyString func(K) string
// Now 注入时钟,仅用于 TTL 过期判断;nil 时默认 time.Now。生产一律留空,
// 测试可注入假时钟以确定地推进 TTL。
Now func() time.Time
}
type lruEntry[K comparable, V any] struct {
key K
value V
hash int64
expireAt time.Time // 零值 = 不过期
}
// Cache 是泛型 read-model 缓存。零值不可用,必须经 New 构造。nil *Cache 合法:
// 所有方法对 nil 安全,GetOrLoad 退化为直接调用 load。
type Cache[K comparable, V any] struct {
mu sync.Mutex
ll *list.List // LRU 顺序,Front=最近使用
items map[K]*list.Element
cap int
ttl time.Duration
epoch uint64
sf singleflight.Group
clone func(V) V
keyString func(K) string
now func() time.Time
}
// New 构造一个 Cache。MaxEntries<=0 时返回 nil(禁用缓存,沿用既有惯例)。
func New[K comparable, V any](cfg Config[K, V]) *Cache[K, V] {
if cfg.MaxEntries <= 0 {
return nil
}
keyString := cfg.KeyString
if keyString == nil {
keyString = func(k K) string { return fmt.Sprint(k) }
}
now := cfg.Now
if now == nil {
now = time.Now
}
return &Cache[K, V]{
ll: list.New(),
items: make(map[K]*list.Element, initialMapHint(cfg.MaxEntries)),
cap: cfg.MaxEntries,
ttl: cfg.TTL,
clone: cfg.Clone,
keyString: keyString,
now: now,
}
}
func initialMapHint(max int) int {
if max < 1024 {
return max
}
return 1024
}
type loadResult[V any] struct {
value V
stored bool
}
// GetOrLoad 命中即返回,否则经 singleflight 调 load。load 受 epoch 守卫:
// 在 load 前快照 epoch,若 load 期间发生过任何 Invalidate/Flush,则**拒绝**把这次
// load 的结果写回缓存,并重查/重载以取最新值(避免 lost-update 把陈旧值钉进缓存)。
func (c *Cache[K, V]) GetOrLoad(ctx context.Context, key K, load func() (V, error)) (V, error) {
return c.getOrLoad(ctx, key, 0, false, load)
}
// GetOrLoadVersioned 在 GetOrLoad 基础上加版本闸门:仅当缓存项的 storedHash ==
// currentHash 时复用,否则重载。currentHash==0 表示"版本未知/绕过版本闸门"
// (与既有 snap.hash==currentHash 检查的语义一致)。
func (c *Cache[K, V]) GetOrLoadVersioned(ctx context.Context, key K, currentHash int64, load func() (V, error)) (V, error) {
return c.getOrLoad(ctx, key, currentHash, true, load)
}
func (c *Cache[K, V]) getOrLoad(ctx context.Context, key K, currentHash int64, versioned bool, load func() (V, error)) (V, error) {
if c == nil {
return load()
}
for {
if v, ok := c.lookup(key, currentHash, versioned); ok {
return v, nil
}
res, err, _ := c.sf.Do(c.singleflightKey(key, currentHash, versioned), func() (any, error) {
// 进入 singleflight 后再查一次:可能有并发者刚写入。
if v, ok := c.lookup(key, currentHash, versioned); ok {
return loadResult[V]{value: v, stored: true}, nil
}
loadEpoch := c.cacheEpoch()
v, err := load()
if err != nil {
return loadResult[V]{}, err
}
stored := c.storeIfEpoch(key, v, currentHash, loadEpoch)
return loadResult[V]{value: c.cloneValue(v), stored: stored}, nil
})
if err != nil {
var zero V
return zero, err
}
result := res.(loadResult[V])
if result.stored {
return result.value, nil
}
// store 被 epoch 守卫拒绝(load 期间发生过失效):本次 load 的值可能已陈旧。
// 重查缓存——若有更新值(warm/后续 load)直接用,否则重新 load 取 DB 最新态。
if err := ctx.Err(); err != nil {
var zero V
return zero, err
}
}
}
func (c *Cache[K, V]) lookup(key K, currentHash int64, versioned bool) (V, bool) {
var zero V
c.mu.Lock()
defer c.mu.Unlock()
el, ok := c.items[key]
if !ok {
return zero, false
}
ent := el.Value.(*lruEntry[K, V])
if c.expired(ent) {
// 被动 TTL 过期:纯 per-key 删除,**不**自增 epoch——否则会误杀此刻在飞的
// 不相关 load(epoch 是全局的),造成 thrash。
c.removeElement(el)
return zero, false
}
if versioned && currentHash != 0 && ent.hash != currentHash {
return zero, false
}
c.ll.MoveToFront(el)
return c.cloneValue(ent.value), true
}
// storeIfEpoch 仅在 epoch 未变(load 期间无失效)时写入,返回是否写入。
func (c *Cache[K, V]) storeIfEpoch(key K, v V, hash int64, loadEpoch uint64) bool {
c.mu.Lock()
defer c.mu.Unlock()
if c.epoch != loadEpoch {
return false
}
c.storeLocked(key, v, hash)
return true
}
func (c *Cache[K, V]) storeLocked(key K, v V, hash int64) {
if el, ok := c.items[key]; ok {
ent := el.Value.(*lruEntry[K, V])
ent.value = c.cloneValue(v)
ent.hash = hash
ent.expireAt = c.expireAt()
c.ll.MoveToFront(el)
return
}
ent := &lruEntry[K, V]{key: key, value: c.cloneValue(v), hash: hash, expireAt: c.expireAt()}
c.items[key] = c.ll.PushFront(ent)
if c.ll.Len() > c.cap {
c.evictOldest()
}
}
// Store 把一个已在手的值写入缓存(warm-from-list 路径)。不自增 epoch:它不是失效,
// 不应取消其它在飞 load。
func (c *Cache[K, V]) Store(key K, v V) {
if c == nil {
return
}
c.mu.Lock()
c.storeLocked(key, v, 0)
c.mu.Unlock()
}
// LoadEpoch 在「外部构建值再写回」模式下,于构建前快照 epoch;之后用 StoreIfEpoch 写回。
// 适用于值在缓存之外构建(需要 ctx、多次往返)、无法套进 GetOrLoad 的调用方(如 RPC 投影)。
func (c *Cache[K, V]) LoadEpoch() uint64 {
if c == nil {
return 0
}
return c.cacheEpoch()
}
// StoreIfEpoch 仅在 epoch 自 loadEpoch 以来未变(构建期间没有失效)时写回外部构建的值。
// 与 LoadEpoch 配对,把 GetOrLoad 内建的 epoch 守卫开放给外部构建路径。
func (c *Cache[K, V]) StoreIfEpoch(key K, v V, loadEpoch uint64) {
if c == nil {
return
}
c.storeIfEpoch(key, v, 0, loadEpoch)
}
// StoreVersioned 同 Store,但带版本 hash(供版本闸门缓存的 warm 路径使用)。
func (c *Cache[K, V]) StoreVersioned(key K, hash int64, v V) {
if c == nil {
return
}
c.mu.Lock()
c.storeLocked(key, v, hash)
c.mu.Unlock()
}
// GetOrLoadBatch 一趟解析多个键:per-key 命中(TTL + 可选版本闸门)→ 把所有 miss 一次性
// 批量 load → per-key epoch 守卫写回。服务那些「按键查缓存、把 miss 合批打一次后端」的缓存
// (dialog-peer / privacy / photo / bot profile),避免单键 GetOrLoad 把一次批量 DB 退化成 N 次。
//
// - versionOf(key) 返回 (hash, cacheable):cacheable=false 表示该键绕过缓存(永远重载、不写回,
// 如 dialog-peer 中 version 缺失的 peer);hash!=0 启用版本闸门(仅当 stored hash 匹配才复用);
// hash==0 且 cacheable 表示纯 TTL 缓存(无版本,如 photo/privacy/bot)。
// - loadMissing 必须为它收到的**每个** key 返回一个值(含「查过但不存在」的负缓存哨兵),
// 这样负结果也会被缓存,杜绝无结果键反复打后端。
//
// 若一次失效在批量 load 期间到达(epoch 变更),整趟重试,确保 pre-invalidation 的批量数据
// 不会遮蔽这次失效(比各缓存原先「静默拒绝写回但仍返回旧批数据」更强;ctx 取消兜底防自旋)。
func (c *Cache[K, V]) GetOrLoadBatch(
ctx context.Context,
keys []K,
versionOf func(K) (hash int64, cacheable bool),
loadMissing func(context.Context, []K) (map[K]V, error),
) (map[K]V, error) {
if len(keys) == 0 {
return map[K]V{}, nil
}
if c == nil {
return loadMissing(ctx, dedupeKeys(keys))
}
for {
out := make(map[K]V, len(keys))
loadEpoch := c.cacheEpoch()
// 在查阶段就把每个 miss 的 (hash, cacheable) 快照下来,写回时复用同一份——绝不在写回
// 时重算 versionOf:否则一个版本在查与写之间变更的 key,会把按旧 hash 加载的值以新 hash
// 戳入缓存,随后被当作新版数据命中(stale-as-fresh,旁路版本闸门)。
missing := make([]batchMiss[K], 0, len(keys))
seen := make(map[K]struct{}, len(keys))
for _, key := range keys {
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
hash, cacheable := versionOf(key)
if cacheable {
if v, ok := c.lookup(key, hash, hash != 0); ok {
out[key] = v
continue
}
}
missing = append(missing, batchMiss[K]{key: key, hash: hash, cacheable: cacheable})
}
if len(missing) == 0 {
return out, nil
}
missingKeys := make([]K, len(missing))
for i := range missing {
missingKeys[i] = missing[i].key
}
loaded, err := loadMissing(ctx, missingKeys)
if err != nil {
return nil, err
}
if c.cacheEpoch() != loadEpoch {
// 失效在批量 load 期间到达:重试整趟,避免用 pre-invalidation 数据遮蔽它。
if err := ctx.Err(); err != nil {
return nil, err
}
continue
}
for _, m := range missing {
v, ok := loaded[m.key]
if !ok {
continue
}
out[m.key] = v
if m.cacheable {
c.storeIfEpoch(m.key, v, m.hash, loadEpoch)
}
}
return out, nil
}
}
type batchMiss[K comparable] struct {
key K
hash int64
cacheable bool
}
func dedupeKeys[K comparable](keys []K) []K {
seen := make(map[K]struct{}, len(keys))
out := make([]K, 0, len(keys))
for _, key := range keys {
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, key)
}
return out
}
// Peek 直接读,不触发 load、不算 LRU touch(warm/测试路径)。过期项报 miss 但不删除
// (保持 Peek 无副作用)。
func (c *Cache[K, V]) Peek(key K) (V, bool) {
var zero V
if c == nil {
return zero, false
}
c.mu.Lock()
defer c.mu.Unlock()
el, ok := c.items[key]
if !ok {
return zero, false
}
ent := el.Value.(*lruEntry[K, V])
if c.expired(ent) {
return zero, false
}
return c.cloneValue(ent.value), true
}
// Invalidate 删除指定键并自增 epoch(关掉任何此刻在飞 load 的写回)。
func (c *Cache[K, V]) Invalidate(keys ...K) {
if c == nil || len(keys) == 0 {
return
}
c.mu.Lock()
c.epoch++
for _, key := range keys {
if el, ok := c.items[key]; ok {
c.removeElement(el)
}
}
c.mu.Unlock()
}
// InvalidateWhere 删除所有满足 pred 的键并自增 epoch(viewer/channel 维度扇出失效,
// 取代各缓存手写的 deleteChannel/InvalidateViewer 遍历)。
func (c *Cache[K, V]) InvalidateWhere(pred func(K) bool) {
if c == nil || pred == nil {
return
}
c.mu.Lock()
c.epoch++
for key, el := range c.items {
if pred(key) {
c.removeElement(el)
}
}
c.mu.Unlock()
}
// Flush 清空缓存并自增 epoch(监听器断线重连兜底)。
func (c *Cache[K, V]) Flush() {
if c == nil {
return
}
c.mu.Lock()
c.epoch++
c.ll.Init()
c.items = make(map[K]*list.Element, initialMapHint(c.cap))
c.mu.Unlock()
}
// Len 返回当前缓存项数(测试/指标用)。
func (c *Cache[K, V]) Len() int {
if c == nil {
return 0
}
c.mu.Lock()
n := c.ll.Len()
c.mu.Unlock()
return n
}
func (c *Cache[K, V]) cacheEpoch() uint64 {
c.mu.Lock()
e := c.epoch
c.mu.Unlock()
return e
}
func (c *Cache[K, V]) expired(ent *lruEntry[K, V]) bool {
return c.ttl > 0 && !ent.expireAt.IsZero() && !ent.expireAt.After(c.now())
}
func (c *Cache[K, V]) expireAt() time.Time {
if c.ttl <= 0 {
return time.Time{}
}
return c.now().Add(c.ttl)
}
func (c *Cache[K, V]) evictOldest() {
if el := c.ll.Back(); el != nil {
c.removeElement(el)
}
}
func (c *Cache[K, V]) removeElement(el *list.Element) {
c.ll.Remove(el)
delete(c.items, el.Value.(*lruEntry[K, V]).key)
}
func (c *Cache[K, V]) cloneValue(v V) V {
if c.clone == nil {
return v
}
return c.clone(v)
}
func (c *Cache[K, V]) singleflightKey(key K, hash int64, versioned bool) string {
s := c.keyString(key)
if versioned && hash != 0 {
s += "@" + strconv.FormatInt(hash, 10)
}
return s
}

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