1
0
mirror of https://github.com/redis/go-redis.git synced 2026-10-24 07:59:20 +03:00
Files
go-redis/local_cache_batch_read_bit_test.go
Nedyalko Dyakov f5ef5ed354 fix(csc): apply invalidation batches in one shard pass (#4033)
* fix(csc): apply invalidation batches in one shard pass

ClientSideCacheInvalidationBatchWindow applied only a fraction of the
invalidations it received under load. apply() looped per key calling
deleteByRedisKeyCollectingHot, and that walks all 16 shards taking each
write lock, so one worker doing 16*N contended acquisitions fell
permanently behind the arriving stream. The rest stayed in the spill
buffer, and the cache went on serving values the server had invalidated
while reporting a ~100% hit rate.

Measured on a 20k/sec invalidation stream: 7,729 of 68,846 enqueued
invalidations applied (15.5%), with nothing dropped by policy
(discarded=0, epochSkip=0).

Invert the loops: take each shard's lock once per batch and handle every
key of the batch under it, so a batch costs 16 acquisitions instead of
16 per key. Per-key semantics (fetch-order guard, recency horizon,
target collection) are unchanged; only the locking granularity differs.
Invalidations applied goes to 100%.

* docs(csc): note that cached reads are not monotonic

The invalidation batch window already documents staleness -- a reader can
see the pre-invalidation value for up to the window. It does not document
the ordering consequence: a caller reading through a non-cached path can
observe a newer value than a concurrent in-flight fetch is about to
publish, and a later cached read then returns the older one.

This is inherent to asynchronous invalidation, not specific to the window.
At publish time the client cannot know its in-flight value is already
stale, because the only signal is a push that has not arrived yet; a guard
that refuses to publish values superseded by an observed invalidation was
tried and never fires. The window widens the exposure, because a published
value stays readable until its invalidation is APPLIED rather than
observed.

Observed roughly once per 44M reads with a 5ms window, and not at all with
the window off.

* fix(csc): keep the read bit on batched refreshes

collectHotAndDeleteBatch built the refresh target without the entry's
second-chance bit, which collectHotAndDelete copies for
stageRefreshAccess. With the invalidation batch window and refresh on,
a refreshed hot key was republished with its bit clear and was the next
eviction victim in a warm shard. Copy it, as the single-key path does.
2026-10-01 17:45:12 +03:00

40 lines
1.4 KiB
Go

package redis
import (
"context"
"testing"
)
// The batched invalidation path must hand the refresher the same target the
// single-key path does, second-chance bit included: stageRefreshAccess
// republishes the entry with it. The batch collector left `read` false, so
// with the batch window and refresh on, a refreshed hot key came back with its
// bit clear and was the next eviction victim in a warm shard.
func TestBatchedInvalidationKeepsReadBit(t *testing.T) {
ctx := context.Background()
for _, batched := range []bool{false, true} {
// A full shard, so the read is recorded even under the pressure gate.
lc := NewLocalCache(CacheConfig{MaxEntries: 1})
const key, rk = "get:k", "rk"
tok, _ := lc.Reserve(key, []string{rk})
if !lc.fulfill(key, tok, 0, []byte("v")) {
t.Fatal("seed fulfill failed")
}
if _, ok := lc.Get(ctx, key); !ok {
t.Fatal("seeded key missing")
}
var targets []cscRefreshTarget
if batched {
targets, _ = lc.deleteManyByRedisKeyCollectingHot([]string{rk}, []int64{cscInvalNoHorizon}, []uint64{^uint64(0)}, nil)
} else {
targets = lc.deleteByRedisKeyCollectingHot(rk, cscInvalNoHorizon, ^uint64(0), nil)
}
if len(targets) != 1 {
t.Fatalf("batched=%v: %d targets, want 1", batched, len(targets))
}
if !targets[0].read {
t.Fatalf("batched=%v: the target lost the read bit the entry had", batched)
}
}
}