Skip to content

Commit 9d515ee

Browse files
committed
refactor StorageCircuitBreaker.ts
1 parent 2b6f496 commit 9d515ee

8 files changed

Lines changed: 719 additions & 125 deletions

File tree

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import StateMachine from '../StateMachine';
2+
import {CIRCUIT_BREAKER_TRANSITIONS} from './types';
3+
import type {CircuitBreakerOptions, CircuitBreakerState} from './types';
4+
5+
/**
6+
* Generic circuit breaker built on {@link StateMachine}.
7+
*
8+
* - **closed**: requests are allowed; failures are counted.
9+
* - **open**: requests are rejected until {@link resetTimeoutMs} elapses.
10+
* - **half-open**: the recovery-probe state. After the open timeout the breaker does NOT jump straight
11+
* back to closed (that would re-admit full load onto a still-broken dependency and immediately
12+
* re-trip — flapping). Instead it admits exactly ONE probe request: success means the dependency
13+
* recovered, so the circuit closes; failure means it's still down, so the circuit reopens for
14+
* another window. Admitting a single probe (see {@link isAllowed}) — rather than reopening the
15+
* floodgates — is also what avoids the "thundering herd" of every caller retrying at once.
16+
*
17+
* Subclasses implement the failure-counting policy by overriding {@link recordFailureInClosed} (and
18+
* friends) — e.g. counting consecutive failures, or failures within a rolling time window, or any
19+
* combination of those.
20+
*
21+
* @example
22+
* class MyBreaker extends AbstractCircuitBreaker {
23+
* private failures = 0;
24+
* protected recordFailureInClosed() {
25+
* this.failures += 1;
26+
* return this.failures >= 3 ? `${this.failures} failures` : null;
27+
* }
28+
* protected recordSuccessInClosed() { this.failures = 0; }
29+
* protected resetFailureState() { this.failures = 0; }
30+
* }
31+
*
32+
* const breaker = new MyBreaker({resetTimeoutMs: 30_000});
33+
* if (breaker.isAllowed()) {
34+
* try {
35+
* doWork();
36+
* breaker.recordSuccess();
37+
* } catch {
38+
* breaker.recordFailure();
39+
* }
40+
* }
41+
*/
42+
abstract class AbstractCircuitBreaker {
43+
private machine: StateMachine<typeof CIRCUIT_BREAKER_TRANSITIONS, CircuitBreakerState>;
44+
45+
private openedAt = 0;
46+
47+
private isProbeInFlight = false;
48+
49+
private readonly resetTimeoutMs: number;
50+
51+
private readonly onTrip?: (reason: string) => void;
52+
53+
private readonly onClose?: () => void;
54+
55+
constructor(options: CircuitBreakerOptions = {}) {
56+
this.resetTimeoutMs = options.resetTimeoutMs ?? 60_000;
57+
this.onTrip = options.onTrip;
58+
this.onClose = options.onClose;
59+
this.machine = new StateMachine('closed', CIRCUIT_BREAKER_TRANSITIONS);
60+
}
61+
62+
/** Record a failure while the circuit is closed. Returns a trip reason when the threshold is exceeded. */
63+
protected abstract recordFailureInClosed(): string | null;
64+
65+
/** Update failure state after a successful request while the circuit is closed. */
66+
protected abstract recordSuccessInClosed(): void;
67+
68+
/** Clear accumulated failure state without changing circuit state. */
69+
protected abstract resetFailureState(): void;
70+
71+
/**
72+
* Whether a request may proceed.
73+
*
74+
* Returns `false` while open. In half-open, the FIRST caller is admitted as the recovery probe and
75+
* `isProbeInFlight` is latched so every subsequent caller is rejected until that probe resolves
76+
* (via {@link recordSuccess} → close, or {@link recordFailure} → reopen). That single-probe gate is
77+
* the whole point of half-open: it tests recovery with one request instead of letting a herd of
78+
* waiting callers stampede a dependency that may still be down.
79+
*/
80+
isAllowed(): boolean {
81+
const currentState = this.getCurrentState();
82+
83+
if (currentState === 'open') {
84+
return false;
85+
}
86+
87+
if (currentState === 'half-open') {
88+
if (this.isProbeInFlight) {
89+
return false;
90+
}
91+
this.isProbeInFlight = true;
92+
}
93+
94+
return true;
95+
}
96+
97+
/**
98+
* Record a failed request. May open the circuit from closed or half-open.
99+
* @returns `true` when the circuit is open after recording (the request must not proceed).
100+
*/
101+
recordFailure(): boolean {
102+
if (this.machine.state === 'open') {
103+
return true;
104+
}
105+
106+
if (this.machine.state === 'half-open') {
107+
this.trip();
108+
return true;
109+
}
110+
111+
const reason = this.recordFailureInClosed();
112+
if (reason) {
113+
this.trip(reason);
114+
return true;
115+
}
116+
117+
return false;
118+
}
119+
120+
/** Record a successful request. Closes the circuit from half-open and clears failure counts. */
121+
recordSuccess(): void {
122+
if (this.machine.state === 'half-open') {
123+
this.close();
124+
return;
125+
}
126+
127+
if (this.machine.state === 'closed') {
128+
this.recordSuccessInClosed();
129+
}
130+
}
131+
132+
/**
133+
* The current state WITHOUT advancing recovery — a pure query, safe to call without side effects.
134+
* The open→half-open transition is applied only at the admission point ({@link isAllowed}); by the
135+
* time a caller queries state after being admitted, that transition has already happened.
136+
*/
137+
peekState(): CircuitBreakerState {
138+
return this.machine.state;
139+
}
140+
141+
/**
142+
* Force the circuit back to closed from ANY state. This is a reset, not a transition, so it
143+
* deliberately bypasses the transition graph (and does not fire {@link onClose}). Use only to wipe
144+
* all state — e.g. between tests or sessions.
145+
*/
146+
protected hardReset(): void {
147+
this.machine = new StateMachine('closed', CIRCUIT_BREAKER_TRANSITIONS);
148+
this.openedAt = 0;
149+
this.isProbeInFlight = false;
150+
this.resetFailureState();
151+
}
152+
153+
private getCurrentState(): CircuitBreakerState {
154+
this.maybeRecover();
155+
return this.machine.state;
156+
}
157+
158+
private trip(reason = ''): void {
159+
if (this.machine.state === 'open') {
160+
return;
161+
}
162+
163+
this.machine = this.machine.transition('open');
164+
this.openedAt = Date.now();
165+
this.isProbeInFlight = false;
166+
this.resetFailureState();
167+
this.onTrip?.(reason);
168+
}
169+
170+
private close(): void {
171+
// close() only ever runs from half-open (see recordSuccess), and half-open → closed is the one
172+
// legal closing transition — so go through transition() to keep the illegal open → closed jump
173+
// an error rather than silently constructing a fresh closed machine.
174+
this.machine = this.machine.transition('closed');
175+
this.openedAt = 0;
176+
this.isProbeInFlight = false;
177+
this.resetFailureState();
178+
this.onClose?.();
179+
}
180+
181+
/**
182+
* Lazily advance open → half-open once the reset timeout has elapsed. This is checked on read
183+
* (via {@link getCurrentState}) rather than on a timer, so there's nothing to schedule or clean up:
184+
* the transition simply becomes visible to the next caller after the window. Entering half-open
185+
* clears `isProbeInFlight` so the next admitted request becomes the recovery probe.
186+
*/
187+
private maybeRecover(): void {
188+
if (this.machine.state !== 'open') {
189+
return;
190+
}
191+
192+
if (Date.now() - this.openedAt < this.resetTimeoutMs) {
193+
return;
194+
}
195+
196+
this.machine = this.machine.transition('half-open');
197+
this.isProbeInFlight = false;
198+
}
199+
}
200+
201+
export default AbstractCircuitBreaker;

