@nicnocquee/dataqueue 1.24.0 → 1.26.0

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.
Files changed (38) hide show
  1. package/README.md +44 -0
  2. package/migrations/1751131910825_add_timeout_seconds_to_job_queue.sql +2 -2
  3. package/migrations/1751186053000_add_job_events_table.sql +12 -8
  4. package/migrations/1751984773000_add_tags_to_job_queue.sql +1 -1
  5. package/migrations/1765809419000_add_force_kill_on_timeout_to_job_queue.sql +1 -1
  6. package/migrations/1771100000000_add_idempotency_key_to_job_queue.sql +7 -0
  7. package/migrations/1781200000000_add_wait_support.sql +12 -0
  8. package/migrations/1781200000001_create_waitpoints_table.sql +18 -0
  9. package/migrations/1781200000002_add_performance_indexes.sql +34 -0
  10. package/migrations/1781200000003_add_progress_to_job_queue.sql +7 -0
  11. package/package.json +20 -6
  12. package/src/backend.ts +163 -0
  13. package/src/backends/postgres.ts +1111 -0
  14. package/src/backends/redis-scripts.ts +533 -0
  15. package/src/backends/redis.test.ts +543 -0
  16. package/src/backends/redis.ts +834 -0
  17. package/src/db-util.ts +4 -2
  18. package/src/index.test.ts +6 -1
  19. package/src/index.ts +99 -36
  20. package/src/processor.test.ts +559 -18
  21. package/src/processor.ts +512 -44
  22. package/src/queue.test.ts +217 -6
  23. package/src/queue.ts +311 -902
  24. package/src/test-util.ts +32 -0
  25. package/src/types.ts +349 -16
  26. package/src/wait.test.ts +698 -0
  27. package/dist/cli.cjs +0 -88
  28. package/dist/cli.cjs.map +0 -1
  29. package/dist/cli.d.cts +0 -12
  30. package/dist/cli.d.ts +0 -12
  31. package/dist/cli.js +0 -81
  32. package/dist/cli.js.map +0 -1
  33. package/dist/index.cjs +0 -1420
  34. package/dist/index.cjs.map +0 -1
  35. package/dist/index.d.cts +0 -445
  36. package/dist/index.d.ts +0 -445
  37. package/dist/index.js +0 -1410
  38. package/dist/index.js.map +0 -1
package/src/queue.ts CHANGED
@@ -1,3 +1,12 @@
1
+ /**
2
+ * Backward-compatible re-exports.
3
+ * All SQL logic has moved to backends/postgres.ts (PostgresBackend class).
4
+ * These functions delegate to a temporary PostgresBackend instance so that
5
+ * any existing internal callers continue to work.
6
+ *
7
+ * Wait-related functions (waitJob, updateStepData, createWaitpoint, etc.)
8
+ * are PostgreSQL-only and use direct SQL queries.
9
+ */
1
10
  import { Pool } from 'pg';
2
11
  import {
3
12
  JobOptions,
@@ -6,146 +15,35 @@ import {
6
15
  JobEvent,
7
16
  JobEventType,
8
17
  TagQueryMode,
18
+ WaitpointRecord,
9
19
  } from './types.js';
20
+ import { PostgresBackend } from './backends/postgres.js';
21
+ import { randomUUID } from 'crypto';
10
22
  import { log } from './log-context.js';
11
23
 
12
- /**
13
- * Record a job event in the job_events table
14
- */
24
+ /* Thin wrappers — every function creates a lightweight backend wrapper
25
+ around the given pool and forwards the call. The class itself holds
26
+ no mutable state so this is safe and cheap. */
27
+
15
28
  export const recordJobEvent = async (
16
29
  pool: Pool,
17
30
  jobId: number,
18
31
  eventType: JobEventType,
19
32
  metadata?: any,
20
- ): Promise<void> => {
21
- const client = await pool.connect();
22
- try {
23
- await client.query(
24
- `INSERT INTO job_events (job_id, event_type, metadata) VALUES ($1, $2, $3)`,
25
- [jobId, eventType, metadata ? JSON.stringify(metadata) : null],
26
- );
27
- } catch (error) {
28
- log(`Error recording job event for job ${jobId}: ${error}`);
29
- // Do not throw, to avoid interfering with main job logic
30
- } finally {
31
- client.release();
32
- }
33
- };
33
+ ): Promise<void> =>
34
+ new PostgresBackend(pool).recordJobEvent(jobId, eventType, metadata);
34
35
 
35
- /**
36
- * Add a job to the queue
37
- */
38
36
  export const addJob = async <PayloadMap, T extends keyof PayloadMap & string>(
39
37
  pool: Pool,
40
- {
41
- jobType,
42
- payload,
43
- maxAttempts = 3,
44
- priority = 0,
45
- runAt = null,
46
- timeoutMs = undefined,
47
- forceKillOnTimeout = false,
48
- tags = undefined,
49
- }: JobOptions<PayloadMap, T>,
50
- ): Promise<number> => {
51
- const client = await pool.connect();
52
- try {
53
- let result;
54
- if (runAt) {
55
- result = await client.query(
56
- `INSERT INTO job_queue
57
- (job_type, payload, max_attempts, priority, run_at, timeout_ms, force_kill_on_timeout, tags)
58
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
59
- RETURNING id`,
60
- [
61
- jobType,
62
- payload,
63
- maxAttempts,
64
- priority,
65
- runAt,
66
- timeoutMs ?? null,
67
- forceKillOnTimeout ?? false,
68
- tags ?? null,
69
- ],
70
- );
71
- log(
72
- `Added job ${result.rows[0].id}: payload ${JSON.stringify(payload)}, runAt ${runAt.toISOString()}, priority ${priority}, maxAttempts ${maxAttempts} jobType ${jobType}, tags ${JSON.stringify(tags)}`,
73
- );
74
- } else {
75
- result = await client.query(
76
- `INSERT INTO job_queue
77
- (job_type, payload, max_attempts, priority, timeout_ms, force_kill_on_timeout, tags)
78
- VALUES ($1, $2, $3, $4, $5, $6, $7)
79
- RETURNING id`,
80
- [
81
- jobType,
82
- payload,
83
- maxAttempts,
84
- priority,
85
- timeoutMs ?? null,
86
- forceKillOnTimeout ?? false,
87
- tags ?? null,
88
- ],
89
- );
90
- log(
91
- `Added job ${result.rows[0].id}: payload ${JSON.stringify(payload)}, priority ${priority}, maxAttempts ${maxAttempts} jobType ${jobType}, tags ${JSON.stringify(tags)}`,
92
- );
93
- }
94
- await recordJobEvent(pool, result.rows[0].id, JobEventType.Added, {
95
- jobType,
96
- payload,
97
- tags,
98
- });
99
- return result.rows[0].id;
100
- } catch (error) {
101
- log(`Error adding job: ${error}`);
102
- throw error;
103
- } finally {
104
- client.release();
105
- }
106
- };
38
+ job: JobOptions<PayloadMap, T>,
39
+ ): Promise<number> => new PostgresBackend(pool).addJob(job);
107
40
 
