@nicnocquee/dataqueue 1.30.0 → 1.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +769 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +216 -1
- package/dist/index.d.ts +216 -1
- package/dist/index.js +768 -4
- package/dist/index.js.map +1 -1
- package/migrations/1781200000004_create_cron_schedules_table.sql +33 -0
- package/package.json +3 -2
- package/src/backend.ts +69 -0
- package/src/backends/postgres.ts +331 -1
- package/src/backends/redis.test.ts +350 -0
- package/src/backends/redis.ts +389 -1
- package/src/cron.test.ts +126 -0
- package/src/cron.ts +40 -0
- package/src/index.test.ts +361 -0
- package/src/index.ts +157 -4
- package/src/processor.ts +22 -4
- package/src/types.ts +149 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
-- Create cron_schedules table for recurring job definitions
|
|
2
|
+
|
|
3
|
+
-- Up Migration
|
|
4
|
+
CREATE TABLE IF NOT EXISTS cron_schedules (
|
|
5
|
+
id SERIAL PRIMARY KEY,
|
|
6
|
+
schedule_name VARCHAR(255) NOT NULL UNIQUE,
|
|
7
|
+
cron_expression VARCHAR(255) NOT NULL,
|
|
8
|
+
job_type VARCHAR(255) NOT NULL,
|
|
9
|
+
payload JSONB NOT NULL DEFAULT '{}',
|
|
10
|
+
max_attempts INT DEFAULT 3,
|
|
11
|
+
priority INT DEFAULT 0,
|
|
12
|
+
timeout_ms INT,
|
|
13
|
+
force_kill_on_timeout BOOLEAN DEFAULT FALSE,
|
|
14
|
+
tags TEXT[],
|
|
15
|
+
timezone VARCHAR(100) DEFAULT 'UTC',
|
|
16
|
+
allow_overlap BOOLEAN DEFAULT FALSE,
|
|
17
|
+
status VARCHAR(50) DEFAULT 'active',
|
|
18
|
+
last_enqueued_at TIMESTAMPTZ,
|
|
19
|
+
last_job_id INT,
|
|
20
|
+
next_run_at TIMESTAMPTZ,
|
|
21
|
+
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
22
|
+
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
CREATE INDEX IF NOT EXISTS idx_cron_schedules_status ON cron_schedules(status);
|
|
26
|
+
CREATE INDEX IF NOT EXISTS idx_cron_schedules_next_run_at ON cron_schedules(next_run_at);
|
|
27
|
+
CREATE INDEX IF NOT EXISTS idx_cron_schedules_name ON cron_schedules(schedule_name);
|
|
28
|
+
|
|
29
|
+
-- Down Migration
|
|
30
|
+
DROP INDEX IF EXISTS idx_cron_schedules_name;
|
|
31
|
+
DROP INDEX IF EXISTS idx_cron_schedules_next_run_at;
|
|
32
|
+
DROP INDEX IF EXISTS idx_cron_schedules_status;
|
|
33
|
+
DROP TABLE IF EXISTS cron_schedules;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nicnocquee/dataqueue",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.31.0",
|
|
4
4
|
"description": "PostgreSQL or Redis-backed job queue for Node.js applications with support for serverless environments",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"directory": "packages/dataqueue"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
+
"croner": "^10.0.1",
|
|
46
47
|
"pg": "^8.0.0",
|
|
47
48
|
"pg-connection-string": "^2.9.1"
|
|
48
49
|
},
|
|
@@ -53,8 +54,8 @@
|
|
|
53
54
|
"ioredis": "^5.9.3",
|
|
54
55
|
"node-pg-migrate": "^8.0.3",
|
|
55
56
|
"pnpm": "^9.0.0",
|
|
56
|
-
"ts-node": "^10.9.2",
|
|
57
57
|
"prettier": "^3.6.2",
|
|
58
|
+
"ts-node": "^10.9.2",
|
|
58
59
|
"tsup": "^8.5.0",
|
|
59
60
|
"turbo": "^1.13.0",
|
|
60
61
|
"typescript": "^5.8.3",
|
package/src/backend.ts
CHANGED
|
@@ -6,6 +6,9 @@ import {
|
|
|
6
6
|
FailureReason,
|
|
7
7
|
TagQueryMode,
|
|
8
8
|
JobType,
|
|
9
|
+
CronScheduleRecord,
|
|
10
|
+
CronScheduleStatus,
|
|
11
|
+
EditCronScheduleOptions,
|
|
9
12
|
} from './types.js';
|
|
10
13
|
|
|
11
14
|
/**
|
|
@@ -36,6 +39,25 @@ export interface JobUpdates {
|
|
|
36
39
|
tags?: string[] | null;
|
|
37
40
|
}
|
|
38
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Input shape for creating a cron schedule in the backend.
|
|
44
|
+
* This is the backend-level version of CronScheduleOptions.
|
|
45
|
+
*/
|
|
46
|
+
export interface CronScheduleInput {
|
|
47
|
+
scheduleName: string;
|
|
48
|
+
cronExpression: string;
|
|
49
|
+
jobType: string;
|
|
50
|
+
payload: any;
|
|
51
|
+
maxAttempts: number;
|
|
52
|
+
priority: number;
|
|
53
|
+
timeoutMs: number | null;
|
|
54
|
+
forceKillOnTimeout: boolean;
|
|
55
|
+
tags: string[] | undefined;
|
|
56
|
+
timezone: string;
|
|
57
|
+
allowOverlap: boolean;
|
|
58
|
+
nextRunAt: Date | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
39
61
|
/**
|
|
40
62
|
* Abstract backend interface that both PostgreSQL and Redis implement.
|
|
41
63
|
* All storage operations go through this interface so the processor
|
|
@@ -153,6 +175,53 @@ export interface QueueBackend {
|
|
|
153
175
|
/** Get all events for a job, ordered by createdAt ASC. */
|
|
154
176
|
getJobEvents(jobId: number): Promise<JobEvent[]>;
|
|
155
177
|
|
|
178
|
+
// ── Cron schedules ──────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
/** Create a cron schedule and return its ID. */
|
|
181
|
+
addCronSchedule(input: CronScheduleInput): Promise<number>;
|
|
182
|
+
|
|
183
|
+
/** Get a cron schedule by ID, or null if not found. */
|
|
184
|
+
getCronSchedule(id: number): Promise<CronScheduleRecord | null>;
|
|
185
|
+
|
|
186
|
+
/** Get a cron schedule by its unique name, or null if not found. */
|
|
187
|
+
getCronScheduleByName(name: string): Promise<CronScheduleRecord | null>;
|
|
188
|
+
|
|
189
|
+
/** List cron schedules, optionally filtered by status. */
|
|
190
|
+
listCronSchedules(status?: CronScheduleStatus): Promise<CronScheduleRecord[]>;
|
|
191
|
+
|
|
192
|
+
/** Delete a cron schedule by ID. */
|
|
193
|
+
removeCronSchedule(id: number): Promise<void>;
|
|
194
|
+
|
|
195
|
+
/** Pause a cron schedule. */
|
|
196
|
+
pauseCronSchedule(id: number): Promise<void>;
|
|
197
|
+
|
|
198
|
+
/** Resume a cron schedule. */
|
|
199
|
+
resumeCronSchedule(id: number): Promise<void>;
|
|
200
|
+
|
|
201
|
+
/** Edit a cron schedule. */
|
|
202
|
+
editCronSchedule(
|
|
203
|
+
id: number,
|
|
204
|
+
updates: EditCronScheduleOptions,
|
|
205
|
+
nextRunAt?: Date | null,
|
|
206
|
+
): Promise<void>;
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Atomically fetch all active cron schedules whose nextRunAt <= now.
|
|
210
|
+
* In PostgreSQL this uses FOR UPDATE SKIP LOCKED to prevent duplicate enqueuing.
|
|
211
|
+
*/
|
|
212
|
+
getDueCronSchedules(): Promise<CronScheduleRecord[]>;
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Update a cron schedule after a job has been enqueued.
|
|
216
|
+
* Sets lastEnqueuedAt, lastJobId, and advances nextRunAt.
|
|
217
|
+
*/
|
|
218
|
+
updateCronScheduleAfterEnqueue(
|
|
219
|
+
id: number,
|
|
220
|
+
lastEnqueuedAt: Date,
|
|
221
|
+
lastJobId: number,
|
|
222
|
+
nextRunAt: Date | null,
|
|
223
|
+
): Promise<void>;
|
|
224
|
+
|
|
156
225
|
// ── Internal helpers ──────────────────────────────────────────────────
|
|
157
226
|
|
|
158
227
|
/** Set a pending reason for unpicked jobs of a given type. */
|
package/src/backends/postgres.ts
CHANGED
|
@@ -7,8 +7,16 @@ import {
|
|
|
7
7
|
JobEventType,
|
|
8
8
|
TagQueryMode,
|
|
9
9
|
JobType,
|
|
10
|
+
CronScheduleRecord,
|
|
11
|
+
CronScheduleStatus,
|
|
12
|
+
EditCronScheduleOptions,
|
|
10
13
|
} from '../types.js';
|
|
11
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
QueueBackend,
|
|
16
|
+
JobFilters,
|
|
17
|
+
JobUpdates,
|
|
18
|
+
CronScheduleInput,
|
|
19
|
+
} from '../backend.js';
|
|
12
20
|
import { log } from '../log-context.js';
|
|
13
21
|
|
|
14
22
|
export class PostgresBackend implements QueueBackend {
|
|
@@ -1083,6 +1091,328 @@ export class PostgresBackend implements QueueBackend {
|
|
|
1083
1091
|
}
|
|
1084
1092
|
}
|
|
1085
1093
|
|
|
1094
|
+
// ── Cron schedules ──────────────────────────────────────────────────
|
|
1095
|
+
|
|
1096
|
+
/** Create a cron schedule and return its ID. */
|
|
1097
|
+
async addCronSchedule(input: CronScheduleInput): Promise<number> {
|
|
1098
|
+
const client = await this.pool.connect();
|
|
1099
|
+
try {
|
|
1100
|
+
const result = await client.query(
|
|
1101
|
+
`INSERT INTO cron_schedules
|
|
1102
|
+
(schedule_name, cron_expression, job_type, payload, max_attempts,
|
|
1103
|
+
priority, timeout_ms, force_kill_on_timeout, tags, timezone,
|
|
1104
|
+
allow_overlap, next_run_at)
|
|
1105
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
|
1106
|
+
RETURNING id`,
|
|
1107
|
+
[
|
|
1108
|
+
input.scheduleName,
|
|
1109
|
+
input.cronExpression,
|
|
1110
|
+
input.jobType,
|
|
1111
|
+
input.payload,
|
|
1112
|
+
input.maxAttempts,
|
|
1113
|
+
input.priority,
|
|
1114
|
+
input.timeoutMs,
|
|
1115
|
+
input.forceKillOnTimeout,
|
|
1116
|
+
input.tags ?? null,
|
|
1117
|
+
input.timezone,
|
|
1118
|
+
input.allowOverlap,
|
|
1119
|
+
input.nextRunAt,
|
|
1120
|
+
],
|
|
1121
|
+
);
|
|
1122
|
+
const id = result.rows[0].id;
|
|
1123
|
+
log(`Added cron schedule ${id}: "${input.scheduleName}"`);
|
|
1124
|
+
return id;
|
|
1125
|
+
} catch (error: any) {
|
|
1126
|
+
// Unique constraint violation on schedule_name
|
|
1127
|
+
if (error?.code === '23505') {
|
|
1128
|
+
throw new Error(
|
|
1129
|
+
`Cron schedule with name "${input.scheduleName}" already exists`,
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
log(`Error adding cron schedule: ${error}`);
|
|
1133
|
+
throw error;
|
|
1134
|
+
} finally {
|
|
1135
|
+
client.release();
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/** Get a cron schedule by ID. */
|
|
1140
|
+
async getCronSchedule(id: number): Promise<CronScheduleRecord | null> {
|
|
1141
|
+
const client = await this.pool.connect();
|
|
1142
|
+
try {
|
|
1143
|
+
const result = await client.query(
|
|
1144
|
+
`SELECT id, schedule_name AS "scheduleName", cron_expression AS "cronExpression",
|
|
1145
|
+
job_type AS "jobType", payload, max_attempts AS "maxAttempts",
|
|
1146
|
+
priority, timeout_ms AS "timeoutMs",
|
|
1147
|
+
force_kill_on_timeout AS "forceKillOnTimeout", tags,
|
|
1148
|
+
timezone, allow_overlap AS "allowOverlap", status,
|
|
1149
|
+
last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
|
|
1150
|
+
next_run_at AS "nextRunAt",
|
|
1151
|
+
created_at AS "createdAt", updated_at AS "updatedAt"
|
|
1152
|
+
FROM cron_schedules WHERE id = $1`,
|
|
1153
|
+
[id],
|
|
1154
|
+
);
|
|
1155
|
+
if (result.rows.length === 0) return null;
|
|
1156
|
+
return result.rows[0] as CronScheduleRecord;
|
|
1157
|
+
} catch (error) {
|
|
1158
|
+
log(`Error getting cron schedule ${id}: ${error}`);
|
|
1159
|
+
throw error;
|
|
1160
|
+
} finally {
|
|
1161
|
+
client.release();
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/** Get a cron schedule by its unique name. */
|
|
1166
|
+
async getCronScheduleByName(
|
|
1167
|
+
name: string,
|
|
1168
|
+
): Promise<CronScheduleRecord | null> {
|
|
1169
|
+
const client = await this.pool.connect();
|
|
1170
|
+
try {
|
|
1171
|
+
const result = await client.query(
|
|
1172
|
+
`SELECT id, schedule_name AS "scheduleName", cron_expression AS "cronExpression",
|
|
1173
|
+
job_type AS "jobType", payload, max_attempts AS "maxAttempts",
|
|
1174
|
+
priority, timeout_ms AS "timeoutMs",
|
|
1175
|
+
force_kill_on_timeout AS "forceKillOnTimeout", tags,
|
|
1176
|
+
timezone, allow_overlap AS "allowOverlap", status,
|
|
1177
|
+
last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
|
|
1178
|
+
next_run_at AS "nextRunAt",
|
|
1179
|
+
created_at AS "createdAt", updated_at AS "updatedAt"
|
|
1180
|
+
FROM cron_schedules WHERE schedule_name = $1`,
|
|
1181
|
+
[name],
|
|
1182
|
+
);
|
|
1183
|
+
if (result.rows.length === 0) return null;
|
|
1184
|
+
return result.rows[0] as CronScheduleRecord;
|
|
1185
|
+
} catch (error) {
|
|
1186
|
+
log(`Error getting cron schedule by name "${name}": ${error}`);
|
|
1187
|
+
throw error;
|
|
1188
|
+
} finally {
|
|
1189
|
+
client.release();
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/** List cron schedules, optionally filtered by status. */
|
|
1194
|
+
async listCronSchedules(
|
|
1195
|
+
status?: CronScheduleStatus,
|
|
1196
|
+
): Promise<CronScheduleRecord[]> {
|
|
1197
|
+
const client = await this.pool.connect();
|
|
1198
|
+
try {
|
|
1199
|
+
let query = `SELECT id, schedule_name AS "scheduleName", cron_expression AS "cronExpression",
|
|
1200
|
+
job_type AS "jobType", payload, max_attempts AS "maxAttempts",
|
|
1201
|
+
priority, timeout_ms AS "timeoutMs",
|
|
1202
|
+
force_kill_on_timeout AS "forceKillOnTimeout", tags,
|
|
1203
|
+
timezone, allow_overlap AS "allowOverlap", status,
|
|
1204
|
+
last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
|
|
1205
|
+
next_run_at AS "nextRunAt",
|
|
1206
|
+
created_at AS "createdAt", updated_at AS "updatedAt"
|
|
1207
|
+
FROM cron_schedules`;
|
|
1208
|
+
const params: any[] = [];
|
|
1209
|
+
if (status) {
|
|
1210
|
+
query += ` WHERE status = $1`;
|
|
1211
|
+
params.push(status);
|
|
1212
|
+
}
|
|
1213
|
+
query += ` ORDER BY created_at ASC`;
|
|
1214
|
+
const result = await client.query(query, params);
|
|
1215
|
+
return result.rows as CronScheduleRecord[];
|
|
1216
|
+
} catch (error) {
|
|
1217
|
+
log(`Error listing cron schedules: ${error}`);
|
|
1218
|
+
throw error;
|
|
1219
|
+
} finally {
|
|
1220
|
+
client.release();
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/** Delete a cron schedule by ID. */
|
|
1225
|
+
async removeCronSchedule(id: number): Promise<void> {
|
|
1226
|
+
const client = await this.pool.connect();
|
|
1227
|
+
try {
|
|
1228
|
+
await client.query(`DELETE FROM cron_schedules WHERE id = $1`, [id]);
|
|
1229
|
+
log(`Removed cron schedule ${id}`);
|
|
1230
|
+
} catch (error) {
|
|
1231
|
+
log(`Error removing cron schedule ${id}: ${error}`);
|
|
1232
|
+
throw error;
|
|
1233
|
+
} finally {
|
|
1234
|
+
client.release();
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/** Pause a cron schedule. */
|
|
1239
|
+
async pauseCronSchedule(id: number): Promise<void> {
|
|
1240
|
+
const client = await this.pool.connect();
|
|
1241
|
+
try {
|
|
1242
|
+
await client.query(
|
|
1243
|
+
`UPDATE cron_schedules SET status = 'paused', updated_at = NOW() WHERE id = $1`,
|
|
1244
|
+
[id],
|
|
1245
|
+
);
|
|
1246
|
+
log(`Paused cron schedule ${id}`);
|
|
1247
|
+
} catch (error) {
|
|
1248
|
+
log(`Error pausing cron schedule ${id}: ${error}`);
|
|
1249
|
+
throw error;
|
|
1250
|
+
} finally {
|
|
1251
|
+
client.release();
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/** Resume a paused cron schedule. */
|
|
1256
|
+
async resumeCronSchedule(id: number): Promise<void> {
|
|
1257
|
+
const client = await this.pool.connect();
|
|
1258
|
+
try {
|
|
1259
|
+
await client.query(
|
|
1260
|
+
`UPDATE cron_schedules SET status = 'active', updated_at = NOW() WHERE id = $1`,
|
|
1261
|
+
[id],
|
|
1262
|
+
);
|
|
1263
|
+
log(`Resumed cron schedule ${id}`);
|
|
1264
|
+
} catch (error) {
|
|
1265
|
+
log(`Error resuming cron schedule ${id}: ${error}`);
|
|
1266
|
+
throw error;
|
|
1267
|
+
} finally {
|
|
1268
|
+
client.release();
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/** Edit a cron schedule. */
|
|
1273
|
+
async editCronSchedule(
|
|
1274
|
+
id: number,
|
|
1275
|
+
updates: EditCronScheduleOptions,
|
|
1276
|
+
nextRunAt?: Date | null,
|
|
1277
|
+
): Promise<void> {
|
|
1278
|
+
const client = await this.pool.connect();
|
|
1279
|
+
try {
|
|
1280
|
+
const updateFields: string[] = [];
|
|
1281
|
+
const params: any[] = [];
|
|
1282
|
+
let paramIdx = 1;
|
|
1283
|
+
|
|
1284
|
+
if (updates.cronExpression !== undefined) {
|
|
1285
|
+
updateFields.push(`cron_expression = $${paramIdx++}`);
|
|
1286
|
+
params.push(updates.cronExpression);
|
|
1287
|
+
}
|
|
1288
|
+
if (updates.payload !== undefined) {
|
|
1289
|
+
updateFields.push(`payload = $${paramIdx++}`);
|
|
1290
|
+
params.push(updates.payload);
|
|
1291
|
+
}
|
|
1292
|
+
if (updates.maxAttempts !== undefined) {
|
|
1293
|
+
updateFields.push(`max_attempts = $${paramIdx++}`);
|
|
1294
|
+
params.push(updates.maxAttempts);
|
|
1295
|
+
}
|
|
1296
|
+
if (updates.priority !== undefined) {
|
|
1297
|
+
updateFields.push(`priority = $${paramIdx++}`);
|
|
1298
|
+
params.push(updates.priority);
|
|
1299
|
+
}
|
|
1300
|
+
if (updates.timeoutMs !== undefined) {
|
|
1301
|
+
updateFields.push(`timeout_ms = $${paramIdx++}`);
|
|
1302
|
+
params.push(updates.timeoutMs);
|
|
1303
|
+
}
|
|
1304
|
+
if (updates.forceKillOnTimeout !== undefined) {
|
|
1305
|
+
updateFields.push(`force_kill_on_timeout = $${paramIdx++}`);
|
|
1306
|
+
params.push(updates.forceKillOnTimeout);
|
|
1307
|
+
}
|
|
1308
|
+
if (updates.tags !== undefined) {
|
|
1309
|
+
updateFields.push(`tags = $${paramIdx++}`);
|
|
1310
|
+
params.push(updates.tags);
|
|
1311
|
+
}
|
|
1312
|
+
if (updates.timezone !== undefined) {
|
|
1313
|
+
updateFields.push(`timezone = $${paramIdx++}`);
|
|
1314
|
+
params.push(updates.timezone);
|
|
1315
|
+
}
|
|
1316
|
+
if (updates.allowOverlap !== undefined) {
|
|
1317
|
+
updateFields.push(`allow_overlap = $${paramIdx++}`);
|
|
1318
|
+
params.push(updates.allowOverlap);
|
|
1319
|
+
}
|
|
1320
|
+
if (nextRunAt !== undefined) {
|
|
1321
|
+
updateFields.push(`next_run_at = $${paramIdx++}`);
|
|
1322
|
+
params.push(nextRunAt);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
if (updateFields.length === 0) {
|
|
1326
|
+
log(`No fields to update for cron schedule ${id}`);
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
updateFields.push(`updated_at = NOW()`);
|
|
1331
|
+
params.push(id);
|
|
1332
|
+
|
|
1333
|
+
const query = `UPDATE cron_schedules SET ${updateFields.join(', ')} WHERE id = $${paramIdx}`;
|
|
1334
|
+
await client.query(query, params);
|
|
1335
|
+
log(`Edited cron schedule ${id}`);
|
|
1336
|
+
} catch (error) {
|
|
1337
|
+
log(`Error editing cron schedule ${id}: ${error}`);
|
|
1338
|
+
throw error;
|
|
1339
|
+
} finally {
|
|
1340
|
+
client.release();
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/**
|
|
1345
|
+
* Atomically fetch all active cron schedules whose nextRunAt <= NOW().
|
|
1346
|
+
* Uses FOR UPDATE SKIP LOCKED to prevent duplicate enqueuing across workers.
|
|
1347
|
+
*/
|
|
1348
|
+
async getDueCronSchedules(): Promise<CronScheduleRecord[]> {
|
|
1349
|
+
const client = await this.pool.connect();
|
|
1350
|
+
try {
|
|
1351
|
+
const result = await client.query(
|
|
1352
|
+
`SELECT id, schedule_name AS "scheduleName", cron_expression AS "cronExpression",
|
|
1353
|
+
job_type AS "jobType", payload, max_attempts AS "maxAttempts",
|
|
1354
|
+
priority, timeout_ms AS "timeoutMs",
|
|
1355
|
+
force_kill_on_timeout AS "forceKillOnTimeout", tags,
|
|
1356
|
+
timezone, allow_overlap AS "allowOverlap", status,
|
|
1357
|
+
last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
|
|
1358
|
+
next_run_at AS "nextRunAt",
|
|
1359
|
+
created_at AS "createdAt", updated_at AS "updatedAt"
|
|
1360
|
+
FROM cron_schedules
|
|
1361
|
+
WHERE status = 'active'
|
|
1362
|
+
AND next_run_at IS NOT NULL
|
|
1363
|
+
AND next_run_at <= NOW()
|
|
1364
|
+
ORDER BY next_run_at ASC
|
|
1365
|
+
FOR UPDATE SKIP LOCKED`,
|
|
1366
|
+
);
|
|
1367
|
+
log(`Found ${result.rows.length} due cron schedules`);
|
|
1368
|
+
return result.rows as CronScheduleRecord[];
|
|
1369
|
+
} catch (error: any) {
|
|
1370
|
+
// 42P01 = undefined_table — cron migration hasn't been run yet
|
|
1371
|
+
if (error?.code === '42P01') {
|
|
1372
|
+
log('cron_schedules table does not exist, skipping cron enqueue');
|
|
1373
|
+
return [];
|
|
1374
|
+
}
|
|
1375
|
+
log(`Error getting due cron schedules: ${error}`);
|
|
1376
|
+
throw error;
|
|
1377
|
+
} finally {
|
|
1378
|
+
client.release();
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* Update a cron schedule after a job has been enqueued.
|
|
1384
|
+
* Sets lastEnqueuedAt, lastJobId, and advances nextRunAt.
|
|
1385
|
+
*/
|
|
1386
|
+
async updateCronScheduleAfterEnqueue(
|
|
1387
|
+
id: number,
|
|
1388
|
+
lastEnqueuedAt: Date,
|
|
1389
|
+
lastJobId: number,
|
|
1390
|
+
nextRunAt: Date | null,
|
|
1391
|
+
): Promise<void> {
|
|
1392
|
+
const client = await this.pool.connect();
|
|
1393
|
+
try {
|
|
1394
|
+
await client.query(
|
|
1395
|
+
`UPDATE cron_schedules
|
|
1396
|
+
SET last_enqueued_at = $2,
|
|
1397
|
+
last_job_id = $3,
|
|
1398
|
+
next_run_at = $4,
|
|
1399
|
+
updated_at = NOW()
|
|
1400
|
+
WHERE id = $1`,
|
|
1401
|
+
[id, lastEnqueuedAt, lastJobId, nextRunAt],
|
|
1402
|
+
);
|
|
1403
|
+
log(
|
|
1404
|
+
`Updated cron schedule ${id}: lastJobId=${lastJobId}, nextRunAt=${nextRunAt?.toISOString() ?? 'null'}`,
|
|
1405
|
+
);
|
|
1406
|
+
} catch (error) {
|
|
1407
|
+
log(`Error updating cron schedule ${id} after enqueue: ${error}`);
|
|
1408
|
+
throw error;
|
|
1409
|
+
} finally {
|
|
1410
|
+
client.release();
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
// ── Internal helpers ──────────────────────────────────────────────────
|
|
1415
|
+
|
|
1086
1416
|
async setPendingReasonForUnpickedJobs(
|
|
1087
1417
|
reason: string,
|
|
1088
1418
|
jobType?: string | string[],
|