mirror of
https://github.com/redis/go-redis.git
synced 2026-10-28 08:24:54 +08:00
* 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.
106 lines
3.0 KiB
Go
106 lines
3.0 KiB
Go
package redis
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
)
|
|
|
|
// The batched invalidation apply must report deletions exactly as the
|
|
// single-key path does, including the no-op count.
|
|
//
|
|
// The first version of the batch path derived no-ops from the batch total --
|
|
// "nothing in this batch matched" -- which silently undercounted the common
|
|
// case where some keys match and others do not. DeletionStats is public, so
|
|
// that is a user-visible metric regression, and it is invisible in a
|
|
// throughput benchmark.
|
|
func TestDeleteManyByRedisKeyDeletionAccounting(t *testing.T) {
|
|
newCache := func() *LocalCache {
|
|
return NewLocalCache(CacheConfig{
|
|
MaxEntries: 128,
|
|
})
|
|
}
|
|
ctx := context.Background()
|
|
|
|
// Seed one entry under redis key "present".
|
|
seed := func(c *LocalCache, cacheKey, redisKey string) {
|
|
tok, ok := c.Reserve(cacheKey, []string{redisKey})
|
|
if !ok || tok == 0 {
|
|
t.Fatalf("Reserve(%q) failed", cacheKey)
|
|
}
|
|
if !c.fulfill(cacheKey, tok, 0, []byte("v")) {
|
|
t.Fatalf("Fulfill(%q) failed", cacheKey)
|
|
}
|
|
if _, hit := c.Get(ctx, cacheKey); !hit {
|
|
t.Fatalf("expected %q cached", cacheKey)
|
|
}
|
|
}
|
|
|
|
t.Run("mixed batch counts one no-op per unmatched key", func(t *testing.T) {
|
|
c := newCache()
|
|
seed(c, "ck1", "present")
|
|
|
|
// One key matches, four do not.
|
|
keys := []string{"present", "absent1", "absent2", "absent3", "absent4"}
|
|
since := make([]int64, len(keys))
|
|
snaps := make([]uint64, len(keys))
|
|
for i := range snaps {
|
|
snaps[i] = ^uint64(0)
|
|
}
|
|
_, removed := c.deleteManyByRedisKeyCollectingHot(keys, since, snaps, nil)
|
|
if removed != 1 {
|
|
t.Fatalf("removed = %d, want 1", removed)
|
|
}
|
|
del, noop := c.DeletionStats()
|
|
if del != uint64(len(keys)) {
|
|
t.Errorf("deletions = %d, want %d", del, len(keys))
|
|
}
|
|
// Four keys matched nothing. Deriving this from the batch total
|
|
// would report 0 here.
|
|
if noop != 4 {
|
|
t.Errorf("noop = %d, want 4 (one per unmatched key)", noop)
|
|
}
|
|
})
|
|
|
|
t.Run("matches the single-key path", func(t *testing.T) {
|
|
batch := newCache()
|
|
single := newCache()
|
|
seed(batch, "ck1", "present")
|
|
seed(single, "ck1", "present")
|
|
|
|
keys := []string{"present", "absent1", "absent2"}
|
|
since := make([]int64, len(keys))
|
|
snaps := make([]uint64, len(keys))
|
|
for i := range snaps {
|
|
snaps[i] = ^uint64(0)
|
|
}
|
|
batch.deleteManyByRedisKeyCollectingHot(keys, since, snaps, nil)
|
|
for _, k := range keys {
|
|
single.DeleteByRedisKey(k)
|
|
}
|
|
|
|
bd, bn := batch.DeletionStats()
|
|
sd, sn := single.DeletionStats()
|
|
if bd != sd || bn != sn {
|
|
t.Errorf("batch (del=%d noop=%d) != single (del=%d noop=%d)", bd, bn, sd, sn)
|
|
}
|
|
})
|
|
|
|
t.Run("whole batch unmatched", func(t *testing.T) {
|
|
c := newCache()
|
|
keys := []string{"a", "b", "c"}
|
|
since := make([]int64, len(keys))
|
|
snaps := make([]uint64, len(keys))
|
|
for i := range snaps {
|
|
snaps[i] = ^uint64(0)
|
|
}
|
|
_, removed := c.deleteManyByRedisKeyCollectingHot(keys, since, snaps, nil)
|
|
if removed != 0 {
|
|
t.Fatalf("removed = %d, want 0", removed)
|
|
}
|
|
del, noop := c.DeletionStats()
|
|
if del != 3 || noop != 3 {
|
|
t.Errorf("deletions=%d noop=%d, want 3/3", del, noop)
|
|
}
|
|
})
|
|
}
|