108
- /**
109
- * Get a job by ID
110
- */
111
41
  export const getJob = async <PayloadMap, T extends keyof PayloadMap & string>(
112
42
  pool: Pool,
113
43
  id: number,
114
- ): Promise<JobRecord<PayloadMap, T> | null> => {
115
- const client = await pool.connect();
116
- try {
117
- const result = await client.query(
118
- `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", tags FROM job_queue WHERE id = $1`,
119
- [id],
120
- );
121
-
122
- if (result.rows.length === 0) {
123
- log(`Job ${id} not found`);
124
- return null;
125
- }
126
-
127
- log(`Found job ${id}`);
128
-
129
- const job = result.rows[0] as JobRecord<PayloadMap, T>;
130
-
131
- return {
132
- ...job,
133
- payload: job.payload,
134
- timeoutMs: job.timeoutMs,
135
- forceKillOnTimeout: job.forceKillOnTimeout,
136
- failureReason: job.failureReason,
137
- };
138
- } catch (error) {
139
- log(`Error getting job ${id}: ${error}`);
140
- throw error;
141
- } finally {
142
- client.release();
143
- }
144
- };
44
+ ): Promise<JobRecord<PayloadMap, T> | null> =>
45
+ new PostgresBackend(pool).getJob<PayloadMap, T>(id);
145
46
 
146
- /**
147
- * Get jobs by status
148
- */
149
47
  export const getJobsByStatus = async <
150
48
  PayloadMap,
151
49
  T extends keyof PayloadMap & string,
@@ -154,38 +52,13 @@ export const getJobsByStatus = async <
154
52
  status: string,
155
53
  limit = 100,
156
54
  offset = 0,
157
- ): Promise<JobRecord<PayloadMap, T>[]> => {
158
- const client = await pool.connect();
159
- try {
160
- const result = await client.query(
161
- `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason" FROM job_queue WHERE status = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`,
162
- [status, limit, offset],
163
- );
164
-
165
- log(`Found ${result.rows.length} jobs by status ${status}`);
55
+ ): Promise<JobRecord<PayloadMap, T>[]> =>
56
+ new PostgresBackend(pool).getJobsByStatus<PayloadMap, T>(
57
+ status,
58
+ limit,
59
+ offset,
60
+ );
166
61
 
167
- return result.rows.map((job) => ({
168
- ...job,
169
- payload: job.payload,
170
- timeoutMs: job.timeoutMs,
171
- forceKillOnTimeout: job.forceKillOnTimeout,
172
- failureReason: job.failureReason,
173
- }));
174
- } catch (error) {
175
- log(`Error getting jobs by status ${status}: ${error}`);
176
- throw error;
177
- } finally {
178
- client.release();
179
- }
180
- };
181
-
182
- /**
183
- * Get the next batch of jobs to process
184
- * @param pool - The database pool
185
- * @param workerId - The worker ID
186
- * @param batchSize - The batch size
187
- * @param jobType - Only fetch jobs with this job type (string or array of strings)
188
- */
189
62
  export const getNextBatch = async <
190
63
  PayloadMap,
191
64
  T extends keyof PayloadMap & string,
@@ -194,234 +67,38 @@ export const getNextBatch = async <
194
67
  workerId: string,
195
68
  batchSize = 10,
196
69
  jobType?: string | string[],
197
- ): Promise<JobRecord<PayloadMap, T>[]> => {
198
- const client = await pool.connect();
199
- try {
200
- // Begin transaction
201
- await client.query('BEGIN');
202
-
203
- // Build job type filter
204
- let jobTypeFilter = '';
205
- let params: any[] = [workerId, batchSize];
206
- if (jobType) {
207
- if (Array.isArray(jobType)) {
208
- jobTypeFilter = ` AND job_type = ANY($3)`;
209
- params.push(jobType);
210
- } else {
211
- jobTypeFilter = ` AND job_type = $3`;
212
- params.push(jobType);
213
- }
214
- }
215
-
216
- // Get and lock a batch of jobs
217
- const result = await client.query(
218
- `
219
- UPDATE job_queue
220
- SET status = 'processing',
221
- locked_at = NOW(),
222
- locked_by = $1,
223
- attempts = attempts + 1,
224
- updated_at = NOW(),
225
- pending_reason = NULL,
226
- started_at = COALESCE(started_at, NOW()),
227
- last_retried_at = CASE WHEN attempts > 0 THEN NOW() ELSE last_retried_at END
228
- WHERE id IN (
229
- SELECT id FROM job_queue
230
- WHERE (status = 'pending' OR (status = 'failed' AND next_attempt_at <= NOW()))
231
- AND (attempts < max_attempts)
232
- AND run_at <= NOW()
233
- ${jobTypeFilter}
234
- ORDER BY priority DESC, created_at ASC
235
- LIMIT $2
236
- FOR UPDATE SKIP LOCKED
237
- )
238
- RETURNING id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason"
239
- `,
240
- params,
241
- );
242
-
243
- log(`Found ${result.rows.length} jobs to process`);
244
-
245
- // Commit transaction
246
- await client.query('COMMIT');
70
+ ): Promise<JobRecord<PayloadMap, T>[]> =>
71
+ new PostgresBackend(pool).getNextBatch<PayloadMap, T>(
72
+ workerId,
73
+ batchSize,
74
+ jobType,
75
+ );
247
76
 
248
- // Record processing event for each job
249
- for (const row of result.rows) {
250
- await recordJobEvent(pool, row.id, JobEventType.Processing);
251
- }
77
+ export const completeJob = async (pool: Pool, jobId: number): Promise<void> =>
78
+ new PostgresBackend(pool).completeJob(jobId);
252
79
 
253
- return result.rows.map((job) => ({
254
- ...job,
255
- payload: job.payload,
256
- timeoutMs: job.timeoutMs,
257
- forceKillOnTimeout: job.forceKillOnTimeout,
258
- }));
259
- } catch (error) {
260
- log(`Error getting next batch: ${error}`);
261
- await client.query('ROLLBACK');
262
- throw error;
263
- } finally {
264
- client.release();
265
- }
266
- };
80
+ export const prolongJob = async (pool: Pool, jobId: number): Promise<void> =>
81
+ new PostgresBackend(pool).prolongJob(jobId);
267
82
 
