ts-server-lib 0.0.17

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/db/TSRQW.js ADDED
@@ -0,0 +1,699 @@
1
+ "use strict";
2
+ /**
3
+ * TSRQW — Redis Queue Worker (standalone / sentinel / cluster).
4
+ *
5
+ * R = Redis · Q = Queue · W = Worker
6
+ *
7
+ * Single class unifying queue storage (Redis Streams) + worker lifecycle
8
+ * (events, retry, dead-letter, cron schedule, thread pool).
9
+ *
10
+ * Backend: Redis Streams (XADD / XREADGROUP / XAUTOCLAIM / XACK).
11
+ * - All keys hash-tagged → zero CROSSSLOT on any topology.
12
+ * - No Lua scripts, no EVALSHA, no SCRIPT LOAD broadcast.
13
+ * - Consumer-group model: crashed consumers recovered automatically via XAUTOCLAIM.
14
+ * - Built-in dead-letter stream (`{ns:name}:dlq`).
15
+ *
16
+ * Performance:
17
+ * - send: 1 roundtrip (XADD MAXLEN ~)
18
+ * - receive: 1 roundtrip (XREADGROUP); XAUTOCLAIM throttled to reclaimIntervalMs
19
+ * - node-redis v5 auto-pipelines concurrent callers → linear throughput scaling
20
+ *
21
+ * Events: new · ready · completed · retry · exceeded · failed · deleted · error
22
+ */
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.TSRQW = exports.TSRQWPool = exports.TSRQWEvents = void 0;
25
+ const events_1 = require("events");
26
+ const async_hooks_1 = require("async_hooks");
27
+ const cron_1 = require("cron");
28
+ const os_1 = require("os");
29
+ const worker_threads_1 = require("worker_threads");
30
+ // ─── Events ──────────────────────────────────────────────────────────────────
31
+ var TSRQWEvents;
32
+ (function (TSRQWEvents) {
33
+ TSRQWEvents["new"] = "new";
34
+ TSRQWEvents["ready"] = "ready";
35
+ TSRQWEvents["deleted"] = "deleted";
36
+ TSRQWEvents["completed"] = "completed";
37
+ TSRQWEvents["retry"] = "retry";
38
+ TSRQWEvents["exceeded"] = "exceeded";
39
+ TSRQWEvents["failed"] = "failed";
40
+ TSRQWEvents["error"] = "error";
41
+ })(TSRQWEvents || (exports.TSRQWEvents = TSRQWEvents = {}));
42
+ // ─── Shared error-detection helpers ─────────────────────────────────────────
43
+ const NOGROUP_PATTERN = 'NOGROUP';
44
+ const NO_SUCH_KEY_PATTERN = 'no such key';
45
+ function isNoGroupError(msg) {
46
+ return msg.includes(NOGROUP_PATTERN) || msg.includes(NO_SUCH_KEY_PATTERN);
47
+ }
48
+ // ─── Thread pool ──────────────────────────────────────────────────────────────
49
+ const kTaskInfo = Symbol('kTaskInfo');
50
+ const kWorkerFreedEvent = Symbol('kWorkerFreedEvent');
51
+ class TSRQWPoolTask extends async_hooks_1.AsyncResource {
52
+ callback;
53
+ constructor(callback) {
54
+ super('TSRQWPoolTask');
55
+ this.callback = callback;
56
+ }
57
+ done(err, result) {
58
+ this.runInAsyncScope(this.callback, null, err, result);
59
+ this.emitDestroy();
60
+ }
61
+ }
62
+ class TSRQWPool extends events_1.EventEmitter {
63
+ factor;
64
+ callback;
65
+ wd;
66
+ workers = [];
67
+ freeWorkers = [];
68
+ tasks = [];
69
+ constructor(factor = 1, callback, wd) {
70
+ super();
71
+ this.factor = factor;
72
+ this.callback = callback;
73
+ this.wd = wd;
74
+ const threads = Math.max(1, Math.floor((0, os_1.availableParallelism)() / factor));
75
+ for (let i = 0; i < threads; i++)
76
+ this._addWorker();
77
+ this.on(kWorkerFreedEvent, () => {
78
+ const item = this.tasks.shift();
79
+ if (item)
80
+ this._runTask(item.task, item.callback);
81
+ });
82
+ }
83
+ work = (data) => new Promise((resolve, reject) => this._runTask(data, (err, result) => err ? reject(err) : resolve(result)));
84
+ update(data) {
85
+ data._wd_updated = Date.now();
86
+ this.workers.forEach(w => w.postMessage(data));
87
+ }
88
+ close() { for (const w of this.workers)
89
+ void w.terminate(); }
90
+ _runTask(task, callback) {
91
+ if (this.freeWorkers.length === 0) {
92
+ this.tasks.push({ task, callback });
93
+ return;
94
+ }
95
+ const worker = this.freeWorkers.pop();
96
+ worker[kTaskInfo] = new TSRQWPoolTask(callback);
97
+ worker.postMessage(task);
98
+ }
99
+ _addWorker() {
100
+ let ep = `if (task?._wd_updated) { workerData = task; }`;
101
+ ep += `else { fn(task).then(data => pp.postMessage({ data, threadId })); }`;
102
+ let em = `import('worker_threads').then(({ threadId, isMainThread, parentPort: pp, workerData}) => { if(!isMainThread) {`;
103
+ em += `const cb = ${this.callback.toString()};`;
104
+ em += `cb(workerData).then(fn => pp.on('message', task => {${ep}} ));`;
105
+ em += `} });`;
106
+ if (this.wd?._debug)
107
+ console.log('TSRQWPool:worker', em);
108
+ const worker = new worker_threads_1.Worker(em, { eval: true, workerData: this.wd });
109
+ worker.on('message', result => {
110
+ worker[kTaskInfo].done(null, result);
111
+ worker[kTaskInfo] = null;
112
+ this.freeWorkers.push(worker);
113
+ this.emit(kWorkerFreedEvent);
114
+ });
115
+ worker.on('error', (err) => {
116
+ if (worker[kTaskInfo])
117
+ worker[kTaskInfo].done(err, null);
118
+ else
119
+ this.emit('error', err);
120
+ this.workers.splice(this.workers.indexOf(worker), 1);
121
+ void worker.terminate();
122
+ this._addWorker();
123
+ });
124
+ this.workers.push(worker);
125
+ this.freeWorkers.push(worker);
126
+ this.emit(kWorkerFreedEvent);
127
+ }
128
+ }
129
+ exports.TSRQWPool = TSRQWPool;
130
+ // ─── TSRQW ────────────────────────────────────────────────────────────────────
131
+ class TSRQW extends events_1.EventEmitter {
132
+ redis;
133
+ qname;
134
+ rawNs;
135
+ ns;
136
+ group;
137
+ consumer;
138
+ maxLen;
139
+ attempts;
140
+ reclaimIntervalMs;
141
+ connected = false;
142
+ offlineBuffer = [];
143
+ ensured = false;
144
+ ensurePromise = null;
145
+ lastReclaimAt = 0;
146
+ /** Resolves when the consumer group is ready and the first offline drain completes. */
147
+ initialized;
148
+ constructor(redis, options = {}) {
149
+ super();
150
+ this.redis = redis;
151
+ this.qname = options.name ?? 'queue';
152
+ this.rawNs = options.ns ?? 'tsq';
153
+ this.ns = this.rawNs + ':';
154
+ this.group = options.group ?? this.rawNs;
155
+ this.consumer = options.consumer ?? `${this.rawNs}-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
156
+ this.maxLen = options.maxLen ?? 100_000;
157
+ this.attempts = options.attempts ?? 3;
158
+ this.reclaimIntervalMs = options.reclaimIntervalMs ?? 2_000;
159
+ this.connected = true;
160
+ this.initialized = this._init();
161
+ }
162
+ // ─── Key helpers ─────────────────────────────────────────────────────────────
163
+ _streamKey() { return `{${this.ns}${this.qname}}:stream`; }
164
+ _dlqKey() { return `{${this.ns}${this.qname}}:dlq`; }
165
+ // ─── Lifecycle ────────────────────────────────────────────────────────────────
166
+ async _init() {
167
+ await this._ensureGroup();
168
+ if (this.listenerCount(TSRQWEvents.ready) > 0)
169
+ this.emit(TSRQWEvents.ready);
170
+ await this._drainOffline();
171
+ }
172
+ _ensureGroup() {
173
+ if (this.ensured)
174
+ return Promise.resolve();
175
+ if (!this.ensurePromise) {
176
+ this.ensurePromise = this._createGroup().finally(() => { this.ensurePromise = null; });
177
+ }
178
+ return this.ensurePromise;
179
+ }
180
+ async _createGroup() {
181
+ try {
182
+ await this.redis.xGroupCreate(this._streamKey(), this.group, '$', { MKSTREAM: true });
183
+ }
184
+ catch (err) {
185
+ const msg = err instanceof Error ? err.message : String(err);
186
+ if (!msg.includes('BUSYGROUP'))
187
+ throw err;
188
+ }
189
+ // Register in the namespace discovery index every time the group is (re)created —
190
+ // including NOGROUP self-heal after Redis flush. SADD is idempotent so this is safe
191
+ // to call repeatedly. Swallowed on error so a transient Redis hiccup cannot block startup.
192
+ await this.redis.sAdd(`{${this.rawNs}:QUEUES}`, this.qname).catch(() => { });
193
+ this.ensured = true;
194
+ }
195
+ /** Reset the ensured flag so the next receive call recreates stream+group.
196
+ * Call this when XREADGROUP returns NOGROUP (stream/group wiped, e.g. after Redis flush). */
197
+ resetEnsured() {
198
+ this.ensured = false;
199
+ }
200
+ // ─── Send — 1 roundtrip ───────────────────────────────────────────────────────
201
+ /**
202
+ * Append a message. Returns the stream entry ID for immediate sends;
203
+ * undefined for buffered (pre-connect) or delayed sends.
204
+ */
205
+ async send(message, delay = 0) {
206
+ if (!this.connected) {
207
+ this.offlineBuffer.push({ message, delay });
208
+ return;
209
+ }
210
+ await this._ensureGroup();
211
+ if (delay > 0) {
212
+ setTimeout(() => void this._xadd(message), delay * 1000);
213
+ return;
214
+ }
215
+ const id = await this._xadd(message);
216
+ if (this.listenerCount(TSRQWEvents.new) > 0)
217
+ this.emit(TSRQWEvents.new, message);
218
+ return id;
219
+ }
220
+ _xadd(message) {
221
+ return this.redis.xAdd(this._streamKey(), '*', { p: message }, { TRIM: { strategy: 'MAXLEN', strategyModifier: '~', threshold: this.maxLen } });
222
+ }
223
+ // ─── Receive with handler (hot path) ─────────────────────────────────────────
224
+ async receiveWithStatus({ handle, visibility: vt = 30, }) {
225
+ await this._ensureGroup();
226
+ let raw = null;
227
+ const now = Date.now();
228
+ if (now - this.lastReclaimAt >= this.reclaimIntervalMs) {
229
+ this.lastReclaimAt = now;
230
+ raw = await this._reclaimOne(vt);
231
+ }
232
+ if (!raw)
233
+ raw = await this._readOne();
234
+ if (!raw)
235
+ return { status: 'idle' };
236
+ const { id, message, rc } = raw;
237
+ const meta = { id, rc, fr: this._msFromId(id) };
238
+ const ok = await handle(message, meta);
239
+ if (ok) {
240
+ await this._ack(id);
241
+ if (this.listenerCount(TSRQWEvents.completed) > 0)
242
+ this.emit(TSRQWEvents.completed, { message, meta });
243
+ if (this.listenerCount(TSRQWEvents.deleted) > 0)
244
+ this.emit(TSRQWEvents.deleted, { message, meta });
245
+ return { status: 'completed', message, meta };
246
+ }
247
+ if (rc > this.attempts) {
248
+ await this._moveToDeadLetter(id, message, rc);
249
+ await this._ack(id);
250
+ if (this.listenerCount(TSRQWEvents.exceeded) > 0)
251
+ this.emit(TSRQWEvents.exceeded, { message, meta });
252
+ if (this.listenerCount(TSRQWEvents.failed) > 0)
253
+ this.emit(TSRQWEvents.failed, { message, meta });
254
+ if (this.listenerCount(TSRQWEvents.deleted) > 0)
255
+ this.emit(TSRQWEvents.deleted, { message, meta });
256
+ return { status: 'failed', message, meta };
257
+ }
258
+ if (this.listenerCount(TSRQWEvents.retry) > 0)
259
+ this.emit(TSRQWEvents.retry, { message, meta });
260
+ return { status: 'retry', message, meta };
261
+ }
262
+ async receive(opts) {
263
+ await this.receiveWithStatus(opts);
264
+ }
265
+ /**
266
+ * Blocking receive — XREADGROUP with BLOCK so the connection sleeps until a message
267
+ * arrives, then wakes immediately. Zero polling overhead vs CronJob polling.
268
+ *
269
+ * Preferred consumer pattern for durable/recovery consumers (e.g. integration event
270
+ * streams). Each call blocks for up to `blockMs` ms. On timeout: returns null (no
271
+ * message). On message: calls `handler`, ACKs on success, leaves in PEL on failure.
272
+ *
273
+ * Run in a `while (running) { await q.receiveBlocking(handler) }` loop per channel.
274
+ * The loop is woken by Redis as soon as a message is written — no polling overhead.
275
+ *
276
+ * @param handler - Return true to ACK, false to leave in PEL (retry after vt).
277
+ * @param vt - Visibility timeout seconds.
278
+ * @param blockMs - Max ms to wait for a message (Redis BLOCK option). Default 2 000.
279
+ * Keep short (≤5s) when using a shared cluster client — BLOCK holds
280
+ * the master socket and prevents other commands from executing on it.
281
+ */
282
+ async receiveBlocking(handler, vt = 30, blockMs = 2_000) {
283
+ try {
284
+ await this._ensureGroup();
285
+ }
286
+ catch {
287
+ return; // group creation failed — caller loop will retry
288
+ }
289
+ let result;
290
+ try {
291
+ result = await this.redis.xReadGroup(this.group, this.consumer, [{ key: this._streamKey(), id: '>' }], { COUNT: 1, BLOCK: blockMs });
292
+ }
293
+ catch (err) {
294
+ const msg = err instanceof Error ? err.message : String(err);
295
+ if (isNoGroupError(msg)) {
296
+ this.ensured = false; // stream/group wiped — recreate on next call
297
+ }
298
+ return; // transient error — caller loop will retry
299
+ }
300
+ const entries = result?.[0]?.messages ?? [];
301
+ if (!entries.length)
302
+ return; // BLOCK timeout — no message
303
+ const e = entries[0];
304
+ const fr = this._msFromId(e.id);
305
+ const message = String(e.message['p'] ?? '');
306
+ const ok = await handler(message, { id: e.id, rc: 1, fr }).catch(() => false);
307
+ if (ok) {
308
+ await this._ack(e.id).catch(() => { });
309
+ }
310
+ // On false/error: message stays in PEL; XAUTOCLAIM picks it up after vt seconds
311
+ void vt; // vt is used by XAUTOCLAIM in receiveRawBatch — documented for callers
312
+ }
313
+ // ─── Low-level API (adaptive consumers owning their own ack cycle) ────────────
314
+ /** Pull one raw message without calling a handler or auto-acking. */
315
+ async receiveRaw(vt = 30) {
316
+ await this._ensureGroup();
317
+ let raw = null;
318
+ const now = Date.now();
319
+ if (now - this.lastReclaimAt >= this.reclaimIntervalMs) {
320
+ this.lastReclaimAt = now;
321
+ raw = await this._reclaimOne(vt);
322
+ }
323
+ if (!raw)
324
+ raw = await this._readOne();
325
+ if (!raw)
326
+ return null;
327
+ const fr = this._msFromId(raw.id);
328
+ return { id: raw.id, message: raw.message, rc: raw.rc, fr, sent: fr };
329
+ }
330
+ /** XACK a message by ID. Returns true when the PEL entry was removed. */
331
+ async ack(id) {
332
+ return (await this._ack(id)) > 0;
333
+ }
334
+ /**
335
+ * XACK multiple messages in one roundtrip.
336
+ * All IDs must belong to this stream — guaranteed by the caller holding them from receiveRaw/receiveRawBatch.
337
+ * Returns the number of entries removed from the PEL.
338
+ */
339
+ async ackBatch(ids) {
340
+ if (ids.length === 0)
341
+ return 0;
342
+ return Number(await this.redis.xAck(this._streamKey(), this.group, ids)) || 0;
343
+ }
344
+ /**
345
+ * Pull up to `count` messages in one roundtrip (XREADGROUP COUNT N).
346
+ * Reclaim path uses a single XPENDING RANGE for all stale entries — no per-message round-trips.
347
+ * Returns an empty array when the queue is idle. Caller owns ack/deadLetter for each message.
348
+ */
349
+ async receiveRawBatch(vt = 30, count = 10) {
350
+ try {
351
+ return await this._receiveRawBatchImpl(vt, count);
352
+ }
353
+ catch (err) {
354
+ const msg = err instanceof Error ? err.message : String(err);
355
+ if (isNoGroupError(msg)) {
356
+ // Stream or consumer group was wiped (e.g. Redis flush). Reset ensured flag
357
+ // so the next poll recreates stream+group via _ensureGroup(), then retry once.
358
+ this.ensured = false;
359
+ return this._receiveRawBatchImpl(vt, count);
360
+ }
361
+ throw err;
362
+ }
363
+ }
364
+ // eslint-disable-next-line complexity
365
+ async _receiveRawBatchImpl(vt = 30, count = 10) {
366
+ await this._ensureGroup();
367
+ const results = [];
368
+ // Throttled reclaim — one XAUTOCLAIM COUNT N for all stale entries
369
+ const now = Date.now();
370
+ if (now - this.lastReclaimAt >= this.reclaimIntervalMs) {
371
+ this.lastReclaimAt = now;
372
+ const claimed = await this.redis.xAutoClaim(this._streamKey(), this.group, this.consumer, vt * 1000, '0-0', { COUNT: count });
373
+ const messages = claimed?.messages ?? [];
374
+ if (messages.length > 0) {
375
+ // One XPENDING RANGE for all reclaimed IDs — single roundtrip for delivery counts
376
+ const sortedIds = messages.map(m => m.id).sort();
377
+ const deliveryMap = new Map();
378
+ try {
379
+ const details = await this.redis.xPendingRange(this._streamKey(), this.group, sortedIds[0], sortedIds[sortedIds.length - 1], messages.length);
380
+ for (const d of details) {
381
+ deliveryMap.set(String(d.id ?? ''), Number(d.deliveriesCounter ?? 2));
382
+ }
383
+ }
384
+ catch { /* defaults to 2 below */ }
385
+ for (const e of messages) {
386
+ const fr = this._msFromId(e.id);
387
+ results.push({
388
+ id: e.id,
389
+ message: String(e.message?.['p'] ?? ''),
390
+ rc: deliveryMap.get(e.id) ?? 2,
391
+ fr,
392
+ sent: fr,
393
+ });
394
+ }
395
+ }
396
+ }
397
+ // XREADGROUP COUNT (remaining) for new messages — one roundtrip
398
+ const remaining = count - results.length;
399
+ if (remaining > 0) {
400
+ const readResult = await this.redis.xReadGroup(this.group, this.consumer, [{ key: this._streamKey(), id: '>' }], { COUNT: remaining });
401
+ const entries = readResult?.[0]?.messages ?? [];
402
+ for (const e of entries) {
403
+ const fr = this._msFromId(e.id);
404
+ results.push({ id: e.id, message: String(e.message['p'] ?? ''), rc: 1, fr, sent: fr });
405
+ }
406
+ }
407
+ return results;
408
+ }
409
+ /** Move a message to the `:dlq` stream and XACK the source. */
410
+ async deadLetter(id, message, rc) {
411
+ await this._moveToDeadLetter(id, message, rc);
412
+ await this._ack(id);
413
+ }
414
+ // ─── Internal primitives ─────────────────────────────────────────────────────
415
+ async _readOne() {
416
+ try {
417
+ const result = await this.redis.xReadGroup(this.group, this.consumer, [{ key: this._streamKey(), id: '>' }], { COUNT: 1 });
418
+ if (!result?.length)
419
+ return null;
420
+ const entries = result[0]?.messages ?? [];
421
+ if (!entries.length)
422
+ return null;
423
+ const e = entries[0];
424
+ return { id: e.id, message: String(e.message['p'] ?? ''), rc: 1 };
425
+ }
426
+ catch (err) {
427
+ const msg = err instanceof Error ? err.message : String(err);
428
+ if (isNoGroupError(msg)) {
429
+ this.ensured = false;
430
+ await this._ensureGroup();
431
+ return null; // idle — caller retries next poll
432
+ }
433
+ throw err;
434
+ }
435
+ }
436
+ async _reclaimOne(vt) {
437
+ try {
438
+ const result = await this.redis.xAutoClaim(this._streamKey(), this.group, this.consumer, vt * 1000, '0-0', { COUNT: 1 });
439
+ const messages = result?.messages;
440
+ if (!messages?.length)
441
+ return null;
442
+ const e = messages[0];
443
+ const rc = await this._deliveryCount(e.id);
444
+ return { id: e.id, message: String(e.message?.['p'] ?? ''), rc };
445
+ }
446
+ catch (err) {
447
+ const msg = err instanceof Error ? err.message : String(err);
448
+ if (isNoGroupError(msg)) {
449
+ this.ensured = false;
450
+ await this._ensureGroup();
451
+ return null; // idle — caller retries next poll
452
+ }
453
+ throw err;
454
+ }
455
+ }
456
+ async _deliveryCount(id) {
457
+ try {
458
+ const detail = await this.redis.xPendingRange(this._streamKey(), this.group, id, id, 1);
459
+ return Number(detail?.[0]?.deliveriesCounter ?? 2);
460
+ }
461
+ catch {
462
+ return 2;
463
+ }
464
+ }
465
+ _ack(id) {
466
+ return this.redis.xAck(this._streamKey(), this.group, id);
467
+ }
468
+ _moveToDeadLetter(id, message, rc) {
469
+ return this.redis.xAdd(this._dlqKey(), '*', { p: message, src: id, rc: String(rc) }, { TRIM: { strategy: 'MAXLEN', strategyModifier: '~', threshold: this.maxLen } });
470
+ }
471
+ _msFromId(id) {
472
+ const dash = id.indexOf('-');
473
+ if (dash < 1)
474
+ return 0;
475
+ const ms = Number(id.slice(0, dash));
476
+ return Number.isFinite(ms) ? ms : 0;
477
+ }
478
+ // ─── Attributes ──────────────────────────────────────────────────────────────
479
+ async attributes() {
480
+ try {
481
+ const [len, groups] = await Promise.all([
482
+ this.redis.xLen(this._streamKey()),
483
+ this.redis.xInfoGroups(this._streamKey()),
484
+ ]);
485
+ const g = groups.find((x) => x.name === this.group);
486
+ const totalsent = Number(len) || 0;
487
+ return {
488
+ msgs: totalsent,
489
+ hiddenmsgs: Number(g?.pending ?? 0),
490
+ totalsent,
491
+ totalrecv: Number(g?.['entries-read'] ?? g?.entriesRead ?? 0),
492
+ };
493
+ }
494
+ catch (err) {
495
+ const msg = err instanceof Error ? err.message : String(err);
496
+ if (isNoGroupError(msg) || msg.includes('ERR no such key'))
497
+ return null;
498
+ throw err;
499
+ }
500
+ }
501
+ /**
502
+ * Rich Streams-native snapshot — for dashboards, platform stats, admin UI.
503
+ * 4–5 parallel roundtrips (XLEN×2 + XINFO STREAM + XINFO GROUPS + optional XPENDING).
504
+ * Do NOT call on the hot message path — use attributes() there.
505
+ */
506
+ // eslint-disable-next-line complexity
507
+ async snapshot() {
508
+ try {
509
+ const [len, dlqLen, info, groups] = await Promise.all([
510
+ this.redis.xLen(this._streamKey()),
511
+ this.redis.xLen(this._dlqKey()).catch(() => 0),
512
+ this.redis.xInfoStream(this._streamKey()),
513
+ this.redis.xInfoGroups(this._streamKey()),
514
+ ]);
515
+ const g = groups.find((x) => x.name === this.group);
516
+ // totalsent: prefer entries-added (monotonic, survives TRIM, Redis 7.2+)
517
+ const totalsentExact = Number(info?.['entries-added'] ?? info?.entriesAdded ?? len) || 0;
518
+ // lag: messages not yet delivered (Redis 7.0+ native field, else compute from length - entries-read)
519
+ const lag = Number(g?.lag ?? Math.max(0, (Number(len) || 0) - Number(g?.['entries-read'] ?? g?.entriesRead ?? 0)));
520
+ // last-delivered-id ms component (e.g. "1717000000000-0" → 1717000000000)
521
+ const lastDeliveredId = g?.['last-delivered-id'] ?? g?.lastDeliveredId ?? '0-0';
522
+ const lastDeliveredMs = this._msFromId(lastDeliveredId);
523
+ // oldest pending: one extra roundtrip only when there's something in the PEL
524
+ let oldestPendingMs = 0;
525
+ const pendingCount = Number(g?.pending ?? 0);
526
+ if (pendingCount > 0) {
527
+ try {
528
+ const oldest = await this.redis.xPendingRange(this._streamKey(), this.group, '-', '+', 1);
529
+ if (oldest?.length > 0) {
530
+ oldestPendingMs = this._msFromId(String(oldest[0]?.id ?? '0'));
531
+ }
532
+ }
533
+ catch { /* non-fatal — oldest stays 0 */ }
534
+ }
535
+ // lastAddedMs: ms component of last-generated-id (last XADD)
536
+ const lastGeneratedId = info?.['last-generated-id'] ?? info?.lastGeneratedId ?? '0-0';
537
+ const lastAddedMs = this._msFromId(lastGeneratedId);
538
+ const msgs = Number(len) || 0;
539
+ const hiddenmsgs = pendingCount;
540
+ const totalrecv = Number(g?.['entries-read'] ?? g?.entriesRead ?? 0);
541
+ const dlqDepth = Number(dlqLen) || 0;
542
+ // ─── Derived (zero extra Redis calls) ───────────────────────────────────
543
+ const backpressure = lag + hiddenmsgs;
544
+ const consumptionRate = totalsentExact > 0 ? (totalrecv / totalsentExact) * 100 : 100;
545
+ const dlqRate = totalsentExact > 0 ? (dlqDepth / totalsentExact) * 100 : 0;
546
+ let health;
547
+ if (totalsentExact === 0 && dlqDepth === 0)
548
+ health = 'empty';
549
+ else if (oldestPendingMs > 0 && (Date.now() - oldestPendingMs) > 120_000)
550
+ health = 'stuck';
551
+ else if (lag > 0)
552
+ health = 'lagging';
553
+ else
554
+ health = 'healthy';
555
+ return {
556
+ msgs,
557
+ hiddenmsgs,
558
+ totalsent: msgs,
559
+ totalrecv,
560
+ lag,
561
+ consumers: Number(g?.consumers ?? 0),
562
+ dlqDepth,
563
+ totalsentExact,
564
+ lastDeliveredMs,
565
+ oldestPendingMs,
566
+ lastAddedMs,
567
+ backpressure,
568
+ consumptionRate,
569
+ dlqRate,
570
+ health,
571
+ };
572
+ }
573
+ catch (err) {
574
+ const msg = err instanceof Error ? err.message : String(err);
575
+ if (isNoGroupError(msg) || msg.includes('ERR no such key'))
576
+ return null;
577
+ throw err;
578
+ }
579
+ }
580
+ // ─── Offline buffer ───────────────────────────────────────────────────────────
581
+ async _drainOffline() {
582
+ while (this.offlineBuffer.length > 0) {
583
+ const item = this.offlineBuffer.shift();
584
+ await this.send(item.message, item.delay);
585
+ }
586
+ }
587
+ // ─── Statics: schedule + thread pool ─────────────────────────────────────────
588
+ /** List all queue names registered in a namespace (reads from `{ns:QUEUES}` index set). */
589
+ static async listQueues(redis, ns) {
590
+ return ((await redis.sMembers(`{${ns}:QUEUES}`)) ?? []).sort();
591
+ }
592
+ /**
593
+ * Read-only aggregate snapshot across ALL consumer groups on a stream — no XGROUP CREATE.
594
+ * Use from dashboards instead of `new TSRQW(...).snapshot()` to avoid creating phantom groups.
595
+ *
596
+ * Aggregation rules (correct for fan-out / multi-group topologies):
597
+ * - lag : max across groups (worst consumer defines backpressure)
598
+ * - consumers : sum across groups (total workers active)
599
+ * - totalrecv : max entries-read across groups (how far the fastest consumer has reached)
600
+ * - lastDeliveredMs: max last-delivered-id ms (most-recently-delivered across groups)
601
+ * - dlqRate : dlqDepth / totalsentExact capped at entries-read by the most-advanced group
602
+ * (avoids 100% false-positive when DLQ accumulated across stream recreations)
603
+ *
604
+ * Returns null when the stream does not exist.
605
+ */
606
+ // eslint-disable-next-line complexity
607
+ static async snapshotFromGroups(redis, ns, name) {
608
+ const streamKey = `{${ns}:${name}}:stream`;
609
+ const dlqKey = `{${ns}:${name}}:dlq`;
610
+ try {
611
+ const [len, dlqLen, info, groups] = await Promise.all([
612
+ redis.xLen(streamKey),
613
+ redis.xLen(dlqKey).catch(() => 0),
614
+ redis.xInfoStream(streamKey),
615
+ redis.xInfoGroups(streamKey).catch(() => []),
616
+ ]);
617
+ const gs = groups;
618
+ const msgs = Number(len) || 0;
619
+ const dlqDepth = Number(dlqLen) || 0;
620
+ const totalsentExact = Number(info?.['entries-added'] ?? info?.entriesAdded ?? msgs) || 0;
621
+ const lastGeneratedId = String(info?.['last-generated-id'] ?? info?.lastGeneratedId ?? '0-0');
622
+ const lastAddedMs = TSRQW._msFromIdStatic(lastGeneratedId);
623
+ // Exclude zero-activity groups (entries-read=0, pending=0) from the aggregate when
624
+ // active groups exist. These are either phantom monitoring groups created at $ that
625
+ // never consumed, or producer-only groups. Keeping them would inflate lag to the full
626
+ // stream length even though real consumers are current.
627
+ const activeGs = gs.filter((g) => Number(g?.['entries-read'] ?? g?.entriesRead ?? 0) > 0 || Number(g.pending ?? 0) > 0);
628
+ const effectiveGs = activeGs.length > 0 ? activeGs : gs;
629
+ // Aggregate across effective groups
630
+ let lag = 0;
631
+ let consumers = 0;
632
+ let totalrecv = 0;
633
+ let lastDeliveredMs = 0;
634
+ const oldestPendingMs = 0;
635
+ let hiddenmsgs = 0;
636
+ for (const g of effectiveGs) {
637
+ const gLag = Number(g.lag ?? Math.max(0, msgs - Number(g?.['entries-read'] ?? g?.entriesRead ?? 0)));
638
+ const gConsumers = Number(g.consumers ?? 0);
639
+ const gEntriesRead = Number(g?.['entries-read'] ?? g?.entriesRead ?? 0);
640
+ const gPending = Number(g.pending ?? 0);
641
+ const gLastId = String(g?.['last-delivered-id'] ?? g?.lastDeliveredId ?? '0-0');
642
+ const gLastMs = TSRQW._msFromIdStatic(gLastId);
643
+ lag = Math.max(lag, gLag);
644
+ consumers += gConsumers;
645
+ totalrecv = Math.max(totalrecv, gEntriesRead);
646
+ lastDeliveredMs = Math.max(lastDeliveredMs, gLastMs);
647
+ hiddenmsgs += gPending;
648
+ }
649
+ // dlqRate against entries-read by the most-advanced group to avoid false 100% when
650
+ // DLQ accumulated across stream recreations (entries-added resets, DLQ does not)
651
+ const dlqBase = totalrecv > 0 ? totalrecv : totalsentExact;
652
+ const consumptionRate = totalsentExact > 0 ? (totalrecv / totalsentExact) * 100 : 100;
653
+ const dlqRate = dlqBase > 0 ? (dlqDepth / dlqBase) * 100 : 0;
654
+ const backpressure = lag + hiddenmsgs;
655
+ let health;
656
+ if (totalsentExact === 0 && dlqDepth === 0)
657
+ health = 'empty';
658
+ else if (oldestPendingMs > 0 && (Date.now() - oldestPendingMs) > 120_000)
659
+ health = 'stuck';
660
+ else if (lag > 0)
661
+ health = 'lagging';
662
+ else
663
+ health = 'healthy';
664
+ return {
665
+ msgs, hiddenmsgs,
666
+ totalsent: msgs,
667
+ totalrecv,
668
+ lag,
669
+ consumers,
670
+ dlqDepth,
671
+ totalsentExact,
672
+ lastDeliveredMs,
673
+ oldestPendingMs,
674
+ lastAddedMs,
675
+ backpressure,
676
+ consumptionRate,
677
+ dlqRate,
678
+ health,
679
+ };
680
+ }
681
+ catch (err) {
682
+ const msg = err instanceof Error ? err.message : String(err);
683
+ if (msg.includes('ERR no such key') || msg.includes('WRONGTYPE'))
684
+ return null;
685
+ throw err;
686
+ }
687
+ }
688
+ static _msFromIdStatic(id) {
689
+ const ms = parseInt(id.split('-')[0] ?? '0', 10);
690
+ return isNaN(ms) ? 0 : ms;
691
+ }
692
+ static schedule({ onTick = () => { }, onComplete = null, runOnInit = false, cronTime = '*/9 * * * * *', context = null, start = false, timeZone = 'utc', }) {
693
+ return new cron_1.CronJob(cronTime, onTick, onComplete, start, timeZone, context, runOnInit);
694
+ }
695
+ static worker(cb, factor = 1, wd) {
696
+ return new TSRQWPool(factor, cb, wd);
697
+ }
698
+ }
699
+ exports.TSRQW = TSRQW;