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
package/dist/core/act.cjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HedgeTimeoutError = void 0;
|
|
3
4
|
exports.act = act;
|
|
4
5
|
exports.invalidate = invalidate;
|
|
5
6
|
exports.withStore = withStore;
|
|
@@ -15,24 +16,19 @@ const memory_js_1 = require("../stores/memory.js");
|
|
|
15
16
|
const base_js_1 = require("../stores/base.js");
|
|
16
17
|
const abort_js_1 = require("../utils/abort.js");
|
|
17
18
|
const sanitize_js_1 = require("../utils/sanitize.js");
|
|
19
|
+
const safeCall_js_1 = require("../utils/safeCall.js");
|
|
18
20
|
const health_js_1 = require("./health.js");
|
|
19
21
|
const shutdown_js_1 = require("./shutdown.js");
|
|
20
22
|
const validate_js_1 = require("../utils/validate.js");
|
|
21
|
-
|
|
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()`.
|
|
23
|
+
const errors_js_1 = require("../errors.js");
|
|
26
24
|
const defaultStore = (0, memory_js_1.createDefaultStore)();
|
|
27
|
-
// Namespace prefixes used by policies. Kept here (not in policy files) so
|
|
28
|
-
// `invalidate()` can resolve cache keys without importing policy internals.
|
|
29
25
|
const CACHE_NS = 'cache:';
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
26
|
+
function monotonicNow() {
|
|
27
|
+
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
|
|
28
|
+
return performance.now();
|
|
29
|
+
}
|
|
30
|
+
return Date.now();
|
|
31
|
+
}
|
|
36
32
|
function generateTraceId() {
|
|
37
33
|
const crypto = globalThis.crypto;
|
|
38
34
|
if (crypto && typeof crypto.randomUUID === 'function') {
|
|
@@ -40,18 +36,9 @@ function generateTraceId() {
|
|
|
40
36
|
}
|
|
41
37
|
return `actly-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
42
38
|
}
|
|
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
39
|
function buildObservability(hooks, traceId) {
|
|
49
|
-
// Contract: if no hooks are supplied, return undefined.
|
|
50
|
-
// Policies check `ctx.observability != null` before allocating events.
|
|
51
40
|
if (!hooks)
|
|
52
41
|
return undefined;
|
|
53
|
-
// Check that at least one hook is actually defined (avoid allocating
|
|
54
|
-
// context for an empty hooks object).
|
|
55
42
|
const hasAnyHook = !!hooks.onAttempt ||
|
|
56
43
|
!!hooks.onRetry ||
|
|
57
44
|
!!hooks.onCacheHit ||
|
|
@@ -59,7 +46,9 @@ function buildObservability(hooks, traceId) {
|
|
|
59
46
|
!!hooks.onDedupeJoin ||
|
|
60
47
|
!!hooks.onTimeout ||
|
|
61
48
|
!!hooks.onFinalSuccess ||
|
|
62
|
-
!!hooks.onFinalFailure
|
|
49
|
+
!!hooks.onFinalFailure ||
|
|
50
|
+
!!hooks.onBackpressure ||
|
|
51
|
+
!!hooks.onWatchdog;
|
|
63
52
|
if (!hasAnyHook)
|
|
64
53
|
return undefined;
|
|
65
54
|
return {
|
|
@@ -68,10 +57,6 @@ function buildObservability(hooks, traceId) {
|
|
|
68
57
|
joinerCounter: 0,
|
|
69
58
|
};
|
|
70
59
|
}
|
|
71
|
-
/**
|
|
72
|
-
* Determine the `failedBy` discriminator from a caught error.
|
|
73
|
-
* Name sniffing requires `instanceof Error` to prevent plain-object spoofing.
|
|
74
|
-
*/
|
|
75
60
|
function classifyFailure(error) {
|
|
76
61
|
if (error == null || typeof error !== 'object')
|
|
77
62
|
return 'fn-error';
|
|
@@ -86,8 +71,16 @@ function classifyFailure(error) {
|
|
|
86
71
|
return 'retry-exhausted';
|
|
87
72
|
if (code === 'ACTLY_VALIDATION')
|
|
88
73
|
return 'validation';
|
|
89
|
-
|
|
90
|
-
|
|
74
|
+
if (code === 'ACTLY_CIRCUIT_OPEN')
|
|
75
|
+
return 'circuit-open';
|
|
76
|
+
if (code === 'ACTLY_BULKHEAD_FULL')
|
|
77
|
+
return 'bulkhead-full';
|
|
78
|
+
if (code === 'ACTLY_RATE_LIMIT')
|
|
79
|
+
return 'rate-limited';
|
|
80
|
+
if (code === 'ACTLY_RESOURCE_EXHAUSTED')
|
|
81
|
+
return 'resource-exhausted';
|
|
82
|
+
if (code === 'ACTLY_HEDGE_TIMEOUT')
|
|
83
|
+
return 'hedge-timeout';
|
|
91
84
|
if (error instanceof Error) {
|
|
92
85
|
const name = error.name;
|
|
93
86
|
if (name === 'AbortError')
|
|
@@ -95,72 +88,46 @@ function classifyFailure(error) {
|
|
|
95
88
|
}
|
|
96
89
|
return 'fn-error';
|
|
97
90
|
}
|
|
98
|
-
/**
|
|
99
|
-
* Normalise `dedupe: true` shorthand to `DedupeOptions`.
|
|
100
|
-
* Returns `undefined` if dedupe is disabled or absent.
|
|
101
|
-
*/
|
|
102
91
|
function normalizeDedupe(opt) {
|
|
103
92
|
if (opt === true)
|
|
104
93
|
return { enabled: true };
|
|
105
94
|
if (opt && typeof opt === 'object' && opt.enabled) {
|
|
106
|
-
|
|
107
|
-
enabled: true,
|
|
108
|
-
|
|
109
|
-
};
|
|
95
|
+
if (opt.inflightTtl !== undefined && opt.inflightTtl !== 0 && !Number.isNaN(opt.inflightTtl)) {
|
|
96
|
+
return { enabled: true, inflightTtl: opt.inflightTtl };
|
|
97
|
+
}
|
|
98
|
+
return { enabled: true };
|
|
110
99
|
}
|
|
111
100
|
return undefined;
|
|
112
101
|
}
|
|
113
|
-
/**
|
|
114
|
-
* Build the policy chain from `ActOptions`. The order is fixed and
|
|
115
|
-
* documented in `executor.ts`. Policies with no effect (e.g. `retry.attempts: 1`)
|
|
116
|
-
* are skipped — they would be pure overhead.
|
|
117
|
-
*/
|
|
118
102
|
function buildPolicies(options) {
|
|
119
103
|
const dedupe = normalizeDedupe(options.dedupe);
|
|
120
104
|
const policies = [];
|
|
121
|
-
// Outermost: rate limiter (blocks before any work is done)
|
|
122
105
|
if (options.rateLimit) {
|
|
123
106
|
policies.push((0, rateLimit_js_1.rateLimitPolicy)(options.rateLimit));
|
|
124
107
|
}
|
|
125
|
-
// Circuit breaker (blocks if downstream is failing)
|
|
126
108
|
if (options.circuitBreaker) {
|
|
127
109
|
policies.push((0, circuitBreaker_js_1.circuitBreakerPolicy)(options.circuitBreaker));
|
|
128
110
|
}
|
|
129
|
-
// Hard wall-clock budget over the entire operation
|
|
130
111
|
if (options.totalTimeout && options.totalTimeout.ms > 0) {
|
|
131
112
|
policies.push((0, timeout_js_1.totalTimeoutPolicy)(options.totalTimeout));
|
|
132
113
|
}
|
|
133
|
-
// Cache: a hit short-circuits everything below it
|
|
134
114
|
if (options.cache && options.cache.ttl > 0) {
|
|
135
115
|
policies.push((0, cache_js_1.cachePolicy)(options.cache));
|
|
136
116
|
}
|
|
137
|
-
// Bulkhead: limits concurrency per key
|
|
138
117
|
if (options.bulkhead) {
|
|
139
118
|
policies.push((0, bulkhead_js_1.bulkheadPolicy)(options.bulkhead));
|
|
140
119
|
}
|
|
141
|
-
// Dedupe: collapses concurrent callers before retry fires
|
|
142
120
|
if (dedupe) {
|
|
143
121
|
policies.push((0, dedupe_js_1.dedupePolicy)(dedupe));
|
|
144
122
|
}
|
|
145
|
-
// Retry: owns the attempt loop (attempts: 1 is a no-op — skip)
|
|
146
123
|
if (options.retry && options.retry.attempts > 1) {
|
|
147
124
|
policies.push((0, retry_js_1.retryPolicy)(options.retry));
|
|
148
125
|
}
|
|
149
|
-
// Innermost: per-attempt clock. Resets on every retry
|
|
150
126
|
if (options.timeout && options.timeout.ms > 0) {
|
|
151
127
|
policies.push((0, timeout_js_1.timeoutPolicy)(options.timeout));
|
|
152
128
|
}
|
|
153
129
|
return policies;
|
|
154
130
|
}
|
|
155
|
-
/**
|
|
156
|
-
* Build a root AbortController from `options.signal`.
|
|
157
|
-
*
|
|
158
|
-
* - If no user signal: returns a fresh controller that never aborts unless
|
|
159
|
-
* an outer timeout policy aborts it.
|
|
160
|
-
* - If user signal is already aborted: returns a controller that is already
|
|
161
|
-
* aborted with the user's reason (so the operation rejects immediately).
|
|
162
|
-
* - Otherwise: links the user signal to the controller.
|
|
163
|
-
*/
|
|
164
131
|
function buildRootSignal(userSignal) {
|
|
165
132
|
const controller = new AbortController();
|
|
166
133
|
if (userSignal) {
|
|
@@ -169,40 +136,9 @@ function buildRootSignal(userSignal) {
|
|
|
169
136
|
}
|
|
170
137
|
return { controller, cleanup: () => { } };
|
|
171
138
|
}
|
|
172
|
-
/**
|
|
173
|
-
* Execute `fn` with the given reliability policies.
|
|
174
|
-
*
|
|
175
|
-
* @param key Stable identifier for this action. Scopes dedupe + cache.
|
|
176
|
-
* @param fn The async work to run. Receives an `AbortSignal` for
|
|
177
|
-
* cooperative cancellation (legacy `() => Promise<T>` is
|
|
178
|
-
* still accepted — the signal is simply ignored).
|
|
179
|
-
* @param options Which policies to apply and how. All fields are optional.
|
|
180
|
-
*
|
|
181
|
-
* @returns `ActResult<T>` — always resolves, never throws.
|
|
182
|
-
* Check `result.ok` before reading `result.value`.
|
|
183
|
-
*
|
|
184
|
-
* @example
|
|
185
|
-
* // With cooperative cancellation
|
|
186
|
-
* const result = await act('user:42', async (signal) => {
|
|
187
|
-
* return fetch(`/api/users/42`, { signal })
|
|
188
|
-
* }, {
|
|
189
|
-
* retry: { attempts: 3, delayMs: 200, backoff: 'exponential' },
|
|
190
|
-
* timeout: { ms: 5_000 },
|
|
191
|
-
* totalTimeout: { ms: 12_000 },
|
|
192
|
-
* dedupe: true,
|
|
193
|
-
* cache: { ttl: 60_000 },
|
|
194
|
-
* })
|
|
195
|
-
*
|
|
196
|
-
* if (result.ok) {
|
|
197
|
-
* console.log(result.value, result.source, result.attempts)
|
|
198
|
-
* } else {
|
|
199
|
-
* console.error(result.error)
|
|
200
|
-
* }
|
|
201
|
-
*/
|
|
202
139
|
async function act(key, fn, options = {}) {
|
|
203
140
|
(0, validate_js_1.assertKey)(key);
|
|
204
141
|
(0, validate_js_1.assertOptions)(options);
|
|
205
|
-
// Fast path: no options means no policies, no signal, no observability.
|
|
206
142
|
const hasAnyOption = options.retry !== undefined ||
|
|
207
143
|
options.timeout !== undefined ||
|
|
208
144
|
options.totalTimeout !== undefined ||
|
|
@@ -218,17 +154,25 @@ async function act(key, fn, options = {}) {
|
|
|
218
154
|
options.fallback !== undefined ||
|
|
219
155
|
options.audit !== undefined;
|
|
220
156
|
if (!hasAnyOption) {
|
|
221
|
-
const startedAt =
|
|
222
|
-
|
|
157
|
+
const startedAt = monotonicNow();
|
|
158
|
+
try {
|
|
159
|
+
(0, health_js_1.registerInflight)('default');
|
|
160
|
+
}
|
|
161
|
+
catch (e) {
|
|
162
|
+
const now = monotonicNow();
|
|
163
|
+
const durationMs = now - startedAt;
|
|
164
|
+
(0, health_js_1.recordError)('default', 'ACTLY_RESOURCE_EXHAUSTED', (0, sanitize_js_1.sanitizeErrorMessage)(e));
|
|
165
|
+
return { ok: false, error: e, attempts: 0, durationMs };
|
|
166
|
+
}
|
|
223
167
|
(0, shutdown_js_1.registerDrainable)('default');
|
|
224
168
|
try {
|
|
225
169
|
const value = await fn(new AbortController().signal);
|
|
226
170
|
(0, health_js_1.recordSuccess)('default');
|
|
227
|
-
return { ok: true, value, source: 'fresh', attempts: 1, durationMs:
|
|
171
|
+
return { ok: true, value, source: 'fresh', attempts: 1, durationMs: monotonicNow() - startedAt };
|
|
228
172
|
}
|
|
229
173
|
catch (error) {
|
|
230
174
|
(0, health_js_1.recordError)('default', 'fn-error', (0, sanitize_js_1.sanitizeErrorMessage)(error));
|
|
231
|
-
return { ok: false, error, attempts: 1, durationMs:
|
|
175
|
+
return { ok: false, error, attempts: 1, durationMs: monotonicNow() - startedAt };
|
|
232
176
|
}
|
|
233
177
|
finally {
|
|
234
178
|
(0, health_js_1.unregisterInflight)('default');
|
|
@@ -238,69 +182,149 @@ async function act(key, fn, options = {}) {
|
|
|
238
182
|
const meta = { attempts: 1, source: 'fresh' };
|
|
239
183
|
const { controller: rootController, cleanup } = buildRootSignal(options.signal);
|
|
240
184
|
const observability = buildObservability(options.observability, options.traceId);
|
|
241
|
-
const
|
|
185
|
+
const effectiveTraceId = observability?.traceId ?? options.traceId;
|
|
186
|
+
const startedAt = monotonicNow();
|
|
242
187
|
const scope = 'default';
|
|
243
|
-
|
|
188
|
+
try {
|
|
189
|
+
(0, health_js_1.registerInflight)(scope);
|
|
190
|
+
}
|
|
191
|
+
catch (e) {
|
|
192
|
+
cleanup();
|
|
193
|
+
const now = monotonicNow();
|
|
194
|
+
const durationMs = now - startedAt;
|
|
195
|
+
(0, health_js_1.recordError)(scope, 'ACTLY_RESOURCE_EXHAUSTED', (0, sanitize_js_1.sanitizeErrorMessage)(e));
|
|
196
|
+
if (observability) {
|
|
197
|
+
(0, safeCall_js_1.safeCall)(observability.hooks.onFinalFailure, {
|
|
198
|
+
type: 'final-failure',
|
|
199
|
+
key, traceId: observability.traceId, timestamp: Date.now(),
|
|
200
|
+
attempts: 0, durationMs,
|
|
201
|
+
failedBy: 'resource-exhausted', error: e,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
if (options.audit) {
|
|
205
|
+
(0, safeCall_js_1.safeCall)(options.audit.log, {
|
|
206
|
+
key, traceId: effectiveTraceId ?? '', timestamp: Date.now(), durationMs,
|
|
207
|
+
ok: false, attempts: 0, failedBy: 'resource-exhausted',
|
|
208
|
+
error: (0, sanitize_js_1.sanitizeError)(e),
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
return { ok: false, error: e, attempts: 0, traceId: effectiveTraceId, durationMs };
|
|
212
|
+
}
|
|
244
213
|
(0, shutdown_js_1.registerDrainable)(scope);
|
|
245
214
|
if (rootController.signal.aborted) {
|
|
246
215
|
cleanup();
|
|
247
216
|
(0, health_js_1.unregisterInflight)(scope);
|
|
248
217
|
(0, shutdown_js_1.unregisterDrainable)(scope);
|
|
249
218
|
const error = rootController.signal.reason;
|
|
219
|
+
const now = Date.now();
|
|
220
|
+
const durationMs = monotonicNow() - startedAt;
|
|
250
221
|
if (observability) {
|
|
251
|
-
observability.hooks.onFinalFailure
|
|
222
|
+
(0, safeCall_js_1.safeCall)(observability.hooks.onFinalFailure, {
|
|
252
223
|
type: 'final-failure',
|
|
253
|
-
key, traceId: observability.traceId, timestamp:
|
|
254
|
-
attempts: 0, durationMs
|
|
224
|
+
key, traceId: observability.traceId, timestamp: now,
|
|
225
|
+
attempts: 0, durationMs,
|
|
255
226
|
failedBy: 'abort', error,
|
|
256
227
|
});
|
|
257
228
|
}
|
|
258
229
|
if (options.audit) {
|
|
259
|
-
|
|
230
|
+
const sanitizedError = (0, sanitize_js_1.sanitizeError)(error);
|
|
231
|
+
(0, safeCall_js_1.safeCall)(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: now, durationMs, ok: false, attempts: 0, failedBy: 'abort', error: sanitizedError });
|
|
260
232
|
}
|
|
261
233
|
(0, health_js_1.recordError)(scope, 'ACTLY_ABORT', (0, sanitize_js_1.sanitizeErrorMessage)(error));
|
|
262
|
-
return { ok: false, error, attempts: 0, traceId:
|
|
234
|
+
return { ok: false, error, attempts: 0, traceId: effectiveTraceId, durationMs };
|
|
235
|
+
}
|
|
236
|
+
let policies;
|
|
237
|
+
try {
|
|
238
|
+
policies = buildPolicies(options);
|
|
239
|
+
}
|
|
240
|
+
catch (e) {
|
|
241
|
+
cleanup();
|
|
242
|
+
(0, health_js_1.unregisterInflight)(scope);
|
|
243
|
+
(0, shutdown_js_1.unregisterDrainable)(scope);
|
|
244
|
+
const now = Date.now();
|
|
245
|
+
const durationMs = monotonicNow() - startedAt;
|
|
246
|
+
(0, health_js_1.recordError)(scope, 'ACTLY_VALIDATION', (0, sanitize_js_1.sanitizeErrorMessage)(e));
|
|
247
|
+
if (observability) {
|
|
248
|
+
(0, safeCall_js_1.safeCall)(observability.hooks.onFinalFailure, {
|
|
249
|
+
type: 'final-failure',
|
|
250
|
+
key, traceId: observability.traceId, timestamp: now,
|
|
251
|
+
attempts: 0, durationMs, failedBy: 'validation', error: e,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
if (options.audit) {
|
|
255
|
+
(0, safeCall_js_1.safeCall)(options.audit.log, {
|
|
256
|
+
key, traceId: effectiveTraceId ?? '', timestamp: now, durationMs,
|
|
257
|
+
ok: false, attempts: 0, failedBy: 'validation',
|
|
258
|
+
error: (0, sanitize_js_1.sanitizeError)(e),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
return { ok: false, error: e, attempts: 0, traceId: effectiveTraceId, durationMs };
|
|
263
262
|
}
|
|
264
|
-
const policies = buildPolicies(options);
|
|
265
263
|
const hasRetryPolicy = !!(options.retry && options.retry.attempts > 1);
|
|
266
264
|
if (observability && !hasRetryPolicy) {
|
|
267
|
-
observability.hooks.onAttempt
|
|
265
|
+
(0, safeCall_js_1.safeCall)(observability.hooks.onAttempt, {
|
|
268
266
|
type: 'attempt', key, traceId: observability.traceId,
|
|
269
267
|
timestamp: Date.now(), attempt: 1,
|
|
270
268
|
});
|
|
271
269
|
}
|
|
272
|
-
|
|
273
|
-
const
|
|
270
|
+
const hedgePlacement = options.hedge?.placement ?? 'outside-retry';
|
|
271
|
+
const hedgeKeepLoser = options.hedge?.keepLoser ?? false;
|
|
272
|
+
const fnWithHedge = (options.hedge && hedgePlacement === 'inside-retry')
|
|
273
|
+
? wrapHedge(fn, options.hedge.delayMs, hedgeKeepLoser)
|
|
274
|
+
: fn;
|
|
275
|
+
const needsRaceAbort = options.signal !== undefined;
|
|
274
276
|
try {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
277
|
+
let value;
|
|
278
|
+
if (options.hedge && hedgePlacement === 'outside-retry') {
|
|
279
|
+
const hedgeMeta = { attempts: 1, source: 'fresh' };
|
|
280
|
+
const chainFactory = (signal, m) => (0, executor_js_1.execute)({
|
|
281
|
+
key,
|
|
282
|
+
fn: fnWithHedge,
|
|
283
|
+
policies,
|
|
284
|
+
store: defaultStore,
|
|
285
|
+
meta: m,
|
|
286
|
+
signal,
|
|
287
|
+
observability,
|
|
288
|
+
});
|
|
289
|
+
const hedgeResult = needsRaceAbort
|
|
290
|
+
? await (0, abort_js_1.raceAbort)(Promise.resolve(runWithHedge(chainFactory, meta, hedgeMeta, rootController.signal, options.hedge.delayMs, hedgeKeepLoser)), rootController.signal)
|
|
291
|
+
: await runWithHedge(chainFactory, meta, hedgeMeta, rootController.signal, options.hedge.delayMs, hedgeKeepLoser);
|
|
292
|
+
meta.attempts = hedgeResult.winnerMeta.attempts;
|
|
293
|
+
meta.source = hedgeResult.winnerMeta.source;
|
|
294
|
+
value = hedgeResult.value;
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
const execPromise = (0, executor_js_1.execute)({
|
|
298
|
+
key,
|
|
299
|
+
fn: fnWithHedge,
|
|
300
|
+
policies,
|
|
301
|
+
store: defaultStore,
|
|
302
|
+
meta,
|
|
303
|
+
signal: rootController.signal,
|
|
304
|
+
observability,
|
|
305
|
+
});
|
|
306
|
+
value = needsRaceAbort
|
|
307
|
+
? await (0, abort_js_1.raceAbort)(Promise.resolve(execPromise), rootController.signal)
|
|
308
|
+
: await execPromise;
|
|
309
|
+
}
|
|
310
|
+
const now = Date.now();
|
|
311
|
+
const durationMs = monotonicNow() - startedAt;
|
|
285
312
|
(0, health_js_1.recordSuccess)(scope);
|
|
286
313
|
if (observability) {
|
|
287
|
-
observability.hooks.onFinalSuccess
|
|
314
|
+
(0, safeCall_js_1.safeCall)(observability.hooks.onFinalSuccess, {
|
|
288
315
|
type: 'final-success',
|
|
289
|
-
key, traceId: observability.traceId, timestamp:
|
|
316
|
+
key, traceId: observability.traceId, timestamp: now,
|
|
290
317
|
source: meta.source, attempts: meta.attempts, durationMs,
|
|
291
318
|
});
|
|
292
319
|
}
|
|
293
320
|
if (options.audit) {
|
|
294
|
-
options.audit.log
|
|
321
|
+
(0, safeCall_js_1.safeCall)(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: now, durationMs, ok: true, attempts: meta.attempts });
|
|
295
322
|
}
|
|
296
|
-
return { ok: true, value, source: meta.source, attempts: meta.attempts, traceId:
|
|
323
|
+
return { ok: true, value, source: meta.source, attempts: meta.attempts, traceId: effectiveTraceId, durationMs };
|
|
297
324
|
}
|
|
298
325
|
catch (error) {
|
|
299
|
-
const durationMs =
|
|
326
|
+
const durationMs = monotonicNow() - startedAt;
|
|
300
327
|
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
328
|
let errorRecorded = false;
|
|
305
329
|
if (options.fallback) {
|
|
306
330
|
(0, health_js_1.recordError)(scope, failedBy, (0, sanitize_js_1.sanitizeErrorMessage)(error));
|
|
@@ -310,19 +334,29 @@ async function act(key, fn, options = {}) {
|
|
|
310
334
|
? await options.fallback.value()
|
|
311
335
|
: options.fallback.value;
|
|
312
336
|
if (observability) {
|
|
313
|
-
observability.hooks.onFinalSuccess
|
|
337
|
+
(0, safeCall_js_1.safeCall)(observability.hooks.onFinalSuccess, {
|
|
314
338
|
type: 'final-success',
|
|
315
339
|
key, traceId: observability.traceId, timestamp: Date.now(),
|
|
316
340
|
source: meta.source, attempts: meta.attempts, durationMs,
|
|
317
341
|
});
|
|
318
342
|
}
|
|
319
343
|
if (options.audit) {
|
|
320
|
-
options.audit.log
|
|
344
|
+
(0, safeCall_js_1.safeCall)(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: Date.now(), durationMs, ok: true, attempts: meta.attempts });
|
|
321
345
|
}
|
|
322
|
-
return { ok: true, value: fallbackValue, source: 'fresh', attempts: meta.attempts, traceId:
|
|
346
|
+
return { ok: true, value: fallbackValue, source: 'fresh', attempts: meta.attempts, traceId: effectiveTraceId, durationMs };
|
|
323
347
|
}
|
|
324
|
-
catch {
|
|
325
|
-
|
|
348
|
+
catch (fallbackErr) {
|
|
349
|
+
if (observability) {
|
|
350
|
+
(0, safeCall_js_1.safeCall)(observability.hooks.onFinalFailure, {
|
|
351
|
+
type: 'final-failure',
|
|
352
|
+
key, traceId: observability.traceId, timestamp: Date.now(),
|
|
353
|
+
attempts: meta.attempts, durationMs,
|
|
354
|
+
failedBy: 'fn-error', error: fallbackErr,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
|
|
358
|
+
console.warn('Actly: fallback threw — error swallowed, surfacing original fn error.', fallbackErr);
|
|
359
|
+
}
|
|
326
360
|
}
|
|
327
361
|
}
|
|
328
362
|
const sanitizedError = options.audit ? (0, sanitize_js_1.sanitizeError)(error) : error;
|
|
@@ -330,7 +364,7 @@ async function act(key, fn, options = {}) {
|
|
|
330
364
|
(0, health_js_1.recordError)(scope, failedBy, (0, sanitize_js_1.sanitizeErrorMessage)(error));
|
|
331
365
|
}
|
|
332
366
|
if (observability) {
|
|
333
|
-
observability.hooks.onFinalFailure
|
|
367
|
+
(0, safeCall_js_1.safeCall)(observability.hooks.onFinalFailure, {
|
|
334
368
|
type: 'final-failure',
|
|
335
369
|
key, traceId: observability.traceId, timestamp: Date.now(),
|
|
336
370
|
attempts: meta.attempts, durationMs,
|
|
@@ -338,9 +372,9 @@ async function act(key, fn, options = {}) {
|
|
|
338
372
|
});
|
|
339
373
|
}
|
|
340
374
|
if (options.audit) {
|
|
341
|
-
options.audit.log
|
|
375
|
+
(0, safeCall_js_1.safeCall)(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: Date.now(), durationMs, ok: false, attempts: meta.attempts, failedBy, error: sanitizedError });
|
|
342
376
|
}
|
|
343
|
-
return { ok: false, error, attempts: meta.attempts, traceId:
|
|
377
|
+
return { ok: false, error, attempts: meta.attempts, traceId: effectiveTraceId, durationMs };
|
|
344
378
|
}
|
|
345
379
|
finally {
|
|
346
380
|
cleanup();
|
|
@@ -348,51 +382,115 @@ async function act(key, fn, options = {}) {
|
|
|
348
382
|
(0, shutdown_js_1.unregisterDrainable)(scope);
|
|
349
383
|
}
|
|
350
384
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
385
|
+
var errors_js_2 = require("../errors.js");
|
|
386
|
+
Object.defineProperty(exports, "HedgeTimeoutError", { enumerable: true, get: function () { return errors_js_2.HedgeTimeoutError; } });
|
|
387
|
+
function wrapHedge(fn, delayMs, keepLoser) {
|
|
388
|
+
return async (parentSignal) => {
|
|
389
|
+
const primaryCtl = new AbortController();
|
|
390
|
+
const hedgeCtl = new AbortController();
|
|
391
|
+
const unlinkPrimary = (0, abort_js_1.linkSignal)(parentSignal, primaryCtl);
|
|
392
|
+
const unlinkHedge = (0, abort_js_1.linkSignal)(parentSignal, hedgeCtl);
|
|
356
393
|
let timer;
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
timer = setTimeout(() => reject(new Error('__HEDGE_TIMEOUT__')), delayMs);
|
|
360
|
-
});
|
|
394
|
+
let primary;
|
|
395
|
+
let hedgePromise;
|
|
361
396
|
try {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
Promise.
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
397
|
+
primary = Promise.resolve(fn(primaryCtl.signal));
|
|
398
|
+
const hedgeTimeout = new Promise((_, reject) => {
|
|
399
|
+
timer = setTimeout(() => reject(new errors_js_1.HedgeTimeoutError()), delayMs);
|
|
400
|
+
});
|
|
401
|
+
try {
|
|
402
|
+
return await Promise.race([primary, hedgeTimeout]);
|
|
403
|
+
}
|
|
404
|
+
catch (e) {
|
|
405
|
+
if (!(e instanceof errors_js_1.HedgeTimeoutError)) {
|
|
406
|
+
if (!keepLoser)
|
|
407
|
+
hedgeCtl.abort(new Error('hedge cancelled: primary rejected'));
|
|
408
|
+
throw e;
|
|
409
|
+
}
|
|
410
|
+
hedgePromise = Promise.resolve(fn(hedgeCtl.signal));
|
|
411
|
+
primary.catch(() => { });
|
|
412
|
+
hedgePromise.catch(() => { });
|
|
413
|
+
try {
|
|
414
|
+
const primaryTagged = primary.then(v => ({ value: v, winner: 'primary' }));
|
|
415
|
+
const hedgeTagged = hedgePromise.then(v => ({ value: v, winner: 'hedge' }));
|
|
416
|
+
const winner = await Promise.race([primaryTagged, hedgeTagged]);
|
|
417
|
+
if (!keepLoser) {
|
|
418
|
+
if (winner.winner === 'primary') {
|
|
419
|
+
hedgeCtl.abort(new Error('hedge cancelled: loser'));
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
primaryCtl.abort(new Error('hedge cancelled: loser'));
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return winner.value;
|
|
426
|
+
}
|
|
427
|
+
catch (err) {
|
|
428
|
+
if (!keepLoser) {
|
|
429
|
+
primaryCtl.abort(new Error('hedge cancelled: loser rejected'));
|
|
430
|
+
hedgeCtl.abort(new Error('hedge cancelled: loser rejected'));
|
|
431
|
+
}
|
|
432
|
+
throw err;
|
|
433
|
+
}
|
|
371
434
|
}
|
|
372
|
-
throw e;
|
|
373
435
|
}
|
|
374
436
|
finally {
|
|
375
437
|
if (timer)
|
|
376
438
|
clearTimeout(timer);
|
|
439
|
+
unlinkPrimary();
|
|
440
|
+
unlinkHedge();
|
|
377
441
|
}
|
|
378
442
|
};
|
|
379
443
|
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
444
|
+
async function runWithHedge(chainFactory, primaryMeta, hedgeMeta, parentSignal, delayMs, keepLoser) {
|
|
445
|
+
const primaryCtl = new AbortController();
|
|
446
|
+
const hedgeCtl = new AbortController();
|
|
447
|
+
const unlinkPrimary = (0, abort_js_1.linkSignal)(parentSignal, primaryCtl);
|
|
448
|
+
const unlinkHedge = (0, abort_js_1.linkSignal)(parentSignal, hedgeCtl);
|
|
449
|
+
let timer;
|
|
450
|
+
try {
|
|
451
|
+
const primary = Promise.resolve(chainFactory(primaryCtl.signal, primaryMeta));
|
|
452
|
+
const hedgeTimeout = new Promise((_, reject) => {
|
|
453
|
+
timer = setTimeout(() => reject(new errors_js_1.HedgeTimeoutError()), delayMs);
|
|
454
|
+
});
|
|
455
|
+
try {
|
|
456
|
+
const value = await Promise.race([primary, hedgeTimeout]);
|
|
457
|
+
return { value, winnerMeta: primaryMeta };
|
|
458
|
+
}
|
|
459
|
+
catch (e) {
|
|
460
|
+
if (!(e instanceof errors_js_1.HedgeTimeoutError)) {
|
|
461
|
+
if (!keepLoser)
|
|
462
|
+
hedgeCtl.abort(new Error('hedge cancelled: primary rejected'));
|
|
463
|
+
throw e;
|
|
464
|
+
}
|
|
465
|
+
const hedge = Promise.resolve(chainFactory(hedgeCtl.signal, hedgeMeta));
|
|
466
|
+
primary.catch(() => { });
|
|
467
|
+
hedge.catch(() => { });
|
|
468
|
+
const primaryTagged = primary.then(v => ({ value: v, winnerMeta: primaryMeta }));
|
|
469
|
+
const hedgeTagged = hedge.then(v => ({ value: v, winnerMeta: hedgeMeta }));
|
|
470
|
+
try {
|
|
471
|
+
const winner = await Promise.race([primaryTagged, hedgeTagged]);
|
|
472
|
+
if (!keepLoser) {
|
|
473
|
+
primaryCtl.abort(new Error('hedge cancelled: loser'));
|
|
474
|
+
hedgeCtl.abort(new Error('hedge cancelled: loser'));
|
|
475
|
+
}
|
|
476
|
+
return winner;
|
|
477
|
+
}
|
|
478
|
+
catch (err) {
|
|
479
|
+
if (!keepLoser) {
|
|
480
|
+
primaryCtl.abort(new Error('hedge cancelled: loser rejected'));
|
|
481
|
+
hedgeCtl.abort(new Error('hedge cancelled: loser rejected'));
|
|
482
|
+
}
|
|
483
|
+
throw err;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
finally {
|
|
488
|
+
if (timer)
|
|
489
|
+
clearTimeout(timer);
|
|
490
|
+
unlinkPrimary();
|
|
491
|
+
unlinkHedge();
|
|
492
|
+
}
|
|
493
|
+
}
|
|
396
494
|
function invalidate(key) {
|
|
397
495
|
(0, validate_js_1.assertKey)(key);
|
|
398
496
|
const cacheKey = CACHE_NS + key;
|
|
@@ -401,63 +499,151 @@ function invalidate(key) {
|
|
|
401
499
|
return existed;
|
|
402
500
|
}
|
|
403
501
|
function withStore(store) {
|
|
404
|
-
const
|
|
502
|
+
const crypto = globalThis.crypto;
|
|
503
|
+
const scope = 'scoped:' + (crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2, 14));
|
|
504
|
+
(0, health_js_1.registerStoreScope)(store, scope);
|
|
405
505
|
const scopedAct = async (key, fn, options = {}) => {
|
|
406
506
|
(0, validate_js_1.assertKey)(key);
|
|
407
507
|
(0, validate_js_1.assertOptions)(options);
|
|
408
508
|
const meta = { attempts: 1, source: 'fresh' };
|
|
409
509
|
const { controller: rootController, cleanup } = buildRootSignal(options.signal);
|
|
410
510
|
const observability = buildObservability(options.observability, options.traceId);
|
|
411
|
-
const
|
|
412
|
-
|
|
511
|
+
const effectiveTraceId = observability?.traceId ?? options.traceId;
|
|
512
|
+
const startedAt = monotonicNow();
|
|
513
|
+
try {
|
|
514
|
+
(0, health_js_1.registerInflight)(scope);
|
|
515
|
+
}
|
|
516
|
+
catch (e) {
|
|
517
|
+
cleanup();
|
|
518
|
+
const now = monotonicNow();
|
|
519
|
+
const durationMs = now - startedAt;
|
|
520
|
+
(0, health_js_1.recordError)(scope, 'ACTLY_RESOURCE_EXHAUSTED', (0, sanitize_js_1.sanitizeErrorMessage)(e));
|
|
521
|
+
if (observability) {
|
|
522
|
+
(0, safeCall_js_1.safeCall)(observability.hooks.onFinalFailure, {
|
|
523
|
+
type: 'final-failure', key, traceId: observability.traceId, timestamp: Date.now(),
|
|
524
|
+
attempts: 0, durationMs, failedBy: 'resource-exhausted', error: e,
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
if (options.audit) {
|
|
528
|
+
(0, safeCall_js_1.safeCall)(options.audit.log, {
|
|
529
|
+
key, traceId: effectiveTraceId ?? '', timestamp: Date.now(), durationMs,
|
|
530
|
+
ok: false, attempts: 0, failedBy: 'resource-exhausted',
|
|
531
|
+
error: (0, sanitize_js_1.sanitizeError)(e),
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
return { ok: false, error: e, attempts: 0, traceId: effectiveTraceId, durationMs };
|
|
535
|
+
}
|
|
413
536
|
(0, shutdown_js_1.registerDrainable)(scope);
|
|
414
537
|
if (rootController.signal.aborted) {
|
|
415
538
|
cleanup();
|
|
416
539
|
(0, health_js_1.unregisterInflight)(scope);
|
|
417
540
|
(0, shutdown_js_1.unregisterDrainable)(scope);
|
|
418
541
|
const error = rootController.signal.reason;
|
|
542
|
+
const now = Date.now();
|
|
543
|
+
const durationMs = now - startedAt;
|
|
544
|
+
(0, health_js_1.recordError)(scope, 'ACTLY_ABORT', (0, sanitize_js_1.sanitizeErrorMessage)(error));
|
|
419
545
|
if (observability) {
|
|
420
|
-
observability.hooks.onFinalFailure
|
|
421
|
-
type: 'final-failure', key, traceId: observability.traceId, timestamp:
|
|
422
|
-
attempts: 0, durationMs
|
|
546
|
+
(0, safeCall_js_1.safeCall)(observability.hooks.onFinalFailure, {
|
|
547
|
+
type: 'final-failure', key, traceId: observability.traceId, timestamp: now,
|
|
548
|
+
attempts: 0, durationMs, failedBy: 'abort', error,
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
if (options.audit) {
|
|
552
|
+
const sanitizedError = (0, sanitize_js_1.sanitizeError)(error);
|
|
553
|
+
(0, safeCall_js_1.safeCall)(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: now, durationMs, ok: false, attempts: 0, failedBy: 'abort', error: sanitizedError });
|
|
554
|
+
}
|
|
555
|
+
return { ok: false, error, attempts: 0, traceId: effectiveTraceId, durationMs };
|
|
556
|
+
}
|
|
557
|
+
let policies;
|
|
558
|
+
try {
|
|
559
|
+
policies = buildPolicies(options);
|
|
560
|
+
}
|
|
561
|
+
catch (e) {
|
|
562
|
+
cleanup();
|
|
563
|
+
(0, health_js_1.unregisterInflight)(scope);
|
|
564
|
+
(0, shutdown_js_1.unregisterDrainable)(scope);
|
|
565
|
+
const now = Date.now();
|
|
566
|
+
const durationMs = monotonicNow() - startedAt;
|
|
567
|
+
(0, health_js_1.recordError)(scope, 'ACTLY_VALIDATION', (0, sanitize_js_1.sanitizeErrorMessage)(e));
|
|
568
|
+
if (observability) {
|
|
569
|
+
(0, safeCall_js_1.safeCall)(observability.hooks.onFinalFailure, {
|
|
570
|
+
type: 'final-failure', key, traceId: observability.traceId, timestamp: now,
|
|
571
|
+
attempts: 0, durationMs, failedBy: 'validation', error: e,
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
if (options.audit) {
|
|
575
|
+
(0, safeCall_js_1.safeCall)(options.audit.log, {
|
|
576
|
+
key, traceId: effectiveTraceId ?? '', timestamp: now, durationMs,
|
|
577
|
+
ok: false, attempts: 0, failedBy: 'validation',
|
|
578
|
+
error: (0, sanitize_js_1.sanitizeError)(e),
|
|
423
579
|
});
|
|
424
580
|
}
|
|
425
|
-
return { ok: false, error, attempts: 0, traceId:
|
|
581
|
+
return { ok: false, error: e, attempts: 0, traceId: effectiveTraceId, durationMs };
|
|
426
582
|
}
|
|
427
|
-
const policies = buildPolicies(options);
|
|
428
583
|
const hasRetryPolicy = !!(options.retry && options.retry.attempts > 1);
|
|
429
584
|
if (observability && !hasRetryPolicy) {
|
|
430
|
-
observability.hooks.onAttempt
|
|
585
|
+
(0, safeCall_js_1.safeCall)(observability.hooks.onAttempt, {
|
|
431
586
|
type: 'attempt', key, traceId: observability.traceId,
|
|
432
587
|
timestamp: Date.now(), attempt: 1,
|
|
433
588
|
});
|
|
434
589
|
}
|
|
435
|
-
const
|
|
590
|
+
const hedgePlacement = options.hedge?.placement ?? 'outside-retry';
|
|
591
|
+
const hedgeKeepLoser = options.hedge?.keepLoser ?? false;
|
|
592
|
+
const fnWithHedge = (options.hedge && hedgePlacement === 'inside-retry')
|
|
593
|
+
? wrapHedge(fn, options.hedge.delayMs, hedgeKeepLoser)
|
|
594
|
+
: fn;
|
|
595
|
+
const needsRaceAbort = options.signal !== undefined;
|
|
436
596
|
try {
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
597
|
+
let value;
|
|
598
|
+
if (options.hedge && hedgePlacement === 'outside-retry') {
|
|
599
|
+
const hedgeMeta = { attempts: 1, source: 'fresh' };
|
|
600
|
+
const chainFactory = (signal, m) => (0, executor_js_1.execute)({
|
|
601
|
+
key,
|
|
602
|
+
fn: fnWithHedge,
|
|
603
|
+
policies,
|
|
604
|
+
store,
|
|
605
|
+
meta: m,
|
|
606
|
+
signal,
|
|
607
|
+
observability,
|
|
608
|
+
});
|
|
609
|
+
const hedgeResult = needsRaceAbort
|
|
610
|
+
? await (0, abort_js_1.raceAbort)(Promise.resolve(runWithHedge(chainFactory, meta, hedgeMeta, rootController.signal, options.hedge.delayMs, hedgeKeepLoser)), rootController.signal)
|
|
611
|
+
: await runWithHedge(chainFactory, meta, hedgeMeta, rootController.signal, options.hedge.delayMs, hedgeKeepLoser);
|
|
612
|
+
meta.attempts = hedgeResult.winnerMeta.attempts;
|
|
613
|
+
meta.source = hedgeResult.winnerMeta.source;
|
|
614
|
+
value = hedgeResult.value;
|
|
615
|
+
}
|
|
616
|
+
else {
|
|
617
|
+
const execPromise = (0, executor_js_1.execute)({
|
|
618
|
+
key,
|
|
619
|
+
fn: fnWithHedge,
|
|
620
|
+
policies,
|
|
621
|
+
store,
|
|
622
|
+
meta,
|
|
623
|
+
signal: rootController.signal,
|
|
624
|
+
observability,
|
|
625
|
+
});
|
|
626
|
+
value = needsRaceAbort
|
|
627
|
+
? await (0, abort_js_1.raceAbort)(Promise.resolve(execPromise), rootController.signal)
|
|
628
|
+
: await execPromise;
|
|
629
|
+
}
|
|
630
|
+
const now = Date.now();
|
|
631
|
+
const durationMs = monotonicNow() - startedAt;
|
|
447
632
|
(0, health_js_1.recordSuccess)(scope);
|
|
448
633
|
if (observability) {
|
|
449
|
-
observability.hooks.onFinalSuccess
|
|
450
|
-
type: 'final-success', key, traceId: observability.traceId, timestamp:
|
|
634
|
+
(0, safeCall_js_1.safeCall)(observability.hooks.onFinalSuccess, {
|
|
635
|
+
type: 'final-success', key, traceId: observability.traceId, timestamp: now,
|
|
451
636
|
source: meta.source, attempts: meta.attempts, durationMs,
|
|
452
637
|
});
|
|
453
638
|
}
|
|
454
639
|
if (options.audit) {
|
|
455
|
-
options.audit.log
|
|
640
|
+
(0, safeCall_js_1.safeCall)(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: now, durationMs, ok: true, attempts: meta.attempts });
|
|
456
641
|
}
|
|
457
|
-
return { ok: true, value, source: meta.source, attempts: meta.attempts, traceId:
|
|
642
|
+
return { ok: true, value, source: meta.source, attempts: meta.attempts, traceId: effectiveTraceId, durationMs };
|
|
458
643
|
}
|
|
459
644
|
catch (error) {
|
|
460
|
-
const
|
|
645
|
+
const now = Date.now();
|
|
646
|
+
const durationMs = monotonicNow() - startedAt;
|
|
461
647
|
const failedBy = rootController.signal.aborted ? 'abort' : classifyFailure(error);
|
|
462
648
|
let errorRecorded = false;
|
|
463
649
|
if (options.fallback) {
|
|
@@ -467,29 +653,43 @@ function withStore(store) {
|
|
|
467
653
|
const fallbackValue = typeof options.fallback.value === 'function'
|
|
468
654
|
? await options.fallback.value()
|
|
469
655
|
: options.fallback.value;
|
|
656
|
+
if (observability) {
|
|
657
|
+
(0, safeCall_js_1.safeCall)(observability.hooks.onFinalSuccess, {
|
|
658
|
+
type: 'final-success', key, traceId: observability.traceId, timestamp: Date.now(),
|
|
659
|
+
source: meta.source, attempts: meta.attempts, durationMs,
|
|
660
|
+
});
|
|
661
|
+
}
|
|
470
662
|
if (options.audit) {
|
|
471
|
-
options.audit.log
|
|
663
|
+
(0, safeCall_js_1.safeCall)(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: Date.now(), durationMs, ok: true, attempts: meta.attempts });
|
|
472
664
|
}
|
|
473
|
-
return { ok: true, value: fallbackValue, source: 'fresh', attempts: meta.attempts, traceId:
|
|
665
|
+
return { ok: true, value: fallbackValue, source: 'fresh', attempts: meta.attempts, traceId: effectiveTraceId, durationMs };
|
|
474
666
|
}
|
|
475
|
-
catch {
|
|
476
|
-
|
|
667
|
+
catch (fallbackErr) {
|
|
668
|
+
if (observability) {
|
|
669
|
+
(0, safeCall_js_1.safeCall)(observability.hooks.onFinalFailure, {
|
|
670
|
+
type: 'final-failure', key, traceId: observability.traceId, timestamp: Date.now(),
|
|
671
|
+
attempts: meta.attempts, durationMs, failedBy: 'fn-error', error: fallbackErr,
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
|
|
675
|
+
console.warn('Actly: fallback threw — error swallowed, surfacing original fn error.', fallbackErr);
|
|
676
|
+
}
|
|
477
677
|
}
|
|
478
678
|
}
|
|
479
679
|
if (!errorRecorded) {
|
|
480
680
|
(0, health_js_1.recordError)(scope, failedBy, (0, sanitize_js_1.sanitizeErrorMessage)(error));
|
|
481
681
|
}
|
|
482
682
|
if (observability) {
|
|
483
|
-
observability.hooks.onFinalFailure
|
|
484
|
-
type: 'final-failure', key, traceId: observability.traceId, timestamp:
|
|
683
|
+
(0, safeCall_js_1.safeCall)(observability.hooks.onFinalFailure, {
|
|
684
|
+
type: 'final-failure', key, traceId: observability.traceId, timestamp: now,
|
|
485
685
|
attempts: meta.attempts, durationMs, failedBy, error,
|
|
486
686
|
});
|
|
487
687
|
}
|
|
488
688
|
if (options.audit) {
|
|
489
689
|
const sanitizedError = (0, sanitize_js_1.sanitizeError)(error);
|
|
490
|
-
options.audit.log
|
|
690
|
+
(0, safeCall_js_1.safeCall)(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: Date.now(), durationMs, ok: false, attempts: meta.attempts, failedBy, error: sanitizedError });
|
|
491
691
|
}
|
|
492
|
-
return { ok: false, error, attempts: meta.attempts, traceId:
|
|
692
|
+
return { ok: false, error, attempts: meta.attempts, traceId: effectiveTraceId, durationMs };
|
|
493
693
|
}
|
|
494
694
|
finally {
|
|
495
695
|
cleanup();
|
|
@@ -497,43 +697,29 @@ function withStore(store) {
|
|
|
497
697
|
(0, shutdown_js_1.unregisterDrainable)(scope);
|
|
498
698
|
}
|
|
499
699
|
};
|
|
500
|
-
// Build the `invalidate` implementation. The runtime branch on
|
|
501
|
-
// `isSyncStore` selects the correct path; the cast through `unknown`
|
|
502
|
-
// is required because TypeScript cannot narrow the union return type
|
|
503
|
-
// (`boolean | Promise<boolean>`) to match either overload signature
|
|
504
|
-
// individually. The overloads at the call site guarantee callers see
|
|
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.
|
|
510
700
|
const invalidateImpl = (key) => {
|
|
511
701
|
(0, validate_js_1.assertKey)(key);
|
|
512
702
|
const cacheKey = CACHE_NS + key;
|
|
513
703
|
if ((0, base_js_1.isSyncStore)(store)) {
|
|
514
|
-
|
|
704
|
+
if (typeof store.deleteIfExists === 'function') {
|
|
705
|
+
return store.deleteIfExists(cacheKey);
|
|
706
|
+
}
|
|
515
707
|
const existed = store.has(cacheKey);
|
|
516
708
|
store.delete(cacheKey);
|
|
517
709
|
return existed;
|
|
518
710
|
}
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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.
|
|
711
|
+
if (typeof store.deleteIfExists === 'function') {
|
|
712
|
+
return store.deleteIfExists(cacheKey);
|
|
713
|
+
}
|
|
525
714
|
return (async () => {
|
|
526
715
|
const existed = await store.has(cacheKey);
|
|
527
716
|
await store.delete(cacheKey);
|
|
528
717
|
return existed;
|
|
529
718
|
})();
|
|
530
719
|
};
|
|
531
|
-
// Attach `invalidate` and `store` to the function object. We use
|
|
532
|
-
// `Object.assign` rather than mutation so the types narrow cleanly at
|
|
533
|
-
// the call site. The cast through `unknown` is necessary because the
|
|
534
|
-
// implementation signature is wider than either overload.
|
|
535
720
|
return Object.assign(scopedAct, {
|
|
536
721
|
invalidate: invalidateImpl,
|
|
537
722
|
store,
|
|
723
|
+
scope,
|
|
538
724
|
});
|
|
539
725
|
}
|