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