@nicnocquee/dataqueue 1.25.0 → 1.26.0-beta.20260223202259

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