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
|
@@ -0,0 +1,176 @@
|
|
|
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, 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
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function setState(store, key, state) {
|
|
32
|
+
store.set(NS + key, state);
|
|
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
|
+
}
|
|
66
|
+
export function circuitBreakerPolicy(opts) {
|
|
67
|
+
const threshold = Math.max(1, Math.floor(opts.threshold));
|
|
68
|
+
const cooldownMs = opts.cooldownMs;
|
|
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;
|
|
78
|
+
const applier = (fn, ctx) => {
|
|
79
|
+
const syncCtx = ctx;
|
|
80
|
+
return async (signal) => {
|
|
81
|
+
const key = syncCtx.key;
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
const state = getState(syncCtx.store, key, strategy);
|
|
84
|
+
if (state.isOpen) {
|
|
85
|
+
const elapsed = now - state.openedAt;
|
|
86
|
+
if (elapsed >= cooldownMs) {
|
|
87
|
+
state.isOpen = false;
|
|
88
|
+
state.halfOpen = true;
|
|
89
|
+
setState(syncCtx.store, key, state);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
throw new CircuitBreakerOpenError(key, cooldownMs - elapsed);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
else if (state.halfOpen) {
|
|
96
|
+
throw new CircuitBreakerOpenError(key, 0);
|
|
97
|
+
}
|
|
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
|
+
}
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const result = await fn(signal);
|
|
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
|
+
}
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
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);
|
|
146
|
+
}
|
|
147
|
+
throw err;
|
|
148
|
+
}
|
|
149
|
+
const updated = getState(syncCtx.store, key, strategy);
|
|
150
|
+
const wasHalfOpen = updated.halfOpen;
|
|
151
|
+
updated.lastFailureTime = Date.now();
|
|
152
|
+
updated.halfOpen = false;
|
|
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
|
+
}
|
|
168
|
+
}
|
|
169
|
+
setState(syncCtx.store, key, updated);
|
|
170
|
+
throw err;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
};
|
|
174
|
+
applier[REQUIRES_SYNC_STORE] = true;
|
|
175
|
+
return applier;
|
|
176
|
+
}
|
package/dist/policies/dedupe.cjs
CHANGED
|
@@ -3,85 +3,67 @@ 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
|
-
|
|
10
|
-
|
|
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)
|
|
28
|
-
*
|
|
29
|
-
* Joiners race the in-flight promise against their own AbortSignal via
|
|
30
|
-
* `raceAbort`. If a joiner's signal aborts (e.g. their `totalTimeout`
|
|
31
|
-
* fires), they reject immediately — they don't have to wait for the
|
|
32
|
-
* originator to finish. The originator's promise continues in the
|
|
33
|
-
* background for any other joiners that haven't aborted.
|
|
34
|
-
*
|
|
35
|
-
* If `inflightTtl` is set, the store entry is also TTL'd: if the
|
|
36
|
-
* originator never settles, new callers can start fresh after the TTL
|
|
37
|
-
* expires (the original promise still leaks unless an outer timeout
|
|
38
|
-
* fires, but new callers aren't blocked).
|
|
39
|
-
*
|
|
40
|
-
* # INVARIANT: requires SyncStateStore
|
|
41
|
-
*
|
|
42
|
-
* The read-then-write that makes deduplication work must happen in a single
|
|
43
|
-
* synchronous frame. An async store would introduce an `await` between
|
|
44
|
-
* `get()` and `set()`, letting two concurrent callers both see a miss and
|
|
45
|
-
* both launch work. The `REQUIRES_SYNC_STORE` symbol on the returned
|
|
46
|
-
* `PolicyApplier` lets `execute()` enforce this at runtime for JS callers
|
|
47
|
-
* that bypass TypeScript.
|
|
48
|
-
*/
|
|
9
|
+
let generationCounter = 0;
|
|
10
|
+
function nextGeneration() {
|
|
11
|
+
generationCounter = (generationCounter + 1) % Number.MAX_SAFE_INTEGER;
|
|
12
|
+
return generationCounter;
|
|
13
|
+
}
|
|
49
14
|
function dedupePolicy(opts = { enabled: true }) {
|
|
50
|
-
const inflightTtl = opts.inflightTtl;
|
|
15
|
+
const inflightTtl = opts.inflightTtl ?? limits_js_1.LIMITS.DEFAULT_INFLIGHT_TTL;
|
|
51
16
|
const applier = (fn, ctx) => {
|
|
52
|
-
// Cast is safe: execute() verifies isSyncStore(ctx.store) before calling
|
|
53
|
-
// any policy tagged with REQUIRES_SYNC_STORE.
|
|
54
17
|
const syncCtx = ctx;
|
|
55
18
|
return async (signal) => {
|
|
56
19
|
const key = NS + syncCtx.key;
|
|
57
|
-
// Fast path: an in-flight promise already exists. Join it.
|
|
58
|
-
// raceAbort ensures we don't block on a hung originator if our own
|
|
59
|
-
// signal aborts.
|
|
60
20
|
const existing = syncCtx.store.get(key);
|
|
61
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
|
+
}
|
|
62
30
|
try {
|
|
63
31
|
const value = await (0, abort_js_1.raceAbort)(existing.promise, signal);
|
|
64
|
-
return value;
|
|
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.
|
|
71
32
|
syncCtx.meta.attempts = existing.meta.attempts;
|
|
72
33
|
syncCtx.meta.source = existing.meta.source;
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
if (signal.aborted) {
|
|
38
|
+
syncCtx.meta.attempts = 0;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
syncCtx.meta.attempts = existing.meta.attempts;
|
|
42
|
+
syncCtx.meta.source = existing.meta.source;
|
|
43
|
+
}
|
|
44
|
+
throw err;
|
|
73
45
|
}
|
|
74
46
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const entry = { promise, meta: syncCtx.meta };
|
|
47
|
+
const generation = nextGeneration();
|
|
48
|
+
const rawPromise = Promise.resolve(fn(signal));
|
|
49
|
+
const entry = {
|
|
50
|
+
promise: rawPromise,
|
|
51
|
+
meta: syncCtx.meta,
|
|
52
|
+
generation,
|
|
53
|
+
};
|
|
83
54
|
syncCtx.store.set(key, entry, inflightTtl);
|
|
84
|
-
|
|
55
|
+
const cleanup = () => {
|
|
56
|
+
try {
|
|
57
|
+
const current = syncCtx.store.get(key);
|
|
58
|
+
if (current && current.generation === generation) {
|
|
59
|
+
syncCtx.store.delete(key);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
rawPromise.then(cleanup, cleanup).catch(() => { });
|
|
66
|
+
return (0, abort_js_1.raceAbort)(rawPromise, signal);
|
|
85
67
|
};
|
|
86
68
|
};
|
|
87
69
|
applier[executor_js_1.REQUIRES_SYNC_STORE] = true;
|
|
@@ -1,44 +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
|
-
* # How it works
|
|
6
|
-
*
|
|
7
|
-
* The first caller (originator) starts the work and stores
|
|
8
|
-
* `{ promise, meta }` in the store under `dedupe:<key>`. Every subsequent
|
|
9
|
-
* caller that arrives before the promise settles receives the SAME promise
|
|
10
|
-
* — no duplicate work.
|
|
11
|
-
*
|
|
12
|
-
* # Shared `meta` (fixes v1.0 trade-off)
|
|
13
|
-
*
|
|
14
|
-
* The originator's `ctx.meta` reference is stored alongside the promise.
|
|
15
|
-
* Inner policies (e.g. `retryPolicy`) mutate it as they run. After the
|
|
16
|
-
* promise settles, joiners copy `attempts` and `source` from the shared
|
|
17
|
-
* meta into their own `ctx.meta`. This means a joiner's `ActResult.attempts`
|
|
18
|
-
* reflects the real effort (e.g. `3` if the originator retried twice), not
|
|
19
|
-
* the misleading default of `1`.
|
|
20
|
-
*
|
|
21
|
-
* # Abort safety (fixes hung-fn block)
|
|
22
|
-
*
|
|
23
|
-
* Joiners race the in-flight promise against their own AbortSignal via
|
|
24
|
-
* `raceAbort`. If a joiner's signal aborts (e.g. their `totalTimeout`
|
|
25
|
-
* fires), they reject immediately — they don't have to wait for the
|
|
26
|
-
* originator to finish. The originator's promise continues in the
|
|
27
|
-
* background for any other joiners that haven't aborted.
|
|
28
|
-
*
|
|
29
|
-
* If `inflightTtl` is set, the store entry is also TTL'd: if the
|
|
30
|
-
* originator never settles, new callers can start fresh after the TTL
|
|
31
|
-
* expires (the original promise still leaks unless an outer timeout
|
|
32
|
-
* fires, but new callers aren't blocked).
|
|
33
|
-
*
|
|
34
|
-
* # INVARIANT: requires SyncStateStore
|
|
35
|
-
*
|
|
36
|
-
* The read-then-write that makes deduplication work must happen in a single
|
|
37
|
-
* synchronous frame. An async store would introduce an `await` between
|
|
38
|
-
* `get()` and `set()`, letting two concurrent callers both see a miss and
|
|
39
|
-
* both launch work. The `REQUIRES_SYNC_STORE` symbol on the returned
|
|
40
|
-
* `PolicyApplier` lets `execute()` enforce this at runtime for JS callers
|
|
41
|
-
* that bypass TypeScript.
|
|
42
|
-
*/
|
|
43
2
|
export declare function dedupePolicy<T>(opts?: DedupeOptions): PolicyApplier<T>;
|
|
44
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"}
|
package/dist/policies/dedupe.js
CHANGED
|
@@ -1,87 +1,68 @@
|
|
|
1
1
|
import { REQUIRES_SYNC_STORE } from '../core/executor.js';
|
|
2
2
|
import { raceAbort } from '../utils/abort.js';
|
|
3
|
-
|
|
3
|
+
import { safeCall } from '../utils/safeCall.js';
|
|
4
|
+
import { LIMITS } from '../utils/limits.js';
|
|
4
5
|
const NS = 'dedupe:';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
* The first caller (originator) starts the work and stores
|
|
11
|
-
* `{ promise, meta }` in the store under `dedupe:<key>`. Every subsequent
|
|
12
|
-
* caller that arrives before the promise settles receives the SAME promise
|
|
13
|
-
* — no duplicate work.
|
|
14
|
-
*
|
|
15
|
-
* # Shared `meta` (fixes v1.0 trade-off)
|
|
16
|
-
*
|
|
17
|
-
* The originator's `ctx.meta` reference is stored alongside the promise.
|
|
18
|
-
* Inner policies (e.g. `retryPolicy`) mutate it as they run. After the
|
|
19
|
-
* promise settles, joiners copy `attempts` and `source` from the shared
|
|
20
|
-
* meta into their own `ctx.meta`. This means a joiner's `ActResult.attempts`
|
|
21
|
-
* reflects the real effort (e.g. `3` if the originator retried twice), not
|
|
22
|
-
* the misleading default of `1`.
|
|
23
|
-
*
|
|
24
|
-
* # Abort safety (fixes hung-fn block)
|
|
25
|
-
*
|
|
26
|
-
* Joiners race the in-flight promise against their own AbortSignal via
|
|
27
|
-
* `raceAbort`. If a joiner's signal aborts (e.g. their `totalTimeout`
|
|
28
|
-
* fires), they reject immediately — they don't have to wait for the
|
|
29
|
-
* originator to finish. The originator's promise continues in the
|
|
30
|
-
* background for any other joiners that haven't aborted.
|
|
31
|
-
*
|
|
32
|
-
* If `inflightTtl` is set, the store entry is also TTL'd: if the
|
|
33
|
-
* originator never settles, new callers can start fresh after the TTL
|
|
34
|
-
* expires (the original promise still leaks unless an outer timeout
|
|
35
|
-
* fires, but new callers aren't blocked).
|
|
36
|
-
*
|
|
37
|
-
* # INVARIANT: requires SyncStateStore
|
|
38
|
-
*
|
|
39
|
-
* The read-then-write that makes deduplication work must happen in a single
|
|
40
|
-
* synchronous frame. An async store would introduce an `await` between
|
|
41
|
-
* `get()` and `set()`, letting two concurrent callers both see a miss and
|
|
42
|
-
* both launch work. The `REQUIRES_SYNC_STORE` symbol on the returned
|
|
43
|
-
* `PolicyApplier` lets `execute()` enforce this at runtime for JS callers
|
|
44
|
-
* that bypass TypeScript.
|
|
45
|
-
*/
|
|
6
|
+
let generationCounter = 0;
|
|
7
|
+
function nextGeneration() {
|
|
8
|
+
generationCounter = (generationCounter + 1) % Number.MAX_SAFE_INTEGER;
|
|
9
|
+
return generationCounter;
|
|
10
|
+
}
|
|
46
11
|
export function dedupePolicy(opts = { enabled: true }) {
|
|
47
|
-
const inflightTtl = opts.inflightTtl;
|
|
12
|
+
const inflightTtl = opts.inflightTtl ?? LIMITS.DEFAULT_INFLIGHT_TTL;
|
|
48
13
|
const applier = (fn, ctx) => {
|
|
49
|
-
// Cast is safe: execute() verifies isSyncStore(ctx.store) before calling
|
|
50
|
-
// any policy tagged with REQUIRES_SYNC_STORE.
|
|
51
14
|
const syncCtx = ctx;
|
|
52
15
|
return async (signal) => {
|
|
53
16
|
const key = NS + syncCtx.key;
|
|
54
|
-
// Fast path: an in-flight promise already exists. Join it.
|
|
55
|
-
// raceAbort ensures we don't block on a hung originator if our own
|
|
56
|
-
// signal aborts.
|
|
57
17
|
const existing = syncCtx.store.get(key);
|
|
58
18
|
if (existing) {
|
|
19
|
+
const obs = syncCtx.observability;
|
|
20
|
+
if (obs) {
|
|
21
|
+
obs.joinerCounter++;
|
|
22
|
+
safeCall(obs.hooks.onDedupeJoin, {
|
|
23
|
+
type: 'dedupe-join', key: syncCtx.key, traceId: obs.traceId,
|
|
24
|
+
timestamp: Date.now(), joinerPosition: obs.joinerCounter,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
59
27
|
try {
|
|
60
28
|
const value = await raceAbort(existing.promise, signal);
|
|
61
|
-
return value;
|
|
62
|
-
}
|
|
63
|
-
finally {
|
|
64
|
-
// Copy the originator's final meta into our own, regardless of
|
|
65
|
-
// success or failure. By the time `existing.promise` has settled
|
|
66
|
-
// (or our signal aborted), the originator's retry loop has set
|
|
67
|
-
// the final attempt count.
|
|
68
29
|
syncCtx.meta.attempts = existing.meta.attempts;
|
|
69
30
|
syncCtx.meta.source = existing.meta.source;
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
if (signal.aborted) {
|
|
35
|
+
syncCtx.meta.attempts = 0;
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
syncCtx.meta.attempts = existing.meta.attempts;
|
|
39
|
+
syncCtx.meta.source = existing.meta.source;
|
|
40
|
+
}
|
|
41
|
+
throw err;
|
|
70
42
|
}
|
|
71
43
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const entry = { promise, meta: syncCtx.meta };
|
|
44
|
+
const generation = nextGeneration();
|
|
45
|
+
const rawPromise = Promise.resolve(fn(signal));
|
|
46
|
+
const entry = {
|
|
47
|
+
promise: rawPromise,
|
|
48
|
+
meta: syncCtx.meta,
|
|
49
|
+
generation,
|
|
50
|
+
};
|
|
80
51
|
syncCtx.store.set(key, entry, inflightTtl);
|
|
81
|
-
|
|
52
|
+
const cleanup = () => {
|
|
53
|
+
try {
|
|
54
|
+
const current = syncCtx.store.get(key);
|
|
55
|
+
if (current && current.generation === generation) {
|
|
56
|
+
syncCtx.store.delete(key);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
rawPromise.then(cleanup, cleanup).catch(() => { });
|
|
63
|
+
return raceAbort(rawPromise, signal);
|
|
82
64
|
};
|
|
83
65
|
};
|
|
84
66
|
applier[REQUIRES_SYNC_STORE] = true;
|
|
85
67
|
return applier;
|
|
86
68
|
}
|
|
87
|
-
//# sourceMappingURL=dedupe.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"noop.d.ts","sourceRoot":"","sources":["../../src/policies/noop.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAS,aAAa,EAAiB,MAAM,mBAAmB,CAAA;AAoB5E,wBAAgB,UAAU,CAAC,CAAC,KAAK,aAAa,CAAC,CAAC,CAAC,CAEhD"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.rateLimitPolicy = rateLimitPolicy;
|
|
4
|
+
const executor_js_1 = require("../core/executor.js");
|
|
5
|
+
const errors_js_1 = require("../errors.js");
|
|
6
|
+
const NS = 'rl:';
|
|
7
|
+
function getState(store, key) {
|
|
8
|
+
return store.get(NS + key) ?? { timestamps: [] };
|
|
9
|
+
}
|
|
10
|
+
function setState(store, key, state, ttlMs) {
|
|
11
|
+
store.set(NS + key, state, ttlMs);
|
|
12
|
+
}
|
|
13
|
+
function rateLimitPolicy(opts) {
|
|
14
|
+
const maxCalls = Math.max(1, Math.floor(opts.maxCalls));
|
|
15
|
+
const windowMs = opts.windowMs;
|
|
16
|
+
const applier = (fn, ctx) => {
|
|
17
|
+
const syncCtx = ctx;
|
|
18
|
+
return async (signal) => {
|
|
19
|
+
if (signal.aborted)
|
|
20
|
+
return Promise.reject(signal.reason);
|
|
21
|
+
const key = syncCtx.key;
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
const state = getState(syncCtx.store, key);
|
|
24
|
+
const cutoff = now - windowMs;
|
|
25
|
+
const ts = state.timestamps;
|
|
26
|
+
if (ts.length > 0 && ts[0] <= cutoff) {
|
|
27
|
+
let i = 0;
|
|
28
|
+
while (i < ts.length && ts[i] <= cutoff)
|
|
29
|
+
i++;
|
|
30
|
+
if (i > 0) {
|
|
31
|
+
state.timestamps = i === ts.length ? [] : ts.slice(i);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (state.timestamps.length >= maxCalls) {
|
|
35
|
+
setState(syncCtx.store, key, state, windowMs);
|
|
36
|
+
throw new errors_js_1.RateLimitError(key, maxCalls, windowMs);
|
|
37
|
+
}
|
|
38
|
+
state.timestamps.push(now);
|
|
39
|
+
setState(syncCtx.store, key, state, windowMs);
|
|
40
|
+
return fn(signal);
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
applier[executor_js_1.REQUIRES_SYNC_STORE] = true;
|
|
44
|
+
return applier;
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rateLimit.d.ts","sourceRoot":"","sources":["../../src/policies/rateLimit.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAS,aAAa,EAAiB,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AAmB9F,wBAAgB,eAAe,CAAC,CAAC,EAAE,IAAI,EAAE,gBAAgB,GAAG,aAAa,CAAC,CAAC,CAAC,CA6C3E"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { REQUIRES_SYNC_STORE } from '../core/executor.js';
|
|
2
|
+
import { RateLimitError } from '../errors.js';
|
|
3
|
+
const NS = 'rl:';
|
|
4
|
+
function getState(store, key) {
|
|
5
|
+
return store.get(NS + key) ?? { timestamps: [] };
|
|
6
|
+
}
|
|
7
|
+
function setState(store, key, state, ttlMs) {
|
|
8
|
+
store.set(NS + key, state, ttlMs);
|
|
9
|
+
}
|
|
10
|
+
export function rateLimitPolicy(opts) {
|
|
11
|
+
const maxCalls = Math.max(1, Math.floor(opts.maxCalls));
|
|
12
|
+
const windowMs = opts.windowMs;
|
|
13
|
+
const applier = (fn, ctx) => {
|
|
14
|
+
const syncCtx = ctx;
|
|
15
|
+
return async (signal) => {
|
|
16
|
+
if (signal.aborted)
|
|
17
|
+
return Promise.reject(signal.reason);
|
|
18
|
+
const key = syncCtx.key;
|
|
19
|
+
const now = Date.now();
|
|
20
|
+
const state = getState(syncCtx.store, key);
|
|
21
|
+
const cutoff = now - windowMs;
|
|
22
|
+
const ts = state.timestamps;
|
|
23
|
+
if (ts.length > 0 && ts[0] <= cutoff) {
|
|
24
|
+
let i = 0;
|
|
25
|
+
while (i < ts.length && ts[i] <= cutoff)
|
|
26
|
+
i++;
|
|
27
|
+
if (i > 0) {
|
|
28
|
+
state.timestamps = i === ts.length ? [] : ts.slice(i);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (state.timestamps.length >= maxCalls) {
|
|
32
|
+
setState(syncCtx.store, key, state, windowMs);
|
|
33
|
+
throw new RateLimitError(key, maxCalls, windowMs);
|
|
34
|
+
}
|
|
35
|
+
state.timestamps.push(now);
|
|
36
|
+
setState(syncCtx.store, key, state, windowMs);
|
|
37
|
+
return fn(signal);
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
applier[REQUIRES_SYNC_STORE] = true;
|
|
41
|
+
return applier;
|
|
42
|
+
}
|