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/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
- pool;
77
+ con;
66
78
  queueName;
67
79
  task;
68
80
  checkpointCache;
69
81
  claimTimeout;
70
82
  stepNameCounter = new Map();
71
- constructor(taskID, pool, queueName, task, checkpointCache, claimTimeout) {
83
+ constructor(log, taskID, con, queueName, task, checkpointCache, claimTimeout) {
84
+ this.log = log;
72
85
  this.taskID = taskID;
73
- this.pool = pool;
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, pool, queueName, task, claimTimeout } = args;
81
- const result = await pool.query(`SELECT checkpoint_name, state, status, owner_run_id, updated_at
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, pool, queueName, task, cache, claimTimeout);
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
- * Defines a step in the task execution. Steps are idempotent in
91
- * that they are executed exactly once (unless they fail) and their
92
- * results are cached. As a result the return value of this function
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
- * Sleeps for a given number of seconds. Note that this
107
- * *always* suspends the task, even if you only wait for a very
108
- * short period of time.
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
- * Like `sleepFor` but with an absolute time when the task should be
115
- * awoken again.
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.pool.query(`SELECT checkpoint_name, state, status, owner_run_id, updated_at
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.pool.query(`SELECT absurd.set_task_checkpoint_state($1, $2, $3, $4, $5, $6)`, [
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.pool.query(`SELECT absurd.schedule_run($1, $2, $3)`, [
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
- * Awaits the arrival of an event. Events need to be uniquely
169
- * named so fold in the necessary parameters into the name (eg: customer id).
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.pool.query(`SELECT should_suspend, payload
193
- FROM absurd.await_event($1, $2, $3, $4, $5, $6)`, [
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
- * Emits an event that can be awaited.
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.pool.query(`SELECT absurd.emit_event($1, $2, $3)`, [
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
- pool;
273
+ con;
245
274
  ownedPool;
246
275
  queueName;
247
276
  defaultMaxAttempts;
248
277
  registry = new Map();
278
+ log;
249
279
  worker = null;
250
- constructor(poolOrUrl, queueName = "default", defaultMaxAttempts = 5) {
251
- if (!poolOrUrl) {
252
- poolOrUrl =
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 poolOrUrl === "string") {
256
- this.pool = new pg.Pool({ connectionString: poolOrUrl });
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.pool = poolOrUrl;
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
- * This registers a given function as task.
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.pool.query(`SELECT absurd.create_queue($1)`, [queue]);
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.pool.query(`SELECT absurd.drop_queue($1)`, [queue]);
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.pool.query(`SELECT * FROM absurd.list_queues()`);
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 specific task.
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.pool.query(`SELECT task_id, run_id, attempt
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 from outside of a task.
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.pool.query(`SELECT absurd.emit_event($1, $2, $3)`, [
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.pool.query(`SELECT run_id, task_id, attempt, task_name, params, retry_strategy, max_attempts,
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
- * Polls and processes a batch of messages sequentially.
377
- * For parallel processing, use startWorker with concurrency option.
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 tasks and processes them.
387
- * Returns a Worker instance with a close() method for graceful shutdown.
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: effectiveBatchSize,
552
+ batchSize: toClaim,
406
553
  claimTimeout: claimTimeout,
407
554
  workerId,
408
555
  });
409
556
  if (messages.length === 0) {
410
- await sleep(pollInterval);
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(() => executing.delete(promise));
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 sleep(pollInterval);
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.pool.end();
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
- pool: this.pool,
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 ctx.complete(result);
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
- await ctx.fail(err);
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
- }