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/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
+ }