Skip to content

Commit 9a2fc11

Browse files
committed
fix circuit breaker and request pool correctness
1 parent b604e71 commit 9a2fc11

6 files changed

Lines changed: 297 additions & 118 deletions

File tree

.changeset/fair-panthers-repair.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@3loop/transaction-decoder': patch
3+
---
4+
5+
Fix circuit breaker outcome tracking and atomic state transitions, and use a bounded request-pool outcome window for adaptive concurrency.

packages/transaction-decoder/src/abi-loader.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ export const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: A
202202
}
203203
}
204204

205-
const concurrency = Math.min(...[...concurrencyMap.values(), 50]) // Use minimum concurrency across all chains, capped at 25
205+
const concurrency = Math.min(...[...concurrencyMap.values(), 50]) // Use minimum concurrency across all chains, capped at 50
206206

207207
yield* Effect.logDebug(`Executing ${remaining.length} remaining requests with concurrency ${concurrency}`)
208208

packages/transaction-decoder/src/circuit-breaker/circuit-breaker.ts

Lines changed: 119 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -326,93 +326,146 @@ export const make = <E = unknown>(
326326
const getState = (strategyId: string): Effect.Effect<CircuitBreaker.CircuitBreakerState, never, never> =>
327327
Ref.get(states).pipe(Effect.map((map) => map.get(strategyId) ?? defaultState))
328328

329-
const updateState = (strategyId: string, newState: CircuitBreaker.CircuitBreakerState) =>
330-
Ref.update(states, (map) => new Map(map).set(strategyId, newState))
329+
// Create tripping strategy if provided, otherwise use default failure count
330+
const trippingStrategy = finalConfig.strategy
331+
? yield* finalConfig.strategy
332+
: yield* failureCount(finalConfig.maxFailures ?? 5)
331333

332-
const shouldAllowRequest = (state: CircuitBreaker.CircuitBreakerState): Effect.Effect<boolean, never, never> =>
333-
Effect.gen(function* () {
334-
const now = yield* Clock.currentTimeMillis
334+
type Admission = 'Allowed' | 'Rejected' | 'HalfOpened'
335+
336+
const tryAdmit = (strategyId: string, now: number): Effect.Effect<Admission, never, never> =>
337+
Ref.modify(states, (map): [Admission, Map<string, CircuitBreaker.CircuitBreakerState>] => {
338+
const state = map.get(strategyId) ?? defaultState
335339

336340
switch (state.state) {
337341
case 'Closed':
338-
return true
339-
case 'Open':
340-
return now - state.lastFailureTime >= Duration.toMillis(finalConfig.resetTimeout ?? Duration.seconds(60))
342+
return ['Allowed', map]
341343
case 'HalfOpen':
342-
return state.halfOpenCalls < (finalConfig.halfOpenMaxCalls ?? 3)
344+
if (state.halfOpenCalls >= (finalConfig.halfOpenMaxCalls ?? 3)) {
345+
return ['Rejected', map]
346+
}
347+
348+
return [
349+
'Allowed',
350+
new Map(map).set(strategyId, {
351+
...state,
352+
halfOpenCalls: state.halfOpenCalls + 1,
353+
}),
354+
]
355+
case 'Open':
356+
if (now - state.lastFailureTime < Duration.toMillis(finalConfig.resetTimeout ?? Duration.seconds(60))) {
357+
return ['Rejected', map]
358+
}
359+
360+
return [
361+
'HalfOpened',
362+
new Map(map).set(strategyId, {
363+
...state,
364+
state: 'HalfOpen',
365+
halfOpenCalls: 1,
366+
}),
367+
]
343368
}
344369
})
345370

346-
// Create tripping strategy if provided, otherwise use default failure count
347-
const trippingStrategy = finalConfig.strategy
348-
? yield* finalConfig.strategy
349-
: yield* failureCount(finalConfig.maxFailures ?? 5)
350-
351-
const onSuccess = (strategyId: string, state: CircuitBreaker.CircuitBreakerState) =>
371+
const onSuccess = (strategyId: string) =>
352372
Effect.gen(function* () {
353-
if (state.state === 'HalfOpen') {
373+
yield* trippingStrategy.shouldTrip(true)
374+
375+
const closedCircuit = yield* Ref.modify(
376+
states,
377+
(map): [boolean, Map<string, CircuitBreaker.CircuitBreakerState>] => {
378+
const state = map.get(strategyId) ?? defaultState
379+
380+
if (state.state === 'HalfOpen') {
381+
return [
382+
true,
383+
new Map(map).set(strategyId, {
384+
...defaultState,
385+
state: 'Closed',
386+
}),
387+
]
388+
}
389+
390+
if (state.failures > 0) {
391+
return [
392+
false,
393+
new Map(map).set(strategyId, {
394+
...state,
395+
failures: 0,
396+
}),
397+
]
398+
}
399+
400+
return [false, map]
401+
},
402+
)
403+
404+
if (closedCircuit) {
354405
// Reset to closed state after successful half-open calls
355406
yield* trippingStrategy.onReset
356-
yield* notifyStateChange(state.state, 'Closed')
357-
yield* updateState(strategyId, {
358-
...defaultState,
359-
state: 'Closed',
360-
})
407+
yield* notifyStateChange('HalfOpen', 'Closed')
361408
yield* withMetrics((metrics) =>
362409
Metric.increment(metrics.stateChanges).pipe(
363410
Effect.zipRight(Metric.set(metrics.state, stateToCode('Closed'))),
364411
),
365412
)
366-
} else if (state.failures > 0) {
367-
// Reset failures on success
368-
yield* updateState(strategyId, {
369-
...state,
370-
failures: 0,
371-
})
372413
}
373414
})
374415

375-
const onFailure = (strategyId: string, state: CircuitBreaker.CircuitBreakerState) =>
416+
const onFailure = (strategyId: string) =>
376417
Effect.gen(function* () {
377418
const now = yield* Clock.currentTimeMillis
378419
const shouldTrip = yield* trippingStrategy.shouldTrip(false)
379420

380-
if (state.state === 'HalfOpen') {
381-
// Failed during half-open, go back to open
382-
yield* notifyStateChange(state.state, 'Open')
383-
yield* updateState(strategyId, {
384-
...state,
385-
state: 'Open',
386-
failures: state.failures + 1,
387-
lastFailureTime: now,
388-
halfOpenCalls: 0,
389-
})
421+
const openedFrom = yield* Ref.modify(
422+
states,
423+
(map): [CircuitBreaker.State | undefined, Map<string, CircuitBreaker.CircuitBreakerState>] => {
424+
const state = map.get(strategyId) ?? defaultState
425+
426+
if (state.state === 'HalfOpen') {
427+
return [
428+
'HalfOpen',
429+
new Map(map).set(strategyId, {
430+
...state,
431+
state: 'Open',
432+
failures: state.failures + 1,
433+
lastFailureTime: now,
434+
halfOpenCalls: 0,
435+
}),
436+
]
437+
}
438+
439+
if (shouldTrip && state.state === 'Closed') {
440+
return [
441+
'Closed',
442+
new Map(map).set(strategyId, {
443+
...state,
444+
state: 'Open',
445+
failures: state.failures + 1,
446+
lastFailureTime: now,
447+
}),
448+
]
449+
}
450+
451+
return [
452+
undefined,
453+
new Map(map).set(strategyId, {
454+
...state,
455+
failures: state.failures + 1,
456+
lastFailureTime: now,
457+
}),
458+
]
459+
},
460+
)
461+
462+
if (openedFrom !== undefined) {
463+
yield* notifyStateChange(openedFrom, 'Open')
390464
yield* withMetrics((metrics) =>
391465
Metric.increment(metrics.stateChanges).pipe(
392466
Effect.zipRight(Metric.set(metrics.state, stateToCode('Open'))),
393467
),
394468
)
395-
} else if (shouldTrip && state.state === 'Closed') {
396-
// Threshold reached, open the circuit
397-
yield* notifyStateChange(state.state, 'Open')
398-
yield* updateState(strategyId, {
399-
...state,
400-
state: 'Open',
401-
failures: state.failures + 1,
402-
lastFailureTime: now,
403-
})
404-
yield* withMetrics((metrics) =>
405-
Metric.increment(metrics.stateChanges).pipe(
406-
Effect.zipRight(Metric.set(metrics.state, stateToCode('Open'))),
407-
),
408-
)
409-
} else {
410-
// Increment failures but keep closed
411-
yield* updateState(strategyId, {
412-
...state,
413-
failures: state.failures + 1,
414-
lastFailureTime: now,
415-
})
416469
}
417470
})
418471

@@ -421,53 +474,41 @@ export const make = <E = unknown>(
421474
effect: Effect.Effect<A, E2, R>,
422475
): Effect.Effect<A, E2 | CircuitBreaker.OpenError, R> =>
423476
Effect.gen(function* () {
424-
const state = yield* getState(strategyId)
425-
const shouldAllow = yield* shouldAllowRequest(state)
477+
const now = yield* Clock.currentTimeMillis
478+
const admission = yield* tryAdmit(strategyId, now)
426479

427-
if (!shouldAllow) {
480+
if (admission === 'Rejected') {
428481
yield* withMetrics((metrics) => Metric.increment(metrics.rejectedCalls))
429482
return yield* Effect.fail(OpenError(strategyId))
430483
}
431484

432-
// Transition to half-open if we're allowing a request from open state
433-
if (state.state === 'Open') {
434-
yield* notifyStateChange(state.state, 'HalfOpen')
435-
yield* updateState(strategyId, {
436-
...state,
437-
state: 'HalfOpen',
438-
halfOpenCalls: 1,
439-
})
485+
if (admission === 'HalfOpened') {
486+
yield* notifyStateChange('Open', 'HalfOpen')
440487
yield* withMetrics((metrics) =>
441488
Metric.increment(metrics.stateChanges).pipe(
442489
Effect.zipRight(Metric.set(metrics.state, stateToCode('HalfOpen'))),
443490
),
444491
)
445-
} else if (state.state === 'HalfOpen') {
446-
yield* updateState(strategyId, {
447-
...state,
448-
halfOpenCalls: state.halfOpenCalls + 1,
449-
})
450492
}
451493

452494
const result = yield* Effect.either(effect)
453495

454496
if (Either.isRight(result)) {
455-
yield* onSuccess(strategyId, yield* getState(strategyId))
497+
yield* onSuccess(strategyId)
456498
yield* withMetrics((metrics) => Metric.increment(metrics.successfulCalls))
457499
return result.right
458500
} else {
459501
// Check if this failure should be counted based on the isFailure predicate
460502
const shouldCountFailure = finalConfig.isFailure ? finalConfig.isFailure(result.left) : true
461503

462504
if (shouldCountFailure) {
463-
yield* onFailure(strategyId, yield* getState(strategyId))
505+
yield* onFailure(strategyId)
464506
yield* withMetrics((metrics) => Metric.increment(metrics.failedCalls))
465507
}
466508

467509
return yield* Effect.fail(result.left)
468510
}
469511
})
470-
471512
const currentState = (strategyId: string): Effect.Effect<CircuitBreaker.State, never, never> =>
472513
getState(strategyId).pipe(Effect.map((state) => state.state))
473514

0 commit comments

Comments
 (0)