@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.
- package/README.md +12 -0
- package/esm/_dnt.polyfills.d.ts +12 -0
- package/esm/_dnt.polyfills.d.ts.map +1 -0
- package/esm/_dnt.polyfills.js +15 -0
- package/esm/_dnt.shims.d.ts +11 -0
- package/esm/_dnt.shims.d.ts.map +1 -0
- package/esm/_dnt.shims.js +68 -0
- package/esm/agent-memory.d.ts +60 -0
- package/esm/agent-memory.d.ts.map +1 -0
- package/esm/agent-memory.js +173 -0
- package/esm/cache-administration.d.ts +5 -0
- package/esm/cache-administration.d.ts.map +1 -0
- package/esm/cache-administration.js +113 -0
- package/esm/cache-backend.d.ts +46 -0
- package/esm/cache-backend.d.ts.map +1 -0
- package/esm/cache-backend.js +778 -0
- package/esm/index.d.ts +7 -0
- package/esm/index.d.ts.map +1 -1
- package/esm/index.js +7 -0
- package/esm/rate-limit-store.d.ts +40 -0
- package/esm/rate-limit-store.d.ts.map +1 -0
- package/esm/rate-limit-store.js +306 -0
- package/esm/redis-client-manager.d.ts +8 -0
- package/esm/redis-client-manager.d.ts.map +1 -1
- package/esm/redis-client-manager.js +22 -1
- package/esm/redis-runtime-provider.js +1 -1
- package/esm/render-cache-store.d.ts +34 -0
- package/esm/render-cache-store.d.ts.map +1 -0
- package/esm/render-cache-store.js +191 -0
- package/esm/revisioned-cache-record.d.ts +18 -0
- package/esm/revisioned-cache-record.d.ts.map +1 -0
- package/esm/revisioned-cache-record.js +68 -0
- package/esm/routing-invalidation-bus.d.ts +33 -0
- package/esm/routing-invalidation-bus.d.ts.map +1 -0
- package/esm/routing-invalidation-bus.js +760 -0
- package/package.json +7 -4
|
@@ -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
|
+
}
|