@@ -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+
344393const (
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.
614669func (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
0 commit comments