@zlink-systems/framework-locations-redis 0.21.0 → 0.22.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/dist/.tsbuildinfo +1 -1
- package/dist/opaque-redis-scripts.js +24 -10
- package/dist/opaque-store.js +12 -18
- package/dist/redis-connection.js +6 -7
- package/package.json +2 -2
- package/src/opaque-redis-scripts.ts +32 -10
- package/src/opaque-store.ts +41 -58
- package/src/redis-connection.ts +14 -25
- package/src/relocation-store.ts +22 -31
|
@@ -39,7 +39,9 @@ local function encodeMember(originalKey, bytes, version, expiresAtMs, tombstone)
|
|
|
39
39
|
})
|
|
40
40
|
end
|
|
41
41
|
`;
|
|
42
|
-
exports.OPAQUE_READ_SCRIPT = PROLOGUE +
|
|
42
|
+
exports.OPAQUE_READ_SCRIPT = PROLOGUE +
|
|
43
|
+
DECODE_HELPERS +
|
|
44
|
+
`
|
|
43
45
|
local record = liveRecordAt(KEYS[1], nowMs)
|
|
44
46
|
if not record then return {0, nowMs} end
|
|
45
47
|
return {1, nowMs, record[1], record[2], record[3], tostring(tonumber(record[4]))}
|
|
@@ -54,7 +56,9 @@ return {1, nowMs, record[1], record[2], record[3], tostring(tonumber(record[4]))
|
|
|
54
56
|
// ['put', keyIndex, originalKey, retentionMsOrFalse]
|
|
55
57
|
// | ['delete', keyIndex, originalKey]
|
|
56
58
|
// ARGV[3..] = one raw-bytes argument per 'put' mutation, in mutation order.
|
|
57
|
-
exports.OPAQUE_WRITE_SCRIPT = PROLOGUE +
|
|
59
|
+
exports.OPAQUE_WRITE_SCRIPT = PROLOGUE +
|
|
60
|
+
DECODE_HELPERS +
|
|
61
|
+
`
|
|
58
62
|
local indexKey = KEYS[1]
|
|
59
63
|
local mapKey = KEYS[2]
|
|
60
64
|
local cleanupKey = KEYS[3]
|
|
@@ -163,7 +167,8 @@ return result
|
|
|
163
167
|
// Shared body for point-in-time paged scanning. KEYS[1]=indexKey,
|
|
164
168
|
// KEYS[2]=mapKey, KEYS[3]=snapshotKey, KEYS[4]=cleanupKey,
|
|
165
169
|
// KEYS[5]=sequenceKey, KEYS[6]=snapshotExpiryKey, KEYS[7]=snapshotBoundaryKey.
|
|
166
|
-
const SCAN_CLEANUP_AND_BOUNDARY = DECODE_HELPERS +
|
|
170
|
+
const SCAN_CLEANUP_AND_BOUNDARY = DECODE_HELPERS +
|
|
171
|
+
`
|
|
167
172
|
local expiredSnapshots = redis.call('ZRANGEBYSCORE', KEYS[6], '-inf', nowMs, 'LIMIT', 0, 128)
|
|
168
173
|
for _, expiredId in ipairs(expiredSnapshots) do
|
|
169
174
|
redis.call('ZREM', KEYS[6], expiredId)
|
|
@@ -267,7 +272,9 @@ end
|
|
|
267
272
|
return result
|
|
268
273
|
`;
|
|
269
274
|
// ARGV = [prefix, limit, snapshotId]
|
|
270
|
-
exports.OPAQUE_SCAN_START_SCRIPT = PROLOGUE +
|
|
275
|
+
exports.OPAQUE_SCAN_START_SCRIPT = PROLOGUE +
|
|
276
|
+
SCAN_CLEANUP_AND_BOUNDARY +
|
|
277
|
+
`
|
|
271
278
|
local prefix = ARGV[1]
|
|
272
279
|
local limit = tonumber(ARGV[2])
|
|
273
280
|
local snapshotId = ARGV[3]
|
|
@@ -282,9 +289,12 @@ redis.call('HSET', KEYS[3], 'now', tostring(nowMs), 'boundary', tostring(boundar
|
|
|
282
289
|
redis.call('PEXPIRE', KEYS[3], 60000)
|
|
283
290
|
redis.call('ZADD', KEYS[6], nowMs + 60000, snapshotId)
|
|
284
291
|
redis.call('ZADD', KEYS[7], boundary, snapshotId)
|
|
285
|
-
` +
|
|
292
|
+
` +
|
|
293
|
+
SCAN_PAGE_READ;
|
|
286
294
|
// ARGV = [prefix, lastKeyHex, limit, snapshotId]
|
|
287
|
-
exports.OPAQUE_SCAN_CONTINUE_SCRIPT = PROLOGUE +
|
|
295
|
+
exports.OPAQUE_SCAN_CONTINUE_SCRIPT = PROLOGUE +
|
|
296
|
+
SCAN_CLEANUP_AND_BOUNDARY +
|
|
297
|
+
`
|
|
288
298
|
local prefix = ARGV[1]
|
|
289
299
|
local lastKey = ARGV[2]
|
|
290
300
|
local limit = tonumber(ARGV[3])
|
|
@@ -295,13 +305,15 @@ if redis.call('EXISTS', KEYS[3]) == 0 then
|
|
|
295
305
|
redis.call('ZREM', KEYS[7], snapshotId)
|
|
296
306
|
return {'expired'}
|
|
297
307
|
end
|
|
298
|
-
` +
|
|
308
|
+
` +
|
|
309
|
+
SCAN_PAGE_READ;
|
|
299
310
|
// Relocation Store: raw-bytes STRING payloads at
|
|
300
311
|
// {prefix}:zlink-relocation-v1:blob:{reference}, retention via PSETEX/PX
|
|
301
312
|
// (23-relocation-store-redis.md#8). KEYS[1] is the blob key -- the reference
|
|
302
313
|
// itself is already the key's last segment, so identity on retry is decided
|
|
303
314
|
// by comparing the stored bytes against ARGV[1].
|
|
304
|
-
exports.BLOB_PUT_SCRIPT = PROLOGUE +
|
|
315
|
+
exports.BLOB_PUT_SCRIPT = PROLOGUE +
|
|
316
|
+
`
|
|
305
317
|
local existing = redis.call('GET', KEYS[1])
|
|
306
318
|
if existing then
|
|
307
319
|
if existing ~= ARGV[1] then
|
|
@@ -315,14 +327,16 @@ local retentionMs = tonumber(ARGV[2])
|
|
|
315
327
|
redis.call('SET', KEYS[1], ARGV[1], 'PX', retentionMs)
|
|
316
328
|
return {'stored', nowMs, tostring(nowMs + retentionMs)}
|
|
317
329
|
`;
|
|
318
|
-
exports.BLOB_READ_SCRIPT = PROLOGUE +
|
|
330
|
+
exports.BLOB_READ_SCRIPT = PROLOGUE +
|
|
331
|
+
`
|
|
319
332
|
local bytes = redis.call('GET', KEYS[1])
|
|
320
333
|
if not bytes then return {0, nowMs} end
|
|
321
334
|
local ttl = redis.call('PTTL', KEYS[1])
|
|
322
335
|
local expiresAtMs = nowMs + math.max(ttl, 0)
|
|
323
336
|
return {1, nowMs, bytes, tostring(expiresAtMs)}
|
|
324
337
|
`;
|
|
325
|
-
exports.BLOB_RENEW_SCRIPT = PROLOGUE +
|
|
338
|
+
exports.BLOB_RENEW_SCRIPT = PROLOGUE +
|
|
339
|
+
`
|
|
326
340
|
if redis.call('EXISTS', KEYS[1]) == 0 then return {0, nowMs} end
|
|
327
341
|
local retentionMs = tonumber(ARGV[1])
|
|
328
342
|
redis.call('PEXPIRE', KEYS[1], retentionMs)
|
package/dist/opaque-store.js
CHANGED
|
@@ -50,7 +50,7 @@ class ZLinkRedisLocationStore {
|
|
|
50
50
|
this.sequenceKey(),
|
|
51
51
|
this.snapshotExpiryKey(),
|
|
52
52
|
this.snapshotBoundaryKey(),
|
|
53
|
-
...encoded.keys.map(key => this.rowKey(key))
|
|
53
|
+
...encoded.keys.map((key) => this.rowKey(key))
|
|
54
54
|
], [
|
|
55
55
|
JSON.stringify(encoded.conditions),
|
|
56
56
|
JSON.stringify(encoded.mutations),
|
|
@@ -161,10 +161,10 @@ class ZLinkRedisLocationStore {
|
|
|
161
161
|
}
|
|
162
162
|
exports.ZLinkRedisLocationStore = ZLinkRedisLocationStore;
|
|
163
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
|
-
|
|
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
168
|
throw new RangeError('Location Store condition and mutation keys must be unique.');
|
|
169
169
|
}
|
|
170
170
|
const keys = [...new Set([...conditionKeys, ...mutationKeys])];
|
|
@@ -175,7 +175,7 @@ function encodeWrite(request) {
|
|
|
175
175
|
// adds 6 to this 1-based index before indexing into KEYS.
|
|
176
176
|
const keyIndex = new Map(keys.map((key, index) => [key, index + 1]));
|
|
177
177
|
let encodedBytes = 0;
|
|
178
|
-
const conditions = request.conditions.map(condition => {
|
|
178
|
+
const conditions = request.conditions.map((condition) => {
|
|
179
179
|
const key = requireKey(condition.key);
|
|
180
180
|
encodedBytes += Buffer.byteLength(key, 'utf8');
|
|
181
181
|
if (condition.kind === 'missing')
|
|
@@ -185,7 +185,7 @@ function encodeWrite(request) {
|
|
|
185
185
|
return ['version', keyIndex.get(key), key, expected];
|
|
186
186
|
});
|
|
187
187
|
const putBytes = [];
|
|
188
|
-
const mutations = request.mutations.map(mutation => {
|
|
188
|
+
const mutations = request.mutations.map((mutation) => {
|
|
189
189
|
const key = requireKey(mutation.key);
|
|
190
190
|
encodedBytes += Buffer.byteLength(key, 'utf8');
|
|
191
191
|
if (mutation.kind === 'delete')
|
|
@@ -193,12 +193,7 @@ function encodeWrite(request) {
|
|
|
193
193
|
requireValue(mutation.bytes, mutation.retentionMs);
|
|
194
194
|
encodedBytes += mutation.bytes.byteLength;
|
|
195
195
|
putBytes.push(Buffer.from(mutation.bytes));
|
|
196
|
-
return [
|
|
197
|
-
'put',
|
|
198
|
-
keyIndex.get(key),
|
|
199
|
-
key,
|
|
200
|
-
mutation.retentionMs ?? false
|
|
201
|
-
];
|
|
196
|
+
return ['put', keyIndex.get(key), key, mutation.retentionMs ?? false];
|
|
202
197
|
});
|
|
203
198
|
if (encodedBytes > MAX_WRITE_BYTES) {
|
|
204
199
|
throw new RangeError('Location Store write exceeds 4 MiB encoded input.');
|
|
@@ -248,8 +243,7 @@ function requireValue(bytes, retentionMs) {
|
|
|
248
243
|
if (bytes.byteLength > MAX_VALUE_BYTES) {
|
|
249
244
|
throw new RangeError('Location Store value exceeds 1 MiB.');
|
|
250
245
|
}
|
|
251
|
-
if (retentionMs !== undefined
|
|
252
|
-
&& (!Number.isSafeInteger(retentionMs) || retentionMs < 1)) {
|
|
246
|
+
if (retentionMs !== undefined && (!Number.isSafeInteger(retentionMs) || retentionMs < 1)) {
|
|
253
247
|
throw new RangeError('Location Store retention must be a positive safe integer.');
|
|
254
248
|
}
|
|
255
249
|
}
|
|
@@ -258,9 +252,9 @@ function parseCursor(cursor) {
|
|
|
258
252
|
const separator = value.lastIndexOf(':');
|
|
259
253
|
const snapshotId = separator < 0 ? '' : value.slice(0, separator);
|
|
260
254
|
const lastKeyHex = separator < 0 ? '' : value.slice(separator + 1);
|
|
261
|
-
if (!/^[0-9a-f-]{36}$/.test(snapshotId)
|
|
262
|
-
|
|
263
|
-
|
|
255
|
+
if (!/^[0-9a-f-]{36}$/.test(snapshotId) ||
|
|
256
|
+
!/^[0-9a-f]*$/.test(lastKeyHex) ||
|
|
257
|
+
lastKeyHex.length % 2 !== 0) {
|
|
264
258
|
throw new RangeError('Location Store scan cursor is invalid.');
|
|
265
259
|
}
|
|
266
260
|
return { snapshotId, lastKey: Buffer.from(lastKeyHex, 'hex').toString('utf8') };
|
package/dist/redis-connection.js
CHANGED
|
@@ -97,14 +97,13 @@ function requireOptions(options) {
|
|
|
97
97
|
if (options.keyPrefix.includes('{') || options.keyPrefix.includes('}')) {
|
|
98
98
|
throw new Error('Redis Store keyPrefix must not contain hash-tag braces.');
|
|
99
99
|
}
|
|
100
|
-
if (options.client === undefined
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
if (options.client === undefined &&
|
|
101
|
+
options.url === undefined &&
|
|
102
|
+
options.clientOptions === undefined) {
|
|
103
103
|
throw new Error('Redis Store requires url, clientOptions, or client.');
|
|
104
104
|
}
|
|
105
|
-
if (options.operationTimeoutMs !== undefined
|
|
106
|
-
|
|
107
|
-
|| options.operationTimeoutMs < 1)) {
|
|
105
|
+
if (options.operationTimeoutMs !== undefined &&
|
|
106
|
+
(!Number.isSafeInteger(options.operationTimeoutMs) || options.operationTimeoutMs < 1)) {
|
|
108
107
|
throw new RangeError('Redis Store operationTimeoutMs must be a positive safe integer.');
|
|
109
108
|
}
|
|
110
109
|
}
|
|
@@ -131,6 +130,6 @@ async function waitForOperation(operation, signal, timeoutMs) {
|
|
|
131
130
|
if (timeoutMs !== undefined) {
|
|
132
131
|
timeout = setTimeout(() => settle(() => reject(new Error(`Redis Store operation timed out after ${timeoutMs} ms.`))), timeoutMs);
|
|
133
132
|
}
|
|
134
|
-
operation.then(value => settle(() => resolve(value)), error => settle(() => reject(error)));
|
|
133
|
+
operation.then((value) => settle(() => resolve(value)), (error) => settle(() => reject(error)));
|
|
135
134
|
});
|
|
136
135
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zlink-systems/framework-locations-redis",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
}
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@zlink-systems/framework": "0.
|
|
20
|
+
"@zlink-systems/framework": "0.22.0",
|
|
21
21
|
"@zlink-systems/zlink": "1.2.1",
|
|
22
22
|
"redis": "^6.1.0"
|
|
23
23
|
}
|
|
@@ -38,7 +38,10 @@ local function encodeMember(originalKey, bytes, version, expiresAtMs, tombstone)
|
|
|
38
38
|
end
|
|
39
39
|
`;
|
|
40
40
|
|
|
41
|
-
export const OPAQUE_READ_SCRIPT =
|
|
41
|
+
export const OPAQUE_READ_SCRIPT =
|
|
42
|
+
PROLOGUE +
|
|
43
|
+
DECODE_HELPERS +
|
|
44
|
+
`
|
|
42
45
|
local record = liveRecordAt(KEYS[1], nowMs)
|
|
43
46
|
if not record then return {0, nowMs} end
|
|
44
47
|
return {1, nowMs, record[1], record[2], record[3], tostring(tonumber(record[4]))}
|
|
@@ -54,7 +57,10 @@ return {1, nowMs, record[1], record[2], record[3], tostring(tonumber(record[4]))
|
|
|
54
57
|
// ['put', keyIndex, originalKey, retentionMsOrFalse]
|
|
55
58
|
// | ['delete', keyIndex, originalKey]
|
|
56
59
|
// ARGV[3..] = one raw-bytes argument per 'put' mutation, in mutation order.
|
|
57
|
-
export const OPAQUE_WRITE_SCRIPT =
|
|
60
|
+
export const OPAQUE_WRITE_SCRIPT =
|
|
61
|
+
PROLOGUE +
|
|
62
|
+
DECODE_HELPERS +
|
|
63
|
+
`
|
|
58
64
|
local indexKey = KEYS[1]
|
|
59
65
|
local mapKey = KEYS[2]
|
|
60
66
|
local cleanupKey = KEYS[3]
|
|
@@ -164,7 +170,9 @@ return result
|
|
|
164
170
|
// Shared body for point-in-time paged scanning. KEYS[1]=indexKey,
|
|
165
171
|
// KEYS[2]=mapKey, KEYS[3]=snapshotKey, KEYS[4]=cleanupKey,
|
|
166
172
|
// KEYS[5]=sequenceKey, KEYS[6]=snapshotExpiryKey, KEYS[7]=snapshotBoundaryKey.
|
|
167
|
-
const SCAN_CLEANUP_AND_BOUNDARY =
|
|
173
|
+
const SCAN_CLEANUP_AND_BOUNDARY =
|
|
174
|
+
DECODE_HELPERS +
|
|
175
|
+
`
|
|
168
176
|
local expiredSnapshots = redis.call('ZRANGEBYSCORE', KEYS[6], '-inf', nowMs, 'LIMIT', 0, 128)
|
|
169
177
|
for _, expiredId in ipairs(expiredSnapshots) do
|
|
170
178
|
redis.call('ZREM', KEYS[6], expiredId)
|
|
@@ -270,7 +278,10 @@ return result
|
|
|
270
278
|
`;
|
|
271
279
|
|
|
272
280
|
// ARGV = [prefix, limit, snapshotId]
|
|
273
|
-
export const OPAQUE_SCAN_START_SCRIPT =
|
|
281
|
+
export const OPAQUE_SCAN_START_SCRIPT =
|
|
282
|
+
PROLOGUE +
|
|
283
|
+
SCAN_CLEANUP_AND_BOUNDARY +
|
|
284
|
+
`
|
|
274
285
|
local prefix = ARGV[1]
|
|
275
286
|
local limit = tonumber(ARGV[2])
|
|
276
287
|
local snapshotId = ARGV[3]
|
|
@@ -285,10 +296,14 @@ redis.call('HSET', KEYS[3], 'now', tostring(nowMs), 'boundary', tostring(boundar
|
|
|
285
296
|
redis.call('PEXPIRE', KEYS[3], 60000)
|
|
286
297
|
redis.call('ZADD', KEYS[6], nowMs + 60000, snapshotId)
|
|
287
298
|
redis.call('ZADD', KEYS[7], boundary, snapshotId)
|
|
288
|
-
` +
|
|
299
|
+
` +
|
|
300
|
+
SCAN_PAGE_READ;
|
|
289
301
|
|
|
290
302
|
// ARGV = [prefix, lastKeyHex, limit, snapshotId]
|
|
291
|
-
export const OPAQUE_SCAN_CONTINUE_SCRIPT =
|
|
303
|
+
export const OPAQUE_SCAN_CONTINUE_SCRIPT =
|
|
304
|
+
PROLOGUE +
|
|
305
|
+
SCAN_CLEANUP_AND_BOUNDARY +
|
|
306
|
+
`
|
|
292
307
|
local prefix = ARGV[1]
|
|
293
308
|
local lastKey = ARGV[2]
|
|
294
309
|
local limit = tonumber(ARGV[3])
|
|
@@ -299,14 +314,17 @@ if redis.call('EXISTS', KEYS[3]) == 0 then
|
|
|
299
314
|
redis.call('ZREM', KEYS[7], snapshotId)
|
|
300
315
|
return {'expired'}
|
|
301
316
|
end
|
|
302
|
-
` +
|
|
317
|
+
` +
|
|
318
|
+
SCAN_PAGE_READ;
|
|
303
319
|
|
|
304
320
|
// Relocation Store: raw-bytes STRING payloads at
|
|
305
321
|
// {prefix}:zlink-relocation-v1:blob:{reference}, retention via PSETEX/PX
|
|
306
322
|
// (23-relocation-store-redis.md#8). KEYS[1] is the blob key -- the reference
|
|
307
323
|
// itself is already the key's last segment, so identity on retry is decided
|
|
308
324
|
// by comparing the stored bytes against ARGV[1].
|
|
309
|
-
export const BLOB_PUT_SCRIPT =
|
|
325
|
+
export const BLOB_PUT_SCRIPT =
|
|
326
|
+
PROLOGUE +
|
|
327
|
+
`
|
|
310
328
|
local existing = redis.call('GET', KEYS[1])
|
|
311
329
|
if existing then
|
|
312
330
|
if existing ~= ARGV[1] then
|
|
@@ -321,7 +339,9 @@ redis.call('SET', KEYS[1], ARGV[1], 'PX', retentionMs)
|
|
|
321
339
|
return {'stored', nowMs, tostring(nowMs + retentionMs)}
|
|
322
340
|
`;
|
|
323
341
|
|
|
324
|
-
export const BLOB_READ_SCRIPT =
|
|
342
|
+
export const BLOB_READ_SCRIPT =
|
|
343
|
+
PROLOGUE +
|
|
344
|
+
`
|
|
325
345
|
local bytes = redis.call('GET', KEYS[1])
|
|
326
346
|
if not bytes then return {0, nowMs} end
|
|
327
347
|
local ttl = redis.call('PTTL', KEYS[1])
|
|
@@ -329,7 +349,9 @@ local expiresAtMs = nowMs + math.max(ttl, 0)
|
|
|
329
349
|
return {1, nowMs, bytes, tostring(expiresAtMs)}
|
|
330
350
|
`;
|
|
331
351
|
|
|
332
|
-
export const BLOB_RENEW_SCRIPT =
|
|
352
|
+
export const BLOB_RENEW_SCRIPT =
|
|
353
|
+
PROLOGUE +
|
|
354
|
+
`
|
|
333
355
|
if redis.call('EXISTS', KEYS[1]) == 0 then return {0, nowMs} end
|
|
334
356
|
local retentionMs = tonumber(ARGV[1])
|
|
335
357
|
redis.call('PEXPIRE', KEYS[1], retentionMs)
|
package/src/opaque-store.ts
CHANGED
|
@@ -43,17 +43,11 @@ export class ZLinkRedisLocationStore implements ZLinkLocationStore {
|
|
|
43
43
|
this.domain = `${options.keyPrefix}:${NAMESPACE}`;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
async read(
|
|
47
|
-
key: ZLinkStoreKey,
|
|
48
|
-
signal?: AbortSignal
|
|
49
|
-
): Promise<ZLinkStoreReadResult> {
|
|
46
|
+
async read(key: ZLinkStoreKey, signal?: AbortSignal): Promise<ZLinkStoreReadResult> {
|
|
50
47
|
const logicalKey = requireKey(key);
|
|
51
|
-
const result = asArray(
|
|
52
|
-
OPAQUE_READ_SCRIPT,
|
|
53
|
-
|
|
54
|
-
[],
|
|
55
|
-
signal
|
|
56
|
-
));
|
|
48
|
+
const result = asArray(
|
|
49
|
+
await this.connection.eval(OPAQUE_READ_SCRIPT, [this.rowKey(logicalKey)], [], signal)
|
|
50
|
+
);
|
|
57
51
|
const storeNow = fromUnixMs(toNumber(result[1]));
|
|
58
52
|
if (toNumber(result[0]) !== 1) return { kind: 'missing', storeNow };
|
|
59
53
|
requireMatchingKey(asString(result[2]), logicalKey);
|
|
@@ -73,24 +67,26 @@ export class ZLinkRedisLocationStore implements ZLinkLocationStore {
|
|
|
73
67
|
signal?: AbortSignal
|
|
74
68
|
): Promise<ZLinkStoreWriteResult> {
|
|
75
69
|
const encoded = encodeWrite(request);
|
|
76
|
-
const result = asArray(
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
70
|
+
const result = asArray(
|
|
71
|
+
await this.connection.eval(
|
|
72
|
+
OPAQUE_WRITE_SCRIPT,
|
|
73
|
+
[
|
|
74
|
+
this.indexKey(),
|
|
75
|
+
this.mapKey(),
|
|
76
|
+
this.cleanupKey(),
|
|
77
|
+
this.sequenceKey(),
|
|
78
|
+
this.snapshotExpiryKey(),
|
|
79
|
+
this.snapshotBoundaryKey(),
|
|
80
|
+
...encoded.keys.map((key) => this.rowKey(key))
|
|
81
|
+
],
|
|
82
|
+
[
|
|
83
|
+
JSON.stringify(encoded.conditions),
|
|
84
|
+
JSON.stringify(encoded.mutations),
|
|
85
|
+
...encoded.putBytes
|
|
86
|
+
],
|
|
87
|
+
signal
|
|
88
|
+
)
|
|
89
|
+
);
|
|
94
90
|
const storeNow = fromUnixMs(toNumber(result[1]));
|
|
95
91
|
const outcome = asString(result[0]);
|
|
96
92
|
if (outcome === 'conflict') return { kind: 'conflict', storeNow };
|
|
@@ -110,10 +106,7 @@ export class ZLinkRedisLocationStore implements ZLinkLocationStore {
|
|
|
110
106
|
return { kind: 'applied', putVersions, storeNow };
|
|
111
107
|
}
|
|
112
108
|
|
|
113
|
-
async scan(
|
|
114
|
-
request: ZLinkStoreScanRequest,
|
|
115
|
-
signal?: AbortSignal
|
|
116
|
-
): Promise<ZLinkStoreScanResult> {
|
|
109
|
+
async scan(request: ZLinkStoreScanRequest, signal?: AbortSignal): Promise<ZLinkStoreScanResult> {
|
|
117
110
|
requireScanRequest(request);
|
|
118
111
|
if (request.cursor === undefined) {
|
|
119
112
|
const snapshotId = randomUUID();
|
|
@@ -143,10 +136,7 @@ export class ZLinkRedisLocationStore implements ZLinkLocationStore {
|
|
|
143
136
|
await this.connection.dispose();
|
|
144
137
|
}
|
|
145
138
|
|
|
146
|
-
private async readScanPage(
|
|
147
|
-
snapshotId: string,
|
|
148
|
-
raw: unknown
|
|
149
|
-
): Promise<ZLinkStoreScanResult> {
|
|
139
|
+
private async readScanPage(snapshotId: string, raw: unknown): Promise<ZLinkStoreScanResult> {
|
|
150
140
|
const result = asArray(raw);
|
|
151
141
|
const outcome = asString(result[0]);
|
|
152
142
|
if (outcome === 'expired') return { kind: 'expired' };
|
|
@@ -174,9 +164,10 @@ export class ZLinkRedisLocationStore implements ZLinkLocationStore {
|
|
|
174
164
|
kind: 'page',
|
|
175
165
|
value: {
|
|
176
166
|
items,
|
|
177
|
-
nextCursor:
|
|
178
|
-
|
|
179
|
-
|
|
167
|
+
nextCursor:
|
|
168
|
+
nextKey.length === 0
|
|
169
|
+
? undefined
|
|
170
|
+
: scanCursor(`${snapshotId}:${Buffer.from(nextKey, 'utf8').toString('hex')}`),
|
|
180
171
|
storeNow
|
|
181
172
|
}
|
|
182
173
|
};
|
|
@@ -235,11 +226,11 @@ interface EncodedWrite {
|
|
|
235
226
|
}
|
|
236
227
|
|
|
237
228
|
function encodeWrite(request: ZLinkStoreWriteRequest): EncodedWrite {
|
|
238
|
-
const conditionKeys = request.conditions.map(condition => requireKey(condition.key));
|
|
239
|
-
const mutationKeys = request.mutations.map(mutation => requireKey(mutation.key));
|
|
229
|
+
const conditionKeys = request.conditions.map((condition) => requireKey(condition.key));
|
|
230
|
+
const mutationKeys = request.mutations.map((mutation) => requireKey(mutation.key));
|
|
240
231
|
if (
|
|
241
|
-
new Set(conditionKeys).size !== conditionKeys.length
|
|
242
|
-
|
|
232
|
+
new Set(conditionKeys).size !== conditionKeys.length ||
|
|
233
|
+
new Set(mutationKeys).size !== mutationKeys.length
|
|
243
234
|
) {
|
|
244
235
|
throw new RangeError('Location Store condition and mutation keys must be unique.');
|
|
245
236
|
}
|
|
@@ -251,7 +242,7 @@ function encodeWrite(request: ZLinkStoreWriteRequest): EncodedWrite {
|
|
|
251
242
|
// adds 6 to this 1-based index before indexing into KEYS.
|
|
252
243
|
const keyIndex = new Map(keys.map((key, index) => [key, index + 1]));
|
|
253
244
|
let encodedBytes = 0;
|
|
254
|
-
const conditions = request.conditions.map(condition => {
|
|
245
|
+
const conditions = request.conditions.map((condition) => {
|
|
255
246
|
const key = requireKey(condition.key);
|
|
256
247
|
encodedBytes += Buffer.byteLength(key, 'utf8');
|
|
257
248
|
if (condition.kind === 'missing') return ['missing', keyIndex.get(key), key];
|
|
@@ -260,19 +251,14 @@ function encodeWrite(request: ZLinkStoreWriteRequest): EncodedWrite {
|
|
|
260
251
|
return ['version', keyIndex.get(key), key, expected];
|
|
261
252
|
});
|
|
262
253
|
const putBytes: Buffer[] = [];
|
|
263
|
-
const mutations = request.mutations.map(mutation => {
|
|
254
|
+
const mutations = request.mutations.map((mutation) => {
|
|
264
255
|
const key = requireKey(mutation.key);
|
|
265
256
|
encodedBytes += Buffer.byteLength(key, 'utf8');
|
|
266
257
|
if (mutation.kind === 'delete') return ['delete', keyIndex.get(key), key];
|
|
267
258
|
requireValue(mutation.bytes, mutation.retentionMs);
|
|
268
259
|
encodedBytes += mutation.bytes.byteLength;
|
|
269
260
|
putBytes.push(Buffer.from(mutation.bytes));
|
|
270
|
-
return [
|
|
271
|
-
'put',
|
|
272
|
-
keyIndex.get(key),
|
|
273
|
-
key,
|
|
274
|
-
mutation.retentionMs ?? false
|
|
275
|
-
];
|
|
261
|
+
return ['put', keyIndex.get(key), key, mutation.retentionMs ?? false];
|
|
276
262
|
});
|
|
277
263
|
if (encodedBytes > MAX_WRITE_BYTES) {
|
|
278
264
|
throw new RangeError('Location Store write exceeds 4 MiB encoded input.');
|
|
@@ -327,10 +313,7 @@ function requireValue(bytes: Uint8Array, retentionMs: number | undefined): void
|
|
|
327
313
|
if (bytes.byteLength > MAX_VALUE_BYTES) {
|
|
328
314
|
throw new RangeError('Location Store value exceeds 1 MiB.');
|
|
329
315
|
}
|
|
330
|
-
if (
|
|
331
|
-
retentionMs !== undefined
|
|
332
|
-
&& (!Number.isSafeInteger(retentionMs) || retentionMs < 1)
|
|
333
|
-
) {
|
|
316
|
+
if (retentionMs !== undefined && (!Number.isSafeInteger(retentionMs) || retentionMs < 1)) {
|
|
334
317
|
throw new RangeError('Location Store retention must be a positive safe integer.');
|
|
335
318
|
}
|
|
336
319
|
}
|
|
@@ -344,9 +327,9 @@ function parseCursor(cursor: ZLinkStoreScanCursor): {
|
|
|
344
327
|
const snapshotId = separator < 0 ? '' : value.slice(0, separator);
|
|
345
328
|
const lastKeyHex = separator < 0 ? '' : value.slice(separator + 1);
|
|
346
329
|
if (
|
|
347
|
-
!/^[0-9a-f-]{36}$/.test(snapshotId)
|
|
348
|
-
|
|
349
|
-
|
|
330
|
+
!/^[0-9a-f-]{36}$/.test(snapshotId) ||
|
|
331
|
+
!/^[0-9a-f]*$/.test(lastKeyHex) ||
|
|
332
|
+
lastKeyHex.length % 2 !== 0
|
|
350
333
|
) {
|
|
351
334
|
throw new RangeError('Location Store scan cursor is invalid.');
|
|
352
335
|
}
|
package/src/redis-connection.ts
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { createClient, RESP_TYPES } from 'redis';
|
|
2
2
|
import type { RedisClientType } from 'redis';
|
|
3
|
-
import type {
|
|
4
|
-
ZLinkRedisLocationOptions,
|
|
5
|
-
ZLinkRedisRelocationOptions
|
|
6
|
-
} from './redis-options';
|
|
3
|
+
import type { ZLinkRedisLocationOptions, ZLinkRedisRelocationOptions } from './redis-options';
|
|
7
4
|
|
|
8
5
|
export type RedisCommandValue = string | Buffer;
|
|
9
6
|
export type RedisCommandClient = Pick<
|
|
@@ -56,10 +53,7 @@ export class RedisConnection {
|
|
|
56
53
|
args: readonly RedisCommandValue[],
|
|
57
54
|
signal?: AbortSignal
|
|
58
55
|
): Promise<unknown> {
|
|
59
|
-
return await this.command(
|
|
60
|
-
['EVAL', script, String(keys.length), ...keys, ...args],
|
|
61
|
-
signal
|
|
62
|
-
);
|
|
56
|
+
return await this.command(['EVAL', script, String(keys.length), ...keys, ...args], signal);
|
|
63
57
|
}
|
|
64
58
|
|
|
65
59
|
async dispose(): Promise<void> {
|
|
@@ -110,9 +104,7 @@ function isClientReady(client: RedisCommandClient): boolean {
|
|
|
110
104
|
return client.isReady === true;
|
|
111
105
|
}
|
|
112
106
|
|
|
113
|
-
function requireOptions(
|
|
114
|
-
options: ZLinkRedisLocationOptions | ZLinkRedisRelocationOptions
|
|
115
|
-
): void {
|
|
107
|
+
function requireOptions(options: ZLinkRedisLocationOptions | ZLinkRedisRelocationOptions): void {
|
|
116
108
|
if (options.keyPrefix.length === 0) {
|
|
117
109
|
throw new Error('Redis Store keyPrefix is required.');
|
|
118
110
|
}
|
|
@@ -120,16 +112,15 @@ function requireOptions(
|
|
|
120
112
|
throw new Error('Redis Store keyPrefix must not contain hash-tag braces.');
|
|
121
113
|
}
|
|
122
114
|
if (
|
|
123
|
-
options.client === undefined
|
|
124
|
-
|
|
125
|
-
|
|
115
|
+
options.client === undefined &&
|
|
116
|
+
options.url === undefined &&
|
|
117
|
+
options.clientOptions === undefined
|
|
126
118
|
) {
|
|
127
119
|
throw new Error('Redis Store requires url, clientOptions, or client.');
|
|
128
120
|
}
|
|
129
121
|
if (
|
|
130
|
-
options.operationTimeoutMs !== undefined
|
|
131
|
-
|
|
132
|
-
|| options.operationTimeoutMs < 1)
|
|
122
|
+
options.operationTimeoutMs !== undefined &&
|
|
123
|
+
(!Number.isSafeInteger(options.operationTimeoutMs) || options.operationTimeoutMs < 1)
|
|
133
124
|
) {
|
|
134
125
|
throw new RangeError('Redis Store operationTimeoutMs must be a positive safe integer.');
|
|
135
126
|
}
|
|
@@ -154,21 +145,19 @@ async function waitForOperation<T>(
|
|
|
154
145
|
cleanup();
|
|
155
146
|
action();
|
|
156
147
|
};
|
|
157
|
-
const onAbort = () =>
|
|
158
|
-
() => reject(signal?.reason ?? new Error('Redis Store operation aborted.'))
|
|
159
|
-
);
|
|
148
|
+
const onAbort = () =>
|
|
149
|
+
settle(() => reject(signal?.reason ?? new Error('Redis Store operation aborted.')));
|
|
160
150
|
signal?.addEventListener('abort', onAbort, { once: true });
|
|
161
151
|
if (timeoutMs !== undefined) {
|
|
162
152
|
timeout = setTimeout(
|
|
163
|
-
() =>
|
|
164
|
-
reject(new Error(`Redis Store operation timed out after ${timeoutMs} ms.`))
|
|
165
|
-
),
|
|
153
|
+
() =>
|
|
154
|
+
settle(() => reject(new Error(`Redis Store operation timed out after ${timeoutMs} ms.`))),
|
|
166
155
|
timeoutMs
|
|
167
156
|
);
|
|
168
157
|
}
|
|
169
158
|
operation.then(
|
|
170
|
-
value => settle(() => resolve(value)),
|
|
171
|
-
error => settle(() => reject(error))
|
|
159
|
+
(value) => settle(() => resolve(value)),
|
|
160
|
+
(error) => settle(() => reject(error))
|
|
172
161
|
);
|
|
173
162
|
});
|
|
174
163
|
}
|
package/src/relocation-store.ts
CHANGED
|
@@ -7,11 +7,7 @@ import type {
|
|
|
7
7
|
} from '@zlink-systems/framework';
|
|
8
8
|
import type { ZLinkRedisRelocationOptions } from './redis-options';
|
|
9
9
|
import { RedisConnection } from './redis-connection';
|
|
10
|
-
import {
|
|
11
|
-
BLOB_PUT_SCRIPT,
|
|
12
|
-
BLOB_READ_SCRIPT,
|
|
13
|
-
BLOB_RENEW_SCRIPT
|
|
14
|
-
} from './opaque-redis-scripts';
|
|
10
|
+
import { BLOB_PUT_SCRIPT, BLOB_READ_SCRIPT, BLOB_RENEW_SCRIPT } from './opaque-redis-scripts';
|
|
15
11
|
import { asArray, asString, toNumber } from './redis-values';
|
|
16
12
|
|
|
17
13
|
const MAX_ENCODED_BLOB_BYTES = 64 * 1024 * 1024 + 23;
|
|
@@ -38,12 +34,14 @@ export class ZLinkRedisRelocationStore implements ZLinkRelocationStore {
|
|
|
38
34
|
const referenceValue = requireReference(reference);
|
|
39
35
|
requirePayload(payload);
|
|
40
36
|
const retention = requireRetention(retentionMs);
|
|
41
|
-
const result = asArray(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
37
|
+
const result = asArray(
|
|
38
|
+
await this.connection.eval(
|
|
39
|
+
BLOB_PUT_SCRIPT,
|
|
40
|
+
[this.blobKey(referenceValue)],
|
|
41
|
+
[Buffer.from(payload), String(retention)],
|
|
42
|
+
signal
|
|
43
|
+
)
|
|
44
|
+
);
|
|
47
45
|
const kind = asString(result[0]);
|
|
48
46
|
const storeNow = fromUnixMs(toNumber(result[1]));
|
|
49
47
|
if (kind === 'conflict') return { kind, storeNow };
|
|
@@ -54,17 +52,11 @@ export class ZLinkRedisRelocationStore implements ZLinkRelocationStore {
|
|
|
54
52
|
};
|
|
55
53
|
}
|
|
56
54
|
|
|
57
|
-
async read(
|
|
58
|
-
reference: ZLinkBlobReference,
|
|
59
|
-
signal?: AbortSignal
|
|
60
|
-
): Promise<ZLinkBlobReadResult> {
|
|
55
|
+
async read(reference: ZLinkBlobReference, signal?: AbortSignal): Promise<ZLinkBlobReadResult> {
|
|
61
56
|
const referenceValue = requireReference(reference);
|
|
62
|
-
const result = asArray(
|
|
63
|
-
BLOB_READ_SCRIPT,
|
|
64
|
-
|
|
65
|
-
[],
|
|
66
|
-
signal
|
|
67
|
-
));
|
|
57
|
+
const result = asArray(
|
|
58
|
+
await this.connection.eval(BLOB_READ_SCRIPT, [this.blobKey(referenceValue)], [], signal)
|
|
59
|
+
);
|
|
68
60
|
const storeNow = fromUnixMs(toNumber(result[1]));
|
|
69
61
|
if (toNumber(result[0]) !== 1) return { kind: 'missing', storeNow };
|
|
70
62
|
return {
|
|
@@ -82,12 +74,14 @@ export class ZLinkRedisRelocationStore implements ZLinkRelocationStore {
|
|
|
82
74
|
): Promise<ZLinkBlobRenewResult> {
|
|
83
75
|
const referenceValue = requireReference(reference);
|
|
84
76
|
const retention = requireRetention(retentionMs);
|
|
85
|
-
const result = asArray(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
77
|
+
const result = asArray(
|
|
78
|
+
await this.connection.eval(
|
|
79
|
+
BLOB_RENEW_SCRIPT,
|
|
80
|
+
[this.blobKey(referenceValue)],
|
|
81
|
+
[String(retention)],
|
|
82
|
+
signal
|
|
83
|
+
)
|
|
84
|
+
);
|
|
91
85
|
const storeNow = fromUnixMs(toNumber(result[1]));
|
|
92
86
|
if (toNumber(result[0]) !== 1) return { kind: 'missing', storeNow };
|
|
93
87
|
return {
|
|
@@ -97,10 +91,7 @@ export class ZLinkRedisRelocationStore implements ZLinkRelocationStore {
|
|
|
97
91
|
};
|
|
98
92
|
}
|
|
99
93
|
|
|
100
|
-
async delete(
|
|
101
|
-
reference: ZLinkBlobReference,
|
|
102
|
-
signal?: AbortSignal
|
|
103
|
-
): Promise<void> {
|
|
94
|
+
async delete(reference: ZLinkBlobReference, signal?: AbortSignal): Promise<void> {
|
|
104
95
|
const referenceValue = requireReference(reference);
|
|
105
96
|
await this.connection.command(['DEL', this.blobKey(referenceValue)], signal);
|
|
106
97
|
}
|