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/LICENSE +1 -0
- package/README.md +8 -0
- package/db/TSMongo.d.ts +103 -0
- package/db/TSMongo.js +483 -0
- package/db/TSRQW.d.ts +271 -0
- package/db/TSRQW.js +699 -0
- package/db/TSRedis.d.ts +174 -0
- package/db/TSRedis.js +602 -0
- package/package.json +85 -0
- package/ussd/TSUssdMenu.d.ts +139 -0
- package/ussd/TSUssdMenu.js +364 -0
- package/ussd/TSUssdScreen.d.ts +58 -0
- package/ussd/TSUssdScreen.js +218 -0
- package/ussd/index.d.ts +3 -0
- package/ussd/index.js +19 -0
- package/ussd/providers/AfricasTalking.d.ts +3 -0
- package/ussd/providers/AfricasTalking.js +17 -0
- package/ussd/providers/AirtelDRC.d.ts +9 -0
- package/ussd/providers/AirtelDRC.js +31 -0
- package/ussd/providers/OrangeDRC.d.ts +5 -0
- package/ussd/providers/OrangeDRC.js +213 -0
- package/ussd/providers/VodacomDRC.d.ts +9 -0
- package/ussd/providers/VodacomDRC.js +48 -0
- package/ussd/providers/_.d.ts +55 -0
- package/ussd/providers/_.js +83 -0
- package/ussd/providers/index.d.ts +13 -0
- package/ussd/providers/index.js +56 -0
- package/utils/TSFile.d.ts +36 -0
- package/utils/TSFile.js +244 -0
- package/utils/TSHash.d.ts +20 -0
- package/utils/TSHash.js +75 -0
- package/utils/TSRequest.d.ts +39 -0
- package/utils/TSRequest.js +256 -0
- package/utils/TSStub.d.ts +159 -0
- package/utils/TSStub.js +296 -0
- package/utils/abort.d.ts +18 -0
- package/utils/abort.js +97 -0
- package/utils/mime.json +11358 -0
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;
|