tina4-nodejs 3.13.40 → 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 CHANGED
@@ -1,4 +1,4 @@
1
- # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.40)
1
+ # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.41)
2
2
 
3
3
  > This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
4
4
 
@@ -1209,7 +1209,7 @@ When adding new features, add a corresponding `test/<feature>.test.ts` file.
1209
1209
  - **Database**: 5 engines (SQLite, PostgreSQL, MySQL, MSSQL, Firebird), DB query caching — request-scoped auto cache **off by default — opt-in via `TINA4_AUTO_CACHING=true`** (TTL `TINA4_AUTO_CACHING_TTL=5`s) which dedupes identical `db.fetch()`/ORM reads within a request and flushes on writes (always in-process); default OFF because a request-scoped cache defaulting on is a read-after-write footgun (cached pre-write `SELECT MAX(id)` → duplicate PKs); persistent cross-request cache opt-in via `TINA4_DB_CACHE=true` (TTL `TINA4_DB_CACHE_TTL=30`s) routed through the unified async backend set via `TINA4_DB_CACHE_BACKEND` (memory/file/redis/valkey/memcached/mongodb/database) + `TINA4_DB_CACHE_URL`, so instances share one cache with global write-invalidation (full parity with Python/PHP/Ruby). `db.cacheStats()` reports `mode` (request/persistent/off) + `backend`
1210
1210
  - **Cache**: unified backend set — `memory` (default), `file`, `redis`, `valkey`, `memcached`, `mongodb`, `database` — via `TINA4_CACHE_BACKEND` (+ `TINA4_CACHE_URL`/credentials); file-backend fallback if a backend is unreachable. The KV API, the `responseCache` middleware, and the persistent DB query cache all route through this async backend set (native async clients, no child processes) — a network backend distributes them cross-instance, full parity with Python/PHP/Ruby; `memory` (default) keeps them in-process. `await` the async API (`cacheGet`/`cacheSet`/`clearCache`)
1211
1211
  - **Sessions**: file backend (default). `TINA4_SESSION_SAMESITE` env var (default: Lax)
1212
- - **Queue**: file/RabbitMQ/Kafka/MongoDB backends, configured via env vars
1212
+ - **Queue**: file/RabbitMQ/Kafka/MongoDB backends, configured via env vars. **Reservation/visibility timeout** (file + MongoDB): a popped job is reserved for `TINA4_QUEUE_VISIBILITY_TIMEOUT` seconds (default 300; `visibilityTimeout` Queue option; `<= 0` disables) — if the consumer dies before `complete()`/`fail()`, the next `pop()` reclaims it (incrementing `attempts`, dead-lettering past `maxRetries`), so a crashed/evicted consumer never strands a job. RabbitMQ/Kafka delegate redelivery to the broker.
1213
1213
  - **Cache**: memory/Redis/file backends
1214
1214
  - **Messenger**: .env driven SMTP/IMAP
1215
1215
  - **ORM relationships**: `hasMany`, `hasOne`, `belongsTo` with eager loading (`include`)
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
 
5
5
 
6
- "version": "3.13.40",
6
+ "version": "3.13.41",
7
7
 
8
8
  "type": "module",
9
9
  "description": "Tina4 for Node.js/TypeScript \u2014 54 built-in features, zero dependencies",
@@ -40,6 +40,7 @@ export type QueueJob = JobData & JobLifecycle;
40
40
  export interface JobQueueBridge {
41
41
  _failJob(topic: string, job: QueueJob, reason: string, maxRetries: number): void;
42
42
  _retryJob(topic: string, job: QueueJob, delaySeconds?: number): void;
43
+ _completeJob(topic: string, job: QueueJob): void;
43
44
  getMaxRetries(): number;
44
45
  }
45
46
 
