tina4-nodejs 3.13.42 → 3.13.43
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/CLAUDE.md
CHANGED
package/package.json
CHANGED
|
@@ -81,9 +81,17 @@ export interface ProcessOptions {
|
|
|
81
81
|
maxJobs?: number;
|
|
82
82
|
maxRetries?: number;
|
|
83
83
|
batchSize?: number;
|
|
84
|
+
/**
|
|
85
|
+
* Override the queue's topic for this drain (parity with Python's
|
|
86
|
+
* process(handler, topic=...)). When set, process() retargets the queue so
|
|
87
|
+
* pop() reads the requested topic instead of the construction-time one.
|
|
88
|
+
*/
|
|
89
|
+
topic?: string;
|
|
84
90
|
}
|
|
85
91
|
|
|
86
92
|
export interface ConsumeOptions {
|
|
93
|
+
/** Topic to consume (defaults to the constructor topic). */
|
|
94
|
+
topic?: string;
|
|
87
95
|
batchSize?: number;
|
|
88
96
|
pollInterval?: number;
|
|
89
97
|
iterations?: number;
|
|
@@ -95,6 +103,18 @@ export interface QueueBackendInterface {
|
|
|
95
103
|
pop(queue: string): QueueJob | null;
|
|
96
104
|
size(queue: string): number;
|
|
97
105
|
clear(queue: string): void;
|
|
106
|
+
// Optional full lifecycle. Reservation-based backends (MongoDB) implement
|
|
107
|
+
// these so complete()/fail() ack the ACTIVE store — without complete(), a
|
|
108
|
+
// reserved Mongo job is re-delivered after the visibility window. Backends
|
|
109
|
+
// that auto-ack on pop (RabbitMQ no-ack) or delegate to the broker (Kafka
|
|
110
|
+
// offsets) omit them, and the Queue keeps its prior behaviour for them.
|
|
111
|
+
complete?(queue: string, id: string): void;
|
|
112
|
+
fail?(queue: string, id: string, error: string, maxRetries: number, retryBackoff: number): void;
|
|
113
|
+
retry?(queue: string, id: string, delaySeconds?: number): void;
|
|
114
|
+
deadLetters?(queue: string, maxRetries?: number): QueueJob[];
|
|
115
|
+
failed?(queue: string, maxRetries?: number): QueueJob[];
|
|
116
|
+
retryFailed?(queue: string, maxRetries?: number): number;
|
|
117
|
+
purge?(queue: string, status?: string): number;
|
|
98
118
|
}
|
|
99
119
|
|
|
100
120
|
// ── Queue ────────────────────────────────────────────────────
|
|
@@ -152,6 +172,22 @@ export class Queue {
|
|
|
152
172
|
}
|
|
153
173
|
}
|
|
154
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Point this queue at ``topic`` in place.
|
|
177
|
+
*
|
|
178
|
+
* produce()/consume()/process() call this so a topic argument actually
|
|
179
|
+
* changes which topic is read or written. Without it the argument was
|
|
180
|
+
* accepted but ignored on the read path — pop() always used the
|
|
181
|
+
* construction-time topic, so consume("other") silently drained the wrong
|
|
182
|
+
* queue. The lite + external backends are topic-per-call (every push/pop/size
|
|
183
|
+
* takes the queue name), so changing this.topic retargets all of them; the
|
|
184
|
+
* job lifecycle (complete()/fail()/retry()) routes by the job's own .topic, so
|
|
185
|
+
* it is unaffected. Mirrors Python's Queue._retarget().
|
|
186
|
+
*/
|
|
187
|
+
private retarget(topic: string): void {
|
|
188
|
+
this.topic = topic;
|
|
189
|
+
}
|
|
190
|
+
|
|
155
191
|
// ── Unified API (topic-aware) ────────────────────────────────
|
|
156
192
|
|
|
157
193
|
/**
|
|
@@ -197,6 +233,12 @@ export class Queue {
|
|
|
197
233
|
handler: (job: QueueJob | QueueJob[]) => Promise<void> | void,
|
|
198
234
|
options?: ProcessOptions,
|
|
199
235
|
): void {
|
|
236
|
+
// Honour an explicit topic: retarget so pop() drains the requested topic
|
|
237
|
+
// (parity with Python's process(handler, topic=...)). Without this the
|
|
238
|
+
// argument was accepted but ignored on the read path.
|
|
239
|
+
if (options?.topic !== undefined) {
|
|
240
|
+
this.retarget(options.topic);
|
|
241
|
+
}
|
|
200
242
|
const queue = this.topic;
|
|
201
243
|
const opts = options;
|
|
202
244
|
|
|
@@ -275,6 +317,9 @@ export class Queue {
|
|
|
275
317
|
* auto-retry lifecycle; dead-lettered jobs are returned by deadLetters().
|
|
276
318
|
*/
|
|
277
319
|
failed(): QueueJob[] {
|
|
320
|
+
if (this.externalBackend?.failed) {
|
|
321
|
+
return this.externalBackend.failed(this.topic, this._maxRetries);
|
|
322
|
+
}
|
|
278
323
|
return this.liteBackend.failed(this.topic, this._maxRetries);
|
|
279
324
|
}
|
|
280
325
|
|
|
@@ -288,6 +333,10 @@ export class Queue {
|
|
|
288
333
|
retry(jobId?: string, delaySeconds?: number): boolean {
|
|
289
334
|
if (jobId) {
|
|
290
335
|
// Retry a specific job by ID
|
|
336
|
+
if (this.externalBackend?.retry) {
|
|
337
|
+
this.externalBackend.retry(this.topic, jobId, delaySeconds);
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
291
340
|
return this.liteBackend.retry(this.topic, jobId, delaySeconds);
|
|
292
341
|
}
|
|
293
342
|
// Retry all dead-letter jobs
|
|
@@ -295,8 +344,12 @@ export class Queue {
|
|
|
295
344
|
if (deadJobs.length === 0) return false;
|
|
296
345
|
let retried = false;
|
|
297
346
|
for (const job of deadJobs) {
|
|
298
|
-
|
|
299
|
-
|
|
347
|
+
if (this.externalBackend?.retry) {
|
|
348
|
+
this.externalBackend.retry(this.topic, job.id, delaySeconds);
|
|
349
|
+
retried = true;
|
|
350
|
+
} else if (this.liteBackend.retry(this.topic, job.id, delaySeconds)) {
|
|
351
|
+
retried = true;
|
|
352
|
+
}
|
|
300
353
|
}
|
|
301
354
|
return retried;
|
|
302
355
|
}
|
|
@@ -305,6 +358,9 @@ export class Queue {
|
|
|
305
358
|
* Get dead letter jobs — failed jobs that exceeded max retries.
|
|
306
359
|
*/
|
|
307
360
|
deadLetters(maxRetries?: number): QueueJob[] {
|
|
361
|
+
if (this.externalBackend?.deadLetters) {
|
|
362
|
+
return this.externalBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries);
|
|
363
|
+
}
|
|
308
364
|
return this.liteBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries);
|
|
309
365
|
}
|
|
310
366
|
|
|
@@ -312,6 +368,9 @@ export class Queue {
|
|
|
312
368
|
* Delete messages by status (e.g. "completed", "failed", "dead").
|
|
313
369
|
*/
|
|
314
370
|
purge(status: string, maxRetries?: number): number {
|
|
371
|
+
if (this.externalBackend?.purge) {
|
|
372
|
+
return this.externalBackend.purge(this.topic, status);
|
|
373
|
+
}
|
|
315
374
|
return this.liteBackend.purge(this.topic, status, maxRetries ?? this._maxRetries);
|
|
316
375
|
}
|
|
317
376
|
|
|
@@ -319,17 +378,27 @@ export class Queue {
|
|
|
319
378
|
* Re-queue failed jobs that haven't exceeded max retries back to pending.
|
|
320
379
|
*/
|
|
321
380
|
retryFailed(maxRetries?: number): number {
|
|
381
|
+
if (this.externalBackend?.retryFailed) {
|
|
382
|
+
return this.externalBackend.retryFailed(this.topic, maxRetries ?? this._maxRetries);
|
|
383
|
+
}
|
|
322
384
|
return this.liteBackend.retryFailed(this.topic, maxRetries ?? this._maxRetries);
|
|
323
385
|
}
|
|
324
386
|
|
|
325
387
|
/**
|
|
326
388
|
* Produce a message onto a topic. Convenience wrapper around push().
|
|
389
|
+
*
|
|
390
|
+
* Retargets to the requested topic (restoring the prior one afterwards) so it
|
|
391
|
+
* shares the same retarget path consume()/process() use — keeping produce and
|
|
392
|
+
* consume symmetric on the same topic argument.
|
|
327
393
|
*/
|
|
328
394
|
produce(topic: string, payload: unknown, priority: number = 0, delay: number = 0): string {
|
|
329
|
-
|
|
330
|
-
|
|
395
|
+
const previous = this.topic;
|
|
396
|
+
this.retarget(topic);
|
|
397
|
+
try {
|
|
398
|
+
return this.push(payload, delay, priority);
|
|
399
|
+
} finally {
|
|
400
|
+
this.retarget(previous);
|
|
331
401
|
}
|
|
332
|
-
return this.liteBackend.push(topic, payload, delay, priority);
|
|
333
402
|
}
|
|
334
403
|
|
|
335
404
|
/**
|
|
@@ -368,7 +437,9 @@ export class Queue {
|
|
|
368
437
|
|
|
369
438
|
if (topicOrOptions !== null && typeof topicOrOptions === "object") {
|
|
370
439
|
const opts = topicOrOptions as ConsumeOptions;
|
|
371
|
-
|
|
440
|
+
// Honour opts.topic (parity with the string-arg form) — previously the
|
|
441
|
+
// options-object form ignored it and always drained the constructor topic.
|
|
442
|
+
q = opts.topic ?? this.topic;
|
|
372
443
|
resolvedId = opts.id;
|
|
373
444
|
resolvedPollInterval = opts.pollInterval ?? 1000;
|
|
374
445
|
resolvedIterations = opts.iterations ?? 0;
|
|
@@ -381,6 +452,12 @@ export class Queue {
|
|
|
381
452
|
resolvedBatchSize = batchSize;
|
|
382
453
|
}
|
|
383
454
|
|
|
455
|
+
// Honour the topic argument: point the queue (and the backend pop()/
|
|
456
|
+
// popById() route through) at it. Previously the resolved topic was
|
|
457
|
+
// computed but never used — pop()/popById() read this.topic, so
|
|
458
|
+
// consume("other") silently drained the construction-time topic.
|
|
459
|
+
this.retarget(q);
|
|
460
|
+
|
|
384
461
|
if (resolvedId !== undefined) {
|
|
385
462
|
const raw = this.popById(resolvedId);
|
|
386
463
|
if (raw) yield createJob(raw as any, this);
|
|
@@ -453,6 +530,10 @@ export class Queue {
|
|
|
453
530
|
* after retryBackoff seconds) or dead-letter (attempts >= maxRetries).
|
|
454
531
|
*/
|
|
455
532
|
_failJob(queue: string, job: QueueJob, error: string, maxRetries: number): void {
|
|
533
|
+
if (this.externalBackend?.fail) {
|
|
534
|
+
this.externalBackend.fail(queue, job.id, error, maxRetries, this._retryBackoff);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
456
537
|
this.liteBackend.failJob(queue, job, error, maxRetries, this._retryBackoff);
|
|
457
538
|
}
|
|
458
539
|
|
|
@@ -460,15 +541,26 @@ export class Queue {
|
|
|
460
541
|
* Re-queue a job back to the main queue directory with incremented attempts.
|
|
461
542
|
*/
|
|
462
543
|
_retryJob(queue: string, job: QueueJob, delaySeconds?: number): void {
|
|
544
|
+
if (this.externalBackend?.retry) {
|
|
545
|
+
this.externalBackend.retry(queue, job.id, delaySeconds);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
463
548
|
this.liteBackend.retryJob(queue, job, delaySeconds);
|
|
464
549
|
}
|
|
465
550
|
|
|
466
551
|
/**
|
|
467
|
-
* Acknowledge a completed job — drop its reservation
|
|
468
|
-
*
|
|
469
|
-
*
|
|
552
|
+
* Acknowledge a completed job — drop its reservation so the visibility reclaim
|
|
553
|
+
* never re-delivers it. Routes to the active backend: a reservation-based
|
|
554
|
+
* external backend (MongoDB) acks there (without this its reserved doc would
|
|
555
|
+
* be re-delivered after the visibility window); RabbitMQ (no-ack on get) and
|
|
556
|
+
* Kafka (offset-based) expose no complete(), so the lite path is used and is a
|
|
557
|
+
* harmless no-op for them since they already acked/own redelivery.
|
|
470
558
|
*/
|
|
471
559
|
_completeJob(queue: string, job: QueueJob): void {
|
|
560
|
+
if (this.externalBackend?.complete) {
|
|
561
|
+
this.externalBackend.complete(queue, job.id);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
472
564
|
this.liteBackend.completeJob(queue, job);
|
|
473
565
|
}
|
|
474
566
|
}
|
|
@@ -229,12 +229,20 @@ export class MongoBackend implements QueueBackend {
|
|
|
229
229
|
if (result && result.value) {
|
|
230
230
|
// findOneAndUpdate returns { value: doc } in older drivers
|
|
231
231
|
const doc = result.value;
|
|
232
|
+
// Carry the framework topic on the job so complete()/fail()
|
|
233
|
+
// route the ack/requeue back to THIS topic's docs (the Mongo
|
|
234
|
+
// internal queue field is dropped from the returned shape).
|
|
235
|
+
doc.topic = queueName;
|
|
232
236
|
delete doc._id;
|
|
233
237
|
delete doc.queue;
|
|
234
238
|
process.stdout.write(JSON.stringify(doc));
|
|
235
239
|
} else if (result && result._id) {
|
|
236
240
|
// Some driver versions return the doc directly
|
|
237
241
|
const doc = { ...result };
|
|
242
|
+
// Carry the framework topic on the job so complete()/fail()
|
|
243
|
+
// route the ack/requeue back to THIS topic's docs (the Mongo
|
|
244
|
+
// internal queue field is dropped from the returned shape).
|
|
245
|
+
doc.topic = queueName;
|
|
238
246
|
delete doc._id;
|
|
239
247
|
delete doc.queue;
|
|
240
248
|
process.stdout.write(JSON.stringify(doc));
|
|
@@ -253,6 +261,95 @@ export class MongoBackend implements QueueBackend {
|
|
|
253
261
|
await col.deleteMany({ queue: queueName });
|
|
254
262
|
process.stdout.write("__CLEARED__");
|
|
255
263
|
}
|
|
264
|
+
else if (operation === "complete") {
|
|
265
|
+
// Ack a finished job so the reclaim never re-delivers it. data = job id.
|
|
266
|
+
// (The pop reserved this doc; without this it stays reserved and is
|
|
267
|
+
// re-delivered after the visibility window — the redelivery bug.)
|
|
268
|
+
await col.updateOne(
|
|
269
|
+
{ queue: queueName, id: data },
|
|
270
|
+
{ $set: { status: "completed", completedAt: new Date().toISOString(), reservedAt: null } },
|
|
271
|
+
);
|
|
272
|
+
process.stdout.write("__OK__");
|
|
273
|
+
}
|
|
274
|
+
else if (operation === "fail") {
|
|
275
|
+
// Requeue while retries remain (reset availableAt -> visible again),
|
|
276
|
+
// else dead-letter. Atomic decision in Mongo. data = JSON
|
|
277
|
+
// { id, error, maxRetries, retryBackoff }.
|
|
278
|
+
const info = JSON.parse(data);
|
|
279
|
+
const now = new Date().toISOString();
|
|
280
|
+
const doc = await col.findOne({ queue: queueName, id: info.id });
|
|
281
|
+
if (doc) {
|
|
282
|
+
const attempts = (doc.attempts || 0) + 1;
|
|
283
|
+
if (attempts >= info.maxRetries) {
|
|
284
|
+
await col.insertOne({
|
|
285
|
+
...doc, _id: undefined, attempts,
|
|
286
|
+
status: "dead", queue: queueName + ".dead_letter", error: info.error,
|
|
287
|
+
});
|
|
288
|
+
await col.deleteOne({ _id: doc._id, queue: queueName });
|
|
289
|
+
} else {
|
|
290
|
+
const avail = info.retryBackoff > 0
|
|
291
|
+
? new Date(Date.now() + info.retryBackoff * 1000).toISOString()
|
|
292
|
+
: now;
|
|
293
|
+
await col.updateOne(
|
|
294
|
+
{ _id: doc._id, queue: queueName },
|
|
295
|
+
{ $set: { status: "pending", availableAt: avail, reservedAt: null, error: info.error },
|
|
296
|
+
$inc: { attempts: 1 } },
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
process.stdout.write("__OK__");
|
|
301
|
+
}
|
|
302
|
+
else if (operation === "retry") {
|
|
303
|
+
// Explicit manual re-queue (always re-enqueues). data = JSON
|
|
304
|
+
// { id, delaySeconds }.
|
|
305
|
+
const info = JSON.parse(data);
|
|
306
|
+
const avail = info.delaySeconds > 0
|
|
307
|
+
? new Date(Date.now() + info.delaySeconds * 1000).toISOString()
|
|
308
|
+
: new Date().toISOString();
|
|
309
|
+
await col.updateOne(
|
|
310
|
+
{ queue: queueName, id: info.id },
|
|
311
|
+
{ $set: { status: "pending", availableAt: avail, reservedAt: null }, $inc: { attempts: 1 } },
|
|
312
|
+
);
|
|
313
|
+
process.stdout.write("__OK__");
|
|
314
|
+
}
|
|
315
|
+
else if (operation === "deadLetters") {
|
|
316
|
+
const docs = await col.find({ queue: queueName + ".dead_letter" }).toArray();
|
|
317
|
+
const out = docs.map((d) => { delete d._id; delete d.queue; return d; });
|
|
318
|
+
process.stdout.write(JSON.stringify(out));
|
|
319
|
+
}
|
|
320
|
+
else if (operation === "failed") {
|
|
321
|
+
const docs = await col
|
|
322
|
+
.find({ queue: queueName, status: "failed", attempts: { $lt: maxRetries } })
|
|
323
|
+
.toArray();
|
|
324
|
+
const out = docs.map((d) => { delete d._id; delete d.queue; return d; });
|
|
325
|
+
process.stdout.write(JSON.stringify(out));
|
|
326
|
+
}
|
|
327
|
+
else if (operation === "retryFailed") {
|
|
328
|
+
// Revive dead-lettered jobs under the (possibly raised) limit back to
|
|
329
|
+
// the main queue as pending. data = the max-retries limit.
|
|
330
|
+
const mr = data ? Number(data) : maxRetries;
|
|
331
|
+
const now = new Date().toISOString();
|
|
332
|
+
let revived = 0;
|
|
333
|
+
while (true) {
|
|
334
|
+
const doc = await col.findOneAndUpdate(
|
|
335
|
+
{ queue: queueName + ".dead_letter", attempts: { $lt: mr } },
|
|
336
|
+
{ $set: { status: "pending", availableAt: now, reservedAt: null, queue: queueName, error: null } },
|
|
337
|
+
{ returnDocument: "after" },
|
|
338
|
+
);
|
|
339
|
+
const updated = doc && doc.value ? doc.value : (doc && doc._id ? doc : null);
|
|
340
|
+
if (!updated) break;
|
|
341
|
+
revived++;
|
|
342
|
+
}
|
|
343
|
+
process.stdout.write(String(revived));
|
|
344
|
+
}
|
|
345
|
+
else if (operation === "purge") {
|
|
346
|
+
// Delete docs by status (default: all for the topic). data = JSON { status }.
|
|
347
|
+
const info = data ? JSON.parse(data) : {};
|
|
348
|
+
const filter = { queue: queueName };
|
|
349
|
+
if (info.status) filter.status = info.status;
|
|
350
|
+
const res = await col.deleteMany(filter);
|
|
351
|
+
process.stdout.write(String(res.deletedCount || 0));
|
|
352
|
+
}
|
|
256
353
|
} catch (err) {
|
|
257
354
|
process.stderr.write(err.message || String(err));
|
|
258
355
|
process.exit(1);
|
|
@@ -328,4 +425,50 @@ export class MongoBackend implements QueueBackend {
|
|
|
328
425
|
clear(queue: string): void {
|
|
329
426
|
this.execSync("clear", queue);
|
|
330
427
|
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Acknowledge a completed job — drop its reservation so the reclaim never
|
|
431
|
+
* re-delivers it. Without this a Mongo-popped job stayed reserved and was
|
|
432
|
+
* re-delivered after the visibility window (the redelivery bug).
|
|
433
|
+
*/
|
|
434
|
+
complete(queue: string, id: string): void {
|
|
435
|
+
this.execSync("complete", queue, id);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Record a failed attempt: requeue (reset availableAt, ++attempts) while
|
|
440
|
+
* retries remain, else dead-letter. Mirrors the file/lite backend.
|
|
441
|
+
*/
|
|
442
|
+
fail(queue: string, id: string, error: string, maxRetries: number, retryBackoff: number = 0): void {
|
|
443
|
+
this.execSync("fail", queue, JSON.stringify({ id, error, maxRetries, retryBackoff }));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Explicit manual re-queue (always re-enqueues regardless of the retry limit). */
|
|
447
|
+
retry(queue: string, id: string, delaySeconds: number = 0): void {
|
|
448
|
+
this.execSync("retry", queue, JSON.stringify({ id, delaySeconds }));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Jobs that exceeded max retries (the `<queue>.dead_letter` collection topic). */
|
|
452
|
+
deadLetters(queue: string, maxRetries?: number): QueueJob[] {
|
|
453
|
+
const out = this.execSync("deadLetters", queue, String(maxRetries ?? this.maxRetries));
|
|
454
|
+
try { return JSON.parse(out) as QueueJob[]; } catch { return []; }
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Jobs that failed but are still eligible for retry (status=failed, attempts < max). */
|
|
458
|
+
failed(queue: string, maxRetries?: number): QueueJob[] {
|
|
459
|
+
const out = this.execSync("failed", queue, String(maxRetries ?? this.maxRetries));
|
|
460
|
+
try { return JSON.parse(out) as QueueJob[]; } catch { return []; }
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Revive dead-lettered jobs under the (possibly raised) limit. Returns count revived. */
|
|
464
|
+
retryFailed(queue: string, maxRetries?: number): number {
|
|
465
|
+
const out = this.execSync("retryFailed", queue, String(maxRetries ?? this.maxRetries));
|
|
466
|
+
return parseInt(out, 10) || 0;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Remove jobs by status (default: every doc for the topic). Returns count removed. */
|
|
470
|
+
purge(queue: string, status?: string): number {
|
|
471
|
+
const out = this.execSync("purge", queue, JSON.stringify({ status: status ?? "" }));
|
|
472
|
+
return parseInt(out, 10) || 0;
|
|
473
|
+
}
|
|
331
474
|
}
|