lib/CircuitBreaker/types.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* States of the circuit breaker.
3+
*
4+
* - **closed**: normal operation; requests flow and failures are counted.
5+
* - **open**: tripped; requests are rejected outright so a known-bad dependency isn't hammered.
6+
* - **half-open**: a trial state entered after the open timeout — see {@link CIRCUIT_BREAKER_TRANSITIONS}.
7+
*/
8+
type CircuitBreakerState = 'closed' | 'open' | 'half-open';
9+
10+
/**
11+
* Legal state transitions. The flow is closed → open → half-open → (closed | open).
12+
*
13+
* The **half-open** state exists so the breaker can test whether the dependency has recovered WITHOUT
14+
* flipping straight back to fully closed. Going open → closed blindly would, on a dependency that is
15+
* still down, immediately re-admit the full load and re-trip — flapping between open and closed every
16+
* window. Instead, after the open timeout the breaker moves to half-open and admits a single trial
17+
* ("probe") request:
18+
* - probe succeeds → the dependency is healthy again → transition to **closed** (resume normal flow).
19+
* - probe fails → still broken → transition back to **open** for another timeout window.
20+
*
21+
* Admitting exactly one probe (rather than reopening the floodgates) is also what prevents the
22+
* "thundering herd": many callers retrying at once the instant the timeout elapses, re-overwhelming a
23+
* dependency that was just starting to recover.
24+
*/
25+
const CIRCUIT_BREAKER_TRANSITIONS = {
26+
closed: ['open'],
27+
open: ['half-open'],
28+
'half-open': ['closed', 'open'],
29+
} as const satisfies Record<CircuitBreakerState, readonly CircuitBreakerState[]>;
30+
31+
type CircuitBreakerOptions = {
32+
/** Time in milliseconds the circuit stays open before moving to half-open. */
33+
resetTimeoutMs?: number;
34+
35+
/** Called once each time the circuit opens. */
36+
onTrip?: (reason: string) => void;
37+
38+
/** Called when the circuit closes. */
39+
onClose?: () => void;
40+
};
41+
42+
export type {CircuitBreakerOptions, CircuitBreakerState};
43+
export {CIRCUIT_BREAKER_TRANSITIONS};

