@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,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RedisConnection = void 0;
|
|
4
|
+
const redis_1 = require("redis");
|
|
5
|
+
class RedisConnection {
|
|
6
|
+
providedClient;
|
|
7
|
+
client;
|
|
8
|
+
connectionAttempt;
|
|
9
|
+
disposed = false;
|
|
10
|
+
operationTimeoutMs;
|
|
11
|
+
constructor(options) {
|
|
12
|
+
requireOptions(options);
|
|
13
|
+
this.providedClient = options.client;
|
|
14
|
+
this.operationTimeoutMs = options.operationTimeoutMs;
|
|
15
|
+
if (options.client === undefined) {
|
|
16
|
+
this.client = (0, redis_1.createClient)({
|
|
17
|
+
disableOfflineQueue: true,
|
|
18
|
+
...(options.clientOptions ?? {}),
|
|
19
|
+
socket: {
|
|
20
|
+
reconnectStrategy: false,
|
|
21
|
+
...(options.clientOptions?.socket ?? {})
|
|
22
|
+
},
|
|
23
|
+
url: options.url ?? options.clientOptions?.url
|
|
24
|
+
});
|
|
25
|
+
this.client.on('error', () => { });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async command(args, signal) {
|
|
29
|
+
signal?.throwIfAborted();
|
|
30
|
+
const client = await this.connected(signal);
|
|
31
|
+
// Store values are opaque bytes. Returning RESP bulk strings as UTF-8
|
|
32
|
+
// strings would replace invalid byte sequences before the provider can
|
|
33
|
+
// copy them into Uint8Array.
|
|
34
|
+
const operation = client.sendCommand(args, {
|
|
35
|
+
typeMapping: {
|
|
36
|
+
[redis_1.RESP_TYPES.BLOB_STRING]: Buffer
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
return await waitForOperation(operation, signal, this.operationTimeoutMs);
|
|
40
|
+
}
|
|
41
|
+
async eval(script, keys, args, signal) {
|
|
42
|
+
return await this.command(['EVAL', script, String(keys.length), ...keys, ...args], signal);
|
|
43
|
+
}
|
|
44
|
+
async dispose() {
|
|
45
|
+
if (this.disposed)
|
|
46
|
+
return;
|
|
47
|
+
this.disposed = true;
|
|
48
|
+
const client = this.client;
|
|
49
|
+
this.client = undefined;
|
|
50
|
+
if (client !== undefined && client.isOpen === true) {
|
|
51
|
+
await client.quit();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async connected(signal) {
|
|
55
|
+
signal?.throwIfAborted();
|
|
56
|
+
if (this.disposed)
|
|
57
|
+
throw new Error('Redis Store is disposed.');
|
|
58
|
+
const client = this.providedClient ?? this.client;
|
|
59
|
+
if (client === undefined)
|
|
60
|
+
throw new Error('Redis Store is disposed.');
|
|
61
|
+
if (isClientReady(client))
|
|
62
|
+
return client;
|
|
63
|
+
if (this.connectionAttempt === undefined) {
|
|
64
|
+
// Publish the attempt before any await. A failed connection can leave
|
|
65
|
+
// node-redis open but not ready; the close and reconnect sequence must
|
|
66
|
+
// still be owned by this single promise when callers arrive together.
|
|
67
|
+
const attempt = (async () => {
|
|
68
|
+
if (client.isOpen === true && !isClientReady(client)) {
|
|
69
|
+
await client.disconnect();
|
|
70
|
+
}
|
|
71
|
+
await client.connect();
|
|
72
|
+
})();
|
|
73
|
+
this.connectionAttempt = attempt;
|
|
74
|
+
attempt.then(() => {
|
|
75
|
+
if (this.connectionAttempt === attempt)
|
|
76
|
+
this.connectionAttempt = undefined;
|
|
77
|
+
}, () => {
|
|
78
|
+
if (this.connectionAttempt === attempt)
|
|
79
|
+
this.connectionAttempt = undefined;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
await waitForOperation(this.connectionAttempt, signal, this.operationTimeoutMs);
|
|
83
|
+
if (!isClientReady(client)) {
|
|
84
|
+
throw new Error('Redis client connection completed before it became ready.');
|
|
85
|
+
}
|
|
86
|
+
return client;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
exports.RedisConnection = RedisConnection;
|
|
90
|
+
function isClientReady(client) {
|
|
91
|
+
return client.isReady === true;
|
|
92
|
+
}
|
|
93
|
+
function requireOptions(options) {
|
|
94
|
+
if (options.keyPrefix.length === 0) {
|
|
95
|
+
throw new Error('Redis Store keyPrefix is required.');
|
|
96
|
+
}
|
|
97
|
+
if (options.keyPrefix.includes('{') || options.keyPrefix.includes('}')) {
|
|
98
|
+
throw new Error('Redis Store keyPrefix must not contain hash-tag braces.');
|
|
99
|
+
}
|
|
100
|
+
if (options.client === undefined
|
|
101
|
+
&& options.url === undefined
|
|
102
|
+
&& options.clientOptions === undefined) {
|
|
103
|
+
throw new Error('Redis Store requires url, clientOptions, or client.');
|
|
104
|
+
}
|
|
105
|
+
if (options.operationTimeoutMs !== undefined
|
|
106
|
+
&& (!Number.isSafeInteger(options.operationTimeoutMs)
|
|
107
|
+
|| options.operationTimeoutMs < 1)) {
|
|
108
|
+
throw new RangeError('Redis Store operationTimeoutMs must be a positive safe integer.');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async function waitForOperation(operation, signal, timeoutMs) {
|
|
112
|
+
if (signal === undefined && timeoutMs === undefined)
|
|
113
|
+
return await operation;
|
|
114
|
+
return await new Promise((resolve, reject) => {
|
|
115
|
+
let timeout;
|
|
116
|
+
let settled = false;
|
|
117
|
+
const cleanup = () => {
|
|
118
|
+
signal?.removeEventListener('abort', onAbort);
|
|
119
|
+
if (timeout !== undefined)
|
|
120
|
+
clearTimeout(timeout);
|
|
121
|
+
};
|
|
122
|
+
const settle = (action) => {
|
|
123
|
+
if (settled)
|
|
124
|
+
return;
|
|
125
|
+
settled = true;
|
|
126
|
+
cleanup();
|
|
127
|
+
action();
|
|
128
|
+
};
|
|
129
|
+
const onAbort = () => settle(() => reject(signal?.reason ?? new Error('Redis Store operation aborted.')));
|
|
130
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
131
|
+
if (timeoutMs !== undefined) {
|
|
132
|
+
timeout = setTimeout(() => settle(() => reject(new Error(`Redis Store operation timed out after ${timeoutMs} ms.`))), timeoutMs);
|
|
133
|
+
}
|
|
134
|
+
operation.then(value => settle(() => resolve(value)), error => settle(() => reject(error)));
|
|
135
|
+
});
|
|
136
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { RedisClientOptions, RedisClientType } from 'redis';
|
|
2
|
+
export interface ZLinkRedisLocationOptions {
|
|
3
|
+
readonly url?: string;
|
|
4
|
+
readonly client?: RedisClientType;
|
|
5
|
+
readonly clientOptions?: RedisClientOptions;
|
|
6
|
+
readonly keyPrefix: string;
|
|
7
|
+
readonly operationTimeoutMs?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface ZLinkRedisRelocationOptions {
|
|
10
|
+
readonly url?: string;
|
|
11
|
+
readonly client?: RedisClientType;
|
|
12
|
+
readonly clientOptions?: RedisClientOptions;
|
|
13
|
+
readonly keyPrefix: string;
|
|
14
|
+
readonly operationTimeoutMs?: number;
|
|
15
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.asArray = asArray;
|
|
4
|
+
exports.asString = asString;
|
|
5
|
+
exports.toNumber = toNumber;
|
|
6
|
+
function asArray(value) {
|
|
7
|
+
if (!Array.isArray(value)) {
|
|
8
|
+
throw new TypeError('Redis command returned a non-array value.');
|
|
9
|
+
}
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
function asString(value) {
|
|
13
|
+
if (typeof value === 'string') {
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
if (Buffer.isBuffer(value)) {
|
|
17
|
+
return value.toString();
|
|
18
|
+
}
|
|
19
|
+
if (typeof value === 'number' || typeof value === 'bigint') {
|
|
20
|
+
return String(value);
|
|
21
|
+
}
|
|
22
|
+
throw new TypeError('Redis command returned a non-string value.');
|
|
23
|
+
}
|
|
24
|
+
function toNumber(value) {
|
|
25
|
+
return Number(asString(value));
|
|
26
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ZLinkBlobPutResult, ZLinkBlobReadResult, ZLinkBlobReference, ZLinkBlobRenewResult, ZLinkRelocationStore } from '@zlink-systems/framework';
|
|
2
|
+
import type { ZLinkRedisRelocationOptions } from './redis-options';
|
|
3
|
+
/** Redis implementation of immutable relocation blob storage. */
|
|
4
|
+
export declare class ZLinkRedisRelocationStore implements ZLinkRelocationStore {
|
|
5
|
+
private readonly connection;
|
|
6
|
+
private readonly domain;
|
|
7
|
+
constructor(options: ZLinkRedisRelocationOptions);
|
|
8
|
+
put(reference: ZLinkBlobReference, payload: Uint8Array, retentionMs: number, signal?: AbortSignal): Promise<ZLinkBlobPutResult>;
|
|
9
|
+
read(reference: ZLinkBlobReference, signal?: AbortSignal): Promise<ZLinkBlobReadResult>;
|
|
10
|
+
renew(reference: ZLinkBlobReference, retentionMs: number, signal?: AbortSignal): Promise<ZLinkBlobRenewResult>;
|
|
11
|
+
delete(reference: ZLinkBlobReference, signal?: AbortSignal): Promise<void>;
|
|
12
|
+
dispose(): Promise<void>;
|
|
13
|
+
private blobKey;
|
|
14
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ZLinkRedisRelocationStore = void 0;
|
|
4
|
+
const redis_connection_1 = require("./redis-connection");
|
|
5
|
+
const opaque_redis_scripts_1 = require("./opaque-redis-scripts");
|
|
6
|
+
const redis_values_1 = require("./redis-values");
|
|
7
|
+
const MAX_ENCODED_BLOB_BYTES = 64 * 1024 * 1024 + 23;
|
|
8
|
+
/** Redis implementation of immutable relocation blob storage. */
|
|
9
|
+
class ZLinkRedisRelocationStore {
|
|
10
|
+
connection;
|
|
11
|
+
domain;
|
|
12
|
+
constructor(options) {
|
|
13
|
+
this.connection = new redis_connection_1.RedisConnection(options);
|
|
14
|
+
// {prefix}:{zlink-relocation-v1}:blob:{reference}
|
|
15
|
+
// (23-relocation-store-redis.md#8). Braced for Redis Cluster hash-tag
|
|
16
|
+
// co-location, matching the dotnet/java reference.
|
|
17
|
+
this.domain = `${options.keyPrefix}:{zlink-relocation-v1}`;
|
|
18
|
+
}
|
|
19
|
+
async put(reference, payload, retentionMs, signal) {
|
|
20
|
+
const referenceValue = requireReference(reference);
|
|
21
|
+
requirePayload(payload);
|
|
22
|
+
const retention = requireRetention(retentionMs);
|
|
23
|
+
const result = (0, redis_values_1.asArray)(await this.connection.eval(opaque_redis_scripts_1.BLOB_PUT_SCRIPT, [this.blobKey(referenceValue)], [Buffer.from(payload), String(retention)], signal));
|
|
24
|
+
const kind = (0, redis_values_1.asString)(result[0]);
|
|
25
|
+
const storeNow = fromUnixMs((0, redis_values_1.toNumber)(result[1]));
|
|
26
|
+
if (kind === 'conflict')
|
|
27
|
+
return { kind, storeNow };
|
|
28
|
+
return {
|
|
29
|
+
kind: kind === 'alreadyStored' ? 'alreadyStored' : 'stored',
|
|
30
|
+
storeNow,
|
|
31
|
+
expiresAt: fromUnixMs((0, redis_values_1.toNumber)(result[2]))
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
async read(reference, signal) {
|
|
35
|
+
const referenceValue = requireReference(reference);
|
|
36
|
+
const result = (0, redis_values_1.asArray)(await this.connection.eval(opaque_redis_scripts_1.BLOB_READ_SCRIPT, [this.blobKey(referenceValue)], [], signal));
|
|
37
|
+
const storeNow = fromUnixMs((0, redis_values_1.toNumber)(result[1]));
|
|
38
|
+
if ((0, redis_values_1.toNumber)(result[0]) !== 1)
|
|
39
|
+
return { kind: 'missing', storeNow };
|
|
40
|
+
return {
|
|
41
|
+
kind: 'found',
|
|
42
|
+
bytes: Uint8Array.from(asBuffer(result[2])),
|
|
43
|
+
expiresAt: fromUnixMs((0, redis_values_1.toNumber)(result[3])),
|
|
44
|
+
storeNow
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
async renew(reference, retentionMs, signal) {
|
|
48
|
+
const referenceValue = requireReference(reference);
|
|
49
|
+
const retention = requireRetention(retentionMs);
|
|
50
|
+
const result = (0, redis_values_1.asArray)(await this.connection.eval(opaque_redis_scripts_1.BLOB_RENEW_SCRIPT, [this.blobKey(referenceValue)], [String(retention)], signal));
|
|
51
|
+
const storeNow = fromUnixMs((0, redis_values_1.toNumber)(result[1]));
|
|
52
|
+
if ((0, redis_values_1.toNumber)(result[0]) !== 1)
|
|
53
|
+
return { kind: 'missing', storeNow };
|
|
54
|
+
return {
|
|
55
|
+
kind: 'renewed',
|
|
56
|
+
expiresAt: fromUnixMs((0, redis_values_1.toNumber)(result[2])),
|
|
57
|
+
storeNow
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
async delete(reference, signal) {
|
|
61
|
+
const referenceValue = requireReference(reference);
|
|
62
|
+
await this.connection.command(['DEL', this.blobKey(referenceValue)], signal);
|
|
63
|
+
}
|
|
64
|
+
async dispose() {
|
|
65
|
+
await this.connection.dispose();
|
|
66
|
+
}
|
|
67
|
+
blobKey(reference) {
|
|
68
|
+
return `${this.domain}:blob:${reference}`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
exports.ZLinkRedisRelocationStore = ZLinkRedisRelocationStore;
|
|
72
|
+
function requireReference(reference) {
|
|
73
|
+
const value = reference.value;
|
|
74
|
+
const bytes = Buffer.byteLength(value, 'utf8');
|
|
75
|
+
if (bytes < 1 || bytes > 4_096) {
|
|
76
|
+
throw new RangeError('Relocation Store reference must contain 1..4,096 UTF-8 bytes.');
|
|
77
|
+
}
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
function requirePayload(payload) {
|
|
81
|
+
if (payload.byteLength > MAX_ENCODED_BLOB_BYTES) {
|
|
82
|
+
throw new RangeError('Relocation Store encoded blob exceeds 64 MiB + 23 bytes.');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function requireRetention(retentionMs) {
|
|
86
|
+
if (!Number.isSafeInteger(retentionMs) || retentionMs < 1) {
|
|
87
|
+
throw new RangeError('Relocation Store retention must be a positive safe integer.');
|
|
88
|
+
}
|
|
89
|
+
return retentionMs;
|
|
90
|
+
}
|
|
91
|
+
function asBuffer(value) {
|
|
92
|
+
if (Buffer.isBuffer(value))
|
|
93
|
+
return Buffer.from(value);
|
|
94
|
+
if (value instanceof Uint8Array)
|
|
95
|
+
return Buffer.from(value);
|
|
96
|
+
return Buffer.from((0, redis_values_1.asString)(value), 'utf8');
|
|
97
|
+
}
|
|
98
|
+
function fromUnixMs(value) {
|
|
99
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
100
|
+
throw new Error('Redis Store returned an invalid provider timestamp.');
|
|
101
|
+
}
|
|
102
|
+
return new Date(value);
|
|
103
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zlink-systems/framework-locations-redis",
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@zlink-systems/framework": "0.10.0",
|
|
15
|
+
"@zlink-systems/zlink": "0.17.3",
|
|
16
|
+
"redis": "^6.1.0"
|
|
17
|
+
}
|
|
18
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
const PROLOGUE = `
|
|
2
|
+
if redis.replicate_commands then redis.replicate_commands() end
|
|
3
|
+
local time = redis.call('TIME')
|
|
4
|
+
local nowMs = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
|
|
5
|
+
`;
|
|
6
|
+
|
|
7
|
+
// Shared decode helper. Every opaque row is a Redis ZSET append-log; the
|
|
8
|
+
// member with the highest score (the provider's monotonically increasing
|
|
9
|
+
// INCR sequence) is current. Each member is a 1-byte format tag (0x01)
|
|
10
|
+
// followed by a cmsgpack array {originalKey, rawBytes, version,
|
|
11
|
+
// expiresAtMs, tombstone}. expiresAtMs == 0 means no expiry (unsigned int
|
|
12
|
+
// family, never negative). tombstone is a real MessagePack bool.
|
|
13
|
+
// Unrecognized format tags fail explicitly rather than guessing how to
|
|
14
|
+
// read the value (21-location-runtime.md#2.4, 22-location-store-redis.md#7).
|
|
15
|
+
const DECODE_HELPERS = `
|
|
16
|
+
local function decodeMember(raw)
|
|
17
|
+
if string.byte(raw, 1) ~= 1 then
|
|
18
|
+
return redis.error_reply('zlink opaque record: unrecognized format tag')
|
|
19
|
+
end
|
|
20
|
+
return cmsgpack.unpack(string.sub(raw, 2))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
local function liveRecordAt(rowKey, referenceMs)
|
|
24
|
+
local members = redis.call('ZREVRANGE', rowKey, 0, 0)
|
|
25
|
+
if #members == 0 then return nil end
|
|
26
|
+
local record = decodeMember(members[1])
|
|
27
|
+
local expiresAtMs = tonumber(record[4])
|
|
28
|
+
if record[5] == true or (expiresAtMs > 0 and expiresAtMs <= referenceMs) then
|
|
29
|
+
return nil
|
|
30
|
+
end
|
|
31
|
+
return record
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
local function encodeMember(originalKey, bytes, version, expiresAtMs, tombstone)
|
|
35
|
+
return string.char(1) .. cmsgpack.pack({
|
|
36
|
+
originalKey, bytes, version, expiresAtMs, tombstone
|
|
37
|
+
})
|
|
38
|
+
end
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
export const OPAQUE_READ_SCRIPT = PROLOGUE + DECODE_HELPERS + `
|
|
42
|
+
local record = liveRecordAt(KEYS[1], nowMs)
|
|
43
|
+
if not record then return {0, nowMs} end
|
|
44
|
+
return {1, nowMs, record[1], record[2], record[3], tostring(tonumber(record[4]))}
|
|
45
|
+
`;
|
|
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
|
+
export const 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
|
+
|
|
164
|
+
// Shared body for point-in-time paged scanning. KEYS[1]=indexKey,
|
|
165
|
+
// KEYS[2]=mapKey, KEYS[3]=snapshotKey, KEYS[4]=cleanupKey,
|
|
166
|
+
// KEYS[5]=sequenceKey, KEYS[6]=snapshotExpiryKey, KEYS[7]=snapshotBoundaryKey.
|
|
167
|
+
const SCAN_CLEANUP_AND_BOUNDARY = DECODE_HELPERS + `
|
|
168
|
+
local expiredSnapshots = redis.call('ZRANGEBYSCORE', KEYS[6], '-inf', nowMs, 'LIMIT', 0, 128)
|
|
169
|
+
for _, expiredId in ipairs(expiredSnapshots) do
|
|
170
|
+
redis.call('ZREM', KEYS[6], expiredId)
|
|
171
|
+
redis.call('ZREM', KEYS[7], expiredId)
|
|
172
|
+
end
|
|
173
|
+
local minimumBoundary = nil
|
|
174
|
+
local boundaryEntry = redis.call('ZRANGE', KEYS[7], 0, 0, 'WITHSCORES')
|
|
175
|
+
if #boundaryEntry == 2 then minimumBoundary = tonumber(boundaryEntry[2]) end
|
|
176
|
+
|
|
177
|
+
local due = redis.call('ZRANGEBYSCORE', KEYS[4], '-inf', nowMs, 'LIMIT', 0, 32)
|
|
178
|
+
for _, original in ipairs(due) do
|
|
179
|
+
local rowKey = redis.call('HGET', KEYS[2], original)
|
|
180
|
+
local members = {}
|
|
181
|
+
if rowKey then
|
|
182
|
+
members = redis.call('ZREVRANGE', rowKey, 0, 0, 'WITHSCORES')
|
|
183
|
+
end
|
|
184
|
+
if #members == 0 then
|
|
185
|
+
redis.call('ZREM', KEYS[1], original)
|
|
186
|
+
redis.call('HDEL', KEYS[2], original)
|
|
187
|
+
redis.call('ZREM', KEYS[4], original)
|
|
188
|
+
elseif minimumBoundary then
|
|
189
|
+
local anchor = redis.call('ZREVRANGEBYSCORE', rowKey, minimumBoundary, '-inf', 'WITHSCORES', 'LIMIT', 0, 1)
|
|
190
|
+
if #anchor == 2 then
|
|
191
|
+
redis.call('ZREMRANGEBYSCORE', rowKey, '-inf', '(' .. anchor[2])
|
|
192
|
+
end
|
|
193
|
+
redis.call('ZADD', KEYS[4], nowMs + 1000, original)
|
|
194
|
+
else
|
|
195
|
+
local record = decodeMember(members[1])
|
|
196
|
+
local expiresAtMs = tonumber(record[4])
|
|
197
|
+
if record[5] == true or (expiresAtMs > 0 and expiresAtMs + 60000 <= nowMs) then
|
|
198
|
+
redis.call('DEL', rowKey)
|
|
199
|
+
redis.call('ZREM', KEYS[1], original)
|
|
200
|
+
redis.call('HDEL', KEYS[2], original)
|
|
201
|
+
redis.call('ZREM', KEYS[4], original)
|
|
202
|
+
else
|
|
203
|
+
redis.call('ZREMRANGEBYRANK', rowKey, 0, -2)
|
|
204
|
+
if expiresAtMs > 0 then
|
|
205
|
+
redis.call('ZADD', KEYS[4], math.max(nowMs + 1000, expiresAtMs + 60000), original)
|
|
206
|
+
else
|
|
207
|
+
redis.call('ZREM', KEYS[4], original)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
`;
|
|
213
|
+
|
|
214
|
+
const SCAN_PAGE_READ = `
|
|
215
|
+
local metadata = redis.call('HMGET', KEYS[3], 'now', 'boundary', 'prefix')
|
|
216
|
+
if not metadata[1] or metadata[3] ~= prefix then
|
|
217
|
+
redis.call('ZREM', KEYS[6], snapshotId)
|
|
218
|
+
redis.call('ZREM', KEYS[7], snapshotId)
|
|
219
|
+
return {'expired'}
|
|
220
|
+
end
|
|
221
|
+
local snapshotNow = tonumber(metadata[1])
|
|
222
|
+
local boundary = tonumber(metadata[2])
|
|
223
|
+
local lower = '-'
|
|
224
|
+
if string.len(lastKey) > 0 then lower = '(' .. lastKey end
|
|
225
|
+
local workLimit = math.max(limit * 4, 128)
|
|
226
|
+
local originals = redis.call('ZRANGEBYLEX', KEYS[1], lower, '+', 'LIMIT', 0, workLimit + 1)
|
|
227
|
+
local emitted = 0
|
|
228
|
+
local encodedBytes = 0
|
|
229
|
+
local examined = 0
|
|
230
|
+
local result = {'page', tostring(snapshotNow), ''}
|
|
231
|
+
while examined < #originals and examined < workLimit and emitted < limit do
|
|
232
|
+
local original = originals[examined + 1]
|
|
233
|
+
examined = examined + 1
|
|
234
|
+
if string.sub(original, 1, string.len(prefix)) == prefix then
|
|
235
|
+
local rowKey = redis.call('HGET', KEYS[2], original)
|
|
236
|
+
if rowKey then
|
|
237
|
+
local members = redis.call('ZREVRANGEBYSCORE', rowKey, boundary, '-inf', 'LIMIT', 0, 1)
|
|
238
|
+
if #members > 0 then
|
|
239
|
+
local record = decodeMember(members[1])
|
|
240
|
+
local expiresAtMs = tonumber(record[4])
|
|
241
|
+
if record[1] == original and record[5] ~= true
|
|
242
|
+
and (expiresAtMs == 0 or expiresAtMs > snapshotNow) then
|
|
243
|
+
local itemBytes = string.len(original) + string.len(record[2]) + string.len(record[3]) + 128
|
|
244
|
+
if emitted > 0 and encodedBytes + itemBytes > 4194304 then
|
|
245
|
+
examined = examined - 1
|
|
246
|
+
break
|
|
247
|
+
end
|
|
248
|
+
table.insert(result, original)
|
|
249
|
+
table.insert(result, record[2])
|
|
250
|
+
table.insert(result, record[3])
|
|
251
|
+
table.insert(result, tostring(expiresAtMs))
|
|
252
|
+
encodedBytes = encodedBytes + itemBytes
|
|
253
|
+
emitted = emitted + 1
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
local hasMore = examined < #originals
|
|
261
|
+
if not hasMore and #originals > workLimit then hasMore = true end
|
|
262
|
+
if hasMore then
|
|
263
|
+
result[3] = originals[examined]
|
|
264
|
+
else
|
|
265
|
+
redis.call('DEL', KEYS[3])
|
|
266
|
+
redis.call('ZREM', KEYS[6], snapshotId)
|
|
267
|
+
redis.call('ZREM', KEYS[7], snapshotId)
|
|
268
|
+
end
|
|
269
|
+
return result
|
|
270
|
+
`;
|
|
271
|
+
|
|
272
|
+
// ARGV = [prefix, limit, snapshotId]
|
|
273
|
+
export const OPAQUE_SCAN_START_SCRIPT = PROLOGUE + SCAN_CLEANUP_AND_BOUNDARY + `
|
|
274
|
+
local prefix = ARGV[1]
|
|
275
|
+
local limit = tonumber(ARGV[2])
|
|
276
|
+
local snapshotId = ARGV[3]
|
|
277
|
+
local lastKey = ''
|
|
278
|
+
|
|
279
|
+
if redis.call('ZCARD', KEYS[6]) >= 4096 then
|
|
280
|
+
return {'capacity'}
|
|
281
|
+
end
|
|
282
|
+
redis.call('DEL', KEYS[3])
|
|
283
|
+
local boundary = tonumber(redis.call('GET', KEYS[5]) or '0')
|
|
284
|
+
redis.call('HSET', KEYS[3], 'now', tostring(nowMs), 'boundary', tostring(boundary), 'prefix', prefix)
|
|
285
|
+
redis.call('PEXPIRE', KEYS[3], 60000)
|
|
286
|
+
redis.call('ZADD', KEYS[6], nowMs + 60000, snapshotId)
|
|
287
|
+
redis.call('ZADD', KEYS[7], boundary, snapshotId)
|
|
288
|
+
` + SCAN_PAGE_READ;
|
|
289
|
+
|
|
290
|
+
// ARGV = [prefix, lastKeyHex, limit, snapshotId]
|
|
291
|
+
export const OPAQUE_SCAN_CONTINUE_SCRIPT = PROLOGUE + SCAN_CLEANUP_AND_BOUNDARY + `
|
|
292
|
+
local prefix = ARGV[1]
|
|
293
|
+
local lastKey = ARGV[2]
|
|
294
|
+
local limit = tonumber(ARGV[3])
|
|
295
|
+
local snapshotId = ARGV[4]
|
|
296
|
+
|
|
297
|
+
if redis.call('EXISTS', KEYS[3]) == 0 then
|
|
298
|
+
redis.call('ZREM', KEYS[6], snapshotId)
|
|
299
|
+
redis.call('ZREM', KEYS[7], snapshotId)
|
|
300
|
+
return {'expired'}
|
|
301
|
+
end
|
|
302
|
+
` + SCAN_PAGE_READ;
|
|
303
|
+
|
|
304
|
+
// Relocation Store: raw-bytes STRING payloads at
|
|
305
|
+
// {prefix}:zlink-relocation-v1:blob:{reference}, retention via PSETEX/PX
|
|
306
|
+
// (23-relocation-store-redis.md#8). KEYS[1] is the blob key -- the reference
|
|
307
|
+
// itself is already the key's last segment, so identity on retry is decided
|
|
308
|
+
// by comparing the stored bytes against ARGV[1].
|
|
309
|
+
export const BLOB_PUT_SCRIPT = PROLOGUE + `
|
|
310
|
+
local existing = redis.call('GET', KEYS[1])
|
|
311
|
+
if existing then
|
|
312
|
+
if existing ~= ARGV[1] then
|
|
313
|
+
return {'conflict', nowMs}
|
|
314
|
+
end
|
|
315
|
+
local ttl = redis.call('PTTL', KEYS[1])
|
|
316
|
+
local expiresAtMs = nowMs + math.max(ttl, 0)
|
|
317
|
+
return {'alreadyStored', nowMs, tostring(expiresAtMs)}
|
|
318
|
+
end
|
|
319
|
+
local retentionMs = tonumber(ARGV[2])
|
|
320
|
+
redis.call('SET', KEYS[1], ARGV[1], 'PX', retentionMs)
|
|
321
|
+
return {'stored', nowMs, tostring(nowMs + retentionMs)}
|
|
322
|
+
`;
|
|
323
|
+
|
|
324
|
+
export const BLOB_READ_SCRIPT = PROLOGUE + `
|
|
325
|
+
local bytes = redis.call('GET', KEYS[1])
|
|
326
|
+
if not bytes then return {0, nowMs} end
|
|
327
|
+
local ttl = redis.call('PTTL', KEYS[1])
|
|
328
|
+
local expiresAtMs = nowMs + math.max(ttl, 0)
|
|
329
|
+
return {1, nowMs, bytes, tostring(expiresAtMs)}
|
|
330
|
+
`;
|
|
331
|
+
|
|
332
|
+
export const BLOB_RENEW_SCRIPT = PROLOGUE + `
|
|
333
|
+
if redis.call('EXISTS', KEYS[1]) == 0 then return {0, nowMs} end
|
|
334
|
+
local retentionMs = tonumber(ARGV[1])
|
|
335
|
+
redis.call('PEXPIRE', KEYS[1], retentionMs)
|
|
336
|
+
return {1, nowMs, tostring(nowMs + retentionMs)}
|
|
337
|
+
`;
|