@nicnocquee/dataqueue 1.25.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.
Files changed (59) hide show
  1. package/ai/build-docs-content.ts +96 -0
  2. package/ai/build-llms-full.ts +42 -0
  3. package/ai/docs-content.json +278 -0
  4. package/ai/rules/advanced.md +132 -0
  5. package/ai/rules/basic.md +159 -0
  6. package/ai/rules/react-dashboard.md +83 -0
  7. package/ai/skills/dataqueue-advanced/SKILL.md +320 -0
  8. package/ai/skills/dataqueue-core/SKILL.md +234 -0
  9. package/ai/skills/dataqueue-react/SKILL.md +189 -0
  10. package/dist/cli.cjs +1149 -14
  11. package/dist/cli.cjs.map +1 -1
  12. package/dist/cli.d.cts +66 -1
  13. package/dist/cli.d.ts +66 -1
  14. package/dist/cli.js +1146 -13
  15. package/dist/cli.js.map +1 -1
  16. package/dist/index.cjs +3157 -1237
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +613 -23
  19. package/dist/index.d.ts +613 -23
  20. package/dist/index.js +3156 -1238
  21. package/dist/index.js.map +1 -1
  22. package/dist/mcp-server.cjs +186 -0
  23. package/dist/mcp-server.cjs.map +1 -0
  24. package/dist/mcp-server.d.cts +32 -0
  25. package/dist/mcp-server.d.ts +32 -0
  26. package/dist/mcp-server.js +175 -0
  27. package/dist/mcp-server.js.map +1 -0
  28. package/migrations/1781200000004_create_cron_schedules_table.sql +33 -0
  29. package/migrations/1781200000005_add_retry_config_to_job_queue.sql +17 -0
  30. package/package.json +24 -21
  31. package/src/backend.ts +170 -5
  32. package/src/backends/postgres.ts +992 -63
  33. package/src/backends/redis-scripts.ts +358 -26
  34. package/src/backends/redis.test.ts +1363 -0
  35. package/src/backends/redis.ts +993 -35
  36. package/src/cli.test.ts +82 -6
  37. package/src/cli.ts +73 -10
  38. package/src/cron.test.ts +126 -0
  39. package/src/cron.ts +40 -0
  40. package/src/db-util.ts +1 -1
  41. package/src/index.test.ts +682 -0
  42. package/src/index.ts +209 -34
  43. package/src/init-command.test.ts +449 -0
  44. package/src/init-command.ts +709 -0
  45. package/src/install-mcp-command.test.ts +216 -0
  46. package/src/install-mcp-command.ts +185 -0
  47. package/src/install-rules-command.test.ts +218 -0
  48. package/src/install-rules-command.ts +233 -0
  49. package/src/install-skills-command.test.ts +176 -0
  50. package/src/install-skills-command.ts +124 -0
  51. package/src/mcp-server.test.ts +162 -0
  52. package/src/mcp-server.ts +231 -0
  53. package/src/processor.ts +36 -97
  54. package/src/queue.test.ts +465 -0
  55. package/src/queue.ts +34 -252
  56. package/src/supervisor.test.ts +340 -0
  57. package/src/supervisor.ts +162 -0
  58. package/src/types.ts +388 -12
  59. package/LICENSE +0 -21
package/dist/index.cjs CHANGED
@@ -1,18 +1,21 @@
1
1
  'use strict';
2
2
 
3
- var async_hooks = require('async_hooks');
4
- var crypto = require('crypto');
5
3
  var worker_threads = require('worker_threads');
4
+ var async_hooks = require('async_hooks');
6
5
  var pg = require('pg');
7
6
  var pgConnectionString = require('pg-connection-string');
8
7
  var fs = require('fs');
8
+ var crypto = require('crypto');
9
9
  var module$1 = require('module');
10
+ var croner = require('croner');
10
11
 
11
12
  var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
12
13
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
13
14
 
14
15
  var fs__default = /*#__PURE__*/_interopDefault(fs);
15
16
 
17
+ // src/processor.ts
18
+
16
19
  // src/types.ts
