absurd-sdk 0.0.4 → 0.0.6

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 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) => {
@@ -96,14 +94,8 @@ const payment = await ctx.step("process-payment", async () => {
96
94
  });
97
95
  ```
98
96
 
99
- ## Documentation
100
-
101
- For more information, examples, and documentation, visit:
97
+ ## License and Links
102
98
 
103
- - [Main Repository](https://github.com/earendil-works/absurd)
104
99
  - [Examples](https://github.com/earendil-works/absurd/tree/main/sdks/typescript/examples)
105
100
  - [Issue Tracker](https://github.com/earendil-works/absurd/issues)
106
-
107
- ## License
108
-
109
- Apache-2.0
101
+ - License: [Apache-2.0](https://github.com/earendil-works/absurd/blob/main/LICENSE)
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,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.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
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 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.
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.pool.query(`SELECT task_id, run_id, attempt
415
+ const result = await this.con.query(`SELECT task_id, run_id, attempt, created
338
416
  FROM absurd.spawn_task($1, $2, $3, $4)`, [
339
417
  queue,
340
418
  taskName,
@@ -349,31 +427,49 @@ class Absurd {
349
427
  taskID: row.task_id,
350
428
  runID: row.run_id,
351
429
  attempt: row.attempt,
430
+ created: row.created,
352
431
  };
353
432
  }
354
433
  /**
355
- * Emits an event from outside of a task.
434
+ * Emits an event with an optional payload on the specified or default queue.
435
+ * @param eventName Non-empty event name.
436
+ * @param payload Optional JSON-serializable payload.
437
+ * @param queueName Queue to emit to (defaults to this client's queue).
356
438
  */
357
439
  async emitEvent(eventName, payload, queueName) {
358
440
  if (!eventName) {
359
441
  throw new Error("eventName must be a non-empty string");
360
442
  }
361
- await this.pool.query(`SELECT absurd.emit_event($1, $2, $3)`, [
443
+ await this.con.query(`SELECT absurd.emit_event($1, $2, $3)`, [
362
444
  queueName || this.queueName,
363
445
  eventName,
364
446
  JSON.stringify(payload ?? null),
365
447
  ]);
366
448
  }
449
+ /**
450
+ * Cancels a task by ID on the specified or default queue; running tasks stop at the next checkpoint/heartbeat.
451
+ * @param taskID Task identifier to cancel.
452
+ * @param queueName Queue name (defaults to this client's queue).
453
+ */
454
+ async cancelTask(taskID, queueName) {
455
+ await this.con.query(`SELECT absurd.cancel_task($1, $2)`, [
456
+ queueName || this.queueName,
457
+ taskID,
458
+ ]);
459
+ }
367
460
  async claimTasks(options) {
368
461
  const { batchSize: count = 1, claimTimeout = 120, workerId = "worker", } = options ?? {};
369
- const result = await this.pool.query(`SELECT run_id, task_id, attempt, task_name, params, retry_strategy, max_attempts,
462
+ const result = await this.con.query(`SELECT run_id, task_id, attempt, task_name, params, retry_strategy, max_attempts,
370
463
  headers, wake_event, event_payload
371
464
  FROM absurd.claim_task($1, $2, $3, $4)`, [this.queueName, workerId, claimTimeout, count]);
372
465
  return result.rows;
373
466
  }
374
467
  /**
375
- * Polls and processes a batch of messages sequentially.
376
- * For parallel processing, use startWorker with concurrency option.
468
+ * Claims up to batchSize tasks and processes them sequentially using the given workerId and claimTimeout.
469
+ * @param workerId Worker identifier.
470
+ * @param claimTimeout Lease duration in seconds.
471
+ * @param batchSize Maximum number of tasks to process.
472
+ * Note: For parallel processing, use startWorker().
377
473
  */
378
474
  async workBatch(workerId = "worker", claimTimeout = 120, batchSize = 1) {
379
475
  const tasks = await this.claimTasks({ batchSize, claimTimeout, workerId });
@@ -382,18 +478,37 @@ class Absurd {
382
478
  }
383
479
  }
384
480
  /**
385
- * Starts a worker that continuously polls for tasks and processes them.
386
- * Returns a Worker instance with a close() method for graceful shutdown.
481
+ * Starts a background worker that continuously polls for and processes tasks from the queue.
482
+ * The worker will claim tasks up to the configured concurrency limit and process them in parallel.
483
+ *
484
+ * Tasks are claimed with a lease (claimTimeout) that prevents other workers from processing them.
485
+ * The lease is automatically extended when tasks write checkpoints or call heartbeat(). If a worker
486
+ * crashes or stops making progress, the lease expires and another worker can claim the task.
487
+ *
488
+ * @param options Configure worker behavior:
489
+ * - concurrency: Max parallel tasks (default: 1)
490
+ * - claimTimeout: Task lease duration in seconds (default: 120)
491
+ * - batchSize: Tasks to claim per poll (default: concurrency)
492
+ * - pollInterval: Seconds between polls when idle (default: 0.25)
493
+ * - workerId: Worker identifier for tracking (default: hostname:pid)
494
+ * - onError: Error handler called for execution failures
495
+ * - fatalOnLeaseTimeout: Terminate process if task exceeds 2x claimTimeout (default: true)
496
+ * @returns Worker instance with close() method for graceful shutdown.
387
497
  */
388
498
  async startWorker(options = {}) {
389
- const { workerId = `${os.hostname?.() || "host"}:${process.pid}`, claimTimeout = 120, concurrency = 1, batchSize, pollInterval = 0.25, onError = (err) => console.error("[absurd] Worker error:", err), fatalOnLeaseTimeout = true, } = options;
499
+ const { workerId = `${os.hostname?.() || "host"}:${process.pid}`, claimTimeout = 120, concurrency = 1, batchSize, pollInterval = 0.25, onError = (err) => this.log.error("Worker error:", err), fatalOnLeaseTimeout = true, } = options;
390
500
  const effectiveBatchSize = batchSize ?? concurrency;
391
501
  let running = true;
392
502
  let workerLoopPromise;
393
503
  const executing = new Set();
394
504
  let availabilityPromise = null;
395
505
  let availabilityResolve = null;
506
+ let sleepTimer = null;
396
507
  const notifyAvailability = () => {
508
+ if (sleepTimer) {
509
+ clearTimeout(sleepTimer);
510
+ sleepTimer = null;
511
+ }
397
512
  if (availabilityResolve) {
398
513
  availabilityResolve();
399
514
  availabilityResolve = null;
@@ -401,16 +516,18 @@ class Absurd {
401
516
  }
402
517
  };
403
518
  const waitForAvailability = async () => {
404
- if (executing.size === 0) {
405
- await sleep(pollInterval);
406
- return;
407
- }
408
519
  if (!availabilityPromise) {
409
520
  availabilityPromise = new Promise((resolve) => {
410
521
  availabilityResolve = resolve;
522
+ sleepTimer = setTimeout(() => {
523
+ sleepTimer = null;
524
+ availabilityResolve = null;
525
+ availabilityPromise = null;
526
+ resolve();
527
+ }, pollInterval * 1000);
411
528
  });
412
529
  }
413
- await Promise.race([availabilityPromise, sleep(pollInterval)]);
530
+ await availabilityPromise;
414
531
  };
415
532
  const worker = {
416
533
  close: async () => {
@@ -462,12 +579,15 @@ class Absurd {
462
579
  })();
463
580
  return worker;
464
581
  }
582
+ /**
583
+ * Stops any running worker and closes the underlying pool if owned.
584
+ */
465
585
  async close() {
466
586
  if (this.worker) {
467
587
  await this.worker.close();
468
588
  }
469
589
  if (this.ownedPool) {
470
- await this.pool.end();
590
+ await this.con.end();
471
591
  }
472
592
  }
473
593
  async executeTask(task, claimTimeout, options) {
@@ -475,8 +595,9 @@ class Absurd {
475
595
  let fatalTimer;
476
596
  const registration = this.registry.get(task.task_name);
477
597
  const ctx = await TaskContext.create({
598
+ log: this.log,
478
599
  taskID: task.task_id,
479
- pool: this.pool,
600
+ con: this.con,
480
601
  queueName: registration?.queue ?? "unknown",
481
602
  task: task,
482
603
  claimTimeout,
@@ -485,11 +606,11 @@ class Absurd {
485
606
  if (claimTimeout > 0) {
486
607
  const taskLabel = `${task.task_name} (${task.task_id})`;
487
608
  warnTimer = setTimeout(() => {
488
- console.warn(`[absurd] task ${taskLabel} exceeded claim timeout of ${claimTimeout}s`);
609
+ this.log.warn(`task ${taskLabel} exceeded claim timeout of ${claimTimeout}s`);
489
610
  }, claimTimeout * 1000);
490
611
  if (options?.fatalOnLeaseTimeout) {
491
612
  fatalTimer = setTimeout(() => {
492
- console.error(`[absurd] task ${taskLabel} exceeded claim timeout of ${claimTimeout}s by more than 100%; terminating process`);
613
+ this.log.error(`task ${taskLabel} exceeded claim timeout of ${claimTimeout}s by more than 100%; terminating process`);
493
614
  process.exit(1);
494
615
  }, claimTimeout * 1000 * 2);
495
616
  }
@@ -501,14 +622,15 @@ class Absurd {
501
622
  throw new Error("Misconfigured task (queue mismatch)");
502
623
  }
503
624
  const result = await registration.handler(task.params, ctx);
504
- await ctx.complete(result);
625
+ await completeTaskRun(this.con, this.queueName, task.run_id, result);
505
626
  }
506
627
  catch (err) {
507
- if (err instanceof SuspendTask) {
508
- // Task suspended (sleep or await), don't complete or fail
628
+ if (err instanceof SuspendTask || err instanceof CancelledTask) {
629
+ // Task suspended or cancelled (sleep or await), don't complete or fail
509
630
  return;
510
631
  }
511
- await ctx.fail(err);
632
+ this.log.error("[absurd] task execution failed:", err);
633
+ await failTaskRun(this.con, this.queueName, task.run_id, err);
512
634
  }
513
635
  finally {
514
636
  if (warnTimer) {
@@ -521,6 +643,11 @@ class Absurd {
521
643
  }
522
644
  }
523
645
  exports.Absurd = Absurd;
646
+ function isQueryable(value) {
647
+ return (typeof value === "object" &&
648
+ value !== null &&
649
+ typeof value.query === "function");
650
+ }
524
651
  function serializeError(err) {
525
652
  if (err instanceof Error) {
526
653
  return {
@@ -531,6 +658,21 @@ function serializeError(err) {
531
658
  }
532
659
  return { message: String(err) };
533
660
  }
661
+ async function completeTaskRun(con, queueName, runID, result) {
662
+ await con.query(`SELECT absurd.complete_run($1, $2, $3)`, [
663
+ queueName,
664
+ runID,
665
+ JSON.stringify(result ?? null),
666
+ ]);
667
+ }
668
+ async function failTaskRun(con, queueName, runID, err) {
669
+ await con.query(`SELECT absurd.fail_run($1, $2, $3, $4)`, [
670
+ queueName,
671
+ runID,
672
+ JSON.stringify(serializeError(err)),
673
+ null,
674
+ ]);
675
+ }
534
676
  function normalizeSpawnOptions(options) {
535
677
  const normalized = {};
536
678
  if (options.headers !== undefined) {
@@ -546,6 +688,9 @@ function normalizeSpawnOptions(options) {
546
688
  if (cancellation) {
547
689
  normalized.cancellation = cancellation;
548
690
  }
691
+ if (options.idempotencyKey !== undefined) {
692
+ normalized.idempotency_key = options.idempotencyKey;
693
+ }
549
694
  return normalized;
550
695
  }
551
696
  function serializeRetryStrategy(strategy) {
@@ -576,6 +721,3 @@ function normalizeCancellation(policy) {
576
721
  }
577
722
  return Object.keys(normalized).length > 0 ? normalized : undefined;
578
723
  }
579
- async function sleep(ms) {
580
- return new Promise((resolve) => setTimeout(resolve, ms * 1000));
581
- }