mirror of
https://github.com/redis/go-redis.git
synced 2026-10-28 08:24:54 +08:00
* feat(csc)!: always refresh invalidated entries ClientSideCacheRefreshRecencyWindow bounded refresh to entries read within a window. It was the only consumer that needed a per-READ recency timestamp on the cache entry, and that timestamp is the dominant cost of a cache hit. Removing the option lets the read path stop stamping entries, which the following commit does. Refresh now covers every invalidated entry. BREAKING CHANGE: Options.ClientSideCacheRefreshRecencyWindow is removed. Refresh-on-invalidate always refreshes every invalidated entry. * perf(csc): mark reads with a second-chance bit A cache hit stamped entry.lastAccessNs. The read already touches the entry's cache line, but a store dirties it, so the line must be held exclusive and costs a writeback when evicted from the CPU cache. With many concurrent readers over a working set larger than L2, that store dominates the cost of a hit. A read now sets a readSinceSweep bit instead. A load that finds it set leaves the line SHARED, so every core can hold it at once and only the first read after an eviction sweep writes. Eviction consumes the bit as a second chance; ordering still uses lastAccessNs, written on insert. Measured at 256 concurrent callers, get=90/set=10, 99.94% hit rate: 263,801 -> 328,842 reads/s (+24.7%), CPU 27.0 -> 18.9 us/op, hit rate unchanged. Removing the recency update entirely measured +40.5%, so this recovers about three fifths of the headroom while keeping eviction honest. * perf(csc): decode simple cached replies without a reader Serving a hit allocated a proto.Reader (and its bufio buffer) per hit and re-parsed the cached bytes. applyCachedFast decodes the simple single-frame replies -- nil, status, bulk string -- straight into the Cmder, and getShared serves the bytes without the defensive copy where the caller provably cannot retain them. * fix(csc): do not evict the entry just fulfilled fulfill publishes an entry with its second-chance bit clear and then evicts. In a warm memory-capped shard, where every resident entry has been read, that entry was the only clear-bit candidate and was evicted on the spot; and since a clear candidate always existed, the sweep that resets the others never ran, so the shard admitted nothing new. The entry just fulfilled is now skipped in its own eviction pass. A refresh republish also restores the invalidated entry's read bit with its access token, so a refreshed hot key is not the first victim. * fix(csc): decline negative bulk lengths other than -1 The fast cached-reply decoder took any negative bulk length as a RESP2 null. The reader rejects lengths below -1, and that rejection is what makes processCached drop a bad entry and refetch; the fast path turned it into a false miss. It now handles only -1 and declines the rest. * fix(csc): reject bare LF in cached status replies The fast cached-reply decoder rejected a status frame with an interior CR but took one with a bare LF, such as "+OK\nMORE\r\n", as a valid string. The reader stops at the first LF and rejects the frame, and that rejection is what makes processCached drop the entry and refetch. The fast path now declines an interior CR or LF. * fix(csc): keep refresh recency in the publish lock A refresh republish published the entry with its second-chance bit clear and restored the bit in a second lock. On a warm shard the republished entry was then the only clear-bit victim, so an insert in that gap evicted the key the refresh had just fetched. The refresher now stages the kept recency on its reservation (stageRefreshAccess), and fulfill applies it in the same lock as the publish. * docs(csc)!: refresh-on-invalidate refreshes all ClientSideCacheRefreshOnInvalidate still said it re-fetches recently read keys. Since the recency window was removed, it re-fetches every cached entry of an invalidated key, so on a write-heavy keyspace the refetch traffic covers the whole resident cache. BREAKING CHANGE: Options.ClientSideCacheRefreshRecencyWindow and UniversalOptions.ClientSideCacheRefreshRecencyWindow are removed. They shipped only in v9.23.0-beta.1; delete the field where it is set. * docs(csc): note reads are not monotonic per caller A read that loses the reservation twice reads the server directly. Another caller's fetch, reserved earlier and still running, can store an older value afterwards, and a later hit returns it until the key's invalidation arrives. This is within the staleness a hit already has, so document it rather than drop the older fetch, which would cost its waiters a miss each under the churn that leads here.
281 lines
11 KiB
Go
281 lines
11 KiB
Go
package redis
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
)
|
|
|
|
func TestCollectHotAndDeleteNoHorizonRefreshesEverything(t *testing.T) {
|
|
ctx := context.Background()
|
|
lc := NewLocalCache(CacheConfig{MaxEntries: 64})
|
|
|
|
tok, fetch := lc.Reserve("ck:old", []string{"rk:old"})
|
|
if tok == 0 || !fetch {
|
|
t.Fatalf("Reserve(ck:old) = (%d, %v)", tok, fetch)
|
|
}
|
|
if !lc.fulfill("ck:old", tok, 0, []byte("v")) {
|
|
t.Fatal("fulfill(ck:old) failed")
|
|
}
|
|
|
|
// A horizon taken long after the entry's last read would normally exclude
|
|
// it (see TestCollectHotAndDeleteRecencyFilter). cscInvalNoHorizon must
|
|
// override that and collect it anyway.
|
|
for i := 0; i < 100; i++ {
|
|
lruSequence.Add(1)
|
|
}
|
|
|
|
got := lc.deleteByRedisKeyCollectingHot("rk:old", cscInvalNoHorizon, ^uint64(0), nil)
|
|
if len(got) != 1 || got[0].cacheKey != "ck:old" {
|
|
t.Fatalf("cscInvalNoHorizon did not collect the entry as a refetch target: %v", got)
|
|
}
|
|
if _, ok := lc.Get(ctx, "ck:old"); ok {
|
|
t.Fatal("entry was not deleted by the invalidation")
|
|
}
|
|
}
|
|
|
|
func TestCollectHotFetchOrderGuard(t *testing.T) {
|
|
ctx := context.Background()
|
|
lc := NewLocalCache(CacheConfig{MaxEntries: 64})
|
|
mkValid := func(cacheKey string, redisKeys ...string) {
|
|
lc.DeleteByCacheKey(cacheKey)
|
|
tok, fetch := lc.Reserve(cacheKey, redisKeys)
|
|
if tok == 0 || !fetch {
|
|
t.Fatalf("Reserve(%q) = (%d, %v)", cacheKey, tok, fetch)
|
|
}
|
|
if !lc.fulfill(cacheKey, tok, 0, []byte("v")) {
|
|
t.Fatalf("fulfill(%q) failed", cacheKey)
|
|
}
|
|
}
|
|
|
|
// Entry fetched, then an invalidation observed: snapshot the fetch clock now.
|
|
mkValid("ck", "rk")
|
|
snapStale := cscFetchSeq.Load()
|
|
|
|
// The entry is refetched AFTER the invalidation was observed (a refresh or a
|
|
// concurrent read miss): its fetchSeq now exceeds the stale snapshot.
|
|
mkValid("ck", "rk")
|
|
|
|
// Stale invalidation applied: guard keeps the refetched entry and does not offer
|
|
// it for refetch.
|
|
if got := lc.deleteByRedisKeyCollectingHot("rk", 0, snapStale, nil); len(got) != 0 {
|
|
t.Fatalf("refetched entry offered as a refetch target: %v", got)
|
|
}
|
|
if _, ok := lc.Get(ctx, "ck"); !ok {
|
|
t.Fatal("stale invalidation evicted an entry refetched after it (fetch-order guard failed)")
|
|
}
|
|
|
|
// A GENUINE later invalidation (observed now, after the refetch) must evict.
|
|
snapFresh := cscFetchSeq.Load()
|
|
lc.deleteByRedisKeyCollectingHot("rk", 0, snapFresh, nil)
|
|
if _, ok := lc.Get(ctx, "ck"); ok {
|
|
t.Fatal("genuine post-refetch invalidation did not evict the entry")
|
|
}
|
|
}
|
|
|
|
// TestCollectHotMultiKeyRefreshSurvives pins the multi-key variant of #3965 that
|
|
// dedup (seen) structurally cannot fix: one cached command depends on several
|
|
// Redis keys, so a refresh triggered by one key's invalidation republishes the
|
|
// entry, and another key's (stale) invalidation must not re-evict it. seen is keyed
|
|
// by Redis key, so k1 and k2 are different dedup entries — only the per-entry
|
|
// fetch-order guard can reason about "was THIS entry refetched after me".
|
|
func TestCollectHotMultiKeyRefreshSurvives(t *testing.T) {
|
|
ctx := context.Background()
|
|
lc := NewLocalCache(CacheConfig{MaxEntries: 64})
|
|
mkValid := func(cacheKey string, redisKeys ...string) {
|
|
lc.DeleteByCacheKey(cacheKey)
|
|
tok, fetch := lc.Reserve(cacheKey, redisKeys)
|
|
if tok == 0 || !fetch {
|
|
t.Fatalf("Reserve(%q) = (%d, %v)", cacheKey, tok, fetch)
|
|
}
|
|
if !lc.fulfill(cacheKey, tok, 0, []byte("v")) {
|
|
t.Fatalf("fulfill(%q) failed", cacheKey)
|
|
}
|
|
}
|
|
|
|
// Two cached commands sharing k2: C1 = MGET k1 k2, C2 = GET k2.
|
|
mkValid("mget:k1k2", "k1", "k2")
|
|
mkValid("get:k2", "k2")
|
|
|
|
// Both k1 and k2 change; the client observes both invalidations now (same push,
|
|
// so one snapshot covers both items).
|
|
snap := cscFetchSeq.Load()
|
|
|
|
// k1's invalidation applies first: it deletes C1 (depends on k1).
|
|
if _, ok := lc.Get(ctx, "mget:k1k2"); !ok {
|
|
t.Fatal("precondition: C1 should be present")
|
|
}
|
|
lc.deleteByRedisKeyCollectingHot("k1", 0, snap, nil)
|
|
if _, ok := lc.Get(ctx, "mget:k1k2"); ok {
|
|
t.Fatal("k1 invalidation should have deleted C1")
|
|
}
|
|
|
|
// The refresh refetches C1 (MGET k1 k2) — issued after both writes, so the reply
|
|
// already reflects the new k2. fetchSeq now exceeds snap.
|
|
mkValid("mget:k1k2", "k1", "k2")
|
|
|
|
// k2's invalidation applies second, fanning out to BOTH C1 and C2 via byRedisKey.
|
|
// C1 was refetched after snap -> guard keeps it; C2 predates snap -> evicted.
|
|
lc.deleteByRedisKeyCollectingHot("k2", 0, snap, nil)
|
|
|
|
if _, ok := lc.Get(ctx, "mget:k1k2"); !ok {
|
|
t.Fatal("k2's stale invalidation evicted the refetched multi-key entry C1 " +
|
|
"(guard must keep an MGET result refetched after the write)")
|
|
}
|
|
if _, ok := lc.Get(ctx, "get:k2"); ok {
|
|
t.Fatal("k2's invalidation should have evicted C2 (GET k2 was not refetched)")
|
|
}
|
|
}
|
|
|
|
// TestCSCRefreshReplyCacheable pins the refresh cacheability decision on the FULL
|
|
// reply, not raw[0]. A RESP3 attribute-prefixed error leads with RespAttr ('|'),
|
|
// so a first-byte error check would publish an attributed error as a fresh entry
|
|
// (a false success that bumps Refreshed and forces the next reader to evict and
|
|
// refetch). A nil reply IS cacheable; an empty reply never is.
|
|
func TestCSCRefreshReplyCacheable(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
raw string
|
|
cacheable bool
|
|
}{
|
|
{"plain value", "$2\r\nhi\r\n", true},
|
|
{"resp3 nil", "_\r\n", true},
|
|
{"resp2 nil", "$-1\r\n", true},
|
|
{"top-level error", "-WRONGTYPE nope\r\n", false},
|
|
{"blob error", "!11\r\nERR bad arg\r\n", false},
|
|
// The bug: the error frame is prefixed by a RESP3 attribute, so raw[0]=='|'.
|
|
{"attribute-prefixed error", "|1\r\n$3\r\nttl\r\n:100\r\n-WRONGTYPE nope\r\n", false},
|
|
{"attribute-prefixed value", "|1\r\n$3\r\nttl\r\n:100\r\n$2\r\nhi\r\n", true},
|
|
{"empty", "", false},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := cscRefreshReplyCacheable([]byte(tc.raw)); got != tc.cacheable {
|
|
t.Errorf("%s: cscRefreshReplyCacheable = %v, want %v", tc.name, got, tc.cacheable)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCSCRefreshChunkEnd pins the refresh round-trip boundaries: count cap,
|
|
// request-byte budget (key wire form), and the expected-REPLY-byte budget —
|
|
// the reply side is what jams a flow-controlled path, so large evicted values
|
|
// must shrink the chunk. The first target always goes.
|
|
func TestCSCRefreshChunkEnd(t *testing.T) {
|
|
mk := func(key string, val int) cscRefreshTarget {
|
|
return cscRefreshTarget{cacheKey: key, valBytes: val}
|
|
}
|
|
|
|
// Reply budget splits the chunk: three 15KiB values fit two per chunk
|
|
// under the 32KiB reply budget (request bytes tiny).
|
|
big := []cscRefreshTarget{mk("get:a", 15<<10), mk("get:b", 15<<10), mk("get:c", 15<<10)}
|
|
if got := cscRefreshChunkEnd(big, 0, 0, 1<<20); got != 2 {
|
|
t.Fatalf("reply budget: end = %d, want 2 (two 15KiB replies fit, third exceeds 32KiB)", got)
|
|
}
|
|
if got := cscRefreshChunkEnd(big, 2, 0, 1<<20); got != 3 {
|
|
t.Fatalf("tail: end = %d, want 3", got)
|
|
}
|
|
|
|
// A single oversized value still goes (progress guarantee).
|
|
huge := []cscRefreshTarget{mk("get:x", 1<<20), mk("get:y", 1)}
|
|
if got := cscRefreshChunkEnd(huge, 0, 0, 1<<20); got != 1 {
|
|
t.Fatalf("oversized first: end = %d, want 1 (always include the first target)", got)
|
|
}
|
|
|
|
// Request-byte budget still enforced: long keys, zero-size values.
|
|
longKey := make([]byte, 100)
|
|
for i := range longKey {
|
|
longKey[i] = 'k'
|
|
}
|
|
keys := []cscRefreshTarget{mk(string(longKey), 0), mk(string(longKey), 0), mk(string(longKey), 0)}
|
|
if got := cscRefreshChunkEnd(keys, 0, 0, 150); got != 1 {
|
|
t.Fatalf("request budget: end = %d, want 1 (second 100B key exceeds 150B budget)", got)
|
|
}
|
|
|
|
// Count cap: many tiny targets stop at cscRefreshBatchMax.
|
|
many := make([]cscRefreshTarget, cscRefreshBatchMax+10)
|
|
for i := range many {
|
|
many[i] = mk("get:k", 1)
|
|
}
|
|
if got := cscRefreshChunkEnd(many, 0, 0, 1<<20); got != cscRefreshBatchMax {
|
|
t.Fatalf("count cap: end = %d, want %d", got, cscRefreshBatchMax)
|
|
}
|
|
}
|
|
|
|
// TestCSCRefreshDemandGenerationIgnoresStaleSignal pins the demand generation gate:
|
|
// a demand nudge is stamped with the window generation it belongs to, and the
|
|
// refresher ignores a nudge whose generation was retired — so a nudge for an
|
|
// already-flushed window cannot flush the NEXT one early (which would defeat the
|
|
// coalescing window and inflate DemandFlushes). Pins signalDemand's stamping and the
|
|
// demandIsCurrent truth table (the goroutine race lives inside runCSCRefresher).
|
|
func TestCSCRefreshDemandGenerationIgnoresStaleSignal(t *testing.T) {
|
|
if !cscDemandRefresh {
|
|
t.Skip("demand refresh disabled")
|
|
}
|
|
q := &cscRefreshQueue{demandCh: make(chan uint64, 1)}
|
|
|
|
q.pendingSet.Store("ck", struct{}{})
|
|
q.signalDemand("ck")
|
|
var stale uint64
|
|
select {
|
|
case stale = <-q.demandCh:
|
|
default:
|
|
t.Fatal("signalDemand did not stamp a nudge for a pending key")
|
|
}
|
|
if !q.demandIsCurrent(stale) {
|
|
t.Fatal("a nudge for the CURRENT window must be honored")
|
|
}
|
|
|
|
q.pendingSet.Delete("ck")
|
|
q.demandGen.Add(1) // refresher clears window N and retires its generation
|
|
|
|
if q.demandIsCurrent(stale) {
|
|
t.Fatal("a nudge from a retired generation must be ignored")
|
|
}
|
|
|
|
q.pendingSet.Store("ck2", struct{}{})
|
|
q.signalDemand("ck2")
|
|
select {
|
|
case g := <-q.demandCh:
|
|
if !q.demandIsCurrent(g) {
|
|
t.Fatal("a nudge for the new current window must be honored")
|
|
}
|
|
default:
|
|
t.Fatal("signalDemand did not stamp a nudge for the new window's key")
|
|
}
|
|
}
|
|
|
|
// TestCollectHotGuardKeepsInProgress pins that the fetch-order guard covers
|
|
// in-progress reservations, not only Valid entries (#3989). A reservation issued
|
|
// AFTER the invalidation was observed (fetchSeq > snapshot) cannot hold the
|
|
// pre-write value, so it must survive — removing it would fail its racing Fulfill
|
|
// and wake every waiter as a miss. A reservation issued at or before the observe is
|
|
// still removed (it may predate the write).
|
|
func TestCollectHotGuardKeepsInProgress(t *testing.T) {
|
|
ctx := context.Background()
|
|
lc := NewLocalCache(CacheConfig{MaxEntries: 64})
|
|
|
|
// In-progress reservation issued BEFORE the invalidation is observed.
|
|
tokOld, fetchOld := lc.Reserve("ck:old", []string{"rk"})
|
|
if tokOld == 0 || !fetchOld {
|
|
t.Fatalf("Reserve(ck:old) = (%d, %v)", tokOld, fetchOld)
|
|
}
|
|
snap := cscFetchSeq.Load()
|
|
// In-progress reservation issued AFTER the observe.
|
|
tokNew, fetchNew := lc.Reserve("ck:new", []string{"rk"})
|
|
if tokNew == 0 || !fetchNew {
|
|
t.Fatalf("Reserve(ck:new) = (%d, %v)", tokNew, fetchNew)
|
|
}
|
|
|
|
lc.deleteByRedisKeyCollectingHot("rk", 0, snap, nil)
|
|
|
|
// Pre-observe reservation removed: its racing Fulfill now fails.
|
|
if lc.fulfill("ck:old", tokOld, 0, []byte("v")) {
|
|
t.Fatal("pre-observe in-progress reservation should have been removed (Fulfill must fail)")
|
|
}
|
|
// Post-observe reservation kept: its Fulfill still publishes.
|
|
if !lc.fulfill("ck:new", tokNew, 0, []byte("v")) {
|
|
t.Fatal("post-observe in-progress reservation was wrongly removed (Fulfill failed)")
|
|
}
|
|
if _, ok := lc.Get(ctx, "ck:new"); !ok {
|
|
t.Fatal("post-observe in-progress entry did not survive the invalidation")
|
|
}
|
|
}
|