17
20
  var JobEventType = /* @__PURE__ */ ((JobEventType2) => {
18
21
  JobEventType2["Added"] = "added";
@@ -26,11 +29,11 @@ var JobEventType = /* @__PURE__ */ ((JobEventType2) => {
26
29
  JobEventType2["Waiting"] = "waiting";
27
30
  return JobEventType2;
28
31
  })(JobEventType || {});
29
- var FailureReason = /* @__PURE__ */ ((FailureReason5) => {
30
- FailureReason5["Timeout"] = "timeout";
31
- FailureReason5["HandlerError"] = "handler_error";
32
- FailureReason5["NoHandler"] = "no_handler";
33
- return FailureReason5;
32
+ var FailureReason = /* @__PURE__ */ ((FailureReason4) => {
33
+ FailureReason4["Timeout"] = "timeout";
34
+ FailureReason4["HandlerError"] = "handler_error";
35
+ FailureReason4["NoHandler"] = "no_handler";
36
+ return FailureReason4;
34
37
  })(FailureReason || {});
35
38
  var WaitSignal = class extends Error {
36
39
  constructor(type, waitUntil, tokenId, stepData) {
@@ -57,265 +60,1274 @@ var log = (message) => {
57
60
  }
58
61
  };
59
62
 
60
- // src/backends/postgres.ts
61
- var PostgresBackend = class {
62
- constructor(pool) {
63
- this.pool = pool;
64
- }
65
- /** Expose the raw pool for advanced usage. */
66
- getPool() {
67
- return this.pool;
68
- }
69
- // ── Events ──────────────────────────────────────────────────────────
70
- async recordJobEvent(jobId, eventType, metadata) {
71
- const client = await this.pool.connect();
72
- try {
73
- await client.query(
74
- `INSERT INTO job_events (job_id, event_type, metadata) VALUES ($1, $2, $3)`,
75
- [jobId, eventType, metadata ? JSON.stringify(metadata) : null]
76
- );
77
- } catch (error) {
78
- log(`Error recording job event for job ${jobId}: ${error}`);
79
- } finally {
80
- client.release();
81
- }
82
- }
83
- async getJobEvents(jobId) {
84
- const client = await this.pool.connect();
85
- try {
86
- const res = await client.query(
87
- `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`,
88
- [jobId]
89
- );
90
- return res.rows;
91
- } finally {
92
- client.release();
93
- }
94
- }
95
- // ── Job CRUD ──────────────────────────────────────────────────────────
96
- async addJob({
97
- jobType,
98
- payload,
99
- maxAttempts = 3,
100
- priority = 0,
101
- runAt = null,
102
- timeoutMs = void 0,
103
- forceKillOnTimeout = false,
104
- tags = void 0,
105
- idempotencyKey = void 0
106
- }) {
107
- const client = await this.pool.connect();
108
- try {
109
- let result;
110
- const onConflict = idempotencyKey ? `ON CONFLICT (idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING` : "";
111
- if (runAt) {
112
- result = await client.query(
113
- `INSERT INTO job_queue
114
- (job_type, payload, max_attempts, priority, run_at, timeout_ms, force_kill_on_timeout, tags, idempotency_key)
115
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
116
- ${onConflict}
117
- RETURNING id`,
118
- [
119
- jobType,
120
- payload,
121
- maxAttempts,
122
- priority,
123
- runAt,
124
- timeoutMs ?? null,
125
- forceKillOnTimeout ?? false,
126
- tags ?? null,
127
- idempotencyKey ?? null
128
- ]
129
- );
130
- } else {
131
- result = await client.query(
132
- `INSERT INTO job_queue
133
- (job_type, payload, max_attempts, priority, timeout_ms, force_kill_on_timeout, tags, idempotency_key)
134
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
135
- ${onConflict}
136
- RETURNING id`,
137
- [
138
- jobType,
139
- payload,
140
- maxAttempts,
141
- priority,
142
- timeoutMs ?? null,
143
- forceKillOnTimeout ?? false,
144
- tags ?? null,
145
- idempotencyKey ?? null
146
- ]
147
- );
148
- }
149
- if (result.rows.length === 0 && idempotencyKey) {
150
- const existing = await client.query(
151
- `SELECT id FROM job_queue WHERE idempotency_key = $1`,
152
- [idempotencyKey]
153
- );
154
- if (existing.rows.length > 0) {
155
- log(
156
- `Job with idempotency key "${idempotencyKey}" already exists (id: ${existing.rows[0].id}), returning existing job`
157
- );
158
- return existing.rows[0].id;
159
- }
160
- throw new Error(
161
- `Failed to insert job and could not find existing job with idempotency key "${idempotencyKey}"`
162
- );
163
- }
164
- const jobId = result.rows[0].id;
165
- log(
166
- `Added job ${jobId}: payload ${JSON.stringify(payload)}, ${runAt ? `runAt ${runAt.toISOString()}, ` : ""}priority ${priority}, maxAttempts ${maxAttempts}, jobType ${jobType}, tags ${JSON.stringify(tags)}${idempotencyKey ? `, idempotencyKey "${idempotencyKey}"` : ""}`
63
+ // src/processor.ts
64
+ function validateHandlerSerializable(handler, jobType) {
65
+ try {
66
+ const handlerString = handler.toString();
67
+ if (handlerString.includes("this.") && !handlerString.match(/\([^)]*this[^)]*\)/)) {
68
+ throw new Error(
69
+ `Handler for job type "${jobType}" uses 'this' context which cannot be serialized. Use a regular function or avoid 'this' references when forceKillOnTimeout is enabled.`
167
70
  );
168
- await this.recordJobEvent(jobId, "added" /* Added */, {
169
- jobType,
170
- payload,
171
- tags,
172
- idempotencyKey
173
- });
174
- return jobId;
175
- } catch (error) {
176
- log(`Error adding job: ${error}`);
177
- throw error;
178
- } finally {
179
- client.release();
180
71
  }
181
- }
182
- async getJob(id) {
183
- const client = await this.pool.connect();
184
- try {
185
- const result = await client.query(
186
- `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", tags, idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress FROM job_queue WHERE id = $1`,
187
- [id]
72
+ if (handlerString.includes("[native code]")) {
73
+ throw new Error(
74
+ `Handler for job type "${jobType}" contains native code which cannot be serialized. Ensure your handler is a plain function when forceKillOnTimeout is enabled.`
188
75
  );
189
- if (result.rows.length === 0) {
190
- log(`Job ${id} not found`);
191
- return null;
192
- }
193
- log(`Found job ${id}`);
194
- const job = result.rows[0];
195
- return {
196
- ...job,
197
- payload: job.payload,
198
- timeoutMs: job.timeoutMs,
199
- forceKillOnTimeout: job.forceKillOnTimeout,
200
- failureReason: job.failureReason
201
- };
202
- } catch (error) {
203
- log(`Error getting job ${id}: ${error}`);
204
- throw error;
205
- } finally {
206
- client.release();
207
76
  }
208
- }
209
- async getJobsByStatus(status, limit = 100, offset = 0) {
210
- const client = await this.pool.connect();
211
77
  try {
212
- const result = await client.query(
213
- `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress FROM job_queue WHERE status = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`,
214
- [status, limit, offset]
78
+ new Function("return " + handlerString);
79
+ } catch (parseError) {
80
+ throw new Error(
81
+ `Handler for job type "${jobType}" cannot be serialized: ${parseError instanceof Error ? parseError.message : String(parseError)}. When using forceKillOnTimeout, handlers must be serializable functions without closures over external variables.`
215
82
  );
216
- log(`Found ${result.rows.length} jobs by status ${status}`);
217
- return result.rows.map((job) => ({
218
- ...job,
219
- payload: job.payload,
220
- timeoutMs: job.timeoutMs,
221
- forceKillOnTimeout: job.forceKillOnTimeout,
222
- failureReason: job.failureReason
223
- }));
224
- } catch (error) {
225
- log(`Error getting jobs by status ${status}: ${error}`);
226
- throw error;
227
- } finally {
228
- client.release();
229
83
  }
230
- }
231
- async getAllJobs(limit = 100, offset = 0) {
232
- const client = await this.pool.connect();
233
- try {
234
- const result = await client.query(
235
- `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress FROM job_queue ORDER BY created_at DESC LIMIT $1 OFFSET $2`,
236
- [limit, offset]
237
- );
238
- log(`Found ${result.rows.length} jobs (all)`);
239
- return result.rows.map((job) => ({
240
- ...job,
241
- payload: job.payload,
242
- timeoutMs: job.timeoutMs,
243
- forceKillOnTimeout: job.forceKillOnTimeout
244
- }));
245
- } catch (error) {
246
- log(`Error getting all jobs: ${error}`);
84
+ } catch (error) {
85
+ if (error instanceof Error) {
247
86
  throw error;
248
- } finally {
249
- client.release();
250
87
  }
88
+ throw new Error(
89
+ `Failed to validate handler serialization for job type "${jobType}": ${String(error)}`
90
+ );
251
91
  }
252
- async getJobs(filters, limit = 100, offset = 0) {
253
- const client = await this.pool.connect();
254
- try {
255
- let query = `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", tags, idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress FROM job_queue`;
256
- const params = [];
257
- const where = [];
258
- let paramIdx = 1;
259
- if (filters) {
260
- if (filters.jobType) {
261
- where.push(`job_type = $${paramIdx++}`);
262
- params.push(filters.jobType);
263
- }
264
- if (filters.priority !== void 0) {
265
- where.push(`priority = $${paramIdx++}`);
266
- params.push(filters.priority);
267
- }
268
- if (filters.runAt) {
269
- if (filters.runAt instanceof Date) {
270
- where.push(`run_at = $${paramIdx++}`);
271
- params.push(filters.runAt);
272
- } else if (typeof filters.runAt === "object" && (filters.runAt.gt !== void 0 || filters.runAt.gte !== void 0 || filters.runAt.lt !== void 0 || filters.runAt.lte !== void 0 || filters.runAt.eq !== void 0)) {
273
- const ops = filters.runAt;
274
- if (ops.gt) {
275
- where.push(`run_at > $${paramIdx++}`);
276
- params.push(ops.gt);
277
- }
278
- if (ops.gte) {
279
- where.push(`run_at >= $${paramIdx++}`);
280
- params.push(ops.gte);
281
- }
282
- if (ops.lt) {
283
- where.push(`run_at < $${paramIdx++}`);
284
- params.push(ops.lt);
285
- }
286
- if (ops.lte) {
287
- where.push(`run_at <= $${paramIdx++}`);
288
- params.push(ops.lte);
289
- }
290
- if (ops.eq) {
291
- where.push(`run_at = $${paramIdx++}`);
292
- params.push(ops.eq);
293
- }
294
- }
295
- }
296
- if (filters.tags && filters.tags.values && filters.tags.values.length > 0) {
297
- const mode = filters.tags.mode || "all";
298
- const tagValues = filters.tags.values;
299
- switch (mode) {
300
- case "exact":
301
- where.push(`tags = $${paramIdx++}`);
302
- params.push(tagValues);
303
- break;
304
- case "all":
305
- where.push(`tags @> $${paramIdx++}`);
306
- params.push(tagValues);
307
- break;
308
- case "any":
309
- where.push(`tags && $${paramIdx++}`);
310
- params.push(tagValues);
311
- break;
312
- case "none":
313
- where.push(`NOT (tags && $${paramIdx++})`);
314
- params.push(tagValues);
315
- break;
316
- default:
317
- where.push(`tags @> $${paramIdx++}`);
318
- params.push(tagValues);
92
+ }
93
+ async function runHandlerInWorker(handler, payload, timeoutMs, jobType) {
94
+ validateHandlerSerializable(handler, jobType);
95
+ return new Promise((resolve, reject) => {
96
+ const workerCode = `
97
+ (function() {
98
+ const { parentPort, workerData } = require('worker_threads');
99
+ const { handlerCode, payload, timeoutMs } = workerData;
100
+
101
+ // Create an AbortController for the handler
102
+ const controller = new AbortController();
103
+ const signal = controller.signal;
104
+
105
+ // Set up timeout
106
+ const timeoutId = setTimeout(() => {
107
+ controller.abort();
108
+ parentPort.postMessage({ type: 'timeout' });
109
+ }, timeoutMs);
110
+
111
+ try {
112
+ // Execute the handler
113
+ // Note: This uses Function constructor which requires the handler to be serializable.
114
+ // The handler should be validated before reaching this point.
115
+ let handlerFn;
116
+ try {
117
+ // Wrap handlerCode in parentheses to ensure it's treated as an expression
118
+ // This handles both arrow functions and regular functions
119
+ const wrappedCode = handlerCode.trim().startsWith('async') || handlerCode.trim().startsWith('function')
120
+ ? handlerCode
121
+ : '(' + handlerCode + ')';
122
+ handlerFn = new Function('return ' + wrappedCode)();
123
+ } catch (parseError) {
124
+ clearTimeout(timeoutId);
125
+ parentPort.postMessage({
126
+ type: 'error',
127
+ error: {
128
+ message: 'Handler cannot be deserialized in worker thread. ' +
129
+ 'Ensure your handler is a standalone function without closures over external variables. ' +
130
+ 'Original error: ' + (parseError instanceof Error ? parseError.message : String(parseError)),
131
+ stack: parseError instanceof Error ? parseError.stack : undefined,
132
+ name: 'SerializationError',
133
+ },
134
+ });
135
+ return;
136
+ }
137
+
138
+ // Ensure handlerFn is actually a function
139
+ if (typeof handlerFn !== 'function') {
140
+ clearTimeout(timeoutId);
141
+ parentPort.postMessage({
142
+ type: 'error',
143
+ error: {
144
+ message: 'Handler deserialization did not produce a function. ' +
145
+ 'Ensure your handler is a valid function when forceKillOnTimeout is enabled.',
146
+ name: 'SerializationError',
147
+ },
148
+ });
149
+ return;
150
+ }
151
+
152
+ handlerFn(payload, signal)
153
+ .then(() => {
154
+ clearTimeout(timeoutId);
155
+ parentPort.postMessage({ type: 'success' });
156
+ })
157
+ .catch((error) => {
158
+ clearTimeout(timeoutId);
159
+ parentPort.postMessage({
160
+ type: 'error',
161
+ error: {
162
+ message: error.message,
163
+ stack: error.stack,
164
+ name: error.name,
165
+ },
166
+ });
167
+ });
168
+ } catch (error) {
169
+ clearTimeout(timeoutId);
170
+ parentPort.postMessage({
171
+ type: 'error',
172
+ error: {
173
+ message: error.message,
174
+ stack: error.stack,
175
+ name: error.name,
176
+ },
177
+ });
178
+ }
179
+ })();
180
+ `;
181
+ const worker = new worker_threads.Worker(workerCode, {
182
+ eval: true,
183
+ workerData: {
184
+ handlerCode: handler.toString(),
185
+ payload,
186
+ timeoutMs
187
+ }
188
+ });
189
+ let resolved = false;
190
+ worker.on("message", (message) => {
191
+ if (resolved) return;
192
+ resolved = true;
193
+ if (message.type === "success") {
194
+ resolve();
195
+ } else if (message.type === "timeout") {
196
+ const timeoutError = new Error(
197
+ `Job timed out after ${timeoutMs} ms and was forcefully terminated`
198
+ );
199
+ timeoutError.failureReason = "timeout" /* Timeout */;
200
+ reject(timeoutError);
201
+ } else if (message.type === "error") {
202
+ const error = new Error(message.error.message);
203
+ error.stack = message.error.stack;
204
+ error.name = message.error.name;
205
+ reject(error);
206
+ }
207
+ });
208
+ worker.on("error", (error) => {
209
+ if (resolved) return;
210
+ resolved = true;
211
+ reject(error);
212
+ });
213
+ worker.on("exit", (code) => {
214
+ if (resolved) return;
215
+ if (code !== 0) {
216
+ resolved = true;
217
+ reject(new Error(`Worker stopped with exit code ${code}`));
218
+ }
219
+ });
220
+ setTimeout(() => {
221
+ if (!resolved) {
222
+ resolved = true;
223
+ worker.terminate().then(() => {
224
+ const timeoutError = new Error(
225
+ `Job timed out after ${timeoutMs} ms and was forcefully terminated`
226
+ );
227
+ timeoutError.failureReason = "timeout" /* Timeout */;
228
+ reject(timeoutError);
229
+ }).catch((err) => {
230
+ reject(err);
231
+ });
232
+ }
233
+ }, timeoutMs + 100);
234
+ });
235
+ }
236
+ function calculateWaitUntil(duration) {
237
+ const now = Date.now();
238
+ let ms = 0;
239
+ if (duration.seconds) ms += duration.seconds * 1e3;
240
+ if (duration.minutes) ms += duration.minutes * 60 * 1e3;
241
+ if (duration.hours) ms += duration.hours * 60 * 60 * 1e3;
242
+ if (duration.days) ms += duration.days * 24 * 60 * 60 * 1e3;
243
+ if (duration.weeks) ms += duration.weeks * 7 * 24 * 60 * 60 * 1e3;
244
+ if (duration.months) ms += duration.months * 30 * 24 * 60 * 60 * 1e3;
245
+ if (duration.years) ms += duration.years * 365 * 24 * 60 * 60 * 1e3;
246
+ if (ms <= 0) {
247
+ throw new Error(
248
+ "waitFor duration must be positive. Provide at least one positive duration field."
249
+ );
250
+ }
251
+ return new Date(now + ms);
252
+ }
253
+ async function resolveCompletedWaits(backend, stepData) {
254
+ for (const key of Object.keys(stepData)) {
255
+ if (!key.startsWith("__wait_")) continue;
256
+ const entry = stepData[key];
257
+ if (!entry || typeof entry !== "object" || entry.completed) continue;
258
+ if (entry.type === "duration" || entry.type === "date") {
259
+ stepData[key] = { ...entry, completed: true };
260
+ } else if (entry.type === "token" && entry.tokenId) {
261
+ const wp = await backend.getWaitpoint(entry.tokenId);
262
+ if (wp && wp.status === "completed") {
263
+ stepData[key] = {
264
+ ...entry,
265
+ completed: true,
266
+ result: { ok: true, output: wp.output }
267
+ };
268
+ } else if (wp && wp.status === "timed_out") {
269
+ stepData[key] = {
270
+ ...entry,
271
+ completed: true,
272
+ result: { ok: false, error: "Token timed out" }
273
+ };
274
+ }
275
+ }
276
+ }
277
+ }
278
+ function buildWaitContext(backend, jobId, stepData, baseCtx) {
279
+ let waitCounter = 0;
280
+ const ctx = {
281
+ prolong: baseCtx.prolong,
282
+ onTimeout: baseCtx.onTimeout,
283
+ run: async (stepName, fn) => {
284
+ const cached = stepData[stepName];
285
+ if (cached && typeof cached === "object" && cached.__completed) {
286
+ log(`Step "${stepName}" replayed from cache for job ${jobId}`);
287
+ return cached.result;
288
+ }
289
+ const result = await fn();
290
+ stepData[stepName] = { __completed: true, result };
291
+ await backend.updateStepData(jobId, stepData);
292
+ return result;
293
+ },
294
+ waitFor: async (duration) => {
295
+ const waitKey = `__wait_${waitCounter++}`;
296
+ const cached = stepData[waitKey];
297
+ if (cached && typeof cached === "object" && cached.completed) {
298
+ log(`Wait "${waitKey}" already completed for job ${jobId}, skipping`);
299
+ return;
300
+ }
301
+ const waitUntilDate = calculateWaitUntil(duration);
302
+ stepData[waitKey] = { type: "duration", completed: false };
303
+ throw new WaitSignal("duration", waitUntilDate, void 0, stepData);
304
+ },
305
+ waitUntil: async (date) => {
306
+ const waitKey = `__wait_${waitCounter++}`;
307
+ const cached = stepData[waitKey];
308
+ if (cached && typeof cached === "object" && cached.completed) {
309
+ log(`Wait "${waitKey}" already completed for job ${jobId}, skipping`);
310
+ return;
311
+ }
312
+ stepData[waitKey] = { type: "date", completed: false };
313
+ throw new WaitSignal("date", date, void 0, stepData);
314
+ },
315
+ createToken: async (options) => {
316
+ const token = await backend.createWaitpoint(jobId, options);
317
+ return token;
318
+ },
319
+ waitForToken: async (tokenId) => {
320
+ const waitKey = `__wait_${waitCounter++}`;
321
+ const cached = stepData[waitKey];
322
+ if (cached && typeof cached === "object" && cached.completed) {
323
+ log(
324
+ `Token wait "${waitKey}" already completed for job ${jobId}, returning cached result`
325
+ );
326
+ return cached.result;
327
+ }
328
+ const wp = await backend.getWaitpoint(tokenId);
329
+ if (wp && wp.status === "completed") {
330
+ const result = {
331
+ ok: true,
332
+ output: wp.output
333
+ };
334
+ stepData[waitKey] = {
335
+ type: "token",
336
+ tokenId,
337
+ completed: true,
338
+ result
339
+ };
340
+ await backend.updateStepData(jobId, stepData);
341
+ return result;
342
+ }
343
+ if (wp && wp.status === "timed_out") {
344
+ const result = {
345
+ ok: false,
346
+ error: "Token timed out"
347
+ };
348
+ stepData[waitKey] = {
349
+ type: "token",
350
+ tokenId,
351
+ completed: true,
352
+ result
353
+ };
354
+ await backend.updateStepData(jobId, stepData);
355
+ return result;
356
+ }
357
+ stepData[waitKey] = { type: "token", tokenId, completed: false };
358
+ throw new WaitSignal("token", void 0, tokenId, stepData);
359
+ },
360
+ setProgress: async (percent) => {
361
+ if (percent < 0 || percent > 100)
362
+ throw new Error("Progress must be between 0 and 100");
363
+ await backend.updateProgress(jobId, Math.round(percent));
364
+ }
365
+ };
366
+ return ctx;
367
+ }
368
+ async function processJobWithHandlers(backend, job, jobHandlers) {
369
+ const handler = jobHandlers[job.jobType];
370
+ if (!handler) {
371
+ await backend.setPendingReasonForUnpickedJobs(
372
+ `No handler registered for job type: ${job.jobType}`,
373
+ job.jobType
374
+ );
375
+ await backend.failJob(
376
+ job.id,
377
+ new Error(`No handler registered for job type: ${job.jobType}`),
378
+ "no_handler" /* NoHandler */
379
+ );
380
+ return;
381
+ }
382
+ const stepData = { ...job.stepData || {} };
383
+ const hasStepHistory = Object.keys(stepData).some(
384
+ (k) => k.startsWith("__wait_")
385
+ );
386
+ if (hasStepHistory) {
387
+ await resolveCompletedWaits(backend, stepData);
388
+ await backend.updateStepData(job.id, stepData);
389
+ }
390
+ const timeoutMs = job.timeoutMs ?? void 0;
391
+ const forceKillOnTimeout = job.forceKillOnTimeout ?? false;
392
+ let timeoutId;
393
+ const controller = new AbortController();
394
+ try {
395
+ if (forceKillOnTimeout && timeoutMs && timeoutMs > 0) {
396
+ await runHandlerInWorker(handler, job.payload, timeoutMs, job.jobType);
397
+ } else {
398
+ let onTimeoutCallback;
399
+ let timeoutReject;
400
+ const armTimeout = (ms) => {
401
+ if (timeoutId) clearTimeout(timeoutId);
402
+ timeoutId = setTimeout(() => {
403
+ if (onTimeoutCallback) {
404
+ try {
405
+ const extension = onTimeoutCallback();
406
+ if (typeof extension === "number" && extension > 0) {
407
+ backend.prolongJob(job.id).catch(() => {
408
+ });
409
+ armTimeout(extension);
410
+ return;
411
+ }
412
+ } catch (callbackError) {
413
+ log(
414
+ `onTimeout callback threw for job ${job.id}: ${callbackError}`
415
+ );
416
+ }
417
+ }
418
+ controller.abort();
419
+ const timeoutError = new Error(`Job timed out after ${ms} ms`);
420
+ timeoutError.failureReason = "timeout" /* Timeout */;
421
+ if (timeoutReject) {
422
+ timeoutReject(timeoutError);
423
+ }
424
+ }, ms);
425
+ };
426
+ const hasTimeout = timeoutMs != null && timeoutMs > 0;
427
+ const baseCtx = hasTimeout ? {
428
+ prolong: (ms) => {
429
+ const duration = ms ?? timeoutMs;
430
+ if (duration != null && duration > 0) {
431
+ armTimeout(duration);
432
+ backend.prolongJob(job.id).catch(() => {
433
+ });
434
+ }
435
+ },
436
+ onTimeout: (callback) => {
437
+ onTimeoutCallback = callback;
438
+ }
439
+ } : {
440
+ prolong: () => {
441
+ log("prolong() called but ignored: job has no timeout set");
442
+ },
443
+ onTimeout: () => {
444
+ log("onTimeout() called but ignored: job has no timeout set");
445
+ }
446
+ };
447
+ const ctx = buildWaitContext(backend, job.id, stepData, baseCtx);
448
+ if (forceKillOnTimeout && !hasTimeout) {
449
+ log(
450
+ `forceKillOnTimeout is set but no timeoutMs for job ${job.id}, running without force kill`
451
+ );
452
+ }
453
+ const jobPromise = handler(job.payload, controller.signal, ctx);
454
+ if (hasTimeout) {
455
+ await Promise.race([
456
+ jobPromise,
457
+ new Promise((_, reject) => {
458
+ timeoutReject = reject;
459
+ armTimeout(timeoutMs);
460
+ })
461
+ ]);
462
+ } else {
463
+ await jobPromise;
464
+ }
465
+ }
466
+ if (timeoutId) clearTimeout(timeoutId);
467
+ await backend.completeJob(job.id);
468
+ } catch (error) {
469
+ if (timeoutId) clearTimeout(timeoutId);
470
+ if (error instanceof WaitSignal) {
471
+ log(
472
+ `Job ${job.id} entering wait: type=${error.type}, waitUntil=${error.waitUntil?.toISOString() ?? "none"}, tokenId=${error.tokenId ?? "none"}`
473
+ );
474
+ await backend.waitJob(job.id, {
475
+ waitUntil: error.waitUntil,
476
+ waitTokenId: error.tokenId,
477
+ stepData: error.stepData
478
+ });
479
+ return;
480
+ }
481
+ console.error(`Error processing job ${job.id}:`, error);
482
+ let failureReason = "handler_error" /* HandlerError */;
483
+ if (error && typeof error === "object" && "failureReason" in error && error.failureReason === "timeout" /* Timeout */) {
484
+ failureReason = "timeout" /* Timeout */;
485
+ }
486
+ await backend.failJob(
487
+ job.id,
488
+ error instanceof Error ? error : new Error(String(error)),
489
+ failureReason
490
+ );
491
+ }
492
+ }
493
+ async function processBatchWithHandlers(backend, workerId, batchSize, jobType, jobHandlers, concurrency, onError) {
494
+ const jobs = await backend.getNextBatch(
495
+ workerId,
496
+ batchSize,
497
+ jobType
498
+ );
499
+ if (!concurrency || concurrency >= jobs.length) {
500
+ await Promise.all(
501
+ jobs.map((job) => processJobWithHandlers(backend, job, jobHandlers))
502
+ );
503
+ return jobs.length;
504
+ }
505
+ let idx = 0;
506
+ let running = 0;
507
+ let finished = 0;
508
+ return new Promise((resolve, reject) => {
509
+ const next = () => {
510
+ if (finished === jobs.length) return resolve(jobs.length);
511
+ while (running < concurrency && idx < jobs.length) {
512
+ const job = jobs[idx++];
513
+ running++;
514
+ processJobWithHandlers(backend, job, jobHandlers).then(() => {
515
+ running--;
516
+ finished++;
517
+ next();
518
+ }).catch((err) => {
519
+ running--;
520
+ finished++;
521
+ if (onError) {
522
+ onError(err instanceof Error ? err : new Error(String(err)));
523
+ }
524
+ next();
525
+ });
526
+ }
527
+ };
528
+ next();
529
+ });
530
+ }
531
+ var createProcessor = (backend, handlers, options = {}, onBeforeBatch) => {
532
+ const {
533
+ workerId = `worker-${Math.random().toString(36).substring(2, 9)}`,
534
+ batchSize = 10,
535
+ pollInterval = 5e3,
536
+ onError = (error) => console.error("Job processor error:", error),
537
+ jobType,
538
+ concurrency = 3
539
+ } = options;
540
+ let running = false;
541
+ let intervalId = null;
542
+ let currentBatchPromise = null;
543
+ setLogContext(options.verbose ?? false);
544
+ const processJobs = async () => {
545
+ if (!running) return 0;
546
+ if (onBeforeBatch) {
547
+ try {
548
+ await onBeforeBatch();
549
+ } catch (hookError) {
550
+ log(`onBeforeBatch hook error: ${hookError}`);
551
+ if (onError) {
552
+ onError(
553
+ hookError instanceof Error ? hookError : new Error(String(hookError))
554
+ );
555
+ }
556
+ }
557
+ }
558
+ log(
559
+ `Processing jobs with workerId: ${workerId}${jobType ? ` and jobType: ${Array.isArray(jobType) ? jobType.join(",") : jobType}` : ""}`
560
+ );
561
+ try {
562
+ const processed = await processBatchWithHandlers(
563
+ backend,
564
+ workerId,
565
+ batchSize,
566
+ jobType,
567
+ handlers,
568
+ concurrency,
569
+ onError
570
+ );
571
+ return processed;
572
+ } catch (error) {
573
+ onError(error instanceof Error ? error : new Error(String(error)));
574
+ }
575
+ return 0;
576
+ };
577
+ return {
578
+ /**
579
+ * Start the job processor in the background.
580
+ * - This will run periodically (every pollInterval milliseconds or 5 seconds if not provided) and process jobs as they become available.
581
+ * - You have to call the stop method to stop the processor.
582
+ */
583
+ startInBackground: () => {
584
+ if (running) return;
585
+ log(`Starting job processor with workerId: ${workerId}`);
586
+ running = true;
587
+ const scheduleNext = (immediate) => {
588
+ if (!running) return;
589
+ if (immediate) {
590
+ intervalId = setTimeout(loop, 0);
591
+ } else {
592
+ intervalId = setTimeout(loop, pollInterval);
593
+ }
594
+ };
595
+ const loop = async () => {
596
+ if (!running) return;
597
+ currentBatchPromise = processJobs();
598
+ const processed = await currentBatchPromise;
599
+ currentBatchPromise = null;
600
+ scheduleNext(processed === batchSize);
601
+ };
602
+ loop();
603
+ },
604
+ /**
605
+ * Stop the job processor that runs in the background.
606
+ * Does not wait for in-flight jobs.
607
+ */
608
+ stop: () => {
609
+ log(`Stopping job processor with workerId: ${workerId}`);
610
+ running = false;
611
+ if (intervalId) {
612
+ clearTimeout(intervalId);
613
+ intervalId = null;
614
+ }
615
+ },
616
+ /**
617
+ * Stop the job processor and wait for all in-flight jobs to complete.
618
+ * Useful for graceful shutdown (e.g., SIGTERM handling).
619
+ */
620
+ stopAndDrain: async (drainTimeoutMs = 3e4) => {
621
+ log(`Stopping and draining job processor with workerId: ${workerId}`);
622
+ running = false;
623
+ if (intervalId) {
624
+ clearTimeout(intervalId);
625
+ intervalId = null;
626
+ }
627
+ if (currentBatchPromise) {
628
+ await Promise.race([
629
+ currentBatchPromise.catch(() => {
630
+ }),
631
+ new Promise((resolve) => setTimeout(resolve, drainTimeoutMs))
632
+ ]);
633
+ currentBatchPromise = null;
634
+ }
635
+ log(`Job processor ${workerId} drained`);
636
+ },
637
+ /**
638
+ * Start the job processor synchronously.
639
+ * - This will process all jobs immediately and then stop.
640
+ * - The pollInterval is ignored.
641
+ */
642
+ start: async () => {
643
+ log(`Starting job processor with workerId: ${workerId}`);
644
+ running = true;
645
+ const processed = await processJobs();
646
+ running = false;
647
+ return processed;
648
+ },
649
+ isRunning: () => running
650
+ };
651
+ };
652
+
653
+ // src/supervisor.ts
654
+ var createSupervisor = (backend, options = {}) => {
655
+ const {
656
+ intervalMs = 6e4,
657
+ stuckJobsTimeoutMinutes = 10,
658
+ cleanupJobsDaysToKeep = 30,
659
+ cleanupEventsDaysToKeep = 30,
660
+ cleanupBatchSize = 1e3,
661
+ reclaimStuckJobs = true,
662
+ expireTimedOutTokens = true,
663
+ onError = (error) => console.error("Supervisor maintenance error:", error),
664
+ verbose = false
665
+ } = options;
666
+ let running = false;
667
+ let timeoutId = null;
668
+ let currentRunPromise = null;
669
+ setLogContext(verbose);
670
+ const runOnce = async () => {
671
+ setLogContext(verbose);
672
+ const result = {
673
+ reclaimedJobs: 0,
674
+ cleanedUpJobs: 0,
675
+ cleanedUpEvents: 0,
676
+ expiredTokens: 0
677
+ };
678
+ if (reclaimStuckJobs) {
679
+ try {
680
+ result.reclaimedJobs = await backend.reclaimStuckJobs(
681
+ stuckJobsTimeoutMinutes
682
+ );
683
+ if (result.reclaimedJobs > 0) {
684
+ log(`Supervisor: reclaimed ${result.reclaimedJobs} stuck jobs`);
685
+ }
686
+ } catch (e) {
687
+ onError(e instanceof Error ? e : new Error(String(e)));
688
+ }
689
+ }
690
+ if (cleanupJobsDaysToKeep > 0) {
691
+ try {
692
+ result.cleanedUpJobs = await backend.cleanupOldJobs(
693
+ cleanupJobsDaysToKeep,
694
+ cleanupBatchSize
695
+ );
696
+ if (result.cleanedUpJobs > 0) {
697
+ log(`Supervisor: cleaned up ${result.cleanedUpJobs} old jobs`);
698
+ }
699
+ } catch (e) {
700
+ onError(e instanceof Error ? e : new Error(String(e)));
701
+ }
702
+ }
703
+ if (cleanupEventsDaysToKeep > 0) {
704
+ try {
705
+ result.cleanedUpEvents = await backend.cleanupOldJobEvents(
706
+ cleanupEventsDaysToKeep,
707
+ cleanupBatchSize
708
+ );
709
+ if (result.cleanedUpEvents > 0) {
710
+ log(
711
+ `Supervisor: cleaned up ${result.cleanedUpEvents} old job events`
712
+ );
713
+ }
714
+ } catch (e) {
715
+ onError(e instanceof Error ? e : new Error(String(e)));
716
+ }
717
+ }
718
+ if (expireTimedOutTokens) {
719
+ try {
720
+ result.expiredTokens = await backend.expireTimedOutWaitpoints();
721
+ if (result.expiredTokens > 0) {
722
+ log(`Supervisor: expired ${result.expiredTokens} timed-out tokens`);
723
+ }
724
+ } catch (e) {
725
+ onError(e instanceof Error ? e : new Error(String(e)));
726
+ }
727
+ }
728
+ return result;
729
+ };
730
+ return {
731
+ start: async () => {
732
+ return runOnce();
733
+ },
734
+ startInBackground: () => {
735
+ if (running) return;
736
+ log("Supervisor: starting background maintenance loop");
737
+ running = true;
738
+ const loop = async () => {
739
+ if (!running) return;
740
+ currentRunPromise = runOnce();
741
+ await currentRunPromise;
742
+ currentRunPromise = null;
743
+ if (running) {
744
+ timeoutId = setTimeout(loop, intervalMs);
745
+ }
746
+ };
747
+ loop();
748
+ },
749
+ stop: () => {
750
+ running = false;
751
+ if (timeoutId !== null) {
752
+ clearTimeout(timeoutId);
753
+ timeoutId = null;
754
+ }
755
+ log("Supervisor: stopped");
756
+ },
757
+ stopAndDrain: async (timeoutMs = 3e4) => {
758
+ running = false;
759
+ if (timeoutId !== null) {
760
+ clearTimeout(timeoutId);
761
+ timeoutId = null;
762
+ }
763
+ if (currentRunPromise) {
764
+ log("Supervisor: draining current maintenance run\u2026");
765
+ await Promise.race([
766
+ currentRunPromise,
767
+ new Promise((resolve) => setTimeout(resolve, timeoutMs))
768
+ ]);
769
+ currentRunPromise = null;
770
+ }
771
+ log("Supervisor: drained and stopped");
772
+ },
773
+ isRunning: () => running
774
+ };
775
+ };
776
+ function loadPemOrFile(value) {
777
+ if (!value) return void 0;
778
+ if (value.startsWith("file://")) {
779
+ const filePath = value.slice(7);
780
+ return fs__default.default.readFileSync(filePath, "utf8");
781
+ }
782
+ return value;
783
+ }
784
+ var createPool = (config) => {
785
+ let searchPath;
786
+ let ssl = void 0;
787
+ let customCA;
788
+ let sslmode;
789
+ if (config.connectionString) {
790
+ try {
791
+ const url = new URL(config.connectionString);
792
+ searchPath = url.searchParams.get("search_path") || void 0;
793
+ sslmode = url.searchParams.get("sslmode") || void 0;
794
+ if (sslmode === "no-verify") {
795
+ ssl = { rejectUnauthorized: false };
796
+ }
797
+ } catch (e) {
798
+ const parsed = pgConnectionString.parse(config.connectionString);
799
+ if (parsed.options) {
800
+ const match = parsed.options.match(/search_path=([^\s]+)/);
801
+ if (match) {
802
+ searchPath = match[1];
803
+ }
804
+ }
805
+ sslmode = typeof parsed.sslmode === "string" ? parsed.sslmode : void 0;
806
+ if (sslmode === "no-verify") {
807
+ ssl = { rejectUnauthorized: false };
808
+ }
809
+ }
810
+ }
811
+ if (config.ssl) {
812
+ if (typeof config.ssl.ca === "string") {
813
+ customCA = config.ssl.ca;
814
+ } else if (typeof process.env.PGSSLROOTCERT === "string") {
815
+ customCA = process.env.PGSSLROOTCERT;
816
+ } else {
817
+ customCA = void 0;
818
+ }
819
+ const caValue = typeof customCA === "string" ? loadPemOrFile(customCA) : void 0;
820
+ ssl = {
821
+ ...ssl,
822
+ ...caValue ? { ca: caValue } : {},
823
+ cert: loadPemOrFile(
824
+ typeof config.ssl.cert === "string" ? config.ssl.cert : process.env.PGSSLCERT
825
+ ),
826
+ key: loadPemOrFile(
827
+ typeof config.ssl.key === "string" ? config.ssl.key : process.env.PGSSLKEY
828
+ ),
829
+ rejectUnauthorized: config.ssl.rejectUnauthorized !== void 0 ? config.ssl.rejectUnauthorized : true
830
+ };
831
+ }
832
+ if (sslmode && customCA) {
833
+ const warning = `
834
+
835
+ \x1B[33m**************************************************
836
+ \u26A0\uFE0F WARNING: SSL CONFIGURATION ISSUE
837
+ **************************************************
838
+ Both sslmode ('${sslmode}') is set in the connection string
839
+ and a custom CA is provided (via config.ssl.ca or PGSSLROOTCERT).
840
+ This combination may cause connection failures or unexpected behavior.
841
+
842
+ Recommended: Remove sslmode from the connection string when using a custom CA.
843
+ **************************************************\x1B[0m
844
+ `;
845
+ console.warn(warning);
846
+ }
847
+ const pool = new pg.Pool({
848
+ ...config,
849
+ ...ssl ? { ssl } : {}
850
+ });
851
+ if (searchPath) {
852
+ pool.on("connect", (client) => {
853
+ client.query(`SET search_path TO ${searchPath}`);
854
+ });
855
+ }
856
+ return pool;
857
+ };
858
+ var MAX_TIMEOUT_MS = 365 * 24 * 60 * 60 * 1e3;
859
+ function parseTimeoutString(timeout) {
860
+ const match = timeout.match(/^(\d+)(s|m|h|d)$/);
861
+ if (!match) {
862
+ throw new Error(
863
+ `Invalid timeout format: "${timeout}". Expected format like "10m", "1h", "24h", "7d".`
864
+ );
865
+ }
866
+ const value = parseInt(match[1], 10);
867
+ const unit = match[2];
868
+ let ms;
869
+ switch (unit) {
870
+ case "s":
871
+ ms = value * 1e3;
872
+ break;
873
+ case "m":
874
+ ms = value * 60 * 1e3;
875
+ break;
876
+ case "h":
877
+ ms = value * 60 * 60 * 1e3;
878
+ break;
879
+ case "d":
880
+ ms = value * 24 * 60 * 60 * 1e3;
881
+ break;
882
+ default:
883
+ throw new Error(`Unknown timeout unit: "${unit}"`);
884
+ }
885
+ if (!Number.isFinite(ms) || ms > MAX_TIMEOUT_MS) {
886
+ throw new Error(
887
+ `Timeout value "${timeout}" is too large. Maximum allowed is 365 days.`
888
+ );
889
+ }
890
+ return ms;
891
+ }
892
+ var PostgresBackend = class {
893
+ constructor(pool) {
894
+ this.pool = pool;
895
+ }
896
+ /** Expose the raw pool for advanced usage. */
897
+ getPool() {
898
+ return this.pool;
899
+ }
900
+ // ── Events ──────────────────────────────────────────────────────────
901
+ async recordJobEvent(jobId, eventType, metadata) {
902
+ const client = await this.pool.connect();
903
+ try {
904
+ await client.query(
905
+ `INSERT INTO job_events (job_id, event_type, metadata) VALUES ($1, $2, $3)`,
906
+ [jobId, eventType, metadata ? JSON.stringify(metadata) : null]
907
+ );
908
+ } catch (error) {
909
+ log(`Error recording job event for job ${jobId}: ${error}`);
910
+ } finally {
911
+ client.release();
912
+ }
913
+ }
914
+ async getJobEvents(jobId) {
915
+ const client = await this.pool.connect();
916
+ try {
917
+ const res = await client.query(
918
+ `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`,
919
+ [jobId]
920
+ );
921
+ return res.rows;
922
+ } finally {
923
+ client.release();
924
+ }
925
+ }
926
+ // ── Job CRUD ──────────────────────────────────────────────────────────
927
+ /**
928
+ * Add a job and return its numeric ID.
929
+ *
930
+ * @param job - Job configuration.
931
+ * @param options - Optional. Pass `{ db }` to run the INSERT on an external
932
+ * client (e.g., inside a transaction) so the job is part of the caller's
933
+ * transaction. The event INSERT also uses the same client.
934
+ */
935
+ async addJob({
936
+ jobType,
937
+ payload,
938
+ maxAttempts = 3,
939
+ priority = 0,
940
+ runAt = null,
941
+ timeoutMs = void 0,
942
+ forceKillOnTimeout = false,
943
+ tags = void 0,
944
+ idempotencyKey = void 0,
945
+ retryDelay = void 0,
946
+ retryBackoff = void 0,
947
+ retryDelayMax = void 0
948
+ }, options) {
949
+ const externalClient = options?.db;
950
+ const client = externalClient ?? await this.pool.connect();
951
+ try {
952
+ let result;
953
+ const onConflict = idempotencyKey ? `ON CONFLICT (idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING` : "";
954
+ if (runAt) {
955
+ result = await client.query(
956
+ `INSERT INTO job_queue
957
+ (job_type, payload, max_attempts, priority, run_at, timeout_ms, force_kill_on_timeout, tags, idempotency_key, retry_delay, retry_backoff, retry_delay_max)
958
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
959
+ ${onConflict}
960
+ RETURNING id`,
961
+ [
962
+ jobType,
963
+ payload,
964
+ maxAttempts,
965
+ priority,
966
+ runAt,
967
+ timeoutMs ?? null,
968
+ forceKillOnTimeout ?? false,
969
+ tags ?? null,
970
+ idempotencyKey ?? null,
971
+ retryDelay ?? null,
972
+ retryBackoff ?? null,
973
+ retryDelayMax ?? null
974
+ ]
975
+ );
976
+ } else {
977
+ result = await client.query(
978
+ `INSERT INTO job_queue
979
+ (job_type, payload, max_attempts, priority, timeout_ms, force_kill_on_timeout, tags, idempotency_key, retry_delay, retry_backoff, retry_delay_max)
980
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
981
+ ${onConflict}
982
+ RETURNING id`,
983
+ [
984
+ jobType,
985
+ payload,
986
+ maxAttempts,
987
+ priority,
988
+ timeoutMs ?? null,
989
+ forceKillOnTimeout ?? false,
990
+ tags ?? null,
991
+ idempotencyKey ?? null,
992
+ retryDelay ?? null,
993
+ retryBackoff ?? null,
994
+ retryDelayMax ?? null
995
+ ]
996
+ );
997
+ }
998
+ if (result.rows.length === 0 && idempotencyKey) {
999
+ const existing = await client.query(
1000
+ `SELECT id FROM job_queue WHERE idempotency_key = $1`,
1001
+ [idempotencyKey]
1002
+ );
1003
+ if (existing.rows.length > 0) {
1004
+ log(
1005
+ `Job with idempotency key "${idempotencyKey}" already exists (id: ${existing.rows[0].id}), returning existing job`
1006
+ );
1007
+ return existing.rows[0].id;
1008
+ }
1009
+ throw new Error(
1010
+ `Failed to insert job and could not find existing job with idempotency key "${idempotencyKey}"`
1011
+ );
1012
+ }
1013
+ const jobId = result.rows[0].id;
1014
+ log(
1015
+ `Added job ${jobId}: payload ${JSON.stringify(payload)}, ${runAt ? `runAt ${runAt.toISOString()}, ` : ""}priority ${priority}, maxAttempts ${maxAttempts}, jobType ${jobType}, tags ${JSON.stringify(tags)}${idempotencyKey ? `, idempotencyKey "${idempotencyKey}"` : ""}`
1016
+ );
1017
+ if (externalClient) {
1018
+ try {
1019
+ await client.query(
1020
+ `INSERT INTO job_events (job_id, event_type, metadata) VALUES ($1, $2, $3)`,
1021
+ [
1022
+ jobId,
1023
+ "added" /* Added */,
1024
+ JSON.stringify({ jobType, payload, tags, idempotencyKey })
1025
+ ]
1026
+ );
1027
+ } catch (error) {
1028
+ log(`Error recording job event for job ${jobId}: ${error}`);
1029
+ }
1030
+ } else {
1031
+ await this.recordJobEvent(jobId, "added" /* Added */, {
1032
+ jobType,
1033
+ payload,
1034
+ tags,
1035
+ idempotencyKey
1036
+ });
1037
+ }
1038
+ return jobId;
1039
+ } catch (error) {
1040
+ log(`Error adding job: ${error}`);
1041
+ throw error;
1042
+ } finally {
1043
+ if (!externalClient) client.release();
1044
+ }
1045
+ }
1046
+ /**
1047
+ * Insert multiple jobs in a single database round-trip.
1048
+ *
1049
+ * Uses a multi-row INSERT with ON CONFLICT handling for idempotency keys.
1050
+ * Returns IDs in the same order as the input array.
1051
+ */
1052
+ async addJobs(jobs, options) {
1053
+ if (jobs.length === 0) return [];
1054
+ const externalClient = options?.db;
1055
+ const client = externalClient ?? await this.pool.connect();
1056
+ try {
1057
+ const COLS_PER_JOB = 12;
1058
+ const valueClauses = [];
1059
+ const params = [];
1060
+ const hasAnyIdempotencyKey = jobs.some((j) => j.idempotencyKey);
1061
+ for (let i = 0; i < jobs.length; i++) {
1062
+ const {
1063
+ jobType,
1064
+ payload,
1065
+ maxAttempts = 3,
1066
+ priority = 0,
1067
+ runAt = null,
1068
+ timeoutMs = void 0,
1069
+ forceKillOnTimeout = false,
1070
+ tags = void 0,
1071
+ idempotencyKey = void 0,
1072
+ retryDelay = void 0,
1073
+ retryBackoff = void 0,
1074
+ retryDelayMax = void 0
1075
+ } = jobs[i];
1076
+ const base = i * COLS_PER_JOB;
1077
+ valueClauses.push(
1078
+ `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, COALESCE($${base + 5}::timestamptz, CURRENT_TIMESTAMP), $${base + 6}, $${base + 7}, $${base + 8}, $${base + 9}, $${base + 10}, $${base + 11}, $${base + 12})`
1079
+ );
1080
+ params.push(
1081
+ jobType,
1082
+ payload,
1083
+ maxAttempts,
1084
+ priority,
1085
+ runAt,
1086
+ timeoutMs ?? null,
1087
+ forceKillOnTimeout ?? false,
1088
+ tags ?? null,
1089
+ idempotencyKey ?? null,
1090
+ retryDelay ?? null,
1091
+ retryBackoff ?? null,
1092
+ retryDelayMax ?? null
1093
+ );
1094
+ }
1095
+ const onConflict = hasAnyIdempotencyKey ? `ON CONFLICT (idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING` : "";
1096
+ const result = await client.query(
1097
+ `INSERT INTO job_queue
1098
+ (job_type, payload, max_attempts, priority, run_at, timeout_ms, force_kill_on_timeout, tags, idempotency_key, retry_delay, retry_backoff, retry_delay_max)
1099
+ VALUES ${valueClauses.join(", ")}
1100
+ ${onConflict}
1101
+ RETURNING id, idempotency_key`,
1102
+ params
1103
+ );
1104
+ const returnedKeyToId = /* @__PURE__ */ new Map();
1105
+ const returnedNullKeyIds = [];
1106
+ for (const row of result.rows) {
1107
+ if (row.idempotency_key != null) {
1108
+ returnedKeyToId.set(row.idempotency_key, row.id);
1109
+ } else {
1110
+ returnedNullKeyIds.push(row.id);
1111
+ }
1112
+ }
1113
+ const missingKeys = [];
1114
+ for (const job of jobs) {
1115
+ if (job.idempotencyKey && !returnedKeyToId.has(job.idempotencyKey)) {
1116
+ missingKeys.push(job.idempotencyKey);
1117
+ }
1118
+ }
1119
+ if (missingKeys.length > 0) {
1120
+ const existing = await client.query(
1121
+ `SELECT id, idempotency_key FROM job_queue WHERE idempotency_key = ANY($1)`,
1122
+ [missingKeys]
1123
+ );
1124
+ for (const row of existing.rows) {
1125
+ returnedKeyToId.set(row.idempotency_key, row.id);
1126
+ }
1127
+ }
1128
+ let nullKeyIdx = 0;
1129
+ const ids = [];
1130
+ for (const job of jobs) {
1131
+ if (job.idempotencyKey) {
1132
+ const id = returnedKeyToId.get(job.idempotencyKey);
1133
+ if (id === void 0) {
1134
+ throw new Error(
1135
+ `Failed to resolve job ID for idempotency key "${job.idempotencyKey}"`
1136
+ );
1137
+ }
1138
+ ids.push(id);
1139
+ } else {
1140
+ ids.push(returnedNullKeyIds[nullKeyIdx++]);
1141
+ }
1142
+ }
1143
+ log(`Batch-inserted ${jobs.length} jobs, IDs: [${ids.join(", ")}]`);
1144
+ const newJobEvents = [];
1145
+ for (let i = 0; i < jobs.length; i++) {
1146
+ const job = jobs[i];
1147
+ const wasInserted = !job.idempotencyKey || !missingKeys.includes(job.idempotencyKey);
1148
+ if (wasInserted) {
1149
+ newJobEvents.push({
1150
+ jobId: ids[i],
1151
+ eventType: "added" /* Added */,
1152
+ metadata: {
1153
+ jobType: job.jobType,
1154
+ payload: job.payload,
1155
+ tags: job.tags,
1156
+ idempotencyKey: job.idempotencyKey
1157
+ }
1158
+ });
1159
+ }
1160
+ }
1161
+ if (newJobEvents.length > 0) {
1162
+ if (externalClient) {
1163
+ const evtValues = [];
1164
+ const evtParams = [];
1165
+ let evtIdx = 1;
1166
+ for (const evt of newJobEvents) {
1167
+ evtValues.push(`($${evtIdx++}, $${evtIdx++}, $${evtIdx++})`);
1168
+ evtParams.push(
1169
+ evt.jobId,
1170
+ evt.eventType,
1171
+ evt.metadata ? JSON.stringify(evt.metadata) : null
1172
+ );
1173
+ }
1174
+ try {
1175
+ await client.query(
1176
+ `INSERT INTO job_events (job_id, event_type, metadata) VALUES ${evtValues.join(", ")}`,
1177
+ evtParams
1178
+ );
1179
+ } catch (error) {
1180
+ log(`Error recording batch job events: ${error}`);
1181
+ }
1182
+ } else {
1183
+ await this.recordJobEventsBatch(newJobEvents);
1184
+ }
1185
+ }
1186
+ return ids;
1187
+ } catch (error) {
1188
+ log(`Error batch-inserting jobs: ${error}`);
1189
+ throw error;
1190
+ } finally {
1191
+ if (!externalClient) client.release();
1192
+ }
1193
+ }
1194
+ async getJob(id) {
1195
+ const client = await this.pool.connect();
1196
+ try {
1197
+ const result = await client.query(
1198
+ `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`,
1199
+ [id]
1200
+ );
1201
+ if (result.rows.length === 0) {
1202
+ log(`Job ${id} not found`);
1203
+ return null;
1204
+ }
1205
+ log(`Found job ${id}`);
1206
+ const job = result.rows[0];
1207
+ return {
1208
+ ...job,
1209
+ payload: job.payload,
1210
+ timeoutMs: job.timeoutMs,
1211
+ forceKillOnTimeout: job.forceKillOnTimeout,
1212
+ failureReason: job.failureReason
1213
+ };
1214
+ } catch (error) {
1215
+ log(`Error getting job ${id}: ${error}`);
1216
+ throw error;
1217
+ } finally {
1218
+ client.release();
1219
+ }
1220
+ }
1221
+ async getJobsByStatus(status, limit = 100, offset = 0) {
1222
+ const client = await this.pool.connect();
1223
+ try {
1224
+ const result = await client.query(
1225
+ `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`,
1226
+ [status, limit, offset]
1227
+ );
1228
+ log(`Found ${result.rows.length} jobs by status ${status}`);
1229
+ return result.rows.map((job) => ({
1230
+ ...job,
1231
+ payload: job.payload,
1232
+ timeoutMs: job.timeoutMs,
1233
+ forceKillOnTimeout: job.forceKillOnTimeout,
1234
+ failureReason: job.failureReason
1235
+ }));
1236
+ } catch (error) {
1237
+ log(`Error getting jobs by status ${status}: ${error}`);
1238
+ throw error;
1239
+ } finally {
1240
+ client.release();
1241
+ }
1242
+ }
1243
+ async getAllJobs(limit = 100, offset = 0) {
1244
+ const client = await this.pool.connect();
1245
+ try {
1246
+ const result = await client.query(
1247
+ `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`,
1248
+ [limit, offset]
1249
+ );
1250
+ log(`Found ${result.rows.length} jobs (all)`);
1251
+ return result.rows.map((job) => ({
1252
+ ...job,
1253
+ payload: job.payload,
1254
+ timeoutMs: job.timeoutMs,
1255
+ forceKillOnTimeout: job.forceKillOnTimeout
1256
+ }));
1257
+ } catch (error) {
1258
+ log(`Error getting all jobs: ${error}`);
1259
+ throw error;
1260
+ } finally {
1261
+ client.release();
1262
+ }
1263
+ }
1264
+ async getJobs(filters, limit = 100, offset = 0) {
1265
+ const client = await this.pool.connect();
1266
+ try {
1267
+ 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`;
1268
+ const params = [];
1269
+ const where = [];
1270
+ let paramIdx = 1;
1271
+ if (filters) {
1272
+ if (filters.jobType) {
1273
+ where.push(`job_type = $${paramIdx++}`);
1274
+ params.push(filters.jobType);
1275
+ }
1276
+ if (filters.priority !== void 0) {
1277
+ where.push(`priority = $${paramIdx++}`);
1278
+ params.push(filters.priority);
1279
+ }
1280
+ if (filters.runAt) {
1281
+ if (filters.runAt instanceof Date) {
1282
+ where.push(`run_at = $${paramIdx++}`);
1283
+ params.push(filters.runAt);
1284
+ } else if (typeof filters.runAt === "object" && (filters.runAt.gt !== void 0 || filters.runAt.gte !== void 0 || filters.runAt.lt !== void 0 || filters.runAt.lte !== void 0 || filters.runAt.eq !== void 0)) {
1285
+ const ops = filters.runAt;
1286
+ if (ops.gt) {
1287
+ where.push(`run_at > $${paramIdx++}`);
1288
+ params.push(ops.gt);
1289
+ }
1290
+ if (ops.gte) {
1291
+ where.push(`run_at >= $${paramIdx++}`);
1292
+ params.push(ops.gte);
1293
+ }
1294
+ if (ops.lt) {
1295
+ where.push(`run_at < $${paramIdx++}`);
1296
+ params.push(ops.lt);
1297
+ }
1298
+ if (ops.lte) {
1299
+ where.push(`run_at <= $${paramIdx++}`);
1300
+ params.push(ops.lte);
1301
+ }
1302
+ if (ops.eq) {
1303
+ where.push(`run_at = $${paramIdx++}`);
1304
+ params.push(ops.eq);
1305
+ }
1306
+ }
1307
+ }
1308
+ if (filters.tags && filters.tags.values && filters.tags.values.length > 0) {
1309
+ const mode = filters.tags.mode || "all";
1310
+ const tagValues = filters.tags.values;
1311
+ switch (mode) {
1312
+ case "exact":
1313
+ where.push(`tags = $${paramIdx++}`);
1314
+ params.push(tagValues);
1315
+ break;
1316
+ case "all":
1317
+ where.push(`tags @> $${paramIdx++}`);
1318
+ params.push(tagValues);
1319
+ break;
1320
+ case "any":
1321
+ where.push(`tags && $${paramIdx++}`);
1322
+ params.push(tagValues);
1323
+ break;
1324
+ case "none":
1325
+ where.push(`NOT (tags && $${paramIdx++})`);
1326
+ params.push(tagValues);
1327
+ break;
1328
+ default:
1329
+ where.push(`tags @> $${paramIdx++}`);
1330
+ params.push(tagValues);
319
1331
  }
320
1332
  }
321
1333
  if (filters.cursor !== void 0) {
@@ -353,7 +1365,7 @@ var PostgresBackend = class {
353
1365
  async getJobsByTags(tags, mode = "all", limit = 100, offset = 0) {
354
1366
  const client = await this.pool.connect();
355
1367
  try {
356
- 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
1368
+ 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"
357
1369
  FROM job_queue`;
358
1370
  let params = [];
359
1371
  switch (mode) {
@@ -447,7 +1459,7 @@ var PostgresBackend = class {
447
1459
  LIMIT $2
448
1460
  FOR UPDATE SKIP LOCKED
449
1461
  )
450
- 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
1462
+ 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"
451
1463
  `,
452
1464
  params
453
1465
  );
@@ -509,9 +1521,17 @@ var PostgresBackend = class {
509
1521
  UPDATE job_queue
510
1522
  SET status = 'failed',
511
1523
  updated_at = NOW(),
512
- next_attempt_at = CASE
513
- WHEN attempts < max_attempts THEN NOW() + (POWER(2, attempts) * INTERVAL '1 minute')
514
- ELSE NULL
1524
+ next_attempt_at = CASE
1525
+ WHEN attempts >= max_attempts THEN NULL
1526
+ WHEN retry_delay IS NULL AND retry_backoff IS NULL AND retry_delay_max IS NULL
1527
+ THEN NOW() + (POWER(2, attempts) * INTERVAL '1 minute')
1528
+ WHEN COALESCE(retry_backoff, true) = true
1529
+ THEN NOW() + (LEAST(
1530
+ COALESCE(retry_delay_max, 2147483647),
1531
+ COALESCE(retry_delay, 60) * POWER(2, attempts)
1532
+ ) * (0.5 + 0.5 * random()) * INTERVAL '1 second')
1533
+ ELSE
1534
+ NOW() + (COALESCE(retry_delay, 60) * INTERVAL '1 second')
515
1535
  END,
516
1536
  error_history = COALESCE(error_history, '[]'::jsonb) || $2::jsonb,
517
1537
  failure_reason = $3,
@@ -749,6 +1769,18 @@ var PostgresBackend = class {
749
1769
  updateFields.push(`tags = $${paramIdx++}`);
750
1770
  params.push(updates.tags ?? null);
751
1771
  }
1772
+ if (updates.retryDelay !== void 0) {
1773
+ updateFields.push(`retry_delay = $${paramIdx++}`);
1774
+ params.push(updates.retryDelay ?? null);
1775
+ }
1776
+ if (updates.retryBackoff !== void 0) {
1777
+ updateFields.push(`retry_backoff = $${paramIdx++}`);
1778
+ params.push(updates.retryBackoff ?? null);
1779
+ }
1780
+ if (updates.retryDelayMax !== void 0) {
1781
+ updateFields.push(`retry_delay_max = $${paramIdx++}`);
1782
+ params.push(updates.retryDelayMax ?? null);
1783
+ }
752
1784
  if (updateFields.length === 0) {
753
1785
  log(`No fields to update for job ${jobId}`);
754
1786
  return;
@@ -770,6 +1802,12 @@ var PostgresBackend = class {
770
1802
  if (updates.timeoutMs !== void 0)
771
1803
  metadata.timeoutMs = updates.timeoutMs;
772
1804
  if (updates.tags !== void 0) metadata.tags = updates.tags;
1805
+ if (updates.retryDelay !== void 0)
1806
+ metadata.retryDelay = updates.retryDelay;
1807
+ if (updates.retryBackoff !== void 0)
1808
+ metadata.retryBackoff = updates.retryBackoff;
1809
+ if (updates.retryDelayMax !== void 0)
1810
+ metadata.retryDelayMax = updates.retryDelayMax;
773
1811
  await this.recordJobEvent(jobId, "edited" /* Edited */, metadata);
774
1812
  log(`Edited job ${jobId}: ${JSON.stringify(metadata)}`);
775
1813
  } catch (error) {
@@ -813,6 +1851,18 @@ var PostgresBackend = class {
813
1851
  updateFields.push(`tags = $${paramIdx++}`);
814
1852
  params.push(updates.tags ?? null);
815
1853
  }
1854
+ if (updates.retryDelay !== void 0) {
1855
+ updateFields.push(`retry_delay = $${paramIdx++}`);
1856
+ params.push(updates.retryDelay ?? null);
1857
+ }
1858
+ if (updates.retryBackoff !== void 0) {
1859
+ updateFields.push(`retry_backoff = $${paramIdx++}`);
1860
+ params.push(updates.retryBackoff ?? null);
1861
+ }
1862
+ if (updates.retryDelayMax !== void 0) {
1863
+ updateFields.push(`retry_delay_max = $${paramIdx++}`);
1864
+ params.push(updates.retryDelayMax ?? null);
1865
+ }
816
1866
  if (updateFields.length === 0) {
817
1867
  log(`No fields to update for batch edit`);
818
1868
  return 0;
@@ -909,45 +1959,85 @@ var PostgresBackend = class {
909
1959
  client.release();
910
1960
  }
911
1961
  }
912
- async cleanupOldJobs(daysToKeep = 30) {
913
- const client = await this.pool.connect();
1962
+ /**
1963
+ * Delete completed jobs older than the given number of days.
1964
+ * Deletes in batches of 1000 to avoid long-running transactions
1965
+ * and excessive WAL bloat at scale.
1966
+ *
1967
+ * @param daysToKeep - Number of days to retain completed jobs (default 30).
1968
+ * @param batchSize - Number of rows to delete per batch (default 1000).
1969
+ * @returns Total number of deleted jobs.
1970
+ */
1971
+ async cleanupOldJobs(daysToKeep = 30, batchSize = 1e3) {
1972
+ let totalDeleted = 0;
914
1973
  try {
915
- const result = await client.query(
916
- `
917
- DELETE FROM job_queue
918
- WHERE status = 'completed'
919
- AND updated_at < NOW() - INTERVAL '1 day' * $1::int
920
- RETURNING id
921
- `,
922
- [daysToKeep]
923
- );
924
- log(`Deleted ${result.rowCount} old jobs`);
925
- return result.rowCount || 0;
1974
+ let deletedInBatch;
1975
+ do {
1976
+ const client = await this.pool.connect();
1977
+ try {
1978
+ const result = await client.query(
1979
+ `
1980
+ DELETE FROM job_queue
1981
+ WHERE id IN (
1982
+ SELECT id FROM job_queue
1983
+ WHERE status = 'completed'
1984
+ AND updated_at < NOW() - INTERVAL '1 day' * $1::int
1985
+ LIMIT $2
1986
+ )
1987
+ `,
1988
+ [daysToKeep, batchSize]
1989
+ );
1990
+ deletedInBatch = result.rowCount || 0;
1991
+ totalDeleted += deletedInBatch;
1992
+ } finally {
1993
+ client.release();
1994
+ }
1995
+ } while (deletedInBatch === batchSize);
1996
+ log(`Deleted ${totalDeleted} old jobs`);
1997
+ return totalDeleted;
926
1998
  } catch (error) {
927
1999
  log(`Error cleaning up old jobs: ${error}`);
928
2000
  throw error;
929
- } finally {
930
- client.release();
931
2001
  }
932
2002
  }
933
- async cleanupOldJobEvents(daysToKeep = 30) {
934
- const client = await this.pool.connect();
2003
+ /**
2004
+ * Delete job events older than the given number of days.
2005
+ * Deletes in batches of 1000 to avoid long-running transactions
2006
+ * and excessive WAL bloat at scale.
2007
+ *
2008
+ * @param daysToKeep - Number of days to retain events (default 30).
2009
+ * @param batchSize - Number of rows to delete per batch (default 1000).
2010
+ * @returns Total number of deleted events.
2011
+ */
2012
+ async cleanupOldJobEvents(daysToKeep = 30, batchSize = 1e3) {
2013
+ let totalDeleted = 0;
935
2014
  try {
936
- const result = await client.query(
937
- `
938
- DELETE FROM job_events
939
- WHERE created_at < NOW() - INTERVAL '1 day' * $1::int
940
- RETURNING id
941
- `,
942
- [daysToKeep]
943
- );
944
- log(`Deleted ${result.rowCount} old job events`);
945
- return result.rowCount || 0;
2015
+ let deletedInBatch;
2016
+ do {
2017
+ const client = await this.pool.connect();
2018
+ try {
2019
+ const result = await client.query(
2020
+ `
2021
+ DELETE FROM job_events
2022
+ WHERE id IN (
2023
+ SELECT id FROM job_events
2024
+ WHERE created_at < NOW() - INTERVAL '1 day' * $1::int
2025
+ LIMIT $2
2026
+ )
2027
+ `,
2028
+ [daysToKeep, batchSize]
2029
+ );
2030
+ deletedInBatch = result.rowCount || 0;
2031
+ totalDeleted += deletedInBatch;
2032
+ } finally {
2033
+ client.release();
2034
+ }
2035
+ } while (deletedInBatch === batchSize);
2036
+ log(`Deleted ${totalDeleted} old job events`);
2037
+ return totalDeleted;
946
2038
  } catch (error) {
947
2039
  log(`Error cleaning up old job events: ${error}`);
948
2040
  throw error;
949
- } finally {
950
- client.release();
951
2041
  }
952
2042
  }
953
2043
  async reclaimStuckJobs(maxProcessingTimeMinutes = 10) {
@@ -1005,926 +2095,540 @@ var PostgresBackend = class {
1005
2095
  client.release();
1006
2096
  }
1007
2097
  }
1008
- async setPendingReasonForUnpickedJobs(reason, jobType) {
2098
+ // ── Cron schedules ──────────────────────────────────────────────────
2099
+ /** Create a cron schedule and return its ID. */
2100
+ async addCronSchedule(input) {
1009
2101
  const client = await this.pool.connect();
1010
2102
  try {
1011
- let jobTypeFilter = "";
1012
- const params = [reason];
1013
- if (jobType) {
1014
- if (Array.isArray(jobType)) {
1015
- jobTypeFilter = ` AND job_type = ANY($2)`;
1016
- params.push(jobType);
1017
- } else {
1018
- jobTypeFilter = ` AND job_type = $2`;
1019
- params.push(jobType);
1020
- }
1021
- }
1022
- await client.query(
1023
- `UPDATE job_queue SET pending_reason = $1 WHERE status = 'pending'${jobTypeFilter}`,
1024
- params
2103
+ const result = await client.query(
2104
+ `INSERT INTO cron_schedules
2105
+ (schedule_name, cron_expression, job_type, payload, max_attempts,
2106
+ priority, timeout_ms, force_kill_on_timeout, tags, timezone,
2107
+ allow_overlap, next_run_at, retry_delay, retry_backoff, retry_delay_max)
2108
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
2109
+ RETURNING id`,
2110
+ [
2111
+ input.scheduleName,
2112
+ input.cronExpression,
2113
+ input.jobType,
2114
+ input.payload,
2115
+ input.maxAttempts,
2116
+ input.priority,
2117
+ input.timeoutMs,
2118
+ input.forceKillOnTimeout,
2119
+ input.tags ?? null,
2120
+ input.timezone,
2121
+ input.allowOverlap,
2122
+ input.nextRunAt,
2123
+ input.retryDelay,
2124
+ input.retryBackoff,
2125
+ input.retryDelayMax
2126
+ ]
1025
2127
  );
2128
+ const id = result.rows[0].id;
2129
+ log(`Added cron schedule ${id}: "${input.scheduleName}"`);
2130
+ return id;
2131
+ } catch (error) {
2132
+ if (error?.code === "23505") {
2133
+ throw new Error(
2134
+ `Cron schedule with name "${input.scheduleName}" already exists`
2135
+ );
2136
+ }
2137
+ log(`Error adding cron schedule: ${error}`);
2138
+ throw error;
1026
2139
  } finally {
1027
2140
  client.release();
1028
2141
  }
1029
2142
  }
1030
- };
1031
- var recordJobEvent = async (pool, jobId, eventType, metadata) => new PostgresBackend(pool).recordJobEvent(jobId, eventType, metadata);
1032
- var waitJob = async (pool, jobId, options) => {
1033
- const client = await pool.connect();
1034
- try {
1035
- const result = await client.query(
1036
- `
1037
- UPDATE job_queue
1038
- SET status = 'waiting',
1039
- wait_until = $2,
1040
- wait_token_id = $3,
1041
- step_data = $4,
1042
- locked_at = NULL,
1043
- locked_by = NULL,
1044
- updated_at = NOW()
1045
- WHERE id = $1 AND status = 'processing'
1046
- `,
1047
- [
1048
- jobId,
1049
- options.waitUntil ?? null,
1050
- options.waitTokenId ?? null,
1051
- JSON.stringify(options.stepData)
1052
- ]
1053
- );
1054
- if (result.rowCount === 0) {
1055
- log(
1056
- `Job ${jobId} could not be set to waiting (may have been reclaimed or is no longer processing)`
2143
+ /** Get a cron schedule by ID. */
2144
+ async getCronSchedule(id) {
2145
+ const client = await this.pool.connect();
2146
+ try {
2147
+ const result = await client.query(
2148
+ `SELECT id, schedule_name AS "scheduleName", cron_expression AS "cronExpression",
2149
+ job_type AS "jobType", payload, max_attempts AS "maxAttempts",
2150
+ priority, timeout_ms AS "timeoutMs",
2151
+ force_kill_on_timeout AS "forceKillOnTimeout", tags,
2152
+ timezone, allow_overlap AS "allowOverlap", status,
2153
+ last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
2154
+ next_run_at AS "nextRunAt",
2155
+ created_at AS "createdAt", updated_at AS "updatedAt",
2156
+ retry_delay AS "retryDelay", retry_backoff AS "retryBackoff",
2157
+ retry_delay_max AS "retryDelayMax"
2158
+ FROM cron_schedules WHERE id = $1`,
2159
+ [id]
1057
2160
  );
1058
- return;
1059
- }
1060
- await recordJobEvent(pool, jobId, "waiting" /* Waiting */, {
1061
- waitUntil: options.waitUntil?.toISOString() ?? null,
1062
- waitTokenId: options.waitTokenId ?? null
1063
- });
1064
- log(`Job ${jobId} set to waiting`);
1065
- } catch (error) {
1066
- log(`Error setting job ${jobId} to waiting: ${error}`);
1067
- throw error;
1068
- } finally {
1069
- client.release();
1070
- }
1071
- };
1072
- var updateStepData = async (pool, jobId, stepData) => {
1073
- const client = await pool.connect();
1074
- try {
1075
- await client.query(
1076
- `UPDATE job_queue SET step_data = $2, updated_at = NOW() WHERE id = $1`,
1077
- [jobId, JSON.stringify(stepData)]
1078
- );
1079
- } catch (error) {
1080
- log(`Error updating step_data for job ${jobId}: ${error}`);
1081
- } finally {
1082
- client.release();
1083
- }
1084
- };
1085
- var MAX_TIMEOUT_MS = 365 * 24 * 60 * 60 * 1e3;
1086
- function parseTimeoutString(timeout) {
1087
- const match = timeout.match(/^(\d+)(s|m|h|d)$/);
1088
- if (!match) {
1089
- throw new Error(
1090
- `Invalid timeout format: "${timeout}". Expected format like "10m", "1h", "24h", "7d".`
1091
- );
1092
- }
1093
- const value = parseInt(match[1], 10);
1094
- const unit = match[2];
1095
- let ms;
1096
- switch (unit) {
1097
- case "s":
1098
- ms = value * 1e3;
1099
- break;
1100
- case "m":
1101
- ms = value * 60 * 1e3;
1102
- break;
1103
- case "h":
1104
- ms = value * 60 * 60 * 1e3;
1105
- break;
1106
- case "d":
1107
- ms = value * 24 * 60 * 60 * 1e3;
1108
- break;
1109
- default:
1110
- throw new Error(`Unknown timeout unit: "${unit}"`);
1111
- }
1112
- if (!Number.isFinite(ms) || ms > MAX_TIMEOUT_MS) {
1113
- throw new Error(
1114
- `Timeout value "${timeout}" is too large. Maximum allowed is 365 days.`
1115
- );
1116
- }
1117
- return ms;
1118
- }
1119
- var createWaitpoint = async (pool, jobId, options) => {
1120
- const client = await pool.connect();
1121
- try {
1122
- const id = `wp_${crypto.randomUUID()}`;
1123
- let timeoutAt = null;
1124
- if (options?.timeout) {
1125
- const ms = parseTimeoutString(options.timeout);
1126
- timeoutAt = new Date(Date.now() + ms);
2161
+ if (result.rows.length === 0) return null;
2162
+ return result.rows[0];
2163
+ } catch (error) {
2164
+ log(`Error getting cron schedule ${id}: ${error}`);
2165
+ throw error;
2166
+ } finally {
2167
+ client.release();
1127
2168
  }
1128
- await client.query(
1129
- `INSERT INTO waitpoints (id, job_id, status, timeout_at, tags) VALUES ($1, $2, 'waiting', $3, $4)`,
1130
- [id, jobId, timeoutAt, options?.tags ?? null]
1131
- );
1132
- log(`Created waitpoint ${id} for job ${jobId}`);
1133
- return { id };
1134
- } catch (error) {
1135
- log(`Error creating waitpoint: ${error}`);
1136
- throw error;
1137
- } finally {
1138
- client.release();
1139
2169
  }
1140
- };
1141
- var completeWaitpoint = async (pool, tokenId, data) => {
1142
- const client = await pool.connect();
1143
- try {
1144
- await client.query("BEGIN");
1145
- const wpResult = await client.query(
1146
- `UPDATE waitpoints SET status = 'completed', output = $2, completed_at = NOW()
1147
- WHERE id = $1 AND status = 'waiting'
1148
- RETURNING job_id`,
1149
- [tokenId, data != null ? JSON.stringify(data) : null]
1150
- );
1151
- if (wpResult.rows.length === 0) {
1152
- await client.query("ROLLBACK");
1153
- log(`Waitpoint ${tokenId} not found or already completed`);
1154
- return;
1155
- }
1156
- const jobId = wpResult.rows[0].job_id;
1157
- if (jobId != null) {
1158
- await client.query(
1159
- `UPDATE job_queue
1160
- SET status = 'pending', wait_token_id = NULL, wait_until = NULL, updated_at = NOW()
1161
- WHERE id = $1 AND status = 'waiting'`,
1162
- [jobId]
2170
+ /** Get a cron schedule by its unique name. */
2171
+ async getCronScheduleByName(name) {
2172
+ const client = await this.pool.connect();
2173
+ try {
2174
+ const result = await client.query(
2175
+ `SELECT id, schedule_name AS "scheduleName", cron_expression AS "cronExpression",
2176
+ job_type AS "jobType", payload, max_attempts AS "maxAttempts",
2177
+ priority, timeout_ms AS "timeoutMs",
2178
+ force_kill_on_timeout AS "forceKillOnTimeout", tags,
2179
+ timezone, allow_overlap AS "allowOverlap", status,
2180
+ last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
2181
+ next_run_at AS "nextRunAt",
2182
+ created_at AS "createdAt", updated_at AS "updatedAt",
2183
+ retry_delay AS "retryDelay", retry_backoff AS "retryBackoff",
2184
+ retry_delay_max AS "retryDelayMax"
2185
+ FROM cron_schedules WHERE schedule_name = $1`,
2186
+ [name]
1163
2187
  );
2188
+ if (result.rows.length === 0) return null;
2189
+ return result.rows[0];
2190
+ } catch (error) {
2191
+ log(`Error getting cron schedule by name "${name}": ${error}`);
2192
+ throw error;
2193
+ } finally {
2194
+ client.release();
1164
2195
  }
1165
- await client.query("COMMIT");
1166
- log(`Completed waitpoint ${tokenId} for job ${jobId}`);
1167
- } catch (error) {
1168
- await client.query("ROLLBACK");
1169
- log(`Error completing waitpoint ${tokenId}: ${error}`);
1170
- throw error;
1171
- } finally {
1172
- client.release();
1173
- }
1174
- };
1175
- var getWaitpoint = async (pool, tokenId) => {
1176
- const client = await pool.connect();
1177
- try {
1178
- const result = await client.query(
1179
- `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`,
1180
- [tokenId]
1181
- );
1182
- if (result.rows.length === 0) return null;
1183
- return result.rows[0];
1184
- } catch (error) {
1185
- log(`Error getting waitpoint ${tokenId}: ${error}`);
1186
- throw error;
1187
- } finally {
1188
- client.release();
1189
2196
  }
1190
- };
1191
- var expireTimedOutWaitpoints = async (pool) => {
1192
- const client = await pool.connect();
1193
- try {
1194
- await client.query("BEGIN");
1195
- const result = await client.query(
1196
- `UPDATE waitpoints
1197
- SET status = 'timed_out'
1198
- WHERE status = 'waiting' AND timeout_at IS NOT NULL AND timeout_at <= NOW()
1199
- RETURNING id, job_id`
1200
- );
1201
- for (const row of result.rows) {
1202
- if (row.job_id != null) {
1203
- await client.query(
1204
- `UPDATE job_queue
1205
- SET status = 'pending', wait_token_id = NULL, wait_until = NULL, updated_at = NOW()
1206
- WHERE id = $1 AND status = 'waiting'`,
1207
- [row.job_id]
1208
- );
2197
+ /** List cron schedules, optionally filtered by status. */
2198
+ async listCronSchedules(status) {
2199
+ const client = await this.pool.connect();
2200
+ try {
2201
+ let query = `SELECT id, schedule_name AS "scheduleName", cron_expression AS "cronExpression",
2202
+ job_type AS "jobType", payload, max_attempts AS "maxAttempts",
2203
+ priority, timeout_ms AS "timeoutMs",
2204
+ force_kill_on_timeout AS "forceKillOnTimeout", tags,
2205
+ timezone, allow_overlap AS "allowOverlap", status,
2206
+ last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
2207
+ next_run_at AS "nextRunAt",
2208
+ created_at AS "createdAt", updated_at AS "updatedAt",
2209
+ retry_delay AS "retryDelay", retry_backoff AS "retryBackoff",
2210
+ retry_delay_max AS "retryDelayMax"
2211
+ FROM cron_schedules`;
2212
+ const params = [];
2213
+ if (status) {
2214
+ query += ` WHERE status = $1`;
2215
+ params.push(status);
1209
2216
  }
2217
+ query += ` ORDER BY created_at ASC`;
2218
+ const result = await client.query(query, params);
2219
+ return result.rows;
2220
+ } catch (error) {
2221
+ log(`Error listing cron schedules: ${error}`);
2222
+ throw error;
2223
+ } finally {
2224
+ client.release();
1210
2225
  }
1211
- await client.query("COMMIT");
1212
- const count = result.rowCount || 0;
1213
- if (count > 0) {
1214
- log(`Expired ${count} timed-out waitpoints`);
1215
- }
1216
- return count;
1217
- } catch (error) {
1218
- await client.query("ROLLBACK");
1219
- log(`Error expiring timed-out waitpoints: ${error}`);
1220
- throw error;
1221
- } finally {
1222
- client.release();
1223
- }
1224
- };
1225
- function tryExtractPool(backend) {
1226
- if (backend instanceof PostgresBackend) {
1227
- return backend.getPool();
1228
- }
1229
- return null;
1230
- }
1231
- function buildBasicContext(backend, jobId, baseCtx) {
1232
- const waitError = () => new Error(
1233
- "Wait features (waitFor, waitUntil, createToken, waitForToken, ctx.run) are currently only supported with the PostgreSQL backend."
1234
- );
1235
- return {
1236
- prolong: baseCtx.prolong,
1237
- onTimeout: baseCtx.onTimeout,
1238
- run: async (_stepName, fn) => {
1239
- return fn();
1240
- },
1241
- waitFor: async () => {
1242
- throw waitError();
1243
- },
1244
- waitUntil: async () => {
1245
- throw waitError();
1246
- },
1247
- createToken: async () => {
1248
- throw waitError();
1249
- },
1250
- waitForToken: async () => {
1251
- throw waitError();
1252
- },
1253
- setProgress: async (percent) => {
1254
- if (percent < 0 || percent > 100)
1255
- throw new Error("Progress must be between 0 and 100");
1256
- await backend.updateProgress(jobId, Math.round(percent));
1257
- }
1258
- };
1259
- }
1260
- function validateHandlerSerializable(handler, jobType) {
1261
- try {
1262
- const handlerString = handler.toString();
1263
- if (handlerString.includes("this.") && !handlerString.match(/\([^)]*this[^)]*\)/)) {
1264
- throw new Error(
1265
- `Handler for job type "${jobType}" uses 'this' context which cannot be serialized. Use a regular function or avoid 'this' references when forceKillOnTimeout is enabled.`
1266
- );
1267
- }
1268
- if (handlerString.includes("[native code]")) {
1269
- throw new Error(
1270
- `Handler for job type "${jobType}" contains native code which cannot be serialized. Ensure your handler is a plain function when forceKillOnTimeout is enabled.`
1271
- );
2226
+ }
2227
+ /** Delete a cron schedule by ID. */
2228
+ async removeCronSchedule(id) {
2229
+ const client = await this.pool.connect();
2230
+ try {
2231
+ await client.query(`DELETE FROM cron_schedules WHERE id = $1`, [id]);
2232
+ log(`Removed cron schedule ${id}`);
2233
+ } catch (error) {
2234
+ log(`Error removing cron schedule ${id}: ${error}`);
2235
+ throw error;
2236
+ } finally {
2237
+ client.release();
1272
2238
  }
2239
+ }
2240
+ /** Pause a cron schedule. */
2241
+ async pauseCronSchedule(id) {
2242
+ const client = await this.pool.connect();
1273
2243
  try {
1274
- new Function("return " + handlerString);
1275
- } catch (parseError) {
1276
- throw new Error(
1277
- `Handler for job type "${jobType}" cannot be serialized: ${parseError instanceof Error ? parseError.message : String(parseError)}. When using forceKillOnTimeout, handlers must be serializable functions without closures over external variables.`
2244
+ await client.query(
2245
+ `UPDATE cron_schedules SET status = 'paused', updated_at = NOW() WHERE id = $1`,
2246
+ [id]
1278
2247
  );
2248
+ log(`Paused cron schedule ${id}`);
2249
+ } catch (error) {
2250
+ log(`Error pausing cron schedule ${id}: ${error}`);
2251
+ throw error;
2252
+ } finally {
2253
+ client.release();
1279
2254
  }
1280
- } catch (error) {
1281
- if (error instanceof Error) {
2255
+ }
2256
+ /** Resume a paused cron schedule. */
2257
+ async resumeCronSchedule(id) {
2258
+ const client = await this.pool.connect();
2259
+ try {
2260
+ await client.query(
2261
+ `UPDATE cron_schedules SET status = 'active', updated_at = NOW() WHERE id = $1`,
2262
+ [id]
2263
+ );
2264
+ log(`Resumed cron schedule ${id}`);
2265
+ } catch (error) {
2266
+ log(`Error resuming cron schedule ${id}: ${error}`);
1282
2267
  throw error;
2268
+ } finally {
2269
+ client.release();
1283
2270
  }
1284
- throw new Error(
1285
- `Failed to validate handler serialization for job type "${jobType}": ${String(error)}`
1286
- );
1287
2271
  }
1288
- }
1289
- async function runHandlerInWorker(handler, payload, timeoutMs, jobType) {
1290
- validateHandlerSerializable(handler, jobType);
1291
- return new Promise((resolve, reject) => {
1292
- const workerCode = `
1293
- (function() {
1294
- const { parentPort, workerData } = require('worker_threads');
1295
- const { handlerCode, payload, timeoutMs } = workerData;
1296
-
1297
- // Create an AbortController for the handler
1298
- const controller = new AbortController();
1299
- const signal = controller.signal;
1300
-
1301
- // Set up timeout
1302
- const timeoutId = setTimeout(() => {
1303
- controller.abort();
1304
- parentPort.postMessage({ type: 'timeout' });
1305
- }, timeoutMs);
1306
-
1307
- try {
1308
- // Execute the handler
1309
- // Note: This uses Function constructor which requires the handler to be serializable.
1310
- // The handler should be validated before reaching this point.
1311
- let handlerFn;
1312
- try {
1313
- // Wrap handlerCode in parentheses to ensure it's treated as an expression
1314
- // This handles both arrow functions and regular functions
1315
- const wrappedCode = handlerCode.trim().startsWith('async') || handlerCode.trim().startsWith('function')
1316
- ? handlerCode
1317
- : '(' + handlerCode + ')';
1318
- handlerFn = new Function('return ' + wrappedCode)();
1319
- } catch (parseError) {
1320
- clearTimeout(timeoutId);
1321
- parentPort.postMessage({
1322
- type: 'error',
1323
- error: {
1324
- message: 'Handler cannot be deserialized in worker thread. ' +
1325
- 'Ensure your handler is a standalone function without closures over external variables. ' +
1326
- 'Original error: ' + (parseError instanceof Error ? parseError.message : String(parseError)),
1327
- stack: parseError instanceof Error ? parseError.stack : undefined,
1328
- name: 'SerializationError',
1329
- },
1330
- });
1331
- return;
1332
- }
1333
-
1334
- // Ensure handlerFn is actually a function
1335
- if (typeof handlerFn !== 'function') {
1336
- clearTimeout(timeoutId);
1337
- parentPort.postMessage({
1338
- type: 'error',
1339
- error: {
1340
- message: 'Handler deserialization did not produce a function. ' +
1341
- 'Ensure your handler is a valid function when forceKillOnTimeout is enabled.',
1342
- name: 'SerializationError',
1343
- },
1344
- });
1345
- return;
1346
- }
1347
-
1348
- handlerFn(payload, signal)
1349
- .then(() => {
1350
- clearTimeout(timeoutId);
1351
- parentPort.postMessage({ type: 'success' });
1352
- })
1353
- .catch((error) => {
1354
- clearTimeout(timeoutId);
1355
- parentPort.postMessage({
1356
- type: 'error',
1357
- error: {
1358
- message: error.message,
1359
- stack: error.stack,
1360
- name: error.name,
1361
- },
1362
- });
1363
- });
1364
- } catch (error) {
1365
- clearTimeout(timeoutId);
1366
- parentPort.postMessage({
1367
- type: 'error',
1368
- error: {
1369
- message: error.message,
1370
- stack: error.stack,
1371
- name: error.name,
1372
- },
1373
- });
1374
- }
1375
- })();
1376
- `;
1377
- const worker = new worker_threads.Worker(workerCode, {
1378
- eval: true,
1379
- workerData: {
1380
- handlerCode: handler.toString(),
1381
- payload,
1382
- timeoutMs
1383
- }
1384
- });
1385
- let resolved = false;
1386
- worker.on("message", (message) => {
1387
- if (resolved) return;
1388
- resolved = true;
1389
- if (message.type === "success") {
1390
- resolve();
1391
- } else if (message.type === "timeout") {
1392
- const timeoutError = new Error(
1393
- `Job timed out after ${timeoutMs} ms and was forcefully terminated`
1394
- );
1395
- timeoutError.failureReason = "timeout" /* Timeout */;
1396
- reject(timeoutError);
1397
- } else if (message.type === "error") {
1398
- const error = new Error(message.error.message);
1399
- error.stack = message.error.stack;
1400
- error.name = message.error.name;
1401
- reject(error);
1402
- }
1403
- });
1404
- worker.on("error", (error) => {
1405
- if (resolved) return;
1406
- resolved = true;
1407
- reject(error);
1408
- });
1409
- worker.on("exit", (code) => {
1410
- if (resolved) return;
1411
- if (code !== 0) {
1412
- resolved = true;
1413
- reject(new Error(`Worker stopped with exit code ${code}`));
1414
- }
1415
- });
1416
- setTimeout(() => {
1417
- if (!resolved) {
1418
- resolved = true;
1419
- worker.terminate().then(() => {
1420
- const timeoutError = new Error(
1421
- `Job timed out after ${timeoutMs} ms and was forcefully terminated`
1422
- );
1423
- timeoutError.failureReason = "timeout" /* Timeout */;
1424
- reject(timeoutError);
1425
- }).catch((err) => {
1426
- reject(err);
1427
- });
2272
+ /** Edit a cron schedule. */
2273
+ async editCronSchedule(id, updates, nextRunAt) {
2274
+ const client = await this.pool.connect();
2275
+ try {
2276
+ const updateFields = [];
2277
+ const params = [];
2278
+ let paramIdx = 1;
2279
+ if (updates.cronExpression !== void 0) {
2280
+ updateFields.push(`cron_expression = $${paramIdx++}`);
2281
+ params.push(updates.cronExpression);
1428
2282
  }
1429
- }, timeoutMs + 100);
1430
- });
1431
- }
1432
- function calculateWaitUntil(duration) {
1433
- const now = Date.now();
1434
- let ms = 0;
1435
- if (duration.seconds) ms += duration.seconds * 1e3;
1436
- if (duration.minutes) ms += duration.minutes * 60 * 1e3;
1437
- if (duration.hours) ms += duration.hours * 60 * 60 * 1e3;
1438
- if (duration.days) ms += duration.days * 24 * 60 * 60 * 1e3;
1439
- if (duration.weeks) ms += duration.weeks * 7 * 24 * 60 * 60 * 1e3;
1440
- if (duration.months) ms += duration.months * 30 * 24 * 60 * 60 * 1e3;
1441
- if (duration.years) ms += duration.years * 365 * 24 * 60 * 60 * 1e3;
1442
- if (ms <= 0) {
1443
- throw new Error(
1444
- "waitFor duration must be positive. Provide at least one positive duration field."
1445
- );
1446
- }
1447
- return new Date(now + ms);
1448
- }
1449
- async function resolveCompletedWaits(pool, stepData) {
1450
- for (const key of Object.keys(stepData)) {
1451
- if (!key.startsWith("__wait_")) continue;
1452
- const entry = stepData[key];
1453
- if (!entry || typeof entry !== "object" || entry.completed) continue;
1454
- if (entry.type === "duration" || entry.type === "date") {
1455
- stepData[key] = { ...entry, completed: true };
1456
- } else if (entry.type === "token" && entry.tokenId) {
1457
- const wp = await getWaitpoint(pool, entry.tokenId);
1458
- if (wp && wp.status === "completed") {
1459
- stepData[key] = {
1460
- ...entry,
1461
- completed: true,
1462
- result: { ok: true, output: wp.output }
1463
- };
1464
- } else if (wp && wp.status === "timed_out") {
1465
- stepData[key] = {
1466
- ...entry,
1467
- completed: true,
1468
- result: { ok: false, error: "Token timed out" }
1469
- };
2283
+ if (updates.payload !== void 0) {
2284
+ updateFields.push(`payload = $${paramIdx++}`);
2285
+ params.push(updates.payload);
1470
2286
  }
1471
- }
1472
- }
1473
- }
1474
- function buildWaitContext(backend, pool, jobId, stepData, baseCtx) {
1475
- let waitCounter = 0;
1476
- const ctx = {
1477
- prolong: baseCtx.prolong,
1478
- onTimeout: baseCtx.onTimeout,
1479
- run: async (stepName, fn) => {
1480
- const cached = stepData[stepName];
1481
- if (cached && typeof cached === "object" && cached.__completed) {
1482
- log(`Step "${stepName}" replayed from cache for job ${jobId}`);
1483
- return cached.result;
2287
+ if (updates.maxAttempts !== void 0) {
2288
+ updateFields.push(`max_attempts = $${paramIdx++}`);
2289
+ params.push(updates.maxAttempts);
1484
2290
  }
1485
- const result = await fn();
1486
- stepData[stepName] = { __completed: true, result };
1487
- await updateStepData(pool, jobId, stepData);
1488
- return result;
1489
- },
1490
- waitFor: async (duration) => {
1491
- const waitKey = `__wait_${waitCounter++}`;
1492
- const cached = stepData[waitKey];
1493
- if (cached && typeof cached === "object" && cached.completed) {
1494
- log(`Wait "${waitKey}" already completed for job ${jobId}, skipping`);
1495
- return;
2291
+ if (updates.priority !== void 0) {
2292
+ updateFields.push(`priority = $${paramIdx++}`);
2293
+ params.push(updates.priority);
1496
2294
  }
1497
- const waitUntilDate = calculateWaitUntil(duration);
1498
- stepData[waitKey] = { type: "duration", completed: false };
1499
- throw new WaitSignal("duration", waitUntilDate, void 0, stepData);
1500
- },
1501
- waitUntil: async (date) => {
1502
- const waitKey = `__wait_${waitCounter++}`;
1503
- const cached = stepData[waitKey];
1504
- if (cached && typeof cached === "object" && cached.completed) {
1505
- log(`Wait "${waitKey}" already completed for job ${jobId}, skipping`);
1506
- return;
2295
+ if (updates.timeoutMs !== void 0) {
2296
+ updateFields.push(`timeout_ms = $${paramIdx++}`);
2297
+ params.push(updates.timeoutMs);
1507
2298
  }
1508
- stepData[waitKey] = { type: "date", completed: false };
1509
- throw new WaitSignal("date", date, void 0, stepData);
1510
- },
1511
- createToken: async (options) => {
1512
- const token = await createWaitpoint(pool, jobId, options);
1513
- return token;
1514
- },
1515
- waitForToken: async (tokenId) => {
1516
- const waitKey = `__wait_${waitCounter++}`;
1517
- const cached = stepData[waitKey];
1518
- if (cached && typeof cached === "object" && cached.completed) {
1519
- log(
1520
- `Token wait "${waitKey}" already completed for job ${jobId}, returning cached result`
1521
- );
1522
- return cached.result;
2299
+ if (updates.forceKillOnTimeout !== void 0) {
2300
+ updateFields.push(`force_kill_on_timeout = $${paramIdx++}`);
2301
+ params.push(updates.forceKillOnTimeout);
1523
2302
  }
1524
- const wp = await getWaitpoint(pool, tokenId);
1525
- if (wp && wp.status === "completed") {
1526
- const result = {
1527
- ok: true,
1528
- output: wp.output
1529
- };
1530
- stepData[waitKey] = {
1531
- type: "token",
1532
- tokenId,
1533
- completed: true,
1534
- result
1535
- };
1536
- await updateStepData(pool, jobId, stepData);
1537
- return result;
2303
+ if (updates.tags !== void 0) {
2304
+ updateFields.push(`tags = $${paramIdx++}`);
2305
+ params.push(updates.tags);
1538
2306
  }
1539
- if (wp && wp.status === "timed_out") {
1540
- const result = {
1541
- ok: false,
1542
- error: "Token timed out"
1543
- };
1544
- stepData[waitKey] = {
1545
- type: "token",
1546
- tokenId,
1547
- completed: true,
1548
- result
1549
- };
1550
- await updateStepData(pool, jobId, stepData);
1551
- return result;
2307
+ if (updates.timezone !== void 0) {
2308
+ updateFields.push(`timezone = $${paramIdx++}`);
2309
+ params.push(updates.timezone);
1552
2310
  }
1553
- stepData[waitKey] = { type: "token", tokenId, completed: false };
1554
- throw new WaitSignal("token", void 0, tokenId, stepData);
1555
- },
1556
- setProgress: async (percent) => {
1557
- if (percent < 0 || percent > 100)
1558
- throw new Error("Progress must be between 0 and 100");
1559
- await backend.updateProgress(jobId, Math.round(percent));
1560
- }
1561
- };
1562
- return ctx;
1563
- }
1564
- async function processJobWithHandlers(backend, job, jobHandlers) {
1565
- const handler = jobHandlers[job.jobType];
1566
- if (!handler) {
1567
- await backend.setPendingReasonForUnpickedJobs(
1568
- `No handler registered for job type: ${job.jobType}`,
1569
- job.jobType
1570
- );
1571
- await backend.failJob(
1572
- job.id,
1573
- new Error(`No handler registered for job type: ${job.jobType}`),
1574
- "no_handler" /* NoHandler */
1575
- );
1576
- return;
1577
- }
1578
- const stepData = { ...job.stepData || {} };
1579
- const pool = tryExtractPool(backend);
1580
- const hasStepHistory = Object.keys(stepData).some(
1581
- (k) => k.startsWith("__wait_")
1582
- );
1583
- if (hasStepHistory && pool) {
1584
- await resolveCompletedWaits(pool, stepData);
1585
- await updateStepData(pool, job.id, stepData);
1586
- }
1587
- const timeoutMs = job.timeoutMs ?? void 0;
1588
- const forceKillOnTimeout = job.forceKillOnTimeout ?? false;
1589
- let timeoutId;
1590
- const controller = new AbortController();
1591
- try {
1592
- if (forceKillOnTimeout && timeoutMs && timeoutMs > 0) {
1593
- await runHandlerInWorker(handler, job.payload, timeoutMs, job.jobType);
1594
- } else {
1595
- let onTimeoutCallback;
1596
- let timeoutReject;
1597
- const armTimeout = (ms) => {
1598
- if (timeoutId) clearTimeout(timeoutId);
1599
- timeoutId = setTimeout(() => {
1600
- if (onTimeoutCallback) {
1601
- try {
1602
- const extension = onTimeoutCallback();
1603
- if (typeof extension === "number" && extension > 0) {
1604
- backend.prolongJob(job.id).catch(() => {
1605
- });
1606
- armTimeout(extension);
1607
- return;
1608
- }
1609
- } catch (callbackError) {
1610
- log(
1611
- `onTimeout callback threw for job ${job.id}: ${callbackError}`
1612
- );
1613
- }
1614
- }
1615
- controller.abort();
1616
- const timeoutError = new Error(`Job timed out after ${ms} ms`);
1617
- timeoutError.failureReason = "timeout" /* Timeout */;
1618
- if (timeoutReject) {
1619
- timeoutReject(timeoutError);
1620
- }
1621
- }, ms);
1622
- };
1623
- const hasTimeout = timeoutMs != null && timeoutMs > 0;
1624
- const baseCtx = hasTimeout ? {
1625
- prolong: (ms) => {
1626
- const duration = ms ?? timeoutMs;
1627
- if (duration != null && duration > 0) {
1628
- armTimeout(duration);
1629
- backend.prolongJob(job.id).catch(() => {
1630
- });
1631
- }
1632
- },
1633
- onTimeout: (callback) => {
1634
- onTimeoutCallback = callback;
1635
- }
1636
- } : {
1637
- prolong: () => {
1638
- log("prolong() called but ignored: job has no timeout set");
1639
- },
1640
- onTimeout: () => {
1641
- log("onTimeout() called but ignored: job has no timeout set");
1642
- }
1643
- };
1644
- const ctx = pool ? buildWaitContext(backend, pool, job.id, stepData, baseCtx) : buildBasicContext(backend, job.id, baseCtx);
1645
- if (forceKillOnTimeout && !hasTimeout) {
1646
- log(
1647
- `forceKillOnTimeout is set but no timeoutMs for job ${job.id}, running without force kill`
1648
- );
2311
+ if (updates.allowOverlap !== void 0) {
2312
+ updateFields.push(`allow_overlap = $${paramIdx++}`);
2313
+ params.push(updates.allowOverlap);
1649
2314
  }
1650
- const jobPromise = handler(job.payload, controller.signal, ctx);
1651
- if (hasTimeout) {
1652
- await Promise.race([
1653
- jobPromise,
1654
- new Promise((_, reject) => {
1655
- timeoutReject = reject;
1656
- armTimeout(timeoutMs);
1657
- })
1658
- ]);
1659
- } else {
1660
- await jobPromise;
2315
+ if (updates.retryDelay !== void 0) {
2316
+ updateFields.push(`retry_delay = $${paramIdx++}`);
2317
+ params.push(updates.retryDelay);
1661
2318
  }
1662
- }
1663
- if (timeoutId) clearTimeout(timeoutId);
1664
- await backend.completeJob(job.id);
1665
- } catch (error) {
1666
- if (timeoutId) clearTimeout(timeoutId);
1667
- if (error instanceof WaitSignal) {
1668
- if (!pool) {
1669
- await backend.failJob(
1670
- job.id,
1671
- new Error(
1672
- "WaitSignal received but wait features require the PostgreSQL backend."
1673
- ),
1674
- "handler_error" /* HandlerError */
1675
- );
2319
+ if (updates.retryBackoff !== void 0) {
2320
+ updateFields.push(`retry_backoff = $${paramIdx++}`);
2321
+ params.push(updates.retryBackoff);
2322
+ }
2323
+ if (updates.retryDelayMax !== void 0) {
2324
+ updateFields.push(`retry_delay_max = $${paramIdx++}`);
2325
+ params.push(updates.retryDelayMax);
2326
+ }
2327
+ if (nextRunAt !== void 0) {
2328
+ updateFields.push(`next_run_at = $${paramIdx++}`);
2329
+ params.push(nextRunAt);
2330
+ }
2331
+ if (updateFields.length === 0) {
2332
+ log(`No fields to update for cron schedule ${id}`);
1676
2333
  return;
1677
2334
  }
1678
- log(
1679
- `Job ${job.id} entering wait: type=${error.type}, waitUntil=${error.waitUntil?.toISOString() ?? "none"}, tokenId=${error.tokenId ?? "none"}`
1680
- );
1681
- await waitJob(pool, job.id, {
1682
- waitUntil: error.waitUntil,
1683
- waitTokenId: error.tokenId,
1684
- stepData: error.stepData
1685
- });
1686
- return;
1687
- }
1688
- console.error(`Error processing job ${job.id}:`, error);
1689
- let failureReason = "handler_error" /* HandlerError */;
1690
- if (error && typeof error === "object" && "failureReason" in error && error.failureReason === "timeout" /* Timeout */) {
1691
- failureReason = "timeout" /* Timeout */;
2335
+ updateFields.push(`updated_at = NOW()`);
2336
+ params.push(id);
2337
+ const query = `UPDATE cron_schedules SET ${updateFields.join(", ")} WHERE id = $${paramIdx}`;
2338
+ await client.query(query, params);
2339
+ log(`Edited cron schedule ${id}`);
2340
+ } catch (error) {
2341
+ log(`Error editing cron schedule ${id}: ${error}`);
2342
+ throw error;
2343
+ } finally {
2344
+ client.release();
1692
2345
  }
1693
- await backend.failJob(
1694
- job.id,
1695
- error instanceof Error ? error : new Error(String(error)),
1696
- failureReason
1697
- );
1698
- }
1699
- }
1700
- async function processBatchWithHandlers(backend, workerId, batchSize, jobType, jobHandlers, concurrency, onError) {
1701
- const jobs = await backend.getNextBatch(
1702
- workerId,
1703
- batchSize,
1704
- jobType
1705
- );
1706
- if (!concurrency || concurrency >= jobs.length) {
1707
- await Promise.all(
1708
- jobs.map((job) => processJobWithHandlers(backend, job, jobHandlers))
1709
- );
1710
- return jobs.length;
1711
2346
  }
1712
- let idx = 0;
1713
- let running = 0;
1714
- let finished = 0;
1715
- return new Promise((resolve, reject) => {
1716
- const next = () => {
1717
- if (finished === jobs.length) return resolve(jobs.length);
1718
- while (running < concurrency && idx < jobs.length) {
1719
- const job = jobs[idx++];
1720
- running++;
1721
- processJobWithHandlers(backend, job, jobHandlers).then(() => {
1722
- running--;
1723
- finished++;
1724
- next();
1725
- }).catch((err) => {
1726
- running--;
1727
- finished++;
1728
- if (onError) {
1729
- onError(err instanceof Error ? err : new Error(String(err)));
1730
- }
1731
- next();
1732
- });
2347
+ /**
2348
+ * Atomically fetch all active cron schedules whose nextRunAt <= NOW().
2349
+ * Uses FOR UPDATE SKIP LOCKED to prevent duplicate enqueuing across workers.
2350
+ */
2351
+ async getDueCronSchedules() {
2352
+ const client = await this.pool.connect();
2353
+ try {
2354
+ const result = await client.query(
2355
+ `SELECT id, schedule_name AS "scheduleName", cron_expression AS "cronExpression",
2356
+ job_type AS "jobType", payload, max_attempts AS "maxAttempts",
2357
+ priority, timeout_ms AS "timeoutMs",
2358
+ force_kill_on_timeout AS "forceKillOnTimeout", tags,
2359
+ timezone, allow_overlap AS "allowOverlap", status,
2360
+ last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
2361
+ next_run_at AS "nextRunAt",
2362
+ created_at AS "createdAt", updated_at AS "updatedAt",
2363
+ retry_delay AS "retryDelay", retry_backoff AS "retryBackoff",
2364
+ retry_delay_max AS "retryDelayMax"
2365
+ FROM cron_schedules
2366
+ WHERE status = 'active'
2367
+ AND next_run_at IS NOT NULL
2368
+ AND next_run_at <= NOW()
2369
+ ORDER BY next_run_at ASC
2370
+ FOR UPDATE SKIP LOCKED`
2371
+ );
2372
+ log(`Found ${result.rows.length} due cron schedules`);
2373
+ return result.rows;
2374
+ } catch (error) {
2375
+ if (error?.code === "42P01") {
2376
+ log("cron_schedules table does not exist, skipping cron enqueue");
2377
+ return [];
1733
2378
  }
1734
- };
1735
- next();
1736
- });
1737
- }
1738
- var createProcessor = (backend, handlers, options = {}) => {
1739
- const {
1740
- workerId = `worker-${Math.random().toString(36).substring(2, 9)}`,
1741
- batchSize = 10,
1742
- pollInterval = 5e3,
1743
- onError = (error) => console.error("Job processor error:", error),
1744
- jobType,
1745
- concurrency = 3
1746
- } = options;
1747
- let running = false;
1748
- let intervalId = null;
1749
- let currentBatchPromise = null;
1750
- setLogContext(options.verbose ?? false);
1751
- const processJobs = async () => {
1752
- if (!running) return 0;
1753
- log(
1754
- `Processing jobs with workerId: ${workerId}${jobType ? ` and jobType: ${Array.isArray(jobType) ? jobType.join(",") : jobType}` : ""}`
1755
- );
2379
+ log(`Error getting due cron schedules: ${error}`);
2380
+ throw error;
2381
+ } finally {
2382
+ client.release();
2383
+ }
2384
+ }
2385
+ /**
2386
+ * Update a cron schedule after a job has been enqueued.
2387
+ * Sets lastEnqueuedAt, lastJobId, and advances nextRunAt.
2388
+ */
2389
+ async updateCronScheduleAfterEnqueue(id, lastEnqueuedAt, lastJobId, nextRunAt) {
2390
+ const client = await this.pool.connect();
1756
2391
  try {
1757
- const processed = await processBatchWithHandlers(
1758
- backend,
1759
- workerId,
1760
- batchSize,
1761
- jobType,
1762
- handlers,
1763
- concurrency,
1764
- onError
2392
+ await client.query(
2393
+ `UPDATE cron_schedules
2394
+ SET last_enqueued_at = $2,
2395
+ last_job_id = $3,
2396
+ next_run_at = $4,
2397
+ updated_at = NOW()
2398
+ WHERE id = $1`,
2399
+ [id, lastEnqueuedAt, lastJobId, nextRunAt]
2400
+ );
2401
+ log(
2402
+ `Updated cron schedule ${id}: lastJobId=${lastJobId}, nextRunAt=${nextRunAt?.toISOString() ?? "null"}`
1765
2403
  );
1766
- return processed;
1767
2404
  } catch (error) {
1768
- onError(error instanceof Error ? error : new Error(String(error)));
2405
+ log(`Error updating cron schedule ${id} after enqueue: ${error}`);
2406
+ throw error;
2407
+ } finally {
2408
+ client.release();
1769
2409
  }
1770
- return 0;
1771
- };
1772
- return {
1773
- /**
1774
- * Start the job processor in the background.
1775
- * - This will run periodically (every pollInterval milliseconds or 5 seconds if not provided) and process jobs as they become available.
1776
- * - You have to call the stop method to stop the processor.
1777
- */
1778
- startInBackground: () => {
1779
- if (running) return;
1780
- log(`Starting job processor with workerId: ${workerId}`);
1781
- running = true;
1782
- const scheduleNext = (immediate) => {
1783
- if (!running) return;
1784
- if (immediate) {
1785
- intervalId = setTimeout(loop, 0);
1786
- } else {
1787
- intervalId = setTimeout(loop, pollInterval);
1788
- }
1789
- };
1790
- const loop = async () => {
1791
- if (!running) return;
1792
- currentBatchPromise = processJobs();
1793
- const processed = await currentBatchPromise;
1794
- currentBatchPromise = null;
1795
- scheduleNext(processed === batchSize);
1796
- };
1797
- loop();
1798
- },
1799
- /**
1800
- * Stop the job processor that runs in the background.
1801
- * Does not wait for in-flight jobs.
1802
- */
1803
- stop: () => {
1804
- log(`Stopping job processor with workerId: ${workerId}`);
1805
- running = false;
1806
- if (intervalId) {
1807
- clearTimeout(intervalId);
1808
- intervalId = null;
1809
- }
1810
- },
1811
- /**
1812
- * Stop the job processor and wait for all in-flight jobs to complete.
1813
- * Useful for graceful shutdown (e.g., SIGTERM handling).
1814
- */
1815
- stopAndDrain: async (drainTimeoutMs = 3e4) => {
1816
- log(`Stopping and draining job processor with workerId: ${workerId}`);
1817
- running = false;
1818
- if (intervalId) {
1819
- clearTimeout(intervalId);
1820
- intervalId = null;
1821
- }
1822
- if (currentBatchPromise) {
1823
- await Promise.race([
1824
- currentBatchPromise.catch(() => {
1825
- }),
1826
- new Promise((resolve) => setTimeout(resolve, drainTimeoutMs))
1827
- ]);
1828
- currentBatchPromise = null;
2410
+ }
2411
+ // ── Wait / step-data support ────────────────────────────────────────
2412
+ /**
2413
+ * Transition a job from 'processing' to 'waiting' status.
2414
+ * Persists step data so the handler can resume from where it left off.
2415
+ *
2416
+ * @param jobId - The job to pause.
2417
+ * @param options - Wait configuration including optional waitUntil date, token ID, and step data.
2418
+ */
2419
+ async waitJob(jobId, options) {
2420
+ const client = await this.pool.connect();
2421
+ try {
2422
+ const result = await client.query(
2423
+ `
2424
+ UPDATE job_queue
2425
+ SET status = 'waiting',
2426
+ wait_until = $2,
2427
+ wait_token_id = $3,
2428
+ step_data = $4,
2429
+ locked_at = NULL,
2430
+ locked_by = NULL,
2431
+ updated_at = NOW()
2432
+ WHERE id = $1 AND status = 'processing'
2433
+ `,
2434
+ [
2435
+ jobId,
2436
+ options.waitUntil ?? null,
2437
+ options.waitTokenId ?? null,
2438
+ JSON.stringify(options.stepData)
2439
+ ]
2440
+ );
2441
+ if (result.rowCount === 0) {
2442
+ log(
2443
+ `Job ${jobId} could not be set to waiting (may have been reclaimed or is no longer processing)`
2444
+ );
2445
+ return;
1829
2446
  }
1830
- log(`Job processor ${workerId} drained`);
1831
- },
1832
- /**
1833
- * Start the job processor synchronously.
1834
- * - This will process all jobs immediately and then stop.
1835
- * - The pollInterval is ignored.
1836
- */
1837
- start: async () => {
1838
- log(`Starting job processor with workerId: ${workerId}`);
1839
- running = true;
1840
- const processed = await processJobs();
1841
- running = false;
1842
- return processed;
1843
- },
1844
- isRunning: () => running
1845
- };
1846
- };
1847
- function loadPemOrFile(value) {
1848
- if (!value) return void 0;
1849
- if (value.startsWith("file://")) {
1850
- const filePath = value.slice(7);
1851
- return fs__default.default.readFileSync(filePath, "utf8");
2447
+ await this.recordJobEvent(jobId, "waiting" /* Waiting */, {
2448
+ waitUntil: options.waitUntil?.toISOString() ?? null,
2449
+ waitTokenId: options.waitTokenId ?? null
2450
+ });
2451
+ log(`Job ${jobId} set to waiting`);
2452
+ } catch (error) {
2453
+ log(`Error setting job ${jobId} to waiting: ${error}`);
2454
+ throw error;
2455
+ } finally {
2456
+ client.release();
2457
+ }
1852
2458
  }
1853
- return value;
1854
- }
1855
- var createPool = (config) => {
1856
- let searchPath;
1857
- let ssl = void 0;
1858
- let customCA;
1859
- let sslmode;
1860
- if (config.connectionString) {
2459
+ /**
2460
+ * Persist step data for a job. Called after each ctx.run() step completes.
2461
+ * Best-effort: does not throw to avoid killing the running handler.
2462
+ *
2463
+ * @param jobId - The job to update.
2464
+ * @param stepData - The step data to persist.
2465
+ */
2466
+ async updateStepData(jobId, stepData) {
2467
+ const client = await this.pool.connect();
1861
2468
  try {
1862
- const url = new URL(config.connectionString);
1863
- searchPath = url.searchParams.get("search_path") || void 0;
1864
- sslmode = url.searchParams.get("sslmode") || void 0;
1865
- if (sslmode === "no-verify") {
1866
- ssl = { rejectUnauthorized: false };
2469
+ await client.query(
2470
+ `UPDATE job_queue SET step_data = $2, updated_at = NOW() WHERE id = $1`,
2471
+ [jobId, JSON.stringify(stepData)]
2472
+ );
2473
+ } catch (error) {
2474
+ log(`Error updating step_data for job ${jobId}: ${error}`);
2475
+ } finally {
2476
+ client.release();
2477
+ }
2478
+ }
2479
+ /**
2480
+ * Create a waitpoint token in the database.
2481
+ *
2482
+ * @param jobId - The job ID to associate with the token (null if created outside a handler).
2483
+ * @param options - Optional timeout string (e.g. '10m', '1h') and tags.
2484
+ * @returns The created waitpoint with its unique ID.
2485
+ */
2486
+ async createWaitpoint(jobId, options) {
2487
+ const client = await this.pool.connect();
2488
+ try {
2489
+ const id = `wp_${crypto.randomUUID()}`;
2490
+ let timeoutAt = null;
2491
+ if (options?.timeout) {
2492
+ const ms = parseTimeoutString(options.timeout);
2493
+ timeoutAt = new Date(Date.now() + ms);
1867
2494
  }
1868
- } catch (e) {
1869
- const parsed = pgConnectionString.parse(config.connectionString);
1870
- if (parsed.options) {
1871
- const match = parsed.options.match(/search_path=([^\s]+)/);
1872
- if (match) {
1873
- searchPath = match[1];
1874
- }
2495
+ await client.query(
2496
+ `INSERT INTO waitpoints (id, job_id, status, timeout_at, tags) VALUES ($1, $2, 'waiting', $3, $4)`,
2497
+ [id, jobId, timeoutAt, options?.tags ?? null]
2498
+ );
2499
+ log(`Created waitpoint ${id} for job ${jobId}`);
2500
+ return { id };
2501
+ } catch (error) {
2502
+ log(`Error creating waitpoint: ${error}`);
2503
+ throw error;
2504
+ } finally {
2505
+ client.release();
2506
+ }
2507
+ }
2508
+ /**
2509
+ * Complete a waitpoint token and move the associated job back to 'pending'.
2510
+ *
2511
+ * @param tokenId - The waitpoint token ID to complete.
2512
+ * @param data - Optional data to pass to the waiting handler.
2513
+ */
2514
+ async completeWaitpoint(tokenId, data) {
2515
+ const client = await this.pool.connect();
2516
+ try {
2517
+ await client.query("BEGIN");
2518
+ const wpResult = await client.query(
2519
+ `UPDATE waitpoints SET status = 'completed', output = $2, completed_at = NOW()
2520
+ WHERE id = $1 AND status = 'waiting'
2521
+ RETURNING job_id`,
2522
+ [tokenId, data != null ? JSON.stringify(data) : null]
2523
+ );
2524
+ if (wpResult.rows.length === 0) {
2525
+ await client.query("ROLLBACK");
2526
+ log(`Waitpoint ${tokenId} not found or already completed`);
2527
+ return;
1875
2528
  }
1876
- sslmode = typeof parsed.sslmode === "string" ? parsed.sslmode : void 0;
1877
- if (sslmode === "no-verify") {
1878
- ssl = { rejectUnauthorized: false };
2529
+ const jobId = wpResult.rows[0].job_id;
2530
+ if (jobId != null) {
2531
+ await client.query(
2532
+ `UPDATE job_queue
2533
+ SET status = 'pending', wait_token_id = NULL, wait_until = NULL, updated_at = NOW()
2534
+ WHERE id = $1 AND status = 'waiting'`,
2535
+ [jobId]
2536
+ );
1879
2537
  }
2538
+ await client.query("COMMIT");
2539
+ log(`Completed waitpoint ${tokenId} for job ${jobId}`);
2540
+ } catch (error) {
2541
+ await client.query("ROLLBACK");
2542
+ log(`Error completing waitpoint ${tokenId}: ${error}`);
2543
+ throw error;
2544
+ } finally {
2545
+ client.release();
1880
2546
  }
1881
2547
  }
1882
- if (config.ssl) {
1883
- if (typeof config.ssl.ca === "string") {
1884
- customCA = config.ssl.ca;
1885
- } else if (typeof process.env.PGSSLROOTCERT === "string") {
1886
- customCA = process.env.PGSSLROOTCERT;
1887
- } else {
1888
- customCA = void 0;
2548
+ /**
2549
+ * Retrieve a waitpoint token by its ID.
2550
+ *
2551
+ * @param tokenId - The waitpoint token ID to look up.
2552
+ * @returns The waitpoint record, or null if not found.
2553
+ */
2554
+ async getWaitpoint(tokenId) {
2555
+ const client = await this.pool.connect();
2556
+ try {
2557
+ const result = await client.query(
2558
+ `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`,
2559
+ [tokenId]
2560
+ );
2561
+ if (result.rows.length === 0) return null;
2562
+ return result.rows[0];
2563
+ } catch (error) {
2564
+ log(`Error getting waitpoint ${tokenId}: ${error}`);
2565
+ throw error;
2566
+ } finally {
2567
+ client.release();
1889
2568
  }
1890
- const caValue = typeof customCA === "string" ? loadPemOrFile(customCA) : void 0;
1891
- ssl = {
1892
- ...ssl,
1893
- ...caValue ? { ca: caValue } : {},
1894
- cert: loadPemOrFile(
1895
- typeof config.ssl.cert === "string" ? config.ssl.cert : process.env.PGSSLCERT
1896
- ),
1897
- key: loadPemOrFile(
1898
- typeof config.ssl.key === "string" ? config.ssl.key : process.env.PGSSLKEY
1899
- ),
1900
- rejectUnauthorized: config.ssl.rejectUnauthorized !== void 0 ? config.ssl.rejectUnauthorized : true
1901
- };
1902
- }
1903
- if (sslmode && customCA) {
1904
- const warning = `
1905
-
1906
- \x1B[33m**************************************************
1907
- \u26A0\uFE0F WARNING: SSL CONFIGURATION ISSUE
1908
- **************************************************
1909
- Both sslmode ('${sslmode}') is set in the connection string
1910
- and a custom CA is provided (via config.ssl.ca or PGSSLROOTCERT).
1911
- This combination may cause connection failures or unexpected behavior.
1912
-
1913
- Recommended: Remove sslmode from the connection string when using a custom CA.
1914
- **************************************************\x1B[0m
1915
- `;
1916
- console.warn(warning);
1917
2569
  }
1918
- const pool = new pg.Pool({
1919
- ...config,
1920
- ...ssl ? { ssl } : {}
1921
- });
1922
- if (searchPath) {
1923
- pool.on("connect", (client) => {
1924
- client.query(`SET search_path TO ${searchPath}`);
1925
- });
2570
+ /**
2571
+ * Expire timed-out waitpoint tokens and move their associated jobs back to 'pending'.
2572
+ *
2573
+ * @returns The number of tokens that were expired.
2574
+ */
2575
+ async expireTimedOutWaitpoints() {
2576
+ const client = await this.pool.connect();
2577
+ try {
2578
+ await client.query("BEGIN");
2579
+ const result = await client.query(
2580
+ `UPDATE waitpoints
2581
+ SET status = 'timed_out'
2582
+ WHERE status = 'waiting' AND timeout_at IS NOT NULL AND timeout_at <= NOW()
2583
+ RETURNING id, job_id`
2584
+ );
2585
+ for (const row of result.rows) {
2586
+ if (row.job_id != null) {
2587
+ await client.query(
2588
+ `UPDATE job_queue
2589
+ SET status = 'pending', wait_token_id = NULL, wait_until = NULL, updated_at = NOW()
2590
+ WHERE id = $1 AND status = 'waiting'`,
2591
+ [row.job_id]
2592
+ );
2593
+ }
2594
+ }
2595
+ await client.query("COMMIT");
2596
+ const count = result.rowCount || 0;
2597
+ if (count > 0) {
2598
+ log(`Expired ${count} timed-out waitpoints`);
2599
+ }
2600
+ return count;
2601
+ } catch (error) {
2602
+ await client.query("ROLLBACK");
2603
+ log(`Error expiring timed-out waitpoints: ${error}`);
2604
+ throw error;
2605
+ } finally {
2606
+ client.release();
2607
+ }
2608
+ }
2609
+ // ── Internal helpers ──────────────────────────────────────────────────
2610
+ async setPendingReasonForUnpickedJobs(reason, jobType) {
2611
+ const client = await this.pool.connect();
2612
+ try {
2613
+ let jobTypeFilter = "";
2614
+ const params = [reason];
2615
+ if (jobType) {
2616
+ if (Array.isArray(jobType)) {
2617
+ jobTypeFilter = ` AND job_type = ANY($2)`;
2618
+ params.push(jobType);
2619
+ } else {
2620
+ jobTypeFilter = ` AND job_type = $2`;
2621
+ params.push(jobType);
2622
+ }
2623
+ }
2624
+ await client.query(
2625
+ `UPDATE job_queue SET pending_reason = $1 WHERE status = 'pending'${jobTypeFilter}`,
2626
+ params
2627
+ );
2628
+ } finally {
2629
+ client.release();
2630
+ }
1926
2631
  }
1927
- return pool;
1928
2632
  };
1929
2633
 
1930
2634
  // src/backends/redis-scripts.ts
@@ -1941,6 +2645,9 @@ local forceKillOnTimeout = ARGV[7]
1941
2645
  local tagsJson = ARGV[8] -- "null" or JSON array string
1942
2646
  local idempotencyKey = ARGV[9] -- "null" string if not set
1943
2647
  local nowMs = tonumber(ARGV[10])
2648
+ local retryDelay = ARGV[11] -- "null" or seconds string
2649
+ local retryBackoff = ARGV[12] -- "null" or "true"/"false"
2650
+ local retryDelayMax = ARGV[13] -- "null" or seconds string
1944
2651
 
1945
2652
  -- Idempotency check
1946
2653
  if idempotencyKey ~= "null" then
@@ -1981,7 +2688,13 @@ redis.call('HMSET', jobKey,
1981
2688
  'lastFailedAt', 'null',
1982
2689
  'lastCancelledAt', 'null',
1983
2690
  'tags', tagsJson,
1984
- 'idempotencyKey', idempotencyKey
2691
+ 'idempotencyKey', idempotencyKey,
2692
+ 'waitUntil', 'null',
2693
+ 'waitTokenId', 'null',
2694
+ 'stepData', 'null',
2695
+ 'retryDelay', retryDelay,
2696
+ 'retryBackoff', retryBackoff,
2697
+ 'retryDelayMax', retryDelayMax
1985
2698
  )
1986
2699
 
1987
2700
  -- Status index
@@ -2022,6 +2735,118 @@ end
2022
2735
 
2023
2736
  return id
2024
2737
  `;
2738
+ var ADD_JOBS_SCRIPT = `
2739
+ local prefix = KEYS[1]
2740
+ local jobsJson = ARGV[1]
2741
+ local nowMs = tonumber(ARGV[2])
2742
+
2743
+ local jobs = cjson.decode(jobsJson)
2744
+ local results = {}
2745
+
2746
+ for i, job in ipairs(jobs) do
2747
+ local jobType = job.jobType
2748
+ local payloadJson = job.payload
2749
+ local maxAttempts = tonumber(job.maxAttempts)
2750
+ local priority = tonumber(job.priority)
2751
+ local runAtMs = tostring(job.runAtMs)
2752
+ local timeoutMs = tostring(job.timeoutMs)
2753
+ local forceKillOnTimeout = tostring(job.forceKillOnTimeout)
2754
+ local tagsJson = tostring(job.tags)
2755
+ local idempotencyKey = tostring(job.idempotencyKey)
2756
+ local retryDelay = tostring(job.retryDelay)
2757
+ local retryBackoff = tostring(job.retryBackoff)
2758
+ local retryDelayMax = tostring(job.retryDelayMax)
2759
+
2760
+ -- Idempotency check
2761
+ local skip = false
2762
+ if idempotencyKey ~= "null" then
2763
+ local existing = redis.call('GET', prefix .. 'idempotency:' .. idempotencyKey)
2764
+ if existing then
2765
+ results[i] = tonumber(existing)
2766
+ skip = true
2767
+ end
2768
+ end
2769
+
2770
+ if not skip then
2771
+ -- Generate ID
2772
+ local id = redis.call('INCR', prefix .. 'id_seq')
2773
+ local jobKey = prefix .. 'job:' .. id
2774
+ local runAt = runAtMs ~= "0" and tonumber(runAtMs) or nowMs
2775
+
2776
+ -- Store the job hash
2777
+ redis.call('HMSET', jobKey,
2778
+ 'id', id,
2779
+ 'jobType', jobType,
2780
+ 'payload', payloadJson,
2781
+ 'status', 'pending',
2782
+ 'maxAttempts', maxAttempts,
2783
+ 'attempts', 0,
2784
+ 'priority', priority,
2785
+ 'runAt', runAt,
2786
+ 'timeoutMs', timeoutMs,
2787
+ 'forceKillOnTimeout', forceKillOnTimeout,
2788
+ 'createdAt', nowMs,
2789
+ 'updatedAt', nowMs,
2790
+ 'lockedAt', 'null',
2791
+ 'lockedBy', 'null',
2792
+ 'nextAttemptAt', 'null',
2793
+ 'pendingReason', 'null',
2794
+ 'errorHistory', '[]',
2795
+ 'failureReason', 'null',
2796
+ 'completedAt', 'null',
2797
+ 'startedAt', 'null',
2798
+ 'lastRetriedAt', 'null',
2799
+ 'lastFailedAt', 'null',
2800
+ 'lastCancelledAt', 'null',
2801
+ 'tags', tagsJson,
2802
+ 'idempotencyKey', idempotencyKey,
2803
+ 'waitUntil', 'null',
2804
+ 'waitTokenId', 'null',
2805
+ 'stepData', 'null',
2806
+ 'retryDelay', retryDelay,
2807
+ 'retryBackoff', retryBackoff,
2808
+ 'retryDelayMax', retryDelayMax
2809
+ )
2810
+
2811
+ -- Status index
2812
+ redis.call('SADD', prefix .. 'status:pending', id)
2813
+
2814
+ -- Type index
2815
+ redis.call('SADD', prefix .. 'type:' .. jobType, id)
2816
+
2817
+ -- Tag indexes
2818
+ if tagsJson ~= "null" then
2819
+ local tags = cjson.decode(tagsJson)
2820
+ for _, tag in ipairs(tags) do
2821
+ redis.call('SADD', prefix .. 'tag:' .. tag, id)
2822
+ end
2823
+ for _, tag in ipairs(tags) do
2824
+ redis.call('SADD', prefix .. 'job:' .. id .. ':tags', tag)
2825
+ end
2826
+ end
2827
+
2828
+ -- Idempotency mapping
2829
+ if idempotencyKey ~= "null" then
2830
+ redis.call('SET', prefix .. 'idempotency:' .. idempotencyKey, id)
2831
+ end
2832
+
2833
+ -- All-jobs sorted set
2834
+ redis.call('ZADD', prefix .. 'all', nowMs, id)
2835
+
2836
+ -- Queue or delayed
2837
+ if runAt <= nowMs then
2838
+ local score = priority * ${SCORE_RANGE} + (${SCORE_RANGE} - nowMs)
2839
+ redis.call('ZADD', prefix .. 'queue', score, id)
2840
+ else
2841
+ redis.call('ZADD', prefix .. 'delayed', runAt, id)
2842
+ end
2843
+
2844
+ results[i] = id
2845
+ end
2846
+ end
2847
+
2848
+ return results
2849
+ `;
2025
2850
  var GET_NEXT_BATCH_SCRIPT = `
2026
2851
  local prefix = KEYS[1]
2027
2852
  local workerId = ARGV[1]
@@ -2064,7 +2889,25 @@ for _, jobId in ipairs(retries) do
2064
2889
  redis.call('ZREM', prefix .. 'retry', jobId)
2065
2890
  end
2066
2891
 
2067
- -- 3. Parse job type filter
2892
+ -- 3. Move ready waiting jobs (time-based, no token) into queue
2893
+ local waitingJobs = redis.call('ZRANGEBYSCORE', prefix .. 'waiting', '-inf', nowMs, 'LIMIT', 0, 200)
2894
+ for _, jobId in ipairs(waitingJobs) do
2895
+ local jk = prefix .. 'job:' .. jobId
2896
+ local status = redis.call('HGET', jk, 'status')
2897
+ local waitTokenId = redis.call('HGET', jk, 'waitTokenId')
2898
+ if status == 'waiting' and (waitTokenId == false or waitTokenId == 'null') then
2899
+ local pri = tonumber(redis.call('HGET', jk, 'priority') or '0')
2900
+ local ca = tonumber(redis.call('HGET', jk, 'createdAt'))
2901
+ local score = pri * ${SCORE_RANGE} + (${SCORE_RANGE} - ca)
2902
+ redis.call('ZADD', prefix .. 'queue', score, jobId)
2903
+ redis.call('SREM', prefix .. 'status:waiting', jobId)
2904
+ redis.call('SADD', prefix .. 'status:pending', jobId)
2905
+ redis.call('HMSET', jk, 'status', 'pending', 'waitUntil', 'null')
2906
+ end
2907
+ redis.call('ZREM', prefix .. 'waiting', jobId)
2908
+ end
2909
+
2910
+ -- 4. Parse job type filter
2068
2911
  local filterTypes = nil
2069
2912
  if jobTypeFilter ~= "null" then
2070
2913
  -- Could be a JSON array or a plain string
@@ -2077,7 +2920,7 @@ if jobTypeFilter ~= "null" then
2077
2920
  end
2078
2921
  end
2079
2922
 
2080
- -- 4. Pop candidates from queue (highest score first)
2923
+ -- 5. Pop candidates from queue (highest score first)
2081
2924
  -- We pop more than batchSize because some may be filtered out
2082
2925
  local popCount = batchSize * 3
2083
2926
  local candidates = redis.call('ZPOPMAX', prefix .. 'queue', popCount)
@@ -2161,7 +3004,10 @@ local jk = prefix .. 'job:' .. jobId
2161
3004
  redis.call('HMSET', jk,
2162
3005
  'status', 'completed',
2163
3006
  'updatedAt', nowMs,
2164
- 'completedAt', nowMs
3007
+ 'completedAt', nowMs,
3008
+ 'stepData', 'null',
3009
+ 'waitUntil', 'null',
3010
+ 'waitTokenId', 'null'
2165
3011
  )
2166
3012
  redis.call('SREM', prefix .. 'status:processing', jobId)
2167
3013
  redis.call('SADD', prefix .. 'status:completed', jobId)
@@ -2179,11 +3025,38 @@ local jk = prefix .. 'job:' .. jobId
2179
3025
  local attempts = tonumber(redis.call('HGET', jk, 'attempts'))
2180
3026
  local maxAttempts = tonumber(redis.call('HGET', jk, 'maxAttempts'))
2181
3027
 
2182
- -- Compute next_attempt_at: 2^attempts minutes from now
3028
+ -- Read per-job retry config (may be "null")
3029
+ local rdRaw = redis.call('HGET', jk, 'retryDelay')
3030
+ local rbRaw = redis.call('HGET', jk, 'retryBackoff')
3031
+ local rmRaw = redis.call('HGET', jk, 'retryDelayMax')
3032
+
2183
3033
  local nextAttemptAt = 'null'
2184
3034
  if attempts < maxAttempts then
2185
- local delayMs = math.pow(2, attempts) * 60000
2186
- nextAttemptAt = nowMs + delayMs
3035
+ local allNull = (rdRaw == 'null' or rdRaw == false)
3036
+ and (rbRaw == 'null' or rbRaw == false)
3037
+ and (rmRaw == 'null' or rmRaw == false)
3038
+ if allNull then
3039
+ -- Legacy formula: 2^attempts minutes
3040
+ local delayMs = math.pow(2, attempts) * 60000
3041
+ nextAttemptAt = nowMs + delayMs
3042
+ else
3043
+ local retryDelaySec = 60
3044
+ if rdRaw and rdRaw ~= 'null' then retryDelaySec = tonumber(rdRaw) end
3045
+ local useBackoff = true
3046
+ if rbRaw and rbRaw ~= 'null' then useBackoff = (rbRaw == 'true') end
3047
+ local maxDelaySec = nil
3048
+ if rmRaw and rmRaw ~= 'null' then maxDelaySec = tonumber(rmRaw) end
3049
+
3050
+ local delaySec
3051
+ if useBackoff then
3052
+ delaySec = retryDelaySec * math.pow(2, attempts)
3053
+ if maxDelaySec then delaySec = math.min(delaySec, maxDelaySec) end
3054
+ delaySec = delaySec * (0.5 + 0.5 * math.random())
3055
+ else
3056
+ delaySec = retryDelaySec
3057
+ end
3058
+ nextAttemptAt = nowMs + math.floor(delaySec * 1000)
3059
+ end
2187
3060
  end
2188
3061
 
2189
3062
  -- Append to error_history
@@ -2220,6 +3093,7 @@ local nowMs = tonumber(ARGV[2])
2220
3093
  local jk = prefix .. 'job:' .. jobId
2221
3094
 
2222
3095
  local oldStatus = redis.call('HGET', jk, 'status')
3096
+ if oldStatus ~= 'failed' and oldStatus ~= 'processing' then return 0 end
2223
3097
 
2224
3098
  redis.call('HMSET', jk,
2225
3099
  'status', 'pending',
@@ -2231,9 +3105,7 @@ redis.call('HMSET', jk,
2231
3105
  )
2232
3106
 
2233
3107
  -- Remove from old status, add to pending
2234
- if oldStatus then
2235
- redis.call('SREM', prefix .. 'status:' .. oldStatus, jobId)
2236
- end
3108
+ redis.call('SREM', prefix .. 'status:' .. oldStatus, jobId)
2237
3109
  redis.call('SADD', prefix .. 'status:pending', jobId)
2238
3110
 
2239
3111
  -- Remove from retry sorted set if present
@@ -2254,18 +3126,21 @@ local nowMs = ARGV[2]
2254
3126
  local jk = prefix .. 'job:' .. jobId
2255
3127
 
2256
3128
  local status = redis.call('HGET', jk, 'status')
2257
- if status ~= 'pending' then return 0 end
3129
+ if status ~= 'pending' and status ~= 'waiting' then return 0 end
2258
3130
 
2259
3131
  redis.call('HMSET', jk,
2260
3132
  'status', 'cancelled',
2261
3133
  'updatedAt', nowMs,
2262
- 'lastCancelledAt', nowMs
3134
+ 'lastCancelledAt', nowMs,
3135
+ 'waitUntil', 'null',
3136
+ 'waitTokenId', 'null'
2263
3137
  )
2264
- redis.call('SREM', prefix .. 'status:pending', jobId)
3138
+ redis.call('SREM', prefix .. 'status:' .. status, jobId)
2265
3139
  redis.call('SADD', prefix .. 'status:cancelled', jobId)
2266
- -- Remove from queue / delayed
3140
+ -- Remove from queue / delayed / waiting
2267
3141
  redis.call('ZREM', prefix .. 'queue', jobId)
2268
3142
  redis.call('ZREM', prefix .. 'delayed', jobId)
3143
+ redis.call('ZREM', prefix .. 'waiting', jobId)
2269
3144
 
2270
3145
  return 1
2271
3146
  `;
@@ -2333,18 +3208,16 @@ end
2333
3208
 
2334
3209
  return count
2335
3210
  `;
2336
- var CLEANUP_OLD_JOBS_SCRIPT = `
3211
+ var CLEANUP_OLD_JOBS_BATCH_SCRIPT = `
2337
3212
  local prefix = KEYS[1]
2338
3213
  local cutoffMs = tonumber(ARGV[1])
2339
-
2340
- local completed = redis.call('SMEMBERS', prefix .. 'status:completed')
2341
3214
  local count = 0
2342
3215
 
2343
- for _, jobId in ipairs(completed) do
3216
+ for i = 2, #ARGV do
3217
+ local jobId = ARGV[i]
2344
3218
  local jk = prefix .. 'job:' .. jobId
2345
3219
  local updatedAt = tonumber(redis.call('HGET', jk, 'updatedAt'))
2346
3220
  if updatedAt and updatedAt < cutoffMs then
2347
- -- Remove all indexes
2348
3221
  local jobType = redis.call('HGET', jk, 'jobType')
2349
3222
  local tagsJson = redis.call('HGET', jk, 'tags')
2350
3223
  local idempotencyKey = redis.call('HGET', jk, 'idempotencyKey')
@@ -2367,7 +3240,6 @@ for _, jobId in ipairs(completed) do
2367
3240
  if idempotencyKey and idempotencyKey ~= 'null' then
2368
3241
  redis.call('DEL', prefix .. 'idempotency:' .. idempotencyKey)
2369
3242
  end
2370
- -- Delete events
2371
3243
  redis.call('DEL', prefix .. 'events:' .. jobId)
2372
3244
 
2373
3245
  count = count + 1
@@ -2376,8 +3248,158 @@ end
2376
3248
 
2377
3249
  return count
2378
3250
  `;
3251
+ var WAIT_JOB_SCRIPT = `
3252
+ local prefix = KEYS[1]
3253
+ local jobId = ARGV[1]
3254
+ local waitUntilMs = ARGV[2]
3255
+ local waitTokenId = ARGV[3]
3256
+ local stepDataJson = ARGV[4]
3257
+ local nowMs = ARGV[5]
3258
+ local jk = prefix .. 'job:' .. jobId
3259
+
3260
+ local status = redis.call('HGET', jk, 'status')
3261
+ if status ~= 'processing' then return 0 end
3262
+
3263
+ redis.call('HMSET', jk,
3264
+ 'status', 'waiting',
3265
+ 'waitUntil', waitUntilMs,
3266
+ 'waitTokenId', waitTokenId,
3267
+ 'stepData', stepDataJson,
3268
+ 'lockedAt', 'null',
3269
+ 'lockedBy', 'null',
3270
+ 'updatedAt', nowMs
3271
+ )
3272
+ redis.call('SREM', prefix .. 'status:processing', jobId)
3273
+ redis.call('SADD', prefix .. 'status:waiting', jobId)
3274
+
3275
+ -- Add to waiting sorted set if time-based wait
3276
+ if waitUntilMs ~= 'null' then
3277
+ redis.call('ZADD', prefix .. 'waiting', tonumber(waitUntilMs), jobId)
3278
+ end
3279
+
3280
+ return 1
3281
+ `;
3282
+ var COMPLETE_WAITPOINT_SCRIPT = `
3283
+ local prefix = KEYS[1]
3284
+ local tokenId = ARGV[1]
3285
+ local outputJson = ARGV[2]
3286
+ local nowMs = ARGV[3]
3287
+ local wpk = prefix .. 'waitpoint:' .. tokenId
3288
+
3289
+ local wpStatus = redis.call('HGET', wpk, 'status')
3290
+ if not wpStatus or wpStatus ~= 'waiting' then return 0 end
3291
+
3292
+ redis.call('HMSET', wpk,
3293
+ 'status', 'completed',
3294
+ 'output', outputJson,
3295
+ 'completedAt', nowMs
3296
+ )
3297
+
3298
+ -- Move associated job back to pending
3299
+ local jobId = redis.call('HGET', wpk, 'jobId')
3300
+ if jobId and jobId ~= 'null' then
3301
+ local jk = prefix .. 'job:' .. jobId
3302
+ local jobStatus = redis.call('HGET', jk, 'status')
3303
+ if jobStatus == 'waiting' then
3304
+ redis.call('HMSET', jk,
3305
+ 'status', 'pending',
3306
+ 'waitTokenId', 'null',
3307
+ 'waitUntil', 'null',
3308
+ 'updatedAt', nowMs
3309
+ )
3310
+ redis.call('SREM', prefix .. 'status:waiting', jobId)
3311
+ redis.call('SADD', prefix .. 'status:pending', jobId)
3312
+ redis.call('ZREM', prefix .. 'waiting', jobId)
3313
+
3314
+ -- Re-add to queue
3315
+ local priority = tonumber(redis.call('HGET', jk, 'priority') or '0')
3316
+ local createdAt = tonumber(redis.call('HGET', jk, 'createdAt'))
3317
+ local score = priority * ${SCORE_RANGE} + (${SCORE_RANGE} - createdAt)
3318
+ redis.call('ZADD', prefix .. 'queue', score, jobId)
3319
+ end
3320
+ end
3321
+
3322
+ return 1
3323
+ `;
3324
+ var EXPIRE_TIMED_OUT_WAITPOINTS_SCRIPT = `
3325
+ local prefix = KEYS[1]
3326
+ local nowMs = tonumber(ARGV[1])
3327
+
3328
+ local expiredIds = redis.call('ZRANGEBYSCORE', prefix .. 'waitpoint_timeout', '-inf', nowMs)
3329
+ local count = 0
3330
+
3331
+ for _, tokenId in ipairs(expiredIds) do
3332
+ local wpk = prefix .. 'waitpoint:' .. tokenId
3333
+ local wpStatus = redis.call('HGET', wpk, 'status')
3334
+ if wpStatus == 'waiting' then
3335
+ redis.call('HMSET', wpk,
3336
+ 'status', 'timed_out'
3337
+ )
3338
+
3339
+ -- Move associated job back to pending
3340
+ local jobId = redis.call('HGET', wpk, 'jobId')
3341
+ if jobId and jobId ~= 'null' then
3342
+ local jk = prefix .. 'job:' .. jobId
3343
+ local jobStatus = redis.call('HGET', jk, 'status')
3344
+ if jobStatus == 'waiting' then
3345
+ redis.call('HMSET', jk,
3346
+ 'status', 'pending',
3347
+ 'waitTokenId', 'null',
3348
+ 'waitUntil', 'null',
3349
+ 'updatedAt', nowMs
3350
+ )
3351
+ redis.call('SREM', prefix .. 'status:waiting', jobId)
3352
+ redis.call('SADD', prefix .. 'status:pending', jobId)
3353
+ redis.call('ZREM', prefix .. 'waiting', jobId)
3354
+
3355
+ local priority = tonumber(redis.call('HGET', jk, 'priority') or '0')
3356
+ local createdAt = tonumber(redis.call('HGET', jk, 'createdAt'))
3357
+ local score = priority * ${SCORE_RANGE} + (${SCORE_RANGE} - createdAt)
3358
+ redis.call('ZADD', prefix .. 'queue', score, jobId)
3359
+ end
3360
+ end
3361
+
3362
+ count = count + 1
3363
+ end
3364
+ redis.call('ZREM', prefix .. 'waitpoint_timeout', tokenId)
3365
+ end
2379
3366
 
2380
- // src/backends/redis.ts
3367
+ return count
3368
+ `;
3369
+ var MAX_TIMEOUT_MS2 = 365 * 24 * 60 * 60 * 1e3;
3370
+ function parseTimeoutString2(timeout) {
3371
+ const match = timeout.match(/^(\d+)(s|m|h|d)$/);
3372
+ if (!match) {
3373
+ throw new Error(
3374
+ `Invalid timeout format: "${timeout}". Expected format like "10m", "1h", "24h", "7d".`
3375
+ );
3376
+ }
3377
+ const value = parseInt(match[1], 10);
3378
+ const unit = match[2];
3379
+ let ms;
3380
+ switch (unit) {
3381
+ case "s":
3382
+ ms = value * 1e3;
3383
+ break;
3384
+ case "m":
3385
+ ms = value * 60 * 1e3;
3386
+ break;
3387
+ case "h":
3388
+ ms = value * 60 * 60 * 1e3;
3389
+ break;
3390
+ case "d":
3391
+ ms = value * 24 * 60 * 60 * 1e3;
3392
+ break;
3393
+ default:
3394
+ throw new Error(`Unknown timeout unit: "${unit}"`);
3395
+ }
3396
+ if (!Number.isFinite(ms) || ms > MAX_TIMEOUT_MS2) {
3397
+ throw new Error(
3398
+ `Timeout value "${timeout}" is too large. Maximum allowed is 365 days.`
3399
+ );
3400
+ }
3401
+ return ms;
3402
+ }
2381
3403
  function hashToObject(arr) {
2382
3404
  const obj = {};
2383
3405
  for (let i = 0; i < arr.length; i += 2) {
@@ -2443,11 +3465,41 @@ function deserializeJob(h) {
2443
3465
  lastCancelledAt: dateOrNull(h.lastCancelledAt),
2444
3466
  tags,
2445
3467
  idempotencyKey: nullish(h.idempotencyKey),
2446
- progress: numOrNull(h.progress)
3468
+ progress: numOrNull(h.progress),
3469
+ waitUntil: dateOrNull(h.waitUntil),
3470
+ waitTokenId: nullish(h.waitTokenId),
3471
+ stepData: parseStepData(h.stepData),
3472
+ retryDelay: numOrNull(h.retryDelay),
3473
+ retryBackoff: h.retryBackoff === "true" ? true : h.retryBackoff === "false" ? false : null,
3474
+ retryDelayMax: numOrNull(h.retryDelayMax)
2447
3475
  };
2448
3476
  }
3477
+ function parseStepData(raw) {
3478
+ if (!raw || raw === "null") return void 0;
3479
+ try {
3480
+ return JSON.parse(raw);
3481
+ } catch {
3482
+ return void 0;
3483
+ }
3484
+ }
2449
3485
  var RedisBackend = class {
2450
- constructor(redisConfig) {
3486
+ /**
3487
+ * Create a RedisBackend.
3488
+ *
3489
+ * @param configOrClient - Either `redisConfig` from the config file (the
3490
+ * library creates a new ioredis client) or an existing ioredis client
3491
+ * instance (bring your own).
3492
+ * @param keyPrefix - Key prefix, only used when `configOrClient` is an
3493
+ * external client. Ignored when `redisConfig` is passed (uses
3494
+ * `redisConfig.keyPrefix` instead). Default: `'dq:'`.
3495
+ */
3496
+ constructor(configOrClient, keyPrefix) {
3497
+ if (configOrClient && typeof configOrClient.eval === "function") {
3498
+ this.client = configOrClient;
3499
+ this.prefix = keyPrefix ?? "dq:";
3500
+ return;
3501
+ }
3502
+ const redisConfig = configOrClient;
2451
3503
  let IORedis;
2452
3504
  try {
2453
3505
  const _require = module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)));
@@ -2520,8 +3572,16 @@ var RedisBackend = class {
2520
3572
  timeoutMs = void 0,
2521
3573
  forceKillOnTimeout = false,
2522
3574
  tags = void 0,
2523
- idempotencyKey = void 0
2524
- }) {
3575
+ idempotencyKey = void 0,
3576
+ retryDelay = void 0,
3577
+ retryBackoff = void 0,
3578
+ retryDelayMax = void 0
3579
+ }, options) {
3580
+ if (options?.db) {
3581
+ throw new Error(
3582
+ "The db option is not supported with the Redis backend. Transactional job creation is only available with PostgreSQL."
3583
+ );
3584
+ }
2525
3585
  const now = this.nowMs();
2526
3586
  const runAtMs = runAt ? runAt.getTime() : 0;
2527
3587
  const result = await this.client.eval(
@@ -2537,7 +3597,10 @@ var RedisBackend = class {
2537
3597
  forceKillOnTimeout ? "true" : "false",
2538
3598
  tags ? JSON.stringify(tags) : "null",
2539
3599
  idempotencyKey ?? "null",
2540
- now
3600
+ now,
3601
+ retryDelay !== void 0 ? retryDelay.toString() : "null",
3602
+ retryBackoff !== void 0 ? retryBackoff.toString() : "null",
3603
+ retryDelayMax !== void 0 ? retryDelayMax.toString() : "null"
2541
3604
  );
2542
3605
  const jobId = Number(result);
2543
3606
  log(
@@ -2551,6 +3614,58 @@ var RedisBackend = class {
2551
3614
  });
2552
3615
  return jobId;
2553
3616
  }
3617
+ /**
3618
+ * Insert multiple jobs atomically via a single Lua script.
3619
+ * Returns IDs in the same order as the input array.
3620
+ */
3621
+ async addJobs(jobs, options) {
3622
+ if (jobs.length === 0) return [];
3623
+ if (options?.db) {
3624
+ throw new Error(
3625
+ "The db option is not supported with the Redis backend. Transactional job creation is only available with PostgreSQL."
3626
+ );
3627
+ }
3628
+ const now = this.nowMs();
3629
+ const jobsPayload = jobs.map((job) => ({
3630
+ jobType: job.jobType,
3631
+ payload: JSON.stringify(job.payload),
3632
+ maxAttempts: job.maxAttempts ?? 3,
3633
+ priority: job.priority ?? 0,
3634
+ runAtMs: job.runAt ? job.runAt.getTime() : 0,
3635
+ timeoutMs: job.timeoutMs !== void 0 ? job.timeoutMs.toString() : "null",
3636
+ forceKillOnTimeout: job.forceKillOnTimeout ? "true" : "false",
3637
+ tags: job.tags ? JSON.stringify(job.tags) : "null",
3638
+ idempotencyKey: job.idempotencyKey ?? "null",
3639
+ retryDelay: job.retryDelay !== void 0 ? job.retryDelay.toString() : "null",
3640
+ retryBackoff: job.retryBackoff !== void 0 ? job.retryBackoff.toString() : "null",
3641
+ retryDelayMax: job.retryDelayMax !== void 0 ? job.retryDelayMax.toString() : "null"
3642
+ }));
3643
+ const result = await this.client.eval(
3644
+ ADD_JOBS_SCRIPT,
3645
+ 1,
3646
+ this.prefix,
3647
+ JSON.stringify(jobsPayload),
3648
+ now
3649
+ );
3650
+ const ids = result.map(Number);
3651
+ log(`Batch-inserted ${jobs.length} jobs, IDs: [${ids.join(", ")}]`);
3652
+ const existingIdempotencyIds = /* @__PURE__ */ new Set();
3653
+ for (let i = 0; i < jobs.length; i++) {
3654
+ if (jobs[i].idempotencyKey) {
3655
+ if (existingIdempotencyIds.has(ids[i])) {
3656
+ continue;
3657
+ }
3658
+ existingIdempotencyIds.add(ids[i]);
3659
+ }
3660
+ await this.recordJobEvent(ids[i], "added" /* Added */, {
3661
+ jobType: jobs[i].jobType,
3662
+ payload: jobs[i].payload,
3663
+ tags: jobs[i].tags,
3664
+ idempotencyKey: jobs[i].idempotencyKey
3665
+ });
3666
+ }
3667
+ return ids;
3668
+ }
2554
3669
  async getJob(id) {
2555
3670
  const data = await this.client.hgetall(`${this.prefix}job:${id}`);
2556
3671
  if (!data || Object.keys(data).length === 0) {
@@ -2601,8 +3716,14 @@ var RedisBackend = class {
2601
3716
  if (filters.runAt) {
2602
3717
  jobs = this.filterByRunAt(jobs, filters.runAt);
2603
3718
  }
3719
+ if (filters.cursor !== void 0) {
3720
+ jobs = jobs.filter((j) => j.id < filters.cursor);
3721
+ }
3722
+ }
3723
+ jobs.sort((a, b) => b.id - a.id);
3724
+ if (filters?.cursor !== void 0) {
3725
+ return jobs.slice(0, limit);
2604
3726
  }
2605
- jobs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
2606
3727
  return jobs.slice(offset, offset + limit);
2607
3728
  }
2608
3729
  async getJobsByTags(tags, mode = "all", limit = 100, offset = 0) {
@@ -2811,58 +3932,346 @@ var RedisBackend = class {
2811
3932
  }
2812
3933
  metadata.tags = updates.tags;
2813
3934
  }
3935
+ if (updates.retryDelay !== void 0) {
3936
+ fields.push(
3937
+ "retryDelay",
3938
+ updates.retryDelay !== null ? updates.retryDelay.toString() : "null"
3939
+ );
3940
+ metadata.retryDelay = updates.retryDelay;
3941
+ }
3942
+ if (updates.retryBackoff !== void 0) {
3943
+ fields.push(
3944
+ "retryBackoff",
3945
+ updates.retryBackoff !== null ? updates.retryBackoff.toString() : "null"
3946
+ );
3947
+ metadata.retryBackoff = updates.retryBackoff;
3948
+ }
3949
+ if (updates.retryDelayMax !== void 0) {
3950
+ fields.push(
3951
+ "retryDelayMax",
3952
+ updates.retryDelayMax !== null ? updates.retryDelayMax.toString() : "null"
3953
+ );
3954
+ metadata.retryDelayMax = updates.retryDelayMax;
3955
+ }
2814
3956
  if (fields.length === 0) {
2815
3957
  log(`No fields to update for job ${jobId}`);
2816
3958
  return;
2817
3959
  }
2818
- fields.push("updatedAt", now.toString());
2819
- await this.client.hmset(jk, ...fields);
2820
- await this.recordJobEvent(jobId, "edited" /* Edited */, metadata);
2821
- log(`Edited job ${jobId}: ${JSON.stringify(metadata)}`);
3960
+ fields.push("updatedAt", now.toString());
3961
+ await this.client.hmset(jk, ...fields);
3962
+ await this.recordJobEvent(jobId, "edited" /* Edited */, metadata);
3963
+ log(`Edited job ${jobId}: ${JSON.stringify(metadata)}`);
3964
+ }
3965
+ async editAllPendingJobs(filters, updates) {
3966
+ let ids = await this.client.smembers(`${this.prefix}status:pending`);
3967
+ if (ids.length === 0) return 0;
3968
+ if (filters) {
3969
+ ids = await this.applyFilters(ids, filters);
3970
+ }
3971
+ let count = 0;
3972
+ for (const id of ids) {
3973
+ await this.editJob(Number(id), updates);
3974
+ count++;
3975
+ }
3976
+ log(`Edited ${count} pending jobs`);
3977
+ return count;
3978
+ }
3979
+ /**
3980
+ * Delete completed jobs older than the given number of days.
3981
+ * Uses SSCAN to iterate the completed set in batches, avoiding
3982
+ * loading all IDs into memory and preventing long Redis blocks.
3983
+ *
3984
+ * @param daysToKeep - Number of days to retain completed jobs (default 30).
3985
+ * @param batchSize - Number of IDs to scan per SSCAN iteration (default 200).
3986
+ * @returns Total number of deleted jobs.
3987
+ */
3988
+ async cleanupOldJobs(daysToKeep = 30, batchSize = 200) {
3989
+ const cutoffMs = this.nowMs() - daysToKeep * 24 * 60 * 60 * 1e3;
3990
+ const setKey = `${this.prefix}status:completed`;
3991
+ let totalDeleted = 0;
3992
+ let cursor = "0";
3993
+ do {
3994
+ const [nextCursor, ids] = await this.client.sscan(
3995
+ setKey,
3996
+ cursor,
3997
+ "COUNT",
3998
+ batchSize
3999
+ );
4000
+ cursor = nextCursor;
4001
+ if (ids.length > 0) {
4002
+ const result = await this.client.eval(
4003
+ CLEANUP_OLD_JOBS_BATCH_SCRIPT,
4004
+ 1,
4005
+ this.prefix,
4006
+ cutoffMs,
4007
+ ...ids
4008
+ );
4009
+ totalDeleted += Number(result);
4010
+ }
4011
+ } while (cursor !== "0");
4012
+ log(`Deleted ${totalDeleted} old jobs`);
4013
+ return totalDeleted;
4014
+ }
4015
+ /**
4016
+ * Delete job events older than the given number of days.
4017
+ * Iterates all event lists and removes events whose createdAt is before the cutoff.
4018
+ * Also removes orphaned event lists (where the job no longer exists).
4019
+ *
4020
+ * @param daysToKeep - Number of days to retain events (default 30).
4021
+ * @param batchSize - Number of event keys to scan per SCAN iteration (default 200).
4022
+ * @returns Total number of deleted events.
4023
+ */
4024
+ async cleanupOldJobEvents(daysToKeep = 30, batchSize = 200) {
4025
+ const cutoffMs = this.nowMs() - daysToKeep * 24 * 60 * 60 * 1e3;
4026
+ const pattern = `${this.prefix}events:*`;
4027
+ let totalDeleted = 0;
4028
+ let cursor = "0";
4029
+ do {
4030
+ const [nextCursor, keys] = await this.client.scan(
4031
+ cursor,
4032
+ "MATCH",
4033
+ pattern,
4034
+ "COUNT",
4035
+ batchSize
4036
+ );
4037
+ cursor = nextCursor;
4038
+ for (const key of keys) {
4039
+ const jobIdStr = key.slice(`${this.prefix}events:`.length);
4040
+ const jobExists = await this.client.exists(
4041
+ `${this.prefix}job:${jobIdStr}`
4042
+ );
4043
+ if (!jobExists) {
4044
+ const len = await this.client.llen(key);
4045
+ await this.client.del(key);
4046
+ totalDeleted += len;
4047
+ continue;
4048
+ }
4049
+ const events = await this.client.lrange(key, 0, -1);
4050
+ const kept = [];
4051
+ for (const raw of events) {
4052
+ try {
4053
+ const e = JSON.parse(raw);
4054
+ if (e.createdAt >= cutoffMs) {
4055
+ kept.push(raw);
4056
+ } else {
4057
+ totalDeleted++;
4058
+ }
4059
+ } catch {
4060
+ totalDeleted++;
4061
+ }
4062
+ }
4063
+ if (kept.length === 0) {
4064
+ await this.client.del(key);
4065
+ } else if (kept.length < events.length) {
4066
+ const pipeline = this.client.pipeline();
4067
+ pipeline.del(key);
4068
+ for (const raw of kept) {
4069
+ pipeline.rpush(key, raw);
4070
+ }
4071
+ await pipeline.exec();
4072
+ }
4073
+ }
4074
+ } while (cursor !== "0");
4075
+ log(`Deleted ${totalDeleted} old job events`);
4076
+ return totalDeleted;
4077
+ }
4078
+ async reclaimStuckJobs(maxProcessingTimeMinutes = 10) {
4079
+ const maxAgeMs = maxProcessingTimeMinutes * 60 * 1e3;
4080
+ const now = this.nowMs();
4081
+ const result = await this.client.eval(
4082
+ RECLAIM_STUCK_JOBS_SCRIPT,
4083
+ 1,
4084
+ this.prefix,
4085
+ maxAgeMs,
4086
+ now
4087
+ );
4088
+ log(`Reclaimed ${result} stuck jobs`);
4089
+ return Number(result);
4090
+ }
4091
+ // ── Wait / step-data support ────────────────────────────────────────
4092
+ /**
4093
+ * Transition a job from 'processing' to 'waiting' status.
4094
+ * Persists step data so the handler can resume from where it left off.
4095
+ *
4096
+ * @param jobId - The job to pause.
4097
+ * @param options - Wait configuration including optional waitUntil date, token ID, and step data.
4098
+ */
4099
+ async waitJob(jobId, options) {
4100
+ const now = this.nowMs();
4101
+ const waitUntilMs = options.waitUntil ? options.waitUntil.getTime().toString() : "null";
4102
+ const waitTokenId = options.waitTokenId ?? "null";
4103
+ const stepDataJson = JSON.stringify(options.stepData);
4104
+ const result = await this.client.eval(
4105
+ WAIT_JOB_SCRIPT,
4106
+ 1,
4107
+ this.prefix,
4108
+ jobId,
4109
+ waitUntilMs,
4110
+ waitTokenId,
4111
+ stepDataJson,
4112
+ now
4113
+ );
4114
+ if (Number(result) === 0) {
4115
+ log(
4116
+ `Job ${jobId} could not be set to waiting (may have been reclaimed or is no longer processing)`
4117
+ );
4118
+ return;
4119
+ }
4120
+ await this.recordJobEvent(jobId, "waiting" /* Waiting */, {
4121
+ waitUntil: options.waitUntil?.toISOString() ?? null,
4122
+ waitTokenId: options.waitTokenId ?? null
4123
+ });
4124
+ log(`Job ${jobId} set to waiting`);
2822
4125
  }
2823
- async editAllPendingJobs(filters, updates) {
2824
- let ids = await this.client.smembers(`${this.prefix}status:pending`);
2825
- if (ids.length === 0) return 0;
2826
- if (filters) {
2827
- ids = await this.applyFilters(ids, filters);
4126
+ /**
4127
+ * Persist step data for a job. Called after each ctx.run() step completes.
4128
+ * Best-effort: does not throw to avoid killing the running handler.
4129
+ *
4130
+ * @param jobId - The job to update.
4131
+ * @param stepData - The step data to persist.
4132
+ */
4133
+ async updateStepData(jobId, stepData) {
4134
+ try {
4135
+ const now = this.nowMs();
4136
+ await this.client.hset(
4137
+ `${this.prefix}job:${jobId}`,
4138
+ "stepData",
4139
+ JSON.stringify(stepData),
4140
+ "updatedAt",
4141
+ now.toString()
4142
+ );
4143
+ } catch (error) {
4144
+ log(`Error updating stepData for job ${jobId}: ${error}`);
2828
4145
  }
2829
- let count = 0;
2830
- for (const id of ids) {
2831
- await this.editJob(Number(id), updates);
2832
- count++;
4146
+ }
4147
+ /**
4148
+ * Create a waitpoint token.
4149
+ *
4150
+ * @param jobId - The job ID to associate with the token (null if created outside a handler).
4151
+ * @param options - Optional timeout string (e.g. '10m', '1h') and tags.
4152
+ * @returns The created waitpoint with its unique ID.
4153
+ */
4154
+ async createWaitpoint(jobId, options) {
4155
+ const id = `wp_${crypto.randomUUID()}`;
4156
+ const now = this.nowMs();
4157
+ let timeoutAt = null;
4158
+ if (options?.timeout) {
4159
+ const ms = parseTimeoutString2(options.timeout);
4160
+ timeoutAt = now + ms;
2833
4161
  }
2834
- log(`Edited ${count} pending jobs`);
2835
- return count;
4162
+ const key = `${this.prefix}waitpoint:${id}`;
4163
+ const fields = [
4164
+ "id",
4165
+ id,
4166
+ "jobId",
4167
+ jobId !== null ? jobId.toString() : "null",
4168
+ "status",
4169
+ "waiting",
4170
+ "output",
4171
+ "null",
4172
+ "timeoutAt",
4173
+ timeoutAt !== null ? timeoutAt.toString() : "null",
4174
+ "createdAt",
4175
+ now.toString(),
4176
+ "completedAt",
4177
+ "null",
4178
+ "tags",
4179
+ options?.tags ? JSON.stringify(options.tags) : "null"
4180
+ ];
4181
+ await this.client.hmset(key, ...fields);
4182
+ if (timeoutAt !== null) {
4183
+ await this.client.zadd(`${this.prefix}waitpoint_timeout`, timeoutAt, id);
4184
+ }
4185
+ log(`Created waitpoint ${id} for job ${jobId}`);
4186
+ return { id };
2836
4187
  }
2837
- async cleanupOldJobs(daysToKeep = 30) {
2838
- const cutoffMs = this.nowMs() - daysToKeep * 24 * 60 * 60 * 1e3;
4188
+ /**
4189
+ * Complete a waitpoint token and move the associated job back to 'pending'.
4190
+ *
4191
+ * @param tokenId - The waitpoint token ID to complete.
4192
+ * @param data - Optional data to pass to the waiting handler.
4193
+ */
4194
+ async completeWaitpoint(tokenId, data) {
4195
+ const now = this.nowMs();
4196
+ const outputJson = data != null ? JSON.stringify(data) : "null";
2839
4197
  const result = await this.client.eval(
2840
- CLEANUP_OLD_JOBS_SCRIPT,
4198
+ COMPLETE_WAITPOINT_SCRIPT,
2841
4199
  1,
2842
4200
  this.prefix,
2843
- cutoffMs
4201
+ tokenId,
4202
+ outputJson,
4203
+ now
2844
4204
  );
2845
- log(`Deleted ${result} old jobs`);
2846
- return Number(result);
4205
+ if (Number(result) === 0) {
4206
+ log(`Waitpoint ${tokenId} not found or already completed`);
4207
+ return;
4208
+ }
4209
+ log(`Completed waitpoint ${tokenId}`);
2847
4210
  }
2848
- async cleanupOldJobEvents(daysToKeep = 30) {
2849
- log(
2850
- `cleanupOldJobEvents is a no-op for Redis backend (events are cleaned up with their jobs)`
4211
+ /**
4212
+ * Retrieve a waitpoint token by its ID.
4213
+ *
4214
+ * @param tokenId - The waitpoint token ID to look up.
4215
+ * @returns The waitpoint record, or null if not found.
4216
+ */
4217
+ async getWaitpoint(tokenId) {
4218
+ const data = await this.client.hgetall(
4219
+ `${this.prefix}waitpoint:${tokenId}`
2851
4220
  );
2852
- return 0;
4221
+ if (!data || Object.keys(data).length === 0) return null;
4222
+ const nullish = (v) => v === void 0 || v === "null" || v === "" ? null : v;
4223
+ const numOrNull = (v) => {
4224
+ const n = nullish(v);
4225
+ return n === null ? null : Number(n);
4226
+ };
4227
+ const dateOrNull = (v) => {
4228
+ const n = numOrNull(v);
4229
+ return n === null ? null : new Date(n);
4230
+ };
4231
+ let output = null;
4232
+ if (data.output && data.output !== "null") {
4233
+ try {
4234
+ output = JSON.parse(data.output);
4235
+ } catch {
4236
+ output = data.output;
4237
+ }
4238
+ }
4239
+ let tags = null;
4240
+ if (data.tags && data.tags !== "null") {
4241
+ try {
4242
+ tags = JSON.parse(data.tags);
4243
+ } catch {
4244
+ }
4245
+ }
4246
+ return {
4247
+ id: data.id,
4248
+ jobId: numOrNull(data.jobId),
4249
+ status: data.status,
4250
+ output,
4251
+ timeoutAt: dateOrNull(data.timeoutAt),
4252
+ createdAt: new Date(Number(data.createdAt)),
4253
+ completedAt: dateOrNull(data.completedAt),
4254
+ tags
4255
+ };
2853
4256
  }
2854
- async reclaimStuckJobs(maxProcessingTimeMinutes = 10) {
2855
- const maxAgeMs = maxProcessingTimeMinutes * 60 * 1e3;
4257
+ /**
4258
+ * Expire timed-out waitpoint tokens and move their associated jobs back to 'pending'.
4259
+ *
4260
+ * @returns The number of tokens that were expired.
4261
+ */
4262
+ async expireTimedOutWaitpoints() {
2856
4263
  const now = this.nowMs();
2857
4264
  const result = await this.client.eval(
2858
- RECLAIM_STUCK_JOBS_SCRIPT,
4265
+ EXPIRE_TIMED_OUT_WAITPOINTS_SCRIPT,
2859
4266
  1,
2860
4267
  this.prefix,
2861
- maxAgeMs,
2862
4268
  now
2863
4269
  );
2864
- log(`Reclaimed ${result} stuck jobs`);
2865
- return Number(result);
4270
+ const count = Number(result);
4271
+ if (count > 0) {
4272
+ log(`Expired ${count} timed-out waitpoints`);
4273
+ }
4274
+ return count;
2866
4275
  }
2867
4276
  // ── Internal helpers ──────────────────────────────────────────────────
2868
4277
  async setPendingReasonForUnpickedJobs(reason, jobType) {
@@ -2968,6 +4377,359 @@ var RedisBackend = class {
2968
4377
  return true;
2969
4378
  });
2970
4379
  }
4380
+ // ── Cron schedules ──────────────────────────────────────────────────
4381
+ /** Create a cron schedule and return its ID. */
4382
+ async addCronSchedule(input) {
4383
+ const existingId = await this.client.get(
4384
+ `${this.prefix}cron_name:${input.scheduleName}`
4385
+ );
4386
+ if (existingId !== null) {
4387
+ throw new Error(
4388
+ `Cron schedule with name "${input.scheduleName}" already exists`
4389
+ );
4390
+ }
4391
+ const id = await this.client.incr(`${this.prefix}cron_id_seq`);
4392
+ const now = this.nowMs();
4393
+ const key = `${this.prefix}cron:${id}`;
4394
+ const fields = [
4395
+ "id",
4396
+ id.toString(),
4397
+ "scheduleName",
4398
+ input.scheduleName,
4399
+ "cronExpression",
4400
+ input.cronExpression,
4401
+ "jobType",
4402
+ input.jobType,
4403
+ "payload",
4404
+ JSON.stringify(input.payload),
4405
+ "maxAttempts",
4406
+ input.maxAttempts.toString(),
4407
+ "priority",
4408
+ input.priority.toString(),
4409
+ "timeoutMs",
4410
+ input.timeoutMs !== null ? input.timeoutMs.toString() : "null",
4411
+ "forceKillOnTimeout",
4412
+ input.forceKillOnTimeout ? "true" : "false",
4413
+ "tags",
4414
+ input.tags ? JSON.stringify(input.tags) : "null",
4415
+ "timezone",
4416
+ input.timezone,
4417
+ "allowOverlap",
4418
+ input.allowOverlap ? "true" : "false",
4419
+ "status",
4420
+ "active",
4421
+ "lastEnqueuedAt",
4422
+ "null",
4423
+ "lastJobId",
4424
+ "null",
4425
+ "nextRunAt",
4426
+ input.nextRunAt ? input.nextRunAt.getTime().toString() : "null",
4427
+ "createdAt",
4428
+ now.toString(),
4429
+ "updatedAt",
4430
+ now.toString(),
4431
+ "retryDelay",
4432
+ input.retryDelay !== null && input.retryDelay !== void 0 ? input.retryDelay.toString() : "null",
4433
+ "retryBackoff",
4434
+ input.retryBackoff !== null && input.retryBackoff !== void 0 ? input.retryBackoff.toString() : "null",
4435
+ "retryDelayMax",
4436
+ input.retryDelayMax !== null && input.retryDelayMax !== void 0 ? input.retryDelayMax.toString() : "null"
4437
+ ];
4438
+ await this.client.hmset(key, ...fields);
4439
+ await this.client.set(
4440
+ `${this.prefix}cron_name:${input.scheduleName}`,
4441
+ id.toString()
4442
+ );
4443
+ await this.client.sadd(`${this.prefix}crons`, id.toString());
4444
+ await this.client.sadd(`${this.prefix}cron_status:active`, id.toString());
4445
+ if (input.nextRunAt) {
4446
+ await this.client.zadd(
4447
+ `${this.prefix}cron_due`,
4448
+ input.nextRunAt.getTime(),
4449
+ id.toString()
4450
+ );
4451
+ }
4452
+ log(`Added cron schedule ${id}: "${input.scheduleName}"`);
4453
+ return id;
4454
+ }
4455
+ /** Get a cron schedule by ID. */
4456
+ async getCronSchedule(id) {
4457
+ const data = await this.client.hgetall(`${this.prefix}cron:${id}`);
4458
+ if (!data || Object.keys(data).length === 0) return null;
4459
+ return this.deserializeCronSchedule(data);
4460
+ }
4461
+ /** Get a cron schedule by its unique name. */
4462
+ async getCronScheduleByName(name) {
4463
+ const id = await this.client.get(`${this.prefix}cron_name:${name}`);
4464
+ if (id === null) return null;
4465
+ return this.getCronSchedule(Number(id));
4466
+ }
4467
+ /** List cron schedules, optionally filtered by status. */
4468
+ async listCronSchedules(status) {
4469
+ let ids;
4470
+ if (status) {
4471
+ ids = await this.client.smembers(`${this.prefix}cron_status:${status}`);
4472
+ } else {
4473
+ ids = await this.client.smembers(`${this.prefix}crons`);
4474
+ }
4475
+ if (ids.length === 0) return [];
4476
+ const pipeline = this.client.pipeline();
4477
+ for (const id of ids) {
4478
+ pipeline.hgetall(`${this.prefix}cron:${id}`);
4479
+ }
4480
+ const results = await pipeline.exec();
4481
+ const schedules = [];
4482
+ if (results) {
4483
+ for (const [err, data] of results) {
4484
+ if (!err && data && typeof data === "object" && Object.keys(data).length > 0) {
4485
+ schedules.push(
4486
+ this.deserializeCronSchedule(data)
4487
+ );
4488
+ }
4489
+ }
4490
+ }
4491
+ schedules.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
4492
+ return schedules;
4493
+ }
4494
+ /** Delete a cron schedule by ID. */
4495
+ async removeCronSchedule(id) {
4496
+ const data = await this.client.hgetall(`${this.prefix}cron:${id}`);
4497
+ if (!data || Object.keys(data).length === 0) return;
4498
+ const name = data.scheduleName;
4499
+ const status = data.status;
4500
+ await this.client.del(`${this.prefix}cron:${id}`);
4501
+ await this.client.del(`${this.prefix}cron_name:${name}`);
4502
+ await this.client.srem(`${this.prefix}crons`, id.toString());
4503
+ await this.client.srem(
4504
+ `${this.prefix}cron_status:${status}`,
4505
+ id.toString()
4506
+ );
4507
+ await this.client.zrem(`${this.prefix}cron_due`, id.toString());
4508
+ log(`Removed cron schedule ${id}`);
4509
+ }
4510
+ /** Pause a cron schedule. */
4511
+ async pauseCronSchedule(id) {
4512
+ const now = this.nowMs();
4513
+ await this.client.hset(
4514
+ `${this.prefix}cron:${id}`,
4515
+ "status",
4516
+ "paused",
4517
+ "updatedAt",
4518
+ now.toString()
4519
+ );
4520
+ await this.client.srem(`${this.prefix}cron_status:active`, id.toString());
4521
+ await this.client.sadd(`${this.prefix}cron_status:paused`, id.toString());
4522
+ await this.client.zrem(`${this.prefix}cron_due`, id.toString());
4523
+ log(`Paused cron schedule ${id}`);
4524
+ }
4525
+ /** Resume a paused cron schedule. */
4526
+ async resumeCronSchedule(id) {
4527
+ const now = this.nowMs();
4528
+ await this.client.hset(
4529
+ `${this.prefix}cron:${id}`,
4530
+ "status",
4531
+ "active",
4532
+ "updatedAt",
4533
+ now.toString()
4534
+ );
4535
+ await this.client.srem(`${this.prefix}cron_status:paused`, id.toString());
4536
+ await this.client.sadd(`${this.prefix}cron_status:active`, id.toString());
4537
+ const nextRunAt = await this.client.hget(
4538
+ `${this.prefix}cron:${id}`,
4539
+ "nextRunAt"
4540
+ );
4541
+ if (nextRunAt && nextRunAt !== "null") {
4542
+ await this.client.zadd(
4543
+ `${this.prefix}cron_due`,
4544
+ Number(nextRunAt),
4545
+ id.toString()
4546
+ );
4547
+ }
4548
+ log(`Resumed cron schedule ${id}`);
4549
+ }
4550
+ /** Edit a cron schedule. */
4551
+ async editCronSchedule(id, updates, nextRunAt) {
4552
+ const now = this.nowMs();
4553
+ const fields = [];
4554
+ if (updates.cronExpression !== void 0) {
4555
+ fields.push("cronExpression", updates.cronExpression);
4556
+ }
4557
+ if (updates.payload !== void 0) {
4558
+ fields.push("payload", JSON.stringify(updates.payload));
4559
+ }
4560
+ if (updates.maxAttempts !== void 0) {
4561
+ fields.push("maxAttempts", updates.maxAttempts.toString());
4562
+ }
4563
+ if (updates.priority !== void 0) {
4564
+ fields.push("priority", updates.priority.toString());
4565
+ }
4566
+ if (updates.timeoutMs !== void 0) {
4567
+ fields.push(
4568
+ "timeoutMs",
4569
+ updates.timeoutMs !== null ? updates.timeoutMs.toString() : "null"
4570
+ );
4571
+ }
4572
+ if (updates.forceKillOnTimeout !== void 0) {
4573
+ fields.push(
4574
+ "forceKillOnTimeout",
4575
+ updates.forceKillOnTimeout ? "true" : "false"
4576
+ );
4577
+ }
4578
+ if (updates.tags !== void 0) {
4579
+ fields.push(
4580
+ "tags",
4581
+ updates.tags !== null ? JSON.stringify(updates.tags) : "null"
4582
+ );
4583
+ }
4584
+ if (updates.timezone !== void 0) {
4585
+ fields.push("timezone", updates.timezone);
4586
+ }
4587
+ if (updates.allowOverlap !== void 0) {
4588
+ fields.push("allowOverlap", updates.allowOverlap ? "true" : "false");
4589
+ }
4590
+ if (updates.retryDelay !== void 0) {
4591
+ fields.push(
4592
+ "retryDelay",
4593
+ updates.retryDelay !== null ? updates.retryDelay.toString() : "null"
4594
+ );
4595
+ }
4596
+ if (updates.retryBackoff !== void 0) {
4597
+ fields.push(
4598
+ "retryBackoff",
4599
+ updates.retryBackoff !== null ? updates.retryBackoff.toString() : "null"
4600
+ );
4601
+ }
4602
+ if (updates.retryDelayMax !== void 0) {
4603
+ fields.push(
4604
+ "retryDelayMax",
4605
+ updates.retryDelayMax !== null ? updates.retryDelayMax.toString() : "null"
4606
+ );
4607
+ }
4608
+ if (nextRunAt !== void 0) {
4609
+ const val = nextRunAt !== null ? nextRunAt.getTime().toString() : "null";
4610
+ fields.push("nextRunAt", val);
4611
+ if (nextRunAt !== null) {
4612
+ await this.client.zadd(
4613
+ `${this.prefix}cron_due`,
4614
+ nextRunAt.getTime(),
4615
+ id.toString()
4616
+ );
4617
+ } else {
4618
+ await this.client.zrem(`${this.prefix}cron_due`, id.toString());
4619
+ }
4620
+ }
4621
+ if (fields.length === 0) {
4622
+ log(`No fields to update for cron schedule ${id}`);
4623
+ return;
4624
+ }
4625
+ fields.push("updatedAt", now.toString());
4626
+ await this.client.hmset(`${this.prefix}cron:${id}`, ...fields);
4627
+ log(`Edited cron schedule ${id}`);
4628
+ }
4629
+ /**
4630
+ * Fetch all active cron schedules whose nextRunAt <= now.
4631
+ * Uses a sorted set (cron_due) for efficient range query.
4632
+ */
4633
+ async getDueCronSchedules() {
4634
+ const now = this.nowMs();
4635
+ const ids = await this.client.zrangebyscore(
4636
+ `${this.prefix}cron_due`,
4637
+ 0,
4638
+ now
4639
+ );
4640
+ if (ids.length === 0) {
4641
+ log("Found 0 due cron schedules");
4642
+ return [];
4643
+ }
4644
+ const schedules = [];
4645
+ for (const id of ids) {
4646
+ const data = await this.client.hgetall(`${this.prefix}cron:${id}`);
4647
+ if (data && Object.keys(data).length > 0 && data.status === "active") {
4648
+ schedules.push(this.deserializeCronSchedule(data));
4649
+ }
4650
+ }
4651
+ log(`Found ${schedules.length} due cron schedules`);
4652
+ return schedules;
4653
+ }
4654
+ /**
4655
+ * Update a cron schedule after a job has been enqueued.
4656
+ * Sets lastEnqueuedAt, lastJobId, and advances nextRunAt.
4657
+ */
4658
+ async updateCronScheduleAfterEnqueue(id, lastEnqueuedAt, lastJobId, nextRunAt) {
4659
+ const fields = [
4660
+ "lastEnqueuedAt",
4661
+ lastEnqueuedAt.getTime().toString(),
4662
+ "lastJobId",
4663
+ lastJobId.toString(),
4664
+ "nextRunAt",
4665
+ nextRunAt ? nextRunAt.getTime().toString() : "null",
4666
+ "updatedAt",
4667
+ this.nowMs().toString()
4668
+ ];
4669
+ await this.client.hmset(`${this.prefix}cron:${id}`, ...fields);
4670
+ if (nextRunAt) {
4671
+ await this.client.zadd(
4672
+ `${this.prefix}cron_due`,
4673
+ nextRunAt.getTime(),
4674
+ id.toString()
4675
+ );
4676
+ } else {
4677
+ await this.client.zrem(`${this.prefix}cron_due`, id.toString());
4678
+ }
4679
+ log(
4680
+ `Updated cron schedule ${id}: lastJobId=${lastJobId}, nextRunAt=${nextRunAt?.toISOString() ?? "null"}`
4681
+ );
4682
+ }
4683
+ /** Deserialize a Redis hash into a CronScheduleRecord. */
4684
+ deserializeCronSchedule(h) {
4685
+ const nullish = (v) => v === void 0 || v === "null" || v === "" ? null : v;
4686
+ const numOrNull = (v) => {
4687
+ const n = nullish(v);
4688
+ return n === null ? null : Number(n);
4689
+ };
4690
+ const dateOrNull = (v) => {
4691
+ const n = numOrNull(v);
4692
+ return n === null ? null : new Date(n);
4693
+ };
4694
+ let payload;
4695
+ try {
4696
+ payload = JSON.parse(h.payload);
4697
+ } catch {
4698
+ payload = h.payload;
4699
+ }
4700
+ let tags;
4701
+ try {
4702
+ const raw = h.tags;
4703
+ if (raw && raw !== "null") {
4704
+ tags = JSON.parse(raw);
4705
+ }
4706
+ } catch {
4707
+ }
4708
+ return {
4709
+ id: Number(h.id),
4710
+ scheduleName: h.scheduleName,
4711
+ cronExpression: h.cronExpression,
4712
+ jobType: h.jobType,
4713
+ payload,
4714
+ maxAttempts: Number(h.maxAttempts),
4715
+ priority: Number(h.priority),
4716
+ timeoutMs: numOrNull(h.timeoutMs),
4717
+ forceKillOnTimeout: h.forceKillOnTimeout === "true",
4718
+ tags,
4719
+ timezone: h.timezone,
4720
+ allowOverlap: h.allowOverlap === "true",
4721
+ status: h.status,
4722
+ lastEnqueuedAt: dateOrNull(h.lastEnqueuedAt),
4723
+ lastJobId: numOrNull(h.lastJobId),
4724
+ nextRunAt: dateOrNull(h.nextRunAt),
4725
+ createdAt: new Date(Number(h.createdAt)),
4726
+ updatedAt: new Date(Number(h.updatedAt)),
4727
+ retryDelay: numOrNull(h.retryDelay),
4728
+ retryBackoff: h.retryBackoff === "true" ? true : h.retryBackoff === "false" ? false : null,
4729
+ retryDelayMax: numOrNull(h.retryDelayMax)
4730
+ };
4731
+ }
4732
+ // ── Private helpers (filters) ─────────────────────────────────────────
2971
4733
  async applyFilters(ids, filters) {
2972
4734
  let result = ids;
2973
4735
  if (filters.jobType) {
@@ -2997,6 +4759,19 @@ var RedisBackend = class {
2997
4759
  return result;
2998
4760
  }
2999
4761
  };
4762
+ function getNextCronOccurrence(cronExpression, timezone = "UTC", after, CronImpl = croner.Cron) {
4763
+ const cron = new CronImpl(cronExpression, { timezone });
4764
+ const next = cron.nextRun(after ?? /* @__PURE__ */ new Date());
4765
+ return next ?? null;
4766
+ }
4767
+ function validateCronExpression(cronExpression, CronImpl = croner.Cron) {
4768
+ try {
4769
+ new CronImpl(cronExpression);
4770
+ return true;
4771
+ } catch {
4772
+ return false;
4773
+ }
4774
+ }
3000
4775
 
3001
4776
  // src/handler-validation.ts
3002
4777
  function validateHandlerSerializable2(handler, jobType) {
@@ -3072,29 +4847,89 @@ var initJobQueue = (config) => {
3072
4847
  const backendType = config.backend ?? "postgres";
3073
4848
  setLogContext(config.verbose ?? false);
3074
4849
  let backend;
3075
- let pool;
3076
4850
  if (backendType === "postgres") {
3077
4851
  const pgConfig = config;
3078
- pool = createPool(pgConfig.databaseConfig);
3079
- backend = new PostgresBackend(pool);
4852
+ if (pgConfig.pool) {
4853
+ backend = new PostgresBackend(pgConfig.pool);
4854
+ } else if (pgConfig.databaseConfig) {
4855
+ const pool = createPool(pgConfig.databaseConfig);
4856
+ backend = new PostgresBackend(pool);
4857
+ } else {
4858
+ throw new Error(
4859
+ 'PostgreSQL backend requires either "databaseConfig" or "pool" to be provided.'
4860
+ );
4861
+ }
3080
4862
  } else if (backendType === "redis") {
3081
- const redisConfig = config.redisConfig;
3082
- backend = new RedisBackend(redisConfig);
4863
+ const redisConfig = config;
4864
+ if (redisConfig.client) {
4865
+ backend = new RedisBackend(
4866
+ redisConfig.client,
4867
+ redisConfig.keyPrefix
4868
+ );
4869
+ } else if (redisConfig.redisConfig) {
4870
+ backend = new RedisBackend(redisConfig.redisConfig);
4871
+ } else {
4872
+ throw new Error(
4873
+ 'Redis backend requires either "redisConfig" or "client" to be provided.'
4874
+ );
4875
+ }
3083
4876
  } else {
3084
4877
  throw new Error(`Unknown backend: ${backendType}`);
3085
4878
  }
3086
- const requirePool = () => {
3087
- if (!pool) {
3088
- throw new Error(
3089
- 'Wait/Token features require the PostgreSQL backend. Configure with backend: "postgres" to use these features.'
4879
+ const enqueueDueCronJobsImpl = async () => {
4880
+ const dueSchedules = await backend.getDueCronSchedules();
4881
+ let count = 0;
4882
+ for (const schedule of dueSchedules) {
4883
+ if (!schedule.allowOverlap && schedule.lastJobId !== null) {
4884
+ const lastJob = await backend.getJob(schedule.lastJobId);
4885
+ if (lastJob && (lastJob.status === "pending" || lastJob.status === "processing" || lastJob.status === "waiting")) {
4886
+ const nextRunAt2 = getNextCronOccurrence(
4887
+ schedule.cronExpression,
4888
+ schedule.timezone
4889
+ );
4890
+ await backend.updateCronScheduleAfterEnqueue(
4891
+ schedule.id,
4892
+ /* @__PURE__ */ new Date(),
4893
+ schedule.lastJobId,
4894
+ nextRunAt2
4895
+ );
4896
+ continue;
4897
+ }
4898
+ }
4899
+ const jobId = await backend.addJob({
4900
+ jobType: schedule.jobType,
4901
+ payload: schedule.payload,
4902
+ maxAttempts: schedule.maxAttempts,
4903
+ priority: schedule.priority,
4904
+ timeoutMs: schedule.timeoutMs ?? void 0,
4905
+ forceKillOnTimeout: schedule.forceKillOnTimeout,
4906
+ tags: schedule.tags,
4907
+ retryDelay: schedule.retryDelay ?? void 0,
4908
+ retryBackoff: schedule.retryBackoff ?? void 0,
4909
+ retryDelayMax: schedule.retryDelayMax ?? void 0
4910
+ });
4911
+ const nextRunAt = getNextCronOccurrence(
4912
+ schedule.cronExpression,
4913
+ schedule.timezone
4914
+ );
4915
+ await backend.updateCronScheduleAfterEnqueue(
4916
+ schedule.id,
4917
+ /* @__PURE__ */ new Date(),
4918
+ jobId,
4919
+ nextRunAt
3090
4920
  );
4921
+ count++;
3091
4922
  }
3092
- return pool;
4923
+ return count;
3093
4924
  };
3094
4925
  return {
3095
4926
  // Job queue operations
3096
4927
  addJob: withLogContext(
3097
- (job) => backend.addJob(job),
4928
+ (job, options) => backend.addJob(job, options),
4929
+ config.verbose ?? false
4930
+ ),
4931
+ addJobs: withLogContext(
4932
+ (jobs, options) => backend.addJobs(jobs, options),
3098
4933
  config.verbose ?? false
3099
4934
  ),
3100
4935
  getJob: withLogContext(
@@ -3114,8 +4949,8 @@ var initJobQueue = (config) => {
3114
4949
  config.verbose ?? false
3115
4950
  ),
3116
4951
  retryJob: (jobId) => backend.retryJob(jobId),
3117
- cleanupOldJobs: (daysToKeep) => backend.cleanupOldJobs(daysToKeep),
3118
- cleanupOldJobEvents: (daysToKeep) => backend.cleanupOldJobEvents(daysToKeep),
4952
+ cleanupOldJobs: (daysToKeep, batchSize) => backend.cleanupOldJobs(daysToKeep, batchSize),
4953
+ cleanupOldJobEvents: (daysToKeep, batchSize) => backend.cleanupOldJobEvents(daysToKeep, batchSize),
3119
4954
  cancelJob: withLogContext(
3120
4955
  (jobId) => backend.cancelJob(jobId),
3121
4956
  config.verbose ?? false
@@ -3143,33 +4978,116 @@ var initJobQueue = (config) => {
3143
4978
  (tags, mode = "all", limit, offset) => backend.getJobsByTags(tags, mode, limit, offset),
3144
4979
  config.verbose ?? false
3145
4980
  ),
3146
- // Job processing
3147
- createProcessor: (handlers, options) => createProcessor(backend, handlers, options),
4981
+ // Job processing — automatically enqueues due cron jobs before each batch
4982
+ createProcessor: (handlers, options) => createProcessor(backend, handlers, options, async () => {
4983
+ await enqueueDueCronJobsImpl();
4984
+ }),
4985
+ // Background supervisor — automated maintenance
4986
+ createSupervisor: (options) => createSupervisor(backend, options),
3148
4987
  // Job events
3149
4988
  getJobEvents: withLogContext(
3150
4989
  (jobId) => backend.getJobEvents(jobId),
3151
4990
  config.verbose ?? false
3152
4991
  ),
3153
- // Wait / Token support (PostgreSQL-only for now)
4992
+ // Wait / Token support (works with all backends)
3154
4993
  createToken: withLogContext(
3155
- (options) => createWaitpoint(requirePool(), null, options),
4994
+ (options) => backend.createWaitpoint(null, options),
3156
4995
  config.verbose ?? false
3157
4996
  ),
3158
4997
  completeToken: withLogContext(
3159
- (tokenId, data) => completeWaitpoint(requirePool(), tokenId, data),
4998
+ (tokenId, data) => backend.completeWaitpoint(tokenId, data),
3160
4999
  config.verbose ?? false
3161
5000
  ),
3162
5001
  getToken: withLogContext(
3163
- (tokenId) => getWaitpoint(requirePool(), tokenId),
5002
+ (tokenId) => backend.getWaitpoint(tokenId),
3164
5003
  config.verbose ?? false
3165
5004
  ),
3166
5005
  expireTimedOutTokens: withLogContext(
3167
- () => expireTimedOutWaitpoints(requirePool()),
5006
+ () => backend.expireTimedOutWaitpoints(),
5007
+ config.verbose ?? false
5008
+ ),
5009
+ // Cron schedule operations
5010
+ addCronJob: withLogContext(
5011
+ (options) => {
5012
+ if (!validateCronExpression(options.cronExpression)) {
5013
+ return Promise.reject(
5014
+ new Error(`Invalid cron expression: "${options.cronExpression}"`)
5015
+ );
5016
+ }
5017
+ const nextRunAt = getNextCronOccurrence(
5018
+ options.cronExpression,
5019
+ options.timezone ?? "UTC"
5020
+ );
5021
+ const input = {
5022
+ scheduleName: options.scheduleName,
5023
+ cronExpression: options.cronExpression,
5024
+ jobType: options.jobType,
5025
+ payload: options.payload,
5026
+ maxAttempts: options.maxAttempts ?? 3,
5027
+ priority: options.priority ?? 0,
5028
+ timeoutMs: options.timeoutMs ?? null,
5029
+ forceKillOnTimeout: options.forceKillOnTimeout ?? false,
5030
+ tags: options.tags,
5031
+ timezone: options.timezone ?? "UTC",
5032
+ allowOverlap: options.allowOverlap ?? false,
5033
+ nextRunAt,
5034
+ retryDelay: options.retryDelay ?? null,
5035
+ retryBackoff: options.retryBackoff ?? null,
5036
+ retryDelayMax: options.retryDelayMax ?? null
5037
+ };
5038
+ return backend.addCronSchedule(input);
5039
+ },
5040
+ config.verbose ?? false
5041
+ ),
5042
+ getCronJob: withLogContext(
5043
+ (id) => backend.getCronSchedule(id),
5044
+ config.verbose ?? false
5045
+ ),
5046
+ getCronJobByName: withLogContext(
5047
+ (name) => backend.getCronScheduleByName(name),
5048
+ config.verbose ?? false
5049
+ ),
5050
+ listCronJobs: withLogContext(
5051
+ (status) => backend.listCronSchedules(status),
5052
+ config.verbose ?? false
5053
+ ),
5054
+ removeCronJob: withLogContext(
5055
+ (id) => backend.removeCronSchedule(id),
5056
+ config.verbose ?? false
5057
+ ),
5058
+ pauseCronJob: withLogContext(
5059
+ (id) => backend.pauseCronSchedule(id),
5060
+ config.verbose ?? false
5061
+ ),
5062
+ resumeCronJob: withLogContext(
5063
+ (id) => backend.resumeCronSchedule(id),
5064
+ config.verbose ?? false
5065
+ ),
5066
+ editCronJob: withLogContext(
5067
+ async (id, updates) => {
5068
+ if (updates.cronExpression !== void 0 && !validateCronExpression(updates.cronExpression)) {
5069
+ throw new Error(
5070
+ `Invalid cron expression: "${updates.cronExpression}"`
5071
+ );
5072
+ }
5073
+ let nextRunAt;
5074
+ if (updates.cronExpression !== void 0 || updates.timezone !== void 0) {
5075
+ const existing = await backend.getCronSchedule(id);
5076
+ const expr = updates.cronExpression ?? existing?.cronExpression ?? "";
5077
+ const tz = updates.timezone ?? existing?.timezone ?? "UTC";
5078
+ nextRunAt = getNextCronOccurrence(expr, tz);
5079
+ }
5080
+ await backend.editCronSchedule(id, updates, nextRunAt);
5081
+ },
5082
+ config.verbose ?? false
5083
+ ),
5084
+ enqueueDueCronJobs: withLogContext(
5085
+ () => enqueueDueCronJobsImpl(),
3168
5086
  config.verbose ?? false
3169
5087
  ),
3170
5088
  // Advanced access
3171
5089
  getPool: () => {
3172
- if (backendType !== "postgres") {
5090
+ if (!(backend instanceof PostgresBackend)) {
3173
5091
  throw new Error(
3174
5092
  "getPool() is only available with the PostgreSQL backend."
3175
5093
  );
@@ -3195,8 +5113,10 @@ exports.FailureReason = FailureReason;
3195
5113
  exports.JobEventType = JobEventType;
3196
5114
  exports.PostgresBackend = PostgresBackend;
3197
5115
  exports.WaitSignal = WaitSignal;
5116
+ exports.getNextCronOccurrence = getNextCronOccurrence;
3198
5117
  exports.initJobQueue = initJobQueue;
3199
5118
  exports.testHandlerSerialization = testHandlerSerialization;
5119
+ exports.validateCronExpression = validateCronExpression;
3200
5120
  exports.validateHandlerSerializable = validateHandlerSerializable2;
3201
5121
  //# sourceMappingURL=index.cjs.map
3202
5122
  //# sourceMappingURL=index.cjs.map