Files
go-redis/csc_inval_batch_accounting_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

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