resplite 1.0.0 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -21
- package/package.json +5 -1
- package/spec/SPEC_F.md +505 -0
- package/src/blocking/manager.js +183 -0
- package/src/cli/resplite-dirty-tracker.js +124 -0
- package/src/cli/resplite-import.js +237 -0
- package/src/commands/blpop.js +50 -0
- package/src/commands/brpop.js +50 -0
- package/src/commands/registry.js +11 -5
- package/src/engine/engine.js +11 -3
- package/src/migration/apply-dirty.js +97 -0
- package/src/migration/bulk.js +181 -0
- package/src/migration/import-one.js +106 -0
- package/src/migration/preflight.js +62 -0
- package/src/migration/registry.js +222 -0
- package/src/migration/verify.js +191 -0
- package/src/server/connection.js +55 -13
- package/src/storage/sqlite/db.js +2 -0
- package/src/storage/sqlite/migration-schema.js +66 -0
- package/tasks/todo.md +19 -0
- package/test/integration/blocking.test.js +107 -0
- package/test/unit/migration-registry.test.js +127 -0
package/src/engine/engine.js
CHANGED
|
@@ -9,6 +9,7 @@ import { createHashesStorage } from '../storage/sqlite/hashes.js';
|
|
|
9
9
|
import { createSetsStorage } from '../storage/sqlite/sets.js';
|
|
10
10
|
import { createListsStorage } from '../storage/sqlite/lists.js';
|
|
11
11
|
import { createZsetsStorage } from '../storage/sqlite/zsets.js';
|
|
12
|
+
import { createBlockingManager } from '../blocking/manager.js';
|
|
12
13
|
import { expectString, expectHash, expectSet, expectList, expectZset, typeName } from './validate.js';
|
|
13
14
|
import { asKey, asValue } from '../util/buffers.js';
|
|
14
15
|
|
|
@@ -45,7 +46,7 @@ export function createEngine(opts = {}) {
|
|
|
45
46
|
return meta;
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
|
|
49
|
+
const engine = {
|
|
49
50
|
get(key) {
|
|
50
51
|
const k = asKey(key);
|
|
51
52
|
const meta = getKeyMeta(key);
|
|
@@ -262,14 +263,18 @@ export function createEngine(opts = {}) {
|
|
|
262
263
|
const k = asKey(key);
|
|
263
264
|
getKeyMeta(key);
|
|
264
265
|
const buf = values.map((v) => (Buffer.isBuffer(v) ? v : asValue(v)));
|
|
265
|
-
|
|
266
|
+
const n = lists.lpush(k, buf);
|
|
267
|
+
if (this._blockingManager) this._blockingManager.wakeup(k);
|
|
268
|
+
return n;
|
|
266
269
|
},
|
|
267
270
|
|
|
268
271
|
rpush(key, ...values) {
|
|
269
272
|
const k = asKey(key);
|
|
270
273
|
getKeyMeta(key);
|
|
271
274
|
const buf = values.map((v) => (Buffer.isBuffer(v) ? v : asValue(v)));
|
|
272
|
-
|
|
275
|
+
const n = lists.rpush(k, buf);
|
|
276
|
+
if (this._blockingManager) this._blockingManager.wakeup(k);
|
|
277
|
+
return n;
|
|
273
278
|
},
|
|
274
279
|
|
|
275
280
|
llen(key) {
|
|
@@ -398,7 +403,10 @@ export function createEngine(opts = {}) {
|
|
|
398
403
|
_lists: lists,
|
|
399
404
|
_zsets: zsets,
|
|
400
405
|
_clock: clock,
|
|
406
|
+
_blockingManager: null,
|
|
401
407
|
};
|
|
408
|
+
engine._blockingManager = createBlockingManager(engine, { clock });
|
|
409
|
+
return engine;
|
|
402
410
|
}
|
|
403
411
|
|
|
404
412
|
export { KEY_TYPES };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delta apply: reimport or delete keys from dirty registry (SPEC_F §F.8).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { openDb } from '../storage/sqlite/db.js';
|
|
6
|
+
import { createKeysStorage } from '../storage/sqlite/keys.js';
|
|
7
|
+
import { createStringsStorage } from '../storage/sqlite/strings.js';
|
|
8
|
+
import { createHashesStorage } from '../storage/sqlite/hashes.js';
|
|
9
|
+
import { createSetsStorage } from '../storage/sqlite/sets.js';
|
|
10
|
+
import { createListsStorage } from '../storage/sqlite/lists.js';
|
|
11
|
+
import { createZsetsStorage } from '../storage/sqlite/zsets.js';
|
|
12
|
+
import { getRun, getDirtyBatch, markDirtyState, logError, RUN_STATUS } from './registry.js';
|
|
13
|
+
import { importKeyFromRedis } from './import-one.js';
|
|
14
|
+
|
|
15
|
+
function sleep(ms) {
|
|
16
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Apply dirty keys: for each key in registry with state=dirty, reimport from Redis or delete in destination.
|
|
21
|
+
* @param {import('redis').RedisClientType} redisClient
|
|
22
|
+
* @param {string} dbPath
|
|
23
|
+
* @param {string} runId
|
|
24
|
+
* @param {object} options
|
|
25
|
+
* @param {string} [options.pragmaTemplate='default']
|
|
26
|
+
* @param {number} [options.batch_keys=200]
|
|
27
|
+
* @param {number} [options.max_rps=0]
|
|
28
|
+
*/
|
|
29
|
+
export async function runApplyDirty(redisClient, dbPath, runId, options = {}) {
|
|
30
|
+
const { pragmaTemplate = 'default', batch_keys = 200, max_rps = 0 } = options;
|
|
31
|
+
|
|
32
|
+
const db = openDb(dbPath, { pragmaTemplate });
|
|
33
|
+
const run = getRun(db, runId);
|
|
34
|
+
if (!run) throw new Error(`Run ${runId} not found`);
|
|
35
|
+
|
|
36
|
+
const keys = createKeysStorage(db);
|
|
37
|
+
const strings = createStringsStorage(db, keys);
|
|
38
|
+
const hashes = createHashesStorage(db, keys);
|
|
39
|
+
const sets = createSetsStorage(db, keys);
|
|
40
|
+
const lists = createListsStorage(db, keys);
|
|
41
|
+
const zsets = createZsetsStorage(db, keys);
|
|
42
|
+
const storages = { keys, strings, hashes, sets, lists, zsets };
|
|
43
|
+
|
|
44
|
+
const minIntervalMs = max_rps > 0 ? 1000 / max_rps : 0;
|
|
45
|
+
let lastKeyTime = 0;
|
|
46
|
+
|
|
47
|
+
for (;;) {
|
|
48
|
+
let r = getRun(db, runId);
|
|
49
|
+
if (r && r.status === RUN_STATUS.ABORTED) break;
|
|
50
|
+
while (r && r.status === RUN_STATUS.PAUSED) {
|
|
51
|
+
await sleep(2000);
|
|
52
|
+
r = getRun(db, runId);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const batch = getDirtyBatch(db, runId, 'dirty', batch_keys);
|
|
56
|
+
if (batch.length === 0) break;
|
|
57
|
+
|
|
58
|
+
for (const { key: keyBuf } of batch) {
|
|
59
|
+
r = getRun(db, runId);
|
|
60
|
+
if (r && r.status === RUN_STATUS.ABORTED) break;
|
|
61
|
+
while (r && r.status === RUN_STATUS.PAUSED) {
|
|
62
|
+
await sleep(2000);
|
|
63
|
+
r = getRun(db, runId);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (minIntervalMs > 0) {
|
|
67
|
+
const elapsed = Date.now() - lastKeyTime;
|
|
68
|
+
if (elapsed < minIntervalMs) await sleep(minIntervalMs - elapsed);
|
|
69
|
+
lastKeyTime = Date.now();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const keyName = keyBuf.toString('utf8');
|
|
73
|
+
try {
|
|
74
|
+
const type = (await redisClient.type(keyName)).toLowerCase();
|
|
75
|
+
if (type === 'none' || !type) {
|
|
76
|
+
keys.delete(keyBuf);
|
|
77
|
+
markDirtyState(db, runId, keyBuf, 'deleted');
|
|
78
|
+
} else {
|
|
79
|
+
const outcome = await importKeyFromRedis(redisClient, keyName, storages, {});
|
|
80
|
+
if (outcome.ok) {
|
|
81
|
+
markDirtyState(db, runId, keyBuf, 'applied');
|
|
82
|
+
} else if (outcome.skipped) {
|
|
83
|
+
markDirtyState(db, runId, keyBuf, 'skipped');
|
|
84
|
+
} else {
|
|
85
|
+
logError(db, runId, 'dirty_apply', outcome.error ? 'Import failed' : 'Skipped', keyName);
|
|
86
|
+
markDirtyState(db, runId, keyBuf, 'error');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch (err) {
|
|
90
|
+
logError(db, runId, 'dirty_apply', err.message, keyBuf);
|
|
91
|
+
markDirtyState(db, runId, keyBuf, 'error');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return getRun(db, runId);
|
|
97
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bulk import from Redis with checkpointing, resume, and throttling (SPEC_F §F.7).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { openDb } from '../storage/sqlite/db.js';
|
|
6
|
+
import { createKeysStorage } from '../storage/sqlite/keys.js';
|
|
7
|
+
import { createStringsStorage } from '../storage/sqlite/strings.js';
|
|
8
|
+
import { createHashesStorage } from '../storage/sqlite/hashes.js';
|
|
9
|
+
import { createSetsStorage } from '../storage/sqlite/sets.js';
|
|
10
|
+
import { createListsStorage } from '../storage/sqlite/lists.js';
|
|
11
|
+
import { createZsetsStorage } from '../storage/sqlite/zsets.js';
|
|
12
|
+
import {
|
|
13
|
+
createRun,
|
|
14
|
+
getRun,
|
|
15
|
+
updateBulkProgress,
|
|
16
|
+
setRunStatus,
|
|
17
|
+
logError,
|
|
18
|
+
RUN_STATUS,
|
|
19
|
+
} from './registry.js';
|
|
20
|
+
import { importKeyFromRedis } from './import-one.js';
|
|
21
|
+
|
|
22
|
+
function parseScanResult(result) {
|
|
23
|
+
if (Array.isArray(result)) {
|
|
24
|
+
return { cursor: parseInt(result[0], 10), keys: result[1] || [] };
|
|
25
|
+
}
|
|
26
|
+
if (result && typeof result === 'object') {
|
|
27
|
+
const cursor = typeof result.cursor === 'number' ? result.cursor : parseInt(String(result.cursor), 10);
|
|
28
|
+
const keys = result.keys || [];
|
|
29
|
+
return { cursor, keys };
|
|
30
|
+
}
|
|
31
|
+
return { cursor: 0, keys: [] };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function sleep(ms) {
|
|
35
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Run bulk import: SCAN keys from Redis, import into RespLite DB with checkpointing.
|
|
40
|
+
* @param {import('redis').RedisClientType} redisClient
|
|
41
|
+
* @param {string} dbPath
|
|
42
|
+
* @param {string} runId
|
|
43
|
+
* @param {object} options
|
|
44
|
+
* @param {string} options.sourceUri
|
|
45
|
+
* @param {string} [options.pragmaTemplate='default']
|
|
46
|
+
* @param {number} [options.scan_count=1000]
|
|
47
|
+
* @param {number} [options.max_rps=0] - 0 = no limit
|
|
48
|
+
* @param {number} [options.batch_keys=200]
|
|
49
|
+
* @param {number} [options.batch_bytes=64*1024*1024] - 64MB
|
|
50
|
+
* @param {number} [options.checkpoint_interval_sec=30]
|
|
51
|
+
* @param {boolean} [options.resume=false]
|
|
52
|
+
* @param {function(run): void} [options.onProgress] - called after checkpoint with run row
|
|
53
|
+
*/
|
|
54
|
+
export async function runBulkImport(redisClient, dbPath, runId, options = {}) {
|
|
55
|
+
const {
|
|
56
|
+
sourceUri,
|
|
57
|
+
pragmaTemplate = 'default',
|
|
58
|
+
scan_count = 1000,
|
|
59
|
+
max_rps = 0,
|
|
60
|
+
batch_keys = 200,
|
|
61
|
+
batch_bytes = 64 * 1024 * 1024,
|
|
62
|
+
checkpoint_interval_sec = 30,
|
|
63
|
+
resume = false,
|
|
64
|
+
onProgress,
|
|
65
|
+
} = options;
|
|
66
|
+
|
|
67
|
+
const db = openDb(dbPath, { pragmaTemplate });
|
|
68
|
+
const keys = createKeysStorage(db);
|
|
69
|
+
const strings = createStringsStorage(db, keys);
|
|
70
|
+
const hashes = createHashesStorage(db, keys);
|
|
71
|
+
const sets = createSetsStorage(db, keys);
|
|
72
|
+
const lists = createListsStorage(db, keys);
|
|
73
|
+
const zsets = createZsetsStorage(db, keys);
|
|
74
|
+
const storages = { keys, strings, hashes, sets, lists, zsets };
|
|
75
|
+
|
|
76
|
+
createRun(db, runId, sourceUri, { scan_count_hint: scan_count });
|
|
77
|
+
let run = getRun(db, runId);
|
|
78
|
+
if (!run) throw new Error(`Run ${runId} not found`);
|
|
79
|
+
|
|
80
|
+
let cursor = resume && run.scan_cursor !== undefined ? parseInt(String(run.scan_cursor), 10) : 0;
|
|
81
|
+
let scanned_keys = resume ? (run.scanned_keys || 0) : 0;
|
|
82
|
+
let migrated_keys = resume ? (run.migrated_keys || 0) : 0;
|
|
83
|
+
let skipped_keys = resume ? (run.skipped_keys || 0) : 0;
|
|
84
|
+
let error_keys = resume ? (run.error_keys || 0) : 0;
|
|
85
|
+
let migrated_bytes = resume ? (run.migrated_bytes || 0) : 0;
|
|
86
|
+
|
|
87
|
+
if (!resume) {
|
|
88
|
+
updateBulkProgress(db, runId, { scan_cursor: String(cursor), scanned_keys, migrated_keys, skipped_keys, error_keys, migrated_bytes });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let lastCheckpointTime = Date.now();
|
|
92
|
+
let batchScanned = 0;
|
|
93
|
+
let batchBytes = 0;
|
|
94
|
+
const minIntervalMs = max_rps > 0 ? 1000 / max_rps : 0;
|
|
95
|
+
let lastKeyTime = 0;
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
do {
|
|
99
|
+
run = getRun(db, runId);
|
|
100
|
+
if (run && run.status === RUN_STATUS.ABORTED) {
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
while (run && run.status === RUN_STATUS.PAUSED) {
|
|
104
|
+
await sleep(2000);
|
|
105
|
+
run = getRun(db, runId);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const result = await redisClient.scan(cursor, { COUNT: scan_count });
|
|
109
|
+
const parsed = parseScanResult(result);
|
|
110
|
+
cursor = parsed.cursor;
|
|
111
|
+
const keyList = parsed.keys || [];
|
|
112
|
+
|
|
113
|
+
for (const keyName of keyList) {
|
|
114
|
+
run = getRun(db, runId);
|
|
115
|
+
if (run && run.status === RUN_STATUS.ABORTED) break;
|
|
116
|
+
while (run && run.status === RUN_STATUS.PAUSED) {
|
|
117
|
+
await sleep(2000);
|
|
118
|
+
run = getRun(db, runId);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
scanned_keys++;
|
|
122
|
+
if (minIntervalMs > 0) {
|
|
123
|
+
const elapsed = Date.now() - lastKeyTime;
|
|
124
|
+
if (elapsed < minIntervalMs) await sleep(minIntervalMs - elapsed);
|
|
125
|
+
lastKeyTime = Date.now();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
const outcome = await importKeyFromRedis(redisClient, keyName, storages, { now });
|
|
130
|
+
if (outcome.ok) {
|
|
131
|
+
migrated_keys++;
|
|
132
|
+
migrated_bytes += outcome.bytes || 0;
|
|
133
|
+
batchScanned++;
|
|
134
|
+
batchBytes += outcome.bytes || 0;
|
|
135
|
+
} else if (outcome.skipped) {
|
|
136
|
+
skipped_keys++;
|
|
137
|
+
} else {
|
|
138
|
+
error_keys++;
|
|
139
|
+
logError(db, runId, 'bulk', outcome.error ? 'Import failed' : 'Skipped', keyName);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const now2 = Date.now();
|
|
143
|
+
const shouldCheckpoint =
|
|
144
|
+
batchScanned >= batch_keys ||
|
|
145
|
+
batchBytes >= batch_bytes ||
|
|
146
|
+
now2 - lastCheckpointTime >= checkpoint_interval_sec * 1000;
|
|
147
|
+
if (shouldCheckpoint) {
|
|
148
|
+
updateBulkProgress(db, runId, {
|
|
149
|
+
scan_cursor: String(cursor),
|
|
150
|
+
scanned_keys,
|
|
151
|
+
migrated_keys,
|
|
152
|
+
skipped_keys,
|
|
153
|
+
error_keys,
|
|
154
|
+
migrated_bytes,
|
|
155
|
+
});
|
|
156
|
+
lastCheckpointTime = now2;
|
|
157
|
+
batchScanned = 0;
|
|
158
|
+
batchBytes = 0;
|
|
159
|
+
run = getRun(db, runId);
|
|
160
|
+
if (onProgress && run) onProgress(run);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
} while (cursor !== 0);
|
|
164
|
+
|
|
165
|
+
updateBulkProgress(db, runId, {
|
|
166
|
+
scan_cursor: '0',
|
|
167
|
+
scanned_keys,
|
|
168
|
+
migrated_keys,
|
|
169
|
+
skipped_keys,
|
|
170
|
+
error_keys,
|
|
171
|
+
migrated_bytes,
|
|
172
|
+
});
|
|
173
|
+
setRunStatus(db, runId, RUN_STATUS.COMPLETED);
|
|
174
|
+
return getRun(db, runId);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
setRunStatus(db, runId, RUN_STATUS.FAILED);
|
|
177
|
+
updateBulkProgress(db, runId, { last_error: err.message });
|
|
178
|
+
logError(db, runId, 'bulk', err.message, null);
|
|
179
|
+
throw err;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import a single key from Redis into RespLite storages (shared by bulk and delta apply).
|
|
3
|
+
* SPEC_F §F.7.1, F.8.2.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { asKey, asValue } from '../util/buffers.js';
|
|
7
|
+
|
|
8
|
+
const SUPPORTED_TYPES = new Set(['string', 'hash', 'set', 'list', 'zset']);
|
|
9
|
+
|
|
10
|
+
function toBuffer(value) {
|
|
11
|
+
if (value == null) return null;
|
|
12
|
+
if (Buffer.isBuffer(value)) return value;
|
|
13
|
+
if (typeof value === 'string') return Buffer.from(value, 'utf8');
|
|
14
|
+
return Buffer.from(String(value), 'utf8');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Fetch one key from Redis and write to storages. Idempotent (upsert).
|
|
19
|
+
* @param {import('redis').RedisClientType} redisClient
|
|
20
|
+
* @param {string} keyName
|
|
21
|
+
* @param {{ keys: import('../storage/sqlite/keys.js').ReturnType<import('../storage/sqlite/keys.js').createKeysStorage>; strings: ReturnType<import('../storage/sqlite/strings.js').createStringsStorage>; hashes: ReturnType<import('../storage/sqlite/hashes.js').createHashesStorage>; sets: ReturnType<import('../storage/sqlite/sets.js').createSetsStorage>; lists: ReturnType<import('../storage/sqlite/lists.js').createListsStorage>; zsets: ReturnType<import('../storage/sqlite/zsets.js').createZsetsStorage> }} storages
|
|
22
|
+
* @param {{ now?: number }} options
|
|
23
|
+
* @returns {Promise<{ ok: boolean; skipped?: boolean; error?: boolean; bytes?: number }>}
|
|
24
|
+
*/
|
|
25
|
+
export async function importKeyFromRedis(redisClient, keyName, storages, options = {}) {
|
|
26
|
+
const now = options.now ?? Date.now();
|
|
27
|
+
const { keys, strings, hashes, sets, lists, zsets } = storages;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const type = (await redisClient.type(keyName)).toLowerCase();
|
|
31
|
+
if (!SUPPORTED_TYPES.has(type)) {
|
|
32
|
+
return { ok: false, skipped: true };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let pttl = await redisClient.pTTL(keyName);
|
|
36
|
+
if (pttl === -2) pttl = -1;
|
|
37
|
+
const expiresAt = pttl > 0 ? now + pttl : null;
|
|
38
|
+
const keyBuf = asKey(keyName);
|
|
39
|
+
|
|
40
|
+
let bytes = keyBuf.length;
|
|
41
|
+
|
|
42
|
+
if (type === 'string') {
|
|
43
|
+
const value = await redisClient.get(keyName);
|
|
44
|
+
if (value === undefined || value === null) return { ok: false, skipped: true };
|
|
45
|
+
const valBuf = asValue(value);
|
|
46
|
+
bytes += valBuf.length;
|
|
47
|
+
strings.set(keyBuf, valBuf, { expiresAt, updatedAt: now });
|
|
48
|
+
return { ok: true, bytes };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (type === 'hash') {
|
|
52
|
+
const obj = await redisClient.hGetAll(keyName);
|
|
53
|
+
if (!obj || typeof obj !== 'object') return { ok: false, skipped: true };
|
|
54
|
+
const pairs = [];
|
|
55
|
+
for (const [f, v] of Object.entries(obj)) {
|
|
56
|
+
const fb = toBuffer(f);
|
|
57
|
+
const vb = toBuffer(v);
|
|
58
|
+
pairs.push(fb, vb);
|
|
59
|
+
bytes += fb.length + vb.length;
|
|
60
|
+
}
|
|
61
|
+
if (pairs.length === 0) return { ok: false, skipped: true };
|
|
62
|
+
hashes.setMultiple(keyBuf, pairs, { updatedAt: now });
|
|
63
|
+
keys.setExpires(keyBuf, expiresAt, now);
|
|
64
|
+
return { ok: true, bytes };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (type === 'set') {
|
|
68
|
+
const members = await redisClient.sMembers(keyName);
|
|
69
|
+
if (!members || !members.length) return { ok: false, skipped: true };
|
|
70
|
+
const memberBuffers = members.map((m) => toBuffer(m));
|
|
71
|
+
for (const b of memberBuffers) bytes += b.length;
|
|
72
|
+
sets.add(keyBuf, memberBuffers, { updatedAt: now });
|
|
73
|
+
keys.setExpires(keyBuf, expiresAt, now);
|
|
74
|
+
return { ok: true, bytes };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (type === 'list') {
|
|
78
|
+
const elements = await redisClient.lRange(keyName, 0, -1);
|
|
79
|
+
if (!elements || !elements.length) return { ok: false, skipped: true };
|
|
80
|
+
const valueBuffers = elements.map((e) => toBuffer(e));
|
|
81
|
+
for (const b of valueBuffers) bytes += b.length;
|
|
82
|
+
lists.rpush(keyBuf, valueBuffers, { updatedAt: now });
|
|
83
|
+
keys.setExpires(keyBuf, expiresAt, now);
|
|
84
|
+
return { ok: true, bytes };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (type === 'zset') {
|
|
88
|
+
const withScores = await redisClient.zRangeWithScores(keyName, 0, -1);
|
|
89
|
+
if (!withScores || !withScores.length) return { ok: false, skipped: true };
|
|
90
|
+
const pairs = withScores.map((item) => ({
|
|
91
|
+
member: toBuffer(item.value),
|
|
92
|
+
score: Number(item.score),
|
|
93
|
+
}));
|
|
94
|
+
for (const p of pairs) bytes += p.member.length + 8;
|
|
95
|
+
zsets.add(keyBuf, pairs, { updatedAt: now });
|
|
96
|
+
keys.setExpires(keyBuf, expiresAt, now);
|
|
97
|
+
return { ok: true, bytes };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { ok: false, skipped: true };
|
|
101
|
+
} catch (err) {
|
|
102
|
+
return { ok: false, error: true };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export { SUPPORTED_TYPES };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preflight check: estimate key count, type distribution, notify-keyspace-events (SPEC_F §F.9 Step 0).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {import('redis').RedisClientType} redisClient
|
|
7
|
+
* @returns {Promise<{ keyCountEstimate: number; typeDistribution: Record<string, number>; notifyKeyspaceEvents: string | null; recommended: { scan_count: number; max_concurrency: number; max_rps: number } }>}
|
|
8
|
+
*/
|
|
9
|
+
export async function runPreflight(redisClient) {
|
|
10
|
+
let keyCountEstimate = 0;
|
|
11
|
+
try {
|
|
12
|
+
keyCountEstimate = await redisClient.dbSize();
|
|
13
|
+
} catch (_) {
|
|
14
|
+
keyCountEstimate = 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const typeDistribution = { string: 0, hash: 0, set: 0, list: 0, zset: 0, other: 0 };
|
|
18
|
+
const sampleSize = Math.min(1000, Math.max(100, Math.floor(keyCountEstimate / 10)));
|
|
19
|
+
let cursor = 0;
|
|
20
|
+
let sampled = 0;
|
|
21
|
+
|
|
22
|
+
if (keyCountEstimate > 0) {
|
|
23
|
+
do {
|
|
24
|
+
const result = await redisClient.scan(cursor, { COUNT: 200 });
|
|
25
|
+
const keys = Array.isArray(result) ? result[1] : (result?.keys || []);
|
|
26
|
+
cursor = Array.isArray(result) ? parseInt(result[0], 10) : (result?.cursor ?? 0);
|
|
27
|
+
|
|
28
|
+
for (const key of keys) {
|
|
29
|
+
if (sampled >= sampleSize) break;
|
|
30
|
+
try {
|
|
31
|
+
const type = (await redisClient.type(key)).toLowerCase();
|
|
32
|
+
if (typeDistribution[type] !== undefined) typeDistribution[type]++;
|
|
33
|
+
else typeDistribution.other++;
|
|
34
|
+
sampled++;
|
|
35
|
+
} catch (_) {}
|
|
36
|
+
}
|
|
37
|
+
if (sampled >= sampleSize) break;
|
|
38
|
+
} while (cursor !== 0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let notifyKeyspaceEvents = null;
|
|
42
|
+
try {
|
|
43
|
+
const config = await redisClient.configGet('notify-keyspace-events');
|
|
44
|
+
notifyKeyspaceEvents = config && typeof config === 'object' ? config['notify-keyspace-events'] : null;
|
|
45
|
+
if (typeof notifyKeyspaceEvents !== 'string') notifyKeyspaceEvents = null;
|
|
46
|
+
} catch (_) {
|
|
47
|
+
notifyKeyspaceEvents = null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const recommended = {
|
|
51
|
+
scan_count: 1000,
|
|
52
|
+
max_concurrency: 32,
|
|
53
|
+
max_rps: keyCountEstimate > 100000 ? 2000 : 1000,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
keyCountEstimate,
|
|
58
|
+
typeDistribution,
|
|
59
|
+
notifyKeyspaceEvents,
|
|
60
|
+
recommended,
|
|
61
|
+
};
|
|
62
|
+
}
|