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/index.js
CHANGED
|
@@ -13,6 +13,16 @@ export class SuspendTask extends Error {
|
|
|
13
13
|
this.name = "SuspendTask";
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Internal exception that is thrown to cancel a run. As a user
|
|
18
|
+
* you should never see this exception.
|
|
19
|
+
*/
|
|
20
|
+
export class CancelledTask extends Error {
|
|
21
|
+
constructor() {
|
|
22
|
+
super("Task cancelled");
|
|
23
|
+
this.name = "CancelledTask";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
16
26
|
/**
|
|
17
27
|
* This error is thrown when awaiting an event ran into a timeout.
|
|
18
28
|
*/
|
|
@@ -23,36 +33,48 @@ export class TimeoutError extends Error {
|
|
|
23
33
|
}
|
|
24
34
|
}
|
|
25
35
|
export class TaskContext {
|
|
36
|
+
log;
|
|
26
37
|
taskID;
|
|
27
|
-
|
|
38
|
+
con;
|
|
28
39
|
queueName;
|
|
29
40
|
task;
|
|
30
41
|
checkpointCache;
|
|
31
42
|
claimTimeout;
|
|
32
43
|
stepNameCounter = new Map();
|
|
33
|
-
constructor(taskID,
|
|
44
|
+
constructor(log, taskID, con, queueName, task, checkpointCache, claimTimeout) {
|
|
45
|
+
this.log = log;
|
|
34
46
|
this.taskID = taskID;
|
|
35
|
-
this.
|
|
47
|
+
this.con = con;
|
|
36
48
|
this.queueName = queueName;
|
|
37
49
|
this.task = task;
|
|
38
50
|
this.checkpointCache = checkpointCache;
|
|
39
51
|
this.claimTimeout = claimTimeout;
|
|
40
52
|
}
|
|
41
53
|
static async create(args) {
|
|
42
|
-
const { taskID,
|
|
43
|
-
const result = await
|
|
54
|
+
const { log, taskID, con, queueName, task, claimTimeout } = args;
|
|
55
|
+
const result = await con.query(`SELECT checkpoint_name, state, status, owner_run_id, updated_at
|
|
44
56
|
FROM absurd.get_task_checkpoint_states($1, $2, $3)`, [queueName, task.task_id, task.run_id]);
|
|
45
57
|
const cache = new Map();
|
|
46
58
|
for (const row of result.rows) {
|
|
47
59
|
cache.set(row.checkpoint_name, row.state);
|
|
48
60
|
}
|
|
49
|
-
return new TaskContext(taskID,
|
|
61
|
+
return new TaskContext(log, taskID, con, queueName, task, cache, claimTimeout);
|
|
62
|
+
}
|
|
63
|
+
async queryWithCancelCheck(sql, params) {
|
|
64
|
+
try {
|
|
65
|
+
return await this.con.query(sql, params);
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
if (err?.code === "AB001") {
|
|
69
|
+
throw new CancelledTask();
|
|
70
|
+
}
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
50
73
|
}
|
|
51
74
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* must support `JSON.stringify`.
|
|
75
|
+
* Runs an idempotent step identified by name; caches and reuses its result across retries.
|
|
76
|
+
* @param name Unique checkpoint name for this step.
|
|
77
|
+
* @param fn Async function computing the step result (must be JSON-serializable).
|
|
56
78
|
*/
|
|
57
79
|
async step(name, fn) {
|
|
58
80
|
const checkpointName = this.getCheckpointName(name);
|
|
@@ -65,16 +87,17 @@ export class TaskContext {
|
|
|
65
87
|
return rv;
|
|
66
88
|
}
|
|
67
89
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
90
|
+
* Suspends the task until the given duration (seconds) elapses.
|
|
91
|
+
* @param stepName Checkpoint name for this wait.
|
|
92
|
+
* @param duration Duration to wait in seconds.
|
|
71
93
|
*/
|
|
72
94
|
async sleepFor(stepName, duration) {
|
|
73
95
|
return await this.sleepUntil(stepName, new Date(Date.now() + duration * 1000));
|
|
74
96
|
}
|
|
75
97
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
98
|
+
* Suspends the task until the specified time.
|
|
99
|
+
* @param stepName Checkpoint name for this wait.
|
|
100
|
+
* @param wakeAt Absolute time when the task should resume.
|
|
78
101
|
*/
|
|
79
102
|
async sleepUntil(stepName, wakeAt) {
|
|
80
103
|
const checkpointName = this.getCheckpointName(stepName);
|
|
@@ -99,7 +122,7 @@ export class TaskContext {
|
|
|
99
122
|
if (cached !== undefined) {
|
|
100
123
|
return cached;
|
|
101
124
|
}
|
|
102
|
-
const result = await this.
|
|
125
|
+
const result = await this.con.query(`SELECT checkpoint_name, state, status, owner_run_id, updated_at
|
|
103
126
|
FROM absurd.get_task_checkpoint_state($1, $2, $3)`, [this.queueName, this.task.task_id, checkpointName]);
|
|
104
127
|
if (result.rows.length > 0) {
|
|
105
128
|
const state = result.rows[0].state;
|
|
@@ -109,7 +132,7 @@ export class TaskContext {
|
|
|
109
132
|
return undefined;
|
|
110
133
|
}
|
|
111
134
|
async persistCheckpoint(checkpointName, value) {
|
|
112
|
-
await this.
|
|
135
|
+
await this.queryWithCancelCheck(`SELECT absurd.set_task_checkpoint_state($1, $2, $3, $4, $5, $6)`, [
|
|
113
136
|
this.queueName,
|
|
114
137
|
this.task.task_id,
|
|
115
138
|
checkpointName,
|
|
@@ -120,15 +143,18 @@ export class TaskContext {
|
|
|
120
143
|
this.checkpointCache.set(checkpointName, value);
|
|
121
144
|
}
|
|
122
145
|
async scheduleRun(wakeAt) {
|
|
123
|
-
await this.
|
|
146
|
+
await this.con.query(`SELECT absurd.schedule_run($1, $2, $3)`, [
|
|
124
147
|
this.queueName,
|
|
125
148
|
this.task.run_id,
|
|
126
149
|
wakeAt,
|
|
127
150
|
]);
|
|
128
151
|
}
|
|
129
152
|
/**
|
|
130
|
-
*
|
|
131
|
-
*
|
|
153
|
+
* Waits for an event by name and returns its payload; optionally sets a custom step name and timeout (seconds).
|
|
154
|
+
* @param eventName Event identifier to wait for.
|
|
155
|
+
* @param options.stepName Optional checkpoint name (defaults to $awaitEvent:<eventName>).
|
|
156
|
+
* @param options.timeout Optional timeout in seconds.
|
|
157
|
+
* @throws TimeoutError If the event is not received before the timeout.
|
|
132
158
|
*/
|
|
133
159
|
async awaitEvent(eventName, options) {
|
|
134
160
|
// the default step name is derived from the event name.
|
|
@@ -151,8 +177,8 @@ export class TaskContext {
|
|
|
151
177
|
this.task.event_payload = null;
|
|
152
178
|
throw new TimeoutError(`Timed out waiting for event "${eventName}"`);
|
|
153
179
|
}
|
|
154
|
-
const result = await this.
|
|
155
|
-
|
|
180
|
+
const result = await this.queryWithCancelCheck(`SELECT should_suspend, payload
|
|
181
|
+
FROM absurd.await_event($1, $2, $3, $4, $5, $6)`, [
|
|
156
182
|
this.queueName,
|
|
157
183
|
this.task.task_id,
|
|
158
184
|
this.task.run_id,
|
|
@@ -172,60 +198,89 @@ export class TaskContext {
|
|
|
172
198
|
throw new SuspendTask();
|
|
173
199
|
}
|
|
174
200
|
/**
|
|
175
|
-
*
|
|
201
|
+
* Extends the current run's lease by the given seconds (defaults to the original claim timeout).
|
|
202
|
+
* @param seconds Lease extension in seconds.
|
|
203
|
+
*/
|
|
204
|
+
async heartbeat(seconds) {
|
|
205
|
+
await this.queryWithCancelCheck(`SELECT absurd.extend_claim($1, $2, $3)`, [
|
|
206
|
+
this.queueName,
|
|
207
|
+
this.task.run_id,
|
|
208
|
+
seconds ?? this.claimTimeout,
|
|
209
|
+
]);
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Emits an event to this task's queue with an optional payload.
|
|
213
|
+
* @param eventName Non-empty event name.
|
|
214
|
+
* @param payload Optional JSON-serializable payload.
|
|
176
215
|
*/
|
|
177
216
|
async emitEvent(eventName, payload) {
|
|
178
217
|
if (!eventName) {
|
|
179
218
|
throw new Error("eventName must be a non-empty string");
|
|
180
219
|
}
|
|
181
|
-
await this.
|
|
220
|
+
await this.con.query(`SELECT absurd.emit_event($1, $2, $3)`, [
|
|
182
221
|
this.queueName,
|
|
183
222
|
eventName,
|
|
184
223
|
JSON.stringify(payload ?? null),
|
|
185
224
|
]);
|
|
186
225
|
}
|
|
187
|
-
async complete(result) {
|
|
188
|
-
await this.pool.query(`SELECT absurd.complete_run($1, $2, $3)`, [
|
|
189
|
-
this.queueName,
|
|
190
|
-
this.task.run_id,
|
|
191
|
-
JSON.stringify(result ?? null),
|
|
192
|
-
]);
|
|
193
|
-
}
|
|
194
|
-
async fail(err) {
|
|
195
|
-
console.error("[absurd] task execution failed:", err);
|
|
196
|
-
await this.pool.query(`SELECT absurd.fail_run($1, $2, $3, $4)`, [
|
|
197
|
-
this.queueName,
|
|
198
|
-
this.task.run_id,
|
|
199
|
-
JSON.stringify(serializeError(err)),
|
|
200
|
-
null,
|
|
201
|
-
]);
|
|
202
|
-
}
|
|
203
226
|
}
|
|
227
|
+
/**
|
|
228
|
+
* The Absurd SDK Client.
|
|
229
|
+
*
|
|
230
|
+
* Instanciate this class and keep it around to interact with Absurd.
|
|
231
|
+
*/
|
|
204
232
|
export class Absurd {
|
|
205
|
-
|
|
233
|
+
con;
|
|
206
234
|
ownedPool;
|
|
207
235
|
queueName;
|
|
208
236
|
defaultMaxAttempts;
|
|
209
237
|
registry = new Map();
|
|
238
|
+
log;
|
|
210
239
|
worker = null;
|
|
211
|
-
constructor(
|
|
212
|
-
if (
|
|
213
|
-
|
|
240
|
+
constructor(options = {}) {
|
|
241
|
+
if (typeof options === "string" || isQueryable(options)) {
|
|
242
|
+
options = { db: options };
|
|
243
|
+
}
|
|
244
|
+
let connectionOrUrl = options.db;
|
|
245
|
+
if (!connectionOrUrl) {
|
|
246
|
+
connectionOrUrl =
|
|
214
247
|
process.env.ABSURD_DATABASE_URL || "postgresql://localhost/absurd";
|
|
215
248
|
}
|
|
216
|
-
if (typeof
|
|
217
|
-
this.
|
|
249
|
+
if (typeof connectionOrUrl === "string") {
|
|
250
|
+
this.con = new pg.Pool({ connectionString: connectionOrUrl });
|
|
218
251
|
this.ownedPool = true;
|
|
219
252
|
}
|
|
220
253
|
else {
|
|
221
|
-
this.
|
|
254
|
+
this.con = connectionOrUrl;
|
|
222
255
|
this.ownedPool = false;
|
|
223
256
|
}
|
|
224
|
-
this.queueName = queueName;
|
|
225
|
-
this.defaultMaxAttempts = defaultMaxAttempts;
|
|
257
|
+
this.queueName = options?.queueName ?? "default";
|
|
258
|
+
this.defaultMaxAttempts = options?.defaultMaxAttempts ?? 5;
|
|
259
|
+
this.log = options?.log ?? console;
|
|
226
260
|
}
|
|
227
261
|
/**
|
|
228
|
-
*
|
|
262
|
+
* Returns a new client that uses the provided connection for queries; set owned=true to close it with close().
|
|
263
|
+
* @param con Connection to bind to.
|
|
264
|
+
* @param owned If true, the bound client will close this connection on close().
|
|
265
|
+
*/
|
|
266
|
+
bindToConnection(con, owned = false) {
|
|
267
|
+
const bound = new Absurd({
|
|
268
|
+
db: con, // this is okay because we ensure the invariant later
|
|
269
|
+
queueName: this.queueName,
|
|
270
|
+
defaultMaxAttempts: this.defaultMaxAttempts,
|
|
271
|
+
log: this.log,
|
|
272
|
+
});
|
|
273
|
+
bound.registry = this.registry;
|
|
274
|
+
bound.ownedPool = owned;
|
|
275
|
+
return bound;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Registers a task handler by name (optionally specifying queue, defaultMaxAttempts, and defaultCancellation).
|
|
279
|
+
* @param options.name Task name.
|
|
280
|
+
* @param options.queue Optional queue name (defaults to client queue).
|
|
281
|
+
* @param options.defaultMaxAttempts Optional default max attempts.
|
|
282
|
+
* @param options.defaultCancellation Optional default cancellation policy.
|
|
283
|
+
* @param handler Async task handler.
|
|
229
284
|
*/
|
|
230
285
|
registerTask(options, handler) {
|
|
231
286
|
if (!options?.name) {
|
|
@@ -250,25 +305,46 @@ export class Absurd {
|
|
|
250
305
|
handler: handler,
|
|
251
306
|
});
|
|
252
307
|
}
|
|
308
|
+
/**
|
|
309
|
+
* Creates a queue (defaults to this client's queue).
|
|
310
|
+
* @param queueName Queue name to create.
|
|
311
|
+
*/
|
|
253
312
|
async createQueue(queueName) {
|
|
254
313
|
const queue = queueName ?? this.queueName;
|
|
255
|
-
await this.
|
|
314
|
+
await this.con.query(`SELECT absurd.create_queue($1)`, [queue]);
|
|
256
315
|
}
|
|
316
|
+
/**
|
|
317
|
+
* Drops a queue (defaults to this client's queue).
|
|
318
|
+
* @param queueName Queue name to drop.
|
|
319
|
+
*/
|
|
257
320
|
async dropQueue(queueName) {
|
|
258
321
|
const queue = queueName ?? this.queueName;
|
|
259
|
-
await this.
|
|
322
|
+
await this.con.query(`SELECT absurd.drop_queue($1)`, [queue]);
|
|
260
323
|
}
|
|
324
|
+
/**
|
|
325
|
+
* Lists all queue names.
|
|
326
|
+
* @returns Array of queue names.
|
|
327
|
+
*/
|
|
261
328
|
async listQueues() {
|
|
262
|
-
const result = await this.
|
|
329
|
+
const result = await this.con.query(`SELECT * FROM absurd.list_queues()`);
|
|
263
330
|
const rv = [];
|
|
264
|
-
console.log(result);
|
|
265
331
|
for (const row of result.rows) {
|
|
266
332
|
rv.push(row.queue_name);
|
|
267
333
|
}
|
|
268
334
|
return rv;
|
|
269
335
|
}
|
|
270
336
|
/**
|
|
271
|
-
* Spawns a
|
|
337
|
+
* Spawns a task execution by enqueueing it for processing. The task will be picked up by a worker
|
|
338
|
+
* and executed with the provided parameters. Returns identifiers that can be used to track or cancel the task.
|
|
339
|
+
*
|
|
340
|
+
* For registered tasks, the queue and defaults are inferred from registration. For unregistered tasks,
|
|
341
|
+
* you must provide options.queue.
|
|
342
|
+
*
|
|
343
|
+
* @param taskName Name of the task to spawn (must be registered or provide options.queue).
|
|
344
|
+
* @param params JSON-serializable parameters passed to the task handler.
|
|
345
|
+
* @param options Configure queue, maxAttempts, retryStrategy, headers, and cancellation policies.
|
|
346
|
+
* @returns Object containing taskID (unique task identifier), runID (current attempt identifier), and attempt number.
|
|
347
|
+
* @throws Error If the task is unregistered without a queue, or if the queue mismatches registration.
|
|
272
348
|
*/
|
|
273
349
|
async spawn(taskName, params, options = {}) {
|
|
274
350
|
const registration = this.registry.get(taskName);
|
|
@@ -296,7 +372,7 @@ export class Absurd {
|
|
|
296
372
|
maxAttempts: effectiveMaxAttempts,
|
|
297
373
|
cancellation: effectiveCancellation,
|
|
298
374
|
});
|
|
299
|
-
const result = await this.
|
|
375
|
+
const result = await this.con.query(`SELECT task_id, run_id, attempt
|
|
300
376
|
FROM absurd.spawn_task($1, $2, $3, $4)`, [
|
|
301
377
|
queue,
|
|
302
378
|
taskName,
|
|
@@ -314,28 +390,45 @@ export class Absurd {
|
|
|
314
390
|
};
|
|
315
391
|
}
|
|
316
392
|
/**
|
|
317
|
-
* Emits an event
|
|
393
|
+
* Emits an event with an optional payload on the specified or default queue.
|
|
394
|
+
* @param eventName Non-empty event name.
|
|
395
|
+
* @param payload Optional JSON-serializable payload.
|
|
396
|
+
* @param queueName Queue to emit to (defaults to this client's queue).
|
|
318
397
|
*/
|
|
319
398
|
async emitEvent(eventName, payload, queueName) {
|
|
320
399
|
if (!eventName) {
|
|
321
400
|
throw new Error("eventName must be a non-empty string");
|
|
322
401
|
}
|
|
323
|
-
await this.
|
|
402
|
+
await this.con.query(`SELECT absurd.emit_event($1, $2, $3)`, [
|
|
324
403
|
queueName || this.queueName,
|
|
325
404
|
eventName,
|
|
326
405
|
JSON.stringify(payload ?? null),
|
|
327
406
|
]);
|
|
328
407
|
}
|
|
408
|
+
/**
|
|
409
|
+
* Cancels a task by ID on the specified or default queue; running tasks stop at the next checkpoint/heartbeat.
|
|
410
|
+
* @param taskID Task identifier to cancel.
|
|
411
|
+
* @param queueName Queue name (defaults to this client's queue).
|
|
412
|
+
*/
|
|
413
|
+
async cancelTask(taskID, queueName) {
|
|
414
|
+
await this.con.query(`SELECT absurd.cancel_task($1, $2)`, [
|
|
415
|
+
queueName || this.queueName,
|
|
416
|
+
taskID,
|
|
417
|
+
]);
|
|
418
|
+
}
|
|
329
419
|
async claimTasks(options) {
|
|
330
420
|
const { batchSize: count = 1, claimTimeout = 120, workerId = "worker", } = options ?? {};
|
|
331
|
-
const result = await this.
|
|
421
|
+
const result = await this.con.query(`SELECT run_id, task_id, attempt, task_name, params, retry_strategy, max_attempts,
|
|
332
422
|
headers, wake_event, event_payload
|
|
333
423
|
FROM absurd.claim_task($1, $2, $3, $4)`, [this.queueName, workerId, claimTimeout, count]);
|
|
334
424
|
return result.rows;
|
|
335
425
|
}
|
|
336
426
|
/**
|
|
337
|
-
*
|
|
338
|
-
*
|
|
427
|
+
* Claims up to batchSize tasks and processes them sequentially using the given workerId and claimTimeout.
|
|
428
|
+
* @param workerId Worker identifier.
|
|
429
|
+
* @param claimTimeout Lease duration in seconds.
|
|
430
|
+
* @param batchSize Maximum number of tasks to process.
|
|
431
|
+
* Note: For parallel processing, use startWorker().
|
|
339
432
|
*/
|
|
340
433
|
async workBatch(workerId = "worker", claimTimeout = 120, batchSize = 1) {
|
|
341
434
|
const tasks = await this.claimTasks({ batchSize, claimTimeout, workerId });
|
|
@@ -344,14 +437,57 @@ export class Absurd {
|
|
|
344
437
|
}
|
|
345
438
|
}
|
|
346
439
|
/**
|
|
347
|
-
* Starts a worker that continuously polls for
|
|
348
|
-
*
|
|
440
|
+
* Starts a background worker that continuously polls for and processes tasks from the queue.
|
|
441
|
+
* The worker will claim tasks up to the configured concurrency limit and process them in parallel.
|
|
442
|
+
*
|
|
443
|
+
* Tasks are claimed with a lease (claimTimeout) that prevents other workers from processing them.
|
|
444
|
+
* The lease is automatically extended when tasks write checkpoints or call heartbeat(). If a worker
|
|
445
|
+
* crashes or stops making progress, the lease expires and another worker can claim the task.
|
|
446
|
+
*
|
|
447
|
+
* @param options Configure worker behavior:
|
|
448
|
+
* - concurrency: Max parallel tasks (default: 1)
|
|
449
|
+
* - claimTimeout: Task lease duration in seconds (default: 120)
|
|
450
|
+
* - batchSize: Tasks to claim per poll (default: concurrency)
|
|
451
|
+
* - pollInterval: Seconds between polls when idle (default: 0.25)
|
|
452
|
+
* - workerId: Worker identifier for tracking (default: hostname:pid)
|
|
453
|
+
* - onError: Error handler called for execution failures
|
|
454
|
+
* - fatalOnLeaseTimeout: Terminate process if task exceeds 2x claimTimeout (default: true)
|
|
455
|
+
* @returns Worker instance with close() method for graceful shutdown.
|
|
349
456
|
*/
|
|
350
457
|
async startWorker(options = {}) {
|
|
351
|
-
const { workerId = `${os.hostname?.() || "host"}:${process.pid}`, claimTimeout = 120, concurrency = 1, batchSize, pollInterval = 0.25, onError = (err) => console.error("Worker error:", err), } = options;
|
|
458
|
+
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;
|
|
352
459
|
const effectiveBatchSize = batchSize ?? concurrency;
|
|
353
460
|
let running = true;
|
|
354
461
|
let workerLoopPromise;
|
|
462
|
+
const executing = new Set();
|
|
463
|
+
let availabilityPromise = null;
|
|
464
|
+
let availabilityResolve = null;
|
|
465
|
+
let sleepTimer = null;
|
|
466
|
+
const notifyAvailability = () => {
|
|
467
|
+
if (sleepTimer) {
|
|
468
|
+
clearTimeout(sleepTimer);
|
|
469
|
+
sleepTimer = null;
|
|
470
|
+
}
|
|
471
|
+
if (availabilityResolve) {
|
|
472
|
+
availabilityResolve();
|
|
473
|
+
availabilityResolve = null;
|
|
474
|
+
availabilityPromise = null;
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
const waitForAvailability = async () => {
|
|
478
|
+
if (!availabilityPromise) {
|
|
479
|
+
availabilityPromise = new Promise((resolve) => {
|
|
480
|
+
availabilityResolve = resolve;
|
|
481
|
+
sleepTimer = setTimeout(() => {
|
|
482
|
+
sleepTimer = null;
|
|
483
|
+
availabilityResolve = null;
|
|
484
|
+
availabilityPromise = null;
|
|
485
|
+
resolve();
|
|
486
|
+
}, pollInterval * 1000);
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
await availabilityPromise;
|
|
490
|
+
};
|
|
355
491
|
const worker = {
|
|
356
492
|
close: async () => {
|
|
357
493
|
running = false;
|
|
@@ -362,53 +498,82 @@ export class Absurd {
|
|
|
362
498
|
workerLoopPromise = (async () => {
|
|
363
499
|
while (running) {
|
|
364
500
|
try {
|
|
501
|
+
if (executing.size >= concurrency) {
|
|
502
|
+
await waitForAvailability();
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
const availableCapacity = Math.max(concurrency - executing.size, 0);
|
|
506
|
+
const toClaim = Math.min(effectiveBatchSize, availableCapacity);
|
|
507
|
+
if (toClaim <= 0) {
|
|
508
|
+
await waitForAvailability();
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
365
511
|
const messages = await this.claimTasks({
|
|
366
|
-
batchSize:
|
|
512
|
+
batchSize: toClaim,
|
|
367
513
|
claimTimeout: claimTimeout,
|
|
368
514
|
workerId,
|
|
369
515
|
});
|
|
370
516
|
if (messages.length === 0) {
|
|
371
|
-
await
|
|
517
|
+
await waitForAvailability();
|
|
372
518
|
continue;
|
|
373
519
|
}
|
|
374
|
-
const executing = new Set();
|
|
375
520
|
for (const task of messages) {
|
|
376
|
-
const promise = this.executeTask(task, claimTimeout
|
|
521
|
+
const promise = this.executeTask(task, claimTimeout, {
|
|
522
|
+
fatalOnLeaseTimeout,
|
|
523
|
+
})
|
|
377
524
|
.catch((err) => onError(err))
|
|
378
|
-
.finally(() =>
|
|
525
|
+
.finally(() => {
|
|
526
|
+
executing.delete(promise);
|
|
527
|
+
notifyAvailability();
|
|
528
|
+
});
|
|
379
529
|
executing.add(promise);
|
|
380
|
-
if (executing.size >= concurrency) {
|
|
381
|
-
await Promise.race(executing);
|
|
382
|
-
}
|
|
383
530
|
}
|
|
384
|
-
await Promise.all(executing);
|
|
385
531
|
}
|
|
386
532
|
catch (err) {
|
|
387
533
|
onError(err);
|
|
388
|
-
await
|
|
534
|
+
await waitForAvailability();
|
|
389
535
|
}
|
|
390
536
|
}
|
|
537
|
+
await Promise.allSettled(executing);
|
|
391
538
|
})();
|
|
392
539
|
return worker;
|
|
393
540
|
}
|
|
541
|
+
/**
|
|
542
|
+
* Stops any running worker and closes the underlying pool if owned.
|
|
543
|
+
*/
|
|
394
544
|
async close() {
|
|
395
545
|
if (this.worker) {
|
|
396
546
|
await this.worker.close();
|
|
397
547
|
}
|
|
398
548
|
if (this.ownedPool) {
|
|
399
|
-
await this.
|
|
549
|
+
await this.con.end();
|
|
400
550
|
}
|
|
401
551
|
}
|
|
402
|
-
async executeTask(task, claimTimeout) {
|
|
552
|
+
async executeTask(task, claimTimeout, options) {
|
|
553
|
+
let warnTimer;
|
|
554
|
+
let fatalTimer;
|
|
403
555
|
const registration = this.registry.get(task.task_name);
|
|
404
556
|
const ctx = await TaskContext.create({
|
|
557
|
+
log: this.log,
|
|
405
558
|
taskID: task.task_id,
|
|
406
|
-
|
|
559
|
+
con: this.con,
|
|
407
560
|
queueName: registration?.queue ?? "unknown",
|
|
408
561
|
task: task,
|
|
409
562
|
claimTimeout,
|
|
410
563
|
});
|
|
411
564
|
try {
|
|
565
|
+
if (claimTimeout > 0) {
|
|
566
|
+
const taskLabel = `${task.task_name} (${task.task_id})`;
|
|
567
|
+
warnTimer = setTimeout(() => {
|
|
568
|
+
this.log.warn(`task ${taskLabel} exceeded claim timeout of ${claimTimeout}s`);
|
|
569
|
+
}, claimTimeout * 1000);
|
|
570
|
+
if (options?.fatalOnLeaseTimeout) {
|
|
571
|
+
fatalTimer = setTimeout(() => {
|
|
572
|
+
this.log.error(`task ${taskLabel} exceeded claim timeout of ${claimTimeout}s by more than 100%; terminating process`);
|
|
573
|
+
process.exit(1);
|
|
574
|
+
}, claimTimeout * 1000 * 2);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
412
577
|
if (!registration) {
|
|
413
578
|
throw new Error("Unknown task");
|
|
414
579
|
}
|
|
@@ -416,17 +581,31 @@ export class Absurd {
|
|
|
416
581
|
throw new Error("Misconfigured task (queue mismatch)");
|
|
417
582
|
}
|
|
418
583
|
const result = await registration.handler(task.params, ctx);
|
|
419
|
-
await
|
|
584
|
+
await completeTaskRun(this.con, this.queueName, task.run_id, result);
|
|
420
585
|
}
|
|
421
586
|
catch (err) {
|
|
422
|
-
if (err instanceof SuspendTask) {
|
|
423
|
-
// Task suspended (sleep or await), don't complete or fail
|
|
587
|
+
if (err instanceof SuspendTask || err instanceof CancelledTask) {
|
|
588
|
+
// Task suspended or cancelled (sleep or await), don't complete or fail
|
|
424
589
|
return;
|
|
425
590
|
}
|
|
426
|
-
|
|
591
|
+
this.log.error("[absurd] task execution failed:", err);
|
|
592
|
+
await failTaskRun(this.con, this.queueName, task.run_id, err);
|
|
593
|
+
}
|
|
594
|
+
finally {
|
|
595
|
+
if (warnTimer) {
|
|
596
|
+
clearTimeout(warnTimer);
|
|
597
|
+
}
|
|
598
|
+
if (fatalTimer) {
|
|
599
|
+
clearTimeout(fatalTimer);
|
|
600
|
+
}
|
|
427
601
|
}
|
|
428
602
|
}
|
|
429
603
|
}
|
|
604
|
+
function isQueryable(value) {
|
|
605
|
+
return (typeof value === "object" &&
|
|
606
|
+
value !== null &&
|
|
607
|
+
typeof value.query === "function");
|
|
608
|
+
}
|
|
430
609
|
function serializeError(err) {
|
|
431
610
|
if (err instanceof Error) {
|
|
432
611
|
return {
|
|
@@ -437,6 +616,21 @@ function serializeError(err) {
|
|
|
437
616
|
}
|
|
438
617
|
return { message: String(err) };
|
|
439
618
|
}
|
|
619
|
+
async function completeTaskRun(con, queueName, runID, result) {
|
|
620
|
+
await con.query(`SELECT absurd.complete_run($1, $2, $3)`, [
|
|
621
|
+
queueName,
|
|
622
|
+
runID,
|
|
623
|
+
JSON.stringify(result ?? null),
|
|
624
|
+
]);
|
|
625
|
+
}
|
|
626
|
+
async function failTaskRun(con, queueName, runID, err) {
|
|
627
|
+
await con.query(`SELECT absurd.fail_run($1, $2, $3, $4)`, [
|
|
628
|
+
queueName,
|
|
629
|
+
runID,
|
|
630
|
+
JSON.stringify(serializeError(err)),
|
|
631
|
+
null,
|
|
632
|
+
]);
|
|
633
|
+
}
|
|
440
634
|
function normalizeSpawnOptions(options) {
|
|
441
635
|
const normalized = {};
|
|
442
636
|
if (options.headers !== undefined) {
|
|
@@ -482,7 +676,4 @@ function normalizeCancellation(policy) {
|
|
|
482
676
|
}
|
|
483
677
|
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
|
484
678
|
}
|
|
485
|
-
async function sleep(ms) {
|
|
486
|
-
return new Promise((resolve) => setTimeout(resolve, ms * 1000));
|
|
487
|
-
}
|
|
488
679
|
//# sourceMappingURL=index.js.map
|