redeye-breaker 0.4.0 → 0.5.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/README.md +58 -8
- package/dist/circuit-breaker.d.ts +126 -3
- package/dist/circuit-breaker.d.ts.map +1 -1
- package/dist/circuit-breaker.js +292 -6
- package/dist/circuit-breaker.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/store.d.ts +64 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/stores/redis-store.d.ts +41 -8
- package/dist/stores/redis-store.d.ts.map +1 -1
- package/dist/stores/redis-store.js +333 -84
- package/dist/stores/redis-store.js.map +1 -1
- package/dist/types.d.ts +24 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -104,7 +104,7 @@ Every process pointed at the same Redis instance (and using the same operation n
|
|
|
104
104
|
|
|
105
105
|
### Bring your own store
|
|
106
106
|
|
|
107
|
-
`RedisStore` implements a `Store` interface with two required methods and
|
|
107
|
+
`RedisStore` implements a `Store` interface with two required methods and seven *optional* ones:
|
|
108
108
|
|
|
109
109
|
```ts
|
|
110
110
|
export interface Store {
|
|
@@ -115,19 +115,65 @@ export interface Store {
|
|
|
115
115
|
// Optional — implement these for the reliability guarantees below.
|
|
116
116
|
// Without them, redeye falls back to best-effort semantics and logs a
|
|
117
117
|
// one-time warning telling you exactly what's degraded.
|
|
118
|
-
recordFailureAtomic?(key: string, opts: { ttlSeconds: number; failureThreshold: number; now: number }): Promise<CircuitBreakerState>;
|
|
119
|
-
recordOutcomeAtomic?(key: string, opts: { success: boolean; decay: number; minimumCalls: number; errorRateThreshold: number; ttlSeconds: number; now: number }): Promise<CircuitBreakerState & { openedNow: boolean }>;
|
|
118
|
+
recordFailureAtomic?(key: string, opts: { ttlSeconds: number; failureThreshold: number; now: number; operation: string }): Promise<CircuitBreakerState>;
|
|
119
|
+
recordOutcomeAtomic?(key: string, opts: { success: boolean; decay: number; minimumCalls: number; errorRateThreshold: number; ttlSeconds: number; now: number; operation: string }): Promise<CircuitBreakerState & { openedNow: boolean }>;
|
|
120
120
|
claimTrial?(key: string, ttlSeconds: number): Promise<string | null>;
|
|
121
121
|
releaseTrial?(key: string, token: string): Promise<void>;
|
|
122
|
+
|
|
123
|
+
// Optional — only needed for the closed-state local cache (`localCache`, see below).
|
|
124
|
+
closeAtomic?(key: string, eventsKey: string, opts: { ttlSeconds: number; operation: string }): Promise<CircuitBreakerState>;
|
|
125
|
+
reopenTrialFailureAtomic?(key: string, eventsKey: string, opts: { ttlSeconds: number; failureThreshold: number; now: number; operation: string }): Promise<CircuitBreakerState>;
|
|
126
|
+
subscribeTransitions?(eventsKey: string, handler: (event: TransitionEvent | null) => void): Promise<() => void>;
|
|
122
127
|
}
|
|
123
128
|
```
|
|
124
129
|
|
|
125
|
-
- `recordFailureAtomic` (`strategy: 'consecutive'`) should increment the failure count and decide `isOpen` in one atomic round trip (a Lua script in Redis, a conditional update in DynamoDB, etc.).
|
|
126
|
-
- `recordOutcomeAtomic` (`strategy: 'errorRate'`) should fold one call's outcome into the EWMA rate and decide `isOpen` in one atomic round trip — called on every closed-phase call, not just failures.
|
|
130
|
+
- `recordFailureAtomic` (`strategy: 'consecutive'`) should increment the failure count and decide `isOpen` in one atomic round trip (a Lua script in Redis, a conditional update in DynamoDB, etc.). Should also bump a monotonic `version` counter and publish a transition event to `eventsKey` (see below) when — and only when — this call flips `isOpen`; a plain sub-threshold increment isn't a transition and shouldn't publish one.
|
|
131
|
+
- `recordOutcomeAtomic` (`strategy: 'errorRate'`) should fold one call's outcome into the EWMA rate and decide `isOpen` in one atomic round trip — called on every closed-phase call, not just failures. Same `version`/publish rule as above, keyed off an `isOpen` flip.
|
|
127
132
|
- `claimTrial` should be a conditional "create if absent" write (`SET key token NX EX ttl` in Redis, with `token` a fresh unique value per call, e.g. a UUID) — it's what makes half-open recovery exclusive to one caller instead of a free-for-all. Return the token you wrote if this call won the claim, or `null` if the key was already taken.
|
|
128
133
|
- `releaseTrial` should be a compare-and-delete (`if GET key == token then DEL key` in Redis, a conditional delete elsewhere), not an unconditional delete. This is what stops a trial that outran its TTL (see [item 11](#whats-still-a-fundamental-tradeoff-not-a-bug)) from deleting a *different* instance's newer claim on the same key when it finally gets around to releasing its own now-stale one. If omitted (or if a call has no confirmed ownership token to release at all — e.g. `claimTrial` itself errored), redeye logs a one-time warning and leaves the claim for its TTL to expire naturally, rather than risk an unconditional delete that could destroy a claim it never confirmed was its own.
|
|
134
|
+
- `closeAtomic` should close `key`, bumping `version` and publishing a transition, **but only if there was anything to clear** (it was open, or had accumulated sub-threshold failures) — a true no-op, no write at all, when the state was already clean. This is what lets the healthy-path write-skip (see [limitation item 1](#whats-still-a-fundamental-tradeoff-not-a-bug)) happen atomically server-side instead of relying on the caller's own possibly-stale view.
|
|
135
|
+
- `reopenTrialFailureAtomic` should reopen `key` after a failed half-open trial — `isOpen: true`, `openCount + 1`, `failures` raised to at least `failureThreshold`, `lastFailure: now`, `version + 1` — and always publish a transition (a failed trial is always a real transition, unlike `closeAtomic`).
|
|
136
|
+
- `subscribeTransitions` should subscribe to a stream of transition events at `eventsKey` — **one shared stream per store/prefix, not one per operation** — and invoke `handler` with each decoded event, or with `null` whenever the underlying subscription drops or errors (callers must then distrust every locally cached entry until it's re-verified by a real read; implementations should retry their own subscription with backoff, not expect the caller to re-subscribe). Returns an unsubscribe function.
|
|
137
|
+
|
|
138
|
+
Implement the two required methods against Memcached, DynamoDB, your own cache wrapper, etc., and you have a working (best-effort) distributed breaker. Add `recordFailureAtomic`/`recordOutcomeAtomic`/`claimTrial`/`releaseTrial` when that store supports a real atomic increment and a real conditional write, and you get the full reliability guarantees. Add `closeAtomic`/`reopenTrialFailureAtomic`/`subscribeTransitions` on top of that if you also want the [closed-state local cache](#local-caching-hybrid-mode) — every capability is independently optional, so skip any subset that doesn't fit your store.
|
|
139
|
+
|
|
140
|
+
## Local caching (hybrid mode)
|
|
141
|
+
|
|
142
|
+
Everything up to this point reads the store on every gate check (a genuine read every closed-path call; a cached, 0-read rejection once open — see [limitation item 1](#whats-still-a-fundamental-tradeoff-not-a-bug)). `localCache` goes one step further, opt-in, only for `RedisStore` (or any store implementing `subscribeTransitions`): it caches the **closed** state locally too, invalidated push-style instead of polling, so a healthy call can skip the store read entirely, not just the write.
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
const breaker = new CircuitBreaker({
|
|
146
|
+
failureThreshold: 5,
|
|
147
|
+
resetTimeout: 60_000,
|
|
148
|
+
store,
|
|
149
|
+
localCache: { staleToleranceMs: 100 }, // opt-in; default staleToleranceMs is 100 once the object is present
|
|
150
|
+
});
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**What's cached, and how it's invalidated.** Per `operation`, redeye keeps `{ state, version, fetchedAt, trusted }` in memory. A gate check allows immediately, with zero store reads, when the entry is `trusted`, closed (`!state.isOpen`), and fresher than `staleToleranceMs`. Three independent mechanisms keep this from going stale silently:
|
|
129
154
|
|
|
130
|
-
|
|
155
|
+
- **Push invalidation.** `subscribeTransitions` delivers a decoded event the instant any instance's write flips `isOpen` or clears real accumulated state (never on a plain sub-threshold counter update — those aren't transitions and don't invalidate the cache, by design, since they can't affect the *closed* fast path this cache serves). A higher `version` than what's cached overwrites the entry immediately.
|
|
156
|
+
- **Fallback poll.** The existing local-mode monitor interval doubles as a missed-message safety net in distributed mode: any cached entry older than 5s gets one authoritative `get` and is reconciled by `version`. In steady state (no missed messages) this is the *only* background traffic the cache generates — one `GET` per stale operation per sweep, not per call.
|
|
157
|
+
- **Your own writes are free.** The state `recordFailureAtomic`/`recordOutcomeAtomic`/`closeAtomic`/`reopenTrialFailureAtomic` hand back already updates this instance's own cache — the instance that causes a transition never has a stale window on it, regardless of stream or poll timing.
|
|
158
|
+
|
|
159
|
+
**The fail-open window, as a formula, not an adjective.** This is the one place in redeye that's biased toward availability over freshness on the *closed* side (everywhere else — the open-state cache, `failOpenOnStoreError: false`, etc. — is fail-closed or symmetric by default). If the breaker trips elsewhere in the fleet, a call landing on this instance's still-fresh cached-closed entry can pass through before that instance learns about it. The bound is exactly:
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
worst case: staleToleranceMs
|
|
163
|
+
typical case: one stream delivery (usually much shorter than staleToleranceMs)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`staleToleranceMs` is the hard ceiling, full stop, not one term in a larger sum — the cache stops trusting itself the instant it elapses, regardless of whether the transition event ever arrives (that's what makes the fallback poll a correctness backstop and not just a nice-to-have: a dropped or delayed event can never push the window past this bound, only fail to shorten it). Delivery latency only ever makes the *actual* window tighter than the ceiling, never looser — in the common case it's one Redis stream round trip, resolving the window well before `staleToleranceMs` would have on its own. Set `staleToleranceMs` to whatever window you're comfortable a downstream outage could go un-noticed on this one path — `100`ms (the default) is a reasonable starting point for most services; push it lower if even a hundred milliseconds of continued traffic against a freshly-tripped dependency is unacceptable, at the cost of the cache being useful for a shorter fraction of the healthy path.
|
|
167
|
+
|
|
168
|
+
**Compare this to the open-state cache deliberately.** `openCacheRefreshMs` never risks an extra call through — staleness there can only make a rejection *late*, not wrong, because it always falls through to a real read right at the real eligibility instant (see limitation item 1). `localCache`'s closed-side cache is the opposite shape: staleness there can make an *allow* wrong for a bounded window, in exchange for the healthy path's read too. That's the real tradeoff you're opting into, not a bug to route around — the same one Envoy's outlier detection makes by default (cache state locally with a bounded TTL, accept staleness, skip the round trip), just off by default here and explicit about the bound when you turn it on.
|
|
169
|
+
|
|
170
|
+
**The version/absence rule, and mixed-fleet rolling deploys.** `CircuitBreakerState.version` is a monotonic counter that increments only on a real transition — its *absence* (not `0`) means "written by a library version that predates `localCache`, provenance unknown." A cache entry built from a version-less read is marked `trusted: false` and never used for the fast path; the very next call for that operation performs a real read instead, forever, until a version-bearing write appears. This is what makes a rolling deploy safe: instances running an old version keep working exactly as before (they don't publish transitions, and their writes lack `version`), and new instances simply never trust a versionless observation rather than guessing. Nothing about this requires deploying in any particular order.
|
|
171
|
+
|
|
172
|
+
**Strategy asymmetry, stated plainly.** `consecutive` is the strategy this cache is built for: combined with the healthy-path write skip, a run of successes against a warm cache is a genuine 0-store-op healthy path. `errorRate` cannot get the same benefit — `recordOutcomeAtomic` must fold *every* outcome (success or failure) into the EWMA to keep the rate meaningful, so it floors at 1 write per call no matter what's cached. `localCache` still saves the *read* for `errorRate` the same way it does for `consecutive`; it just can't save the write, structurally, not as an oversight.
|
|
173
|
+
|
|
174
|
+
Read "0-store-op" as *decoupled from request volume*, not as a literal constant: under sustained load it's at most one read per `staleToleranceMs` window (a real read resets that window's clock), plus one poll per idle operation roughly every 5s if nothing else refreshes it sooner. A short benchmark run's literal op count is partly an artifact of the run fitting inside a handful of those windows — see [BENCHMARK.md](BENCHMARK.md#reading-the-numbers).
|
|
175
|
+
|
|
176
|
+
**Requires `subscribeTransitions`.** Without it (a custom `Store` that doesn't implement the capability), `localCache` is a no-op: one-time warning, and behavior is identical to not setting it at all. `RedisStore` implements it via a dedicated (`duplicate()`d) blocking connection running `XREAD BLOCK` against the shared `circuit_breaker:events` stream, reconnecting with backoff on its own if that connection drops — see the `Store` interface docs above for the reconnect contract.
|
|
131
177
|
|
|
132
178
|
## API
|
|
133
179
|
|
|
@@ -167,6 +213,7 @@ redeye can't stop you from doing this, but it can flag it: `execute`/`recordFail
|
|
|
167
213
|
| `logger` | no-op | `{ warn(msg), log(msg) }` — plug in your own logger |
|
|
168
214
|
| `maxOperations` | none | Logs a one-time warning once the breaker has seen more than this many distinct `operation` names — see the cardinality note above. Unset: no limit, no warning. |
|
|
169
215
|
| `openCacheRefreshMs` | `2000` | Distributed mode only: while `operation` is known-open, reject locally without a store read, re-verifying against the store at most once per this many ms (to catch an early close by another instance's trial, or a manual `reset()`). Never delays trial eligibility — see [limitation item 1](#whats-still-a-fundamental-tradeoff-not-a-bug). `0` disables the cache (a store read on every call, the pre-cache behavior). |
|
|
216
|
+
| `localCache` | none (disabled) | Distributed mode, opt-in, requires a store implementing `subscribeTransitions`: `{ staleToleranceMs?: number }` (default `100` once the object is present) — caches CLOSED state locally, invalidated push-style, so a healthy call can skip the store read too, not just the write. See [Local caching (hybrid mode)](#local-caching-hybrid-mode) for the fail-open window this trades for that. |
|
|
170
217
|
|
|
171
218
|
### Store unavailability: fail-open vs fail-closed
|
|
172
219
|
|
|
@@ -204,7 +251,7 @@ If your store *doesn't* implement the atomic method your chosen strategy needs (
|
|
|
204
251
|
|
|
205
252
|
### Split-brain: what's prevented, and what isn't
|
|
206
253
|
|
|
207
|
-
There's no split-brain among your app instances in the classic sense, because instances never hold independent authoritative state to
|
|
254
|
+
There's no split-brain among your app instances in the classic sense, because instances never hold independent authoritative state *to write from* — every state-changing decision goes through the store, and the two local caches (`openCacheRefreshMs`, `localCache`) exist only to skip *reading* it before an already-known-safe decision, never to substitute for it. The store is the only "brain"; instances are just readers/writers of it, some of the time from a recent, bounded-staleness memory of what they last read instead of asking again. Two mechanisms enforce agreement on the common path:
|
|
208
255
|
|
|
209
256
|
- **Atomic writes.** `recordFailureAtomic`/`recordOutcomeAtomic` run as one Lua script — GET, decode, increment, decide, SET — inside Redis's single-threaded execution, so two instances failing at the same instant can't both read the same count and both write the same increment.
|
|
210
257
|
- **Exclusive trial claim.** `claimTrial` is a `SET ... NX`, which Redis resolves atomically, so exactly one instance ever runs the half-open recovery probe — never two instances simultaneously deciding they're the one testing recovery.
|
|
@@ -221,7 +268,8 @@ So the design doesn't eliminate split-brain so much as route around it: it pushe
|
|
|
221
268
|
|
|
222
269
|
These are true of any distributed circuit breaker, rate limiter, or lock built on a shared store — not gaps specific to redeye:
|
|
223
270
|
|
|
224
|
-
1. **Distributed mode
|
|
271
|
+
1. **Distributed mode's per-call store cost is opt-in-able down to a bound you choose, not fixed — but "zero" always means "zero, at a stated staleness price," never "zero, for free."** By default, every `execute()` call does a gate read (`store.get`) before deciding whether to proceed; what happens after differs by strategy — `errorRate` writes (`recordOutcomeAtomic`) on every closed-phase call, success or failure, since the rate needs both to be meaningful (this never goes away, at any cache setting — see below), while `consecutive` only writes when the gate's own read observed accumulated state to clear (an open breaker, or `failures > 0`), making a run of healthy successes 1 read and 0 writes per call by default, not 1-and-1. **Once the breaker is open, rejections stop costing a read at all**, unconditionally, no opt-in required: the gate caches the open state locally (`lastFailure`, `openCount`, and the same jittered `expiresAt` the eligibility check itself uses) and rejects straight from that cache, re-verifying against the store only once per `openCacheRefreshMs` (default 2000ms), and always falling through to a real read right at `expiresAt` — so this cache can delay a rejection's freshness but can never delay a trial's eligibility, and never risks letting an extra call through. That's the fail-*closed*-biased half of the story: staleness there can only make things more conservative.
|
|
272
|
+
The closed, healthy path is the other half, and it's where a real tradeoff lives: **`localCache` (opt-in) is redeye's answer to exactly the caching-with-bounded-staleness tradeoff Envoy's outlier detection makes by default** — cache state locally, accept a bounded staleness window, skip the round trip. Off by default (redeye chooses per-call freshness there until you say otherwise), and explicit about the bound when you turn it on: worst case `staleToleranceMs`, full stop — the cache stops trusting itself at that bound regardless of whether a transition event ever arrives; typical case one stream delivery, usually well under it — a formula, not an adjective — see [Local caching (hybrid mode)](#local-caching-hybrid-mode) for exactly what that means and what it doesn't cover (`errorRate`'s write floor, in particular, which `localCache` cannot remove — only `consecutive` reaches a genuine 0-store-op healthy path). A resolving half-open trial always adds a round trip to claim it and another to release its claim, at any cache setting, on either strategy — that path is never cacheable, and rightly so. Local mode has none of this cost at all — see [Local mode vs. distributed mode](#local-mode-vs-distributed-mode-which-do-you-need) if you're not sure you need shared state to begin with.
|
|
225
273
|
2. **Correctness depends on trusting your store.** If Redis evicts a breaker key early under memory pressure (e.g. `maxmemory-policy allkeys-lru` with `circuit_breaker:*` competing for space with everything else), the breaker can reset earlier than intended. Mitigate by giving circuit-breaker keys their own keyspace/DB with a policy that doesn't evict them, or by monitoring `onStoreError` and Redis memory pressure directly. This is not fixable in-library — it's an operational configuration matter.
|
|
226
274
|
3. **Distributed timing is sensitive to clock skew.** Backoff and jitter windows are computed by comparing each instance's local `Date.now()` against a `lastFailure` timestamp written by whichever instance recorded it. With reasonably synchronized clocks (standard NTP) this is a non-issue; on a fleet with significant clock drift, instances can disagree by that drift amount about exactly when a trial becomes eligible. This affects *timing* only — the half-open claim mechanism (`claimTrial`) still guarantees exactly one trial runs regardless of skew, so skew cannot cause a thundering herd, only a slightly early or late trial attempt.
|
|
227
275
|
4. **`recordFailure`/`recordSuccess`/`canExecuteAsync` don't participate in trial claiming.** Only `execute()` claims and releases the half-open trial slot. If you build your own call flow around the manual API instead of `execute()`, you lose the single-trial guarantee and get the old "everyone retries once elapsed" behavior for that flow. Prefer `execute()`.
|
|
@@ -252,6 +300,8 @@ docker compose down
|
|
|
252
300
|
|
|
253
301
|
It connects to `REDIS_URL` (default `redis://localhost:6379`).
|
|
254
302
|
|
|
303
|
+
`npm run bench` runs `bench/index.js` against a real Redis, comparing total store round trips and p50/p99 latency across three configurations: no caching, today's defaults (`openCacheRefreshMs`), and `localCache` on top. Same `REDIS_URL`; start Redis first the same way as the integration suite. See [BENCHMARK.md](BENCHMARK.md) for a full run's results and how to read them.
|
|
304
|
+
|
|
255
305
|
## Contributing
|
|
256
306
|
|
|
257
307
|
See [CONTRIBUTING.md](CONTRIBUTING.md). Changes are tracked in [CHANGELOG.md](CHANGELOG.md).
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { Store } from './store';
|
|
2
|
-
import { CircuitBreakerState, CircuitMetrics } from './types';
|
|
2
|
+
import { CircuitBreakerState, CircuitMetrics, TransitionEvent } from './types';
|
|
3
3
|
export type CircuitState = 'closed' | 'open' | 'half-open';
|
|
4
4
|
export interface BreakerLogger {
|
|
5
5
|
warn(message: string): void;
|
|
6
6
|
log(message: string): void;
|
|
7
7
|
}
|
|
8
|
-
export type { CircuitBreakerState, CircuitMetrics };
|
|
8
|
+
export type { CircuitBreakerState, CircuitMetrics, TransitionEvent };
|
|
9
9
|
/**
|
|
10
10
|
* Thrown in distributed mode when the backing store is unreachable and
|
|
11
11
|
* `failOpenOnStoreError` is `false`. Distinguishable from a downstream
|
|
@@ -122,6 +122,34 @@ export interface CircuitBreakerOptions {
|
|
|
122
122
|
* Default: 2000.
|
|
123
123
|
*/
|
|
124
124
|
openCacheRefreshMs?: number;
|
|
125
|
+
/**
|
|
126
|
+
* Distributed mode, opt-in. Serves gate decisions for CLOSED state from a
|
|
127
|
+
* local cache while it's fresher than `staleToleranceMs`, invalidated
|
|
128
|
+
* push-style via the store's transition stream (`Store.subscribeTransitions`)
|
|
129
|
+
* instead of polling — so a healthy call can skip the store read entirely,
|
|
130
|
+
* not just the write Release 1 already skips.
|
|
131
|
+
*
|
|
132
|
+
* This only ever short-circuits an *allow*: a cache entry observed open
|
|
133
|
+
* always defers to the open-state cache (`openCacheRefreshMs`) and a real
|
|
134
|
+
* read, which remains authoritative for all rejection handling. Requires
|
|
135
|
+
* a store implementing `subscribeTransitions`; without one, this option
|
|
136
|
+
* is a no-op (one-time warning) and behavior is unchanged from not
|
|
137
|
+
* setting it at all.
|
|
138
|
+
*
|
|
139
|
+
* The worst case is a bounded fail-open window of exactly
|
|
140
|
+
* `staleToleranceMs`, full stop: if the breaker trips elsewhere in the
|
|
141
|
+
* fleet, a call landing on a cached entry can be let through until that
|
|
142
|
+
* entry ages out, since it stops trusting itself at `staleToleranceMs`
|
|
143
|
+
* regardless of whether the transition event ever arrives. Delivery
|
|
144
|
+
* latency only ever makes the *actual* window tighter than that ceiling
|
|
145
|
+
* (typically one stream round trip, well under `staleToleranceMs`), never
|
|
146
|
+
* looser. See the README's local caching section for the full
|
|
147
|
+
* fail-open/fail-closed asymmetry this creates versus the
|
|
148
|
+
* (fail-closed-biased) open-state cache.
|
|
149
|
+
*/
|
|
150
|
+
localCache?: {
|
|
151
|
+
staleToleranceMs?: number;
|
|
152
|
+
};
|
|
125
153
|
}
|
|
126
154
|
/**
|
|
127
155
|
* A circuit breaker with an optional distributed mode.
|
|
@@ -159,6 +187,22 @@ export declare class CircuitBreaker {
|
|
|
159
187
|
* an early close by another instance or a manual `reset()`.
|
|
160
188
|
*/
|
|
161
189
|
private readonly cachedOpen;
|
|
190
|
+
/** Fixed, unprefixed key for the shared transition-event stream — one per store/prefix, not one per operation. `RedisStore` applies its own `keyPrefix` the same way it does for every other key this class hands it. */
|
|
191
|
+
private readonly eventsKey;
|
|
192
|
+
/**
|
|
193
|
+
* Distributed mode only, `localCache` opt-in: a local, push-invalidated
|
|
194
|
+
* record of the last known state for `operation`, used to serve gate
|
|
195
|
+
* decisions for CLOSED state with zero store reads while fresh and
|
|
196
|
+
* trusted. `trusted: false` means the last write we saw for this entry
|
|
197
|
+
* had no `version` (unknown provenance — an older library version wrote
|
|
198
|
+
* it) or the transition subscription itself dropped; either way, the
|
|
199
|
+
* gate must not use it and instead performs a real read next time,
|
|
200
|
+
* which re-establishes trust.
|
|
201
|
+
*/
|
|
202
|
+
private readonly closedCache;
|
|
203
|
+
/** True once `subscribeTransitions` has actually succeeded; false (the closed cache is never consulted) if `localCache` wasn't set, the store lacks the capability, or the initial subscribe attempt failed. */
|
|
204
|
+
private localCacheActive;
|
|
205
|
+
private unsubscribeTransitions?;
|
|
162
206
|
private readonly failures;
|
|
163
207
|
private readonly lastFailureTime;
|
|
164
208
|
private readonly openOperations;
|
|
@@ -169,7 +213,63 @@ export declare class CircuitBreaker {
|
|
|
169
213
|
private readonly sampleCounts;
|
|
170
214
|
private readonly monitorHandle;
|
|
171
215
|
constructor(options?: CircuitBreakerOptions);
|
|
172
|
-
/**
|
|
216
|
+
/**
|
|
217
|
+
* Attempts to establish the closed-state cache's push invalidation via
|
|
218
|
+
* `Store.subscribeTransitions`. Best-effort and one-shot: if the store
|
|
219
|
+
* doesn't implement it, or the initial subscribe attempt itself throws,
|
|
220
|
+
* `localCache` stays disabled for this breaker's lifetime (a one-time
|
|
221
|
+
* warning either way) — reconnects *after* a successful subscription are
|
|
222
|
+
* the store implementation's own responsibility (see the interface doc).
|
|
223
|
+
*/
|
|
224
|
+
private setupLocalCache;
|
|
225
|
+
/**
|
|
226
|
+
* `null` means the subscription's connection dropped or errored: every
|
|
227
|
+
* cached entry may now be missing events, so all of them are marked
|
|
228
|
+
* untrusted until their next authoritative (real-read) refresh — the
|
|
229
|
+
* fallback poll in `monitor()` is what eventually forces that refresh if
|
|
230
|
+
* no call happens to trigger one first.
|
|
231
|
+
*
|
|
232
|
+
* A version at or below what's cached is deliberately *not* dropped as a
|
|
233
|
+
* plain stale/duplicate delivery: a key's version sequence is only
|
|
234
|
+
* monotonic within that key's lifetime, and keys legitimately die (the
|
|
235
|
+
* state TTL expiring after a long idle period, or another instance's
|
|
236
|
+
* `reset()`, which DELs with no published event at all). A reborn key
|
|
237
|
+
* restarts at a low version, which is indistinguishable from a stale
|
|
238
|
+
* delivery by version alone — so instead of guessing, this triggers a
|
|
239
|
+
* real read to resolve it. Reads always win (see `updateClosedCache`),
|
|
240
|
+
* so this self-corrects regardless of which case it actually was.
|
|
241
|
+
*/
|
|
242
|
+
private handleTransitionEvent;
|
|
243
|
+
/**
|
|
244
|
+
* Refreshes the closed-state cache entry for `operation` with a fresh
|
|
245
|
+
* authoritative read, whether the write came from this instance (already
|
|
246
|
+
* has the resulting state in hand) or a real gate read. A no-op if
|
|
247
|
+
* `localCache` isn't active. Always overwrites *by version* — comparing a
|
|
248
|
+
* read's version against a cached one can't distinguish "this read is
|
|
249
|
+
* stale" from "the key was reborn with a fresh, lower version," so it
|
|
250
|
+
* doesn't try to (see `handleTransitionEvent`) — but an authoritative
|
|
251
|
+
* read can still be stale *in time*: it can have been in flight when a
|
|
252
|
+
* newer push event already installed a fresher entry, and resolve after.
|
|
253
|
+
* `readStartedAt` (captured by the caller before issuing whatever async
|
|
254
|
+
* call produced `state`) guards exactly that: if the cached entry is
|
|
255
|
+
* strictly newer than that, something demonstrably landed while this
|
|
256
|
+
* read was in flight, and this write is dropped instead of regressing
|
|
257
|
+
* the cache in time. An entry marked untrusted by a dropped subscription
|
|
258
|
+
* keeps its old `fetchedAt`, so a read started after the drop still
|
|
259
|
+
* overwrites it and re-establishes trust — this guard only ever blocks a
|
|
260
|
+
* write that's genuinely older than what's already cached.
|
|
261
|
+
*
|
|
262
|
+
* Strictly-greater-than, not greater-or-equal: `Date.now()` is
|
|
263
|
+
* millisecond-resolution, and a reconcile triggered synchronously off
|
|
264
|
+
* the back of another update to the same operation (e.g. a resolved
|
|
265
|
+
* event's reconcile read, or two updates from the same fast test/request
|
|
266
|
+
* path) can legitimately share a millisecond with `existing.fetchedAt`
|
|
267
|
+
* without either being stale relative to the other — treating a tie as
|
|
268
|
+
* "blocked" would reject correct, newer writes as often as it catches
|
|
269
|
+
* the race it's meant to guard against.
|
|
270
|
+
*/
|
|
271
|
+
private updateClosedCache;
|
|
272
|
+
/** Stops the local-mode monitor interval and any transition-event subscription. Call this when the breaker is no longer needed. */
|
|
173
273
|
destroy(): void;
|
|
174
274
|
execute<T>(operation: string, fn: () => Promise<T>): Promise<T>;
|
|
175
275
|
/**
|
|
@@ -240,6 +340,15 @@ export declare class CircuitBreaker {
|
|
|
240
340
|
private key;
|
|
241
341
|
private trialKey;
|
|
242
342
|
private stateTtlSeconds;
|
|
343
|
+
/**
|
|
344
|
+
* Same generous, non-load-bearing safety-net TTL as `stateTtlSeconds`, for
|
|
345
|
+
* the one call site (`reopenTrialFailureAtomic`) that must pick a TTL
|
|
346
|
+
* *before* knowing the resulting `openCount` the atomic script itself
|
|
347
|
+
* computes server-side. Sized off `maxResetTimeout` — the ceiling
|
|
348
|
+
* `backoffCapped` can ever reach at any `openCount` — so it's always at
|
|
349
|
+
* least as generous as the true per-`openCount` value would be.
|
|
350
|
+
*/
|
|
351
|
+
private maxStateTtlSeconds;
|
|
243
352
|
private trialTtlSeconds;
|
|
244
353
|
private reportStoreError;
|
|
245
354
|
private warnMissingCapability;
|
|
@@ -302,6 +411,20 @@ export declare class CircuitBreaker {
|
|
|
302
411
|
* breaker open with no way to ever attempt another trial.
|
|
303
412
|
*/
|
|
304
413
|
private monitor;
|
|
414
|
+
/**
|
|
415
|
+
* Deliberately doesn't use `safeGet`: that collapses a store error into
|
|
416
|
+
* the same `null` a genuinely-absent key produces, and `updateClosedCache`
|
|
417
|
+
* treats `null` as "never written, unambiguously clean" -- so a store
|
|
418
|
+
* error reaching it would install a trusted, closed entry from a read
|
|
419
|
+
* that never actually happened. Under `failOpenOnStoreError: false` that
|
|
420
|
+
* would be a real contract violation: the gate would start serving
|
|
421
|
+
* allows from this cache with zero store contact instead of throwing
|
|
422
|
+
* `StoreUnavailableError` as the caller explicitly asked for. A failed
|
|
423
|
+
* read here instead marks any existing entry untrusted (never installs
|
|
424
|
+
* one) and leaves its `fetchedAt` alone, so the next monitor tick retries
|
|
425
|
+
* the reconcile -- the behavior wanted during an outage.
|
|
426
|
+
*/
|
|
427
|
+
private reconcileClosedCache;
|
|
305
428
|
private executeWithTimeout;
|
|
306
429
|
}
|
|
307
430
|
//# sourceMappingURL=circuit-breaker.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"circuit-breaker.d.ts","sourceRoot":"","sources":["../src/circuit-breaker.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAChC,OAAO,EAAE,mBAAmB,EAAE,cAAc,EAA8B,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"circuit-breaker.d.ts","sourceRoot":"","sources":["../src/circuit-breaker.ts"],"names":[],"mappings":"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"}
|