Skip to content

Commit aa40a1c

Browse files
committed
fix(multidb): outcome classification and selection races from review
- retryable server replies (LOADING, READONLY, CLUSTERDOWN, ...) now classify as availability failures instead of healthy replies, so the breaker/detector see them and failover can trigger - locally synthesized Redis errors (ErrCrossSlot) are neutral: they no longer count as proof of a healthy server - single-command retries honor Cmder.NoRetry: streaming commands record the failure but are never replayed - candidate snapshots, background checks and failover re-checks use a non-reserving circuit-state read so they cannot exhaust a recovering member's bounded half-open probe budget - auto-fallback selection runs under failoverMu, closing the same removal race fixed for manual failover - a successful SetActiveIndex probe resets the target's breaker so the switch sticks when the member recovered before the grace period
1 parent 94a8643 commit aa40a1c

2 files changed

Lines changed: 178 additions & 18 deletions

File tree

multidb_core.go

Lines changed: 74 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,16 @@ func (db *multidbDatabase) closeClient() error {
5353
return db.c.Close()
5454
}
5555

56+
// selectable reports whether the database's circuit permits selecting it,
57+
// WITHOUT reserving a half-open probe slot. IsAllowed consumes one of the
58+
// breaker's bounded half-open requests, so it must only be called right
59+
// before actually executing a command; candidate snapshots, background
60+
// checks and failover re-checks use this instead, or repeated selections
61+
// would exhaust a recovering database's probe budget without ever probing it.
62+
func (db *multidbDatabase) selectable() bool {
63+
return db.cb.CheckState() != imultidb.CircuitOpen
64+
}
65+
5666
// probe runs the database's health checks under the configured policy,
5767
// bounded by HealthCheckTimeout, and feeds the result into the circuit
5868
// breaker and the OTel recorder.
@@ -252,7 +262,7 @@ func (c *multidbCore) candidates(exclude int) []MultiDBDatabaseState {
252262
out = append(out, MultiDBDatabaseState{
253263
Index: i,
254264
Weight: db.weight,
255-
Allowed: db.cb.IsAllowed(),
265+
Allowed: db.selectable(),
256266
})
257267
}
258268
return out
@@ -313,23 +323,26 @@ func (c *multidbCore) process(ctx context.Context, cmd Cmder) error {
313323
cmd.SetErr(nil)
314324
}
315325
err := db.process(ctx, cmd)
316-
if err == nil || isRedisReplyError(err) {
317-
// A server reply — including error replies like WRONGTYPE and
318-
// redis.Nil — proves the database is reachable and healthy.
326+
switch classifyOutcome(err) {
327+
case outcomeSuccess:
319328
db.cb.RecordSuccess()
320329
c.detector.RecordSuccess()
321330
return err
322-
}
323-
if !shouldRetry(err, true) {
324-
// Client-side errors (context cancellation, deterministic local
325-
// rejections) are not database-health signals: return them to
326-
// the caller without recording a failure or failing over.
331+
case outcomeNeutral:
332+
// Not a database-health signal: return to the caller without
333+
// recording a failure or failing over.
327334
return err
335+
case outcomeFailure:
336+
db.cb.RecordFailure()
337+
c.detector.RecordFailure(err)
338+
lastErr = err
339+
if cmd.NoRetry() {
340+
// Commands that stream into caller-owned writers/buffers
341+
// must never be replayed after a partial read: the failure
342+
// is recorded, but the error goes straight to the caller.
343+
return err
344+
}
328345
}
329-
330-
db.cb.RecordFailure()
331-
c.detector.RecordFailure(err)
332-
lastErr = err
333346
}
334347
return lastErr
335348
}
@@ -341,6 +354,42 @@ func isRedisReplyError(err error) bool {
341354
return errors.As(err, &redisErr)
342355
}
343356

