Class: Insika::CircuitState
- Inherits:
-
Object
- Object
- Insika::CircuitState
- Defined in:
- lib/insika/circuit_state.rb
Overview
The circuit breaker's durable state (WS3): one cell per
(tenant, provider/model ref) holding the recent failures and when the
circuit opened. The read-modify-write rides @store.transaction — the same
discipline as the dispatch claim (A1): two concurrent failures of the same
cell serialize, so the "10 failures in 60s" count is never lost to a race.
States, standard semantics:
:closed fewer than `after` failures in the `within` window — attempts flow.
:open the window count is met AND the cooldown hasn't elapsed — the
edge FAIL-FASTS (no provider call) with retry_after = remaining
cooldown. Reached at the `after`-th failure, which stamps
opened_at.
:half_open the cooldown elapsed — the next attempt is the TRIAL: a success
closes (record_success clears the cell), a failure reopens
(a new failure count / opened_at).
Defined Under Namespace
Classes: Record
Constant Summary collapse
- SCOPE =
"circuit_state"- COUNT_LIMIT =
Timestamps are pruned to COUNT_LIMIT — a pathological loop cannot grow the cell unboundedly (the window is 60s-wide; 100 reads and writes bounded).
100
Instance Method Summary collapse
-
#initialize(store:) ⇒ CircuitState
constructor
A new instance of CircuitState.
-
#record_failure(tenant:, ref:, after: 10, within: 60, now: Time.now) ⇒ Object
Records ONE failure for (tenant, ref).
-
#record_success(tenant:, ref:) ⇒ Object
A successful attempt CLOSES the circuit: the cell is cleared so the failure window starts fresh (half-open trial success included).
-
#retry_after(tenant:, ref:, cooldown: 300, now: Time.now) ⇒ Object
Seconds until the circuit can be retried (the remaining cooldown).
-
#state(tenant:, ref:, after: 10, within: 60, cooldown: 300, now: Time.now) ⇒ Object
-> :closed | :open | :half_open.
Constructor Details
#initialize(store:) ⇒ CircuitState
Returns a new instance of CircuitState.
30 31 32 |
# File 'lib/insika/circuit_state.rb', line 30 def initialize(store:) @store = store end |
Instance Method Details
#record_failure(tenant:, ref:, after: 10, within: 60, now: Time.now) ⇒ Object
Records ONE failure for (tenant, ref). If this failure makes the window
reach after and the circuit is not already open, it stamps opened_at
(the instant the breaker trips). -> :closed (still closed) | :open (JUST
tripped — closed->open, the ONLY transition that alerts) | :reopened (an
already-tripped cell re-stamped: a half-open trial failed; the circuit is
open again but the node already told the operator it is sick — no NEW
alert (WS3)).
41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
# File 'lib/insika/circuit_state.rb', line 41 def record_failure(tenant:, ref:, after: 10, within: 60, now: Time.now) key = key_for(tenant, ref) @store.transaction do record = load(key) cutoff = (now.to_i - within) retained = record.failures.select { |t| t > cutoff }.last(COUNT_LIMIT) failures = (retained + [now.to_i]).last(COUNT_LIMIT) opened_at = record.opened_at tripped = :closed if opened_at.nil? if failures.size >= after opened_at = now.to_i tripped = :open end else # the circuit was tripped before; a failure here can only be a HALF-OPEN # trial that failed. Re-stamp opened_at so it RE-OPENS — a breaker that # never restamps is one-shot: after the first cooldown every later turn # is an unlocked half-open trial (WS3). opened_at = now.to_i tripped = :reopened end @store.set(SCOPE, key, { "failures" => failures, "opened_at" => opened_at }) tripped end end |
#record_success(tenant:, ref:) ⇒ Object
A successful attempt CLOSES the circuit: the cell is cleared so the failure window starts fresh (half-open trial success included).
70 71 72 |
# File 'lib/insika/circuit_state.rb', line 70 def record_success(tenant:, ref:) @store.delete(SCOPE, key_for(tenant, ref)) end |
#retry_after(tenant:, ref:, cooldown: 300, now: Time.now) ⇒ Object
Seconds until the circuit can be retried (the remaining cooldown). nil while closed.
92 93 94 95 96 97 98 99 100 101 |
# File 'lib/insika/circuit_state.rb', line 92 def retry_after(tenant:, ref:, cooldown: 300, now: Time.now) record = load(key_for(tenant, ref)) return nil if record.failures.empty? opened = record.opened_at.to_i return nil if opened.zero? remaining = cooldown - (now.to_i - opened) remaining.positive? ? remaining : nil end |
#state(tenant:, ref:, after: 10, within: 60, cooldown: 300, now: Time.now) ⇒ Object
-> :closed | :open | :half_open
75 76 77 78 79 80 81 82 83 84 85 86 87 88 |
# File 'lib/insika/circuit_state.rb', line 75 def state(tenant:, ref:, after: 10, within: 60, cooldown: 300, now: Time.now) record = load(key_for(tenant, ref)) opened = record.opened_at.to_i # :closed needs BOTH doors shut: the window count is under `after` AND the # circuit never tripped. A surviving opened_at tombstone keeps the circuit # governed by the cooldown (open, then half-open) even after its original # failures age out of the rolling window — only a SUCCESS clears it (WS3: # a failed half-open trial must reopen, not silently close). return :closed if record.failures.size < after && opened.zero? return :open if opened.zero? || (now.to_i - opened) < cooldown :half_open end |