mirror of
https://github.com/redis/go-redis.git
synced 2026-10-28 08:24:54 +08:00
* feat(command): add metadata-driven routing * fix(cluster): preserve routing compatibility * fix spellcheck ci * fix(cluster): harden metadata routing - Preserve legacy routing, hook order and typed results. - Handle topology health and invalid metadata safely. - Reject fan-out from ordered async pipelines before dispatch. - Reduce allocations and duplicate routing work. - Add regression coverage and document remaining limitations. * fix(cluster): correct routing and cursor ownership - Handle unavailable nodes and malformed topology replies - Bound metadata verification and retry refreshes in the background - Bind client-local cursor tokens to their originating nodes - Validate routing policies and refresh topology after redirects - Reduce routing allocations and extend tests and documentation * fix spellcheck ci * fix ci * fix ci * refactor(cluster): split cursor ownership * fix cursor bug * fix ci
2554 lines
84 KiB
Go
2554 lines
84 KiB
Go
package redis
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"reflect"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/redis/go-redis/v9/internal/hashtag"
|
|
"github.com/redis/go-redis/v9/internal/pool"
|
|
"github.com/redis/go-redis/v9/internal/proto"
|
|
"github.com/redis/go-redis/v9/internal/routing"
|
|
"github.com/redis/go-redis/v9/maintnotifications"
|
|
)
|
|
|
|
func newClusterCSCTestServer(t testing.TB, reply func(string) string) string {
|
|
t.Helper()
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var mu sync.Mutex
|
|
var conns []net.Conn
|
|
var wg sync.WaitGroup
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
mu.Lock()
|
|
conns = append(conns, conn)
|
|
mu.Unlock()
|
|
wg.Add(1)
|
|
go func() { defer wg.Done(); serveTestRESPConn(conn, reply) }()
|
|
}
|
|
}()
|
|
t.Cleanup(func() {
|
|
_ = ln.Close()
|
|
mu.Lock()
|
|
for _, c := range conns {
|
|
_ = c.Close()
|
|
}
|
|
mu.Unlock()
|
|
wg.Wait()
|
|
})
|
|
_, port, _ := net.SplitHostPort(ln.Addr().String())
|
|
return ":" + port
|
|
}
|
|
|
|
func clusterCSCTestOptions(slots func() []ClusterSlot) *ClusterOptions {
|
|
return &ClusterOptions{
|
|
Protocol: 3, PoolSize: 2, DisableIdentity: true, DisableRoutingPolicies: true,
|
|
ClientSideCacheConfig: &ClientSideCacheConfig{MaxEntries: 16, MaxStaleness: time.Minute},
|
|
MaintNotificationsConfig: &maintnotifications.Config{Mode: maintnotifications.ModeDisabled},
|
|
ClusterSlots: func(context.Context) ([]ClusterSlot, error) { return slots(), nil },
|
|
}
|
|
}
|
|
|
|
func clusterCSCTestReply(calls *atomic.Int32) func(string) string {
|
|
return func(command string) string {
|
|
switch command {
|
|
case "hello":
|
|
return "%0\r\n"
|
|
case "get":
|
|
calls.Add(1)
|
|
return "$0\r\n\r\n"
|
|
case "exists":
|
|
calls.Add(1)
|
|
return ":1\r\n"
|
|
case "mget":
|
|
calls.Add(1)
|
|
return "*2\r\n$0\r\n\r\n$0\r\n\r\n"
|
|
default:
|
|
return "+OK\r\n"
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClusterCSCIndependentCachesAndDirectHandles(t *testing.T) {
|
|
var callsA, callsB atomic.Int32
|
|
a := newClusterCSCTestServer(t, clusterCSCTestReply(&callsA))
|
|
b := newClusterCSCTestServer(t, clusterCSCTestReply(&callsB))
|
|
opt := clusterCSCTestOptions(func() []ClusterSlot {
|
|
return []ClusterSlot{
|
|
{Start: 0, End: 8191, Nodes: []ClusterNode{{Addr: a, ID: "A"}}},
|
|
{Start: 8192, End: 16383, Nodes: []ClusterNode{{Addr: b, ID: "B"}}},
|
|
}
|
|
})
|
|
c := NewClusterClient(opt)
|
|
t.Cleanup(func() { _ = c.Close() })
|
|
ctx := context.Background()
|
|
keys := []string{"{bar}:present", "{foo}:present"}
|
|
if hashtag.Slot(keys[0]) >= 8192 || hashtag.Slot(keys[1]) < 8192 {
|
|
t.Fatal("test tags changed")
|
|
}
|
|
for _, key := range keys {
|
|
for i := 0; i < 2; i++ {
|
|
if err := c.Get(ctx, key).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
if callsA.Load() != 1 || callsB.Load() != 1 {
|
|
t.Fatalf("server calls A=%d B=%d", callsA.Load(), callsB.Load())
|
|
}
|
|
ca, _ := c.MasterForKey(ctx, keys[0])
|
|
cb, _ := c.MasterForKey(ctx, keys[1])
|
|
if ca.csc == cb.csc {
|
|
t.Fatal("nodes share cache storage")
|
|
}
|
|
if got := c.CSCStats(); got.Entries != 2 || got.Hits != 2 {
|
|
t.Fatalf("stats: %+v", got)
|
|
}
|
|
for _, handle := range []*Client{ca, ca.WithTimeout(time.Second)} {
|
|
if err := handle.Get(ctx, keys[0]).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if callsA.Load() != 1 {
|
|
t.Fatal("direct handle/clone did not reuse the standalone node cache")
|
|
}
|
|
for i := 0; i < 2; i++ {
|
|
if n, err := c.Exists(ctx, keys[0]).Result(); err != nil || n != 1 {
|
|
t.Fatalf("EXISTS %d %v", n, err)
|
|
}
|
|
}
|
|
if callsA.Load() != 2 {
|
|
t.Fatal("EXISTS positive result not cached")
|
|
}
|
|
if _, err := c.state.Reload(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := c.Get(ctx, keys[0]).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if callsA.Load() != 2 {
|
|
t.Fatal("unchanged topology cleared cache")
|
|
}
|
|
}
|
|
|
|
func TestClusterCSCEntryAgeConfiguration(t *testing.T) {
|
|
for _, age := range []time.Duration{0, -time.Second, 5 * time.Second} {
|
|
t.Run(age.String(), func(t *testing.T) {
|
|
cfg := &ClientSideCacheConfig{MaxEntries: 23, MaxStaleness: age}
|
|
opt := &ClusterOptions{ClientSideCacheConfig: cfg}
|
|
a, b := opt.clientOptions(), opt.clientOptions()
|
|
want := age
|
|
if a.ClientSideCacheConfig.MaxStaleness != want || b.ClientSideCacheConfig.MaxStaleness != want {
|
|
t.Fatal("node configuration changed the requested entry age")
|
|
}
|
|
if cfg.MaxStaleness != age || a.ClientSideCacheConfig == cfg || a.ClientSideCacheConfig == b.ClientSideCacheConfig {
|
|
t.Fatal("node configuration aliases another node or the caller")
|
|
}
|
|
a.ClientSideCacheConfig.MaxEntries++
|
|
if b.ClientSideCacheConfig.MaxEntries != 23 || cfg.MaxEntries != 23 {
|
|
t.Fatal("node factory can mutate another node's cache configuration")
|
|
}
|
|
})
|
|
}
|
|
if (&ClusterOptions{}).clientOptions().ClientSideCacheConfig != nil {
|
|
t.Fatal("default age enabled caching on a disabled cluster")
|
|
}
|
|
if NewLocalCache(CacheConfig{}).effectiveMaxStaleness() != 0 {
|
|
t.Fatal("standalone default age changed")
|
|
}
|
|
}
|
|
|
|
func TestClusterCSCTopologyReloadPreservesRoutedNodeCaches(t *testing.T) {
|
|
var calls atomic.Int32
|
|
a := newClusterCSCTestServer(t, clusterCSCTestReply(&calls))
|
|
b := newClusterCSCTestServer(t, clusterCSCTestReply(&calls))
|
|
var swapped atomic.Bool
|
|
opt := clusterCSCTestOptions(func() []ClusterSlot {
|
|
first, second := a, b
|
|
if swapped.Load() {
|
|
first, second = second, first
|
|
}
|
|
return []ClusterSlot{
|
|
{Start: 0, End: 8191, Nodes: []ClusterNode{{Addr: first}}},
|
|
{Start: 8192, End: 16383, Nodes: []ClusterNode{{Addr: second}}},
|
|
}
|
|
})
|
|
opt.NewClient = func(o *Options) *Client {
|
|
o.ClientSideCache = &clusterCSCRecordingCache{LocalCache: NewLocalCache(*o.ClientSideCacheConfig)}
|
|
return NewClient(o)
|
|
}
|
|
c := NewClusterClient(opt)
|
|
t.Cleanup(func() { _ = c.Close() })
|
|
ctx := context.Background()
|
|
for _, key := range []string{"{bar}:a", "{foo}:b"} {
|
|
if err := c.Get(ctx, key).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for _, swap := range []bool{false, true, false} {
|
|
swapped.Store(swap)
|
|
if _, err := c.state.Reload(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
nodes, _ := c.nodes.All()
|
|
for _, node := range nodes {
|
|
cache := node.Client.csc.(*clusterCSCRecordingCache)
|
|
if cache.flushes.Load() != 0 || cache.deletes.Load() != 0 || cache.Stats().Entries != 1 {
|
|
t.Fatal("routing reload cleared a standalone node cache")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClusterCSCRoutingLossClearsOnlyRemovedNode(t *testing.T) {
|
|
ctx := context.Background()
|
|
var callsA, callsB atomic.Int32
|
|
var changed atomic.Bool
|
|
a := newClusterCSCTestServer(t, func(command string) string {
|
|
if command == "get" {
|
|
callsA.Add(1)
|
|
if changed.Load() {
|
|
return "$2\r\nv2\r\n"
|
|
}
|
|
return "$2\r\nv1\r\n"
|
|
}
|
|
return clusterCSCTestReply(&callsA)(command)
|
|
})
|
|
b := newClusterCSCTestServer(t, clusterCSCTestReply(&callsB))
|
|
var removed bool
|
|
opt := clusterCSCTestOptions(func() []ClusterSlot {
|
|
if removed {
|
|
return []ClusterSlot{{Start: 0, End: 16383, Nodes: []ClusterNode{{Addr: b}}}}
|
|
}
|
|
return []ClusterSlot{
|
|
{Start: 0, End: 8191, Nodes: []ClusterNode{{Addr: a}}},
|
|
{Start: 8192, End: 16383, Nodes: []ClusterNode{{Addr: b}}},
|
|
}
|
|
})
|
|
opt.NewClient = func(o *Options) *Client {
|
|
o.ClientSideCache = &clusterCSCRecordingCache{LocalCache: NewLocalCache(*o.ClientSideCacheConfig)}
|
|
return NewClient(o)
|
|
}
|
|
c := NewClusterClient(opt)
|
|
t.Cleanup(func() { _ = c.Close() })
|
|
if got, err := c.Get(ctx, "{bar}:key").Result(); err != nil || got != "v1" {
|
|
t.Fatalf("initial read: %q, %v", got, err)
|
|
}
|
|
if err := c.Get(ctx, "{foo}:other").Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
old, err := c.MasterForKey(ctx, "{bar}:key")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cache := old.csc.(*clusterCSCRecordingCache)
|
|
token, fetch := cache.Reserve("pending", []string{"pending"})
|
|
if !fetch {
|
|
t.Fatal("expected a new reservation")
|
|
}
|
|
var lateToken uint64
|
|
cache.afterFlush = func() {
|
|
if cache.flushes.Load() != 1 {
|
|
return
|
|
}
|
|
state := c.state.state.Load().(*clusterState)
|
|
if state.slotNodes(hashtag.Slot("{bar}:key"))[0].Client == old {
|
|
t.Error("removed node is still routed during departure cleanup")
|
|
}
|
|
// A request that selected A before removal can still refill its cache
|
|
// after cleanup. Re-entry must clear that value and any new reservation.
|
|
if got, err := old.Get(ctx, "{bar}:key").Result(); err != nil || got != "v1" {
|
|
t.Errorf("read during cleanup: %q, %v", got, err)
|
|
}
|
|
var fetch bool
|
|
lateToken, fetch = cache.Reserve("late", []string{"late"})
|
|
if !fetch {
|
|
t.Error("expected a reservation after departure cleanup")
|
|
}
|
|
}
|
|
removed = true
|
|
if _, err := c.state.Reload(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cache.flushes.Load() != 1 {
|
|
t.Fatal("routing loss did not clear the removed node before registry GC")
|
|
}
|
|
if cache.FulfillOwned("pending", token, 1, []byte("stale")) {
|
|
t.Fatal("removed node accepted a fill reserved before routing loss")
|
|
}
|
|
if err := c.Get(ctx, "{foo}:other").Err(); err != nil || callsB.Load() != 1 {
|
|
t.Fatalf("retained node lost its cached result: %v, calls=%d", err, callsB.Load())
|
|
}
|
|
// No invalidation or disconnect: a later stale slot map reuses the same
|
|
// node client, but must not revive its value from before routing loss.
|
|
changed.Store(true)
|
|
removed = false
|
|
if _, err := c.state.Reload(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cache.FulfillOwned("late", lateToken, 1, []byte("stale")) {
|
|
t.Error("re-entering node accepted a fill reserved after routing loss")
|
|
}
|
|
if got, err := c.Get(ctx, "{bar}:key").Result(); err != nil || got != "v2" {
|
|
t.Fatalf("read after re-entry: %q, %v", got, err)
|
|
}
|
|
if callsA.Load() != 3 || cache.flushes.Load() != 2 {
|
|
t.Fatalf("calls=%d, flushes=%d; want 3, 2", callsA.Load(), cache.flushes.Load())
|
|
}
|
|
}
|
|
|
|
func TestClusterCSCRoutingLossFlushesOnceAndPreservesReplicas(t *testing.T) {
|
|
newNode := func() (*clusterNode, *clusterCSCRecordingCache) {
|
|
cache := &clusterCSCRecordingCache{LocalCache: NewLocalCache(CacheConfig{})}
|
|
return &clusterNode{Client: &Client{baseClient: &baseClient{csc: cache}}}, cache
|
|
}
|
|
a, cacheA := newNode()
|
|
b, cacheB := newNode()
|
|
disabled := &clusterNode{Client: &Client{baseClient: &baseClient{}}}
|
|
states := []*clusterState{
|
|
{slots: []*clusterSlot{
|
|
{start: 0, end: 8191, nodes: []*clusterNode{a, b, disabled}},
|
|
{start: 8192, end: 16383, nodes: []*clusterNode{a, b}},
|
|
}},
|
|
// A changes role but is still routed as a replica: no flush.
|
|
{slots: []*clusterSlot{
|
|
{start: 0, end: 8191, nodes: []*clusterNode{b, a}},
|
|
{start: 8192, end: 16383, nodes: []*clusterNode{b, a}},
|
|
}},
|
|
{slots: []*clusterSlot{{start: 0, end: 16383, nodes: []*clusterNode{b}}}},
|
|
{slots: []*clusterSlot{{start: 0, end: 16383, nodes: []*clusterNode{b}}}},
|
|
}
|
|
var next int
|
|
holder := newClusterStateHolder(func(context.Context) (*clusterState, error) {
|
|
state := states[next]
|
|
next++
|
|
return state, nil
|
|
}, time.Minute)
|
|
for i, want := range []int32{0, 0, 1, 1} {
|
|
if _, err := holder.Reload(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cacheA.flushes.Load() != want || cacheB.flushes.Load() != 0 {
|
|
t.Fatalf("reload %d: flushes A=%d B=%d, want %d, 0", i, cacheA.flushes.Load(), cacheB.flushes.Load(), want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClusterCSCRoutingCleanupLastPublicationWins(t *testing.T) {
|
|
ctx := context.Background()
|
|
a := &clusterNode{Client: &Client{baseClient: &baseClient{}}}
|
|
cacheB := &clusterCSCRecordingCache{LocalCache: NewLocalCache(CacheConfig{})}
|
|
cacheC := &clusterCSCRecordingCache{LocalCache: NewLocalCache(CacheConfig{})}
|
|
b := &clusterNode{Client: &Client{baseClient: &baseClient{csc: cacheB}}}
|
|
c := &clusterNode{Client: &Client{baseClient: &baseClient{csc: cacheC}}}
|
|
withNodes := func(nodes ...*clusterNode) *clusterState {
|
|
return &clusterState{
|
|
slots: []*clusterSlot{{start: 0, end: 16383, nodes: nodes}},
|
|
createdAt: time.Now(),
|
|
}
|
|
}
|
|
initial, adding, competing := withNodes(a), withNodes(a, b), withNodes(a, c)
|
|
var loads atomic.Int32
|
|
holder := newClusterStateHolder(func(context.Context) (*clusterState, error) {
|
|
if loads.Add(1) == 1 {
|
|
return adding, nil
|
|
}
|
|
return competing, nil
|
|
}, time.Minute)
|
|
holder.state.Store(initial)
|
|
cacheB.beforeFlush = func() {
|
|
if holder.state.Load() != initial {
|
|
t.Error("entering node was published before cache cleanup")
|
|
}
|
|
// Publish a competing snapshot while cleanup is in progress. Calling
|
|
// back synchronously also detects a mutex held around custom Flush.
|
|
if got, err := holder.ReloadOrGet(ctx); err != nil || got != competing {
|
|
t.Errorf("competing reload: %p, %v", got, err)
|
|
}
|
|
}
|
|
cacheC.beforeFlush = func() {
|
|
want := initial // Entering during the nested reload.
|
|
if cacheC.flushes.Load() == 2 {
|
|
want = adding // Departing after the outer reload publishes.
|
|
}
|
|
if holder.state.Load() != want {
|
|
t.Error("cache cleanup used the wrong publication order")
|
|
}
|
|
}
|
|
completed := make(chan *clusterState, 1)
|
|
go func() {
|
|
state, err := holder.Reload(ctx)
|
|
if err != nil {
|
|
t.Error(err)
|
|
}
|
|
completed <- state
|
|
}()
|
|
select {
|
|
case state := <-completed:
|
|
if state != adding || holder.state.Load() != adding {
|
|
t.Fatal("the last publication must win and return its own snapshot")
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("custom cache callback blocked reload")
|
|
}
|
|
if cacheB.flushes.Load() != 1 || cacheC.flushes.Load() != 2 {
|
|
t.Fatalf("flushes B=%d C=%d; want 1, 2 from the actual replaced snapshot", cacheB.flushes.Load(), cacheC.flushes.Load())
|
|
}
|
|
}
|
|
|
|
// No notification reaches A when another client creates a missing key on B.
|
|
// Without an entry-age limit, rejecting absence-dependent replies must still
|
|
// let the next read reach Redis and discover the redirect.
|
|
func TestClusterCSCUnobservedMigrationDoesNotCacheAbsence(t *testing.T) {
|
|
for _, coalesce := range []bool{false, true} {
|
|
for _, tc := range []struct {
|
|
command string
|
|
before string
|
|
after string
|
|
old interface{}
|
|
want interface{}
|
|
err error
|
|
}{
|
|
{"get", "$-1\r\n", "$5\r\nvalue\r\n", nil, "value", Nil},
|
|
{"exists", ":0\r\n", ":1\r\n", int64(0), int64(1), nil},
|
|
{"mget", "*2\r\n$-1\r\n$0\r\n\r\n", "*2\r\n$5\r\nvalue\r\n$0\r\n\r\n", []interface{}{nil, ""}, []interface{}{"value", ""}, nil},
|
|
} {
|
|
t.Run(fmt.Sprintf("%s/coalesce=%v", tc.command, coalesce), func(t *testing.T) {
|
|
var migrating atomic.Bool
|
|
var readsA, readsB atomic.Int32
|
|
b := newClusterCSCTestServer(t, func(cmd string) string {
|
|
if cmd == "hello" {
|
|
return "%0\r\n"
|
|
}
|
|
if cmd == tc.command {
|
|
readsB.Add(1)
|
|
return tc.after
|
|
}
|
|
return "+OK\r\n"
|
|
})
|
|
key := "{option-b}:key"
|
|
a := newClusterCSCTestServer(t, func(cmd string) string {
|
|
if cmd == "hello" {
|
|
return "%0\r\n"
|
|
}
|
|
if cmd == tc.command {
|
|
readsA.Add(1)
|
|
if migrating.Load() {
|
|
return fmt.Sprintf("-ASK %d %s\r\n", hashtag.Slot(key), b)
|
|
}
|
|
return tc.before
|
|
}
|
|
return "+OK\r\n"
|
|
})
|
|
opt := clusterCSCTestOptions(func() []ClusterSlot {
|
|
return []ClusterSlot{{Start: 0, End: 16383, Nodes: []ClusterNode{{Addr: a}}}}
|
|
})
|
|
opt.ClientSideCacheConfig.MaxStaleness = 0 // Exercise the cluster default.
|
|
opt.ClientSideCacheCoalesceMisses = coalesce
|
|
c := NewClusterClient(opt)
|
|
t.Cleanup(func() { _ = c.Close() })
|
|
ctx := context.Background()
|
|
args := []interface{}{tc.command, key}
|
|
if tc.command == "mget" {
|
|
args = append(args, "{option-b}:other")
|
|
}
|
|
read := func(want interface{}, wantErr error) {
|
|
t.Helper()
|
|
cmd := NewCmd(ctx, args...)
|
|
if err := c.Process(ctx, cmd); err != wantErr || !reflect.DeepEqual(cmd.Val(), want) {
|
|
t.Fatalf("read: value=%#v error=%v; want %#v, %v", cmd.Val(), err, want, wantErr)
|
|
}
|
|
}
|
|
read(tc.old, tc.err)
|
|
read(tc.old, tc.err)
|
|
if readsA.Load() != 2 || c.CSCStats().Entries != 0 {
|
|
t.Fatal("absence-dependent reply was cached")
|
|
}
|
|
migrating.Store(true) // No invalidation or disconnect reaches our client.
|
|
read(tc.want, nil)
|
|
if readsA.Load() != 3 || readsB.Load() != 1 {
|
|
t.Fatalf("next read did not expose ASK: A=%d B=%d", readsA.Load(), readsB.Load())
|
|
}
|
|
destination, _ := c.nodes.GetOrCreate(b)
|
|
if destination.Client.CSCStats().Entries != 0 {
|
|
t.Fatal("ASK-directed response populated destination cache")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClusterCSCRedirectEvictsKeysAcrossConnections(t *testing.T) {
|
|
for _, redirect := range []string{"MOVED", "ASK"} {
|
|
t.Run(redirect, func(t *testing.T) {
|
|
ctx := context.Background()
|
|
cache := NewLocalCache(CacheConfig{MaxEntries: 32})
|
|
c := &baseClient{opt: &Options{cscPresenceOnly: true}, csc: cache, cscKeyPrefix: "node:"}
|
|
for _, entry := range []struct {
|
|
key, dependency string
|
|
owner uint64
|
|
}{
|
|
{"get:k", "node:k", 1},
|
|
{"strlen:k", "node:k", 2},
|
|
{"get:other", "node:other", 1},
|
|
{"other-node:k", "other-node:k", 1},
|
|
} {
|
|
token, _ := cache.Reserve(entry.key, []string{entry.dependency})
|
|
if !cache.FulfillOwned(entry.key, token, entry.owner, []byte("value")) {
|
|
t.Fatal("fill failed")
|
|
}
|
|
}
|
|
token, _ := cache.Reserve("pending:k", []string{cscNamespacedKey("node:", "k")})
|
|
err := fmt.Errorf("wrapped: %w", proto.ParseErrorReply([]byte("-"+redirect+" 7629 localhost:7001")))
|
|
c.cscEvictOnRedirect([]string{"node:k"}, err)
|
|
for _, key := range []string{"get:k", "strlen:k"} {
|
|
if _, hit := cache.Get(ctx, key); hit {
|
|
t.Fatalf("dependent entry survived: %s", key)
|
|
}
|
|
}
|
|
if cache.FulfillOwned("pending:k", token, 2, []byte("late")) {
|
|
t.Fatal("canceled reservation accepted late publication")
|
|
}
|
|
for _, key := range []string{"get:other", "other-node:k"} {
|
|
if _, hit := cache.Get(ctx, key); !hit {
|
|
t.Fatalf("unrelated entry evicted: %s", key)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClusterCSCRedirectOnlyForClusterAndRedirects(t *testing.T) {
|
|
for _, cluster := range []bool{false, true} {
|
|
for _, err := range []error{nil, Nil, fmt.Errorf("transport"), proto.ParseErrorReply([]byte("-TRYAGAIN retry")), proto.ParseErrorReply([]byte("-MOVED 1 localhost:7001"))} {
|
|
cache := NewLocalCache(CacheConfig{MaxEntries: 4})
|
|
c := &baseClient{opt: &Options{cscPresenceOnly: cluster}, csc: cache, cscKeyPrefix: "n:"}
|
|
token, _ := cache.Reserve("get:k", []string{cscNamespacedKey("n:", "k")})
|
|
cache.FulfillOwned("get:k", token, 1, []byte("v"))
|
|
c.cscEvictOnRedirect([]string{"n:k"}, err)
|
|
_, hit := cache.Get(context.Background(), "get:k")
|
|
moved := false
|
|
if err != nil {
|
|
moved, _, _ = isMovedError(err)
|
|
}
|
|
if hit == (cluster && moved) {
|
|
t.Fatalf("cluster=%v err=%v hit=%v", cluster, err, hit)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClusterCSCFetchRedirectEvictsOtherDependentResults(t *testing.T) {
|
|
for _, path := range []string{"foreground", "refresh", "coalesced", "abandoned"} {
|
|
for _, redirect := range []string{"ASK", "MOVED"} {
|
|
t.Run(path+"/"+redirect, func(t *testing.T) {
|
|
raw := "-" + redirect + " 7629 localhost:7001\r\n"
|
|
addr := newClusterCSCTestServer(t, func(command string) string {
|
|
if command == "hello" {
|
|
return "%0\r\n"
|
|
}
|
|
if command == "get" {
|
|
return raw
|
|
}
|
|
return "+OK\r\n"
|
|
})
|
|
c := NewClusterClient(clusterCSCTestOptions(func() []ClusterSlot {
|
|
return []ClusterSlot{{Start: 0, End: 16383, Nodes: []ClusterNode{{Addr: addr}}}}
|
|
}))
|
|
defer c.Close()
|
|
ctx := context.Background()
|
|
node, err := c.MasterForKey(ctx, "k")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := node.Ping(ctx).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dependency := cscNamespacedKey(node.cscKeyPrefix, "k")
|
|
for _, entry := range []struct{ key, dependency string }{
|
|
{"dependent", dependency}, {"unrelated", cscNamespacedKey(node.cscKeyPrefix, "other")},
|
|
} {
|
|
token, _ := node.csc.Reserve(entry.key, []string{entry.dependency})
|
|
if !node.csc.FulfillOwned(entry.key, token, 1, []byte("v")) {
|
|
t.Fatal("fill failed")
|
|
}
|
|
}
|
|
cmd := NewStringCmd(ctx, "get", "k")
|
|
key, _ := cscRenderEntryKey(node.cscKeyPrefix, node.metadataView().cscFingerprint, cmd)
|
|
if path == "foreground" {
|
|
if err := node.Process(ctx, cmd); err == nil {
|
|
t.Fatal("redirect was lost")
|
|
}
|
|
} else if path == "refresh" {
|
|
if _, err := node.refreshInvalidatedBatch(ctx, []cscRefreshTarget{{cacheKey: key, redisKeys: []string{dependency}}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
} else {
|
|
token, _ := node.csc.Reserve(key, []string{dependency})
|
|
req := &cscMissReq{cmd: cmd, cacheKey: key, token: token, redisKeys: []string{dependency}, done: make(chan error, 1)}
|
|
if path == "abandoned" {
|
|
req.cmd = nil
|
|
req.apply.Store(cscReqAbandoned)
|
|
}
|
|
mc := &cscMissCoalescer{c: node.baseClient}
|
|
mc.applyAndSettle(req, []byte(raw), 1, 0)
|
|
if err := <-req.done; err == nil {
|
|
t.Fatal("redirect was lost")
|
|
}
|
|
}
|
|
if _, hit := node.csc.Get(ctx, "dependent"); hit {
|
|
t.Fatal("another result for the redirected key survived")
|
|
}
|
|
if _, hit := node.csc.Get(ctx, "unrelated"); !hit {
|
|
t.Fatal("unrelated result evicted")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClusterCSCRedirectWithoutKnownKeysDoesNotFlush(t *testing.T) {
|
|
cache := &clusterCSCRecordingCache{LocalCache: NewLocalCache(CacheConfig{MaxEntries: 4})}
|
|
c := &baseClient{opt: &Options{cscPresenceOnly: true}, csc: cache}
|
|
token, _ := cache.Reserve("cached", []string{"node:k"})
|
|
cache.FulfillOwned("cached", token, 1, []byte("v"))
|
|
for _, redirect := range []string{"MOVED", "ASK"} {
|
|
c.cscEvictOnRedirect(nil, proto.ParseErrorReply([]byte("-"+redirect+" 1 localhost:7001")))
|
|
}
|
|
if cache.flushes.Load() != 0 || cache.deletes.Load() != 0 {
|
|
t.Fatal("unknown dependencies triggered cache cleanup")
|
|
}
|
|
if _, hit := cache.Get(context.Background(), "cached"); !hit {
|
|
t.Fatal("unknown dependencies discarded cached data")
|
|
}
|
|
}
|
|
|
|
type clusterCSCRecordingCache struct {
|
|
*LocalCache
|
|
flushes atomic.Int32
|
|
deletes atomic.Int32
|
|
beforeFlush func()
|
|
afterFlush func()
|
|
}
|
|
|
|
func TestClusterCSCRefreshRedirectPreservesUnrelatedEntries(t *testing.T) {
|
|
for _, redirect := range []string{"ASK", "MOVED"} {
|
|
t.Run(redirect, func(t *testing.T) {
|
|
var migrating atomic.Bool
|
|
b := newClusterCSCTestServer(t, func(cmd string) string {
|
|
if cmd == "hello" {
|
|
return "%0\r\n"
|
|
}
|
|
if cmd == "get" {
|
|
return "$5\r\nvalue\r\n"
|
|
}
|
|
return "+OK\r\n"
|
|
})
|
|
a := newClusterCSCTestServer(t, func(cmd string) string {
|
|
if cmd == "hello" {
|
|
return "%0\r\n"
|
|
}
|
|
if cmd == "get" {
|
|
if migrating.Load() {
|
|
return fmt.Sprintf("-%s %d %s\r\n", redirect, hashtag.Slot("{bar}:refresh"), b)
|
|
}
|
|
return "$0\r\n\r\n"
|
|
}
|
|
return "+OK\r\n"
|
|
})
|
|
c := NewClusterClient(clusterCSCTestOptions(func() []ClusterSlot {
|
|
return []ClusterSlot{
|
|
{Start: 0, End: 8191, Nodes: []ClusterNode{{Addr: a}}},
|
|
{Start: 8192, End: 16383, Nodes: []ClusterNode{{Addr: b}}},
|
|
}
|
|
}))
|
|
t.Cleanup(func() { _ = c.Close() })
|
|
ctx := context.Background()
|
|
for _, key := range []string{"{bar}:refresh", "{bar}:sibling"} {
|
|
if err := c.Get(ctx, key).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if value := c.Get(ctx, "{foo}:destination").Val(); value != "value" {
|
|
t.Fatal(value)
|
|
}
|
|
source, _ := c.MasterForKey(ctx, "{bar}:refresh")
|
|
destination, _ := c.MasterForKey(ctx, "{foo}:destination")
|
|
key, _ := cscRenderEntryKey(source.cscKeyPrefix, source.metadataView().cscFingerprint, NewStringCmd(ctx, "get", "{bar}:refresh"))
|
|
dependency := cscNamespacedKey(source.cscKeyPrefix, "{bar}:refresh")
|
|
source.csc.DeleteByRedisKey(dependency)
|
|
migrating.Store(true)
|
|
// Only the refresh sees the redirect. Its canceled reservation must
|
|
// not discard the unrelated sibling or destination entry.
|
|
count, err := source.refreshInvalidatedBatch(ctx, []cscRefreshTarget{{cacheKey: key, redisKeys: []string{dependency}}})
|
|
if err != nil || count != 0 {
|
|
t.Fatalf("refresh: %d %v", count, err)
|
|
}
|
|
if source.CSCStats().Entries != 1 {
|
|
t.Fatal("refresh redirect evicted unrelated source entries")
|
|
}
|
|
// The destination's unrelated key survives both redirect types.
|
|
wantEntries := int64(1)
|
|
if got := int64(destination.CSCStats().Entries); got != wantEntries {
|
|
t.Fatalf("destination entries after %s: %d, want %d", redirect, got, wantEntries)
|
|
}
|
|
if value, err := c.Get(ctx, "{bar}:refresh").Result(); err != nil || value != "value" {
|
|
t.Fatalf("foreground redirect: %q %v", value, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func (c *clusterCSCRecordingCache) Flush() int {
|
|
c.flushes.Add(1)
|
|
if c.beforeFlush != nil {
|
|
c.beforeFlush()
|
|
}
|
|
n := c.LocalCache.Flush()
|
|
if c.afterFlush != nil {
|
|
c.afterFlush()
|
|
}
|
|
return n
|
|
}
|
|
|
|
func (c *clusterCSCRecordingCache) DeleteByRedisKey(key string) int {
|
|
c.deletes.Add(1)
|
|
return c.LocalCache.DeleteByRedisKey(key)
|
|
}
|
|
|
|
func TestClusterCSCReloadIntervalDoesNotDisableNodeCache(t *testing.T) {
|
|
var calls atomic.Int32
|
|
addr := newClusterCSCTestServer(t, clusterCSCTestReply(&calls))
|
|
opt := clusterCSCTestOptions(func() []ClusterSlot { return []ClusterSlot{{Start: 0, End: 16383, Nodes: []ClusterNode{{Addr: addr}}}} })
|
|
opt.ClusterStateReloadInterval = -1
|
|
c := NewClusterClient(opt)
|
|
t.Cleanup(func() { _ = c.Close() })
|
|
for i := 0; i < 2; i++ {
|
|
if err := c.Get(context.Background(), "k").Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if calls.Load() != 1 || c.CSCStats().Entries != 1 {
|
|
t.Fatal("routing reload interval disabled standalone caching")
|
|
}
|
|
}
|
|
|
|
func TestClusterCSCUniversalAndFactoryControls(t *testing.T) {
|
|
cfg := &ClientSideCacheConfig{MaxEntries: 23}
|
|
u := &UniversalOptions{ClientSideCacheConfig: cfg, ClientSideCacheCoalesceMisses: true, ClientSideCacheRefreshOnInvalidate: true, ClientSideCacheInvalidationBatchWindow: time.Millisecond}
|
|
o := u.Cluster().clientOptions()
|
|
if o.ClientSideCacheConfig == cfg || o.ClientSideCacheConfig.MaxEntries != cfg.MaxEntries || o.ClientSideCacheConfig.MaxStaleness != 0 || cfg.MaxStaleness != 0 || !o.ClientSideCacheCoalesceMisses || !o.ClientSideCacheRefreshOnInvalidate || o.ClientSideCacheInvalidationBatchWindow != time.Millisecond {
|
|
t.Fatal("configuration not propagated")
|
|
}
|
|
var calls atomic.Int32
|
|
addr := newClusterCSCTestServer(t, clusterCSCTestReply(&calls))
|
|
opt := clusterCSCTestOptions(func() []ClusterSlot { return []ClusterSlot{{Start: 0, End: 16383, Nodes: []ClusterNode{{Addr: addr}}}} })
|
|
opt.NewClient = func(o *Options) *Client { o.cscPresenceOnly = false; return NewClient(o) }
|
|
c := NewClusterClient(opt)
|
|
t.Cleanup(func() { _ = c.Close() })
|
|
for i := 0; i < 2; i++ {
|
|
if err := c.Get(context.Background(), "k").Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if calls.Load() != 2 {
|
|
t.Fatal("factory discarded controls but retained active CSC")
|
|
}
|
|
}
|
|
|
|
func TestClusterCSCFailoverConfiguration(t *testing.T) {
|
|
cfg := &ClientSideCacheConfig{MaxEntries: 23, MaxStaleness: time.Minute}
|
|
u := &UniversalOptions{
|
|
MasterName: "csc-primary", Addrs: []string{":26379"},
|
|
ClientSideCacheConfig: cfg,
|
|
ClientSideCacheStrategy: CSCStrategySharedTracking,
|
|
ClientSideCacheRefreshOnInvalidate: true,
|
|
ClientSideCacheCoalesceMisses: true,
|
|
ClientSideCacheInvalidationBatchWindow: time.Millisecond,
|
|
}
|
|
f := u.Failover()
|
|
o := f.clusterOptions().clientOptions()
|
|
if o.ClientSideCacheConfig == nil || !reflect.DeepEqual(*o.ClientSideCacheConfig, *cfg) ||
|
|
o.ClientSideCacheConfig == cfg || o.ClientSideCacheStrategy != u.ClientSideCacheStrategy ||
|
|
!o.ClientSideCacheRefreshOnInvalidate || !o.ClientSideCacheCoalesceMisses ||
|
|
o.ClientSideCacheInvalidationBatchWindow != u.ClientSideCacheInvalidationBatchWindow {
|
|
t.Fatal("failover cluster dropped or aliased CSC configuration")
|
|
}
|
|
// Sentinel discovery connections and single-node failover clients do not
|
|
// participate in Cluster CSC.
|
|
if f.sentinelOptions(":26379").ClientSideCacheConfig != nil || f.clientOptions().ClientSideCacheConfig != nil {
|
|
t.Fatal("Cluster CSC configuration enabled caching on a non-cluster connection")
|
|
}
|
|
}
|
|
|
|
func TestClusterCSCUniversalFailoverCluster(t *testing.T) {
|
|
for _, route := range []string{"latency", "random", "cluster mode"} {
|
|
for _, tc := range []struct {
|
|
db int
|
|
cache bool
|
|
}{{0, true}, {3, true}, {0, false}, {3, false}} {
|
|
t.Run(fmt.Sprintf("%s/db=%d/cache=%v", route, tc.db, tc.cache), func(t *testing.T) {
|
|
db := tc.db
|
|
var calls, selects, connects atomic.Int32
|
|
addr := newClusterCSCTestServer(t, func(cmd string) string {
|
|
if cmd == "command" {
|
|
return "*0\r\n" // Valid empty metadata; no failed discovery sockets.
|
|
}
|
|
if cmd == "select" {
|
|
selects.Add(1)
|
|
}
|
|
return clusterCSCTestReply(&calls)(cmd)
|
|
})
|
|
u := &UniversalOptions{
|
|
MasterName: "csc-primary", Addrs: []string{":26379"}, DB: db,
|
|
Protocol: 3, PoolSize: 1, DisableIdentity: true,
|
|
OnConnect: func(_ context.Context, cn *Conn) error {
|
|
connects.Add(1)
|
|
wantDB := 0
|
|
if tc.cache {
|
|
wantDB = db
|
|
}
|
|
if cn.opt.DB != wantDB {
|
|
return fmt.Errorf("connection DB=%d, want %d", cn.opt.DB, wantDB)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
if tc.cache {
|
|
u.ClientSideCacheConfig = &ClientSideCacheConfig{MaxEntries: 16}
|
|
}
|
|
switch route {
|
|
case "latency":
|
|
u.RouteByLatency = true
|
|
case "random":
|
|
u.RouteRandomly = true
|
|
default:
|
|
u.IsClusterMode = true
|
|
}
|
|
client := NewUniversalClient(u)
|
|
t.Cleanup(func() { _ = client.Close() })
|
|
c, ok := client.(*ClusterClient)
|
|
if !ok {
|
|
t.Fatal("universal failover path did not construct a ClusterClient")
|
|
}
|
|
// Replace discovery, not construction, with a deterministic primary.
|
|
// There is no replica, so every routing mode selects this primary.
|
|
c.opt.ClusterSlots = func(context.Context) ([]ClusterSlot, error) {
|
|
return []ClusterSlot{{Start: 0, End: 16383, Nodes: []ClusterNode{{Addr: addr}}}}, nil
|
|
}
|
|
ctx := context.Background()
|
|
for i := 0; i < 2; i++ {
|
|
if err := c.Get(ctx, "key").Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
// SharedTracking remains DB-0-only. Other databases must execute
|
|
// normally, without attempting a SELECT from a CSC OnConnect hook.
|
|
wantCalls, wantHits := int32(2), uint64(0)
|
|
if tc.cache && db == 0 {
|
|
wantCalls, wantHits = 1, 1
|
|
}
|
|
if calls.Load() != wantCalls || c.CSCStats().Hits != wantHits || connects.Load() == 0 {
|
|
t.Fatalf("calls=%d connects=%d stats=%+v", calls.Load(), connects.Load(), c.CSCStats())
|
|
}
|
|
node, err := c.MasterForKey(ctx, "key")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantDB := 0
|
|
if tc.cache {
|
|
wantDB = db
|
|
}
|
|
if node.opt.DB != wantDB || (tc.cache && db == 0 && node.cscKeyPrefix != cscNamespacePrefix(db, "")) {
|
|
t.Fatal("node cache namespace does not match the selected database")
|
|
}
|
|
if wantHits == 0 && node.csc != nil {
|
|
t.Fatal("disabled or nonzero-DB client attached CSC")
|
|
}
|
|
if db != 0 && selects.Load() == 0 {
|
|
t.Fatal("node connection did not select the configured database")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClusterCSCDisabledAndInitializationFallback(t *testing.T) {
|
|
for _, mode := range []string{"disabled", "RESP2", "tracking rejected", "factory initialization rejects tracking"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
var calls atomic.Int32
|
|
rejected := mode == "tracking rejected" || mode == "factory initialization rejects tracking"
|
|
addr := newClusterCSCTestServer(t, func(command string) string {
|
|
if rejected && command == "client" {
|
|
return "-ERR tracking is unavailable\r\n"
|
|
}
|
|
return clusterCSCTestReply(&calls)(command)
|
|
})
|
|
opt := clusterCSCTestOptions(func() []ClusterSlot {
|
|
return []ClusterSlot{{Start: 0, End: 16383, Nodes: []ClusterNode{{Addr: addr}}}}
|
|
})
|
|
switch mode {
|
|
case "disabled":
|
|
opt.ClientSideCacheConfig = nil
|
|
case "RESP2":
|
|
opt.Protocol = 2
|
|
default:
|
|
opt.ClientSideCacheConfig.DrainInterval = time.Millisecond
|
|
opt.ClientSideCacheRefreshOnInvalidate = true
|
|
opt.ClientSideCacheCoalesceMisses = true
|
|
}
|
|
if mode == "factory initialization rejects tracking" {
|
|
opt.NewClient = func(o *Options) *Client {
|
|
c := NewClient(o)
|
|
// Self-disable may tear down workers before or during Cluster's
|
|
// registration of the returned client. No mutable handles may
|
|
// be read by registration.
|
|
if err := c.Ping(context.Background()).Err(); err != nil {
|
|
t.Error(err)
|
|
}
|
|
return c
|
|
}
|
|
}
|
|
c := NewClusterClient(opt)
|
|
t.Cleanup(func() { _ = c.Close() })
|
|
for i := 0; i < 2; i++ {
|
|
if err := c.Get(context.Background(), "k").Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if stats := c.CSCStats(); calls.Load() != 2 || stats.Hits != 0 || stats.Entries != 0 {
|
|
t.Fatalf("fallback served cache: calls=%d stats=%+v", calls.Load(), stats)
|
|
}
|
|
if rejected {
|
|
n, _ := c.nodes.GetOrCreate(addr)
|
|
select {
|
|
case <-n.Client.cscDrainHandle.done:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("failed tracking initialization left CSC workers running")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClusterCSCFactoryCloneWithoutControlsIsUncached(t *testing.T) {
|
|
var calls atomic.Int32
|
|
addr := newClusterCSCTestServer(t, clusterCSCTestReply(&calls))
|
|
opt := clusterCSCTestOptions(func() []ClusterSlot {
|
|
return []ClusterSlot{{Start: 0, End: 16383, Nodes: []ClusterNode{{Addr: addr}}}}
|
|
})
|
|
opt.NewClient = func(o *Options) *Client {
|
|
o.cscPresenceOnly = false
|
|
return NewClient(o).WithTimeout(time.Second)
|
|
}
|
|
c := NewClusterClient(opt)
|
|
t.Cleanup(func() { _ = c.Close() })
|
|
for i := 0; i < 2; i++ {
|
|
if err := c.Get(context.Background(), "k").Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if calls.Load() != 2 {
|
|
t.Fatal("factory clone discarded controls but retained active CSC")
|
|
}
|
|
}
|
|
|
|
func TestClusterCSCFactoryCloneJoinsCanonicalWorkers(t *testing.T) {
|
|
var calls atomic.Int32
|
|
addr := newClusterCSCTestServer(t, clusterCSCTestReply(&calls))
|
|
opt := clusterCSCTestOptions(func() []ClusterSlot {
|
|
return []ClusterSlot{{Start: 0, End: 16383, Nodes: []ClusterNode{{Addr: addr}}}}
|
|
})
|
|
opt.ClientSideCacheConfig.DrainInterval = time.Hour
|
|
opt.ClientSideCacheRefreshOnInvalidate = true
|
|
var canonical *Client
|
|
opt.NewClient = func(o *Options) *Client {
|
|
canonical = NewClient(o)
|
|
t.Cleanup(func() { _ = canonical.Close() })
|
|
return canonical.WithTimeout(time.Second)
|
|
}
|
|
c := NewClusterClient(opt)
|
|
t.Cleanup(func() { _ = c.Close() })
|
|
for i := 0; i < 2; i++ {
|
|
if err := c.Get(context.Background(), "k").Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if calls.Load() != 1 {
|
|
t.Fatal("factory clone preserving controls failed to cache")
|
|
}
|
|
if err := c.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case <-canonical.cscDrainHandle.done:
|
|
default:
|
|
t.Fatal("Cluster Close returned before the factory clone's canonical CSC workers stopped")
|
|
}
|
|
if c.CSCStats().Hits != 0 {
|
|
t.Fatal("closed cluster retained node statistics")
|
|
}
|
|
}
|
|
|
|
func TestClusterCSCFailedReloadPreservesNodeCache(t *testing.T) {
|
|
var calls atomic.Int32
|
|
addr := newClusterCSCTestServer(t, clusterCSCTestReply(&calls))
|
|
c := NewClusterClient(clusterCSCTestOptions(func() []ClusterSlot { return []ClusterSlot{{Start: 0, End: 16383, Nodes: []ClusterNode{{Addr: addr}}}} }))
|
|
t.Cleanup(func() { _ = c.Close() })
|
|
ctx := context.Background()
|
|
_ = c.Get(ctx, "k").Err()
|
|
state := *c.state.state.Load().(*clusterState)
|
|
state.createdAt = time.Now().Add(-2 * time.Minute)
|
|
c.state.state.Store(&state)
|
|
c.state.load = func(context.Context) (*clusterState, error) { return nil, fmt.Errorf("test unavailable topology") }
|
|
if _, err := c.state.Reload(ctx); err == nil {
|
|
t.Fatal("failed reload unexpectedly succeeded")
|
|
}
|
|
if c.state.state.Load().(*clusterState).createdAt != state.createdAt {
|
|
t.Fatal("failed reload renewed freshness")
|
|
}
|
|
if err := c.Get(ctx, "k").Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if calls.Load() != 1 {
|
|
t.Fatal("failed reload disabled the standalone cache")
|
|
}
|
|
}
|
|
|
|
func TestClusterCSCNewAndRemovedNodesAndPerNodeLimits(t *testing.T) {
|
|
var calls atomic.Int32
|
|
a := newClusterCSCTestServer(t, clusterCSCTestReply(&calls))
|
|
b := newClusterCSCTestServer(t, clusterCSCTestReply(&calls))
|
|
var switched atomic.Bool
|
|
opt := clusterCSCTestOptions(func() []ClusterSlot {
|
|
addr := a
|
|
if switched.Load() {
|
|
addr = b
|
|
}
|
|
return []ClusterSlot{{Start: 0, End: 16383, Nodes: []ClusterNode{{Addr: addr}}}}
|
|
})
|
|
opt.ClientSideCacheConfig.MaxEntries = 2
|
|
c := NewClusterClient(opt)
|
|
t.Cleanup(func() { _ = c.Close() })
|
|
ctx := context.Background()
|
|
for i := 0; i < 5; i++ {
|
|
_ = c.Get(ctx, fmt.Sprintf("key%d", i)).Err()
|
|
}
|
|
na, _ := c.nodes.GetOrCreate(a)
|
|
if got := na.Client.CSCStats().Entries; got > 2 {
|
|
t.Fatalf("per-node capacity exceeded: %d", got)
|
|
}
|
|
switched.Store(true)
|
|
if _, err := c.state.Reload(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
nb, _ := c.nodes.GetOrCreate(b)
|
|
for i := 0; i < 5; i++ {
|
|
_ = c.Get(ctx, fmt.Sprintf("key%d", i)).Err()
|
|
}
|
|
if na.Client.csc == nb.Client.csc || nb.Client.CSCStats().Entries > 2 {
|
|
t.Fatal("new node reused storage or exceeded local limit")
|
|
}
|
|
// Ordinary registry GC closes the removed standalone client, with no
|
|
// separate cache retirement registry or Cluster cleanup worker.
|
|
c.nodes.GC(c.state.state.Load().(*clusterState).generation)
|
|
select {
|
|
case <-na.Client.cscDrainHandle.done:
|
|
default:
|
|
t.Fatal("registry GC did not join the removed node's workers")
|
|
}
|
|
if na.Client.CSCStats().Entries != 0 || !nb.Client.cscActive.Load() {
|
|
t.Fatal("removed node cleanup affected the wrong cache")
|
|
}
|
|
}
|
|
|
|
func TestClusterCSCStatisticsOnlyIncludeRegisteredNodes(t *testing.T) {
|
|
var calls atomic.Int32
|
|
addr := newClusterCSCTestServer(t, clusterCSCTestReply(&calls))
|
|
c := NewClusterClient(clusterCSCTestOptions(func() []ClusterSlot { return []ClusterSlot{{Start: 0, End: 16383, Nodes: []ClusterNode{{Addr: addr}}}} }))
|
|
t.Cleanup(func() { _ = c.Close() })
|
|
ctx := context.Background()
|
|
for i := 0; i < 2; i++ {
|
|
_ = c.Get(ctx, "k").Err()
|
|
}
|
|
old, _ := c.nodes.GetOrCreate(addr)
|
|
// Model the registry's GC gap: removed from address lookup, but Close has
|
|
// not joined the old workers yet. Recreating the endpoint starts a lifetime.
|
|
c.nodes.mu.Lock()
|
|
delete(c.nodes.nodes, addr)
|
|
c.nodes.mu.Unlock()
|
|
_, err := c.nodes.GetOrCreate(addr)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := c.CSCStats().Hits; got != 0 {
|
|
t.Fatalf("unregistered node retained in statistics: %d", got)
|
|
}
|
|
_ = old.Client.Close()
|
|
if got := c.CSCStats().Hits; got != 0 {
|
|
t.Fatalf("closed node retained in statistics: %d", got)
|
|
}
|
|
}
|
|
|
|
func TestClusterCSCDroppedClientRemainsCollectible(t *testing.T) {
|
|
var calls atomic.Int32
|
|
addr := newClusterCSCTestServer(t, clusterCSCTestReply(&calls))
|
|
makeDropped := func() <-chan struct{} {
|
|
c := NewClusterClient(clusterCSCTestOptions(func() []ClusterSlot { return []ClusterSlot{{Start: 0, End: 16383, Nodes: []ClusterNode{{Addr: addr}}}} }))
|
|
_ = c.Get(context.Background(), "k").Err()
|
|
collected := make(chan struct{})
|
|
runtime.AddCleanup(c, func(done chan struct{}) { close(done) }, collected)
|
|
// The ordinary routing GC timer retains node clients for a minute,
|
|
// but must not retain the owning ClusterClient through its workers.
|
|
return collected
|
|
}
|
|
stop := makeDropped()
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
runtime.GC()
|
|
select {
|
|
case <-stop:
|
|
return
|
|
default:
|
|
}
|
|
runtime.Gosched()
|
|
}
|
|
t.Fatal("CSC background work retained the owning ClusterClient")
|
|
}
|
|
|
|
func TestClusterCSCRetainedFactoryCloneOwnsItsWorkers(t *testing.T) {
|
|
var calls atomic.Int32
|
|
addr := newClusterCSCTestServer(t, clusterCSCTestReply(&calls))
|
|
makeDropped := func() (*Client, <-chan struct{}) {
|
|
opt := clusterCSCTestOptions(func() []ClusterSlot {
|
|
return []ClusterSlot{{Start: 0, End: 16383, Nodes: []ClusterNode{{Addr: addr}}}}
|
|
})
|
|
opt.ClientSideCacheConfig.DrainInterval = time.Hour
|
|
opt.NewClient = func(o *Options) *Client { return NewClient(o).WithTimeout(time.Second) }
|
|
c := NewClusterClient(opt)
|
|
if err := c.Get(context.Background(), "k").Err(); err != nil {
|
|
_ = c.Close()
|
|
t.Fatal(err)
|
|
}
|
|
node, _ := c.MasterForKey(context.Background(), "k")
|
|
return node, node.lifecycleOwner.cscDrainHandle.done
|
|
}
|
|
handle, drained := makeDropped()
|
|
defer handle.Close()
|
|
runtime.GC()
|
|
if err := handle.Get(context.Background(), "k").Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if calls.Load() != 1 {
|
|
t.Fatal("retained handle lost its standalone cache")
|
|
}
|
|
if err := handle.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case <-drained:
|
|
default:
|
|
t.Fatal("node Close did not join canonical workers")
|
|
}
|
|
}
|
|
|
|
func TestClusterCSCNodeFullInvalidation(t *testing.T) {
|
|
var calls atomic.Int32
|
|
addr := newClusterCSCTestServer(t, clusterCSCTestReply(&calls))
|
|
c := NewClusterClient(clusterCSCTestOptions(func() []ClusterSlot {
|
|
return []ClusterSlot{{Start: 0, End: 16383, Nodes: []ClusterNode{{Addr: addr}}}}
|
|
}))
|
|
t.Cleanup(func() { _ = c.Close() })
|
|
ctx := context.Background()
|
|
if err := c.Get(ctx, "k").Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
node, _ := c.MasterForKey(ctx, "k")
|
|
lookupInvalidateHandler(node.pushProcessor).fullFlush(node.csc)
|
|
if node.CSCStats().Entries != 0 {
|
|
t.Fatal("full invalidation did not clear node cache")
|
|
}
|
|
}
|
|
|
|
func TestClusterCSCRedirectsPreserveUnrelatedNodeCaches(t *testing.T) {
|
|
for _, path := range []string{"command", "pipeline", "transaction", "full-duplex"} {
|
|
for _, redirect := range []string{"ASK", "MOVED"} {
|
|
t.Run(path+"/"+redirect, func(t *testing.T) {
|
|
var moved atomic.Bool
|
|
var multiA, multiB bool
|
|
var muA, muB sync.Mutex
|
|
b := newClusterCSCTestServer(t, func(command string) string {
|
|
muB.Lock()
|
|
defer muB.Unlock()
|
|
switch command {
|
|
case "hello":
|
|
return "%0\r\n"
|
|
case "multi":
|
|
multiB = true
|
|
return "+OK\r\n"
|
|
case "exec":
|
|
multiB = false
|
|
return "*1\r\n+OK\r\n"
|
|
case "set":
|
|
if multiB {
|
|
return "+QUEUED\r\n"
|
|
}
|
|
return "+OK\r\n"
|
|
case "get":
|
|
return "$1\r\nv\r\n"
|
|
default:
|
|
return "+OK\r\n"
|
|
}
|
|
})
|
|
a := newClusterCSCTestServer(t, func(command string) string {
|
|
muA.Lock()
|
|
defer muA.Unlock()
|
|
switch command {
|
|
case "hello":
|
|
return "%0\r\n"
|
|
case "multi":
|
|
multiA = true
|
|
return "+OK\r\n"
|
|
case "exec":
|
|
multiA = false
|
|
return "-EXECABORT Transaction discarded because of previous errors.\r\n"
|
|
case "set":
|
|
moved.Store(true)
|
|
return fmt.Sprintf("-%s %d %s\r\n", redirect, hashtag.Slot("{bar}:key"), b)
|
|
case "get":
|
|
if moved.Load() {
|
|
return fmt.Sprintf("-%s %d %s\r\n", redirect, hashtag.Slot("{bar}:key"), b)
|
|
}
|
|
if multiA {
|
|
return "+QUEUED\r\n"
|
|
}
|
|
return "$0\r\n\r\n"
|
|
default:
|
|
return "+OK\r\n"
|
|
}
|
|
})
|
|
opt := clusterCSCTestOptions(func() []ClusterSlot {
|
|
return []ClusterSlot{{Start: 0, End: 8191, Nodes: []ClusterNode{{Addr: a}}}, {Start: 8192, End: 16383, Nodes: []ClusterNode{{Addr: b}}}}
|
|
})
|
|
opt.NewClient = func(o *Options) *Client {
|
|
o.ClientSideCache = &clusterCSCRecordingCache{LocalCache: NewLocalCache(*o.ClientSideCacheConfig)}
|
|
return NewClient(o)
|
|
}
|
|
c := NewClusterClient(opt)
|
|
t.Cleanup(func() { _ = c.Close() })
|
|
ctx := context.Background()
|
|
key := "{bar}:key"
|
|
// Uncached paths leave existing caches to server invalidations.
|
|
// Unrelated keys must survive every redirect path.
|
|
for _, cached := range []string{"{bar}:cached", "{foo}:cached"} {
|
|
if err := c.Get(ctx, cached).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
var err error
|
|
switch path {
|
|
case "command":
|
|
err = c.Set(ctx, key, "v", 0).Err()
|
|
case "pipeline":
|
|
_, err = c.Pipelined(ctx, func(p Pipeliner) error { p.Set(ctx, key, "v", 0); return nil })
|
|
case "transaction":
|
|
_, err = c.TxPipelined(ctx, func(p Pipeliner) error { p.Set(ctx, key, "v", 0); return nil })
|
|
case "full-duplex":
|
|
ap, e := c.AsyncAutoPipelineWithOptions(&AutoPipelineOptions{FullDuplex: true})
|
|
if e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
err = ap.Set(ctx, key, "v", 0).Err()
|
|
_ = ap.Close()
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, addr := range []string{a, b} {
|
|
node, err := c.nodes.GetOrCreate(addr)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cache := node.Client.csc.(*clusterCSCRecordingCache)
|
|
if cache.flushes.Load() != 0 || cache.deletes.Load() != 0 {
|
|
t.Fatalf("redirect invoked cache cleanup on %s: flushes=%d deletes=%d", addr, cache.flushes.Load(), cache.deletes.Load())
|
|
}
|
|
}
|
|
before := c.CSCStats().Hits
|
|
for _, cached := range []struct{ key, value string }{{"{bar}:cached", ""}, {"{foo}:cached", "v"}} {
|
|
if value, err := c.Get(ctx, cached.key).Result(); err != nil || value != cached.value {
|
|
t.Fatalf("redirect discarded unrelated cache entry %q: %q %v", cached.key, value, err)
|
|
}
|
|
}
|
|
if stats := c.CSCStats(); stats.Hits != before+2 || stats.Entries != 2 {
|
|
t.Fatalf("redirect changed unrelated node caches: %+v", stats)
|
|
}
|
|
if got, err := c.Get(ctx, key).Result(); err != nil || got != "v" {
|
|
t.Fatalf("read did not follow redirect: %q %v", got, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClusterCSCCachingIsNodeLocalAcrossRoles(t *testing.T) {
|
|
var calls atomic.Int32
|
|
a := newClusterCSCTestServer(t, clusterCSCTestReply(&calls))
|
|
b := newClusterCSCTestServer(t, clusterCSCTestReply(&calls))
|
|
c := NewClusterClient(clusterCSCTestOptions(func() []ClusterSlot {
|
|
return []ClusterSlot{
|
|
{Start: 0, End: 8191, Nodes: []ClusterNode{{Addr: a}, {Addr: b}}}, {Start: 8192, End: 16383, Nodes: []ClusterNode{{Addr: b}}},
|
|
}
|
|
}))
|
|
t.Cleanup(func() { _ = c.Close() })
|
|
_, err := c.state.Reload(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
node, _ := c.nodes.GetOrCreate(b)
|
|
ctx := context.Background()
|
|
for i := 0; i < 2; i++ {
|
|
cmd := NewStringCmd(ctx, "get", "{bar}:replica")
|
|
if err := node.Client.Process(ctx, cmd); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if calls.Load() != 1 || node.Client.CSCStats().Entries != 1 {
|
|
t.Fatal("replica node did not use its standalone cache")
|
|
}
|
|
if err := c.Get(context.Background(), "{foo}:primary").Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := c.state.Reload(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before := calls.Load()
|
|
if err := c.Get(context.Background(), "{foo}:primary").Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if calls.Load() != before {
|
|
t.Fatal("unchanged mixed-role map retired a primary's cache")
|
|
}
|
|
// Explicit bypass must survive another dispatch to an otherwise eligible
|
|
// primary, as happens when a full-duplex retry re-enters Cluster routing.
|
|
ctx = cscBypass(ctx)
|
|
for i := 0; i < 2; i++ {
|
|
cmd := NewStringCmd(ctx, "get", "{foo}:primary")
|
|
if err := node.Client.Process(ctx, cmd); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if calls.Load() != before+2 {
|
|
t.Fatal("primary dispatch replaced explicit cache bypass")
|
|
}
|
|
}
|
|
|
|
func BenchmarkClusterCSCRoutedHit(b *testing.B) {
|
|
var calls atomic.Int32
|
|
addr := newClusterCSCTestServer(b, func(command string) string {
|
|
if command == "get" {
|
|
calls.Add(1)
|
|
return "$1\r\nv\r\n"
|
|
}
|
|
return clusterCSCTestReply(&calls)(command)
|
|
})
|
|
c := NewClusterClient(clusterCSCTestOptions(func() []ClusterSlot { return []ClusterSlot{{Start: 0, End: 16383, Nodes: []ClusterNode{{Addr: addr}}}} }))
|
|
b.Cleanup(func() { _ = c.Close() })
|
|
ctx := context.Background()
|
|
_ = c.Get(ctx, "k").Err()
|
|
b.ReportAllocs()
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
_ = c.Get(ctx, "k").Err()
|
|
}
|
|
}
|
|
|
|
func BenchmarkClientCSCHit(b *testing.B) {
|
|
var calls atomic.Int32
|
|
addr := newClusterCSCTestServer(b, func(command string) string {
|
|
if command == "get" {
|
|
return "$1\r\nv\r\n"
|
|
}
|
|
return clusterCSCTestReply(&calls)(command)
|
|
})
|
|
c := NewClient(&Options{
|
|
Addr: addr, Protocol: 3, DisableIdentity: true,
|
|
ClientSideCacheConfig: &ClientSideCacheConfig{MaxEntries: 16, MaxStaleness: time.Minute},
|
|
MaintNotificationsConfig: &maintnotifications.Config{Mode: maintnotifications.ModeDisabled},
|
|
})
|
|
b.Cleanup(func() { _ = c.Close() })
|
|
ctx := context.Background()
|
|
_ = c.Get(ctx, "k").Err()
|
|
b.ReportAllocs()
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
_ = c.Get(ctx, "k").Err()
|
|
}
|
|
}
|
|
|
|
func BenchmarkClusterCSCMiss(b *testing.B) {
|
|
var calls atomic.Int32
|
|
addr := newClusterCSCTestServer(b, clusterCSCTestReply(&calls))
|
|
c := NewClusterClient(clusterCSCTestOptions(func() []ClusterSlot {
|
|
return []ClusterSlot{{Start: 0, End: 16383, Nodes: []ClusterNode{{Addr: addr}}}}
|
|
}))
|
|
b.Cleanup(func() { _ = c.Close() })
|
|
ctx := context.Background()
|
|
_ = c.Get(ctx, "warmup").Err()
|
|
b.ReportAllocs()
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
_ = c.Get(ctx, strconv.Itoa(i)).Err()
|
|
}
|
|
}
|
|
|
|
func BenchmarkClusterCSCConcurrentHits(b *testing.B) {
|
|
var calls atomic.Int32
|
|
reply := func(command string) string {
|
|
if command == "get" {
|
|
return "$1\r\nv\r\n"
|
|
}
|
|
return clusterCSCTestReply(&calls)(command)
|
|
}
|
|
a, other := newClusterCSCTestServer(b, reply), newClusterCSCTestServer(b, reply)
|
|
c := NewClusterClient(clusterCSCTestOptions(func() []ClusterSlot {
|
|
return []ClusterSlot{{Start: 0, End: 8191, Nodes: []ClusterNode{{Addr: a}}}, {Start: 8192, End: 16383, Nodes: []ClusterNode{{Addr: other}}}}
|
|
}))
|
|
b.Cleanup(func() { _ = c.Close() })
|
|
ctx := context.Background()
|
|
keys := []string{"{bar}:hit", "{foo}:hit"}
|
|
for _, key := range keys {
|
|
_ = c.Get(ctx, key).Err()
|
|
}
|
|
var worker atomic.Uint32
|
|
b.ReportAllocs()
|
|
b.ResetTimer()
|
|
b.RunParallel(func(pb *testing.PB) {
|
|
key := keys[worker.Add(1)%2]
|
|
for pb.Next() {
|
|
_ = c.Get(ctx, key).Err()
|
|
}
|
|
})
|
|
}
|
|
|
|
func BenchmarkClusterCSCTopologyReload(b *testing.B) {
|
|
var calls atomic.Int32
|
|
a, other := newClusterCSCTestServer(b, clusterCSCTestReply(&calls)), newClusterCSCTestServer(b, clusterCSCTestReply(&calls))
|
|
var swapped bool
|
|
c := NewClusterClient(clusterCSCTestOptions(func() []ClusterSlot {
|
|
first, second := a, other
|
|
if swapped {
|
|
first, second = second, first
|
|
}
|
|
return []ClusterSlot{{Start: 0, End: 8191, Nodes: []ClusterNode{{Addr: first}}}, {Start: 8192, End: 16383, Nodes: []ClusterNode{{Addr: second}}}}
|
|
}))
|
|
b.Cleanup(func() { _ = c.Close() })
|
|
ctx := context.Background()
|
|
for _, key := range []string{"{bar}:churn", "{foo}:churn"} {
|
|
_ = c.Get(ctx, key).Err()
|
|
}
|
|
b.ReportAllocs()
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
swapped = !swapped
|
|
_, err := c.state.Reload(ctx)
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
_ = c.Get(ctx, "{bar}:churn").Err()
|
|
_ = c.Get(ctx, "{foo}:churn").Err()
|
|
}
|
|
}
|
|
|
|
func TestClusterCSCFixtureRequirement(t *testing.T) {
|
|
if os.Getenv("GOREDIS_CSC_GATE_CHILD") == "1" {
|
|
newClusterCSCLive(t)
|
|
t.Fatal("fixture unexpectedly started without redis-server")
|
|
}
|
|
binary, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, tc := range []struct {
|
|
name string
|
|
ci string
|
|
required string
|
|
}{
|
|
{name: "local"},
|
|
{name: "generic CI", ci: "true"},
|
|
{name: "dedicated live job", ci: "true", required: "1"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
cmd := exec.Command(binary, "-test.run=^TestClusterCSCFixtureRequirement$", "-test.v")
|
|
cmd.Env = append(os.Environ(), "PATH=", "GOREDIS_CSC_GATE_CHILD=1",
|
|
"CI="+tc.ci, "RE_CLUSTER=true", "GOREDIS_CSC_LIVE="+tc.required)
|
|
out, err := cmd.CombinedOutput()
|
|
if tc.required == "" {
|
|
if err != nil || !bytes.Contains(out, []byte("--- SKIP: TestClusterCSCFixtureRequirement")) {
|
|
t.Fatalf("expected skip without redis-server: %v\n%s", err, out)
|
|
}
|
|
} else if err == nil || !bytes.Contains(out, []byte("live CSC tests require redis-server")) {
|
|
t.Fatalf("expected failure without required redis-server: %v\n%s", err, out)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Run against a private six-process OSS cluster, never the developer's database.
|
|
// CI runs these fixtures inside the integration test stack's Redis image.
|
|
func newClusterCSCLive(t *testing.T) (*ClusterClient, []*Client) {
|
|
t.Helper()
|
|
binary, err := exec.LookPath("redis-server")
|
|
if err != nil {
|
|
if os.Getenv("GOREDIS_CSC_LIVE") != "" {
|
|
t.Fatal("live CSC tests require redis-server on PATH")
|
|
}
|
|
t.Skip("live CSC requires redis-server on PATH")
|
|
}
|
|
base := 18030
|
|
for ; base < 19000; base += 6 {
|
|
var listeners []net.Listener
|
|
for _, offset := range []int{0, 1, 2, 3, 4, 5, 10000, 10001, 10002, 10003, 10004, 10005} {
|
|
ln, e := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", base+offset))
|
|
if e != nil {
|
|
break
|
|
}
|
|
listeners = append(listeners, ln)
|
|
}
|
|
for _, ln := range listeners {
|
|
_ = ln.Close()
|
|
}
|
|
if len(listeners) == 12 {
|
|
break
|
|
}
|
|
}
|
|
if base >= 19000 {
|
|
t.Fatal("no free private Cluster ports")
|
|
}
|
|
dir := t.TempDir()
|
|
busFlags := clusterCSCLiveBusFlags(t, binary, dir, base)
|
|
var nodes []*Client
|
|
ctx := context.Background()
|
|
for i := 0; i < 6; i++ {
|
|
port := base + i
|
|
var output bytes.Buffer
|
|
t.Cleanup(func() {
|
|
if t.Failed() {
|
|
log, _ := os.ReadFile(filepath.Join(dir, fmt.Sprintf("node-%d.log", port)))
|
|
if len(log) == 0 {
|
|
log = output.Bytes()
|
|
}
|
|
if len(log) > 4096 {
|
|
log = log[len(log)-4096:]
|
|
}
|
|
t.Logf("private Redis %d log:\n%s", port, log)
|
|
}
|
|
})
|
|
args := []string{
|
|
"--port", strconv.Itoa(port), "--bind", "127.0.0.1", "--protected-mode", "no",
|
|
"--save", "", "--appendonly", "no", "--dbfilename", fmt.Sprintf("node-%d.rdb", port),
|
|
"--repl-diskless-sync-delay", "0",
|
|
"--cluster-enabled", "yes", "--cluster-config-file", fmt.Sprintf("node-%d.conf", port),
|
|
"--cluster-announce-ip", "127.0.0.1", "--cluster-node-timeout", "1000", "--dir", dir, "--logfile", fmt.Sprintf("node-%d.log", port),
|
|
}
|
|
cmd := exec.Command(binary, append(args, busFlags...)...)
|
|
// Redis opens startup logs before applying --dir on some versions.
|
|
// Keep every generated file in this test's directory, even then.
|
|
cmd.Dir = dir
|
|
cmd.Stdout, cmd.Stderr = &output, &output
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
done := make(chan error, 1)
|
|
go func() { done <- cmd.Wait() }()
|
|
t.Cleanup(func() { _ = cmd.Process.Kill(); <-done })
|
|
n := NewClient(&Options{
|
|
Addr: fmt.Sprintf("127.0.0.1:%d", port), Protocol: 3, DisableIdentity: true,
|
|
DialTimeout: 100 * time.Millisecond, MaxRetries: -1, MaintNotificationsConfig: &maintnotifications.Config{Mode: maintnotifications.ModeDisabled},
|
|
})
|
|
nodes = append(nodes, n)
|
|
t.Cleanup(func() { _ = n.Close() })
|
|
clusterCSCLiveOwnedReady(t, n, cmd.Process.Pid)
|
|
}
|
|
for _, n := range nodes[1:] {
|
|
if err := n.ClusterMeet(ctx, "127.0.0.1", strconv.Itoa(base)).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
clusterCSCLiveEventually(t, func() bool {
|
|
for _, n := range nodes {
|
|
s, err := n.ClusterNodes(ctx).Result()
|
|
if err != nil || len(strings.Split(strings.TrimSpace(s), "\n")) != 6 {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
for i := 0; i < 3; i++ {
|
|
start, end := i*5461, (i+1)*5461-1
|
|
if i == 2 {
|
|
end = 16383
|
|
}
|
|
if err := nodes[i].ClusterAddSlotsRange(ctx, start, end).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
id, err := nodes[i].ClusterMyID(ctx).Result()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := nodes[i+3].ClusterReplicate(ctx, id).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
clusterCSCLiveEventually(t, func() bool {
|
|
for _, n := range nodes {
|
|
s, e := n.ClusterInfo(ctx).Result()
|
|
if e != nil || !strings.Contains(s, "cluster_state:ok") {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
info, _ := nodes[0].Info(ctx, "server").Result()
|
|
for _, line := range strings.Split(info, "\n") {
|
|
if strings.HasPrefix(line, "redis_version:") {
|
|
t.Log(strings.TrimSpace(line))
|
|
}
|
|
}
|
|
c := NewClusterClient(&ClusterOptions{
|
|
Addrs: []string{nodes[0].opt.Addr}, Protocol: 3, DisableIdentity: true,
|
|
PoolSize: 4, ClientSideCacheConfig: &ClientSideCacheConfig{MaxEntries: 128, DrainInterval: time.Millisecond},
|
|
MaintNotificationsConfig: &maintnotifications.Config{Mode: maintnotifications.ModeDisabled},
|
|
})
|
|
t.Cleanup(func() { _ = c.Close() })
|
|
return c, nodes
|
|
}
|
|
|
|
// Probe with a short-lived standalone process: new servers require an explicit
|
|
// cluster-bus security setting before Cluster startup, while older versions
|
|
// reject that setting. Do not guess support from a development version number.
|
|
func clusterCSCLiveBusFlags(t *testing.T, binary, dir string, port int) []string {
|
|
t.Helper()
|
|
var output bytes.Buffer
|
|
cmd := exec.Command(binary, "--port", strconv.Itoa(port), "--bind", "127.0.0.1", "--protected-mode", "no",
|
|
"--save", "", "--appendonly", "no", "--dir", dir)
|
|
cmd.Dir = dir
|
|
cmd.Stdout, cmd.Stderr = &output, &output
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
done := make(chan error, 1)
|
|
go func() { done <- cmd.Wait() }()
|
|
var once sync.Once
|
|
stop := func() { once.Do(func() { _ = cmd.Process.Kill(); <-done }) }
|
|
t.Cleanup(func() {
|
|
stop()
|
|
if t.Failed() {
|
|
t.Logf("private Redis startup probe:\n%s", output.String())
|
|
}
|
|
})
|
|
c := NewClient(&Options{
|
|
Addr: fmt.Sprintf("127.0.0.1:%d", port), Protocol: 3, DisableIdentity: true, MaxRetries: -1,
|
|
DialTimeout: 100 * time.Millisecond, MaintNotificationsConfig: &maintnotifications.Config{Mode: maintnotifications.ModeDisabled},
|
|
})
|
|
t.Cleanup(func() { _ = c.Close() })
|
|
ctx := context.Background()
|
|
clusterCSCLiveOwnedReady(t, c, cmd.Process.Pid)
|
|
cfg, err := c.ConfigGet(ctx, "cluster-bus-port-protected-mode").Result()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = c.Close()
|
|
stop()
|
|
if _, supported := cfg["cluster-bus-port-protected-mode"]; supported {
|
|
// Every bus peer is bound to loopback and belongs to this fixture.
|
|
return []string{"--cluster-bus-port-protected-mode", "no"}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func clusterCSCLiveOwnedReady(t *testing.T, c *Client, pid int) {
|
|
t.Helper()
|
|
// Port probing cannot reserve ports across process startup. Before issuing
|
|
// any Cluster mutation, reject a different service that won that race.
|
|
clusterCSCLiveEventually(t, func() bool {
|
|
info, err := c.Info(context.Background(), "server").Result()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, line := range strings.Split(info, "\n") {
|
|
if strings.HasPrefix(line, "process_id:") {
|
|
if strings.TrimSpace(line) != "process_id:"+strconv.Itoa(pid) {
|
|
t.Fatalf("private Redis port belongs to another process: %s, expected PID %d", strings.TrimSpace(line), pid)
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
t.Fatal("private Redis did not report its process identity")
|
|
return false
|
|
})
|
|
}
|
|
|
|
func clusterCSCLiveEventually(t testing.TB, check func() bool) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(10 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if check() {
|
|
return
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
t.Fatal("live Cluster condition did not complete")
|
|
}
|
|
|
|
func TestClusterCSCLiveReplicaInvalidation(t *testing.T) {
|
|
writer, _ := newClusterCSCLive(t)
|
|
ctx := context.Background()
|
|
reader := NewClusterClient(&ClusterOptions{
|
|
Addrs: writer.opt.Addrs, Protocol: 3, ReadOnly: true, DisableIdentity: true,
|
|
ClientSideCacheConfig: &ClientSideCacheConfig{MaxEntries: 32, DrainInterval: time.Millisecond},
|
|
MaintNotificationsConfig: &maintnotifications.Config{Mode: maintnotifications.ModeDisabled},
|
|
})
|
|
t.Cleanup(func() { _ = reader.Close() })
|
|
key := "{csc-replica}:key"
|
|
// Do not accidentally exercise ReadOnly's primary fallback while the new
|
|
// replica is still completing its initial synchronization. CLUSTER SHARDS
|
|
// includes replicas while their health is still loading.
|
|
clusterCSCLiveEventually(t, func() bool {
|
|
state, err := reader.state.Reload(ctx)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
entry := state.slotEntry(hashtag.Slot(key))
|
|
return entry != nil && len(entry.nodes) > 1 && state.nodeOnline(entry.nodes[1])
|
|
})
|
|
for _, value := range []string{"before", "after"} {
|
|
if err := writer.Set(ctx, key, value, 0).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
clusterCSCLiveEventually(t, func() bool {
|
|
got, err := reader.Get(ctx, key).Result()
|
|
return err == nil && got == value
|
|
})
|
|
before := reader.CSCStats().Hits
|
|
if got, err := reader.Get(ctx, key).Result(); err != nil || got != value {
|
|
t.Fatalf("replica cached read: %q %v", got, err)
|
|
}
|
|
if reader.CSCStats().Hits != before+1 {
|
|
t.Fatal("replica-routed read did not hit its node cache")
|
|
}
|
|
}
|
|
state, err := reader.state.Get(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, primary := range state.Masters {
|
|
if primary.Client.CSCStats().Entries != 0 {
|
|
t.Fatal("replica-routed read populated a primary cache")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClusterCSCLiveAtomicSlotMigration(t *testing.T) {
|
|
reader, nodes := newClusterCSCLive(t)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
info, err := nodes[0].Info(ctx, "server").Result()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var major, minor int
|
|
for _, line := range strings.Split(info, "\n") {
|
|
if version, ok := strings.CutPrefix(line, "redis_version:"); ok {
|
|
if _, err := fmt.Sscanf(version, "%d.%d", &major, &minor); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
// This private fixture's version can differ from the Ginkgo suite's server.
|
|
if major == 0 {
|
|
t.Fatal("private Redis did not report its version")
|
|
}
|
|
if major < 8 || (major == 8 && minor < 4) {
|
|
t.Skip("atomic slot migration requires Redis 8.4+")
|
|
}
|
|
key := "{csc-atomic-migration}:key"
|
|
if err := reader.Set(ctx, key, "before", 0).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for i := 0; i < 2; i++ {
|
|
if got, err := reader.Get(ctx, key).Result(); err != nil || got != "before" {
|
|
t.Fatalf("warm cache: %q %v", got, err)
|
|
}
|
|
}
|
|
if reader.CSCStats().Hits == 0 {
|
|
t.Fatal("migration must start with a verified cache hit")
|
|
}
|
|
source, err := reader.MasterForKey(ctx, key)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var destination *Client
|
|
for _, node := range nodes[:3] {
|
|
if node.opt.Addr != source.opt.Addr {
|
|
destination = node
|
|
break
|
|
}
|
|
}
|
|
if destination == nil {
|
|
t.Fatal("no importing node")
|
|
}
|
|
// Start probing before IMPORT to write as soon as the importer accepts it,
|
|
// not after STATUS/completed or a topology poll has hidden the handoff window.
|
|
written := make(chan time.Time, 1)
|
|
writeErr := make(chan error, 1)
|
|
writerDone := make(chan struct{})
|
|
defer func() { cancel(); <-writerDone }()
|
|
go func() {
|
|
defer close(writerDone)
|
|
for ctx.Err() == nil {
|
|
err := destination.Set(ctx, key, "after", 0).Err()
|
|
if err == nil {
|
|
written <- time.Now()
|
|
return
|
|
}
|
|
if moved, ask, _ := isMovedError(err); !moved && !ask && !IsTryAgainError(err) {
|
|
writeErr <- err
|
|
return
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
}()
|
|
slot := hashtag.Slot(key)
|
|
task, err := destination.Do(ctx, "CLUSTER", "MIGRATION", "IMPORT", slot, slot).Text()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var acknowledged time.Time
|
|
select {
|
|
case acknowledged = <-written:
|
|
case err := <-writeErr:
|
|
t.Fatalf("importer write: %v", err)
|
|
case <-ctx.Done():
|
|
t.Fatal("importer never accepted the write")
|
|
}
|
|
staleReads := 0
|
|
var lastStale time.Duration
|
|
for {
|
|
if ctx.Err() != nil {
|
|
t.Fatalf("cache did not converge after atomic migration: stale reads=%d", staleReads)
|
|
}
|
|
value, err := reader.Get(ctx, key).Result()
|
|
if err != nil {
|
|
t.Fatalf("read during atomic migration: %v", err)
|
|
}
|
|
if value == "after" {
|
|
t.Logf("stale reads=%d, last stale=%s, first fresh=%s", staleReads, lastStale, time.Since(acknowledged))
|
|
break
|
|
}
|
|
if value != "before" {
|
|
t.Fatalf("unexpected value during atomic migration: %q", value)
|
|
}
|
|
staleReads++
|
|
lastStale = time.Since(acknowledged)
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
// Measure rather than require a positive window or a machine-specific timing
|
|
// bound: server versions may close the window, and CI scheduling adds latency.
|
|
status, err := destination.Do(ctx, "CLUSTER", "MIGRATION", "STATUS", "ID", task).Result()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Logf("atomic migration %s status: %v", task, status)
|
|
}
|
|
|
|
func TestClusterCSCLiveMissingKeyMigration(t *testing.T) {
|
|
c, nodes := newClusterCSCLive(t)
|
|
ctx := context.Background()
|
|
key := "{csc-cluster-absence}:key"
|
|
for i := 0; i < 2; i++ {
|
|
if err := c.Get(ctx, key).Err(); err != Nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if stats := c.CSCStats(); stats.Hits != 0 || stats.Entries != 0 {
|
|
t.Fatalf("missing reads populated the cache: %+v", stats)
|
|
}
|
|
source, err := c.MasterForKey(ctx, key)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var a, b *Client
|
|
for _, n := range nodes[:3] {
|
|
if n.opt.Addr == source.opt.Addr {
|
|
a = n
|
|
} else if b == nil {
|
|
b = n
|
|
}
|
|
}
|
|
if a == nil || b == nil {
|
|
t.Fatal("migration nodes not found")
|
|
}
|
|
aid, err := a.ClusterMyID(ctx).Result()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bid, err := b.ClusterMyID(ctx).Result()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
slot := hashtag.Slot(key)
|
|
if err := b.Do(ctx, "cluster", "setslot", slot, "importing", aid).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := a.Do(ctx, "cluster", "setslot", slot, "migrating", bid).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
conn := b.Conn()
|
|
defer conn.Close()
|
|
if err := conn.Do(ctx, "asking").Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := conn.Set(ctx, key, "created-on-importer", 0).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := source.Ping(ctx).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := c.Get(ctx, key).Result()
|
|
if err != nil || got != "created-on-importer" {
|
|
t.Fatalf("cached absence hid ASK: got=%q err=%v", got, err)
|
|
}
|
|
}
|
|
|
|
func TestClusterCSCLiveChangedValueFailoverFailback(t *testing.T) {
|
|
c, nodes := newClusterCSCLive(t)
|
|
ctx := context.Background()
|
|
key := "{csc-cluster-failback}:key"
|
|
if err := c.Set(ctx, key, "before", 0).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := c.Get(ctx, key).Val(); got != "before" {
|
|
t.Fatal(got)
|
|
}
|
|
if got := c.Get(ctx, key).Val(); got != "before" {
|
|
t.Fatal(got)
|
|
}
|
|
if c.CSCStats().Hits == 0 {
|
|
t.Fatal("failover test did not warm the primary cache")
|
|
}
|
|
source, err := c.MasterForKey(ctx, key)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var a, b *Client
|
|
for i, n := range nodes[:3] {
|
|
if n.opt.Addr == source.opt.Addr {
|
|
a, b = n, nodes[i+3]
|
|
break
|
|
}
|
|
}
|
|
if a == nil {
|
|
t.Fatal("original primary not found")
|
|
}
|
|
if err := b.ReadOnly(ctx).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
clusterCSCLiveEventually(t, func() bool { return b.Get(ctx, key).Val() == "before" })
|
|
reloadOwner := func(expected *Client) {
|
|
t.Helper()
|
|
clusterCSCLiveEventually(t, func() bool {
|
|
if _, err := c.state.Reload(ctx); err != nil {
|
|
return false
|
|
}
|
|
n, err := c.MasterForKey(ctx, key)
|
|
return err == nil && n.opt.Addr == expected.opt.Addr
|
|
})
|
|
}
|
|
if err := b.ClusterFailover(ctx).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reloadOwner(b)
|
|
if err := c.Set(ctx, key, "changed-on-b", 0).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := c.Get(ctx, key).Val(); got != "changed-on-b" {
|
|
t.Fatal(got)
|
|
}
|
|
if err := a.ReadOnly(ctx).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
clusterCSCLiveEventually(t, func() bool { return a.Get(ctx, key).Val() == "changed-on-b" })
|
|
// Data synchronization can precede gossip convergence; wait for the
|
|
// cluster to recognize A as B's replica before requesting failback.
|
|
aid, err := a.ClusterMyID(ctx).Result()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bid, err := b.ClusterMyID(ctx).Result()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
clusterCSCLiveEventually(t, func() bool {
|
|
for _, observer := range nodes {
|
|
view, err := observer.ClusterNodes(ctx).Result()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
found := false
|
|
for _, line := range strings.Split(view, "\n") {
|
|
fields := strings.Fields(line)
|
|
if len(fields) > 3 && fields[0] == aid {
|
|
found = strings.Contains(fields[2], "slave") && fields[3] == bid
|
|
}
|
|
}
|
|
if !found {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
if err := a.ClusterFailover(ctx).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reloadOwner(a)
|
|
for i := 0; i < 3; i++ {
|
|
if got, err := c.Get(ctx, key).Result(); err != nil || got != "changed-on-b" {
|
|
t.Fatalf("failback reused an old value: %q %v", got, err)
|
|
}
|
|
}
|
|
if err := a.Set(ctx, key, "after-failback", 0).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
clusterCSCLiveEventually(t, func() bool { return c.Get(ctx, key).Val() == "after-failback" })
|
|
}
|
|
|
|
func TestClusterCSCLive(t *testing.T) {
|
|
c, nodes := newClusterCSCLive(t)
|
|
ctx := context.Background()
|
|
t.Run("hits external invalidation aggregate replies and uncached pipelines", func(t *testing.T) {
|
|
keys := []string{"{bar}:csc-live", "{foo}:csc-live", "{baz}:csc-live"}
|
|
for i, key := range keys {
|
|
value := fmt.Sprintf("v%d", i)
|
|
if err := c.Set(ctx, key, value, 0).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for j := 0; j < 2; j++ {
|
|
if got, err := c.Get(ctx, key).Result(); err != nil || got != value {
|
|
t.Fatalf("GET %q %v", got, err)
|
|
}
|
|
}
|
|
}
|
|
if c.CSCStats().Hits < 3 {
|
|
t.Fatal("routed cache hits not observed")
|
|
}
|
|
writer := NewClusterClient(&ClusterOptions{Addrs: []string{nodes[0].opt.Addr}, Protocol: 3, MaintNotificationsConfig: &maintnotifications.Config{Mode: maintnotifications.ModeDisabled}})
|
|
defer writer.Close()
|
|
if err := writer.Set(ctx, keys[0], "external", 0).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
clusterCSCLiveEventually(t, func() bool { return c.Get(ctx, keys[0]).Val() == "external" })
|
|
values, err := c.MGet(ctx, keys[0], "{bar}:other").Result()
|
|
if err != nil || len(values) != 2 || values[0] != "external" {
|
|
t.Fatalf("MGET %v %v", values, err)
|
|
}
|
|
before := c.CSCStats().Hits
|
|
if _, err := c.Pipelined(ctx, func(p Pipeliner) error { p.Get(ctx, keys[0]); p.Get(ctx, keys[1]); return nil }); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if c.CSCStats().Hits != before {
|
|
t.Fatal("ordinary pipeline used CSC")
|
|
}
|
|
if _, err := c.TxPipelined(ctx, func(p Pipeliner) error { p.Get(ctx, keys[0]); return nil }); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
t.Run("borrowed Conn and Watch retain node caching", func(t *testing.T) {
|
|
key := "{bar}:sticky"
|
|
_ = c.Set(ctx, key, "v", 0).Err()
|
|
_ = c.Get(ctx, key).Err()
|
|
node, err := c.MasterForKey(ctx, key)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
conn := node.Conn()
|
|
if err := conn.Ping(ctx).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = conn.Close()
|
|
if err := c.Watch(ctx, func(tx *Tx) error { return tx.Get(ctx, key).Err() }, key); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before := c.CSCStats().Hits
|
|
_ = c.Get(ctx, key).Err()
|
|
if !node.cscActive.Load() {
|
|
t.Fatal("sticky wrapper Close disabled the parent cache")
|
|
}
|
|
clusterCSCLiveEventually(t, func() bool { _ = c.Get(ctx, key).Err(); return c.CSCStats().Hits > before })
|
|
})
|
|
t.Run("coalesced misses refresh and concurrent hits", func(t *testing.T) {
|
|
r := NewClusterClient(&ClusterOptions{
|
|
Addrs: []string{nodes[0].opt.Addr}, Protocol: 3, DisableIdentity: true, PoolSize: 4,
|
|
ClientSideCacheConfig: &ClientSideCacheConfig{MaxEntries: 128, DrainInterval: time.Millisecond},
|
|
ClientSideCacheRefreshOnInvalidate: true, ClientSideCacheCoalesceMisses: true,
|
|
ClientSideCacheInvalidationBatchWindow: time.Millisecond,
|
|
MaintNotificationsConfig: &maintnotifications.Config{Mode: maintnotifications.ModeDisabled},
|
|
})
|
|
defer r.Close()
|
|
key := "{foo}:refresh"
|
|
if err := c.Set(ctx, key, "initial", 0).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := r.Get(ctx, key).Val(); got != "initial" {
|
|
t.Fatal(got)
|
|
}
|
|
if err := c.Set(ctx, key, "updated", 0).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
clusterCSCLiveEventually(t, func() bool { return r.CSCRefreshStats().Refreshed > 0 })
|
|
if got := r.Get(ctx, key).Val(); got != "updated" {
|
|
t.Fatal(got)
|
|
}
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 32; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
if got, err := r.Get(ctx, key).Result(); err != nil || got != "updated" {
|
|
t.Errorf("concurrent GET %q %v", got, err)
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
})
|
|
t.Run("disconnect evicts only the serving connection", func(t *testing.T) {
|
|
// Keep the drainer out of this ownership test: no invalidations are
|
|
// needed, and borrowing all idle connections must be deterministic.
|
|
r := NewClusterClient(&ClusterOptions{
|
|
Addrs: []string{nodes[0].opt.Addr}, Protocol: 3, DisableIdentity: true, PoolSize: 4,
|
|
ClientSideCacheConfig: &ClientSideCacheConfig{MaxEntries: 128, DrainInterval: time.Hour},
|
|
MaintNotificationsConfig: &maintnotifications.Config{Mode: maintnotifications.ModeDisabled},
|
|
})
|
|
defer r.Close()
|
|
k1, k2 := "{bar}:conn1", "{bar}:conn2"
|
|
_ = c.Set(ctx, k1, "one", 0).Err()
|
|
_ = c.Set(ctx, k2, "two", 0).Err()
|
|
if err := r.Get(ctx, k1).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
node, _ := r.MasterForKey(ctx, k1)
|
|
cache := node.csc.(*LocalCache)
|
|
owner := func(key string) uint64 {
|
|
entryKey, _ := cscRenderEntryKey(node.cscKeyPrefix, node.metadataView().cscFingerprint, NewStringCmd(ctx, "get", key))
|
|
s := cache.shardFor(entryKey)
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if entry := s.entries[entryKey]; entry != nil {
|
|
return entry.ownerConnID
|
|
}
|
|
return 0
|
|
}
|
|
ownerID := owner(k1)
|
|
if ownerID == 0 {
|
|
t.Fatal("first entry has no tracking owner")
|
|
}
|
|
var held *pool.Conn
|
|
var borrowed []*pool.Conn
|
|
defer func() {
|
|
for _, cn := range borrowed {
|
|
node.releaseConn(ctx, cn, nil)
|
|
}
|
|
}()
|
|
for i := 0; i < 4; i++ {
|
|
cn, err := node.getConn(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cn.GetID() == ownerID {
|
|
held = cn
|
|
break
|
|
}
|
|
borrowed = append(borrowed, cn)
|
|
}
|
|
if held == nil {
|
|
t.Fatal("tracking owner was not present in its pool")
|
|
}
|
|
if err := r.Get(ctx, k2).Err(); err != nil {
|
|
node.releaseConn(ctx, held, nil)
|
|
t.Fatal(err)
|
|
}
|
|
if id := owner(k2); id == 0 || id == ownerID {
|
|
node.releaseConn(ctx, held, nil)
|
|
t.Fatal("second entry must use a different tracking connection")
|
|
}
|
|
node.connPool.Remove(ctx, held, fmt.Errorf("test connection loss"))
|
|
if owner(k1) != 0 {
|
|
t.Fatal("lost connection's entry was not evicted")
|
|
}
|
|
before := r.CSCStats()
|
|
if got := r.Get(ctx, k2).Val(); got != "two" {
|
|
t.Fatal(got)
|
|
}
|
|
if r.CSCStats().Hits != before.Hits+1 {
|
|
t.Fatal("unrelated connection's entry was flushed")
|
|
}
|
|
if got := r.Get(ctx, k1).Val(); got != "one" {
|
|
t.Fatal(got)
|
|
}
|
|
if r.CSCStats().Misses != before.Misses+1 || owner(k1) == 0 {
|
|
t.Fatal("evicted entry was not fetched over a new tracked connection")
|
|
}
|
|
})
|
|
t.Run("fanout caches actual subcommands", func(t *testing.T) {
|
|
r := NewClusterClient(&ClusterOptions{
|
|
Addrs: []string{nodes[0].opt.Addr}, Protocol: 3, DisableIdentity: true,
|
|
ClientSideCacheConfig: &ClientSideCacheConfig{MaxEntries: 128},
|
|
MaintNotificationsConfig: &maintnotifications.Config{Mode: maintnotifications.ModeDisabled},
|
|
})
|
|
defer r.Close()
|
|
r.SetCommandInfoResolver(NewCommandInfoResolver(func(_ context.Context, cmd Cmder) *routing.CommandPolicy {
|
|
if cmd.Name() == "mget" {
|
|
return &routing.CommandPolicy{Request: routing.ReqMultiShard, Response: routing.RespDefaultHashSlot}
|
|
}
|
|
return nil
|
|
}))
|
|
keys := []string{"{bar}:fanout", "{foo}:fanout", "{baz}:fanout"}
|
|
for _, key := range keys {
|
|
if err := c.Set(ctx, key, key, 0).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for attempt := 0; attempt < 2; attempt++ {
|
|
values, err := r.MGet(ctx, keys...).Result()
|
|
if err != nil || len(values) != len(keys) {
|
|
t.Fatalf("fanout MGET: %v %v", values, err)
|
|
}
|
|
for i, value := range values {
|
|
if value != keys[i] {
|
|
t.Fatalf("fanout reply order: %v", values)
|
|
}
|
|
}
|
|
}
|
|
if stats := r.CSCStats(); stats.Hits != 3 || stats.Entries != 3 {
|
|
t.Fatalf("want three node-local subcommand hits/entries, got %+v", stats)
|
|
}
|
|
})
|
|
t.Run("ordered AutoPipeline solo uses CSC without overtaking writes", func(t *testing.T) {
|
|
ap, err := c.AsyncAutoPipelineWithOptions(&AutoPipelineOptions{MaxBatchSize: 1})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
key := "{foo}:solo-order"
|
|
set := ap.Set(ctx, key, "ordered", 0)
|
|
get := ap.Get(ctx, key)
|
|
if err := set.Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if value, err := get.Result(); err != nil || value != "ordered" {
|
|
t.Fatalf("queued GET overtook SET: %q %v", value, err)
|
|
}
|
|
before := c.CSCStats().Hits
|
|
clusterCSCLiveEventually(t, func() bool {
|
|
return ap.Get(ctx, key).Val() == "ordered" && c.CSCStats().Hits > before
|
|
})
|
|
})
|
|
t.Run("another client creates missing key via ASK without reader entry age", func(t *testing.T) {
|
|
newCachingClient := func() *ClusterClient {
|
|
client := NewClusterClient(&ClusterOptions{
|
|
Addrs: []string{nodes[0].opt.Addr}, Protocol: 3, PoolSize: 1,
|
|
DisableIdentity: true, DisableRoutingPolicies: true,
|
|
ClientSideCacheConfig: &ClientSideCacheConfig{MaxEntries: 16},
|
|
MaintNotificationsConfig: &maintnotifications.Config{Mode: maintnotifications.ModeDisabled},
|
|
})
|
|
t.Cleanup(func() { _ = client.Close() })
|
|
return client
|
|
}
|
|
reader, writer := newCachingClient(), newCachingClient()
|
|
key := "{csc-external-ask}:absent"
|
|
existing := "{csc-external-ask}:present"
|
|
slot := hashtag.Slot(key)
|
|
source, err := reader.MasterForKey(ctx, key)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var a, b *Client
|
|
for _, node := range nodes[:3] {
|
|
if node.opt.Addr == source.opt.Addr {
|
|
a = node
|
|
} else if b == nil {
|
|
b = node
|
|
}
|
|
}
|
|
if a == nil || b == nil {
|
|
t.Fatal("test owners not found")
|
|
}
|
|
aid, err := a.ClusterMyID(ctx).Result()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bid, err := b.ClusterMyID(ctx).Result()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := writer.Set(ctx, existing, "kept-on-a", 0).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if source.csc.(*LocalCache).effectiveMaxStaleness() != 0 {
|
|
t.Fatal("test must not rely on an entry-age limit")
|
|
}
|
|
for _, client := range []*ClusterClient{reader, writer} {
|
|
if err := client.Get(ctx, key).Err(); err != Nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for i := 0; i < 2; i++ {
|
|
got, err := reader.MGet(ctx, existing, key).Result()
|
|
if err != nil || !reflect.DeepEqual(got, []interface{}{"kept-on-a", nil}) {
|
|
t.Fatalf("initial mixed MGET: %v %v", got, err)
|
|
}
|
|
}
|
|
if reader.CSCStats().Hits != 0 || reader.CSCStats().Entries != 0 {
|
|
t.Fatal("mixed MGET must not cache even though one key exists")
|
|
}
|
|
t.Cleanup(func() {
|
|
conn := b.Conn()
|
|
_ = conn.Do(ctx, "asking").Err()
|
|
_ = conn.Del(ctx, key, existing).Err()
|
|
_ = conn.Close()
|
|
_ = a.Do(ctx, "cluster", "setslot", slot, "stable").Err()
|
|
_ = b.Do(ctx, "cluster", "setslot", slot, "stable").Err()
|
|
_ = a.Del(ctx, existing).Err()
|
|
})
|
|
if err := b.Do(ctx, "cluster", "setslot", slot, "importing", aid).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := a.Do(ctx, "cluster", "setslot", slot, "migrating", bid).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := writer.Set(ctx, key, "created-on-b", 0).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, err := writer.Get(ctx, key).Result(); err != nil || got != "created-on-b" {
|
|
t.Fatalf("writer did not follow ASK: %q %v", got, err)
|
|
}
|
|
// Read A's connection after the write; pending push frames would be
|
|
// consumed here. No invalidation for creating this key on B is sent to A.
|
|
if err := source.Ping(ctx).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before := reader.CSCStats().Hits
|
|
if source.CSCStats().Entries != 0 {
|
|
t.Fatal("missing GET populated the source cache")
|
|
}
|
|
// The existing key has NOT migrated. Same-slot membership does not
|
|
// make the earlier mixed reply safe: Redis must now return TRYAGAIN,
|
|
// not the cached [kept-on-a, nil]. Check before GET observes any ASK.
|
|
if _, err := reader.MGet(ctx, existing, key).Result(); err == nil || !strings.HasPrefix(err.Error(), "TRYAGAIN") {
|
|
t.Fatalf("split-slot MGET did not reach Redis: %v", err)
|
|
}
|
|
if got, err := reader.Get(ctx, key).Result(); err != nil || got != "created-on-b" {
|
|
t.Fatalf("uncached absence did not expose ASK: %q %v", got, err)
|
|
}
|
|
if reader.CSCStats().Hits != before {
|
|
t.Fatal("reader served a cached absence")
|
|
}
|
|
_, destPort, _ := net.SplitHostPort(b.opt.Addr)
|
|
if err := a.Migrate(ctx, "127.0.0.1", destPort, existing, 0, 3*time.Second).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := reader.MGet(ctx, existing, key).Result()
|
|
if err != nil || !reflect.DeepEqual(got, []interface{}{"kept-on-a", "created-on-b"}) {
|
|
t.Fatalf("MGET after migrating both keys: %v %v", got, err)
|
|
}
|
|
})
|
|
t.Run("ASK missing key and MOVED migration", func(t *testing.T) {
|
|
// This slot must not also contain keys from earlier subtests: assigning
|
|
// its new owner is valid only after every source key has migrated.
|
|
key := "{csc-migration}:absent"
|
|
slot := hashtag.Slot(key)
|
|
source, err := c.MasterForKey(ctx, key)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var a, b *Client
|
|
for _, n := range nodes[:3] {
|
|
if n.opt.Addr == source.opt.Addr {
|
|
a = n
|
|
} else if b == nil {
|
|
b = n
|
|
}
|
|
}
|
|
if a == nil || b == nil {
|
|
t.Fatal("test owners not found")
|
|
}
|
|
aid, _ := a.ClusterMyID(ctx).Result()
|
|
bid, _ := b.ClusterMyID(ctx).Result()
|
|
// Read the missing key before IMPORTING/MIGRATING starts, without caching it.
|
|
if err := c.Get(ctx, key).Err(); err != Nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := b.Do(ctx, "cluster", "setslot", slot, "importing", aid).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := a.Do(ctx, "cluster", "setslot", slot, "migrating", bid).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := c.Set(ctx, key, "imported", 0).Err(); err != nil {
|
|
t.Fatal(err)
|
|
} // ASK is observed on a write.
|
|
if got, err := c.Get(ctx, key).Result(); err != nil || got != "imported" {
|
|
t.Fatalf("GET did not follow ASK after creation: %q %v", got, err)
|
|
}
|
|
// Make permanent ownership visible. Reads must discover MOVED on a miss.
|
|
for _, n := range []*Client{b, a} {
|
|
if err := n.Do(ctx, "cluster", "setslot", slot, "node", bid).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for _, n := range nodes[:3] {
|
|
if n != a && n != b {
|
|
_ = n.Do(ctx, "cluster", "setslot", slot, "node", bid).Err()
|
|
}
|
|
}
|
|
if _, err := c.state.Reload(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := c.Get(ctx, key).Val(); got != "imported" {
|
|
t.Fatal(got)
|
|
}
|
|
// Existing tracked-key migration sends invalidation to the original owner.
|
|
existing := "{csc-migration}:existing"
|
|
_ = c.Set(ctx, existing, "move", 0).Err()
|
|
_ = c.Get(ctx, existing).Err()
|
|
refreshClient := NewClusterClient(&ClusterOptions{
|
|
Addrs: []string{b.opt.Addr}, Protocol: 3, DisableIdentity: true,
|
|
ClientSideCacheConfig: &ClientSideCacheConfig{MaxEntries: 64, DrainInterval: time.Millisecond},
|
|
ClientSideCacheRefreshOnInvalidate: true,
|
|
MaintNotificationsConfig: &maintnotifications.Config{Mode: maintnotifications.ModeDisabled},
|
|
})
|
|
defer refreshClient.Close()
|
|
if got := refreshClient.Get(ctx, existing).Val(); got != "move" {
|
|
t.Fatal(got)
|
|
}
|
|
refreshSource, _ := refreshClient.MasterForKey(ctx, existing)
|
|
refreshCacheKey, _ := cscRenderEntryKey(refreshSource.cscKeyPrefix, refreshSource.metadataView().cscFingerprint, NewStringCmd(ctx, "get", existing))
|
|
if err := a.Do(ctx, "cluster", "setslot", slot, "importing", bid).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := b.Do(ctx, "cluster", "setslot", slot, "migrating", aid).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, port, _ := net.SplitHostPort(a.opt.Addr)
|
|
if err := b.Migrate(ctx, "127.0.0.1", port, existing, 0, 3*time.Second).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// No foreground call observes the redirect for refreshClient: its tracked
|
|
// source gets invalidated, and node-local refresh sees ASK first.
|
|
clusterCSCLiveEventually(t, func() bool {
|
|
_, hit := refreshSource.csc.Get(ctx, refreshCacheKey)
|
|
return !hit
|
|
})
|
|
importConn := a.Conn()
|
|
if err := importConn.Process(ctx, NewCmd(ctx, "asking")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := importConn.Set(ctx, existing, "moved-updated", 0).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = importConn.Close()
|
|
clusterCSCLiveEventually(t, func() bool { return c.Get(ctx, existing).Val() == "moved-updated" })
|
|
if got := c.Get(ctx, existing).Val(); got != "moved-updated" {
|
|
t.Fatal(got)
|
|
}
|
|
// Move all remaining keys in this slot before completing failback.
|
|
migrating, err := b.ClusterGetKeysInSlot(ctx, slot, 100).Result()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, k := range migrating {
|
|
if err := b.Migrate(ctx, "127.0.0.1", port, k, 0, 3*time.Second).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for _, n := range []*Client{a, b} {
|
|
if err := n.Do(ctx, "cluster", "setslot", slot, "node", aid).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for _, n := range nodes[:3] {
|
|
if n != a && n != b {
|
|
_ = n.Do(ctx, "cluster", "setslot", slot, "node", aid).Err()
|
|
}
|
|
}
|
|
if _, err := c.state.Reload(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := c.Get(ctx, existing).Val(); got != "moved-updated" {
|
|
t.Fatal(got)
|
|
}
|
|
})
|
|
t.Run("primary promotion invalidates previous ownership", func(t *testing.T) {
|
|
key := "{bar}:promote"
|
|
if err := c.Set(ctx, key, "before", 0).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := c.Get(ctx, key).Val(); got != "before" {
|
|
t.Fatal(got)
|
|
}
|
|
old, err := c.MasterForKey(ctx, key)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var replica *Client
|
|
for _, n := range nodes[3:] {
|
|
info, e := n.Info(ctx, "replication").Result()
|
|
if e == nil && strings.Contains(info, "master_port:"+strings.TrimPrefix(old.opt.Addr, "127.0.0.1:")) {
|
|
replica = n
|
|
break
|
|
}
|
|
}
|
|
if replica == nil {
|
|
t.Fatal("replica not found")
|
|
}
|
|
if err := replica.ReadOnly(ctx).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
clusterCSCLiveEventually(t, func() bool { return replica.Get(ctx, key).Val() == "before" })
|
|
if err := replica.ClusterFailover(ctx).Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
clusterCSCLiveEventually(t, func() bool {
|
|
_, e := c.state.Reload(ctx)
|
|
if e != nil {
|
|
return false
|
|
}
|
|
n, e := c.MasterForKey(ctx, key)
|
|
return e == nil && n.opt.Addr == replica.opt.Addr
|
|
})
|
|
if got := c.Get(ctx, key).Val(); got != "before" {
|
|
t.Fatal(got)
|
|
}
|
|
})
|
|
}
|