| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233 |
- // Package circuitbreaker provides a per-component circuit breaker
- // for the ingest pipeline (SPEC §22 layer 6). It wraps a function
- // that performs an outbound call and trips the circuit when error
- // rates exceed a threshold.
- //
- // State machine (3 states, 4 transitions):
- //
- // CLOSED → OPEN failure_count >= threshold within window
- // OPEN → HALF open_duration elapsed
- // HALF → CLOSED successful call observed
- // HALF → OPEN call in HALF state also fails
- //
- // The breaker is in-process (no shared state across multiple
- // ingestd instances). For the dev stack that is fine; for a
- // multi-instance production deploy, the state would live in
- // Redis (deferred to a future milestone).
- //
- // Metrics: callers are expected to call cb.Measure(state, err)
- // after each Do() call so the gauge maintained here stays in
- // sync with what the Prometheus scrape sees.
- package circuitbreaker
- import (
- "context"
- "errors"
- "sync"
- "time"
- )
- // State values. Mirrors the ba_ingestd_circuit_breaker_state gauge.
- const (
- StateClosed = 0
- StateHalfOpen = 1
- StateOpen = 2
- )
- // Config is the static configuration for one circuit breaker.
- // All fields must be set by the caller.
- type Config struct {
- // Name is the component label used in Prometheus metrics.
- Name string
- // FailureThreshold is the number of consecutive failures
- // (within FailureWindow) that trips the circuit to OPEN.
- FailureThreshold int
- // FailureWindow is the rolling window for counting failures.
- FailureWindow time.Duration
- // OpenDuration is how long the circuit stays OPEN before
- // transitioning to HALF-OPEN (testing).
- OpenDuration time.Duration
- // MaxHalfOpen is the number of test calls admitted while
- // in HALF-OPEN state. Default 1 (admit one, decide).
- MaxHalfOpen int
- }
- // DefaultConfig is a reasonable starting point for a NATS broker
- // circuit breaker: 5 failures in 10s trips; 30s open; 1 test call.
- func DefaultConfig(name string) Config {
- return Config{
- Name: name,
- FailureThreshold: 5,
- FailureWindow: 10 * time.Second,
- OpenDuration: 30 * time.Second,
- MaxHalfOpen: 1,
- }
- }
- // Breaker is the per-component circuit breaker. It is safe for
- // concurrent use by the HTTP, MQTT, and WebSocket ingest paths.
- type Breaker struct {
- config Config
- mu sync.RWMutex
- // state is one of StateClosed, StateHalfOpen, StateOpen.
- state int
- // failures is a circular buffer of recent failure timestamps.
- // We keep it as a slice and prune anything older than
- // config.FailureWindow on every call.
- failures []time.Time
- // halfOpenCount is how many test calls have been admitted
- // in the current HALF-OPEN window. Resets to 0 on transition
- // out of HALF-OPEN.
- halfOpenCount int
- // openSince is when we entered the OPEN state. Used to
- // decide when to transition to HALF-OPEN.
- openSince time.Time
- // Measure is called after every Do() call with the observed
- // state and error. It is nil in production; tests can inject
- // a spy to observe state transitions without polling.
- Measure func(state int, err error)
- }
- // New creates a new circuit breaker from cfg.
- func New(cfg Config) *Breaker {
- if cfg.MaxHalfOpen <= 0 {
- cfg.MaxHalfOpen = 1
- }
- return &Breaker{config: cfg, state: StateClosed}
- }
- // Do runs fn if the circuit is CLOSED or HALF-OPEN. Returns
- // ErrCircuitOpen when the circuit is OPEN. Returns the error
- // from fn on failure; nil on success.
- //
- // If the circuit trips OPEN, Do records the failure internally
- // so the next caller gets ErrCircuitOpen immediately.
- func (cb *Breaker) Do(ctx context.Context, fn func() error) error {
- // Check context cancellation before doing any work.
- if err := ctx.Err(); err != nil {
- return err
- }
- // Check and handle any time-based transition BEFORE acquiring
- // the lock to avoid holding the lock across a time.Sleep.
- // (time.Since inside a mutex is a deadlock risk in writer-
- // biased RWMutex implementations).
- cb.tryHalfOpen()
- cb.mu.Lock()
- defer cb.mu.Unlock()
- switch cb.state {
- case StateOpen:
- return ErrCircuitOpen
- case StateHalfOpen:
- if cb.halfOpenCount >= cb.config.MaxHalfOpen {
- return ErrCircuitOpen
- }
- cb.halfOpenCount++
- }
- // Run the protected function.
- err := fn()
- // Record the result under the lock.
- cb.recordResultLocked(err)
- return err
- }
- // tryHalfOpen checks if an OPEN circuit's duration has elapsed
- // and transitions it to HALF-OPEN. Safe to call without the lock;
- // it acquires the lock internally for the write.
- func (cb *Breaker) tryHalfOpen() {
- cb.mu.Lock()
- defer cb.mu.Unlock()
- if cb.state == StateOpen && time.Since(cb.openSince) >= cb.config.OpenDuration {
- cb.state = StateHalfOpen
- cb.halfOpenCount = 0
- cb.failures = nil
- if cb.Measure != nil {
- cb.Measure(StateHalfOpen, nil)
- }
- }
- }
- // recordResultLocked updates internal state based on fn's result.
- // Caller MUST hold cb.mu. Exported as recordResult for tests
- // that hold the lock externally.
- func (cb *Breaker) recordResultLocked(err error) {
- if err == nil {
- // Successful call.
- if cb.state == StateHalfOpen {
- cb.state = StateClosed
- cb.halfOpenCount = 0
- cb.failures = nil
- if cb.Measure != nil {
- cb.Measure(StateClosed, nil)
- }
- }
- return
- }
- // Failure.
- if cb.state == StateHalfOpen {
- // A failure in HALF-OPEN trips back to OPEN.
- cb.state = StateOpen
- cb.openSince = time.Now()
- cb.halfOpenCount = 0
- if cb.Measure != nil {
- cb.Measure(StateOpen, err)
- }
- return
- }
- // Failure in CLOSED: record it and check threshold.
- now := time.Now()
- cb.failures = append(cb.failures, now)
- // Prune anything outside the failure window.
- cutoff := now.Add(-cb.config.FailureWindow)
- j := 0
- for i, t := range cb.failures {
- if t.After(cutoff) {
- j = i
- break
- }
- }
- if j > 0 {
- cb.failures = cb.failures[j:]
- }
- // Check threshold.
- if len(cb.failures) >= cb.config.FailureThreshold {
- cb.state = StateOpen
- cb.openSince = now
- if cb.Measure != nil {
- cb.Measure(StateOpen, err)
- }
- }
- }
- // recordResult is a convenience wrapper for tests that don't
- // already hold the lock.
- func (cb *Breaker) recordResult(err error) {
- cb.mu.Lock()
- defer cb.mu.Unlock()
- cb.recordResultLocked(err)
- }
- // State returns the current state (0=closed, 1=half-open, 2=open)
- // without acquiring the write lock. Suitable for metrics reporting.
- func (cb *Breaker) State() int {
- cb.mu.RLock()
- defer cb.mu.RUnlock()
- return cb.state
- }
- // ErrCircuitOpen is returned by Do when the circuit is OPEN.
- var ErrCircuitOpen = errors.New("circuit breaker open")
- // Ensure errors don't get shadowed.
- var _ = ErrCircuitOpen.Error
|