@zudojs/cache 0.0.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -14
- package/dist/cache.d.ts +107 -18
- package/dist/cache.js +393 -33
- package/dist/constants.d.ts +46 -12
- package/dist/constants.js +47 -19
- package/dist/index.d.ts +10 -9
- package/dist/index.js +9 -8
- package/dist/invalidation.d.ts +23 -16
- package/dist/invalidation.js +43 -33
- package/dist/key-builder.d.ts +15 -0
- package/dist/key-builder.js +43 -3
- package/dist/lock.d.ts +36 -2
- package/dist/lock.js +160 -30
- package/dist/memory.d.ts +63 -7
- package/dist/memory.js +223 -39
- package/dist/metrics.d.ts +10 -0
- package/dist/metrics.js +49 -4
- package/dist/serializer.d.ts +19 -10
- package/dist/serializer.js +88 -21
- package/dist/store.d.ts +21 -11
- package/dist/store.js +125 -65
- package/dist/tags.d.ts +32 -12
- package/dist/tags.js +113 -33
- package/dist/types-adapter.d.ts +23 -9
- package/dist/types-config.d.ts +46 -9
- package/dist/types-health.d.ts +5 -2
- package/dist/types-keys.d.ts +8 -4
- package/dist/types-lock.d.ts +8 -0
- package/dist/types-operations.d.ts +13 -20
- package/dist/types-results.d.ts +7 -1
- package/dist/types-tags.d.ts +9 -0
- package/dist/types-utility.d.ts +6 -10
- package/dist/types-values.d.ts +9 -14
- package/dist/types.d.ts +6 -6
- package/dist/utils.d.ts +65 -0
- package/dist/utils.js +210 -0
- package/package.json +14 -7
- package/dist/.tsbuildinfo +0 -1
- package/dist/cache.d.ts.map +0 -1
- package/dist/cache.js.map +0 -1
- package/dist/constants.d.ts.map +0 -1
- package/dist/constants.js.map +0 -1
- package/dist/errors.d.ts.map +0 -1
- package/dist/errors.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/invalidation.d.ts.map +0 -1
- package/dist/invalidation.js.map +0 -1
- package/dist/key-builder.d.ts.map +0 -1
- package/dist/key-builder.js.map +0 -1
- package/dist/lock.d.ts.map +0 -1
- package/dist/lock.js.map +0 -1
- package/dist/memory.d.ts.map +0 -1
- package/dist/memory.js.map +0 -1
- package/dist/metrics.d.ts.map +0 -1
- package/dist/metrics.js.map +0 -1
- package/dist/serializer.d.ts.map +0 -1
- package/dist/serializer.js.map +0 -1
- package/dist/store.d.ts.map +0 -1
- package/dist/store.js.map +0 -1
- package/dist/tags.d.ts.map +0 -1
- package/dist/tags.js.map +0 -1
- package/dist/types-adapter.d.ts.map +0 -1
- package/dist/types-adapter.js.map +0 -1
- package/dist/types-config.d.ts.map +0 -1
- package/dist/types-config.js.map +0 -1
- package/dist/types-events.d.ts.map +0 -1
- package/dist/types-events.js.map +0 -1
- package/dist/types-health.d.ts.map +0 -1
- package/dist/types-health.js.map +0 -1
- package/dist/types-keys.d.ts.map +0 -1
- package/dist/types-keys.js.map +0 -1
- package/dist/types-lock.d.ts.map +0 -1
- package/dist/types-lock.js.map +0 -1
- package/dist/types-metrics.d.ts.map +0 -1
- package/dist/types-metrics.js.map +0 -1
- package/dist/types-operations.d.ts.map +0 -1
- package/dist/types-operations.js.map +0 -1
- package/dist/types-results.d.ts.map +0 -1
- package/dist/types-results.js.map +0 -1
- package/dist/types-tags.d.ts.map +0 -1
- package/dist/types-tags.js.map +0 -1
- package/dist/types-utility.d.ts.map +0 -1
- package/dist/types-utility.js.map +0 -1
- package/dist/types-values.d.ts.map +0 -1
- package/dist/types-values.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js.map +0 -1
package/dist/cache.js
CHANGED
|
@@ -1,14 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @zudojs/cache — Cache Service
|
|
3
|
-
* High-level cache service combining adapter,
|
|
3
|
+
* High-level cache service combining adapter, key builder, tags,
|
|
4
|
+
* invalidation, locking, metrics, middleware, events, and an optional
|
|
5
|
+
* serializer.
|
|
6
|
+
*
|
|
7
|
+
* Serialization: when `config.serializer` is provided, values are
|
|
8
|
+
* serialized on set and deserialized on get, so cached values are
|
|
9
|
+
* structural copies. Without a serializer the memory adapter stores
|
|
10
|
+
* values by reference — mutations of a cached object are visible to
|
|
11
|
+
* later readers.
|
|
12
|
+
*
|
|
13
|
+
* Scoping: keys, tags and locks are all namespace-scoped. A namespace is
|
|
14
|
+
* validated as an identity part everywhere it is used (including in glob
|
|
15
|
+
* patterns), so it can never widen an operation beyond its own tenant.
|
|
4
16
|
*/
|
|
5
|
-
import { DEFAULT_TTL_MS } from "./constants.js";
|
|
17
|
+
import { DEFAULT_LOCK_RETRY_DELAY_MS, DEFAULT_SEPARATOR, DEFAULT_TTL_MS, } from "./constants.js";
|
|
6
18
|
import { DefaultKeyBuilder } from "./key-builder.js";
|
|
7
19
|
import { createCacheStore, DefaultCacheStore } from "./store.js";
|
|
8
|
-
import { createTagStore, InMemoryTagStore } from "./tags.js";
|
|
20
|
+
import { assertValidTag, createTagStore, InMemoryTagStore } from "./tags.js";
|
|
9
21
|
import { CacheInvalidationManager, createInvalidationManager, } from "./invalidation.js";
|
|
10
22
|
import { CacheLockManager, createLockManager } from "./lock.js";
|
|
11
23
|
import { createCacheMetrics, InMemoryCacheMetrics } from "./metrics.js";
|
|
24
|
+
import { CacheError, cacheDeserializationError, cacheSerializationError, isCacheError, } from "./errors.js";
|
|
25
|
+
import { createGlobMatcher } from "./utils.js";
|
|
12
26
|
export class CacheService {
|
|
13
27
|
store;
|
|
14
28
|
keyBuilder;
|
|
@@ -16,93 +30,376 @@ export class CacheService {
|
|
|
16
30
|
invalidation;
|
|
17
31
|
lockManager;
|
|
18
32
|
metrics;
|
|
33
|
+
serializer;
|
|
19
34
|
defaultTtl;
|
|
20
35
|
enabled;
|
|
36
|
+
failSilently;
|
|
37
|
+
/** Namespace configured for this service; the default scope for keys, tags and locks. */
|
|
38
|
+
namespace;
|
|
39
|
+
separator;
|
|
40
|
+
/** In-flight getOrSet computations, keyed by full key (stampede protection). */
|
|
41
|
+
inFlight = new Map();
|
|
21
42
|
constructor(options) {
|
|
22
43
|
this.enabled = options.config?.enabled ?? true;
|
|
23
|
-
this.
|
|
44
|
+
this.failSilently = options.config?.failSilently ?? false;
|
|
45
|
+
this.defaultTtl =
|
|
46
|
+
options.config?.defaultTtl !== undefined
|
|
47
|
+
? options.config.defaultTtl
|
|
48
|
+
: DEFAULT_TTL_MS;
|
|
49
|
+
this.serializer = options.config?.serializer ?? null;
|
|
50
|
+
this.namespace = options.config?.namespace;
|
|
51
|
+
this.separator = options.config?.separator ?? DEFAULT_SEPARATOR;
|
|
24
52
|
this.metrics =
|
|
25
53
|
options.config?.collectStats !== false ? createCacheMetrics() : null;
|
|
26
54
|
this.store = createCacheStore({
|
|
27
55
|
adapter: options.adapter,
|
|
28
|
-
metrics: this.metrics,
|
|
56
|
+
...(this.metrics ? { metrics: this.metrics } : {}),
|
|
57
|
+
...(options.config?.middlewares
|
|
58
|
+
? { middlewares: options.config.middlewares }
|
|
59
|
+
: {}),
|
|
29
60
|
});
|
|
30
61
|
this.keyBuilder =
|
|
31
62
|
options.keyBuilder ??
|
|
32
63
|
new DefaultKeyBuilder({
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
64
|
+
...(options.config?.prefix !== undefined
|
|
65
|
+
? { prefix: options.config.prefix }
|
|
66
|
+
: {}),
|
|
67
|
+
...(options.config?.separator !== undefined
|
|
68
|
+
? { separator: options.config.separator }
|
|
69
|
+
: {}),
|
|
70
|
+
...(options.config?.namespace !== undefined
|
|
71
|
+
? { namespace: options.config.namespace }
|
|
72
|
+
: {}),
|
|
36
73
|
});
|
|
37
74
|
this.tagStore = createTagStore();
|
|
75
|
+
// Route invalidation through the instrumented store so metrics and
|
|
76
|
+
// events fire for invalidation-driven deletions too.
|
|
38
77
|
this.invalidation = createInvalidationManager({
|
|
39
|
-
adapter:
|
|
78
|
+
adapter: this.store,
|
|
40
79
|
tagStore: this.tagStore,
|
|
80
|
+
keyBuilder: this.keyBuilder,
|
|
41
81
|
});
|
|
42
|
-
this.lockManager = createLockManager();
|
|
82
|
+
this.lockManager = createLockManager(options.config?.lockStore ? { store: options.config.lockStore } : {});
|
|
43
83
|
}
|
|
44
84
|
async get(key, options) {
|
|
45
85
|
if (!this.enabled)
|
|
46
86
|
return { hit: false, value: null };
|
|
47
|
-
|
|
87
|
+
const fullKey = this.keyBuilder.build(key, options);
|
|
88
|
+
try {
|
|
89
|
+
const result = await this.store.get(fullKey);
|
|
90
|
+
if (!result.hit || !this.serializer)
|
|
91
|
+
return result;
|
|
92
|
+
const value = this.deserialize(result.value);
|
|
93
|
+
return {
|
|
94
|
+
...result,
|
|
95
|
+
value,
|
|
96
|
+
...(result.entry
|
|
97
|
+
? { entry: { ...result.entry, value } }
|
|
98
|
+
: {}),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (this.failSilently)
|
|
103
|
+
return { hit: false, value: null };
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
48
106
|
}
|
|
49
107
|
async set(key, value, options) {
|
|
50
108
|
if (!this.enabled)
|
|
51
109
|
return { success: false, key, expiresAt: null };
|
|
52
110
|
const fullKey = this.keyBuilder.build(key, options);
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
await this.
|
|
59
|
-
|
|
111
|
+
if (options?.tags)
|
|
112
|
+
for (const tag of options.tags)
|
|
113
|
+
assertValidTag(tag);
|
|
114
|
+
try {
|
|
115
|
+
const stored = this.serializer ? this.serialize(value) : value;
|
|
116
|
+
const result = await this.store.set(fullKey, stored, {
|
|
117
|
+
ttl: options?.ttl !== undefined ? options.ttl : this.defaultTtl,
|
|
118
|
+
...(options?.tags !== undefined ? { tags: options.tags } : {}),
|
|
119
|
+
...(options?.overwrite !== undefined
|
|
120
|
+
? { overwrite: options.overwrite }
|
|
121
|
+
: {}),
|
|
122
|
+
...(options?.metadata !== undefined
|
|
123
|
+
? { metadata: options.metadata }
|
|
124
|
+
: {}),
|
|
125
|
+
});
|
|
126
|
+
if (result.success && options?.tags && options.tags.length > 0)
|
|
127
|
+
await this.tagStore.add(fullKey, options.tags, this.tagScope(options));
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
if (this.failSilently)
|
|
132
|
+
return { success: false, key, expiresAt: null };
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
60
135
|
}
|
|
61
136
|
async delete(key, options) {
|
|
62
137
|
if (!this.enabled)
|
|
63
138
|
return { deleted: false, key };
|
|
64
|
-
|
|
139
|
+
const fullKey = this.keyBuilder.build(key, options);
|
|
140
|
+
try {
|
|
141
|
+
const result = await this.store.delete(fullKey);
|
|
142
|
+
this.tagStore.removeKey(fullKey);
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
if (this.failSilently)
|
|
147
|
+
return { deleted: false, key };
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
65
150
|
}
|
|
66
151
|
async has(key, options) {
|
|
67
152
|
if (!this.enabled)
|
|
68
153
|
return false;
|
|
69
|
-
|
|
154
|
+
const fullKey = this.keyBuilder.build(key, options);
|
|
155
|
+
try {
|
|
156
|
+
return await this.store.has(fullKey);
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
if (this.failSilently)
|
|
160
|
+
return false;
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
70
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* Clears cache entries.
|
|
166
|
+
* - No options: clears everything (and flushes the tag store).
|
|
167
|
+
* - `namespace` and/or `pattern`: builds a fully-qualified pattern via
|
|
168
|
+
* the key builder (prefix + namespace + pattern) so only matching
|
|
169
|
+
* entries are removed. Both parts are validated: a `namespace` of `"*"`
|
|
170
|
+
* is rejected rather than escaping its own scope.
|
|
171
|
+
*/
|
|
71
172
|
async clear(options) {
|
|
72
173
|
if (!this.enabled)
|
|
73
174
|
return { cleared: 0 };
|
|
74
|
-
|
|
175
|
+
if (options?.pattern !== undefined || options?.namespace !== undefined) {
|
|
176
|
+
// Validation happens outside the failSilently guard: a malformed
|
|
177
|
+
// pattern is programmer error, not an adapter fault.
|
|
178
|
+
const pattern = this.qualifyPattern(options.pattern ?? "*", options.namespace);
|
|
179
|
+
try {
|
|
180
|
+
const result = await this.store.clear({ pattern });
|
|
181
|
+
this.purgeTagsMatching(pattern);
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
if (this.failSilently)
|
|
186
|
+
return { cleared: 0 };
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
const result = await this.store.clear();
|
|
192
|
+
this.tagStore.clear();
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
if (this.failSilently)
|
|
197
|
+
return { cleared: 0 };
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/** Remaining TTL for a key (undefined = missing, null = never expires). */
|
|
202
|
+
async ttl(key, options) {
|
|
203
|
+
if (!this.enabled)
|
|
204
|
+
return undefined;
|
|
205
|
+
const fullKey = this.keyBuilder.build(key, options);
|
|
206
|
+
try {
|
|
207
|
+
return await this.store.ttl?.(fullKey);
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
if (this.failSilently)
|
|
211
|
+
return undefined;
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** Updates the TTL of an existing key. Returns false when unsupported or missing. */
|
|
216
|
+
async expire(key, ttl, options) {
|
|
217
|
+
if (!this.enabled)
|
|
218
|
+
return false;
|
|
219
|
+
const fullKey = this.keyBuilder.build(key, options);
|
|
220
|
+
try {
|
|
221
|
+
return (await this.store.expire?.(fullKey, ttl)) ?? false;
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
if (this.failSilently)
|
|
225
|
+
return false;
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
75
228
|
}
|
|
76
229
|
async getOrSet(key, fn, options) {
|
|
77
230
|
if (!this.enabled)
|
|
78
231
|
return { value: await fn(), cached: false };
|
|
232
|
+
const fullKey = this.keyBuilder.build(key, options);
|
|
79
233
|
if (!options?.forceRefresh) {
|
|
80
234
|
const cached = await this.get(key, options);
|
|
81
235
|
if (cached.hit)
|
|
82
236
|
return { value: cached.value, cached: true };
|
|
237
|
+
// Stampede protection: concurrent misses share a single fn() call.
|
|
238
|
+
const pending = this.inFlight.get(fullKey);
|
|
239
|
+
if (pending)
|
|
240
|
+
return { value: (await pending), cached: false };
|
|
241
|
+
}
|
|
242
|
+
const promise = (async () => {
|
|
243
|
+
const value = await fn();
|
|
244
|
+
await this.set(key, value, options);
|
|
245
|
+
return value;
|
|
246
|
+
})();
|
|
247
|
+
this.inFlight.set(fullKey, promise);
|
|
248
|
+
try {
|
|
249
|
+
return { value: (await promise), cached: false };
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
if (this.inFlight.get(fullKey) === promise)
|
|
253
|
+
this.inFlight.delete(fullKey);
|
|
83
254
|
}
|
|
84
|
-
const value = await fn();
|
|
85
|
-
await this.set(key, value, options);
|
|
86
|
-
return { value, cached: false };
|
|
87
255
|
}
|
|
88
|
-
|
|
89
|
-
|
|
256
|
+
/**
|
|
257
|
+
* Invalidates every entry tagged with any of `tags`, within this
|
|
258
|
+
* service's namespace (or `options.namespace`). Tags registered under a
|
|
259
|
+
* different namespace are untouched.
|
|
260
|
+
*/
|
|
261
|
+
async invalidateByTag(tags, options) {
|
|
262
|
+
if (!this.enabled)
|
|
263
|
+
return { cleared: 0 };
|
|
264
|
+
for (const tag of tags)
|
|
265
|
+
assertValidTag(tag);
|
|
266
|
+
try {
|
|
267
|
+
return await this.invalidation.invalidateByTag(tags, this.tagScope(options));
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
if (this.failSilently)
|
|
271
|
+
return { cleared: 0 };
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
90
274
|
}
|
|
91
|
-
|
|
92
|
-
|
|
275
|
+
/**
|
|
276
|
+
* Invalidates entries matching a service-level glob pattern. The
|
|
277
|
+
* pattern is qualified with the key builder's prefix (and namespace,
|
|
278
|
+
* if configured), so `invalidateByPattern("user.*")` matches keys this
|
|
279
|
+
* service wrote via `set("user.1", ...)`.
|
|
280
|
+
*
|
|
281
|
+
* `*` never crosses the key separator, so a pattern cannot reach into a
|
|
282
|
+
* namespace the caller did not name. Use `**` as the pattern to span
|
|
283
|
+
* whole namespaces deliberately.
|
|
284
|
+
*/
|
|
285
|
+
async invalidateByPattern(pattern, options) {
|
|
286
|
+
if (!this.enabled)
|
|
287
|
+
return { cleared: 0 };
|
|
288
|
+
const qualified = this.qualifyPattern(pattern, options?.namespace);
|
|
289
|
+
try {
|
|
290
|
+
const result = await this.invalidation.invalidateByPattern(qualified);
|
|
291
|
+
this.purgeTagsMatching(qualified);
|
|
292
|
+
return result;
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
if (this.failSilently)
|
|
296
|
+
return { cleared: 0 };
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
93
299
|
}
|
|
300
|
+
/**
|
|
301
|
+
* Runs `fn` under a namespace-scoped, fully-qualified lock.
|
|
302
|
+
*
|
|
303
|
+
* The lock name goes through the key builder, so it is prefixed and
|
|
304
|
+
* namespaced exactly like a cache key and validated the same way — two
|
|
305
|
+
* tenants using the same lock name do not collide.
|
|
306
|
+
*
|
|
307
|
+
* The lease is renewed while `fn` runs and a lost lease throws (see
|
|
308
|
+
* `CacheLockManager.withLock`). `failSilently` deliberately does not apply:
|
|
309
|
+
* running a critical section without exclusion is never a safe
|
|
310
|
+
* degradation, and neither is running it while the cache is disabled.
|
|
311
|
+
*/
|
|
94
312
|
async withLock(key, fn, options) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
313
|
+
if (!this.enabled) {
|
|
314
|
+
const code = "CACHE_DISABLED";
|
|
315
|
+
throw new CacheError(`Cannot acquire lock "${key}": the cache is disabled.`, { code, statusCode: 503 });
|
|
316
|
+
}
|
|
317
|
+
const lockKey = this.keyBuilder.build(key, options?.namespace !== undefined
|
|
318
|
+
? { namespace: options.namespace }
|
|
319
|
+
: undefined);
|
|
320
|
+
return this.lockManager.withLock(lockKey, fn, {
|
|
321
|
+
...(options?.ttl !== undefined ? { ttl: options.ttl } : {}),
|
|
322
|
+
// `retryAttempts: 0` is honored (single attempt, no retries).
|
|
323
|
+
...(options?.retryAttempts !== undefined
|
|
324
|
+
? {
|
|
325
|
+
retry: {
|
|
326
|
+
attempts: options.retryAttempts,
|
|
327
|
+
delay: DEFAULT_LOCK_RETRY_DELAY_MS,
|
|
328
|
+
},
|
|
329
|
+
}
|
|
330
|
+
: {}),
|
|
100
331
|
});
|
|
101
332
|
}
|
|
333
|
+
/**
|
|
334
|
+
* Applies a sequence of operations, one at a time, returning one result
|
|
335
|
+
* per operation in submission order. A failing operation does not stop
|
|
336
|
+
* the batch; its error is reported on its own result.
|
|
337
|
+
*/
|
|
338
|
+
async batch(operations, options) {
|
|
339
|
+
const results = [];
|
|
340
|
+
for (const operation of operations) {
|
|
341
|
+
try {
|
|
342
|
+
const result = await this.applyBatchOperation(operation, options);
|
|
343
|
+
results.push({ operation, success: true, result });
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
results.push({ operation, success: false, error });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return results;
|
|
350
|
+
}
|
|
351
|
+
/* ---- Observability ---- */
|
|
102
352
|
getStats() {
|
|
103
353
|
return this.metrics?.getStats() ?? null;
|
|
104
354
|
}
|
|
355
|
+
/** Number of live entries, when the underlying adapter can report it. */
|
|
356
|
+
async size() {
|
|
357
|
+
if (!this.enabled)
|
|
358
|
+
return undefined;
|
|
359
|
+
try {
|
|
360
|
+
return await this.store.size();
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
if (this.failSilently)
|
|
364
|
+
return undefined;
|
|
365
|
+
throw error;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/** Latency percentiles for one operation. */
|
|
369
|
+
getLatencyStats(operation) {
|
|
370
|
+
return this.metrics?.getLatencyStats(operation) ?? null;
|
|
371
|
+
}
|
|
372
|
+
/** Latency histogram for one operation. */
|
|
373
|
+
getLatencyHistogram(operation) {
|
|
374
|
+
return this.metrics?.getLatencyHistogram(operation) ?? null;
|
|
375
|
+
}
|
|
376
|
+
/** The most-read keys currently tracked, hottest first. */
|
|
377
|
+
getHotKeys(topN) {
|
|
378
|
+
return this.metrics?.getHotKeys(topN) ?? null;
|
|
379
|
+
}
|
|
380
|
+
/** Resets all collected metrics. */
|
|
381
|
+
resetStats() {
|
|
382
|
+
this.metrics?.reset();
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Subscribes to cache events (`cache.hit`, `cache.miss`, `cache.set`,
|
|
386
|
+
* `cache.delete`, `cache.clear`, `cache.error`), or to `"*"` for all.
|
|
387
|
+
*/
|
|
388
|
+
subscribe(eventType, handler) {
|
|
389
|
+
return this.store.subscribe(eventType, handler);
|
|
390
|
+
}
|
|
105
391
|
async healthCheck() {
|
|
392
|
+
if (!this.enabled) {
|
|
393
|
+
// A disabled cache is not unhealthy — but the caller must be able to
|
|
394
|
+
// tell the two apart, so say so explicitly instead of probing an
|
|
395
|
+
// adapter the service has been told not to use.
|
|
396
|
+
return {
|
|
397
|
+
healthy: true,
|
|
398
|
+
adapter: this.store.name,
|
|
399
|
+
checkedAt: new Date(),
|
|
400
|
+
disabled: true,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
106
403
|
const start = performance.now();
|
|
107
404
|
try {
|
|
108
405
|
await this.store.has("__health__");
|
|
@@ -127,6 +424,69 @@ export class CacheService {
|
|
|
127
424
|
}
|
|
128
425
|
async disconnect() {
|
|
129
426
|
await this.store.disconnect?.();
|
|
427
|
+
// Drop service-local state so a reconnect does not resurrect stale
|
|
428
|
+
// in-flight computations or tag mappings.
|
|
429
|
+
this.inFlight.clear();
|
|
430
|
+
this.tagStore.clear();
|
|
431
|
+
}
|
|
432
|
+
/* ---- Internals ---- */
|
|
433
|
+
async applyBatchOperation(operation, options) {
|
|
434
|
+
const scope = options?.namespace !== undefined
|
|
435
|
+
? { namespace: options.namespace }
|
|
436
|
+
: undefined;
|
|
437
|
+
switch (operation.type) {
|
|
438
|
+
case "get":
|
|
439
|
+
return this.get(operation.key, scope);
|
|
440
|
+
case "set":
|
|
441
|
+
return this.set(operation.key, operation.value, {
|
|
442
|
+
...operation.options,
|
|
443
|
+
...(scope ?? {}),
|
|
444
|
+
});
|
|
445
|
+
case "delete":
|
|
446
|
+
return this.delete(operation.key, scope);
|
|
447
|
+
default: {
|
|
448
|
+
const code = "CACHE_OPERATION_FAILED";
|
|
449
|
+
throw new CacheError(`Unsupported batch operation type: ${String(operation.type)}.`, { code, key: operation.key, statusCode: 400, expose: true });
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
/** The namespace scope applied to tag registrations and lookups. */
|
|
454
|
+
tagScope(options) {
|
|
455
|
+
const namespace = options?.namespace ?? this.namespace;
|
|
456
|
+
return namespace !== undefined ? { namespace } : {};
|
|
457
|
+
}
|
|
458
|
+
qualifyPattern(pattern, namespace) {
|
|
459
|
+
if (this.keyBuilder.buildPattern) {
|
|
460
|
+
return this.keyBuilder.buildPattern(pattern, namespace !== undefined ? { namespace } : undefined);
|
|
461
|
+
}
|
|
462
|
+
return pattern;
|
|
463
|
+
}
|
|
464
|
+
purgeTagsMatching(pattern) {
|
|
465
|
+
const matches = createGlobMatcher(pattern, { separator: this.separator });
|
|
466
|
+
for (const key of this.tagStore.trackedKeys()) {
|
|
467
|
+
if (matches(key))
|
|
468
|
+
this.tagStore.removeKey(key);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
serialize(value) {
|
|
472
|
+
try {
|
|
473
|
+
return this.serializer.serialize(value);
|
|
474
|
+
}
|
|
475
|
+
catch (error) {
|
|
476
|
+
if (isCacheError(error))
|
|
477
|
+
throw error;
|
|
478
|
+
throw cacheSerializationError(undefined, { cause: error });
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
deserialize(value) {
|
|
482
|
+
try {
|
|
483
|
+
return this.serializer.deserialize(value);
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
if (isCacheError(error))
|
|
487
|
+
throw error;
|
|
488
|
+
throw cacheDeserializationError(undefined, { cause: error });
|
|
489
|
+
}
|
|
130
490
|
}
|
|
131
491
|
}
|
|
132
492
|
export function createCacheService(options) {
|
package/dist/constants.d.ts
CHANGED
|
@@ -5,36 +5,70 @@
|
|
|
5
5
|
*/
|
|
6
6
|
/** Default time-to-live in milliseconds (5 minutes). */
|
|
7
7
|
export declare const DEFAULT_TTL_MS: number;
|
|
8
|
-
/** Maximum supported TTL (24 hours). */
|
|
8
|
+
/** Maximum supported TTL (24 hours). TTLs above this are rejected. */
|
|
9
9
|
export declare const MAX_TTL_MS: number;
|
|
10
|
-
/**
|
|
11
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Minimum TTL (1 millisecond). TTLs below this (zero or negative) are
|
|
12
|
+
* rejected; use `ttl: null` for entries that never expire.
|
|
13
|
+
*/
|
|
14
|
+
export declare const MIN_TTL_MS = 1;
|
|
12
15
|
/** Default namespace separator. */
|
|
13
16
|
export declare const DEFAULT_SEPARATOR = ":";
|
|
14
17
|
/** Default key prefix. */
|
|
15
18
|
export declare const DEFAULT_PREFIX = "zudojs";
|
|
16
19
|
/** Maximum key length in characters. */
|
|
17
20
|
export declare const MAX_KEY_LENGTH = 256;
|
|
18
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* Pattern used to validate each individual cache key part (prefix,
|
|
23
|
+
* namespace, and raw key). Deliberately excludes the default separator
|
|
24
|
+
* (`:`) so callers cannot forge namespaced keys (e.g. `build("admin:x")`
|
|
25
|
+
* throws). Parts are additionally checked against the active separator.
|
|
26
|
+
*/
|
|
19
27
|
export declare const CACHE_KEY_PATTERN: RegExp;
|
|
28
|
+
/**
|
|
29
|
+
* Pattern used to validate the caller-supplied *glob* segment of a key
|
|
30
|
+
* pattern. It is the key alphabet plus the two glob metacharacters `*` and
|
|
31
|
+
* `?` — nothing else has meaning for the matcher, so anything else is
|
|
32
|
+
* caller error. The separator is excluded so a pattern cannot escape the
|
|
33
|
+
* prefix/namespace scope it is composed into.
|
|
34
|
+
*/
|
|
35
|
+
export declare const CACHE_PATTERN_PART_PATTERN: RegExp;
|
|
36
|
+
/** Maximum length of a cache tag. Tags are untrusted map keys. */
|
|
37
|
+
export declare const MAX_TAG_LENGTH = 128;
|
|
20
38
|
/** Default lock TTL in milliseconds (30 seconds). */
|
|
21
39
|
export declare const DEFAULT_LOCK_TTL_MS = 30000;
|
|
22
40
|
/** Default number of retry attempts for lock acquisition. */
|
|
23
41
|
export declare const DEFAULT_LOCK_RETRY_ATTEMPTS = 3;
|
|
24
42
|
/** Default delay between lock retry attempts in milliseconds. */
|
|
25
43
|
export declare const DEFAULT_LOCK_RETRY_DELAY_MS = 100;
|
|
44
|
+
/**
|
|
45
|
+
* Divisor applied to a lock's TTL to derive its heartbeat interval, so a
|
|
46
|
+
* lease is renewed roughly three times per TTL window while the critical
|
|
47
|
+
* section runs.
|
|
48
|
+
*/
|
|
49
|
+
export declare const LOCK_HEARTBEAT_DIVISOR = 3;
|
|
26
50
|
/** Maximum number of latency samples to keep per operation. */
|
|
27
51
|
export declare const MAX_LATENCY_SAMPLES = 1000;
|
|
28
|
-
/**
|
|
52
|
+
/** Maximum number of distinct keys tracked for hot-key metrics. */
|
|
53
|
+
export declare const MAX_TRACKED_KEYS = 1024;
|
|
54
|
+
/** Bucket boundaries for latency histograms (ms). A +Infinity bucket is appended at query time. */
|
|
29
55
|
export declare const LATENCY_BUCKETS: readonly [1, 5, 10, 25, 50, 100, 250, 500, 1000];
|
|
30
56
|
/** Default maximum number of entries for the in-memory adapter. */
|
|
31
57
|
export declare const DEFAULT_MAX_ENTRIES = 10000;
|
|
32
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Default maximum memory budget in bytes (50 MB) for the in-memory adapter.
|
|
60
|
+
* Entry sizes are estimated (see `MemoryCacheAdapter`), so the budget is
|
|
61
|
+
* approximate; it exists to bound worst-case retention, not to be exact.
|
|
62
|
+
*/
|
|
33
63
|
export declare const DEFAULT_MAX_MEMORY_BYTES: number;
|
|
34
|
-
/**
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
64
|
+
/**
|
|
65
|
+
* How often (at most) the in-memory adapter opportunistically purges expired
|
|
66
|
+
* entries on the overwrite path, where no eviction is otherwise triggered.
|
|
67
|
+
*/
|
|
68
|
+
export declare const EXPIRED_PURGE_INTERVAL_MS = 30000;
|
|
69
|
+
/**
|
|
70
|
+
* Maximum number of value nodes visited when estimating an entry's size.
|
|
71
|
+
* Keeps `set` O(1)-ish for large object graphs at the cost of accuracy.
|
|
72
|
+
*/
|
|
73
|
+
export declare const SIZE_ESTIMATE_NODE_BUDGET = 512;
|
|
40
74
|
//# sourceMappingURL=constants.d.ts.map
|