merged from gramsrv upstream
This commit is contained in:
parent
79c64ee916
commit
21a0856587
651 changed files with 54774 additions and 4590 deletions
|
|
@ -32,6 +32,11 @@ type Config[K comparable, V any] struct {
|
|||
// MaxEntries 是 LRU 上界。<=0 时 New 返回 nil(等价"禁用缓存",沿用各处
|
||||
// New*Cache(max<=0)->nil 的惯例;所有方法对 nil 安全,退化为直接 load)。
|
||||
MaxEntries int
|
||||
// MaxWeight 是可选的第二容量边界。>0 时每个值由 Weight 计算权重,缓存同时
|
||||
// 满足 MaxEntries 与 MaxWeight;单项超过上限时本次仍可返回但不驻留。
|
||||
MaxWeight int64
|
||||
// Weight 计算一个缓存值的相对占用;仅 MaxWeight>0 时使用。nil 时每项权重为 1。
|
||||
Weight func(V) int64
|
||||
// TTL 仅作安全兜底(漏掉的带外写)。0 = 纯事件驱动,无时间过期。
|
||||
TTL time.Duration
|
||||
// Clone 在 store 与返回两个边界上对值做深拷贝,隔离调用方与缓存项的别名突变。
|
||||
|
|
@ -43,6 +48,10 @@ type Config[K comparable, V any] struct {
|
|||
// Now 注入时钟,仅用于 TTL 过期判断;nil 时默认 time.Now。生产一律留空,
|
||||
// 测试可注入假时钟以确定地推进 TTL。
|
||||
Now func() time.Time
|
||||
// OnStore/OnRemove 供依赖倒排索引同步生命周期。回调在缓存锁内执行,
|
||||
// 不得回调本 Cache 或阻塞;收到的 value 是缓存持有的 immutable clone。
|
||||
OnStore func(K, V)
|
||||
OnRemove func(K, V)
|
||||
}
|
||||
|
||||
type lruEntry[K comparable, V any] struct {
|
||||
|
|
@ -50,6 +59,7 @@ type lruEntry[K comparable, V any] struct {
|
|||
value V
|
||||
hash int64
|
||||
expireAt time.Time // 零值 = 不过期
|
||||
weight int64
|
||||
}
|
||||
|
||||
// Cache 是泛型 read-model 缓存。零值不可用,必须经 New 构造。nil *Cache 合法:
|
||||
|
|
@ -59,12 +69,39 @@ type Cache[K comparable, V any] struct {
|
|||
ll *list.List // LRU 顺序,Front=最近使用
|
||||
items map[K]*list.Element
|
||||
cap int
|
||||
maxWeight int64
|
||||
weight int64
|
||||
ttl time.Duration
|
||||
epoch uint64
|
||||
sf singleflight.Group
|
||||
clone func(V) V
|
||||
weigh func(V) int64
|
||||
keyString func(K) string
|
||||
now func() time.Time
|
||||
onStore func(K, V)
|
||||
onRemove func(K, V)
|
||||
|
||||
// batchFlights coordinates individual keys across overlapping concurrent
|
||||
// GetOrLoadBatch calls. A singleflight key for the whole input slice cannot
|
||||
// coalesce {1,2,3} with {2,3,4}; tracking the misses per key lets the first
|
||||
// caller own 2/3 while the second still loads 4 in its own backend batch.
|
||||
batchMu sync.Mutex
|
||||
batchFlights map[batchFlightKey[K]]*batchFlight[V]
|
||||
}
|
||||
|
||||
type batchFlightKey[K comparable] struct {
|
||||
key K
|
||||
hash int64
|
||||
cacheable bool
|
||||
epoch uint64
|
||||
}
|
||||
|
||||
type batchFlight[V any] struct {
|
||||
done chan struct{}
|
||||
value V
|
||||
ok bool
|
||||
err error
|
||||
retry bool
|
||||
}
|
||||
|
||||
// New 构造一个 Cache。MaxEntries<=0 时返回 nil(禁用缓存,沿用既有惯例)。
|
||||
|
|
@ -81,13 +118,18 @@ func New[K comparable, V any](cfg Config[K, V]) *Cache[K, V] {
|
|||
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,
|
||||
ll: list.New(),
|
||||
items: make(map[K]*list.Element, initialMapHint(cfg.MaxEntries)),
|
||||
cap: cfg.MaxEntries,
|
||||
maxWeight: cfg.MaxWeight,
|
||||
ttl: cfg.TTL,
|
||||
clone: cfg.Clone,
|
||||
weigh: cfg.Weight,
|
||||
keyString: keyString,
|
||||
now: now,
|
||||
onStore: cfg.OnStore,
|
||||
onRemove: cfg.OnRemove,
|
||||
batchFlights: make(map[batchFlightKey[K]]*batchFlight[V]),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -189,19 +231,38 @@ func (c *Cache[K, V]) storeIfEpoch(key K, v V, hash int64, loadEpoch uint64) boo
|
|||
}
|
||||
|
||||
func (c *Cache[K, V]) storeLocked(key K, v V, hash int64) {
|
||||
weight := c.valueWeight(v)
|
||||
if c.maxWeight > 0 && weight > c.maxWeight {
|
||||
if el, ok := c.items[key]; ok {
|
||||
c.removeElement(el)
|
||||
}
|
||||
return
|
||||
}
|
||||
if el, ok := c.items[key]; ok {
|
||||
ent := el.Value.(*lruEntry[K, V])
|
||||
if c.onRemove != nil {
|
||||
c.onRemove(ent.key, ent.value)
|
||||
}
|
||||
c.weight -= ent.weight
|
||||
ent.value = c.cloneValue(v)
|
||||
ent.hash = hash
|
||||
ent.expireAt = c.expireAt()
|
||||
ent.weight = weight
|
||||
c.weight += weight
|
||||
if c.onStore != nil {
|
||||
c.onStore(ent.key, ent.value)
|
||||
}
|
||||
c.ll.MoveToFront(el)
|
||||
c.evictOverflow()
|
||||
return
|
||||
}
|
||||
ent := &lruEntry[K, V]{key: key, value: c.cloneValue(v), hash: hash, expireAt: c.expireAt()}
|
||||
ent := &lruEntry[K, V]{key: key, value: c.cloneValue(v), hash: hash, expireAt: c.expireAt(), weight: weight}
|
||||
c.items[key] = c.ll.PushFront(ent)
|
||||
if c.ll.Len() > c.cap {
|
||||
c.evictOldest()
|
||||
c.weight += weight
|
||||
if c.onStore != nil {
|
||||
c.onStore(ent.key, ent.value)
|
||||
}
|
||||
c.evictOverflow()
|
||||
}
|
||||
|
||||
// Store 把一个已在手的值写入缓存(warm-from-list 路径)。不自增 epoch:它不是失效,
|
||||
|
|
@ -292,31 +353,58 @@ func (c *Cache[K, V]) GetOrLoadBatch(
|
|||
if len(missing) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
missingKeys := make([]K, len(missing))
|
||||
for i := range missing {
|
||||
missingKeys[i] = missing[i].key
|
||||
waits, owned := c.claimBatchFlights(missing, loadEpoch)
|
||||
if len(owned) > 0 {
|
||||
ownedKeys := make([]K, len(owned))
|
||||
for i := range owned {
|
||||
ownedKeys[i] = owned[i].miss.key
|
||||
}
|
||||
loaded, loadErr := loadMissing(ctx, ownedKeys)
|
||||
retry := false
|
||||
if loadErr == nil {
|
||||
entries := make([]batchStoreEntry[K, V], 0, len(owned))
|
||||
for _, owner := range owned {
|
||||
value, ok := loaded[owner.miss.key]
|
||||
if ok && owner.miss.cacheable {
|
||||
entries = append(entries, batchStoreEntry[K, V]{
|
||||
key: owner.miss.key, value: value, hash: owner.miss.hash,
|
||||
})
|
||||
}
|
||||
}
|
||||
retry = !c.storeBatchIfEpoch(entries, loadEpoch)
|
||||
}
|
||||
for _, owner := range owned {
|
||||
value, ok := loaded[owner.miss.key]
|
||||
c.completeBatchFlight(owner.key, owner.flight, value, ok, loadErr, retry)
|
||||
}
|
||||
}
|
||||
loaded, err := loadMissing(ctx, missingKeys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
retry := false
|
||||
for _, wait := range waits {
|
||||
select {
|
||||
case <-wait.flight.done:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if wait.flight.err != nil {
|
||||
return nil, wait.flight.err
|
||||
}
|
||||
if wait.flight.retry {
|
||||
retry = true
|
||||
continue
|
||||
}
|
||||
if wait.flight.ok {
|
||||
out[wait.miss.key] = c.cloneValue(wait.flight.value)
|
||||
}
|
||||
}
|
||||
if c.cacheEpoch() != loadEpoch {
|
||||
// 失效在批量 load 期间到达:重试整趟,避免用 pre-invalidation 数据遮蔽它。
|
||||
if retry {
|
||||
// 失效在任一 owner 的批量 load 期间到达:所有参与者重查,
|
||||
// 不让 pre-invalidation 的共享 flight 值越过 epoch 边界。
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -327,6 +415,76 @@ type batchMiss[K comparable] struct {
|
|||
cacheable bool
|
||||
}
|
||||
|
||||
type batchWait[K comparable, V any] struct {
|
||||
miss batchMiss[K]
|
||||
key batchFlightKey[K]
|
||||
flight *batchFlight[V]
|
||||
}
|
||||
|
||||
type batchStoreEntry[K comparable, V any] struct {
|
||||
key K
|
||||
value V
|
||||
hash int64
|
||||
}
|
||||
|
||||
func (c *Cache[K, V]) claimBatchFlights(
|
||||
missing []batchMiss[K],
|
||||
epoch uint64,
|
||||
) (waits []batchWait[K, V], owned []batchWait[K, V]) {
|
||||
waits = make([]batchWait[K, V], 0, len(missing))
|
||||
owned = make([]batchWait[K, V], 0, len(missing))
|
||||
c.batchMu.Lock()
|
||||
for _, miss := range missing {
|
||||
key := batchFlightKey[K]{key: miss.key, hash: miss.hash, cacheable: miss.cacheable, epoch: epoch}
|
||||
flight, found := c.batchFlights[key]
|
||||
wait := batchWait[K, V]{miss: miss, key: key, flight: flight}
|
||||
if !found {
|
||||
flight = &batchFlight[V]{done: make(chan struct{})}
|
||||
c.batchFlights[key] = flight
|
||||
wait.flight = flight
|
||||
owned = append(owned, wait)
|
||||
}
|
||||
waits = append(waits, wait)
|
||||
}
|
||||
c.batchMu.Unlock()
|
||||
return waits, owned
|
||||
}
|
||||
|
||||
func (c *Cache[K, V]) completeBatchFlight(
|
||||
key batchFlightKey[K],
|
||||
flight *batchFlight[V],
|
||||
value V,
|
||||
ok bool,
|
||||
err error,
|
||||
retry bool,
|
||||
) {
|
||||
c.batchMu.Lock()
|
||||
flight.value = c.cloneValue(value)
|
||||
flight.ok = ok
|
||||
flight.err = err
|
||||
flight.retry = retry
|
||||
if current := c.batchFlights[key]; current == flight {
|
||||
delete(c.batchFlights, key)
|
||||
}
|
||||
close(flight.done)
|
||||
c.batchMu.Unlock()
|
||||
}
|
||||
|
||||
// storeBatchIfEpoch makes the write side of one batch atomic with respect to
|
||||
// invalidation. Besides avoiding partial warm state, this gives every waiter
|
||||
// one unambiguous retry decision for the batch generation it joined.
|
||||
func (c *Cache[K, V]) storeBatchIfEpoch(entries []batchStoreEntry[K, V], expected uint64) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.epoch != expected {
|
||||
return false
|
||||
}
|
||||
for _, entry := range entries {
|
||||
c.storeLocked(entry.key, entry.value, entry.hash)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func dedupeKeys[K comparable](keys []K) []K {
|
||||
seen := make(map[K]struct{}, len(keys))
|
||||
out := make([]K, 0, len(keys))
|
||||
|
|
@ -391,6 +549,26 @@ func (c *Cache[K, V]) InvalidateWhere(pred func(K) bool) {
|
|||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// InvalidateWhereValue is the dependency-aware form of InvalidateWhere. It is
|
||||
// intended for bounded composite snapshots whose invalidation key is carried
|
||||
// by the immutable cached value (for example channel_id -> owner dialog page).
|
||||
// pred runs under the cache lock and therefore must be fast and must not call
|
||||
// back into this cache.
|
||||
func (c *Cache[K, V]) InvalidateWhereValue(pred func(K, V) bool) {
|
||||
if c == nil || pred == nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.epoch++
|
||||
for key, el := range c.items {
|
||||
ent := el.Value.(*lruEntry[K, V])
|
||||
if pred(key, ent.value) {
|
||||
c.removeElement(el)
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// Flush 清空缓存并自增 epoch(监听器断线重连兜底)。
|
||||
func (c *Cache[K, V]) Flush() {
|
||||
if c == nil {
|
||||
|
|
@ -398,8 +576,15 @@ func (c *Cache[K, V]) Flush() {
|
|||
}
|
||||
c.mu.Lock()
|
||||
c.epoch++
|
||||
if c.onRemove != nil {
|
||||
for el := c.ll.Front(); el != nil; el = el.Next() {
|
||||
ent := el.Value.(*lruEntry[K, V])
|
||||
c.onRemove(ent.key, ent.value)
|
||||
}
|
||||
}
|
||||
c.ll.Init()
|
||||
c.items = make(map[K]*list.Element, initialMapHint(c.cap))
|
||||
c.weight = 0
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
|
|
@ -414,6 +599,19 @@ func (c *Cache[K, V]) Len() int {
|
|||
return n
|
||||
}
|
||||
|
||||
// Weight returns the current aggregate configured weight. It is intended for
|
||||
// bounded observability and tests; callers must not use it as a correctness
|
||||
// input because Weight is deliberately an approximation chosen by each cache.
|
||||
func (c *Cache[K, V]) Weight() int64 {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
c.mu.Lock()
|
||||
weight := c.weight
|
||||
c.mu.Unlock()
|
||||
return weight
|
||||
}
|
||||
|
||||
func (c *Cache[K, V]) cacheEpoch() uint64 {
|
||||
c.mu.Lock()
|
||||
e := c.epoch
|
||||
|
|
@ -438,9 +636,34 @@ func (c *Cache[K, V]) evictOldest() {
|
|||
}
|
||||
}
|
||||
|
||||
func (c *Cache[K, V]) evictOverflow() {
|
||||
for c.ll.Len() > c.cap || (c.maxWeight > 0 && c.weight > c.maxWeight) {
|
||||
if c.ll.Back() == nil {
|
||||
return
|
||||
}
|
||||
c.evictOldest()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache[K, V]) removeElement(el *list.Element) {
|
||||
ent := el.Value.(*lruEntry[K, V])
|
||||
if c.onRemove != nil {
|
||||
c.onRemove(ent.key, ent.value)
|
||||
}
|
||||
c.weight -= ent.weight
|
||||
c.ll.Remove(el)
|
||||
delete(c.items, el.Value.(*lruEntry[K, V]).key)
|
||||
delete(c.items, ent.key)
|
||||
}
|
||||
|
||||
func (c *Cache[K, V]) valueWeight(v V) int64 {
|
||||
if c.maxWeight <= 0 || c.weigh == nil {
|
||||
return 1
|
||||
}
|
||||
weight := c.weigh(v)
|
||||
if weight <= 0 {
|
||||
return 1
|
||||
}
|
||||
return weight
|
||||
}
|
||||
|
||||
func (c *Cache[K, V]) cloneValue(v V) V {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package readmodelcache
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
|
@ -81,6 +82,28 @@ func TestGetOrLoadSingleflightsConcurrentMiss(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestInvalidateWhereValueUsesImmutableDependency(t *testing.T) {
|
||||
type value struct{ channels []int64 }
|
||||
c := New[int, value](Config[int, value]{MaxEntries: 4})
|
||||
c.Store(1, value{channels: []int64{7, 8}})
|
||||
c.Store(2, value{channels: []int64{9}})
|
||||
|
||||
c.InvalidateWhereValue(func(_ int, v value) bool {
|
||||
for _, id := range v.channels {
|
||||
if id == 8 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
if _, ok := c.Peek(1); ok {
|
||||
t.Fatal("dependency match remained cached")
|
||||
}
|
||||
if got, ok := c.Peek(2); !ok || len(got.channels) != 1 || got.channels[0] != 9 {
|
||||
t.Fatalf("unrelated value = %+v,%v, want cached channel 9", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEpochGuardRejectsStaleWriteback 证明 epoch 守卫堵住 lost-update:一次锁外 load
|
||||
// 期间到达的 Invalidate 不得被这次 load 的(已陈旧)结果覆盖;在飞读者最终拿到的是
|
||||
// 失效后重载的新值,且缓存未被陈旧值污染。
|
||||
|
|
@ -178,6 +201,76 @@ func TestLRUTouchOnGet(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestWeightedLRUEvictsByTotalWeightAndSkipsOversize(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
c := New[int, int](Config[int, int]{
|
||||
MaxEntries: 10,
|
||||
MaxWeight: 5,
|
||||
Weight: func(v int) int64 { return int64(v) },
|
||||
})
|
||||
mustLoad(t, c, 1, 2)
|
||||
mustLoad(t, c, 2, 2)
|
||||
mustLoad(t, c, 3, 3)
|
||||
if _, ok := c.Peek(1); ok {
|
||||
t.Fatal("oldest entry should be evicted when aggregate weight exceeds five")
|
||||
}
|
||||
for _, key := range []int{2, 3} {
|
||||
if _, ok := c.Peek(key); !ok {
|
||||
t.Fatalf("weighted LRU lost retained key %d", key)
|
||||
}
|
||||
}
|
||||
loads := 0
|
||||
loadOversize := func() (int, error) { loads++; return 6, nil }
|
||||
if v, err := c.GetOrLoad(ctx, 4, loadOversize); err != nil || v != 6 {
|
||||
t.Fatalf("oversize first load = %d,%v", v, err)
|
||||
}
|
||||
if v, err := c.GetOrLoad(ctx, 4, loadOversize); err != nil || v != 6 {
|
||||
t.Fatalf("oversize second load = %d,%v", v, err)
|
||||
}
|
||||
if loads != 2 {
|
||||
t.Fatalf("oversize value unexpectedly retained: loads=%d, want 2", loads)
|
||||
}
|
||||
if _, ok := c.Peek(4); ok {
|
||||
t.Fatal("single value above MaxWeight must not remain cached")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLifecycleCallbacksCoverReplaceEvictAndFlush(t *testing.T) {
|
||||
type event struct {
|
||||
op string
|
||||
key int
|
||||
value string
|
||||
}
|
||||
var events []event
|
||||
c := New[int, string](Config[int, string]{
|
||||
MaxEntries: 2,
|
||||
OnStore: func(key int, value string) {
|
||||
events = append(events, event{op: "store", key: key, value: value})
|
||||
},
|
||||
OnRemove: func(key int, value string) {
|
||||
events = append(events, event{op: "remove", key: key, value: value})
|
||||
},
|
||||
})
|
||||
c.Store(1, "a")
|
||||
c.Store(1, "b")
|
||||
c.Store(2, "c")
|
||||
c.Store(3, "d")
|
||||
c.Flush()
|
||||
want := []event{
|
||||
{op: "store", key: 1, value: "a"},
|
||||
{op: "remove", key: 1, value: "a"},
|
||||
{op: "store", key: 1, value: "b"},
|
||||
{op: "store", key: 2, value: "c"},
|
||||
{op: "store", key: 3, value: "d"},
|
||||
{op: "remove", key: 1, value: "b"},
|
||||
{op: "remove", key: 3, value: "d"},
|
||||
{op: "remove", key: 2, value: "c"},
|
||||
}
|
||||
if !reflect.DeepEqual(events, want) {
|
||||
t.Fatalf("lifecycle events = %#v, want %#v", events, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionGateReloadsOnHashChange(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
c := New[int, string](Config[int, string]{MaxEntries: 16})
|
||||
|
|
@ -389,6 +482,74 @@ func TestGetOrLoadBatchCachesHitsMissesAndNegatives(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetOrLoadBatchCoalescesOverlappingConcurrentMissesPerKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
c := New[int, batchVal](Config[int, batchVal]{MaxEntries: 64})
|
||||
noVersion := func(int) (int64, bool) { return 0, true }
|
||||
|
||||
firstStarted := make(chan struct{})
|
||||
secondLoaded := make(chan struct{})
|
||||
releaseFirst := make(chan struct{})
|
||||
var calls atomic.Int32
|
||||
var mu sync.Mutex
|
||||
loadedKeys := make(map[int]int)
|
||||
load := func(_ context.Context, missing []int) (map[int]batchVal, error) {
|
||||
call := calls.Add(1)
|
||||
mu.Lock()
|
||||
for _, key := range missing {
|
||||
loadedKeys[key]++
|
||||
}
|
||||
mu.Unlock()
|
||||
if call == 1 {
|
||||
close(firstStarted)
|
||||
<-releaseFirst
|
||||
} else {
|
||||
close(secondLoaded)
|
||||
}
|
||||
out := make(map[int]batchVal, len(missing))
|
||||
for _, key := range missing {
|
||||
out[key] = batchVal{n: key * 10, found: true}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
firstResult := make(chan map[int]batchVal, 1)
|
||||
firstErr := make(chan error, 1)
|
||||
go func() {
|
||||
got, err := c.GetOrLoadBatch(ctx, []int{1, 2, 3}, noVersion, load)
|
||||
firstResult <- got
|
||||
firstErr <- err
|
||||
}()
|
||||
<-firstStarted
|
||||
|
||||
secondResult := make(chan map[int]batchVal, 1)
|
||||
secondErr := make(chan error, 1)
|
||||
go func() {
|
||||
got, err := c.GetOrLoadBatch(ctx, []int{2, 3, 4}, noVersion, load)
|
||||
secondResult <- got
|
||||
secondErr <- err
|
||||
}()
|
||||
<-secondLoaded
|
||||
close(releaseFirst)
|
||||
|
||||
first, second := <-firstResult, <-secondResult
|
||||
if err := <-firstErr; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := <-secondErr; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first[1].n != 10 || first[2].n != 20 || first[3].n != 30 ||
|
||||
second[2].n != 20 || second[3].n != 30 || second[4].n != 40 {
|
||||
t.Fatalf("overlapping results first=%+v second=%+v", first, second)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if calls.Load() != 2 || loadedKeys[1] != 1 || loadedKeys[2] != 1 || loadedKeys[3] != 1 || loadedKeys[4] != 1 {
|
||||
t.Fatalf("backend calls=%d loaded=%v, want two batches and every key exactly once", calls.Load(), loadedKeys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrLoadBatchVersionGateReloadsOnHashChange(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
c := New[int, batchVal](Config[int, batchVal]{MaxEntries: 64})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue