actly 1.1.5 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +234 -0
- package/MIGRATION.md +141 -0
- package/README.md +334 -428
- package/SECURITY.md +56 -0
- package/dist/core/act.cjs +630 -126
- package/dist/core/act.d.ts +6 -84
- package/dist/core/act.d.ts.map +1 -1
- package/dist/core/act.js +630 -129
- package/dist/core/executor.cjs +1 -26
- package/dist/core/executor.d.ts +2 -38
- package/dist/core/executor.d.ts.map +1 -1
- package/dist/core/executor.js +1 -27
- package/dist/core/health.cjs +163 -0
- package/dist/core/health.d.ts +33 -0
- package/dist/core/health.d.ts.map +1 -0
- package/dist/core/health.js +150 -0
- package/dist/core/shutdown.cjs +71 -0
- package/dist/core/shutdown.d.ts +5 -0
- package/dist/core/shutdown.d.ts.map +1 -0
- package/dist/core/shutdown.js +65 -0
- package/dist/core/tenant.cjs +113 -0
- package/dist/core/tenant.d.ts +24 -0
- package/dist/core/tenant.d.ts.map +1 -0
- package/dist/core/tenant.js +109 -0
- package/dist/errors.cjs +149 -0
- package/dist/errors.d.ts +94 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +134 -0
- package/dist/index.cjs +51 -8
- package/dist/index.d.ts +22 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -6
- package/dist/observability.cjs +6 -0
- package/dist/observability.d.ts +86 -0
- package/dist/observability.d.ts.map +1 -0
- package/dist/observability.js +3 -0
- package/dist/policies/bulkhead.cjs +137 -0
- package/dist/policies/bulkhead.d.ts +3 -0
- package/dist/policies/bulkhead.d.ts.map +1 -0
- package/dist/policies/bulkhead.js +134 -0
- package/dist/policies/cache.cjs +84 -67
- package/dist/policies/cache.d.ts +0 -33
- package/dist/policies/cache.d.ts.map +1 -1
- package/dist/policies/cache.js +84 -68
- package/dist/policies/circuitBreaker.cjs +179 -0
- package/dist/policies/circuitBreaker.d.ts +3 -0
- package/dist/policies/circuitBreaker.d.ts.map +1 -0
- package/dist/policies/circuitBreaker.js +176 -0
- package/dist/policies/dedupe.cjs +46 -64
- package/dist/policies/dedupe.d.ts +0 -41
- package/dist/policies/dedupe.d.ts.map +1 -1
- package/dist/policies/dedupe.js +46 -65
- package/dist/policies/noop.cjs +6 -0
- package/dist/policies/noop.d.ts +3 -0
- package/dist/policies/noop.d.ts.map +1 -0
- package/dist/policies/noop.js +3 -0
- package/dist/policies/rateLimit.cjs +45 -0
- package/dist/policies/rateLimit.d.ts +3 -0
- package/dist/policies/rateLimit.d.ts.map +1 -0
- package/dist/policies/rateLimit.js +42 -0
- package/dist/policies/retry.cjs +129 -57
- package/dist/policies/retry.d.ts +0 -27
- package/dist/policies/retry.d.ts.map +1 -1
- package/dist/policies/retry.js +129 -58
- package/dist/policies/timeout.cjs +47 -103
- package/dist/policies/timeout.d.ts +2 -46
- package/dist/policies/timeout.d.ts.map +1 -1
- package/dist/policies/timeout.js +46 -102
- package/dist/stores/base.cjs +0 -10
- package/dist/stores/base.d.ts +4 -67
- package/dist/stores/base.d.ts.map +1 -1
- package/dist/stores/base.js +0 -11
- package/dist/stores/memory.cjs +147 -87
- package/dist/stores/memory.d.ts +11 -59
- package/dist/stores/memory.d.ts.map +1 -1
- package/dist/stores/memory.js +146 -88
- package/dist/testing/index.cjs +56 -0
- package/dist/testing/index.d.ts +6 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +52 -0
- package/dist/types/index.cjs +0 -1
- package/dist/types/index.d.ts +62 -179
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +0 -2
- package/dist/utils/abort.cjs +46 -73
- package/dist/utils/abort.d.ts +4 -51
- package/dist/utils/abort.d.ts.map +1 -1
- package/dist/utils/abort.js +46 -74
- package/dist/utils/abortPool.cjs +25 -0
- package/dist/utils/abortPool.d.ts +4 -0
- package/dist/utils/abortPool.d.ts.map +1 -0
- package/dist/utils/abortPool.js +20 -0
- package/dist/utils/backoff.cjs +8 -15
- package/dist/utils/backoff.d.ts +0 -11
- package/dist/utils/backoff.d.ts.map +1 -1
- package/dist/utils/backoff.js +8 -16
- package/dist/utils/decorator.cjs +44 -0
- package/dist/utils/decorator.d.ts +3 -0
- package/dist/utils/decorator.d.ts.map +1 -0
- package/dist/utils/decorator.js +41 -0
- package/dist/utils/key.cjs +45 -0
- package/dist/utils/key.d.ts +2 -0
- package/dist/utils/key.d.ts.map +1 -0
- package/dist/utils/key.js +42 -0
- package/dist/utils/limits.cjs +22 -0
- package/dist/utils/limits.d.ts +21 -0
- package/dist/utils/limits.d.ts.map +1 -0
- package/dist/utils/limits.js +19 -0
- package/dist/utils/safeCall.cjs +28 -0
- package/dist/utils/safeCall.d.ts +2 -0
- package/dist/utils/safeCall.d.ts.map +1 -0
- package/dist/utils/safeCall.js +25 -0
- package/dist/utils/sanitize.cjs +50 -0
- package/dist/utils/sanitize.d.ts +3 -0
- package/dist/utils/sanitize.d.ts.map +1 -0
- package/dist/utils/sanitize.js +46 -0
- package/dist/utils/validate.cjs +163 -26
- package/dist/utils/validate.d.ts +6 -8
- package/dist/utils/validate.d.ts.map +1 -1
- package/dist/utils/validate.js +158 -27
- package/package.json +42 -7
- package/dist/core/act.js.map +0 -1
- package/dist/core/executor.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/policies/cache.js.map +0 -1
- package/dist/policies/dedupe.js.map +0 -1
- package/dist/policies/retry.js.map +0 -1
- package/dist/policies/timeout.js.map +0 -1
- package/dist/state/store.cjs +0 -14
- package/dist/state/store.d.ts +0 -11
- package/dist/state/store.d.ts.map +0 -1
- package/dist/state/store.js +0 -11
- package/dist/state/store.js.map +0 -1
- package/dist/stores/base.js.map +0 -1
- package/dist/stores/memory.js.map +0 -1
- package/dist/types/index.js.map +0 -1
- package/dist/utils/abort.js.map +0 -1
- package/dist/utils/backoff.js.map +0 -1
- package/dist/utils/validate.js.map +0 -1
package/dist/core/act.js
CHANGED
|
@@ -3,215 +3,716 @@ import { retryPolicy } from '../policies/retry.js';
|
|
|
3
3
|
import { timeoutPolicy, totalTimeoutPolicy } from '../policies/timeout.js';
|
|
4
4
|
import { dedupePolicy } from '../policies/dedupe.js';
|
|
5
5
|
import { cachePolicy } from '../policies/cache.js';
|
|
6
|
-
import {
|
|
6
|
+
import { circuitBreakerPolicy } from '../policies/circuitBreaker.js';
|
|
7
|
+
import { bulkheadPolicy } from '../policies/bulkhead.js';
|
|
8
|
+
import { rateLimitPolicy } from '../policies/rateLimit.js';
|
|
9
|
+
import { createDefaultStore } from '../stores/memory.js';
|
|
7
10
|
import { isSyncStore } from '../stores/base.js';
|
|
8
|
-
import { linkSignal } from '../utils/abort.js';
|
|
11
|
+
import { linkSignal, raceAbort } from '../utils/abort.js';
|
|
12
|
+
import { sanitizeError, sanitizeErrorMessage } from '../utils/sanitize.js';
|
|
13
|
+
import { safeCall } from '../utils/safeCall.js';
|
|
14
|
+
import { registerInflight, unregisterInflight, recordError, recordSuccess, registerStoreScope } from './health.js';
|
|
15
|
+
import { registerDrainable, unregisterDrainable } from './shutdown.js';
|
|
9
16
|
import { assertKey, assertOptions, } from '../utils/validate.js';
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
// chain may include dedupePolicy, which mandates synchronous store access.
|
|
13
|
-
// For SSR isolation or per-test control, use `withStore()` or call
|
|
14
|
-
// `execute()` directly with an explicit store.
|
|
15
|
-
const defaultStore = new InMemoryStore();
|
|
16
|
-
// Namespace prefixes used by policies. Kept here (not in policy files) so
|
|
17
|
-
// `invalidate()` can resolve cache keys without importing policy internals.
|
|
17
|
+
import { HedgeTimeoutError } from '../errors.js';
|
|
18
|
+
const defaultStore = createDefaultStore();
|
|
18
19
|
const CACHE_NS = 'cache:';
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
function monotonicNow() {
|
|
21
|
+
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
|
|
22
|
+
return performance.now();
|
|
23
|
+
}
|
|
24
|
+
return Date.now();
|
|
25
|
+
}
|
|
26
|
+
function generateTraceId() {
|
|
27
|
+
const crypto = globalThis.crypto;
|
|
28
|
+
if (crypto && typeof crypto.randomUUID === 'function') {
|
|
29
|
+
return crypto.randomUUID();
|
|
30
|
+
}
|
|
31
|
+
return `actly-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
32
|
+
}
|
|
33
|
+
function buildObservability(hooks, traceId) {
|
|
34
|
+
if (!hooks)
|
|
35
|
+
return undefined;
|
|
36
|
+
const hasAnyHook = !!hooks.onAttempt ||
|
|
37
|
+
!!hooks.onRetry ||
|
|
38
|
+
!!hooks.onCacheHit ||
|
|
39
|
+
!!hooks.onCacheMiss ||
|
|
40
|
+
!!hooks.onDedupeJoin ||
|
|
41
|
+
!!hooks.onTimeout ||
|
|
42
|
+
!!hooks.onFinalSuccess ||
|
|
43
|
+
!!hooks.onFinalFailure ||
|
|
44
|
+
!!hooks.onBackpressure ||
|
|
45
|
+
!!hooks.onWatchdog;
|
|
46
|
+
if (!hasAnyHook)
|
|
47
|
+
return undefined;
|
|
48
|
+
return {
|
|
49
|
+
traceId: traceId ?? generateTraceId(),
|
|
50
|
+
hooks,
|
|
51
|
+
joinerCounter: 0,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function classifyFailure(error) {
|
|
55
|
+
if (error == null || typeof error !== 'object')
|
|
56
|
+
return 'fn-error';
|
|
57
|
+
const code = error.code;
|
|
58
|
+
if (code === 'ACTLY_ABORT')
|
|
59
|
+
return 'abort';
|
|
60
|
+
if (code === 'ACTLY_TIMEOUT')
|
|
61
|
+
return 'timeout';
|
|
62
|
+
if (code === 'ACTLY_TOTAL_TIMEOUT')
|
|
63
|
+
return 'total-timeout';
|
|
64
|
+
if (code === 'ACTLY_RETRY_EXHAUSTED')
|
|
65
|
+
return 'retry-exhausted';
|
|
66
|
+
if (code === 'ACTLY_VALIDATION')
|
|
67
|
+
return 'validation';
|
|
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';
|
|
78
|
+
if (error instanceof Error) {
|
|
79
|
+
const name = error.name;
|
|
80
|
+
if (name === 'AbortError')
|
|
81
|
+
return 'abort';
|
|
82
|
+
}
|
|
83
|
+
return 'fn-error';
|
|
84
|
+
}
|
|
23
85
|
function normalizeDedupe(opt) {
|
|
24
86
|
if (opt === true)
|
|
25
87
|
return { enabled: true };
|
|
26
88
|
if (opt && typeof opt === 'object' && opt.enabled) {
|
|
27
|
-
|
|
28
|
-
enabled: true,
|
|
29
|
-
|
|
30
|
-
};
|
|
89
|
+
if (opt.inflightTtl !== undefined && opt.inflightTtl !== 0 && !Number.isNaN(opt.inflightTtl)) {
|
|
90
|
+
return { enabled: true, inflightTtl: opt.inflightTtl };
|
|
91
|
+
}
|
|
92
|
+
return { enabled: true };
|
|
31
93
|
}
|
|
32
94
|
return undefined;
|
|
33
95
|
}
|
|
34
|
-
/**
|
|
35
|
-
* Build the policy chain from `ActOptions`. The order is fixed and
|
|
36
|
-
* documented in `executor.ts`. Policies with no effect (e.g. `retry.attempts: 1`)
|
|
37
|
-
* are skipped — they would be pure overhead.
|
|
38
|
-
*/
|
|
39
96
|
function buildPolicies(options) {
|
|
40
97
|
const dedupe = normalizeDedupe(options.dedupe);
|
|
41
98
|
const policies = [];
|
|
42
|
-
|
|
43
|
-
|
|
99
|
+
if (options.rateLimit) {
|
|
100
|
+
policies.push(rateLimitPolicy(options.rateLimit));
|
|
101
|
+
}
|
|
102
|
+
if (options.circuitBreaker) {
|
|
103
|
+
policies.push(circuitBreakerPolicy(options.circuitBreaker));
|
|
104
|
+
}
|
|
44
105
|
if (options.totalTimeout && options.totalTimeout.ms > 0) {
|
|
45
106
|
policies.push(totalTimeoutPolicy(options.totalTimeout));
|
|
46
107
|
}
|
|
47
|
-
// 1. Cache: a hit short-circuits everything below it.
|
|
48
108
|
if (options.cache && options.cache.ttl > 0) {
|
|
49
109
|
policies.push(cachePolicy(options.cache));
|
|
50
110
|
}
|
|
51
|
-
|
|
111
|
+
if (options.bulkhead) {
|
|
112
|
+
policies.push(bulkheadPolicy(options.bulkhead));
|
|
113
|
+
}
|
|
52
114
|
if (dedupe) {
|
|
53
115
|
policies.push(dedupePolicy(dedupe));
|
|
54
116
|
}
|
|
55
|
-
// 3. Retry: owns the attempt loop.
|
|
56
|
-
// `attempts: 1` is a no-op — skip to avoid overhead.
|
|
57
117
|
if (options.retry && options.retry.attempts > 1) {
|
|
58
118
|
policies.push(retryPolicy(options.retry));
|
|
59
119
|
}
|
|
60
|
-
// 4. Innermost: per-attempt clock. Resets on every retry.
|
|
61
120
|
if (options.timeout && options.timeout.ms > 0) {
|
|
62
121
|
policies.push(timeoutPolicy(options.timeout));
|
|
63
122
|
}
|
|
64
123
|
return policies;
|
|
65
124
|
}
|
|
66
|
-
/**
|
|
67
|
-
* Build a root AbortController from `options.signal`.
|
|
68
|
-
*
|
|
69
|
-
* - If no user signal: returns a fresh controller that never aborts unless
|
|
70
|
-
* an outer timeout policy aborts it.
|
|
71
|
-
* - If user signal is already aborted: returns a controller that is already
|
|
72
|
-
* aborted with the user's reason (so the operation rejects immediately).
|
|
73
|
-
* - Otherwise: links the user signal to the controller.
|
|
74
|
-
*/
|
|
75
125
|
function buildRootSignal(userSignal) {
|
|
76
126
|
const controller = new AbortController();
|
|
77
|
-
if (userSignal)
|
|
78
|
-
linkSignal(userSignal, controller);
|
|
79
|
-
|
|
127
|
+
if (userSignal) {
|
|
128
|
+
const unlink = linkSignal(userSignal, controller);
|
|
129
|
+
return { controller, cleanup: unlink };
|
|
130
|
+
}
|
|
131
|
+
return { controller, cleanup: () => { } };
|
|
80
132
|
}
|
|
81
|
-
/**
|
|
82
|
-
* Execute `fn` with the given reliability policies.
|
|
83
|
-
*
|
|
84
|
-
* @param key Stable identifier for this action. Scopes dedupe + cache.
|
|
85
|
-
* @param fn The async work to run. Receives an `AbortSignal` for
|
|
86
|
-
* cooperative cancellation (legacy `() => Promise<T>` is
|
|
87
|
-
* still accepted — the signal is simply ignored).
|
|
88
|
-
* @param options Which policies to apply and how. All fields are optional.
|
|
89
|
-
*
|
|
90
|
-
* @returns `ActResult<T>` — always resolves, never throws.
|
|
91
|
-
* Check `result.ok` before reading `result.value`.
|
|
92
|
-
*
|
|
93
|
-
* @example
|
|
94
|
-
* // With cooperative cancellation
|
|
95
|
-
* const result = await act('user:42', async (signal) => {
|
|
96
|
-
* return fetch(`/api/users/42`, { signal })
|
|
97
|
-
* }, {
|
|
98
|
-
* retry: { attempts: 3, delayMs: 200, backoff: 'exponential' },
|
|
99
|
-
* timeout: { ms: 5_000 },
|
|
100
|
-
* totalTimeout: { ms: 12_000 },
|
|
101
|
-
* dedupe: true,
|
|
102
|
-
* cache: { ttl: 60_000 },
|
|
103
|
-
* })
|
|
104
|
-
*
|
|
105
|
-
* if (result.ok) {
|
|
106
|
-
* console.log(result.value, result.source, result.attempts)
|
|
107
|
-
* } else {
|
|
108
|
-
* console.error(result.error)
|
|
109
|
-
* }
|
|
110
|
-
*/
|
|
111
133
|
export async function act(key, fn, options = {}) {
|
|
112
|
-
// Validate input upfront. Programmer errors throw — they should not be
|
|
113
|
-
// swallowed into an ActFailure because the caller's code is broken.
|
|
114
134
|
assertKey(key);
|
|
115
135
|
assertOptions(options);
|
|
136
|
+
const hasAnyOption = options.retry !== undefined ||
|
|
137
|
+
options.timeout !== undefined ||
|
|
138
|
+
options.totalTimeout !== undefined ||
|
|
139
|
+
options.dedupe !== undefined ||
|
|
140
|
+
options.cache !== undefined ||
|
|
141
|
+
options.signal !== undefined ||
|
|
142
|
+
options.observability !== undefined ||
|
|
143
|
+
options.traceId !== undefined ||
|
|
144
|
+
options.circuitBreaker !== undefined ||
|
|
145
|
+
options.bulkhead !== undefined ||
|
|
146
|
+
options.rateLimit !== undefined ||
|
|
147
|
+
options.hedge !== undefined ||
|
|
148
|
+
options.fallback !== undefined ||
|
|
149
|
+
options.audit !== undefined;
|
|
150
|
+
if (!hasAnyOption) {
|
|
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
|
+
}
|
|
161
|
+
registerDrainable('default');
|
|
162
|
+
try {
|
|
163
|
+
const value = await fn(new AbortController().signal);
|
|
164
|
+
recordSuccess('default');
|
|
165
|
+
return { ok: true, value, source: 'fresh', attempts: 1, durationMs: monotonicNow() - startedAt };
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
recordError('default', 'fn-error', sanitizeErrorMessage(error));
|
|
169
|
+
return { ok: false, error, attempts: 1, durationMs: monotonicNow() - startedAt };
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
unregisterInflight('default');
|
|
173
|
+
unregisterDrainable('default');
|
|
174
|
+
}
|
|
175
|
+
}
|
|
116
176
|
const meta = { attempts: 1, source: 'fresh' };
|
|
117
|
-
const rootController = buildRootSignal(options.signal);
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
177
|
+
const { controller: rootController, cleanup } = buildRootSignal(options.signal);
|
|
178
|
+
const observability = buildObservability(options.observability, options.traceId);
|
|
179
|
+
const effectiveTraceId = observability?.traceId ?? options.traceId;
|
|
180
|
+
const startedAt = monotonicNow();
|
|
181
|
+
const scope = 'default';
|
|
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
|
+
}
|
|
207
|
+
registerDrainable(scope);
|
|
121
208
|
if (rootController.signal.aborted) {
|
|
122
|
-
|
|
209
|
+
cleanup();
|
|
210
|
+
unregisterInflight(scope);
|
|
211
|
+
unregisterDrainable(scope);
|
|
212
|
+
const error = rootController.signal.reason;
|
|
213
|
+
const now = Date.now();
|
|
214
|
+
const durationMs = monotonicNow() - startedAt;
|
|
215
|
+
if (observability) {
|
|
216
|
+
safeCall(observability.hooks.onFinalFailure, {
|
|
217
|
+
type: 'final-failure',
|
|
218
|
+
key, traceId: observability.traceId, timestamp: now,
|
|
219
|
+
attempts: 0, durationMs,
|
|
220
|
+
failedBy: 'abort', error,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
if (options.audit) {
|
|
224
|
+
const sanitizedError = sanitizeError(error);
|
|
225
|
+
safeCall(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: now, durationMs, ok: false, attempts: 0, failedBy: 'abort', error: sanitizedError });
|
|
226
|
+
}
|
|
227
|
+
recordError(scope, 'ACTLY_ABORT', sanitizeErrorMessage(error));
|
|
228
|
+
return { ok: false, error, attempts: 0, traceId: effectiveTraceId, durationMs };
|
|
123
229
|
}
|
|
124
|
-
|
|
230
|
+
let policies;
|
|
125
231
|
try {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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 };
|
|
256
|
+
}
|
|
257
|
+
const hasRetryPolicy = !!(options.retry && options.retry.attempts > 1);
|
|
258
|
+
if (observability && !hasRetryPolicy) {
|
|
259
|
+
safeCall(observability.hooks.onAttempt, {
|
|
260
|
+
type: 'attempt', key, traceId: observability.traceId,
|
|
261
|
+
timestamp: Date.now(), attempt: 1,
|
|
133
262
|
});
|
|
134
|
-
|
|
263
|
+
}
|
|
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;
|
|
270
|
+
try {
|
|
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;
|
|
306
|
+
recordSuccess(scope);
|
|
307
|
+
if (observability) {
|
|
308
|
+
safeCall(observability.hooks.onFinalSuccess, {
|
|
309
|
+
type: 'final-success',
|
|
310
|
+
key, traceId: observability.traceId, timestamp: now,
|
|
311
|
+
source: meta.source, attempts: meta.attempts, durationMs,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
if (options.audit) {
|
|
315
|
+
safeCall(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: now, durationMs, ok: true, attempts: meta.attempts });
|
|
316
|
+
}
|
|
317
|
+
return { ok: true, value, source: meta.source, attempts: meta.attempts, traceId: effectiveTraceId, durationMs };
|
|
135
318
|
}
|
|
136
319
|
catch (error) {
|
|
137
|
-
|
|
320
|
+
const durationMs = monotonicNow() - startedAt;
|
|
321
|
+
const failedBy = rootController.signal.aborted ? 'abort' : classifyFailure(error);
|
|
322
|
+
let errorRecorded = false;
|
|
323
|
+
if (options.fallback) {
|
|
324
|
+
recordError(scope, failedBy, sanitizeErrorMessage(error));
|
|
325
|
+
errorRecorded = true;
|
|
326
|
+
try {
|
|
327
|
+
const fallbackValue = typeof options.fallback.value === 'function'
|
|
328
|
+
? await options.fallback.value()
|
|
329
|
+
: options.fallback.value;
|
|
330
|
+
if (observability) {
|
|
331
|
+
safeCall(observability.hooks.onFinalSuccess, {
|
|
332
|
+
type: 'final-success',
|
|
333
|
+
key, traceId: observability.traceId, timestamp: Date.now(),
|
|
334
|
+
source: meta.source, attempts: meta.attempts, durationMs,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
if (options.audit) {
|
|
338
|
+
safeCall(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: Date.now(), durationMs, ok: true, attempts: meta.attempts });
|
|
339
|
+
}
|
|
340
|
+
return { ok: true, value: fallbackValue, source: 'fresh', attempts: meta.attempts, traceId: effectiveTraceId, durationMs };
|
|
341
|
+
}
|
|
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
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const sanitizedError = options.audit ? sanitizeError(error) : error;
|
|
357
|
+
if (!errorRecorded) {
|
|
358
|
+
recordError(scope, failedBy, sanitizeErrorMessage(error));
|
|
359
|
+
}
|
|
360
|
+
if (observability) {
|
|
361
|
+
safeCall(observability.hooks.onFinalFailure, {
|
|
362
|
+
type: 'final-failure',
|
|
363
|
+
key, traceId: observability.traceId, timestamp: Date.now(),
|
|
364
|
+
attempts: meta.attempts, durationMs,
|
|
365
|
+
failedBy, error,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
if (options.audit) {
|
|
369
|
+
safeCall(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: Date.now(), durationMs, ok: false, attempts: meta.attempts, failedBy, error: sanitizedError });
|
|
370
|
+
}
|
|
371
|
+
return { ok: false, error, attempts: meta.attempts, traceId: effectiveTraceId, durationMs };
|
|
372
|
+
}
|
|
373
|
+
finally {
|
|
374
|
+
cleanup();
|
|
375
|
+
unregisterInflight(scope);
|
|
376
|
+
unregisterDrainable(scope);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
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);
|
|
386
|
+
let timer;
|
|
387
|
+
let primary;
|
|
388
|
+
let hedgePromise;
|
|
389
|
+
try {
|
|
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
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
finally {
|
|
430
|
+
if (timer)
|
|
431
|
+
clearTimeout(timer);
|
|
432
|
+
unlinkPrimary();
|
|
433
|
+
unlinkHedge();
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
}
|
|
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();
|
|
138
485
|
}
|
|
139
486
|
}
|
|
140
|
-
/**
|
|
141
|
-
* Invalidate the cached value for `key` on the default module-level store.
|
|
142
|
-
*
|
|
143
|
-
* Only clears the cache slot — does not affect in-flight dedupe entries
|
|
144
|
-
* (those will settle on their own). Returns `true` if a cache entry was
|
|
145
|
-
* removed, `false` otherwise.
|
|
146
|
-
*
|
|
147
|
-
* Useful when you know the underlying data has changed and you want the
|
|
148
|
-
* next `act()` call to re-run `fn` instead of serving stale cache:
|
|
149
|
-
*
|
|
150
|
-
* ```ts
|
|
151
|
-
* await act('user:42', () => fetchUser(42), { cache: { ttl: 60_000 } })
|
|
152
|
-
* // ... user updates their profile ...
|
|
153
|
-
* invalidate('user:42') // next call will re-fetch
|
|
154
|
-
* ```
|
|
155
|
-
*/
|
|
156
487
|
export function invalidate(key) {
|
|
488
|
+
assertKey(key);
|
|
157
489
|
const cacheKey = CACHE_NS + key;
|
|
158
490
|
const existed = defaultStore.has(cacheKey);
|
|
159
491
|
defaultStore.delete(cacheKey);
|
|
160
492
|
return existed;
|
|
161
493
|
}
|
|
162
494
|
export function withStore(store) {
|
|
495
|
+
const crypto = globalThis.crypto;
|
|
496
|
+
const scope = 'scoped:' + (crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2, 14));
|
|
497
|
+
registerStoreScope(store, scope);
|
|
163
498
|
const scopedAct = async (key, fn, options = {}) => {
|
|
164
499
|
assertKey(key);
|
|
165
500
|
assertOptions(options);
|
|
166
501
|
const meta = { attempts: 1, source: 'fresh' };
|
|
167
|
-
const rootController = buildRootSignal(options.signal);
|
|
502
|
+
const { controller: rootController, cleanup } = buildRootSignal(options.signal);
|
|
503
|
+
const observability = buildObservability(options.observability, options.traceId);
|
|
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
|
+
}
|
|
529
|
+
registerDrainable(scope);
|
|
168
530
|
if (rootController.signal.aborted) {
|
|
169
|
-
|
|
531
|
+
cleanup();
|
|
532
|
+
unregisterInflight(scope);
|
|
533
|
+
unregisterDrainable(scope);
|
|
534
|
+
const error = rootController.signal.reason;
|
|
535
|
+
const now = Date.now();
|
|
536
|
+
const durationMs = now - startedAt;
|
|
537
|
+
recordError(scope, 'ACTLY_ABORT', sanitizeErrorMessage(error));
|
|
538
|
+
if (observability) {
|
|
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 };
|
|
170
549
|
}
|
|
171
|
-
|
|
550
|
+
let policies;
|
|
172
551
|
try {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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),
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
return { ok: false, error: e, attempts: 0, traceId: effectiveTraceId, durationMs };
|
|
575
|
+
}
|
|
576
|
+
const hasRetryPolicy = !!(options.retry && options.retry.attempts > 1);
|
|
577
|
+
if (observability && !hasRetryPolicy) {
|
|
578
|
+
safeCall(observability.hooks.onAttempt, {
|
|
579
|
+
type: 'attempt', key, traceId: observability.traceId,
|
|
580
|
+
timestamp: Date.now(), attempt: 1,
|
|
180
581
|
});
|
|
181
|
-
|
|
582
|
+
}
|
|
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;
|
|
589
|
+
try {
|
|
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;
|
|
625
|
+
recordSuccess(scope);
|
|
626
|
+
if (observability) {
|
|
627
|
+
safeCall(observability.hooks.onFinalSuccess, {
|
|
628
|
+
type: 'final-success', key, traceId: observability.traceId, timestamp: now,
|
|
629
|
+
source: meta.source, attempts: meta.attempts, durationMs,
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
if (options.audit) {
|
|
633
|
+
safeCall(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: now, durationMs, ok: true, attempts: meta.attempts });
|
|
634
|
+
}
|
|
635
|
+
return { ok: true, value, source: meta.source, attempts: meta.attempts, traceId: effectiveTraceId, durationMs };
|
|
182
636
|
}
|
|
183
637
|
catch (error) {
|
|
184
|
-
|
|
638
|
+
const now = Date.now();
|
|
639
|
+
const durationMs = monotonicNow() - startedAt;
|
|
640
|
+
const failedBy = rootController.signal.aborted ? 'abort' : classifyFailure(error);
|
|
641
|
+
let errorRecorded = false;
|
|
642
|
+
if (options.fallback) {
|
|
643
|
+
recordError(scope, failedBy, sanitizeErrorMessage(error));
|
|
644
|
+
errorRecorded = true;
|
|
645
|
+
try {
|
|
646
|
+
const fallbackValue = typeof options.fallback.value === 'function'
|
|
647
|
+
? await options.fallback.value()
|
|
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
|
+
}
|
|
655
|
+
if (options.audit) {
|
|
656
|
+
safeCall(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: Date.now(), durationMs, ok: true, attempts: meta.attempts });
|
|
657
|
+
}
|
|
658
|
+
return { ok: true, value: fallbackValue, source: 'fresh', attempts: meta.attempts, traceId: effectiveTraceId, durationMs };
|
|
659
|
+
}
|
|
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
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
if (!errorRecorded) {
|
|
673
|
+
recordError(scope, failedBy, sanitizeErrorMessage(error));
|
|
674
|
+
}
|
|
675
|
+
if (observability) {
|
|
676
|
+
safeCall(observability.hooks.onFinalFailure, {
|
|
677
|
+
type: 'final-failure', key, traceId: observability.traceId, timestamp: now,
|
|
678
|
+
attempts: meta.attempts, durationMs, failedBy, error,
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
if (options.audit) {
|
|
682
|
+
const sanitizedError = sanitizeError(error);
|
|
683
|
+
safeCall(options.audit.log, { key, traceId: effectiveTraceId ?? '', timestamp: Date.now(), durationMs, ok: false, attempts: meta.attempts, failedBy, error: sanitizedError });
|
|
684
|
+
}
|
|
685
|
+
return { ok: false, error, attempts: meta.attempts, traceId: effectiveTraceId, durationMs };
|
|
686
|
+
}
|
|
687
|
+
finally {
|
|
688
|
+
cleanup();
|
|
689
|
+
unregisterInflight(scope);
|
|
690
|
+
unregisterDrainable(scope);
|
|
185
691
|
}
|
|
186
692
|
};
|
|
187
|
-
// Build the `invalidate` implementation. The runtime branch on
|
|
188
|
-
// `isSyncStore` selects the correct path; the cast through `unknown`
|
|
189
|
-
// is required because TypeScript cannot narrow the union return type
|
|
190
|
-
// (`boolean | Promise<boolean>`) to match either overload signature
|
|
191
|
-
// individually. The overloads at the call site guarantee callers see
|
|
192
|
-
// the correct type.
|
|
193
693
|
const invalidateImpl = (key) => {
|
|
194
694
|
assertKey(key);
|
|
195
695
|
const cacheKey = CACHE_NS + key;
|
|
196
696
|
if (isSyncStore(store)) {
|
|
697
|
+
if (typeof store.deleteIfExists === 'function') {
|
|
698
|
+
return store.deleteIfExists(cacheKey);
|
|
699
|
+
}
|
|
197
700
|
const existed = store.has(cacheKey);
|
|
198
701
|
store.delete(cacheKey);
|
|
199
702
|
return existed;
|
|
200
703
|
}
|
|
201
|
-
|
|
704
|
+
if (typeof store.deleteIfExists === 'function') {
|
|
705
|
+
return store.deleteIfExists(cacheKey);
|
|
706
|
+
}
|
|
202
707
|
return (async () => {
|
|
203
708
|
const existed = await store.has(cacheKey);
|
|
204
709
|
await store.delete(cacheKey);
|
|
205
710
|
return existed;
|
|
206
711
|
})();
|
|
207
712
|
};
|
|
208
|
-
// Attach `invalidate` and `store` to the function object. We use
|
|
209
|
-
// `Object.assign` rather than mutation so the types narrow cleanly at
|
|
210
|
-
// the call site. The cast through `unknown` is necessary because the
|
|
211
|
-
// implementation signature is wider than either overload.
|
|
212
713
|
return Object.assign(scopedAct, {
|
|
213
714
|
invalidate: invalidateImpl,
|
|
214
715
|
store,
|
|
716
|
+
scope,
|
|
215
717
|
});
|
|
216
718
|
}
|
|
217
|
-
//# sourceMappingURL=act.js.map
|