circuitbreaker.go 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. // Package circuitbreaker provides a per-component circuit breaker
  2. // for the ingest pipeline (SPEC §22 layer 6). It wraps a function
  3. // that performs an outbound call and trips the circuit when error
  4. // rates exceed a threshold.
  5. //
  6. // State machine (3 states, 4 transitions):
  7. //
  8. // CLOSED → OPEN failure_count >= threshold within window
  9. // OPEN → HALF open_duration elapsed
  10. // HALF → CLOSED successful call observed
  11. // HALF → OPEN call in HALF state also fails
  12. //
  13. // The breaker is in-process (no shared state across multiple
  14. // ingestd instances). For the dev stack that is fine; for a
  15. // multi-instance production deploy, the state would live in
  16. // Redis (deferred to a future milestone).
  17. //
  18. // Metrics: callers are expected to call cb.Measure(state, err)
  19. // after each Do() call so the gauge maintained here stays in
  20. // sync with what the Prometheus scrape sees.
  21. package circuitbreaker
  22. import (
  23. "context"
  24. "errors"
  25. "sync"
  26. "time"
  27. )
  28. // State values. Mirrors the ba_ingestd_circuit_breaker_state gauge.
  29. const (
  30. StateClosed = 0
  31. StateHalfOpen = 1
  32. StateOpen = 2
  33. )
  34. // Config is the static configuration for one circuit breaker.
  35. // All fields must be set by the caller.
  36. type Config struct {
  37. // Name is the component label used in Prometheus metrics.
  38. Name string
  39. // FailureThreshold is the number of consecutive failures
  40. // (within FailureWindow) that trips the circuit to OPEN.
  41. FailureThreshold int
  42. // FailureWindow is the rolling window for counting failures.
  43. FailureWindow time.Duration
  44. // OpenDuration is how long the circuit stays OPEN before
  45. // transitioning to HALF-OPEN (testing).
  46. OpenDuration time.Duration
  47. // MaxHalfOpen is the number of test calls admitted while
  48. // in HALF-OPEN state. Default 1 (admit one, decide).
  49. MaxHalfOpen int
  50. }
  51. // DefaultConfig is a reasonable starting point for a NATS broker
  52. // circuit breaker: 5 failures in 10s trips; 30s open; 1 test call.
  53. func DefaultConfig(name string) Config {
  54. return Config{
  55. Name: name,
  56. FailureThreshold: 5,
  57. FailureWindow: 10 * time.Second,
  58. OpenDuration: 30 * time.Second,
  59. MaxHalfOpen: 1,
  60. }
  61. }
  62. // Breaker is the per-component circuit breaker. It is safe for
  63. // concurrent use by the HTTP, MQTT, and WebSocket ingest paths.
  64. type Breaker struct {
  65. config Config
  66. mu sync.RWMutex
  67. // state is one of StateClosed, StateHalfOpen, StateOpen.
  68. state int
  69. // failures is a circular buffer of recent failure timestamps.
  70. // We keep it as a slice and prune anything older than
  71. // config.FailureWindow on every call.
  72. failures []time.Time
  73. // halfOpenCount is how many test calls have been admitted
  74. // in the current HALF-OPEN window. Resets to 0 on transition
  75. // out of HALF-OPEN.
  76. halfOpenCount int
  77. // openSince is when we entered the OPEN state. Used to
  78. // decide when to transition to HALF-OPEN.
  79. openSince time.Time
  80. // Measure is called after every Do() call with the observed
  81. // state and error. It is nil in production; tests can inject
  82. // a spy to observe state transitions without polling.
  83. Measure func(state int, err error)
  84. }
  85. // New creates a new circuit breaker from cfg.
  86. func New(cfg Config) *Breaker {
  87. if cfg.MaxHalfOpen <= 0 {
  88. cfg.MaxHalfOpen = 1
  89. }
  90. return &Breaker{config: cfg, state: StateClosed}
  91. }
  92. // Do runs fn if the circuit is CLOSED or HALF-OPEN. Returns
  93. // ErrCircuitOpen when the circuit is OPEN. Returns the error
  94. // from fn on failure; nil on success.
  95. //
  96. // If the circuit trips OPEN, Do records the failure internally
  97. // so the next caller gets ErrCircuitOpen immediately.
  98. func (cb *Breaker) Do(ctx context.Context, fn func() error) error {
  99. // Check context cancellation before doing any work.
  100. if err := ctx.Err(); err != nil {
  101. return err
  102. }
  103. // Check and handle any time-based transition BEFORE acquiring
  104. // the lock to avoid holding the lock across a time.Sleep.
  105. // (time.Since inside a mutex is a deadlock risk in writer-
  106. // biased RWMutex implementations).
  107. cb.tryHalfOpen()
  108. cb.mu.Lock()
  109. defer cb.mu.Unlock()
  110. switch cb.state {
  111. case StateOpen:
  112. return ErrCircuitOpen
  113. case StateHalfOpen:
  114. if cb.halfOpenCount >= cb.config.MaxHalfOpen {
  115. return ErrCircuitOpen
  116. }
  117. cb.halfOpenCount++
  118. }
  119. // Run the protected function.
  120. err := fn()
  121. // Record the result under the lock.
  122. cb.recordResultLocked(err)
  123. return err
  124. }
  125. // tryHalfOpen checks if an OPEN circuit's duration has elapsed
  126. // and transitions it to HALF-OPEN. Safe to call without the lock;
  127. // it acquires the lock internally for the write.
  128. func (cb *Breaker) tryHalfOpen() {
  129. cb.mu.Lock()
  130. defer cb.mu.Unlock()
  131. if cb.state == StateOpen && time.Since(cb.openSince) >= cb.config.OpenDuration {
  132. cb.state = StateHalfOpen
  133. cb.halfOpenCount = 0
  134. cb.failures = nil
  135. if cb.Measure != nil {
  136. cb.Measure(StateHalfOpen, nil)
  137. }
  138. }
  139. }
  140. // recordResultLocked updates internal state based on fn's result.
  141. // Caller MUST hold cb.mu. Exported as recordResult for tests
  142. // that hold the lock externally.
  143. func (cb *Breaker) recordResultLocked(err error) {
  144. if err == nil {
  145. // Successful call.
  146. if cb.state == StateHalfOpen {
  147. cb.state = StateClosed
  148. cb.halfOpenCount = 0
  149. cb.failures = nil
  150. if cb.Measure != nil {
  151. cb.Measure(StateClosed, nil)
  152. }
  153. }
  154. return
  155. }
  156. // Failure.
  157. if cb.state == StateHalfOpen {
  158. // A failure in HALF-OPEN trips back to OPEN.
  159. cb.state = StateOpen
  160. cb.openSince = time.Now()
  161. cb.halfOpenCount = 0
  162. if cb.Measure != nil {
  163. cb.Measure(StateOpen, err)
  164. }
  165. return
  166. }
  167. // Failure in CLOSED: record it and check threshold.
  168. now := time.Now()
  169. cb.failures = append(cb.failures, now)
  170. // Prune anything outside the failure window.
  171. cutoff := now.Add(-cb.config.FailureWindow)
  172. j := 0
  173. for i, t := range cb.failures {
  174. if t.After(cutoff) {
  175. j = i
  176. break
  177. }
  178. }
  179. if j > 0 {
  180. cb.failures = cb.failures[j:]
  181. }
  182. // Check threshold.
  183. if len(cb.failures) >= cb.config.FailureThreshold {
  184. cb.state = StateOpen
  185. cb.openSince = now
  186. if cb.Measure != nil {
  187. cb.Measure(StateOpen, err)
  188. }
  189. }
  190. }
  191. // recordResult is a convenience wrapper for tests that don't
  192. // already hold the lock.
  193. func (cb *Breaker) recordResult(err error) {
  194. cb.mu.Lock()
  195. defer cb.mu.Unlock()
  196. cb.recordResultLocked(err)
  197. }
  198. // State returns the current state (0=closed, 1=half-open, 2=open)
  199. // without acquiring the write lock. Suitable for metrics reporting.
  200. func (cb *Breaker) State() int {
  201. cb.mu.RLock()
  202. defer cb.mu.RUnlock()
  203. return cb.state
  204. }
  205. // ErrCircuitOpen is returned by Do when the circuit is OPEN.
  206. var ErrCircuitOpen = errors.New("circuit breaker open")
  207. // Ensure errors don't get shadowed.
  208. var _ = ErrCircuitOpen.Error