absurd-sdk 0.0.3 → 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 +16 -18
- package/dist/cjs/index.js +277 -85
- package/dist/index.d.ts +131 -29
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +275 -84
- package/dist/index.js.map +1 -1
- package/package.json +14 -7
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;
|
|
265
300
|
}
|
|
266
301
|
/**
|
|
267
|
-
*
|
|
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;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
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,25 +345,46 @@ 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
|
-
console.log(result);
|
|
304
371
|
for (const row of result.rows) {
|
|
305
372
|
rv.push(row.queue_name);
|
|
306
373
|
}
|
|
307
374
|
return rv;
|
|
308
375
|
}
|
|
309
376
|
/**
|
|
310
|
-
* 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.
|
|
311
388
|
*/
|
|
312
389
|
async spawn(taskName, params, options = {}) {
|
|
313
390
|
const registration = this.registry.get(taskName);
|
|
@@ -335,7 +412,7 @@ class Absurd {
|
|
|
335
412
|
maxAttempts: effectiveMaxAttempts,
|
|
336
413
|
cancellation: effectiveCancellation,
|
|
337
414
|
});
|
|
338
|
-
const result = await this.
|
|
415
|
+
const result = await this.con.query(`SELECT task_id, run_id, attempt
|
|
339
416
|
FROM absurd.spawn_task($1, $2, $3, $4)`, [
|
|
340
417
|
queue,
|
|
341
418
|
taskName,
|
|
@@ -353,28 +430,45 @@ class Absurd {
|
|
|
353
430
|
};
|
|
354
431
|
}
|
|
355
432
|
/**
|
|
356
|
-
* 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).
|
|
357
437
|
*/
|
|
358
438
|
async emitEvent(eventName, payload, queueName) {
|
|
359
439
|
if (!eventName) {
|
|
360
440
|
throw new Error("eventName must be a non-empty string");
|
|
361
441
|
}
|
|
362
|
-
await this.
|
|
442
|
+
await this.con.query(`SELECT absurd.emit_event($1, $2, $3)`, [
|
|
363
443
|
queueName || this.queueName,
|
|
364
444
|
eventName,
|
|
365
445
|
JSON.stringify(payload ?? null),
|
|
366
446
|
]);
|
|
367
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
|
+
}
|
|
368
459
|
async claimTasks(options) {
|
|
369
460
|
const { batchSize: count = 1, claimTimeout = 120, workerId = "worker", } = options ?? {};
|
|
370
|
-
const result = await this.
|
|
461
|
+
const result = await this.con.query(`SELECT run_id, task_id, attempt, task_name, params, retry_strategy, max_attempts,
|
|
371
462
|
headers, wake_event, event_payload
|
|
372
463
|
FROM absurd.claim_task($1, $2, $3, $4)`, [this.queueName, workerId, claimTimeout, count]);
|
|
373
464
|
return result.rows;
|
|
374
465
|
}
|
|
375
466
|
/**
|
|
376
|
-
*
|
|
377
|
-
*
|
|
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().
|
|
378
472
|
*/
|
|
379
473
|
async workBatch(workerId = "worker", claimTimeout = 120, batchSize = 1) {
|
|
380
474
|
const tasks = await this.claimTasks({ batchSize, claimTimeout, workerId });
|
|
@@ -383,14 +477,57 @@ class Absurd {
|
|
|
383
477
|
}
|
|
384
478
|
}
|
|
385
479
|
/**
|
|
386
|
-
* Starts a worker that continuously polls for
|
|
387
|
-
*
|
|
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.
|
|
388
496
|
*/
|
|
389
497
|
async startWorker(options = {}) {
|
|
390
|
-
const { workerId = `${os.hostname?.() || "host"}:${process.pid}`, claimTimeout = 120, concurrency = 1, batchSize, pollInterval = 0.25, onError = (err) => console.error("Worker error:", err), } = options;
|
|
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;
|
|
391
499
|
const effectiveBatchSize = batchSize ?? concurrency;
|
|
392
500
|
let running = true;
|
|
393
501
|
let workerLoopPromise;
|
|
502
|
+
const executing = new Set();
|
|
503
|
+
let availabilityPromise = null;
|
|
504
|
+
let availabilityResolve = null;
|
|
505
|
+
let sleepTimer = null;
|
|
506
|
+
const notifyAvailability = () => {
|
|
507
|
+
if (sleepTimer) {
|
|
508
|
+
clearTimeout(sleepTimer);
|
|
509
|
+
sleepTimer = null;
|
|
510
|
+
}
|
|
511
|
+
if (availabilityResolve) {
|
|
512
|
+
availabilityResolve();
|
|
513
|
+
availabilityResolve = null;
|
|
514
|
+
availabilityPromise = null;
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
const waitForAvailability = async () => {
|
|
518
|
+
if (!availabilityPromise) {
|
|
519
|
+
availabilityPromise = new Promise((resolve) => {
|
|
520
|
+
availabilityResolve = resolve;
|
|
521
|
+
sleepTimer = setTimeout(() => {
|
|
522
|
+
sleepTimer = null;
|
|
523
|
+
availabilityResolve = null;
|
|
524
|
+
availabilityPromise = null;
|
|
525
|
+
resolve();
|
|
526
|
+
}, pollInterval * 1000);
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
await availabilityPromise;
|
|
530
|
+
};
|
|
394
531
|
const worker = {
|
|
395
532
|
close: async () => {
|
|
396
533
|
running = false;
|
|
@@ -401,53 +538,82 @@ class Absurd {
|
|
|
401
538
|
workerLoopPromise = (async () => {
|
|
402
539
|
while (running) {
|
|
403
540
|
try {
|
|
541
|
+
if (executing.size >= concurrency) {
|
|
542
|
+
await waitForAvailability();
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
const availableCapacity = Math.max(concurrency - executing.size, 0);
|
|
546
|
+
const toClaim = Math.min(effectiveBatchSize, availableCapacity);
|
|
547
|
+
if (toClaim <= 0) {
|
|
548
|
+
await waitForAvailability();
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
404
551
|
const messages = await this.claimTasks({
|
|
405
|
-
batchSize:
|
|
552
|
+
batchSize: toClaim,
|
|
406
553
|
claimTimeout: claimTimeout,
|
|
407
554
|
workerId,
|
|
408
555
|
});
|
|
409
556
|
if (messages.length === 0) {
|
|
410
|
-
await
|
|
557
|
+
await waitForAvailability();
|
|
411
558
|
continue;
|
|
412
559
|
}
|
|
413
|
-
const executing = new Set();
|
|
414
560
|
for (const task of messages) {
|
|
415
|
-
const promise = this.executeTask(task, claimTimeout
|
|
561
|
+
const promise = this.executeTask(task, claimTimeout, {
|
|
562
|
+
fatalOnLeaseTimeout,
|
|
563
|
+
})
|
|
416
564
|
.catch((err) => onError(err))
|
|
417
|
-
.finally(() =>
|
|
565
|
+
.finally(() => {
|
|
566
|
+
executing.delete(promise);
|
|
567
|
+
notifyAvailability();
|
|
568
|
+
});
|
|
418
569
|
executing.add(promise);
|
|
419
|
-
if (executing.size >= concurrency) {
|
|
420
|
-
await Promise.race(executing);
|
|
421
|
-
}
|
|
422
570
|
}
|
|
423
|
-
await Promise.all(executing);
|
|
424
571
|
}
|
|
425
572
|
catch (err) {
|
|
426
573
|
onError(err);
|
|
427
|
-
await
|
|
574
|
+
await waitForAvailability();
|
|
428
575
|
}
|
|
429
576
|
}
|
|
577
|
+
await Promise.allSettled(executing);
|
|
430
578
|
})();
|
|
431
579
|
return worker;
|
|
432
580
|
}
|
|
581
|
+
/**
|
|
582
|
+
* Stops any running worker and closes the underlying pool if owned.
|
|
583
|
+
*/
|
|
433
584
|
async close() {
|
|
434
585
|
if (this.worker) {
|
|
435
586
|
await this.worker.close();
|
|
436
587
|
}
|
|
437
588
|
if (this.ownedPool) {
|
|
438
|
-
await this.
|
|
589
|
+
await this.con.end();
|
|
439
590
|
}
|
|
440
591
|
}
|
|
441
|
-
async executeTask(task, claimTimeout) {
|
|
592
|
+
async executeTask(task, claimTimeout, options) {
|
|
593
|
+
let warnTimer;
|
|
594
|
+
let fatalTimer;
|
|
442
595
|
const registration = this.registry.get(task.task_name);
|
|
443
596
|
const ctx = await TaskContext.create({
|
|
597
|
+
log: this.log,
|
|
444
598
|
taskID: task.task_id,
|
|
445
|
-
|
|
599
|
+
con: this.con,
|
|
446
600
|
queueName: registration?.queue ?? "unknown",
|
|
447
601
|
task: task,
|
|
448
602
|
claimTimeout,
|
|
449
603
|
});
|
|
450
604
|
try {
|
|
605
|
+
if (claimTimeout > 0) {
|
|
606
|
+
const taskLabel = `${task.task_name} (${task.task_id})`;
|
|
607
|
+
warnTimer = setTimeout(() => {
|
|
608
|
+
this.log.warn(`task ${taskLabel} exceeded claim timeout of ${claimTimeout}s`);
|
|
609
|
+
}, claimTimeout * 1000);
|
|
610
|
+
if (options?.fatalOnLeaseTimeout) {
|
|
611
|
+
fatalTimer = setTimeout(() => {
|
|
612
|
+
this.log.error(`task ${taskLabel} exceeded claim timeout of ${claimTimeout}s by more than 100%; terminating process`);
|
|
613
|
+
process.exit(1);
|
|
614
|
+
}, claimTimeout * 1000 * 2);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
451
617
|
if (!registration) {
|
|
452
618
|
throw new Error("Unknown task");
|
|
453
619
|
}
|
|
@@ -455,18 +621,32 @@ class Absurd {
|
|
|
455
621
|
throw new Error("Misconfigured task (queue mismatch)");
|
|
456
622
|
}
|
|
457
623
|
const result = await registration.handler(task.params, ctx);
|
|
458
|
-
await
|
|
624
|
+
await completeTaskRun(this.con, this.queueName, task.run_id, result);
|
|
459
625
|
}
|
|
460
626
|
catch (err) {
|
|
461
|
-
if (err instanceof SuspendTask) {
|
|
462
|
-
// 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
|
|
463
629
|
return;
|
|
464
630
|
}
|
|
465
|
-
|
|
631
|
+
this.log.error("[absurd] task execution failed:", err);
|
|
632
|
+
await failTaskRun(this.con, this.queueName, task.run_id, err);
|
|
633
|
+
}
|
|
634
|
+
finally {
|
|
635
|
+
if (warnTimer) {
|
|
636
|
+
clearTimeout(warnTimer);
|
|
637
|
+
}
|
|
638
|
+
if (fatalTimer) {
|
|
639
|
+
clearTimeout(fatalTimer);
|
|
640
|
+
}
|
|
466
641
|
}
|
|
467
642
|
}
|
|
468
643
|
}
|
|
469
644
|
exports.Absurd = Absurd;
|
|
645
|
+
function isQueryable(value) {
|
|
646
|
+
return (typeof value === "object" &&
|
|
647
|
+
value !== null &&
|
|
648
|
+
typeof value.query === "function");
|
|
649
|
+
}
|
|
470
650
|
function serializeError(err) {
|
|
471
651
|
if (err instanceof Error) {
|
|
472
652
|
return {
|
|
@@ -477,6 +657,21 @@ function serializeError(err) {
|
|
|
477
657
|
}
|
|
478
658
|
return { message: String(err) };
|
|
479
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
|
+
}
|
|
480
675
|
function normalizeSpawnOptions(options) {
|
|
481
676
|
const normalized = {};
|
|
482
677
|
if (options.headers !== undefined) {
|
|
@@ -522,6 +717,3 @@ function normalizeCancellation(policy) {
|
|
|
522
717
|
}
|
|
523
718
|
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
|
524
719
|
}
|
|
525
|
-
async function sleep(ms) {
|
|
526
|
-
return new Promise((resolve) => setTimeout(resolve, ms * 1000));
|
|
527
|
-
}
|