actly 1.1.5 → 1.2.0
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 +232 -434
- package/dist/core/act.cjs +353 -35
- package/dist/core/act.d.ts.map +1 -1
- package/dist/core/act.js +355 -37
- package/dist/core/act.js.map +1 -1
- package/dist/core/executor.cjs +2 -0
- package/dist/core/executor.d.ts +6 -1
- package/dist/core/executor.d.ts.map +1 -1
- package/dist/core/executor.js +2 -0
- package/dist/core/executor.js.map +1 -1
- package/dist/core/health.cjs +34 -0
- package/dist/core/health.d.ts +18 -0
- package/dist/core/health.d.ts.map +1 -0
- package/dist/core/health.js +28 -0
- package/dist/core/health.js.map +1 -0
- package/dist/core/shutdown.cjs +60 -0
- package/dist/core/shutdown.d.ts +8 -0
- package/dist/core/shutdown.d.ts.map +1 -0
- package/dist/core/shutdown.js +56 -0
- package/dist/core/shutdown.js.map +1 -0
- package/dist/core/tenant.cjs +71 -0
- package/dist/core/tenant.d.ts +29 -0
- package/dist/core/tenant.d.ts.map +1 -0
- package/dist/core/tenant.js +68 -0
- package/dist/core/tenant.js.map +1 -0
- package/dist/errors.cjs +182 -0
- package/dist/errors.d.ts +151 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +171 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.cjs +44 -4
- package/dist/index.d.ts +17 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -1
- package/dist/index.js.map +1 -1
- package/dist/observability.cjs +45 -0
- package/dist/observability.d.ts +145 -0
- package/dist/observability.d.ts.map +1 -0
- package/dist/observability.js +43 -0
- package/dist/observability.js.map +1 -0
- package/dist/policies/bulkhead.cjs +97 -0
- package/dist/policies/bulkhead.d.ts +3 -0
- package/dist/policies/bulkhead.d.ts.map +1 -0
- package/dist/policies/bulkhead.js +95 -0
- package/dist/policies/bulkhead.js.map +1 -0
- package/dist/policies/cache.cjs +168 -31
- package/dist/policies/cache.d.ts +17 -4
- package/dist/policies/cache.d.ts.map +1 -1
- package/dist/policies/cache.js +168 -31
- package/dist/policies/cache.js.map +1 -1
- package/dist/policies/circuitBreaker.cjs +85 -0
- package/dist/policies/circuitBreaker.d.ts +3 -0
- package/dist/policies/circuitBreaker.d.ts.map +1 -0
- package/dist/policies/circuitBreaker.js +83 -0
- package/dist/policies/circuitBreaker.js.map +1 -0
- package/dist/policies/dedupe.cjs +74 -45
- package/dist/policies/dedupe.d.ts +10 -28
- package/dist/policies/dedupe.d.ts.map +1 -1
- package/dist/policies/dedupe.js +74 -45
- package/dist/policies/dedupe.js.map +1 -1
- package/dist/policies/rateLimit.cjs +35 -0
- package/dist/policies/rateLimit.d.ts +3 -0
- package/dist/policies/rateLimit.d.ts.map +1 -0
- package/dist/policies/rateLimit.js +33 -0
- package/dist/policies/rateLimit.js.map +1 -0
- package/dist/policies/retry.cjs +45 -8
- package/dist/policies/retry.d.ts.map +1 -1
- package/dist/policies/retry.js +45 -8
- package/dist/policies/retry.js.map +1 -1
- package/dist/policies/timeout.cjs +25 -53
- package/dist/policies/timeout.d.ts +2 -33
- package/dist/policies/timeout.d.ts.map +1 -1
- package/dist/policies/timeout.js +31 -48
- package/dist/policies/timeout.js.map +1 -1
- package/dist/state/store.cjs +3 -3
- package/dist/state/store.d.ts +3 -3
- package/dist/state/store.js +3 -3
- package/dist/stores/memory.cjs +126 -53
- package/dist/stores/memory.d.ts +35 -16
- package/dist/stores/memory.d.ts.map +1 -1
- package/dist/stores/memory.js +125 -53
- package/dist/stores/memory.js.map +1 -1
- package/dist/types/index.d.ts +176 -3
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/abort.cjs +108 -38
- package/dist/utils/abort.d.ts +43 -19
- package/dist/utils/abort.d.ts.map +1 -1
- package/dist/utils/abort.js +108 -38
- package/dist/utils/abort.js.map +1 -1
- package/dist/utils/abortPool.cjs +26 -0
- package/dist/utils/abortPool.d.ts +4 -0
- package/dist/utils/abortPool.d.ts.map +1 -0
- package/dist/utils/abortPool.js +22 -0
- package/dist/utils/abortPool.js.map +1 -0
- package/dist/utils/backoff.cjs +12 -1
- package/dist/utils/backoff.d.ts.map +1 -1
- package/dist/utils/backoff.js +12 -1
- package/dist/utils/backoff.js.map +1 -1
- package/dist/utils/key.cjs +77 -0
- package/dist/utils/key.d.ts +29 -0
- package/dist/utils/key.d.ts.map +1 -0
- package/dist/utils/key.js +75 -0
- package/dist/utils/key.js.map +1 -0
- package/dist/utils/limits.cjs +40 -0
- package/dist/utils/limits.d.ts +39 -0
- package/dist/utils/limits.d.ts.map +1 -0
- package/dist/utils/limits.js +38 -0
- package/dist/utils/limits.js.map +1 -0
- package/dist/utils/sanitize.cjs +29 -0
- package/dist/utils/sanitize.d.ts +3 -0
- package/dist/utils/sanitize.d.ts.map +1 -0
- package/dist/utils/sanitize.js +26 -0
- package/dist/utils/sanitize.js.map +1 -0
- package/dist/utils/validate.cjs +90 -16
- package/dist/utils/validate.d.ts +11 -0
- package/dist/utils/validate.d.ts.map +1 -1
- package/dist/utils/validate.js +85 -16
- package/dist/utils/validate.js.map +1 -1
- package/package.json +6 -5
package/dist/policies/cache.js
CHANGED
|
@@ -1,11 +1,31 @@
|
|
|
1
1
|
import { isSyncStore } from '../stores/base.js';
|
|
2
2
|
const NS = 'cache:';
|
|
3
3
|
const INFLIGHT_NS = '__inflight:cache:';
|
|
4
|
+
let generationCounter = 0;
|
|
5
|
+
function nextGeneration() {
|
|
6
|
+
generationCounter = (generationCounter + 1) % Number.MAX_SAFE_INTEGER;
|
|
7
|
+
return generationCounter;
|
|
8
|
+
}
|
|
4
9
|
/**
|
|
5
10
|
* Short-circuit the entire downstream chain on a cache hit.
|
|
6
11
|
* On a miss, run `fn` and store the result with TTL.
|
|
7
12
|
*
|
|
8
|
-
* #
|
|
13
|
+
* # Properties
|
|
14
|
+
*
|
|
15
|
+
* - **Single-flight originator isolation**: the stored in-flight promise is
|
|
16
|
+
* the RAW `fn(signal)` — NOT raceAbort-wrapped. An originator's signal
|
|
17
|
+
* abort no longer causes all cache-miss joiners to reject. Each caller
|
|
18
|
+
* races against their OWN signal only.
|
|
19
|
+
* - **Generation-safe inflight cleanup**: stale originators don't delete
|
|
20
|
+
* newer entries (same pattern as dedupePolicy).
|
|
21
|
+
* - **Signal-aware async store path**: between `await store.get()` and
|
|
22
|
+
* `await store.set()`, we re-check `signal.aborted`. The previous
|
|
23
|
+
* version would happily return a cached value even after the caller
|
|
24
|
+
* aborted mid-Redis-latency.
|
|
25
|
+
* - **Cache hit honours abort**: if `signal.aborted` is true at policy
|
|
26
|
+
* entry, we reject immediately — consistent with `act()`'s contract.
|
|
27
|
+
*
|
|
28
|
+
* # Single-flight (cache stampede prevention)
|
|
9
29
|
*
|
|
10
30
|
* On a sync store, the policy also stores the in-flight Promise under a
|
|
11
31
|
* separate `__inflight:cache:<key>` slot. Concurrent callers that miss the
|
|
@@ -28,73 +48,148 @@ const INFLIGHT_NS = '__inflight:cache:';
|
|
|
28
48
|
* # Cache hit semantics
|
|
29
49
|
*
|
|
30
50
|
* On a cache hit, `meta.source` is set to `'cache'` and `meta.attempts` is
|
|
31
|
-
* set to `0` — no work was performed.
|
|
32
|
-
* cache hits, which was inconsistent with the documented meaning of
|
|
33
|
-
* `attempts`. v1.1.5 fixes this.)
|
|
51
|
+
* set to `0` — no work was performed.
|
|
34
52
|
*
|
|
35
53
|
* Failures are NEVER cached. Only successful values are stored.
|
|
36
54
|
*/
|
|
37
55
|
export function cachePolicy(opts) {
|
|
38
56
|
return (fn, ctx) => async (signal) => {
|
|
57
|
+
// Honour abort on cache hit too. Consistent with act()'s
|
|
58
|
+
// "reject immediately if signal aborted" contract.
|
|
59
|
+
if (signal.aborted)
|
|
60
|
+
return Promise.reject(signal.reason);
|
|
39
61
|
const key = NS + ctx.key;
|
|
40
62
|
const inflightKey = INFLIGHT_NS + ctx.key;
|
|
41
63
|
// ─── Sync store: fast path with single-flight ────────────────────────
|
|
42
64
|
if (isSyncStore(ctx.store)) {
|
|
65
|
+
// Capture the narrowed sync store so closures (cleanup) keep the
|
|
66
|
+
// type information — TS doesn't carry `isSyncStore` narrowing
|
|
67
|
+
// into nested function bodies.
|
|
68
|
+
const store = ctx.store;
|
|
69
|
+
const obs = ctx.observability;
|
|
43
70
|
// 1. Cache hit?
|
|
44
|
-
const hit =
|
|
71
|
+
const hit = store.get(key);
|
|
45
72
|
if (hit) {
|
|
46
73
|
ctx.meta.source = 'cache';
|
|
47
74
|
ctx.meta.attempts = 0;
|
|
75
|
+
// Emit cache-hit event if hooks are registered.
|
|
76
|
+
// Compute actual age from the entry's insertion timestamp.
|
|
77
|
+
if (obs) {
|
|
78
|
+
const ageMs = Date.now() - hit.insertedAt;
|
|
79
|
+
obs.hooks.onCacheHit?.({
|
|
80
|
+
type: 'cache-hit', key: ctx.key, traceId: obs.traceId,
|
|
81
|
+
timestamp: Date.now(), ageMs: Math.max(0, ageMs),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
48
84
|
return hit.value;
|
|
49
85
|
}
|
|
86
|
+
// Emit cache-miss event if hooks are registered.
|
|
87
|
+
if (obs) {
|
|
88
|
+
obs.hooks.onCacheMiss?.({
|
|
89
|
+
type: 'cache-miss', key: ctx.key, traceId: obs.traceId,
|
|
90
|
+
timestamp: Date.now(),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
50
93
|
// 2. In-flight single-flight hit? Join it.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
94
|
+
//
|
|
95
|
+
// The stored promise is RAW fn(signal). Joiners race it
|
|
96
|
+
// against their OWN signal only — originator abort doesn't
|
|
97
|
+
// propagate to joiners.
|
|
98
|
+
const inflight = store.get(inflightKey);
|
|
99
|
+
if (inflight) {
|
|
100
|
+
// Race the in-flight promise against OUR OWN signal. If we
|
|
101
|
+
// abort, we reject — but other joiners and the originator
|
|
102
|
+
// continue unaffected.
|
|
103
|
+
try {
|
|
104
|
+
const value = await raceWithOwnSignal(inflight.promise, signal);
|
|
105
|
+
// Mirror originator's meta so joiner's ActResult reflects
|
|
106
|
+
// the real effort (attempts, source).
|
|
107
|
+
ctx.meta.attempts = inflight.meta.attempts;
|
|
108
|
+
ctx.meta.source = inflight.meta.source;
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
// Copy meta on failure too (same as dedupe joiners)
|
|
113
|
+
if (signal.aborted) {
|
|
114
|
+
ctx.meta.attempts = 0;
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
ctx.meta.attempts = inflight.meta.attempts;
|
|
118
|
+
ctx.meta.source = inflight.meta.source;
|
|
119
|
+
}
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
54
123
|
// 3. Originator: launch fn, cache on success (fail-open).
|
|
55
|
-
|
|
124
|
+
//
|
|
125
|
+
// The stored promise is the RAW fn(signal) — NOT raceAbort-wrapped.
|
|
126
|
+
// Originator's own await is wrapped in raceWithOwnSignal so they
|
|
127
|
+
// can bail on their signal without affecting joiners.
|
|
128
|
+
const generation = nextGeneration();
|
|
129
|
+
const rawPromise = Promise.resolve(fn(signal)).then((value) => {
|
|
56
130
|
try {
|
|
57
|
-
|
|
131
|
+
store.set(key, { value, insertedAt: Date.now() }, opts.ttl);
|
|
58
132
|
}
|
|
59
133
|
catch {
|
|
60
134
|
// Fail-open: cache write failure should not surface to caller.
|
|
61
|
-
// The value is still valid; next call will just re-run fn.
|
|
62
135
|
}
|
|
63
136
|
return value;
|
|
64
|
-
},
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
//
|
|
68
|
-
//
|
|
137
|
+
},
|
|
138
|
+
// Re-throw the error so joiners see it. Don't cache failures.
|
|
139
|
+
(err) => { throw err; });
|
|
140
|
+
// Publish the in-flight entry for single-flight. If store.set
|
|
141
|
+
// throws, single-flight is disabled for this call — concurrent
|
|
142
|
+
// callers will all run fn. Still correct, just less efficient.
|
|
69
143
|
try {
|
|
70
|
-
|
|
71
|
-
// Cleanup the in-flight slot on settle. The `.catch(() => {})`
|
|
72
|
-
// suppresses the unhandled-rejection warning that would otherwise
|
|
73
|
-
// fire on the `.finally()` chain — the original `promise` (with
|
|
74
|
-
// its rejection) is returned to the caller, which has its own
|
|
75
|
-
// try/catch via act().
|
|
76
|
-
promise.finally(() => {
|
|
77
|
-
try {
|
|
78
|
-
ctx.store.delete(inflightKey);
|
|
79
|
-
}
|
|
80
|
-
catch { /* ignore */ }
|
|
81
|
-
}).catch(() => { });
|
|
144
|
+
store.set(inflightKey, { promise: rawPromise, generation, meta: ctx.meta });
|
|
82
145
|
}
|
|
83
146
|
catch {
|
|
84
147
|
// Single-flight unavailable; proceed without publishing.
|
|
148
|
+
// Originator still gets their value (or error).
|
|
85
149
|
}
|
|
86
|
-
|
|
150
|
+
// Cleanup the in-flight slot on settle — generation-safe.
|
|
151
|
+
const cleanup = () => {
|
|
152
|
+
const current = store.get(inflightKey);
|
|
153
|
+
if (current && current.generation === generation) {
|
|
154
|
+
try {
|
|
155
|
+
store.delete(inflightKey);
|
|
156
|
+
}
|
|
157
|
+
catch { /* ignore */ }
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
rawPromise.then(cleanup, cleanup);
|
|
161
|
+
// Originator races the raw promise against their OWN signal.
|
|
162
|
+
return raceWithOwnSignal(rawPromise, signal);
|
|
87
163
|
}
|
|
88
|
-
//
|
|
164
|
+
// Async store: no single-flight (race window unavoidable).
|
|
165
|
+
// Re-check signal.aborted between awaits.
|
|
166
|
+
const obs = ctx.observability;
|
|
89
167
|
const hit = await ctx.store.get(key);
|
|
168
|
+
if (signal.aborted)
|
|
169
|
+
return Promise.reject(signal.reason);
|
|
90
170
|
if (hit) {
|
|
91
171
|
ctx.meta.source = 'cache';
|
|
92
172
|
ctx.meta.attempts = 0;
|
|
173
|
+
if (obs) {
|
|
174
|
+
const ageMs = Date.now() - hit.insertedAt;
|
|
175
|
+
obs.hooks.onCacheHit?.({
|
|
176
|
+
type: 'cache-hit', key: ctx.key, traceId: obs.traceId,
|
|
177
|
+
timestamp: Date.now(), ageMs: Math.max(0, ageMs),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
93
180
|
return hit.value;
|
|
94
181
|
}
|
|
182
|
+
if (obs) {
|
|
183
|
+
obs.hooks.onCacheMiss?.({
|
|
184
|
+
type: 'cache-miss', key: ctx.key, traceId: obs.traceId,
|
|
185
|
+
timestamp: Date.now(),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
95
188
|
const value = await fn(signal);
|
|
189
|
+
if (signal.aborted)
|
|
190
|
+
return Promise.reject(signal.reason);
|
|
96
191
|
try {
|
|
97
|
-
await ctx.store.set(key, { value }, opts.ttl);
|
|
192
|
+
await ctx.store.set(key, { value, insertedAt: Date.now() }, opts.ttl);
|
|
98
193
|
}
|
|
99
194
|
catch {
|
|
100
195
|
// Fail-open: see sync path comment.
|
|
@@ -102,4 +197,46 @@ export function cachePolicy(opts) {
|
|
|
102
197
|
return value;
|
|
103
198
|
};
|
|
104
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* Race a promise against a signal — but if the signal aborts, mark the
|
|
202
|
+
* underlying promise as handled (so its eventual rejection doesn't surface
|
|
203
|
+
* as an unhandled rejection). The underlying promise keeps running for any
|
|
204
|
+
* other consumers (e.g. joiners in single-flight).
|
|
205
|
+
*
|
|
206
|
+
* This is `raceAbort` from `utils/abort.ts`, inlined here to avoid an import
|
|
207
|
+
* cycle (cache.ts already imports from stores, dedupe imports from abort —
|
|
208
|
+
* keeping cache independent of abort reduces the import graph depth).
|
|
209
|
+
*
|
|
210
|
+
* Internal — not exported.
|
|
211
|
+
*/
|
|
212
|
+
function raceWithOwnSignal(promise, signal) {
|
|
213
|
+
if (signal.aborted) {
|
|
214
|
+
promise.catch(() => { }); // mark as handled
|
|
215
|
+
return Promise.reject(signal.reason);
|
|
216
|
+
}
|
|
217
|
+
return new Promise((resolve, reject) => {
|
|
218
|
+
let settled = false;
|
|
219
|
+
const onAbort = () => {
|
|
220
|
+
if (settled)
|
|
221
|
+
return;
|
|
222
|
+
settled = true;
|
|
223
|
+
signal.removeEventListener('abort', onAbort);
|
|
224
|
+
reject(signal.reason);
|
|
225
|
+
};
|
|
226
|
+
signal.addEventListener('abort', onAbort);
|
|
227
|
+
promise.then((value) => {
|
|
228
|
+
if (settled)
|
|
229
|
+
return;
|
|
230
|
+
settled = true;
|
|
231
|
+
signal.removeEventListener('abort', onAbort);
|
|
232
|
+
resolve(value);
|
|
233
|
+
}, (error) => {
|
|
234
|
+
if (settled)
|
|
235
|
+
return;
|
|
236
|
+
settled = true;
|
|
237
|
+
signal.removeEventListener('abort', onAbort);
|
|
238
|
+
reject(error);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
}
|
|
105
242
|
//# sourceMappingURL=cache.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cache.js","sourceRoot":"","sources":["../../src/policies/cache.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAE/C,MAAM,EAAE,GAAG,QAAQ,CAAA;AACnB,MAAM,WAAW,GAAG,mBAAmB,CAAA;
|
|
1
|
+
{"version":3,"file":"cache.js","sourceRoot":"","sources":["../../src/policies/cache.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.circuitBreakerPolicy = circuitBreakerPolicy;
|
|
4
|
+
const executor_js_1 = require("../core/executor.js");
|
|
5
|
+
const errors_js_1 = require("../errors.js");
|
|
6
|
+
const abort_js_1 = require("../utils/abort.js");
|
|
7
|
+
const NS = 'cb:';
|
|
8
|
+
function getState(store, key) {
|
|
9
|
+
return store.get(NS + key) ?? { failures: 0, lastFailureTime: 0, isOpen: false, openedAt: 0, halfOpen: false };
|
|
10
|
+
}
|
|
11
|
+
function setState(store, key, state) {
|
|
12
|
+
store.set(NS + key, state);
|
|
13
|
+
}
|
|
14
|
+
function circuitBreakerPolicy(opts) {
|
|
15
|
+
const threshold = Math.max(1, Math.floor(opts.threshold));
|
|
16
|
+
const cooldownMs = opts.cooldownMs;
|
|
17
|
+
const resetTimeoutMs = opts.resetTimeoutMs ?? Number.POSITIVE_INFINITY;
|
|
18
|
+
const applier = (fn, ctx) => {
|
|
19
|
+
const syncCtx = ctx;
|
|
20
|
+
return async (signal) => {
|
|
21
|
+
const key = syncCtx.key;
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
// Read state synchronously and make decisions atomically
|
|
24
|
+
const state = getState(syncCtx.store, key);
|
|
25
|
+
if (state.isOpen) {
|
|
26
|
+
const elapsed = now - state.openedAt;
|
|
27
|
+
if (elapsed >= cooldownMs) {
|
|
28
|
+
// Transition to half-open: allow only ONE probe call
|
|
29
|
+
state.isOpen = false;
|
|
30
|
+
state.halfOpen = true;
|
|
31
|
+
setState(syncCtx.store, key, state);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
throw new errors_js_1.CircuitBreakerOpenError(key, cooldownMs - elapsed);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
else if (state.halfOpen) {
|
|
38
|
+
// Another call is already probing — block this one
|
|
39
|
+
throw new errors_js_1.CircuitBreakerOpenError(key, 0);
|
|
40
|
+
}
|
|
41
|
+
if (now - state.lastFailureTime > resetTimeoutMs && state.failures > 0) {
|
|
42
|
+
state.failures = 0;
|
|
43
|
+
setState(syncCtx.store, key, state);
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const result = await fn(signal);
|
|
47
|
+
// Re-read state (it may have changed during await) and update atomically
|
|
48
|
+
const updated = getState(syncCtx.store, key);
|
|
49
|
+
updated.failures = 0;
|
|
50
|
+
updated.isOpen = false;
|
|
51
|
+
updated.halfOpen = false;
|
|
52
|
+
setState(syncCtx.store, key, updated);
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
// Signal aborts are not downstream failures — don't count them
|
|
57
|
+
if ((0, abort_js_1.isAbortError)(err) || (signal.aborted && err === signal.reason)) {
|
|
58
|
+
const updated = getState(syncCtx.store, key);
|
|
59
|
+
if (updated.halfOpen) {
|
|
60
|
+
// Abort during half-open: probe didn't succeed or fail —
|
|
61
|
+
// downstream health is still unknown. Go back to OPEN with
|
|
62
|
+
// fresh cooldown so next call waits before probing again.
|
|
63
|
+
updated.isOpen = true;
|
|
64
|
+
updated.openedAt = Date.now();
|
|
65
|
+
updated.halfOpen = false;
|
|
66
|
+
}
|
|
67
|
+
setState(syncCtx.store, key, updated);
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
const updated = getState(syncCtx.store, key);
|
|
71
|
+
updated.failures++;
|
|
72
|
+
updated.lastFailureTime = Date.now();
|
|
73
|
+
updated.halfOpen = false;
|
|
74
|
+
if (updated.failures >= threshold) {
|
|
75
|
+
updated.isOpen = true;
|
|
76
|
+
updated.openedAt = Date.now();
|
|
77
|
+
}
|
|
78
|
+
setState(syncCtx.store, key, updated);
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
applier[executor_js_1.REQUIRES_SYNC_STORE] = true;
|
|
84
|
+
return applier;
|
|
85
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"circuitBreaker.d.ts","sourceRoot":"","sources":["../../src/policies/circuitBreaker.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAS,aAAa,EAAiB,qBAAqB,EAAE,MAAM,mBAAmB,CAAA;AAwBnG,wBAAgB,oBAAoB,CAAC,CAAC,EAAE,IAAI,EAAE,qBAAqB,GAAG,aAAa,CAAC,CAAC,CAAC,CA2ErF"}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { REQUIRES_SYNC_STORE } from '../core/executor.js';
|
|
2
|
+
import { CircuitBreakerOpenError } from '../errors.js';
|
|
3
|
+
import { isAbortError } from '../utils/abort.js';
|
|
4
|
+
const NS = 'cb:';
|
|
5
|
+
function getState(store, key) {
|
|
6
|
+
return store.get(NS + key) ?? { failures: 0, lastFailureTime: 0, isOpen: false, openedAt: 0, halfOpen: false };
|
|
7
|
+
}
|
|
8
|
+
function setState(store, key, state) {
|
|
9
|
+
store.set(NS + key, state);
|
|
10
|
+
}
|
|
11
|
+
export function circuitBreakerPolicy(opts) {
|
|
12
|
+
const threshold = Math.max(1, Math.floor(opts.threshold));
|
|
13
|
+
const cooldownMs = opts.cooldownMs;
|
|
14
|
+
const resetTimeoutMs = opts.resetTimeoutMs ?? Number.POSITIVE_INFINITY;
|
|
15
|
+
const applier = (fn, ctx) => {
|
|
16
|
+
const syncCtx = ctx;
|
|
17
|
+
return async (signal) => {
|
|
18
|
+
const key = syncCtx.key;
|
|
19
|
+
const now = Date.now();
|
|
20
|
+
// Read state synchronously and make decisions atomically
|
|
21
|
+
const state = getState(syncCtx.store, key);
|
|
22
|
+
if (state.isOpen) {
|
|
23
|
+
const elapsed = now - state.openedAt;
|
|
24
|
+
if (elapsed >= cooldownMs) {
|
|
25
|
+
// Transition to half-open: allow only ONE probe call
|
|
26
|
+
state.isOpen = false;
|
|
27
|
+
state.halfOpen = true;
|
|
28
|
+
setState(syncCtx.store, key, state);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
throw new CircuitBreakerOpenError(key, cooldownMs - elapsed);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
else if (state.halfOpen) {
|
|
35
|
+
// Another call is already probing — block this one
|
|
36
|
+
throw new CircuitBreakerOpenError(key, 0);
|
|
37
|
+
}
|
|
38
|
+
if (now - state.lastFailureTime > resetTimeoutMs && state.failures > 0) {
|
|
39
|
+
state.failures = 0;
|
|
40
|
+
setState(syncCtx.store, key, state);
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const result = await fn(signal);
|
|
44
|
+
// Re-read state (it may have changed during await) and update atomically
|
|
45
|
+
const updated = getState(syncCtx.store, key);
|
|
46
|
+
updated.failures = 0;
|
|
47
|
+
updated.isOpen = false;
|
|
48
|
+
updated.halfOpen = false;
|
|
49
|
+
setState(syncCtx.store, key, updated);
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
// Signal aborts are not downstream failures — don't count them
|
|
54
|
+
if (isAbortError(err) || (signal.aborted && err === signal.reason)) {
|
|
55
|
+
const updated = getState(syncCtx.store, key);
|
|
56
|
+
if (updated.halfOpen) {
|
|
57
|
+
// Abort during half-open: probe didn't succeed or fail —
|
|
58
|
+
// downstream health is still unknown. Go back to OPEN with
|
|
59
|
+
// fresh cooldown so next call waits before probing again.
|
|
60
|
+
updated.isOpen = true;
|
|
61
|
+
updated.openedAt = Date.now();
|
|
62
|
+
updated.halfOpen = false;
|
|
63
|
+
}
|
|
64
|
+
setState(syncCtx.store, key, updated);
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
const updated = getState(syncCtx.store, key);
|
|
68
|
+
updated.failures++;
|
|
69
|
+
updated.lastFailureTime = Date.now();
|
|
70
|
+
updated.halfOpen = false;
|
|
71
|
+
if (updated.failures >= threshold) {
|
|
72
|
+
updated.isOpen = true;
|
|
73
|
+
updated.openedAt = Date.now();
|
|
74
|
+
}
|
|
75
|
+
setState(syncCtx.store, key, updated);
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
applier[REQUIRES_SYNC_STORE] = true;
|
|
81
|
+
return applier;
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=circuitBreaker.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"circuitBreaker.js","sourceRoot":"","sources":["../../src/policies/circuitBreaker.ts"],"names":[],"mappings":"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"}
|
package/dist/policies/dedupe.cjs
CHANGED
|
@@ -5,37 +5,29 @@ const executor_js_1 = require("../core/executor.js");
|
|
|
5
5
|
const abort_js_1 = require("../utils/abort.js");
|
|
6
6
|
// Namespace so dedupe keys never collide with cache keys in the shared store.
|
|
7
7
|
const NS = 'dedupe:';
|
|
8
|
+
/**
|
|
9
|
+
* Module-level generation counter. Monotonic — guarantees uniqueness
|
|
10
|
+
* across the lifetime of the process. Wraps at `Number.MAX_SAFE_INTEGER`
|
|
11
|
+
* (which would take ~285 000 years at 1M increments/sec).
|
|
12
|
+
*/
|
|
13
|
+
let generationCounter = 0;
|
|
14
|
+
function nextGeneration() {
|
|
15
|
+
generationCounter = (generationCounter + 1) % Number.MAX_SAFE_INTEGER;
|
|
16
|
+
return generationCounter;
|
|
17
|
+
}
|
|
8
18
|
/**
|
|
9
19
|
* Collapse concurrent calls that share the same key into one in-flight Promise.
|
|
10
20
|
*
|
|
11
|
-
* #
|
|
12
|
-
*
|
|
13
|
-
* The first caller (originator) starts the work and stores
|
|
14
|
-
* `{ promise, meta }` in the store under `dedupe:<key>`. Every subsequent
|
|
15
|
-
* caller that arrives before the promise settles receives the SAME promise
|
|
16
|
-
* — no duplicate work.
|
|
17
|
-
*
|
|
18
|
-
* # Shared `meta` (fixes v1.0 trade-off)
|
|
19
|
-
*
|
|
20
|
-
* The originator's `ctx.meta` reference is stored alongside the promise.
|
|
21
|
-
* Inner policies (e.g. `retryPolicy`) mutate it as they run. After the
|
|
22
|
-
* promise settles, joiners copy `attempts` and `source` from the shared
|
|
23
|
-
* meta into their own `ctx.meta`. This means a joiner's `ActResult.attempts`
|
|
24
|
-
* reflects the real effort (e.g. `3` if the originator retried twice), not
|
|
25
|
-
* the misleading default of `1`.
|
|
26
|
-
*
|
|
27
|
-
* # Abort safety (fixes hung-fn block)
|
|
21
|
+
* # Properties
|
|
28
22
|
*
|
|
29
|
-
*
|
|
30
|
-
* `
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* expires (the original promise still leaks unless an outer timeout
|
|
38
|
-
* fires, but new callers aren't blocked).
|
|
23
|
+
* - **Generation-safe cleanup**: stale originators never delete newer
|
|
24
|
+
* entries when `inflightTtl` triggers replacement.
|
|
25
|
+
* - **Joiner isolation**: originator's caller-signal abort does NOT
|
|
26
|
+
* propagate to joiners. Each joiner races the shared in-flight promise
|
|
27
|
+
* against their OWN signal only.
|
|
28
|
+
* - **Truthful joiner attempts**: joiners that abort before the originator
|
|
29
|
+
* settles report `attempts: 0` (they did no work), not the originator's
|
|
30
|
+
* in-progress count.
|
|
39
31
|
*
|
|
40
32
|
* # INVARIANT: requires SyncStateStore
|
|
41
33
|
*
|
|
@@ -54,34 +46,71 @@ function dedupePolicy(opts = { enabled: true }) {
|
|
|
54
46
|
const syncCtx = ctx;
|
|
55
47
|
return async (signal) => {
|
|
56
48
|
const key = NS + syncCtx.key;
|
|
57
|
-
// Fast path: an in-flight promise already exists. Join it.
|
|
58
|
-
//
|
|
59
|
-
// signal
|
|
49
|
+
// ─── Fast path: an in-flight promise already exists. Join it. ──────
|
|
50
|
+
//
|
|
51
|
+
// The joiner races the in-flight promise against THEIR OWN signal.
|
|
52
|
+
// They do NOT inherit the originator's signal state — if the
|
|
53
|
+
// originator's caller cancels, joiners continue waiting (or get
|
|
54
|
+
// the eventual settled value).
|
|
60
55
|
const existing = syncCtx.store.get(key);
|
|
61
56
|
if (existing) {
|
|
62
57
|
try {
|
|
63
58
|
const value = await (0, abort_js_1.raceAbort)(existing.promise, signal);
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
finally {
|
|
67
|
-
// Copy the originator's final meta into our own, regardless of
|
|
68
|
-
// success or failure. By the time `existing.promise` has settled
|
|
69
|
-
// (or our signal aborted), the originator's retry loop has set
|
|
70
|
-
// the final attempt count.
|
|
59
|
+
// Success — copy originator's final meta (attempts, source)
|
|
60
|
+
// so the joiner's ActResult reflects the real effort.
|
|
71
61
|
syncCtx.meta.attempts = existing.meta.attempts;
|
|
72
62
|
syncCtx.meta.source = existing.meta.source;
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
// Joiner either aborted (their own signal) or got the
|
|
67
|
+
// originator's settled error.
|
|
68
|
+
if (signal.aborted) {
|
|
69
|
+
// Joiner's own signal aborted before originator settled.
|
|
70
|
+
// They did no work — report `attempts: 0` (truthful).
|
|
71
|
+
syncCtx.meta.attempts = 0;
|
|
72
|
+
// source stays 'fresh' (default) — joiner didn't read from cache
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
// Originator settled with failure — copy its final meta.
|
|
76
|
+
syncCtx.meta.attempts = existing.meta.attempts;
|
|
77
|
+
syncCtx.meta.source = existing.meta.source;
|
|
78
|
+
}
|
|
79
|
+
throw err;
|
|
73
80
|
}
|
|
74
81
|
}
|
|
75
|
-
// Originator path: start the work and publish the promise.
|
|
82
|
+
// ─── Originator path: start the work and publish the promise. ──────
|
|
76
83
|
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
84
|
+
// The stored promise is the RAW fn(signal) — NOT raceAbort-wrapped.
|
|
85
|
+
// This is critical: if we stored raceAbort(fn, signal), an
|
|
86
|
+
// originator signal abort would cause the stored promise to reject,
|
|
87
|
+
// which would propagate to all joiners. Instead:
|
|
88
|
+
// - stored: raw fn(signal) — joiners await this
|
|
89
|
+
// - originator's await: raceAbort(stored, signal) — originator
|
|
90
|
+
// can bail out on their own signal without affecting joiners
|
|
91
|
+
const generation = nextGeneration();
|
|
92
|
+
const rawPromise = Promise.resolve(fn(signal));
|
|
93
|
+
const entry = {
|
|
94
|
+
promise: rawPromise,
|
|
95
|
+
meta: syncCtx.meta,
|
|
96
|
+
generation,
|
|
97
|
+
};
|
|
83
98
|
syncCtx.store.set(key, entry, inflightTtl);
|
|
84
|
-
|
|
99
|
+
// Cleanup on settle: only delete if generation matches. If a newer
|
|
100
|
+
// originator has replaced this entry (because inflightTtl expired),
|
|
101
|
+
// our cleanup is a no-op — the newer entry stays.
|
|
102
|
+
const cleanup = () => {
|
|
103
|
+
const current = syncCtx.store.get(key);
|
|
104
|
+
if (current && current.generation === generation) {
|
|
105
|
+
syncCtx.store.delete(key);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
// Attach cleanup to the RAW promise (not the raceAbort-wrapped one)
|
|
109
|
+
// so cleanup runs whenever fn settles, regardless of originator abort.
|
|
110
|
+
rawPromise.then(cleanup, cleanup);
|
|
111
|
+
// Originator races the raw promise against their own signal —
|
|
112
|
+
// they can bail out without affecting joiners.
|
|
113
|
+
return (0, abort_js_1.raceAbort)(rawPromise, signal);
|
|
85
114
|
};
|
|
86
115
|
};
|
|
87
116
|
applier[executor_js_1.REQUIRES_SYNC_STORE] = true;
|