@nicnocquee/dataqueue 1.24.0 → 1.26.0-beta.20260223195940
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/ai/build-docs-content.ts +96 -0
- package/ai/build-llms-full.ts +42 -0
- package/ai/docs-content.json +278 -0
- package/ai/rules/advanced.md +132 -0
- package/ai/rules/basic.md +159 -0
- package/ai/rules/react-dashboard.md +83 -0
- package/ai/skills/dataqueue-advanced/SKILL.md +320 -0
- package/ai/skills/dataqueue-core/SKILL.md +234 -0
- package/ai/skills/dataqueue-react/SKILL.md +189 -0
- package/dist/cli.cjs +1149 -14
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.d.cts +66 -1
- package/dist/cli.d.ts +66 -1
- package/dist/cli.js +1146 -13
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +4630 -928
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1033 -15
- package/dist/index.d.ts +1033 -15
- package/dist/index.js +4626 -929
- package/dist/index.js.map +1 -1
- package/dist/mcp-server.cjs +186 -0
- package/dist/mcp-server.cjs.map +1 -0
- package/dist/mcp-server.d.cts +32 -0
- package/dist/mcp-server.d.ts +32 -0
- package/dist/mcp-server.js +175 -0
- package/dist/mcp-server.js.map +1 -0
- 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/migrations/1781200000004_create_cron_schedules_table.sql +33 -0
- package/migrations/1781200000005_add_retry_config_to_job_queue.sql +17 -0
- package/package.json +40 -23
- package/src/backend.ts +328 -0
- package/src/backends/postgres.ts +2040 -0
- package/src/backends/redis-scripts.ts +865 -0
- package/src/backends/redis.test.ts +1906 -0
- package/src/backends/redis.ts +1792 -0
- package/src/cli.test.ts +82 -6
- package/src/cli.ts +73 -10
- package/src/cron.test.ts +126 -0
- package/src/cron.ts +40 -0
- package/src/db-util.ts +4 -2
- package/src/index.test.ts +688 -1
- package/src/index.ts +277 -39
- package/src/init-command.test.ts +449 -0
- package/src/init-command.ts +709 -0
- package/src/install-mcp-command.test.ts +216 -0
- package/src/install-mcp-command.ts +185 -0
- package/src/install-rules-command.test.ts +218 -0
- package/src/install-rules-command.ts +233 -0
- package/src/install-skills-command.test.ts +176 -0
- package/src/install-skills-command.ts +124 -0
- package/src/mcp-server.test.ts +162 -0
- package/src/mcp-server.ts +231 -0
- package/src/processor.test.ts +559 -18
- package/src/processor.ts +456 -49
- package/src/queue.test.ts +682 -6
- package/src/queue.ts +135 -944
- package/src/supervisor.test.ts +340 -0
- package/src/supervisor.ts +162 -0
- package/src/test-util.ts +32 -0
- package/src/types.ts +726 -17
- package/src/wait.test.ts +698 -0
- package/LICENSE +0 -21
|
@@ -0,0 +1,2040 @@
|
|
|
1
|
+
import { Pool } from 'pg';
|
|
2
|
+
import {
|
|
3
|
+
JobOptions,
|
|
4
|
+
JobRecord,
|
|
5
|
+
FailureReason,
|
|
6
|
+
JobEvent,
|
|
7
|
+
JobEventType,
|
|
8
|
+
TagQueryMode,
|
|
9
|
+
JobType,
|
|
10
|
+
CronScheduleRecord,
|
|
11
|
+
CronScheduleStatus,
|
|
12
|
+
EditCronScheduleOptions,
|
|
13
|
+
WaitpointRecord,
|
|
14
|
+
CreateTokenOptions,
|
|
15
|
+
AddJobOptions,
|
|
16
|
+
DatabaseClient,
|
|
17
|
+
} from '../types.js';
|
|
18
|
+
import { randomUUID } from 'crypto';
|
|
19
|
+
import {
|
|
20
|
+
QueueBackend,
|
|
21
|
+
JobFilters,
|
|
22
|
+
JobUpdates,
|
|
23
|
+
CronScheduleInput,
|
|
24
|
+
} from '../backend.js';
|
|
25
|
+
import { log } from '../log-context.js';
|
|
26
|
+
|
|
27
|
+
const MAX_TIMEOUT_MS = 365 * 24 * 60 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
/** Parse a timeout string like '10m', '1h', '24h', '7d' into milliseconds. */
|
|
30
|
+
function parseTimeoutString(timeout: string): number {
|
|
31
|
+
const match = timeout.match(/^(\d+)(s|m|h|d)$/);
|
|
32
|
+
if (!match) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Invalid timeout format: "${timeout}". Expected format like "10m", "1h", "24h", "7d".`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
const value = parseInt(match[1], 10);
|
|
38
|
+
const unit = match[2];
|
|
39
|
+
let ms: number;
|
|
40
|
+
switch (unit) {
|
|
41
|
+
case 's':
|
|
42
|
+
ms = value * 1000;
|
|
43
|
+
break;
|
|
44
|
+
case 'm':
|
|
45
|
+
ms = value * 60 * 1000;
|
|
46
|
+
break;
|
|
47
|
+
case 'h':
|
|
48
|
+
ms = value * 60 * 60 * 1000;
|
|
49
|
+
break;
|
|
50
|
+
case 'd':
|
|
51
|
+
ms = value * 24 * 60 * 60 * 1000;
|
|
52
|
+
break;
|
|
53
|
+
default:
|
|
54
|
+
throw new Error(`Unknown timeout unit: "${unit}"`);
|
|
55
|
+
}
|
|
56
|
+
if (!Number.isFinite(ms) || ms > MAX_TIMEOUT_MS) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Timeout value "${timeout}" is too large. Maximum allowed is 365 days.`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return ms;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class PostgresBackend implements QueueBackend {
|
|
65
|
+
constructor(private pool: Pool) {}
|
|
66
|
+
|
|
67
|
+
/** Expose the raw pool for advanced usage. */
|
|
68
|
+
getPool(): Pool {
|
|
69
|
+
return this.pool;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Events ──────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
async recordJobEvent(
|
|
75
|
+
jobId: number,
|
|
76
|
+
eventType: JobEventType,
|
|
77
|
+
metadata?: any,
|
|
78
|
+
): Promise<void> {
|
|
79
|
+
const client = await this.pool.connect();
|
|
80
|
+
try {
|
|
81
|
+
await client.query(
|
|
82
|
+
`INSERT INTO job_events (job_id, event_type, metadata) VALUES ($1, $2, $3)`,
|
|
83
|
+
[jobId, eventType, metadata ? JSON.stringify(metadata) : null],
|
|
84
|
+
);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
log(`Error recording job event for job ${jobId}: ${error}`);
|
|
87
|
+
// Do not throw, to avoid interfering with main job logic
|
|
88
|
+
} finally {
|
|
89
|
+
client.release();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async getJobEvents(jobId: number): Promise<JobEvent[]> {
|
|
94
|
+
const client = await this.pool.connect();
|
|
95
|
+
try {
|
|
96
|
+
const res = await client.query(
|
|
97
|
+
`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`,
|
|
98
|
+
[jobId],
|
|
99
|
+
);
|
|
100
|
+
return res.rows as JobEvent[];
|
|
101
|
+
} finally {
|
|
102
|
+
client.release();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Job CRUD ──────────────────────────────────────────────────────────
|
|
107
|
+
|
|
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());
|
|
136
|
+
try {
|
|
137
|
+
let result;
|
|
138
|
+
const onConflict = idempotencyKey
|
|
139
|
+
? `ON CONFLICT (idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING`
|
|
140
|
+
: '';
|
|
141
|
+
|
|
142
|
+
if (runAt) {
|
|
143
|
+
result = await client.query(
|
|
144
|
+
`INSERT INTO job_queue
|
|
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)
|
|
147
|
+
${onConflict}
|
|
148
|
+
RETURNING id`,
|
|
149
|
+
[
|
|
150
|
+
jobType,
|
|
151
|
+
payload,
|
|
152
|
+
maxAttempts,
|
|
153
|
+
priority,
|
|
154
|
+
runAt,
|
|
155
|
+
timeoutMs ?? null,
|
|
156
|
+
forceKillOnTimeout ?? false,
|
|
157
|
+
tags ?? null,
|
|
158
|
+
idempotencyKey ?? null,
|
|
159
|
+
retryDelay ?? null,
|
|
160
|
+
retryBackoff ?? null,
|
|
161
|
+
retryDelayMax ?? null,
|
|
162
|
+
],
|
|
163
|
+
);
|
|
164
|
+
} else {
|
|
165
|
+
result = await client.query(
|
|
166
|
+
`INSERT INTO job_queue
|
|
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)
|
|
169
|
+
${onConflict}
|
|
170
|
+
RETURNING id`,
|
|
171
|
+
[
|
|
172
|
+
jobType,
|
|
173
|
+
payload,
|
|
174
|
+
maxAttempts,
|
|
175
|
+
priority,
|
|
176
|
+
timeoutMs ?? null,
|
|
177
|
+
forceKillOnTimeout ?? false,
|
|
178
|
+
tags ?? null,
|
|
179
|
+
idempotencyKey ?? null,
|
|
180
|
+
retryDelay ?? null,
|
|
181
|
+
retryBackoff ?? null,
|
|
182
|
+
retryDelayMax ?? null,
|
|
183
|
+
],
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (result.rows.length === 0 && idempotencyKey) {
|
|
188
|
+
const existing = await client.query(
|
|
189
|
+
`SELECT id FROM job_queue WHERE idempotency_key = $1`,
|
|
190
|
+
[idempotencyKey],
|
|
191
|
+
);
|
|
192
|
+
if (existing.rows.length > 0) {
|
|
193
|
+
log(
|
|
194
|
+
`Job with idempotency key "${idempotencyKey}" already exists (id: ${existing.rows[0].id}), returning existing job`,
|
|
195
|
+
);
|
|
196
|
+
return existing.rows[0].id;
|
|
197
|
+
}
|
|
198
|
+
throw new Error(
|
|
199
|
+
`Failed to insert job and could not find existing job with idempotency key "${idempotencyKey}"`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const jobId = result.rows[0].id;
|
|
204
|
+
log(
|
|
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}"` : ''}`,
|
|
206
|
+
);
|
|
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
|
+
}
|
|
229
|
+
return jobId;
|
|
230
|
+
} catch (error) {
|
|
231
|
+
log(`Error adding job: ${error}`);
|
|
232
|
+
throw error;
|
|
233
|
+
} finally {
|
|
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();
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async getJob<PayloadMap, T extends JobType<PayloadMap>>(
|
|
422
|
+
id: number,
|
|
423
|
+
): Promise<JobRecord<PayloadMap, T> | null> {
|
|
424
|
+
const client = await this.pool.connect();
|
|
425
|
+
try {
|
|
426
|
+
const result = await client.query(
|
|
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" FROM job_queue WHERE id = $1`,
|
|
428
|
+
[id],
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
if (result.rows.length === 0) {
|
|
432
|
+
log(`Job ${id} not found`);
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
log(`Found job ${id}`);
|
|
437
|
+
const job = result.rows[0] as JobRecord<PayloadMap, T>;
|
|
438
|
+
return {
|
|
439
|
+
...job,
|
|
440
|
+
payload: job.payload,
|
|
441
|
+
timeoutMs: job.timeoutMs,
|
|
442
|
+
forceKillOnTimeout: job.forceKillOnTimeout,
|
|
443
|
+
failureReason: job.failureReason,
|
|
444
|
+
};
|
|
445
|
+
} catch (error) {
|
|
446
|
+
log(`Error getting job ${id}: ${error}`);
|
|
447
|
+
throw error;
|
|
448
|
+
} finally {
|
|
449
|
+
client.release();
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async getJobsByStatus<PayloadMap, T extends JobType<PayloadMap>>(
|
|
454
|
+
status: string,
|
|
455
|
+
limit = 100,
|
|
456
|
+
offset = 0,
|
|
457
|
+
): Promise<JobRecord<PayloadMap, T>[]> {
|
|
458
|
+
const client = await this.pool.connect();
|
|
459
|
+
try {
|
|
460
|
+
const result = await client.query(
|
|
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" FROM job_queue WHERE status = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`,
|
|
462
|
+
[status, limit, offset],
|
|
463
|
+
);
|
|
464
|
+
log(`Found ${result.rows.length} jobs by status ${status}`);
|
|
465
|
+
return result.rows.map((job) => ({
|
|
466
|
+
...job,
|
|
467
|
+
payload: job.payload,
|
|
468
|
+
timeoutMs: job.timeoutMs,
|
|
469
|
+
forceKillOnTimeout: job.forceKillOnTimeout,
|
|
470
|
+
failureReason: job.failureReason,
|
|
471
|
+
}));
|
|
472
|
+
} catch (error) {
|
|
473
|
+
log(`Error getting jobs by status ${status}: ${error}`);
|
|
474
|
+
throw error;
|
|
475
|
+
} finally {
|
|
476
|
+
client.release();
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async getAllJobs<PayloadMap, T extends JobType<PayloadMap>>(
|
|
481
|
+
limit = 100,
|
|
482
|
+
offset = 0,
|
|
483
|
+
): Promise<JobRecord<PayloadMap, T>[]> {
|
|
484
|
+
const client = await this.pool.connect();
|
|
485
|
+
try {
|
|
486
|
+
const result = await client.query(
|
|
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" FROM job_queue ORDER BY created_at DESC LIMIT $1 OFFSET $2`,
|
|
488
|
+
[limit, offset],
|
|
489
|
+
);
|
|
490
|
+
log(`Found ${result.rows.length} jobs (all)`);
|
|
491
|
+
return result.rows.map((job) => ({
|
|
492
|
+
...job,
|
|
493
|
+
payload: job.payload,
|
|
494
|
+
timeoutMs: job.timeoutMs,
|
|
495
|
+
forceKillOnTimeout: job.forceKillOnTimeout,
|
|
496
|
+
}));
|
|
497
|
+
} catch (error) {
|
|
498
|
+
log(`Error getting all jobs: ${error}`);
|
|
499
|
+
throw error;
|
|
500
|
+
} finally {
|
|
501
|
+
client.release();
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async getJobs<PayloadMap, T extends JobType<PayloadMap>>(
|
|
506
|
+
filters?: JobFilters,
|
|
507
|
+
limit = 100,
|
|
508
|
+
offset = 0,
|
|
509
|
+
): Promise<JobRecord<PayloadMap, T>[]> {
|
|
510
|
+
const client = await this.pool.connect();
|
|
511
|
+
try {
|
|
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" FROM job_queue`;
|
|
513
|
+
const params: any[] = [];
|
|
514
|
+
const where: string[] = [];
|
|
515
|
+
let paramIdx = 1;
|
|
516
|
+
if (filters) {
|
|
517
|
+
if (filters.jobType) {
|
|
518
|
+
where.push(`job_type = $${paramIdx++}`);
|
|
519
|
+
params.push(filters.jobType);
|
|
520
|
+
}
|
|
521
|
+
if (filters.priority !== undefined) {
|
|
522
|
+
where.push(`priority = $${paramIdx++}`);
|
|
523
|
+
params.push(filters.priority);
|
|
524
|
+
}
|
|
525
|
+
if (filters.runAt) {
|
|
526
|
+
if (filters.runAt instanceof Date) {
|
|
527
|
+
where.push(`run_at = $${paramIdx++}`);
|
|
528
|
+
params.push(filters.runAt);
|
|
529
|
+
} else if (
|
|
530
|
+
typeof filters.runAt === 'object' &&
|
|
531
|
+
(filters.runAt.gt !== undefined ||
|
|
532
|
+
filters.runAt.gte !== undefined ||
|
|
533
|
+
filters.runAt.lt !== undefined ||
|
|
534
|
+
filters.runAt.lte !== undefined ||
|
|
535
|
+
filters.runAt.eq !== undefined)
|
|
536
|
+
) {
|
|
537
|
+
const ops = filters.runAt as {
|
|
538
|
+
gt?: Date;
|
|
539
|
+
gte?: Date;
|
|
540
|
+
lt?: Date;
|
|
541
|
+
lte?: Date;
|
|
542
|
+
eq?: Date;
|
|
543
|
+
};
|
|
544
|
+
if (ops.gt) {
|
|
545
|
+
where.push(`run_at > $${paramIdx++}`);
|
|
546
|
+
params.push(ops.gt);
|
|
547
|
+
}
|
|
548
|
+
if (ops.gte) {
|
|
549
|
+
where.push(`run_at >= $${paramIdx++}`);
|
|
550
|
+
params.push(ops.gte);
|
|
551
|
+
}
|
|
552
|
+
if (ops.lt) {
|
|
553
|
+
where.push(`run_at < $${paramIdx++}`);
|
|
554
|
+
params.push(ops.lt);
|
|
555
|
+
}
|
|
556
|
+
if (ops.lte) {
|
|
557
|
+
where.push(`run_at <= $${paramIdx++}`);
|
|
558
|
+
params.push(ops.lte);
|
|
559
|
+
}
|
|
560
|
+
if (ops.eq) {
|
|
561
|
+
where.push(`run_at = $${paramIdx++}`);
|
|
562
|
+
params.push(ops.eq);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
if (
|
|
567
|
+
filters.tags &&
|
|
568
|
+
filters.tags.values &&
|
|
569
|
+
filters.tags.values.length > 0
|
|
570
|
+
) {
|
|
571
|
+
const mode = filters.tags.mode || 'all';
|
|
572
|
+
const tagValues = filters.tags.values;
|
|
573
|
+
switch (mode) {
|
|
574
|
+
case 'exact':
|
|
575
|
+
where.push(`tags = $${paramIdx++}`);
|
|
576
|
+
params.push(tagValues);
|
|
577
|
+
break;
|
|
578
|
+
case 'all':
|
|
579
|
+
where.push(`tags @> $${paramIdx++}`);
|
|
580
|
+
params.push(tagValues);
|
|
581
|
+
break;
|
|
582
|
+
case 'any':
|
|
583
|
+
where.push(`tags && $${paramIdx++}`);
|
|
584
|
+
params.push(tagValues);
|
|
585
|
+
break;
|
|
586
|
+
case 'none':
|
|
587
|
+
where.push(`NOT (tags && $${paramIdx++})`);
|
|
588
|
+
params.push(tagValues);
|
|
589
|
+
break;
|
|
590
|
+
default:
|
|
591
|
+
where.push(`tags @> $${paramIdx++}`);
|
|
592
|
+
params.push(tagValues);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
// Keyset pagination: use cursor (id < cursor) instead of OFFSET
|
|
596
|
+
if (filters.cursor !== undefined) {
|
|
597
|
+
where.push(`id < $${paramIdx++}`);
|
|
598
|
+
params.push(filters.cursor);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
if (where.length > 0) {
|
|
602
|
+
query += ` WHERE ${where.join(' AND ')}`;
|
|
603
|
+
}
|
|
604
|
+
paramIdx = params.length + 1;
|
|
605
|
+
// Use ORDER BY id DESC for consistent keyset pagination
|
|
606
|
+
query += ` ORDER BY id DESC LIMIT $${paramIdx++}`;
|
|
607
|
+
// Only apply OFFSET when cursor is not used
|
|
608
|
+
if (!filters?.cursor) {
|
|
609
|
+
query += ` OFFSET $${paramIdx}`;
|
|
610
|
+
params.push(limit, offset);
|
|
611
|
+
} else {
|
|
612
|
+
params.push(limit);
|
|
613
|
+
}
|
|
614
|
+
const result = await client.query(query, params);
|
|
615
|
+
log(`Found ${result.rows.length} jobs`);
|
|
616
|
+
return result.rows.map((job) => ({
|
|
617
|
+
...job,
|
|
618
|
+
payload: job.payload,
|
|
619
|
+
timeoutMs: job.timeoutMs,
|
|
620
|
+
forceKillOnTimeout: job.forceKillOnTimeout,
|
|
621
|
+
failureReason: job.failureReason,
|
|
622
|
+
}));
|
|
623
|
+
} catch (error) {
|
|
624
|
+
log(`Error getting jobs: ${error}`);
|
|
625
|
+
throw error;
|
|
626
|
+
} finally {
|
|
627
|
+
client.release();
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async getJobsByTags<PayloadMap, T extends JobType<PayloadMap>>(
|
|
632
|
+
tags: string[],
|
|
633
|
+
mode: TagQueryMode = 'all',
|
|
634
|
+
limit = 100,
|
|
635
|
+
offset = 0,
|
|
636
|
+
): Promise<JobRecord<PayloadMap, T>[]> {
|
|
637
|
+
const client = await this.pool.connect();
|
|
638
|
+
try {
|
|
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"
|
|
640
|
+
FROM job_queue`;
|
|
641
|
+
let params: any[] = [];
|
|
642
|
+
switch (mode) {
|
|
643
|
+
case 'exact':
|
|
644
|
+
query += ' WHERE tags = $1';
|
|
645
|
+
params = [tags];
|
|
646
|
+
break;
|
|
647
|
+
case 'all':
|
|
648
|
+
query += ' WHERE tags @> $1';
|
|
649
|
+
params = [tags];
|
|
650
|
+
break;
|
|
651
|
+
case 'any':
|
|
652
|
+
query += ' WHERE tags && $1';
|
|
653
|
+
params = [tags];
|
|
654
|
+
break;
|
|
655
|
+
case 'none':
|
|
656
|
+
query += ' WHERE NOT (tags && $1)';
|
|
657
|
+
params = [tags];
|
|
658
|
+
break;
|
|
659
|
+
default:
|
|
660
|
+
query += ' WHERE tags @> $1';
|
|
661
|
+
params = [tags];
|
|
662
|
+
}
|
|
663
|
+
query += ' ORDER BY created_at DESC LIMIT $2 OFFSET $3';
|
|
664
|
+
params.push(limit, offset);
|
|
665
|
+
const result = await client.query(query, params);
|
|
666
|
+
log(
|
|
667
|
+
`Found ${result.rows.length} jobs by tags ${JSON.stringify(tags)} (mode: ${mode})`,
|
|
668
|
+
);
|
|
669
|
+
return result.rows.map((job) => ({
|
|
670
|
+
...job,
|
|
671
|
+
payload: job.payload,
|
|
672
|
+
timeoutMs: job.timeoutMs,
|
|
673
|
+
forceKillOnTimeout: job.forceKillOnTimeout,
|
|
674
|
+
failureReason: job.failureReason,
|
|
675
|
+
}));
|
|
676
|
+
} catch (error) {
|
|
677
|
+
log(
|
|
678
|
+
`Error getting jobs by tags ${JSON.stringify(tags)} (mode: ${mode}): ${error}`,
|
|
679
|
+
);
|
|
680
|
+
throw error;
|
|
681
|
+
} finally {
|
|
682
|
+
client.release();
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// ── Processing lifecycle ──────────────────────────────────────────────
|
|
687
|
+
|
|
688
|
+
async getNextBatch<PayloadMap, T extends JobType<PayloadMap>>(
|
|
689
|
+
workerId: string,
|
|
690
|
+
batchSize = 10,
|
|
691
|
+
jobType?: string | string[],
|
|
692
|
+
): Promise<JobRecord<PayloadMap, T>[]> {
|
|
693
|
+
const client = await this.pool.connect();
|
|
694
|
+
try {
|
|
695
|
+
await client.query('BEGIN');
|
|
696
|
+
|
|
697
|
+
let jobTypeFilter = '';
|
|
698
|
+
const params: any[] = [workerId, batchSize];
|
|
699
|
+
if (jobType) {
|
|
700
|
+
if (Array.isArray(jobType)) {
|
|
701
|
+
jobTypeFilter = ` AND job_type = ANY($3)`;
|
|
702
|
+
params.push(jobType);
|
|
703
|
+
} else {
|
|
704
|
+
jobTypeFilter = ` AND job_type = $3`;
|
|
705
|
+
params.push(jobType);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const result = await client.query(
|
|
710
|
+
`
|
|
711
|
+
UPDATE job_queue
|
|
712
|
+
SET status = 'processing',
|
|
713
|
+
locked_at = NOW(),
|
|
714
|
+
locked_by = $1,
|
|
715
|
+
attempts = CASE WHEN status = 'waiting' THEN attempts ELSE attempts + 1 END,
|
|
716
|
+
updated_at = NOW(),
|
|
717
|
+
pending_reason = NULL,
|
|
718
|
+
started_at = COALESCE(started_at, NOW()),
|
|
719
|
+
last_retried_at = CASE WHEN status != 'waiting' AND attempts > 0 THEN NOW() ELSE last_retried_at END,
|
|
720
|
+
wait_until = NULL
|
|
721
|
+
WHERE id IN (
|
|
722
|
+
SELECT id FROM job_queue
|
|
723
|
+
WHERE (
|
|
724
|
+
(
|
|
725
|
+
(status = 'pending' OR (status = 'failed' AND next_attempt_at <= NOW()))
|
|
726
|
+
AND (attempts < max_attempts)
|
|
727
|
+
AND run_at <= NOW()
|
|
728
|
+
)
|
|
729
|
+
OR (
|
|
730
|
+
status = 'waiting'
|
|
731
|
+
AND wait_until IS NOT NULL
|
|
732
|
+
AND wait_until <= NOW()
|
|
733
|
+
AND wait_token_id IS NULL
|
|
734
|
+
)
|
|
735
|
+
)
|
|
736
|
+
${jobTypeFilter}
|
|
737
|
+
ORDER BY priority DESC, created_at ASC
|
|
738
|
+
LIMIT $2
|
|
739
|
+
FOR UPDATE SKIP LOCKED
|
|
740
|
+
)
|
|
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"
|
|
742
|
+
`,
|
|
743
|
+
params,
|
|
744
|
+
);
|
|
745
|
+
|
|
746
|
+
log(`Found ${result.rows.length} jobs to process`);
|
|
747
|
+
await client.query('COMMIT');
|
|
748
|
+
|
|
749
|
+
// Batch-insert processing events in a single query
|
|
750
|
+
if (result.rows.length > 0) {
|
|
751
|
+
await this.recordJobEventsBatch(
|
|
752
|
+
result.rows.map((row) => ({
|
|
753
|
+
jobId: row.id,
|
|
754
|
+
eventType: JobEventType.Processing,
|
|
755
|
+
})),
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
return result.rows.map((job) => ({
|
|
760
|
+
...job,
|
|
761
|
+
payload: job.payload,
|
|
762
|
+
timeoutMs: job.timeoutMs,
|
|
763
|
+
forceKillOnTimeout: job.forceKillOnTimeout,
|
|
764
|
+
}));
|
|
765
|
+
} catch (error) {
|
|
766
|
+
log(`Error getting next batch: ${error}`);
|
|
767
|
+
await client.query('ROLLBACK');
|
|
768
|
+
throw error;
|
|
769
|
+
} finally {
|
|
770
|
+
client.release();
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
async completeJob(jobId: number): Promise<void> {
|
|
775
|
+
const client = await this.pool.connect();
|
|
776
|
+
try {
|
|
777
|
+
const result = await client.query(
|
|
778
|
+
`
|
|
779
|
+
UPDATE job_queue
|
|
780
|
+
SET status = 'completed', updated_at = NOW(), completed_at = NOW(),
|
|
781
|
+
step_data = NULL, wait_until = NULL, wait_token_id = NULL
|
|
782
|
+
WHERE id = $1 AND status = 'processing'
|
|
783
|
+
`,
|
|
784
|
+
[jobId],
|
|
785
|
+
);
|
|
786
|
+
if (result.rowCount === 0) {
|
|
787
|
+
log(
|
|
788
|
+
`Job ${jobId} could not be completed (not in processing state or does not exist)`,
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
await this.recordJobEvent(jobId, JobEventType.Completed);
|
|
792
|
+
log(`Completed job ${jobId}`);
|
|
793
|
+
} catch (error) {
|
|
794
|
+
log(`Error completing job ${jobId}: ${error}`);
|
|
795
|
+
throw error;
|
|
796
|
+
} finally {
|
|
797
|
+
client.release();
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
async failJob(
|
|
802
|
+
jobId: number,
|
|
803
|
+
error: Error,
|
|
804
|
+
failureReason?: FailureReason,
|
|
805
|
+
): Promise<void> {
|
|
806
|
+
const client = await this.pool.connect();
|
|
807
|
+
try {
|
|
808
|
+
const result = await client.query(
|
|
809
|
+
`
|
|
810
|
+
UPDATE job_queue
|
|
811
|
+
SET status = 'failed',
|
|
812
|
+
updated_at = NOW(),
|
|
813
|
+
next_attempt_at = CASE
|
|
814
|
+
WHEN attempts >= max_attempts THEN NULL
|
|
815
|
+
WHEN retry_delay IS NULL AND retry_backoff IS NULL AND retry_delay_max IS NULL
|
|
816
|
+
THEN NOW() + (POWER(2, attempts) * INTERVAL '1 minute')
|
|
817
|
+
WHEN COALESCE(retry_backoff, true) = true
|
|
818
|
+
THEN NOW() + (LEAST(
|
|
819
|
+
COALESCE(retry_delay_max, 2147483647),
|
|
820
|
+
COALESCE(retry_delay, 60) * POWER(2, attempts)
|
|
821
|
+
) * (0.5 + 0.5 * random()) * INTERVAL '1 second')
|
|
822
|
+
ELSE
|
|
823
|
+
NOW() + (COALESCE(retry_delay, 60) * INTERVAL '1 second')
|
|
824
|
+
END,
|
|
825
|
+
error_history = COALESCE(error_history, '[]'::jsonb) || $2::jsonb,
|
|
826
|
+
failure_reason = $3,
|
|
827
|
+
last_failed_at = NOW()
|
|
828
|
+
WHERE id = $1 AND status IN ('processing', 'pending')
|
|
829
|
+
`,
|
|
830
|
+
[
|
|
831
|
+
jobId,
|
|
832
|
+
JSON.stringify([
|
|
833
|
+
{
|
|
834
|
+
message: error.message || String(error),
|
|
835
|
+
timestamp: new Date().toISOString(),
|
|
836
|
+
},
|
|
837
|
+
]),
|
|
838
|
+
failureReason ?? null,
|
|
839
|
+
],
|
|
840
|
+
);
|
|
841
|
+
if (result.rowCount === 0) {
|
|
842
|
+
log(
|
|
843
|
+
`Job ${jobId} could not be failed (not in processing/pending state or does not exist)`,
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
await this.recordJobEvent(jobId, JobEventType.Failed, {
|
|
847
|
+
message: error.message || String(error),
|
|
848
|
+
failureReason,
|
|
849
|
+
});
|
|
850
|
+
log(`Failed job ${jobId}`);
|
|
851
|
+
} catch (err) {
|
|
852
|
+
log(`Error failing job ${jobId}: ${err}`);
|
|
853
|
+
throw err;
|
|
854
|
+
} finally {
|
|
855
|
+
client.release();
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
async prolongJob(jobId: number): Promise<void> {
|
|
860
|
+
const client = await this.pool.connect();
|
|
861
|
+
try {
|
|
862
|
+
await client.query(
|
|
863
|
+
`
|
|
864
|
+
UPDATE job_queue
|
|
865
|
+
SET locked_at = NOW(), updated_at = NOW()
|
|
866
|
+
WHERE id = $1 AND status = 'processing'
|
|
867
|
+
`,
|
|
868
|
+
[jobId],
|
|
869
|
+
);
|
|
870
|
+
await this.recordJobEvent(jobId, JobEventType.Prolonged);
|
|
871
|
+
log(`Prolonged job ${jobId}`);
|
|
872
|
+
} catch (error) {
|
|
873
|
+
log(`Error prolonging job ${jobId}: ${error}`);
|
|
874
|
+
// Do not throw -- prolong is best-effort
|
|
875
|
+
} finally {
|
|
876
|
+
client.release();
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// ── Progress ──────────────────────────────────────────────────────────
|
|
881
|
+
|
|
882
|
+
async updateProgress(jobId: number, progress: number): Promise<void> {
|
|
883
|
+
const client = await this.pool.connect();
|
|
884
|
+
try {
|
|
885
|
+
await client.query(
|
|
886
|
+
`UPDATE job_queue SET progress = $2, updated_at = NOW() WHERE id = $1`,
|
|
887
|
+
[jobId, progress],
|
|
888
|
+
);
|
|
889
|
+
log(`Updated progress for job ${jobId}: ${progress}%`);
|
|
890
|
+
} catch (error) {
|
|
891
|
+
log(`Error updating progress for job ${jobId}: ${error}`);
|
|
892
|
+
// Best-effort: do not throw to avoid killing the running handler
|
|
893
|
+
} finally {
|
|
894
|
+
client.release();
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// ── Job management ────────────────────────────────────────────────────
|
|
899
|
+
|
|
900
|
+
async retryJob(jobId: number): Promise<void> {
|
|
901
|
+
const client = await this.pool.connect();
|
|
902
|
+
try {
|
|
903
|
+
const result = await client.query(
|
|
904
|
+
`
|
|
905
|
+
UPDATE job_queue
|
|
906
|
+
SET status = 'pending',
|
|
907
|
+
updated_at = NOW(),
|
|
908
|
+
locked_at = NULL,
|
|
909
|
+
locked_by = NULL,
|
|
910
|
+
next_attempt_at = NOW(),
|
|
911
|
+
last_retried_at = NOW()
|
|
912
|
+
WHERE id = $1 AND status IN ('failed', 'processing')
|
|
913
|
+
`,
|
|
914
|
+
[jobId],
|
|
915
|
+
);
|
|
916
|
+
if (result.rowCount === 0) {
|
|
917
|
+
log(
|
|
918
|
+
`Job ${jobId} could not be retried (not in failed/processing state or does not exist)`,
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
await this.recordJobEvent(jobId, JobEventType.Retried);
|
|
922
|
+
log(`Retried job ${jobId}`);
|
|
923
|
+
} catch (error) {
|
|
924
|
+
log(`Error retrying job ${jobId}: ${error}`);
|
|
925
|
+
throw error;
|
|
926
|
+
} finally {
|
|
927
|
+
client.release();
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
async cancelJob(jobId: number): Promise<void> {
|
|
932
|
+
const client = await this.pool.connect();
|
|
933
|
+
try {
|
|
934
|
+
await client.query(
|
|
935
|
+
`
|
|
936
|
+
UPDATE job_queue
|
|
937
|
+
SET status = 'cancelled', updated_at = NOW(), last_cancelled_at = NOW(),
|
|
938
|
+
wait_until = NULL, wait_token_id = NULL
|
|
939
|
+
WHERE id = $1 AND status IN ('pending', 'waiting')
|
|
940
|
+
`,
|
|
941
|
+
[jobId],
|
|
942
|
+
);
|
|
943
|
+
await this.recordJobEvent(jobId, JobEventType.Cancelled);
|
|
944
|
+
log(`Cancelled job ${jobId}`);
|
|
945
|
+
} catch (error) {
|
|
946
|
+
log(`Error cancelling job ${jobId}: ${error}`);
|
|
947
|
+
throw error;
|
|
948
|
+
} finally {
|
|
949
|
+
client.release();
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
async cancelAllUpcomingJobs(filters?: JobFilters): Promise<number> {
|
|
954
|
+
const client = await this.pool.connect();
|
|
955
|
+
try {
|
|
956
|
+
let query = `
|
|
957
|
+
UPDATE job_queue
|
|
958
|
+
SET status = 'cancelled', updated_at = NOW()
|
|
959
|
+
WHERE status = 'pending'`;
|
|
960
|
+
const params: any[] = [];
|
|
961
|
+
let paramIdx = 1;
|
|
962
|
+
if (filters) {
|
|
963
|
+
if (filters.jobType) {
|
|
964
|
+
query += ` AND job_type = $${paramIdx++}`;
|
|
965
|
+
params.push(filters.jobType);
|
|
966
|
+
}
|
|
967
|
+
if (filters.priority !== undefined) {
|
|
968
|
+
query += ` AND priority = $${paramIdx++}`;
|
|
969
|
+
params.push(filters.priority);
|
|
970
|
+
}
|
|
971
|
+
if (filters.runAt) {
|
|
972
|
+
if (filters.runAt instanceof Date) {
|
|
973
|
+
query += ` AND run_at = $${paramIdx++}`;
|
|
974
|
+
params.push(filters.runAt);
|
|
975
|
+
} else if (typeof filters.runAt === 'object') {
|
|
976
|
+
const ops = filters.runAt;
|
|
977
|
+
if (ops.gt) {
|
|
978
|
+
query += ` AND run_at > $${paramIdx++}`;
|
|
979
|
+
params.push(ops.gt);
|
|
980
|
+
}
|
|
981
|
+
if (ops.gte) {
|
|
982
|
+
query += ` AND run_at >= $${paramIdx++}`;
|
|
983
|
+
params.push(ops.gte);
|
|
984
|
+
}
|
|
985
|
+
if (ops.lt) {
|
|
986
|
+
query += ` AND run_at < $${paramIdx++}`;
|
|
987
|
+
params.push(ops.lt);
|
|
988
|
+
}
|
|
989
|
+
if (ops.lte) {
|
|
990
|
+
query += ` AND run_at <= $${paramIdx++}`;
|
|
991
|
+
params.push(ops.lte);
|
|
992
|
+
}
|
|
993
|
+
if (ops.eq) {
|
|
994
|
+
query += ` AND run_at = $${paramIdx++}`;
|
|
995
|
+
params.push(ops.eq);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
if (
|
|
1000
|
+
filters.tags &&
|
|
1001
|
+
filters.tags.values &&
|
|
1002
|
+
filters.tags.values.length > 0
|
|
1003
|
+
) {
|
|
1004
|
+
const mode = filters.tags.mode || 'all';
|
|
1005
|
+
const tagValues = filters.tags.values;
|
|
1006
|
+
switch (mode) {
|
|
1007
|
+
case 'exact':
|
|
1008
|
+
query += ` AND tags = $${paramIdx++}`;
|
|
1009
|
+
params.push(tagValues);
|
|
1010
|
+
break;
|
|
1011
|
+
case 'all':
|
|
1012
|
+
query += ` AND tags @> $${paramIdx++}`;
|
|
1013
|
+
params.push(tagValues);
|
|
1014
|
+
break;
|
|
1015
|
+
case 'any':
|
|
1016
|
+
query += ` AND tags && $${paramIdx++}`;
|
|
1017
|
+
params.push(tagValues);
|
|
1018
|
+
break;
|
|
1019
|
+
case 'none':
|
|
1020
|
+
query += ` AND NOT (tags && $${paramIdx++})`;
|
|
1021
|
+
params.push(tagValues);
|
|
1022
|
+
break;
|
|
1023
|
+
default:
|
|
1024
|
+
query += ` AND tags @> $${paramIdx++}`;
|
|
1025
|
+
params.push(tagValues);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
query += '\nRETURNING id';
|
|
1030
|
+
const result = await client.query(query, params);
|
|
1031
|
+
log(`Cancelled ${result.rowCount} jobs`);
|
|
1032
|
+
return result.rowCount || 0;
|
|
1033
|
+
} catch (error) {
|
|
1034
|
+
log(`Error cancelling upcoming jobs: ${error}`);
|
|
1035
|
+
throw error;
|
|
1036
|
+
} finally {
|
|
1037
|
+
client.release();
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
async editJob(jobId: number, updates: JobUpdates): Promise<void> {
|
|
1042
|
+
const client = await this.pool.connect();
|
|
1043
|
+
try {
|
|
1044
|
+
const updateFields: string[] = [];
|
|
1045
|
+
const params: any[] = [];
|
|
1046
|
+
let paramIdx = 1;
|
|
1047
|
+
|
|
1048
|
+
if (updates.payload !== undefined) {
|
|
1049
|
+
updateFields.push(`payload = $${paramIdx++}`);
|
|
1050
|
+
params.push(updates.payload);
|
|
1051
|
+
}
|
|
1052
|
+
if (updates.maxAttempts !== undefined) {
|
|
1053
|
+
updateFields.push(`max_attempts = $${paramIdx++}`);
|
|
1054
|
+
params.push(updates.maxAttempts);
|
|
1055
|
+
}
|
|
1056
|
+
if (updates.priority !== undefined) {
|
|
1057
|
+
updateFields.push(`priority = $${paramIdx++}`);
|
|
1058
|
+
params.push(updates.priority);
|
|
1059
|
+
}
|
|
1060
|
+
if (updates.runAt !== undefined) {
|
|
1061
|
+
if (updates.runAt === null) {
|
|
1062
|
+
updateFields.push(`run_at = NOW()`);
|
|
1063
|
+
} else {
|
|
1064
|
+
updateFields.push(`run_at = $${paramIdx++}`);
|
|
1065
|
+
params.push(updates.runAt);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
if (updates.timeoutMs !== undefined) {
|
|
1069
|
+
updateFields.push(`timeout_ms = $${paramIdx++}`);
|
|
1070
|
+
params.push(updates.timeoutMs ?? null);
|
|
1071
|
+
}
|
|
1072
|
+
if (updates.tags !== undefined) {
|
|
1073
|
+
updateFields.push(`tags = $${paramIdx++}`);
|
|
1074
|
+
params.push(updates.tags ?? null);
|
|
1075
|
+
}
|
|
1076
|
+
if (updates.retryDelay !== undefined) {
|
|
1077
|
+
updateFields.push(`retry_delay = $${paramIdx++}`);
|
|
1078
|
+
params.push(updates.retryDelay ?? null);
|
|
1079
|
+
}
|
|
1080
|
+
if (updates.retryBackoff !== undefined) {
|
|
1081
|
+
updateFields.push(`retry_backoff = $${paramIdx++}`);
|
|
1082
|
+
params.push(updates.retryBackoff ?? null);
|
|
1083
|
+
}
|
|
1084
|
+
if (updates.retryDelayMax !== undefined) {
|
|
1085
|
+
updateFields.push(`retry_delay_max = $${paramIdx++}`);
|
|
1086
|
+
params.push(updates.retryDelayMax ?? null);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
if (updateFields.length === 0) {
|
|
1090
|
+
log(`No fields to update for job ${jobId}`);
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
updateFields.push(`updated_at = NOW()`);
|
|
1095
|
+
params.push(jobId);
|
|
1096
|
+
|
|
1097
|
+
const query = `
|
|
1098
|
+
UPDATE job_queue
|
|
1099
|
+
SET ${updateFields.join(', ')}
|
|
1100
|
+
WHERE id = $${paramIdx} AND status = 'pending'
|
|
1101
|
+
`;
|
|
1102
|
+
|
|
1103
|
+
await client.query(query, params);
|
|
1104
|
+
|
|
1105
|
+
const metadata: any = {};
|
|
1106
|
+
if (updates.payload !== undefined) metadata.payload = updates.payload;
|
|
1107
|
+
if (updates.maxAttempts !== undefined)
|
|
1108
|
+
metadata.maxAttempts = updates.maxAttempts;
|
|
1109
|
+
if (updates.priority !== undefined) metadata.priority = updates.priority;
|
|
1110
|
+
if (updates.runAt !== undefined) metadata.runAt = updates.runAt;
|
|
1111
|
+
if (updates.timeoutMs !== undefined)
|
|
1112
|
+
metadata.timeoutMs = updates.timeoutMs;
|
|
1113
|
+
if (updates.tags !== undefined) metadata.tags = updates.tags;
|
|
1114
|
+
if (updates.retryDelay !== undefined)
|
|
1115
|
+
metadata.retryDelay = updates.retryDelay;
|
|
1116
|
+
if (updates.retryBackoff !== undefined)
|
|
1117
|
+
metadata.retryBackoff = updates.retryBackoff;
|
|
1118
|
+
if (updates.retryDelayMax !== undefined)
|
|
1119
|
+
metadata.retryDelayMax = updates.retryDelayMax;
|
|
1120
|
+
|
|
1121
|
+
await this.recordJobEvent(jobId, JobEventType.Edited, metadata);
|
|
1122
|
+
log(`Edited job ${jobId}: ${JSON.stringify(metadata)}`);
|
|
1123
|
+
} catch (error) {
|
|
1124
|
+
log(`Error editing job ${jobId}: ${error}`);
|
|
1125
|
+
throw error;
|
|
1126
|
+
} finally {
|
|
1127
|
+
client.release();
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
async editAllPendingJobs(
|
|
1132
|
+
filters: JobFilters | undefined = undefined,
|
|
1133
|
+
updates: JobUpdates,
|
|
1134
|
+
): Promise<number> {
|
|
1135
|
+
const client = await this.pool.connect();
|
|
1136
|
+
try {
|
|
1137
|
+
const updateFields: string[] = [];
|
|
1138
|
+
const params: any[] = [];
|
|
1139
|
+
let paramIdx = 1;
|
|
1140
|
+
|
|
1141
|
+
if (updates.payload !== undefined) {
|
|
1142
|
+
updateFields.push(`payload = $${paramIdx++}`);
|
|
1143
|
+
params.push(updates.payload);
|
|
1144
|
+
}
|
|
1145
|
+
if (updates.maxAttempts !== undefined) {
|
|
1146
|
+
updateFields.push(`max_attempts = $${paramIdx++}`);
|
|
1147
|
+
params.push(updates.maxAttempts);
|
|
1148
|
+
}
|
|
1149
|
+
if (updates.priority !== undefined) {
|
|
1150
|
+
updateFields.push(`priority = $${paramIdx++}`);
|
|
1151
|
+
params.push(updates.priority);
|
|
1152
|
+
}
|
|
1153
|
+
if (updates.runAt !== undefined) {
|
|
1154
|
+
if (updates.runAt === null) {
|
|
1155
|
+
updateFields.push(`run_at = NOW()`);
|
|
1156
|
+
} else {
|
|
1157
|
+
updateFields.push(`run_at = $${paramIdx++}`);
|
|
1158
|
+
params.push(updates.runAt);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
if (updates.timeoutMs !== undefined) {
|
|
1162
|
+
updateFields.push(`timeout_ms = $${paramIdx++}`);
|
|
1163
|
+
params.push(updates.timeoutMs ?? null);
|
|
1164
|
+
}
|
|
1165
|
+
if (updates.tags !== undefined) {
|
|
1166
|
+
updateFields.push(`tags = $${paramIdx++}`);
|
|
1167
|
+
params.push(updates.tags ?? null);
|
|
1168
|
+
}
|
|
1169
|
+
if (updates.retryDelay !== undefined) {
|
|
1170
|
+
updateFields.push(`retry_delay = $${paramIdx++}`);
|
|
1171
|
+
params.push(updates.retryDelay ?? null);
|
|
1172
|
+
}
|
|
1173
|
+
if (updates.retryBackoff !== undefined) {
|
|
1174
|
+
updateFields.push(`retry_backoff = $${paramIdx++}`);
|
|
1175
|
+
params.push(updates.retryBackoff ?? null);
|
|
1176
|
+
}
|
|
1177
|
+
if (updates.retryDelayMax !== undefined) {
|
|
1178
|
+
updateFields.push(`retry_delay_max = $${paramIdx++}`);
|
|
1179
|
+
params.push(updates.retryDelayMax ?? null);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
if (updateFields.length === 0) {
|
|
1183
|
+
log(`No fields to update for batch edit`);
|
|
1184
|
+
return 0;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
updateFields.push(`updated_at = NOW()`);
|
|
1188
|
+
|
|
1189
|
+
let query = `
|
|
1190
|
+
UPDATE job_queue
|
|
1191
|
+
SET ${updateFields.join(', ')}
|
|
1192
|
+
WHERE status = 'pending'`;
|
|
1193
|
+
|
|
1194
|
+
if (filters) {
|
|
1195
|
+
if (filters.jobType) {
|
|
1196
|
+
query += ` AND job_type = $${paramIdx++}`;
|
|
1197
|
+
params.push(filters.jobType);
|
|
1198
|
+
}
|
|
1199
|
+
if (filters.priority !== undefined) {
|
|
1200
|
+
query += ` AND priority = $${paramIdx++}`;
|
|
1201
|
+
params.push(filters.priority);
|
|
1202
|
+
}
|
|
1203
|
+
if (filters.runAt) {
|
|
1204
|
+
if (filters.runAt instanceof Date) {
|
|
1205
|
+
query += ` AND run_at = $${paramIdx++}`;
|
|
1206
|
+
params.push(filters.runAt);
|
|
1207
|
+
} else if (typeof filters.runAt === 'object') {
|
|
1208
|
+
const ops = filters.runAt;
|
|
1209
|
+
if (ops.gt) {
|
|
1210
|
+
query += ` AND run_at > $${paramIdx++}`;
|
|
1211
|
+
params.push(ops.gt);
|
|
1212
|
+
}
|
|
1213
|
+
if (ops.gte) {
|
|
1214
|
+
query += ` AND run_at >= $${paramIdx++}`;
|
|
1215
|
+
params.push(ops.gte);
|
|
1216
|
+
}
|
|
1217
|
+
if (ops.lt) {
|
|
1218
|
+
query += ` AND run_at < $${paramIdx++}`;
|
|
1219
|
+
params.push(ops.lt);
|
|
1220
|
+
}
|
|
1221
|
+
if (ops.lte) {
|
|
1222
|
+
query += ` AND run_at <= $${paramIdx++}`;
|
|
1223
|
+
params.push(ops.lte);
|
|
1224
|
+
}
|
|
1225
|
+
if (ops.eq) {
|
|
1226
|
+
query += ` AND run_at = $${paramIdx++}`;
|
|
1227
|
+
params.push(ops.eq);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
if (
|
|
1232
|
+
filters.tags &&
|
|
1233
|
+
filters.tags.values &&
|
|
1234
|
+
filters.tags.values.length > 0
|
|
1235
|
+
) {
|
|
1236
|
+
const mode = filters.tags.mode || 'all';
|
|
1237
|
+
const tagValues = filters.tags.values;
|
|
1238
|
+
switch (mode) {
|
|
1239
|
+
case 'exact':
|
|
1240
|
+
query += ` AND tags = $${paramIdx++}`;
|
|
1241
|
+
params.push(tagValues);
|
|
1242
|
+
break;
|
|
1243
|
+
case 'all':
|
|
1244
|
+
query += ` AND tags @> $${paramIdx++}`;
|
|
1245
|
+
params.push(tagValues);
|
|
1246
|
+
break;
|
|
1247
|
+
case 'any':
|
|
1248
|
+
query += ` AND tags && $${paramIdx++}`;
|
|
1249
|
+
params.push(tagValues);
|
|
1250
|
+
break;
|
|
1251
|
+
case 'none':
|
|
1252
|
+
query += ` AND NOT (tags && $${paramIdx++})`;
|
|
1253
|
+
params.push(tagValues);
|
|
1254
|
+
break;
|
|
1255
|
+
default:
|
|
1256
|
+
query += ` AND tags @> $${paramIdx++}`;
|
|
1257
|
+
params.push(tagValues);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
query += '\nRETURNING id';
|
|
1262
|
+
|
|
1263
|
+
const result = await client.query(query, params);
|
|
1264
|
+
const editedCount = result.rowCount || 0;
|
|
1265
|
+
|
|
1266
|
+
const metadata: any = {};
|
|
1267
|
+
if (updates.payload !== undefined) metadata.payload = updates.payload;
|
|
1268
|
+
if (updates.maxAttempts !== undefined)
|
|
1269
|
+
metadata.maxAttempts = updates.maxAttempts;
|
|
1270
|
+
if (updates.priority !== undefined) metadata.priority = updates.priority;
|
|
1271
|
+
if (updates.runAt !== undefined) metadata.runAt = updates.runAt;
|
|
1272
|
+
if (updates.timeoutMs !== undefined)
|
|
1273
|
+
metadata.timeoutMs = updates.timeoutMs;
|
|
1274
|
+
if (updates.tags !== undefined) metadata.tags = updates.tags;
|
|
1275
|
+
|
|
1276
|
+
for (const row of result.rows) {
|
|
1277
|
+
await this.recordJobEvent(row.id, JobEventType.Edited, metadata);
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
log(`Edited ${editedCount} pending jobs: ${JSON.stringify(metadata)}`);
|
|
1281
|
+
return editedCount;
|
|
1282
|
+
} catch (error) {
|
|
1283
|
+
log(`Error editing pending jobs: ${error}`);
|
|
1284
|
+
throw error;
|
|
1285
|
+
} finally {
|
|
1286
|
+
client.release();
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/**
|
|
1291
|
+
* Delete completed jobs older than the given number of days.
|
|
1292
|
+
* Deletes in batches of 1000 to avoid long-running transactions
|
|
1293
|
+
* and excessive WAL bloat at scale.
|
|
1294
|
+
*
|
|
1295
|
+
* @param daysToKeep - Number of days to retain completed jobs (default 30).
|
|
1296
|
+
* @param batchSize - Number of rows to delete per batch (default 1000).
|
|
1297
|
+
* @returns Total number of deleted jobs.
|
|
1298
|
+
*/
|
|
1299
|
+
async cleanupOldJobs(daysToKeep = 30, batchSize = 1000): Promise<number> {
|
|
1300
|
+
let totalDeleted = 0;
|
|
1301
|
+
|
|
1302
|
+
try {
|
|
1303
|
+
let deletedInBatch: number;
|
|
1304
|
+
do {
|
|
1305
|
+
const client = await this.pool.connect();
|
|
1306
|
+
try {
|
|
1307
|
+
const result = await client.query(
|
|
1308
|
+
`
|
|
1309
|
+
DELETE FROM job_queue
|
|
1310
|
+
WHERE id IN (
|
|
1311
|
+
SELECT id FROM job_queue
|
|
1312
|
+
WHERE status = 'completed'
|
|
1313
|
+
AND updated_at < NOW() - INTERVAL '1 day' * $1::int
|
|
1314
|
+
LIMIT $2
|
|
1315
|
+
)
|
|
1316
|
+
`,
|
|
1317
|
+
[daysToKeep, batchSize],
|
|
1318
|
+
);
|
|
1319
|
+
deletedInBatch = result.rowCount || 0;
|
|
1320
|
+
totalDeleted += deletedInBatch;
|
|
1321
|
+
} finally {
|
|
1322
|
+
client.release();
|
|
1323
|
+
}
|
|
1324
|
+
} while (deletedInBatch === batchSize);
|
|
1325
|
+
|
|
1326
|
+
log(`Deleted ${totalDeleted} old jobs`);
|
|
1327
|
+
return totalDeleted;
|
|
1328
|
+
} catch (error) {
|
|
1329
|
+
log(`Error cleaning up old jobs: ${error}`);
|
|
1330
|
+
throw error;
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/**
|
|
1335
|
+
* Delete job events older than the given number of days.
|
|
1336
|
+
* Deletes in batches of 1000 to avoid long-running transactions
|
|
1337
|
+
* and excessive WAL bloat at scale.
|
|
1338
|
+
*
|
|
1339
|
+
* @param daysToKeep - Number of days to retain events (default 30).
|
|
1340
|
+
* @param batchSize - Number of rows to delete per batch (default 1000).
|
|
1341
|
+
* @returns Total number of deleted events.
|
|
1342
|
+
*/
|
|
1343
|
+
async cleanupOldJobEvents(
|
|
1344
|
+
daysToKeep = 30,
|
|
1345
|
+
batchSize = 1000,
|
|
1346
|
+
): Promise<number> {
|
|
1347
|
+
let totalDeleted = 0;
|
|
1348
|
+
|
|
1349
|
+
try {
|
|
1350
|
+
let deletedInBatch: number;
|
|
1351
|
+
do {
|
|
1352
|
+
const client = await this.pool.connect();
|
|
1353
|
+
try {
|
|
1354
|
+
const result = await client.query(
|
|
1355
|
+
`
|
|
1356
|
+
DELETE FROM job_events
|
|
1357
|
+
WHERE id IN (
|
|
1358
|
+
SELECT id FROM job_events
|
|
1359
|
+
WHERE created_at < NOW() - INTERVAL '1 day' * $1::int
|
|
1360
|
+
LIMIT $2
|
|
1361
|
+
)
|
|
1362
|
+
`,
|
|
1363
|
+
[daysToKeep, batchSize],
|
|
1364
|
+
);
|
|
1365
|
+
deletedInBatch = result.rowCount || 0;
|
|
1366
|
+
totalDeleted += deletedInBatch;
|
|
1367
|
+
} finally {
|
|
1368
|
+
client.release();
|
|
1369
|
+
}
|
|
1370
|
+
} while (deletedInBatch === batchSize);
|
|
1371
|
+
|
|
1372
|
+
log(`Deleted ${totalDeleted} old job events`);
|
|
1373
|
+
return totalDeleted;
|
|
1374
|
+
} catch (error) {
|
|
1375
|
+
log(`Error cleaning up old job events: ${error}`);
|
|
1376
|
+
throw error;
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
async reclaimStuckJobs(maxProcessingTimeMinutes = 10): Promise<number> {
|
|
1381
|
+
const client = await this.pool.connect();
|
|
1382
|
+
try {
|
|
1383
|
+
const result = await client.query(
|
|
1384
|
+
`
|
|
1385
|
+
UPDATE job_queue
|
|
1386
|
+
SET status = 'pending', locked_at = NULL, locked_by = NULL, updated_at = NOW()
|
|
1387
|
+
WHERE status = 'processing'
|
|
1388
|
+
AND locked_at < NOW() - GREATEST(
|
|
1389
|
+
INTERVAL '1 minute' * $1::int,
|
|
1390
|
+
INTERVAL '1 millisecond' * COALESCE(timeout_ms, 0)
|
|
1391
|
+
)
|
|
1392
|
+
RETURNING id
|
|
1393
|
+
`,
|
|
1394
|
+
[maxProcessingTimeMinutes],
|
|
1395
|
+
);
|
|
1396
|
+
log(`Reclaimed ${result.rowCount} stuck jobs`);
|
|
1397
|
+
return result.rowCount || 0;
|
|
1398
|
+
} catch (error) {
|
|
1399
|
+
log(`Error reclaiming stuck jobs: ${error}`);
|
|
1400
|
+
throw error;
|
|
1401
|
+
} finally {
|
|
1402
|
+
client.release();
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// ── Internal helpers ──────────────────────────────────────────────────
|
|
1407
|
+
|
|
1408
|
+
/**
|
|
1409
|
+
* Batch-insert multiple job events in a single query.
|
|
1410
|
+
* More efficient than individual recordJobEvent calls.
|
|
1411
|
+
*/
|
|
1412
|
+
private async recordJobEventsBatch(
|
|
1413
|
+
events: { jobId: number; eventType: JobEventType; metadata?: any }[],
|
|
1414
|
+
): Promise<void> {
|
|
1415
|
+
if (events.length === 0) return;
|
|
1416
|
+
const client = await this.pool.connect();
|
|
1417
|
+
try {
|
|
1418
|
+
const values: string[] = [];
|
|
1419
|
+
const params: any[] = [];
|
|
1420
|
+
let paramIdx = 1;
|
|
1421
|
+
for (const event of events) {
|
|
1422
|
+
values.push(`($${paramIdx++}, $${paramIdx++}, $${paramIdx++})`);
|
|
1423
|
+
params.push(
|
|
1424
|
+
event.jobId,
|
|
1425
|
+
event.eventType,
|
|
1426
|
+
event.metadata ? JSON.stringify(event.metadata) : null,
|
|
1427
|
+
);
|
|
1428
|
+
}
|
|
1429
|
+
await client.query(
|
|
1430
|
+
`INSERT INTO job_events (job_id, event_type, metadata) VALUES ${values.join(', ')}`,
|
|
1431
|
+
params,
|
|
1432
|
+
);
|
|
1433
|
+
} catch (error) {
|
|
1434
|
+
log(`Error recording batch job events: ${error}`);
|
|
1435
|
+
// Do not throw, to avoid interfering with main job logic
|
|
1436
|
+
} finally {
|
|
1437
|
+
client.release();
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
// ── Cron schedules ──────────────────────────────────────────────────
|
|
1442
|
+
|
|
1443
|
+
/** Create a cron schedule and return its ID. */
|
|
1444
|
+
async addCronSchedule(input: CronScheduleInput): Promise<number> {
|
|
1445
|
+
const client = await this.pool.connect();
|
|
1446
|
+
try {
|
|
1447
|
+
const result = await client.query(
|
|
1448
|
+
`INSERT INTO cron_schedules
|
|
1449
|
+
(schedule_name, cron_expression, job_type, payload, max_attempts,
|
|
1450
|
+
priority, timeout_ms, force_kill_on_timeout, tags, timezone,
|
|
1451
|
+
allow_overlap, next_run_at, retry_delay, retry_backoff, retry_delay_max)
|
|
1452
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
|
1453
|
+
RETURNING id`,
|
|
1454
|
+
[
|
|
1455
|
+
input.scheduleName,
|
|
1456
|
+
input.cronExpression,
|
|
1457
|
+
input.jobType,
|
|
1458
|
+
input.payload,
|
|
1459
|
+
input.maxAttempts,
|
|
1460
|
+
input.priority,
|
|
1461
|
+
input.timeoutMs,
|
|
1462
|
+
input.forceKillOnTimeout,
|
|
1463
|
+
input.tags ?? null,
|
|
1464
|
+
input.timezone,
|
|
1465
|
+
input.allowOverlap,
|
|
1466
|
+
input.nextRunAt,
|
|
1467
|
+
input.retryDelay,
|
|
1468
|
+
input.retryBackoff,
|
|
1469
|
+
input.retryDelayMax,
|
|
1470
|
+
],
|
|
1471
|
+
);
|
|
1472
|
+
const id = result.rows[0].id;
|
|
1473
|
+
log(`Added cron schedule ${id}: "${input.scheduleName}"`);
|
|
1474
|
+
return id;
|
|
1475
|
+
} catch (error: any) {
|
|
1476
|
+
// Unique constraint violation on schedule_name
|
|
1477
|
+
if (error?.code === '23505') {
|
|
1478
|
+
throw new Error(
|
|
1479
|
+
`Cron schedule with name "${input.scheduleName}" already exists`,
|
|
1480
|
+
);
|
|
1481
|
+
}
|
|
1482
|
+
log(`Error adding cron schedule: ${error}`);
|
|
1483
|
+
throw error;
|
|
1484
|
+
} finally {
|
|
1485
|
+
client.release();
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
/** Get a cron schedule by ID. */
|
|
1490
|
+
async getCronSchedule(id: number): Promise<CronScheduleRecord | null> {
|
|
1491
|
+
const client = await this.pool.connect();
|
|
1492
|
+
try {
|
|
1493
|
+
const result = await client.query(
|
|
1494
|
+
`SELECT id, schedule_name AS "scheduleName", cron_expression AS "cronExpression",
|
|
1495
|
+
job_type AS "jobType", payload, max_attempts AS "maxAttempts",
|
|
1496
|
+
priority, timeout_ms AS "timeoutMs",
|
|
1497
|
+
force_kill_on_timeout AS "forceKillOnTimeout", tags,
|
|
1498
|
+
timezone, allow_overlap AS "allowOverlap", status,
|
|
1499
|
+
last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
|
|
1500
|
+
next_run_at AS "nextRunAt",
|
|
1501
|
+
created_at AS "createdAt", updated_at AS "updatedAt",
|
|
1502
|
+
retry_delay AS "retryDelay", retry_backoff AS "retryBackoff",
|
|
1503
|
+
retry_delay_max AS "retryDelayMax"
|
|
1504
|
+
FROM cron_schedules WHERE id = $1`,
|
|
1505
|
+
[id],
|
|
1506
|
+
);
|
|
1507
|
+
if (result.rows.length === 0) return null;
|
|
1508
|
+
return result.rows[0] as CronScheduleRecord;
|
|
1509
|
+
} catch (error) {
|
|
1510
|
+
log(`Error getting cron schedule ${id}: ${error}`);
|
|
1511
|
+
throw error;
|
|
1512
|
+
} finally {
|
|
1513
|
+
client.release();
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
/** Get a cron schedule by its unique name. */
|
|
1518
|
+
async getCronScheduleByName(
|
|
1519
|
+
name: string,
|
|
1520
|
+
): Promise<CronScheduleRecord | null> {
|
|
1521
|
+
const client = await this.pool.connect();
|
|
1522
|
+
try {
|
|
1523
|
+
const result = await client.query(
|
|
1524
|
+
`SELECT id, schedule_name AS "scheduleName", cron_expression AS "cronExpression",
|
|
1525
|
+
job_type AS "jobType", payload, max_attempts AS "maxAttempts",
|
|
1526
|
+
priority, timeout_ms AS "timeoutMs",
|
|
1527
|
+
force_kill_on_timeout AS "forceKillOnTimeout", tags,
|
|
1528
|
+
timezone, allow_overlap AS "allowOverlap", status,
|
|
1529
|
+
last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
|
|
1530
|
+
next_run_at AS "nextRunAt",
|
|
1531
|
+
created_at AS "createdAt", updated_at AS "updatedAt",
|
|
1532
|
+
retry_delay AS "retryDelay", retry_backoff AS "retryBackoff",
|
|
1533
|
+
retry_delay_max AS "retryDelayMax"
|
|
1534
|
+
FROM cron_schedules WHERE schedule_name = $1`,
|
|
1535
|
+
[name],
|
|
1536
|
+
);
|
|
1537
|
+
if (result.rows.length === 0) return null;
|
|
1538
|
+
return result.rows[0] as CronScheduleRecord;
|
|
1539
|
+
} catch (error) {
|
|
1540
|
+
log(`Error getting cron schedule by name "${name}": ${error}`);
|
|
1541
|
+
throw error;
|
|
1542
|
+
} finally {
|
|
1543
|
+
client.release();
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
/** List cron schedules, optionally filtered by status. */
|
|
1548
|
+
async listCronSchedules(
|
|
1549
|
+
status?: CronScheduleStatus,
|
|
1550
|
+
): Promise<CronScheduleRecord[]> {
|
|
1551
|
+
const client = await this.pool.connect();
|
|
1552
|
+
try {
|
|
1553
|
+
let query = `SELECT id, schedule_name AS "scheduleName", cron_expression AS "cronExpression",
|
|
1554
|
+
job_type AS "jobType", payload, max_attempts AS "maxAttempts",
|
|
1555
|
+
priority, timeout_ms AS "timeoutMs",
|
|
1556
|
+
force_kill_on_timeout AS "forceKillOnTimeout", tags,
|
|
1557
|
+
timezone, allow_overlap AS "allowOverlap", status,
|
|
1558
|
+
last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
|
|
1559
|
+
next_run_at AS "nextRunAt",
|
|
1560
|
+
created_at AS "createdAt", updated_at AS "updatedAt",
|
|
1561
|
+
retry_delay AS "retryDelay", retry_backoff AS "retryBackoff",
|
|
1562
|
+
retry_delay_max AS "retryDelayMax"
|
|
1563
|
+
FROM cron_schedules`;
|
|
1564
|
+
const params: any[] = [];
|
|
1565
|
+
if (status) {
|
|
1566
|
+
query += ` WHERE status = $1`;
|
|
1567
|
+
params.push(status);
|
|
1568
|
+
}
|
|
1569
|
+
query += ` ORDER BY created_at ASC`;
|
|
1570
|
+
const result = await client.query(query, params);
|
|
1571
|
+
return result.rows as CronScheduleRecord[];
|
|
1572
|
+
} catch (error) {
|
|
1573
|
+
log(`Error listing cron schedules: ${error}`);
|
|
1574
|
+
throw error;
|
|
1575
|
+
} finally {
|
|
1576
|
+
client.release();
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
/** Delete a cron schedule by ID. */
|
|
1581
|
+
async removeCronSchedule(id: number): Promise<void> {
|
|
1582
|
+
const client = await this.pool.connect();
|
|
1583
|
+
try {
|
|
1584
|
+
await client.query(`DELETE FROM cron_schedules WHERE id = $1`, [id]);
|
|
1585
|
+
log(`Removed cron schedule ${id}`);
|
|
1586
|
+
} catch (error) {
|
|
1587
|
+
log(`Error removing cron schedule ${id}: ${error}`);
|
|
1588
|
+
throw error;
|
|
1589
|
+
} finally {
|
|
1590
|
+
client.release();
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
/** Pause a cron schedule. */
|
|
1595
|
+
async pauseCronSchedule(id: number): Promise<void> {
|
|
1596
|
+
const client = await this.pool.connect();
|
|
1597
|
+
try {
|
|
1598
|
+
await client.query(
|
|
1599
|
+
`UPDATE cron_schedules SET status = 'paused', updated_at = NOW() WHERE id = $1`,
|
|
1600
|
+
[id],
|
|
1601
|
+
);
|
|
1602
|
+
log(`Paused cron schedule ${id}`);
|
|
1603
|
+
} catch (error) {
|
|
1604
|
+
log(`Error pausing cron schedule ${id}: ${error}`);
|
|
1605
|
+
throw error;
|
|
1606
|
+
} finally {
|
|
1607
|
+
client.release();
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
/** Resume a paused cron schedule. */
|
|
1612
|
+
async resumeCronSchedule(id: number): Promise<void> {
|
|
1613
|
+
const client = await this.pool.connect();
|
|
1614
|
+
try {
|
|
1615
|
+
await client.query(
|
|
1616
|
+
`UPDATE cron_schedules SET status = 'active', updated_at = NOW() WHERE id = $1`,
|
|
1617
|
+
[id],
|
|
1618
|
+
);
|
|
1619
|
+
log(`Resumed cron schedule ${id}`);
|
|
1620
|
+
} catch (error) {
|
|
1621
|
+
log(`Error resuming cron schedule ${id}: ${error}`);
|
|
1622
|
+
throw error;
|
|
1623
|
+
} finally {
|
|
1624
|
+
client.release();
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
/** Edit a cron schedule. */
|
|
1629
|
+
async editCronSchedule(
|
|
1630
|
+
id: number,
|
|
1631
|
+
updates: EditCronScheduleOptions,
|
|
1632
|
+
nextRunAt?: Date | null,
|
|
1633
|
+
): Promise<void> {
|
|
1634
|
+
const client = await this.pool.connect();
|
|
1635
|
+
try {
|
|
1636
|
+
const updateFields: string[] = [];
|
|
1637
|
+
const params: any[] = [];
|
|
1638
|
+
let paramIdx = 1;
|
|
1639
|
+
|
|
1640
|
+
if (updates.cronExpression !== undefined) {
|
|
1641
|
+
updateFields.push(`cron_expression = $${paramIdx++}`);
|
|
1642
|
+
params.push(updates.cronExpression);
|
|
1643
|
+
}
|
|
1644
|
+
if (updates.payload !== undefined) {
|
|
1645
|
+
updateFields.push(`payload = $${paramIdx++}`);
|
|
1646
|
+
params.push(updates.payload);
|
|
1647
|
+
}
|
|
1648
|
+
if (updates.maxAttempts !== undefined) {
|
|
1649
|
+
updateFields.push(`max_attempts = $${paramIdx++}`);
|
|
1650
|
+
params.push(updates.maxAttempts);
|
|
1651
|
+
}
|
|
1652
|
+
if (updates.priority !== undefined) {
|
|
1653
|
+
updateFields.push(`priority = $${paramIdx++}`);
|
|
1654
|
+
params.push(updates.priority);
|
|
1655
|
+
}
|
|
1656
|
+
if (updates.timeoutMs !== undefined) {
|
|
1657
|
+
updateFields.push(`timeout_ms = $${paramIdx++}`);
|
|
1658
|
+
params.push(updates.timeoutMs);
|
|
1659
|
+
}
|
|
1660
|
+
if (updates.forceKillOnTimeout !== undefined) {
|
|
1661
|
+
updateFields.push(`force_kill_on_timeout = $${paramIdx++}`);
|
|
1662
|
+
params.push(updates.forceKillOnTimeout);
|
|
1663
|
+
}
|
|
1664
|
+
if (updates.tags !== undefined) {
|
|
1665
|
+
updateFields.push(`tags = $${paramIdx++}`);
|
|
1666
|
+
params.push(updates.tags);
|
|
1667
|
+
}
|
|
1668
|
+
if (updates.timezone !== undefined) {
|
|
1669
|
+
updateFields.push(`timezone = $${paramIdx++}`);
|
|
1670
|
+
params.push(updates.timezone);
|
|
1671
|
+
}
|
|
1672
|
+
if (updates.allowOverlap !== undefined) {
|
|
1673
|
+
updateFields.push(`allow_overlap = $${paramIdx++}`);
|
|
1674
|
+
params.push(updates.allowOverlap);
|
|
1675
|
+
}
|
|
1676
|
+
if (updates.retryDelay !== undefined) {
|
|
1677
|
+
updateFields.push(`retry_delay = $${paramIdx++}`);
|
|
1678
|
+
params.push(updates.retryDelay);
|
|
1679
|
+
}
|
|
1680
|
+
if (updates.retryBackoff !== undefined) {
|
|
1681
|
+
updateFields.push(`retry_backoff = $${paramIdx++}`);
|
|
1682
|
+
params.push(updates.retryBackoff);
|
|
1683
|
+
}
|
|
1684
|
+
if (updates.retryDelayMax !== undefined) {
|
|
1685
|
+
updateFields.push(`retry_delay_max = $${paramIdx++}`);
|
|
1686
|
+
params.push(updates.retryDelayMax);
|
|
1687
|
+
}
|
|
1688
|
+
if (nextRunAt !== undefined) {
|
|
1689
|
+
updateFields.push(`next_run_at = $${paramIdx++}`);
|
|
1690
|
+
params.push(nextRunAt);
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
if (updateFields.length === 0) {
|
|
1694
|
+
log(`No fields to update for cron schedule ${id}`);
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
updateFields.push(`updated_at = NOW()`);
|
|
1699
|
+
params.push(id);
|
|
1700
|
+
|
|
1701
|
+
const query = `UPDATE cron_schedules SET ${updateFields.join(', ')} WHERE id = $${paramIdx}`;
|
|
1702
|
+
await client.query(query, params);
|
|
1703
|
+
log(`Edited cron schedule ${id}`);
|
|
1704
|
+
} catch (error) {
|
|
1705
|
+
log(`Error editing cron schedule ${id}: ${error}`);
|
|
1706
|
+
throw error;
|
|
1707
|
+
} finally {
|
|
1708
|
+
client.release();
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
/**
|
|
1713
|
+
* Atomically fetch all active cron schedules whose nextRunAt <= NOW().
|
|
1714
|
+
* Uses FOR UPDATE SKIP LOCKED to prevent duplicate enqueuing across workers.
|
|
1715
|
+
*/
|
|
1716
|
+
async getDueCronSchedules(): Promise<CronScheduleRecord[]> {
|
|
1717
|
+
const client = await this.pool.connect();
|
|
1718
|
+
try {
|
|
1719
|
+
const result = await client.query(
|
|
1720
|
+
`SELECT id, schedule_name AS "scheduleName", cron_expression AS "cronExpression",
|
|
1721
|
+
job_type AS "jobType", payload, max_attempts AS "maxAttempts",
|
|
1722
|
+
priority, timeout_ms AS "timeoutMs",
|
|
1723
|
+
force_kill_on_timeout AS "forceKillOnTimeout", tags,
|
|
1724
|
+
timezone, allow_overlap AS "allowOverlap", status,
|
|
1725
|
+
last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
|
|
1726
|
+
next_run_at AS "nextRunAt",
|
|
1727
|
+
created_at AS "createdAt", updated_at AS "updatedAt",
|
|
1728
|
+
retry_delay AS "retryDelay", retry_backoff AS "retryBackoff",
|
|
1729
|
+
retry_delay_max AS "retryDelayMax"
|
|
1730
|
+
FROM cron_schedules
|
|
1731
|
+
WHERE status = 'active'
|
|
1732
|
+
AND next_run_at IS NOT NULL
|
|
1733
|
+
AND next_run_at <= NOW()
|
|
1734
|
+
ORDER BY next_run_at ASC
|
|
1735
|
+
FOR UPDATE SKIP LOCKED`,
|
|
1736
|
+
);
|
|
1737
|
+
log(`Found ${result.rows.length} due cron schedules`);
|
|
1738
|
+
return result.rows as CronScheduleRecord[];
|
|
1739
|
+
} catch (error: any) {
|
|
1740
|
+
// 42P01 = undefined_table — cron migration hasn't been run yet
|
|
1741
|
+
if (error?.code === '42P01') {
|
|
1742
|
+
log('cron_schedules table does not exist, skipping cron enqueue');
|
|
1743
|
+
return [];
|
|
1744
|
+
}
|
|
1745
|
+
log(`Error getting due cron schedules: ${error}`);
|
|
1746
|
+
throw error;
|
|
1747
|
+
} finally {
|
|
1748
|
+
client.release();
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
/**
|
|
1753
|
+
* Update a cron schedule after a job has been enqueued.
|
|
1754
|
+
* Sets lastEnqueuedAt, lastJobId, and advances nextRunAt.
|
|
1755
|
+
*/
|
|
1756
|
+
async updateCronScheduleAfterEnqueue(
|
|
1757
|
+
id: number,
|
|
1758
|
+
lastEnqueuedAt: Date,
|
|
1759
|
+
lastJobId: number,
|
|
1760
|
+
nextRunAt: Date | null,
|
|
1761
|
+
): Promise<void> {
|
|
1762
|
+
const client = await this.pool.connect();
|
|
1763
|
+
try {
|
|
1764
|
+
await client.query(
|
|
1765
|
+
`UPDATE cron_schedules
|
|
1766
|
+
SET last_enqueued_at = $2,
|
|
1767
|
+
last_job_id = $3,
|
|
1768
|
+
next_run_at = $4,
|
|
1769
|
+
updated_at = NOW()
|
|
1770
|
+
WHERE id = $1`,
|
|
1771
|
+
[id, lastEnqueuedAt, lastJobId, nextRunAt],
|
|
1772
|
+
);
|
|
1773
|
+
log(
|
|
1774
|
+
`Updated cron schedule ${id}: lastJobId=${lastJobId}, nextRunAt=${nextRunAt?.toISOString() ?? 'null'}`,
|
|
1775
|
+
);
|
|
1776
|
+
} catch (error) {
|
|
1777
|
+
log(`Error updating cron schedule ${id} after enqueue: ${error}`);
|
|
1778
|
+
throw error;
|
|
1779
|
+
} finally {
|
|
1780
|
+
client.release();
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
// ── Wait / step-data support ────────────────────────────────────────
|
|
1785
|
+
|
|
1786
|
+
/**
|
|
1787
|
+
* Transition a job from 'processing' to 'waiting' status.
|
|
1788
|
+
* Persists step data so the handler can resume from where it left off.
|
|
1789
|
+
*
|
|
1790
|
+
* @param jobId - The job to pause.
|
|
1791
|
+
* @param options - Wait configuration including optional waitUntil date, token ID, and step data.
|
|
1792
|
+
*/
|
|
1793
|
+
async waitJob(
|
|
1794
|
+
jobId: number,
|
|
1795
|
+
options: {
|
|
1796
|
+
waitUntil?: Date;
|
|
1797
|
+
waitTokenId?: string;
|
|
1798
|
+
stepData: Record<string, any>;
|
|
1799
|
+
},
|
|
1800
|
+
): Promise<void> {
|
|
1801
|
+
const client = await this.pool.connect();
|
|
1802
|
+
try {
|
|
1803
|
+
const result = await client.query(
|
|
1804
|
+
`
|
|
1805
|
+
UPDATE job_queue
|
|
1806
|
+
SET status = 'waiting',
|
|
1807
|
+
wait_until = $2,
|
|
1808
|
+
wait_token_id = $3,
|
|
1809
|
+
step_data = $4,
|
|
1810
|
+
locked_at = NULL,
|
|
1811
|
+
locked_by = NULL,
|
|
1812
|
+
updated_at = NOW()
|
|
1813
|
+
WHERE id = $1 AND status = 'processing'
|
|
1814
|
+
`,
|
|
1815
|
+
[
|
|
1816
|
+
jobId,
|
|
1817
|
+
options.waitUntil ?? null,
|
|
1818
|
+
options.waitTokenId ?? null,
|
|
1819
|
+
JSON.stringify(options.stepData),
|
|
1820
|
+
],
|
|
1821
|
+
);
|
|
1822
|
+
if (result.rowCount === 0) {
|
|
1823
|
+
log(
|
|
1824
|
+
`Job ${jobId} could not be set to waiting (may have been reclaimed or is no longer processing)`,
|
|
1825
|
+
);
|
|
1826
|
+
return;
|
|
1827
|
+
}
|
|
1828
|
+
await this.recordJobEvent(jobId, JobEventType.Waiting, {
|
|
1829
|
+
waitUntil: options.waitUntil?.toISOString() ?? null,
|
|
1830
|
+
waitTokenId: options.waitTokenId ?? null,
|
|
1831
|
+
});
|
|
1832
|
+
log(`Job ${jobId} set to waiting`);
|
|
1833
|
+
} catch (error) {
|
|
1834
|
+
log(`Error setting job ${jobId} to waiting: ${error}`);
|
|
1835
|
+
throw error;
|
|
1836
|
+
} finally {
|
|
1837
|
+
client.release();
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
/**
|
|
1842
|
+
* Persist step data for a job. Called after each ctx.run() step completes.
|
|
1843
|
+
* Best-effort: does not throw to avoid killing the running handler.
|
|
1844
|
+
*
|
|
1845
|
+
* @param jobId - The job to update.
|
|
1846
|
+
* @param stepData - The step data to persist.
|
|
1847
|
+
*/
|
|
1848
|
+
async updateStepData(
|
|
1849
|
+
jobId: number,
|
|
1850
|
+
stepData: Record<string, any>,
|
|
1851
|
+
): Promise<void> {
|
|
1852
|
+
const client = await this.pool.connect();
|
|
1853
|
+
try {
|
|
1854
|
+
await client.query(
|
|
1855
|
+
`UPDATE job_queue SET step_data = $2, updated_at = NOW() WHERE id = $1`,
|
|
1856
|
+
[jobId, JSON.stringify(stepData)],
|
|
1857
|
+
);
|
|
1858
|
+
} catch (error) {
|
|
1859
|
+
log(`Error updating step_data for job ${jobId}: ${error}`);
|
|
1860
|
+
} finally {
|
|
1861
|
+
client.release();
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
/**
|
|
1866
|
+
* Create a waitpoint token in the database.
|
|
1867
|
+
*
|
|
1868
|
+
* @param jobId - The job ID to associate with the token (null if created outside a handler).
|
|
1869
|
+
* @param options - Optional timeout string (e.g. '10m', '1h') and tags.
|
|
1870
|
+
* @returns The created waitpoint with its unique ID.
|
|
1871
|
+
*/
|
|
1872
|
+
async createWaitpoint(
|
|
1873
|
+
jobId: number | null,
|
|
1874
|
+
options?: CreateTokenOptions,
|
|
1875
|
+
): Promise<{ id: string }> {
|
|
1876
|
+
const client = await this.pool.connect();
|
|
1877
|
+
try {
|
|
1878
|
+
const id = `wp_${randomUUID()}`;
|
|
1879
|
+
let timeoutAt: Date | null = null;
|
|
1880
|
+
|
|
1881
|
+
if (options?.timeout) {
|
|
1882
|
+
const ms = parseTimeoutString(options.timeout);
|
|
1883
|
+
timeoutAt = new Date(Date.now() + ms);
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
await client.query(
|
|
1887
|
+
`INSERT INTO waitpoints (id, job_id, status, timeout_at, tags) VALUES ($1, $2, 'waiting', $3, $4)`,
|
|
1888
|
+
[id, jobId, timeoutAt, options?.tags ?? null],
|
|
1889
|
+
);
|
|
1890
|
+
|
|
1891
|
+
log(`Created waitpoint ${id} for job ${jobId}`);
|
|
1892
|
+
return { id };
|
|
1893
|
+
} catch (error) {
|
|
1894
|
+
log(`Error creating waitpoint: ${error}`);
|
|
1895
|
+
throw error;
|
|
1896
|
+
} finally {
|
|
1897
|
+
client.release();
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
/**
|
|
1902
|
+
* Complete a waitpoint token and move the associated job back to 'pending'.
|
|
1903
|
+
*
|
|
1904
|
+
* @param tokenId - The waitpoint token ID to complete.
|
|
1905
|
+
* @param data - Optional data to pass to the waiting handler.
|
|
1906
|
+
*/
|
|
1907
|
+
async completeWaitpoint(tokenId: string, data?: any): Promise<void> {
|
|
1908
|
+
const client = await this.pool.connect();
|
|
1909
|
+
try {
|
|
1910
|
+
await client.query('BEGIN');
|
|
1911
|
+
|
|
1912
|
+
const wpResult = await client.query(
|
|
1913
|
+
`UPDATE waitpoints SET status = 'completed', output = $2, completed_at = NOW()
|
|
1914
|
+
WHERE id = $1 AND status = 'waiting'
|
|
1915
|
+
RETURNING job_id`,
|
|
1916
|
+
[tokenId, data != null ? JSON.stringify(data) : null],
|
|
1917
|
+
);
|
|
1918
|
+
|
|
1919
|
+
if (wpResult.rows.length === 0) {
|
|
1920
|
+
await client.query('ROLLBACK');
|
|
1921
|
+
log(`Waitpoint ${tokenId} not found or already completed`);
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
const jobId = wpResult.rows[0].job_id;
|
|
1926
|
+
|
|
1927
|
+
if (jobId != null) {
|
|
1928
|
+
await client.query(
|
|
1929
|
+
`UPDATE job_queue
|
|
1930
|
+
SET status = 'pending', wait_token_id = NULL, wait_until = NULL, updated_at = NOW()
|
|
1931
|
+
WHERE id = $1 AND status = 'waiting'`,
|
|
1932
|
+
[jobId],
|
|
1933
|
+
);
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
await client.query('COMMIT');
|
|
1937
|
+
log(`Completed waitpoint ${tokenId} for job ${jobId}`);
|
|
1938
|
+
} catch (error) {
|
|
1939
|
+
await client.query('ROLLBACK');
|
|
1940
|
+
log(`Error completing waitpoint ${tokenId}: ${error}`);
|
|
1941
|
+
throw error;
|
|
1942
|
+
} finally {
|
|
1943
|
+
client.release();
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
/**
|
|
1948
|
+
* Retrieve a waitpoint token by its ID.
|
|
1949
|
+
*
|
|
1950
|
+
* @param tokenId - The waitpoint token ID to look up.
|
|
1951
|
+
* @returns The waitpoint record, or null if not found.
|
|
1952
|
+
*/
|
|
1953
|
+
async getWaitpoint(tokenId: string): Promise<WaitpointRecord | null> {
|
|
1954
|
+
const client = await this.pool.connect();
|
|
1955
|
+
try {
|
|
1956
|
+
const result = await client.query(
|
|
1957
|
+
`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`,
|
|
1958
|
+
[tokenId],
|
|
1959
|
+
);
|
|
1960
|
+
if (result.rows.length === 0) return null;
|
|
1961
|
+
return result.rows[0] as WaitpointRecord;
|
|
1962
|
+
} catch (error) {
|
|
1963
|
+
log(`Error getting waitpoint ${tokenId}: ${error}`);
|
|
1964
|
+
throw error;
|
|
1965
|
+
} finally {
|
|
1966
|
+
client.release();
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
/**
|
|
1971
|
+
* Expire timed-out waitpoint tokens and move their associated jobs back to 'pending'.
|
|
1972
|
+
*
|
|
1973
|
+
* @returns The number of tokens that were expired.
|
|
1974
|
+
*/
|
|
1975
|
+
async expireTimedOutWaitpoints(): Promise<number> {
|
|
1976
|
+
const client = await this.pool.connect();
|
|
1977
|
+
try {
|
|
1978
|
+
await client.query('BEGIN');
|
|
1979
|
+
|
|
1980
|
+
const result = await client.query(
|
|
1981
|
+
`UPDATE waitpoints
|
|
1982
|
+
SET status = 'timed_out'
|
|
1983
|
+
WHERE status = 'waiting' AND timeout_at IS NOT NULL AND timeout_at <= NOW()
|
|
1984
|
+
RETURNING id, job_id`,
|
|
1985
|
+
);
|
|
1986
|
+
|
|
1987
|
+
for (const row of result.rows) {
|
|
1988
|
+
if (row.job_id != null) {
|
|
1989
|
+
await client.query(
|
|
1990
|
+
`UPDATE job_queue
|
|
1991
|
+
SET status = 'pending', wait_token_id = NULL, wait_until = NULL, updated_at = NOW()
|
|
1992
|
+
WHERE id = $1 AND status = 'waiting'`,
|
|
1993
|
+
[row.job_id],
|
|
1994
|
+
);
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
await client.query('COMMIT');
|
|
1999
|
+
const count = result.rowCount || 0;
|
|
2000
|
+
if (count > 0) {
|
|
2001
|
+
log(`Expired ${count} timed-out waitpoints`);
|
|
2002
|
+
}
|
|
2003
|
+
return count;
|
|
2004
|
+
} catch (error) {
|
|
2005
|
+
await client.query('ROLLBACK');
|
|
2006
|
+
log(`Error expiring timed-out waitpoints: ${error}`);
|
|
2007
|
+
throw error;
|
|
2008
|
+
} finally {
|
|
2009
|
+
client.release();
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
// ── Internal helpers ──────────────────────────────────────────────────
|
|
2014
|
+
|
|
2015
|
+
async setPendingReasonForUnpickedJobs(
|
|
2016
|
+
reason: string,
|
|
2017
|
+
jobType?: string | string[],
|
|
2018
|
+
): Promise<void> {
|
|
2019
|
+
const client = await this.pool.connect();
|
|
2020
|
+
try {
|
|
2021
|
+
let jobTypeFilter = '';
|
|
2022
|
+
const params: any[] = [reason];
|
|
2023
|
+
if (jobType) {
|
|
2024
|
+
if (Array.isArray(jobType)) {
|
|
2025
|
+
jobTypeFilter = ` AND job_type = ANY($2)`;
|
|
2026
|
+
params.push(jobType);
|
|
2027
|
+
} else {
|
|
2028
|
+
jobTypeFilter = ` AND job_type = $2`;
|
|
2029
|
+
params.push(jobType);
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
await client.query(
|
|
2033
|
+
`UPDATE job_queue SET pending_reason = $1 WHERE status = 'pending'${jobTypeFilter}`,
|
|
2034
|
+
params,
|
|
2035
|
+
);
|
|
2036
|
+
} finally {
|
|
2037
|
+
client.release();
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
}
|