resplite 1.0.6 → 1.1.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 +102 -23
- package/package.json +20 -1
- package/scripts/benchmark-redis-vs-resplite.js +41 -8
- package/src/cli/resplite-dirty-tracker.js +15 -47
- package/src/commands/hlen.js +15 -0
- package/src/commands/lrem.js +15 -0
- package/src/commands/registry.js +4 -0
- package/src/engine/engine.js +19 -0
- package/src/migration/apply-dirty.js +34 -3
- package/src/migration/index.js +24 -4
- package/src/migration/preflight.js +80 -10
- package/src/migration/tracker.js +90 -0
- package/src/storage/sqlite/lists.js +50 -0
- package/src/storage/sqlite/zsets.js +15 -2
- package/test/contract/redis-client.test.js +40 -0
- package/test/integration/hashes.test.js +25 -0
- package/test/integration/lists.test.js +52 -0
- package/test/integration/migration-dirty-tracker.test.js +201 -0
- package/test/unit/engine-hashes.test.js +21 -0
- package/test/unit/engine-lists.test.js +73 -0
package/README.md
CHANGED
|
@@ -1,15 +1,63 @@
|
|
|
1
1
|
# RESPLite
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A RESP server backed by SQLite. Compatible with `redis` clients and `redis-cli`, persistent by default, zero external daemons, and minimal memory footprint.
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
|
7
|
-
RESPLite
|
|
7
|
+
RESPLite speaks **RESP** (the Redis Serialization Protocol), so your existing `redis` npm client and `redis-cli` work without changes. The storage layer is **SQLite**: WAL mode, FTS5 for full-text search, and a single `.db` file that survives restarts without snapshots or AOF.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
It is not a Redis clone. It covers a practical subset of commands that map naturally to SQLite, suited for single-node workloads where Redis' in-memory latency is not a hard requirement.
|
|
10
|
+
|
|
11
|
+
- **Zero external services** — just Node.js and a `.db` file.
|
|
10
12
|
- **Drop-in compatible** — works with the official `redis` npm client and `redis-cli`.
|
|
11
|
-
- **Persistent by default** —
|
|
12
|
-
- **Embeddable** — start the server and connect from the same script
|
|
13
|
+
- **Persistent by default** — no snapshots, no AOF, no config.
|
|
14
|
+
- **Embeddable** — start the server and connect from the same script.
|
|
15
|
+
- **Full-text search** — FT.\* commands via SQLite FTS5.
|
|
16
|
+
- **Simple queues** — lists with BLPOP/BRPOP.
|
|
17
|
+
|
|
18
|
+
### When RESPLite beats Redis in Docker
|
|
19
|
+
|
|
20
|
+
Building this project surfaced a clear finding: **Redis running inside Docker** on the same host often has **worse latency** than **RESPLite running locally**. Docker's virtual network adds overhead that disappears when the server runs in the same process/host. For single-node workloads this makes RESPLite the faster, simpler option.
|
|
21
|
+
|
|
22
|
+
The strongest use case is **migrating a non-replicated Redis instance that has grown large** (tens of GB). You don't need to manage replicas, AOF, or RDB. Once migrated, you get a single SQLite file and latency that is good enough for most workloads. The built-in migration tooling (see [Migration from Redis](#migration-from-redis)) handles datasets of that size with minimal downtime.
|
|
23
|
+
|
|
24
|
+
## Benchmark (Redis vs RESPLite)
|
|
25
|
+
|
|
26
|
+
A typical comparison is **Redis (e.g. in Docker)** on one side and **RESPLite locally** on the other. In that setup, RESPLite often shows **better latency** because it avoids Docker networking and runs in the same process/host. The benchmark below uses RESPLite with the **default** PRAGMA template only.
|
|
27
|
+
|
|
28
|
+
**Example results (Redis vs RESPLite, default pragma, 10k iterations):**
|
|
29
|
+
|
|
30
|
+
| Suite | Redis (Docker) | RESPLite (default) |
|
|
31
|
+
|-----------------|----------------|--------------------|
|
|
32
|
+
| PING | 9.17K/s | 27.59K/s |
|
|
33
|
+
| SET+GET | 4.65K/s | 9.53K/s |
|
|
34
|
+
| MSET+MGET(10) | 4.47K/s | 5.35K/s |
|
|
35
|
+
| INCR | 9.84K/s | 15.56K/s |
|
|
36
|
+
| HSET+HGET | 4.63K/s | 10.95K/s |
|
|
37
|
+
| HGETALL(50) | 9.19K/s | 8.19K/s |
|
|
38
|
+
| SADD+SMEMBERS | 9.16K/s | 15.54K/s |
|
|
39
|
+
| LPUSH+LRANGE | 7.97K/s | 10.24K/s |
|
|
40
|
+
| ZADD+ZRANGE | 8.08K/s | 2.89K/s |
|
|
41
|
+
| SET+DEL | 4.60K/s | 6.62K/s |
|
|
42
|
+
| FT.SEARCH | 8.20K/s | 7.20K/s |
|
|
43
|
+
|
|
44
|
+
*Run `npm run benchmark -- --template default` to reproduce. Numbers depend on host and whether Redis is native or in Docker.*
|
|
45
|
+
|
|
46
|
+
How to run:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# Terminal 1: Redis on 6379 (e.g. docker run -p 6379:6379 redis). Terminal 2: RESPLite on 6380
|
|
50
|
+
RESPLITE_PORT=6380 npm start
|
|
51
|
+
|
|
52
|
+
# Terminal 3: run benchmark (Redis=6379, RESPLite=6380 by default)
|
|
53
|
+
npm run benchmark
|
|
54
|
+
|
|
55
|
+
# Only RESPLite with default pragma
|
|
56
|
+
npm run benchmark -- --template default
|
|
57
|
+
|
|
58
|
+
# Custom iterations and ports
|
|
59
|
+
npm run benchmark -- --iterations 10000 --redis-port 6379 --resplite-port 6380
|
|
60
|
+
```
|
|
13
61
|
|
|
14
62
|
## Install
|
|
15
63
|
|
|
@@ -265,6 +313,8 @@ await srv2.close();
|
|
|
265
313
|
|
|
266
314
|
## Compatibility matrix
|
|
267
315
|
|
|
316
|
+
RESPLite implements **47 core Redis commands** (~19% of the ~246 commands in Redis 7). The uncovered 81% is mostly entire subsystems that are out of scope by design: pub/sub (~10 commands), Streams (~20), cluster/replication (~30), Lua scripting (~5), server admin (~40), and extended variants of data-structure commands. For typical single-node application workloads — strings, hashes, sets, lists, sorted sets, key TTLs — coverage is close to the commands developers reach for daily.
|
|
317
|
+
|
|
268
318
|
### Supported (v1)
|
|
269
319
|
|
|
270
320
|
| Category | Commands |
|
|
@@ -294,6 +344,8 @@ Unsupported commands return: `ERR command not supported yet`.
|
|
|
294
344
|
|
|
295
345
|
## Migration from Redis
|
|
296
346
|
|
|
347
|
+
RESPLite is a good fit for migrating **non-replicated Redis** instances that have **grown large** (e.g. tens of GB) and where RESPLite’s latency is acceptable. The SPEC_F flow (dirty-key tracker, bulk import, cutover) is designed for that scenario with minimal downtime.
|
|
348
|
+
|
|
297
349
|
Migration supports two modes:
|
|
298
350
|
|
|
299
351
|
### Simple one-shot import (legacy)
|
|
@@ -341,16 +393,18 @@ For large datasets (~30 GB), use the Dirty Key Registry flow so the bulk of the
|
|
|
341
393
|
**Enable keyspace notifications in Redis** (required for the dirty-key tracker). Either run at runtime:
|
|
342
394
|
|
|
343
395
|
```bash
|
|
344
|
-
redis-cli CONFIG SET notify-keyspace-events
|
|
396
|
+
redis-cli CONFIG SET notify-keyspace-events KEA
|
|
345
397
|
```
|
|
346
398
|
|
|
347
399
|
Or add to `redis.conf` and restart Redis:
|
|
348
400
|
|
|
349
401
|
```
|
|
350
|
-
notify-keyspace-events
|
|
402
|
+
notify-keyspace-events KEA
|
|
351
403
|
```
|
|
352
404
|
|
|
353
|
-
(`K` = keyspace, `
|
|
405
|
+
(`K` = keyspace prefix, `E` = keyevent prefix, `A` = all event types — lets the tracker see every key change and expiration.)
|
|
406
|
+
|
|
407
|
+
> **Renamed CONFIG command?** Some Redis deployments rename `CONFIG` for security. Pass `--config-command <name>` to the CLI tools, or the `configCommand` option to the JS API — see below.
|
|
354
408
|
|
|
355
409
|
1. **Preflight** – Check Redis, key count, type distribution, and that keyspace notifications are enabled:
|
|
356
410
|
```bash
|
|
@@ -360,6 +414,8 @@ notify-keyspace-events Kgxe
|
|
|
360
414
|
2. **Start dirty-key tracker** – Captures keys modified during bulk (requires `notify-keyspace-events` in Redis):
|
|
361
415
|
```bash
|
|
362
416
|
npx resplite-dirty-tracker start --run-id run_001 --from redis://10.0.0.10:6379 --to ./resplite.db
|
|
417
|
+
# If CONFIG was renamed:
|
|
418
|
+
npx resplite-dirty-tracker start --run-id run_001 --from redis://10.0.0.10:6379 --to ./resplite.db --config-command MYCONFIG
|
|
363
419
|
```
|
|
364
420
|
|
|
365
421
|
3. **Bulk import** – SCAN and copy all keys; progress is checkpointed and resumable:
|
|
@@ -408,14 +464,25 @@ const m = createMigration({
|
|
|
408
464
|
batchBytes: 64 * 1024 * 1024, // 64 MB
|
|
409
465
|
maxRps: 0, // 0 = unlimited
|
|
410
466
|
pragmaTemplate: 'default',
|
|
467
|
+
|
|
468
|
+
// If your Redis deployment renamed CONFIG for security:
|
|
469
|
+
// configCommand: 'MYCONFIG',
|
|
411
470
|
});
|
|
412
471
|
|
|
413
472
|
// Step 0 — Preflight: inspect Redis before starting
|
|
414
473
|
const info = await m.preflight();
|
|
415
474
|
console.log('keys (estimate):', info.keyCountEstimate);
|
|
416
475
|
console.log('type distribution:', info.typeDistribution);
|
|
476
|
+
console.log('notify-keyspace-events:', info.notifyKeyspaceEvents);
|
|
477
|
+
console.log('CONFIG available:', info.configCommandAvailable); // false if renamed
|
|
417
478
|
console.log('recommended params:', info.recommended);
|
|
418
479
|
|
|
480
|
+
// Step 0b — Enable keyspace notifications (required for dirty-key tracking)
|
|
481
|
+
// Reads the current value and merges the new flags — existing flags are preserved.
|
|
482
|
+
const ks = await m.enableKeyspaceNotifications();
|
|
483
|
+
// → { ok: true, previous: '', applied: 'KEA' }
|
|
484
|
+
// If CONFIG is renamed and configCommand was not set, ok=false and error explains how to fix it.
|
|
485
|
+
|
|
419
486
|
// Step 1 — Bulk import (checkpointed, resumable)
|
|
420
487
|
await m.bulk({
|
|
421
488
|
resume: false, // true to resume a previous run
|
|
@@ -441,30 +508,42 @@ await m.close();
|
|
|
441
508
|
|
|
442
509
|
The dirty-key tracker (to capture writes during bulk) still runs as a separate process via `npx resplite-dirty-tracker`. The API above handles everything else in a single script.
|
|
443
510
|
|
|
444
|
-
####
|
|
511
|
+
#### Renamed CONFIG command
|
|
445
512
|
|
|
446
|
-
If
|
|
513
|
+
If your Redis instance has the `CONFIG` command renamed (a common hardening practice), pass the new name to `createMigration`:
|
|
447
514
|
|
|
448
515
|
```javascript
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
516
|
+
const m = createMigration({
|
|
517
|
+
from: 'redis://10.0.0.10:6379',
|
|
518
|
+
to: './resplite.db',
|
|
519
|
+
runId: 'run_001',
|
|
520
|
+
configCommand: 'MYCONFIG', // the renamed command
|
|
521
|
+
});
|
|
454
522
|
|
|
455
|
-
|
|
523
|
+
// preflight will use MYCONFIG GET notify-keyspace-events
|
|
524
|
+
const info = await m.preflight();
|
|
525
|
+
// info.configCommandAvailable → false if the name is wrong
|
|
526
|
+
|
|
527
|
+
// enableKeyspaceNotifications will use MYCONFIG SET notify-keyspace-events KEA
|
|
528
|
+
const result = await m.enableKeyspaceNotifications({ value: 'KEA' });
|
|
529
|
+
```
|
|
456
530
|
|
|
457
|
-
|
|
531
|
+
The same flag is available in the CLI:
|
|
458
532
|
|
|
459
533
|
```bash
|
|
460
|
-
|
|
461
|
-
|
|
534
|
+
npx resplite-dirty-tracker start --run-id run_001 --to ./resplite.db \
|
|
535
|
+
--from redis://10.0.0.10:6379 --config-command MYCONFIG
|
|
536
|
+
```
|
|
462
537
|
|
|
463
|
-
|
|
464
|
-
npm run benchmark
|
|
538
|
+
#### Low-level re-exports
|
|
465
539
|
|
|
466
|
-
|
|
467
|
-
|
|
540
|
+
If you need more control, the individual functions and registry helpers are also exported:
|
|
541
|
+
|
|
542
|
+
```javascript
|
|
543
|
+
import {
|
|
544
|
+
runPreflight, runBulkImport, runApplyDirty, runVerify,
|
|
545
|
+
getRun, getDirtyCounts, createRun, setRunStatus, logError,
|
|
546
|
+
} from 'resplite/migration';
|
|
468
547
|
```
|
|
469
548
|
|
|
470
549
|
## Scripts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "resplite",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.4",
|
|
4
4
|
"description": "A RESP2 server with practical Redis compatibility, backed by SQLite",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -24,6 +24,25 @@
|
|
|
24
24
|
"benchmark": "node scripts/benchmark-redis-vs-resplite.js",
|
|
25
25
|
"test:all": "node --test 'test/**/*.test.js'"
|
|
26
26
|
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"redis",
|
|
29
|
+
"sqlite",
|
|
30
|
+
"resp",
|
|
31
|
+
"emulator",
|
|
32
|
+
"sdd",
|
|
33
|
+
"persist",
|
|
34
|
+
"cache",
|
|
35
|
+
"key-value",
|
|
36
|
+
"store",
|
|
37
|
+
"embedded",
|
|
38
|
+
"server",
|
|
39
|
+
"valkey",
|
|
40
|
+
"compatible",
|
|
41
|
+
"dragonfly",
|
|
42
|
+
"keydb",
|
|
43
|
+
"garnet",
|
|
44
|
+
"clasen"
|
|
45
|
+
],
|
|
27
46
|
"dependencies": {
|
|
28
47
|
"better-sqlite3": "^11.6.0"
|
|
29
48
|
},
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Comparative benchmark: Redis (local) vs RESPlite (all PRAGMA
|
|
3
|
+
* Comparative benchmark: Redis (local) vs RESPlite (all or one PRAGMA template).
|
|
4
4
|
*
|
|
5
5
|
* Prerequisites:
|
|
6
6
|
* - Redis running on port 6379 (default)
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
* on consecutive ports
|
|
8
|
+
* By default the script spawns one RESPlite process per PRAGMA template (default, performance, safety, minimal)
|
|
9
|
+
* on consecutive ports. Use --template <name> to run only one template (e.g. default).
|
|
10
10
|
*
|
|
11
11
|
* Usage:
|
|
12
|
-
* node scripts/benchmark-redis-vs-resplite.js [--iterations N] [--redis-port P] [--resplite-port P]
|
|
12
|
+
* node scripts/benchmark-redis-vs-resplite.js [--iterations N] [--redis-port P] [--resplite-port P] [--template NAME]
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { createClient } from 'redis';
|
|
@@ -26,8 +26,11 @@ const DEFAULTS = {
|
|
|
26
26
|
iterations: 10000,
|
|
27
27
|
redisPort: 6379,
|
|
28
28
|
resplitePort: 6380,
|
|
29
|
+
template: null, // null = all templates (except none); or 'default' | 'performance' | 'safety' | 'minimal'
|
|
29
30
|
};
|
|
30
31
|
|
|
32
|
+
const VALID_TEMPLATES = ['default', 'performance', 'safety', 'minimal'];
|
|
33
|
+
|
|
31
34
|
function parseArgs() {
|
|
32
35
|
const out = { ...DEFAULTS };
|
|
33
36
|
const args = process.argv.slice(2);
|
|
@@ -38,6 +41,13 @@ function parseArgs() {
|
|
|
38
41
|
out.redisPort = parseInt(args[++i], 10);
|
|
39
42
|
} else if (args[i] === '--resplite-port' && args[i + 1]) {
|
|
40
43
|
out.resplitePort = parseInt(args[++i], 10);
|
|
44
|
+
} else if (args[i] === '--template' && args[i + 1]) {
|
|
45
|
+
const name = args[++i];
|
|
46
|
+
if (!VALID_TEMPLATES.includes(name)) {
|
|
47
|
+
console.error(`Invalid --template "${name}". Must be one of: ${VALID_TEMPLATES.join(', ')}`);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
out.template = name;
|
|
41
51
|
}
|
|
42
52
|
}
|
|
43
53
|
return out;
|
|
@@ -204,6 +214,13 @@ async function benchHgetall(client, n) {
|
|
|
204
214
|
for (let i = 0; i < n; i++) await client.hGetAll(key);
|
|
205
215
|
}
|
|
206
216
|
|
|
217
|
+
async function benchHlen(client, n) {
|
|
218
|
+
const key = 'bm:hash:hlen';
|
|
219
|
+
await client.del(key);
|
|
220
|
+
await client.hSet(key, Object.fromEntries(Array.from({ length: 50 }, (_, i) => [`f${i}`, `v${i}`])));
|
|
221
|
+
for (let i = 0; i < n; i++) await client.hLen(key);
|
|
222
|
+
}
|
|
223
|
+
|
|
207
224
|
async function benchSaddSmembers(client, n) {
|
|
208
225
|
const key = 'bm:set';
|
|
209
226
|
for (let i = 0; i < n; i++) {
|
|
@@ -221,6 +238,18 @@ async function benchLpushLrange(client, n) {
|
|
|
221
238
|
}
|
|
222
239
|
}
|
|
223
240
|
|
|
241
|
+
async function benchLrem(client, n) {
|
|
242
|
+
const key = 'bm:list:lrem';
|
|
243
|
+
await client.del(key);
|
|
244
|
+
// Pre-populate with a mix of values so LREM always finds something to do.
|
|
245
|
+
// Each iteration pushes one 'target' element and removes it — net-zero list size.
|
|
246
|
+
await client.rPush(key, Array.from({ length: 20 }, (_, i) => `item-${i}`));
|
|
247
|
+
for (let i = 0; i < n; i++) {
|
|
248
|
+
await client.rPush(key, 'target');
|
|
249
|
+
await client.lRem(key, 1, 'target');
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
224
253
|
async function benchZaddZrange(client, n) {
|
|
225
254
|
const key = 'bm:zset';
|
|
226
255
|
for (let i = 0; i < n; i++) {
|
|
@@ -277,8 +306,10 @@ const SUITES = [
|
|
|
277
306
|
{ name: 'INCR', fn: benchIncr, iterScale: 1 },
|
|
278
307
|
{ name: 'HSET+HGET', fn: benchHsetHget, iterScale: 1 },
|
|
279
308
|
{ name: 'HGETALL(50)', fn: benchHgetall, iterScale: 1 },
|
|
309
|
+
{ name: 'HLEN(50)', fn: benchHlen, iterScale: 1 },
|
|
280
310
|
{ name: 'SADD+SMEMBERS', fn: benchSaddSmembers, iterScale: 1 },
|
|
281
311
|
{ name: 'LPUSH+LRANGE', fn: benchLpushLrange, iterScale: 1 },
|
|
312
|
+
{ name: 'LREM', fn: benchLrem, iterScale: 1 },
|
|
282
313
|
{ name: 'ZADD+ZRANGE', fn: benchZaddZrange, iterScale: 1 },
|
|
283
314
|
{ name: 'SET+DEL', fn: benchDel, iterScale: 1 },
|
|
284
315
|
{ name: 'FT.SEARCH', fn: benchFtSearch, iterScale: 1 },
|
|
@@ -304,15 +335,17 @@ async function runSuite(redis, respliteClients, suite, iterations) {
|
|
|
304
335
|
}
|
|
305
336
|
|
|
306
337
|
async function main() {
|
|
307
|
-
const { iterations, redisPort, resplitePort } = parseArgs();
|
|
308
|
-
const templateNames =
|
|
338
|
+
const { iterations, redisPort, resplitePort, template } = parseArgs();
|
|
339
|
+
const templateNames = template
|
|
340
|
+
? [template]
|
|
341
|
+
: getPragmaTemplateNames().filter((t) => t !== 'none');
|
|
309
342
|
|
|
310
343
|
const benchTmpDir = path.join(PROJECT_ROOT, 'tmp', 'bench');
|
|
311
344
|
fs.mkdirSync(benchTmpDir, { recursive: true });
|
|
312
345
|
|
|
313
|
-
console.log('Benchmark: Redis vs RESPlite (all PRAGMA templates)');
|
|
346
|
+
console.log(template ? `Benchmark: Redis vs RESPlite (template: ${template})` : 'Benchmark: Redis vs RESPlite (all PRAGMA templates)');
|
|
314
347
|
console.log(` Redis: 127.0.0.1:${redisPort}`);
|
|
315
|
-
console.log(` RESPlite:
|
|
348
|
+
console.log(` RESPlite: ${templateNames.length} process(es) on port(s) ${resplitePort}${templateNames.length > 1 ? `..${resplitePort + templateNames.length - 1}` : ''}`);
|
|
316
349
|
console.log(` Templates: ${templateNames.join(', ')}`);
|
|
317
350
|
console.log(` Iterations per suite: ${iterations}`);
|
|
318
351
|
console.log('');
|
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
* Usage: resplite-dirty-tracker start|stop [options]
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { createClient } from 'redis';
|
|
8
7
|
import { fileURLToPath } from 'node:url';
|
|
9
8
|
import { openDb } from '../storage/sqlite/db.js';
|
|
10
|
-
import {
|
|
9
|
+
import { getRun, setRunStatus, RUN_STATUS } from '../migration/registry.js';
|
|
10
|
+
import { startDirtyTracker } from '../migration/tracker.js';
|
|
11
11
|
|
|
12
12
|
function parseArgs(argv = process.argv.slice(2)) {
|
|
13
13
|
const args = { _: [] };
|
|
@@ -18,71 +18,39 @@ function parseArgs(argv = process.argv.slice(2)) {
|
|
|
18
18
|
else if (arg === '--run-id' && argv[i + 1]) args.runId = argv[++i];
|
|
19
19
|
else if (arg === '--channels' && argv[i + 1]) args.channels = argv[++i];
|
|
20
20
|
else if (arg === '--pragma-template' && argv[i + 1]) args.pragmaTemplate = argv[++i];
|
|
21
|
+
else if (arg === '--config-command' && argv[i + 1]) args.configCommand = argv[++i];
|
|
21
22
|
else if (!arg.startsWith('--')) args._.push(arg);
|
|
22
23
|
}
|
|
23
24
|
return args;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
async function
|
|
27
|
-
let val = null;
|
|
28
|
-
try {
|
|
29
|
-
const config = await client.configGet('notify-keyspace-events');
|
|
30
|
-
val = config && typeof config === 'object' ? config['notify-keyspace-events'] : null;
|
|
31
|
-
} catch (_) {}
|
|
32
|
-
if (!val || val === '') {
|
|
33
|
-
throw new Error('Redis notify-keyspace-events is not set. Enable it (e.g. CONFIG SET notify-keyspace-events Kgxe) for the dirty-key tracker.');
|
|
34
|
-
}
|
|
35
|
-
return val;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
async function startTracker(args) {
|
|
27
|
+
async function startTrackerCli(args) {
|
|
39
28
|
const redisUrl = args.from || process.env.RESPLITE_IMPORT_FROM || 'redis://127.0.0.1:6379';
|
|
40
29
|
const dbPath = args.to;
|
|
41
30
|
const runId = args.runId || process.env.RESPLITE_RUN_ID;
|
|
42
31
|
if (!dbPath || !runId) {
|
|
43
|
-
console.error('Usage: resplite-dirty-tracker start --run-id <id> --to <db-path> [--from <redis-url>] [--
|
|
32
|
+
console.error('Usage: resplite-dirty-tracker start --run-id <id> --to <db-path> [--from <redis-url>] [--config-command <name>]');
|
|
44
33
|
process.exit(1);
|
|
45
34
|
}
|
|
46
35
|
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
await checkKeyspaceNotifications(mainClient);
|
|
54
|
-
|
|
55
|
-
const subClient = mainClient.duplicate();
|
|
56
|
-
subClient.on('error', (e) => {
|
|
57
|
-
console.error('Redis (sub):', e.message);
|
|
58
|
-
logError(db, runId, 'dirty_apply', 'Tracker disconnect: ' + e.message, null);
|
|
36
|
+
const tracker = await startDirtyTracker({
|
|
37
|
+
from: redisUrl,
|
|
38
|
+
to: dbPath,
|
|
39
|
+
runId,
|
|
40
|
+
pragmaTemplate: args.pragmaTemplate || 'default',
|
|
41
|
+
configCommand: args.configCommand || 'CONFIG',
|
|
59
42
|
});
|
|
60
|
-
await subClient.connect();
|
|
61
43
|
|
|
62
|
-
|
|
63
|
-
console.log('Subscribing to', pattern, '...');
|
|
64
|
-
|
|
65
|
-
await subClient.pSubscribe(pattern, (message, channel) => {
|
|
66
|
-
const event = typeof channel === 'string' ? channel.split(':').pop() : (channel && channel.toString?.())?.split(':').pop() || 'unknown';
|
|
67
|
-
const key = message;
|
|
68
|
-
try {
|
|
69
|
-
upsertDirtyKey(db, runId, key, event);
|
|
70
|
-
} catch (err) {
|
|
71
|
-
logError(db, runId, 'dirty_apply', err.message, key);
|
|
72
|
-
}
|
|
73
|
-
});
|
|
44
|
+
console.log('Subscribed to __keyevent@0__:* — dirty tracker running. Ctrl+C to stop.');
|
|
74
45
|
|
|
75
46
|
const shutdown = async () => {
|
|
76
47
|
console.log('Stopping dirty tracker...');
|
|
77
|
-
await
|
|
78
|
-
await subClient.quit();
|
|
79
|
-
await mainClient.quit();
|
|
48
|
+
await tracker.stop();
|
|
80
49
|
process.exit(0);
|
|
81
50
|
};
|
|
82
51
|
|
|
83
52
|
process.on('SIGINT', shutdown);
|
|
84
53
|
process.on('SIGTERM', shutdown);
|
|
85
|
-
console.log('Dirty tracker running. Ctrl+C to stop.');
|
|
86
54
|
}
|
|
87
55
|
|
|
88
56
|
async function stopTracker(args) {
|
|
@@ -105,7 +73,7 @@ async function stopTracker(args) {
|
|
|
105
73
|
async function main() {
|
|
106
74
|
const args = parseArgs();
|
|
107
75
|
const sub = args._[0];
|
|
108
|
-
if (sub === 'start') await
|
|
76
|
+
if (sub === 'start') await startTrackerCli(args);
|
|
109
77
|
else if (sub === 'stop') await stopTracker(args);
|
|
110
78
|
else {
|
|
111
79
|
console.error('Usage: resplite-dirty-tracker <start|stop> --run-id <id> --to <db-path> [--from <redis-url>]');
|
|
@@ -121,4 +89,4 @@ if (isMain) {
|
|
|
121
89
|
});
|
|
122
90
|
}
|
|
123
91
|
|
|
124
|
-
export { parseArgs,
|
|
92
|
+
export { parseArgs, stopTracker };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HLEN key
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export function handleHlen(engine, args) {
|
|
6
|
+
if (!args || args.length < 1) {
|
|
7
|
+
return { error: "ERR wrong number of arguments for 'HLEN' command" };
|
|
8
|
+
}
|
|
9
|
+
try {
|
|
10
|
+
return engine.hlen(args[0]);
|
|
11
|
+
} catch (e) {
|
|
12
|
+
const msg = e && e.message ? e.message : String(e);
|
|
13
|
+
return { error: msg.startsWith('ERR ') ? msg : msg.startsWith('WRONGTYPE') ? msg : 'ERR ' + msg };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LREM key count element
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export function handleLrem(engine, args) {
|
|
6
|
+
if (!args || args.length < 3) {
|
|
7
|
+
return { error: "ERR wrong number of arguments for 'LREM' command" };
|
|
8
|
+
}
|
|
9
|
+
try {
|
|
10
|
+
return engine.lrem(args[0], args[1], args[2]);
|
|
11
|
+
} catch (e) {
|
|
12
|
+
const msg = e && e.message ? e.message : String(e);
|
|
13
|
+
return { error: msg.startsWith('ERR ') ? msg : msg.startsWith('WRONGTYPE') ? msg : 'ERR ' + msg };
|
|
14
|
+
}
|
|
15
|
+
}
|
package/src/commands/registry.js
CHANGED
|
@@ -27,6 +27,7 @@ import * as hget from './hget.js';
|
|
|
27
27
|
import * as hmget from './hmget.js';
|
|
28
28
|
import * as hgetall from './hgetall.js';
|
|
29
29
|
import * as hdel from './hdel.js';
|
|
30
|
+
import * as hlen from './hlen.js';
|
|
30
31
|
import * as hexists from './hexists.js';
|
|
31
32
|
import * as hincrby from './hincrby.js';
|
|
32
33
|
import * as sadd from './sadd.js';
|
|
@@ -41,6 +42,7 @@ import * as lrange from './lrange.js';
|
|
|
41
42
|
import * as lindex from './lindex.js';
|
|
42
43
|
import * as lpop from './lpop.js';
|
|
43
44
|
import * as rpop from './rpop.js';
|
|
45
|
+
import * as lrem from './lrem.js';
|
|
44
46
|
import * as blpop from './blpop.js';
|
|
45
47
|
import * as brpop from './brpop.js';
|
|
46
48
|
import * as scan from './scan.js';
|
|
@@ -87,6 +89,7 @@ const HANDLERS = new Map([
|
|
|
87
89
|
['HMGET', (e, a) => hmget.handleHmget(e, a)],
|
|
88
90
|
['HGETALL', (e, a) => hgetall.handleHgetall(e, a)],
|
|
89
91
|
['HDEL', (e, a) => hdel.handleHdel(e, a)],
|
|
92
|
+
['HLEN', (e, a) => hlen.handleHlen(e, a)],
|
|
90
93
|
['HEXISTS', (e, a) => hexists.handleHexists(e, a)],
|
|
91
94
|
['HINCRBY', (e, a) => hincrby.handleHincrby(e, a)],
|
|
92
95
|
['SADD', (e, a) => sadd.handleSadd(e, a)],
|
|
@@ -101,6 +104,7 @@ const HANDLERS = new Map([
|
|
|
101
104
|
['LINDEX', (e, a) => lindex.handleLindex(e, a)],
|
|
102
105
|
['LPOP', (e, a, ctx) => lpop.handleLpop(e, a)],
|
|
103
106
|
['RPOP', (e, a, ctx) => rpop.handleRpop(e, a)],
|
|
107
|
+
['LREM', (e, a) => lrem.handleLrem(e, a)],
|
|
104
108
|
['BLPOP', (e, a, ctx) => blpop.handleBlpop(e, a, ctx)],
|
|
105
109
|
['BRPOP', (e, a, ctx) => brpop.handleBrpop(e, a, ctx)],
|
|
106
110
|
['SCAN', (e, a) => scan.handleScan(e, a)],
|
package/src/engine/engine.js
CHANGED
|
@@ -209,6 +209,14 @@ export function createEngine(opts = {}) {
|
|
|
209
209
|
return hashes.delete(k, fields.map((f) => asKey(f)));
|
|
210
210
|
},
|
|
211
211
|
|
|
212
|
+
hlen(key) {
|
|
213
|
+
const k = asKey(key);
|
|
214
|
+
const meta = getKeyMeta(key);
|
|
215
|
+
if (!meta) return 0;
|
|
216
|
+
expectHash(meta);
|
|
217
|
+
return hashes.count(k);
|
|
218
|
+
},
|
|
219
|
+
|
|
212
220
|
hexists(key, field) {
|
|
213
221
|
const v = this.hget(key, field);
|
|
214
222
|
return v != null ? 1 : 0;
|
|
@@ -322,6 +330,17 @@ export function createEngine(opts = {}) {
|
|
|
322
330
|
return lists.rpop(k, count);
|
|
323
331
|
},
|
|
324
332
|
|
|
333
|
+
lrem(key, count, element) {
|
|
334
|
+
const k = asKey(key);
|
|
335
|
+
const meta = getKeyMeta(key);
|
|
336
|
+
if (!meta) return 0;
|
|
337
|
+
expectList(meta);
|
|
338
|
+
const c = parseInt(Buffer.isBuffer(count) ? count.toString() : String(count), 10);
|
|
339
|
+
if (Number.isNaN(c)) throw new Error('ERR value is not an integer or out of range');
|
|
340
|
+
const elem = Buffer.isBuffer(element) ? element : asValue(element);
|
|
341
|
+
return lists.lrem(k, c, elem);
|
|
342
|
+
},
|
|
343
|
+
|
|
325
344
|
zadd(key, scoreMemberPairs) {
|
|
326
345
|
const k = asKey(key);
|
|
327
346
|
getKeyMeta(key);
|
|
@@ -52,10 +52,12 @@ export async function runApplyDirty(redisClient, dbPath, runId, options = {}) {
|
|
|
52
52
|
r = getRun(db, runId);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
const
|
|
56
|
-
|
|
55
|
+
const dirtyBatch = getDirtyBatch(db, runId, 'dirty', batch_keys);
|
|
56
|
+
const deletedBatch = getDirtyBatch(db, runId, 'deleted', batch_keys);
|
|
57
|
+
if (dirtyBatch.length === 0 && deletedBatch.length === 0) break;
|
|
57
58
|
|
|
58
|
-
|
|
59
|
+
// ── Re-import (or remove) keys that changed while bulk was running ──
|
|
60
|
+
for (const { key: keyBuf } of dirtyBatch) {
|
|
59
61
|
r = getRun(db, runId);
|
|
60
62
|
if (r && r.status === RUN_STATUS.ABORTED) break;
|
|
61
63
|
while (r && r.status === RUN_STATUS.PAUSED) {
|
|
@@ -91,6 +93,35 @@ export async function runApplyDirty(redisClient, dbPath, runId, options = {}) {
|
|
|
91
93
|
markDirtyState(db, runId, keyBuf, 'error');
|
|
92
94
|
}
|
|
93
95
|
}
|
|
96
|
+
|
|
97
|
+
// ── Apply deletions recorded by the tracker (del / expired events) ──
|
|
98
|
+
// The tracker already determined these keys are gone; delete from destination.
|
|
99
|
+
// Marked as 'deleted' in the run counter; state changed away from 'deleted'
|
|
100
|
+
// so the next getDirtyBatch call won't return them again (avoiding infinite loop).
|
|
101
|
+
for (const { key: keyBuf } of deletedBatch) {
|
|
102
|
+
r = getRun(db, runId);
|
|
103
|
+
if (r && r.status === RUN_STATUS.ABORTED) break;
|
|
104
|
+
while (r && r.status === RUN_STATUS.PAUSED) {
|
|
105
|
+
await sleep(2000);
|
|
106
|
+
r = getRun(db, runId);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
keys.delete(keyBuf);
|
|
111
|
+
// Increment dirty_keys_deleted counter and transition state out of 'deleted'
|
|
112
|
+
// so this key is not re-processed in the next batch iteration.
|
|
113
|
+
const now = Date.now();
|
|
114
|
+
db.prepare(
|
|
115
|
+
`UPDATE migration_dirty_keys SET state = 'applied', last_seen_at = ? WHERE run_id = ? AND key = ?`
|
|
116
|
+
).run(now, runId, keyBuf);
|
|
117
|
+
db.prepare(
|
|
118
|
+
`UPDATE migration_runs SET dirty_keys_deleted = dirty_keys_deleted + 1, updated_at = ? WHERE run_id = ?`
|
|
119
|
+
).run(now, runId);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
logError(db, runId, 'dirty_apply', err.message, keyBuf);
|
|
122
|
+
markDirtyState(db, runId, keyBuf, 'error');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
94
125
|
}
|
|
95
126
|
|
|
96
127
|
return getRun(db, runId);
|