@nicnocquee/dataqueue 1.34.0 → 1.35.0-beta.20260224075710
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/ai/docs-content.json +23 -11
- package/ai/rules/advanced.md +77 -1
- package/ai/rules/basic.md +72 -3
- package/ai/rules/react-dashboard.md +5 -1
- package/ai/skills/dataqueue-advanced/SKILL.md +159 -0
- package/ai/skills/dataqueue-core/SKILL.md +107 -3
- package/ai/skills/dataqueue-react/SKILL.md +19 -7
- package/dist/index.cjs +937 -108
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +358 -11
- package/dist/index.d.ts +358 -11
- package/dist/index.js +937 -108
- package/dist/index.js.map +1 -1
- package/migrations/1781200000005_add_retry_config_to_job_queue.sql +17 -0
- package/migrations/1781200000006_add_output_to_job_queue.sql +3 -0
- package/package.json +1 -1
- package/src/backend.ts +36 -3
- package/src/backends/postgres.ts +344 -42
- package/src/backends/redis-scripts.ts +173 -8
- package/src/backends/redis.test.ts +668 -0
- package/src/backends/redis.ts +244 -15
- package/src/db-util.ts +1 -1
- package/src/index.test.ts +811 -12
- package/src/index.ts +106 -14
- package/src/processor.ts +133 -49
- package/src/queue.test.ts +477 -0
- package/src/queue.ts +20 -3
- package/src/supervisor.test.ts +340 -0
- package/src/supervisor.ts +177 -0
- package/src/types.ts +318 -3
package/src/backends/postgres.ts
CHANGED
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
EditCronScheduleOptions,
|
|
13
13
|
WaitpointRecord,
|
|
14
14
|
CreateTokenOptions,
|
|
15
|
+
AddJobOptions,
|
|
16
|
+
DatabaseClient,
|
|
15
17
|
} from '../types.js';
|
|
16
18
|
import { randomUUID } from 'crypto';
|
|
17
19
|
import {
|
|
@@ -103,18 +105,34 @@ export class PostgresBackend implements QueueBackend {
|
|
|
103
105
|
|
|
104
106
|
// ── Job CRUD ──────────────────────────────────────────────────────────
|
|
105
107
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Add a job and return its numeric ID.
|
|
110
|
+
*
|
|
111
|
+
* @param job - Job configuration.
|
|
112
|
+
* @param options - Optional. Pass `{ db }` to run the INSERT on an external
|
|
113
|
+
* client (e.g., inside a transaction) so the job is part of the caller's
|
|
114
|
+
* transaction. The event INSERT also uses the same client.
|
|
115
|
+
*/
|
|
116
|
+
async addJob<PayloadMap, T extends JobType<PayloadMap>>(
|
|
117
|
+
{
|
|
118
|
+
jobType,
|
|
119
|
+
payload,
|
|
120
|
+
maxAttempts = 3,
|
|
121
|
+
priority = 0,
|
|
122
|
+
runAt = null,
|
|
123
|
+
timeoutMs = undefined,
|
|
124
|
+
forceKillOnTimeout = false,
|
|
125
|
+
tags = undefined,
|
|
126
|
+
idempotencyKey = undefined,
|
|
127
|
+
retryDelay = undefined,
|
|
128
|
+
retryBackoff = undefined,
|
|
129
|
+
retryDelayMax = undefined,
|
|
130
|
+
}: JobOptions<PayloadMap, T>,
|
|
131
|
+
options?: AddJobOptions,
|
|
132
|
+
): Promise<number> {
|
|
133
|
+
const externalClient = options?.db;
|
|
134
|
+
const client: DatabaseClient =
|
|
135
|
+
externalClient ?? (await this.pool.connect());
|
|
118
136
|
try {
|
|
119
137
|
let result;
|
|
120
138
|
const onConflict = idempotencyKey
|
|
@@ -124,8 +142,8 @@ export class PostgresBackend implements QueueBackend {
|
|
|
124
142
|
if (runAt) {
|
|
125
143
|
result = await client.query(
|
|
126
144
|
`INSERT INTO job_queue
|
|
127
|
-
(job_type, payload, max_attempts, priority, run_at, timeout_ms, force_kill_on_timeout, tags, idempotency_key)
|
|
128
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
145
|
+
(job_type, payload, max_attempts, priority, run_at, timeout_ms, force_kill_on_timeout, tags, idempotency_key, retry_delay, retry_backoff, retry_delay_max)
|
|
146
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
|
129
147
|
${onConflict}
|
|
130
148
|
RETURNING id`,
|
|
131
149
|
[
|
|
@@ -138,13 +156,16 @@ export class PostgresBackend implements QueueBackend {
|
|
|
138
156
|
forceKillOnTimeout ?? false,
|
|
139
157
|
tags ?? null,
|
|
140
158
|
idempotencyKey ?? null,
|
|
159
|
+
retryDelay ?? null,
|
|
160
|
+
retryBackoff ?? null,
|
|
161
|
+
retryDelayMax ?? null,
|
|
141
162
|
],
|
|
142
163
|
);
|
|
143
164
|
} else {
|
|
144
165
|
result = await client.query(
|
|
145
166
|
`INSERT INTO job_queue
|
|
146
|
-
(job_type, payload, max_attempts, priority, timeout_ms, force_kill_on_timeout, tags, idempotency_key)
|
|
147
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
167
|
+
(job_type, payload, max_attempts, priority, timeout_ms, force_kill_on_timeout, tags, idempotency_key, retry_delay, retry_backoff, retry_delay_max)
|
|
168
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
|
148
169
|
${onConflict}
|
|
149
170
|
RETURNING id`,
|
|
150
171
|
[
|
|
@@ -156,11 +177,13 @@ export class PostgresBackend implements QueueBackend {
|
|
|
156
177
|
forceKillOnTimeout ?? false,
|
|
157
178
|
tags ?? null,
|
|
158
179
|
idempotencyKey ?? null,
|
|
180
|
+
retryDelay ?? null,
|
|
181
|
+
retryBackoff ?? null,
|
|
182
|
+
retryDelayMax ?? null,
|
|
159
183
|
],
|
|
160
184
|
);
|
|
161
185
|
}
|
|
162
186
|
|
|
163
|
-
// If ON CONFLICT DO NOTHING was triggered, no rows are returned.
|
|
164
187
|
if (result.rows.length === 0 && idempotencyKey) {
|
|
165
188
|
const existing = await client.query(
|
|
166
189
|
`SELECT id FROM job_queue WHERE idempotency_key = $1`,
|
|
@@ -181,18 +204,217 @@ export class PostgresBackend implements QueueBackend {
|
|
|
181
204
|
log(
|
|
182
205
|
`Added job ${jobId}: payload ${JSON.stringify(payload)}, ${runAt ? `runAt ${runAt.toISOString()}, ` : ''}priority ${priority}, maxAttempts ${maxAttempts}, jobType ${jobType}, tags ${JSON.stringify(tags)}${idempotencyKey ? `, idempotencyKey "${idempotencyKey}"` : ''}`,
|
|
183
206
|
);
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
207
|
+
|
|
208
|
+
if (externalClient) {
|
|
209
|
+
try {
|
|
210
|
+
await client.query(
|
|
211
|
+
`INSERT INTO job_events (job_id, event_type, metadata) VALUES ($1, $2, $3)`,
|
|
212
|
+
[
|
|
213
|
+
jobId,
|
|
214
|
+
JobEventType.Added,
|
|
215
|
+
JSON.stringify({ jobType, payload, tags, idempotencyKey }),
|
|
216
|
+
],
|
|
217
|
+
);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
log(`Error recording job event for job ${jobId}: ${error}`);
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
await this.recordJobEvent(jobId, JobEventType.Added, {
|
|
223
|
+
jobType,
|
|
224
|
+
payload,
|
|
225
|
+
tags,
|
|
226
|
+
idempotencyKey,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
190
229
|
return jobId;
|
|
191
230
|
} catch (error) {
|
|
192
231
|
log(`Error adding job: ${error}`);
|
|
193
232
|
throw error;
|
|
194
233
|
} finally {
|
|
195
|
-
client.release();
|
|
234
|
+
if (!externalClient) (client as any).release();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Insert multiple jobs in a single database round-trip.
|
|
240
|
+
*
|
|
241
|
+
* Uses a multi-row INSERT with ON CONFLICT handling for idempotency keys.
|
|
242
|
+
* Returns IDs in the same order as the input array.
|
|
243
|
+
*/
|
|
244
|
+
async addJobs<PayloadMap, T extends JobType<PayloadMap>>(
|
|
245
|
+
jobs: JobOptions<PayloadMap, T>[],
|
|
246
|
+
options?: AddJobOptions,
|
|
247
|
+
): Promise<number[]> {
|
|
248
|
+
if (jobs.length === 0) return [];
|
|
249
|
+
|
|
250
|
+
const externalClient = options?.db;
|
|
251
|
+
const client: DatabaseClient =
|
|
252
|
+
externalClient ?? (await this.pool.connect());
|
|
253
|
+
try {
|
|
254
|
+
const COLS_PER_JOB = 12;
|
|
255
|
+
const valueClauses: string[] = [];
|
|
256
|
+
const params: any[] = [];
|
|
257
|
+
|
|
258
|
+
const hasAnyIdempotencyKey = jobs.some((j) => j.idempotencyKey);
|
|
259
|
+
|
|
260
|
+
for (let i = 0; i < jobs.length; i++) {
|
|
261
|
+
const {
|
|
262
|
+
jobType,
|
|
263
|
+
payload,
|
|
264
|
+
maxAttempts = 3,
|
|
265
|
+
priority = 0,
|
|
266
|
+
runAt = null,
|
|
267
|
+
timeoutMs = undefined,
|
|
268
|
+
forceKillOnTimeout = false,
|
|
269
|
+
tags = undefined,
|
|
270
|
+
idempotencyKey = undefined,
|
|
271
|
+
retryDelay = undefined,
|
|
272
|
+
retryBackoff = undefined,
|
|
273
|
+
retryDelayMax = undefined,
|
|
274
|
+
} = jobs[i];
|
|
275
|
+
|
|
276
|
+
const base = i * COLS_PER_JOB;
|
|
277
|
+
valueClauses.push(
|
|
278
|
+
`($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, ` +
|
|
279
|
+
`COALESCE($${base + 5}::timestamptz, CURRENT_TIMESTAMP), ` +
|
|
280
|
+
`$${base + 6}, $${base + 7}, $${base + 8}, $${base + 9}, ` +
|
|
281
|
+
`$${base + 10}, $${base + 11}, $${base + 12})`,
|
|
282
|
+
);
|
|
283
|
+
params.push(
|
|
284
|
+
jobType,
|
|
285
|
+
payload,
|
|
286
|
+
maxAttempts,
|
|
287
|
+
priority,
|
|
288
|
+
runAt,
|
|
289
|
+
timeoutMs ?? null,
|
|
290
|
+
forceKillOnTimeout ?? false,
|
|
291
|
+
tags ?? null,
|
|
292
|
+
idempotencyKey ?? null,
|
|
293
|
+
retryDelay ?? null,
|
|
294
|
+
retryBackoff ?? null,
|
|
295
|
+
retryDelayMax ?? null,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const onConflict = hasAnyIdempotencyKey
|
|
300
|
+
? `ON CONFLICT (idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING`
|
|
301
|
+
: '';
|
|
302
|
+
|
|
303
|
+
const result = await client.query(
|
|
304
|
+
`INSERT INTO job_queue
|
|
305
|
+
(job_type, payload, max_attempts, priority, run_at, timeout_ms, force_kill_on_timeout, tags, idempotency_key, retry_delay, retry_backoff, retry_delay_max)
|
|
306
|
+
VALUES ${valueClauses.join(', ')}
|
|
307
|
+
${onConflict}
|
|
308
|
+
RETURNING id, idempotency_key`,
|
|
309
|
+
params,
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
// Build a map of idempotency_key -> id from returned rows
|
|
313
|
+
const returnedKeyToId = new Map<string, number>();
|
|
314
|
+
const returnedNullKeyIds: number[] = [];
|
|
315
|
+
for (const row of result.rows) {
|
|
316
|
+
if (row.idempotency_key != null) {
|
|
317
|
+
returnedKeyToId.set(row.idempotency_key, row.id);
|
|
318
|
+
} else {
|
|
319
|
+
returnedNullKeyIds.push(row.id);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Identify idempotency keys that conflicted (not in RETURNING)
|
|
324
|
+
const missingKeys: string[] = [];
|
|
325
|
+
for (const job of jobs) {
|
|
326
|
+
if (job.idempotencyKey && !returnedKeyToId.has(job.idempotencyKey)) {
|
|
327
|
+
missingKeys.push(job.idempotencyKey);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Batch-fetch existing IDs for conflicted keys
|
|
332
|
+
if (missingKeys.length > 0) {
|
|
333
|
+
const existing = await client.query(
|
|
334
|
+
`SELECT id, idempotency_key FROM job_queue WHERE idempotency_key = ANY($1)`,
|
|
335
|
+
[missingKeys],
|
|
336
|
+
);
|
|
337
|
+
for (const row of existing.rows) {
|
|
338
|
+
returnedKeyToId.set(row.idempotency_key, row.id);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Assemble result array in input order
|
|
343
|
+
let nullKeyIdx = 0;
|
|
344
|
+
const ids: number[] = [];
|
|
345
|
+
for (const job of jobs) {
|
|
346
|
+
if (job.idempotencyKey) {
|
|
347
|
+
const id = returnedKeyToId.get(job.idempotencyKey);
|
|
348
|
+
if (id === undefined) {
|
|
349
|
+
throw new Error(
|
|
350
|
+
`Failed to resolve job ID for idempotency key "${job.idempotencyKey}"`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
ids.push(id);
|
|
354
|
+
} else {
|
|
355
|
+
ids.push(returnedNullKeyIds[nullKeyIdx++]);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
log(`Batch-inserted ${jobs.length} jobs, IDs: [${ids.join(', ')}]`);
|
|
360
|
+
|
|
361
|
+
// Record 'added' events — only for newly inserted jobs
|
|
362
|
+
const newJobEvents: {
|
|
363
|
+
jobId: number;
|
|
364
|
+
eventType: JobEventType;
|
|
365
|
+
metadata?: any;
|
|
366
|
+
}[] = [];
|
|
367
|
+
for (let i = 0; i < jobs.length; i++) {
|
|
368
|
+
const job = jobs[i];
|
|
369
|
+
const wasInserted =
|
|
370
|
+
!job.idempotencyKey || !missingKeys.includes(job.idempotencyKey);
|
|
371
|
+
if (wasInserted) {
|
|
372
|
+
newJobEvents.push({
|
|
373
|
+
jobId: ids[i],
|
|
374
|
+
eventType: JobEventType.Added,
|
|
375
|
+
metadata: {
|
|
376
|
+
jobType: job.jobType,
|
|
377
|
+
payload: job.payload,
|
|
378
|
+
tags: job.tags,
|
|
379
|
+
idempotencyKey: job.idempotencyKey,
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (newJobEvents.length > 0) {
|
|
386
|
+
if (externalClient) {
|
|
387
|
+
// Record events on the same transaction client
|
|
388
|
+
const evtValues: string[] = [];
|
|
389
|
+
const evtParams: any[] = [];
|
|
390
|
+
let evtIdx = 1;
|
|
391
|
+
for (const evt of newJobEvents) {
|
|
392
|
+
evtValues.push(`($${evtIdx++}, $${evtIdx++}, $${evtIdx++})`);
|
|
393
|
+
evtParams.push(
|
|
394
|
+
evt.jobId,
|
|
395
|
+
evt.eventType,
|
|
396
|
+
evt.metadata ? JSON.stringify(evt.metadata) : null,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
try {
|
|
400
|
+
await client.query(
|
|
401
|
+
`INSERT INTO job_events (job_id, event_type, metadata) VALUES ${evtValues.join(', ')}`,
|
|
402
|
+
evtParams,
|
|
403
|
+
);
|
|
404
|
+
} catch (error) {
|
|
405
|
+
log(`Error recording batch job events: ${error}`);
|
|
406
|
+
}
|
|
407
|
+
} else {
|
|
408
|
+
await this.recordJobEventsBatch(newJobEvents);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return ids;
|
|
413
|
+
} catch (error) {
|
|
414
|
+
log(`Error batch-inserting jobs: ${error}`);
|
|
415
|
+
throw error;
|
|
416
|
+
} finally {
|
|
417
|
+
if (!externalClient) (client as any).release();
|
|
196
418
|
}
|
|
197
419
|
}
|
|
198
420
|
|
|
@@ -202,7 +424,7 @@ export class PostgresBackend implements QueueBackend {
|
|
|
202
424
|
const client = await this.pool.connect();
|
|
203
425
|
try {
|
|
204
426
|
const result = await client.query(
|
|
205
|
-
`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, idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress FROM job_queue WHERE id = $1`,
|
|
427
|
+
`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, idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress, retry_delay AS "retryDelay", retry_backoff AS "retryBackoff", retry_delay_max AS "retryDelayMax", output FROM job_queue WHERE id = $1`,
|
|
206
428
|
[id],
|
|
207
429
|
);
|
|
208
430
|
|
|
@@ -236,7 +458,7 @@ export class PostgresBackend implements QueueBackend {
|
|
|
236
458
|
const client = await this.pool.connect();
|
|
237
459
|
try {
|
|
238
460
|
const result = await client.query(
|
|
239
|
-
`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", idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress FROM job_queue WHERE status = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`,
|
|
461
|
+
`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", idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress, retry_delay AS "retryDelay", retry_backoff AS "retryBackoff", retry_delay_max AS "retryDelayMax", output FROM job_queue WHERE status = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`,
|
|
240
462
|
[status, limit, offset],
|
|
241
463
|
);
|
|
242
464
|
log(`Found ${result.rows.length} jobs by status ${status}`);
|
|
@@ -262,7 +484,7 @@ export class PostgresBackend implements QueueBackend {
|
|
|
262
484
|
const client = await this.pool.connect();
|
|
263
485
|
try {
|
|
264
486
|
const result = await client.query(
|
|
265
|
-
`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", idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress FROM job_queue ORDER BY created_at DESC LIMIT $1 OFFSET $2`,
|
|
487
|
+
`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", idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress, retry_delay AS "retryDelay", retry_backoff AS "retryBackoff", retry_delay_max AS "retryDelayMax", output FROM job_queue ORDER BY created_at DESC LIMIT $1 OFFSET $2`,
|
|
266
488
|
[limit, offset],
|
|
267
489
|
);
|
|
268
490
|
log(`Found ${result.rows.length} jobs (all)`);
|
|
@@ -287,7 +509,7 @@ export class PostgresBackend implements QueueBackend {
|
|
|
287
509
|
): Promise<JobRecord<PayloadMap, T>[]> {
|
|
288
510
|
const client = await this.pool.connect();
|
|
289
511
|
try {
|
|
290
|
-
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, idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress FROM job_queue`;
|
|
512
|
+
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, idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress, retry_delay AS "retryDelay", retry_backoff AS "retryBackoff", retry_delay_max AS "retryDelayMax", output FROM job_queue`;
|
|
291
513
|
const params: any[] = [];
|
|
292
514
|
const where: string[] = [];
|
|
293
515
|
let paramIdx = 1;
|
|
@@ -414,7 +636,7 @@ export class PostgresBackend implements QueueBackend {
|
|
|
414
636
|
): Promise<JobRecord<PayloadMap, T>[]> {
|
|
415
637
|
const client = await this.pool.connect();
|
|
416
638
|
try {
|
|
417
|
-
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, idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress
|
|
639
|
+
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, idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress, retry_delay AS "retryDelay", retry_backoff AS "retryBackoff", retry_delay_max AS "retryDelayMax", output
|
|
418
640
|
FROM job_queue`;
|
|
419
641
|
let params: any[] = [];
|
|
420
642
|
switch (mode) {
|
|
@@ -516,7 +738,7 @@ export class PostgresBackend implements QueueBackend {
|
|
|
516
738
|
LIMIT $2
|
|
517
739
|
FOR UPDATE SKIP LOCKED
|
|
518
740
|
)
|
|
519
|
-
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", idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress
|
|
741
|
+
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", idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress, retry_delay AS "retryDelay", retry_backoff AS "retryBackoff", retry_delay_max AS "retryDelayMax", output
|
|
520
742
|
`,
|
|
521
743
|
params,
|
|
522
744
|
);
|
|
@@ -549,17 +771,19 @@ export class PostgresBackend implements QueueBackend {
|
|
|
549
771
|
}
|
|
550
772
|
}
|
|
551
773
|
|
|
552
|
-
async completeJob(jobId: number): Promise<void> {
|
|
774
|
+
async completeJob(jobId: number, output?: unknown): Promise<void> {
|
|
553
775
|
const client = await this.pool.connect();
|
|
554
776
|
try {
|
|
777
|
+
const outputJson = output !== undefined ? JSON.stringify(output) : null;
|
|
555
778
|
const result = await client.query(
|
|
556
779
|
`
|
|
557
780
|
UPDATE job_queue
|
|
558
781
|
SET status = 'completed', updated_at = NOW(), completed_at = NOW(),
|
|
559
|
-
step_data = NULL, wait_until = NULL, wait_token_id = NULL
|
|
782
|
+
step_data = NULL, wait_until = NULL, wait_token_id = NULL,
|
|
783
|
+
output = COALESCE($2::jsonb, output)
|
|
560
784
|
WHERE id = $1 AND status = 'processing'
|
|
561
785
|
`,
|
|
562
|
-
[jobId],
|
|
786
|
+
[jobId, outputJson],
|
|
563
787
|
);
|
|
564
788
|
if (result.rowCount === 0) {
|
|
565
789
|
log(
|
|
@@ -588,9 +812,17 @@ export class PostgresBackend implements QueueBackend {
|
|
|
588
812
|
UPDATE job_queue
|
|
589
813
|
SET status = 'failed',
|
|
590
814
|
updated_at = NOW(),
|
|
591
|
-
next_attempt_at = CASE
|
|
592
|
-
WHEN attempts
|
|
593
|
-
|
|
815
|
+
next_attempt_at = CASE
|
|
816
|
+
WHEN attempts >= max_attempts THEN NULL
|
|
817
|
+
WHEN retry_delay IS NULL AND retry_backoff IS NULL AND retry_delay_max IS NULL
|
|
818
|
+
THEN NOW() + (POWER(2, attempts) * INTERVAL '1 minute')
|
|
819
|
+
WHEN COALESCE(retry_backoff, true) = true
|
|
820
|
+
THEN NOW() + (LEAST(
|
|
821
|
+
COALESCE(retry_delay_max, 2147483647),
|
|
822
|
+
COALESCE(retry_delay, 60) * POWER(2, attempts)
|
|
823
|
+
) * (0.5 + 0.5 * random()) * INTERVAL '1 second')
|
|
824
|
+
ELSE
|
|
825
|
+
NOW() + (COALESCE(retry_delay, 60) * INTERVAL '1 second')
|
|
594
826
|
END,
|
|
595
827
|
error_history = COALESCE(error_history, '[]'::jsonb) || $2::jsonb,
|
|
596
828
|
failure_reason = $3,
|
|
@@ -665,6 +897,23 @@ export class PostgresBackend implements QueueBackend {
|
|
|
665
897
|
}
|
|
666
898
|
}
|
|
667
899
|
|
|
900
|
+
// ── Output ────────────────────────────────────────────────────────────
|
|
901
|
+
|
|
902
|
+
async updateOutput(jobId: number, output: unknown): Promise<void> {
|
|
903
|
+
const client = await this.pool.connect();
|
|
904
|
+
try {
|
|
905
|
+
await client.query(
|
|
906
|
+
`UPDATE job_queue SET output = $2::jsonb, updated_at = NOW() WHERE id = $1`,
|
|
907
|
+
[jobId, JSON.stringify(output)],
|
|
908
|
+
);
|
|
909
|
+
log(`Updated output for job ${jobId}`);
|
|
910
|
+
} catch (error) {
|
|
911
|
+
log(`Error updating output for job ${jobId}: ${error}`);
|
|
912
|
+
} finally {
|
|
913
|
+
client.release();
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
668
917
|
// ── Job management ────────────────────────────────────────────────────
|
|
669
918
|
|
|
670
919
|
async retryJob(jobId: number): Promise<void> {
|
|
@@ -843,6 +1092,18 @@ export class PostgresBackend implements QueueBackend {
|
|
|
843
1092
|
updateFields.push(`tags = $${paramIdx++}`);
|
|
844
1093
|
params.push(updates.tags ?? null);
|
|
845
1094
|
}
|
|
1095
|
+
if (updates.retryDelay !== undefined) {
|
|
1096
|
+
updateFields.push(`retry_delay = $${paramIdx++}`);
|
|
1097
|
+
params.push(updates.retryDelay ?? null);
|
|
1098
|
+
}
|
|
1099
|
+
if (updates.retryBackoff !== undefined) {
|
|
1100
|
+
updateFields.push(`retry_backoff = $${paramIdx++}`);
|
|
1101
|
+
params.push(updates.retryBackoff ?? null);
|
|
1102
|
+
}
|
|
1103
|
+
if (updates.retryDelayMax !== undefined) {
|
|
1104
|
+
updateFields.push(`retry_delay_max = $${paramIdx++}`);
|
|
1105
|
+
params.push(updates.retryDelayMax ?? null);
|
|
1106
|
+
}
|
|
846
1107
|
|
|
847
1108
|
if (updateFields.length === 0) {
|
|
848
1109
|
log(`No fields to update for job ${jobId}`);
|
|
@@ -869,6 +1130,12 @@ export class PostgresBackend implements QueueBackend {
|
|
|
869
1130
|
if (updates.timeoutMs !== undefined)
|
|
870
1131
|
metadata.timeoutMs = updates.timeoutMs;
|
|
871
1132
|
if (updates.tags !== undefined) metadata.tags = updates.tags;
|
|
1133
|
+
if (updates.retryDelay !== undefined)
|
|
1134
|
+
metadata.retryDelay = updates.retryDelay;
|
|
1135
|
+
if (updates.retryBackoff !== undefined)
|
|
1136
|
+
metadata.retryBackoff = updates.retryBackoff;
|
|
1137
|
+
if (updates.retryDelayMax !== undefined)
|
|
1138
|
+
metadata.retryDelayMax = updates.retryDelayMax;
|
|
872
1139
|
|
|
873
1140
|
await this.recordJobEvent(jobId, JobEventType.Edited, metadata);
|
|
874
1141
|
log(`Edited job ${jobId}: ${JSON.stringify(metadata)}`);
|
|
@@ -918,6 +1185,18 @@ export class PostgresBackend implements QueueBackend {
|
|
|
918
1185
|
updateFields.push(`tags = $${paramIdx++}`);
|
|
919
1186
|
params.push(updates.tags ?? null);
|
|
920
1187
|
}
|
|
1188
|
+
if (updates.retryDelay !== undefined) {
|
|
1189
|
+
updateFields.push(`retry_delay = $${paramIdx++}`);
|
|
1190
|
+
params.push(updates.retryDelay ?? null);
|
|
1191
|
+
}
|
|
1192
|
+
if (updates.retryBackoff !== undefined) {
|
|
1193
|
+
updateFields.push(`retry_backoff = $${paramIdx++}`);
|
|
1194
|
+
params.push(updates.retryBackoff ?? null);
|
|
1195
|
+
}
|
|
1196
|
+
if (updates.retryDelayMax !== undefined) {
|
|
1197
|
+
updateFields.push(`retry_delay_max = $${paramIdx++}`);
|
|
1198
|
+
params.push(updates.retryDelayMax ?? null);
|
|
1199
|
+
}
|
|
921
1200
|
|
|
922
1201
|
if (updateFields.length === 0) {
|
|
923
1202
|
log(`No fields to update for batch edit`);
|
|
@@ -1188,8 +1467,8 @@ export class PostgresBackend implements QueueBackend {
|
|
|
1188
1467
|
`INSERT INTO cron_schedules
|
|
1189
1468
|
(schedule_name, cron_expression, job_type, payload, max_attempts,
|
|
1190
1469
|
priority, timeout_ms, force_kill_on_timeout, tags, timezone,
|
|
1191
|
-
allow_overlap, next_run_at)
|
|
1192
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
|
1470
|
+
allow_overlap, next_run_at, retry_delay, retry_backoff, retry_delay_max)
|
|
1471
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
|
1193
1472
|
RETURNING id`,
|
|
1194
1473
|
[
|
|
1195
1474
|
input.scheduleName,
|
|
@@ -1204,6 +1483,9 @@ export class PostgresBackend implements QueueBackend {
|
|
|
1204
1483
|
input.timezone,
|
|
1205
1484
|
input.allowOverlap,
|
|
1206
1485
|
input.nextRunAt,
|
|
1486
|
+
input.retryDelay,
|
|
1487
|
+
input.retryBackoff,
|
|
1488
|
+
input.retryDelayMax,
|
|
1207
1489
|
],
|
|
1208
1490
|
);
|
|
1209
1491
|
const id = result.rows[0].id;
|
|
@@ -1235,7 +1517,9 @@ export class PostgresBackend implements QueueBackend {
|
|
|
1235
1517
|
timezone, allow_overlap AS "allowOverlap", status,
|
|
1236
1518
|
last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
|
|
1237
1519
|
next_run_at AS "nextRunAt",
|
|
1238
|
-
created_at AS "createdAt", updated_at AS "updatedAt"
|
|
1520
|
+
created_at AS "createdAt", updated_at AS "updatedAt",
|
|
1521
|
+
retry_delay AS "retryDelay", retry_backoff AS "retryBackoff",
|
|
1522
|
+
retry_delay_max AS "retryDelayMax"
|
|
1239
1523
|
FROM cron_schedules WHERE id = $1`,
|
|
1240
1524
|
[id],
|
|
1241
1525
|
);
|
|
@@ -1263,7 +1547,9 @@ export class PostgresBackend implements QueueBackend {
|
|
|
1263
1547
|
timezone, allow_overlap AS "allowOverlap", status,
|
|
1264
1548
|
last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
|
|
1265
1549
|
next_run_at AS "nextRunAt",
|
|
1266
|
-
created_at AS "createdAt", updated_at AS "updatedAt"
|
|
1550
|
+
created_at AS "createdAt", updated_at AS "updatedAt",
|
|
1551
|
+
retry_delay AS "retryDelay", retry_backoff AS "retryBackoff",
|
|
1552
|
+
retry_delay_max AS "retryDelayMax"
|
|
1267
1553
|
FROM cron_schedules WHERE schedule_name = $1`,
|
|
1268
1554
|
[name],
|
|
1269
1555
|
);
|
|
@@ -1290,7 +1576,9 @@ export class PostgresBackend implements QueueBackend {
|
|
|
1290
1576
|
timezone, allow_overlap AS "allowOverlap", status,
|
|
1291
1577
|
last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
|
|
1292
1578
|
next_run_at AS "nextRunAt",
|
|
1293
|
-
created_at AS "createdAt", updated_at AS "updatedAt"
|
|
1579
|
+
created_at AS "createdAt", updated_at AS "updatedAt",
|
|
1580
|
+
retry_delay AS "retryDelay", retry_backoff AS "retryBackoff",
|
|
1581
|
+
retry_delay_max AS "retryDelayMax"
|
|
1294
1582
|
FROM cron_schedules`;
|
|
1295
1583
|
const params: any[] = [];
|
|
1296
1584
|
if (status) {
|
|
@@ -1404,6 +1692,18 @@ export class PostgresBackend implements QueueBackend {
|
|
|
1404
1692
|
updateFields.push(`allow_overlap = $${paramIdx++}`);
|
|
1405
1693
|
params.push(updates.allowOverlap);
|
|
1406
1694
|
}
|
|
1695
|
+
if (updates.retryDelay !== undefined) {
|
|
1696
|
+
updateFields.push(`retry_delay = $${paramIdx++}`);
|
|
1697
|
+
params.push(updates.retryDelay);
|
|
1698
|
+
}
|
|
1699
|
+
if (updates.retryBackoff !== undefined) {
|
|
1700
|
+
updateFields.push(`retry_backoff = $${paramIdx++}`);
|
|
1701
|
+
params.push(updates.retryBackoff);
|
|
1702
|
+
}
|
|
1703
|
+
if (updates.retryDelayMax !== undefined) {
|
|
1704
|
+
updateFields.push(`retry_delay_max = $${paramIdx++}`);
|
|
1705
|
+
params.push(updates.retryDelayMax);
|
|
1706
|
+
}
|
|
1407
1707
|
if (nextRunAt !== undefined) {
|
|
1408
1708
|
updateFields.push(`next_run_at = $${paramIdx++}`);
|
|
1409
1709
|
params.push(nextRunAt);
|
|
@@ -1443,7 +1743,9 @@ export class PostgresBackend implements QueueBackend {
|
|
|
1443
1743
|
timezone, allow_overlap AS "allowOverlap", status,
|
|
1444
1744
|
last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
|
|
1445
1745
|
next_run_at AS "nextRunAt",
|
|
1446
|
-
created_at AS "createdAt", updated_at AS "updatedAt"
|
|
1746
|
+
created_at AS "createdAt", updated_at AS "updatedAt",
|
|
1747
|
+
retry_delay AS "retryDelay", retry_backoff AS "retryBackoff",
|
|
1748
|
+
retry_delay_max AS "retryDelayMax"
|
|
1447
1749
|
FROM cron_schedules
|
|
1448
1750
|
WHERE status = 'active'
|
|
1449
1751
|
AND next_run_at IS NOT NULL
|