1
0
mirror of https://github.com/redis/go-redis.git synced 2026-10-22 19:49:00 +03:00
Files
go-redis/csc_refresh_on_invalidate_test.go
ofekshenawa 856a2f0acd feat(csc): derive command eligibility (#3960)
* feat(csc): metadata-driven command eligibility

* fix linting issues

* feat(csc): add dynamic metadata resolver

* fix incompatibillity issues

* fix issues

* fix issues

* fix issues

* feat(csc): share dynamic command metadata

* correct cluster metadata and routing

* Reuse identicle records without building it from scratch again

* Address comments

* Address comments

* Address comments

* Address ci failures

* harden metadata and cluster routing

* verify metadata and list all nodes

* fix hello callback

* Perf improvements

* fix bless scan routing methods

* refactor(csc): separate cluster routing

* Add server identification in HELLO && Split csc checks in autopipline
2026-10-20 16:33:57 +03:00

481 lines
18 KiB
Go

package redis
import (
"context"
"errors"
"strconv"
"sync/atomic"
"testing"
"time"
"github.com/redis/go-redis/v9/internal/pool"
)
// erroringPooler fails every Get, so refreshInvalidatedBatch (via withConn)
// returns an error deterministically without a live server. gets counts calls,
// so a test can assert the stop-drain never even attempts a round trip.
type erroringPooler struct {
pool.Pooler
gets atomic.Int64
}
func (p *erroringPooler) Get(context.Context) (*pool.Conn, error) {
p.gets.Add(1)
return nil, errors.New("csc test: pool get always fails")
}
// TestCSCRefresherStopDrainSkipsRefetchEntirely pins #3989 P1: the Close
// stop-drain must not spend ANY network round trip on the buffered backlog — a
// refetch here is always discarded a moment later by the teardown that follows
// (invalidateAllCoverage evicts every entry this connection would publish), so
// attempting it only delays Close. This supersedes #3965 F2's weaker "bail
// after the first failed chunk" bound, which never triggered against a HEALTHY
// server: with several chunks queued and Get never even called, the drain must
// abandon every target without touching the pool.
func TestCSCRefresherStopDrainSkipsRefetchEntirely(t *testing.T) {
// No cscRefreshWindow mutation: runCSCRefresher runs synchronously below with
// stop already signalled, so the drain completes in microseconds against the
// failing pool — the default window/recency timers never fire, and mutating the
// shared global would risk a -race read against another test's live refresher.
lc := NewLocalCache(CacheConfig{MaxEntries: 10000})
pooler := &erroringPooler{}
c := &baseClient{
opt: &Options{},
csc: lc,
cscKeyPrefix: "p:",
connPool: pooler,
}
q := &cscRefreshQueue{
ch: make(chan cscRefreshTarget, 1024),
demandCh: make(chan uint64, 1),
}
q.sinceToken.Store(cscInvalNoHorizon)
h := &cscRevalidateHandle{stop: make(chan struct{}), done: make(chan struct{})}
// More than one chunk's worth (cscRefreshBatchMax) but within one drainQueue
// round (< cscRefreshWindowMaxKeys) so the whole backlog is flushed at once; a
// regression that still refetches would then attempt multiple chunks.
const chunks = 3
const targets = chunks * cscRefreshBatchMax
for i := 0; i < targets; i++ {
key := cscEntryKey(c.cscKeyPrefix, c.metadataView().cscFingerprint, strconv.Itoa(i))
q.ch <- cscRefreshTarget{cacheKey: key, redisKeys: []string{key}}
}
// Signal stop up front, then run the refresher synchronously: it takes the
// stop-drain path, abandons the backlog, and returns.
close(h.stop)
c.runCSCRefresher(h, lc, q)
if got := pooler.gets.Load(); got != 0 {
t.Fatalf("pool.Get calls = %d; want 0 — the stop-drain must not attempt any "+
"refetch at all, not even the first chunk (#3989 P1)", got)
}
if got := q.refreshed.Load(); got != 0 {
t.Fatalf("refreshed = %d; want 0 — no refetch ran", got)
}
if got := q.refreshFailed.Load(); got != 0 {
t.Fatalf("refreshFailed = %d; want 0 — these targets were abandoned by choice, "+
"not by a failed round trip, so the round-trip-failure metric must stay "+
"untouched (its doc: \"counts refresh round trips that errored\")", got)
}
}
// stopClosingPooler closes stop on its first Get, then fails every Get, counting calls.
// It drives a NORMAL (non-stopping) refresh flush that is interrupted by Close mid-loop.
type stopClosingPooler struct {
pool.Pooler
stop chan struct{}
gets int
}
func (p *stopClosingPooler) Get(context.Context) (*pool.Conn, error) {
p.gets++
if p.gets == 1 {
close(p.stop) // Close arrives while this normal flush is already running
}
return nil, errors.New("csc test: pool get always fails")
}
// TestCSCRefresherNormalFlushAbortsOnClose pins r3937722481: a normal (non-stopping)
// refresh flush already running when Close begins must abort BETWEEN chunks instead of
// giving each remaining chunk a fresh cscRefreshBatchTimeout against a dead/stalled
// server (which, with reply budgeting producing one-target chunks, could hold Close for
// tens of minutes). A full window is 4 chunks; the first chunk's Get closes h.stop, so
// the between-chunks check must bail the rest — exactly ONE chunk is attempted.
//
// Red-check: drop the `if !stopping { select <-h.stop }` guard in flush's chunk loop —
// the normal flush ignores the now-closed stop and attempts all 4 chunks (gets == 4).
func TestCSCRefresherNormalFlushAbortsOnClose(t *testing.T) {
lc := NewLocalCache(CacheConfig{MaxEntries: 100000})
h := &cscRevalidateHandle{stop: make(chan struct{}), done: make(chan struct{})}
p := &stopClosingPooler{stop: h.stop}
c := &baseClient{opt: &Options{}, csc: lc, cscKeyPrefix: "p:", connPool: p}
q := &cscRefreshQueue{
ch: make(chan cscRefreshTarget, cscRefreshWindowMaxKeys+16),
demandCh: make(chan uint64, 1),
}
q.sinceToken.Store(cscInvalNoHorizon)
// A full window (4 * cscRefreshBatchMax) triggers a NORMAL flush(false,false) from the
// q.ch case — this is NOT the stop-drain path (h.stop is not pre-closed).
for i := 0; i < cscRefreshWindowMaxKeys; i++ {
key := cscEntryKey(c.cscKeyPrefix, c.metadataView().cscFingerprint, strconv.Itoa(i))
q.ch <- cscRefreshTarget{cacheKey: key, redisKeys: []string{key}}
}
done := make(chan struct{})
go func() { defer close(done); c.runCSCRefresher(h, lc, q) }()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("refresher did not converge — a normal flush kept refetching after Close began")
}
if p.gets != 1 {
t.Fatalf("pool Gets = %d; want 1 — a normal flush must abort mid-loop once Close begins "+
"(r3937722481), not attempt every chunk", p.gets)
}
}
func TestCloneSharesRefreshQueueForDemand(t *testing.T) {
parent := &baseClient{
opt: &Options{},
cscRefreshQueue: &cscRefreshQueue{demandCh: make(chan uint64, 1)},
}
// A key sitting in the refresher's window: signalDemand nudges only for these.
parent.cscRefreshQueue.pendingSet.Store("ck:x", struct{}{})
clone := parent.clone()
if clone.cscRefreshQueue != parent.cscRefreshQueue {
t.Fatal("clone did not share the owner's refresh queue; a clone's signalDemand " +
"would no-op on a nil queue (#3965 F4)")
}
// A demand nudge through the clone's pointer must reach the SHARED queue.
clone.cscRefreshQueue.signalDemand("ck:x")
select {
case <-parent.cscRefreshQueue.demandCh:
// The clone's signal landed on the owner's window.
default:
t.Fatal("clone signalDemand did not reach the shared queue's demand channel (#3965 F4)")
}
}
func TestCSCRefreshOnInvalidateTurnsAMissIntoAHit(t *testing.T) {
for _, enabled := range []bool{true, false} {
name := "enabled_next_read_is_a_hit"
if !enabled {
name = "disabled_next_read_is_a_miss"
}
t.Run(name, func(t *testing.T) {
ctx := context.Background()
cached := NewClient(&Options{
Addr: internalTestRedisAddr(),
Protocol: 3,
// Default DrainInterval: the invalidation must actually arrive.
ClientSideCacheConfig: &ClientSideCacheConfig{MaxEntries: 1000},
ClientSideCacheRefreshOnInvalidate: enabled,
})
seeder := NewClient(&Options{Addr: internalTestRedisAddr(), Protocol: 3})
if err := cached.Ping(ctx).Err(); err != nil {
cached.Close()
seeder.Close()
t.Skipf("no redis: %v", err)
}
defer cached.Close()
defer seeder.Close()
key := "cscrefresh:" + name
t.Cleanup(func() { seeder.Del(context.Background(), key) })
// Retry loop: the "recently read" horizon advances on a ticker, so a read
// can fall just the wrong side of it. Each attempt re-reads immediately
// before the write to put the entry firmly inside the window.
var observed bool
for attempt := 0; attempt < 12 && !observed; attempt++ {
if err := seeder.Set(ctx, key, "v1", 0).Err(); err != nil {
t.Fatal(err)
}
// Warm it, and mark it recently read.
if got, err := cached.Get(ctx, key).Result(); err != nil || got != "v1" {
t.Fatalf("warm read = %q, %v; want v1", got, err)
}
beforeRefresh := cached.CSCRefreshStats().Refreshed
if err := seeder.Set(ctx, key, "v2", 0).Err(); err != nil {
t.Fatal(err)
}
if !enabled {
// Give the drainer time to apply the invalidation, then confirm the
// read is a MISS: this is the behaviour the feature improves on.
time.Sleep(150 * time.Millisecond)
missesBefore := cached.CSCStats().Misses
if got, err := cached.Get(ctx, key).Result(); err != nil || got != "v2" {
t.Fatalf("read after invalidation = %q, %v; want v2", got, err)
}
if cached.CSCStats().Misses == missesBefore {
t.Fatal("with refresh-on-invalidate DISABLED the read after an " +
"invalidation was a cache HIT; this case cannot detect the " +
"feature working")
}
observed = true
break
}
// Enabled: wait for the background refresh to republish the entry.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if cached.CSCRefreshStats().Refreshed > beforeRefresh {
observed = true
break
}
time.Sleep(5 * time.Millisecond)
}
if !observed {
continue // the entry fell outside the recency window; try again
}
// The refreshed entry must serve the NEW value, with no miss.
missesBefore := cached.CSCStats().Misses
got, err := cached.Get(ctx, key).Result()
if err != nil {
t.Fatal(err)
}
if got != "v2" {
t.Fatalf("read after refresh = %q; want v2 — the refresh republished "+
"a stale value", got)
}
if cached.CSCStats().Misses != missesBefore {
t.Fatalf("read after refresh was a MISS; the whole point is that the "+
"background refresh had already put the new value in place "+
"(refreshed=%d)", cached.CSCRefreshStats().Refreshed)
}
}
if !observed {
t.Fatal("no attempt produced the behaviour under test in 12 tries")
}
})
}
}
func TestCSCRefreshDemandTriggerFlushesWindowEarly(t *testing.T) {
origWindow := cscRefreshWindow
cscRefreshWindow = 5 * time.Second // timer must not be the cause
defer func() { cscRefreshWindow = origWindow }()
ctx := context.Background()
cached := NewClient(&Options{
Addr: internalTestRedisAddr(),
Protocol: 3,
ClientSideCacheConfig: &ClientSideCacheConfig{MaxEntries: 1000},
ClientSideCacheRefreshOnInvalidate: true,
})
seeder := NewClient(&Options{Addr: internalTestRedisAddr(), Protocol: 3})
if err := cached.Ping(ctx).Err(); err != nil {
cached.Close()
seeder.Close()
t.Skipf("no redis: %v", err)
}
defer cached.Close()
defer seeder.Close()
const demandKey = "cscrefresh:demand:trigger"
const siblingKey = "cscrefresh:demand:sibling"
t.Cleanup(func() { seeder.Del(context.Background(), demandKey, siblingKey) })
var observed bool
for attempt := 0; attempt < 12 && !observed; attempt++ {
if err := seeder.MSet(ctx, demandKey, "d1", siblingKey, "s1").Err(); err != nil {
t.Fatal(err)
}
// Warm both and mark them recently read, so both are collected (the
// refresher ignores cold keys).
if _, err := cached.Get(ctx, demandKey).Result(); err != nil {
t.Fatal(err)
}
if got, err := cached.Get(ctx, siblingKey).Result(); err != nil || got != "s1" {
t.Fatalf("warm sibling = %q, %v; want s1", got, err)
}
beforeDemand := cached.CSCRefreshStats().DemandFlushes
beforeRefresh := cached.CSCRefreshStats().Refreshed
// Invalidate BOTH: each push deletes its entry and collects it into the
// window (both are recently read).
if err := seeder.MSet(ctx, demandKey, "d2", siblingKey, "s2").Err(); err != nil {
t.Fatal(err)
}
// Let the drainer apply both invalidations and the refresher collect them.
time.Sleep(120 * time.Millisecond)
// Read the demand key: it is a miss (just invalidated), which fires the
// demand signal and must flush the whole window — refetching the sibling.
if got, err := cached.Get(ctx, demandKey).Result(); err != nil || got != "d2" {
t.Fatalf("read demand key = %q, %v; want d2", got, err)
}
// The sibling must now refresh WELL within the 5s window. If it does, the
// timer cannot be responsible — only the demand flush.
deadline := time.Now().Add(1500 * time.Millisecond)
for time.Now().Before(deadline) {
if cached.CSCRefreshStats().Refreshed > beforeRefresh {
observed = true
break
}
time.Sleep(5 * time.Millisecond)
}
if !observed {
continue // entries fell outside the recency horizon; retry
}
if got := cached.CSCRefreshStats().DemandFlushes; got <= beforeDemand {
t.Fatalf("DemandFlushes did not advance (%d -> %d): the sibling refreshed "+
"but not via the demand trigger", beforeDemand, got)
}
// The sibling read must now be a HIT serving the new value: the demand
// flush refetched it a window early.
missesBefore := cached.CSCStats().Misses
if got, err := cached.Get(ctx, siblingKey).Result(); err != nil || got != "s2" {
t.Fatalf("sibling after demand flush = %q, %v; want s2", got, err)
}
if cached.CSCStats().Misses != missesBefore {
t.Fatal("sibling read after the demand flush was a MISS; the demand trigger " +
"should have refetched it when its neighbor was read")
}
}
if !observed {
t.Fatal("no attempt produced a demand-triggered refresh in 12 tries")
}
}
// TestCSCRefreshRepublishedEntryIsInvalidatable pins the invariant that the
// background refresh path publishes its entry on a CSC-TRACKED connection.
// Refresh reads run on a background pipelined connection (withPipelineConn),
// which on this base falls back to / draws from a pool whose connections all
// run CLIENT TRACKING while CSC is active. If a refetch ever published on an
// untracked connection, the republished entry could never be invalidated and
// would serve stale until TTL — the exact trap the coalescer's main-pool
// choice avoids. This test would catch that regression: it refreshes an entry,
// then mutates the key again from a second client and requires the caching
// client to converge to the new value. With no TTL set, convergence is only
// possible if the second invalidation was actually delivered, i.e. the
// republished entry was tracked.
func TestCSCRefreshRepublishedEntryIsInvalidatable(t *testing.T) {
ctx := context.Background()
cached := NewClient(&Options{
Addr: internalTestRedisAddr(),
Protocol: 3,
ClientSideCacheConfig: &ClientSideCacheConfig{MaxEntries: 1000},
ClientSideCacheRefreshOnInvalidate: true,
})
seeder := NewClient(&Options{Addr: internalTestRedisAddr(), Protocol: 3})
if err := cached.Ping(ctx).Err(); err != nil {
cached.Close()
seeder.Close()
t.Skipf("no redis: %v", err)
}
defer cached.Close()
defer seeder.Close()
key := "cscrefresh:republished-invalidatable"
t.Cleanup(func() { seeder.Del(context.Background(), key) })
var observed bool
for attempt := 0; attempt < 12 && !observed; attempt++ {
if err := seeder.Set(ctx, key, "v1", 0).Err(); err != nil {
t.Fatal(err)
}
// Warm it and mark it recently read so the refresher will collect it.
if got, err := cached.Get(ctx, key).Result(); err != nil || got != "v1" {
t.Fatalf("warm read = %q, %v; want v1", got, err)
}
beforeRefresh := cached.CSCRefreshStats().Refreshed
if err := seeder.Set(ctx, key, "v2", 0).Err(); err != nil {
t.Fatal(err)
}
// Wait for the background refresh to REPUBLISH the entry.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if cached.CSCRefreshStats().Refreshed > beforeRefresh {
break
}
time.Sleep(5 * time.Millisecond)
}
if cached.CSCRefreshStats().Refreshed == beforeRefresh {
continue // the entry fell outside the recency window; retry
}
if got, err := cached.Get(ctx, key).Result(); err != nil || got != "v2" {
t.Fatalf("read after refresh = %q, %v; want v2", got, err)
}
// Discriminating check: mutate the key again from the second client. The
// republished entry must be invalidated and the client must converge to
// v3. No TTL is set, so if the republished entry were published on an
// untracked connection no invalidation would arrive and the client would
// serve v2 forever — this poll would then time out.
if err := seeder.Set(ctx, key, "v3", 0).Err(); err != nil {
t.Fatal(err)
}
converge := time.Now().Add(2 * time.Second)
for time.Now().Before(converge) {
if got, _ := cached.Get(ctx, key).Result(); got == "v3" {
observed = true
break
}
time.Sleep(10 * time.Millisecond)
}
if !observed {
t.Fatal("after a background refresh republished the entry, a later " +
"invalidation never reached the client: the republished entry was " +
"NOT server-side tracked and would serve stale until TTL")
}
}
if !observed {
t.Fatal("no attempt produced a background refresh to test in 12 tries")
}
}
// TestRefreshInvalidatedBatchCancelsReservationsOnSizerPanic pins #3989: if a user
// CacheSizer panics inside Reserve mid reservation loop, the targets reserved so far
// must be cancelled during unwind (the cancellation defer is armed BEFORE the loop),
// not left IN_PROGRESS for readers to block on until StaleTimeout.
func TestRefreshInvalidatedBatchCancelsReservationsOnSizerPanic(t *testing.T) {
calls := 0
sizer := func(_ string, _ []string, value []byte) int64 {
calls++
if calls == 2 {
panic("boom: CacheSizer panic mid reservation loop")
}
return int64(len(value))
}
lc := NewLocalCache(CacheConfig{MaxEntries: 64, Sizer: sizer})
c := &baseClient{csc: lc, cscKeyPrefix: "p"}
prefix := cscEntryKey(c.cscKeyPrefix, c.metadataView().cscFingerprint, "")
targets := []cscRefreshTarget{
{cacheKey: prefix + "ck:1", redisKeys: []string{"rk:1"}},
{cacheKey: prefix + "ck:2", redisKeys: []string{"rk:2"}},
}
func() {
defer func() {
if r := recover(); r == nil {
t.Fatal("expected the sizer panic to propagate (the outer refresher recovers it)")
}
}()
_, _ = c.refreshInvalidatedBatch(context.Background(), targets)
}()
// ck:1 was reserved before the panic on ck:2; the defer must have cancelled it, so a
// fresh Reserve sees no lingering IN_PROGRESS placeholder (shouldFetch, non-zero tok).
tok, shouldFetch := lc.Reserve(targets[0].cacheKey, targets[0].redisKeys)
if !shouldFetch || tok == 0 {
t.Fatalf("ck:1 reservation not cancelled after the panic (Reserve = %d, %v); a reader "+
"would block on the orphaned placeholder until StaleTimeout (#3989)", tok, shouldFetch)
}
}