actly 1.2.0 → 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 +326 -218
- package/SECURITY.md +56 -0
- package/dist/core/act.cjs +406 -220
- package/dist/core/act.d.ts +6 -84
- package/dist/core/act.d.ts.map +1 -1
- package/dist/core/act.js +405 -222
- package/dist/core/executor.cjs +0 -27
- package/dist/core/executor.d.ts +0 -41
- package/dist/core/executor.d.ts.map +1 -1
- package/dist/core/executor.js +0 -28
- package/dist/core/health.cjs +146 -17
- package/dist/core/health.d.ts +20 -5
- package/dist/core/health.d.ts.map +1 -1
- package/dist/core/health.js +140 -18
- package/dist/core/shutdown.cjs +19 -8
- package/dist/core/shutdown.d.ts +1 -4
- package/dist/core/shutdown.d.ts.map +1 -1
- package/dist/core/shutdown.js +18 -9
- package/dist/core/tenant.cjs +61 -19
- package/dist/core/tenant.d.ts +5 -10
- package/dist/core/tenant.d.ts.map +1 -1
- package/dist/core/tenant.js +61 -20
- package/dist/errors.cjs +70 -103
- package/dist/errors.d.ts +33 -90
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +66 -103
- package/dist/index.cjs +25 -22
- package/dist/index.d.ts +12 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -15
- package/dist/observability.cjs +0 -39
- package/dist/observability.d.ts +19 -78
- package/dist/observability.d.ts.map +1 -1
- package/dist/observability.js +0 -40
- package/dist/policies/bulkhead.cjs +44 -4
- package/dist/policies/bulkhead.d.ts +0 -0
- package/dist/policies/bulkhead.d.ts.map +1 -1
- package/dist/policies/bulkhead.js +44 -5
- package/dist/policies/cache.cjs +22 -142
- package/dist/policies/cache.d.ts +0 -46
- package/dist/policies/cache.d.ts.map +1 -1
- package/dist/policies/cache.js +22 -143
- package/dist/policies/circuitBreaker.cjs +125 -31
- package/dist/policies/circuitBreaker.d.ts +0 -0
- package/dist/policies/circuitBreaker.d.ts.map +1 -1
- package/dist/policies/circuitBreaker.js +125 -32
- package/dist/policies/dedupe.cjs +19 -66
- package/dist/policies/dedupe.d.ts +0 -23
- package/dist/policies/dedupe.d.ts.map +1 -1
- package/dist/policies/dedupe.js +19 -67
- 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 +15 -5
- package/dist/policies/rateLimit.d.ts +0 -0
- package/dist/policies/rateLimit.d.ts.map +1 -1
- package/dist/policies/rateLimit.js +15 -6
- package/dist/policies/retry.cjs +100 -65
- package/dist/policies/retry.d.ts +0 -27
- package/dist/policies/retry.d.ts.map +1 -1
- package/dist/policies/retry.js +100 -66
- package/dist/policies/timeout.cjs +32 -60
- package/dist/policies/timeout.d.ts +0 -13
- package/dist/policies/timeout.d.ts.map +1 -1
- package/dist/policies/timeout.js +32 -71
- 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 +75 -88
- package/dist/stores/memory.d.ts +7 -74
- package/dist/stores/memory.d.ts.map +1 -1
- package/dist/stores/memory.js +75 -89
- 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 +17 -307
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +0 -2
- package/dist/utils/abort.cjs +14 -111
- package/dist/utils/abort.d.ts +3 -74
- package/dist/utils/abort.d.ts.map +1 -1
- package/dist/utils/abort.js +14 -112
- package/dist/utils/abortPool.cjs +1 -2
- package/dist/utils/abortPool.d.ts +0 -0
- package/dist/utils/abortPool.d.ts.map +1 -1
- package/dist/utils/abortPool.js +1 -3
- package/dist/utils/backoff.cjs +3 -21
- package/dist/utils/backoff.d.ts +0 -11
- package/dist/utils/backoff.d.ts.map +1 -1
- package/dist/utils/backoff.js +3 -22
- 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 +13 -45
- package/dist/utils/key.d.ts +0 -27
- package/dist/utils/key.d.ts.map +1 -1
- package/dist/utils/key.js +13 -46
- package/dist/utils/limits.cjs +9 -27
- package/dist/utils/limits.d.ts +9 -27
- package/dist/utils/limits.d.ts.map +1 -1
- package/dist/utils/limits.js +9 -28
- 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 +22 -1
- package/dist/utils/sanitize.d.ts +0 -0
- package/dist/utils/sanitize.d.ts.map +1 -1
- package/dist/utils/sanitize.js +22 -2
- package/dist/utils/validate.cjs +81 -18
- package/dist/utils/validate.d.ts +6 -19
- package/dist/utils/validate.d.ts.map +1 -1
- package/dist/utils/validate.js +81 -19
- package/package.json +38 -4
- package/dist/core/act.js.map +0 -1
- package/dist/core/executor.js.map +0 -1
- package/dist/core/health.js.map +0 -1
- package/dist/core/shutdown.js.map +0 -1
- package/dist/core/tenant.js.map +0 -1
- package/dist/errors.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/observability.js.map +0 -1
- package/dist/policies/bulkhead.js.map +0 -1
- package/dist/policies/cache.js.map +0 -1
- package/dist/policies/circuitBreaker.js.map +0 -1
- package/dist/policies/dedupe.js.map +0 -1
- package/dist/policies/rateLimit.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/abortPool.js.map +0 -1
- package/dist/utils/backoff.js.map +0 -1
- package/dist/utils/key.js.map +0 -1
- package/dist/utils/limits.js.map +0 -1
- package/dist/utils/sanitize.js.map +0 -1
- package/dist/utils/validate.js.map +0 -1
|
@@ -5,27 +5,88 @@ const executor_js_1 = require("../core/executor.js");
|
|
|
5
5
|
const errors_js_1 = require("../errors.js");
|
|
6
6
|
const abort_js_1 = require("../utils/abort.js");
|
|
7
7
|
const NS = 'cb:';
|
|
8
|
-
function getState(store, key) {
|
|
9
|
-
|
|
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
|
+
};
|
|
10
33
|
}
|
|
11
34
|
function setState(store, key, state) {
|
|
12
35
|
store.set(NS + key, state);
|
|
13
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
|
+
}
|
|
14
69
|
function circuitBreakerPolicy(opts) {
|
|
15
70
|
const threshold = Math.max(1, Math.floor(opts.threshold));
|
|
16
71
|
const cooldownMs = opts.cooldownMs;
|
|
17
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;
|
|
18
81
|
const applier = (fn, ctx) => {
|
|
19
82
|
const syncCtx = ctx;
|
|
20
83
|
return async (signal) => {
|
|
21
84
|
const key = syncCtx.key;
|
|
22
85
|
const now = Date.now();
|
|
23
|
-
|
|
24
|
-
const state = getState(syncCtx.store, key);
|
|
86
|
+
const state = getState(syncCtx.store, key, strategy);
|
|
25
87
|
if (state.isOpen) {
|
|
26
88
|
const elapsed = now - state.openedAt;
|
|
27
89
|
if (elapsed >= cooldownMs) {
|
|
28
|
-
// Transition to half-open: allow only ONE probe call
|
|
29
90
|
state.isOpen = false;
|
|
30
91
|
state.halfOpen = true;
|
|
31
92
|
setState(syncCtx.store, key, state);
|
|
@@ -35,45 +96,78 @@ function circuitBreakerPolicy(opts) {
|
|
|
35
96
|
}
|
|
36
97
|
}
|
|
37
98
|
else if (state.halfOpen) {
|
|
38
|
-
// Another call is already probing — block this one
|
|
39
99
|
throw new errors_js_1.CircuitBreakerOpenError(key, 0);
|
|
40
100
|
}
|
|
41
|
-
if (now - state.lastFailureTime > resetTimeoutMs && state.
|
|
42
|
-
state.
|
|
43
|
-
|
|
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
|
+
}
|
|
44
114
|
}
|
|
45
115
|
try {
|
|
46
116
|
const result = await fn(signal);
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
updated.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
+
}
|
|
53
139
|
return result;
|
|
54
140
|
}
|
|
55
141
|
catch (err) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
updated.isOpen = true;
|
|
64
|
-
updated.openedAt = Date.now();
|
|
65
|
-
updated.halfOpen = false;
|
|
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);
|
|
66
149
|
}
|
|
67
|
-
setState(syncCtx.store, key, updated);
|
|
68
150
|
throw err;
|
|
69
151
|
}
|
|
70
|
-
const updated = getState(syncCtx.store, key);
|
|
71
|
-
updated.
|
|
152
|
+
const updated = getState(syncCtx.store, key, strategy);
|
|
153
|
+
const wasHalfOpen = updated.halfOpen;
|
|
72
154
|
updated.lastFailureTime = Date.now();
|
|
73
155
|
updated.halfOpen = false;
|
|
74
|
-
if (updated.
|
|
75
|
-
updated
|
|
76
|
-
|
|
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
|
+
}
|
|
77
171
|
}
|
|
78
172
|
setState(syncCtx.store, key, updated);
|
|
79
173
|
throw err;
|
|
File without changes
|
|
@@ -1 +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;
|
|
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"}
|
|
@@ -2,27 +2,88 @@ import { REQUIRES_SYNC_STORE } from '../core/executor.js';
|
|
|
2
2
|
import { CircuitBreakerOpenError } from '../errors.js';
|
|
3
3
|
import { isAbortError } from '../utils/abort.js';
|
|
4
4
|
const NS = 'cb:';
|
|
5
|
-
function getState(store, key) {
|
|
6
|
-
|
|
5
|
+
function getState(store, key, strategy) {
|
|
6
|
+
const existing = store.get(NS + key);
|
|
7
|
+
if (existing)
|
|
8
|
+
return existing;
|
|
9
|
+
if (strategy === 'count') {
|
|
10
|
+
return {
|
|
11
|
+
strategy: 'count',
|
|
12
|
+
outcomes: [],
|
|
13
|
+
index: 0,
|
|
14
|
+
failures: 0,
|
|
15
|
+
filled: 0,
|
|
16
|
+
isOpen: false,
|
|
17
|
+
openedAt: 0,
|
|
18
|
+
halfOpen: false,
|
|
19
|
+
lastFailureTime: 0,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
strategy: 'consecutive',
|
|
24
|
+
failures: 0,
|
|
25
|
+
lastFailureTime: 0,
|
|
26
|
+
isOpen: false,
|
|
27
|
+
openedAt: 0,
|
|
28
|
+
halfOpen: false,
|
|
29
|
+
};
|
|
7
30
|
}
|
|
8
31
|
function setState(store, key, state) {
|
|
9
32
|
store.set(NS + key, state);
|
|
10
33
|
}
|
|
34
|
+
function recordCountOutcome(state, success, windowSize) {
|
|
35
|
+
if (state.outcomes.length < windowSize) {
|
|
36
|
+
state.outcomes.push(success);
|
|
37
|
+
state.filled = state.outcomes.length;
|
|
38
|
+
if (!success)
|
|
39
|
+
state.failures++;
|
|
40
|
+
state.index = (state.index + 1) % windowSize;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (state.outcomes[state.index] === false)
|
|
44
|
+
state.failures--;
|
|
45
|
+
state.outcomes[state.index] = success;
|
|
46
|
+
if (!success)
|
|
47
|
+
state.failures++;
|
|
48
|
+
state.index = (state.index + 1) % windowSize;
|
|
49
|
+
state.filled = windowSize;
|
|
50
|
+
}
|
|
51
|
+
function countFailureRate(state) {
|
|
52
|
+
if (state.filled === 0)
|
|
53
|
+
return 0;
|
|
54
|
+
return state.failures / state.filled;
|
|
55
|
+
}
|
|
56
|
+
function resetCountState(state) {
|
|
57
|
+
state.outcomes = [];
|
|
58
|
+
state.index = 0;
|
|
59
|
+
state.failures = 0;
|
|
60
|
+
state.filled = 0;
|
|
61
|
+
state.isOpen = false;
|
|
62
|
+
state.openedAt = 0;
|
|
63
|
+
state.halfOpen = false;
|
|
64
|
+
state.lastFailureTime = 0;
|
|
65
|
+
}
|
|
11
66
|
export function circuitBreakerPolicy(opts) {
|
|
12
67
|
const threshold = Math.max(1, Math.floor(opts.threshold));
|
|
13
68
|
const cooldownMs = opts.cooldownMs;
|
|
14
69
|
const resetTimeoutMs = opts.resetTimeoutMs ?? Number.POSITIVE_INFINITY;
|
|
70
|
+
const strategy = opts.strategy ?? 'consecutive';
|
|
71
|
+
const countSize = strategy === 'count' ? Math.max(1, Math.floor(opts.countSize ?? 100)) : 0;
|
|
72
|
+
const countThreshold = strategy === 'count'
|
|
73
|
+
? Math.min(1, Math.max(0, opts.countThreshold ?? 0.5))
|
|
74
|
+
: 0;
|
|
75
|
+
const countMinimumCalls = strategy === 'count'
|
|
76
|
+
? Math.max(1, Math.floor(opts.countMinimumCalls ?? countSize))
|
|
77
|
+
: 0;
|
|
15
78
|
const applier = (fn, ctx) => {
|
|
16
79
|
const syncCtx = ctx;
|
|
17
80
|
return async (signal) => {
|
|
18
81
|
const key = syncCtx.key;
|
|
19
82
|
const now = Date.now();
|
|
20
|
-
|
|
21
|
-
const state = getState(syncCtx.store, key);
|
|
83
|
+
const state = getState(syncCtx.store, key, strategy);
|
|
22
84
|
if (state.isOpen) {
|
|
23
85
|
const elapsed = now - state.openedAt;
|
|
24
86
|
if (elapsed >= cooldownMs) {
|
|
25
|
-
// Transition to half-open: allow only ONE probe call
|
|
26
87
|
state.isOpen = false;
|
|
27
88
|
state.halfOpen = true;
|
|
28
89
|
setState(syncCtx.store, key, state);
|
|
@@ -32,45 +93,78 @@ export function circuitBreakerPolicy(opts) {
|
|
|
32
93
|
}
|
|
33
94
|
}
|
|
34
95
|
else if (state.halfOpen) {
|
|
35
|
-
// Another call is already probing — block this one
|
|
36
96
|
throw new CircuitBreakerOpenError(key, 0);
|
|
37
97
|
}
|
|
38
|
-
if (now - state.lastFailureTime > resetTimeoutMs && state.
|
|
39
|
-
state.
|
|
40
|
-
|
|
98
|
+
if (now - state.lastFailureTime > resetTimeoutMs && state.lastFailureTime > 0) {
|
|
99
|
+
if (!state.halfOpen) {
|
|
100
|
+
if (state.strategy === 'count') {
|
|
101
|
+
resetCountState(state);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
state.failures = 0;
|
|
105
|
+
state.isOpen = false;
|
|
106
|
+
state.openedAt = 0;
|
|
107
|
+
state.halfOpen = false;
|
|
108
|
+
}
|
|
109
|
+
setState(syncCtx.store, key, state);
|
|
110
|
+
}
|
|
41
111
|
}
|
|
42
112
|
try {
|
|
43
113
|
const result = await fn(signal);
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
updated.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
114
|
+
const updated = getState(syncCtx.store, key, strategy);
|
|
115
|
+
const wasHalfOpen = updated.halfOpen;
|
|
116
|
+
if (wasHalfOpen && updated.strategy === 'count') {
|
|
117
|
+
resetCountState(updated);
|
|
118
|
+
}
|
|
119
|
+
else if (updated.strategy === 'count') {
|
|
120
|
+
recordCountOutcome(updated, true, countSize);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
updated.failures = 0;
|
|
124
|
+
}
|
|
125
|
+
if (wasHalfOpen) {
|
|
126
|
+
updated.isOpen = false;
|
|
127
|
+
updated.halfOpen = false;
|
|
128
|
+
}
|
|
129
|
+
const isIdle = updated.failures === 0 && !updated.isOpen && !updated.halfOpen;
|
|
130
|
+
if (isIdle) {
|
|
131
|
+
syncCtx.store.delete(NS + key);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
setState(syncCtx.store, key, updated);
|
|
135
|
+
}
|
|
50
136
|
return result;
|
|
51
137
|
}
|
|
52
138
|
catch (err) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
updated.isOpen = true;
|
|
61
|
-
updated.openedAt = Date.now();
|
|
62
|
-
updated.halfOpen = false;
|
|
139
|
+
if (signal.aborted && (isAbortError(err) || err === signal.reason)) {
|
|
140
|
+
const updated = getState(syncCtx.store, key, strategy);
|
|
141
|
+
if (updated.failures === 0 && !updated.isOpen && !updated.halfOpen) {
|
|
142
|
+
syncCtx.store.delete(NS + key);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
setState(syncCtx.store, key, updated);
|
|
63
146
|
}
|
|
64
|
-
setState(syncCtx.store, key, updated);
|
|
65
147
|
throw err;
|
|
66
148
|
}
|
|
67
|
-
const updated = getState(syncCtx.store, key);
|
|
68
|
-
updated.
|
|
149
|
+
const updated = getState(syncCtx.store, key, strategy);
|
|
150
|
+
const wasHalfOpen = updated.halfOpen;
|
|
69
151
|
updated.lastFailureTime = Date.now();
|
|
70
152
|
updated.halfOpen = false;
|
|
71
|
-
if (updated.
|
|
72
|
-
updated
|
|
73
|
-
|
|
153
|
+
if (updated.strategy === 'count') {
|
|
154
|
+
recordCountOutcome(updated, false, countSize);
|
|
155
|
+
const rate = countFailureRate(updated);
|
|
156
|
+
const enoughCalls = updated.filled >= countMinimumCalls;
|
|
157
|
+
if (wasHalfOpen || (enoughCalls && rate > countThreshold)) {
|
|
158
|
+
updated.isOpen = true;
|
|
159
|
+
updated.openedAt = Date.now();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
updated.failures++;
|
|
164
|
+
if (updated.failures >= threshold || wasHalfOpen) {
|
|
165
|
+
updated.isOpen = true;
|
|
166
|
+
updated.openedAt = Date.now();
|
|
167
|
+
}
|
|
74
168
|
}
|
|
75
169
|
setState(syncCtx.store, key, updated);
|
|
76
170
|
throw err;
|
|
@@ -80,4 +174,3 @@ export function circuitBreakerPolicy(opts) {
|
|
|
80
174
|
applier[REQUIRES_SYNC_STORE] = true;
|
|
81
175
|
return applier;
|
|
82
176
|
}
|
|
83
|
-
//# sourceMappingURL=circuitBreaker.js.map
|
package/dist/policies/dedupe.cjs
CHANGED
|
@@ -3,91 +3,47 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.dedupePolicy = dedupePolicy;
|
|
4
4
|
const executor_js_1 = require("../core/executor.js");
|
|
5
5
|
const abort_js_1 = require("../utils/abort.js");
|
|
6
|
-
|
|
6
|
+
const safeCall_js_1 = require("../utils/safeCall.js");
|
|
7
|
+
const limits_js_1 = require("../utils/limits.js");
|
|
7
8
|
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
9
|
let generationCounter = 0;
|
|
14
10
|
function nextGeneration() {
|
|
15
11
|
generationCounter = (generationCounter + 1) % Number.MAX_SAFE_INTEGER;
|
|
16
12
|
return generationCounter;
|
|
17
13
|
}
|
|
18
|
-
/**
|
|
19
|
-
* Collapse concurrent calls that share the same key into one in-flight Promise.
|
|
20
|
-
*
|
|
21
|
-
* # Properties
|
|
22
|
-
*
|
|
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.
|
|
31
|
-
*
|
|
32
|
-
* # INVARIANT: requires SyncStateStore
|
|
33
|
-
*
|
|
34
|
-
* The read-then-write that makes deduplication work must happen in a single
|
|
35
|
-
* synchronous frame. An async store would introduce an `await` between
|
|
36
|
-
* `get()` and `set()`, letting two concurrent callers both see a miss and
|
|
37
|
-
* both launch work. The `REQUIRES_SYNC_STORE` symbol on the returned
|
|
38
|
-
* `PolicyApplier` lets `execute()` enforce this at runtime for JS callers
|
|
39
|
-
* that bypass TypeScript.
|
|
40
|
-
*/
|
|
41
14
|
function dedupePolicy(opts = { enabled: true }) {
|
|
42
|
-
const inflightTtl = opts.inflightTtl;
|
|
15
|
+
const inflightTtl = opts.inflightTtl ?? limits_js_1.LIMITS.DEFAULT_INFLIGHT_TTL;
|
|
43
16
|
const applier = (fn, ctx) => {
|
|
44
|
-
// Cast is safe: execute() verifies isSyncStore(ctx.store) before calling
|
|
45
|
-
// any policy tagged with REQUIRES_SYNC_STORE.
|
|
46
17
|
const syncCtx = ctx;
|
|
47
18
|
return async (signal) => {
|
|
48
19
|
const key = NS + syncCtx.key;
|
|
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).
|
|
55
20
|
const existing = syncCtx.store.get(key);
|
|
56
21
|
if (existing) {
|
|
22
|
+
const obs = syncCtx.observability;
|
|
23
|
+
if (obs) {
|
|
24
|
+
obs.joinerCounter++;
|
|
25
|
+
(0, safeCall_js_1.safeCall)(obs.hooks.onDedupeJoin, {
|
|
26
|
+
type: 'dedupe-join', key: syncCtx.key, traceId: obs.traceId,
|
|
27
|
+
timestamp: Date.now(), joinerPosition: obs.joinerCounter,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
57
30
|
try {
|
|
58
31
|
const value = await (0, abort_js_1.raceAbort)(existing.promise, signal);
|
|
59
|
-
// Success — copy originator's final meta (attempts, source)
|
|
60
|
-
// so the joiner's ActResult reflects the real effort.
|
|
61
32
|
syncCtx.meta.attempts = existing.meta.attempts;
|
|
62
33
|
syncCtx.meta.source = existing.meta.source;
|
|
63
34
|
return value;
|
|
64
35
|
}
|
|
65
36
|
catch (err) {
|
|
66
|
-
// Joiner either aborted (their own signal) or got the
|
|
67
|
-
// originator's settled error.
|
|
68
37
|
if (signal.aborted) {
|
|
69
|
-
// Joiner's own signal aborted before originator settled.
|
|
70
|
-
// They did no work — report `attempts: 0` (truthful).
|
|
71
38
|
syncCtx.meta.attempts = 0;
|
|
72
|
-
// source stays 'fresh' (default) — joiner didn't read from cache
|
|
73
39
|
}
|
|
74
40
|
else {
|
|
75
|
-
// Originator settled with failure — copy its final meta.
|
|
76
41
|
syncCtx.meta.attempts = existing.meta.attempts;
|
|
77
42
|
syncCtx.meta.source = existing.meta.source;
|
|
78
43
|
}
|
|
79
44
|
throw err;
|
|
80
45
|
}
|
|
81
46
|
}
|
|
82
|
-
// ─── Originator path: start the work and publish the promise. ──────
|
|
83
|
-
//
|
|
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
47
|
const generation = nextGeneration();
|
|
92
48
|
const rawPromise = Promise.resolve(fn(signal));
|
|
93
49
|
const entry = {
|
|
@@ -96,20 +52,17 @@ function dedupePolicy(opts = { enabled: true }) {
|
|
|
96
52
|
generation,
|
|
97
53
|
};
|
|
98
54
|
syncCtx.store.set(key, entry, inflightTtl);
|
|
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
55
|
const cleanup = () => {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
56
|
+
try {
|
|
57
|
+
const current = syncCtx.store.get(key);
|
|
58
|
+
if (current && current.generation === generation) {
|
|
59
|
+
syncCtx.store.delete(key);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
106
63
|
}
|
|
107
64
|
};
|
|
108
|
-
|
|
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.
|
|
65
|
+
rawPromise.then(cleanup, cleanup).catch(() => { });
|
|
113
66
|
return (0, abort_js_1.raceAbort)(rawPromise, signal);
|
|
114
67
|
};
|
|
115
68
|
};
|
|
@@ -1,26 +1,3 @@
|
|
|
1
1
|
import type { PolicyApplier, DedupeOptions } from '../types/index.js';
|
|
2
|
-
/**
|
|
3
|
-
* Collapse concurrent calls that share the same key into one in-flight Promise.
|
|
4
|
-
*
|
|
5
|
-
* # Properties
|
|
6
|
-
*
|
|
7
|
-
* - **Generation-safe cleanup**: stale originators never delete newer
|
|
8
|
-
* entries when `inflightTtl` triggers replacement.
|
|
9
|
-
* - **Joiner isolation**: originator's caller-signal abort does NOT
|
|
10
|
-
* propagate to joiners. Each joiner races the shared in-flight promise
|
|
11
|
-
* against their OWN signal only.
|
|
12
|
-
* - **Truthful joiner attempts**: joiners that abort before the originator
|
|
13
|
-
* settles report `attempts: 0` (they did no work), not the originator's
|
|
14
|
-
* in-progress count.
|
|
15
|
-
*
|
|
16
|
-
* # INVARIANT: requires SyncStateStore
|
|
17
|
-
*
|
|
18
|
-
* The read-then-write that makes deduplication work must happen in a single
|
|
19
|
-
* synchronous frame. An async store would introduce an `await` between
|
|
20
|
-
* `get()` and `set()`, letting two concurrent callers both see a miss and
|
|
21
|
-
* both launch work. The `REQUIRES_SYNC_STORE` symbol on the returned
|
|
22
|
-
* `PolicyApplier` lets `execute()` enforce this at runtime for JS callers
|
|
23
|
-
* that bypass TypeScript.
|
|
24
|
-
*/
|
|
25
2
|
export declare function dedupePolicy<T>(opts?: DedupeOptions): PolicyApplier<T>;
|
|
26
3
|
//# sourceMappingURL=dedupe.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dedupe.d.ts","sourceRoot":"","sources":["../../src/policies/dedupe.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAS,aAAa,EAAiB,aAAa,EAAE,MAAM,mBAAmB,CAAA;
|
|
1
|
+
{"version":3,"file":"dedupe.d.ts","sourceRoot":"","sources":["../../src/policies/dedupe.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAS,aAAa,EAAiB,aAAa,EAAE,MAAM,mBAAmB,CAAA;AA0D3F,wBAAgB,YAAY,CAAC,CAAC,EAAE,IAAI,GAAE,aAAiC,GAAG,aAAa,CAAC,CAAC,CAAC,CA6FzF"}
|