@@ -48,8 +49,11 @@ export function createJob(data: JobData, queue: JobQueueBridge): QueueJob {
48
49
  const job: QueueJob = {
49
50
  ...data,
50
51
  complete() {
51
- // Terminal — the job was already removed from the queue on pop().
52
+ // Terminal — the pending file was claimed on pop and a reservation record
53
+ // written; complete() drops the reservation so a dead-consumer reclaim
54
+ // never re-delivers an already-acked job. The job is done.
52
55
  job.status = "completed";
56
+ queue._completeJob(job.topic, job);
53
57
  },
54
58
  fail(reason = "") {
55
59
  // Record a failed attempt. `attempts` is incremented exactly once, inside
@@ -51,6 +51,29 @@ export interface QueueConfig {
51
51
  * straight away. Parity with Python's retry_backoff.
52
52
  */
53
53
  retryBackoff?: number;
54
+ /**
55
+ * Reservation/visibility timeout (seconds). A popped job is reserved for this
56
+ * long; if the consumer dies before complete()/fail() (crash, OOM, k8s
57
+ * eviction) the next pop() reclaims it — incrementing attempts and
58
+ * re-enqueuing, or dead-lettering past maxRetries (at-least-once delivery).
59
+ * Falls back to TINA4_QUEUE_VISIBILITY_TIMEOUT, else 300 (5 min). <= 0
60
+ * disables the reclaim (a reservation then lasts until the consumer acks —
61
+ * the old at-most-once behaviour). File + MongoDB backends only;
62
+ * RabbitMQ/Kafka delegate visibility to the broker. Parity with Python's
63
+ * visibility_timeout.
64
+ */
65
+ visibilityTimeout?: number;
66
+ }
67
+
68
+ /**
69
+ * Reservation/visibility timeout in seconds, from env (default 300 = 5 min).
70
+ * Mirrors Python's _default_visibility_timeout().
71
+ */
72
+ function defaultVisibilityTimeout(): number {
73
+ const raw = process.env.TINA4_QUEUE_VISIBILITY_TIMEOUT;
74
+ if (raw === undefined || raw === "") return 300;
75
+ const parsed = Number(raw);
76
+ return Number.isFinite(parsed) ? parsed : 300;
54
77
  }
55
78
 
56
79
  export interface ProcessOptions {
@@ -82,6 +105,7 @@ export class Queue {
82
105
  private topic: string;
83
106
  private _maxRetries: number;
84
107
  private _retryBackoff: number;
108
+ private _visibilityTimeout: number;
85
109
  private externalBackend: QueueBackendInterface | null = null;
86
110
  private liteBackend!: LiteBackend;
87
111
 
@@ -112,15 +136,19 @@ export class Queue {
112
136
  this.topic = resolvedConfig.topic ?? "default";
113
137
  this._maxRetries = resolvedConfig.maxRetries ?? 3;
114
138
  this._retryBackoff = resolvedConfig.retryBackoff ?? 0;
115
- this.liteBackend = new LiteBackend(this.basePath);
139
+ this._visibilityTimeout = resolvedConfig.visibilityTimeout ?? defaultVisibilityTimeout();
140
+ this.liteBackend = new LiteBackend(this.basePath, this._visibilityTimeout);
116
141
 
117
142
  // Initialize external backends
118
143
  if (this.backendName === "rabbitmq") {
119
- this.externalBackend = new RabbitMQBackend();
144
+ // Broker manages visibility/redelivery (unacked messages requeue on
145
+ // channel close) — the framework timeout is accepted but not used.
146
+ this.externalBackend = new RabbitMQBackend({ visibilityTimeout: this._visibilityTimeout });
120
147
  } else if (this.backendName === "kafka") {
121
- this.externalBackend = new KafkaBackend();
148
+ // Consumer-group offsets manage redelivery — framework timeout N/A.
149
+ this.externalBackend = new KafkaBackend({ visibilityTimeout: this._visibilityTimeout });
122
150
  } else if (this.backendName === "mongodb" || this.backendName === "mongo") {
123
- this.externalBackend = new MongoBackend();
151
+ this.externalBackend = new MongoBackend({ visibilityTimeout: this._visibilityTimeout });
124
152
  }
125
153
  }
126
154
 
@@ -410,6 +438,15 @@ export class Queue {
410
438
  return this._retryBackoff;
411
439
  }
412
440
 
441
+ /**
442
+ * Resolved reservation/visibility timeout (seconds). <= 0 means the reclaim
443
+ * is disabled. File + MongoDB backends honour it; RabbitMQ/Kafka delegate to
444
+ * the broker.
445
+ */
446
+ getVisibilityTimeout(): number {
447
+ return this._visibilityTimeout;
448
+ }
449
+
413
450
  /**
414
451
  * Record a failed attempt for a job. The backend increments `attempts`
415
452
  * exactly once and decides whether to re-enqueue (attempts < maxRetries,
@@ -425,4 +462,13 @@ export class Queue {
425
462
  _retryJob(queue: string, job: QueueJob, delaySeconds?: number): void {
426
463
  this.liteBackend.retryJob(queue, job, delaySeconds);
427
464
  }
465
+
466
+ /**
467
+ * Acknowledge a completed job — drop its reservation record so the visibility
468
+ * reclaim never re-delivers it. No-op for external backends (they ack their
469
+ * own way; lite-backend reservations are file records).
470
+ */
471
+ _completeJob(queue: string, job: QueueJob): void {
472
+ this.liteBackend.completeJob(queue, job);
473
+ }
428
474
  }
@@ -27,6 +27,12 @@ import type { QueueJob } from "../queue.js";
27
27
  export interface KafkaConfig {
28
28
  brokers?: string;
29
29
  groupId?: string;
30
+ /**
31
+ * Accepted for API parity with the file/MongoDB backends and IGNORED —
32
+ * consumer-group offsets own redelivery, so the framework-level visibility
33
+ * timeout does not apply here.
34
+ */
35
+ visibilityTimeout?: number;
30
36
  }
31
37
 
32
38
  /**
@@ -134,7 +140,7 @@ export class KafkaBackend implements QueueBackend {
134
140
  /**
135
141
  * Resolved connection config — exposed for testing/introspection.
136
142
  */
137
- getConfig(): Required<KafkaConfig> {
143
+ getConfig(): Required<Omit<KafkaConfig, "visibilityTimeout">> {
138
144
  return { brokers: this.brokers, groupId: this.groupId };
139
145
  }
140
146
 
@@ -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
- const now = new Date().toISOString();
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
- // Claim the job by deleting the file.
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
- continue; // Already consumed by another worker
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
- const now = new Date().toISOString();
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 scanDir = isDead ? this.ensureFailedDir(queue) : this.ensureDir(queue);
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/ is a dead-letter; count them all regardless of
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
- try { unlinkSync(filePath); } catch { /* already consumed */ }
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 { uri: this.uri, database: this.database, collection: this.collection };
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
- * Execute a MongoDB operation synchronously via a child process.
118
+ * Resolved reservation/visibility timeout (seconds). <= 0 disables the
119
+ * reclaim. Exposed for testing/introspection.
93
120
  */
94
- private execSync(operation: string, queue: string, data?: string): string {
95
- const script = `
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, delayUntil: 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,