@blamejs/core 0.7.18 → 0.7.19
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 +425 -423
- package/README.md +150 -150
- package/bin/blamejs.js +0 -0
- package/index.js +310 -308
- package/lib/api-key.js +660 -660
- package/lib/api-snapshot.js +338 -338
- package/lib/app-shutdown.js +385 -385
- package/lib/app.js +365 -365
- package/lib/archive.js +250 -250
- package/lib/atomic-file.js +544 -544
- package/lib/audit-chain.js +177 -177
- package/lib/audit-sign.js +344 -344
- package/lib/audit-tools.js +677 -677
- package/lib/audit.js +766 -766
- package/lib/auth/jwt-external.js +365 -0
- package/lib/auth/jwt.js +337 -311
- package/lib/auth/lockout.js +436 -436
- package/lib/auth/oauth.js +721 -721
- package/lib/auth/passkey.js +181 -181
- package/lib/auth/password.js +628 -594
- package/lib/backup/bundle.js +217 -217
- package/lib/backup/crypto.js +176 -176
- package/lib/backup/index.js +515 -515
- package/lib/backup/manifest.js +282 -282
- package/lib/break-glass.js +1338 -1338
- package/lib/bundler.js +441 -441
- package/lib/cache-redis.js +256 -256
- package/lib/cache.js +1206 -1206
- package/lib/canonical-json.js +115 -115
- package/lib/chain-writer.js +234 -234
- package/lib/cli-helpers.js +206 -206
- package/lib/cli.js +2334 -2334
- package/lib/cluster-provider-db.js +317 -317
- package/lib/cluster-storage.js +226 -226
- package/lib/cluster.js +703 -703
- package/lib/config-drift.js +301 -301
- package/lib/consent.js +222 -222
- package/lib/constants.js +191 -191
- package/lib/cookies.js +315 -315
- package/lib/credential-hash.js +322 -322
- package/lib/crypto.js +266 -266
- package/lib/csv.js +275 -275
- package/lib/db-declare-row-policy.js +267 -267
- package/lib/db-declare-view.js +420 -420
- package/lib/db-query.js +406 -406
- package/lib/db-schema.js +319 -319
- package/lib/db.js +1288 -1288
- package/lib/deprecate.js +222 -222
- package/lib/dev.js +335 -335
- package/lib/dual-control.js +473 -473
- package/lib/error-page.js +420 -420
- package/lib/external-db-migrate.js +441 -441
- package/lib/external-db.js +1061 -1061
- package/lib/file-type.js +273 -273
- package/lib/forms.js +422 -422
- package/lib/framework-error.js +293 -293
- package/lib/framework-schema.js +717 -717
- package/lib/handlers.js +350 -350
- package/lib/http-client-cookie-jar.js +508 -508
- package/lib/http-client.js +1195 -1195
- package/lib/i18n.js +878 -878
- package/lib/jobs.js +185 -185
- package/lib/log-stream-cloudwatch.js +369 -369
- package/lib/log-stream-local.js +146 -146
- package/lib/log-stream-otlp-grpc.js +410 -410
- package/lib/log-stream-otlp.js +286 -286
- package/lib/log-stream-syslog.js +302 -302
- package/lib/log-stream-webhook.js +199 -199
- package/lib/log-stream.js +330 -330
- package/lib/log.js +500 -500
- package/lib/mail-bounce.js +528 -528
- package/lib/mail-dkim.js +369 -369
- package/lib/mail.js +981 -981
- package/lib/metrics.js +683 -683
- package/lib/middleware/api-encrypt.js +936 -936
- package/lib/middleware/attach-user.js +157 -157
- package/lib/middleware/bearer-auth.js +152 -0
- package/lib/middleware/body-parser.js +1170 -1170
- package/lib/middleware/bot-guard.js +178 -178
- package/lib/middleware/compression.js +452 -452
- package/lib/middleware/cors.js +314 -314
- package/lib/middleware/csp-nonce.js +348 -348
- package/lib/middleware/csrf-protect.js +316 -316
- package/lib/middleware/db-role-for.js +264 -264
- package/lib/middleware/health.js +392 -392
- package/lib/middleware/index.js +82 -79
- package/lib/middleware/rate-limit.js +358 -358
- package/lib/middleware/request-id.js +61 -61
- package/lib/middleware/request-log.js +168 -168
- package/lib/middleware/require-auth.js +104 -104
- package/lib/middleware/security-headers.js +116 -116
- package/lib/middleware/sse.js +166 -166
- package/lib/migrations.js +383 -383
- package/lib/mtls-ca.js +518 -518
- package/lib/mtls-engine-default.js +481 -481
- package/lib/network-dns.js +632 -632
- package/lib/network-heartbeat.js +290 -290
- package/lib/network-nts.js +574 -574
- package/lib/network-proxy.js +265 -265
- package/lib/network-tls.js +328 -328
- package/lib/network.js +233 -233
- package/lib/notify.js +612 -612
- package/lib/ntp-check.js +229 -229
- package/lib/numeric-bounds.js +111 -111
- package/lib/object-store/azure-blob-bucket-ops.js +349 -349
- package/lib/object-store/azure-blob.js +488 -488
- package/lib/object-store/gcs-bucket-ops.js +351 -351
- package/lib/object-store/gcs.js +519 -519
- package/lib/object-store/http-put.js +153 -153
- package/lib/object-store/index.js +197 -197
- package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
- package/lib/object-store/sigv4.js +903 -903
- package/lib/observability.js +151 -151
- package/lib/otel-export.js +269 -269
- package/lib/pagination.js +464 -464
- package/lib/parsers/index.js +80 -80
- package/lib/parsers/safe-env.js +642 -642
- package/lib/parsers/safe-ini.js +292 -292
- package/lib/parsers/safe-toml.js +784 -784
- package/lib/parsers/safe-xml.js +390 -390
- package/lib/parsers/safe-yaml.js +1015 -1015
- package/lib/permissions.js +708 -708
- package/lib/pqc-agent.js +87 -87
- package/lib/pqc-gate.js +279 -279
- package/lib/protobuf-encoder.js +190 -190
- package/lib/protocol-dispatcher.js +161 -161
- package/lib/pubsub-redis.js +167 -167
- package/lib/pubsub.js +429 -429
- package/lib/queue-local.js +476 -476
- package/lib/queue-redis.js +745 -745
- package/lib/queue-sqs.js +319 -319
- package/lib/queue.js +695 -695
- package/lib/redis-client.js +519 -519
- package/lib/request-helpers.js +340 -340
- package/lib/restore-bundle.js +237 -237
- package/lib/restore-rollback.js +259 -259
- package/lib/restore.js +409 -409
- package/lib/retry.js +376 -376
- package/lib/router.js +748 -748
- package/lib/safe-async.js +735 -735
- package/lib/safe-buffer.js +237 -237
- package/lib/safe-json.js +541 -541
- package/lib/safe-schema.js +1266 -1266
- package/lib/safe-url.js +159 -159
- package/lib/scheduler.js +706 -706
- package/lib/security-assert.js +373 -373
- package/lib/seeders.js +618 -618
- package/lib/session.js +535 -478
- package/lib/slug.js +269 -269
- package/lib/ssrf-guard.js +401 -401
- package/lib/storage.js +471 -471
- package/lib/subject.js +281 -281
- package/lib/template.js +791 -791
- package/lib/testing.js +798 -798
- package/lib/time.js +310 -310
- package/lib/totp.js +302 -302
- package/lib/tracing.js +494 -494
- package/lib/uuid.js +132 -132
- package/lib/validate-opts.js +340 -340
- package/lib/vault/index.js +308 -308
- package/lib/vault/rotate.js +784 -784
- package/lib/vault/wrap.js +296 -296
- package/lib/vendor/noble-ciphers.cjs +9 -9
- package/lib/webhook.js +595 -595
- package/lib/websocket.js +1048 -1048
- package/package.json +77 -77
- package/sbom.cyclonedx.json +7 -7
package/lib/cache.js
CHANGED
|
@@ -1,1206 +1,1206 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* b.cache — operator-facing cache primitive.
|
|
4
|
-
*
|
|
5
|
-
* var cache = b.cache.create({
|
|
6
|
-
* namespace: "session.user",
|
|
7
|
-
* backend: "memory",
|
|
8
|
-
* ttlMs: C.TIME.minutes(5),
|
|
9
|
-
* maxEntries: 10000,
|
|
10
|
-
* maxBytes: C.BYTES.mib(100), // memory backend only
|
|
11
|
-
* sizeOf: function (v) { return v.byteLength; }, // optional override
|
|
12
|
-
* slidingTtl: true, // bump expiresAt on hit
|
|
13
|
-
* audit: b.audit, // optional
|
|
14
|
-
* });
|
|
15
|
-
*
|
|
16
|
-
* await cache.set("u-42", record, { ttlMs: C.TIME.minutes(10), tags: ["user:42", "session"] });
|
|
17
|
-
* var hit = await cache.get("u-42");
|
|
18
|
-
*
|
|
19
|
-
* // Memoize / read-through:
|
|
20
|
-
* var profile = await cache.wrap("u-42", function () {
|
|
21
|
-
* return db.users.findOne({ _id: "u-42" });
|
|
22
|
-
* });
|
|
23
|
-
*
|
|
24
|
-
* // Bulk invalidate (memory backend):
|
|
25
|
-
* await cache.invalidateTag("user:42"); // purges every entry tagged user:42
|
|
26
|
-
*
|
|
27
|
-
* Surface (returned by create):
|
|
28
|
-
*
|
|
29
|
-
* get(key) → value | undefined
|
|
30
|
-
* set(key, value, opts?) → void (opts: { ttlMs, tags })
|
|
31
|
-
* del(key) → boolean (existed)
|
|
32
|
-
* has(key) → boolean (does NOT bump LRU recency)
|
|
33
|
-
* clear(opts?) → number (purged) (opts: { req, context })
|
|
34
|
-
* size() → number
|
|
35
|
-
* bytes() → number (memory backend only — total stored bytes)
|
|
36
|
-
* wrap(key, fn, opts?) → fn's return value (opts: { ttlMs, singleFlight })
|
|
37
|
-
* invalidateTag(tag, opts?) → number (purged) (opts: { req, context })
|
|
38
|
-
* getTags(key) → string[] | null
|
|
39
|
-
* close() → void
|
|
40
|
-
*
|
|
41
|
-
* Backends:
|
|
42
|
-
*
|
|
43
|
-
* "memory" (default) — Map + LRU eviction (maxEntries) + periodic
|
|
44
|
-
* sweep timer (sweepIntervalMs). Single-process accuracy only.
|
|
45
|
-
*
|
|
46
|
-
* "cluster" — _blamejs_cache table via cluster-storage. PRIMARY KEY
|
|
47
|
-
* is "<namespace>:<key>" so one table serves every CacheInstance.
|
|
48
|
-
* UPSERT via ON CONFLICT for atomic set; DELETE WHERE expiresAt
|
|
49
|
-
* for sweep. JSON-only value serialization.
|
|
50
|
-
*
|
|
51
|
-
* { get, set, del, clear, size, close } — operator-supplied custom
|
|
52
|
-
* backend (Redis, Memcached, …). All methods async.
|
|
53
|
-
*
|
|
54
|
-
* Validation policy:
|
|
55
|
-
*
|
|
56
|
-
* - create() opts → throw at boot
|
|
57
|
-
* - get/set/del/has/wrap key arg type → throw at call site (programming bug)
|
|
58
|
-
* - set value type → tolerant (operator decides what to store)
|
|
59
|
-
* - per-call ttlMs override → throw at call site (bad ttl is silent footgun)
|
|
60
|
-
* - audit / observability emit failures → drop silent (hot-path sink)
|
|
61
|
-
* - method called after close() → throw BAD_STATE at call site
|
|
62
|
-
*
|
|
63
|
-
* Security defaults:
|
|
64
|
-
*
|
|
65
|
-
* - auditClear: true — mass purge is operator-action shaped (can hide forensics)
|
|
66
|
-
* - auditFailures: true — backend errors are signal
|
|
67
|
-
* - hot-path get/set/hit/miss/eviction → observability only (audit chain
|
|
68
|
-
* would drown at any reasonable QPS)
|
|
69
|
-
*
|
|
70
|
-
* The cache supports single-flight wrap (concurrent calls collapse),
|
|
71
|
-
* stale-while-revalidate, LRU + bytes eviction on the memory backend,
|
|
72
|
-
* sliding TTL on hit, tag-based bulk invalidation (memory backend), a
|
|
73
|
-
* shared cluster backend, and a custom-backend escape hatch.
|
|
74
|
-
*
|
|
75
|
-
* What is NOT in the box:
|
|
76
|
-
*
|
|
77
|
-
* - maxBytes on the cluster backend — per-row size accounting against
|
|
78
|
-
* a shared table would mean an aggregate query on every set. The
|
|
79
|
-
* operator controls cluster-table size with their own pruning if
|
|
80
|
-
* bytes pressure surfaces.
|
|
81
|
-
* - Per-entry exact slidingTtl on the cluster backend — sliding works
|
|
82
|
-
* on cluster but extends by the cache's defaultTtlMs (we don't
|
|
83
|
-
* store per-row ttl). Operators with mixed-TTL writes wanting
|
|
84
|
-
* strict per-entry sliding use the memory backend or extend at
|
|
85
|
-
* the application layer.
|
|
86
|
-
*/
|
|
87
|
-
|
|
88
|
-
var cacheRedis = require("./cache-redis");
|
|
89
|
-
var redisClient = require("./redis-client");
|
|
90
|
-
var clusterStorage = require("./cluster-storage");
|
|
91
|
-
var C = require("./constants");
|
|
92
|
-
var lazyRequire = require("./lazy-require");
|
|
93
|
-
var { boot } = require("./log");
|
|
94
|
-
var numericChecks = require("./numeric-checks");
|
|
95
|
-
var requestHelpers = require("./request-helpers");
|
|
96
|
-
var safeAsync = require("./safe-async");
|
|
97
|
-
var safeJson = require("./safe-json");
|
|
98
|
-
var validateOpts = require("./validate-opts");
|
|
99
|
-
var { CacheError } = require("./framework-error");
|
|
100
|
-
|
|
101
|
-
var log = boot("cache");
|
|
102
|
-
var observability = lazyRequire(function () { return require("./observability"); });
|
|
103
|
-
|
|
104
|
-
var _err = CacheError.factory;
|
|
105
|
-
|
|
106
|
-
var DEFAULTS = Object.freeze({
|
|
107
|
-
backend: "memory",
|
|
108
|
-
ttlMs: C.TIME.minutes(5),
|
|
109
|
-
maxEntries: C.BYTES.bytes(10000),
|
|
110
|
-
maxBytes: Infinity,
|
|
111
|
-
sweepIntervalMs: C.TIME.minutes(1),
|
|
112
|
-
staleWhileRevalidate: false,
|
|
113
|
-
slidingTtl: false,
|
|
114
|
-
auditFailures: true,
|
|
115
|
-
auditClear: true,
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
// ---- Config-time validation helpers (throw on bad input) ----
|
|
119
|
-
|
|
120
|
-
var _isFiniteNonNegative = numericChecks.isFiniteNonNegative;
|
|
121
|
-
var _isPositiveInt = numericChecks.isPositiveInt;
|
|
122
|
-
|
|
123
|
-
// ttlMs accepts: any non-negative finite number OR Infinity. NaN, negative,
|
|
124
|
-
// or non-number is rejected.
|
|
125
|
-
function _validateTtl(name, value) {
|
|
126
|
-
if (value === Infinity) return;
|
|
127
|
-
if (typeof value !== "number" || isNaN(value) || !isFinite(value) || value < 0) {
|
|
128
|
-
throw _err("BAD_OPT", name + " must be a non-negative finite number or Infinity, got " +
|
|
129
|
-
(typeof value) + " " + JSON.stringify(value));
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function _validateMaxEntries(value) {
|
|
134
|
-
if (value === Infinity) return;
|
|
135
|
-
if (!_isPositiveInt(value)) {
|
|
136
|
-
throw _err("BAD_OPT", "cache.create: maxEntries must be a positive integer or Infinity, got " +
|
|
137
|
-
JSON.stringify(value));
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function _validateMaxBytes(value) {
|
|
142
|
-
if (value === Infinity) return;
|
|
143
|
-
if (!_isFiniteNonNegative(value) || value < 1) {
|
|
144
|
-
throw _err("BAD_OPT", "cache.create: maxBytes must be a positive finite number or Infinity, got " +
|
|
145
|
-
JSON.stringify(value));
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Default sizeOf — best-effort byte estimate. Operators with structured
|
|
150
|
-
// values (large objects, custom classes) should pass their own sizeOf
|
|
151
|
-
// for accuracy.
|
|
152
|
-
function _defaultSizeOf(value) {
|
|
153
|
-
if (value === null || value === undefined) return 0;
|
|
154
|
-
if (Buffer.isBuffer(value)) return value.length;
|
|
155
|
-
if (typeof value === "string") return Buffer.byteLength(value, "utf8");
|
|
156
|
-
if (typeof value === "number" || typeof value === "boolean") return C.BYTES.bytes(8);
|
|
157
|
-
// Fallback: round-trip through JSON. Cost is real; documented in the
|
|
158
|
-
// DEFAULTS docstring so operators with hot-path size accounting know
|
|
159
|
-
// to supply their own sizeOf.
|
|
160
|
-
try { return Buffer.byteLength(JSON.stringify(value), "utf8"); }
|
|
161
|
-
catch (_e) { return 0; }
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function _validateBackendObject(backend) {
|
|
165
|
-
var required = ["get", "set", "del", "clear", "size", "close"];
|
|
166
|
-
if (typeof backend !== "object" || backend === null) {
|
|
167
|
-
throw _err("BAD_OPT", "cache.create: custom backend must be an object");
|
|
168
|
-
}
|
|
169
|
-
for (var i = 0; i < required.length; i++) {
|
|
170
|
-
if (typeof backend[required[i]] !== "function") {
|
|
171
|
-
throw _err("BAD_OPT", "cache.create: custom backend missing method '" + required[i] +
|
|
172
|
-
"' (required: " + required.join(", ") + ")");
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function _validateCreateOpts(opts) {
|
|
178
|
-
validateOpts.requireObject(opts, "cache.create", CacheError);
|
|
179
|
-
validateOpts.requireNonEmptyString(opts.namespace, "cache.create: namespace", CacheError, "BAD_OPT");
|
|
180
|
-
// Composite cluster-key separator is ":" — namespace must not contain it
|
|
181
|
-
// or two namespaces could collide ("a:b" + "c" = "a:b:c" = "a" + "b:c").
|
|
182
|
-
if (opts.namespace.indexOf(":") !== -1) {
|
|
183
|
-
throw _err("BAD_OPT", "cache.create: namespace must not contain ':' (used as cluster-key separator), got " +
|
|
184
|
-
JSON.stringify(opts.namespace));
|
|
185
|
-
}
|
|
186
|
-
if (opts.backend !== undefined) {
|
|
187
|
-
if (typeof opts.backend === "string") {
|
|
188
|
-
if (opts.backend !== "memory" && opts.backend !== "cluster" && opts.backend !== "redis") {
|
|
189
|
-
throw _err("BAD_OPT", "cache.create: backend string must be 'memory' | 'cluster' | 'redis', got " +
|
|
190
|
-
JSON.stringify(opts.backend));
|
|
191
|
-
}
|
|
192
|
-
if (opts.backend === "redis") {
|
|
193
|
-
if (typeof opts.redisUrl !== "string" || opts.redisUrl.length === 0) {
|
|
194
|
-
throw _err("BAD_OPT", "cache.create: backend='redis' requires opts.redisUrl (e.g. redis://localhost:6379/0)");
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
} else {
|
|
198
|
-
_validateBackendObject(opts.backend);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
if (opts.ttlMs !== undefined) _validateTtl("cache.create: ttlMs", opts.ttlMs);
|
|
202
|
-
if (opts.maxEntries !== undefined) _validateMaxEntries(opts.maxEntries);
|
|
203
|
-
if (opts.maxBytes !== undefined) _validateMaxBytes(opts.maxBytes);
|
|
204
|
-
validateOpts.optionalFunction(opts.sizeOf, "cache.create: sizeOf", CacheError);
|
|
205
|
-
validateOpts.optionalBoolean(opts.slidingTtl, "cache.create: slidingTtl", CacheError);
|
|
206
|
-
if (opts.sweepIntervalMs !== undefined) {
|
|
207
|
-
validateOpts.optionalFiniteNonNegative(opts.sweepIntervalMs, "cache.create: sweepIntervalMs", CacheError);
|
|
208
|
-
if (opts.sweepIntervalMs < C.TIME.seconds(1)) {
|
|
209
|
-
throw _err("BAD_OPT", "cache.create: sweepIntervalMs must be >= 1000ms, got " +
|
|
210
|
-
JSON.stringify(opts.sweepIntervalMs));
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
validateOpts.optionalBoolean(opts.staleWhileRevalidate, "cache.create: staleWhileRevalidate", CacheError);
|
|
214
|
-
validateOpts.optionalBoolean(opts.auditFailures, "cache.create: auditFailures", CacheError);
|
|
215
|
-
validateOpts.optionalBoolean(opts.auditClear, "cache.create: auditClear", CacheError);
|
|
216
|
-
validateOpts.auditShape(opts.audit, "cache.create", CacheError);
|
|
217
|
-
validateOpts.observabilityShape(opts.observability, "cache.create", CacheError);
|
|
218
|
-
validateOpts.optionalFunction(opts.clock, "cache.create: clock", CacheError);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function _validateKey(key, ctx) {
|
|
222
|
-
if (typeof key !== "string" || key.length === 0) {
|
|
223
|
-
throw _err("BAD_KEY", ctx + ": key must be a non-empty string, got " +
|
|
224
|
-
(typeof key) + " " + JSON.stringify(key));
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// ---- Memory backend ----
|
|
229
|
-
// LRU realized by Map insertion order (Node Map iterates in insertion order;
|
|
230
|
-
// re-inserting a key on hit moves it to the most-recent position).
|
|
231
|
-
|
|
232
|
-
function _memoryBackend(cfg) {
|
|
233
|
-
var entries = new Map(); // key → { value, expiresAt, ttlMs, bytes, tags }
|
|
234
|
-
var maxEntries = cfg.maxEntries;
|
|
235
|
-
var maxBytes = cfg.maxBytes;
|
|
236
|
-
var sizeOf = cfg.sizeOf;
|
|
237
|
-
var slidingTtl = cfg.slidingTtl;
|
|
238
|
-
var clock = cfg.clock;
|
|
239
|
-
var emitObs = cfg.emitObs;
|
|
240
|
-
var namespace = cfg.namespace;
|
|
241
|
-
var sweepTimer = null;
|
|
242
|
-
var totalBytes = 0;
|
|
243
|
-
|
|
244
|
-
// tag → Set<key>. Bidirectional with entry.tags for fast invalidate.
|
|
245
|
-
var tagIndex = new Map();
|
|
246
|
-
|
|
247
|
-
function _isExpired(entry, now) {
|
|
248
|
-
return entry.expiresAt !== Infinity && entry.expiresAt <= now;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
function _untrack(key, entry) {
|
|
252
|
-
if (!entry) return;
|
|
253
|
-
totalBytes -= entry.bytes || 0;
|
|
254
|
-
if (totalBytes < 0) totalBytes = 0;
|
|
255
|
-
if (entry.tags && entry.tags.length > 0) {
|
|
256
|
-
for (var i = 0; i < entry.tags.length; i++) {
|
|
257
|
-
var s = tagIndex.get(entry.tags[i]);
|
|
258
|
-
if (s) {
|
|
259
|
-
s.delete(key);
|
|
260
|
-
if (s.size === 0) tagIndex.delete(entry.tags[i]);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
function _evictByCounts() {
|
|
267
|
-
while (maxEntries !== Infinity && entries.size > maxEntries) {
|
|
268
|
-
var oldest = entries.keys().next().value;
|
|
269
|
-
var e = entries.get(oldest);
|
|
270
|
-
_untrack(oldest, e);
|
|
271
|
-
entries.delete(oldest);
|
|
272
|
-
emitObs("cache.eviction.size", { namespace: namespace });
|
|
273
|
-
}
|
|
274
|
-
while (maxBytes !== Infinity && totalBytes > maxBytes && entries.size > 0) {
|
|
275
|
-
var oldestB = entries.keys().next().value;
|
|
276
|
-
var eb = entries.get(oldestB);
|
|
277
|
-
_untrack(oldestB, eb);
|
|
278
|
-
entries.delete(oldestB);
|
|
279
|
-
emitObs("cache.eviction.bytes", { namespace: namespace });
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
async function get(key) {
|
|
284
|
-
var now = clock();
|
|
285
|
-
var entry = entries.get(key);
|
|
286
|
-
if (!entry) return undefined;
|
|
287
|
-
if (_isExpired(entry, now)) {
|
|
288
|
-
_untrack(key, entry);
|
|
289
|
-
entries.delete(key);
|
|
290
|
-
emitObs("cache.eviction.expired", { namespace: namespace });
|
|
291
|
-
return undefined;
|
|
292
|
-
}
|
|
293
|
-
// Sliding TTL: extend lifetime on each successful read by the
|
|
294
|
-
// entry's original ttlMs. Infinity stays Infinity.
|
|
295
|
-
if (slidingTtl && entry.ttlMs !== Infinity && typeof entry.ttlMs === "number") {
|
|
296
|
-
entry.expiresAt = now + entry.ttlMs;
|
|
297
|
-
}
|
|
298
|
-
// LRU recency bump: re-insert moves to the most-recent slot.
|
|
299
|
-
entries.delete(key);
|
|
300
|
-
entries.set(key, entry);
|
|
301
|
-
return entry.value;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
async function set(key, value, expiresAt, meta) {
|
|
305
|
-
// Existing key replacement: untrack first to rebalance bytes + tags.
|
|
306
|
-
var prior = entries.get(key);
|
|
307
|
-
if (prior) {
|
|
308
|
-
_untrack(key, prior);
|
|
309
|
-
entries.delete(key);
|
|
310
|
-
}
|
|
311
|
-
var bytes = sizeOf(value) || 0;
|
|
312
|
-
var ttlMs = meta && typeof meta.ttlMs === "number" ? meta.ttlMs : null;
|
|
313
|
-
var tags = (meta && Array.isArray(meta.tags)) ? meta.tags.slice() : null;
|
|
314
|
-
entries.set(key, {
|
|
315
|
-
value: value,
|
|
316
|
-
expiresAt: expiresAt,
|
|
317
|
-
ttlMs: ttlMs,
|
|
318
|
-
bytes: bytes,
|
|
319
|
-
tags: tags,
|
|
320
|
-
});
|
|
321
|
-
totalBytes += bytes;
|
|
322
|
-
if (tags && tags.length > 0) {
|
|
323
|
-
for (var i = 0; i < tags.length; i++) {
|
|
324
|
-
var s = tagIndex.get(tags[i]);
|
|
325
|
-
if (!s) { s = new Set(); tagIndex.set(tags[i], s); }
|
|
326
|
-
s.add(key);
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
_evictByCounts();
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
async function del(key) {
|
|
333
|
-
var entry = entries.get(key);
|
|
334
|
-
if (!entry) return false;
|
|
335
|
-
_untrack(key, entry);
|
|
336
|
-
entries.delete(key);
|
|
337
|
-
return true;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
async function has(key) {
|
|
341
|
-
var entry = entries.get(key);
|
|
342
|
-
if (!entry) return false;
|
|
343
|
-
if (_isExpired(entry, clock())) {
|
|
344
|
-
_untrack(key, entry);
|
|
345
|
-
entries.delete(key);
|
|
346
|
-
emitObs("cache.eviction.expired", { namespace: namespace });
|
|
347
|
-
return false;
|
|
348
|
-
}
|
|
349
|
-
return true;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
async function clear() {
|
|
353
|
-
var n = entries.size;
|
|
354
|
-
entries.clear();
|
|
355
|
-
tagIndex.clear();
|
|
356
|
-
totalBytes = 0;
|
|
357
|
-
return n;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
async function size() {
|
|
361
|
-
// Lazy purge: count only non-expired so size() reflects "live" entries.
|
|
362
|
-
var now = clock();
|
|
363
|
-
var live = 0;
|
|
364
|
-
for (var entry of entries.values()) {
|
|
365
|
-
if (!_isExpired(entry, now)) live++;
|
|
366
|
-
}
|
|
367
|
-
return live;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
async function invalidateTag(tag) {
|
|
371
|
-
var keys = tagIndex.get(tag);
|
|
372
|
-
if (!keys || keys.size === 0) return 0;
|
|
373
|
-
var purged = 0;
|
|
374
|
-
var toDelete = Array.from(keys);
|
|
375
|
-
for (var i = 0; i < toDelete.length; i++) {
|
|
376
|
-
var k = toDelete[i];
|
|
377
|
-
var entry = entries.get(k);
|
|
378
|
-
if (entry) {
|
|
379
|
-
_untrack(k, entry);
|
|
380
|
-
entries.delete(k);
|
|
381
|
-
purged++;
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
return purged;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
async function getTags(key) {
|
|
388
|
-
var entry = entries.get(key);
|
|
389
|
-
if (!entry) return null;
|
|
390
|
-
return entry.tags ? entry.tags.slice() : [];
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
async function bytes() {
|
|
394
|
-
return totalBytes;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
function _sweep() {
|
|
398
|
-
var now = clock();
|
|
399
|
-
var purged = 0;
|
|
400
|
-
for (var k of Array.from(entries.keys())) {
|
|
401
|
-
var e = entries.get(k);
|
|
402
|
-
if (_isExpired(e, now)) {
|
|
403
|
-
_untrack(k, e);
|
|
404
|
-
entries.delete(k);
|
|
405
|
-
purged++;
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
if (purged > 0) {
|
|
409
|
-
for (var i = 0; i < purged; i++) emitObs("cache.eviction.expired", { namespace: namespace });
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
function _startSweep(intervalMs) {
|
|
414
|
-
if (sweepTimer) return;
|
|
415
|
-
sweepTimer = safeAsync.repeating(_sweep, intervalMs, { name: "cache-sweep" });
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
async function close() {
|
|
419
|
-
if (sweepTimer) { sweepTimer.stop(); sweepTimer = null; }
|
|
420
|
-
entries.clear();
|
|
421
|
-
tagIndex.clear();
|
|
422
|
-
totalBytes = 0;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
return {
|
|
426
|
-
name: "memory",
|
|
427
|
-
get: get,
|
|
428
|
-
set: set,
|
|
429
|
-
del: del,
|
|
430
|
-
has: has,
|
|
431
|
-
clear: clear,
|
|
432
|
-
size: size,
|
|
433
|
-
bytes: bytes,
|
|
434
|
-
invalidateTag: invalidateTag,
|
|
435
|
-
getTags: getTags,
|
|
436
|
-
close: close,
|
|
437
|
-
_startSweep: _startSweep,
|
|
438
|
-
// Test hook: raw entries map for state inspection
|
|
439
|
-
_entries: entries,
|
|
440
|
-
};
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
// ---- Cluster backend ----
|
|
444
|
-
// Single _blamejs_cache table; cacheKey = "<namespace>:<key>". JSON-only
|
|
445
|
-
// value serialization. UPSERT via ON CONFLICT for atomic set.
|
|
446
|
-
|
|
447
|
-
function _clusterBackend(cfg) {
|
|
448
|
-
var namespace = cfg.namespace;
|
|
449
|
-
var clock = cfg.clock;
|
|
450
|
-
var emitObs = cfg.emitObs;
|
|
451
|
-
var slidingTtl = cfg.slidingTtl;
|
|
452
|
-
var defaultTtlMs = cfg.defaultTtlMs;
|
|
453
|
-
|
|
454
|
-
// Composite cluster key. Namespace was validated to not contain ":"
|
|
455
|
-
// at create time, so the split is unambiguous.
|
|
456
|
-
function _composedKey(key) { return namespace + ":" + key; }
|
|
457
|
-
|
|
458
|
-
async function get(key) {
|
|
459
|
-
var now = clock();
|
|
460
|
-
var result = await clusterStorage.execute(
|
|
461
|
-
"SELECT valueJson, expiresAt FROM _blamejs_cache WHERE cacheKey = ?",
|
|
462
|
-
[_composedKey(key)]
|
|
463
|
-
);
|
|
464
|
-
if (!result || !result.rows || result.rows.length === 0) return undefined;
|
|
465
|
-
var row = result.rows[0];
|
|
466
|
-
if (row.expiresAt <= now) {
|
|
467
|
-
// Lazy purge: opportunistic delete on stale read.
|
|
468
|
-
try {
|
|
469
|
-
await clusterStorage.execute(
|
|
470
|
-
"DELETE FROM _blamejs_cache WHERE cacheKey = ? AND expiresAt <= ?",
|
|
471
|
-
[_composedKey(key), now]
|
|
472
|
-
);
|
|
473
|
-
} catch (_e) { /* sweeper will catch it next pass */ }
|
|
474
|
-
emitObs("cache.eviction.expired", { namespace: namespace });
|
|
475
|
-
return undefined;
|
|
476
|
-
}
|
|
477
|
-
// Sliding TTL on cluster: extend by the cache's defaultTtlMs (we don't
|
|
478
|
-
// store per-row ttl). Operators with mixed-TTL writes wanting strict
|
|
479
|
-
// per-entry sliding use the memory backend or extend at app layer.
|
|
480
|
-
// Fire-and-forget — best-effort lifetime extension.
|
|
481
|
-
if (slidingTtl && defaultTtlMs !== Infinity && typeof defaultTtlMs === "number") {
|
|
482
|
-
var newExpires = now + defaultTtlMs;
|
|
483
|
-
clusterStorage.execute(
|
|
484
|
-
"UPDATE _blamejs_cache SET expiresAt = ?, updatedAt = ? " +
|
|
485
|
-
"WHERE cacheKey = ? AND expiresAt > ?",
|
|
486
|
-
[newExpires, now, _composedKey(key), now]
|
|
487
|
-
).catch(function () { /* best-effort */ });
|
|
488
|
-
}
|
|
489
|
-
try { return safeJson.parse(row.valueJson, { maxBytes: C.BYTES.mib(64) }); }
|
|
490
|
-
catch (_e) { return undefined; }
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
async function set(key, value, expiresAt, meta) {
|
|
494
|
-
var json = JSON.stringify(value);
|
|
495
|
-
var storedExpires = (expiresAt === Infinity) ? Number.MAX_SAFE_INTEGER : expiresAt;
|
|
496
|
-
var now = clock();
|
|
497
|
-
var ck = _composedKey(key);
|
|
498
|
-
// SQLite + Postgres both honor ON CONFLICT (cacheKey) DO UPDATE.
|
|
499
|
-
await clusterStorage.execute(
|
|
500
|
-
"INSERT INTO _blamejs_cache (cacheKey, valueJson, expiresAt, updatedAt) " +
|
|
501
|
-
"VALUES (?, ?, ?, ?) " +
|
|
502
|
-
"ON CONFLICT (cacheKey) DO UPDATE SET " +
|
|
503
|
-
"valueJson = ?, expiresAt = ?, updatedAt = ?",
|
|
504
|
-
[ck, json, storedExpires, now, json, storedExpires, now]
|
|
505
|
-
);
|
|
506
|
-
// Tag handling: drop any prior tags for this key (tags can change
|
|
507
|
-
// across sets), then INSERT the new ones. The PRIMARY KEY on
|
|
508
|
-
// (cacheKey, tag) makes the INSERT idempotent if duplicate tags
|
|
509
|
-
// sneak in.
|
|
510
|
-
var tags = meta && Array.isArray(meta.tags) ? meta.tags : null;
|
|
511
|
-
await clusterStorage.execute(
|
|
512
|
-
"DELETE FROM _blamejs_cache_tags WHERE cacheKey = ?",
|
|
513
|
-
[ck]
|
|
514
|
-
);
|
|
515
|
-
if (tags && tags.length > 0) {
|
|
516
|
-
for (var i = 0; i < tags.length; i++) {
|
|
517
|
-
await clusterStorage.execute(
|
|
518
|
-
"INSERT INTO _blamejs_cache_tags (cacheKey, tag) VALUES (?, ?) " +
|
|
519
|
-
"ON CONFLICT (cacheKey, tag) DO NOTHING",
|
|
520
|
-
[ck, tags[i]]
|
|
521
|
-
);
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
async function del(key) {
|
|
527
|
-
var ck = _composedKey(key);
|
|
528
|
-
var result = await clusterStorage.execute(
|
|
529
|
-
"DELETE FROM _blamejs_cache WHERE cacheKey = ?",
|
|
530
|
-
[ck]
|
|
531
|
-
);
|
|
532
|
-
// Drop any matching tag rows. Best-effort: a stale tag row pointing
|
|
533
|
-
// at a non-existent cacheKey is dropped on the next invalidateTag
|
|
534
|
-
// sweep (by the JOIN-shape DELETE) anyway.
|
|
535
|
-
await clusterStorage.execute(
|
|
536
|
-
"DELETE FROM _blamejs_cache_tags WHERE cacheKey = ?",
|
|
537
|
-
[ck]
|
|
538
|
-
).catch(function () { /* best-effort */ });
|
|
539
|
-
return !!(result && result.rowCount && result.rowCount > 0);
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
async function invalidateTag(tag) {
|
|
543
|
-
// Find every cacheKey carrying the tag (namespace-scoped via the LIKE
|
|
544
|
-
// on the composed key), delete from the cache table + the junction.
|
|
545
|
-
var like = namespace + ":%";
|
|
546
|
-
var keysResult = await clusterStorage.execute(
|
|
547
|
-
"SELECT cacheKey FROM _blamejs_cache_tags WHERE tag = ? AND cacheKey LIKE ?",
|
|
548
|
-
[tag, like]
|
|
549
|
-
);
|
|
550
|
-
var keys = (keysResult && keysResult.rows) || [];
|
|
551
|
-
if (keys.length === 0) {
|
|
552
|
-
// Nothing to invalidate; still drop any orphan tag rows for
|
|
553
|
-
// this tag scoped to our namespace.
|
|
554
|
-
await clusterStorage.execute(
|
|
555
|
-
"DELETE FROM _blamejs_cache_tags WHERE tag = ? AND cacheKey LIKE ?",
|
|
556
|
-
[tag, like]
|
|
557
|
-
);
|
|
558
|
-
return 0;
|
|
559
|
-
}
|
|
560
|
-
var purged = 0;
|
|
561
|
-
for (var i = 0; i < keys.length; i++) {
|
|
562
|
-
var ck = keys[i].cacheKey;
|
|
563
|
-
var r = await clusterStorage.execute(
|
|
564
|
-
"DELETE FROM _blamejs_cache WHERE cacheKey = ?",
|
|
565
|
-
[ck]
|
|
566
|
-
);
|
|
567
|
-
if (r && r.rowCount > 0) purged += r.rowCount;
|
|
568
|
-
await clusterStorage.execute(
|
|
569
|
-
"DELETE FROM _blamejs_cache_tags WHERE cacheKey = ?",
|
|
570
|
-
[ck]
|
|
571
|
-
);
|
|
572
|
-
}
|
|
573
|
-
return purged;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
async function getTags(key) {
|
|
577
|
-
var result = await clusterStorage.execute(
|
|
578
|
-
"SELECT tag FROM _blamejs_cache_tags WHERE cacheKey = ?",
|
|
579
|
-
[_composedKey(key)]
|
|
580
|
-
);
|
|
581
|
-
if (!result || !result.rows) return [];
|
|
582
|
-
return result.rows.map(function (r) { return r.tag; });
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
async function has(key) {
|
|
586
|
-
// Existence check without recency bump — cluster backend doesn't
|
|
587
|
-
// track LRU at all, so "without bumping" is automatic. Honors
|
|
588
|
-
// expiresAt the same as get().
|
|
589
|
-
var now = clock();
|
|
590
|
-
var result = await clusterStorage.execute(
|
|
591
|
-
"SELECT expiresAt FROM _blamejs_cache WHERE cacheKey = ? AND expiresAt > ?",
|
|
592
|
-
[_composedKey(key), now]
|
|
593
|
-
);
|
|
594
|
-
return !!(result && result.rows && result.rows.length > 0);
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
async function clear() {
|
|
598
|
-
// Namespace-scoped wipe so two CacheInstance instances sharing the
|
|
599
|
-
// table don't cross-purge each other.
|
|
600
|
-
var like = namespace + ":%";
|
|
601
|
-
var result = await clusterStorage.execute(
|
|
602
|
-
"DELETE FROM _blamejs_cache WHERE cacheKey LIKE ?",
|
|
603
|
-
[like]
|
|
604
|
-
);
|
|
605
|
-
// Drop matching tag rows in the same namespace.
|
|
606
|
-
await clusterStorage.execute(
|
|
607
|
-
"DELETE FROM _blamejs_cache_tags WHERE cacheKey LIKE ?",
|
|
608
|
-
[like]
|
|
609
|
-
).catch(function () { /* best-effort */ });
|
|
610
|
-
return (result && result.rowCount) || 0;
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
async function size() {
|
|
614
|
-
var now = clock();
|
|
615
|
-
var like = namespace + ":%";
|
|
616
|
-
var result = await clusterStorage.execute(
|
|
617
|
-
"SELECT COUNT(*) AS n FROM _blamejs_cache WHERE cacheKey LIKE ? AND expiresAt > ?",
|
|
618
|
-
[like, now]
|
|
619
|
-
);
|
|
620
|
-
if (!result || !result.rows || result.rows.length === 0) return 0;
|
|
621
|
-
return result.rows[0].n || 0;
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
async function _sweep() {
|
|
625
|
-
var now = clock();
|
|
626
|
-
var like = namespace + ":%";
|
|
627
|
-
// Capture the to-be-purged keys first so we can drop matching tag
|
|
628
|
-
// rows in the same sweep — keeps the junction table free of orphans
|
|
629
|
-
// pointing at expired cacheKeys.
|
|
630
|
-
var expiredResult = await clusterStorage.execute(
|
|
631
|
-
"SELECT cacheKey FROM _blamejs_cache WHERE cacheKey LIKE ? AND expiresAt <= ?",
|
|
632
|
-
[like, now]
|
|
633
|
-
);
|
|
634
|
-
var expiredKeys = (expiredResult && expiredResult.rows) || [];
|
|
635
|
-
await clusterStorage.execute(
|
|
636
|
-
"DELETE FROM _blamejs_cache WHERE cacheKey LIKE ? AND expiresAt <= ?",
|
|
637
|
-
[like, now]
|
|
638
|
-
);
|
|
639
|
-
for (var i = 0; i < expiredKeys.length; i++) {
|
|
640
|
-
await clusterStorage.execute(
|
|
641
|
-
"DELETE FROM _blamejs_cache_tags WHERE cacheKey = ?",
|
|
642
|
-
[expiredKeys[i].cacheKey]
|
|
643
|
-
).catch(function () { /* best-effort */ });
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
function _startSweep(intervalMs) {
|
|
648
|
-
cfg._sweepTimer = safeAsync.repeating(_sweep, intervalMs, { name: "cache-sweep-cluster" });
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
async function close() {
|
|
652
|
-
if (cfg._sweepTimer) { cfg._sweepTimer.stop(); cfg._sweepTimer = null; }
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
return {
|
|
656
|
-
name: "cluster",
|
|
657
|
-
get: get,
|
|
658
|
-
set: set,
|
|
659
|
-
del: del,
|
|
660
|
-
has: has,
|
|
661
|
-
clear: clear,
|
|
662
|
-
size: size,
|
|
663
|
-
close: close,
|
|
664
|
-
invalidateTag: invalidateTag,
|
|
665
|
-
getTags: getTags,
|
|
666
|
-
_startSweep: _startSweep,
|
|
667
|
-
};
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
// ---- Custom backend wrapper ----
|
|
671
|
-
// Operator-supplied { get, set, del, clear, size, close } — wrap to
|
|
672
|
-
// uniform-shape (no _startSweep, _entries). The operator is responsible
|
|
673
|
-
// for their own expiration; we pass expiresAt to set().
|
|
674
|
-
|
|
675
|
-
function _customBackend(operatorBackend, cfg) {
|
|
676
|
-
return {
|
|
677
|
-
name: "custom",
|
|
678
|
-
get: function (key) { return operatorBackend.get(key); },
|
|
679
|
-
set: function (key, value, expiresAt, meta) {
|
|
680
|
-
// Older 3-arg backends remain compatible — meta is opt-in.
|
|
681
|
-
return operatorBackend.set(key, value, expiresAt, meta);
|
|
682
|
-
},
|
|
683
|
-
del: function (key) { return operatorBackend.del(key); },
|
|
684
|
-
has: function (key) {
|
|
685
|
-
// Optional has() — fall back to get-and-coerce if operator didn't
|
|
686
|
-
// implement it.
|
|
687
|
-
if (typeof operatorBackend.has === "function") return operatorBackend.has(key);
|
|
688
|
-
return Promise.resolve(operatorBackend.get(key)).then(function (v) { return v !== undefined; });
|
|
689
|
-
},
|
|
690
|
-
clear: function () { return operatorBackend.clear(); },
|
|
691
|
-
size: function () { return operatorBackend.size(); },
|
|
692
|
-
bytes: function () {
|
|
693
|
-
if (typeof operatorBackend.bytes === "function") return operatorBackend.bytes();
|
|
694
|
-
return Promise.resolve(0);
|
|
695
|
-
},
|
|
696
|
-
invalidateTag: function (tag) {
|
|
697
|
-
if (typeof operatorBackend.invalidateTag === "function") return operatorBackend.invalidateTag(tag);
|
|
698
|
-
return Promise.resolve(0);
|
|
699
|
-
},
|
|
700
|
-
getTags: function (key) {
|
|
701
|
-
if (typeof operatorBackend.getTags === "function") return operatorBackend.getTags(key);
|
|
702
|
-
return Promise.resolve(null);
|
|
703
|
-
},
|
|
704
|
-
close: function () { return operatorBackend.close(); },
|
|
705
|
-
_startSweep: function () { /* operator backend manages its own sweep */ },
|
|
706
|
-
};
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
// ---- Public create ----
|
|
710
|
-
|
|
711
|
-
function create(opts) {
|
|
712
|
-
opts = opts || {};
|
|
713
|
-
validateOpts(opts, [
|
|
714
|
-
"namespace", "backend", "ttlMs", "maxEntries", "maxBytes", "sizeOf",
|
|
715
|
-
"sweepIntervalMs", "staleWhileRevalidate", "slidingTtl",
|
|
716
|
-
"auditFailures", "auditClear",
|
|
717
|
-
"audit", "observability", "clock",
|
|
718
|
-
// backend === "redis" connection options. Ignored for memory /
|
|
719
|
-
// cluster / custom-backend modes.
|
|
720
|
-
"redisUrl", "redisPassword", "redisUsername", "redisTls", "redisCa",
|
|
721
|
-
"redisServername", "redisConnectTimeoutMs", "redisCommandTimeoutMs",
|
|
722
|
-
"redisMaxReconnectAttempts",
|
|
723
|
-
// Cross-node invalidation: when set, every successful
|
|
724
|
-
// del/clear/invalidateTag publishes an event on the supplied
|
|
725
|
-
// pubsub instance. Other cache instances on other nodes (or in
|
|
726
|
-
// other processes sharing the pubsub backend) react locally —
|
|
727
|
-
// mostly useful for the memory backend so stale per-node entries
|
|
728
|
-
// don't survive a global tag wipe. The cluster + redis backends
|
|
729
|
-
// are coherent by virtue of their shared store, but a hot
|
|
730
|
-
// memory-tier on top of either still benefits.
|
|
731
|
-
"invalidationPubsub",
|
|
732
|
-
], "cache");
|
|
733
|
-
_validateCreateOpts(opts);
|
|
734
|
-
var cfg0 = validateOpts.applyDefaults(opts, DEFAULTS);
|
|
735
|
-
|
|
736
|
-
var namespace = opts.namespace;
|
|
737
|
-
var backendKind = cfg0.backend;
|
|
738
|
-
var defaultTtlMs = cfg0.ttlMs;
|
|
739
|
-
var maxEntries = cfg0.maxEntries;
|
|
740
|
-
var maxBytes = cfg0.maxBytes;
|
|
741
|
-
var sizeOf = (typeof opts.sizeOf === "function") ? opts.sizeOf : _defaultSizeOf;
|
|
742
|
-
var sweepIntervalMs = cfg0.sweepIntervalMs;
|
|
743
|
-
var staleRevalidate = cfg0.staleWhileRevalidate;
|
|
744
|
-
var slidingTtl = cfg0.slidingTtl;
|
|
745
|
-
var auditFailures = cfg0.auditFailures;
|
|
746
|
-
var auditClear = cfg0.auditClear;
|
|
747
|
-
var audit = opts.audit || null;
|
|
748
|
-
var operatorObs = opts.observability || null;
|
|
749
|
-
var clock = opts.clock || function () { return Date.now(); };
|
|
750
|
-
var invalidationPubsub = opts.invalidationPubsub || null;
|
|
751
|
-
if (invalidationPubsub && (
|
|
752
|
-
typeof invalidationPubsub.publish !== "function" ||
|
|
753
|
-
typeof invalidationPubsub.subscribe !== "function" ||
|
|
754
|
-
typeof invalidationPubsub.unsubscribe !== "function")) {
|
|
755
|
-
throw _err("BAD_OPT",
|
|
756
|
-
"cache.create: invalidationPubsub must implement { publish, subscribe, unsubscribe } (b.pubsub.create instance)");
|
|
757
|
-
}
|
|
758
|
-
var invalidationChannel = "cache:" + namespace + ":invalidate";
|
|
759
|
-
var invalidationToken = null;
|
|
760
|
-
// Re-entrancy guard — when we receive an invalidation event from
|
|
761
|
-
// another node we MUST NOT re-publish it (infinite fan-out loop).
|
|
762
|
-
var inboundInvalidation = false;
|
|
763
|
-
|
|
764
|
-
function emitObs(name, labels) {
|
|
765
|
-
try {
|
|
766
|
-
if (operatorObs) operatorObs.event(name, 1, labels || {});
|
|
767
|
-
else observability().event(name, 1, labels || {});
|
|
768
|
-
} catch (_e) { /* hot-path observability sink — drops silent on internal throws */ }
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
var emitAudit = validateOpts.makeAuditEmitter(audit);
|
|
772
|
-
|
|
773
|
-
function _actor(callerOpts) {
|
|
774
|
-
return requestHelpers.resolveActorWithOverride(callerOpts);
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
function _backendFailedAudit(op, err) {
|
|
778
|
-
if (!auditFailures) return;
|
|
779
|
-
emitAudit("cache.backend.failed", {
|
|
780
|
-
actor: requestHelpers.extractActorContext(null),
|
|
781
|
-
resource: { kind: "cache", id: namespace },
|
|
782
|
-
outcome: "failure",
|
|
783
|
-
reason: "backend-error",
|
|
784
|
-
metadata: { op: op, code: (err && err.code) || null, message: (err && err.message) || String(err) },
|
|
785
|
-
});
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
// Resolve backend
|
|
789
|
-
var cfg = {
|
|
790
|
-
namespace: namespace,
|
|
791
|
-
maxEntries: maxEntries,
|
|
792
|
-
maxBytes: maxBytes,
|
|
793
|
-
sizeOf: sizeOf,
|
|
794
|
-
slidingTtl: slidingTtl,
|
|
795
|
-
defaultTtlMs: defaultTtlMs,
|
|
796
|
-
clock: clock,
|
|
797
|
-
emitObs: emitObs,
|
|
798
|
-
_sweepTimer: null,
|
|
799
|
-
};
|
|
800
|
-
var backend;
|
|
801
|
-
if (backendKind === "memory") {
|
|
802
|
-
backend = _memoryBackend(cfg);
|
|
803
|
-
} else if (backendKind === "cluster") {
|
|
804
|
-
backend = _clusterBackend(cfg);
|
|
805
|
-
} else if (backendKind === "redis") {
|
|
806
|
-
backend = _customBackend(cacheRedis.create(Object.assign(
|
|
807
|
-
redisClient.pickClientOpts(opts, "redis"),
|
|
808
|
-
{
|
|
809
|
-
namespace: namespace,
|
|
810
|
-
slidingTtl: slidingTtl,
|
|
811
|
-
defaultTtlMs: defaultTtlMs,
|
|
812
|
-
clock: clock,
|
|
813
|
-
emitObs: emitObs,
|
|
814
|
-
}
|
|
815
|
-
)), cfg);
|
|
816
|
-
} else {
|
|
817
|
-
backend = _customBackend(opts.backend, cfg);
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
backend._startSweep(sweepIntervalMs);
|
|
821
|
-
|
|
822
|
-
var closed = false;
|
|
823
|
-
function _ensureOpen(method) {
|
|
824
|
-
if (closed) {
|
|
825
|
-
throw _err("BAD_STATE", "cache." + method + ": cache instance has been closed");
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
// Single-flight inflight map for wrap()
|
|
830
|
-
var inflight = new Map();
|
|
831
|
-
|
|
832
|
-
// Stale-while-revalidate tracking (per-instance, in-memory). When SWR
|
|
833
|
-
// is on, wrap() stores entries with a HARD TTL of 2× ttlMs and tracks
|
|
834
|
-
// the SOFT expiration here. Reads after soft but before hard return
|
|
835
|
-
// the cached value AND kick off a background refresh; reads after
|
|
836
|
-
// hard fall through to a normal miss + compute. The soft-TTL map is
|
|
837
|
-
// memory-only even when the backend is cluster — refreshes are a
|
|
838
|
-
// best-effort optimization, not a correctness invariant, so a cache
|
|
839
|
-
// miss after restart (no soft data) just means we serve fresh once.
|
|
840
|
-
var softExpiry = new Map(); // key → softExpiresAt
|
|
841
|
-
var swrInflight = new Map(); // key → background-refresh promise
|
|
842
|
-
var SWR_HARD_MULTIPLIER = 2;
|
|
843
|
-
|
|
844
|
-
// ---- Public methods ----
|
|
845
|
-
|
|
846
|
-
function _resolveTtl(callerOpts, methodName) {
|
|
847
|
-
if (callerOpts && callerOpts.ttlMs !== undefined) {
|
|
848
|
-
_validateTtl("cache." + methodName + ": ttlMs", callerOpts.ttlMs);
|
|
849
|
-
return callerOpts.ttlMs;
|
|
850
|
-
}
|
|
851
|
-
return defaultTtlMs;
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
async function get(key) {
|
|
855
|
-
_ensureOpen("get");
|
|
856
|
-
_validateKey(key, "cache.get");
|
|
857
|
-
var v;
|
|
858
|
-
try { v = await backend.get(key); }
|
|
859
|
-
catch (e) {
|
|
860
|
-
emitObs("cache.backend.failed", { namespace: namespace, op: "get" });
|
|
861
|
-
_backendFailedAudit("get", e);
|
|
862
|
-
throw e;
|
|
863
|
-
}
|
|
864
|
-
if (v === undefined) emitObs("cache.miss", { namespace: namespace });
|
|
865
|
-
else emitObs("cache.hit", { namespace: namespace });
|
|
866
|
-
return v;
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
async function set(key, value, callerOpts) {
|
|
870
|
-
_ensureOpen("set");
|
|
871
|
-
_validateKey(key, "cache.set");
|
|
872
|
-
var ttlMs = _resolveTtl(callerOpts, "set");
|
|
873
|
-
if (ttlMs === 0) return; // 0 means "do not cache"
|
|
874
|
-
var expiresAt = (ttlMs === Infinity) ? Infinity : (clock() + ttlMs);
|
|
875
|
-
var tags = (callerOpts && Array.isArray(callerOpts.tags)) ? callerOpts.tags : null;
|
|
876
|
-
if (tags) {
|
|
877
|
-
for (var i = 0; i < tags.length; i++) {
|
|
878
|
-
if (typeof tags[i] !== "string" || tags[i].length === 0) {
|
|
879
|
-
throw _err("BAD_OPT", "cache.set: tags must be an array of non-empty strings");
|
|
880
|
-
}
|
|
881
|
-
}
|
|
882
|
-
}
|
|
883
|
-
try { await backend.set(key, value, expiresAt, { ttlMs: ttlMs, tags: tags }); }
|
|
884
|
-
catch (e) {
|
|
885
|
-
emitObs("cache.backend.failed", { namespace: namespace, op: "set" });
|
|
886
|
-
_backendFailedAudit("set", e);
|
|
887
|
-
throw e;
|
|
888
|
-
}
|
|
889
|
-
emitObs("cache.set", { namespace: namespace });
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
async function del(key) {
|
|
893
|
-
_ensureOpen("del");
|
|
894
|
-
_validateKey(key, "cache.del");
|
|
895
|
-
var existed;
|
|
896
|
-
try { existed = await backend.del(key); }
|
|
897
|
-
catch (e) {
|
|
898
|
-
emitObs("cache.backend.failed", { namespace: namespace, op: "del" });
|
|
899
|
-
_backendFailedAudit("del", e);
|
|
900
|
-
throw e;
|
|
901
|
-
}
|
|
902
|
-
if (existed) emitObs("cache.del", { namespace: namespace });
|
|
903
|
-
softExpiry.delete(key);
|
|
904
|
-
_publishInvalidation({ kind: "del", key: key });
|
|
905
|
-
return existed;
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
async function has(key) {
|
|
909
|
-
_ensureOpen("has");
|
|
910
|
-
_validateKey(key, "cache.has");
|
|
911
|
-
try { return await backend.has(key); }
|
|
912
|
-
catch (e) {
|
|
913
|
-
emitObs("cache.backend.failed", { namespace: namespace, op: "has" });
|
|
914
|
-
_backendFailedAudit("has", e);
|
|
915
|
-
throw e;
|
|
916
|
-
}
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
async function clear(callerOpts) {
|
|
920
|
-
_ensureOpen("clear");
|
|
921
|
-
var purged;
|
|
922
|
-
try { purged = await backend.clear(); }
|
|
923
|
-
catch (e) {
|
|
924
|
-
emitObs("cache.backend.failed", { namespace: namespace, op: "clear" });
|
|
925
|
-
_backendFailedAudit("clear", e);
|
|
926
|
-
throw e;
|
|
927
|
-
}
|
|
928
|
-
emitObs("cache.clear", { namespace: namespace });
|
|
929
|
-
if (auditClear) {
|
|
930
|
-
emitAudit("cache.cleared", {
|
|
931
|
-
actor: _actor(callerOpts),
|
|
932
|
-
resource: { kind: "cache", id: namespace },
|
|
933
|
-
outcome: "success",
|
|
934
|
-
metadata: { itemCount: purged },
|
|
935
|
-
});
|
|
936
|
-
}
|
|
937
|
-
// Drop any in-flight wrap promises — operator clear means "consumers
|
|
938
|
-
// should re-fetch", and in-flight resolves would seed stale entries
|
|
939
|
-
// post-clear.
|
|
940
|
-
inflight.clear();
|
|
941
|
-
swrInflight.clear();
|
|
942
|
-
softExpiry.clear();
|
|
943
|
-
_publishInvalidation({ kind: "clear" });
|
|
944
|
-
return purged;
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
async function size() {
|
|
948
|
-
_ensureOpen("size");
|
|
949
|
-
try { return await backend.size(); }
|
|
950
|
-
catch (e) {
|
|
951
|
-
emitObs("cache.backend.failed", { namespace: namespace, op: "size" });
|
|
952
|
-
_backendFailedAudit("size", e);
|
|
953
|
-
throw e;
|
|
954
|
-
}
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
async function bytes() {
|
|
958
|
-
_ensureOpen("bytes");
|
|
959
|
-
try {
|
|
960
|
-
if (typeof backend.bytes !== "function") return 0;
|
|
961
|
-
return await backend.bytes();
|
|
962
|
-
} catch (e) {
|
|
963
|
-
emitObs("cache.backend.failed", { namespace: namespace, op: "bytes" });
|
|
964
|
-
_backendFailedAudit("bytes", e);
|
|
965
|
-
throw e;
|
|
966
|
-
}
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
async function invalidateTag(tag, callerOpts) {
|
|
970
|
-
_ensureOpen("invalidateTag");
|
|
971
|
-
if (typeof tag !== "string" || tag.length === 0) {
|
|
972
|
-
throw _err("BAD_OPT", "cache.invalidateTag: tag must be a non-empty string");
|
|
973
|
-
}
|
|
974
|
-
if (typeof backend.invalidateTag !== "function") {
|
|
975
|
-
throw _err("NOT_SUPPORTED",
|
|
976
|
-
"cache.invalidateTag: backend '" + (backend.name || "custom") +
|
|
977
|
-
"' does not implement invalidateTag. Operator-supplied custom backends " +
|
|
978
|
-
"must export invalidateTag(tag) → number to participate in tag-based wipes.");
|
|
979
|
-
}
|
|
980
|
-
var purged;
|
|
981
|
-
try { purged = await backend.invalidateTag(tag); }
|
|
982
|
-
catch (e) {
|
|
983
|
-
emitObs("cache.backend.failed", { namespace: namespace, op: "invalidateTag" });
|
|
984
|
-
_backendFailedAudit("invalidateTag", e);
|
|
985
|
-
throw e;
|
|
986
|
-
}
|
|
987
|
-
emitObs("cache.tag.invalidated", { namespace: namespace, tag: tag });
|
|
988
|
-
if (auditClear && purged > 0) {
|
|
989
|
-
emitAudit("cache.tag.invalidated", {
|
|
990
|
-
actor: _actor(callerOpts),
|
|
991
|
-
resource: { kind: "cache.tag", id: namespace + ":" + tag },
|
|
992
|
-
outcome: "success",
|
|
993
|
-
metadata: { tag: tag, itemCount: purged },
|
|
994
|
-
});
|
|
995
|
-
}
|
|
996
|
-
// Drop in-flight wrap promises whose key WOULD have just been
|
|
997
|
-
// invalidated. We don't track per-key tags inflight, so a coarse
|
|
998
|
-
// drop matches clear()'s safer-than-stale posture.
|
|
999
|
-
inflight.clear();
|
|
1000
|
-
swrInflight.clear();
|
|
1001
|
-
_publishInvalidation({ kind: "tag", tag: tag });
|
|
1002
|
-
return purged;
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
async function getTags(key) {
|
|
1006
|
-
_ensureOpen("getTags");
|
|
1007
|
-
_validateKey(key, "cache.getTags");
|
|
1008
|
-
if (typeof backend.getTags !== "function") return null;
|
|
1009
|
-
try { return await backend.getTags(key); }
|
|
1010
|
-
catch (e) {
|
|
1011
|
-
emitObs("cache.backend.failed", { namespace: namespace, op: "getTags" });
|
|
1012
|
-
_backendFailedAudit("getTags", e);
|
|
1013
|
-
throw e;
|
|
1014
|
-
}
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
function _backgroundRefresh(key, fn, ttlMs) {
|
|
1018
|
-
if (swrInflight.has(key)) return; // already refreshing
|
|
1019
|
-
var p = (async function () {
|
|
1020
|
-
var startedAt = clock();
|
|
1021
|
-
var computed;
|
|
1022
|
-
try { computed = await fn(); }
|
|
1023
|
-
finally {
|
|
1024
|
-
emitObs("cache.wrap.compute", { namespace: namespace, ms: clock() - startedAt });
|
|
1025
|
-
}
|
|
1026
|
-
var expiresAt = _writeWithSwr(key, computed, ttlMs);
|
|
1027
|
-
void expiresAt;
|
|
1028
|
-
return computed;
|
|
1029
|
-
})();
|
|
1030
|
-
swrInflight.set(key, p);
|
|
1031
|
-
p.then(
|
|
1032
|
-
function () { swrInflight.delete(key); },
|
|
1033
|
-
function (_e) {
|
|
1034
|
-
swrInflight.delete(key);
|
|
1035
|
-
// Background refresh failed; stale value already served. Surface
|
|
1036
|
-
// via observability so operators see it without breaking the
|
|
1037
|
-
// request that triggered the refresh.
|
|
1038
|
-
emitObs("cache.refresh.failed", { namespace: namespace });
|
|
1039
|
-
}
|
|
1040
|
-
);
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
function _writeWithSwr(key, value, ttlMs) {
|
|
1044
|
-
if (ttlMs === 0) return null; // 0 means "do not cache"
|
|
1045
|
-
var now = clock();
|
|
1046
|
-
var hardTtlMs = (ttlMs === Infinity)
|
|
1047
|
-
? Infinity
|
|
1048
|
-
: (staleRevalidate ? ttlMs * SWR_HARD_MULTIPLIER : ttlMs);
|
|
1049
|
-
var expiresAt = (hardTtlMs === Infinity) ? Infinity : (now + hardTtlMs);
|
|
1050
|
-
if (staleRevalidate && ttlMs !== Infinity) {
|
|
1051
|
-
softExpiry.set(key, now + ttlMs);
|
|
1052
|
-
} else {
|
|
1053
|
-
softExpiry.delete(key);
|
|
1054
|
-
}
|
|
1055
|
-
// Backend write — failure surfaces via observability + audit but
|
|
1056
|
-
// doesn't bubble (caller already has the computed value).
|
|
1057
|
-
backend.set(key, value, expiresAt, { ttlMs: ttlMs }).catch(function (e) {
|
|
1058
|
-
emitObs("cache.backend.failed", { namespace: namespace, op: "set" });
|
|
1059
|
-
_backendFailedAudit("set", e);
|
|
1060
|
-
});
|
|
1061
|
-
return expiresAt;
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
|
-
async function wrap(key, fn, callerOpts) {
|
|
1065
|
-
_ensureOpen("wrap");
|
|
1066
|
-
_validateKey(key, "cache.wrap");
|
|
1067
|
-
if (typeof fn !== "function") {
|
|
1068
|
-
throw _err("BAD_OPT", "cache.wrap: fn must be a function, got " + typeof fn);
|
|
1069
|
-
}
|
|
1070
|
-
var ttlMs = _resolveTtl(callerOpts, "wrap");
|
|
1071
|
-
var singleFlight = !(callerOpts && callerOpts.singleFlight === false);
|
|
1072
|
-
|
|
1073
|
-
var existing;
|
|
1074
|
-
try { existing = await backend.get(key); }
|
|
1075
|
-
catch (e) {
|
|
1076
|
-
emitObs("cache.backend.failed", { namespace: namespace, op: "get" });
|
|
1077
|
-
_backendFailedAudit("get", e);
|
|
1078
|
-
throw e;
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
if (existing !== undefined) {
|
|
1082
|
-
// SWR: served from backend, but might be stale (past soft TTL).
|
|
1083
|
-
var soft = softExpiry.get(key);
|
|
1084
|
-
var now = clock();
|
|
1085
|
-
if (staleRevalidate && soft !== undefined && soft <= now) {
|
|
1086
|
-
emitObs("cache.hit", { namespace: namespace });
|
|
1087
|
-
_backgroundRefresh(key, fn, ttlMs);
|
|
1088
|
-
return existing;
|
|
1089
|
-
}
|
|
1090
|
-
emitObs("cache.hit", { namespace: namespace });
|
|
1091
|
-
return existing;
|
|
1092
|
-
}
|
|
1093
|
-
emitObs("cache.miss", { namespace: namespace });
|
|
1094
|
-
|
|
1095
|
-
if (singleFlight && inflight.has(key)) {
|
|
1096
|
-
emitObs("cache.wrap.singleflight.collapsed", { namespace: namespace });
|
|
1097
|
-
return inflight.get(key);
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
var promise = (async function () {
|
|
1101
|
-
var startedAt = clock();
|
|
1102
|
-
var computed;
|
|
1103
|
-
try { computed = await fn(); }
|
|
1104
|
-
finally {
|
|
1105
|
-
emitObs("cache.wrap.compute", { namespace: namespace, ms: clock() - startedAt });
|
|
1106
|
-
}
|
|
1107
|
-
if (ttlMs !== 0) {
|
|
1108
|
-
if (staleRevalidate) {
|
|
1109
|
-
_writeWithSwr(key, computed, ttlMs);
|
|
1110
|
-
} else {
|
|
1111
|
-
var expiresAt = (ttlMs === Infinity) ? Infinity : (clock() + ttlMs);
|
|
1112
|
-
try { await backend.set(key, computed, expiresAt, { ttlMs: ttlMs }); }
|
|
1113
|
-
catch (e) {
|
|
1114
|
-
emitObs("cache.backend.failed", { namespace: namespace, op: "set" });
|
|
1115
|
-
_backendFailedAudit("set", e);
|
|
1116
|
-
// Failed write doesn't fail the wrap — caller still gets the
|
|
1117
|
-
// computed value; cache just didn't persist.
|
|
1118
|
-
}
|
|
1119
|
-
}
|
|
1120
|
-
}
|
|
1121
|
-
return computed;
|
|
1122
|
-
})();
|
|
1123
|
-
if (singleFlight) {
|
|
1124
|
-
inflight.set(key, promise);
|
|
1125
|
-
promise.then(
|
|
1126
|
-
function () { inflight.delete(key); },
|
|
1127
|
-
function () { inflight.delete(key); }
|
|
1128
|
-
);
|
|
1129
|
-
}
|
|
1130
|
-
return promise;
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
function _publishInvalidation(ev) {
|
|
1134
|
-
if (!invalidationPubsub || inboundInvalidation) return;
|
|
1135
|
-
try { invalidationPubsub.publish(invalidationChannel, ev); }
|
|
1136
|
-
catch (_e) { /* publish best-effort — local invalidation already happened */ }
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
async function _onInboundInvalidation(ev /*, meta */) {
|
|
1140
|
-
if (!ev || closed) return;
|
|
1141
|
-
inboundInvalidation = true;
|
|
1142
|
-
try {
|
|
1143
|
-
if (ev.kind === "tag" && typeof ev.tag === "string" &&
|
|
1144
|
-
typeof backend.invalidateTag === "function") {
|
|
1145
|
-
try { await backend.invalidateTag(ev.tag); }
|
|
1146
|
-
catch (e) { log.debug("invalidation-apply-failed", { op: "invalidateTag", tag: ev.tag, error: e.message }); }
|
|
1147
|
-
} else if (ev.kind === "del" && typeof ev.key === "string") {
|
|
1148
|
-
try { await backend.del(ev.key); }
|
|
1149
|
-
catch (e) { log.debug("invalidation-apply-failed", { op: "del", key: ev.key, error: e.message }); }
|
|
1150
|
-
} else if (ev.kind === "clear") {
|
|
1151
|
-
try { await backend.clear(); }
|
|
1152
|
-
catch (e) { log.debug("invalidation-apply-failed", { op: "clear", error: e.message }); }
|
|
1153
|
-
}
|
|
1154
|
-
// Wipe local in-flight memoization so a freshly-invalidated key
|
|
1155
|
-
// can't resolve from a still-pending fetch on this node.
|
|
1156
|
-
inflight.clear();
|
|
1157
|
-
swrInflight.clear();
|
|
1158
|
-
softExpiry.clear();
|
|
1159
|
-
} finally {
|
|
1160
|
-
inboundInvalidation = false;
|
|
1161
|
-
}
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
if (invalidationPubsub) {
|
|
1165
|
-
invalidationToken = invalidationPubsub.subscribe(invalidationChannel, _onInboundInvalidation);
|
|
1166
|
-
}
|
|
1167
|
-
|
|
1168
|
-
async function close() {
|
|
1169
|
-
if (closed) return;
|
|
1170
|
-
closed = true;
|
|
1171
|
-
if (invalidationPubsub && invalidationToken) {
|
|
1172
|
-
try { invalidationPubsub.unsubscribe(invalidationToken); }
|
|
1173
|
-
catch (e) { log.debug("close-cleanup-failed", { op: "unsubscribe", error: e.message }); }
|
|
1174
|
-
invalidationToken = null;
|
|
1175
|
-
}
|
|
1176
|
-
inflight.clear();
|
|
1177
|
-
swrInflight.clear();
|
|
1178
|
-
softExpiry.clear();
|
|
1179
|
-
try { await backend.close(); }
|
|
1180
|
-
catch (_e) { /* close best-effort */ }
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
return {
|
|
1184
|
-
get: get,
|
|
1185
|
-
set: set,
|
|
1186
|
-
del: del,
|
|
1187
|
-
has: has,
|
|
1188
|
-
clear: clear,
|
|
1189
|
-
size: size,
|
|
1190
|
-
bytes: bytes,
|
|
1191
|
-
wrap: wrap,
|
|
1192
|
-
invalidateTag: invalidateTag,
|
|
1193
|
-
getTags: getTags,
|
|
1194
|
-
close: close,
|
|
1195
|
-
namespace: namespace,
|
|
1196
|
-
// Test hooks
|
|
1197
|
-
_backend: backend,
|
|
1198
|
-
_inflight: inflight,
|
|
1199
|
-
};
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
module.exports = {
|
|
1203
|
-
create: create,
|
|
1204
|
-
CacheError: CacheError,
|
|
1205
|
-
DEFAULTS: DEFAULTS,
|
|
1206
|
-
};
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.cache — operator-facing cache primitive.
|
|
4
|
+
*
|
|
5
|
+
* var cache = b.cache.create({
|
|
6
|
+
* namespace: "session.user",
|
|
7
|
+
* backend: "memory",
|
|
8
|
+
* ttlMs: C.TIME.minutes(5),
|
|
9
|
+
* maxEntries: 10000,
|
|
10
|
+
* maxBytes: C.BYTES.mib(100), // memory backend only
|
|
11
|
+
* sizeOf: function (v) { return v.byteLength; }, // optional override
|
|
12
|
+
* slidingTtl: true, // bump expiresAt on hit
|
|
13
|
+
* audit: b.audit, // optional
|
|
14
|
+
* });
|
|
15
|
+
*
|
|
16
|
+
* await cache.set("u-42", record, { ttlMs: C.TIME.minutes(10), tags: ["user:42", "session"] });
|
|
17
|
+
* var hit = await cache.get("u-42");
|
|
18
|
+
*
|
|
19
|
+
* // Memoize / read-through:
|
|
20
|
+
* var profile = await cache.wrap("u-42", function () {
|
|
21
|
+
* return db.users.findOne({ _id: "u-42" });
|
|
22
|
+
* });
|
|
23
|
+
*
|
|
24
|
+
* // Bulk invalidate (memory backend):
|
|
25
|
+
* await cache.invalidateTag("user:42"); // purges every entry tagged user:42
|
|
26
|
+
*
|
|
27
|
+
* Surface (returned by create):
|
|
28
|
+
*
|
|
29
|
+
* get(key) → value | undefined
|
|
30
|
+
* set(key, value, opts?) → void (opts: { ttlMs, tags })
|
|
31
|
+
* del(key) → boolean (existed)
|
|
32
|
+
* has(key) → boolean (does NOT bump LRU recency)
|
|
33
|
+
* clear(opts?) → number (purged) (opts: { req, context })
|
|
34
|
+
* size() → number
|
|
35
|
+
* bytes() → number (memory backend only — total stored bytes)
|
|
36
|
+
* wrap(key, fn, opts?) → fn's return value (opts: { ttlMs, singleFlight })
|
|
37
|
+
* invalidateTag(tag, opts?) → number (purged) (opts: { req, context })
|
|
38
|
+
* getTags(key) → string[] | null
|
|
39
|
+
* close() → void
|
|
40
|
+
*
|
|
41
|
+
* Backends:
|
|
42
|
+
*
|
|
43
|
+
* "memory" (default) — Map + LRU eviction (maxEntries) + periodic
|
|
44
|
+
* sweep timer (sweepIntervalMs). Single-process accuracy only.
|
|
45
|
+
*
|
|
46
|
+
* "cluster" — _blamejs_cache table via cluster-storage. PRIMARY KEY
|
|
47
|
+
* is "<namespace>:<key>" so one table serves every CacheInstance.
|
|
48
|
+
* UPSERT via ON CONFLICT for atomic set; DELETE WHERE expiresAt
|
|
49
|
+
* for sweep. JSON-only value serialization.
|
|
50
|
+
*
|
|
51
|
+
* { get, set, del, clear, size, close } — operator-supplied custom
|
|
52
|
+
* backend (Redis, Memcached, …). All methods async.
|
|
53
|
+
*
|
|
54
|
+
* Validation policy:
|
|
55
|
+
*
|
|
56
|
+
* - create() opts → throw at boot
|
|
57
|
+
* - get/set/del/has/wrap key arg type → throw at call site (programming bug)
|
|
58
|
+
* - set value type → tolerant (operator decides what to store)
|
|
59
|
+
* - per-call ttlMs override → throw at call site (bad ttl is silent footgun)
|
|
60
|
+
* - audit / observability emit failures → drop silent (hot-path sink)
|
|
61
|
+
* - method called after close() → throw BAD_STATE at call site
|
|
62
|
+
*
|
|
63
|
+
* Security defaults:
|
|
64
|
+
*
|
|
65
|
+
* - auditClear: true — mass purge is operator-action shaped (can hide forensics)
|
|
66
|
+
* - auditFailures: true — backend errors are signal
|
|
67
|
+
* - hot-path get/set/hit/miss/eviction → observability only (audit chain
|
|
68
|
+
* would drown at any reasonable QPS)
|
|
69
|
+
*
|
|
70
|
+
* The cache supports single-flight wrap (concurrent calls collapse),
|
|
71
|
+
* stale-while-revalidate, LRU + bytes eviction on the memory backend,
|
|
72
|
+
* sliding TTL on hit, tag-based bulk invalidation (memory backend), a
|
|
73
|
+
* shared cluster backend, and a custom-backend escape hatch.
|
|
74
|
+
*
|
|
75
|
+
* What is NOT in the box:
|
|
76
|
+
*
|
|
77
|
+
* - maxBytes on the cluster backend — per-row size accounting against
|
|
78
|
+
* a shared table would mean an aggregate query on every set. The
|
|
79
|
+
* operator controls cluster-table size with their own pruning if
|
|
80
|
+
* bytes pressure surfaces.
|
|
81
|
+
* - Per-entry exact slidingTtl on the cluster backend — sliding works
|
|
82
|
+
* on cluster but extends by the cache's defaultTtlMs (we don't
|
|
83
|
+
* store per-row ttl). Operators with mixed-TTL writes wanting
|
|
84
|
+
* strict per-entry sliding use the memory backend or extend at
|
|
85
|
+
* the application layer.
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
var cacheRedis = require("./cache-redis");
|
|
89
|
+
var redisClient = require("./redis-client");
|
|
90
|
+
var clusterStorage = require("./cluster-storage");
|
|
91
|
+
var C = require("./constants");
|
|
92
|
+
var lazyRequire = require("./lazy-require");
|
|
93
|
+
var { boot } = require("./log");
|
|
94
|
+
var numericChecks = require("./numeric-checks");
|
|
95
|
+
var requestHelpers = require("./request-helpers");
|
|
96
|
+
var safeAsync = require("./safe-async");
|
|
97
|
+
var safeJson = require("./safe-json");
|
|
98
|
+
var validateOpts = require("./validate-opts");
|
|
99
|
+
var { CacheError } = require("./framework-error");
|
|
100
|
+
|
|
101
|
+
var log = boot("cache");
|
|
102
|
+
var observability = lazyRequire(function () { return require("./observability"); });
|
|
103
|
+
|
|
104
|
+
var _err = CacheError.factory;
|
|
105
|
+
|
|
106
|
+
var DEFAULTS = Object.freeze({
|
|
107
|
+
backend: "memory",
|
|
108
|
+
ttlMs: C.TIME.minutes(5),
|
|
109
|
+
maxEntries: C.BYTES.bytes(10000),
|
|
110
|
+
maxBytes: Infinity,
|
|
111
|
+
sweepIntervalMs: C.TIME.minutes(1),
|
|
112
|
+
staleWhileRevalidate: false,
|
|
113
|
+
slidingTtl: false,
|
|
114
|
+
auditFailures: true,
|
|
115
|
+
auditClear: true,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ---- Config-time validation helpers (throw on bad input) ----
|
|
119
|
+
|
|
120
|
+
var _isFiniteNonNegative = numericChecks.isFiniteNonNegative;
|
|
121
|
+
var _isPositiveInt = numericChecks.isPositiveInt;
|
|
122
|
+
|
|
123
|
+
// ttlMs accepts: any non-negative finite number OR Infinity. NaN, negative,
|
|
124
|
+
// or non-number is rejected.
|
|
125
|
+
function _validateTtl(name, value) {
|
|
126
|
+
if (value === Infinity) return;
|
|
127
|
+
if (typeof value !== "number" || isNaN(value) || !isFinite(value) || value < 0) {
|
|
128
|
+
throw _err("BAD_OPT", name + " must be a non-negative finite number or Infinity, got " +
|
|
129
|
+
(typeof value) + " " + JSON.stringify(value));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function _validateMaxEntries(value) {
|
|
134
|
+
if (value === Infinity) return;
|
|
135
|
+
if (!_isPositiveInt(value)) {
|
|
136
|
+
throw _err("BAD_OPT", "cache.create: maxEntries must be a positive integer or Infinity, got " +
|
|
137
|
+
JSON.stringify(value));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function _validateMaxBytes(value) {
|
|
142
|
+
if (value === Infinity) return;
|
|
143
|
+
if (!_isFiniteNonNegative(value) || value < 1) {
|
|
144
|
+
throw _err("BAD_OPT", "cache.create: maxBytes must be a positive finite number or Infinity, got " +
|
|
145
|
+
JSON.stringify(value));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Default sizeOf — best-effort byte estimate. Operators with structured
|
|
150
|
+
// values (large objects, custom classes) should pass their own sizeOf
|
|
151
|
+
// for accuracy.
|
|
152
|
+
function _defaultSizeOf(value) {
|
|
153
|
+
if (value === null || value === undefined) return 0;
|
|
154
|
+
if (Buffer.isBuffer(value)) return value.length;
|
|
155
|
+
if (typeof value === "string") return Buffer.byteLength(value, "utf8");
|
|
156
|
+
if (typeof value === "number" || typeof value === "boolean") return C.BYTES.bytes(8);
|
|
157
|
+
// Fallback: round-trip through JSON. Cost is real; documented in the
|
|
158
|
+
// DEFAULTS docstring so operators with hot-path size accounting know
|
|
159
|
+
// to supply their own sizeOf.
|
|
160
|
+
try { return Buffer.byteLength(JSON.stringify(value), "utf8"); }
|
|
161
|
+
catch (_e) { return 0; }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function _validateBackendObject(backend) {
|
|
165
|
+
var required = ["get", "set", "del", "clear", "size", "close"];
|
|
166
|
+
if (typeof backend !== "object" || backend === null) {
|
|
167
|
+
throw _err("BAD_OPT", "cache.create: custom backend must be an object");
|
|
168
|
+
}
|
|
169
|
+
for (var i = 0; i < required.length; i++) {
|
|
170
|
+
if (typeof backend[required[i]] !== "function") {
|
|
171
|
+
throw _err("BAD_OPT", "cache.create: custom backend missing method '" + required[i] +
|
|
172
|
+
"' (required: " + required.join(", ") + ")");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function _validateCreateOpts(opts) {
|
|
178
|
+
validateOpts.requireObject(opts, "cache.create", CacheError);
|
|
179
|
+
validateOpts.requireNonEmptyString(opts.namespace, "cache.create: namespace", CacheError, "BAD_OPT");
|
|
180
|
+
// Composite cluster-key separator is ":" — namespace must not contain it
|
|
181
|
+
// or two namespaces could collide ("a:b" + "c" = "a:b:c" = "a" + "b:c").
|
|
182
|
+
if (opts.namespace.indexOf(":") !== -1) {
|
|
183
|
+
throw _err("BAD_OPT", "cache.create: namespace must not contain ':' (used as cluster-key separator), got " +
|
|
184
|
+
JSON.stringify(opts.namespace));
|
|
185
|
+
}
|
|
186
|
+
if (opts.backend !== undefined) {
|
|
187
|
+
if (typeof opts.backend === "string") {
|
|
188
|
+
if (opts.backend !== "memory" && opts.backend !== "cluster" && opts.backend !== "redis") {
|
|
189
|
+
throw _err("BAD_OPT", "cache.create: backend string must be 'memory' | 'cluster' | 'redis', got " +
|
|
190
|
+
JSON.stringify(opts.backend));
|
|
191
|
+
}
|
|
192
|
+
if (opts.backend === "redis") {
|
|
193
|
+
if (typeof opts.redisUrl !== "string" || opts.redisUrl.length === 0) {
|
|
194
|
+
throw _err("BAD_OPT", "cache.create: backend='redis' requires opts.redisUrl (e.g. redis://localhost:6379/0)");
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
_validateBackendObject(opts.backend);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (opts.ttlMs !== undefined) _validateTtl("cache.create: ttlMs", opts.ttlMs);
|
|
202
|
+
if (opts.maxEntries !== undefined) _validateMaxEntries(opts.maxEntries);
|
|
203
|
+
if (opts.maxBytes !== undefined) _validateMaxBytes(opts.maxBytes);
|
|
204
|
+
validateOpts.optionalFunction(opts.sizeOf, "cache.create: sizeOf", CacheError);
|
|
205
|
+
validateOpts.optionalBoolean(opts.slidingTtl, "cache.create: slidingTtl", CacheError);
|
|
206
|
+
if (opts.sweepIntervalMs !== undefined) {
|
|
207
|
+
validateOpts.optionalFiniteNonNegative(opts.sweepIntervalMs, "cache.create: sweepIntervalMs", CacheError);
|
|
208
|
+
if (opts.sweepIntervalMs < C.TIME.seconds(1)) {
|
|
209
|
+
throw _err("BAD_OPT", "cache.create: sweepIntervalMs must be >= 1000ms, got " +
|
|
210
|
+
JSON.stringify(opts.sweepIntervalMs));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
validateOpts.optionalBoolean(opts.staleWhileRevalidate, "cache.create: staleWhileRevalidate", CacheError);
|
|
214
|
+
validateOpts.optionalBoolean(opts.auditFailures, "cache.create: auditFailures", CacheError);
|
|
215
|
+
validateOpts.optionalBoolean(opts.auditClear, "cache.create: auditClear", CacheError);
|
|
216
|
+
validateOpts.auditShape(opts.audit, "cache.create", CacheError);
|
|
217
|
+
validateOpts.observabilityShape(opts.observability, "cache.create", CacheError);
|
|
218
|
+
validateOpts.optionalFunction(opts.clock, "cache.create: clock", CacheError);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function _validateKey(key, ctx) {
|
|
222
|
+
if (typeof key !== "string" || key.length === 0) {
|
|
223
|
+
throw _err("BAD_KEY", ctx + ": key must be a non-empty string, got " +
|
|
224
|
+
(typeof key) + " " + JSON.stringify(key));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---- Memory backend ----
|
|
229
|
+
// LRU realized by Map insertion order (Node Map iterates in insertion order;
|
|
230
|
+
// re-inserting a key on hit moves it to the most-recent position).
|
|
231
|
+
|
|
232
|
+
function _memoryBackend(cfg) {
|
|
233
|
+
var entries = new Map(); // key → { value, expiresAt, ttlMs, bytes, tags }
|
|
234
|
+
var maxEntries = cfg.maxEntries;
|
|
235
|
+
var maxBytes = cfg.maxBytes;
|
|
236
|
+
var sizeOf = cfg.sizeOf;
|
|
237
|
+
var slidingTtl = cfg.slidingTtl;
|
|
238
|
+
var clock = cfg.clock;
|
|
239
|
+
var emitObs = cfg.emitObs;
|
|
240
|
+
var namespace = cfg.namespace;
|
|
241
|
+
var sweepTimer = null;
|
|
242
|
+
var totalBytes = 0;
|
|
243
|
+
|
|
244
|
+
// tag → Set<key>. Bidirectional with entry.tags for fast invalidate.
|
|
245
|
+
var tagIndex = new Map();
|
|
246
|
+
|
|
247
|
+
function _isExpired(entry, now) {
|
|
248
|
+
return entry.expiresAt !== Infinity && entry.expiresAt <= now;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function _untrack(key, entry) {
|
|
252
|
+
if (!entry) return;
|
|
253
|
+
totalBytes -= entry.bytes || 0;
|
|
254
|
+
if (totalBytes < 0) totalBytes = 0;
|
|
255
|
+
if (entry.tags && entry.tags.length > 0) {
|
|
256
|
+
for (var i = 0; i < entry.tags.length; i++) {
|
|
257
|
+
var s = tagIndex.get(entry.tags[i]);
|
|
258
|
+
if (s) {
|
|
259
|
+
s.delete(key);
|
|
260
|
+
if (s.size === 0) tagIndex.delete(entry.tags[i]);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function _evictByCounts() {
|
|
267
|
+
while (maxEntries !== Infinity && entries.size > maxEntries) {
|
|
268
|
+
var oldest = entries.keys().next().value;
|
|
269
|
+
var e = entries.get(oldest);
|
|
270
|
+
_untrack(oldest, e);
|
|
271
|
+
entries.delete(oldest);
|
|
272
|
+
emitObs("cache.eviction.size", { namespace: namespace });
|
|
273
|
+
}
|
|
274
|
+
while (maxBytes !== Infinity && totalBytes > maxBytes && entries.size > 0) {
|
|
275
|
+
var oldestB = entries.keys().next().value;
|
|
276
|
+
var eb = entries.get(oldestB);
|
|
277
|
+
_untrack(oldestB, eb);
|
|
278
|
+
entries.delete(oldestB);
|
|
279
|
+
emitObs("cache.eviction.bytes", { namespace: namespace });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function get(key) {
|
|
284
|
+
var now = clock();
|
|
285
|
+
var entry = entries.get(key);
|
|
286
|
+
if (!entry) return undefined;
|
|
287
|
+
if (_isExpired(entry, now)) {
|
|
288
|
+
_untrack(key, entry);
|
|
289
|
+
entries.delete(key);
|
|
290
|
+
emitObs("cache.eviction.expired", { namespace: namespace });
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
// Sliding TTL: extend lifetime on each successful read by the
|
|
294
|
+
// entry's original ttlMs. Infinity stays Infinity.
|
|
295
|
+
if (slidingTtl && entry.ttlMs !== Infinity && typeof entry.ttlMs === "number") {
|
|
296
|
+
entry.expiresAt = now + entry.ttlMs;
|
|
297
|
+
}
|
|
298
|
+
// LRU recency bump: re-insert moves to the most-recent slot.
|
|
299
|
+
entries.delete(key);
|
|
300
|
+
entries.set(key, entry);
|
|
301
|
+
return entry.value;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function set(key, value, expiresAt, meta) {
|
|
305
|
+
// Existing key replacement: untrack first to rebalance bytes + tags.
|
|
306
|
+
var prior = entries.get(key);
|
|
307
|
+
if (prior) {
|
|
308
|
+
_untrack(key, prior);
|
|
309
|
+
entries.delete(key);
|
|
310
|
+
}
|
|
311
|
+
var bytes = sizeOf(value) || 0;
|
|
312
|
+
var ttlMs = meta && typeof meta.ttlMs === "number" ? meta.ttlMs : null;
|
|
313
|
+
var tags = (meta && Array.isArray(meta.tags)) ? meta.tags.slice() : null;
|
|
314
|
+
entries.set(key, {
|
|
315
|
+
value: value,
|
|
316
|
+
expiresAt: expiresAt,
|
|
317
|
+
ttlMs: ttlMs,
|
|
318
|
+
bytes: bytes,
|
|
319
|
+
tags: tags,
|
|
320
|
+
});
|
|
321
|
+
totalBytes += bytes;
|
|
322
|
+
if (tags && tags.length > 0) {
|
|
323
|
+
for (var i = 0; i < tags.length; i++) {
|
|
324
|
+
var s = tagIndex.get(tags[i]);
|
|
325
|
+
if (!s) { s = new Set(); tagIndex.set(tags[i], s); }
|
|
326
|
+
s.add(key);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
_evictByCounts();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function del(key) {
|
|
333
|
+
var entry = entries.get(key);
|
|
334
|
+
if (!entry) return false;
|
|
335
|
+
_untrack(key, entry);
|
|
336
|
+
entries.delete(key);
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function has(key) {
|
|
341
|
+
var entry = entries.get(key);
|
|
342
|
+
if (!entry) return false;
|
|
343
|
+
if (_isExpired(entry, clock())) {
|
|
344
|
+
_untrack(key, entry);
|
|
345
|
+
entries.delete(key);
|
|
346
|
+
emitObs("cache.eviction.expired", { namespace: namespace });
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function clear() {
|
|
353
|
+
var n = entries.size;
|
|
354
|
+
entries.clear();
|
|
355
|
+
tagIndex.clear();
|
|
356
|
+
totalBytes = 0;
|
|
357
|
+
return n;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function size() {
|
|
361
|
+
// Lazy purge: count only non-expired so size() reflects "live" entries.
|
|
362
|
+
var now = clock();
|
|
363
|
+
var live = 0;
|
|
364
|
+
for (var entry of entries.values()) {
|
|
365
|
+
if (!_isExpired(entry, now)) live++;
|
|
366
|
+
}
|
|
367
|
+
return live;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function invalidateTag(tag) {
|
|
371
|
+
var keys = tagIndex.get(tag);
|
|
372
|
+
if (!keys || keys.size === 0) return 0;
|
|
373
|
+
var purged = 0;
|
|
374
|
+
var toDelete = Array.from(keys);
|
|
375
|
+
for (var i = 0; i < toDelete.length; i++) {
|
|
376
|
+
var k = toDelete[i];
|
|
377
|
+
var entry = entries.get(k);
|
|
378
|
+
if (entry) {
|
|
379
|
+
_untrack(k, entry);
|
|
380
|
+
entries.delete(k);
|
|
381
|
+
purged++;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return purged;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function getTags(key) {
|
|
388
|
+
var entry = entries.get(key);
|
|
389
|
+
if (!entry) return null;
|
|
390
|
+
return entry.tags ? entry.tags.slice() : [];
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function bytes() {
|
|
394
|
+
return totalBytes;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function _sweep() {
|
|
398
|
+
var now = clock();
|
|
399
|
+
var purged = 0;
|
|
400
|
+
for (var k of Array.from(entries.keys())) {
|
|
401
|
+
var e = entries.get(k);
|
|
402
|
+
if (_isExpired(e, now)) {
|
|
403
|
+
_untrack(k, e);
|
|
404
|
+
entries.delete(k);
|
|
405
|
+
purged++;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (purged > 0) {
|
|
409
|
+
for (var i = 0; i < purged; i++) emitObs("cache.eviction.expired", { namespace: namespace });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function _startSweep(intervalMs) {
|
|
414
|
+
if (sweepTimer) return;
|
|
415
|
+
sweepTimer = safeAsync.repeating(_sweep, intervalMs, { name: "cache-sweep" });
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function close() {
|
|
419
|
+
if (sweepTimer) { sweepTimer.stop(); sweepTimer = null; }
|
|
420
|
+
entries.clear();
|
|
421
|
+
tagIndex.clear();
|
|
422
|
+
totalBytes = 0;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
name: "memory",
|
|
427
|
+
get: get,
|
|
428
|
+
set: set,
|
|
429
|
+
del: del,
|
|
430
|
+
has: has,
|
|
431
|
+
clear: clear,
|
|
432
|
+
size: size,
|
|
433
|
+
bytes: bytes,
|
|
434
|
+
invalidateTag: invalidateTag,
|
|
435
|
+
getTags: getTags,
|
|
436
|
+
close: close,
|
|
437
|
+
_startSweep: _startSweep,
|
|
438
|
+
// Test hook: raw entries map for state inspection
|
|
439
|
+
_entries: entries,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ---- Cluster backend ----
|
|
444
|
+
// Single _blamejs_cache table; cacheKey = "<namespace>:<key>". JSON-only
|
|
445
|
+
// value serialization. UPSERT via ON CONFLICT for atomic set.
|
|
446
|
+
|
|
447
|
+
function _clusterBackend(cfg) {
|
|
448
|
+
var namespace = cfg.namespace;
|
|
449
|
+
var clock = cfg.clock;
|
|
450
|
+
var emitObs = cfg.emitObs;
|
|
451
|
+
var slidingTtl = cfg.slidingTtl;
|
|
452
|
+
var defaultTtlMs = cfg.defaultTtlMs;
|
|
453
|
+
|
|
454
|
+
// Composite cluster key. Namespace was validated to not contain ":"
|
|
455
|
+
// at create time, so the split is unambiguous.
|
|
456
|
+
function _composedKey(key) { return namespace + ":" + key; }
|
|
457
|
+
|
|
458
|
+
async function get(key) {
|
|
459
|
+
var now = clock();
|
|
460
|
+
var result = await clusterStorage.execute(
|
|
461
|
+
"SELECT valueJson, expiresAt FROM _blamejs_cache WHERE cacheKey = ?",
|
|
462
|
+
[_composedKey(key)]
|
|
463
|
+
);
|
|
464
|
+
if (!result || !result.rows || result.rows.length === 0) return undefined;
|
|
465
|
+
var row = result.rows[0];
|
|
466
|
+
if (row.expiresAt <= now) {
|
|
467
|
+
// Lazy purge: opportunistic delete on stale read.
|
|
468
|
+
try {
|
|
469
|
+
await clusterStorage.execute(
|
|
470
|
+
"DELETE FROM _blamejs_cache WHERE cacheKey = ? AND expiresAt <= ?",
|
|
471
|
+
[_composedKey(key), now]
|
|
472
|
+
);
|
|
473
|
+
} catch (_e) { /* sweeper will catch it next pass */ }
|
|
474
|
+
emitObs("cache.eviction.expired", { namespace: namespace });
|
|
475
|
+
return undefined;
|
|
476
|
+
}
|
|
477
|
+
// Sliding TTL on cluster: extend by the cache's defaultTtlMs (we don't
|
|
478
|
+
// store per-row ttl). Operators with mixed-TTL writes wanting strict
|
|
479
|
+
// per-entry sliding use the memory backend or extend at app layer.
|
|
480
|
+
// Fire-and-forget — best-effort lifetime extension.
|
|
481
|
+
if (slidingTtl && defaultTtlMs !== Infinity && typeof defaultTtlMs === "number") {
|
|
482
|
+
var newExpires = now + defaultTtlMs;
|
|
483
|
+
clusterStorage.execute(
|
|
484
|
+
"UPDATE _blamejs_cache SET expiresAt = ?, updatedAt = ? " +
|
|
485
|
+
"WHERE cacheKey = ? AND expiresAt > ?",
|
|
486
|
+
[newExpires, now, _composedKey(key), now]
|
|
487
|
+
).catch(function () { /* best-effort */ });
|
|
488
|
+
}
|
|
489
|
+
try { return safeJson.parse(row.valueJson, { maxBytes: C.BYTES.mib(64) }); }
|
|
490
|
+
catch (_e) { return undefined; }
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function set(key, value, expiresAt, meta) {
|
|
494
|
+
var json = JSON.stringify(value);
|
|
495
|
+
var storedExpires = (expiresAt === Infinity) ? Number.MAX_SAFE_INTEGER : expiresAt;
|
|
496
|
+
var now = clock();
|
|
497
|
+
var ck = _composedKey(key);
|
|
498
|
+
// SQLite + Postgres both honor ON CONFLICT (cacheKey) DO UPDATE.
|
|
499
|
+
await clusterStorage.execute(
|
|
500
|
+
"INSERT INTO _blamejs_cache (cacheKey, valueJson, expiresAt, updatedAt) " +
|
|
501
|
+
"VALUES (?, ?, ?, ?) " +
|
|
502
|
+
"ON CONFLICT (cacheKey) DO UPDATE SET " +
|
|
503
|
+
"valueJson = ?, expiresAt = ?, updatedAt = ?",
|
|
504
|
+
[ck, json, storedExpires, now, json, storedExpires, now]
|
|
505
|
+
);
|
|
506
|
+
// Tag handling: drop any prior tags for this key (tags can change
|
|
507
|
+
// across sets), then INSERT the new ones. The PRIMARY KEY on
|
|
508
|
+
// (cacheKey, tag) makes the INSERT idempotent if duplicate tags
|
|
509
|
+
// sneak in.
|
|
510
|
+
var tags = meta && Array.isArray(meta.tags) ? meta.tags : null;
|
|
511
|
+
await clusterStorage.execute(
|
|
512
|
+
"DELETE FROM _blamejs_cache_tags WHERE cacheKey = ?",
|
|
513
|
+
[ck]
|
|
514
|
+
);
|
|
515
|
+
if (tags && tags.length > 0) {
|
|
516
|
+
for (var i = 0; i < tags.length; i++) {
|
|
517
|
+
await clusterStorage.execute(
|
|
518
|
+
"INSERT INTO _blamejs_cache_tags (cacheKey, tag) VALUES (?, ?) " +
|
|
519
|
+
"ON CONFLICT (cacheKey, tag) DO NOTHING",
|
|
520
|
+
[ck, tags[i]]
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async function del(key) {
|
|
527
|
+
var ck = _composedKey(key);
|
|
528
|
+
var result = await clusterStorage.execute(
|
|
529
|
+
"DELETE FROM _blamejs_cache WHERE cacheKey = ?",
|
|
530
|
+
[ck]
|
|
531
|
+
);
|
|
532
|
+
// Drop any matching tag rows. Best-effort: a stale tag row pointing
|
|
533
|
+
// at a non-existent cacheKey is dropped on the next invalidateTag
|
|
534
|
+
// sweep (by the JOIN-shape DELETE) anyway.
|
|
535
|
+
await clusterStorage.execute(
|
|
536
|
+
"DELETE FROM _blamejs_cache_tags WHERE cacheKey = ?",
|
|
537
|
+
[ck]
|
|
538
|
+
).catch(function () { /* best-effort */ });
|
|
539
|
+
return !!(result && result.rowCount && result.rowCount > 0);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async function invalidateTag(tag) {
|
|
543
|
+
// Find every cacheKey carrying the tag (namespace-scoped via the LIKE
|
|
544
|
+
// on the composed key), delete from the cache table + the junction.
|
|
545
|
+
var like = namespace + ":%";
|
|
546
|
+
var keysResult = await clusterStorage.execute(
|
|
547
|
+
"SELECT cacheKey FROM _blamejs_cache_tags WHERE tag = ? AND cacheKey LIKE ?",
|
|
548
|
+
[tag, like]
|
|
549
|
+
);
|
|
550
|
+
var keys = (keysResult && keysResult.rows) || [];
|
|
551
|
+
if (keys.length === 0) {
|
|
552
|
+
// Nothing to invalidate; still drop any orphan tag rows for
|
|
553
|
+
// this tag scoped to our namespace.
|
|
554
|
+
await clusterStorage.execute(
|
|
555
|
+
"DELETE FROM _blamejs_cache_tags WHERE tag = ? AND cacheKey LIKE ?",
|
|
556
|
+
[tag, like]
|
|
557
|
+
);
|
|
558
|
+
return 0;
|
|
559
|
+
}
|
|
560
|
+
var purged = 0;
|
|
561
|
+
for (var i = 0; i < keys.length; i++) {
|
|
562
|
+
var ck = keys[i].cacheKey;
|
|
563
|
+
var r = await clusterStorage.execute(
|
|
564
|
+
"DELETE FROM _blamejs_cache WHERE cacheKey = ?",
|
|
565
|
+
[ck]
|
|
566
|
+
);
|
|
567
|
+
if (r && r.rowCount > 0) purged += r.rowCount;
|
|
568
|
+
await clusterStorage.execute(
|
|
569
|
+
"DELETE FROM _blamejs_cache_tags WHERE cacheKey = ?",
|
|
570
|
+
[ck]
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
return purged;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async function getTags(key) {
|
|
577
|
+
var result = await clusterStorage.execute(
|
|
578
|
+
"SELECT tag FROM _blamejs_cache_tags WHERE cacheKey = ?",
|
|
579
|
+
[_composedKey(key)]
|
|
580
|
+
);
|
|
581
|
+
if (!result || !result.rows) return [];
|
|
582
|
+
return result.rows.map(function (r) { return r.tag; });
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
async function has(key) {
|
|
586
|
+
// Existence check without recency bump — cluster backend doesn't
|
|
587
|
+
// track LRU at all, so "without bumping" is automatic. Honors
|
|
588
|
+
// expiresAt the same as get().
|
|
589
|
+
var now = clock();
|
|
590
|
+
var result = await clusterStorage.execute(
|
|
591
|
+
"SELECT expiresAt FROM _blamejs_cache WHERE cacheKey = ? AND expiresAt > ?",
|
|
592
|
+
[_composedKey(key), now]
|
|
593
|
+
);
|
|
594
|
+
return !!(result && result.rows && result.rows.length > 0);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async function clear() {
|
|
598
|
+
// Namespace-scoped wipe so two CacheInstance instances sharing the
|
|
599
|
+
// table don't cross-purge each other.
|
|
600
|
+
var like = namespace + ":%";
|
|
601
|
+
var result = await clusterStorage.execute(
|
|
602
|
+
"DELETE FROM _blamejs_cache WHERE cacheKey LIKE ?",
|
|
603
|
+
[like]
|
|
604
|
+
);
|
|
605
|
+
// Drop matching tag rows in the same namespace.
|
|
606
|
+
await clusterStorage.execute(
|
|
607
|
+
"DELETE FROM _blamejs_cache_tags WHERE cacheKey LIKE ?",
|
|
608
|
+
[like]
|
|
609
|
+
).catch(function () { /* best-effort */ });
|
|
610
|
+
return (result && result.rowCount) || 0;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function size() {
|
|
614
|
+
var now = clock();
|
|
615
|
+
var like = namespace + ":%";
|
|
616
|
+
var result = await clusterStorage.execute(
|
|
617
|
+
"SELECT COUNT(*) AS n FROM _blamejs_cache WHERE cacheKey LIKE ? AND expiresAt > ?",
|
|
618
|
+
[like, now]
|
|
619
|
+
);
|
|
620
|
+
if (!result || !result.rows || result.rows.length === 0) return 0;
|
|
621
|
+
return result.rows[0].n || 0;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
async function _sweep() {
|
|
625
|
+
var now = clock();
|
|
626
|
+
var like = namespace + ":%";
|
|
627
|
+
// Capture the to-be-purged keys first so we can drop matching tag
|
|
628
|
+
// rows in the same sweep — keeps the junction table free of orphans
|
|
629
|
+
// pointing at expired cacheKeys.
|
|
630
|
+
var expiredResult = await clusterStorage.execute(
|
|
631
|
+
"SELECT cacheKey FROM _blamejs_cache WHERE cacheKey LIKE ? AND expiresAt <= ?",
|
|
632
|
+
[like, now]
|
|
633
|
+
);
|
|
634
|
+
var expiredKeys = (expiredResult && expiredResult.rows) || [];
|
|
635
|
+
await clusterStorage.execute(
|
|
636
|
+
"DELETE FROM _blamejs_cache WHERE cacheKey LIKE ? AND expiresAt <= ?",
|
|
637
|
+
[like, now]
|
|
638
|
+
);
|
|
639
|
+
for (var i = 0; i < expiredKeys.length; i++) {
|
|
640
|
+
await clusterStorage.execute(
|
|
641
|
+
"DELETE FROM _blamejs_cache_tags WHERE cacheKey = ?",
|
|
642
|
+
[expiredKeys[i].cacheKey]
|
|
643
|
+
).catch(function () { /* best-effort */ });
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function _startSweep(intervalMs) {
|
|
648
|
+
cfg._sweepTimer = safeAsync.repeating(_sweep, intervalMs, { name: "cache-sweep-cluster" });
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async function close() {
|
|
652
|
+
if (cfg._sweepTimer) { cfg._sweepTimer.stop(); cfg._sweepTimer = null; }
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
return {
|
|
656
|
+
name: "cluster",
|
|
657
|
+
get: get,
|
|
658
|
+
set: set,
|
|
659
|
+
del: del,
|
|
660
|
+
has: has,
|
|
661
|
+
clear: clear,
|
|
662
|
+
size: size,
|
|
663
|
+
close: close,
|
|
664
|
+
invalidateTag: invalidateTag,
|
|
665
|
+
getTags: getTags,
|
|
666
|
+
_startSweep: _startSweep,
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// ---- Custom backend wrapper ----
|
|
671
|
+
// Operator-supplied { get, set, del, clear, size, close } — wrap to
|
|
672
|
+
// uniform-shape (no _startSweep, _entries). The operator is responsible
|
|
673
|
+
// for their own expiration; we pass expiresAt to set().
|
|
674
|
+
|
|
675
|
+
function _customBackend(operatorBackend, cfg) {
|
|
676
|
+
return {
|
|
677
|
+
name: "custom",
|
|
678
|
+
get: function (key) { return operatorBackend.get(key); },
|
|
679
|
+
set: function (key, value, expiresAt, meta) {
|
|
680
|
+
// Older 3-arg backends remain compatible — meta is opt-in.
|
|
681
|
+
return operatorBackend.set(key, value, expiresAt, meta);
|
|
682
|
+
},
|
|
683
|
+
del: function (key) { return operatorBackend.del(key); },
|
|
684
|
+
has: function (key) {
|
|
685
|
+
// Optional has() — fall back to get-and-coerce if operator didn't
|
|
686
|
+
// implement it.
|
|
687
|
+
if (typeof operatorBackend.has === "function") return operatorBackend.has(key);
|
|
688
|
+
return Promise.resolve(operatorBackend.get(key)).then(function (v) { return v !== undefined; });
|
|
689
|
+
},
|
|
690
|
+
clear: function () { return operatorBackend.clear(); },
|
|
691
|
+
size: function () { return operatorBackend.size(); },
|
|
692
|
+
bytes: function () {
|
|
693
|
+
if (typeof operatorBackend.bytes === "function") return operatorBackend.bytes();
|
|
694
|
+
return Promise.resolve(0);
|
|
695
|
+
},
|
|
696
|
+
invalidateTag: function (tag) {
|
|
697
|
+
if (typeof operatorBackend.invalidateTag === "function") return operatorBackend.invalidateTag(tag);
|
|
698
|
+
return Promise.resolve(0);
|
|
699
|
+
},
|
|
700
|
+
getTags: function (key) {
|
|
701
|
+
if (typeof operatorBackend.getTags === "function") return operatorBackend.getTags(key);
|
|
702
|
+
return Promise.resolve(null);
|
|
703
|
+
},
|
|
704
|
+
close: function () { return operatorBackend.close(); },
|
|
705
|
+
_startSweep: function () { /* operator backend manages its own sweep */ },
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// ---- Public create ----
|
|
710
|
+
|
|
711
|
+
function create(opts) {
|
|
712
|
+
opts = opts || {};
|
|
713
|
+
validateOpts(opts, [
|
|
714
|
+
"namespace", "backend", "ttlMs", "maxEntries", "maxBytes", "sizeOf",
|
|
715
|
+
"sweepIntervalMs", "staleWhileRevalidate", "slidingTtl",
|
|
716
|
+
"auditFailures", "auditClear",
|
|
717
|
+
"audit", "observability", "clock",
|
|
718
|
+
// backend === "redis" connection options. Ignored for memory /
|
|
719
|
+
// cluster / custom-backend modes.
|
|
720
|
+
"redisUrl", "redisPassword", "redisUsername", "redisTls", "redisCa",
|
|
721
|
+
"redisServername", "redisConnectTimeoutMs", "redisCommandTimeoutMs",
|
|
722
|
+
"redisMaxReconnectAttempts",
|
|
723
|
+
// Cross-node invalidation: when set, every successful
|
|
724
|
+
// del/clear/invalidateTag publishes an event on the supplied
|
|
725
|
+
// pubsub instance. Other cache instances on other nodes (or in
|
|
726
|
+
// other processes sharing the pubsub backend) react locally —
|
|
727
|
+
// mostly useful for the memory backend so stale per-node entries
|
|
728
|
+
// don't survive a global tag wipe. The cluster + redis backends
|
|
729
|
+
// are coherent by virtue of their shared store, but a hot
|
|
730
|
+
// memory-tier on top of either still benefits.
|
|
731
|
+
"invalidationPubsub",
|
|
732
|
+
], "cache");
|
|
733
|
+
_validateCreateOpts(opts);
|
|
734
|
+
var cfg0 = validateOpts.applyDefaults(opts, DEFAULTS);
|
|
735
|
+
|
|
736
|
+
var namespace = opts.namespace;
|
|
737
|
+
var backendKind = cfg0.backend;
|
|
738
|
+
var defaultTtlMs = cfg0.ttlMs;
|
|
739
|
+
var maxEntries = cfg0.maxEntries;
|
|
740
|
+
var maxBytes = cfg0.maxBytes;
|
|
741
|
+
var sizeOf = (typeof opts.sizeOf === "function") ? opts.sizeOf : _defaultSizeOf;
|
|
742
|
+
var sweepIntervalMs = cfg0.sweepIntervalMs;
|
|
743
|
+
var staleRevalidate = cfg0.staleWhileRevalidate;
|
|
744
|
+
var slidingTtl = cfg0.slidingTtl;
|
|
745
|
+
var auditFailures = cfg0.auditFailures;
|
|
746
|
+
var auditClear = cfg0.auditClear;
|
|
747
|
+
var audit = opts.audit || null;
|
|
748
|
+
var operatorObs = opts.observability || null;
|
|
749
|
+
var clock = opts.clock || function () { return Date.now(); };
|
|
750
|
+
var invalidationPubsub = opts.invalidationPubsub || null;
|
|
751
|
+
if (invalidationPubsub && (
|
|
752
|
+
typeof invalidationPubsub.publish !== "function" ||
|
|
753
|
+
typeof invalidationPubsub.subscribe !== "function" ||
|
|
754
|
+
typeof invalidationPubsub.unsubscribe !== "function")) {
|
|
755
|
+
throw _err("BAD_OPT",
|
|
756
|
+
"cache.create: invalidationPubsub must implement { publish, subscribe, unsubscribe } (b.pubsub.create instance)");
|
|
757
|
+
}
|
|
758
|
+
var invalidationChannel = "cache:" + namespace + ":invalidate";
|
|
759
|
+
var invalidationToken = null;
|
|
760
|
+
// Re-entrancy guard — when we receive an invalidation event from
|
|
761
|
+
// another node we MUST NOT re-publish it (infinite fan-out loop).
|
|
762
|
+
var inboundInvalidation = false;
|
|
763
|
+
|
|
764
|
+
function emitObs(name, labels) {
|
|
765
|
+
try {
|
|
766
|
+
if (operatorObs) operatorObs.event(name, 1, labels || {});
|
|
767
|
+
else observability().event(name, 1, labels || {});
|
|
768
|
+
} catch (_e) { /* hot-path observability sink — drops silent on internal throws */ }
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
var emitAudit = validateOpts.makeAuditEmitter(audit);
|
|
772
|
+
|
|
773
|
+
function _actor(callerOpts) {
|
|
774
|
+
return requestHelpers.resolveActorWithOverride(callerOpts);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function _backendFailedAudit(op, err) {
|
|
778
|
+
if (!auditFailures) return;
|
|
779
|
+
emitAudit("cache.backend.failed", {
|
|
780
|
+
actor: requestHelpers.extractActorContext(null),
|
|
781
|
+
resource: { kind: "cache", id: namespace },
|
|
782
|
+
outcome: "failure",
|
|
783
|
+
reason: "backend-error",
|
|
784
|
+
metadata: { op: op, code: (err && err.code) || null, message: (err && err.message) || String(err) },
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Resolve backend
|
|
789
|
+
var cfg = {
|
|
790
|
+
namespace: namespace,
|
|
791
|
+
maxEntries: maxEntries,
|
|
792
|
+
maxBytes: maxBytes,
|
|
793
|
+
sizeOf: sizeOf,
|
|
794
|
+
slidingTtl: slidingTtl,
|
|
795
|
+
defaultTtlMs: defaultTtlMs,
|
|
796
|
+
clock: clock,
|
|
797
|
+
emitObs: emitObs,
|
|
798
|
+
_sweepTimer: null,
|
|
799
|
+
};
|
|
800
|
+
var backend;
|
|
801
|
+
if (backendKind === "memory") {
|
|
802
|
+
backend = _memoryBackend(cfg);
|
|
803
|
+
} else if (backendKind === "cluster") {
|
|
804
|
+
backend = _clusterBackend(cfg);
|
|
805
|
+
} else if (backendKind === "redis") {
|
|
806
|
+
backend = _customBackend(cacheRedis.create(Object.assign(
|
|
807
|
+
redisClient.pickClientOpts(opts, "redis"),
|
|
808
|
+
{
|
|
809
|
+
namespace: namespace,
|
|
810
|
+
slidingTtl: slidingTtl,
|
|
811
|
+
defaultTtlMs: defaultTtlMs,
|
|
812
|
+
clock: clock,
|
|
813
|
+
emitObs: emitObs,
|
|
814
|
+
}
|
|
815
|
+
)), cfg);
|
|
816
|
+
} else {
|
|
817
|
+
backend = _customBackend(opts.backend, cfg);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
backend._startSweep(sweepIntervalMs);
|
|
821
|
+
|
|
822
|
+
var closed = false;
|
|
823
|
+
function _ensureOpen(method) {
|
|
824
|
+
if (closed) {
|
|
825
|
+
throw _err("BAD_STATE", "cache." + method + ": cache instance has been closed");
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// Single-flight inflight map for wrap()
|
|
830
|
+
var inflight = new Map();
|
|
831
|
+
|
|
832
|
+
// Stale-while-revalidate tracking (per-instance, in-memory). When SWR
|
|
833
|
+
// is on, wrap() stores entries with a HARD TTL of 2× ttlMs and tracks
|
|
834
|
+
// the SOFT expiration here. Reads after soft but before hard return
|
|
835
|
+
// the cached value AND kick off a background refresh; reads after
|
|
836
|
+
// hard fall through to a normal miss + compute. The soft-TTL map is
|
|
837
|
+
// memory-only even when the backend is cluster — refreshes are a
|
|
838
|
+
// best-effort optimization, not a correctness invariant, so a cache
|
|
839
|
+
// miss after restart (no soft data) just means we serve fresh once.
|
|
840
|
+
var softExpiry = new Map(); // key → softExpiresAt
|
|
841
|
+
var swrInflight = new Map(); // key → background-refresh promise
|
|
842
|
+
var SWR_HARD_MULTIPLIER = 2;
|
|
843
|
+
|
|
844
|
+
// ---- Public methods ----
|
|
845
|
+
|
|
846
|
+
function _resolveTtl(callerOpts, methodName) {
|
|
847
|
+
if (callerOpts && callerOpts.ttlMs !== undefined) {
|
|
848
|
+
_validateTtl("cache." + methodName + ": ttlMs", callerOpts.ttlMs);
|
|
849
|
+
return callerOpts.ttlMs;
|
|
850
|
+
}
|
|
851
|
+
return defaultTtlMs;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
async function get(key) {
|
|
855
|
+
_ensureOpen("get");
|
|
856
|
+
_validateKey(key, "cache.get");
|
|
857
|
+
var v;
|
|
858
|
+
try { v = await backend.get(key); }
|
|
859
|
+
catch (e) {
|
|
860
|
+
emitObs("cache.backend.failed", { namespace: namespace, op: "get" });
|
|
861
|
+
_backendFailedAudit("get", e);
|
|
862
|
+
throw e;
|
|
863
|
+
}
|
|
864
|
+
if (v === undefined) emitObs("cache.miss", { namespace: namespace });
|
|
865
|
+
else emitObs("cache.hit", { namespace: namespace });
|
|
866
|
+
return v;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
async function set(key, value, callerOpts) {
|
|
870
|
+
_ensureOpen("set");
|
|
871
|
+
_validateKey(key, "cache.set");
|
|
872
|
+
var ttlMs = _resolveTtl(callerOpts, "set");
|
|
873
|
+
if (ttlMs === 0) return; // 0 means "do not cache"
|
|
874
|
+
var expiresAt = (ttlMs === Infinity) ? Infinity : (clock() + ttlMs);
|
|
875
|
+
var tags = (callerOpts && Array.isArray(callerOpts.tags)) ? callerOpts.tags : null;
|
|
876
|
+
if (tags) {
|
|
877
|
+
for (var i = 0; i < tags.length; i++) {
|
|
878
|
+
if (typeof tags[i] !== "string" || tags[i].length === 0) {
|
|
879
|
+
throw _err("BAD_OPT", "cache.set: tags must be an array of non-empty strings");
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
try { await backend.set(key, value, expiresAt, { ttlMs: ttlMs, tags: tags }); }
|
|
884
|
+
catch (e) {
|
|
885
|
+
emitObs("cache.backend.failed", { namespace: namespace, op: "set" });
|
|
886
|
+
_backendFailedAudit("set", e);
|
|
887
|
+
throw e;
|
|
888
|
+
}
|
|
889
|
+
emitObs("cache.set", { namespace: namespace });
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
async function del(key) {
|
|
893
|
+
_ensureOpen("del");
|
|
894
|
+
_validateKey(key, "cache.del");
|
|
895
|
+
var existed;
|
|
896
|
+
try { existed = await backend.del(key); }
|
|
897
|
+
catch (e) {
|
|
898
|
+
emitObs("cache.backend.failed", { namespace: namespace, op: "del" });
|
|
899
|
+
_backendFailedAudit("del", e);
|
|
900
|
+
throw e;
|
|
901
|
+
}
|
|
902
|
+
if (existed) emitObs("cache.del", { namespace: namespace });
|
|
903
|
+
softExpiry.delete(key);
|
|
904
|
+
_publishInvalidation({ kind: "del", key: key });
|
|
905
|
+
return existed;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
async function has(key) {
|
|
909
|
+
_ensureOpen("has");
|
|
910
|
+
_validateKey(key, "cache.has");
|
|
911
|
+
try { return await backend.has(key); }
|
|
912
|
+
catch (e) {
|
|
913
|
+
emitObs("cache.backend.failed", { namespace: namespace, op: "has" });
|
|
914
|
+
_backendFailedAudit("has", e);
|
|
915
|
+
throw e;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
async function clear(callerOpts) {
|
|
920
|
+
_ensureOpen("clear");
|
|
921
|
+
var purged;
|
|
922
|
+
try { purged = await backend.clear(); }
|
|
923
|
+
catch (e) {
|
|
924
|
+
emitObs("cache.backend.failed", { namespace: namespace, op: "clear" });
|
|
925
|
+
_backendFailedAudit("clear", e);
|
|
926
|
+
throw e;
|
|
927
|
+
}
|
|
928
|
+
emitObs("cache.clear", { namespace: namespace });
|
|
929
|
+
if (auditClear) {
|
|
930
|
+
emitAudit("cache.cleared", {
|
|
931
|
+
actor: _actor(callerOpts),
|
|
932
|
+
resource: { kind: "cache", id: namespace },
|
|
933
|
+
outcome: "success",
|
|
934
|
+
metadata: { itemCount: purged },
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
// Drop any in-flight wrap promises — operator clear means "consumers
|
|
938
|
+
// should re-fetch", and in-flight resolves would seed stale entries
|
|
939
|
+
// post-clear.
|
|
940
|
+
inflight.clear();
|
|
941
|
+
swrInflight.clear();
|
|
942
|
+
softExpiry.clear();
|
|
943
|
+
_publishInvalidation({ kind: "clear" });
|
|
944
|
+
return purged;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
async function size() {
|
|
948
|
+
_ensureOpen("size");
|
|
949
|
+
try { return await backend.size(); }
|
|
950
|
+
catch (e) {
|
|
951
|
+
emitObs("cache.backend.failed", { namespace: namespace, op: "size" });
|
|
952
|
+
_backendFailedAudit("size", e);
|
|
953
|
+
throw e;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
async function bytes() {
|
|
958
|
+
_ensureOpen("bytes");
|
|
959
|
+
try {
|
|
960
|
+
if (typeof backend.bytes !== "function") return 0;
|
|
961
|
+
return await backend.bytes();
|
|
962
|
+
} catch (e) {
|
|
963
|
+
emitObs("cache.backend.failed", { namespace: namespace, op: "bytes" });
|
|
964
|
+
_backendFailedAudit("bytes", e);
|
|
965
|
+
throw e;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
async function invalidateTag(tag, callerOpts) {
|
|
970
|
+
_ensureOpen("invalidateTag");
|
|
971
|
+
if (typeof tag !== "string" || tag.length === 0) {
|
|
972
|
+
throw _err("BAD_OPT", "cache.invalidateTag: tag must be a non-empty string");
|
|
973
|
+
}
|
|
974
|
+
if (typeof backend.invalidateTag !== "function") {
|
|
975
|
+
throw _err("NOT_SUPPORTED",
|
|
976
|
+
"cache.invalidateTag: backend '" + (backend.name || "custom") +
|
|
977
|
+
"' does not implement invalidateTag. Operator-supplied custom backends " +
|
|
978
|
+
"must export invalidateTag(tag) → number to participate in tag-based wipes.");
|
|
979
|
+
}
|
|
980
|
+
var purged;
|
|
981
|
+
try { purged = await backend.invalidateTag(tag); }
|
|
982
|
+
catch (e) {
|
|
983
|
+
emitObs("cache.backend.failed", { namespace: namespace, op: "invalidateTag" });
|
|
984
|
+
_backendFailedAudit("invalidateTag", e);
|
|
985
|
+
throw e;
|
|
986
|
+
}
|
|
987
|
+
emitObs("cache.tag.invalidated", { namespace: namespace, tag: tag });
|
|
988
|
+
if (auditClear && purged > 0) {
|
|
989
|
+
emitAudit("cache.tag.invalidated", {
|
|
990
|
+
actor: _actor(callerOpts),
|
|
991
|
+
resource: { kind: "cache.tag", id: namespace + ":" + tag },
|
|
992
|
+
outcome: "success",
|
|
993
|
+
metadata: { tag: tag, itemCount: purged },
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
// Drop in-flight wrap promises whose key WOULD have just been
|
|
997
|
+
// invalidated. We don't track per-key tags inflight, so a coarse
|
|
998
|
+
// drop matches clear()'s safer-than-stale posture.
|
|
999
|
+
inflight.clear();
|
|
1000
|
+
swrInflight.clear();
|
|
1001
|
+
_publishInvalidation({ kind: "tag", tag: tag });
|
|
1002
|
+
return purged;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
async function getTags(key) {
|
|
1006
|
+
_ensureOpen("getTags");
|
|
1007
|
+
_validateKey(key, "cache.getTags");
|
|
1008
|
+
if (typeof backend.getTags !== "function") return null;
|
|
1009
|
+
try { return await backend.getTags(key); }
|
|
1010
|
+
catch (e) {
|
|
1011
|
+
emitObs("cache.backend.failed", { namespace: namespace, op: "getTags" });
|
|
1012
|
+
_backendFailedAudit("getTags", e);
|
|
1013
|
+
throw e;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function _backgroundRefresh(key, fn, ttlMs) {
|
|
1018
|
+
if (swrInflight.has(key)) return; // already refreshing
|
|
1019
|
+
var p = (async function () {
|
|
1020
|
+
var startedAt = clock();
|
|
1021
|
+
var computed;
|
|
1022
|
+
try { computed = await fn(); }
|
|
1023
|
+
finally {
|
|
1024
|
+
emitObs("cache.wrap.compute", { namespace: namespace, ms: clock() - startedAt });
|
|
1025
|
+
}
|
|
1026
|
+
var expiresAt = _writeWithSwr(key, computed, ttlMs);
|
|
1027
|
+
void expiresAt;
|
|
1028
|
+
return computed;
|
|
1029
|
+
})();
|
|
1030
|
+
swrInflight.set(key, p);
|
|
1031
|
+
p.then(
|
|
1032
|
+
function () { swrInflight.delete(key); },
|
|
1033
|
+
function (_e) {
|
|
1034
|
+
swrInflight.delete(key);
|
|
1035
|
+
// Background refresh failed; stale value already served. Surface
|
|
1036
|
+
// via observability so operators see it without breaking the
|
|
1037
|
+
// request that triggered the refresh.
|
|
1038
|
+
emitObs("cache.refresh.failed", { namespace: namespace });
|
|
1039
|
+
}
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function _writeWithSwr(key, value, ttlMs) {
|
|
1044
|
+
if (ttlMs === 0) return null; // 0 means "do not cache"
|
|
1045
|
+
var now = clock();
|
|
1046
|
+
var hardTtlMs = (ttlMs === Infinity)
|
|
1047
|
+
? Infinity
|
|
1048
|
+
: (staleRevalidate ? ttlMs * SWR_HARD_MULTIPLIER : ttlMs);
|
|
1049
|
+
var expiresAt = (hardTtlMs === Infinity) ? Infinity : (now + hardTtlMs);
|
|
1050
|
+
if (staleRevalidate && ttlMs !== Infinity) {
|
|
1051
|
+
softExpiry.set(key, now + ttlMs);
|
|
1052
|
+
} else {
|
|
1053
|
+
softExpiry.delete(key);
|
|
1054
|
+
}
|
|
1055
|
+
// Backend write — failure surfaces via observability + audit but
|
|
1056
|
+
// doesn't bubble (caller already has the computed value).
|
|
1057
|
+
backend.set(key, value, expiresAt, { ttlMs: ttlMs }).catch(function (e) {
|
|
1058
|
+
emitObs("cache.backend.failed", { namespace: namespace, op: "set" });
|
|
1059
|
+
_backendFailedAudit("set", e);
|
|
1060
|
+
});
|
|
1061
|
+
return expiresAt;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
async function wrap(key, fn, callerOpts) {
|
|
1065
|
+
_ensureOpen("wrap");
|
|
1066
|
+
_validateKey(key, "cache.wrap");
|
|
1067
|
+
if (typeof fn !== "function") {
|
|
1068
|
+
throw _err("BAD_OPT", "cache.wrap: fn must be a function, got " + typeof fn);
|
|
1069
|
+
}
|
|
1070
|
+
var ttlMs = _resolveTtl(callerOpts, "wrap");
|
|
1071
|
+
var singleFlight = !(callerOpts && callerOpts.singleFlight === false);
|
|
1072
|
+
|
|
1073
|
+
var existing;
|
|
1074
|
+
try { existing = await backend.get(key); }
|
|
1075
|
+
catch (e) {
|
|
1076
|
+
emitObs("cache.backend.failed", { namespace: namespace, op: "get" });
|
|
1077
|
+
_backendFailedAudit("get", e);
|
|
1078
|
+
throw e;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
if (existing !== undefined) {
|
|
1082
|
+
// SWR: served from backend, but might be stale (past soft TTL).
|
|
1083
|
+
var soft = softExpiry.get(key);
|
|
1084
|
+
var now = clock();
|
|
1085
|
+
if (staleRevalidate && soft !== undefined && soft <= now) {
|
|
1086
|
+
emitObs("cache.hit", { namespace: namespace });
|
|
1087
|
+
_backgroundRefresh(key, fn, ttlMs);
|
|
1088
|
+
return existing;
|
|
1089
|
+
}
|
|
1090
|
+
emitObs("cache.hit", { namespace: namespace });
|
|
1091
|
+
return existing;
|
|
1092
|
+
}
|
|
1093
|
+
emitObs("cache.miss", { namespace: namespace });
|
|
1094
|
+
|
|
1095
|
+
if (singleFlight && inflight.has(key)) {
|
|
1096
|
+
emitObs("cache.wrap.singleflight.collapsed", { namespace: namespace });
|
|
1097
|
+
return inflight.get(key);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
var promise = (async function () {
|
|
1101
|
+
var startedAt = clock();
|
|
1102
|
+
var computed;
|
|
1103
|
+
try { computed = await fn(); }
|
|
1104
|
+
finally {
|
|
1105
|
+
emitObs("cache.wrap.compute", { namespace: namespace, ms: clock() - startedAt });
|
|
1106
|
+
}
|
|
1107
|
+
if (ttlMs !== 0) {
|
|
1108
|
+
if (staleRevalidate) {
|
|
1109
|
+
_writeWithSwr(key, computed, ttlMs);
|
|
1110
|
+
} else {
|
|
1111
|
+
var expiresAt = (ttlMs === Infinity) ? Infinity : (clock() + ttlMs);
|
|
1112
|
+
try { await backend.set(key, computed, expiresAt, { ttlMs: ttlMs }); }
|
|
1113
|
+
catch (e) {
|
|
1114
|
+
emitObs("cache.backend.failed", { namespace: namespace, op: "set" });
|
|
1115
|
+
_backendFailedAudit("set", e);
|
|
1116
|
+
// Failed write doesn't fail the wrap — caller still gets the
|
|
1117
|
+
// computed value; cache just didn't persist.
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
return computed;
|
|
1122
|
+
})();
|
|
1123
|
+
if (singleFlight) {
|
|
1124
|
+
inflight.set(key, promise);
|
|
1125
|
+
promise.then(
|
|
1126
|
+
function () { inflight.delete(key); },
|
|
1127
|
+
function () { inflight.delete(key); }
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
return promise;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function _publishInvalidation(ev) {
|
|
1134
|
+
if (!invalidationPubsub || inboundInvalidation) return;
|
|
1135
|
+
try { invalidationPubsub.publish(invalidationChannel, ev); }
|
|
1136
|
+
catch (_e) { /* publish best-effort — local invalidation already happened */ }
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
async function _onInboundInvalidation(ev /*, meta */) {
|
|
1140
|
+
if (!ev || closed) return;
|
|
1141
|
+
inboundInvalidation = true;
|
|
1142
|
+
try {
|
|
1143
|
+
if (ev.kind === "tag" && typeof ev.tag === "string" &&
|
|
1144
|
+
typeof backend.invalidateTag === "function") {
|
|
1145
|
+
try { await backend.invalidateTag(ev.tag); }
|
|
1146
|
+
catch (e) { log.debug("invalidation-apply-failed", { op: "invalidateTag", tag: ev.tag, error: e.message }); }
|
|
1147
|
+
} else if (ev.kind === "del" && typeof ev.key === "string") {
|
|
1148
|
+
try { await backend.del(ev.key); }
|
|
1149
|
+
catch (e) { log.debug("invalidation-apply-failed", { op: "del", key: ev.key, error: e.message }); }
|
|
1150
|
+
} else if (ev.kind === "clear") {
|
|
1151
|
+
try { await backend.clear(); }
|
|
1152
|
+
catch (e) { log.debug("invalidation-apply-failed", { op: "clear", error: e.message }); }
|
|
1153
|
+
}
|
|
1154
|
+
// Wipe local in-flight memoization so a freshly-invalidated key
|
|
1155
|
+
// can't resolve from a still-pending fetch on this node.
|
|
1156
|
+
inflight.clear();
|
|
1157
|
+
swrInflight.clear();
|
|
1158
|
+
softExpiry.clear();
|
|
1159
|
+
} finally {
|
|
1160
|
+
inboundInvalidation = false;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
if (invalidationPubsub) {
|
|
1165
|
+
invalidationToken = invalidationPubsub.subscribe(invalidationChannel, _onInboundInvalidation);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
async function close() {
|
|
1169
|
+
if (closed) return;
|
|
1170
|
+
closed = true;
|
|
1171
|
+
if (invalidationPubsub && invalidationToken) {
|
|
1172
|
+
try { invalidationPubsub.unsubscribe(invalidationToken); }
|
|
1173
|
+
catch (e) { log.debug("close-cleanup-failed", { op: "unsubscribe", error: e.message }); }
|
|
1174
|
+
invalidationToken = null;
|
|
1175
|
+
}
|
|
1176
|
+
inflight.clear();
|
|
1177
|
+
swrInflight.clear();
|
|
1178
|
+
softExpiry.clear();
|
|
1179
|
+
try { await backend.close(); }
|
|
1180
|
+
catch (_e) { /* close best-effort */ }
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
return {
|
|
1184
|
+
get: get,
|
|
1185
|
+
set: set,
|
|
1186
|
+
del: del,
|
|
1187
|
+
has: has,
|
|
1188
|
+
clear: clear,
|
|
1189
|
+
size: size,
|
|
1190
|
+
bytes: bytes,
|
|
1191
|
+
wrap: wrap,
|
|
1192
|
+
invalidateTag: invalidateTag,
|
|
1193
|
+
getTags: getTags,
|
|
1194
|
+
close: close,
|
|
1195
|
+
namespace: namespace,
|
|
1196
|
+
// Test hooks
|
|
1197
|
+
_backend: backend,
|
|
1198
|
+
_inflight: inflight,
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
module.exports = {
|
|
1203
|
+
create: create,
|
|
1204
|
+
CacheError: CacheError,
|
|
1205
|
+
DEFAULTS: DEFAULTS,
|
|
1206
|
+
};
|