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/policies/cache.cjs
CHANGED
|
@@ -2,117 +2,53 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.cachePolicy = cachePolicy;
|
|
4
4
|
const base_js_1 = require("../stores/base.js");
|
|
5
|
+
const safeCall_js_1 = require("../utils/safeCall.js");
|
|
6
|
+
const abort_js_1 = require("../utils/abort.js");
|
|
7
|
+
const limits_js_1 = require("../utils/limits.js");
|
|
5
8
|
const NS = 'cache:';
|
|
6
|
-
const INFLIGHT_NS = '
|
|
9
|
+
const INFLIGHT_NS = 'inflight:cache:';
|
|
7
10
|
let generationCounter = 0;
|
|
8
11
|
function nextGeneration() {
|
|
9
12
|
generationCounter = (generationCounter + 1) % Number.MAX_SAFE_INTEGER;
|
|
10
13
|
return generationCounter;
|
|
11
14
|
}
|
|
12
|
-
/**
|
|
13
|
-
* Short-circuit the entire downstream chain on a cache hit.
|
|
14
|
-
* On a miss, run `fn` and store the result with TTL.
|
|
15
|
-
*
|
|
16
|
-
* # Properties
|
|
17
|
-
*
|
|
18
|
-
* - **Single-flight originator isolation**: the stored in-flight promise is
|
|
19
|
-
* the RAW `fn(signal)` — NOT raceAbort-wrapped. An originator's signal
|
|
20
|
-
* abort no longer causes all cache-miss joiners to reject. Each caller
|
|
21
|
-
* races against their OWN signal only.
|
|
22
|
-
* - **Generation-safe inflight cleanup**: stale originators don't delete
|
|
23
|
-
* newer entries (same pattern as dedupePolicy).
|
|
24
|
-
* - **Signal-aware async store path**: between `await store.get()` and
|
|
25
|
-
* `await store.set()`, we re-check `signal.aborted`. The previous
|
|
26
|
-
* version would happily return a cached value even after the caller
|
|
27
|
-
* aborted mid-Redis-latency.
|
|
28
|
-
* - **Cache hit honours abort**: if `signal.aborted` is true at policy
|
|
29
|
-
* entry, we reject immediately — consistent with `act()`'s contract.
|
|
30
|
-
*
|
|
31
|
-
* # Single-flight (cache stampede prevention)
|
|
32
|
-
*
|
|
33
|
-
* On a sync store, the policy also stores the in-flight Promise under a
|
|
34
|
-
* separate `__inflight:cache:<key>` slot. Concurrent callers that miss the
|
|
35
|
-
* cache but find an in-flight Promise join it instead of launching duplicate
|
|
36
|
-
* work. This is the same mechanism `dedupePolicy` uses, applied internally
|
|
37
|
-
* to cache misses so users don't need to combine `cache` + `dedupe` to
|
|
38
|
-
* avoid stampedes.
|
|
39
|
-
*
|
|
40
|
-
* On an async store, single-flight is not possible (the same sync-store
|
|
41
|
-
* constraint as dedupe applies). Stampedes are a known limitation — document
|
|
42
|
-
* and pair with dedupe at a higher layer if you need single-flight semantics.
|
|
43
|
-
*
|
|
44
|
-
* # Fail-open writes
|
|
45
|
-
*
|
|
46
|
-
* If `store.set()` throws (e.g. Redis transient error), we swallow the error
|
|
47
|
-
* and return the value anyway. The caller gets their result; the next call
|
|
48
|
-
* will simply re-run `fn` and try to cache again. Caching is an optimisation,
|
|
49
|
-
* not a correctness requirement.
|
|
50
|
-
*
|
|
51
|
-
* # Cache hit semantics
|
|
52
|
-
*
|
|
53
|
-
* On a cache hit, `meta.source` is set to `'cache'` and `meta.attempts` is
|
|
54
|
-
* set to `0` — no work was performed.
|
|
55
|
-
*
|
|
56
|
-
* Failures are NEVER cached. Only successful values are stored.
|
|
57
|
-
*/
|
|
58
15
|
function cachePolicy(opts) {
|
|
59
16
|
return (fn, ctx) => async (signal) => {
|
|
60
|
-
// Honour abort on cache hit too. Consistent with act()'s
|
|
61
|
-
// "reject immediately if signal aborted" contract.
|
|
62
17
|
if (signal.aborted)
|
|
63
18
|
return Promise.reject(signal.reason);
|
|
64
19
|
const key = NS + ctx.key;
|
|
65
20
|
const inflightKey = INFLIGHT_NS + ctx.key;
|
|
66
|
-
// ─── Sync store: fast path with single-flight ────────────────────────
|
|
67
21
|
if ((0, base_js_1.isSyncStore)(ctx.store)) {
|
|
68
|
-
// Capture the narrowed sync store so closures (cleanup) keep the
|
|
69
|
-
// type information — TS doesn't carry `isSyncStore` narrowing
|
|
70
|
-
// into nested function bodies.
|
|
71
22
|
const store = ctx.store;
|
|
72
23
|
const obs = ctx.observability;
|
|
73
|
-
// 1. Cache hit?
|
|
74
24
|
const hit = store.get(key);
|
|
75
25
|
if (hit) {
|
|
76
26
|
ctx.meta.source = 'cache';
|
|
77
27
|
ctx.meta.attempts = 0;
|
|
78
|
-
// Emit cache-hit event if hooks are registered.
|
|
79
|
-
// Compute actual age from the entry's insertion timestamp.
|
|
80
28
|
if (obs) {
|
|
81
29
|
const ageMs = Date.now() - hit.insertedAt;
|
|
82
|
-
obs.hooks.onCacheHit
|
|
30
|
+
(0, safeCall_js_1.safeCall)(obs.hooks.onCacheHit, {
|
|
83
31
|
type: 'cache-hit', key: ctx.key, traceId: obs.traceId,
|
|
84
32
|
timestamp: Date.now(), ageMs: Math.max(0, ageMs),
|
|
85
33
|
});
|
|
86
34
|
}
|
|
87
35
|
return hit.value;
|
|
88
36
|
}
|
|
89
|
-
// Emit cache-miss event if hooks are registered.
|
|
90
37
|
if (obs) {
|
|
91
|
-
obs.hooks.onCacheMiss
|
|
38
|
+
(0, safeCall_js_1.safeCall)(obs.hooks.onCacheMiss, {
|
|
92
39
|
type: 'cache-miss', key: ctx.key, traceId: obs.traceId,
|
|
93
40
|
timestamp: Date.now(),
|
|
94
41
|
});
|
|
95
42
|
}
|
|
96
|
-
// 2. In-flight single-flight hit? Join it.
|
|
97
|
-
//
|
|
98
|
-
// The stored promise is RAW fn(signal). Joiners race it
|
|
99
|
-
// against their OWN signal only — originator abort doesn't
|
|
100
|
-
// propagate to joiners.
|
|
101
43
|
const inflight = store.get(inflightKey);
|
|
102
44
|
if (inflight) {
|
|
103
|
-
// Race the in-flight promise against OUR OWN signal. If we
|
|
104
|
-
// abort, we reject — but other joiners and the originator
|
|
105
|
-
// continue unaffected.
|
|
106
45
|
try {
|
|
107
|
-
const value = await
|
|
108
|
-
// Mirror originator's meta so joiner's ActResult reflects
|
|
109
|
-
// the real effort (attempts, source).
|
|
46
|
+
const value = await (0, abort_js_1.raceAbort)(inflight.promise, signal);
|
|
110
47
|
ctx.meta.attempts = inflight.meta.attempts;
|
|
111
48
|
ctx.meta.source = inflight.meta.source;
|
|
112
49
|
return value;
|
|
113
50
|
}
|
|
114
51
|
catch (err) {
|
|
115
|
-
// Copy meta on failure too (same as dedupe joiners)
|
|
116
52
|
if (signal.aborted) {
|
|
117
53
|
ctx.meta.attempts = 0;
|
|
118
54
|
}
|
|
@@ -123,49 +59,36 @@ function cachePolicy(opts) {
|
|
|
123
59
|
throw err;
|
|
124
60
|
}
|
|
125
61
|
}
|
|
126
|
-
// 3. Originator: launch fn, cache on success (fail-open).
|
|
127
|
-
//
|
|
128
|
-
// The stored promise is the RAW fn(signal) — NOT raceAbort-wrapped.
|
|
129
|
-
// Originator's own await is wrapped in raceWithOwnSignal so they
|
|
130
|
-
// can bail on their signal without affecting joiners.
|
|
131
62
|
const generation = nextGeneration();
|
|
132
63
|
const rawPromise = Promise.resolve(fn(signal)).then((value) => {
|
|
133
64
|
try {
|
|
134
65
|
store.set(key, { value, insertedAt: Date.now() }, opts.ttl);
|
|
135
66
|
}
|
|
136
67
|
catch {
|
|
137
|
-
// Fail-open: cache write failure should not surface to caller.
|
|
138
68
|
}
|
|
139
69
|
return value;
|
|
140
|
-
},
|
|
141
|
-
// Re-throw the error so joiners see it. Don't cache failures.
|
|
142
|
-
(err) => { throw err; });
|
|
143
|
-
// Publish the in-flight entry for single-flight. If store.set
|
|
144
|
-
// throws, single-flight is disabled for this call — concurrent
|
|
145
|
-
// callers will all run fn. Still correct, just less efficient.
|
|
70
|
+
}, (err) => { throw err; });
|
|
146
71
|
try {
|
|
147
|
-
store.set(inflightKey, { promise: rawPromise, generation, meta: ctx.meta });
|
|
72
|
+
store.set(inflightKey, { promise: rawPromise, generation, meta: ctx.meta }, limits_js_1.LIMITS.DEFAULT_INFLIGHT_TTL);
|
|
148
73
|
}
|
|
149
74
|
catch {
|
|
150
|
-
// Single-flight unavailable; proceed without publishing.
|
|
151
|
-
// Originator still gets their value (or error).
|
|
152
75
|
}
|
|
153
|
-
// Cleanup the in-flight slot on settle — generation-safe.
|
|
154
76
|
const cleanup = () => {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
77
|
+
try {
|
|
78
|
+
const current = store.get(inflightKey);
|
|
79
|
+
if (current && current.generation === generation) {
|
|
80
|
+
try {
|
|
81
|
+
store.delete(inflightKey);
|
|
82
|
+
}
|
|
83
|
+
catch { }
|
|
159
84
|
}
|
|
160
|
-
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
161
87
|
}
|
|
162
88
|
};
|
|
163
|
-
rawPromise.then(cleanup, cleanup);
|
|
164
|
-
|
|
165
|
-
return raceWithOwnSignal(rawPromise, signal);
|
|
89
|
+
rawPromise.then(cleanup, cleanup).catch(() => { });
|
|
90
|
+
return (0, abort_js_1.raceAbort)(rawPromise, signal);
|
|
166
91
|
}
|
|
167
|
-
// Async store: no single-flight (race window unavoidable).
|
|
168
|
-
// Re-check signal.aborted between awaits.
|
|
169
92
|
const obs = ctx.observability;
|
|
170
93
|
const hit = await ctx.store.get(key);
|
|
171
94
|
if (signal.aborted)
|
|
@@ -175,7 +98,7 @@ function cachePolicy(opts) {
|
|
|
175
98
|
ctx.meta.attempts = 0;
|
|
176
99
|
if (obs) {
|
|
177
100
|
const ageMs = Date.now() - hit.insertedAt;
|
|
178
|
-
obs.hooks.onCacheHit
|
|
101
|
+
(0, safeCall_js_1.safeCall)(obs.hooks.onCacheHit, {
|
|
179
102
|
type: 'cache-hit', key: ctx.key, traceId: obs.traceId,
|
|
180
103
|
timestamp: Date.now(), ageMs: Math.max(0, ageMs),
|
|
181
104
|
});
|
|
@@ -183,7 +106,7 @@ function cachePolicy(opts) {
|
|
|
183
106
|
return hit.value;
|
|
184
107
|
}
|
|
185
108
|
if (obs) {
|
|
186
|
-
obs.hooks.onCacheMiss
|
|
109
|
+
(0, safeCall_js_1.safeCall)(obs.hooks.onCacheMiss, {
|
|
187
110
|
type: 'cache-miss', key: ctx.key, traceId: obs.traceId,
|
|
188
111
|
timestamp: Date.now(),
|
|
189
112
|
});
|
|
@@ -195,50 +118,7 @@ function cachePolicy(opts) {
|
|
|
195
118
|
await ctx.store.set(key, { value, insertedAt: Date.now() }, opts.ttl);
|
|
196
119
|
}
|
|
197
120
|
catch {
|
|
198
|
-
// Fail-open: see sync path comment.
|
|
199
121
|
}
|
|
200
122
|
return value;
|
|
201
123
|
};
|
|
202
124
|
}
|
|
203
|
-
/**
|
|
204
|
-
* Race a promise against a signal — but if the signal aborts, mark the
|
|
205
|
-
* underlying promise as handled (so its eventual rejection doesn't surface
|
|
206
|
-
* as an unhandled rejection). The underlying promise keeps running for any
|
|
207
|
-
* other consumers (e.g. joiners in single-flight).
|
|
208
|
-
*
|
|
209
|
-
* This is `raceAbort` from `utils/abort.ts`, inlined here to avoid an import
|
|
210
|
-
* cycle (cache.ts already imports from stores, dedupe imports from abort —
|
|
211
|
-
* keeping cache independent of abort reduces the import graph depth).
|
|
212
|
-
*
|
|
213
|
-
* Internal — not exported.
|
|
214
|
-
*/
|
|
215
|
-
function raceWithOwnSignal(promise, signal) {
|
|
216
|
-
if (signal.aborted) {
|
|
217
|
-
promise.catch(() => { }); // mark as handled
|
|
218
|
-
return Promise.reject(signal.reason);
|
|
219
|
-
}
|
|
220
|
-
return new Promise((resolve, reject) => {
|
|
221
|
-
let settled = false;
|
|
222
|
-
const onAbort = () => {
|
|
223
|
-
if (settled)
|
|
224
|
-
return;
|
|
225
|
-
settled = true;
|
|
226
|
-
signal.removeEventListener('abort', onAbort);
|
|
227
|
-
reject(signal.reason);
|
|
228
|
-
};
|
|
229
|
-
signal.addEventListener('abort', onAbort);
|
|
230
|
-
promise.then((value) => {
|
|
231
|
-
if (settled)
|
|
232
|
-
return;
|
|
233
|
-
settled = true;
|
|
234
|
-
signal.removeEventListener('abort', onAbort);
|
|
235
|
-
resolve(value);
|
|
236
|
-
}, (error) => {
|
|
237
|
-
if (settled)
|
|
238
|
-
return;
|
|
239
|
-
settled = true;
|
|
240
|
-
signal.removeEventListener('abort', onAbort);
|
|
241
|
-
reject(error);
|
|
242
|
-
});
|
|
243
|
-
});
|
|
244
|
-
}
|
package/dist/policies/cache.d.ts
CHANGED
|
@@ -1,49 +1,3 @@
|
|
|
1
1
|
import type { CacheOptions, PolicyApplier } from '../types/index.js';
|
|
2
|
-
/**
|
|
3
|
-
* Short-circuit the entire downstream chain on a cache hit.
|
|
4
|
-
* On a miss, run `fn` and store the result with TTL.
|
|
5
|
-
*
|
|
6
|
-
* # Properties
|
|
7
|
-
*
|
|
8
|
-
* - **Single-flight originator isolation**: the stored in-flight promise is
|
|
9
|
-
* the RAW `fn(signal)` — NOT raceAbort-wrapped. An originator's signal
|
|
10
|
-
* abort no longer causes all cache-miss joiners to reject. Each caller
|
|
11
|
-
* races against their OWN signal only.
|
|
12
|
-
* - **Generation-safe inflight cleanup**: stale originators don't delete
|
|
13
|
-
* newer entries (same pattern as dedupePolicy).
|
|
14
|
-
* - **Signal-aware async store path**: between `await store.get()` and
|
|
15
|
-
* `await store.set()`, we re-check `signal.aborted`. The previous
|
|
16
|
-
* version would happily return a cached value even after the caller
|
|
17
|
-
* aborted mid-Redis-latency.
|
|
18
|
-
* - **Cache hit honours abort**: if `signal.aborted` is true at policy
|
|
19
|
-
* entry, we reject immediately — consistent with `act()`'s contract.
|
|
20
|
-
*
|
|
21
|
-
* # Single-flight (cache stampede prevention)
|
|
22
|
-
*
|
|
23
|
-
* On a sync store, the policy also stores the in-flight Promise under a
|
|
24
|
-
* separate `__inflight:cache:<key>` slot. Concurrent callers that miss the
|
|
25
|
-
* cache but find an in-flight Promise join it instead of launching duplicate
|
|
26
|
-
* work. This is the same mechanism `dedupePolicy` uses, applied internally
|
|
27
|
-
* to cache misses so users don't need to combine `cache` + `dedupe` to
|
|
28
|
-
* avoid stampedes.
|
|
29
|
-
*
|
|
30
|
-
* On an async store, single-flight is not possible (the same sync-store
|
|
31
|
-
* constraint as dedupe applies). Stampedes are a known limitation — document
|
|
32
|
-
* and pair with dedupe at a higher layer if you need single-flight semantics.
|
|
33
|
-
*
|
|
34
|
-
* # Fail-open writes
|
|
35
|
-
*
|
|
36
|
-
* If `store.set()` throws (e.g. Redis transient error), we swallow the error
|
|
37
|
-
* and return the value anyway. The caller gets their result; the next call
|
|
38
|
-
* will simply re-run `fn` and try to cache again. Caching is an optimisation,
|
|
39
|
-
* not a correctness requirement.
|
|
40
|
-
*
|
|
41
|
-
* # Cache hit semantics
|
|
42
|
-
*
|
|
43
|
-
* On a cache hit, `meta.source` is set to `'cache'` and `meta.attempts` is
|
|
44
|
-
* set to `0` — no work was performed.
|
|
45
|
-
*
|
|
46
|
-
* Failures are NEVER cached. Only successful values are stored.
|
|
47
|
-
*/
|
|
48
2
|
export declare function cachePolicy<T>(opts: CacheOptions): PolicyApplier<T>;
|
|
49
3
|
//# sourceMappingURL=cache.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../../src/policies/cache.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAS,YAAY,EAAE,aAAa,EAAiB,MAAM,mBAAmB,CAAA;
|
|
1
|
+
{"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../../src/policies/cache.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAS,YAAY,EAAE,aAAa,EAAiB,MAAM,mBAAmB,CAAA;AAiD1F,wBAAgB,WAAW,CAAC,CAAC,EAAE,IAAI,EAAE,YAAY,GAAG,aAAa,CAAC,CAAC,CAAC,CAqInE"}
|
package/dist/policies/cache.js
CHANGED
|
@@ -1,115 +1,51 @@
|
|
|
1
1
|
import { isSyncStore } from '../stores/base.js';
|
|
2
|
+
import { safeCall } from '../utils/safeCall.js';
|
|
3
|
+
import { raceAbort } from '../utils/abort.js';
|
|
4
|
+
import { LIMITS } from '../utils/limits.js';
|
|
2
5
|
const NS = 'cache:';
|
|
3
|
-
const INFLIGHT_NS = '
|
|
6
|
+
const INFLIGHT_NS = 'inflight:cache:';
|
|
4
7
|
let generationCounter = 0;
|
|
5
8
|
function nextGeneration() {
|
|
6
9
|
generationCounter = (generationCounter + 1) % Number.MAX_SAFE_INTEGER;
|
|
7
10
|
return generationCounter;
|
|
8
11
|
}
|
|
9
|
-
/**
|
|
10
|
-
* Short-circuit the entire downstream chain on a cache hit.
|
|
11
|
-
* On a miss, run `fn` and store the result with TTL.
|
|
12
|
-
*
|
|
13
|
-
* # Properties
|
|
14
|
-
*
|
|
15
|
-
* - **Single-flight originator isolation**: the stored in-flight promise is
|
|
16
|
-
* the RAW `fn(signal)` — NOT raceAbort-wrapped. An originator's signal
|
|
17
|
-
* abort no longer causes all cache-miss joiners to reject. Each caller
|
|
18
|
-
* races against their OWN signal only.
|
|
19
|
-
* - **Generation-safe inflight cleanup**: stale originators don't delete
|
|
20
|
-
* newer entries (same pattern as dedupePolicy).
|
|
21
|
-
* - **Signal-aware async store path**: between `await store.get()` and
|
|
22
|
-
* `await store.set()`, we re-check `signal.aborted`. The previous
|
|
23
|
-
* version would happily return a cached value even after the caller
|
|
24
|
-
* aborted mid-Redis-latency.
|
|
25
|
-
* - **Cache hit honours abort**: if `signal.aborted` is true at policy
|
|
26
|
-
* entry, we reject immediately — consistent with `act()`'s contract.
|
|
27
|
-
*
|
|
28
|
-
* # Single-flight (cache stampede prevention)
|
|
29
|
-
*
|
|
30
|
-
* On a sync store, the policy also stores the in-flight Promise under a
|
|
31
|
-
* separate `__inflight:cache:<key>` slot. Concurrent callers that miss the
|
|
32
|
-
* cache but find an in-flight Promise join it instead of launching duplicate
|
|
33
|
-
* work. This is the same mechanism `dedupePolicy` uses, applied internally
|
|
34
|
-
* to cache misses so users don't need to combine `cache` + `dedupe` to
|
|
35
|
-
* avoid stampedes.
|
|
36
|
-
*
|
|
37
|
-
* On an async store, single-flight is not possible (the same sync-store
|
|
38
|
-
* constraint as dedupe applies). Stampedes are a known limitation — document
|
|
39
|
-
* and pair with dedupe at a higher layer if you need single-flight semantics.
|
|
40
|
-
*
|
|
41
|
-
* # Fail-open writes
|
|
42
|
-
*
|
|
43
|
-
* If `store.set()` throws (e.g. Redis transient error), we swallow the error
|
|
44
|
-
* and return the value anyway. The caller gets their result; the next call
|
|
45
|
-
* will simply re-run `fn` and try to cache again. Caching is an optimisation,
|
|
46
|
-
* not a correctness requirement.
|
|
47
|
-
*
|
|
48
|
-
* # Cache hit semantics
|
|
49
|
-
*
|
|
50
|
-
* On a cache hit, `meta.source` is set to `'cache'` and `meta.attempts` is
|
|
51
|
-
* set to `0` — no work was performed.
|
|
52
|
-
*
|
|
53
|
-
* Failures are NEVER cached. Only successful values are stored.
|
|
54
|
-
*/
|
|
55
12
|
export function cachePolicy(opts) {
|
|
56
13
|
return (fn, ctx) => async (signal) => {
|
|
57
|
-
// Honour abort on cache hit too. Consistent with act()'s
|
|
58
|
-
// "reject immediately if signal aborted" contract.
|
|
59
14
|
if (signal.aborted)
|
|
60
15
|
return Promise.reject(signal.reason);
|
|
61
16
|
const key = NS + ctx.key;
|
|
62
17
|
const inflightKey = INFLIGHT_NS + ctx.key;
|
|
63
|
-
// ─── Sync store: fast path with single-flight ────────────────────────
|
|
64
18
|
if (isSyncStore(ctx.store)) {
|
|
65
|
-
// Capture the narrowed sync store so closures (cleanup) keep the
|
|
66
|
-
// type information — TS doesn't carry `isSyncStore` narrowing
|
|
67
|
-
// into nested function bodies.
|
|
68
19
|
const store = ctx.store;
|
|
69
20
|
const obs = ctx.observability;
|
|
70
|
-
// 1. Cache hit?
|
|
71
21
|
const hit = store.get(key);
|
|
72
22
|
if (hit) {
|
|
73
23
|
ctx.meta.source = 'cache';
|
|
74
24
|
ctx.meta.attempts = 0;
|
|
75
|
-
// Emit cache-hit event if hooks are registered.
|
|
76
|
-
// Compute actual age from the entry's insertion timestamp.
|
|
77
25
|
if (obs) {
|
|
78
26
|
const ageMs = Date.now() - hit.insertedAt;
|
|
79
|
-
obs.hooks.onCacheHit
|
|
27
|
+
safeCall(obs.hooks.onCacheHit, {
|
|
80
28
|
type: 'cache-hit', key: ctx.key, traceId: obs.traceId,
|
|
81
29
|
timestamp: Date.now(), ageMs: Math.max(0, ageMs),
|
|
82
30
|
});
|
|
83
31
|
}
|
|
84
32
|
return hit.value;
|
|
85
33
|
}
|
|
86
|
-
// Emit cache-miss event if hooks are registered.
|
|
87
34
|
if (obs) {
|
|
88
|
-
obs.hooks.onCacheMiss
|
|
35
|
+
safeCall(obs.hooks.onCacheMiss, {
|
|
89
36
|
type: 'cache-miss', key: ctx.key, traceId: obs.traceId,
|
|
90
37
|
timestamp: Date.now(),
|
|
91
38
|
});
|
|
92
39
|
}
|
|
93
|
-
// 2. In-flight single-flight hit? Join it.
|
|
94
|
-
//
|
|
95
|
-
// The stored promise is RAW fn(signal). Joiners race it
|
|
96
|
-
// against their OWN signal only — originator abort doesn't
|
|
97
|
-
// propagate to joiners.
|
|
98
40
|
const inflight = store.get(inflightKey);
|
|
99
41
|
if (inflight) {
|
|
100
|
-
// Race the in-flight promise against OUR OWN signal. If we
|
|
101
|
-
// abort, we reject — but other joiners and the originator
|
|
102
|
-
// continue unaffected.
|
|
103
42
|
try {
|
|
104
|
-
const value = await
|
|
105
|
-
// Mirror originator's meta so joiner's ActResult reflects
|
|
106
|
-
// the real effort (attempts, source).
|
|
43
|
+
const value = await raceAbort(inflight.promise, signal);
|
|
107
44
|
ctx.meta.attempts = inflight.meta.attempts;
|
|
108
45
|
ctx.meta.source = inflight.meta.source;
|
|
109
46
|
return value;
|
|
110
47
|
}
|
|
111
48
|
catch (err) {
|
|
112
|
-
// Copy meta on failure too (same as dedupe joiners)
|
|
113
49
|
if (signal.aborted) {
|
|
114
50
|
ctx.meta.attempts = 0;
|
|
115
51
|
}
|
|
@@ -120,49 +56,36 @@ export function cachePolicy(opts) {
|
|
|
120
56
|
throw err;
|
|
121
57
|
}
|
|
122
58
|
}
|
|
123
|
-
// 3. Originator: launch fn, cache on success (fail-open).
|
|
124
|
-
//
|
|
125
|
-
// The stored promise is the RAW fn(signal) — NOT raceAbort-wrapped.
|
|
126
|
-
// Originator's own await is wrapped in raceWithOwnSignal so they
|
|
127
|
-
// can bail on their signal without affecting joiners.
|
|
128
59
|
const generation = nextGeneration();
|
|
129
60
|
const rawPromise = Promise.resolve(fn(signal)).then((value) => {
|
|
130
61
|
try {
|
|
131
62
|
store.set(key, { value, insertedAt: Date.now() }, opts.ttl);
|
|
132
63
|
}
|
|
133
64
|
catch {
|
|
134
|
-
// Fail-open: cache write failure should not surface to caller.
|
|
135
65
|
}
|
|
136
66
|
return value;
|
|
137
|
-
},
|
|
138
|
-
// Re-throw the error so joiners see it. Don't cache failures.
|
|
139
|
-
(err) => { throw err; });
|
|
140
|
-
// Publish the in-flight entry for single-flight. If store.set
|
|
141
|
-
// throws, single-flight is disabled for this call — concurrent
|
|
142
|
-
// callers will all run fn. Still correct, just less efficient.
|
|
67
|
+
}, (err) => { throw err; });
|
|
143
68
|
try {
|
|
144
|
-
store.set(inflightKey, { promise: rawPromise, generation, meta: ctx.meta });
|
|
69
|
+
store.set(inflightKey, { promise: rawPromise, generation, meta: ctx.meta }, LIMITS.DEFAULT_INFLIGHT_TTL);
|
|
145
70
|
}
|
|
146
71
|
catch {
|
|
147
|
-
// Single-flight unavailable; proceed without publishing.
|
|
148
|
-
// Originator still gets their value (or error).
|
|
149
72
|
}
|
|
150
|
-
// Cleanup the in-flight slot on settle — generation-safe.
|
|
151
73
|
const cleanup = () => {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
74
|
+
try {
|
|
75
|
+
const current = store.get(inflightKey);
|
|
76
|
+
if (current && current.generation === generation) {
|
|
77
|
+
try {
|
|
78
|
+
store.delete(inflightKey);
|
|
79
|
+
}
|
|
80
|
+
catch { }
|
|
156
81
|
}
|
|
157
|
-
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
158
84
|
}
|
|
159
85
|
};
|
|
160
|
-
rawPromise.then(cleanup, cleanup);
|
|
161
|
-
|
|
162
|
-
return raceWithOwnSignal(rawPromise, signal);
|
|
86
|
+
rawPromise.then(cleanup, cleanup).catch(() => { });
|
|
87
|
+
return raceAbort(rawPromise, signal);
|
|
163
88
|
}
|
|
164
|
-
// Async store: no single-flight (race window unavoidable).
|
|
165
|
-
// Re-check signal.aborted between awaits.
|
|
166
89
|
const obs = ctx.observability;
|
|
167
90
|
const hit = await ctx.store.get(key);
|
|
168
91
|
if (signal.aborted)
|
|
@@ -172,7 +95,7 @@ export function cachePolicy(opts) {
|
|
|
172
95
|
ctx.meta.attempts = 0;
|
|
173
96
|
if (obs) {
|
|
174
97
|
const ageMs = Date.now() - hit.insertedAt;
|
|
175
|
-
obs.hooks.onCacheHit
|
|
98
|
+
safeCall(obs.hooks.onCacheHit, {
|
|
176
99
|
type: 'cache-hit', key: ctx.key, traceId: obs.traceId,
|
|
177
100
|
timestamp: Date.now(), ageMs: Math.max(0, ageMs),
|
|
178
101
|
});
|
|
@@ -180,7 +103,7 @@ export function cachePolicy(opts) {
|
|
|
180
103
|
return hit.value;
|
|
181
104
|
}
|
|
182
105
|
if (obs) {
|
|
183
|
-
obs.hooks.onCacheMiss
|
|
106
|
+
safeCall(obs.hooks.onCacheMiss, {
|
|
184
107
|
type: 'cache-miss', key: ctx.key, traceId: obs.traceId,
|
|
185
108
|
timestamp: Date.now(),
|
|
186
109
|
});
|
|
@@ -192,51 +115,7 @@ export function cachePolicy(opts) {
|
|
|
192
115
|
await ctx.store.set(key, { value, insertedAt: Date.now() }, opts.ttl);
|
|
193
116
|
}
|
|
194
117
|
catch {
|
|
195
|
-
// Fail-open: see sync path comment.
|
|
196
118
|
}
|
|
197
119
|
return value;
|
|
198
120
|
};
|
|
199
121
|
}
|
|
200
|
-
/**
|
|
201
|
-
* Race a promise against a signal — but if the signal aborts, mark the
|
|
202
|
-
* underlying promise as handled (so its eventual rejection doesn't surface
|
|
203
|
-
* as an unhandled rejection). The underlying promise keeps running for any
|
|
204
|
-
* other consumers (e.g. joiners in single-flight).
|
|
205
|
-
*
|
|
206
|
-
* This is `raceAbort` from `utils/abort.ts`, inlined here to avoid an import
|
|
207
|
-
* cycle (cache.ts already imports from stores, dedupe imports from abort —
|
|
208
|
-
* keeping cache independent of abort reduces the import graph depth).
|
|
209
|
-
*
|
|
210
|
-
* Internal — not exported.
|
|
211
|
-
*/
|
|
212
|
-
function raceWithOwnSignal(promise, signal) {
|
|
213
|
-
if (signal.aborted) {
|
|
214
|
-
promise.catch(() => { }); // mark as handled
|
|
215
|
-
return Promise.reject(signal.reason);
|
|
216
|
-
}
|
|
217
|
-
return new Promise((resolve, reject) => {
|
|
218
|
-
let settled = false;
|
|
219
|
-
const onAbort = () => {
|
|
220
|
-
if (settled)
|
|
221
|
-
return;
|
|
222
|
-
settled = true;
|
|
223
|
-
signal.removeEventListener('abort', onAbort);
|
|
224
|
-
reject(signal.reason);
|
|
225
|
-
};
|
|
226
|
-
signal.addEventListener('abort', onAbort);
|
|
227
|
-
promise.then((value) => {
|
|
228
|
-
if (settled)
|
|
229
|
-
return;
|
|
230
|
-
settled = true;
|
|
231
|
-
signal.removeEventListener('abort', onAbort);
|
|
232
|
-
resolve(value);
|
|
233
|
-
}, (error) => {
|
|
234
|
-
if (settled)
|
|
235
|
-
return;
|
|
236
|
-
settled = true;
|
|
237
|
-
signal.removeEventListener('abort', onAbort);
|
|
238
|
-
reject(error);
|
|
239
|
-
});
|
|
240
|
-
});
|
|
241
|
-
}
|
|
242
|
-
//# sourceMappingURL=cache.js.map
|