-
Notifications
You must be signed in to change notification settings - Fork 2.6k
feat(multidb): Introduce MultiDB client for Active-Active support #3954
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
ndyakov
wants to merge
23
commits into
master
Choose a base branch
from
feature/multidb-integration
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from 9 commits
Commits
Show all changes
23 commits
Select commit
Hold shift + click to select a range
3f87ef5
ci: run PR workflows for feature/** base branches
ndyakov 11d1341
feat(multidb): #1 internal/circuitbreaker - add unified circuit break…
ndyakov 7f8f5f3
feat(multidb): #2 internal/failuredetector: add failure detector (#3836)
ndyakov 448a5cd
feat(multidb, otel): add geofailover OTel recorder methods (#3838)
ndyakov 0fd6b11
feat(multidb): #3 add health check implementations (#3837)
ndyakov c93a7ed
Merge remote-tracking branch 'origin/master' into feature/multidb-int…
ndyakov 681c952
fix(multidb): foundation review feedback
ndyakov 1b4f425
fix(multidb): foundation review round two
ndyakov 796679c
fix(multidb): breaker races and TLS aliasing from review
ndyakov b8ff1fc
fix(multidb): foundation round three from codex review
ndyakov 5488e77
fix(multidb): foundation round four from codex review
ndyakov ef75bad
fix(multidb): foundation round five from codex review
ndyakov 77885ba
feat(multidb): breaker success recording for unadmitted probes
ndyakov d55e416
fix(multidb): foundation round six from codex review
ndyakov 40ff95f
fix(multidb): foundation round seven from codex review
ndyakov dc44d23
fix(multidb): treat tx aborts as successes in the detector
ndyakov 960d962
fix(multidb): application replies are not detector failures
ndyakov c3d268b
fix(multidb): breaker timestamp repair, typed reply classification
ndyakov 40d540f
feat(multidb): breaker Allow reports half-open reservation
ndyakov af1138c
fix(multidb): move lap-swap bucket design down to integration
ndyakov 7ec15fb
fix(multidb): thread half-open reservation through breaker callers
ndyakov 3920fe3
fix(multidb): resolve service-name ports in lag-aware matching
ndyakov 6bc3d9a
fix(multidb): ping only masters in cluster health checks
ndyakov File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,350 @@ | ||
| // Package circuitbreaker provides a circuit breaker implementation for fault tolerance. | ||
| package circuitbreaker | ||
|
|
||
| import ( | ||
| "sync" | ||
| "sync/atomic" | ||
| "time" | ||
| ) | ||
|
|
||
| // State represents the state of a circuit breaker. | ||
| type State int32 | ||
|
|
||
| const ( | ||
| // StateClosed indicates the circuit is closed and requests are allowed. | ||
| StateClosed State = iota | ||
| // StateOpen indicates the circuit is open and requests are blocked. | ||
| StateOpen | ||
| // StateHalfOpen indicates the circuit is testing if the service has recovered. | ||
| StateHalfOpen | ||
| ) | ||
|
|
||
| // String returns the string representation of the circuit state. | ||
| func (s State) String() string { | ||
| switch s { | ||
| case StateClosed: | ||
| return "closed" | ||
| case StateOpen: | ||
| return "open" | ||
| case StateHalfOpen: | ||
| return "half-open" | ||
| default: | ||
| return "unknown" | ||
| } | ||
| } | ||
|
|
||
| // Config holds configuration for a circuit breaker. | ||
| type Config struct { | ||
| // FailureThreshold is the number of failures before opening the circuit. | ||
| // Default: 5 | ||
| FailureThreshold int | ||
|
|
||
| // SuccessThreshold is the number of successes in half-open state before closing. | ||
| // Default: 2 | ||
| SuccessThreshold int | ||
|
|
||
| // MaxHalfOpenRequests is the maximum number of requests allowed in half-open state. | ||
| // If 0, uses SuccessThreshold as the limit. | ||
| // Default: 0 (uses SuccessThreshold) | ||
| MaxHalfOpenRequests int | ||
|
|
||
| // OpenTimeout is how long to wait before transitioning from open to half-open. | ||
| // This is the circuit "grace period" that gives a failed database time to | ||
| // self-heal before it is probed again. | ||
| // Default: 60 seconds | ||
| OpenTimeout time.Duration | ||
| } | ||
|
|
||
| // DefaultConfig returns the default circuit breaker configuration. | ||
| func DefaultConfig() Config { | ||
| return Config{ | ||
| FailureThreshold: 5, | ||
| SuccessThreshold: 2, | ||
| MaxHalfOpenRequests: 0, | ||
| OpenTimeout: 60 * time.Second, | ||
| } | ||
| } | ||
|
|
||
| // applyDefaults fills in zero values with defaults. | ||
| func (c *Config) applyDefaults() { | ||
| if c.FailureThreshold <= 0 { | ||
| c.FailureThreshold = 5 | ||
| } | ||
| if c.SuccessThreshold <= 0 { | ||
| c.SuccessThreshold = 2 | ||
| } | ||
| if c.MaxHalfOpenRequests <= 0 { | ||
| c.MaxHalfOpenRequests = c.SuccessThreshold | ||
| } | ||
| if c.OpenTimeout <= 0 { | ||
| c.OpenTimeout = 60 * time.Second | ||
| } | ||
| } | ||
|
|
||
| // StateChangeCallback is called when the circuit breaker state changes. | ||
| type StateChangeCallback func(oldState, newState State) | ||
|
|
||
| // CircuitBreaker implements the circuit breaker pattern. | ||
| type CircuitBreaker struct { | ||
| config Config | ||
|
|
||
| state atomic.Int32 | ||
| failures atomic.Int32 | ||
| successes atomic.Int32 | ||
| requests atomic.Int32 // Request count in half-open state | ||
| lastFailure atomic.Int64 // Unix nano timestamp | ||
|
|
||
| // transitionMu serializes the open -> half-open transition so the | ||
| // half-open counters can be cleared before the new state is published. | ||
| transitionMu sync.Mutex | ||
|
|
||
| mu sync.RWMutex | ||
| callbacks []StateChangeCallback | ||
| } | ||
|
|
||
| // New creates a new circuit breaker with the given configuration. | ||
| func New(config Config) *CircuitBreaker { | ||
| config.applyDefaults() | ||
| cb := &CircuitBreaker{ | ||
| config: config, | ||
| } | ||
| cb.state.Store(int32(StateClosed)) | ||
| return cb | ||
| } | ||
|
|
||
| // State returns the current state without triggering any transitions. | ||
| func (cb *CircuitBreaker) State() State { | ||
| return State(cb.state.Load()) | ||
| } | ||
|
|
||
| // CheckState returns the current state and may trigger state transitions. | ||
| // Use this when you need to check if requests should be allowed. | ||
| func (cb *CircuitBreaker) CheckState() State { | ||
| state := State(cb.state.Load()) | ||
|
|
||
| if state == StateOpen { | ||
| // Check if we should transition to half-open. | ||
| // Guard against a zero timestamp (no failure recorded yet) so we don't | ||
| // treat the Unix epoch as the last failure and transition immediately. | ||
| lastFailure := cb.lastFailure.Load() | ||
| if lastFailure != 0 && time.Now().UnixNano()-lastFailure >= int64(cb.config.OpenTimeout) { | ||
| // Clear the half-open counters BEFORE half-open becomes visible: | ||
| // clearing after a CAS would erase reservations and successes | ||
| // recorded by requests that observe the new state in between. | ||
| // The mutex keeps a second (stale) transition attempt from | ||
| // re-clearing counters that live probes are already using; while | ||
| // the state is still Open no request touches these counters, so | ||
| // clearing here is race-free. | ||
| cb.transitionMu.Lock() | ||
| if State(cb.state.Load()) == StateOpen { | ||
| cb.successes.Store(0) | ||
| cb.requests.Store(0) | ||
| cb.state.Store(int32(StateHalfOpen)) | ||
|
ndyakov marked this conversation as resolved.
Outdated
|
||
| cb.transitionMu.Unlock() | ||
| cb.notifyCallbacks(StateOpen, StateHalfOpen) | ||
| return StateHalfOpen | ||
| } | ||
| cb.transitionMu.Unlock() | ||
| } | ||
| } | ||
|
|
||
| return State(cb.state.Load()) | ||
| } | ||
|
|
||
| // IsAllowed returns true if a request should be allowed through. | ||
| // This is a convenience method that combines CheckState with half-open request limiting. | ||
| func (cb *CircuitBreaker) IsAllowed() bool { | ||
| state := cb.CheckState() | ||
|
|
||
| switch state { | ||
| case StateClosed: | ||
| return true | ||
| case StateOpen: | ||
| return false | ||
| case StateHalfOpen: | ||
| // Limit requests in half-open state | ||
| requests := cb.requests.Add(1) | ||
| if int(requests) > cb.config.MaxHalfOpenRequests { | ||
| cb.requests.Add(-1) // Revert | ||
|
ndyakov marked this conversation as resolved.
|
||
| return false | ||
| } | ||
| return true | ||
|
ndyakov marked this conversation as resolved.
Outdated
|
||
| default: | ||
| return false | ||
| } | ||
| } | ||
|
|
||
| // ReleaseHalfOpen returns a half-open request slot previously reserved by a | ||
| // successful IsAllowed call when the operation produced neither a recordable | ||
| // success nor failure (for example, it was aborted for an unrelated reason). | ||
| // Without this, a reserved-but-never-completed probe could permanently starve | ||
| // half-open recovery once MaxHalfOpenRequests slots are exhausted. It only has | ||
| // an effect while the breaker is half-open. | ||
| func (cb *CircuitBreaker) ReleaseHalfOpen() { | ||
| if State(cb.state.Load()) != StateHalfOpen { | ||
| return | ||
| } | ||
| if cb.requests.Add(-1) < 0 { | ||
| cb.requests.Store(0) | ||
| } | ||
| } | ||
|
|
||
| // RecordSuccess records a successful operation. | ||
| func (cb *CircuitBreaker) RecordSuccess() { | ||
| state := State(cb.state.Load()) | ||
|
|
||
| switch state { | ||
| case StateHalfOpen: | ||
| successes := cb.successes.Add(1) | ||
| // Re-check state after increment - another goroutine may have changed it | ||
| if State(cb.state.Load()) != StateHalfOpen { | ||
|
ndyakov marked this conversation as resolved.
|
||
| return | ||
| } | ||
| if int(successes) >= cb.config.SuccessThreshold { | ||
| // Clear the failure counter BEFORE Closed becomes visible: it | ||
| // still holds the count that opened the circuit, and a failure | ||
| // recorded between the state swap and a later reset would | ||
| // immediately re-open the circuit off that stale count. | ||
| cb.failures.Store(0) | ||
| if cb.state.CompareAndSwap(int32(StateHalfOpen), int32(StateClosed)) { | ||
| // Notify callbacks before resetting the half-open counters so | ||
| // they observe the success count that triggered the transition. | ||
| cb.notifyCallbacks(StateHalfOpen, StateClosed) | ||
| cb.successes.Store(0) | ||
| cb.requests.Store(0) | ||
| } | ||
| return | ||
| } | ||
| // The probe completed but the circuit is still half-open: give its | ||
| // admission slot back, so MaxHalfOpenRequests bounds CONCURRENT | ||
| // probes rather than a lifetime budget. Without this, a | ||
| // MaxHalfOpenRequests lower than SuccessThreshold could never | ||
| // accumulate enough successes to close the circuit. | ||
| cb.ReleaseHalfOpen() | ||
| case StateClosed: | ||
| // Reset failure count on success | ||
| cb.failures.Store(0) | ||
| } | ||
| } | ||
|
|
||
| // RecordFailure records a failed operation. | ||
| func (cb *CircuitBreaker) RecordFailure() { | ||
| cb.lastFailure.Store(time.Now().UnixNano()) | ||
| state := State(cb.state.Load()) | ||
|
|
||
| switch state { | ||
| case StateClosed: | ||
| failures := cb.failures.Add(1) | ||
| if int(failures) >= cb.config.FailureThreshold { | ||
| if cb.state.CompareAndSwap(int32(StateClosed), int32(StateOpen)) { | ||
| // Notify callbacks before clearing the half-open counters so | ||
| // observers see the failure count that triggered the | ||
| // transition, matching the half-open -> closed/open paths. | ||
| cb.notifyCallbacks(StateClosed, StateOpen) | ||
|
ndyakov marked this conversation as resolved.
|
||
| // successes and requests should already be 0 in Closed (they | ||
| // are only incremented while half-open, and every half-open | ||
| // exit zeroes them). Reset defensively so the invariant | ||
| // "successes/requests are clean on entry to Open" is upheld | ||
| // consistently across all transitions into Open, even if a | ||
| // future change starts touching those counters in Closed. | ||
| cb.successes.Store(0) | ||
| cb.requests.Store(0) | ||
| } | ||
| } | ||
| case StateHalfOpen: | ||
| // Any failure in half-open state opens the circuit. | ||
| if cb.state.CompareAndSwap(int32(StateHalfOpen), int32(StateOpen)) { | ||
| // Notify callbacks before resetting counters so they observe the | ||
| // counts that triggered the transition, matching the half-open -> | ||
| // closed path in RecordSuccess. | ||
| cb.notifyCallbacks(StateHalfOpen, StateOpen) | ||
| cb.successes.Store(0) | ||
| cb.requests.Store(0) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // OnStateChange registers a callback to be called when the state changes. | ||
| func (cb *CircuitBreaker) OnStateChange(callback StateChangeCallback) { | ||
| cb.mu.Lock() | ||
| defer cb.mu.Unlock() | ||
| cb.callbacks = append(cb.callbacks, callback) | ||
| } | ||
|
|
||
| // notifyCallbacks notifies all registered callbacks of a state change. | ||
| func (cb *CircuitBreaker) notifyCallbacks(oldState, newState State) { | ||
| cb.mu.RLock() | ||
| callbacks := make([]StateChangeCallback, len(cb.callbacks)) | ||
| copy(callbacks, cb.callbacks) | ||
| cb.mu.RUnlock() | ||
|
|
||
| for _, callback := range callbacks { | ||
| callback(oldState, newState) | ||
| } | ||
| } | ||
|
|
||
| // Reset resets the circuit breaker to closed state. | ||
| // If the circuit was not already closed, callbacks are notified. | ||
| func (cb *CircuitBreaker) Reset() { | ||
| oldState := State(cb.state.Swap(int32(StateClosed))) | ||
|
ndyakov marked this conversation as resolved.
Outdated
|
||
| cb.failures.Store(0) | ||
| cb.successes.Store(0) | ||
| cb.requests.Store(0) | ||
| cb.lastFailure.Store(0) | ||
| if oldState != StateClosed { | ||
| cb.notifyCallbacks(oldState, StateClosed) | ||
| } | ||
| } | ||
|
|
||
| // Stats returns current statistics for monitoring. | ||
| type Stats struct { | ||
| State State | ||
| Failures int32 | ||
| Successes int32 | ||
| Requests int32 | ||
| LastFailureTime time.Time | ||
| } | ||
|
|
||
| // Stats returns current statistics. | ||
| func (cb *CircuitBreaker) Stats() Stats { | ||
| lastFailure := cb.lastFailure.Load() | ||
| var lastFailureTime time.Time | ||
| if lastFailure > 0 { | ||
| lastFailureTime = time.Unix(0, lastFailure) | ||
| } | ||
|
|
||
| return Stats{ | ||
| State: cb.State(), | ||
| Failures: cb.failures.Load(), | ||
| Successes: cb.successes.Load(), | ||
| Requests: cb.requests.Load(), | ||
| LastFailureTime: lastFailureTime, | ||
| } | ||
| } | ||
|
|
||
| // Execute runs the given function with circuit breaker protection. | ||
| // Returns ErrCircuitOpen if the circuit is open and not ready for testing. | ||
| func (cb *CircuitBreaker) Execute(fn func() error) error { | ||
| if !cb.IsAllowed() { | ||
|
ndyakov marked this conversation as resolved.
Outdated
|
||
| return ErrCircuitOpen | ||
| } | ||
|
|
||
| err := fn() | ||
| if err != nil { | ||
| cb.RecordFailure() | ||
|
ndyakov marked this conversation as resolved.
|
||
| return err | ||
| } | ||
|
|
||
| cb.RecordSuccess() | ||
| return nil | ||
| } | ||
|
|
||
| // ErrCircuitOpen is returned when the circuit breaker is open. | ||
| var ErrCircuitOpen = &CircuitOpenError{} | ||
|
|
||
| // CircuitOpenError indicates the circuit breaker is open. | ||
| type CircuitOpenError struct{} | ||
|
|
||
| func (e *CircuitOpenError) Error() string { | ||
| return "circuit breaker is open" | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.