lib/OnyxUtils.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -826,10 +826,12 @@ function retryOperation<TMethod extends RetriableOnyxOperation>(
826826
const nextRetryAttempt = currentRetryAttempt + 1;
827827
const errorClass = Storage.classifyError(error);
828828

829-
// Once the breaker is open, every capacity write is going to fail the same way. Drop it silently —
829+
// While open (or while a half-open probe is already in flight), drop capacity retries silently —
830830
// the breaker already emitted its single alert, and logging per failed write is exactly the storm
831-
// we are suppressing. (We return before the log line below on purpose.)
832-
if (errorClass === StorageErrorClass.CAPACITY && StorageCircuitBreaker.isTripped()) {
831+
// we are suppressing. A rejected half-open caller is the in-flight probe failing; record that so
832+
// the circuit reopens for another window. (We return before the log line below on purpose.)
833+
if (errorClass === StorageErrorClass.CAPACITY && !StorageCircuitBreaker.isAllowed()) {
834+
StorageCircuitBreaker.recordProbeFailure();
833835
return Promise.resolve();
834836
}
835837

@@ -869,8 +871,7 @@ function retryOperation<TMethod extends RetriableOnyxOperation>(
869871
// cannot stop a session-wide storm — each evicted key triggers an OnyxDerived recompute that spawns
870872
// a fresh write with its own budget — so the breaker is what actually halts the meltdown. (The
871873
// already-open case returned silently at the top of this function.)
872-
StorageCircuitBreaker.recordCapacityFailure();
873-
if (StorageCircuitBreaker.isTripped()) {
874+
if (StorageCircuitBreaker.recordCapacityFailure()) {
874875
// This failure tripped the breaker; it already emitted its single alert. Stop here.
875876
return Promise.resolve();
876877
}

lib/StateMachine.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type {ReadonlyDeep} from 'type-fest';
2+
3+
/**
4+
* A directed transition graph keyed by state name.
5+
* Use `as const` when defining a graph so illegal transitions are caught at compile time.
6+
*
7+
* @example
8+
* const transitions = {
9+
* idle: ['loading'],
10+
* loading: ['success', 'error'],
11+
* success: [],
12+
* error: ['idle'],
13+
* } as const;
14+
*/
15+
type TransitionGraph = Readonly<Record<string, readonly string[]>>;
16+
17+
/** Target states reachable from `Current` according to `Graph`. */
18+
type TransitionsFrom<Graph extends TransitionGraph, Current extends keyof Graph & string> = Graph[Current] extends ReadonlyArray<infer Target extends string> ? Target : never;
19+
20+
/**
21+
* An immutable, type-safe finite state machine.
22+
* Pass the transition graph with `as const` so `transition` only accepts legal target states.
23+
*
24+
* @example
25+
* const transitions = {
26+
* idle: ['loading'],
27+
* loading: ['success', 'error'],
28+
* success: [],
29+
* error: ['idle'],
30+
* } as const;
31+
*
32+
* const idleMachine = new StateMachine('idle', transitions);
33+
* const loadingMachine = idleMachine.transition('loading');
34+
* loadingMachine.transition('success');
35+
*/
36+
class StateMachine<const Graph extends TransitionGraph, Current extends keyof Graph & string> {
37+
/** The current state. Deeply readonly and owned by this state machine instance. */
38+
readonly state: ReadonlyDeep<Current>;
39+
40+
private readonly transitions: Graph;
41+
42+
constructor(currentState: Current, transitions: Graph) {
43+
this.state = currentState as ReadonlyDeep<Current>;
44+
this.transitions = transitions;
45+
Object.freeze(this);
46+
}
47+
48+
/**
49+
* Transition to a new state, returning a new state machine instance.
50+
* Only transitions declared in the graph for the current state are accepted.
51+
*/
52+
transition<Target extends TransitionsFrom<Graph, Current>>(target: Target): StateMachine<Graph, Target> {
53+
const validTargets = this.transitions[this.state as Current];
54+
if (!validTargets?.includes(target)) {
55+
throw new Error(`Illegal transition from "${String(this.state)}" to "${String(target)}"`);
56+
}
57+
58+
return new StateMachine(target, this.transitions);
59+
}
60+
}
61+
62+
export default StateMachine;
63+
export type {TransitionGraph, TransitionsFrom};

0 commit comments

Comments
 (0)