268
- /**
269
- * Mark a job as completed
270
- */
271
- export const completeJob = async (pool: Pool, jobId: number): Promise<void> => {
272
- const client = await pool.connect();
273
- try {
274
- await client.query(
275
- `
276
- UPDATE job_queue
277
- SET status = 'completed', updated_at = NOW(), completed_at = NOW()
278
- WHERE id = $1
279
- `,
280
- [jobId],
281
- );
282
- await recordJobEvent(pool, jobId, JobEventType.Completed);
283
- } catch (error) {
284
- log(`Error completing job ${jobId}: ${error}`);
285
- throw error;
286
- } finally {
287
- log(`Completed job ${jobId}`);
288
- client.release();
289
- }
290
- };
291
-
292
- /**
293
- * Mark a job as failed
294
- */
295
83
  export const failJob = async (
296
84
  pool: Pool,
297
85
  jobId: number,
298
86
  error: Error,
299
87
  failureReason?: FailureReason,
300
- ): Promise<void> => {
301
- const client = await pool.connect();
302
- try {
303
- /**
304
- * The next attempt will be scheduled after `2^attempts * 1 minute` from the last attempt.
305
- */
306
- await client.query(
307
- `
308
- UPDATE job_queue
309
- SET status = 'failed',
310
- updated_at = NOW(),
311
- next_attempt_at = CASE
312
- WHEN attempts < max_attempts THEN NOW() + (POWER(2, attempts) * INTERVAL '1 minute')
313
- ELSE NULL
314
- END,
315
- error_history = COALESCE(error_history, '[]'::jsonb) || $2::jsonb,
316
- failure_reason = $3,
317
- last_failed_at = NOW()
318
- WHERE id = $1
319
- `,
320
- [
321
- jobId,
322
- JSON.stringify([
323
- {
324
- message: error.message || String(error),
325
- timestamp: new Date().toISOString(),
326
- },
327
- ]),
328
- failureReason ?? null,
329
- ],
330
- );
331
- await recordJobEvent(pool, jobId, JobEventType.Failed, {
332
- message: error.message || String(error),
333
- failureReason,
334
- });
335
- } catch (error) {
336
- log(`Error failing job ${jobId}: ${error}`);
337
- throw error;
338
- } finally {
339
- log(`Failed job ${jobId}`);
340
- client.release();
341
- }
342
- };
88
+ ): Promise<void> =>
89
+ new PostgresBackend(pool).failJob(jobId, error, failureReason);
343
90
 
344
- /**
345
- * Retry a failed job immediately
346
- */
347
- export const retryJob = async (pool: Pool, jobId: number): Promise<void> => {
348
- const client = await pool.connect();
349
- try {
350
- await client.query(
351
- `
352
- UPDATE job_queue
353
- SET status = 'pending',
354
- updated_at = NOW(),
355
- locked_at = NULL,
356
- locked_by = NULL,
357
- next_attempt_at = NOW(),
358
- last_retried_at = NOW()
359
- WHERE id = $1
360
- `,
361
- [jobId],
362
- );
363
- await recordJobEvent(pool, jobId, JobEventType.Retried);
364
- } catch (error) {
365
- log(`Error retrying job ${jobId}: ${error}`);
366
- throw error;
367
- } finally {
368
- log(`Retried job ${jobId}`);
369
- client.release();
370
- }
371
- };
91
+ export const retryJob = async (pool: Pool, jobId: number): Promise<void> =>
92
+ new PostgresBackend(pool).retryJob(jobId);
372
93
 
373
- /**
374
- * Delete old completed jobs
375
- */
376
94
  export const cleanupOldJobs = async (
377
95
  pool: Pool,
378
96
  daysToKeep = 30,
379
- ): Promise<number> => {
380
- const client = await pool.connect();
381
- try {
382
- const result = await client.query(`
383
- DELETE FROM job_queue
384
- WHERE status = 'completed'
385
- AND updated_at < NOW() - INTERVAL '${daysToKeep} days'
386
- RETURNING id
387
- `);
388
- log(`Deleted ${result.rowCount} old jobs`);
389
- return result.rowCount || 0;
390
- } catch (error) {
391
- log(`Error cleaning up old jobs: ${error}`);
392
- throw error;
393
- } finally {
394
- client.release();
395
- }
396
- };
97
+ ): Promise<number> => new PostgresBackend(pool).cleanupOldJobs(daysToKeep);
397
98
 
398
- /**
399
- * Cancel a scheduled job (only if still pending)
400
- */
401
- export const cancelJob = async (pool: Pool, jobId: number): Promise<void> => {
402
- const client = await pool.connect();
403
- try {
404
- await client.query(
405
- `
406
- UPDATE job_queue
407
- SET status = 'cancelled', updated_at = NOW(), last_cancelled_at = NOW()
408
- WHERE id = $1 AND status = 'pending'
409
- `,
410
- [jobId],
411
- );
412
- await recordJobEvent(pool, jobId, JobEventType.Cancelled);
413
- } catch (error) {
414
- log(`Error cancelling job ${jobId}: ${error}`);
415
- throw error;
416
- } finally {
417
- log(`Cancelled job ${jobId}`);
418
- client.release();
419
- }
420
- };
99
+ export const cancelJob = async (pool: Pool, jobId: number): Promise<void> =>
100
+ new PostgresBackend(pool).cancelJob(jobId);
421
101
 
422
- /**
423
- * Edit a pending job (only if still pending)
424
- */
425
102
  export const editJob = async <PayloadMap, T extends keyof PayloadMap & string>(
426
103
  pool: Pool,
427
104
  jobId: number,
@@ -433,87 +110,8 @@ export const editJob = async <PayloadMap, T extends keyof PayloadMap & string>(
433
110
  timeoutMs?: number | null;
434
111
  tags?: string[] | null;
435
112
  },
436
- ): Promise<void> => {
437
- const client = await pool.connect();
438
- try {
439
- const updateFields: string[] = [];
440
- const params: any[] = [];
441
- let paramIdx = 1;
442
-
443
- // Build dynamic UPDATE query based on provided fields
444
- if (updates.payload !== undefined) {
445
- updateFields.push(`payload = $${paramIdx++}`);
446
- params.push(updates.payload);
447
- }
448
- if (updates.maxAttempts !== undefined) {
449
- updateFields.push(`max_attempts = $${paramIdx++}`);
450
- params.push(updates.maxAttempts);
451
- }
452
- if (updates.priority !== undefined) {
453
- updateFields.push(`priority = $${paramIdx++}`);
454
- params.push(updates.priority);
455
- }
456
- if (updates.runAt !== undefined) {
457
- if (updates.runAt === null) {
458
- // null means run now (use current timestamp)
459
- updateFields.push(`run_at = NOW()`);
460
- } else {
461
- updateFields.push(`run_at = $${paramIdx++}`);
462
- params.push(updates.runAt);
463
- }
464
- }
465
- if (updates.timeoutMs !== undefined) {
466
- updateFields.push(`timeout_ms = $${paramIdx++}`);
467
- params.push(updates.timeoutMs ?? null);
468
- }
469
- if (updates.tags !== undefined) {
470
- updateFields.push(`tags = $${paramIdx++}`);
471
- params.push(updates.tags ?? null);
472
- }
473
-
474
- // If no fields to update, return early
475
- if (updateFields.length === 0) {
476
- log(`No fields to update for job ${jobId}`);
477
- return;
478
- }
479
-
480
- // Always update updated_at timestamp
481
- updateFields.push(`updated_at = NOW()`);
482
-
483
- // Add jobId as the last parameter for WHERE clause
484
- params.push(jobId);
485
-
486
- const query = `
487
- UPDATE job_queue
488
- SET ${updateFields.join(', ')}
489
- WHERE id = $${paramIdx} AND status = 'pending'
490
- `;
491
-
492
- await client.query(query, params);
493
-
494
- // Record edit event with metadata containing updated fields
495
- const metadata: any = {};
496
- if (updates.payload !== undefined) metadata.payload = updates.payload;
497
- if (updates.maxAttempts !== undefined)
498
- metadata.maxAttempts = updates.maxAttempts;
499
- if (updates.priority !== undefined) metadata.priority = updates.priority;
500
- if (updates.runAt !== undefined) metadata.runAt = updates.runAt;
501
- if (updates.timeoutMs !== undefined) metadata.timeoutMs = updates.timeoutMs;
502
- if (updates.tags !== undefined) metadata.tags = updates.tags;
503
-
504
- await recordJobEvent(pool, jobId, JobEventType.Edited, metadata);
505
- log(`Edited job ${jobId}: ${JSON.stringify(metadata)}`);
506
- } catch (error) {
507
- log(`Error editing job ${jobId}: ${error}`);
508
- throw error;
509
- } finally {
510
- client.release();
511
- }
512
- };
113
+ ): Promise<void> => new PostgresBackend(pool).editJob(jobId, updates);
513
114
 
