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.
@@ -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,
@@ -183,8 +183,13 @@ export function _checkLegacyEnvVars(): void {
183
183
  }
184
184
  lines.push(
185
185
  "",
186
- "Run `tina4 env --migrate` to rewrite your .env automatically,",
187
- "or rename manually. See https://tina4.com/release/3.12.0",
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 {