@zlink-systems/framework-locations-redis 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +105 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +19 -0
- package/dist/opaque-redis-scripts.d.ts +7 -0
- package/dist/opaque-redis-scripts.js +330 -0
- package/dist/opaque-store.d.ts +22 -0
- package/dist/opaque-store.js +295 -0
- package/dist/redis-connection.d.ts +16 -0
- package/dist/redis-connection.js +136 -0
- package/dist/redis-options.d.ts +15 -0
- package/dist/redis-options.js +2 -0
- package/dist/redis-values.d.ts +3 -0
- package/dist/redis-values.js +26 -0
- package/dist/relocation-store.d.ts +14 -0
- package/dist/relocation-store.js +103 -0
- package/package.json +18 -0
- package/src/index.ts +3 -0
- package/src/opaque-redis-scripts.ts +337 -0
- package/src/opaque-store.ts +387 -0
- package/src/redis-connection.ts +174 -0
- package/src/redis-options.ts +17 -0
- package/src/redis-values.ts +23 -0
- package/src/relocation-store.ts +150 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BLOB_RENEW_SCRIPT = exports.BLOB_READ_SCRIPT = exports.BLOB_PUT_SCRIPT = exports.OPAQUE_SCAN_CONTINUE_SCRIPT = exports.OPAQUE_SCAN_START_SCRIPT = exports.OPAQUE_WRITE_SCRIPT = exports.OPAQUE_READ_SCRIPT = void 0;
|
|
4
|
+
const PROLOGUE = `
|
|
5
|
+
if redis.replicate_commands then redis.replicate_commands() end
|
|
6
|
+
local time = redis.call('TIME')
|
|
7
|
+
local nowMs = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
|
|
8
|
+
`;
|
|
9
|
+
// Shared decode helper. Every opaque row is a Redis ZSET append-log; the
|
|
10
|
+
// member with the highest score (the provider's monotonically increasing
|
|
11
|
+
// INCR sequence) is current. Each member is a 1-byte format tag (0x01)
|
|
12
|
+
// followed by a cmsgpack array {originalKey, rawBytes, version,
|
|
13
|
+
// expiresAtMs, tombstone}. expiresAtMs == 0 means no expiry (unsigned int
|
|
14
|
+
// family, never negative). tombstone is a real MessagePack bool.
|
|
15
|
+
// Unrecognized format tags fail explicitly rather than guessing how to
|
|
16
|
+
// read the value (21-location-runtime.md#2.4, 22-location-store-redis.md#7).
|
|
17
|
+
const DECODE_HELPERS = `
|
|
18
|
+
local function decodeMember(raw)
|
|
19
|
+
if string.byte(raw, 1) ~= 1 then
|
|
20
|
+
return redis.error_reply('zlink opaque record: unrecognized format tag')
|
|
21
|
+
end
|
|
22
|
+
return cmsgpack.unpack(string.sub(raw, 2))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
local function liveRecordAt(rowKey, referenceMs)
|
|
26
|
+
local members = redis.call('ZREVRANGE', rowKey, 0, 0)
|
|
27
|
+
if #members == 0 then return nil end
|
|
28
|
+
local record = decodeMember(members[1])
|
|
29
|
+
local expiresAtMs = tonumber(record[4])
|
|
30
|
+
if record[5] == true or (expiresAtMs > 0 and expiresAtMs <= referenceMs) then
|
|
31
|
+
return nil
|
|
32
|
+
end
|
|
33
|
+
return record
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
local function encodeMember(originalKey, bytes, version, expiresAtMs, tombstone)
|
|
37
|
+
return string.char(1) .. cmsgpack.pack({
|
|
38
|
+
originalKey, bytes, version, expiresAtMs, tombstone
|
|
39
|
+
})
|
|
40
|
+
end
|
|
41
|
+
`;
|
|
42
|
+
exports.OPAQUE_READ_SCRIPT = PROLOGUE + DECODE_HELPERS + `
|
|
43
|
+
local record = liveRecordAt(KEYS[1], nowMs)
|
|
44
|
+
if not record then return {0, nowMs} end
|
|
45
|
+
return {1, nowMs, record[1], record[2], record[3], tostring(tonumber(record[4]))}
|
|
46
|
+
`;
|
|
47
|
+
// KEYS[1..6] = indexKey, mapKey, cleanupKey, sequenceKey, snapshotExpiryKey,
|
|
48
|
+
// snapshotBoundaryKey (private auxiliary keys, not part of the public
|
|
49
|
+
// contract). KEYS[7..] = the opaque record row keys referenced by
|
|
50
|
+
// conditions/mutations, in the order fixed by ARGV[1]/ARGV[2].
|
|
51
|
+
// ARGV[1] = JSON conditions: ['missing', keyIndex, originalKey]
|
|
52
|
+
// | ['version', keyIndex, originalKey, expectedVersion]
|
|
53
|
+
// ARGV[2] = JSON mutation metadata (no raw bytes, JSON must stay text-safe):
|
|
54
|
+
// ['put', keyIndex, originalKey, retentionMsOrFalse]
|
|
55
|
+
// | ['delete', keyIndex, originalKey]
|
|
56
|
+
// ARGV[3..] = one raw-bytes argument per 'put' mutation, in mutation order.
|
|
57
|
+
exports.OPAQUE_WRITE_SCRIPT = PROLOGUE + DECODE_HELPERS + `
|
|
58
|
+
local indexKey = KEYS[1]
|
|
59
|
+
local mapKey = KEYS[2]
|
|
60
|
+
local cleanupKey = KEYS[3]
|
|
61
|
+
local sequenceKey = KEYS[4]
|
|
62
|
+
local snapshotExpiryKey = KEYS[5]
|
|
63
|
+
local snapshotBoundaryKey = KEYS[6]
|
|
64
|
+
|
|
65
|
+
local expiredSnapshots = redis.call('ZRANGEBYSCORE', snapshotExpiryKey, '-inf', nowMs, 'LIMIT', 0, 128)
|
|
66
|
+
for _, snapshotId in ipairs(expiredSnapshots) do
|
|
67
|
+
redis.call('ZREM', snapshotExpiryKey, snapshotId)
|
|
68
|
+
redis.call('ZREM', snapshotBoundaryKey, snapshotId)
|
|
69
|
+
end
|
|
70
|
+
local minimumBoundary = nil
|
|
71
|
+
local boundaryEntry = redis.call('ZRANGE', snapshotBoundaryKey, 0, 0, 'WITHSCORES')
|
|
72
|
+
if #boundaryEntry == 2 then minimumBoundary = tonumber(boundaryEntry[2]) end
|
|
73
|
+
|
|
74
|
+
local due = redis.call('ZRANGEBYSCORE', cleanupKey, '-inf', nowMs, 'LIMIT', 0, 32)
|
|
75
|
+
for _, original in ipairs(due) do
|
|
76
|
+
local rowKey = redis.call('HGET', mapKey, original)
|
|
77
|
+
local members = {}
|
|
78
|
+
if rowKey then
|
|
79
|
+
members = redis.call('ZREVRANGE', rowKey, 0, 0, 'WITHSCORES')
|
|
80
|
+
end
|
|
81
|
+
if #members == 0 then
|
|
82
|
+
redis.call('ZREM', indexKey, original)
|
|
83
|
+
redis.call('HDEL', mapKey, original)
|
|
84
|
+
redis.call('ZREM', cleanupKey, original)
|
|
85
|
+
elseif minimumBoundary then
|
|
86
|
+
local anchor = redis.call('ZREVRANGEBYSCORE', rowKey, minimumBoundary, '-inf', 'WITHSCORES', 'LIMIT', 0, 1)
|
|
87
|
+
if #anchor == 2 then
|
|
88
|
+
redis.call('ZREMRANGEBYSCORE', rowKey, '-inf', '(' .. anchor[2])
|
|
89
|
+
end
|
|
90
|
+
redis.call('ZADD', cleanupKey, nowMs + 1000, original)
|
|
91
|
+
else
|
|
92
|
+
local record = decodeMember(members[1])
|
|
93
|
+
local expiresAtMs = tonumber(record[4])
|
|
94
|
+
if record[5] == true or (expiresAtMs > 0 and expiresAtMs + 60000 <= nowMs) then
|
|
95
|
+
redis.call('DEL', rowKey)
|
|
96
|
+
redis.call('ZREM', indexKey, original)
|
|
97
|
+
redis.call('HDEL', mapKey, original)
|
|
98
|
+
redis.call('ZREM', cleanupKey, original)
|
|
99
|
+
else
|
|
100
|
+
redis.call('ZREMRANGEBYRANK', rowKey, 0, -2)
|
|
101
|
+
if expiresAtMs > 0 then
|
|
102
|
+
redis.call('ZADD', cleanupKey, math.max(nowMs + 1000, expiresAtMs + 60000), original)
|
|
103
|
+
else
|
|
104
|
+
redis.call('ZREM', cleanupKey, original)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
local conditions = cjson.decode(ARGV[1])
|
|
111
|
+
local mutations = cjson.decode(ARGV[2])
|
|
112
|
+
|
|
113
|
+
for _, condition in ipairs(conditions) do
|
|
114
|
+
local record = liveRecordAt(KEYS[condition[2] + 6], nowMs)
|
|
115
|
+
local currentVersion = record and record[3] or nil
|
|
116
|
+
if condition[1] == 'missing' then
|
|
117
|
+
if currentVersion ~= nil then return {'conflict', nowMs} end
|
|
118
|
+
elseif currentVersion ~= condition[4] then
|
|
119
|
+
return {'conflict', nowMs}
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
for _, mutation in ipairs(mutations) do
|
|
124
|
+
local rowKey = KEYS[mutation[2] + 6]
|
|
125
|
+
if not minimumBoundary then
|
|
126
|
+
redis.call('ZREMRANGEBYRANK', rowKey, 0, -2)
|
|
127
|
+
end
|
|
128
|
+
if redis.call('ZCARD', rowKey) >= 128 then
|
|
129
|
+
return {'backlog', nowMs}
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
local sequence = tostring(redis.call('INCR', sequenceKey))
|
|
134
|
+
local byteArg = 3
|
|
135
|
+
local result = {'applied', nowMs}
|
|
136
|
+
for _, mutation in ipairs(mutations) do
|
|
137
|
+
local rowKey = KEYS[mutation[2] + 6]
|
|
138
|
+
local originalKey = mutation[3]
|
|
139
|
+
if mutation[1] == 'put' then
|
|
140
|
+
local bytes = ARGV[byteArg]
|
|
141
|
+
byteArg = byteArg + 1
|
|
142
|
+
local retention = mutation[4]
|
|
143
|
+
local expiresAtMs = 0
|
|
144
|
+
if retention ~= false then expiresAtMs = nowMs + tonumber(retention) end
|
|
145
|
+
redis.call('ZADD', rowKey, sequence, encodeMember(originalKey, bytes, sequence, expiresAtMs, false))
|
|
146
|
+
redis.call('ZADD', indexKey, 0, originalKey)
|
|
147
|
+
redis.call('HSET', mapKey, originalKey, rowKey)
|
|
148
|
+
table.insert(result, originalKey)
|
|
149
|
+
table.insert(result, sequence)
|
|
150
|
+
else
|
|
151
|
+
redis.call('ZADD', rowKey, sequence, encodeMember(originalKey, '', sequence, 0, true))
|
|
152
|
+
redis.call('ZADD', indexKey, 0, originalKey)
|
|
153
|
+
redis.call('HSET', mapKey, originalKey, rowKey)
|
|
154
|
+
end
|
|
155
|
+
local dueAt = nowMs + 1000
|
|
156
|
+
local scheduled = redis.call('ZSCORE', cleanupKey, originalKey)
|
|
157
|
+
if not scheduled or tonumber(scheduled) > dueAt then
|
|
158
|
+
redis.call('ZADD', cleanupKey, dueAt, originalKey)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
return result
|
|
162
|
+
`;
|
|
163
|
+
// Shared body for point-in-time paged scanning. KEYS[1]=indexKey,
|
|
164
|
+
// KEYS[2]=mapKey, KEYS[3]=snapshotKey, KEYS[4]=cleanupKey,
|
|
165
|
+
// KEYS[5]=sequenceKey, KEYS[6]=snapshotExpiryKey, KEYS[7]=snapshotBoundaryKey.
|
|
166
|
+
const SCAN_CLEANUP_AND_BOUNDARY = DECODE_HELPERS + `
|
|
167
|
+
local expiredSnapshots = redis.call('ZRANGEBYSCORE', KEYS[6], '-inf', nowMs, 'LIMIT', 0, 128)
|
|
168
|
+
for _, expiredId in ipairs(expiredSnapshots) do
|
|
169
|
+
redis.call('ZREM', KEYS[6], expiredId)
|
|
170
|
+
redis.call('ZREM', KEYS[7], expiredId)
|
|
171
|
+
end
|
|
172
|
+
local minimumBoundary = nil
|
|
173
|
+
local boundaryEntry = redis.call('ZRANGE', KEYS[7], 0, 0, 'WITHSCORES')
|
|
174
|
+
if #boundaryEntry == 2 then minimumBoundary = tonumber(boundaryEntry[2]) end
|
|
175
|
+
|
|
176
|
+
local due = redis.call('ZRANGEBYSCORE', KEYS[4], '-inf', nowMs, 'LIMIT', 0, 32)
|
|
177
|
+
for _, original in ipairs(due) do
|
|
178
|
+
local rowKey = redis.call('HGET', KEYS[2], original)
|
|
179
|
+
local members = {}
|
|
180
|
+
if rowKey then
|
|
181
|
+
members = redis.call('ZREVRANGE', rowKey, 0, 0, 'WITHSCORES')
|
|
182
|
+
end
|
|
183
|
+
if #members == 0 then
|
|
184
|
+
redis.call('ZREM', KEYS[1], original)
|
|
185
|
+
redis.call('HDEL', KEYS[2], original)
|
|
186
|
+
redis.call('ZREM', KEYS[4], original)
|
|
187
|
+
elseif minimumBoundary then
|
|
188
|
+
local anchor = redis.call('ZREVRANGEBYSCORE', rowKey, minimumBoundary, '-inf', 'WITHSCORES', 'LIMIT', 0, 1)
|
|
189
|
+
if #anchor == 2 then
|
|
190
|
+
redis.call('ZREMRANGEBYSCORE', rowKey, '-inf', '(' .. anchor[2])
|
|
191
|
+
end
|
|
192
|
+
redis.call('ZADD', KEYS[4], nowMs + 1000, original)
|
|
193
|
+
else
|
|
194
|
+
local record = decodeMember(members[1])
|
|
195
|
+
local expiresAtMs = tonumber(record[4])
|
|
196
|
+
if record[5] == true or (expiresAtMs > 0 and expiresAtMs + 60000 <= nowMs) then
|
|
197
|
+
redis.call('DEL', rowKey)
|
|
198
|
+
redis.call('ZREM', KEYS[1], original)
|
|
199
|
+
redis.call('HDEL', KEYS[2], original)
|
|
200
|
+
redis.call('ZREM', KEYS[4], original)
|
|
201
|
+
else
|
|
202
|
+
redis.call('ZREMRANGEBYRANK', rowKey, 0, -2)
|
|
203
|
+
if expiresAtMs > 0 then
|
|
204
|
+
redis.call('ZADD', KEYS[4], math.max(nowMs + 1000, expiresAtMs + 60000), original)
|
|
205
|
+
else
|
|
206
|
+
redis.call('ZREM', KEYS[4], original)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
`;
|
|
212
|
+
const SCAN_PAGE_READ = `
|
|
213
|
+
local metadata = redis.call('HMGET', KEYS[3], 'now', 'boundary', 'prefix')
|
|
214
|
+
if not metadata[1] or metadata[3] ~= prefix then
|
|
215
|
+
redis.call('ZREM', KEYS[6], snapshotId)
|
|
216
|
+
redis.call('ZREM', KEYS[7], snapshotId)
|
|
217
|
+
return {'expired'}
|
|
218
|
+
end
|
|
219
|
+
local snapshotNow = tonumber(metadata[1])
|
|
220
|
+
local boundary = tonumber(metadata[2])
|
|
221
|
+
local lower = '-'
|
|
222
|
+
if string.len(lastKey) > 0 then lower = '(' .. lastKey end
|
|
223
|
+
local workLimit = math.max(limit * 4, 128)
|
|
224
|
+
local originals = redis.call('ZRANGEBYLEX', KEYS[1], lower, '+', 'LIMIT', 0, workLimit + 1)
|
|
225
|
+
local emitted = 0
|
|
226
|
+
local encodedBytes = 0
|
|
227
|
+
local examined = 0
|
|
228
|
+
local result = {'page', tostring(snapshotNow), ''}
|
|
229
|
+
while examined < #originals and examined < workLimit and emitted < limit do
|
|
230
|
+
local original = originals[examined + 1]
|
|
231
|
+
examined = examined + 1
|
|
232
|
+
if string.sub(original, 1, string.len(prefix)) == prefix then
|
|
233
|
+
local rowKey = redis.call('HGET', KEYS[2], original)
|
|
234
|
+
if rowKey then
|
|
235
|
+
local members = redis.call('ZREVRANGEBYSCORE', rowKey, boundary, '-inf', 'LIMIT', 0, 1)
|
|
236
|
+
if #members > 0 then
|
|
237
|
+
local record = decodeMember(members[1])
|
|
238
|
+
local expiresAtMs = tonumber(record[4])
|
|
239
|
+
if record[1] == original and record[5] ~= true
|
|
240
|
+
and (expiresAtMs == 0 or expiresAtMs > snapshotNow) then
|
|
241
|
+
local itemBytes = string.len(original) + string.len(record[2]) + string.len(record[3]) + 128
|
|
242
|
+
if emitted > 0 and encodedBytes + itemBytes > 4194304 then
|
|
243
|
+
examined = examined - 1
|
|
244
|
+
break
|
|
245
|
+
end
|
|
246
|
+
table.insert(result, original)
|
|
247
|
+
table.insert(result, record[2])
|
|
248
|
+
table.insert(result, record[3])
|
|
249
|
+
table.insert(result, tostring(expiresAtMs))
|
|
250
|
+
encodedBytes = encodedBytes + itemBytes
|
|
251
|
+
emitted = emitted + 1
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
local hasMore = examined < #originals
|
|
259
|
+
if not hasMore and #originals > workLimit then hasMore = true end
|
|
260
|
+
if hasMore then
|
|
261
|
+
result[3] = originals[examined]
|
|
262
|
+
else
|
|
263
|
+
redis.call('DEL', KEYS[3])
|
|
264
|
+
redis.call('ZREM', KEYS[6], snapshotId)
|
|
265
|
+
redis.call('ZREM', KEYS[7], snapshotId)
|
|
266
|
+
end
|
|
267
|
+
return result
|
|
268
|
+
`;
|
|
269
|
+
// ARGV = [prefix, limit, snapshotId]
|
|
270
|
+
exports.OPAQUE_SCAN_START_SCRIPT = PROLOGUE + SCAN_CLEANUP_AND_BOUNDARY + `
|
|
271
|
+
local prefix = ARGV[1]
|
|
272
|
+
local limit = tonumber(ARGV[2])
|
|
273
|
+
local snapshotId = ARGV[3]
|
|
274
|
+
local lastKey = ''
|
|
275
|
+
|
|
276
|
+
if redis.call('ZCARD', KEYS[6]) >= 4096 then
|
|
277
|
+
return {'capacity'}
|
|
278
|
+
end
|
|
279
|
+
redis.call('DEL', KEYS[3])
|
|
280
|
+
local boundary = tonumber(redis.call('GET', KEYS[5]) or '0')
|
|
281
|
+
redis.call('HSET', KEYS[3], 'now', tostring(nowMs), 'boundary', tostring(boundary), 'prefix', prefix)
|
|
282
|
+
redis.call('PEXPIRE', KEYS[3], 60000)
|
|
283
|
+
redis.call('ZADD', KEYS[6], nowMs + 60000, snapshotId)
|
|
284
|
+
redis.call('ZADD', KEYS[7], boundary, snapshotId)
|
|
285
|
+
` + SCAN_PAGE_READ;
|
|
286
|
+
// ARGV = [prefix, lastKeyHex, limit, snapshotId]
|
|
287
|
+
exports.OPAQUE_SCAN_CONTINUE_SCRIPT = PROLOGUE + SCAN_CLEANUP_AND_BOUNDARY + `
|
|
288
|
+
local prefix = ARGV[1]
|
|
289
|
+
local lastKey = ARGV[2]
|
|
290
|
+
local limit = tonumber(ARGV[3])
|
|
291
|
+
local snapshotId = ARGV[4]
|
|
292
|
+
|
|
293
|
+
if redis.call('EXISTS', KEYS[3]) == 0 then
|
|
294
|
+
redis.call('ZREM', KEYS[6], snapshotId)
|
|
295
|
+
redis.call('ZREM', KEYS[7], snapshotId)
|
|
296
|
+
return {'expired'}
|
|
297
|
+
end
|
|
298
|
+
` + SCAN_PAGE_READ;
|
|
299
|
+
// Relocation Store: raw-bytes STRING payloads at
|
|
300
|
+
// {prefix}:zlink-relocation-v1:blob:{reference}, retention via PSETEX/PX
|
|
301
|
+
// (23-relocation-store-redis.md#8). KEYS[1] is the blob key -- the reference
|
|
302
|
+
// itself is already the key's last segment, so identity on retry is decided
|
|
303
|
+
// by comparing the stored bytes against ARGV[1].
|
|
304
|
+
exports.BLOB_PUT_SCRIPT = PROLOGUE + `
|
|
305
|
+
local existing = redis.call('GET', KEYS[1])
|
|
306
|
+
if existing then
|
|
307
|
+
if existing ~= ARGV[1] then
|
|
308
|
+
return {'conflict', nowMs}
|
|
309
|
+
end
|
|
310
|
+
local ttl = redis.call('PTTL', KEYS[1])
|
|
311
|
+
local expiresAtMs = nowMs + math.max(ttl, 0)
|
|
312
|
+
return {'alreadyStored', nowMs, tostring(expiresAtMs)}
|
|
313
|
+
end
|
|
314
|
+
local retentionMs = tonumber(ARGV[2])
|
|
315
|
+
redis.call('SET', KEYS[1], ARGV[1], 'PX', retentionMs)
|
|
316
|
+
return {'stored', nowMs, tostring(nowMs + retentionMs)}
|
|
317
|
+
`;
|
|
318
|
+
exports.BLOB_READ_SCRIPT = PROLOGUE + `
|
|
319
|
+
local bytes = redis.call('GET', KEYS[1])
|
|
320
|
+
if not bytes then return {0, nowMs} end
|
|
321
|
+
local ttl = redis.call('PTTL', KEYS[1])
|
|
322
|
+
local expiresAtMs = nowMs + math.max(ttl, 0)
|
|
323
|
+
return {1, nowMs, bytes, tostring(expiresAtMs)}
|
|
324
|
+
`;
|
|
325
|
+
exports.BLOB_RENEW_SCRIPT = PROLOGUE + `
|
|
326
|
+
if redis.call('EXISTS', KEYS[1]) == 0 then return {0, nowMs} end
|
|
327
|
+
local retentionMs = tonumber(ARGV[1])
|
|
328
|
+
redis.call('PEXPIRE', KEYS[1], retentionMs)
|
|
329
|
+
return {1, nowMs, tostring(nowMs + retentionMs)}
|
|
330
|
+
`;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ZLinkLocationStore, ZLinkStoreKey, ZLinkStoreReadResult, ZLinkStoreScanRequest, ZLinkStoreScanResult, ZLinkStoreWriteRequest, ZLinkStoreWriteResult } from '@zlink-systems/framework';
|
|
2
|
+
import type { ZLinkRedisLocationOptions } from './redis-options';
|
|
3
|
+
/** Redis implementation of the opaque Location Store provider SPI. */
|
|
4
|
+
export declare class ZLinkRedisLocationStore implements ZLinkLocationStore {
|
|
5
|
+
private readonly connection;
|
|
6
|
+
private readonly domain;
|
|
7
|
+
constructor(options: ZLinkRedisLocationOptions);
|
|
8
|
+
read(key: ZLinkStoreKey, signal?: AbortSignal): Promise<ZLinkStoreReadResult>;
|
|
9
|
+
write(request: ZLinkStoreWriteRequest, signal?: AbortSignal): Promise<ZLinkStoreWriteResult>;
|
|
10
|
+
scan(request: ZLinkStoreScanRequest, signal?: AbortSignal): Promise<ZLinkStoreScanResult>;
|
|
11
|
+
dispose(): Promise<void>;
|
|
12
|
+
private readScanPage;
|
|
13
|
+
private scanKeys;
|
|
14
|
+
private indexKey;
|
|
15
|
+
private mapKey;
|
|
16
|
+
private cleanupKey;
|
|
17
|
+
private sequenceKey;
|
|
18
|
+
private snapshotExpiryKey;
|
|
19
|
+
private snapshotBoundaryKey;
|
|
20
|
+
private snapshotKey;
|
|
21
|
+
private rowKey;
|
|
22
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ZLinkRedisLocationStore = void 0;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
const redis_connection_1 = require("./redis-connection");
|
|
6
|
+
const opaque_redis_scripts_1 = require("./opaque-redis-scripts");
|
|
7
|
+
const redis_values_1 = require("./redis-values");
|
|
8
|
+
const MAX_VALUE_BYTES = 1024 * 1024;
|
|
9
|
+
const MAX_WRITE_KEYS = 2_048;
|
|
10
|
+
const MAX_WRITE_BYTES = 4 * 1024 * 1024;
|
|
11
|
+
// {prefix}:{zlink-location-v3}:opaque:{sha256hex(preimage)} is the public
|
|
12
|
+
// contract (21-location-runtime.md#2.4, 22-location-store-redis.md#7). The
|
|
13
|
+
// braces are a Redis Cluster hash tag: every key this provider's scripts
|
|
14
|
+
// touch in one EVAL (the record row plus the private auxiliary keys below)
|
|
15
|
+
// must land on the same hash slot, matching the dotnet/java reference. Only
|
|
16
|
+
// the six auxiliary keys below (index/map/cleanup/sequence/snapshot*) are a
|
|
17
|
+
// private implementation detail of this provider's point-in-time scan.
|
|
18
|
+
const NAMESPACE = '{zlink-location-v3}:opaque';
|
|
19
|
+
/** Redis implementation of the opaque Location Store provider SPI. */
|
|
20
|
+
class ZLinkRedisLocationStore {
|
|
21
|
+
connection;
|
|
22
|
+
domain;
|
|
23
|
+
constructor(options) {
|
|
24
|
+
this.connection = new redis_connection_1.RedisConnection(options);
|
|
25
|
+
this.domain = `${options.keyPrefix}:${NAMESPACE}`;
|
|
26
|
+
}
|
|
27
|
+
async read(key, signal) {
|
|
28
|
+
const logicalKey = requireKey(key);
|
|
29
|
+
const result = (0, redis_values_1.asArray)(await this.connection.eval(opaque_redis_scripts_1.OPAQUE_READ_SCRIPT, [this.rowKey(logicalKey)], [], signal));
|
|
30
|
+
const storeNow = fromUnixMs((0, redis_values_1.toNumber)(result[1]));
|
|
31
|
+
if ((0, redis_values_1.toNumber)(result[0]) !== 1)
|
|
32
|
+
return { kind: 'missing', storeNow };
|
|
33
|
+
requireMatchingKey((0, redis_values_1.asString)(result[2]), logicalKey);
|
|
34
|
+
return {
|
|
35
|
+
kind: 'found',
|
|
36
|
+
value: {
|
|
37
|
+
bytes: rawBytes(result[3]),
|
|
38
|
+
version: storeVersion((0, redis_values_1.asString)(result[4])),
|
|
39
|
+
expiresAt: expiresAtOrUndefined((0, redis_values_1.toNumber)(result[5])),
|
|
40
|
+
storeNow
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
async write(request, signal) {
|
|
45
|
+
const encoded = encodeWrite(request);
|
|
46
|
+
const result = (0, redis_values_1.asArray)(await this.connection.eval(opaque_redis_scripts_1.OPAQUE_WRITE_SCRIPT, [
|
|
47
|
+
this.indexKey(),
|
|
48
|
+
this.mapKey(),
|
|
49
|
+
this.cleanupKey(),
|
|
50
|
+
this.sequenceKey(),
|
|
51
|
+
this.snapshotExpiryKey(),
|
|
52
|
+
this.snapshotBoundaryKey(),
|
|
53
|
+
...encoded.keys.map(key => this.rowKey(key))
|
|
54
|
+
], [
|
|
55
|
+
JSON.stringify(encoded.conditions),
|
|
56
|
+
JSON.stringify(encoded.mutations),
|
|
57
|
+
...encoded.putBytes
|
|
58
|
+
], signal));
|
|
59
|
+
const storeNow = fromUnixMs((0, redis_values_1.toNumber)(result[1]));
|
|
60
|
+
const outcome = (0, redis_values_1.asString)(result[0]);
|
|
61
|
+
if (outcome === 'conflict')
|
|
62
|
+
return { kind: 'conflict', storeNow };
|
|
63
|
+
if (outcome === 'backlog') {
|
|
64
|
+
throw new Error('Redis Location Store version backlog is full.');
|
|
65
|
+
}
|
|
66
|
+
if (outcome !== 'applied') {
|
|
67
|
+
throw new Error('Redis Location Store returned an unrecognized write outcome.');
|
|
68
|
+
}
|
|
69
|
+
const putVersions = [];
|
|
70
|
+
for (let index = 2; index < result.length; index += 2) {
|
|
71
|
+
putVersions.push({
|
|
72
|
+
key: storeKey((0, redis_values_1.asString)(result[index])),
|
|
73
|
+
version: storeVersion((0, redis_values_1.asString)(result[index + 1]))
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return { kind: 'applied', putVersions, storeNow };
|
|
77
|
+
}
|
|
78
|
+
async scan(request, signal) {
|
|
79
|
+
requireScanRequest(request);
|
|
80
|
+
if (request.cursor === undefined) {
|
|
81
|
+
const snapshotId = (0, node_crypto_1.randomUUID)();
|
|
82
|
+
return await this.readScanPage(snapshotId, await this.connection.eval(opaque_redis_scripts_1.OPAQUE_SCAN_START_SCRIPT, this.scanKeys(snapshotId), [request.prefix, String(request.limit), snapshotId], signal));
|
|
83
|
+
}
|
|
84
|
+
const cursor = parseCursor(request.cursor);
|
|
85
|
+
return await this.readScanPage(cursor.snapshotId, await this.connection.eval(opaque_redis_scripts_1.OPAQUE_SCAN_CONTINUE_SCRIPT, this.scanKeys(cursor.snapshotId), [request.prefix, cursor.lastKey, String(request.limit), cursor.snapshotId], signal));
|
|
86
|
+
}
|
|
87
|
+
async dispose() {
|
|
88
|
+
await this.connection.dispose();
|
|
89
|
+
}
|
|
90
|
+
async readScanPage(snapshotId, raw) {
|
|
91
|
+
const result = (0, redis_values_1.asArray)(raw);
|
|
92
|
+
const outcome = (0, redis_values_1.asString)(result[0]);
|
|
93
|
+
if (outcome === 'expired')
|
|
94
|
+
return { kind: 'expired' };
|
|
95
|
+
if (outcome === 'capacity') {
|
|
96
|
+
throw new Error('Redis Location Store snapshot capacity is full.');
|
|
97
|
+
}
|
|
98
|
+
if (outcome !== 'page') {
|
|
99
|
+
throw new Error('Redis Location Store returned an unrecognized scan outcome.');
|
|
100
|
+
}
|
|
101
|
+
const storeNow = fromUnixMs((0, redis_values_1.toNumber)(result[1]));
|
|
102
|
+
const nextKey = (0, redis_values_1.asString)(result[2]);
|
|
103
|
+
const items = [];
|
|
104
|
+
for (let index = 3; index < result.length; index += 4) {
|
|
105
|
+
items.push({
|
|
106
|
+
key: storeKey((0, redis_values_1.asString)(result[index])),
|
|
107
|
+
value: {
|
|
108
|
+
bytes: rawBytes(result[index + 1]),
|
|
109
|
+
version: storeVersion((0, redis_values_1.asString)(result[index + 2])),
|
|
110
|
+
expiresAt: expiresAtOrUndefined((0, redis_values_1.toNumber)(result[index + 3])),
|
|
111
|
+
storeNow
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
kind: 'page',
|
|
117
|
+
value: {
|
|
118
|
+
items,
|
|
119
|
+
nextCursor: nextKey.length === 0
|
|
120
|
+
? undefined
|
|
121
|
+
: scanCursor(`${snapshotId}:${Buffer.from(nextKey, 'utf8').toString('hex')}`),
|
|
122
|
+
storeNow
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
scanKeys(snapshotId) {
|
|
127
|
+
return [
|
|
128
|
+
this.indexKey(),
|
|
129
|
+
this.mapKey(),
|
|
130
|
+
this.snapshotKey(snapshotId),
|
|
131
|
+
this.cleanupKey(),
|
|
132
|
+
this.sequenceKey(),
|
|
133
|
+
this.snapshotExpiryKey(),
|
|
134
|
+
this.snapshotBoundaryKey()
|
|
135
|
+
];
|
|
136
|
+
}
|
|
137
|
+
indexKey() {
|
|
138
|
+
return `${this.domain}:index`;
|
|
139
|
+
}
|
|
140
|
+
mapKey() {
|
|
141
|
+
return `${this.domain}:map`;
|
|
142
|
+
}
|
|
143
|
+
cleanupKey() {
|
|
144
|
+
return `${this.domain}:cleanup`;
|
|
145
|
+
}
|
|
146
|
+
sequenceKey() {
|
|
147
|
+
return `${this.domain}:sequence`;
|
|
148
|
+
}
|
|
149
|
+
snapshotExpiryKey() {
|
|
150
|
+
return `${this.domain}:snapshot-expiry`;
|
|
151
|
+
}
|
|
152
|
+
snapshotBoundaryKey() {
|
|
153
|
+
return `${this.domain}:snapshot-boundary`;
|
|
154
|
+
}
|
|
155
|
+
snapshotKey(snapshotId) {
|
|
156
|
+
return `${this.domain}:scan:${snapshotId}`;
|
|
157
|
+
}
|
|
158
|
+
rowKey(logicalKey) {
|
|
159
|
+
return `${this.domain}:${digest(logicalKey)}`;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
exports.ZLinkRedisLocationStore = ZLinkRedisLocationStore;
|
|
163
|
+
function encodeWrite(request) {
|
|
164
|
+
const conditionKeys = request.conditions.map(condition => requireKey(condition.key));
|
|
165
|
+
const mutationKeys = request.mutations.map(mutation => requireKey(mutation.key));
|
|
166
|
+
if (new Set(conditionKeys).size !== conditionKeys.length
|
|
167
|
+
|| new Set(mutationKeys).size !== mutationKeys.length) {
|
|
168
|
+
throw new RangeError('Location Store condition and mutation keys must be unique.');
|
|
169
|
+
}
|
|
170
|
+
const keys = [...new Set([...conditionKeys, ...mutationKeys])];
|
|
171
|
+
if (keys.length > MAX_WRITE_KEYS) {
|
|
172
|
+
throw new RangeError('Location Store write exceeds 2,048 unique keys.');
|
|
173
|
+
}
|
|
174
|
+
// Row keys are appended after the six fixed auxiliary keys; the script
|
|
175
|
+
// adds 6 to this 1-based index before indexing into KEYS.
|
|
176
|
+
const keyIndex = new Map(keys.map((key, index) => [key, index + 1]));
|
|
177
|
+
let encodedBytes = 0;
|
|
178
|
+
const conditions = request.conditions.map(condition => {
|
|
179
|
+
const key = requireKey(condition.key);
|
|
180
|
+
encodedBytes += Buffer.byteLength(key, 'utf8');
|
|
181
|
+
if (condition.kind === 'missing')
|
|
182
|
+
return ['missing', keyIndex.get(key), key];
|
|
183
|
+
const expected = requireVersion(condition.expected);
|
|
184
|
+
encodedBytes += Buffer.byteLength(expected, 'utf8');
|
|
185
|
+
return ['version', keyIndex.get(key), key, expected];
|
|
186
|
+
});
|
|
187
|
+
const putBytes = [];
|
|
188
|
+
const mutations = request.mutations.map(mutation => {
|
|
189
|
+
const key = requireKey(mutation.key);
|
|
190
|
+
encodedBytes += Buffer.byteLength(key, 'utf8');
|
|
191
|
+
if (mutation.kind === 'delete')
|
|
192
|
+
return ['delete', keyIndex.get(key), key];
|
|
193
|
+
requireValue(mutation.bytes, mutation.retentionMs);
|
|
194
|
+
encodedBytes += mutation.bytes.byteLength;
|
|
195
|
+
putBytes.push(Buffer.from(mutation.bytes));
|
|
196
|
+
return [
|
|
197
|
+
'put',
|
|
198
|
+
keyIndex.get(key),
|
|
199
|
+
key,
|
|
200
|
+
mutation.retentionMs ?? false
|
|
201
|
+
];
|
|
202
|
+
});
|
|
203
|
+
if (encodedBytes > MAX_WRITE_BYTES) {
|
|
204
|
+
throw new RangeError('Location Store write exceeds 4 MiB encoded input.');
|
|
205
|
+
}
|
|
206
|
+
return { keys, conditions, mutations, putBytes };
|
|
207
|
+
}
|
|
208
|
+
function requireScanRequest(request) {
|
|
209
|
+
if (Buffer.byteLength(request.prefix, 'utf8') > 1_024) {
|
|
210
|
+
throw new RangeError('Location Store scan prefix exceeds 1,024 UTF-8 bytes.');
|
|
211
|
+
}
|
|
212
|
+
if (!Number.isSafeInteger(request.limit) || request.limit < 1 || request.limit > 1_000) {
|
|
213
|
+
throw new RangeError('Location Store scan limit must be in 1..1000.');
|
|
214
|
+
}
|
|
215
|
+
if (request.cursor !== undefined)
|
|
216
|
+
requireCursor(request.cursor);
|
|
217
|
+
}
|
|
218
|
+
function requireKey(key) {
|
|
219
|
+
const value = key.value;
|
|
220
|
+
const bytes = Buffer.byteLength(value, 'utf8');
|
|
221
|
+
if (bytes < 1 || bytes > 1_024) {
|
|
222
|
+
throw new RangeError('Location Store key must contain 1..1,024 UTF-8 bytes.');
|
|
223
|
+
}
|
|
224
|
+
return value;
|
|
225
|
+
}
|
|
226
|
+
function requireMatchingKey(actual, expected) {
|
|
227
|
+
if (actual !== expected) {
|
|
228
|
+
throw new Error('Redis opaque record key digest resolved to a different logical key.');
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function requireVersion(version) {
|
|
232
|
+
const value = version.value;
|
|
233
|
+
const bytes = Buffer.byteLength(value, 'utf8');
|
|
234
|
+
if (bytes < 1 || bytes > 4_096) {
|
|
235
|
+
throw new RangeError('Location Store version must contain 1..4,096 UTF-8 bytes.');
|
|
236
|
+
}
|
|
237
|
+
return value;
|
|
238
|
+
}
|
|
239
|
+
function requireCursor(cursor) {
|
|
240
|
+
const value = cursor.value;
|
|
241
|
+
const bytes = Buffer.byteLength(value, 'utf8');
|
|
242
|
+
if (bytes < 1 || bytes > 4_096) {
|
|
243
|
+
throw new RangeError('Location Store cursor must contain 1..4,096 UTF-8 bytes.');
|
|
244
|
+
}
|
|
245
|
+
return value;
|
|
246
|
+
}
|
|
247
|
+
function requireValue(bytes, retentionMs) {
|
|
248
|
+
if (bytes.byteLength > MAX_VALUE_BYTES) {
|
|
249
|
+
throw new RangeError('Location Store value exceeds 1 MiB.');
|
|
250
|
+
}
|
|
251
|
+
if (retentionMs !== undefined
|
|
252
|
+
&& (!Number.isSafeInteger(retentionMs) || retentionMs < 1)) {
|
|
253
|
+
throw new RangeError('Location Store retention must be a positive safe integer.');
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
function parseCursor(cursor) {
|
|
257
|
+
const value = requireCursor(cursor);
|
|
258
|
+
const separator = value.lastIndexOf(':');
|
|
259
|
+
const snapshotId = separator < 0 ? '' : value.slice(0, separator);
|
|
260
|
+
const lastKeyHex = separator < 0 ? '' : value.slice(separator + 1);
|
|
261
|
+
if (!/^[0-9a-f-]{36}$/.test(snapshotId)
|
|
262
|
+
|| !/^[0-9a-f]*$/.test(lastKeyHex)
|
|
263
|
+
|| lastKeyHex.length % 2 !== 0) {
|
|
264
|
+
throw new RangeError('Location Store scan cursor is invalid.');
|
|
265
|
+
}
|
|
266
|
+
return { snapshotId, lastKey: Buffer.from(lastKeyHex, 'hex').toString('utf8') };
|
|
267
|
+
}
|
|
268
|
+
function rawBytes(value) {
|
|
269
|
+
if (Buffer.isBuffer(value))
|
|
270
|
+
return Uint8Array.from(value);
|
|
271
|
+
if (value instanceof Uint8Array)
|
|
272
|
+
return Uint8Array.from(value);
|
|
273
|
+
return Uint8Array.from(Buffer.from((0, redis_values_1.asString)(value), 'utf8'));
|
|
274
|
+
}
|
|
275
|
+
function digest(value) {
|
|
276
|
+
return (0, node_crypto_1.createHash)('sha256').update(value, 'utf8').digest('hex');
|
|
277
|
+
}
|
|
278
|
+
function storeKey(value) {
|
|
279
|
+
return { value };
|
|
280
|
+
}
|
|
281
|
+
function storeVersion(value) {
|
|
282
|
+
return { value };
|
|
283
|
+
}
|
|
284
|
+
function scanCursor(value) {
|
|
285
|
+
return { value };
|
|
286
|
+
}
|
|
287
|
+
function expiresAtOrUndefined(expiresAtMs) {
|
|
288
|
+
return expiresAtMs === 0 ? undefined : fromUnixMs(expiresAtMs);
|
|
289
|
+
}
|
|
290
|
+
function fromUnixMs(value) {
|
|
291
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
292
|
+
throw new Error('Redis Store returned an invalid provider timestamp.');
|
|
293
|
+
}
|
|
294
|
+
return new Date(value);
|
|
295
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { RedisClientType } from 'redis';
|
|
2
|
+
import type { ZLinkRedisLocationOptions, ZLinkRedisRelocationOptions } from './redis-options';
|
|
3
|
+
export type RedisCommandValue = string | Buffer;
|
|
4
|
+
export type RedisCommandClient = Pick<RedisClientType, 'isOpen' | 'isReady' | 'connect' | 'disconnect' | 'sendCommand' | 'quit' | 'on'>;
|
|
5
|
+
export declare class RedisConnection {
|
|
6
|
+
private readonly providedClient?;
|
|
7
|
+
private client?;
|
|
8
|
+
private connectionAttempt?;
|
|
9
|
+
private disposed;
|
|
10
|
+
private readonly operationTimeoutMs?;
|
|
11
|
+
constructor(options: ZLinkRedisLocationOptions | ZLinkRedisRelocationOptions);
|
|
12
|
+
command(args: RedisCommandValue[], signal?: AbortSignal): Promise<unknown>;
|
|
13
|
+
eval(script: string, keys: readonly string[], args: readonly RedisCommandValue[], signal?: AbortSignal): Promise<unknown>;
|
|
14
|
+
dispose(): Promise<void>;
|
|
15
|
+
private connected;
|
|
16
|
+
}
|