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/core/act.cjs
CHANGED
|
@@ -8,19 +8,93 @@ const retry_js_1 = require("../policies/retry.js");
|
|
|
8
8
|
const timeout_js_1 = require("../policies/timeout.js");
|
|
9
9
|
const dedupe_js_1 = require("../policies/dedupe.js");
|
|
10
10
|
const cache_js_1 = require("../policies/cache.js");
|
|
11
|
+
const circuitBreaker_js_1 = require("../policies/circuitBreaker.js");
|
|
12
|
+
const bulkhead_js_1 = require("../policies/bulkhead.js");
|
|
13
|
+
const rateLimit_js_1 = require("../policies/rateLimit.js");
|
|
11
14
|
const memory_js_1 = require("../stores/memory.js");
|
|
12
15
|
const base_js_1 = require("../stores/base.js");
|
|
13
16
|
const abort_js_1 = require("../utils/abort.js");
|
|
17
|
+
const sanitize_js_1 = require("../utils/sanitize.js");
|
|
18
|
+
const health_js_1 = require("./health.js");
|
|
19
|
+
const shutdown_js_1 = require("./shutdown.js");
|
|
14
20
|
const validate_js_1 = require("../utils/validate.js");
|
|
15
21
|
// Module-level default store so cache and dedupe persist across calls.
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// `
|
|
20
|
-
const defaultStore =
|
|
22
|
+
// Bounded by default (maxSize: 10_000, autoCleanup: 60s) to
|
|
23
|
+
// prevent unbounded memory growth in long-running servers. Callers who
|
|
24
|
+
// want truly unbounded storage must construct their own `InMemoryStore`
|
|
25
|
+
// with `maxSize: Infinity` and pass it via `withStore()`.
|
|
26
|
+
const defaultStore = (0, memory_js_1.createDefaultStore)();
|
|
21
27
|
// Namespace prefixes used by policies. Kept here (not in policy files) so
|
|
22
28
|
// `invalidate()` can resolve cache keys without importing policy internals.
|
|
23
29
|
const CACHE_NS = 'cache:';
|
|
30
|
+
/**
|
|
31
|
+
* Generate a trace ID for correlation across logs/metrics.
|
|
32
|
+
*
|
|
33
|
+
* Uses `crypto.randomUUID()` on Node 20+ (fast, native). Falls back to a
|
|
34
|
+
* timestamp+random string for environments without it.
|
|
35
|
+
*/
|
|
36
|
+
function generateTraceId() {
|
|
37
|
+
const crypto = globalThis.crypto;
|
|
38
|
+
if (crypto && typeof crypto.randomUUID === 'function') {
|
|
39
|
+
return crypto.randomUUID();
|
|
40
|
+
}
|
|
41
|
+
return `actly-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Build the observability context (or return undefined if no hooks).
|
|
45
|
+
* The context is shared via PolicyContext.observability so every policy
|
|
46
|
+
* can emit events through the same traceId + hooks.
|
|
47
|
+
*/
|
|
48
|
+
function buildObservability(hooks, traceId) {
|
|
49
|
+
// Contract: if no hooks are supplied, return undefined.
|
|
50
|
+
// Policies check `ctx.observability != null` before allocating events.
|
|
51
|
+
if (!hooks)
|
|
52
|
+
return undefined;
|
|
53
|
+
// Check that at least one hook is actually defined (avoid allocating
|
|
54
|
+
// context for an empty hooks object).
|
|
55
|
+
const hasAnyHook = !!hooks.onAttempt ||
|
|
56
|
+
!!hooks.onRetry ||
|
|
57
|
+
!!hooks.onCacheHit ||
|
|
58
|
+
!!hooks.onCacheMiss ||
|
|
59
|
+
!!hooks.onDedupeJoin ||
|
|
60
|
+
!!hooks.onTimeout ||
|
|
61
|
+
!!hooks.onFinalSuccess ||
|
|
62
|
+
!!hooks.onFinalFailure;
|
|
63
|
+
if (!hasAnyHook)
|
|
64
|
+
return undefined;
|
|
65
|
+
return {
|
|
66
|
+
traceId: traceId ?? generateTraceId(),
|
|
67
|
+
hooks,
|
|
68
|
+
joinerCounter: 0,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Determine the `failedBy` discriminator from a caught error.
|
|
73
|
+
* Name sniffing requires `instanceof Error` to prevent plain-object spoofing.
|
|
74
|
+
*/
|
|
75
|
+
function classifyFailure(error) {
|
|
76
|
+
if (error == null || typeof error !== 'object')
|
|
77
|
+
return 'fn-error';
|
|
78
|
+
const code = error.code;
|
|
79
|
+
if (code === 'ACTLY_ABORT')
|
|
80
|
+
return 'abort';
|
|
81
|
+
if (code === 'ACTLY_TIMEOUT')
|
|
82
|
+
return 'timeout';
|
|
83
|
+
if (code === 'ACTLY_TOTAL_TIMEOUT')
|
|
84
|
+
return 'total-timeout';
|
|
85
|
+
if (code === 'ACTLY_RETRY_EXHAUSTED')
|
|
86
|
+
return 'retry-exhausted';
|
|
87
|
+
if (code === 'ACTLY_VALIDATION')
|
|
88
|
+
return 'validation';
|
|
89
|
+
// Fall back to name sniffing for non-actly errors (e.g. DOMException abort).
|
|
90
|
+
// Require instanceof Error to prevent plain-object spoofing.
|
|
91
|
+
if (error instanceof Error) {
|
|
92
|
+
const name = error.name;
|
|
93
|
+
if (name === 'AbortError')
|
|
94
|
+
return 'abort';
|
|
95
|
+
}
|
|
96
|
+
return 'fn-error';
|
|
97
|
+
}
|
|
24
98
|
/**
|
|
25
99
|
* Normalise `dedupe: true` shorthand to `DedupeOptions`.
|
|
26
100
|
* Returns `undefined` if dedupe is disabled or absent.
|
|
@@ -44,25 +118,35 @@ function normalizeDedupe(opt) {
|
|
|
44
118
|
function buildPolicies(options) {
|
|
45
119
|
const dedupe = normalizeDedupe(options.dedupe);
|
|
46
120
|
const policies = [];
|
|
47
|
-
//
|
|
48
|
-
|
|
121
|
+
// Outermost: rate limiter (blocks before any work is done)
|
|
122
|
+
if (options.rateLimit) {
|
|
123
|
+
policies.push((0, rateLimit_js_1.rateLimitPolicy)(options.rateLimit));
|
|
124
|
+
}
|
|
125
|
+
// Circuit breaker (blocks if downstream is failing)
|
|
126
|
+
if (options.circuitBreaker) {
|
|
127
|
+
policies.push((0, circuitBreaker_js_1.circuitBreakerPolicy)(options.circuitBreaker));
|
|
128
|
+
}
|
|
129
|
+
// Hard wall-clock budget over the entire operation
|
|
49
130
|
if (options.totalTimeout && options.totalTimeout.ms > 0) {
|
|
50
131
|
policies.push((0, timeout_js_1.totalTimeoutPolicy)(options.totalTimeout));
|
|
51
132
|
}
|
|
52
|
-
//
|
|
133
|
+
// Cache: a hit short-circuits everything below it
|
|
53
134
|
if (options.cache && options.cache.ttl > 0) {
|
|
54
135
|
policies.push((0, cache_js_1.cachePolicy)(options.cache));
|
|
55
136
|
}
|
|
56
|
-
//
|
|
137
|
+
// Bulkhead: limits concurrency per key
|
|
138
|
+
if (options.bulkhead) {
|
|
139
|
+
policies.push((0, bulkhead_js_1.bulkheadPolicy)(options.bulkhead));
|
|
140
|
+
}
|
|
141
|
+
// Dedupe: collapses concurrent callers before retry fires
|
|
57
142
|
if (dedupe) {
|
|
58
143
|
policies.push((0, dedupe_js_1.dedupePolicy)(dedupe));
|
|
59
144
|
}
|
|
60
|
-
//
|
|
61
|
-
// `attempts: 1` is a no-op — skip to avoid overhead.
|
|
145
|
+
// Retry: owns the attempt loop (attempts: 1 is a no-op — skip)
|
|
62
146
|
if (options.retry && options.retry.attempts > 1) {
|
|
63
147
|
policies.push((0, retry_js_1.retryPolicy)(options.retry));
|
|
64
148
|
}
|
|
65
|
-
//
|
|
149
|
+
// Innermost: per-attempt clock. Resets on every retry
|
|
66
150
|
if (options.timeout && options.timeout.ms > 0) {
|
|
67
151
|
policies.push((0, timeout_js_1.timeoutPolicy)(options.timeout));
|
|
68
152
|
}
|
|
@@ -79,9 +163,11 @@ function buildPolicies(options) {
|
|
|
79
163
|
*/
|
|
80
164
|
function buildRootSignal(userSignal) {
|
|
81
165
|
const controller = new AbortController();
|
|
82
|
-
if (userSignal)
|
|
83
|
-
(0, abort_js_1.linkSignal)(userSignal, controller);
|
|
84
|
-
|
|
166
|
+
if (userSignal) {
|
|
167
|
+
const unlink = (0, abort_js_1.linkSignal)(userSignal, controller);
|
|
168
|
+
return { controller, cleanup: unlink };
|
|
169
|
+
}
|
|
170
|
+
return { controller, cleanup: () => { } };
|
|
85
171
|
}
|
|
86
172
|
/**
|
|
87
173
|
* Execute `fn` with the given reliability policies.
|
|
@@ -114,34 +200,183 @@ function buildRootSignal(userSignal) {
|
|
|
114
200
|
* }
|
|
115
201
|
*/
|
|
116
202
|
async function act(key, fn, options = {}) {
|
|
117
|
-
// Validate input upfront. Programmer errors throw — they should not be
|
|
118
|
-
// swallowed into an ActFailure because the caller's code is broken.
|
|
119
203
|
(0, validate_js_1.assertKey)(key);
|
|
120
204
|
(0, validate_js_1.assertOptions)(options);
|
|
205
|
+
// Fast path: no options means no policies, no signal, no observability.
|
|
206
|
+
const hasAnyOption = options.retry !== undefined ||
|
|
207
|
+
options.timeout !== undefined ||
|
|
208
|
+
options.totalTimeout !== undefined ||
|
|
209
|
+
options.dedupe !== undefined ||
|
|
210
|
+
options.cache !== undefined ||
|
|
211
|
+
options.signal !== undefined ||
|
|
212
|
+
options.observability !== undefined ||
|
|
213
|
+
options.traceId !== undefined ||
|
|
214
|
+
options.circuitBreaker !== undefined ||
|
|
215
|
+
options.bulkhead !== undefined ||
|
|
216
|
+
options.rateLimit !== undefined ||
|
|
217
|
+
options.hedge !== undefined ||
|
|
218
|
+
options.fallback !== undefined ||
|
|
219
|
+
options.audit !== undefined;
|
|
220
|
+
if (!hasAnyOption) {
|
|
221
|
+
const startedAt = Date.now();
|
|
222
|
+
(0, health_js_1.registerInflight)('default');
|
|
223
|
+
(0, shutdown_js_1.registerDrainable)('default');
|
|
224
|
+
try {
|
|
225
|
+
const value = await fn(new AbortController().signal);
|
|
226
|
+
(0, health_js_1.recordSuccess)('default');
|
|
227
|
+
return { ok: true, value, source: 'fresh', attempts: 1, durationMs: Date.now() - startedAt };
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
(0, health_js_1.recordError)('default', 'fn-error', (0, sanitize_js_1.sanitizeErrorMessage)(error));
|
|
231
|
+
return { ok: false, error, attempts: 1, durationMs: Date.now() - startedAt };
|
|
232
|
+
}
|
|
233
|
+
finally {
|
|
234
|
+
(0, health_js_1.unregisterInflight)('default');
|
|
235
|
+
(0, shutdown_js_1.unregisterDrainable)('default');
|
|
236
|
+
}
|
|
237
|
+
}
|
|
121
238
|
const meta = { attempts: 1, source: 'fresh' };
|
|
122
|
-
const rootController = buildRootSignal(options.signal);
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
239
|
+
const { controller: rootController, cleanup } = buildRootSignal(options.signal);
|
|
240
|
+
const observability = buildObservability(options.observability, options.traceId);
|
|
241
|
+
const startedAt = Date.now();
|
|
242
|
+
const scope = 'default';
|
|
243
|
+
(0, health_js_1.registerInflight)(scope);
|
|
244
|
+
(0, shutdown_js_1.registerDrainable)(scope);
|
|
126
245
|
if (rootController.signal.aborted) {
|
|
127
|
-
|
|
246
|
+
cleanup();
|
|
247
|
+
(0, health_js_1.unregisterInflight)(scope);
|
|
248
|
+
(0, shutdown_js_1.unregisterDrainable)(scope);
|
|
249
|
+
const error = rootController.signal.reason;
|
|
250
|
+
if (observability) {
|
|
251
|
+
observability.hooks.onFinalFailure?.({
|
|
252
|
+
type: 'final-failure',
|
|
253
|
+
key, traceId: observability.traceId, timestamp: Date.now(),
|
|
254
|
+
attempts: 0, durationMs: Date.now() - startedAt,
|
|
255
|
+
failedBy: 'abort', error,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
if (options.audit) {
|
|
259
|
+
options.audit.log({ key, traceId: observability?.traceId ?? '', timestamp: Date.now(), durationMs: Date.now() - startedAt, ok: false, attempts: 0, failedBy: 'abort', error });
|
|
260
|
+
}
|
|
261
|
+
(0, health_js_1.recordError)(scope, 'ACTLY_ABORT', (0, sanitize_js_1.sanitizeErrorMessage)(error));
|
|
262
|
+
return { ok: false, error, attempts: 0, traceId: observability?.traceId, durationMs: Date.now() - startedAt };
|
|
128
263
|
}
|
|
129
264
|
const policies = buildPolicies(options);
|
|
265
|
+
const hasRetryPolicy = !!(options.retry && options.retry.attempts > 1);
|
|
266
|
+
if (observability && !hasRetryPolicy) {
|
|
267
|
+
observability.hooks.onAttempt?.({
|
|
268
|
+
type: 'attempt', key, traceId: observability.traceId,
|
|
269
|
+
timestamp: Date.now(), attempt: 1,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
// Wrap fn with hedge request if configured
|
|
273
|
+
const fnWithHedge = options.hedge ? wrapHedge(fn, options.hedge.delayMs) : fn;
|
|
130
274
|
try {
|
|
131
|
-
const value = await (0, executor_js_1.execute)({
|
|
275
|
+
const value = await (0, abort_js_1.raceAbort)((0, executor_js_1.execute)({
|
|
132
276
|
key,
|
|
133
|
-
fn,
|
|
277
|
+
fn: fnWithHedge,
|
|
134
278
|
policies,
|
|
135
279
|
store: defaultStore,
|
|
136
280
|
meta,
|
|
137
281
|
signal: rootController.signal,
|
|
138
|
-
|
|
139
|
-
|
|
282
|
+
observability,
|
|
283
|
+
}), rootController.signal);
|
|
284
|
+
const durationMs = Date.now() - startedAt;
|
|
285
|
+
(0, health_js_1.recordSuccess)(scope);
|
|
286
|
+
if (observability) {
|
|
287
|
+
observability.hooks.onFinalSuccess?.({
|
|
288
|
+
type: 'final-success',
|
|
289
|
+
key, traceId: observability.traceId, timestamp: Date.now(),
|
|
290
|
+
source: meta.source, attempts: meta.attempts, durationMs,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
if (options.audit) {
|
|
294
|
+
options.audit.log({ key, traceId: observability?.traceId ?? '', timestamp: Date.now(), durationMs, ok: true, attempts: meta.attempts });
|
|
295
|
+
}
|
|
296
|
+
return { ok: true, value, source: meta.source, attempts: meta.attempts, traceId: observability?.traceId, durationMs };
|
|
140
297
|
}
|
|
141
298
|
catch (error) {
|
|
142
|
-
|
|
299
|
+
const durationMs = Date.now() - startedAt;
|
|
300
|
+
const failedBy = rootController.signal.aborted ? 'abort' : classifyFailure(error);
|
|
301
|
+
// Fallback: if configured, return fallback value instead of failure.
|
|
302
|
+
// Record the error in health check so monitoring can detect downstream
|
|
303
|
+
// failures even when fallback masks them from the caller.
|
|
304
|
+
let errorRecorded = false;
|
|
305
|
+
if (options.fallback) {
|
|
306
|
+
(0, health_js_1.recordError)(scope, failedBy, (0, sanitize_js_1.sanitizeErrorMessage)(error));
|
|
307
|
+
errorRecorded = true;
|
|
308
|
+
try {
|
|
309
|
+
const fallbackValue = typeof options.fallback.value === 'function'
|
|
310
|
+
? await options.fallback.value()
|
|
311
|
+
: options.fallback.value;
|
|
312
|
+
if (observability) {
|
|
313
|
+
observability.hooks.onFinalSuccess?.({
|
|
314
|
+
type: 'final-success',
|
|
315
|
+
key, traceId: observability.traceId, timestamp: Date.now(),
|
|
316
|
+
source: meta.source, attempts: meta.attempts, durationMs,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
if (options.audit) {
|
|
320
|
+
options.audit.log({ key, traceId: observability?.traceId ?? '', timestamp: Date.now(), durationMs, ok: true, attempts: meta.attempts });
|
|
321
|
+
}
|
|
322
|
+
return { ok: true, value: fallbackValue, source: 'fresh', attempts: meta.attempts, traceId: observability?.traceId, durationMs };
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
// fallback itself failed — fall through to normal failure
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const sanitizedError = options.audit ? (0, sanitize_js_1.sanitizeError)(error) : error;
|
|
329
|
+
if (!errorRecorded) {
|
|
330
|
+
(0, health_js_1.recordError)(scope, failedBy, (0, sanitize_js_1.sanitizeErrorMessage)(error));
|
|
331
|
+
}
|
|
332
|
+
if (observability) {
|
|
333
|
+
observability.hooks.onFinalFailure?.({
|
|
334
|
+
type: 'final-failure',
|
|
335
|
+
key, traceId: observability.traceId, timestamp: Date.now(),
|
|
336
|
+
attempts: meta.attempts, durationMs,
|
|
337
|
+
failedBy, error,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
if (options.audit) {
|
|
341
|
+
options.audit.log({ key, traceId: observability?.traceId ?? '', timestamp: Date.now(), durationMs, ok: false, attempts: meta.attempts, failedBy, error: sanitizedError });
|
|
342
|
+
}
|
|
343
|
+
return { ok: false, error, attempts: meta.attempts, traceId: observability?.traceId, durationMs };
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
cleanup();
|
|
347
|
+
(0, health_js_1.unregisterInflight)(scope);
|
|
348
|
+
(0, shutdown_js_1.unregisterDrainable)(scope);
|
|
143
349
|
}
|
|
144
350
|
}
|
|
351
|
+
// Hedge request: sends a second call after delayMs, races them.
|
|
352
|
+
// Clears timer on settle to prevent leak. Marks primary as handled
|
|
353
|
+
// to prevent unhandled rejection if hedge wins.
|
|
354
|
+
function wrapHedge(fn, delayMs) {
|
|
355
|
+
return async (signal) => {
|
|
356
|
+
let timer;
|
|
357
|
+
const primary = fn(signal);
|
|
358
|
+
const hedgeTimeout = new Promise((_, reject) => {
|
|
359
|
+
timer = setTimeout(() => reject(new Error('__HEDGE_TIMEOUT__')), delayMs);
|
|
360
|
+
});
|
|
361
|
+
try {
|
|
362
|
+
return await Promise.race([primary, hedgeTimeout]);
|
|
363
|
+
}
|
|
364
|
+
catch (e) {
|
|
365
|
+
if (e instanceof Error && e.message === '__HEDGE_TIMEOUT__') {
|
|
366
|
+
// Primary is still running — mark as handled to prevent unhandled rejection
|
|
367
|
+
Promise.resolve(primary).catch(() => { });
|
|
368
|
+
const hedgePromise = Promise.resolve(fn(signal));
|
|
369
|
+
hedgePromise.catch(() => { }); // mark hedge as handled if primary wins
|
|
370
|
+
return await Promise.race([primary, hedgePromise]);
|
|
371
|
+
}
|
|
372
|
+
throw e;
|
|
373
|
+
}
|
|
374
|
+
finally {
|
|
375
|
+
if (timer)
|
|
376
|
+
clearTimeout(timer);
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
}
|
|
145
380
|
/**
|
|
146
381
|
* Invalidate the cached value for `key` on the default module-level store.
|
|
147
382
|
*
|
|
@@ -159,34 +394,107 @@ async function act(key, fn, options = {}) {
|
|
|
159
394
|
* ```
|
|
160
395
|
*/
|
|
161
396
|
function invalidate(key) {
|
|
397
|
+
(0, validate_js_1.assertKey)(key);
|
|
162
398
|
const cacheKey = CACHE_NS + key;
|
|
163
399
|
const existed = defaultStore.has(cacheKey);
|
|
164
400
|
defaultStore.delete(cacheKey);
|
|
165
401
|
return existed;
|
|
166
402
|
}
|
|
167
403
|
function withStore(store) {
|
|
404
|
+
const scope = 'scoped:' + Math.random().toString(36).slice(2, 8);
|
|
168
405
|
const scopedAct = async (key, fn, options = {}) => {
|
|
169
406
|
(0, validate_js_1.assertKey)(key);
|
|
170
407
|
(0, validate_js_1.assertOptions)(options);
|
|
171
408
|
const meta = { attempts: 1, source: 'fresh' };
|
|
172
|
-
const rootController = buildRootSignal(options.signal);
|
|
409
|
+
const { controller: rootController, cleanup } = buildRootSignal(options.signal);
|
|
410
|
+
const observability = buildObservability(options.observability, options.traceId);
|
|
411
|
+
const startedAt = Date.now();
|
|
412
|
+
(0, health_js_1.registerInflight)(scope);
|
|
413
|
+
(0, shutdown_js_1.registerDrainable)(scope);
|
|
173
414
|
if (rootController.signal.aborted) {
|
|
174
|
-
|
|
415
|
+
cleanup();
|
|
416
|
+
(0, health_js_1.unregisterInflight)(scope);
|
|
417
|
+
(0, shutdown_js_1.unregisterDrainable)(scope);
|
|
418
|
+
const error = rootController.signal.reason;
|
|
419
|
+
if (observability) {
|
|
420
|
+
observability.hooks.onFinalFailure?.({
|
|
421
|
+
type: 'final-failure', key, traceId: observability.traceId, timestamp: Date.now(),
|
|
422
|
+
attempts: 0, durationMs: Date.now() - startedAt, failedBy: 'abort', error,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
return { ok: false, error, attempts: 0, traceId: observability?.traceId, durationMs: Date.now() - startedAt };
|
|
175
426
|
}
|
|
176
427
|
const policies = buildPolicies(options);
|
|
428
|
+
const hasRetryPolicy = !!(options.retry && options.retry.attempts > 1);
|
|
429
|
+
if (observability && !hasRetryPolicy) {
|
|
430
|
+
observability.hooks.onAttempt?.({
|
|
431
|
+
type: 'attempt', key, traceId: observability.traceId,
|
|
432
|
+
timestamp: Date.now(), attempt: 1,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
const fnWithHedge = options.hedge ? wrapHedge(fn, options.hedge.delayMs) : fn;
|
|
177
436
|
try {
|
|
178
|
-
const value = await (0, executor_js_1.execute)({
|
|
437
|
+
const value = await (0, abort_js_1.raceAbort)((0, executor_js_1.execute)({
|
|
179
438
|
key,
|
|
180
|
-
fn,
|
|
439
|
+
fn: fnWithHedge,
|
|
181
440
|
policies,
|
|
182
441
|
store,
|
|
183
442
|
meta,
|
|
184
443
|
signal: rootController.signal,
|
|
185
|
-
|
|
186
|
-
|
|
444
|
+
observability,
|
|
445
|
+
}), rootController.signal);
|
|
446
|
+
const durationMs = Date.now() - startedAt;
|
|
447
|
+
(0, health_js_1.recordSuccess)(scope);
|
|
448
|
+
if (observability) {
|
|
449
|
+
observability.hooks.onFinalSuccess?.({
|
|
450
|
+
type: 'final-success', key, traceId: observability.traceId, timestamp: Date.now(),
|
|
451
|
+
source: meta.source, attempts: meta.attempts, durationMs,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
if (options.audit) {
|
|
455
|
+
options.audit.log({ key, traceId: observability?.traceId ?? '', timestamp: Date.now(), durationMs, ok: true, attempts: meta.attempts });
|
|
456
|
+
}
|
|
457
|
+
return { ok: true, value, source: meta.source, attempts: meta.attempts, traceId: observability?.traceId, durationMs };
|
|
187
458
|
}
|
|
188
459
|
catch (error) {
|
|
189
|
-
|
|
460
|
+
const durationMs = Date.now() - startedAt;
|
|
461
|
+
const failedBy = rootController.signal.aborted ? 'abort' : classifyFailure(error);
|
|
462
|
+
let errorRecorded = false;
|
|
463
|
+
if (options.fallback) {
|
|
464
|
+
(0, health_js_1.recordError)(scope, failedBy, (0, sanitize_js_1.sanitizeErrorMessage)(error));
|
|
465
|
+
errorRecorded = true;
|
|
466
|
+
try {
|
|
467
|
+
const fallbackValue = typeof options.fallback.value === 'function'
|
|
468
|
+
? await options.fallback.value()
|
|
469
|
+
: options.fallback.value;
|
|
470
|
+
if (options.audit) {
|
|
471
|
+
options.audit.log({ key, traceId: observability?.traceId ?? '', timestamp: Date.now(), durationMs, ok: true, attempts: meta.attempts });
|
|
472
|
+
}
|
|
473
|
+
return { ok: true, value: fallbackValue, source: 'fresh', attempts: meta.attempts, traceId: observability?.traceId, durationMs };
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
// fallback failed — fall through
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (!errorRecorded) {
|
|
480
|
+
(0, health_js_1.recordError)(scope, failedBy, (0, sanitize_js_1.sanitizeErrorMessage)(error));
|
|
481
|
+
}
|
|
482
|
+
if (observability) {
|
|
483
|
+
observability.hooks.onFinalFailure?.({
|
|
484
|
+
type: 'final-failure', key, traceId: observability.traceId, timestamp: Date.now(),
|
|
485
|
+
attempts: meta.attempts, durationMs, failedBy, error,
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
if (options.audit) {
|
|
489
|
+
const sanitizedError = (0, sanitize_js_1.sanitizeError)(error);
|
|
490
|
+
options.audit.log({ key, traceId: observability?.traceId ?? '', timestamp: Date.now(), durationMs, ok: false, attempts: meta.attempts, failedBy, error: sanitizedError });
|
|
491
|
+
}
|
|
492
|
+
return { ok: false, error, attempts: meta.attempts, traceId: observability?.traceId, durationMs };
|
|
493
|
+
}
|
|
494
|
+
finally {
|
|
495
|
+
cleanup();
|
|
496
|
+
(0, health_js_1.unregisterInflight)(scope);
|
|
497
|
+
(0, shutdown_js_1.unregisterDrainable)(scope);
|
|
190
498
|
}
|
|
191
499
|
};
|
|
192
500
|
// Build the `invalidate` implementation. The runtime branch on
|
|
@@ -195,15 +503,25 @@ function withStore(store) {
|
|
|
195
503
|
// (`boolean | Promise<boolean>`) to match either overload signature
|
|
196
504
|
// individually. The overloads at the call site guarantee callers see
|
|
197
505
|
// the correct type.
|
|
506
|
+
//
|
|
507
|
+
// FIX: The old async path did has() then delete() in separate awaits —
|
|
508
|
+
// a TOCTOU race. Sync stores are safe (single synchronous frame).
|
|
509
|
+
// Async stores document this as a known limitation.
|
|
198
510
|
const invalidateImpl = (key) => {
|
|
199
511
|
(0, validate_js_1.assertKey)(key);
|
|
200
512
|
const cacheKey = CACHE_NS + key;
|
|
201
513
|
if ((0, base_js_1.isSyncStore)(store)) {
|
|
514
|
+
// Sync store: has() + delete() in the same synchronous frame is safe in single-frame.
|
|
202
515
|
const existed = store.has(cacheKey);
|
|
203
516
|
store.delete(cacheKey);
|
|
204
517
|
return existed;
|
|
205
518
|
}
|
|
206
|
-
// Async store
|
|
519
|
+
// Async store: safe in single-frame delete. We check has() first, then delete.
|
|
520
|
+
// Between the two awaits, another caller could delete — but the return
|
|
521
|
+
// value is "did this key exist at the time we checked", which is the
|
|
522
|
+
// best we can do without a store-level delete-and-return-existed API.
|
|
523
|
+
// For true safe in single-frame semantics, async stores should implement a
|
|
524
|
+
// `deleteIfExists()` method — documented as a future API enhancement.
|
|
207
525
|
return (async () => {
|
|
208
526
|
const existed = await store.has(cacheKey);
|
|
209
527
|
await store.delete(cacheKey);
|
package/dist/core/act.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"act.d.ts","sourceRoot":"","sources":["../../src/core/act.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,KAAK,EACL,UAAU,EACV,SAAS,EAGT,aAAa,EACb,cAAc,EACd,eAAe,
|
|
1
|
+
{"version":3,"file":"act.d.ts","sourceRoot":"","sources":["../../src/core/act.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,KAAK,EACL,UAAU,EACV,SAAS,EAGT,aAAa,EACb,cAAc,EACd,eAAe,EAEhB,MAAM,mBAAmB,CAAA;AA4L1B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAsB,GAAG,CAAC,CAAC,EACzB,GAAG,EAAE,MAAM,EACX,EAAE,EAAE,KAAK,CAAC,CAAC,CAAC,EACZ,OAAO,GAAE,UAAe,GACvB,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CA8JvB;AA+BD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAM/C;AAID,2EAA2E;AAC3E,MAAM,WAAW,aAAa;IAC5B,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAA;IAC3E,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;IAChC,0EAA0E;IAC1E,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAA;CAC9B;AAED,8EAA8E;AAC9E,MAAM,WAAW,cAAc;IAC7B,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAA;IAC3E,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IACzC,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAA;CAC9B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,cAAc,GAAG,aAAa,CAAA;AAC/D,wBAAgB,SAAS,CAAC,KAAK,EAAE,eAAe,GAAG,cAAc,CAAA;AACjE,wBAAgB,SAAS,CAAC,KAAK,EAAE,aAAa,GAAG,aAAa,GAAG,cAAc,CAAA"}
|