Skip to content

Commit 6433a82

Browse files
authored
Merge pull request #42 from joomcode/feature/tolerate-recent-replica-link-down
Tolerate a recently broken replica link when picking hosts for reads
2 parents 24a7f79 + cc922c8 commit 6433a82

6 files changed

Lines changed: 147 additions & 7 deletions

File tree

‎redis/error.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ var (
7373
ErrClusterDown = ErrResult.NewSubtype("clusterdown", ErrTraitNotSent)
7474
// ErrLoading - redis didn't finish start
7575
ErrLoading = ErrResult.NewSubtype("loading", ErrTraitNotSent)
76+
// ErrMasterDown - MASTERDOWN response: replica lost its master and replica-serve-stale-data is 'no'
77+
ErrMasterDown = ErrResult.NewSubtype("masterdown", ErrTraitNotSent)
7678
// ErrExecEmpty - EXEC returns nil (WATCH failed) (it is strange, cause we don't support WATCH)
7779
ErrExecEmpty = ErrResult.NewSubtype("exec_empty")
7880
// ErrExecAbort - EXEC returns EXECABORT

‎redis/reader.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ func ReadResponse(b *bufio.Reader) (interface{}, int) {
5454
if strings.HasPrefix(txt, "LOADING") {
5555
return ErrLoading.New(txt), len(line)
5656
}
57+
if strings.HasPrefix(txt, "MASTERDOWN") {
58+
return ErrMasterDown.New(txt), len(line)
59+
}
5760
if strings.HasPrefix(txt, "EXECABORT") {
5861
return ErrExecAbort.New(txt), len(line)
5962
}

‎redis/reader_test.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,12 @@ func TestReadResponse_Correct(t *testing.T) {
145145
assert.Equal(t, "LOADING", err.Message())
146146
}
147147

148+
res = readLines("-MASTERDOWN Link with MASTER is down and replica-serve-stale-data is set to 'no'.\r\n")
149+
if checkErrType(t, res, ErrMasterDown) {
150+
err := res.(*errorx.Error)
151+
assert.True(t, err.HasTrait(ErrTraitNotSent))
152+
}
153+
148154
for i := -1000; i <= 1000; i++ {
149155
res = readLines(fmt.Sprintf(":%d\r\n", i))
150156
assert.Equal(t, int64(i), res)

‎rediscluster/cluster.go‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,13 @@ const (
5050

5151
const (
5252
defaultCheckInterval = 5 * time.Second
53-
defaultWaitToMigrate = 20 * time.Millisecond
53+
// A failover takes cluster-node-timeout plus an election of a second or two, and the
54+
// client notices the new master one CheckInterval later; 60 seconds cover the default
55+
// 15-second node timeout several times over and stay well below the ~160 seconds
56+
// (repl-ping-replica-period + node-timeout × cluster-replica-validity-factor) after
57+
// which redis itself stops considering the replica fresh enough for promotion.
58+
defaultReplicaLinkDownTolerance = 60 * time.Second
59+
defaultWaitToMigrate = 20 * time.Millisecond
5460

5561
forceInterval = 100 * time.Millisecond
5662

@@ -112,6 +118,11 @@ type Opts struct {
112118
ForceMinLatencyReplica bool
113119
// WeightProvider - enables to explicitly set weights of replicas (has higher priority than LatencyOrientedRR)
114120
WeightProvider WeightProvider
121+
// ReplicaLinkDownTolerance - a replica whose link with its master is down keeps receiving
122+
// reads while its master_link_down_since_seconds stays below this value. A replica that
123+
// has never synced since it started is never read regardless of it.
124+
// default: 60 seconds; negative: a replica with a broken link is never read
125+
ReplicaLinkDownTolerance time.Duration
115126
// Enable connection with TLS
116127
TLSEnabled bool
117128
// Config for TLS connection
@@ -241,6 +252,10 @@ func NewCluster(ctx context.Context, initAddrs []string, opts Opts) (*Cluster, e
241252
cluster.opts.WaitToMigrate = 100 * time.Millisecond
242253
}
243254

255+
if cluster.opts.ReplicaLinkDownTolerance == 0 {
256+
cluster.opts.ReplicaLinkDownTolerance = defaultReplicaLinkDownTolerance
257+
}
258+
244259
cluster.latencyAwareness = disabled
245260
if cluster.opts.LatencyOrientedRR {
246261
cluster.latencyAwareness = enabled
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package rediscluster
2+
3+
import (
4+
"strings"
5+
"testing"
6+
"time"
7+
)
8+
9+
func TestReplicaHealthy(t *testing.T) {
10+
info := func(lines ...string) []byte {
11+
return []byte("# Replication\r\nrole:slave\r\n" + strings.Join(lines, "\r\n") + "\r\n# Persistence\r\nloading:0\r\n")
12+
}
13+
for _, tc := range []struct {
14+
name string
15+
info []byte
16+
linkDownTolerance time.Duration
17+
want bool
18+
}{
19+
{name: "link up", info: info("master_link_status:up"), linkDownTolerance: time.Minute, want: true},
20+
{name: "link down for a while", info: info("master_link_status:down", "master_link_down_since_seconds:3"), linkDownTolerance: time.Minute, want: true},
21+
{name: "link down for the whole linkDownTolerance", info: info("master_link_status:down", "master_link_down_since_seconds:60"), linkDownTolerance: time.Minute, want: false},
22+
{name: "never synced since start", info: info("master_link_status:down", "master_link_down_since_seconds:-1"), linkDownTolerance: time.Minute, want: false},
23+
{name: "link down without duration", info: info("master_link_status:down"), linkDownTolerance: time.Minute, want: false},
24+
{name: "negative tolerance", info: info("master_link_status:down", "master_link_down_since_seconds:0"), linkDownTolerance: -1, want: false},
25+
{name: "loading", info: []byte("# Replication\r\nmaster_link_status:up\r\n# Persistence\r\nloading:1\r\n"), linkDownTolerance: time.Minute, want: false},
26+
{name: "async loading is not loading", info: []byte("# Replication\r\nmaster_link_status:up\r\n# Persistence\r\nloading:0\r\nasync_loading:1\r\n"), linkDownTolerance: time.Minute, want: true},
27+
} {
28+
t.Run(tc.name, func(t *testing.T) {
29+
if got := replicaHealthy(tc.info, tc.linkDownTolerance); got != tc.want {
30+
t.Errorf("replicaHealthy(%q, %v) = %v, want %v", tc.info, tc.linkDownTolerance, got, tc.want)
31+
}
32+
})
33+
}
34+
}
35+
36+
func TestInfoField(t *testing.T) {
37+
info := []byte("loading:0\r\nasync_loading:1\r\nmaster_link_status:down\r\nmaster_link_down_since_seconds:-1\r\n")
38+
for _, tc := range []struct {
39+
field string
40+
want string
41+
ok bool
42+
}{
43+
{field: "loading", want: "0", ok: true},
44+
{field: "async_loading", want: "1", ok: true},
45+
{field: "master_link_status", want: "down", ok: true},
46+
{field: "master_link_down_since_seconds", want: "-1", ok: true},
47+
{field: "link_status", ok: false},
48+
{field: "role", ok: false},
49+
} {
50+
got, ok := infoField(info, tc.field)
51+
if ok != tc.ok || string(got) != tc.want {
52+
t.Errorf("infoField(%q) = %q, %v; want %q, %v", tc.field, got, ok, tc.want, tc.ok)
53+
}
54+
}
55+
}

‎rediscluster/slotrange.go‎

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package rediscluster
33
import (
44
"bytes"
55
"math"
6+
"strconv"
67
"sync/atomic"
78
"time"
89

@@ -142,7 +143,7 @@ func (c *Cluster) updateMappings(slotRanges []redisclusterutil.SlotsRange) {
142143
c.nodeWait.promises = make(map[string]*[]connThen, 1)
143144
c.nodeWait.Unlock()
144145

145-
go newConfig.setConnRoles()
146+
go newConfig.setConnRoles(c.opts.ReplicaLinkDownTolerance)
146147

147148
var sh uint32
148149
for i := 0; i < redisclusterutil.NumSlots; i++ {
@@ -213,7 +214,13 @@ func (c *Cluster) updateMappings(slotRanges []redisclusterutil.SlotsRange) {
213214
})
214215
}
215216

216-
func (s *shard) setReplicaInfo(res interface{}, n uint64) {
217+
func (s *shard) replicaInfoFuture(linkDownTolerance time.Duration) redis.FuncFuture {
218+
return func(res interface{}, n uint64) {
219+
s.setReplicaInfo(res, n, linkDownTolerance)
220+
}
221+
}
222+
223+
func (s *shard) setReplicaInfo(res interface{}, n uint64, linkDownTolerance time.Duration) {
217224
haserr := false
218225
if err := redis.AsError(res); err != nil {
219226
haserr = true
@@ -222,8 +229,8 @@ func (s *shard) setReplicaInfo(res interface{}, n uint64) {
222229
haserr = !(ok && str == "OK")
223230
} else if buf, ok := res.([]byte); !ok {
224231
haserr = true
225-
} else if bytes.Contains(buf, []byte("master_link_status:down")) || bytes.Contains(buf, []byte("loading:1")) {
226-
haserr = true
232+
} else {
233+
haserr = !replicaHealthy(buf, linkDownTolerance)
227234
}
228235
for {
229236
oldstate := atomic.LoadUint32(&s.good)
@@ -242,7 +249,59 @@ func (s *shard) setReplicaInfo(res interface{}, n uint64) {
242249
}
243250
}
244251

245-
func (cfg *clusterConfig) setConnRoles() {
252+
// replicaHealthy tells whether INFO output describes a replica worth reading from.
253+
// master_link_down_since_seconds is -1 for a replica that has never synced since it
254+
// started, and its dataset is then anything from empty to the RDB it booted from.
255+
func replicaHealthy(info []byte, linkDownTolerance time.Duration) bool {
256+
if infoHas(info, "loading", "1") {
257+
return false
258+
}
259+
if !infoHas(info, "master_link_status", "down") {
260+
return true
261+
}
262+
since, ok := infoInt(info, "master_link_down_since_seconds")
263+
if !ok || since < 0 {
264+
return false
265+
}
266+
return time.Duration(since)*time.Second < linkDownTolerance
267+
}
268+
269+
// infoField returns the value of a named INFO field. The name is matched as a whole
270+
// line prefix: `loading` must not be found inside `async_loading`.
271+
func infoField(info []byte, field string) ([]byte, bool) {
272+
key := []byte(field + ":")
273+
for off := 0; ; {
274+
i := bytes.Index(info[off:], key)
275+
if i < 0 {
276+
return nil, false
277+
}
278+
i += off
279+
if i == 0 || info[i-1] == '\n' {
280+
value := info[i+len(key):]
281+
if end := bytes.IndexAny(value, "\r\n"); end >= 0 {
282+
value = value[:end]
283+
}
284+
return value, true
285+
}
286+
off = i + 1
287+
}
288+
}
289+
290+
func infoHas(info []byte, field, value string) bool {
291+
got, ok := infoField(info, field)
292+
return ok && string(got) == value
293+
}
294+
295+
func infoInt(info []byte, field string) (int64, bool) {
296+
value, ok := infoField(info, field)
297+
if !ok {
298+
return 0, false
299+
}
300+
v, err := strconv.ParseInt(string(value), 10, 64)
301+
return v, err == nil
302+
}
303+
304+
func (cfg *clusterConfig) setConnRoles(linkDownTolerance time.Duration) {
246305
for _, sh := range cfg.shards {
247306
for i, addr := range sh.addr {
248307
node := cfg.nodes[addr]
@@ -254,7 +313,7 @@ func (cfg *clusterConfig) setConnRoles() {
254313
conn.Send(Request{"READWRITE", nil}, nil, 0)
255314
} else {
256315
conn.SendBatch([]Request{{"READONLY", nil}, {"INFO", nil}},
257-
redis.FuncFuture(sh.setReplicaInfo), uint64(i*2))
316+
sh.replicaInfoFuture(linkDownTolerance), uint64(i*2))
258317
}
259318
}
260319
}

0 commit comments

Comments
 (0)