@nicnocquee/dataqueue 1.24.0 → 1.25.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.
- package/README.md +44 -0
- package/dist/index.cjs +2754 -972
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +440 -12
- package/dist/index.d.ts +440 -12
- package/dist/index.js +2752 -973
- package/dist/index.js.map +1 -1
- package/migrations/1751131910825_add_timeout_seconds_to_job_queue.sql +2 -2
- package/migrations/1751186053000_add_job_events_table.sql +12 -8
- package/migrations/1751984773000_add_tags_to_job_queue.sql +1 -1
- package/migrations/1765809419000_add_force_kill_on_timeout_to_job_queue.sql +1 -1
- package/migrations/1771100000000_add_idempotency_key_to_job_queue.sql +7 -0
- package/migrations/1781200000000_add_wait_support.sql +12 -0
- package/migrations/1781200000001_create_waitpoints_table.sql +18 -0
- package/migrations/1781200000002_add_performance_indexes.sql +34 -0
- package/migrations/1781200000003_add_progress_to_job_queue.sql +7 -0
- package/package.json +20 -6
- package/src/backend.ts +163 -0
- package/src/backends/postgres.ts +1111 -0
- package/src/backends/redis-scripts.ts +533 -0
- package/src/backends/redis.test.ts +543 -0
- package/src/backends/redis.ts +834 -0
- package/src/db-util.ts +4 -2
- package/src/index.test.ts +6 -1
- package/src/index.ts +99 -36
- package/src/processor.test.ts +559 -18
- package/src/processor.ts +512 -44
- package/src/queue.test.ts +217 -6
- package/src/queue.ts +311 -902
- package/src/test-util.ts +32 -0
- package/src/types.ts +349 -16
- package/src/wait.test.ts +698 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
|
|
663
|
-
|
|
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
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
174
|
+
export const getJobEvents = async (
|
|
175
|
+
pool: Pool,
|
|
176
|
+
jobId: number,
|
|
177
|
+
): Promise<JobEvent[]> => new PostgresBackend(pool).getJobEvents(jobId);
|
|
679
178
|
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
-
|
|
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
|
-
|
|
234
|
+
const result = await client.query(
|
|
235
|
+
`
|
|
705
236
|
UPDATE job_queue
|
|
706
|
-
SET status = '
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
|
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
|
-
*
|
|
273
|
+
* Update step_data for a job. Called after each ctx.run() step completes
|
|
274
|
+
* to persist intermediate progress.
|
|
791
275
|
*/
|
|
792
|
-
export const
|
|
793
|
-
PayloadMap,
|
|
794
|
-
T extends keyof PayloadMap & string,
|
|
795
|
-
>(
|
|
276
|
+
export const updateStepData = async (
|
|
796
277
|
pool: Pool,
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
): Promise<
|
|
278
|
+
jobId: number,
|
|
279
|
+
stepData: Record<string, any>,
|
|
280
|
+
): Promise<void> => {
|
|
800
281
|
const client = await pool.connect();
|
|
801
282
|
try {
|
|
802
|
-
|
|
803
|
-
`
|
|
804
|
-
[
|
|
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
|
|
815
|
-
throw
|
|
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
|
-
*
|
|
296
|
+
* Parse a timeout string like '10m', '1h', '24h', '7d' into milliseconds.
|
|
823
297
|
*/
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
-
*
|
|
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
|
|
857
|
-
* @
|
|
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
|
|
347
|
+
export const createWaitpoint = async (
|
|
860
348
|
pool: Pool,
|
|
861
|
-
|
|
862
|
-
|
|
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
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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
|
-
|
|
875
|
-
|
|
366
|
+
|
|
367
|
+
log(`Created waitpoint ${id} for job ${jobId}`);
|
|
368
|
+
return { id };
|
|
876
369
|
} catch (error) {
|
|
877
|
-
log(`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
|
-
*
|
|
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
|
|
382
|
+
export const completeWaitpoint = async (
|
|
888
383
|
pool: Pool,
|
|
889
|
-
|
|
890
|
-
|
|
384
|
+
tokenId: string,
|
|
385
|
+
data?: any,
|
|
386
|
+
): Promise<void> => {
|
|
891
387
|
const client = await pool.connect();
|
|
892
388
|
try {
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
429
|
+
* Retrieve a waitpoint token by its ID.
|
|
905
430
|
*/
|
|
906
|
-
export const
|
|
907
|
-
PayloadMap,
|
|
908
|
-
T extends keyof PayloadMap & string,
|
|
909
|
-
>(
|
|
431
|
+
export const getWaitpoint = async (
|
|
910
432
|
pool: Pool,
|
|
911
|
-
|
|
912
|
-
|
|
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
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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
|
-
|
|
949
|
-
|
|
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
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
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
|
-
|
|
1063
|
-
|
|
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
|
-
|
|
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
|
-
|
|
487
|
+
await client.query('ROLLBACK');
|
|
488
|
+
log(`Error expiring timed-out waitpoints: ${error}`);
|
|
1080
489
|
throw error;
|
|
1081
490
|
} finally {
|
|
1082
491
|
client.release();
|