@veryfront/ext-redis 0.1.1185 → 0.1.1189

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.
@@ -0,0 +1,778 @@
1
+ import { logger as baseLogger } from "veryfront/utils/logger";
2
+ import { SpanNames } from "veryfront/observability";
3
+ import { withSpan } from "veryfront/observability/otlp-setup";
4
+ import { isProxy as isProxyWithoutHooks } from "node:util/types";
5
+ import { disconnectRedisClient, getRedisClient, isRedisConfigured, } from "./redis-client-manager.js";
6
+ import { assertCacheBatchSize, assertCacheReadMaximumBytes, assertCacheValueWithinLimit, buildBatchResults, CacheValueTooLargeError, DEFAULT_CACHE_TTL_SECONDS, escapeCacheGlobLiteral, expiresImmediately, isRevisionedCacheKey, resolveIntegerCacheTtlSeconds, REVISIONED_CACHE_KEY_PREFIX, validateDistributedCacheKeyPrefix, } from "veryfront/extensions/distributed/cache-support";
7
+ import { parseRedisRevisionExchangeResult, parseRedisRevisionReadResult, parseRevisionedCacheRecord, } from "./revisioned-cache-record.js";
8
+ const logger = baseLogger.component("redis-cache-backend");
9
+ const REDIS_PATTERN_DELETE_SCAN_COUNT = 100;
10
+ const REDIS_PATTERN_DELETE_BATCH_SIZE = 1_000;
11
+ const MAX_REDIS_PATTERN_DELETE_KEYS = 100_000;
12
+ const MAX_REDIS_SCAN_ITERATIONS = 1_000_000;
13
+ const ATOMIC_COUNTER_KEY_PREFIX = "\0vf:cache:atomic:v1:counter:";
14
+ const ATOMIC_TOMBSTONE_TTL_MS = 300_000;
15
+ const MAX_SIGNED_REDIS_INTEGER = "9223372036854775807";
16
+ const REDIS_BOUNDED_GET_SCRIPT = String.raw `
17
+ if #KEYS ~= 1 or #ARGV ~= 2 then
18
+ error('Veryfront bounded cache read received invalid inputs', 0)
19
+ end
20
+ local limit = tonumber(ARGV[1])
21
+ if limit == nil or limit < 0 or limit ~= math.floor(limit) then
22
+ error('Veryfront bounded cache read limit is invalid', 0)
23
+ end
24
+ local mode = ARGV[2]
25
+ if mode ~= 'ordinary' and mode ~= 'revisioned' then
26
+ error('Veryfront bounded cache read mode is invalid', 0)
27
+ end
28
+ local size = redis.call('STRLEN', KEYS[1])
29
+ if size == 0 and redis.call('EXISTS', KEYS[1]) == 0 then return {0} end
30
+ if mode == 'ordinary' then
31
+ if size > limit then return {2, tostring(size)} end
32
+ return {1, redis.call('GET', KEYS[1])}
33
+ end
34
+
35
+ local nul = string.char(0)
36
+ local frame_prefix = nul .. 'VFCAS1' .. nul
37
+ local max_revision = '9223372036854775807'
38
+ local header = redis.call(
39
+ 'GETRANGE',
40
+ KEYS[1],
41
+ 0,
42
+ #frame_prefix + 2 + #max_revision
43
+ )
44
+ if string.sub(header, 1, #frame_prefix) ~= frame_prefix then
45
+ error('Veryfront revisioned cache record is malformed', 0)
46
+ end
47
+ local state_index = #frame_prefix + 1
48
+ local state = string.sub(header, state_index, state_index)
49
+ if (state ~= 'p' and state ~= 'a') or
50
+ string.sub(header, state_index + 1, state_index + 1) ~= nul then
51
+ error('Veryfront revisioned cache record state is malformed', 0)
52
+ end
53
+ local revision_start = state_index + 2
54
+ local revision_end = string.find(header, nul, revision_start, true)
55
+ if revision_end == nil then
56
+ error('Veryfront revisioned cache record revision is malformed', 0)
57
+ end
58
+ local revision = string.sub(header, revision_start, revision_end - 1)
59
+ if string.match(revision, '^[1-9][0-9]*$') == nil or
60
+ #revision > #max_revision or
61
+ (#revision == #max_revision and revision > max_revision) then
62
+ error('Veryfront revisioned cache record revision is invalid', 0)
63
+ end
64
+ local payload_size = size - revision_end
65
+ if state == 'a' then
66
+ if payload_size ~= 0 then
67
+ error('Veryfront absent revisioned cache record contains a payload', 0)
68
+ end
69
+ return {0}
70
+ end
71
+ if payload_size > limit then return {2, tostring(payload_size)} end
72
+ return {1, redis.call('GETRANGE', KEYS[1], revision_end, -1)}
73
+ `;
74
+ const LUA_RECORD_LIBRARY = String.raw `
75
+ local nul = string.char(0)
76
+ local frame_prefix = nul .. 'VFCAS1' .. nul
77
+ local max_counter = '9223372036854775807'
78
+
79
+ local function fail(message)
80
+ error(message, 0)
81
+ end
82
+
83
+ local function is_canonical_decimal(value, allow_zero, maximum)
84
+ if type(value) ~= 'string' then return false end
85
+ if value == '0' then return allow_zero end
86
+ if string.match(value, '^[1-9][0-9]*$') == nil then return false end
87
+ if #value > #maximum then return false end
88
+ if #value == #maximum and value > maximum then return false end
89
+ return true
90
+ end
91
+
92
+ local function decimal_lte(left, right)
93
+ if #left ~= #right then return #left < #right end
94
+ return left <= right
95
+ end
96
+
97
+ local function require_counter()
98
+ local counter = redis.call('GET', KEYS[2])
99
+ if not is_canonical_decimal(counter, true, max_counter) then
100
+ fail('Veryfront atomic counter is missing or malformed')
101
+ end
102
+ if redis.call('TTL', KEYS[2]) ~= -1 then
103
+ fail('Veryfront atomic counter must not expire')
104
+ end
105
+ return counter
106
+ end
107
+
108
+ local function allocate_revision()
109
+ redis.call('INCR', KEYS[2])
110
+ local revision = redis.call('GET', KEYS[2])
111
+ if not is_canonical_decimal(revision, false, max_counter) then
112
+ fail('Veryfront atomic counter did not produce a valid revision')
113
+ end
114
+ return revision
115
+ end
116
+
117
+ local function parse_record(raw, counter)
118
+ if type(raw) ~= 'string' or string.sub(raw, 1, #frame_prefix) ~= frame_prefix then
119
+ fail('Veryfront revisioned cache record is malformed')
120
+ end
121
+ local state_index = #frame_prefix + 1
122
+ local state = string.sub(raw, state_index, state_index)
123
+ if (state ~= 'p' and state ~= 'a') or string.sub(raw, state_index + 1, state_index + 1) ~= nul then
124
+ fail('Veryfront revisioned cache record state is malformed')
125
+ end
126
+ local revision_start = state_index + 2
127
+ local revision_end = string.find(raw, nul, revision_start, true)
128
+ if revision_end == nil then fail('Veryfront revisioned cache record revision is malformed') end
129
+ local revision = string.sub(raw, revision_start, revision_end - 1)
130
+ if not is_canonical_decimal(revision, false, max_counter) or not decimal_lte(revision, counter) then
131
+ fail('Veryfront revisioned cache record revision is invalid')
132
+ end
133
+ local payload = string.sub(raw, revision_end + 1)
134
+ if state == 'a' and #payload ~= 0 then
135
+ fail('Veryfront absent revisioned cache record contains a payload')
136
+ end
137
+ return state, revision, payload
138
+ end
139
+
140
+ local function absent_frame(revision)
141
+ return frame_prefix .. 'a' .. nul .. revision .. nul
142
+ end
143
+
144
+ local function present_frame(revision, payload)
145
+ return frame_prefix .. 'p' .. nul .. revision .. nul .. payload
146
+ end
147
+ `;
148
+ const REDIS_REVISION_READ_SCRIPT = `${LUA_RECORD_LIBRARY}
149
+ if #KEYS ~= 2 or #ARGV ~= 0 then fail('Veryfront revision read received invalid inputs') end
150
+ local counter = require_counter()
151
+ local raw = redis.call('GET', KEYS[1])
152
+ if raw == false then
153
+ local revision = allocate_revision()
154
+ redis.call('SET', KEYS[1], absent_frame(revision), 'PX', '${ATOMIC_TOMBSTONE_TTL_MS}')
155
+ return {0, revision}
156
+ end
157
+ local state, revision, payload = parse_record(raw, counter)
158
+ if state == 'a' then return {0, revision} end
159
+ return {1, revision, payload}
160
+ `;
161
+ const REDIS_REVISION_EXCHANGE_SCRIPT = `${LUA_RECORD_LIBRARY}
162
+ if #KEYS ~= 2 then fail('Veryfront revision exchange received invalid keys') end
163
+ if #ARGV < 2 then fail('Veryfront revision exchange received invalid arguments') end
164
+ local expected = ARGV[1]
165
+ local operation = ARGV[2]
166
+ if not is_canonical_decimal(expected, false, max_counter) then
167
+ fail('Veryfront revision exchange expected revision is invalid')
168
+ end
169
+ if operation == 'd' then
170
+ if #ARGV ~= 2 then fail('Veryfront delete mutation received invalid arguments') end
171
+ elseif operation == 's' then
172
+ if #ARGV ~= 4 or type(ARGV[3]) ~= 'string' then
173
+ fail('Veryfront set mutation received invalid arguments')
174
+ end
175
+ local max_safe_integer = '9007199254740991'
176
+ if not is_canonical_decimal(ARGV[4], false, max_safe_integer) then
177
+ fail('Veryfront set mutation deadline is invalid')
178
+ end
179
+ else
180
+ fail('Veryfront revision exchange operation is invalid')
181
+ end
182
+
183
+ local counter = require_counter()
184
+ local raw = redis.call('GET', KEYS[1])
185
+ if raw == false then
186
+ local revision = allocate_revision()
187
+ redis.call('SET', KEYS[1], absent_frame(revision), 'PX', '${ATOMIC_TOMBSTONE_TTL_MS}')
188
+ return 0
189
+ end
190
+ local _, current_revision = parse_record(raw, counter)
191
+ if current_revision ~= expected then return 0 end
192
+
193
+ local revision = allocate_revision()
194
+ if operation == 'd' then
195
+ redis.call('SET', KEYS[1], absent_frame(revision), 'PX', '${ATOMIC_TOMBSTONE_TTL_MS}')
196
+ return 1
197
+ end
198
+
199
+ local server_time = redis.call('TIME')
200
+ local now_ms = (tonumber(server_time[1]) * 1000) + math.floor(tonumber(server_time[2]) / 1000)
201
+ local deadline_ms = tonumber(ARGV[4])
202
+ if deadline_ms <= now_ms then
203
+ redis.call('SET', KEYS[1], absent_frame(revision), 'PX', '${ATOMIC_TOMBSTONE_TTL_MS}')
204
+ else
205
+ redis.call('SET', KEYS[1], present_frame(revision, ARGV[3]), 'PXAT', ARGV[4])
206
+ end
207
+ return 1
208
+ `;
209
+ export const REDIS_LOGICAL_DELETE_SCRIPT = `${LUA_RECORD_LIBRARY}
210
+ if #KEYS == 0 or #ARGV ~= 2 or ARGV[2] ~= 'vf-logical-delete-v1' then
211
+ fail('Veryfront logical deletion received invalid inputs')
212
+ end
213
+ local classifications = ARGV[1]
214
+ if #classifications ~= #KEYS or string.match(classifications, '^[01]+$') == nil then
215
+ fail('Veryfront logical deletion classifications are invalid')
216
+ end
217
+ local live = 0
218
+ for index, key in ipairs(KEYS) do
219
+ local raw = redis.call('GET', key)
220
+ if raw ~= false then
221
+ local is_reserved = string.sub(classifications, index, index) == '1'
222
+ if is_reserved then
223
+ if string.sub(raw, 1, #frame_prefix) ~= frame_prefix then
224
+ fail('Veryfront revisioned cache record is malformed')
225
+ end
226
+ local state_index = #frame_prefix + 1
227
+ local state = string.sub(raw, state_index, state_index)
228
+ if (state ~= 'p' and state ~= 'a') or string.sub(raw, state_index + 1, state_index + 1) ~= nul then
229
+ fail('Veryfront revisioned cache record state is malformed')
230
+ end
231
+ local revision_start = state_index + 2
232
+ local revision_end = string.find(raw, nul, revision_start, true)
233
+ if revision_end == nil then fail('Veryfront revisioned cache record revision is malformed') end
234
+ local revision = string.sub(raw, revision_start, revision_end - 1)
235
+ if not is_canonical_decimal(revision, false, max_counter) then
236
+ fail('Veryfront revisioned cache record revision is invalid')
237
+ end
238
+ local payload = string.sub(raw, revision_end + 1)
239
+ if state == 'a' and #payload ~= 0 then
240
+ fail('Veryfront absent revisioned cache record contains a payload')
241
+ end
242
+ if state == 'p' then live = live + 1 end
243
+ else
244
+ live = live + 1
245
+ end
246
+ end
247
+ end
248
+ redis.call('DEL', unpack(KEYS))
249
+ return live
250
+ `;
251
+ function readStrictInfoField(info, field) {
252
+ if (typeof info !== "string")
253
+ return null;
254
+ let result = null;
255
+ for (const line of info.split(/\r?\n/)) {
256
+ if (!line.startsWith(`${field}:`))
257
+ continue;
258
+ if (result !== null)
259
+ return null;
260
+ const value = line.slice(field.length + 1);
261
+ if (value.length === 0 || !/^[!-~]+$/.test(value))
262
+ return null;
263
+ result = value;
264
+ }
265
+ return result;
266
+ }
267
+ function isCanonicalCounter(value) {
268
+ return typeof value === "string" &&
269
+ /^(0|[1-9]\d*)$/.test(value) &&
270
+ value.length <= MAX_SIGNED_REDIS_INTEGER.length &&
271
+ (value.length < MAX_SIGNED_REDIS_INTEGER.length || value <= MAX_SIGNED_REDIS_INTEGER);
272
+ }
273
+ function isRevisionedCachePrefixOwned(key) {
274
+ return key.startsWith(REVISIONED_CACHE_KEY_PREFIX);
275
+ }
276
+ function parseRedisBoundedReadResult(value, maximumBytes) {
277
+ if (!Array.isArray(value) || isProxyWithoutHooks(value)) {
278
+ throw new TypeError("Redis bounded cache read returned an invalid result");
279
+ }
280
+ const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length");
281
+ const firstDescriptor = Object.getOwnPropertyDescriptor(value, "0");
282
+ const secondDescriptor = Object.getOwnPropertyDescriptor(value, "1");
283
+ const length = lengthDescriptor && "value" in lengthDescriptor
284
+ ? lengthDescriptor.value
285
+ : undefined;
286
+ const tag = firstDescriptor && "value" in firstDescriptor ? firstDescriptor.value : undefined;
287
+ if (!Number.isSafeInteger(length) || (length !== 1 && length !== 2)) {
288
+ throw new TypeError("Redis bounded cache read returned an invalid result");
289
+ }
290
+ const expectedKeys = length === 1 ? ["0", "length"] : ["0", "1", "length"];
291
+ const keys = Reflect.ownKeys(value);
292
+ if (keys.length !== expectedKeys.length || !expectedKeys.every((key) => keys.includes(key))) {
293
+ throw new TypeError("Redis bounded cache read returned an invalid result");
294
+ }
295
+ if (tag === 0 && length === 1)
296
+ return { kind: "missing" };
297
+ if (tag === 1 &&
298
+ length === 2 &&
299
+ secondDescriptor &&
300
+ "value" in secondDescriptor &&
301
+ typeof secondDescriptor.value === "string") {
302
+ return { kind: "present", value: secondDescriptor.value };
303
+ }
304
+ if (tag === 2 &&
305
+ length === 2 &&
306
+ secondDescriptor &&
307
+ "value" in secondDescriptor &&
308
+ typeof secondDescriptor.value === "string" &&
309
+ /^[1-9]\d*$/.test(secondDescriptor.value) &&
310
+ Number.isSafeInteger(Number(secondDescriptor.value)) &&
311
+ Number(secondDescriptor.value) > maximumBytes) {
312
+ return { kind: "oversized" };
313
+ }
314
+ throw new TypeError("Redis bounded cache read returned an invalid result");
315
+ }
316
+ function requireRedisRevisionMutation(value) {
317
+ try {
318
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
319
+ throw new TypeError();
320
+ }
321
+ const descriptors = Object.getOwnPropertyDescriptors(value);
322
+ const keys = Reflect.ownKeys(descriptors);
323
+ const kindDescriptor = descriptors.kind;
324
+ if (!kindDescriptor || !Object.hasOwn(kindDescriptor, "value")) {
325
+ throw new TypeError();
326
+ }
327
+ if (kindDescriptor.value === "delete") {
328
+ if (keys.length !== 1 || keys[0] !== "kind")
329
+ throw new TypeError();
330
+ return Object.freeze({ kind: "delete" });
331
+ }
332
+ if (kindDescriptor.value !== "set" || keys.length !== 3)
333
+ throw new TypeError();
334
+ if (!keys.includes("kind") || !keys.includes("value") || !keys.includes("expiresAtMs")) {
335
+ throw new TypeError();
336
+ }
337
+ const valueDescriptor = descriptors.value;
338
+ const deadlineDescriptor = descriptors.expiresAtMs;
339
+ if (!valueDescriptor ||
340
+ !deadlineDescriptor ||
341
+ !Object.hasOwn(valueDescriptor, "value") ||
342
+ !Object.hasOwn(deadlineDescriptor, "value") ||
343
+ typeof valueDescriptor.value !== "string" ||
344
+ typeof deadlineDescriptor.value !== "number" ||
345
+ !Number.isSafeInteger(deadlineDescriptor.value) ||
346
+ deadlineDescriptor.value <= 0) {
347
+ throw new TypeError();
348
+ }
349
+ return Object.freeze({
350
+ kind: "set",
351
+ value: valueDescriptor.value,
352
+ expiresAtMs: deadlineDescriptor.value,
353
+ });
354
+ }
355
+ catch {
356
+ throw new TypeError("Redis revision mutation has an invalid runtime shape");
357
+ }
358
+ }
359
+ function isAtomicRedisTopology(serverInfo, clusterInfo, memoryInfo) {
360
+ const version = readStrictInfoField(serverInfo, "redis_version");
361
+ const redisMode = readStrictInfoField(serverInfo, "redis_mode");
362
+ const clusterEnabled = readStrictInfoField(clusterInfo, "cluster_enabled");
363
+ const evictionPolicy = readStrictInfoField(memoryInfo, "maxmemory_policy");
364
+ return version !== null && /^7\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version) &&
365
+ redisMode === "standalone" &&
366
+ clusterEnabled === "0" &&
367
+ evictionPolicy !== null &&
368
+ (evictionPolicy === "noeviction" ||
369
+ evictionPolicy === "volatile-lru" ||
370
+ evictionPolicy === "volatile-lfu" ||
371
+ evictionPolicy === "volatile-random" ||
372
+ evictionPolicy === "volatile-ttl");
373
+ }
374
+ /** Test whether a physical Redis key is the protected namespace counter. */
375
+ export function isRedisAtomicCounterKey(key) {
376
+ return typeof key === "string" && key.startsWith(ATOMIC_COUNTER_KEY_PREFIX);
377
+ }
378
+ const sharedRedisClientManager = {
379
+ getClient: getRedisClient,
380
+ disconnect: disconnectRedisClient,
381
+ isConfigured: isRedisConfigured,
382
+ };
383
+ // Re-export for use by factory
384
+ export { isRedisConfigured };
385
+ export class RedisCacheBackend {
386
+ type = "distributed";
387
+ keyPrefix;
388
+ atomicCounterKey;
389
+ clientManager;
390
+ clientOptions;
391
+ constructor(keyPrefix = "vf:cache:default:", options = {}) {
392
+ this.keyPrefix = validateDistributedCacheKeyPrefix(keyPrefix);
393
+ this.atomicCounterKey = `${ATOMIC_COUNTER_KEY_PREFIX}${this.keyPrefix}`;
394
+ this.clientManager = options.clientManager ?? sharedRedisClientManager;
395
+ this.clientOptions = Object.freeze({ ...options.clientOptions });
396
+ }
397
+ prefixKey(key) {
398
+ return `${this.keyPrefix}${key}`;
399
+ }
400
+ async resetAfterFailure(error) {
401
+ this.clearRevisionCapability();
402
+ try {
403
+ await this.clientManager.disconnect();
404
+ }
405
+ catch (disconnectError) {
406
+ logger.warn("Failed to reset Redis connection", { error, disconnectError });
407
+ }
408
+ }
409
+ clearRevisionCapability() {
410
+ delete this.getWithRevision;
411
+ delete this.compareExchange;
412
+ }
413
+ publishRevisionCapability() {
414
+ Object.defineProperties(this, {
415
+ getWithRevision: {
416
+ value: this.readWithRevision.bind(this),
417
+ configurable: true,
418
+ enumerable: true,
419
+ writable: true,
420
+ },
421
+ compareExchange: {
422
+ value: this.exchangeRevision.bind(this),
423
+ configurable: true,
424
+ enumerable: true,
425
+ writable: true,
426
+ },
427
+ });
428
+ }
429
+ async probeRevisionCapability(client) {
430
+ if (typeof client.ttl !== "function" || typeof client.info !== "function")
431
+ return false;
432
+ try {
433
+ const [serverInfo, clusterInfo, memoryInfo] = await Promise.all([
434
+ client.info("server"),
435
+ client.info("cluster"),
436
+ client.info("memory"),
437
+ ]);
438
+ if (!isAtomicRedisTopology(serverInfo, clusterInfo, memoryInfo))
439
+ return false;
440
+ const created = await client.set(this.atomicCounterKey, "0", { NX: true });
441
+ if (created !== "OK" && created !== null)
442
+ return false;
443
+ const [counter, ttl] = await Promise.all([
444
+ client.get(this.atomicCounterKey),
445
+ client.ttl(this.atomicCounterKey),
446
+ ]);
447
+ return isCanonicalCounter(counter) && ttl === -1;
448
+ }
449
+ catch (error) {
450
+ logger.debug("Atomic revision capability probe failed", {
451
+ errorName: error instanceof Error ? error.name : typeof error,
452
+ });
453
+ return false;
454
+ }
455
+ }
456
+ async getClientForRead() {
457
+ if (!this.clientManager.isConfigured(this.clientOptions))
458
+ return null;
459
+ try {
460
+ return await this.clientManager.getClient(this.clientOptions);
461
+ }
462
+ catch (error) {
463
+ logger.debug("Redis client acquisition failed", {
464
+ errorName: error instanceof Error ? error.name : typeof error,
465
+ });
466
+ return null;
467
+ }
468
+ }
469
+ async requireClient() {
470
+ if (!this.clientManager.isConfigured(this.clientOptions)) {
471
+ throw new Error("Redis cache backend is not configured");
472
+ }
473
+ return await this.clientManager.getClient(this.clientOptions);
474
+ }
475
+ initialize() {
476
+ this.clearRevisionCapability();
477
+ if (!this.clientManager.isConfigured(this.clientOptions))
478
+ return Promise.resolve(false);
479
+ return withSpan(SpanNames.CACHE_DISTRIBUTED_INIT, async (span) => {
480
+ try {
481
+ const client = await this.clientManager.getClient(this.clientOptions);
482
+ const revisioned = await this.probeRevisionCapability(client);
483
+ if (revisioned)
484
+ this.publishRevisionCapability();
485
+ span?.setAttribute("cache.redis.connected", true);
486
+ span?.setAttribute("cache.redis.atomic_revision", revisioned);
487
+ return true;
488
+ }
489
+ catch (error) {
490
+ span?.setAttribute("cache.redis.connected", false);
491
+ logger.warn("Failed to connect", {
492
+ errorName: error instanceof Error ? error.name : typeof error,
493
+ });
494
+ return false;
495
+ }
496
+ }, { "cache.key_prefix": this.keyPrefix });
497
+ }
498
+ async get(key) {
499
+ const client = await this.getClientForRead();
500
+ if (!client)
501
+ return null;
502
+ try {
503
+ const value = await client.get(this.prefixKey(key));
504
+ return this.decodeOrdinaryRead(key, value);
505
+ }
506
+ catch (error) {
507
+ await this.resetAfterFailure(error);
508
+ logger.debug("Get failed", {
509
+ keyLength: key.length,
510
+ errorName: error instanceof Error ? error.name : typeof error,
511
+ });
512
+ return null;
513
+ }
514
+ }
515
+ async getWithinLimit(key, maximumBytes) {
516
+ const admittedMaximum = assertCacheReadMaximumBytes(maximumBytes);
517
+ const client = await this.getClientForRead();
518
+ if (!client)
519
+ return null;
520
+ const mode = isRevisionedCachePrefixOwned(key) ? "revisioned" : "ordinary";
521
+ try {
522
+ const result = parseRedisBoundedReadResult(await client.eval(REDIS_BOUNDED_GET_SCRIPT, {
523
+ keys: [this.prefixKey(key)],
524
+ arguments: [String(admittedMaximum), mode],
525
+ }), admittedMaximum);
526
+ if (result.kind === "missing")
527
+ return null;
528
+ if (result.kind === "oversized") {
529
+ throw new CacheValueTooLargeError(admittedMaximum);
530
+ }
531
+ assertCacheValueWithinLimit(result.value, admittedMaximum);
532
+ return result.value;
533
+ }
534
+ catch (error) {
535
+ if (error instanceof CacheValueTooLargeError)
536
+ throw error;
537
+ await this.resetAfterFailure(error);
538
+ logger.debug("Bounded GET failed", {
539
+ keyLength: key.length,
540
+ errorName: error instanceof Error ? error.name : typeof error,
541
+ });
542
+ return null;
543
+ }
544
+ }
545
+ decodeOrdinaryRead(key, value) {
546
+ if (value === null || !isRevisionedCachePrefixOwned(key))
547
+ return value;
548
+ if (!isRevisionedCacheKey(key)) {
549
+ throw new TypeError("Redis cache key uses a malformed reserved revisioned namespace");
550
+ }
551
+ const record = parseRevisionedCacheRecord(value);
552
+ return record.kind === "present" ? record.value : null;
553
+ }
554
+ async getRemainingTtlSeconds(key) {
555
+ const client = await this.getClientForRead();
556
+ if (!client?.ttl)
557
+ return null;
558
+ try {
559
+ const remaining = await client.ttl(this.prefixKey(key));
560
+ if (remaining === -1)
561
+ return Infinity;
562
+ return Number.isSafeInteger(remaining) && remaining >= 0 ? remaining : null;
563
+ }
564
+ catch (error) {
565
+ await this.resetAfterFailure(error);
566
+ logger.debug("TTL lookup failed", {
567
+ errorName: error instanceof Error ? error.name : typeof error,
568
+ });
569
+ return null;
570
+ }
571
+ }
572
+ async getBatch(keys) {
573
+ assertCacheBatchSize(keys, "Redis cache getBatch");
574
+ if (keys.length === 0)
575
+ return new Map();
576
+ const client = await this.getClientForRead();
577
+ if (!client)
578
+ return buildBatchResults(keys, () => null);
579
+ try {
580
+ const prefixedKeys = keys.map((key) => this.prefixKey(key));
581
+ const fetched = await client.mGet(prefixedKeys);
582
+ if (!Array.isArray(fetched) ||
583
+ fetched.length !== keys.length ||
584
+ !fetched.every((value) => value === null || typeof value === "string")) {
585
+ throw new TypeError("Redis MGET returned an invalid result");
586
+ }
587
+ const values = new Map(keys.map((key, index) => [
588
+ key,
589
+ this.decodeOrdinaryRead(key, fetched[index] ?? null),
590
+ ]));
591
+ return buildBatchResults(keys, (key) => values.get(key) ?? null);
592
+ }
593
+ catch (error) {
594
+ await this.resetAfterFailure(error);
595
+ logger.debug("GetBatch MGET failed, falling back to GET", {
596
+ keyCount: keys.length,
597
+ errorName: error instanceof Error ? error.name : typeof error,
598
+ });
599
+ const fallbackFetched = await Promise.all(keys.map(async (key) => [key, await this.get(key)]));
600
+ const fallbackValues = new Map(fallbackFetched);
601
+ return buildBatchResults(keys, (key) => fallbackValues.get(key) ?? null);
602
+ }
603
+ }
604
+ async set(key, value, ttlSeconds = DEFAULT_CACHE_TTL_SECONDS) {
605
+ if (isRevisionedCachePrefixOwned(key)) {
606
+ throw new TypeError("Ordinary Redis cache writes cannot use the reserved revisioned namespace");
607
+ }
608
+ const ttl = resolveIntegerCacheTtlSeconds(ttlSeconds, DEFAULT_CACHE_TTL_SECONDS);
609
+ if (expiresImmediately(ttl)) {
610
+ await this.del(key);
611
+ return;
612
+ }
613
+ const client = await this.requireClient();
614
+ try {
615
+ const result = await client.set(this.prefixKey(key), value, { EX: ttl });
616
+ if (result !== "OK")
617
+ throw new Error("Redis SET did not acknowledge the write");
618
+ }
619
+ catch (error) {
620
+ await this.resetAfterFailure(error);
621
+ logger.debug("Set failed", {
622
+ keyLength: key.length,
623
+ errorName: error instanceof Error ? error.name : typeof error,
624
+ });
625
+ throw error;
626
+ }
627
+ }
628
+ async setBatch(entries) {
629
+ assertCacheBatchSize(entries, "Redis cache setBatch");
630
+ if (entries.length === 0)
631
+ return;
632
+ for (const { key } of entries) {
633
+ if (isRevisionedCachePrefixOwned(key)) {
634
+ throw new TypeError("Ordinary Redis cache writes cannot use the reserved revisioned namespace");
635
+ }
636
+ }
637
+ const finalEntriesByKey = new Map();
638
+ for (const { key, value, ttl } of entries) {
639
+ finalEntriesByKey.set(key, {
640
+ key,
641
+ value,
642
+ ttl: resolveIntegerCacheTtlSeconds(ttl, DEFAULT_CACHE_TTL_SECONDS),
643
+ });
644
+ }
645
+ const writes = await Promise.allSettled([...finalEntriesByKey.values()].map(({ key, value, ttl }) => this.set(key, value, ttl)));
646
+ const firstFailure = writes.find((result) => result.status === "rejected");
647
+ if (firstFailure)
648
+ throw firstFailure.reason;
649
+ }
650
+ async del(key) {
651
+ const client = await this.requireClient();
652
+ try {
653
+ const deleted = await client.del(this.prefixKey(key));
654
+ this.assertDeleteCount(deleted, 1);
655
+ }
656
+ catch (error) {
657
+ await this.resetAfterFailure(error);
658
+ logger.debug("Del failed", {
659
+ keyLength: key.length,
660
+ errorName: error instanceof Error ? error.name : typeof error,
661
+ });
662
+ throw error;
663
+ }
664
+ }
665
+ async delByPattern(pattern) {
666
+ const client = await this.requireClient();
667
+ try {
668
+ const fullPattern = `${escapeCacheGlobLiteral(this.keyPrefix)}${pattern}`;
669
+ const keysToDelete = new Set();
670
+ const seenCursors = new Set();
671
+ let cursor = 0;
672
+ let iterations = 0;
673
+ do {
674
+ if (++iterations > MAX_REDIS_SCAN_ITERATIONS) {
675
+ throw new Error("Redis SCAN exceeded the safe iteration limit");
676
+ }
677
+ const result = await client.scan(cursor, {
678
+ MATCH: fullPattern,
679
+ COUNT: REDIS_PATTERN_DELETE_SCAN_COUNT,
680
+ });
681
+ if (!result ||
682
+ !Number.isSafeInteger(result.cursor) ||
683
+ result.cursor < 0 ||
684
+ !Array.isArray(result.keys) ||
685
+ !result.keys.every((key) => typeof key === "string" && key.startsWith(this.keyPrefix))) {
686
+ throw new TypeError("Redis returned an invalid SCAN result");
687
+ }
688
+ if (result.cursor !== 0 && seenCursors.has(result.cursor)) {
689
+ throw new Error("Redis SCAN repeated a cursor before completing");
690
+ }
691
+ if (result.cursor !== 0)
692
+ seenCursors.add(result.cursor);
693
+ for (const key of result.keys) {
694
+ keysToDelete.add(key);
695
+ if (keysToDelete.size > MAX_REDIS_PATTERN_DELETE_KEYS) {
696
+ throw new RangeError("Redis pattern deletion exceeds the safe key limit");
697
+ }
698
+ }
699
+ cursor = result.cursor;
700
+ } while (cursor !== 0);
701
+ const keys = [...keysToDelete];
702
+ let deletedCount = 0;
703
+ for (let index = 0; index < keys.length; index += REDIS_PATTERN_DELETE_BATCH_SIZE) {
704
+ const batch = keys.slice(index, index + REDIS_PATTERN_DELETE_BATCH_SIZE);
705
+ const classifications = batch.map((key) => isRevisionedCachePrefixOwned(key.slice(this.keyPrefix.length)) ? "1" : "0").join("");
706
+ const deleted = await client.eval(REDIS_LOGICAL_DELETE_SCRIPT, {
707
+ keys: batch,
708
+ arguments: [classifications, "vf-logical-delete-v1"],
709
+ });
710
+ this.assertDeleteCount(deleted, batch.length);
711
+ deletedCount += deleted;
712
+ }
713
+ return deletedCount;
714
+ }
715
+ catch (error) {
716
+ await this.resetAfterFailure(error);
717
+ logger.debug("DelByPattern failed", {
718
+ patternLength: pattern.length,
719
+ errorName: error instanceof Error ? error.name : typeof error,
720
+ });
721
+ throw error;
722
+ }
723
+ }
724
+ assertDeleteCount(value, requested) {
725
+ if (typeof value !== "number" ||
726
+ !Number.isSafeInteger(value) ||
727
+ value < 0 ||
728
+ value > requested) {
729
+ throw new TypeError("Redis DEL returned an invalid count");
730
+ }
731
+ }
732
+ requireRevisionedKey(key) {
733
+ if (!isRevisionedCacheKey(key)) {
734
+ throw new TypeError("Redis revision operations require the reserved revisioned namespace");
735
+ }
736
+ return this.prefixKey(key);
737
+ }
738
+ async readWithRevision(key) {
739
+ const dataKey = this.requireRevisionedKey(key);
740
+ try {
741
+ const client = await this.requireClient();
742
+ const result = await client.eval(REDIS_REVISION_READ_SCRIPT, {
743
+ keys: [dataKey, this.atomicCounterKey],
744
+ arguments: [],
745
+ });
746
+ return parseRedisRevisionReadResult(result);
747
+ }
748
+ catch (error) {
749
+ await this.resetAfterFailure(error);
750
+ throw error;
751
+ }
752
+ }
753
+ async exchangeRevision(key, expectedRevision, mutation) {
754
+ const dataKey = this.requireRevisionedKey(key);
755
+ if (typeof expectedRevision !== "string") {
756
+ throw new TypeError("Redis expected revision must be a string");
757
+ }
758
+ const validatedMutation = requireRedisRevisionMutation(mutation);
759
+ const args = validatedMutation.kind === "delete" ? [expectedRevision, "d"] : [
760
+ expectedRevision,
761
+ "s",
762
+ validatedMutation.value,
763
+ String(validatedMutation.expiresAtMs),
764
+ ];
765
+ try {
766
+ const client = await this.requireClient();
767
+ const result = await client.eval(REDIS_REVISION_EXCHANGE_SCRIPT, {
768
+ keys: [dataKey, this.atomicCounterKey],
769
+ arguments: args,
770
+ });
771
+ return parseRedisRevisionExchangeResult(result);
772
+ }
773
+ catch (error) {
774
+ await this.resetAfterFailure(error);
775
+ throw error;
776
+ }
777
+ }
778
+ }