@animastor/orchestration 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/package.json +51 -0
- package/src/host/host-bindings.js +138 -0
- package/src/index.js +99 -0
- package/src/orchestration/index.js +40 -0
- package/src/orchestration/orchestrator.js +630 -0
- package/src/orchestration/runtime-result-consumer.js +54 -0
- package/src/orchestration/scene-callbacks.js +542 -0
- package/src/orchestration/scene-orchestrator.js +601 -0
- package/src/orchestration/scene-restoration.js +129 -0
- package/src/orchestration/scene-utils.js +36 -0
- package/src/runtime/active-scenes-index.js +164 -0
- package/src/runtime/audio-fsm-port.js +187 -0
- package/src/runtime/circuit-breaker.js +588 -0
- package/src/runtime/counter-reconciliation.js +308 -0
- package/src/runtime/dispatch-engine.js +1768 -0
- package/src/runtime/failure-taxonomy.js +125 -0
- package/src/runtime/hub-cancel-port.js +122 -0
- package/src/runtime/layer-config-port.js +136 -0
- package/src/runtime/lease-manager.js +607 -0
- package/src/runtime/persistence-port.js +162 -0
- package/src/runtime/placeholder-audio-port.js +117 -0
- package/src/runtime/progress-events-port.js +121 -0
- package/src/runtime/reconciliation-engine.js +2405 -0
- package/src/runtime/retry-budget-manager.js +229 -0
- package/src/runtime/runtime-metrics.js +427 -0
- package/src/runtime/runtime-result-emitter.js +123 -0
- package/src/runtime/runtime-scheduler.js +629 -0
- package/src/runtime/scene-data-port.js +114 -0
- package/src/runtime/scene-window.js +827 -0
- package/src/runtime/video-fsm-port.js +189 -0
- package/src/runtime/worker-health.js +270 -0
- package/test/counter-reconciliation.test.js +164 -0
- package/test/mocks/redis-mock.js +221 -0
- package/test/test-bindings.js +60 -0
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
// ======================================================
|
|
2
|
+
// Circuit Breaker - v1.0.0
|
|
3
|
+
// ======================================================
|
|
4
|
+
// Implements circuit breaker pattern for resilient runtime.
|
|
5
|
+
// Prevents cascading failures and retry storms.
|
|
6
|
+
//
|
|
7
|
+
// STATES:
|
|
8
|
+
// - CLOSED: normal operation, dispatches allowed
|
|
9
|
+
// - OPEN: dispatches blocked, circuit tripped
|
|
10
|
+
// - HALF_OPEN: test dispatches allowed, limited recovery
|
|
11
|
+
//
|
|
12
|
+
// TARGETS:
|
|
13
|
+
// - audio generation
|
|
14
|
+
// - image generation
|
|
15
|
+
// - video generation
|
|
16
|
+
// - Redis connection
|
|
17
|
+
// - filesystem operations
|
|
18
|
+
// - external worker callbacks
|
|
19
|
+
|
|
20
|
+
// S-2: media service targets resolved from the registry
|
|
21
|
+
const mediaRegistry = require('@animastor/generation').mediaRegistry;
|
|
22
|
+
|
|
23
|
+
const logPrefix = '[CIRCUIT]';
|
|
24
|
+
|
|
25
|
+
function log(msg) {
|
|
26
|
+
console.log(`${logPrefix} ${msg}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function warn(msg) {
|
|
30
|
+
console.warn(`${logPrefix} ⚠️ ${msg}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function error(msg) {
|
|
34
|
+
console.error(`${logPrefix} ❌ ${msg}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ======================================================
|
|
38
|
+
// CIRCUIT STATES
|
|
39
|
+
// ======================================================
|
|
40
|
+
|
|
41
|
+
const CircuitState = {
|
|
42
|
+
CLOSED: 'closed',
|
|
43
|
+
OPEN: 'open',
|
|
44
|
+
HALF_OPEN: 'half_open'
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// ======================================================
|
|
48
|
+
// CIRCUIT CONFIGURATION
|
|
49
|
+
// ======================================================
|
|
50
|
+
|
|
51
|
+
const CIRCUIT_CONFIG = {
|
|
52
|
+
// Failure thresholds
|
|
53
|
+
failureThreshold: 5, // 5 failures triggers OPEN
|
|
54
|
+
failureWindowMs: 2 * 60 * 1000, // 2 minutes window
|
|
55
|
+
|
|
56
|
+
// Recovery
|
|
57
|
+
recoveryTimeoutMs: 60 * 1000, // 60 seconds cooldown before HALF_OPEN
|
|
58
|
+
|
|
59
|
+
// Half-open limits
|
|
60
|
+
halfOpenMaxRequests: 2, // Allow 2 test dispatches in HALF_OPEN
|
|
61
|
+
|
|
62
|
+
// Satiety config (to recover from failure)
|
|
63
|
+
saturationThreshold: 8, // 8 failures triggers saturation (more aggressive)
|
|
64
|
+
saturationRecoveryMs: 5 * 60 * 1000, // 5 minutes saturation recovery
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// ======================================================
|
|
68
|
+
// CIRCUIT KEYS
|
|
69
|
+
// ======================================================
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get circuit state key for service.
|
|
73
|
+
*/
|
|
74
|
+
function getCircuitStateKey(service) {
|
|
75
|
+
return `animastor:circuit:${service}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get circuit failure count key.
|
|
80
|
+
*/
|
|
81
|
+
function getCircuitFailureKey(service) {
|
|
82
|
+
return `animastor:circuit:${service}:failures`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Get circuit last failure timestamp key.
|
|
87
|
+
*/
|
|
88
|
+
function getCircuitLastFailureKey(service) {
|
|
89
|
+
return `animastor:circuit:${service}:last-failure`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Get circuit half-open requests key.
|
|
94
|
+
*/
|
|
95
|
+
function getCircuitHalfOpenKey(service) {
|
|
96
|
+
return `animastor:circuit:${service}:half-open`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ======================================================
|
|
100
|
+
// SERVICE TARGETS
|
|
101
|
+
// ======================================================
|
|
102
|
+
|
|
103
|
+
const SERVICE_TARGETS = {
|
|
104
|
+
// S-2: media types resolved from registry at runtime
|
|
105
|
+
...Object.fromEntries(mediaRegistry.listMediaTypes().map(t => [t.toUpperCase(), t])),
|
|
106
|
+
REDIS: 'redis',
|
|
107
|
+
FILESYSTEM: 'filesystem',
|
|
108
|
+
WORKER_CALLBACK: 'worker_callback',
|
|
109
|
+
SCHEDULER: 'scheduler'
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// ======================================================
|
|
113
|
+
// CIRCUIT STATE MANAGEMENT
|
|
114
|
+
// ======================================================
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get current circuit state for a service.
|
|
118
|
+
*/
|
|
119
|
+
async function getCircuitState(redis, service) {
|
|
120
|
+
const stateKey = getCircuitStateKey(service);
|
|
121
|
+
const rawState = await redis.get(stateKey);
|
|
122
|
+
|
|
123
|
+
if (!rawState) {
|
|
124
|
+
// Default to CLOSED if not set
|
|
125
|
+
return CircuitState.CLOSED;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return rawState;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Set circuit state for a service.
|
|
133
|
+
*/
|
|
134
|
+
async function setCircuitState(redis, service, state) {
|
|
135
|
+
const stateKey = getCircuitStateKey(service);
|
|
136
|
+
await redis.set(stateKey, state, 'EX', 24 * 60 * 60); // 24 hour TTL
|
|
137
|
+
|
|
138
|
+
log(`CIRCUIT_${state.toUpperCase()}: ${service}`);
|
|
139
|
+
return { state, setAt: Date.now() };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Check if circuit is open for a service.
|
|
144
|
+
*/
|
|
145
|
+
async function isCircuitOpen(redis, service) {
|
|
146
|
+
const state = await getCircuitState(redis, service);
|
|
147
|
+
return state === CircuitState.OPEN;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Check if circuit is in half-open state.
|
|
152
|
+
*/
|
|
153
|
+
async function isCircuitHalfOpen(redis, service) {
|
|
154
|
+
const state = await getCircuitState(redis, service);
|
|
155
|
+
return state === CircuitState.HALF_OPEN;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ======================================================
|
|
159
|
+
// FAILURE TRACKING
|
|
160
|
+
// ======================================================
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Record a failure for a service.
|
|
164
|
+
* Returns: { circuitState, isTripped, newCount }
|
|
165
|
+
*/
|
|
166
|
+
async function recordFailure(redis, service) {
|
|
167
|
+
const failureKey = getCircuitFailureKey(service);
|
|
168
|
+
const lastFailureKey = getCircuitLastFailureKey(service);
|
|
169
|
+
|
|
170
|
+
// HALF_OPEN test request failed → re-open immediately (single test failure
|
|
171
|
+
// is enough to abort recovery; no need to wait for the full threshold).
|
|
172
|
+
const currentState = await getCircuitState(redis, service);
|
|
173
|
+
if (currentState === CircuitState.HALF_OPEN) {
|
|
174
|
+
const halfOpenKey = getCircuitHalfOpenKey(service);
|
|
175
|
+
await redis.del(halfOpenKey);
|
|
176
|
+
await redis.set(failureKey, '1', 'EX', Math.ceil(CIRCUIT_CONFIG.failureWindowMs / 1000));
|
|
177
|
+
await redis.set(lastFailureKey, Date.now().toString());
|
|
178
|
+
await setCircuitState(redis, service, CircuitState.OPEN);
|
|
179
|
+
log(`CIRCUIT_REOPENED: ${service} (half-open test failed)`);
|
|
180
|
+
return { circuitState: CircuitState.OPEN, isTripped: true, newCount: 1, service };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Increment failure count
|
|
184
|
+
const newCount = await redis.incr(failureKey);
|
|
185
|
+
await redis.set(lastFailureKey, Date.now().toString());
|
|
186
|
+
|
|
187
|
+
// Check if threshold exceeded
|
|
188
|
+
const isTripped = newCount >= CIRCUIT_CONFIG.failureThreshold;
|
|
189
|
+
|
|
190
|
+
// If tripped, open the circuit
|
|
191
|
+
if (isTripped) {
|
|
192
|
+
await setCircuitState(redis, service, CircuitState.OPEN);
|
|
193
|
+
log(`CIRCUIT_TRIPPED: ${service} (failures: ${newCount})`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Set expiry on failure count (auto-cleanup after failure window)
|
|
197
|
+
await redis.expire(failureKey, Math.ceil(CIRCUIT_CONFIG.failureWindowMs / 1000));
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
circuitState: await getCircuitState(redis, service),
|
|
201
|
+
isTripped,
|
|
202
|
+
newCount,
|
|
203
|
+
service
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Record a successful operation.
|
|
209
|
+
* Resets failure count for closed circuits; heals a HALF_OPEN circuit
|
|
210
|
+
* (test request succeeded → back to CLOSED).
|
|
211
|
+
*/
|
|
212
|
+
async function recordSuccess(redis, service) {
|
|
213
|
+
const failureKey = getCircuitFailureKey(service);
|
|
214
|
+
|
|
215
|
+
// B8: recordSuccess НЕ переключает OPEN → HALF_OPEN.
|
|
216
|
+
// Этот переход делает только tryRecover с проверкой recoveryTimeout.
|
|
217
|
+
// Если circuit OPEN — успех не должен пробивать защиту.
|
|
218
|
+
const currentState = await getCircuitState(redis, service);
|
|
219
|
+
if (currentState === CircuitState.OPEN) {
|
|
220
|
+
// Circuit is open — success calls should not happen through normal flow
|
|
221
|
+
// But if they do (e.g., test request passed the guard), just log and return
|
|
222
|
+
return { success: false, state: CircuitState.OPEN, reason: 'circuit_open' };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// HALF_OPEN: the test dispatch succeeded → circuit heals back to CLOSED.
|
|
226
|
+
if (currentState === CircuitState.HALF_OPEN) {
|
|
227
|
+
const halfOpenKey = getCircuitHalfOpenKey(service);
|
|
228
|
+
await redis.del(halfOpenKey);
|
|
229
|
+
await redis.set(failureKey, '0', 'EX', Math.ceil(CIRCUIT_CONFIG.failureWindowMs / 1000));
|
|
230
|
+
await setCircuitState(redis, service, CircuitState.CLOSED);
|
|
231
|
+
log(`CIRCUIT_HEALED: ${service} (half-open test succeeded)`);
|
|
232
|
+
return { success: true, state: CircuitState.CLOSED, reset: true, healed: true, testRequest: true };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Reset failure count if circuit is closed
|
|
236
|
+
if (currentState === CircuitState.CLOSED) {
|
|
237
|
+
await redis.set(failureKey, '0', 'EX', Math.ceil(CIRCUIT_CONFIG.failureWindowMs / 1000));
|
|
238
|
+
return { success: true, state: CircuitState.CLOSED, reset: true };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { success: true, state: currentState };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Release a half-open test permit.
|
|
246
|
+
*
|
|
247
|
+
* Fix (video-retry incident follow-up): a permit acquired by
|
|
248
|
+
* checkAndIncrementTestRequest was only released by recordSuccess/
|
|
249
|
+
* recordFailure. A half-open test dispatch that aborted BEFORE finalization
|
|
250
|
+
* (retry-budget exceeded, backpressure, duplicate, lease failure, cancelled)
|
|
251
|
+
* leaked its permit forever — with halfOpenMaxRequests=2 both permits leaked,
|
|
252
|
+
* the counter stuck at the limit and every later dispatch got
|
|
253
|
+
* 'half_open_limit_reached' → the stage stayed blocked permanently.
|
|
254
|
+
*
|
|
255
|
+
* Callers MUST call this when an admitted test request will NOT reach
|
|
256
|
+
* finalizeDispatch with outcome 'success'|'failure' (those two already DEL
|
|
257
|
+
* the counter themselves).
|
|
258
|
+
*/
|
|
259
|
+
async function releaseHalfOpenPermit(redis, service) {
|
|
260
|
+
const currentState = await getCircuitState(redis, service);
|
|
261
|
+
if (currentState !== CircuitState.HALF_OPEN) {
|
|
262
|
+
// Nothing to release (circuit already healed/reopened — the key was DELeted).
|
|
263
|
+
return { released: false, reason: 'not_half_open' };
|
|
264
|
+
}
|
|
265
|
+
const halfOpenKey = getCircuitHalfOpenKey(service);
|
|
266
|
+
const count = await redis.decr(halfOpenKey);
|
|
267
|
+
if (count <= 0) {
|
|
268
|
+
await redis.del(halfOpenKey);
|
|
269
|
+
}
|
|
270
|
+
return { released: true, remaining: Math.max(count, 0) };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Check if circuit has failed recently.
|
|
275
|
+
*/
|
|
276
|
+
async function hasRecentFailures(redis, service) {
|
|
277
|
+
const failureKey = getCircuitFailureKey(service);
|
|
278
|
+
const lastFailureKey = getCircuitLastFailureKey(service);
|
|
279
|
+
|
|
280
|
+
const failureCount = await redis.get(failureKey);
|
|
281
|
+
const lastFailure = await redis.get(lastFailureKey);
|
|
282
|
+
|
|
283
|
+
if (!failureCount || !lastFailure) {
|
|
284
|
+
return { recent: false, count: 0 };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const count = parseInt(failureCount, 10);
|
|
288
|
+
const lastFailureTime = parseInt(lastFailure, 10);
|
|
289
|
+
const now = Date.now();
|
|
290
|
+
const elapsed = now - lastFailureTime;
|
|
291
|
+
|
|
292
|
+
const recent = elapsed < CIRCUIT_CONFIG.failureWindowMs;
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
recent,
|
|
296
|
+
count,
|
|
297
|
+
elapsedMs: elapsed,
|
|
298
|
+
withinWindow: elapsed < CIRCUIT_CONFIG.failureWindowMs
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ======================================================
|
|
303
|
+
// DISPATCH CHECK
|
|
304
|
+
// ======================================================
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Check if dispatch should be allowed.
|
|
308
|
+
* Returns: { allowed, reason, circuitState }
|
|
309
|
+
*/
|
|
310
|
+
async function checkDispatch(redis, service) {
|
|
311
|
+
const currentState = await getCircuitState(redis, service);
|
|
312
|
+
|
|
313
|
+
switch (currentState) {
|
|
314
|
+
case CircuitState.CLOSED:
|
|
315
|
+
return { allowed: true, reason: 'circuit_closed', circuitState: currentState };
|
|
316
|
+
|
|
317
|
+
case CircuitState.OPEN:
|
|
318
|
+
return { allowed: false, reason: 'circuit_open', circuitState: currentState };
|
|
319
|
+
|
|
320
|
+
case CircuitState.HALF_OPEN:
|
|
321
|
+
const halfOpenKey = getCircuitHalfOpenKey(service);
|
|
322
|
+
const halfOpenCount = await redis.get(halfOpenKey);
|
|
323
|
+
const count = parseInt(halfOpenCount || '0', 10);
|
|
324
|
+
|
|
325
|
+
if (count >= CIRCUIT_CONFIG.halfOpenMaxRequests) {
|
|
326
|
+
// Too many test requests, go back to open
|
|
327
|
+
return { allowed: false, reason: 'half_open_limit_reached', circuitState: currentState };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return { allowed: true, reason: 'half_open_test', circuitState: currentState, isTestRequest: true };
|
|
331
|
+
|
|
332
|
+
default:
|
|
333
|
+
return { allowed: false, reason: 'unknown_state', circuitState: currentState };
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Check dispatch and increment test counter if half-open.
|
|
339
|
+
*/
|
|
340
|
+
async function checkAndIncrementTestRequest(redis, service) {
|
|
341
|
+
const result = await checkDispatch(redis, service);
|
|
342
|
+
|
|
343
|
+
if (result.allowed && result.isTestRequest) {
|
|
344
|
+
const halfOpenKey = getCircuitHalfOpenKey(service);
|
|
345
|
+
await redis.incr(halfOpenKey);
|
|
346
|
+
await redis.expire(halfOpenKey, Math.ceil(CIRCUIT_CONFIG.recoveryTimeoutMs / 1000));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return result;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Check dispatch with automatic recovery.
|
|
354
|
+
*
|
|
355
|
+
* When the circuit is OPEN and the recovery timeout has elapsed, transitions
|
|
356
|
+
* to HALF_OPEN and allows a limited test dispatch — so a tripped circuit
|
|
357
|
+
* recovers on its own instead of blocking the stage forever.
|
|
358
|
+
*
|
|
359
|
+
* Returns the same shape as checkDispatch, with an extra `recovered` flag:
|
|
360
|
+
* { allowed, reason, circuitState, recovered? }
|
|
361
|
+
*/
|
|
362
|
+
async function checkDispatchWithRecovery(redis, service) {
|
|
363
|
+
const currentState = await getCircuitState(redis, service);
|
|
364
|
+
|
|
365
|
+
if (currentState !== CircuitState.OPEN) {
|
|
366
|
+
return checkAndIncrementTestRequest(redis, service);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const recovery = await tryRecover(redis, service);
|
|
370
|
+
if (recovery.recoverable) {
|
|
371
|
+
// Transitioned OPEN → HALF_OPEN: this dispatch becomes the test request.
|
|
372
|
+
const result = await checkAndIncrementTestRequest(redis, service);
|
|
373
|
+
return { ...result, recovered: true };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Not recoverable yet (timeout not met or no failure record) — still blocked.
|
|
377
|
+
return { allowed: false, reason: 'circuit_open', circuitState: currentState, recovery };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ======================================================
|
|
381
|
+
// RECOVERY LOGIC
|
|
382
|
+
// ======================================================
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Try to recover a circuit from OPEN to HALF_OPEN.
|
|
386
|
+
* Only allowed after recovery timeout.
|
|
387
|
+
*/
|
|
388
|
+
async function tryRecover(redis, service) {
|
|
389
|
+
const stateKey = getCircuitStateKey(service);
|
|
390
|
+
const state = await getCircuitState(redis, service);
|
|
391
|
+
|
|
392
|
+
if (state !== CircuitState.OPEN) {
|
|
393
|
+
return { recoverable: false, reason: 'not_open', state };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Check if enough time has passed
|
|
397
|
+
const lastFailureKey = getCircuitLastFailureKey(service);
|
|
398
|
+
const lastFailure = await redis.get(lastFailureKey);
|
|
399
|
+
|
|
400
|
+
if (!lastFailure) {
|
|
401
|
+
// No failures recorded, safe to recover
|
|
402
|
+
const result = await setCircuitState(redis, service, CircuitState.HALF_OPEN);
|
|
403
|
+
return { recoverable: true, reason: 'no_failures', state, ...result };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const lastFailureTime = parseInt(lastFailure, 10);
|
|
407
|
+
const elapsed = Date.now() - lastFailureTime;
|
|
408
|
+
|
|
409
|
+
if (elapsed < CIRCUIT_CONFIG.recoveryTimeoutMs) {
|
|
410
|
+
return {
|
|
411
|
+
recoverable: false,
|
|
412
|
+
reason: 'timeout_not_met',
|
|
413
|
+
state,
|
|
414
|
+
elapsedMs: elapsed,
|
|
415
|
+
timeoutRemainingMs: CIRCUIT_CONFIG.recoveryTimeoutMs - elapsed
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Recover to half-open
|
|
420
|
+
const halfOpenKey = getCircuitHalfOpenKey(service);
|
|
421
|
+
await redis.set(halfOpenKey, '0', 'EX', Math.ceil(CIRCUIT_CONFIG.recoveryTimeoutMs / 1000));
|
|
422
|
+
|
|
423
|
+
const result = await setCircuitState(redis, service, CircuitState.HALF_OPEN);
|
|
424
|
+
log(`CIRCUIT_RECOVERED: ${service} (elapsed: ${Math.floor(elapsed / 1000)}s)`);
|
|
425
|
+
|
|
426
|
+
return { recoverable: true, reason: 'timeout_met', state, ...result };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Force open a circuit (emergency stop).
|
|
431
|
+
* Sets a last-failure timestamp so automatic recovery (tryRecover) still
|
|
432
|
+
* respects the recovery timeout — an emergency stop must not be immediately
|
|
433
|
+
* recoverable on the very next dispatch.
|
|
434
|
+
*/
|
|
435
|
+
async function forceOpen(redis, service, reason) {
|
|
436
|
+
const result = await setCircuitState(redis, service, CircuitState.OPEN);
|
|
437
|
+
const lastFailureKey = getCircuitLastFailureKey(service);
|
|
438
|
+
await redis.set(lastFailureKey, Date.now().toString());
|
|
439
|
+
log(`CIRCUIT_FORCED_OPEN: ${service} (reason: ${reason})`);
|
|
440
|
+
return result;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Force close a circuit (manual recovery).
|
|
445
|
+
*/
|
|
446
|
+
async function forceClose(redis, service, reason) {
|
|
447
|
+
const failureKey = getCircuitFailureKey(service);
|
|
448
|
+
await redis.set(failureKey, '0');
|
|
449
|
+
|
|
450
|
+
const result = await setCircuitState(redis, service, CircuitState.CLOSED);
|
|
451
|
+
log(`CIRCUIT_FORCED_CLOSED: ${service} (reason: ${reason})`);
|
|
452
|
+
return result;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ======================================================
|
|
456
|
+
// CIRCUIT METRICS
|
|
457
|
+
// ======================================================
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Get all circuit states.
|
|
461
|
+
*/
|
|
462
|
+
async function getCircuitsStatus(redis) {
|
|
463
|
+
const circuits = {};
|
|
464
|
+
const services = Object.values(SERVICE_TARGETS);
|
|
465
|
+
|
|
466
|
+
for (const service of services) {
|
|
467
|
+
circuits[service] = {
|
|
468
|
+
state: await getCircuitState(redis, service),
|
|
469
|
+
failures: await redis.get(getCircuitFailureKey(service)),
|
|
470
|
+
lastFailure: await redis.get(getCircuitLastFailureKey(service)),
|
|
471
|
+
halfOpenCount: await redis.get(getCircuitHalfOpenKey(service))
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return circuits;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Get circuit metrics for a specific service.
|
|
480
|
+
*/
|
|
481
|
+
async function getCircuitMetrics(redis, service) {
|
|
482
|
+
const stateKey = getCircuitStateKey(service);
|
|
483
|
+
const state = await getCircuitState(redis, service);
|
|
484
|
+
|
|
485
|
+
const failureCount = await redis.get(getCircuitFailureKey(service));
|
|
486
|
+
const lastFailure = await redis.get(getCircuitLastFailureKey(service));
|
|
487
|
+
const halfOpenCount = await redis.get(getCircuitHalfOpenKey(service));
|
|
488
|
+
|
|
489
|
+
let lastFailureTime = null;
|
|
490
|
+
let timeSinceLastFailure = null;
|
|
491
|
+
if (lastFailure) {
|
|
492
|
+
lastFailureTime = parseInt(lastFailure, 10);
|
|
493
|
+
timeSinceLastFailure = Date.now() - lastFailureTime;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
let halfOpenValue = null;
|
|
497
|
+
if (halfOpenCount) {
|
|
498
|
+
halfOpenValue = parseInt(halfOpenCount, 10);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return {
|
|
502
|
+
service,
|
|
503
|
+
state,
|
|
504
|
+
failureCount: parseInt(failureCount || '0', 10),
|
|
505
|
+
halfOpenCount: halfOpenValue,
|
|
506
|
+
lastFailureTime,
|
|
507
|
+
timeSinceLastFailure,
|
|
508
|
+
config: {
|
|
509
|
+
failureThreshold: CIRCUIT_CONFIG.failureThreshold,
|
|
510
|
+
failureWindowMs: CIRCUIT_CONFIG.failureWindowMs,
|
|
511
|
+
recoveryTimeoutMs: CIRCUIT_CONFIG.recoveryTimeoutMs
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ======================================================
|
|
517
|
+
// HELPER: Check all circuit breakers at once
|
|
518
|
+
// ======================================================
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Check if any circuit is open (overall runtime health).
|
|
522
|
+
*/
|
|
523
|
+
async function hasAnyOpenCircuit(redis) {
|
|
524
|
+
const services = Object.values(SERVICE_TARGETS);
|
|
525
|
+
const openCircuits = [];
|
|
526
|
+
|
|
527
|
+
for (const service of services) {
|
|
528
|
+
const state = await getCircuitState(redis, service);
|
|
529
|
+
if (state === CircuitState.OPEN) {
|
|
530
|
+
openCircuits.push(service);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return {
|
|
535
|
+
hasOpen: openCircuits.length > 0,
|
|
536
|
+
openCircuits
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Check if runtime is healthy (all circuits closed).
|
|
542
|
+
*/
|
|
543
|
+
async function isRuntimeHealthy(redis) {
|
|
544
|
+
const status = await getCircuitsStatus(redis);
|
|
545
|
+
return Object.values(status).every(c => c.state === CircuitState.CLOSED);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ======================================================
|
|
549
|
+
// exported
|
|
550
|
+
// ======================================================
|
|
551
|
+
|
|
552
|
+
module.exports = {
|
|
553
|
+
// Constants
|
|
554
|
+
CircuitState,
|
|
555
|
+
SERVICE_TARGETS,
|
|
556
|
+
|
|
557
|
+
// Config
|
|
558
|
+
CIRCUIT_CONFIG,
|
|
559
|
+
|
|
560
|
+
// State management
|
|
561
|
+
getCircuitStateKey,
|
|
562
|
+
getCircuitState,
|
|
563
|
+
setCircuitState,
|
|
564
|
+
isCircuitOpen,
|
|
565
|
+
isCircuitHalfOpen,
|
|
566
|
+
|
|
567
|
+
// Failure tracking
|
|
568
|
+
recordFailure,
|
|
569
|
+
recordSuccess,
|
|
570
|
+
hasRecentFailures,
|
|
571
|
+
|
|
572
|
+
// Dispatch check
|
|
573
|
+
checkDispatch,
|
|
574
|
+
checkAndIncrementTestRequest,
|
|
575
|
+
checkDispatchWithRecovery,
|
|
576
|
+
releaseHalfOpenPermit,
|
|
577
|
+
|
|
578
|
+
// Recovery
|
|
579
|
+
tryRecover,
|
|
580
|
+
forceOpen,
|
|
581
|
+
forceClose,
|
|
582
|
+
|
|
583
|
+
// Metrics
|
|
584
|
+
getCircuitsStatus,
|
|
585
|
+
getCircuitMetrics,
|
|
586
|
+
hasAnyOpenCircuit,
|
|
587
|
+
isRuntimeHealthy
|
|
588
|
+
};
|