Skip to content

feat(multidb): add MultiDBClient orchestration layer - #3950

Open
ndyakov wants to merge 24 commits into
feature/multidb-integrationfrom
feature/multidb/client
Open

feat(multidb): add MultiDBClient orchestration layer#3950
ndyakov wants to merge 24 commits into
feature/multidb-integrationfrom
feature/multidb/client

Conversation

@ndyakov

@ndyakov ndyakov commented Aug 5, 2026

Copy link
Copy Markdown
Member

Implements the client-side Active-Active failover design (MultiDB v0.9) on
top of the foundation PRs (#3835-#3838):

  • MultiDBClient (embeds cmdable, full command surface) +
    NewMultiDBClient with InitialDBState gating (all/majority/one; blocking
    opt-in via ctx deadline)
  • MultiDBCtrl: probe-then-switch SetActiveIndex (refuses unhealthy
    targets), unconditional ForceActiveIndex, runtime
    AddDatabase/RemoveDatabase/SetWeight, SetAutoFallback
  • Command hot path feeding the per-DB circuit breaker and the aggregate
    failure detector; retries against the newly selected database
  • Background-driven failover (idle/PubSub-only workloads fail over without
    command traffic) and auto-fallback to a recovered higher-weight database
  • Escalation: ErrTemporarilyNotAvailable -> ErrPermanentlyNotAvailable
    rate-limited by FailoverAttemptDelay
  • Health-check merge semantics (global + per-DB additive, default PING),
    public MultiDBCircuitBreakerConfig (GracePeriod naming)
  • PubSub follows the active database (lazy re-dial + reconnect nudge via new
    exported PubSub.Reconnect); PubSub errors deliberately do not feed the
    detector
  • OTel wiring through the existing RecordMultiDB* recorder pipeline
  • SCH/maintnotifications disabled by default per member database

Unit tests use hook-faked clients (no server needed) and run under -race.

Follow-ups tracked separately: redisotel-native geofailover instruments,
cluster-active PubSub, pipeline/autopipeline support (next PR in the stack).


Note

High Risk
Introduces a large, concurrency-heavy failover path on the hot command path (breakers, locks, background health checks, Pub/Sub re-dial) where misclassification or races could mis-route traffic or strand clients in permanent unavailability.

Overview
Adds MultiDBClient, a drop-in client that routes commands to one active member among standalone, Sentinel, or cluster backends and fails over using per-DB circuit breakers, a pluggable failure detector, health checks, and weight-based (or custom) target selection.

Construction and control: NewMultiDBClient enforces InitialDBState (all / majority / one healthy) with optional blocking init on a context deadline. MultiDBCtrl exposes SetActiveIndex (probe-first), ForceActiveIndex, runtime AddDatabase / RemoveDatabase, weights, and auto-fallback toggles. Options are copied on build so shared MultiDBOptions are not mutated.

Runtime behavior: The command path admits traffic through breakers, classifies outcomes (transport vs app errors vs neutral), retries on other members, and escalates to ErrTemporarilyNotAvailable / ErrPermanentlyNotAvailable. A background loop probes members, can fail over without command traffic, and can auto-fallback to a higher-weight recovered DB. Pub/Sub follows the active member via a new exported PubSub.Reconnect. Cluster-active paths delegate DBSize and script admin to the member client; HIMPORT is rejected (including raw Process) until fieldsets can fan out.

Hardening: Async circuit-state callbacks, panic recovery on strategies/checks/callbacks, races with removal, half-open probe slot accounting, and maintenance notifications disabled by default on members unless configured.

Large hook-based test suite plus export_test helpers for breaker and removal races.

Reviewed by Cursor Bugbot for commit 7566dc0. Bugbot is set up for automated code reviews on this repo. Configure here.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a new MultiDBClient orchestration layer in the root redis package to support client-side active-active failover across multiple underlying Redis databases/clients, and adds a small PubSub API extension to help subscriptions follow active database changes.

Changes:

  • Added MultiDBClient, MultiDBCtrl, configuration/types, and initialization gating via InitialDBState policies.
  • Implemented multidbCore for command routing, health-check probing, circuit-breaker + detector-driven failover, background failover, and auto-fallback.
  • Exported PubSub.Reconnect to force a PubSub re-dial/resubscribe cycle (used by MultiDB PubSub handoff).

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 7 comments.

File Description
pubsub.go Exposes PubSub.Reconnect and applies minor formatting fixes to logger calls.
multidb.go Adds public MultiDB API/types (MultiDBClient, options, errors, control interface) and disables maintnotifications by default for standalone Options members.
multidb_core.go Implements MultiDB routing/failover core, health probing, PubSub-follow-active logic, and background management loop.
multidb_test.go Adds unit tests using hook-faked clients to validate init policies, routing, failover/escalation, membership changes, and concurrency behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread multidb_core.go
Comment thread multidb_core.go Outdated
Comment thread multidb_core.go
Comment thread multidb_core.go
Comment thread multidb_core.go
Comment thread multidb_core.go
Comment thread multidb_core.go
@ndyakov
ndyakov requested a lite review from Copilot August 5, 2026 14:55
@ndyakov

ndyakov commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

@codex review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

multidb_core.go:570

  • SetAutoFallback(enabled) becomes ineffective when AutoFallbackInterval is negative: even after enabling, the background loop checks c.opts.AutoFallbackInterval > 0 and will never run fallback. Either document that negative disables auto-fallback permanently, or treat negative as "disabled by default" by using the absolute value as the interval when enabled.
			if c.opts.AutoFallbackInterval > 0 && !c.autoFallbackDisabled.Load() &&
				time.Since(lastFallbackCheck) >= c.opts.AutoFallbackInterval {
				lastFallbackCheck = time.Now()
				c.tryFallbackToPrimary(ctx)
			}

multidb_core.go:706

  • notifyPubSubs passes through the failover's ctx to PubSub.Reconnect. If failover is triggered from a command with a tight deadline / already-canceled ctx, Reconnect will fail to dial and the PubSub may stay on the old database until a later read error (Reconnect ignores dial errors). Consider using a background context with a bounded timeout for these internal reconnections.
	for _, ps := range subs {
		ps.Reconnect(ctx, errors.New("multidb: active database changed"))
	}

Comment thread multidb_core.go

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d827179c9b

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread multidb_core.go Outdated
Comment thread multidb_core.go Outdated
Comment thread multidb_core.go
Comment thread multidb_core.go Outdated
@ndyakov
ndyakov requested a lite review from Copilot August 5, 2026 15:38
@ndyakov

ndyakov commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

@codex review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

Suppressed comments (1)

multidb.go:412

  • MultiDBClient.Process returns the error from core.process but does not set cmd.SetErr(err). In this codebase, Process implementations consistently do this before returning (e.g. Client.Process in redis.go:2146-2149, ClusterClient.Process in osscluster.go:1287-1290, Ring.Process in ring.go:671-675, Conn.Process in redis.go:2454-2457) so cmd.Err()/Result() reflect the latest outcome.

Set cmd.SetErr(err) here to match that contract (this also covers non-retryable errors where core.process just returns).

func (c *MultiDBClient) Process(ctx context.Context, cmd Cmder) error {
	return c.core.process(ctx, cmd)
}

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 94a86433e7

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread multidb_core.go Outdated
Comment thread multidb_core.go Outdated
Comment thread multidb_core.go Outdated
Comment thread multidb_core.go Outdated
Comment thread multidb_core.go
@ndyakov
ndyakov requested a lite review from Copilot August 5, 2026 16:04
@ndyakov

ndyakov commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

@codex review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

Suppressed comments (1)

multidb_core.go:229

  • db.cb.IsAllowed() reserves a half-open request slot (internal/circuitbreaker). Calling it during initialization consumes probe budget even though no command is executed, which can prematurely exhaust half-open probes on startup. Use a non-reserving check (e.g. db.selectable() / CheckState()!=Open) when building candidate snapshots.
			Allowed: probeHealthy[i] && db.cb.IsAllowed(),

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: aa40a1c8bd

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread multidb_core.go Outdated
Comment thread multidb_core.go
Comment thread multidb.go
Comment thread multidb_core.go Outdated
Comment thread multidb_core.go
Comment thread multidb_core.go Outdated
Comment thread multidb_core.go Outdated
Comment thread multidb.go Outdated
@ndyakov
ndyakov force-pushed the feature/multidb/client branch from aa40a1c to 00af763 Compare August 5, 2026 16:33
@ndyakov
ndyakov requested a lite review from Copilot August 5, 2026 16:35
@ndyakov

ndyakov commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

@codex review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

Suppressed comments (4)

multidb.go:243

  • HealthCheckTimeout is clamped to HealthCheckInterval/2 when it is >= the interval, but for very small intervals (e.g. 1ns) the division can produce a zero timeout. That would make probes immediately time out via context.WithTimeout(ctx, 0). Consider enforcing a minimum positive timeout after clamping.
	if opt.HealthCheckTimeout >= opt.HealthCheckInterval {
		opt.HealthCheckTimeout = opt.HealthCheckInterval / 2
	}

multidb_core.go:765

  • In MultiDB PubSub, the per-connection dial uses the currently active member, but pubsub.opt and pubsub.pushProcessor are only initialized once at creation time. After a failover, the PubSub connection can move to a different underlying client while still using stale protocol/timeouts/push notification processing settings from the original member (e.g., opt.Protocol, read/write timeouts). Consider refreshing pubsub.opt/pubsub.pushProcessor inside newConn each time it dials a member, so the PubSub runtime settings always match the active database it is connecting to.
		newConn: func(ctx context.Context, _ string, channels []string) (*pool.Conn, error) {
			db, _ := c.activeSnapshot()
			if db == nil {
				return nil, ErrTemporarilyNotAvailable
			}
			if db.c == nil {
				return nil, errors.New("redis: multidb: PubSub requires a standalone or sentinel active database")
			}
			cn, err := db.c.pubSubPool.NewConn(ctx, db.c.opt.Network, db.c.opt.Addr, channels)

pubsub.go:188

  • PubSub.Reconnect is now exported, but it does not return any error and the underlying reconnect path ignores dial/resubscribe errors. The current doc comment reads like it deterministically reconnects and resubscribes, which could be misleading for callers. Consider clarifying that it is best-effort and that failures are surfaced on subsequent PubSub operations.
// Reconnect closes the current connection and re-dials through the PubSub's
// connection factory, resubscribing to all channels and patterns. It is used
// by MultiDBClient to move subscriptions to the new active database after a
// failover, and can be called by applications to force a re-dial.
func (c *PubSub) Reconnect(ctx context.Context, reason error) {

multidb_core.go:869

  • closeAll holds dbMu while closing each underlying client. Closing clients can block (network I/O, hook cleanup), so keeping the lock held increases the chance of lock contention during shutdown and raises deadlock risk if any close path indirectly tries to acquire dbMu. Consider snapshotting c.dbs under the lock, clearing it, and closing clients after unlocking.
func (c *multidbCore) closeAll() error {
	c.dbMu.Lock()
	defer c.dbMu.Unlock()
	var firstErr error
	for _, db := range c.dbs {

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 00af76319d

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread multidb_core.go Outdated
Comment thread multidb_core.go Outdated
Comment thread multidb_core.go Outdated
Comment thread multidb_core.go Outdated
Comment thread multidb_core.go
Comment thread multidb_core.go
Comment thread multidb_core.go Outdated
Comment thread multidb_core.go
@ndyakov
ndyakov force-pushed the feature/multidb/client branch from 00af763 to 0221df0 Compare August 5, 2026 20:39
@ndyakov
ndyakov requested a lite review from Copilot August 5, 2026 20:43
@ndyakov

ndyakov commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

@codex review

This comment was marked as outdated.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0221df0458

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread multidb_core.go
Comment thread multidb_core.go
Comment thread multidb_core.go Outdated
Comment thread multidb_core.go Outdated
Comment thread multidb_core.go
Comment thread multidb_core.go
Comment thread multidb_core.go
@ndyakov
ndyakov force-pushed the feature/multidb/client branch from 0221df0 to 8831e50 Compare August 5, 2026 23:48
ndyakov added 23 commits August 7, 2026 15:11
- PubSub conns are untracked from their owning member's pool on close
  (per-conn owner map), the registry deregisters closed subscriptions via
  onClose, and pubsub options are always initialized (cloned, not shared)
- retried commands clear the previous attempt's error before re-execution
- SetActiveIndex/ForceActiveIndex read the active index under failoverMu,
  honor the switch result and reset the failure detector on a real change
- RemoveDatabase serializes with failoverMu so the active-index shift
  cannot race a concurrent failover
- cluster member databases also get maintnotifications disabled by default
- Close snapshots the pubsub registry and closes subscriptions outside
  pubsubMu (PubSub.Close re-enters the registry via onClose)
- initial active selection keys off the startup probe results, not just
  circuit state, so a probe-failed high-weight member is never selected
- SetActiveIndex holds failoverMu across lookup, probe and switch so a
  concurrent RemoveDatabase cannot shift the target
- client-side errors (context cancellation, deterministic local
  rejections per shouldRetry) no longer record breaker/detector failures
  or trigger failover; they return to the caller directly
- failuredetector: replace the claim-then-zero bucket reset with an
  atomically swapped immutable-epoch bucket state, fixing the lost-count
  race behind the flaky concurrent-record test
- 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
- initialization reconciles breakers with the final probe pass: probe-
  healthy members get a fresh breaker (a blocking init that opened it is
  forgiven), probe-failed members get theirs opened so failover cannot
  select a database already known to be down
- process rejects commands with ErrClosed after Close instead of walking
  the failover escalation
- neutral outcomes release the half-open probe slot reserved by IsAllowed
- ForceActiveIndex resets the target breaker (unconditional override
  semantics); SetActiveIndex keeps the probe-then-reset behavior
- SetAutoFallback(true) works when the client was constructed with a
  negative AutoFallbackInterval (interval normalized on the core)
- the default failure detector is allocated per client instead of being
  written back into the caller's MultiDBOptions
- activeSnapshot loads the index under dbMu so it stays coherent with a
  concurrent RemoveDatabase slice shift
- switchActive returns an announce closure that callers invoke after
  releasing failoverMu, so OnFailover/OnActiveDatabaseChanged callbacks
  can safely call control APIs without self-deadlocking
- the retry loop re-enters the breaker gate after a failover (half-open
  slot accounting) without consuming a retry attempt
- blocking commands with their own read timeout are not retried on local
  read deadlines, matching *Client semantics
- manual selection resets the failure detector also for same-index
  overrides
- AddDatabase opens the breaker of a member that fails its initial probe
- the default cluster PING check reports an empty topology as unhealthy
- HealthCheckTimeout documents the ContextTimeoutEnabled interaction
Clone the caller's options (init normalizes in place and a second
client built from the same value would re-enable retries on the first),
reject and re-check Close in AddDatabase so a raced member cannot leak,
assign the new member's index before its initial probe, deliver
OnCircuitStateChanged asynchronously (FIFO per database) so callbacks
may call control APIs without self-deadlocking, stay on a recovered
active instead of escalating when no alternate candidate exists, return
ErrClosed from post-close PubSub dials so the channel loop terminates,
and treat caller-canceled probes as neutral for the breaker.
Check the failure detector before the breaker admission gate so a
detector-routed failover cannot leak a half-open probe slot, surface
the caller's context error from a canceled pre-failover probe instead
of charging a failover attempt, break the consecutive-failed-attempts
escalation chain on any successful command, reset the detector after
an auto-fallback switch, return terminal ErrClosed for PubSub dials on
cluster-only configurations, and stop recording probe outcomes for
members removed while a background snapshot was in flight.
Reconnect PubSubs asynchronously after an active-database change so
the command that triggered the failover is not billed for dial and
resubscribe work, reject RouteByLatency/RouteRandomly on sentinel
members during validation (NewFailoverClient panics on them), leave
breakers untouched for startup probes that died with the caller's
context, wrap default-policy health checks with panic recovery, and
document why PubSub options are captured at Subscribe time.
Record background health-check successes through the breaker's
external path so they cannot release half-open slots held by real
command probes, delegate DBSize/ScriptLoad/ScriptFlush/ScriptExists to
the active member so cluster members keep their fan-out semantics, and
reject the HIMPORT command family (fieldset registrations are per
member client and would silently be lost on failover; fan-out is
tracked as follow-up in the design doc).
Classify local pool saturation (ErrPoolTimeout/ErrPoolExhausted) as
neutral so client capacity pressure cannot open a healthy member's
breaker or trigger failover, and bound consecutive admission-gate
rejections: when every selectable member is half-open with a full
probe budget, the command now surfaces ErrTemporarilyNotAvailable
instead of ping-ponging the active index in a busy loop.
Check the caller's context after successful control-path probes so a
SetActiveIndex/AddDatabase whose context died mid-probe cannot still
switch the active database or mutate membership, and give sentinel
members a private FailoverOptions copy like standalone and cluster
members get.
Callbacks run on a library-owned goroutine: recover a panicking user
callback (logged via internal.Logger) so it can neither crash the
process nor leave the queue wedged in the draining state.
Use the live active index (not the caller's possibly-stale snapshot)
for the no-candidate recovery verdict, reset detector and escalation
state only when the active switch actually happened (a lost CAS means
a concurrent failover already handled it), re-check the caller's
context after a successful pre-failover probe and after waiting for
the failover lock, and document that a custom FailureDetector must not
be shared across clients.
Filter circuit-state callbacks for removed members at dispatch time
(a stale probe snapshot can record an outcome after the removal), and
re-check the caller's context after acquiring the failover lock in
AddDatabase (whose SkipInitialHealthCheck path had no later check) and
RemoveDatabase (which never checked it), so operator requests that
died while queued cannot still mutate membership.
SetActiveIndex/ForceActiveIndex report ErrClosed after Close instead
of an out-of-range error from the drained membership, and an explicit
healthy selection clears the failed-failover escalation chain also
when the selected member is already active.
Fail construction when the caller's context dies during the startup
probes (a late-healthy probe must not birth a live client the caller
gave up on — supersedes the earlier neutral-probe reconciliation, now
unreachable), re-read the removed flag and index at callback DELIVERY
time so a removal landing while a callback is queued cannot surface a
reindexed member, clear the failed-failover chain on successful
auto-fallback, re-check the context after tryFailover's lock wait, and
deep-copy the nested CircuitBreakerConfig into the private options so
runtime-added members cannot diverge from the initial ones.
Recover panicking OnFailover/OnActiveDatabaseChanged callbacks (the
announce closure also runs on the background loop), reject hand-built
HIMPORT commands at Process, add the ErrClosed guard to RemoveDatabase
and the cluster fan-out overrides, mark aborted AddDatabase members
removed so queued callbacks cannot surface a never-added index, and
give the private options copies of HealthChecks and cluster seed
addresses. Also pins that mid-read connection resets are recorded on
the breaker (shouldRetry matches every net.OpError via Timeout()) —
refuting a review claim to the contrary.
Re-check closed after acquiring the failover lock in tryFailover,
setActiveIndex and RemoveDatabase (an op queued behind the lock when
Close lands must report ErrClosed, not act on drained state), notice
Close inside the command retry loop instead of escalating through an
empty membership, make canceled probes neutral for BOTH verdicts (a
late healthy result could close an open circuit for an operation that
returns context.Canceled), and run user failover strategies with panic
recovery — they execute on the library-owned background loop too.
A raw Do(ctx, "himport", ...) builds a plain *Cmd that the typed
marker interface misses; match the command name as well. Also classify
the local HIMPORT rejection as neutral defensively — it is a RedisError
only so batch machinery treats it per-command, and must never count as
proof of a healthy server.
isRedisReplyError matched only the concrete proto.RedisError string;
typed replies parsed by the reader (NOAUTH, NOPERM, EXECABORT, MOVED,
...) fell through to outcomeNeutral instead of outcomeSuccess. Match
the root Error marker interface so typed and string replies classify
identically (availability replies already reached outcomeFailure via
shouldRetry's typed checks).
The single-command gate admitted closed-state commands via IsAllowed,
then settled successes with RecordSuccess and neutrals with an
unconditional ReleaseHalfOpen — a command outliving a later open ->
half-open transition freed a probe slot it never reserved. The gate now
uses Allow: unreserved successes count via RecordExternalSuccess and
unreserved neutrals release nothing.
DBSize/ScriptLoad/ScriptFlush/ScriptExists delegate to the member
client directly when the member is a cluster (fan-out semantics), so
MultiDB-level hooks do not wrap them — same contract as Watch; use
AddDatabaseHook to instrument that traffic.
@ndyakov
ndyakov force-pushed the feature/multidb/client branch from 4540c2b to 7566dc0 Compare August 7, 2026 12:12
@ndyakov
ndyakov requested a lite review from Copilot August 7, 2026 12:15
@ndyakov

ndyakov commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

@codex review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Suppressed comments (5)

export_test.go:145

  • These test helpers currently panic with a nil-pointer dereference if an invalid index is passed (dbAt returns nil). It’s better to fail with an explicit panic message so debugging test failures is straightforward.
func (c *MultiDBClient) TestBreakerReserveHalfOpen(index int) bool {
	return c.core.dbAt(index).cb.IsAllowed()
}

export_test.go:154

  • TestProbeRacingRemoval assumes dbAt(index) always returns a non-nil database. If a test accidentally passes an invalid index, this will panic later with a nil-pointer deref. Adding an explicit check makes failures clearer.
func (c *MultiDBClient) TestProbeRacingRemoval(index int) {
	db := c.core.dbAt(index)
	if err := c.core.removeDatabase(context.Background(), index); err != nil {
		panic(err)
	}

export_test.go:170

  • TestStaleRecordAfterRemoval assumes dbAt(index) always returns a non-nil database. If a test accidentally passes an invalid index, this will panic with a nil-pointer deref; an explicit check makes test failures clearer.
func (c *MultiDBClient) TestStaleRecordAfterRemoval(index int) {
	db := c.core.dbAt(index)
	if err := c.core.removeDatabase(context.Background(), index); err != nil {
		panic(err)
	}

multidb.go:568

  • Comment grammar: “cluster members AND the per-shard answers” reads like a typo and is a bit unclear. Reword to make it a proper sentence (e.g., “cluster members aggregate the per-shard answers …”).
// ScriptExists delegates to the active member (cluster members AND the
// per-shard answers so a script only counts as present when every shard has
// it).

export_test.go:138

  • These test helpers currently panic with a nil-pointer dereference if an invalid index is passed (dbAt returns nil). It’s better to fail with an explicit panic message so debugging test failures is straightforward.

This issue also appears in the following locations of the same file:

  • line 143
  • line 150
  • line 166
func (c *MultiDBClient) TestBreakerRecordFailure(index int) {
	c.core.dbAt(index).cb.RecordFailure()
}

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

There are 9 total unresolved issues (including 8 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 7566dc0. Configure here.

Comment thread multidb_core.go
}
c.dbs = append(c.dbs, db)
c.dbMu.Unlock()
return idx, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AddDatabase succeeds when closed

Medium Severity

AddDatabase reads closed once under dbMu and then appends without re-checking. Close sets closed without holding dbMu, so a concurrent Close can mark the client closed between that load and append, yet AddDatabase still returns a new index and nil even though Process and other control APIs already report ErrClosed.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7566dc0. Configure here.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7566dc0001

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread multidb_core.go
Comment on lines +1345 to +1347
err := client.ForEachShard(ctx, func(ctx context.Context, shard *Client) error {
shards.Add(1)
return shard.Ping(ctx).Err()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Probe only cluster masters by default

When a ClusterClient member uses the built-in default health check (no HealthChecks configured), this calls ForEachShard, which walks replicas as well as masters. In a cluster where a replica is unreachable but the masters are still serving normal MultiDB traffic, startup/background probes mark the whole member unhealthy and can fail initialization or fail over away from a usable cluster; default to master-only probing unless replica routing is explicitly part of the member's traffic.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants