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 CHANGED
@@ -1,15 +1,63 @@
1
1
  # RESPLite
2
2
 
3
- A RESP2 server with practical Redis compatibility, backed by SQLite for persistent single-node workloads.
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 is not a full Redis clone. It is a RESP server with Redis-like semantics for a carefully selected subset of commands that map naturally to SQLite. Ideal for small to medium applications, persistent caches, local development, and low-ops deployments.
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
- - **Zero external services** just Node.js and a SQLite file.
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** — data survives restarts without snapshots or AOF.
12
- - **Embeddable** — start the server and connect from the same script (see examples below).
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 Kgxe
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 Kgxe
402
+ notify-keyspace-events KEA
351
403
  ```
352
404
 
353
- (`K` = keyspace, `g` = generic, `x` = expired; `e` = keyevent. This lets the tracker see key changes and expirations.)
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
- #### Low-level re-exports
511
+ #### Renamed CONFIG command
445
512
 
446
- If you need more control, the individual functions and registry helpers are also exported:
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
- import {
450
- runPreflight, runBulkImport, runApplyDirty, runVerify,
451
- getRun, getDirtyCounts, createRun, setRunStatus, logError,
452
- } from 'resplite/migration';
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
- ## Benchmark (Redis vs RESPLite)
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
- Compare throughput of local Redis and RESPLite with the same workload (PING, SET/GET, hashes, sets, lists, zsets, etc.):
531
+ The same flag is available in the CLI:
458
532
 
459
533
  ```bash
460
- # Terminal 1: Redis on 6379 (default). Terminal 2: RESPLite on 6380
461
- RESPLITE_PORT=6380 npm start
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
- # Terminal 3: run benchmark (Redis=6379, RESPLite=6380 by default)
464
- npm run benchmark
538
+ #### Low-level re-exports
465
539
 
466
- # Optional: custom iterations and ports
467
- npm run benchmark -- --iterations 10000 --redis-port 6379 --resplite-port 6380
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.0.6",
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 templates).
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
- * The script spawns one RESPlite process per PRAGMA template (default, performance, safety, minimal)
9
- * on consecutive ports (6380, 6381, 6382, 6383 by default) and runs the same workload against each.
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 = getPragmaTemplateNames().filter((t) => t !== 'none');
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: one process per template on ports ${resplitePort}..${resplitePort + templateNames.length - 1}`);
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 { createRun, getRun, setRunStatus, upsertDirtyKey, logError, RUN_STATUS } from '../migration/registry.js';
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 checkKeyspaceNotifications(client) {
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>] [--channels keyevent]');
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 db = openDb(dbPath, { pragmaTemplate: args.pragmaTemplate || 'default' });
48
- createRun(db, runId, redisUrl);
49
-
50
- const mainClient = createClient({ url: redisUrl });
51
- mainClient.on('error', (e) => console.error('Redis (main):', e.message));
52
- await mainClient.connect();
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
- const pattern = '__keyevent@0__:*';
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 subClient.pUnsubscribe(pattern);
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 startTracker(args);
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, startTracker, stopTracker };
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
+ }
@@ -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)],
@@ -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 batch = getDirtyBatch(db, runId, 'dirty', batch_keys);
56
- if (batch.length === 0) break;
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
- for (const { key: keyBuf } of batch) {
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);