tina4-nodejs 3.13.39 → 3.13.41
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 +39 -3
- package/package.json +1 -1
- package/packages/core/src/auth.ts +4 -1
- package/packages/core/src/devAdmin.ts +56 -3
- package/packages/core/src/index.ts +1 -1
- package/packages/core/src/job.ts +5 -1
- package/packages/core/src/mcp.ts +100 -24
- package/packages/core/src/queue.ts +50 -4
- package/packages/core/src/queueBackends/kafkaBackend.ts +7 -1
- package/packages/core/src/queueBackends/liteBackend.ts +188 -14
- package/packages/core/src/queueBackends/mongoBackend.ts +102 -8
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +7 -1
- package/packages/core/src/server.ts +7 -2
- package/packages/core/src/sessionHandlers/mongoHandler.ts +2 -0
- package/packages/core/src/types.ts +4 -0
- package/packages/orm/src/docstore.ts +819 -0
- package/packages/orm/src/index.ts +10 -0
- package/packages/swagger/src/generator.ts +119 -16
- package/packages/swagger/src/ui.ts +10 -2
|
@@ -24,9 +24,19 @@ import { createJob, type JobQueueBridge } from "../job.js";
|
|
|
24
24
|
export class LiteBackend {
|
|
25
25
|
private basePath: string;
|
|
26
26
|
private seq: number = 0;
|
|
27
|
+
/**
|
|
28
|
+
* Reservation/visibility timeout (seconds). A popped job is held in reserved/
|
|
29
|
+
* with availableAt = now + visibilityTimeout. If the consumer dies before
|
|
30
|
+
* complete()/fail() (crash, OOM, k8s eviction) the next pop() reclaims it once
|
|
31
|
+
* the window expires — incrementing attempts and re-enqueuing, or
|
|
32
|
+
* dead-lettering past maxRetries. <= 0 disables the reclaim (a reservation
|
|
33
|
+
* then lasts until the consumer acks — the old at-most-once behaviour).
|
|
34
|
+
*/
|
|
35
|
+
private visibilityTimeout: number;
|
|
27
36
|
|
|
28
|
-
constructor(basePath: string = "data/queue") {
|
|
37
|
+
constructor(basePath: string = "data/queue", visibilityTimeout: number = 300) {
|
|
29
38
|
this.basePath = basePath;
|
|
39
|
+
this.visibilityTimeout = visibilityTimeout;
|
|
30
40
|
}
|
|
31
41
|
|
|
32
42
|
private ensureDir(queue: string): string {
|
|
@@ -41,6 +51,24 @@ export class LiteBackend {
|
|
|
41
51
|
return dir;
|
|
42
52
|
}
|
|
43
53
|
|
|
54
|
+
private ensureReservedDir(queue: string): string {
|
|
55
|
+
const dir = join(this.basePath, queue, "reserved");
|
|
56
|
+
mkdirSync(dir, { recursive: true });
|
|
57
|
+
return dir;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private reservedPath(queue: string, jobId: string): string {
|
|
61
|
+
return join(this.ensureReservedDir(queue), `${jobId}.queue-data`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private nowIso(): string {
|
|
65
|
+
return new Date().toISOString();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private futureIso(seconds: number): string {
|
|
69
|
+
return new Date(Date.now() + seconds * 1000).toISOString();
|
|
70
|
+
}
|
|
71
|
+
|
|
44
72
|
private nextPrefix(): string {
|
|
45
73
|
this.seq++;
|
|
46
74
|
return `${Date.now()}-${String(this.seq).padStart(6, "0")}`;
|
|
@@ -111,22 +139,112 @@ export class LiteBackend {
|
|
|
111
139
|
return candidates;
|
|
112
140
|
}
|
|
113
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Persist a reservation record so a dead consumer's job is reclaimable.
|
|
144
|
+
*
|
|
145
|
+
* Stores reservedAt + availableAt = now + visibilityTimeout. The next pop()
|
|
146
|
+
* reclaims this job once availableAt has passed (see reclaimExpired).
|
|
147
|
+
* complete()/fail()/retry() delete the record.
|
|
148
|
+
*/
|
|
149
|
+
private writeReserved(queue: string, job: any): void {
|
|
150
|
+
const now = this.nowIso();
|
|
151
|
+
const vt = this.visibilityTimeout || 0;
|
|
152
|
+
const record = {
|
|
153
|
+
id: job.id,
|
|
154
|
+
payload: job.payload,
|
|
155
|
+
status: "reserved" as const,
|
|
156
|
+
priority: job.priority ?? 0,
|
|
157
|
+
attempts: job.attempts ?? 0,
|
|
158
|
+
error: job.error,
|
|
159
|
+
reservedAt: now,
|
|
160
|
+
availableAt: vt > 0 ? this.futureIso(vt) : now,
|
|
161
|
+
createdAt: job.createdAt ?? now,
|
|
162
|
+
topic: job.topic ?? queue,
|
|
163
|
+
};
|
|
164
|
+
writeFileSync(this.reservedPath(queue, record.id), JSON.stringify(record, null, 2));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Return expired reservations to the queue (at-least-once delivery).
|
|
169
|
+
*
|
|
170
|
+
* A reserved job whose availableAt <= now means its consumer never
|
|
171
|
+
* acknowledged in time (crash / OOM / pod eviction). Atomically claim it
|
|
172
|
+
* (delete the reservation file), increment attempts, and either re-enqueue it
|
|
173
|
+
* (so the next pop picks it up) or dead-letter it once it has hit maxRetries.
|
|
174
|
+
* Disabled when visibilityTimeout <= 0.
|
|
175
|
+
*/
|
|
176
|
+
private reclaimExpired(queue: string, maxRetries: number, now: string): void {
|
|
177
|
+
if (!this.visibilityTimeout || this.visibilityTimeout <= 0) return;
|
|
178
|
+
const reservedDir = this.ensureReservedDir(queue);
|
|
179
|
+
|
|
180
|
+
let filenames: string[];
|
|
181
|
+
try {
|
|
182
|
+
filenames = readdirSync(reservedDir).filter(f => f.endsWith(".queue-data"));
|
|
183
|
+
} catch {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
for (const filename of filenames) {
|
|
188
|
+
const filePath = join(reservedDir, filename);
|
|
189
|
+
let record: any;
|
|
190
|
+
try {
|
|
191
|
+
record = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
192
|
+
} catch {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (record.availableAt && record.availableAt > now) continue; // still valid
|
|
196
|
+
// Atomically claim the expired reservation by deleting its file.
|
|
197
|
+
try {
|
|
198
|
+
unlinkSync(filePath);
|
|
199
|
+
} catch {
|
|
200
|
+
continue; // another worker reclaimed it first
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const attempts = (record.attempts ?? 0) + 1;
|
|
204
|
+
const error = "reservation timed out — consumer did not acknowledge within the visibility timeout";
|
|
205
|
+
const job: QueueJob = {
|
|
206
|
+
id: record.id,
|
|
207
|
+
payload: record.payload,
|
|
208
|
+
status: "reserved",
|
|
209
|
+
createdAt: record.createdAt ?? now,
|
|
210
|
+
attempts,
|
|
211
|
+
delayUntil: null,
|
|
212
|
+
priority: record.priority ?? 0,
|
|
213
|
+
topic: record.topic ?? queue,
|
|
214
|
+
error,
|
|
215
|
+
} as QueueJob;
|
|
216
|
+
|
|
217
|
+
if (attempts >= maxRetries) {
|
|
218
|
+
this.deadLetter(queue, job, error);
|
|
219
|
+
} else {
|
|
220
|
+
this.requeue(queue, job, 0, error);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
114
225
|
pop(queue: string, bridge: JobQueueBridge): QueueJob | null {
|
|
115
226
|
const dir = this.ensureDir(queue);
|
|
116
|
-
|
|
227
|
+
// First return any reservations whose consumer died mid-flight.
|
|
228
|
+
this.reclaimExpired(queue, bridge.getMaxRetries(), this.nowIso());
|
|
229
|
+
const now = this.nowIso();
|
|
117
230
|
|
|
118
231
|
for (const [filename, job] of this.availableCandidates(queue, now)) {
|
|
119
232
|
const filePath = join(dir, filename);
|
|
120
|
-
|
|
233
|
+
job.topic = queue;
|
|
234
|
+
job.priority = job.priority ?? 0;
|
|
235
|
+
// Write the reservation BEFORE claiming the pending file, so a crash
|
|
236
|
+
// between claim and reserve can never strand the job. Only the worker
|
|
237
|
+
// that wins the unlink owns — and returns — it.
|
|
238
|
+
this.writeReserved(queue, job);
|
|
121
239
|
try {
|
|
122
240
|
unlinkSync(filePath);
|
|
123
241
|
} catch {
|
|
124
|
-
|
|
242
|
+
// Already consumed by another worker — drop the speculative reservation.
|
|
243
|
+
try { unlinkSync(this.reservedPath(queue, job.id)); } catch { /* ignore */ }
|
|
244
|
+
continue;
|
|
125
245
|
}
|
|
126
246
|
|
|
127
247
|
job.status = "reserved";
|
|
128
|
-
job.topic = queue;
|
|
129
|
-
job.priority = job.priority ?? 0;
|
|
130
248
|
return createJob(job as any, bridge);
|
|
131
249
|
}
|
|
132
250
|
|
|
@@ -135,34 +253,61 @@ export class LiteBackend {
|
|
|
135
253
|
|
|
136
254
|
popBatch(queue: string, bridge: JobQueueBridge, count: number): QueueJob[] {
|
|
137
255
|
const dir = this.ensureDir(queue);
|
|
138
|
-
|
|
256
|
+
this.reclaimExpired(queue, bridge.getMaxRetries(), this.nowIso());
|
|
257
|
+
const now = this.nowIso();
|
|
139
258
|
const results: QueueJob[] = [];
|
|
140
259
|
|
|
141
260
|
for (const [filename, job] of this.availableCandidates(queue, now)) {
|
|
142
261
|
if (results.length >= count) break;
|
|
143
262
|
const filePath = join(dir, filename);
|
|
263
|
+
job.topic = queue;
|
|
264
|
+
job.priority = job.priority ?? 0;
|
|
265
|
+
this.writeReserved(queue, job);
|
|
144
266
|
try {
|
|
145
267
|
unlinkSync(filePath);
|
|
146
268
|
} catch {
|
|
269
|
+
try { unlinkSync(this.reservedPath(queue, job.id)); } catch { /* ignore */ }
|
|
147
270
|
continue; // Already consumed by another worker
|
|
148
271
|
}
|
|
149
272
|
|
|
150
273
|
job.status = "reserved";
|
|
151
|
-
job.topic = queue;
|
|
152
|
-
job.priority = job.priority ?? 0;
|
|
153
274
|
results.push(createJob(job as any, bridge));
|
|
154
275
|
}
|
|
155
276
|
|
|
156
277
|
return results;
|
|
157
278
|
}
|
|
158
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Delete a job's reservation record (best-effort).
|
|
282
|
+
*/
|
|
283
|
+
private clearReservation(queue: string, jobId: string): void {
|
|
284
|
+
try {
|
|
285
|
+
unlinkSync(this.reservedPath(queue, jobId));
|
|
286
|
+
} catch {
|
|
287
|
+
// no reservation record — nothing to clear
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Acknowledge a completed job — drop its reservation record so the visibility
|
|
293
|
+
* reclaim never re-delivers an already-acked job.
|
|
294
|
+
*/
|
|
295
|
+
completeJob(queue: string, job: QueueJob): void {
|
|
296
|
+
this.clearReservation(queue, job.id);
|
|
297
|
+
}
|
|
298
|
+
|
|
159
299
|
// Status aliases that live in the failed/ directory (dead-lettered jobs)
|
|
160
300
|
// rather than as pending files in the queue directory.
|
|
161
301
|
private static readonly DEAD_STATES = ["failed", "dead", "dead_letter"];
|
|
162
302
|
|
|
163
303
|
size(queue: string, status: string = "pending"): number {
|
|
164
304
|
const isDead = LiteBackend.DEAD_STATES.includes(status);
|
|
165
|
-
const
|
|
305
|
+
const isReserved = status === "reserved";
|
|
306
|
+
const scanDir = isDead
|
|
307
|
+
? this.ensureFailedDir(queue)
|
|
308
|
+
: isReserved
|
|
309
|
+
? this.ensureReservedDir(queue)
|
|
310
|
+
: this.ensureDir(queue);
|
|
166
311
|
|
|
167
312
|
let files: string[];
|
|
168
313
|
try {
|
|
@@ -171,9 +316,9 @@ export class LiteBackend {
|
|
|
171
316
|
return 0;
|
|
172
317
|
}
|
|
173
318
|
|
|
174
|
-
if (isDead) {
|
|
175
|
-
// Every file in failed/
|
|
176
|
-
// the exact stored status string.
|
|
319
|
+
if (isDead || isReserved) {
|
|
320
|
+
// Every file in failed/ (or reserved/) matches the requested status;
|
|
321
|
+
// count them all regardless of the exact stored status string.
|
|
177
322
|
return files.length;
|
|
178
323
|
}
|
|
179
324
|
|
|
@@ -215,6 +360,20 @@ export class LiteBackend {
|
|
|
215
360
|
} catch {
|
|
216
361
|
// ignore
|
|
217
362
|
}
|
|
363
|
+
|
|
364
|
+
// Also clear reservation records.
|
|
365
|
+
const reservedDir = join(dir, "reserved");
|
|
366
|
+
try {
|
|
367
|
+
if (existsSync(reservedDir)) {
|
|
368
|
+
const files = readdirSync(reservedDir).filter(f => f.endsWith(".queue-data"));
|
|
369
|
+
for (const file of files) {
|
|
370
|
+
unlinkSync(join(reservedDir, file));
|
|
371
|
+
count++;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
} catch {
|
|
375
|
+
// ignore
|
|
376
|
+
}
|
|
218
377
|
return count;
|
|
219
378
|
}
|
|
220
379
|
|
|
@@ -416,7 +575,18 @@ export class LiteBackend {
|
|
|
416
575
|
|
|
417
576
|
if (job.status !== "pending") continue;
|
|
418
577
|
if (job.id === id) {
|
|
419
|
-
|
|
578
|
+
job.topic = queue;
|
|
579
|
+
job.priority = job.priority ?? 0;
|
|
580
|
+
// Reserve (so a dead consumer's job is reclaimable) then claim the
|
|
581
|
+
// pending file — mirrors pop().
|
|
582
|
+
this.writeReserved(queue, job);
|
|
583
|
+
try {
|
|
584
|
+
unlinkSync(filePath);
|
|
585
|
+
} catch {
|
|
586
|
+
try { unlinkSync(this.reservedPath(queue, job.id)); } catch { /* ignore */ }
|
|
587
|
+
continue; // already consumed
|
|
588
|
+
}
|
|
589
|
+
job.status = "reserved";
|
|
420
590
|
return job;
|
|
421
591
|
}
|
|
422
592
|
}
|
|
@@ -479,6 +649,8 @@ export class LiteBackend {
|
|
|
479
649
|
* (attempts >= maxRetries) it is moved to the dead-letter store.
|
|
480
650
|
*/
|
|
481
651
|
failJob(queue: string, job: QueueJob, error: string, maxRetries: number, retryBackoff: number = 0): void {
|
|
652
|
+
// Clear the reservation — the consumer acknowledged (with a failure).
|
|
653
|
+
this.clearReservation(queue, job.id);
|
|
482
654
|
job.attempts = (job.attempts || 0) + 1;
|
|
483
655
|
job.error = error;
|
|
484
656
|
if (job.attempts < maxRetries) {
|
|
@@ -495,6 +667,8 @@ export class LiteBackend {
|
|
|
495
667
|
* distinct from the automatic failJob() path.
|
|
496
668
|
*/
|
|
497
669
|
retryJob(queue: string, job: QueueJob, delaySeconds?: number): void {
|
|
670
|
+
// Clear the reservation — the consumer acknowledged (with an explicit retry).
|
|
671
|
+
this.clearReservation(queue, job.id);
|
|
498
672
|
job.attempts = (job.attempts || 0) + 1;
|
|
499
673
|
job.error = undefined;
|
|
500
674
|
this.requeue(queue, job, delaySeconds ?? 0, undefined);
|
|
@@ -32,6 +32,15 @@ export interface MongoConfig {
|
|
|
32
32
|
password?: string;
|
|
33
33
|
database?: string;
|
|
34
34
|
collection?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Reservation/visibility timeout (seconds). A dequeued message is held
|
|
37
|
+
* reserved with availableAt = now + timeout; reclaim returns it once that
|
|
38
|
+
* passes (consumer died mid-flight, before complete()/fail()). <= 0 disables
|
|
39
|
+
* the reclaim. Falls back to TINA4_QUEUE_VISIBILITY_TIMEOUT, else 300.
|
|
40
|
+
*/
|
|
41
|
+
visibilityTimeout?: number;
|
|
42
|
+
/** Max attempts before the reclaim dead-letters a job instead of re-delivering. */
|
|
43
|
+
maxRetries?: number;
|
|
35
44
|
}
|
|
36
45
|
|
|
37
46
|
export interface QueueBackend {
|
|
@@ -58,6 +67,8 @@ export class MongoBackend implements QueueBackend {
|
|
|
58
67
|
private password: string;
|
|
59
68
|
private database: string;
|
|
60
69
|
private collection: string;
|
|
70
|
+
private visibilityTimeout: number;
|
|
71
|
+
private maxRetries: number;
|
|
61
72
|
|
|
62
73
|
constructor(config?: MongoConfig) {
|
|
63
74
|
this.host = config?.host ?? process.env.TINA4_MONGO_HOST ?? "localhost";
|
|
@@ -68,6 +79,16 @@ export class MongoBackend implements QueueBackend {
|
|
|
68
79
|
this.database = config?.database ?? process.env.TINA4_MONGO_DB ?? "tina4";
|
|
69
80
|
this.collection = config?.collection ?? process.env.TINA4_MONGO_COLLECTION ?? "tina4_queue";
|
|
70
81
|
|
|
82
|
+
// Reservation/visibility timeout (seconds): config wins, else env, else 300.
|
|
83
|
+
if (config?.visibilityTimeout !== undefined) {
|
|
84
|
+
this.visibilityTimeout = config.visibilityTimeout;
|
|
85
|
+
} else {
|
|
86
|
+
const raw = process.env.TINA4_QUEUE_VISIBILITY_TIMEOUT;
|
|
87
|
+
const parsed = raw === undefined || raw === "" ? 300 : Number(raw);
|
|
88
|
+
this.visibilityTimeout = Number.isFinite(parsed) ? parsed : 300;
|
|
89
|
+
}
|
|
90
|
+
this.maxRetries = config?.maxRetries ?? 3;
|
|
91
|
+
|
|
71
92
|
// Connection URI precedence: explicit config.uri > TINA4_MONGO_URI
|
|
72
93
|
// > TINA4_QUEUE_URL > a URI built from the host/port/auth field vars.
|
|
73
94
|
const explicitUri = config?.uri ?? process.env.TINA4_MONGO_URI ?? process.env.TINA4_QUEUE_URL;
|
|
@@ -84,15 +105,34 @@ export class MongoBackend implements QueueBackend {
|
|
|
84
105
|
/**
|
|
85
106
|
* Resolved connection config — exposed for testing/introspection.
|
|
86
107
|
*/
|
|
87
|
-
getConfig(): { uri: string; database: string; collection: string } {
|
|
88
|
-
return {
|
|
108
|
+
getConfig(): { uri: string; database: string; collection: string; visibilityTimeout: number } {
|
|
109
|
+
return {
|
|
110
|
+
uri: this.uri,
|
|
111
|
+
database: this.database,
|
|
112
|
+
collection: this.collection,
|
|
113
|
+
visibilityTimeout: this.visibilityTimeout,
|
|
114
|
+
};
|
|
89
115
|
}
|
|
90
116
|
|
|
91
117
|
/**
|
|
92
|
-
*
|
|
118
|
+
* Resolved reservation/visibility timeout (seconds). <= 0 disables the
|
|
119
|
+
* reclaim. Exposed for testing/introspection.
|
|
93
120
|
*/
|
|
94
|
-
|
|
95
|
-
|
|
121
|
+
getVisibilityTimeout(): number {
|
|
122
|
+
return this.visibilityTimeout;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Build the Node script that performs one MongoDB queue operation in a child
|
|
127
|
+
* process. Exposed (not private) so tests can assert the visibility-timeout
|
|
128
|
+
* behaviour without a live MongoDB — the script's pop branch advances
|
|
129
|
+
* availableAt = now + visibilityTimeout and stamps reservedAt (the core fix),
|
|
130
|
+
* and the reclaim branch flips an expired { status: reserved } back to
|
|
131
|
+
* pending with attempts incremented (dead-lettering past maxRetries),
|
|
132
|
+
* disabled when visibilityTimeout <= 0.
|
|
133
|
+
*/
|
|
134
|
+
buildScript(operation: string, queue: string, data?: string): string {
|
|
135
|
+
return `
|
|
96
136
|
async function main() {
|
|
97
137
|
let mongodb;
|
|
98
138
|
try {
|
|
@@ -109,6 +149,8 @@ export class MongoBackend implements QueueBackend {
|
|
|
109
149
|
const operation = ${JSON.stringify(operation)};
|
|
110
150
|
const queueName = ${JSON.stringify(queue)};
|
|
111
151
|
const data = ${JSON.stringify(data ?? "")};
|
|
152
|
+
const visibilityTimeout = ${JSON.stringify(this.visibilityTimeout)};
|
|
153
|
+
const maxRetries = ${JSON.stringify(this.maxRetries)};
|
|
112
154
|
|
|
113
155
|
const client = new MongoClient(uri, {
|
|
114
156
|
connectTimeoutMS: 5000,
|
|
@@ -121,7 +163,7 @@ export class MongoBackend implements QueueBackend {
|
|
|
121
163
|
const col = db.collection(collName);
|
|
122
164
|
|
|
123
165
|
// Ensure indexes on first use
|
|
124
|
-
await col.createIndex({ queue: 1, status: 1,
|
|
166
|
+
await col.createIndex({ queue: 1, status: 1, availableAt: 1 });
|
|
125
167
|
await col.createIndex({ queue: 1, createdAt: 1 });
|
|
126
168
|
|
|
127
169
|
if (operation === "push") {
|
|
@@ -129,19 +171,59 @@ export class MongoBackend implements QueueBackend {
|
|
|
129
171
|
await col.insertOne({ ...job, queue: queueName });
|
|
130
172
|
process.stdout.write("__PUSHED__");
|
|
131
173
|
}
|
|
174
|
+
else if (operation === "reclaim") {
|
|
175
|
+
// Return reservations whose visibility window expired (at-least-once
|
|
176
|
+
// delivery). A doc left { status: reserved, availableAt <= now } had
|
|
177
|
+
// its consumer die before acknowledging — flip it back to pending
|
|
178
|
+
// with attempts incremented, or dead-letter once attempts hit the
|
|
179
|
+
// limit. Disabled when visibilityTimeout <= 0.
|
|
180
|
+
let reclaimed = 0;
|
|
181
|
+
if (visibilityTimeout > 0) {
|
|
182
|
+
while (true) {
|
|
183
|
+
const now = new Date().toISOString();
|
|
184
|
+
const doc = await col.findOneAndUpdate(
|
|
185
|
+
{ queue: queueName, status: "reserved", availableAt: { $lte: now } },
|
|
186
|
+
{ $set: { status: "pending", availableAt: now, reservedAt: null }, $inc: { attempts: 1 } },
|
|
187
|
+
{ sort: { availableAt: 1 }, returnDocument: "after" },
|
|
188
|
+
);
|
|
189
|
+
const updated = doc && doc.value ? doc.value : (doc && doc._id ? doc : null);
|
|
190
|
+
if (!updated) break;
|
|
191
|
+
reclaimed++;
|
|
192
|
+
if ((updated.attempts || 0) >= maxRetries) {
|
|
193
|
+
await col.insertOne({
|
|
194
|
+
...updated,
|
|
195
|
+
_id: undefined,
|
|
196
|
+
status: "dead",
|
|
197
|
+
queue: queueName + ".dead_letter",
|
|
198
|
+
error: "reservation timed out — consumer did not acknowledge within the visibility timeout",
|
|
199
|
+
});
|
|
200
|
+
await col.deleteOne({ _id: updated._id, queue: queueName });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
process.stdout.write(String(reclaimed));
|
|
205
|
+
}
|
|
132
206
|
else if (operation === "pop") {
|
|
133
207
|
const now = new Date().toISOString();
|
|
208
|
+
// The claim advances availableAt = now + visibilityTimeout and
|
|
209
|
+
// stamps reservedAt so reclaim can return the job if the consumer
|
|
210
|
+
// dies before complete()/fail() — this is the fix for the "reserved
|
|
211
|
+
// forever" bug (previously availableAt was left unchanged).
|
|
212
|
+
const future = new Date(Date.now() + visibilityTimeout * 1000).toISOString();
|
|
134
213
|
const result = await col.findOneAndUpdate(
|
|
135
214
|
{
|
|
136
215
|
queue: queueName,
|
|
137
216
|
status: "pending",
|
|
138
217
|
$or: [
|
|
218
|
+
{ availableAt: null },
|
|
219
|
+
{ availableAt: { $exists: false } },
|
|
220
|
+
{ availableAt: { $lte: now } },
|
|
139
221
|
{ delayUntil: null },
|
|
140
222
|
{ delayUntil: { $lte: now } },
|
|
141
223
|
],
|
|
142
224
|
},
|
|
143
|
-
{ $set: { status: "reserved" } },
|
|
144
|
-
{ sort: { createdAt: 1 }, returnDocument: "before" },
|
|
225
|
+
{ $set: { status: "reserved", reservedAt: now, availableAt: future } },
|
|
226
|
+
{ sort: { priority: -1, createdAt: 1 }, returnDocument: "before" },
|
|
145
227
|
);
|
|
146
228
|
|
|
147
229
|
if (result && result.value) {
|
|
@@ -181,6 +263,13 @@ export class MongoBackend implements QueueBackend {
|
|
|
181
263
|
|
|
182
264
|
main();
|
|
183
265
|
`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Execute a MongoDB operation synchronously via a child process.
|
|
270
|
+
*/
|
|
271
|
+
private execSync(operation: string, queue: string, data?: string): string {
|
|
272
|
+
const script = this.buildScript(operation, queue, data);
|
|
184
273
|
|
|
185
274
|
try {
|
|
186
275
|
const result = execFileSync(process.execPath, ["-e", script], {
|
|
@@ -215,6 +304,11 @@ export class MongoBackend implements QueueBackend {
|
|
|
215
304
|
}
|
|
216
305
|
|
|
217
306
|
pop(queue: string): QueueJob | null {
|
|
307
|
+
// Reclaim any reservations whose consumer died before acking, then take the
|
|
308
|
+
// next available message (at-least-once delivery). Disabled at timeout <= 0.
|
|
309
|
+
if (this.visibilityTimeout > 0) {
|
|
310
|
+
this.execSync("reclaim", queue);
|
|
311
|
+
}
|
|
218
312
|
const result = this.execSync("pop", queue);
|
|
219
313
|
if (!result || result === "__EMPTY__") return null;
|
|
220
314
|
|
|
@@ -28,6 +28,12 @@ export interface RabbitMQConfig {
|
|
|
28
28
|
username?: string;
|
|
29
29
|
password?: string;
|
|
30
30
|
vhost?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Accepted for API parity with the file/MongoDB backends and IGNORED — the
|
|
33
|
+
* broker owns redelivery (unacked messages requeue on channel close), so the
|
|
34
|
+
* framework-level visibility timeout does not apply here.
|
|
35
|
+
*/
|
|
36
|
+
visibilityTimeout?: number;
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
/**
|
|
@@ -200,7 +206,7 @@ export class RabbitMQBackend implements QueueBackend {
|
|
|
200
206
|
/**
|
|
201
207
|
* Resolved connection config — exposed for testing/introspection.
|
|
202
208
|
*/
|
|
203
|
-
getConfig(): Required<RabbitMQConfig
|
|
209
|
+
getConfig(): Required<Omit<RabbitMQConfig, "visibilityTimeout">> {
|
|
204
210
|
return {
|
|
205
211
|
host: this.host,
|
|
206
212
|
port: this.port,
|
|
@@ -183,8 +183,13 @@ export function _checkLegacyEnvVars(): void {
|
|
|
183
183
|
}
|
|
184
184
|
lines.push(
|
|
185
185
|
"",
|
|
186
|
-
"
|
|
187
|
-
"
|
|
186
|
+
"Note: these may come from a .env file loaded by dotenv, not just",
|
|
187
|
+
"the runtime environment — check your image / build context (a .env",
|
|
188
|
+
"baked into a Docker image is loaded at startup) as well as k8s/CI env.",
|
|
189
|
+
"",
|
|
190
|
+
"FIX: run `tina4 env --migrate` to rewrite your .env automatically",
|
|
191
|
+
"(it renames every legacy name to its TINA4_ form in place).",
|
|
192
|
+
"Or rename manually. See https://tina4.com/release/3.12.0",
|
|
188
193
|
"Set TINA4_ALLOW_LEGACY_ENV=true to bypass during migration.",
|
|
189
194
|
bar,
|
|
190
195
|
"",
|
|
@@ -66,8 +66,10 @@ export class MongoSessionHandler implements SessionHandler {
|
|
|
66
66
|
?? (process.env.TINA4_SESSION_MONGO_PORT
|
|
67
67
|
? parseInt(process.env.TINA4_SESSION_MONGO_PORT, 10)
|
|
68
68
|
: 27017);
|
|
69
|
+
// Canonical TINA4_SESSION_MONGO_URI; TINA4_SESSION_MONGO_URL is a legacy alias.
|
|
69
70
|
this.uri = config?.uri
|
|
70
71
|
?? process.env.TINA4_SESSION_MONGO_URI
|
|
72
|
+
?? process.env.TINA4_SESSION_MONGO_URL
|
|
71
73
|
?? "";
|
|
72
74
|
this.username = config?.username
|
|
73
75
|
?? process.env.TINA4_SESSION_MONGO_USERNAME
|
|
@@ -134,6 +134,10 @@ export interface RouteMeta {
|
|
|
134
134
|
description?: string;
|
|
135
135
|
tags?: string[];
|
|
136
136
|
responses?: Record<string, { description: string }>;
|
|
137
|
+
/** Request-body example surfaced in the OpenAPI requestBody. */
|
|
138
|
+
example?: unknown;
|
|
139
|
+
/** Marks the operation deprecated in the spec. */
|
|
140
|
+
deprecated?: boolean;
|
|
137
141
|
}
|
|
138
142
|
|
|
139
143
|
export interface Tina4Config {
|