514
- /**
515
- * Edit all pending jobs matching the filters
516
- */
517
115
  export const editAllPendingJobs = async <
518
116
  PayloadMap,
519
117
  T extends keyof PayloadMap & string,
@@ -528,7 +126,7 @@ export const editAllPendingJobs = async <
528
126
  | { gt?: Date; gte?: Date; lt?: Date; lte?: Date; eq?: Date };
529
127
  tags?: { values: string[]; mode?: TagQueryMode };
530
128
  }
531
- | undefined = undefined,
129
+ | undefined,
532
130
  updates: {
533
131
  payload?: PayloadMap[T];
534
132
  maxAttempts?: number;
@@ -537,160 +135,65 @@ export const editAllPendingJobs = async <
537
135
  timeoutMs?: number;
538
136
  tags?: string[];
539
137
  },
540
- ): Promise<number> => {
541
- const client = await pool.connect();
542
- try {
543
- // Build SET clause from updates
544
- const updateFields: string[] = [];
545
- const params: any[] = [];
546
- let paramIdx = 1;
138
+ ): Promise<number> =>
139
+ new PostgresBackend(pool).editAllPendingJobs(filters, updates);
547
140
 
548
- if (updates.payload !== undefined) {
549
- updateFields.push(`payload = $${paramIdx++}`);
550
- params.push(updates.payload);
551
- }
552
- if (updates.maxAttempts !== undefined) {
553
- updateFields.push(`max_attempts = $${paramIdx++}`);
554
- params.push(updates.maxAttempts);
555
- }
556
- if (updates.priority !== undefined) {
557
- updateFields.push(`priority = $${paramIdx++}`);
558
- params.push(updates.priority);
559
- }
560
- if (updates.runAt !== undefined) {
561
- if (updates.runAt === null) {
562
- // null means run now (use current timestamp)
563
- updateFields.push(`run_at = NOW()`);
564
- } else {
565
- updateFields.push(`run_at = $${paramIdx++}`);
566
- params.push(updates.runAt);
567
- }
568
- }
569
- if (updates.timeoutMs !== undefined) {
570
- updateFields.push(`timeout_ms = $${paramIdx++}`);
571
- params.push(updates.timeoutMs ?? null);
572
- }
573
- if (updates.tags !== undefined) {
574
- updateFields.push(`tags = $${paramIdx++}`);
575
- params.push(updates.tags ?? null);
576
- }
577
-
578
- // If no fields to update, return early
579
- if (updateFields.length === 0) {
580
- log(`No fields to update for batch edit`);
581
- return 0;
582
- }
583
-
584
- // Always update updated_at timestamp
585
- updateFields.push(`updated_at = NOW()`);
586
-
587
- // Build WHERE clause from filters
588
- let query = `
589
- UPDATE job_queue
590
- SET ${updateFields.join(', ')}
591
- WHERE status = 'pending'`;
141
+ export const cancelAllUpcomingJobs = async (
142
+ pool: Pool,
143
+ filters?: {
144
+ jobType?: string;
145
+ priority?: number;
146
+ runAt?: Date | { gt?: Date; gte?: Date; lt?: Date; lte?: Date; eq?: Date };
147
+ tags?: { values: string[]; mode?: TagQueryMode };
148
+ },
149
+ ): Promise<number> => new PostgresBackend(pool).cancelAllUpcomingJobs(filters);
592
150
 
593
- if (filters) {
594
- if (filters.jobType) {
595
- query += ` AND job_type = $${paramIdx++}`;
596
- params.push(filters.jobType);
597
- }
598
- if (filters.priority !== undefined) {
599
- query += ` AND priority = $${paramIdx++}`;
600
- params.push(filters.priority);
601
- }
602
- if (filters.runAt) {
603
- if (filters.runAt instanceof Date) {
604
- query += ` AND run_at = $${paramIdx++}`;
605
- params.push(filters.runAt);
606
- } else if (typeof filters.runAt === 'object') {
607
- const ops = filters.runAt;
608
- if (ops.gt) {
609
- query += ` AND run_at > $${paramIdx++}`;
610
- params.push(ops.gt);
611
- }
612
- if (ops.gte) {
613
- query += ` AND run_at >= $${paramIdx++}`;
614
- params.push(ops.gte);
615
- }
616
- if (ops.lt) {
617
- query += ` AND run_at < $${paramIdx++}`;
618
- params.push(ops.lt);
619
- }
620
- if (ops.lte) {
621
- query += ` AND run_at <= $${paramIdx++}`;
622
- params.push(ops.lte);
623
- }
624
- if (ops.eq) {
625
- query += ` AND run_at = $${paramIdx++}`;
626
- params.push(ops.eq);
627
- }
628
- }
629
- }
630
- if (
631
- filters.tags &&
632
- filters.tags.values &&
633
- filters.tags.values.length > 0
634
- ) {
635
- const mode = filters.tags.mode || 'all';
636
- const tagValues = filters.tags.values;
637
- switch (mode) {
638
- case 'exact':
639
- query += ` AND tags = $${paramIdx++}`;
640
- params.push(tagValues);
641
- break;
642
- case 'all':
643
- query += ` AND tags @> $${paramIdx++}`;
644
- params.push(tagValues);
645
- break;
646
- case 'any':
647
- query += ` AND tags && $${paramIdx++}`;
648
- params.push(tagValues);
649
- break;
650
- case 'none':
651
- query += ` AND NOT (tags && $${paramIdx++})`;
652
- params.push(tagValues);
653
- break;
654
- default:
655
- query += ` AND tags @> $${paramIdx++}`;
656
- params.push(tagValues);
657
- }
658
- }
659
- }
660
- query += '\nRETURNING id';
151
+ export const getAllJobs = async <
152
+ PayloadMap,
153
+ T extends keyof PayloadMap & string,
154
+ >(
155
+ pool: Pool,
156
+ limit = 100,
157
+ offset = 0,
158
+ ): Promise<JobRecord<PayloadMap, T>[]> =>
159
+ new PostgresBackend(pool).getAllJobs<PayloadMap, T>(limit, offset);
661
160
 
662
- const result = await client.query(query, params);
663
- const editedCount = result.rowCount || 0;
161
+ export const setPendingReasonForUnpickedJobs = async (
162
+ pool: Pool,
163
+ reason: string,
164
+ jobType?: string | string[],
165
+ ): Promise<void> =>
166
+ new PostgresBackend(pool).setPendingReasonForUnpickedJobs(reason, jobType);
664
167
 
665
- // Record edit event with metadata containing updated fields for each job
666
- const metadata: any = {};
667
- if (updates.payload !== undefined) metadata.payload = updates.payload;
668
- if (updates.maxAttempts !== undefined)
669
- metadata.maxAttempts = updates.maxAttempts;
670
- if (updates.priority !== undefined) metadata.priority = updates.priority;
671
- if (updates.runAt !== undefined) metadata.runAt = updates.runAt;
672
- if (updates.timeoutMs !== undefined) metadata.timeoutMs = updates.timeoutMs;
673
- if (updates.tags !== undefined) metadata.tags = updates.tags;
168
+ export const reclaimStuckJobs = async (
169
+ pool: Pool,
170
+ maxProcessingTimeMinutes = 10,
171
+ ): Promise<number> =>
172
+ new PostgresBackend(pool).reclaimStuckJobs(maxProcessingTimeMinutes);
674
173
 
675
- // Record events for each affected job
676
- for (const row of result.rows) {
677
- await recordJobEvent(pool, row.id, JobEventType.Edited, metadata);
678
- }
174
+ export const getJobEvents = async (
175
+ pool: Pool,
176
+ jobId: number,
177
+ ): Promise<JobEvent[]> => new PostgresBackend(pool).getJobEvents(jobId);
679
178
 
680
- log(`Edited ${editedCount} pending jobs: ${JSON.stringify(metadata)}`);
681
- return editedCount;
682
- } catch (error) {
683
- log(`Error editing pending jobs: ${error}`);
684
- throw error;
685
- } finally {
686
- client.release();
687
- }
688
- };
179
+ export const getJobsByTags = async <
180
+ PayloadMap,
181
+ T extends keyof PayloadMap & string,
182
+ >(
183
+ pool: Pool,
184
+ tags: string[],
185
+ mode: TagQueryMode = 'all',
186
+ limit = 100,
187
+ offset = 0,
188
+ ): Promise<JobRecord<PayloadMap, T>[]> =>
189
+ new PostgresBackend(pool).getJobsByTags<PayloadMap, T>(
190
+ tags,
191
+ mode,
192
+ limit,
193
+ offset,
194
+ );
689
195
 
