resplite 1.0.2 → 1.0.6
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 +182 -5
- package/package.json +7 -2
- 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/index.js +170 -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/spec/SPEC_F.md
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
# Appendix F: Migration with Dirty Key Registry (Keyspace Notifications)
|
|
2
|
+
|
|
3
|
+
## F.1 Goals
|
|
4
|
+
|
|
5
|
+
* Migrate a large Redis dataset (example: ~30 GB) into RespLite with minimal downtime.
|
|
6
|
+
* Perform the bulk of the migration online while the application continues using Redis.
|
|
7
|
+
* Capture keys modified during the bulk copy into a **persistent Dirty Key Registry**.
|
|
8
|
+
* During a short cutover window, apply a **delta migration** from the Dirty Key Registry to reach consistency.
|
|
9
|
+
* Provide progress reporting, resumability, throttling controls, and verification.
|
|
10
|
+
|
|
11
|
+
## F.2 Non-Goals (v1)
|
|
12
|
+
|
|
13
|
+
* Perfect change-data-capture guarantees equivalent to replication logs.
|
|
14
|
+
* Distributed migration across multiple import workers with strict ordering semantics.
|
|
15
|
+
* Full fidelity for unsupported Redis data types (streams, modules, Lua scripts, etc.).
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
# F.3 Overview
|
|
20
|
+
|
|
21
|
+
This migration strategy uses two cooperating processes:
|
|
22
|
+
|
|
23
|
+
1. **Bulk Importer**
|
|
24
|
+
|
|
25
|
+
* Scans the entire keyspace with `SCAN`.
|
|
26
|
+
* Copies supported key types and TTLs into the RespLite SQLite database.
|
|
27
|
+
* Checkpoints progress frequently.
|
|
28
|
+
|
|
29
|
+
2. **Dirty Key Tracker**
|
|
30
|
+
|
|
31
|
+
* Subscribes to Redis Keyspace Notifications.
|
|
32
|
+
* Records keys that are modified (and keys that are deleted or expire) into a persistent registry in SQLite.
|
|
33
|
+
* Enables the delta migration to focus only on changed keys.
|
|
34
|
+
|
|
35
|
+
After bulk completes, you perform a controlled **cutover**:
|
|
36
|
+
|
|
37
|
+
* Temporarily freeze writes to Redis (application maintenance window).
|
|
38
|
+
* Apply the delta migration by reimporting dirty keys (and deleting keys that were removed in Redis).
|
|
39
|
+
* Switch clients to RespLite.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
# F.4 Redis Requirements
|
|
44
|
+
|
|
45
|
+
## F.4.1 Keyspace Notifications
|
|
46
|
+
|
|
47
|
+
Redis must be configured to emit keyspace and/or keyevent notifications. The exact flags depend on your required coverage.
|
|
48
|
+
|
|
49
|
+
### Recommended minimal event coverage for delta migration
|
|
50
|
+
|
|
51
|
+
You must capture:
|
|
52
|
+
|
|
53
|
+
* Key modifications (writes) for all supported types
|
|
54
|
+
* TTL changes (EXPIRE/PEXPIRE/PERSIST)
|
|
55
|
+
* Deletions
|
|
56
|
+
* Expiration events
|
|
57
|
+
|
|
58
|
+
### Recommended `notify-keyspace-events` flags (pragmatic v1)
|
|
59
|
+
|
|
60
|
+
A practical baseline is:
|
|
61
|
+
|
|
62
|
+
* `K` (Keyspace events) or `E` (Keyevent events)
|
|
63
|
+
* `g` (generic commands like DEL, EXPIRE)
|
|
64
|
+
* `x` (expired events)
|
|
65
|
+
* plus type-specific sets as needed:
|
|
66
|
+
|
|
67
|
+
* `s` (string)
|
|
68
|
+
* `h` (hash)
|
|
69
|
+
* `l` (list)
|
|
70
|
+
* `z` (zset)
|
|
71
|
+
* `t` (set)
|
|
72
|
+
|
|
73
|
+
If you need the broadest coverage, use “all” (often `AKE`-style in some docs), but configuration specifics vary by Redis version and operational policy. The migration tool should:
|
|
74
|
+
|
|
75
|
+
* detect whether notifications are enabled
|
|
76
|
+
* refuse or warn if they are not enabled
|
|
77
|
+
|
|
78
|
+
## F.4.2 Permissions
|
|
79
|
+
|
|
80
|
+
The tracking client needs:
|
|
81
|
+
|
|
82
|
+
* `PSUBSCRIBE` capability to the keyevent/keyspace channels
|
|
83
|
+
* Ability to read keys during delta verification (optional)
|
|
84
|
+
The bulk importer needs:
|
|
85
|
+
* `SCAN`, `TYPE`, read commands per type, and `PTTL`
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
# F.5 Dirty Key Registry (SQLite)
|
|
90
|
+
|
|
91
|
+
The registry lives in the destination SQLite database so it is persistent and resumable.
|
|
92
|
+
|
|
93
|
+
## F.5.1 Schema
|
|
94
|
+
|
|
95
|
+
### Migration run registry
|
|
96
|
+
|
|
97
|
+
```sql id="e1f4j9"
|
|
98
|
+
CREATE TABLE migration_runs (
|
|
99
|
+
run_id TEXT PRIMARY KEY,
|
|
100
|
+
source_uri TEXT NOT NULL,
|
|
101
|
+
started_at INTEGER NOT NULL,
|
|
102
|
+
updated_at INTEGER NOT NULL,
|
|
103
|
+
status TEXT NOT NULL, -- running|paused|completed|failed|aborted
|
|
104
|
+
|
|
105
|
+
scan_cursor TEXT NOT NULL DEFAULT "0",
|
|
106
|
+
scan_count_hint INTEGER NOT NULL DEFAULT 1000,
|
|
107
|
+
|
|
108
|
+
scanned_keys INTEGER NOT NULL DEFAULT 0,
|
|
109
|
+
migrated_keys INTEGER NOT NULL DEFAULT 0,
|
|
110
|
+
skipped_keys INTEGER NOT NULL DEFAULT 0,
|
|
111
|
+
error_keys INTEGER NOT NULL DEFAULT 0,
|
|
112
|
+
migrated_bytes INTEGER NOT NULL DEFAULT 0,
|
|
113
|
+
|
|
114
|
+
dirty_keys_seen INTEGER NOT NULL DEFAULT 0,
|
|
115
|
+
dirty_keys_applied INTEGER NOT NULL DEFAULT 0,
|
|
116
|
+
dirty_keys_deleted INTEGER NOT NULL DEFAULT 0,
|
|
117
|
+
|
|
118
|
+
last_error TEXT
|
|
119
|
+
);
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Dirty keys table
|
|
123
|
+
|
|
124
|
+
```sql id="4x3p9c"
|
|
125
|
+
CREATE TABLE migration_dirty_keys (
|
|
126
|
+
run_id TEXT NOT NULL,
|
|
127
|
+
key BLOB NOT NULL,
|
|
128
|
+
|
|
129
|
+
first_seen_at INTEGER NOT NULL,
|
|
130
|
+
last_seen_at INTEGER NOT NULL,
|
|
131
|
+
events_count INTEGER NOT NULL DEFAULT 1,
|
|
132
|
+
|
|
133
|
+
last_event TEXT, -- e.g. "set","hset","del","expire","expired"
|
|
134
|
+
state TEXT NOT NULL DEFAULT "dirty", -- dirty|applied|deleted|skipped|error
|
|
135
|
+
|
|
136
|
+
PRIMARY KEY (run_id, key)
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
CREATE INDEX migration_dirty_keys_state_idx
|
|
140
|
+
ON migration_dirty_keys(run_id, state);
|
|
141
|
+
|
|
142
|
+
CREATE INDEX migration_dirty_keys_last_seen_idx
|
|
143
|
+
ON migration_dirty_keys(run_id, last_seen_at);
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Optional: error log (bounded)
|
|
147
|
+
|
|
148
|
+
To avoid exploding database size, log only errors and a small bounded sample:
|
|
149
|
+
|
|
150
|
+
```sql id="3o3xga"
|
|
151
|
+
CREATE TABLE migration_errors (
|
|
152
|
+
run_id TEXT NOT NULL,
|
|
153
|
+
at INTEGER NOT NULL,
|
|
154
|
+
key BLOB,
|
|
155
|
+
stage TEXT NOT NULL, -- bulk|dirty_apply|verify
|
|
156
|
+
message TEXT NOT NULL
|
|
157
|
+
);
|
|
158
|
+
CREATE INDEX migration_errors_at_idx ON migration_errors(run_id, at);
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## F.5.2 Registry update semantics
|
|
162
|
+
|
|
163
|
+
When the tracker sees an event for key `K`:
|
|
164
|
+
|
|
165
|
+
* Insert if not present:
|
|
166
|
+
|
|
167
|
+
* `first_seen_at = now`, `last_seen_at = now`, `events_count = 1`, `last_event = event`
|
|
168
|
+
* If present:
|
|
169
|
+
|
|
170
|
+
* `last_seen_at = now`
|
|
171
|
+
* `events_count += 1`
|
|
172
|
+
* `last_event = event`
|
|
173
|
+
* `state = "dirty"` unless state is terminal (`deleted` can be reverted to dirty if a new write arrives)
|
|
174
|
+
|
|
175
|
+
This is a **set-like deduplicated registry** with useful metadata.
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
# F.6 Event Capture: Mapping Notifications to Dirty Keys
|
|
180
|
+
|
|
181
|
+
## F.6.1 Channel subscription strategy
|
|
182
|
+
|
|
183
|
+
Prefer subscribing to **keyevent** channels because they give you the event name, not only the keyspace operation.
|
|
184
|
+
|
|
185
|
+
Examples (conceptual):
|
|
186
|
+
|
|
187
|
+
* `__keyevent@0__:set`
|
|
188
|
+
* `__keyevent@0__:hset`
|
|
189
|
+
* `__keyevent@0__:del`
|
|
190
|
+
* `__keyevent@0__:expire`
|
|
191
|
+
* `__keyevent@0__:expired`
|
|
192
|
+
|
|
193
|
+
The payload is the key name.
|
|
194
|
+
|
|
195
|
+
If only keyspace notifications are available, you will receive:
|
|
196
|
+
|
|
197
|
+
* channel includes the key, payload includes the event
|
|
198
|
+
You must support both, but keyevent is simpler.
|
|
199
|
+
|
|
200
|
+
## F.6.2 Events to treat as “dirty”
|
|
201
|
+
|
|
202
|
+
Mark key as dirty when you see any of:
|
|
203
|
+
|
|
204
|
+
* `set`, `mset`, `incrby`, etc. (string writes)
|
|
205
|
+
* `hset`, `hdel`, `hincrby`, etc.
|
|
206
|
+
* `sadd`, `srem`, etc.
|
|
207
|
+
* `lpush`, `rpush`, `lpop`, `rpop`, etc.
|
|
208
|
+
* `zadd`, `zrem`, etc.
|
|
209
|
+
* `expire`, `pexpire`, `persist` (TTL changes)
|
|
210
|
+
|
|
211
|
+
## F.6.3 Events to treat as “deleted”
|
|
212
|
+
|
|
213
|
+
Mark key as deleted when you see:
|
|
214
|
+
|
|
215
|
+
* `del` / `unlink` event
|
|
216
|
+
* `expired` event
|
|
217
|
+
|
|
218
|
+
**Important:** A key can be deleted and later recreated. If a write event arrives after a deleted mark, you must set state back to `dirty`.
|
|
219
|
+
|
|
220
|
+
## F.6.4 Limitations and mitigation
|
|
221
|
+
|
|
222
|
+
Keyspace notifications are not a guaranteed durable log:
|
|
223
|
+
|
|
224
|
+
* if the tracker disconnects, events can be missed
|
|
225
|
+
Mitigation:
|
|
226
|
+
* treat the final cutover delta as authoritative with the application frozen
|
|
227
|
+
* optionally run one short SCAN after freeze as a “safety sweep” if you want extra assurance
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
# F.7 Bulk Importer Behavior (No Patterns)
|
|
232
|
+
|
|
233
|
+
## F.7.1 Bulk scan loop
|
|
234
|
+
|
|
235
|
+
* Use `SCAN cursor COUNT scan_count_hint`
|
|
236
|
+
* For each returned key:
|
|
237
|
+
|
|
238
|
+
1. `TYPE key`
|
|
239
|
+
2. If type unsupported: `skipped_keys++`
|
|
240
|
+
3. Else fetch full value depending on type:
|
|
241
|
+
|
|
242
|
+
* string: `GET`
|
|
243
|
+
* hash: `HGETALL`
|
|
244
|
+
* set: `SMEMBERS`
|
|
245
|
+
* list: `LRANGE 0 -1` (if lists supported)
|
|
246
|
+
* zset: `ZRANGE 0 -1 WITHSCORES` (if zsets supported)
|
|
247
|
+
4. `PTTL key` (preserve TTL)
|
|
248
|
+
5. Write to destination in one batch transaction
|
|
249
|
+
|
|
250
|
+
## F.7.2 Checkpointing
|
|
251
|
+
|
|
252
|
+
Persist:
|
|
253
|
+
|
|
254
|
+
* cursor
|
|
255
|
+
* counters
|
|
256
|
+
* last update time
|
|
257
|
+
every N seconds or every M keys.
|
|
258
|
+
|
|
259
|
+
If interrupted, `--resume` restarts from the stored cursor.
|
|
260
|
+
|
|
261
|
+
## F.7.3 Throughput controls
|
|
262
|
+
|
|
263
|
+
The importer must support:
|
|
264
|
+
|
|
265
|
+
* `max_concurrency`: number of inflight fetches
|
|
266
|
+
* `max_rps`: throttle reads against Redis
|
|
267
|
+
* `batch_keys` / `batch_bytes`: commit grouping
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
|
|
271
|
+
# F.8 Delta Apply (Using Dirty Key Registry)
|
|
272
|
+
|
|
273
|
+
## F.8.1 When to run delta
|
|
274
|
+
|
|
275
|
+
* During cutover window, with application writes frozen.
|
|
276
|
+
* Optional: run a “pre-delta” while still live to reduce final delta size.
|
|
277
|
+
|
|
278
|
+
## F.8.2 Delta algorithm
|
|
279
|
+
|
|
280
|
+
Repeat until no dirty keys remain:
|
|
281
|
+
|
|
282
|
+
1. Select dirty keys in batches:
|
|
283
|
+
|
|
284
|
+
```sql
|
|
285
|
+
SELECT key
|
|
286
|
+
FROM migration_dirty_keys
|
|
287
|
+
WHERE run_id=? AND state="dirty"
|
|
288
|
+
ORDER BY last_seen_at ASC
|
|
289
|
+
LIMIT ?;
|
|
290
|
+
```
|
|
291
|
+
2. For each key:
|
|
292
|
+
|
|
293
|
+
* Check existence in Redis:
|
|
294
|
+
|
|
295
|
+
* Option A: attempt `TYPE`. If `none`, treat as deleted.
|
|
296
|
+
* If deleted:
|
|
297
|
+
|
|
298
|
+
* `DEL key` on RespLite destination
|
|
299
|
+
* mark state = `deleted`
|
|
300
|
+
* increment `dirty_keys_deleted`
|
|
301
|
+
* Else:
|
|
302
|
+
|
|
303
|
+
* fetch by type (same as bulk)
|
|
304
|
+
* fetch `PTTL`
|
|
305
|
+
* write into RespLite
|
|
306
|
+
* mark state = `applied`
|
|
307
|
+
* increment `dirty_keys_applied`
|
|
308
|
+
|
|
309
|
+
All writes should update progress counters in `migration_runs`.
|
|
310
|
+
|
|
311
|
+
## F.8.3 Safety sweep (optional but recommended for large/high-write systems)
|
|
312
|
+
|
|
313
|
+
After freeze begins and delta completes:
|
|
314
|
+
|
|
315
|
+
* run a quick SCAN pass limited by time (or a full pass if feasible)
|
|
316
|
+
* compare to destination by spot checks or reimport a final time
|
|
317
|
+
This is a belt-and-suspenders option.
|
|
318
|
+
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
# F.9 Suggested End-to-End Migration Process (Example)
|
|
322
|
+
|
|
323
|
+
Assume:
|
|
324
|
+
|
|
325
|
+
* Redis source: `redis://10.0.0.10:6379`
|
|
326
|
+
* RespLite destination DB: `./resplite.db`
|
|
327
|
+
* Full migration without patterns
|
|
328
|
+
* Supported types: string/hash/set/list/zset
|
|
329
|
+
* Goal: minimal downtime
|
|
330
|
+
|
|
331
|
+
## Step 0: Preflight
|
|
332
|
+
|
|
333
|
+
```bash id="4bct0i"
|
|
334
|
+
resplite-import preflight \
|
|
335
|
+
--from redis://10.0.0.10:6379 \
|
|
336
|
+
--to ./resplite.db
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
Outputs:
|
|
340
|
+
|
|
341
|
+
* estimated key count
|
|
342
|
+
* type distribution sample
|
|
343
|
+
* recommended concurrency and scan count
|
|
344
|
+
* detection of unsupported types
|
|
345
|
+
|
|
346
|
+
## Step 1: Start Dirty Key Tracker
|
|
347
|
+
|
|
348
|
+
Start the tracker first, so it captures changes during the entire bulk run.
|
|
349
|
+
|
|
350
|
+
```bash id="6km4l7"
|
|
351
|
+
resplite-dirty-tracker start \
|
|
352
|
+
--run-id run_2026_03_03 \
|
|
353
|
+
--from redis://10.0.0.10:6379 \
|
|
354
|
+
--to ./resplite.db \
|
|
355
|
+
--channels keyevent
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
## Step 2: Run Bulk Import Online
|
|
359
|
+
|
|
360
|
+
```bash id="a9g2aa"
|
|
361
|
+
resplite-import bulk \
|
|
362
|
+
--run-id run_2026_03_03 \
|
|
363
|
+
--from redis://10.0.0.10:6379 \
|
|
364
|
+
--to ./resplite.db \
|
|
365
|
+
--scan-count 1000 \
|
|
366
|
+
--max-concurrency 32 \
|
|
367
|
+
--max-rps 2000 \
|
|
368
|
+
--batch-keys 200 \
|
|
369
|
+
--batch-bytes 64MB \
|
|
370
|
+
--ttl-mode preserve \
|
|
371
|
+
--resume
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
Monitor progress:
|
|
375
|
+
|
|
376
|
+
```bash id="rkf6uv"
|
|
377
|
+
resplite-import status --run-id run_2026_03_03 --to ./resplite.db
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
## Step 3 (Optional): Pre-Delta While Still Live
|
|
381
|
+
|
|
382
|
+
Apply dirty keys while Redis is still live to reduce final delta size:
|
|
383
|
+
|
|
384
|
+
```bash id="v5xc6f"
|
|
385
|
+
resplite-import apply-dirty \
|
|
386
|
+
--run-id run_2026_03_03 \
|
|
387
|
+
--from redis://10.0.0.10:6379 \
|
|
388
|
+
--to ./resplite.db \
|
|
389
|
+
--max-concurrency 32 \
|
|
390
|
+
--max-rps 2000 \
|
|
391
|
+
--batch-keys 200 \
|
|
392
|
+
--ttl-mode preserve
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
You can run this repeatedly (or continuously) while bulk is still running.
|
|
396
|
+
|
|
397
|
+
## Step 4: Cutover Window (Freeze Writes)
|
|
398
|
+
|
|
399
|
+
* Put the application into maintenance mode (freeze writes to Redis).
|
|
400
|
+
* Keep dirty tracker running for a moment to capture any last writes.
|
|
401
|
+
|
|
402
|
+
## Step 5: Final Delta Apply
|
|
403
|
+
|
|
404
|
+
With writes frozen, apply all remaining dirty keys:
|
|
405
|
+
|
|
406
|
+
```bash id="v0x8xo"
|
|
407
|
+
resplite-import apply-dirty \
|
|
408
|
+
--run-id run_2026_03_03 \
|
|
409
|
+
--from redis://10.0.0.10:6379 \
|
|
410
|
+
--to ./resplite.db \
|
|
411
|
+
--max-concurrency 64 \
|
|
412
|
+
--max-rps 5000 \
|
|
413
|
+
--batch-keys 500 \
|
|
414
|
+
--ttl-mode preserve
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
Verify no remaining dirty keys:
|
|
418
|
+
|
|
419
|
+
```bash id="0ca1y7"
|
|
420
|
+
resplite-import status --run-id run_2026_03_03 --to ./resplite.db
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
## Step 6: Stop Dirty Tracker and Switch Clients
|
|
424
|
+
|
|
425
|
+
Stop tracker:
|
|
426
|
+
|
|
427
|
+
```bash id="1v1u1j"
|
|
428
|
+
resplite-dirty-tracker stop --run-id run_2026_03_03 --to ./resplite.db
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
Switch application Redis endpoint to RespLite server (RESP port).
|
|
432
|
+
|
|
433
|
+
## Step 7: Verification (Post-Cutover)
|
|
434
|
+
|
|
435
|
+
Run a sampling verification:
|
|
436
|
+
|
|
437
|
+
```bash id="p6w5q6"
|
|
438
|
+
resplite-import verify \
|
|
439
|
+
--run-id run_2026_03_03 \
|
|
440
|
+
--from redis://10.0.0.10:6379 \
|
|
441
|
+
--to ./resplite.db \
|
|
442
|
+
--sample 0.5%
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
---
|
|
446
|
+
|
|
447
|
+
# F.10 Progress Reporting and Controls
|
|
448
|
+
|
|
449
|
+
## F.10.1 Progress output requirements
|
|
450
|
+
|
|
451
|
+
Both bulk importer and dirty applier must print and persist:
|
|
452
|
+
|
|
453
|
+
* scanned_keys, migrated_keys, migrated_bytes
|
|
454
|
+
* dirty_keys_seen, dirty_keys_applied, dirty_keys_deleted
|
|
455
|
+
* current cursor
|
|
456
|
+
* rates (keys/s, MB/s)
|
|
457
|
+
* recent errors summary
|
|
458
|
+
* checkpoint time
|
|
459
|
+
|
|
460
|
+
## F.10.2 Runtime controls
|
|
461
|
+
|
|
462
|
+
Provide:
|
|
463
|
+
|
|
464
|
+
* `pause`, `resume`, `abort`
|
|
465
|
+
* adjust `max_concurrency`, `max_rps`
|
|
466
|
+
* adjust `batch_keys`, `scan_count`
|
|
467
|
+
|
|
468
|
+
Implementation may use:
|
|
469
|
+
|
|
470
|
+
* updating `migration_runs.status`
|
|
471
|
+
* a simple control file
|
|
472
|
+
* or a CLI that updates the SQLite run row
|
|
473
|
+
|
|
474
|
+
---
|
|
475
|
+
|
|
476
|
+
# F.11 Failure and Recovery Rules
|
|
477
|
+
|
|
478
|
+
## F.11.1 Tracker disconnect
|
|
479
|
+
|
|
480
|
+
If dirty tracker disconnects:
|
|
481
|
+
|
|
482
|
+
* it must attempt reconnect with backoff
|
|
483
|
+
* record a warning in `migration_errors`
|
|
484
|
+
* migration can proceed, but final delta should be done after freeze (which provides correctness)
|
|
485
|
+
|
|
486
|
+
## F.11.2 Importer crash/restart
|
|
487
|
+
|
|
488
|
+
* On restart with `--resume`, continue from stored cursor.
|
|
489
|
+
* Already migrated keys may be overwritten idempotently.
|
|
490
|
+
|
|
491
|
+
## F.11.3 Idempotency requirements
|
|
492
|
+
|
|
493
|
+
* Bulk and dirty apply must be safe to rerun:
|
|
494
|
+
|
|
495
|
+
* writes should upsert
|
|
496
|
+
* deletions should be no-op if missing
|
|
497
|
+
|
|
498
|
+
---
|
|
499
|
+
|
|
500
|
+
# F.12 Operational Guidance (Large datasets)
|
|
501
|
+
|
|
502
|
+
* Use a dedicated Redis replica for reads if possible to reduce load on primary.
|
|
503
|
+
* Keep `max_concurrency` conservative at first; increase only if Redis latency remains stable.
|
|
504
|
+
* Keep dirty tracker running from before bulk starts until just before cutover switch.
|
|
505
|
+
* Prefer application-level maintenance mode for freeze.
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blocking list waiters: BLPOP/BRPOP (SPEC_E).
|
|
3
|
+
* In-memory wait queues by key; single delivery; wake on LPUSH/RPUSH.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { asKey } from '../util/buffers.js';
|
|
7
|
+
|
|
8
|
+
function toMapKey(key) {
|
|
9
|
+
const k = Buffer.isBuffer(key) ? key : asKey(key);
|
|
10
|
+
return k.toString('binary');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {object} engine - RESPlite engine (for lpop/rpop on wake)
|
|
15
|
+
* @param {object} [opts]
|
|
16
|
+
* @param {number} [opts.maxWaitersPerKey=10000]
|
|
17
|
+
* @param {number} [opts.maxTotalWaiters=50000]
|
|
18
|
+
* @param {number} [opts.maxKeysPerWait=128]
|
|
19
|
+
* @param {() => number} [opts.clock=Date.now]
|
|
20
|
+
*/
|
|
21
|
+
export function createBlockingManager(engine, opts = {}) {
|
|
22
|
+
const maxWaitersPerKey = opts.maxWaitersPerKey ?? 10000;
|
|
23
|
+
const maxTotalWaiters = opts.maxTotalWaiters ?? 50000;
|
|
24
|
+
const maxKeysPerWait = opts.maxKeysPerWait ?? 128;
|
|
25
|
+
const clock = opts.clock ?? (() => Date.now());
|
|
26
|
+
|
|
27
|
+
/** @type {Map<string, Array<{ waiter: object }>>} key -> deque of waiter refs */
|
|
28
|
+
const waitersByKey = new Map();
|
|
29
|
+
/** @type {Map<string | number, Set<object>>} connectionId -> waiters for cancel */
|
|
30
|
+
const waitersByConnection = new Map();
|
|
31
|
+
let totalWaiters = 0;
|
|
32
|
+
|
|
33
|
+
function removeWaiterFromQueues(waiter) {
|
|
34
|
+
for (const key of waiter.keys) {
|
|
35
|
+
const mapKey = toMapKey(key);
|
|
36
|
+
const q = waitersByKey.get(mapKey);
|
|
37
|
+
if (!q) continue;
|
|
38
|
+
const idx = q.findIndex((ref) => ref.waiter === waiter);
|
|
39
|
+
if (idx !== -1) q.splice(idx, 1);
|
|
40
|
+
if (q.length === 0) waitersByKey.delete(mapKey);
|
|
41
|
+
}
|
|
42
|
+
const connSet = waitersByConnection.get(waiter.connectionId);
|
|
43
|
+
if (connSet) {
|
|
44
|
+
connSet.delete(waiter);
|
|
45
|
+
if (connSet.size === 0) waitersByConnection.delete(waiter.connectionId);
|
|
46
|
+
}
|
|
47
|
+
totalWaiters--;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Try to satisfy one waiter: scan keys in order, pop from first non-empty.
|
|
52
|
+
* @returns {boolean} true if waiter was satisfied and removed
|
|
53
|
+
*/
|
|
54
|
+
function tryWake(waiter) {
|
|
55
|
+
if (waiter.completed || waiter.canceled) return false;
|
|
56
|
+
for (const key of waiter.keys) {
|
|
57
|
+
try {
|
|
58
|
+
const val =
|
|
59
|
+
waiter.kind === 'BLPOP'
|
|
60
|
+
? engine.lpop(key, null)
|
|
61
|
+
: engine.rpop(key, null);
|
|
62
|
+
if (val != null) {
|
|
63
|
+
waiter.completed = true;
|
|
64
|
+
removeWaiterFromQueues(waiter);
|
|
65
|
+
waiter.resolve([key, val]);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
} catch (_) {
|
|
69
|
+
// wrong type / missing: skip key
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Register a blocked client. Keys in order; timeout in seconds (0 = indefinite).
|
|
77
|
+
* @param {Buffer[]} keys
|
|
78
|
+
* @param {'BLPOP'|'BRPOP'} kind
|
|
79
|
+
* @param {number} timeoutSeconds
|
|
80
|
+
* @param {(value: [Buffer, Buffer]|null) => void} resolve - called with [key, element] or null on timeout
|
|
81
|
+
* @param {string|number} connectionId
|
|
82
|
+
* @returns {{ error?: string }}
|
|
83
|
+
*/
|
|
84
|
+
function registerWaiter(keys, kind, timeoutSeconds, resolve, connectionId) {
|
|
85
|
+
if (keys.length > maxKeysPerWait) {
|
|
86
|
+
return { error: 'ERR too many keys per blocked command' };
|
|
87
|
+
}
|
|
88
|
+
if (totalWaiters >= maxTotalWaiters) {
|
|
89
|
+
return { error: 'ERR too many blocked clients' };
|
|
90
|
+
}
|
|
91
|
+
for (const k of keys) {
|
|
92
|
+
const mapKey = toMapKey(k);
|
|
93
|
+
const q = waitersByKey.get(mapKey) ?? [];
|
|
94
|
+
if (q.length >= maxWaitersPerKey) {
|
|
95
|
+
return { error: 'ERR too many blocked clients' };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const deadline =
|
|
100
|
+
timeoutSeconds > 0 ? clock() + timeoutSeconds * 1000 : null;
|
|
101
|
+
const waiter = {
|
|
102
|
+
connectionId,
|
|
103
|
+
kind,
|
|
104
|
+
keys: keys.slice(),
|
|
105
|
+
deadline,
|
|
106
|
+
resolve,
|
|
107
|
+
completed: false,
|
|
108
|
+
canceled: false,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
let timer = null;
|
|
112
|
+
if (deadline != null) {
|
|
113
|
+
timer = setTimeout(() => {
|
|
114
|
+
if (waiter.completed || waiter.canceled) return;
|
|
115
|
+
waiter.completed = true;
|
|
116
|
+
removeWaiterFromQueues(waiter);
|
|
117
|
+
resolve(null);
|
|
118
|
+
}, timeoutSeconds * 1000);
|
|
119
|
+
if (timer.unref) timer.unref();
|
|
120
|
+
}
|
|
121
|
+
waiter.timer = timer;
|
|
122
|
+
|
|
123
|
+
totalWaiters++;
|
|
124
|
+
for (const key of keys) {
|
|
125
|
+
const mapKey = toMapKey(key);
|
|
126
|
+
let q = waitersByKey.get(mapKey);
|
|
127
|
+
if (!q) {
|
|
128
|
+
q = [];
|
|
129
|
+
waitersByKey.set(mapKey, q);
|
|
130
|
+
}
|
|
131
|
+
q.push({ waiter });
|
|
132
|
+
}
|
|
133
|
+
let connSet = waitersByConnection.get(connectionId);
|
|
134
|
+
if (!connSet) {
|
|
135
|
+
connSet = new Set();
|
|
136
|
+
waitersByConnection.set(connectionId, connSet);
|
|
137
|
+
}
|
|
138
|
+
connSet.add(waiter);
|
|
139
|
+
return {};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Called after LPUSH/RPUSH on key. Wake at most one waiter (oldest for this key).
|
|
144
|
+
* @param {Buffer|string} key - key that received the push
|
|
145
|
+
*/
|
|
146
|
+
function wakeup(key) {
|
|
147
|
+
const mapKey = toMapKey(key);
|
|
148
|
+
const q = waitersByKey.get(mapKey);
|
|
149
|
+
if (!q || q.length === 0) return;
|
|
150
|
+
const ref = q[0];
|
|
151
|
+
const waiter = ref.waiter;
|
|
152
|
+
if (waiter.canceled || waiter.completed) {
|
|
153
|
+
removeWaiterFromQueues(waiter);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
tryWake(waiter);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* On client disconnect: mark waiters canceled and remove from queues.
|
|
161
|
+
* @param {string|number} connectionId
|
|
162
|
+
*/
|
|
163
|
+
function cancel(connectionId) {
|
|
164
|
+
const connSet = waitersByConnection.get(connectionId);
|
|
165
|
+
if (!connSet) return;
|
|
166
|
+
for (const waiter of new Set(connSet)) {
|
|
167
|
+
if (waiter.completed) continue;
|
|
168
|
+
waiter.canceled = true;
|
|
169
|
+
if (waiter.timer) {
|
|
170
|
+
clearTimeout(waiter.timer);
|
|
171
|
+
waiter.timer = null;
|
|
172
|
+
}
|
|
173
|
+
removeWaiterFromQueues(waiter);
|
|
174
|
+
}
|
|
175
|
+
waitersByConnection.delete(connectionId);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
registerWaiter,
|
|
180
|
+
wakeup,
|
|
181
|
+
cancel,
|
|
182
|
+
};
|
|
183
|
+
}
|