357+
// outcomeKind classifies a command outcome for breaker/detector recording.
358+
type outcomeKind int
359+
360+
const (
361+
// outcomeSuccess proves the database served the request (including
362+
// definitive error replies like WRONGTYPE or redis.Nil).
363+
outcomeSuccess outcomeKind = iota
364+
// outcomeFailure is an availability signal: transport-level failures and
365+
// retryable server replies (LOADING, READONLY, CLUSTERDOWN, ...).
366+
outcomeFailure
367+
// outcomeNeutral is not a database-health signal at all: client-side
368+
// errors (context cancellation, deterministic local rejections) and
369+
// locally synthesized Redis errors such as ErrCrossSlot.
370+
outcomeNeutral
371+
)
372+
373+
// classifyOutcome decides how a command outcome feeds the circuit breaker
374+
// and the failure detector. Order matters: retryable server replies (LOADING,
375+
// READONLY, ...) are availability failures even though they are RedisErrors,
376+
// and locally synthesized RedisErrors (ErrCrossSlot) must not count as proof
377+
// of a healthy server because no round trip happened.
378+
func classifyOutcome(err error) outcomeKind {
379+
switch {
380+
case err == nil:
381+
return outcomeSuccess
382+
case shouldRetry(err, true):
383+
return outcomeFailure
384+
case errors.Is(err, ErrCrossSlot):
385+
return outcomeNeutral
386+
case isRedisReplyError(err):
387+
return outcomeSuccess
388+
default:
389+
return outcomeNeutral
390+
}
391+
}
392+
344393
const (
345394
failoverReasonAutomatic = "automatic"
346395
failoverReasonManual = "manual"
@@ -357,7 +406,7 @@ func (c *multidbCore) tryFailover(ctx context.Context, from int) error {
357406

358407
// Re-check under the lock: a concurrent failover may already have fixed
359408
// the active database.
360-
if db, idx := c.activeSnapshot(); db != nil && idx != from && db.cb.IsAllowed() {
409+
if db, idx := c.activeSnapshot(); db != nil && idx != from && db.selectable() {
361410
return nil
362411
}
363412

@@ -464,6 +513,10 @@ func (c *multidbCore) setActiveIndex(ctx context.Context, index int, probe bool)
464513
if !db.probe(ctx, c.opts.HealthCheckTimeout) {
465514
return ErrTargetUnhealthy
466515
}
516+
// The operator asked for this database and a fresh probe just passed:
517+
// reset its breaker so a still-open circuit (recovered before the
518+
// grace period elapsed) does not immediately fail the switch away.
519+
db.cb.Reset()
467520
}
468521
from := int(c.active.Load())
469522
if from == index {
@@ -580,7 +633,7 @@ func (c *multidbCore) startBackgroundLoop() {
580633

581634
// Background-driven failover: the active index must move even
582635
// with no command traffic.
583-
if db, idx := c.activeSnapshot(); db != nil && !db.cb.IsAllowed() {
636+
if db, idx := c.activeSnapshot(); db != nil && !db.selectable() {
584637
_ = c.tryFailover(ctx, idx)
585638
}
586639

@@ -610,8 +663,13 @@ func (c *multidbCore) runHealthChecksOnce(ctx context.Context) {
610663
}
611664

612665
// tryFallbackToPrimary switches back to a strictly-higher-weight database
613-
// whose circuit is closed again.
666+
// whose circuit is closed again. Selection and switch happen under
667+
// failoverMu so a concurrent RemoveDatabase (which also holds it) cannot
668+
// remove the selected member or shift the slice in between.
614669
func (c *multidbCore) tryFallbackToPrimary(ctx context.Context) {
670+
c.failoverMu.Lock()
671+
defer c.failoverMu.Unlock()
672+
615673
active, idx := c.activeSnapshot()
616674
if active == nil {
617675
return
@@ -633,9 +691,7 @@ func (c *multidbCore) tryFallbackToPrimary(ctx context.Context) {
633691
if best < 0 {
634692
return
635693
}
636-
c.failoverMu.Lock()
637694
c.switchActive(ctx, idx, best, failoverReasonFallback, 0)
638-
c.failoverMu.Unlock()
639695
}
640696

641697
// newPubSub creates a PubSub whose connections always target the currently

multidb_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,15 @@ func (hc *fakeHealthCheck) CheckClusterHealth(ctx context.Context, client *redis
5050
return hc.healthy.Load(), nil
5151
}
5252

53+
// customErr lets a test inject an arbitrary error from the hook.
54+
type customErr struct{ err error }
55+
5356
// hookedDB is a process hook that short-circuits every command (never dials),
5457
// recording the commands it saw and failing while `fail` is set.
5558
type hookedDB struct {
5659
name string
5760
fail atomic.Bool
61+
custom atomic.Pointer[customErr]
5862
commands atomic.Int64
5963
}
6064

@@ -63,6 +67,10 @@ func (h *hookedDB) DialHook(next redis.DialHook) redis.DialHook { return next }
6367
func (h *hookedDB) ProcessHook(next redis.ProcessHook) redis.ProcessHook {
6468
return func(ctx context.Context, cmd redis.Cmder) error {
6569
h.commands.Add(1)
70+
if ce := h.custom.Load(); ce != nil {
71+
cmd.SetErr(ce.err)
72+
return ce.err
73+
}
6674
if h.fail.Load() {
6775
// Wrap io.EOF so the failure classifies as a transport error
6876
// (the kind that records on the breaker/detector and retries).
@@ -540,6 +548,102 @@ func TestMultiDBClientSideErrorsDoNotFailOver(t *testing.T) {
540548
}
541549
}
542550

551+
func TestMultiDBLocalRedisErrorIsNeutral(t *testing.T) {
552+
db1 := newTestDB("db1", "127.0.0.1:1", 2.0, true)
553+
db2 := newTestDB("db2", "127.0.0.1:2", 1.0, true)
554+
555+
opts := baseOptions()
556+
opts.CommandRetries = 3
557+
opts.CircuitBreakerConfig = &redis.MultiDBCircuitBreakerConfig{
558+
FailureThreshold: 1,
559+
SuccessThreshold: 1,
560+
GracePeriod: time.Hour,
561+
}
562+
mdb := newTestMultiDB(t, opts, db1, db2)
563+
ctx := context.Background()
564+
565+
// A locally synthesized Redis error (no round trip) must surface to the
566+
// caller without failover and without being recorded as a success.
567+
db1.hook.custom.Store(&customErr{err: redis.ErrCrossSlot})
568+
if err := mdb.Get(ctx, "k").Err(); !errors.Is(err, redis.ErrCrossSlot) {
569+
t.Fatalf("Get: err = %v, want ErrCrossSlot", err)
570+
}
571+
if got := mdb.ActiveIndex(); got != 0 {
572+
t.Fatalf("local error caused failover; active = %d", got)
573+
}
574+
if got := db1.hook.commands.Load(); got != 1 {
575+
t.Fatalf("local error was retried; attempts = %d, want 1", got)
576+
}
577+
}
578+
579+
func TestMultiDBNoRetryCommandNotReplayed(t *testing.T) {
580+
db1 := newTestDB("db1", "127.0.0.1:1", 2.0, true)
581+
db2 := newTestDB("db2", "127.0.0.1:2", 1.0, true)
582+
583+
opts := baseOptions()
584+
opts.CommandRetries = 3
585+
opts.CircuitBreakerConfig = &redis.MultiDBCircuitBreakerConfig{
586+
FailureThreshold: 1,
587+
SuccessThreshold: 1,
588+
GracePeriod: time.Hour,
589+
}
590+
mdb := newTestMultiDB(t, opts, db1, db2)
591+
ctx := context.Background()
592+
593+
db1.hook.fail.Store(true)
594+
595+
cmd := redis.NewRawWriteToCmd(ctx, io.Discard, "get", "k")
596+
if err := mdb.Process(ctx, cmd); err == nil {
597+
t.Fatal("NoRetry command should surface the transport failure")
598+
}
599+
// One attempt only: replaying a command that streams into caller-owned
600+
// buffers could corrupt output.
601+
if got := db1.hook.commands.Load(); got != 1 {
602+
t.Fatalf("NoRetry command executed %d times, want 1", got)
603+
}
604+
if db2.hook.commands.Load() != 0 {
605+
t.Error("NoRetry command was replayed on another member")
606+
}
607+
}
608+
609+
func TestMultiDBManualFailoverResetsBreaker(t *testing.T) {
610+
db1 := newTestDB("db1", "127.0.0.1:1", 2.0, true)
611+
db2 := newTestDB("db2", "127.0.0.1:2", 1.0, true)
612+
613+
opts := baseOptions()
614+
opts.CommandRetries = 1
615+
opts.CircuitBreakerConfig = &redis.MultiDBCircuitBreakerConfig{
616+
FailureThreshold: 1,
617+
SuccessThreshold: 1,
618+
GracePeriod: time.Hour, // breaker stays open without a reset
619+
}
620+
mdb := newTestMultiDB(t, opts, db1, db2)
621+
ctx := context.Background()
622+
623+
// Open db2's breaker: force it active while failing, let a command fail.
624+
db2.hook.fail.Store(true)
625+
if err := mdb.ForceActiveIndex(ctx, 1); err != nil {
626+
t.Fatalf("ForceActiveIndex: %v", err)
627+
}
628+
_ = mdb.Set(ctx, "k", "v", 0).Err() // opens db2's breaker, fails over back to db1
629+
630+
// db2 recovers before the grace period; the manual probe passes and must
631+
// reset the still-open breaker, or the switch would immediately fail away.
632+
db2.hook.fail.Store(false)
633+
if err := mdb.SetActiveIndex(ctx, 1); err != nil {
634+
t.Fatalf("SetActiveIndex after recovery: %v", err)
635+
}
636+
if got := mdb.ActiveIndex(); got != 1 {
637+
t.Fatalf("active = %d, want 1", got)
638+
}
639+
if err := mdb.Set(ctx, "k", "v", 0).Err(); err != nil {
640+
t.Fatalf("Set on manually selected member: %v", err)
641+
}
642+
if got := mdb.ActiveIndex(); got != 1 {
643+
t.Fatalf("switch did not stick; active = %d", got)
644+
}
645+
}
646+
543647
func TestMultiDBValidation(t *testing.T) {
544648
ctx := context.Background()
545649

0 commit comments

Comments
 (0)