actly 1.1.5 → 1.3.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/CHANGELOG.md +234 -0
- package/MIGRATION.md +141 -0
- package/README.md +334 -428
- package/SECURITY.md +56 -0
- package/dist/core/act.cjs +630 -126
- package/dist/core/act.d.ts +6 -84
- package/dist/core/act.d.ts.map +1 -1
- package/dist/core/act.js +630 -129
- package/dist/core/executor.cjs +1 -26
- package/dist/core/executor.d.ts +2 -38
- package/dist/core/executor.d.ts.map +1 -1
- package/dist/core/executor.js +1 -27
- package/dist/core/health.cjs +163 -0
- package/dist/core/health.d.ts +33 -0
- package/dist/core/health.d.ts.map +1 -0
- package/dist/core/health.js +150 -0
- package/dist/core/shutdown.cjs +71 -0
- package/dist/core/shutdown.d.ts +5 -0
- package/dist/core/shutdown.d.ts.map +1 -0
- package/dist/core/shutdown.js +65 -0
- package/dist/core/tenant.cjs +113 -0
- package/dist/core/tenant.d.ts +24 -0
- package/dist/core/tenant.d.ts.map +1 -0
- package/dist/core/tenant.js +109 -0
- package/dist/errors.cjs +149 -0
- package/dist/errors.d.ts +94 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +134 -0
- package/dist/index.cjs +51 -8
- package/dist/index.d.ts +22 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -6
- package/dist/observability.cjs +6 -0
- package/dist/observability.d.ts +86 -0
- package/dist/observability.d.ts.map +1 -0
- package/dist/observability.js +3 -0
- package/dist/policies/bulkhead.cjs +137 -0
- package/dist/policies/bulkhead.d.ts +3 -0
- package/dist/policies/bulkhead.d.ts.map +1 -0
- package/dist/policies/bulkhead.js +134 -0
- package/dist/policies/cache.cjs +84 -67
- package/dist/policies/cache.d.ts +0 -33
- package/dist/policies/cache.d.ts.map +1 -1
- package/dist/policies/cache.js +84 -68
- package/dist/policies/circuitBreaker.cjs +179 -0
- package/dist/policies/circuitBreaker.d.ts +3 -0
- package/dist/policies/circuitBreaker.d.ts.map +1 -0
- package/dist/policies/circuitBreaker.js +176 -0
- package/dist/policies/dedupe.cjs +46 -64
- package/dist/policies/dedupe.d.ts +0 -41
- package/dist/policies/dedupe.d.ts.map +1 -1
- package/dist/policies/dedupe.js +46 -65
- package/dist/policies/noop.cjs +6 -0
- package/dist/policies/noop.d.ts +3 -0
- package/dist/policies/noop.d.ts.map +1 -0
- package/dist/policies/noop.js +3 -0
- package/dist/policies/rateLimit.cjs +45 -0
- package/dist/policies/rateLimit.d.ts +3 -0
- package/dist/policies/rateLimit.d.ts.map +1 -0
- package/dist/policies/rateLimit.js +42 -0
- package/dist/policies/retry.cjs +129 -57
- package/dist/policies/retry.d.ts +0 -27
- package/dist/policies/retry.d.ts.map +1 -1
- package/dist/policies/retry.js +129 -58
- package/dist/policies/timeout.cjs +47 -103
- package/dist/policies/timeout.d.ts +2 -46
- package/dist/policies/timeout.d.ts.map +1 -1
- package/dist/policies/timeout.js +46 -102
- package/dist/stores/base.cjs +0 -10
- package/dist/stores/base.d.ts +4 -67
- package/dist/stores/base.d.ts.map +1 -1
- package/dist/stores/base.js +0 -11
- package/dist/stores/memory.cjs +147 -87
- package/dist/stores/memory.d.ts +11 -59
- package/dist/stores/memory.d.ts.map +1 -1
- package/dist/stores/memory.js +146 -88
- package/dist/testing/index.cjs +56 -0
- package/dist/testing/index.d.ts +6 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +52 -0
- package/dist/types/index.cjs +0 -1
- package/dist/types/index.d.ts +62 -179
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +0 -2
- package/dist/utils/abort.cjs +46 -73
- package/dist/utils/abort.d.ts +4 -51
- package/dist/utils/abort.d.ts.map +1 -1
- package/dist/utils/abort.js +46 -74
- package/dist/utils/abortPool.cjs +25 -0
- package/dist/utils/abortPool.d.ts +4 -0
- package/dist/utils/abortPool.d.ts.map +1 -0
- package/dist/utils/abortPool.js +20 -0
- package/dist/utils/backoff.cjs +8 -15
- package/dist/utils/backoff.d.ts +0 -11
- package/dist/utils/backoff.d.ts.map +1 -1
- package/dist/utils/backoff.js +8 -16
- package/dist/utils/decorator.cjs +44 -0
- package/dist/utils/decorator.d.ts +3 -0
- package/dist/utils/decorator.d.ts.map +1 -0
- package/dist/utils/decorator.js +41 -0
- package/dist/utils/key.cjs +45 -0
- package/dist/utils/key.d.ts +2 -0
- package/dist/utils/key.d.ts.map +1 -0
- package/dist/utils/key.js +42 -0
- package/dist/utils/limits.cjs +22 -0
- package/dist/utils/limits.d.ts +21 -0
- package/dist/utils/limits.d.ts.map +1 -0
- package/dist/utils/limits.js +19 -0
- package/dist/utils/safeCall.cjs +28 -0
- package/dist/utils/safeCall.d.ts +2 -0
- package/dist/utils/safeCall.d.ts.map +1 -0
- package/dist/utils/safeCall.js +25 -0
- package/dist/utils/sanitize.cjs +50 -0
- package/dist/utils/sanitize.d.ts +3 -0
- package/dist/utils/sanitize.d.ts.map +1 -0
- package/dist/utils/sanitize.js +46 -0
- package/dist/utils/validate.cjs +163 -26
- package/dist/utils/validate.d.ts +6 -8
- package/dist/utils/validate.d.ts.map +1 -1
- package/dist/utils/validate.js +158 -27
- package/package.json +42 -7
- package/dist/core/act.js.map +0 -1
- package/dist/core/executor.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/policies/cache.js.map +0 -1
- package/dist/policies/dedupe.js.map +0 -1
- package/dist/policies/retry.js.map +0 -1
- package/dist/policies/timeout.js.map +0 -1
- package/dist/state/store.cjs +0 -14
- package/dist/state/store.d.ts +0 -11
- package/dist/state/store.d.ts.map +0 -1
- package/dist/state/store.js +0 -11
- package/dist/state/store.js.map +0 -1
- package/dist/stores/base.js.map +0 -1
- package/dist/stores/memory.js.map +0 -1
- package/dist/types/index.js.map +0 -1
- package/dist/utils/abort.js.map +0 -1
- package/dist/utils/backoff.js.map +0 -1
- package/dist/utils/validate.js.map +0 -1
package/dist/policies/cache.cjs
CHANGED
|
@@ -2,105 +2,122 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.cachePolicy = cachePolicy;
|
|
4
4
|
const base_js_1 = require("../stores/base.js");
|
|
5
|
+
const safeCall_js_1 = require("../utils/safeCall.js");
|
|
6
|
+
const abort_js_1 = require("../utils/abort.js");
|
|
7
|
+
const limits_js_1 = require("../utils/limits.js");
|
|
5
8
|
const NS = 'cache:';
|
|
6
|
-
const INFLIGHT_NS = '
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
*
|
|
13
|
-
* On a sync store, the policy also stores the in-flight Promise under a
|
|
14
|
-
* separate `__inflight:cache:<key>` slot. Concurrent callers that miss the
|
|
15
|
-
* cache but find an in-flight Promise join it instead of launching duplicate
|
|
16
|
-
* work. This is the same mechanism `dedupePolicy` uses, applied internally
|
|
17
|
-
* to cache misses so users don't need to combine `cache` + `dedupe` to
|
|
18
|
-
* avoid stampedes.
|
|
19
|
-
*
|
|
20
|
-
* On an async store, single-flight is not possible (the same sync-store
|
|
21
|
-
* constraint as dedupe applies). Stampedes are a known limitation — document
|
|
22
|
-
* and pair with dedupe at a higher layer if you need single-flight semantics.
|
|
23
|
-
*
|
|
24
|
-
* # Fail-open writes
|
|
25
|
-
*
|
|
26
|
-
* If `store.set()` throws (e.g. Redis transient error), we swallow the error
|
|
27
|
-
* and return the value anyway. The caller gets their result; the next call
|
|
28
|
-
* will simply re-run `fn` and try to cache again. Caching is an optimisation,
|
|
29
|
-
* not a correctness requirement.
|
|
30
|
-
*
|
|
31
|
-
* # Cache hit semantics
|
|
32
|
-
*
|
|
33
|
-
* On a cache hit, `meta.source` is set to `'cache'` and `meta.attempts` is
|
|
34
|
-
* set to `0` — no work was performed. (v1.1.0 reported `attempts: 1` on
|
|
35
|
-
* cache hits, which was inconsistent with the documented meaning of
|
|
36
|
-
* `attempts`. v1.1.5 fixes this.)
|
|
37
|
-
*
|
|
38
|
-
* Failures are NEVER cached. Only successful values are stored.
|
|
39
|
-
*/
|
|
9
|
+
const INFLIGHT_NS = 'inflight:cache:';
|
|
10
|
+
let generationCounter = 0;
|
|
11
|
+
function nextGeneration() {
|
|
12
|
+
generationCounter = (generationCounter + 1) % Number.MAX_SAFE_INTEGER;
|
|
13
|
+
return generationCounter;
|
|
14
|
+
}
|
|
40
15
|
function cachePolicy(opts) {
|
|
41
16
|
return (fn, ctx) => async (signal) => {
|
|
17
|
+
if (signal.aborted)
|
|
18
|
+
return Promise.reject(signal.reason);
|
|
42
19
|
const key = NS + ctx.key;
|
|
43
20
|
const inflightKey = INFLIGHT_NS + ctx.key;
|
|
44
|
-
// ─── Sync store: fast path with single-flight ────────────────────────
|
|
45
21
|
if ((0, base_js_1.isSyncStore)(ctx.store)) {
|
|
46
|
-
|
|
47
|
-
const
|
|
22
|
+
const store = ctx.store;
|
|
23
|
+
const obs = ctx.observability;
|
|
24
|
+
const hit = store.get(key);
|
|
48
25
|
if (hit) {
|
|
49
26
|
ctx.meta.source = 'cache';
|
|
50
27
|
ctx.meta.attempts = 0;
|
|
28
|
+
if (obs) {
|
|
29
|
+
const ageMs = Date.now() - hit.insertedAt;
|
|
30
|
+
(0, safeCall_js_1.safeCall)(obs.hooks.onCacheHit, {
|
|
31
|
+
type: 'cache-hit', key: ctx.key, traceId: obs.traceId,
|
|
32
|
+
timestamp: Date.now(), ageMs: Math.max(0, ageMs),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
51
35
|
return hit.value;
|
|
52
36
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
37
|
+
if (obs) {
|
|
38
|
+
(0, safeCall_js_1.safeCall)(obs.hooks.onCacheMiss, {
|
|
39
|
+
type: 'cache-miss', key: ctx.key, traceId: obs.traceId,
|
|
40
|
+
timestamp: Date.now(),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const inflight = store.get(inflightKey);
|
|
44
|
+
if (inflight) {
|
|
45
|
+
try {
|
|
46
|
+
const value = await (0, abort_js_1.raceAbort)(inflight.promise, signal);
|
|
47
|
+
ctx.meta.attempts = inflight.meta.attempts;
|
|
48
|
+
ctx.meta.source = inflight.meta.source;
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
if (signal.aborted) {
|
|
53
|
+
ctx.meta.attempts = 0;
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
ctx.meta.attempts = inflight.meta.attempts;
|
|
57
|
+
ctx.meta.source = inflight.meta.source;
|
|
58
|
+
}
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const generation = nextGeneration();
|
|
63
|
+
const rawPromise = Promise.resolve(fn(signal)).then((value) => {
|
|
59
64
|
try {
|
|
60
|
-
|
|
65
|
+
store.set(key, { value, insertedAt: Date.now() }, opts.ttl);
|
|
61
66
|
}
|
|
62
67
|
catch {
|
|
63
|
-
// Fail-open: cache write failure should not surface to caller.
|
|
64
|
-
// The value is still valid; next call will just re-run fn.
|
|
65
68
|
}
|
|
66
69
|
return value;
|
|
67
70
|
}, (err) => { throw err; });
|
|
68
|
-
// Try to publish the in-flight promise so concurrent callers can
|
|
69
|
-
// join (single-flight). If store.set throws, single-flight is
|
|
70
|
-
// disabled for this call — concurrent callers will all run fn.
|
|
71
|
-
// That's still correct, just less efficient.
|
|
72
71
|
try {
|
|
73
|
-
|
|
74
|
-
// Cleanup the in-flight slot on settle. The `.catch(() => {})`
|
|
75
|
-
// suppresses the unhandled-rejection warning that would otherwise
|
|
76
|
-
// fire on the `.finally()` chain — the original `promise` (with
|
|
77
|
-
// its rejection) is returned to the caller, which has its own
|
|
78
|
-
// try/catch via act().
|
|
79
|
-
promise.finally(() => {
|
|
80
|
-
try {
|
|
81
|
-
ctx.store.delete(inflightKey);
|
|
82
|
-
}
|
|
83
|
-
catch { /* ignore */ }
|
|
84
|
-
}).catch(() => { });
|
|
72
|
+
store.set(inflightKey, { promise: rawPromise, generation, meta: ctx.meta }, limits_js_1.LIMITS.DEFAULT_INFLIGHT_TTL);
|
|
85
73
|
}
|
|
86
74
|
catch {
|
|
87
|
-
// Single-flight unavailable; proceed without publishing.
|
|
88
75
|
}
|
|
89
|
-
|
|
76
|
+
const cleanup = () => {
|
|
77
|
+
try {
|
|
78
|
+
const current = store.get(inflightKey);
|
|
79
|
+
if (current && current.generation === generation) {
|
|
80
|
+
try {
|
|
81
|
+
store.delete(inflightKey);
|
|
82
|
+
}
|
|
83
|
+
catch { }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
rawPromise.then(cleanup, cleanup).catch(() => { });
|
|
90
|
+
return (0, abort_js_1.raceAbort)(rawPromise, signal);
|
|
90
91
|
}
|
|
91
|
-
|
|
92
|
+
const obs = ctx.observability;
|
|
92
93
|
const hit = await ctx.store.get(key);
|
|
94
|
+
if (signal.aborted)
|
|
95
|
+
return Promise.reject(signal.reason);
|
|
93
96
|
if (hit) {
|
|
94
97
|
ctx.meta.source = 'cache';
|
|
95
98
|
ctx.meta.attempts = 0;
|
|
99
|
+
if (obs) {
|
|
100
|
+
const ageMs = Date.now() - hit.insertedAt;
|
|
101
|
+
(0, safeCall_js_1.safeCall)(obs.hooks.onCacheHit, {
|
|
102
|
+
type: 'cache-hit', key: ctx.key, traceId: obs.traceId,
|
|
103
|
+
timestamp: Date.now(), ageMs: Math.max(0, ageMs),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
96
106
|
return hit.value;
|
|
97
107
|
}
|
|
108
|
+
if (obs) {
|
|
109
|
+
(0, safeCall_js_1.safeCall)(obs.hooks.onCacheMiss, {
|
|
110
|
+
type: 'cache-miss', key: ctx.key, traceId: obs.traceId,
|
|
111
|
+
timestamp: Date.now(),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
98
114
|
const value = await fn(signal);
|
|
115
|
+
if (signal.aborted)
|
|
116
|
+
return Promise.reject(signal.reason);
|
|
99
117
|
try {
|
|
100
|
-
await ctx.store.set(key, { value }, opts.ttl);
|
|
118
|
+
await ctx.store.set(key, { value, insertedAt: Date.now() }, opts.ttl);
|
|
101
119
|
}
|
|
102
120
|
catch {
|
|
103
|
-
// Fail-open: see sync path comment.
|
|
104
121
|
}
|
|
105
122
|
return value;
|
|
106
123
|
};
|
package/dist/policies/cache.d.ts
CHANGED
|
@@ -1,36 +1,3 @@
|
|
|
1
1
|
import type { CacheOptions, PolicyApplier } from '../types/index.js';
|
|
2
|
-
/**
|
|
3
|
-
* Short-circuit the entire downstream chain on a cache hit.
|
|
4
|
-
* On a miss, run `fn` and store the result with TTL.
|
|
5
|
-
*
|
|
6
|
-
* # Single-flight (fixes cache stampede)
|
|
7
|
-
*
|
|
8
|
-
* On a sync store, the policy also stores the in-flight Promise under a
|
|
9
|
-
* separate `__inflight:cache:<key>` slot. Concurrent callers that miss the
|
|
10
|
-
* cache but find an in-flight Promise join it instead of launching duplicate
|
|
11
|
-
* work. This is the same mechanism `dedupePolicy` uses, applied internally
|
|
12
|
-
* to cache misses so users don't need to combine `cache` + `dedupe` to
|
|
13
|
-
* avoid stampedes.
|
|
14
|
-
*
|
|
15
|
-
* On an async store, single-flight is not possible (the same sync-store
|
|
16
|
-
* constraint as dedupe applies). Stampedes are a known limitation — document
|
|
17
|
-
* and pair with dedupe at a higher layer if you need single-flight semantics.
|
|
18
|
-
*
|
|
19
|
-
* # Fail-open writes
|
|
20
|
-
*
|
|
21
|
-
* If `store.set()` throws (e.g. Redis transient error), we swallow the error
|
|
22
|
-
* and return the value anyway. The caller gets their result; the next call
|
|
23
|
-
* will simply re-run `fn` and try to cache again. Caching is an optimisation,
|
|
24
|
-
* not a correctness requirement.
|
|
25
|
-
*
|
|
26
|
-
* # Cache hit semantics
|
|
27
|
-
*
|
|
28
|
-
* On a cache hit, `meta.source` is set to `'cache'` and `meta.attempts` is
|
|
29
|
-
* set to `0` — no work was performed. (v1.1.0 reported `attempts: 1` on
|
|
30
|
-
* cache hits, which was inconsistent with the documented meaning of
|
|
31
|
-
* `attempts`. v1.1.5 fixes this.)
|
|
32
|
-
*
|
|
33
|
-
* Failures are NEVER cached. Only successful values are stored.
|
|
34
|
-
*/
|
|
35
2
|
export declare function cachePolicy<T>(opts: CacheOptions): PolicyApplier<T>;
|
|
36
3
|
//# sourceMappingURL=cache.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../../src/policies/cache.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAS,YAAY,EAAE,aAAa,EAAiB,MAAM,mBAAmB,CAAA;
|
|
1
|
+
{"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../../src/policies/cache.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAS,YAAY,EAAE,aAAa,EAAiB,MAAM,mBAAmB,CAAA;AAiD1F,wBAAgB,WAAW,CAAC,CAAC,EAAE,IAAI,EAAE,YAAY,GAAG,aAAa,CAAC,CAAC,CAAC,CAqInE"}
|
package/dist/policies/cache.js
CHANGED
|
@@ -1,105 +1,121 @@
|
|
|
1
1
|
import { isSyncStore } from '../stores/base.js';
|
|
2
|
+
import { safeCall } from '../utils/safeCall.js';
|
|
3
|
+
import { raceAbort } from '../utils/abort.js';
|
|
4
|
+
import { LIMITS } from '../utils/limits.js';
|
|
2
5
|
const NS = 'cache:';
|
|
3
|
-
const INFLIGHT_NS = '
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
*
|
|
10
|
-
* On a sync store, the policy also stores the in-flight Promise under a
|
|
11
|
-
* separate `__inflight:cache:<key>` slot. Concurrent callers that miss the
|
|
12
|
-
* cache but find an in-flight Promise join it instead of launching duplicate
|
|
13
|
-
* work. This is the same mechanism `dedupePolicy` uses, applied internally
|
|
14
|
-
* to cache misses so users don't need to combine `cache` + `dedupe` to
|
|
15
|
-
* avoid stampedes.
|
|
16
|
-
*
|
|
17
|
-
* On an async store, single-flight is not possible (the same sync-store
|
|
18
|
-
* constraint as dedupe applies). Stampedes are a known limitation — document
|
|
19
|
-
* and pair with dedupe at a higher layer if you need single-flight semantics.
|
|
20
|
-
*
|
|
21
|
-
* # Fail-open writes
|
|
22
|
-
*
|
|
23
|
-
* If `store.set()` throws (e.g. Redis transient error), we swallow the error
|
|
24
|
-
* and return the value anyway. The caller gets their result; the next call
|
|
25
|
-
* will simply re-run `fn` and try to cache again. Caching is an optimisation,
|
|
26
|
-
* not a correctness requirement.
|
|
27
|
-
*
|
|
28
|
-
* # Cache hit semantics
|
|
29
|
-
*
|
|
30
|
-
* On a cache hit, `meta.source` is set to `'cache'` and `meta.attempts` is
|
|
31
|
-
* set to `0` — no work was performed. (v1.1.0 reported `attempts: 1` on
|
|
32
|
-
* cache hits, which was inconsistent with the documented meaning of
|
|
33
|
-
* `attempts`. v1.1.5 fixes this.)
|
|
34
|
-
*
|
|
35
|
-
* Failures are NEVER cached. Only successful values are stored.
|
|
36
|
-
*/
|
|
6
|
+
const INFLIGHT_NS = 'inflight:cache:';
|
|
7
|
+
let generationCounter = 0;
|
|
8
|
+
function nextGeneration() {
|
|
9
|
+
generationCounter = (generationCounter + 1) % Number.MAX_SAFE_INTEGER;
|
|
10
|
+
return generationCounter;
|
|
11
|
+
}
|
|
37
12
|
export function cachePolicy(opts) {
|
|
38
13
|
return (fn, ctx) => async (signal) => {
|
|
14
|
+
if (signal.aborted)
|
|
15
|
+
return Promise.reject(signal.reason);
|
|
39
16
|
const key = NS + ctx.key;
|
|
40
17
|
const inflightKey = INFLIGHT_NS + ctx.key;
|
|
41
|
-
// ─── Sync store: fast path with single-flight ────────────────────────
|
|
42
18
|
if (isSyncStore(ctx.store)) {
|
|
43
|
-
|
|
44
|
-
const
|
|
19
|
+
const store = ctx.store;
|
|
20
|
+
const obs = ctx.observability;
|
|
21
|
+
const hit = store.get(key);
|
|
45
22
|
if (hit) {
|
|
46
23
|
ctx.meta.source = 'cache';
|
|
47
24
|
ctx.meta.attempts = 0;
|
|
25
|
+
if (obs) {
|
|
26
|
+
const ageMs = Date.now() - hit.insertedAt;
|
|
27
|
+
safeCall(obs.hooks.onCacheHit, {
|
|
28
|
+
type: 'cache-hit', key: ctx.key, traceId: obs.traceId,
|
|
29
|
+
timestamp: Date.now(), ageMs: Math.max(0, ageMs),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
48
32
|
return hit.value;
|
|
49
33
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
34
|
+
if (obs) {
|
|
35
|
+
safeCall(obs.hooks.onCacheMiss, {
|
|
36
|
+
type: 'cache-miss', key: ctx.key, traceId: obs.traceId,
|
|
37
|
+
timestamp: Date.now(),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
const inflight = store.get(inflightKey);
|
|
41
|
+
if (inflight) {
|
|
42
|
+
try {
|
|
43
|
+
const value = await raceAbort(inflight.promise, signal);
|
|
44
|
+
ctx.meta.attempts = inflight.meta.attempts;
|
|
45
|
+
ctx.meta.source = inflight.meta.source;
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
if (signal.aborted) {
|
|
50
|
+
ctx.meta.attempts = 0;
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
ctx.meta.attempts = inflight.meta.attempts;
|
|
54
|
+
ctx.meta.source = inflight.meta.source;
|
|
55
|
+
}
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const generation = nextGeneration();
|
|
60
|
+
const rawPromise = Promise.resolve(fn(signal)).then((value) => {
|
|
56
61
|
try {
|
|
57
|
-
|
|
62
|
+
store.set(key, { value, insertedAt: Date.now() }, opts.ttl);
|
|
58
63
|
}
|
|
59
64
|
catch {
|
|
60
|
-
// Fail-open: cache write failure should not surface to caller.
|
|
61
|
-
// The value is still valid; next call will just re-run fn.
|
|
62
65
|
}
|
|
63
66
|
return value;
|
|
64
67
|
}, (err) => { throw err; });
|
|
65
|
-
// Try to publish the in-flight promise so concurrent callers can
|
|
66
|
-
// join (single-flight). If store.set throws, single-flight is
|
|
67
|
-
// disabled for this call — concurrent callers will all run fn.
|
|
68
|
-
// That's still correct, just less efficient.
|
|
69
68
|
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(() => { });
|
|
69
|
+
store.set(inflightKey, { promise: rawPromise, generation, meta: ctx.meta }, LIMITS.DEFAULT_INFLIGHT_TTL);
|
|
82
70
|
}
|
|
83
71
|
catch {
|
|
84
|
-
// Single-flight unavailable; proceed without publishing.
|
|
85
72
|
}
|
|
86
|
-
|
|
73
|
+
const cleanup = () => {
|
|
74
|
+
try {
|
|
75
|
+
const current = store.get(inflightKey);
|
|
76
|
+
if (current && current.generation === generation) {
|
|
77
|
+
try {
|
|
78
|
+
store.delete(inflightKey);
|
|
79
|
+
}
|
|
80
|
+
catch { }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
rawPromise.then(cleanup, cleanup).catch(() => { });
|
|
87
|
+
return raceAbort(rawPromise, signal);
|
|
87
88
|
}
|
|
88
|
-
|
|
89
|
+
const obs = ctx.observability;
|
|
89
90
|
const hit = await ctx.store.get(key);
|
|
91
|
+
if (signal.aborted)
|
|
92
|
+
return Promise.reject(signal.reason);
|
|
90
93
|
if (hit) {
|
|
91
94
|
ctx.meta.source = 'cache';
|
|
92
95
|
ctx.meta.attempts = 0;
|
|
96
|
+
if (obs) {
|
|
97
|
+
const ageMs = Date.now() - hit.insertedAt;
|
|
98
|
+
safeCall(obs.hooks.onCacheHit, {
|
|
99
|
+
type: 'cache-hit', key: ctx.key, traceId: obs.traceId,
|
|
100
|
+
timestamp: Date.now(), ageMs: Math.max(0, ageMs),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
93
103
|
return hit.value;
|
|
94
104
|
}
|
|
105
|
+
if (obs) {
|
|
106
|
+
safeCall(obs.hooks.onCacheMiss, {
|
|
107
|
+
type: 'cache-miss', key: ctx.key, traceId: obs.traceId,
|
|
108
|
+
timestamp: Date.now(),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
95
111
|
const value = await fn(signal);
|
|
112
|
+
if (signal.aborted)
|
|
113
|
+
return Promise.reject(signal.reason);
|
|
96
114
|
try {
|
|
97
|
-
await ctx.store.set(key, { value }, opts.ttl);
|
|
115
|
+
await ctx.store.set(key, { value, insertedAt: Date.now() }, opts.ttl);
|
|
98
116
|
}
|
|
99
117
|
catch {
|
|
100
|
-
// Fail-open: see sync path comment.
|
|
101
118
|
}
|
|
102
119
|
return value;
|
|
103
120
|
};
|
|
104
121
|
}
|
|
105
|
-
//# sourceMappingURL=cache.js.map
|
|
@@ -0,0 +1,179 @@
|
|
|
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, strategy) {
|
|
9
|
+
const existing = store.get(NS + key);
|
|
10
|
+
if (existing)
|
|
11
|
+
return existing;
|
|
12
|
+
if (strategy === 'count') {
|
|
13
|
+
return {
|
|
14
|
+
strategy: 'count',
|
|
15
|
+
outcomes: [],
|
|
16
|
+
index: 0,
|
|
17
|
+
failures: 0,
|
|
18
|
+
filled: 0,
|
|
19
|
+
isOpen: false,
|
|
20
|
+
openedAt: 0,
|
|
21
|
+
halfOpen: false,
|
|
22
|
+
lastFailureTime: 0,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
strategy: 'consecutive',
|
|
27
|
+
failures: 0,
|
|
28
|
+
lastFailureTime: 0,
|
|
29
|
+
isOpen: false,
|
|
30
|
+
openedAt: 0,
|
|
31
|
+
halfOpen: false,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function setState(store, key, state) {
|
|
35
|
+
store.set(NS + key, state);
|
|
36
|
+
}
|
|
37
|
+
function recordCountOutcome(state, success, windowSize) {
|
|
38
|
+
if (state.outcomes.length < windowSize) {
|
|
39
|
+
state.outcomes.push(success);
|
|
40
|
+
state.filled = state.outcomes.length;
|
|
41
|
+
if (!success)
|
|
42
|
+
state.failures++;
|
|
43
|
+
state.index = (state.index + 1) % windowSize;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (state.outcomes[state.index] === false)
|
|
47
|
+
state.failures--;
|
|
48
|
+
state.outcomes[state.index] = success;
|
|
49
|
+
if (!success)
|
|
50
|
+
state.failures++;
|
|
51
|
+
state.index = (state.index + 1) % windowSize;
|
|
52
|
+
state.filled = windowSize;
|
|
53
|
+
}
|
|
54
|
+
function countFailureRate(state) {
|
|
55
|
+
if (state.filled === 0)
|
|
56
|
+
return 0;
|
|
57
|
+
return state.failures / state.filled;
|
|
58
|
+
}
|
|
59
|
+
function resetCountState(state) {
|
|
60
|
+
state.outcomes = [];
|
|
61
|
+
state.index = 0;
|
|
62
|
+
state.failures = 0;
|
|
63
|
+
state.filled = 0;
|
|
64
|
+
state.isOpen = false;
|
|
65
|
+
state.openedAt = 0;
|
|
66
|
+
state.halfOpen = false;
|
|
67
|
+
state.lastFailureTime = 0;
|
|
68
|
+
}
|
|
69
|
+
function circuitBreakerPolicy(opts) {
|
|
70
|
+
const threshold = Math.max(1, Math.floor(opts.threshold));
|
|
71
|
+
const cooldownMs = opts.cooldownMs;
|
|
72
|
+
const resetTimeoutMs = opts.resetTimeoutMs ?? Number.POSITIVE_INFINITY;
|
|
73
|
+
const strategy = opts.strategy ?? 'consecutive';
|
|
74
|
+
const countSize = strategy === 'count' ? Math.max(1, Math.floor(opts.countSize ?? 100)) : 0;
|
|
75
|
+
const countThreshold = strategy === 'count'
|
|
76
|
+
? Math.min(1, Math.max(0, opts.countThreshold ?? 0.5))
|
|
77
|
+
: 0;
|
|
78
|
+
const countMinimumCalls = strategy === 'count'
|
|
79
|
+
? Math.max(1, Math.floor(opts.countMinimumCalls ?? countSize))
|
|
80
|
+
: 0;
|
|
81
|
+
const applier = (fn, ctx) => {
|
|
82
|
+
const syncCtx = ctx;
|
|
83
|
+
return async (signal) => {
|
|
84
|
+
const key = syncCtx.key;
|
|
85
|
+
const now = Date.now();
|
|
86
|
+
const state = getState(syncCtx.store, key, strategy);
|
|
87
|
+
if (state.isOpen) {
|
|
88
|
+
const elapsed = now - state.openedAt;
|
|
89
|
+
if (elapsed >= cooldownMs) {
|
|
90
|
+
state.isOpen = false;
|
|
91
|
+
state.halfOpen = true;
|
|
92
|
+
setState(syncCtx.store, key, state);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
throw new errors_js_1.CircuitBreakerOpenError(key, cooldownMs - elapsed);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else if (state.halfOpen) {
|
|
99
|
+
throw new errors_js_1.CircuitBreakerOpenError(key, 0);
|
|
100
|
+
}
|
|
101
|
+
if (now - state.lastFailureTime > resetTimeoutMs && state.lastFailureTime > 0) {
|
|
102
|
+
if (!state.halfOpen) {
|
|
103
|
+
if (state.strategy === 'count') {
|
|
104
|
+
resetCountState(state);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
state.failures = 0;
|
|
108
|
+
state.isOpen = false;
|
|
109
|
+
state.openedAt = 0;
|
|
110
|
+
state.halfOpen = false;
|
|
111
|
+
}
|
|
112
|
+
setState(syncCtx.store, key, state);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
const result = await fn(signal);
|
|
117
|
+
const updated = getState(syncCtx.store, key, strategy);
|
|
118
|
+
const wasHalfOpen = updated.halfOpen;
|
|
119
|
+
if (wasHalfOpen && updated.strategy === 'count') {
|
|
120
|
+
resetCountState(updated);
|
|
121
|
+
}
|
|
122
|
+
else if (updated.strategy === 'count') {
|
|
123
|
+
recordCountOutcome(updated, true, countSize);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
updated.failures = 0;
|
|
127
|
+
}
|
|
128
|
+
if (wasHalfOpen) {
|
|
129
|
+
updated.isOpen = false;
|
|
130
|
+
updated.halfOpen = false;
|
|
131
|
+
}
|
|
132
|
+
const isIdle = updated.failures === 0 && !updated.isOpen && !updated.halfOpen;
|
|
133
|
+
if (isIdle) {
|
|
134
|
+
syncCtx.store.delete(NS + key);
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
setState(syncCtx.store, key, updated);
|
|
138
|
+
}
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
if (signal.aborted && ((0, abort_js_1.isAbortError)(err) || err === signal.reason)) {
|
|
143
|
+
const updated = getState(syncCtx.store, key, strategy);
|
|
144
|
+
if (updated.failures === 0 && !updated.isOpen && !updated.halfOpen) {
|
|
145
|
+
syncCtx.store.delete(NS + key);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
setState(syncCtx.store, key, updated);
|
|
149
|
+
}
|
|
150
|
+
throw err;
|
|
151
|
+
}
|
|
152
|
+
const updated = getState(syncCtx.store, key, strategy);
|
|
153
|
+
const wasHalfOpen = updated.halfOpen;
|
|
154
|
+
updated.lastFailureTime = Date.now();
|
|
155
|
+
updated.halfOpen = false;
|
|
156
|
+
if (updated.strategy === 'count') {
|
|
157
|
+
recordCountOutcome(updated, false, countSize);
|
|
158
|
+
const rate = countFailureRate(updated);
|
|
159
|
+
const enoughCalls = updated.filled >= countMinimumCalls;
|
|
160
|
+
if (wasHalfOpen || (enoughCalls && rate > countThreshold)) {
|
|
161
|
+
updated.isOpen = true;
|
|
162
|
+
updated.openedAt = Date.now();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
updated.failures++;
|
|
167
|
+
if (updated.failures >= threshold || wasHalfOpen) {
|
|
168
|
+
updated.isOpen = true;
|
|
169
|
+
updated.openedAt = Date.now();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
setState(syncCtx.store, key, updated);
|
|
173
|
+
throw err;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
};
|
|
177
|
+
applier[executor_js_1.REQUIRES_SYNC_STORE] = true;
|
|
178
|
+
return applier;
|
|
179
|
+
}
|
|
@@ -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;AAoGnG,wBAAgB,oBAAoB,CAAC,CAAC,EAAE,IAAI,EAAE,qBAAqB,GAAG,aAAa,CAAC,CAAC,CAAC,CAiIrF"}
|