690
- /**
691
- * Cancel all upcoming jobs (pending and scheduled in the future) with optional filters
692
- */
693
- export const cancelAllUpcomingJobs = async (
196
+ export const getJobs = async <PayloadMap, T extends keyof PayloadMap & string>(
694
197
  pool: Pool,
695
198
  filters?: {
696
199
  jobType?: string;
@@ -698,88 +201,68 @@ export const cancelAllUpcomingJobs = async (
698
201
  runAt?: Date | { gt?: Date; gte?: Date; lt?: Date; lte?: Date; eq?: Date };
699
202
  tags?: { values: string[]; mode?: TagQueryMode };
700
203
  },
701
- ): Promise<number> => {
204
+ limit = 100,
205
+ offset = 0,
206
+ ): Promise<JobRecord<PayloadMap, T>[]> =>
207
+ new PostgresBackend(pool).getJobs<PayloadMap, T>(filters, limit, offset);
208
+
209
+ // ── Progress ──────────────────────────────────────────────────────────────────
210
+
211
+ export const updateProgress = async (
212
+ pool: Pool,
213
+ jobId: number,
214
+ progress: number,
215
+ ): Promise<void> => new PostgresBackend(pool).updateProgress(jobId, progress);
216
+
217
+ // ── Wait support functions (PostgreSQL-only) ─────────────────────────────────
218
+
219
+ /**
220
+ * Transition a job to 'waiting' status with wait_until and/or wait_token_id.
221
+ * Saves step_data so the handler can resume from where it left off.
222
+ */
223
+ export const waitJob = async (
224
+ pool: Pool,
225
+ jobId: number,
226
+ options: {
227
+ waitUntil?: Date;
228
+ waitTokenId?: string;
229
+ stepData: Record<string, any>;
230
+ },
231
+ ): Promise<void> => {
702
232
  const client = await pool.connect();
703
233
  try {
704
- let query = `
234
+ const result = await client.query(
235
+ `
705
236
  UPDATE job_queue
706
- SET status = 'cancelled', updated_at = NOW()
707
- WHERE status = 'pending'`;
708
- const params: any[] = [];
709
- let paramIdx = 1;
710
- if (filters) {
711
- if (filters.jobType) {
712
- query += ` AND job_type = $${paramIdx++}`;
713
- params.push(filters.jobType);
714
- }
715
- if (filters.priority !== undefined) {
716
- query += ` AND priority = $${paramIdx++}`;
717
- params.push(filters.priority);
718
- }
719
- if (filters.runAt) {
720
- if (filters.runAt instanceof Date) {
721
- query += ` AND run_at = $${paramIdx++}`;
722
- params.push(filters.runAt);
723
- } else if (typeof filters.runAt === 'object') {
724
- const ops = filters.runAt;
725
- if (ops.gt) {
726
- query += ` AND run_at > $${paramIdx++}`;
727
- params.push(ops.gt);
728
- }
729
- if (ops.gte) {
730
- query += ` AND run_at >= $${paramIdx++}`;
731
- params.push(ops.gte);
732
- }
733
- if (ops.lt) {
734
- query += ` AND run_at < $${paramIdx++}`;
735
- params.push(ops.lt);
736
- }
737
- if (ops.lte) {
738
- query += ` AND run_at <= $${paramIdx++}`;
739
- params.push(ops.lte);
740
- }
741
- if (ops.eq) {
742
- query += ` AND run_at = $${paramIdx++}`;
743
- params.push(ops.eq);
744
- }
745
- }
746
- }
747
- if (
748
- filters.tags &&
749
- filters.tags.values &&
750
- filters.tags.values.length > 0
751
- ) {
752
- const mode = filters.tags.mode || 'all';
753
- const tagValues = filters.tags.values;
754
- switch (mode) {
755
- case 'exact':
756
- query += ` AND tags = $${paramIdx++}`;
757
- params.push(tagValues);
758
- break;
759
- case 'all':
760
- query += ` AND tags @> $${paramIdx++}`;
761
- params.push(tagValues);
762
- break;
763
- case 'any':
764
- query += ` AND tags && $${paramIdx++}`;
765
- params.push(tagValues);
766
- break;
767
- case 'none':
768
- query += ` AND NOT (tags && $${paramIdx++})`;
769
- params.push(tagValues);
770
- break;
771
- default:
772
- query += ` AND tags @> $${paramIdx++}`;
773
- params.push(tagValues);
774
- }
775
- }
237
+ SET status = 'waiting',
238
+ wait_until = $2,
239
+ wait_token_id = $3,
240
+ step_data = $4,
241
+ locked_at = NULL,
242
+ locked_by = NULL,
243
+ updated_at = NOW()
244
+ WHERE id = $1 AND status = 'processing'
245
+ `,
246
+ [
247
+ jobId,
248
+ options.waitUntil ?? null,
249
+ options.waitTokenId ?? null,
250
+ JSON.stringify(options.stepData),
251
+ ],
252
+ );
253
+ if (result.rowCount === 0) {
254
+ log(
255
+ `Job ${jobId} could not be set to waiting (may have been reclaimed or is no longer processing)`,
256
+ );
257
+ return;
776
258
  }
777
- query += '\nRETURNING id';
778
- const result = await client.query(query, params);
779
- log(`Cancelled ${result.rowCount} jobs`);
780
- return result.rowCount || 0;
259
+ await recordJobEvent(pool, jobId, JobEventType.Waiting, {
260
+ waitUntil: options.waitUntil?.toISOString() ?? null,
261
+ waitTokenId: options.waitTokenId ?? null,
262
+ });
263
+ log(`Job ${jobId} set to waiting`);
781
264
  } catch (error) {
782
- log(`Error cancelling upcoming jobs: ${error}`);
265
+ log(`Error setting job ${jobId} to waiting: ${error}`);
783
266
  throw error;
784
267
  } finally {
785
268
  client.release();
@@ -787,94 +270,104 @@ export const cancelAllUpcomingJobs = async (
787
270
  };
788
271
 
789
272
  /**
790
- * Get all jobs with optional pagination
273
+ * Update step_data for a job. Called after each ctx.run() step completes
274
+ * to persist intermediate progress.
791
275
  */
792
- export const getAllJobs = async <
793
- PayloadMap,
794
- T extends keyof PayloadMap & string,
795
- >(
276
+ export const updateStepData = async (
796
277
  pool: Pool,
797
- limit = 100,
798
- offset = 0,
799
- ): Promise<JobRecord<PayloadMap, T>[]> => {
278
+ jobId: number,
279
+ stepData: Record<string, any>,
280
+ ): Promise<void> => {
800
281
  const client = await pool.connect();
801
282
  try {
802
- const result = await client.query(
803
- `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason" FROM job_queue ORDER BY created_at DESC LIMIT $1 OFFSET $2`,
804
- [limit, offset],
283
+ await client.query(
284
+ `UPDATE job_queue SET step_data = $2, updated_at = NOW() WHERE id = $1`,
285
+ [jobId, JSON.stringify(stepData)],
805
286
  );
806
- log(`Found ${result.rows.length} jobs (all)`);
807
- return result.rows.map((job) => ({
808
- ...job,
809
- payload: job.payload,
810
- timeoutMs: job.timeoutMs,
811
- forceKillOnTimeout: job.forceKillOnTimeout,
812
- }));
813
287
  } catch (error) {
814
- log(`Error getting all jobs: ${error}`);
815
- throw error;
288
+ log(`Error updating step_data for job ${jobId}: ${error}`);
289
+ // Best-effort: do not throw to avoid killing the running handler
816
290
  } finally {
817
291
  client.release();
818
292
  }
819
293
  };
820
294
 
821
295
  /**
822
- * Set a pending reason for unpicked jobs
296
+ * Parse a timeout string like '10m', '1h', '24h', '7d' into milliseconds.
823
297
  */
824
- export const setPendingReasonForUnpickedJobs = async (
825
- pool: Pool,
826
- reason: string,
827
- jobType?: string | string[],
828
- ) => {
829
- const client = await pool.connect();
830
- try {
831
- let jobTypeFilter = '';
832
- let params: any[] = [reason];
833
- if (jobType) {
834
- if (Array.isArray(jobType)) {
835
- jobTypeFilter = ` AND job_type = ANY($2)`;
836
- params.push(jobType);
837
- } else {
838
- jobTypeFilter = ` AND job_type = $2`;
839
- params.push(jobType);
840
- }
841
- }
842
- await client.query(
843
- `UPDATE job_queue SET pending_reason = $1 WHERE status = 'pending'${jobTypeFilter}`,
844
- params,
298
+ /**
299
+ * Maximum allowed timeout in milliseconds (~365 days).
300
+ * Prevents overflow to Infinity when computing Date offsets.
301
+ */
302
+ const MAX_TIMEOUT_MS = 365 * 24 * 60 * 60 * 1000;
303
+
304
+ function parseTimeoutString(timeout: string): number {
305
+ const match = timeout.match(/^(\d+)(s|m|h|d)$/);
306
+ if (!match) {
307
+ throw new Error(
308
+ `Invalid timeout format: "${timeout}". Expected format like "10m", "1h", "24h", "7d".`,
845
309
  );
846
- } finally {
847
- client.release();
848
310
  }
849
- };
311
+ const value = parseInt(match[1], 10);
312
+ const unit = match[2];
313
+ let ms: number;
314
+ switch (unit) {
315
+ case 's':
316
+ ms = value * 1000;
317
+ break;
318
+ case 'm':
319
+ ms = value * 60 * 1000;
320
+ break;
321
+ case 'h':
322
+ ms = value * 60 * 60 * 1000;
323
+ break;
324
+ case 'd':
325
+ ms = value * 24 * 60 * 60 * 1000;
326
+ break;
327
+ default:
328
+ throw new Error(`Unknown timeout unit: "${unit}"`);
329
+ }
330
+ if (!Number.isFinite(ms) || ms > MAX_TIMEOUT_MS) {
331
+ throw new Error(
332
+ `Timeout value "${timeout}" is too large. Maximum allowed is 365 days.`,
333
+ );
334
+ }
335
+ return ms;
336
+ }
850
337
 
851
338
  /**
852
- * Reclaim jobs stuck in 'processing' for too long.
339
+ * Create a waitpoint token in the database.
340
+ * The token can be used to pause a job until an external signal completes it.
853
341
  *
854
- * If a process (e.g., API route or worker) crashes after marking a job as 'processing' but before completing it, the job can remain stuck in the 'processing' state indefinitely. This can happen if the process is killed or encounters an unhandled error after updating the job status but before marking it as 'completed' or 'failed'.
855
342
  * @param pool - The database pool
856
- * @param maxProcessingTimeMinutes - Max allowed processing time in minutes (default: 10)
857
- * @returns Number of jobs reclaimed
343
+ * @param jobId - The job ID to associate with the token (null if created outside a handler)
344
+ * @param options - Optional timeout and tags
345
+ * @returns The created waitpoint token
858
346
  */
859
- export const reclaimStuckJobs = async (
347
+ export const createWaitpoint = async (
860
348
  pool: Pool,
861
- maxProcessingTimeMinutes = 10,
862
- ): Promise<number> => {
349
+ jobId: number | null,
350
+ options?: { timeout?: string; tags?: string[] },
351
+ ): Promise<{ id: string }> => {
863
352
  const client = await pool.connect();
864
353
  try {
865
- const result = await client.query(
866
- `
867
- UPDATE job_queue
868
- SET status = 'pending', locked_at = NULL, locked_by = NULL, updated_at = NOW()
869
- WHERE status = 'processing'
870
- AND locked_at < NOW() - INTERVAL '${maxProcessingTimeMinutes} minutes'
871
- RETURNING id
872
- `,
354
+ const id = `wp_${randomUUID()}`;
355
+ let timeoutAt: Date | null = null;
356
+
357
+ if (options?.timeout) {
358
+ const ms = parseTimeoutString(options.timeout);
359
+ timeoutAt = new Date(Date.now() + ms);
360
+ }
361
+
362
+ await client.query(
363
+ `INSERT INTO waitpoints (id, job_id, status, timeout_at, tags) VALUES ($1, $2, 'waiting', $3, $4)`,
364
+ [id, jobId, timeoutAt, options?.tags ?? null],
873
365
  );
874
- log(`Reclaimed ${result.rowCount} stuck jobs`);
875
- return result.rowCount || 0;
366
+
367
+ log(`Created waitpoint ${id} for job ${jobId}`);
368
+ return { id };
876
369
  } catch (error) {
877
- log(`Error reclaiming stuck jobs: ${error}`);
370
+ log(`Error creating waitpoint: ${error}`);
878
371
  throw error;
879
372
  } finally {
880
373
  client.release();
@@ -882,201 +375,117 @@ export const reclaimStuckJobs = async (
882
375
  };
883
376
 
884
377
  /**
885
- * Get all events for a job, ordered by createdAt ascending
378
+ * Complete a waitpoint token, optionally providing output data.
379
+ * This also moves the associated job from 'waiting' back to 'pending' so
380
+ * it gets picked up by the polling loop.
886
381
  */
887
- export const getJobEvents = async (
382
+ export const completeWaitpoint = async (
888
383
  pool: Pool,
889
- jobId: number,
890
- ): Promise<JobEvent[]> => {
384
+ tokenId: string,
385
+ data?: any,
386
+ ): Promise<void> => {
891
387
  const client = await pool.connect();
892
388
  try {
893
- const res = await client.query(
894
- `SELECT id, job_id AS "jobId", event_type AS "eventType", metadata, created_at AS "createdAt" FROM job_events WHERE job_id = $1 ORDER BY created_at ASC`,
895
- [jobId],
389
+ await client.query('BEGIN');
390
+
391
+ // Update the waitpoint
392
+ const wpResult = await client.query(
393
+ `UPDATE waitpoints SET status = 'completed', output = $2, completed_at = NOW()
394
+ WHERE id = $1 AND status = 'waiting'
395
+ RETURNING job_id`,
396
+ [tokenId, data != null ? JSON.stringify(data) : null],
896
397
  );
897
- return res.rows as JobEvent[];
398
+
399
+ if (wpResult.rows.length === 0) {
400
+ await client.query('ROLLBACK');
401
+ log(`Waitpoint ${tokenId} not found or already completed`);
402
+ return;
403
+ }
404
+
405
+ const jobId = wpResult.rows[0].job_id;
406
+
407
+ // Move the associated job back to 'pending' so it gets picked up
408
+ if (jobId != null) {
409
+ await client.query(
410
+ `UPDATE job_queue
411
+ SET status = 'pending', wait_token_id = NULL, wait_until = NULL, updated_at = NOW()
412
+ WHERE id = $1 AND status = 'waiting'`,
413
+ [jobId],
414
+ );
415
+ }
416
+
417
+ await client.query('COMMIT');
418
+ log(`Completed waitpoint ${tokenId} for job ${jobId}`);
419
+ } catch (error) {
420
+ await client.query('ROLLBACK');
421
+ log(`Error completing waitpoint ${tokenId}: ${error}`);
422
+ throw error;
898
423
  } finally {
899
424
  client.release();
900
425
  }
901
426
  };
902
427
 
903
428
  /**
904
- * Get jobs by tags (matches all specified tags)
429
+ * Retrieve a waitpoint token by its ID.
905
430
  */
906
- export const getJobsByTags = async <
907
- PayloadMap,
908
- T extends keyof PayloadMap & string,
909
- >(
431
+ export const getWaitpoint = async (
910
432
  pool: Pool,
911
- tags: string[],
912
- mode: TagQueryMode = 'all',
913
- limit = 100,
914
- offset = 0,
915
- ): Promise<JobRecord<PayloadMap, T>[]> => {
433
+ tokenId: string,
434
+ ): Promise<WaitpointRecord | null> => {
916
435
  const client = await pool.connect();
917
436
  try {
918
- let query = `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", tags
919
- FROM job_queue`;
920
- let params: any[] = [];
921
- switch (mode) {
922
- case 'exact':
923
- query += ' WHERE tags = $1';
924
- params = [tags];
925
- break;
926
- case 'all':
927
- query += ' WHERE tags @> $1';
928
- params = [tags];
929
- break;
930
- case 'any':
931
- query += ' WHERE tags && $1';
932
- params = [tags];
933
- break;
934
- case 'none':
935
- query += ' WHERE NOT (tags && $1)';
936
- params = [tags];
937
- break;
938
- default:
939
- query += ' WHERE tags @> $1';
940
- params = [tags];
941
- }
942
- query += ' ORDER BY created_at DESC LIMIT $2 OFFSET $3';
943
- params.push(limit, offset);
944
- const result = await client.query(query, params);
945
- log(
946
- `Found ${result.rows.length} jobs by tags ${JSON.stringify(tags)} (mode: ${mode})`,
437
+ const result = await client.query(
438
+ `SELECT id, job_id AS "jobId", status, output, timeout_at AS "timeoutAt", created_at AS "createdAt", completed_at AS "completedAt", tags FROM waitpoints WHERE id = $1`,
439
+ [tokenId],
947
440
  );
948
- return result.rows.map((job) => ({
949
- ...job,
950
- payload: job.payload,
951
- timeoutMs: job.timeoutMs,
952
- forceKillOnTimeout: job.forceKillOnTimeout,
953
- failureReason: job.failureReason,
954
- }));
441
+ if (result.rows.length === 0) return null;
442
+ return result.rows[0] as WaitpointRecord;
955
443
  } catch (error) {
956
- log(
957
- `Error getting jobs by tags ${JSON.stringify(tags)} (mode: ${mode}): ${error}`,
958
- );
444
+ log(`Error getting waitpoint ${tokenId}: ${error}`);
959
445
  throw error;
960
446
  } finally {
961
447
  client.release();
962
448
  }
963
449
  };
964
450
 
965
- export const getJobs = async <PayloadMap, T extends keyof PayloadMap & string>(
966
- pool: Pool,
967
- filters?: {
968
- jobType?: string;
969
- priority?: number;
970
- runAt?: Date | { gt?: Date; gte?: Date; lt?: Date; lte?: Date; eq?: Date };
971
- tags?: { values: string[]; mode?: TagQueryMode };
972
- },
973
- limit = 100,
974
- offset = 0,
975
- ): Promise<JobRecord<PayloadMap, T>[]> => {
451
+ /**
452
+ * Expire timed-out waitpoint tokens and move their associated jobs back to 'pending'.
453
+ * Should be called periodically (e.g., alongside reclaimStuckJobs).
454
+ */
455
+ export const expireTimedOutWaitpoints = async (pool: Pool): Promise<number> => {
976
456
  const client = await pool.connect();
977
457
  try {
978
- let query = `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", tags FROM job_queue`;
979
- const params: any[] = [];
980
- let where: string[] = [];
981
- let paramIdx = 1;
982
- if (filters) {
983
- if (filters.jobType) {
984
- where.push(`job_type = $${paramIdx++}`);
985
- params.push(filters.jobType);
986
- }
987
- if (filters.priority !== undefined) {
988
- where.push(`priority = $${paramIdx++}`);
989
- params.push(filters.priority);
990
- }
991
- if (filters.runAt) {
992
- if (filters.runAt instanceof Date) {
993
- where.push(`run_at = $${paramIdx++}`);
994
- params.push(filters.runAt);
995
- } else if (
996
- typeof filters.runAt === 'object' &&
997
- (filters.runAt.gt !== undefined ||
998
- filters.runAt.gte !== undefined ||
999
- filters.runAt.lt !== undefined ||
1000
- filters.runAt.lte !== undefined ||
1001
- filters.runAt.eq !== undefined)
1002
- ) {
1003
- const ops = filters.runAt as {
1004
- gt?: Date;
1005
- gte?: Date;
1006
- lt?: Date;
1007
- lte?: Date;
1008
- eq?: Date;
1009
- };
1010
- if (ops.gt) {
1011
- where.push(`run_at > $${paramIdx++}`);
1012
- params.push(ops.gt);
1013
- }
1014
- if (ops.gte) {
1015
- where.push(`run_at >= $${paramIdx++}`);
1016
- params.push(ops.gte);
1017
- }
1018
- if (ops.lt) {
1019
- where.push(`run_at < $${paramIdx++}`);
1020
- params.push(ops.lt);
1021
- }
1022
- if (ops.lte) {
1023
- where.push(`run_at <= $${paramIdx++}`);
1024
- params.push(ops.lte);
1025
- }
1026
- if (ops.eq) {
1027
- where.push(`run_at = $${paramIdx++}`);
1028
- params.push(ops.eq);
1029
- }
1030
- }
1031
- }
1032
- if (
1033
- filters.tags &&
1034
- filters.tags.values &&
1035
- filters.tags.values.length > 0
1036
- ) {
1037
- const mode = filters.tags.mode || 'all';
1038
- const tagValues = filters.tags.values;
1039
- switch (mode) {
1040
- case 'exact':
1041
- where.push(`tags = $${paramIdx++}`);
1042
- params.push(tagValues);
1043
- break;
1044
- case 'all':
1045
- where.push(`tags @> $${paramIdx++}`);
1046
- params.push(tagValues);
1047
- break;
1048
- case 'any':
1049
- where.push(`tags && $${paramIdx++}`);
1050
- params.push(tagValues);
1051
- break;
1052
- case 'none':
1053
- where.push(`NOT (tags && $${paramIdx++})`);
1054
- params.push(tagValues);
1055
- break;
1056
- default:
1057
- where.push(`tags @> $${paramIdx++}`);
1058
- params.push(tagValues);
1059
- }
458
+ await client.query('BEGIN');
459
+
460
+ // Find and expire timed-out waitpoints
461
+ const result = await client.query(
462
+ `UPDATE waitpoints
463
+ SET status = 'timed_out'
464
+ WHERE status = 'waiting' AND timeout_at IS NOT NULL AND timeout_at <= NOW()
465
+ RETURNING id, job_id`,
466
+ );
467
+
468
+ // Move associated jobs back to 'pending'
469
+ for (const row of result.rows) {
470
+ if (row.job_id != null) {
471
+ await client.query(
472
+ `UPDATE job_queue
473
+ SET status = 'pending', wait_token_id = NULL, wait_until = NULL, updated_at = NOW()
474
+ WHERE id = $1 AND status = 'waiting'`,
475
+ [row.job_id],
476
+ );
1060
477
  }
1061
478
  }
1062
- if (where.length > 0) {
1063
- query += ` WHERE ${where.join(' AND ')}`;
479
+
480
+ await client.query('COMMIT');
481
+ const count = result.rowCount || 0;
482
+ if (count > 0) {
483
+ log(`Expired ${count} timed-out waitpoints`);
1064
484
  }
1065
- // Always add LIMIT and OFFSET as the last parameters
1066
- paramIdx = params.length + 1;
1067
- query += ` ORDER BY created_at DESC LIMIT $${paramIdx++} OFFSET $${paramIdx}`;
1068
- params.push(limit, offset);
1069
- const result = await client.query(query, params);
1070
- log(`Found ${result.rows.length} jobs`);
1071
- return result.rows.map((job) => ({
1072
- ...job,
1073
- payload: job.payload,
1074
- timeoutMs: job.timeoutMs,
1075
- forceKillOnTimeout: job.forceKillOnTimeout,
1076
- failureReason: job.failureReason,
1077
- }));
485
+ return count;
1078
486
  } catch (error) {
1079
- log(`Error getting jobs: ${error}`);
487
+ await client.query('ROLLBACK');
488
+ log(`Error expiring timed-out waitpoints: ${error}`);
1080
489
  throw error;
1081
490
  } finally {
1082
491
  client.release();