absurd-sdk 0.0.4 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -4
- package/dist/cjs/index.js +218 -80
- package/dist/index.d.ts +130 -29
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +216 -79
- package/dist/index.js.map +1 -1
- package/package.json +13 -7
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ Durable execution (or durable workflows) is a way to run long-lived, reliable fu
|
|
|
13
13
|
## Installation
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
npm install absurd-sdk
|
|
16
|
+
npm install absurd-sdk pg
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
## Prerequisites
|
|
@@ -31,9 +31,7 @@ absurdctl create-queue -d your-database-name default
|
|
|
31
31
|
```typescript
|
|
32
32
|
import { Absurd } from "absurd-sdk";
|
|
33
33
|
|
|
34
|
-
const app = new Absurd({
|
|
35
|
-
connectionString: "postgresql://localhost/mydb",
|
|
36
|
-
});
|
|
34
|
+
const app = new Absurd({ db: "postgresql://localhost/mydb" });
|
|
37
35
|
|
|
38
36
|
// Register a task
|
|
39
37
|
app.registerTask({ name: "order-fulfillment" }, async (params, ctx) => {
|
package/dist/cjs/index.js
CHANGED
|
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.Absurd = exports.TaskContext = exports.TimeoutError = exports.SuspendTask = void 0;
|
|
36
|
+
exports.Absurd = exports.TaskContext = exports.TimeoutError = exports.CancelledTask = exports.SuspendTask = void 0;
|
|
37
37
|
/**
|
|
38
38
|
* Absurd SDK for TypeScript and JavaScript
|
|
39
39
|
*/
|
|
@@ -50,6 +50,17 @@ class SuspendTask extends Error {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
exports.SuspendTask = SuspendTask;
|
|
53
|
+
/**
|
|
54
|
+
* Internal exception that is thrown to cancel a run. As a user
|
|
55
|
+
* you should never see this exception.
|
|
56
|
+
*/
|
|
57
|
+
class CancelledTask extends Error {
|
|
58
|
+
constructor() {
|
|
59
|
+
super("Task cancelled");
|
|
60
|
+
this.name = "CancelledTask";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
exports.CancelledTask = CancelledTask;
|
|
53
64
|
/**
|
|
54
65
|
* This error is thrown when awaiting an event ran into a timeout.
|
|
55
66
|
*/
|
|
@@ -61,36 +72,48 @@ class TimeoutError extends Error {
|
|
|
61
72
|
}
|
|
62
73
|
exports.TimeoutError = TimeoutError;
|
|
63
74
|
class TaskContext {
|
|
75
|
+
log;
|
|
64
76
|
taskID;
|
|
65
|
-
|
|
77
|
+
con;
|
|
66
78
|
queueName;
|
|
67
79
|
task;
|
|
68
80
|
checkpointCache;
|
|
69
81
|
claimTimeout;
|
|
70
82
|
stepNameCounter = new Map();
|
|
71
|
-
constructor(taskID,
|
|
83
|
+
constructor(log, taskID, con, queueName, task, checkpointCache, claimTimeout) {
|
|
84
|
+
this.log = log;
|
|
72
85
|
this.taskID = taskID;
|
|
73
|
-
this.
|
|
86
|
+
this.con = con;
|
|
74
87
|
this.queueName = queueName;
|
|
75
88
|
this.task = task;
|
|
76
89
|
this.checkpointCache = checkpointCache;
|
|
77
90
|
this.claimTimeout = claimTimeout;
|
|
78
91
|
}
|
|
79
92
|
static async create(args) {
|
|
80
|
-
const { taskID,
|
|
81
|
-
const result = await
|
|
93
|
+
const { log, taskID, con, queueName, task, claimTimeout } = args;
|
|
94
|
+
const result = await con.query(`SELECT checkpoint_name, state, status, owner_run_id, updated_at
|
|
82
95
|
FROM absurd.get_task_checkpoint_states($1, $2, $3)`, [queueName, task.task_id, task.run_id]);
|
|
83
96
|
const cache = new Map();
|
|
84
97
|
for (const row of result.rows) {
|
|
85
98
|
cache.set(row.checkpoint_name, row.state);
|
|
86
99
|
}
|
|
87
|
-
return new TaskContext(taskID,
|
|
100
|
+
return new TaskContext(log, taskID, con, queueName, task, cache, claimTimeout);
|
|
101
|
+
}
|
|
102
|
+
async queryWithCancelCheck(sql, params) {
|
|
103
|
+
try {
|
|
104
|
+
return await this.con.query(sql, params);
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
if (err?.code === "AB001") {
|
|
108
|
+
throw new CancelledTask();
|
|
109
|
+
}
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
88
112
|
}
|
|
89
113
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* must support `JSON.stringify`.
|
|
114
|
+
* Runs an idempotent step identified by name; caches and reuses its result across retries.
|
|
115
|
+
* @param name Unique checkpoint name for this step.
|
|
116
|
+
* @param fn Async function computing the step result (must be JSON-serializable).
|
|
94
117
|
*/
|
|
95
118
|
async step(name, fn) {
|
|
96
119
|
const checkpointName = this.getCheckpointName(name);
|
|
@@ -103,16 +126,17 @@ class TaskContext {
|
|
|
103
126
|
return rv;
|
|
104
127
|
}
|
|
105
128
|
/**
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
129
|
+
* Suspends the task until the given duration (seconds) elapses.
|
|
130
|
+
* @param stepName Checkpoint name for this wait.
|
|
131
|
+
* @param duration Duration to wait in seconds.
|
|
109
132
|
*/
|
|
110
133
|
async sleepFor(stepName, duration) {
|
|
111
134
|
return await this.sleepUntil(stepName, new Date(Date.now() + duration * 1000));
|
|
112
135
|
}
|
|
113
136
|
/**
|
|
114
|
-
*
|
|
115
|
-
*
|
|
137
|
+
* Suspends the task until the specified time.
|
|
138
|
+
* @param stepName Checkpoint name for this wait.
|
|
139
|
+
* @param wakeAt Absolute time when the task should resume.
|
|
116
140
|
*/
|
|
117
141
|
async sleepUntil(stepName, wakeAt) {
|
|
118
142
|
const checkpointName = this.getCheckpointName(stepName);
|
|
@@ -137,7 +161,7 @@ class TaskContext {
|
|
|
137
161
|
if (cached !== undefined) {
|
|
138
162
|
return cached;
|
|
139
163
|
}
|
|
140
|
-
const result = await this.
|
|
164
|
+
const result = await this.con.query(`SELECT checkpoint_name, state, status, owner_run_id, updated_at
|
|
141
165
|
FROM absurd.get_task_checkpoint_state($1, $2, $3)`, [this.queueName, this.task.task_id, checkpointName]);
|
|
142
166
|
if (result.rows.length > 0) {
|
|
143
167
|
const state = result.rows[0].state;
|
|
@@ -147,7 +171,7 @@ class TaskContext {
|
|
|
147
171
|
return undefined;
|
|
148
172
|
}
|
|
149
173
|
async persistCheckpoint(checkpointName, value) {
|
|
150
|
-
await this.
|
|
174
|
+
await this.queryWithCancelCheck(`SELECT absurd.set_task_checkpoint_state($1, $2, $3, $4, $5, $6)`, [
|
|
151
175
|
this.queueName,
|
|
152
176
|
this.task.task_id,
|
|
153
177
|
checkpointName,
|
|
@@ -158,15 +182,18 @@ class TaskContext {
|
|
|
158
182
|
this.checkpointCache.set(checkpointName, value);
|
|
159
183
|
}
|
|
160
184
|
async scheduleRun(wakeAt) {
|
|
161
|
-
await this.
|
|
185
|
+
await this.con.query(`SELECT absurd.schedule_run($1, $2, $3)`, [
|
|
162
186
|
this.queueName,
|
|
163
187
|
this.task.run_id,
|
|
164
188
|
wakeAt,
|
|
165
189
|
]);
|
|
166
190
|
}
|
|
167
191
|
/**
|
|
168
|
-
*
|
|
169
|
-
*
|
|
192
|
+
* Waits for an event by name and returns its payload; optionally sets a custom step name and timeout (seconds).
|
|
193
|
+
* @param eventName Event identifier to wait for.
|
|
194
|
+
* @param options.stepName Optional checkpoint name (defaults to $awaitEvent:<eventName>).
|
|
195
|
+
* @param options.timeout Optional timeout in seconds.
|
|
196
|
+
* @throws TimeoutError If the event is not received before the timeout.
|
|
170
197
|
*/
|
|
171
198
|
async awaitEvent(eventName, options) {
|
|
172
199
|
// the default step name is derived from the event name.
|
|
@@ -189,8 +216,8 @@ class TaskContext {
|
|
|
189
216
|
this.task.event_payload = null;
|
|
190
217
|
throw new TimeoutError(`Timed out waiting for event "${eventName}"`);
|
|
191
218
|
}
|
|
192
|
-
const result = await this.
|
|
193
|
-
|
|
219
|
+
const result = await this.queryWithCancelCheck(`SELECT should_suspend, payload
|
|
220
|
+
FROM absurd.await_event($1, $2, $3, $4, $5, $6)`, [
|
|
194
221
|
this.queueName,
|
|
195
222
|
this.task.task_id,
|
|
196
223
|
this.task.run_id,
|
|
@@ -210,61 +237,90 @@ class TaskContext {
|
|
|
210
237
|
throw new SuspendTask();
|
|
211
238
|
}
|
|
212
239
|
/**
|
|
213
|
-
*
|
|
240
|
+
* Extends the current run's lease by the given seconds (defaults to the original claim timeout).
|
|
241
|
+
* @param seconds Lease extension in seconds.
|
|
242
|
+
*/
|
|
243
|
+
async heartbeat(seconds) {
|
|
244
|
+
await this.queryWithCancelCheck(`SELECT absurd.extend_claim($1, $2, $3)`, [
|
|
245
|
+
this.queueName,
|
|
246
|
+
this.task.run_id,
|
|
247
|
+
seconds ?? this.claimTimeout,
|
|
248
|
+
]);
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Emits an event to this task's queue with an optional payload.
|
|
252
|
+
* @param eventName Non-empty event name.
|
|
253
|
+
* @param payload Optional JSON-serializable payload.
|
|
214
254
|
*/
|
|
215
255
|
async emitEvent(eventName, payload) {
|
|
216
256
|
if (!eventName) {
|
|
217
257
|
throw new Error("eventName must be a non-empty string");
|
|
218
258
|
}
|
|
219
|
-
await this.
|
|
259
|
+
await this.con.query(`SELECT absurd.emit_event($1, $2, $3)`, [
|
|
220
260
|
this.queueName,
|
|
221
261
|
eventName,
|
|
222
262
|
JSON.stringify(payload ?? null),
|
|
223
263
|
]);
|
|
224
264
|
}
|
|
225
|
-
async complete(result) {
|
|
226
|
-
await this.pool.query(`SELECT absurd.complete_run($1, $2, $3)`, [
|
|
227
|
-
this.queueName,
|
|
228
|
-
this.task.run_id,
|
|
229
|
-
JSON.stringify(result ?? null),
|
|
230
|
-
]);
|
|
231
|
-
}
|
|
232
|
-
async fail(err) {
|
|
233
|
-
console.error("[absurd] task execution failed:", err);
|
|
234
|
-
await this.pool.query(`SELECT absurd.fail_run($1, $2, $3, $4)`, [
|
|
235
|
-
this.queueName,
|
|
236
|
-
this.task.run_id,
|
|
237
|
-
JSON.stringify(serializeError(err)),
|
|
238
|
-
null,
|
|
239
|
-
]);
|
|
240
|
-
}
|
|
241
265
|
}
|
|
242
266
|
exports.TaskContext = TaskContext;
|
|
267
|
+
/**
|
|
268
|
+
* The Absurd SDK Client.
|
|
269
|
+
*
|
|
270
|
+
* Instanciate this class and keep it around to interact with Absurd.
|
|
271
|
+
*/
|
|
243
272
|
class Absurd {
|
|
244
|
-
|
|
273
|
+
con;
|
|
245
274
|
ownedPool;
|
|
246
275
|
queueName;
|
|
247
276
|
defaultMaxAttempts;
|
|
248
277
|
registry = new Map();
|
|
278
|
+
log;
|
|
249
279
|
worker = null;
|
|
250
|
-
constructor(
|
|
251
|
-
if (
|
|
252
|
-
|
|
280
|
+
constructor(options = {}) {
|
|
281
|
+
if (typeof options === "string" || isQueryable(options)) {
|
|
282
|
+
options = { db: options };
|
|
283
|
+
}
|
|
284
|
+
let connectionOrUrl = options.db;
|
|
285
|
+
if (!connectionOrUrl) {
|
|
286
|
+
connectionOrUrl =
|
|
253
287
|
process.env.ABSURD_DATABASE_URL || "postgresql://localhost/absurd";
|
|
254
288
|
}
|
|
255
|
-
if (typeof
|
|
256
|
-
this.
|
|
289
|
+
if (typeof connectionOrUrl === "string") {
|
|
290
|
+
this.con = new pg.Pool({ connectionString: connectionOrUrl });
|
|
257
291
|
this.ownedPool = true;
|
|
258
292
|
}
|
|
259
293
|
else {
|
|
260
|
-
this.
|
|
294
|
+
this.con = connectionOrUrl;
|
|
261
295
|
this.ownedPool = false;
|
|
262
296
|
}
|
|
263
|
-
this.queueName = queueName;
|
|
264
|
-
this.defaultMaxAttempts = defaultMaxAttempts;
|
|
297
|
+
this.queueName = options?.queueName ?? "default";
|
|
298
|
+
this.defaultMaxAttempts = options?.defaultMaxAttempts ?? 5;
|
|
299
|
+
this.log = options?.log ?? console;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Returns a new client that uses the provided connection for queries; set owned=true to close it with close().
|
|
303
|
+
* @param con Connection to bind to.
|
|
304
|
+
* @param owned If true, the bound client will close this connection on close().
|
|
305
|
+
*/
|
|
306
|
+
bindToConnection(con, owned = false) {
|
|
307
|
+
const bound = new Absurd({
|
|
308
|
+
db: con, // this is okay because we ensure the invariant later
|
|
309
|
+
queueName: this.queueName,
|
|
310
|
+
defaultMaxAttempts: this.defaultMaxAttempts,
|
|
311
|
+
log: this.log,
|
|
312
|
+
});
|
|
313
|
+
bound.registry = this.registry;
|
|
314
|
+
bound.ownedPool = owned;
|
|
315
|
+
return bound;
|
|
265
316
|
}
|
|
266
317
|
/**
|
|
267
|
-
*
|
|
318
|
+
* Registers a task handler by name (optionally specifying queue, defaultMaxAttempts, and defaultCancellation).
|
|
319
|
+
* @param options.name Task name.
|
|
320
|
+
* @param options.queue Optional queue name (defaults to client queue).
|
|
321
|
+
* @param options.defaultMaxAttempts Optional default max attempts.
|
|
322
|
+
* @param options.defaultCancellation Optional default cancellation policy.
|
|
323
|
+
* @param handler Async task handler.
|
|
268
324
|
*/
|
|
269
325
|
registerTask(options, handler) {
|
|
270
326
|
if (!options?.name) {
|
|
@@ -289,16 +345,28 @@ class Absurd {
|
|
|
289
345
|
handler: handler,
|
|
290
346
|
});
|
|
291
347
|
}
|
|
348
|
+
/**
|
|
349
|
+
* Creates a queue (defaults to this client's queue).
|
|
350
|
+
* @param queueName Queue name to create.
|
|
351
|
+
*/
|
|
292
352
|
async createQueue(queueName) {
|
|
293
353
|
const queue = queueName ?? this.queueName;
|
|
294
|
-
await this.
|
|
354
|
+
await this.con.query(`SELECT absurd.create_queue($1)`, [queue]);
|
|
295
355
|
}
|
|
356
|
+
/**
|
|
357
|
+
* Drops a queue (defaults to this client's queue).
|
|
358
|
+
* @param queueName Queue name to drop.
|
|
359
|
+
*/
|
|
296
360
|
async dropQueue(queueName) {
|
|
297
361
|
const queue = queueName ?? this.queueName;
|
|
298
|
-
await this.
|
|
362
|
+
await this.con.query(`SELECT absurd.drop_queue($1)`, [queue]);
|
|
299
363
|
}
|
|
364
|
+
/**
|
|
365
|
+
* Lists all queue names.
|
|
366
|
+
* @returns Array of queue names.
|
|
367
|
+
*/
|
|
300
368
|
async listQueues() {
|
|
301
|
-
const result = await this.
|
|
369
|
+
const result = await this.con.query(`SELECT * FROM absurd.list_queues()`);
|
|
302
370
|
const rv = [];
|
|
303
371
|
for (const row of result.rows) {
|
|
304
372
|
rv.push(row.queue_name);
|
|
@@ -306,7 +374,17 @@ class Absurd {
|
|
|
306
374
|
return rv;
|
|
307
375
|
}
|
|
308
376
|
/**
|
|
309
|
-
* Spawns a
|
|
377
|
+
* Spawns a task execution by enqueueing it for processing. The task will be picked up by a worker
|
|
378
|
+
* and executed with the provided parameters. Returns identifiers that can be used to track or cancel the task.
|
|
379
|
+
*
|
|
380
|
+
* For registered tasks, the queue and defaults are inferred from registration. For unregistered tasks,
|
|
381
|
+
* you must provide options.queue.
|
|
382
|
+
*
|
|
383
|
+
* @param taskName Name of the task to spawn (must be registered or provide options.queue).
|
|
384
|
+
* @param params JSON-serializable parameters passed to the task handler.
|
|
385
|
+
* @param options Configure queue, maxAttempts, retryStrategy, headers, and cancellation policies.
|
|
386
|
+
* @returns Object containing taskID (unique task identifier), runID (current attempt identifier), and attempt number.
|
|
387
|
+
* @throws Error If the task is unregistered without a queue, or if the queue mismatches registration.
|
|
310
388
|
*/
|
|
311
389
|
async spawn(taskName, params, options = {}) {
|
|
312
390
|
const registration = this.registry.get(taskName);
|
|
@@ -334,7 +412,7 @@ class Absurd {
|
|
|
334
412
|
maxAttempts: effectiveMaxAttempts,
|
|
335
413
|
cancellation: effectiveCancellation,
|
|
336
414
|
});
|
|
337
|
-
const result = await this.
|
|
415
|
+
const result = await this.con.query(`SELECT task_id, run_id, attempt
|
|
338
416
|
FROM absurd.spawn_task($1, $2, $3, $4)`, [
|
|
339
417
|
queue,
|
|
340
418
|
taskName,
|
|
@@ -352,28 +430,45 @@ class Absurd {
|
|
|
352
430
|
};
|
|
353
431
|
}
|
|
354
432
|
/**
|
|
355
|
-
* Emits an event
|
|
433
|
+
* Emits an event with an optional payload on the specified or default queue.
|
|
434
|
+
* @param eventName Non-empty event name.
|
|
435
|
+
* @param payload Optional JSON-serializable payload.
|
|
436
|
+
* @param queueName Queue to emit to (defaults to this client's queue).
|
|
356
437
|
*/
|
|
357
438
|
async emitEvent(eventName, payload, queueName) {
|
|
358
439
|
if (!eventName) {
|
|
359
440
|
throw new Error("eventName must be a non-empty string");
|
|
360
441
|
}
|
|
361
|
-
await this.
|
|
442
|
+
await this.con.query(`SELECT absurd.emit_event($1, $2, $3)`, [
|
|
362
443
|
queueName || this.queueName,
|
|
363
444
|
eventName,
|
|
364
445
|
JSON.stringify(payload ?? null),
|
|
365
446
|
]);
|
|
366
447
|
}
|
|
448
|
+
/**
|
|
449
|
+
* Cancels a task by ID on the specified or default queue; running tasks stop at the next checkpoint/heartbeat.
|
|
450
|
+
* @param taskID Task identifier to cancel.
|
|
451
|
+
* @param queueName Queue name (defaults to this client's queue).
|
|
452
|
+
*/
|
|
453
|
+
async cancelTask(taskID, queueName) {
|
|
454
|
+
await this.con.query(`SELECT absurd.cancel_task($1, $2)`, [
|
|
455
|
+
queueName || this.queueName,
|
|
456
|
+
taskID,
|
|
457
|
+
]);
|
|
458
|
+
}
|
|
367
459
|
async claimTasks(options) {
|
|
368
460
|
const { batchSize: count = 1, claimTimeout = 120, workerId = "worker", } = options ?? {};
|
|
369
|
-
const result = await this.
|
|
461
|
+
const result = await this.con.query(`SELECT run_id, task_id, attempt, task_name, params, retry_strategy, max_attempts,
|
|
370
462
|
headers, wake_event, event_payload
|
|
371
463
|
FROM absurd.claim_task($1, $2, $3, $4)`, [this.queueName, workerId, claimTimeout, count]);
|
|
372
464
|
return result.rows;
|
|
373
465
|
}
|
|
374
466
|
/**
|
|
375
|
-
*
|
|
376
|
-
*
|
|
467
|
+
* Claims up to batchSize tasks and processes them sequentially using the given workerId and claimTimeout.
|
|
468
|
+
* @param workerId Worker identifier.
|
|
469
|
+
* @param claimTimeout Lease duration in seconds.
|
|
470
|
+
* @param batchSize Maximum number of tasks to process.
|
|
471
|
+
* Note: For parallel processing, use startWorker().
|
|
377
472
|
*/
|
|
378
473
|
async workBatch(workerId = "worker", claimTimeout = 120, batchSize = 1) {
|
|
379
474
|
const tasks = await this.claimTasks({ batchSize, claimTimeout, workerId });
|
|
@@ -382,18 +477,37 @@ class Absurd {
|
|
|
382
477
|
}
|
|
383
478
|
}
|
|
384
479
|
/**
|
|
385
|
-
* Starts a worker that continuously polls for
|
|
386
|
-
*
|
|
480
|
+
* Starts a background worker that continuously polls for and processes tasks from the queue.
|
|
481
|
+
* The worker will claim tasks up to the configured concurrency limit and process them in parallel.
|
|
482
|
+
*
|
|
483
|
+
* Tasks are claimed with a lease (claimTimeout) that prevents other workers from processing them.
|
|
484
|
+
* The lease is automatically extended when tasks write checkpoints or call heartbeat(). If a worker
|
|
485
|
+
* crashes or stops making progress, the lease expires and another worker can claim the task.
|
|
486
|
+
*
|
|
487
|
+
* @param options Configure worker behavior:
|
|
488
|
+
* - concurrency: Max parallel tasks (default: 1)
|
|
489
|
+
* - claimTimeout: Task lease duration in seconds (default: 120)
|
|
490
|
+
* - batchSize: Tasks to claim per poll (default: concurrency)
|
|
491
|
+
* - pollInterval: Seconds between polls when idle (default: 0.25)
|
|
492
|
+
* - workerId: Worker identifier for tracking (default: hostname:pid)
|
|
493
|
+
* - onError: Error handler called for execution failures
|
|
494
|
+
* - fatalOnLeaseTimeout: Terminate process if task exceeds 2x claimTimeout (default: true)
|
|
495
|
+
* @returns Worker instance with close() method for graceful shutdown.
|
|
387
496
|
*/
|
|
388
497
|
async startWorker(options = {}) {
|
|
389
|
-
const { workerId = `${os.hostname?.() || "host"}:${process.pid}`, claimTimeout = 120, concurrency = 1, batchSize, pollInterval = 0.25, onError = (err) => console.error("
|
|
498
|
+
const { workerId = `${os.hostname?.() || "host"}:${process.pid}`, claimTimeout = 120, concurrency = 1, batchSize, pollInterval = 0.25, onError = (err) => console.error("Worker error:", err), fatalOnLeaseTimeout = true, } = options;
|
|
390
499
|
const effectiveBatchSize = batchSize ?? concurrency;
|
|
391
500
|
let running = true;
|
|
392
501
|
let workerLoopPromise;
|
|
393
502
|
const executing = new Set();
|
|
394
503
|
let availabilityPromise = null;
|
|
395
504
|
let availabilityResolve = null;
|
|
505
|
+
let sleepTimer = null;
|
|
396
506
|
const notifyAvailability = () => {
|
|
507
|
+
if (sleepTimer) {
|
|
508
|
+
clearTimeout(sleepTimer);
|
|
509
|
+
sleepTimer = null;
|
|
510
|
+
}
|
|
397
511
|
if (availabilityResolve) {
|
|
398
512
|
availabilityResolve();
|
|
399
513
|
availabilityResolve = null;
|
|
@@ -401,16 +515,18 @@ class Absurd {
|
|
|
401
515
|
}
|
|
402
516
|
};
|
|
403
517
|
const waitForAvailability = async () => {
|
|
404
|
-
if (executing.size === 0) {
|
|
405
|
-
await sleep(pollInterval);
|
|
406
|
-
return;
|
|
407
|
-
}
|
|
408
518
|
if (!availabilityPromise) {
|
|
409
519
|
availabilityPromise = new Promise((resolve) => {
|
|
410
520
|
availabilityResolve = resolve;
|
|
521
|
+
sleepTimer = setTimeout(() => {
|
|
522
|
+
sleepTimer = null;
|
|
523
|
+
availabilityResolve = null;
|
|
524
|
+
availabilityPromise = null;
|
|
525
|
+
resolve();
|
|
526
|
+
}, pollInterval * 1000);
|
|
411
527
|
});
|
|
412
528
|
}
|
|
413
|
-
await
|
|
529
|
+
await availabilityPromise;
|
|
414
530
|
};
|
|
415
531
|
const worker = {
|
|
416
532
|
close: async () => {
|
|
@@ -462,12 +578,15 @@ class Absurd {
|
|
|
462
578
|
})();
|
|
463
579
|
return worker;
|
|
464
580
|
}
|
|
581
|
+
/**
|
|
582
|
+
* Stops any running worker and closes the underlying pool if owned.
|
|
583
|
+
*/
|
|
465
584
|
async close() {
|
|
466
585
|
if (this.worker) {
|
|
467
586
|
await this.worker.close();
|
|
468
587
|
}
|
|
469
588
|
if (this.ownedPool) {
|
|
470
|
-
await this.
|
|
589
|
+
await this.con.end();
|
|
471
590
|
}
|
|
472
591
|
}
|
|
473
592
|
async executeTask(task, claimTimeout, options) {
|
|
@@ -475,8 +594,9 @@ class Absurd {
|
|
|
475
594
|
let fatalTimer;
|
|
476
595
|
const registration = this.registry.get(task.task_name);
|
|
477
596
|
const ctx = await TaskContext.create({
|
|
597
|
+
log: this.log,
|
|
478
598
|
taskID: task.task_id,
|
|
479
|
-
|
|
599
|
+
con: this.con,
|
|
480
600
|
queueName: registration?.queue ?? "unknown",
|
|
481
601
|
task: task,
|
|
482
602
|
claimTimeout,
|
|
@@ -485,11 +605,11 @@ class Absurd {
|
|
|
485
605
|
if (claimTimeout > 0) {
|
|
486
606
|
const taskLabel = `${task.task_name} (${task.task_id})`;
|
|
487
607
|
warnTimer = setTimeout(() => {
|
|
488
|
-
|
|
608
|
+
this.log.warn(`task ${taskLabel} exceeded claim timeout of ${claimTimeout}s`);
|
|
489
609
|
}, claimTimeout * 1000);
|
|
490
610
|
if (options?.fatalOnLeaseTimeout) {
|
|
491
611
|
fatalTimer = setTimeout(() => {
|
|
492
|
-
|
|
612
|
+
this.log.error(`task ${taskLabel} exceeded claim timeout of ${claimTimeout}s by more than 100%; terminating process`);
|
|
493
613
|
process.exit(1);
|
|
494
614
|
}, claimTimeout * 1000 * 2);
|
|
495
615
|
}
|
|
@@ -501,14 +621,15 @@ class Absurd {
|
|
|
501
621
|
throw new Error("Misconfigured task (queue mismatch)");
|
|
502
622
|
}
|
|
503
623
|
const result = await registration.handler(task.params, ctx);
|
|
504
|
-
await
|
|
624
|
+
await completeTaskRun(this.con, this.queueName, task.run_id, result);
|
|
505
625
|
}
|
|
506
626
|
catch (err) {
|
|
507
|
-
if (err instanceof SuspendTask) {
|
|
508
|
-
// Task suspended (sleep or await), don't complete or fail
|
|
627
|
+
if (err instanceof SuspendTask || err instanceof CancelledTask) {
|
|
628
|
+
// Task suspended or cancelled (sleep or await), don't complete or fail
|
|
509
629
|
return;
|
|
510
630
|
}
|
|
511
|
-
|
|
631
|
+
this.log.error("[absurd] task execution failed:", err);
|
|
632
|
+
await failTaskRun(this.con, this.queueName, task.run_id, err);
|
|
512
633
|
}
|
|
513
634
|
finally {
|
|
514
635
|
if (warnTimer) {
|
|
@@ -521,6 +642,11 @@ class Absurd {
|
|
|
521
642
|
}
|
|
522
643
|
}
|
|
523
644
|
exports.Absurd = Absurd;
|
|
645
|
+
function isQueryable(value) {
|
|
646
|
+
return (typeof value === "object" &&
|
|
647
|
+
value !== null &&
|
|
648
|
+
typeof value.query === "function");
|
|
649
|
+
}
|
|
524
650
|
function serializeError(err) {
|
|
525
651
|
if (err instanceof Error) {
|
|
526
652
|
return {
|
|
@@ -531,6 +657,21 @@ function serializeError(err) {
|
|
|
531
657
|
}
|
|
532
658
|
return { message: String(err) };
|
|
533
659
|
}
|
|
660
|
+
async function completeTaskRun(con, queueName, runID, result) {
|
|
661
|
+
await con.query(`SELECT absurd.complete_run($1, $2, $3)`, [
|
|
662
|
+
queueName,
|
|
663
|
+
runID,
|
|
664
|
+
JSON.stringify(result ?? null),
|
|
665
|
+
]);
|
|
666
|
+
}
|
|
667
|
+
async function failTaskRun(con, queueName, runID, err) {
|
|
668
|
+
await con.query(`SELECT absurd.fail_run($1, $2, $3, $4)`, [
|
|
669
|
+
queueName,
|
|
670
|
+
runID,
|
|
671
|
+
JSON.stringify(serializeError(err)),
|
|
672
|
+
null,
|
|
673
|
+
]);
|
|
674
|
+
}
|
|
534
675
|
function normalizeSpawnOptions(options) {
|
|
535
676
|
const normalized = {};
|
|
536
677
|
if (options.headers !== undefined) {
|
|
@@ -576,6 +717,3 @@ function normalizeCancellation(policy) {
|
|
|
576
717
|
}
|
|
577
718
|
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
|
578
719
|
}
|
|
579
|
-
async function sleep(ms) {
|
|
580
|
-
return new Promise((resolve) => setTimeout(resolve, ms * 1000));
|
|
581
|
-
}
|