@absurd-sqlite/sdk 0.2.1-alpha.2 → 0.3.0-alpha.1
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/absurd.d.ts +305 -0
- package/dist/absurd.d.ts.map +1 -0
- package/dist/absurd.js +711 -0
- package/dist/absurd.js.map +1 -0
- package/dist/cjs/absurd.js +751 -0
- package/dist/cjs/index.js +22 -13
- package/dist/{sqlite.js → cjs/sqlite-connection.js} +78 -22
- package/dist/index.d.ts +100 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -12
- package/dist/index.js.map +1 -1
- package/dist/sqlite-connection.d.ts +46 -0
- package/dist/sqlite-connection.d.ts.map +1 -0
- package/dist/{cjs/sqlite.js → sqlite-connection.js} +75 -25
- package/dist/sqlite-connection.js.map +1 -0
- package/dist/sqlite-types.d.ts +1 -1
- package/dist/sqlite-types.d.ts.map +1 -1
- package/package.json +5 -4
- package/src/absurd.ts +1030 -0
- package/src/index.ts +163 -30
- package/src/sqlite-connection.ts +298 -0
- package/src/sqlite-types.ts +1 -1
- package/test/basic.test.ts +6 -6
- package/test/events.test.ts +6 -6
- package/test/index.test.ts +7 -5
- package/test/retry.test.ts +5 -5
- package/test/setup.ts +38 -44
- package/test/sqlite.test.ts +50 -19
- package/test/step.test.ts +6 -5
- package/dist/absurd-types.d.ts +0 -109
- package/dist/absurd-types.d.ts.map +0 -1
- package/dist/absurd-types.js +0 -2
- package/dist/absurd-types.js.map +0 -1
- package/dist/cjs/absurd-types.js +0 -2
- package/dist/sqlite.d.ts +0 -14
- package/dist/sqlite.d.ts.map +0 -1
- package/dist/sqlite.js.map +0 -1
- package/src/absurd-types.ts +0 -149
- package/src/sqlite.ts +0 -211
package/dist/absurd.js
ADDED
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file is derived from the Absurd TypeScript SDK:
|
|
3
|
+
* https://github.com/earendil-works/absurd/blob/d68d5dd23a29b45240f156d78ccf98d39ffce744/sdks/typescript/src/index.ts
|
|
4
|
+
*
|
|
5
|
+
* Original work Copyright (c) earendil-works.
|
|
6
|
+
* Licensed under the Apache License, Version 2.0.
|
|
7
|
+
*
|
|
8
|
+
* Modifications Copyright (c) absurd-sqlite contributors.
|
|
9
|
+
*/
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
import { Temporal } from "temporal-polyfill";
|
|
12
|
+
/**
|
|
13
|
+
* Internal exception that is thrown to suspend a run.
|
|
14
|
+
*/
|
|
15
|
+
export class SuspendTask extends Error {
|
|
16
|
+
constructor() {
|
|
17
|
+
super("Task suspended");
|
|
18
|
+
this.name = "SuspendTask";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Internal exception that is thrown to cancel a run.
|
|
23
|
+
*/
|
|
24
|
+
export class CancelledTask extends Error {
|
|
25
|
+
constructor() {
|
|
26
|
+
super("Task cancelled");
|
|
27
|
+
this.name = "CancelledTask";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* This error is thrown when awaiting an event ran into a timeout.
|
|
32
|
+
*/
|
|
33
|
+
export class TimeoutError extends Error {
|
|
34
|
+
constructor(message) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = "TimeoutError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
class LeaseTimerManager {
|
|
40
|
+
log;
|
|
41
|
+
taskLabel;
|
|
42
|
+
fatalOnLeaseTimeout;
|
|
43
|
+
warnTimer = null;
|
|
44
|
+
fatalTimer = null;
|
|
45
|
+
constructor(log, taskLabel, fatalOnLeaseTimeout) {
|
|
46
|
+
this.log = log;
|
|
47
|
+
this.taskLabel = taskLabel;
|
|
48
|
+
this.fatalOnLeaseTimeout = fatalOnLeaseTimeout;
|
|
49
|
+
}
|
|
50
|
+
update(leaseSeconds) {
|
|
51
|
+
this.clear();
|
|
52
|
+
if (leaseSeconds <= 0) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
this.warnTimer = setTimeout(() => {
|
|
56
|
+
this.log.warn(`task ${this.taskLabel} exceeded claim timeout of ${leaseSeconds}s`);
|
|
57
|
+
}, leaseSeconds * 1000);
|
|
58
|
+
if (this.fatalOnLeaseTimeout) {
|
|
59
|
+
this.fatalTimer = setTimeout(() => {
|
|
60
|
+
this.log.error(`task ${this.taskLabel} exceeded claim timeout of ${leaseSeconds}s by more than 100%; terminating process`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}, leaseSeconds * 1000 * 2);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
stop() {
|
|
66
|
+
this.clear();
|
|
67
|
+
}
|
|
68
|
+
clear() {
|
|
69
|
+
if (this.warnTimer) {
|
|
70
|
+
clearTimeout(this.warnTimer);
|
|
71
|
+
this.warnTimer = null;
|
|
72
|
+
}
|
|
73
|
+
if (this.fatalTimer) {
|
|
74
|
+
clearTimeout(this.fatalTimer);
|
|
75
|
+
this.fatalTimer = null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Execution context passed to task handlers.
|
|
81
|
+
*/
|
|
82
|
+
export class TaskContext {
|
|
83
|
+
log;
|
|
84
|
+
taskID;
|
|
85
|
+
con;
|
|
86
|
+
queueName;
|
|
87
|
+
task;
|
|
88
|
+
checkpointCache;
|
|
89
|
+
claimTimeout;
|
|
90
|
+
leaseTimer;
|
|
91
|
+
stepNameCounter = new Map();
|
|
92
|
+
constructor(log, taskID, con, queueName, task, checkpointCache, claimTimeout, leaseTimer) {
|
|
93
|
+
this.log = log;
|
|
94
|
+
this.taskID = taskID;
|
|
95
|
+
this.con = con;
|
|
96
|
+
this.queueName = queueName;
|
|
97
|
+
this.task = task;
|
|
98
|
+
this.checkpointCache = checkpointCache;
|
|
99
|
+
this.claimTimeout = claimTimeout;
|
|
100
|
+
this.leaseTimer = leaseTimer;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Returns all headers attached to this task.
|
|
104
|
+
*/
|
|
105
|
+
get headers() {
|
|
106
|
+
return this.task.headers ?? {};
|
|
107
|
+
}
|
|
108
|
+
static async create(args) {
|
|
109
|
+
const { log, taskID, con, queueName, task, claimTimeout, leaseTimer } = args;
|
|
110
|
+
const result = await con.query(`SELECT checkpoint_name, state, status, owner_run_id, updated_at
|
|
111
|
+
FROM absurd.get_task_checkpoint_states($1, $2, $3)`, [queueName, task.task_id, task.run_id]);
|
|
112
|
+
const cache = new Map();
|
|
113
|
+
for (const row of result.rows) {
|
|
114
|
+
cache.set(row.checkpoint_name, row.state);
|
|
115
|
+
}
|
|
116
|
+
return new TaskContext(log, taskID, con, queueName, task, cache, claimTimeout, leaseTimer);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Runs an idempotent step identified by name; caches and reuses its result across retries.
|
|
120
|
+
* @param name Unique checkpoint name for this step.
|
|
121
|
+
* @param fn Async function computing the step result (must be JSON-serializable).
|
|
122
|
+
*/
|
|
123
|
+
async step(name, fn) {
|
|
124
|
+
const checkpointName = this.getCheckpointName(name);
|
|
125
|
+
const state = await this.lookupCheckpoint(checkpointName);
|
|
126
|
+
if (state !== undefined) {
|
|
127
|
+
return state;
|
|
128
|
+
}
|
|
129
|
+
const rv = await fn();
|
|
130
|
+
await this.persistCheckpoint(checkpointName, rv);
|
|
131
|
+
return rv;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Suspends the task until the given duration elapses.
|
|
135
|
+
* @param stepName Checkpoint name for this wait.
|
|
136
|
+
* @param duration Duration to wait.
|
|
137
|
+
*/
|
|
138
|
+
async sleepFor(stepName, duration) {
|
|
139
|
+
const now = Temporal.Now.instant();
|
|
140
|
+
const wakeAt = now.add(duration);
|
|
141
|
+
return await this.sleepUntil(stepName, wakeAt);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Suspends the task until the specified time.
|
|
145
|
+
* @param stepName Checkpoint name for this wait.
|
|
146
|
+
* @param wakeAt Absolute time when the task should resume.
|
|
147
|
+
*/
|
|
148
|
+
async sleepUntil(stepName, wakeAt) {
|
|
149
|
+
const checkpointName = this.getCheckpointName(stepName);
|
|
150
|
+
const state = await this.lookupCheckpoint(checkpointName);
|
|
151
|
+
const actualWakeAt = typeof state === "string" ? Temporal.Instant.from(state) : wakeAt;
|
|
152
|
+
if (!state) {
|
|
153
|
+
await this.persistCheckpoint(checkpointName, wakeAt.toString());
|
|
154
|
+
}
|
|
155
|
+
if (Temporal.Instant.compare(Temporal.Now.instant(), actualWakeAt) < 0) {
|
|
156
|
+
await this.scheduleRun(actualWakeAt);
|
|
157
|
+
throw new SuspendTask();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
getCheckpointName(name) {
|
|
161
|
+
const count = (this.stepNameCounter.get(name) ?? 0) + 1;
|
|
162
|
+
this.stepNameCounter.set(name, count);
|
|
163
|
+
return count === 1 ? name : `${name}#${count}`;
|
|
164
|
+
}
|
|
165
|
+
async lookupCheckpoint(checkpointName) {
|
|
166
|
+
const cached = this.checkpointCache.get(checkpointName);
|
|
167
|
+
if (cached !== undefined) {
|
|
168
|
+
return cached;
|
|
169
|
+
}
|
|
170
|
+
const result = await this.con.query(`SELECT checkpoint_name, state, status, owner_run_id, updated_at
|
|
171
|
+
FROM absurd.get_task_checkpoint_state($1, $2, $3)`, [this.queueName, this.task.task_id, checkpointName]);
|
|
172
|
+
if (result.rows.length > 0) {
|
|
173
|
+
const state = result.rows[0].state;
|
|
174
|
+
this.checkpointCache.set(checkpointName, state);
|
|
175
|
+
return state;
|
|
176
|
+
}
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
async persistCheckpoint(checkpointName, value) {
|
|
180
|
+
await this.con.query(`SELECT absurd.set_task_checkpoint_state($1, $2, $3, $4, $5, $6)`, [
|
|
181
|
+
this.queueName,
|
|
182
|
+
this.task.task_id,
|
|
183
|
+
checkpointName,
|
|
184
|
+
JSON.stringify(value),
|
|
185
|
+
this.task.run_id,
|
|
186
|
+
this.claimTimeout,
|
|
187
|
+
]);
|
|
188
|
+
this.checkpointCache.set(checkpointName, value);
|
|
189
|
+
this.recordLeaseExtension(this.claimTimeout);
|
|
190
|
+
}
|
|
191
|
+
async scheduleRun(wakeAt) {
|
|
192
|
+
await this.con.query(`SELECT absurd.schedule_run($1, $2, $3)`, [
|
|
193
|
+
this.queueName,
|
|
194
|
+
this.task.run_id,
|
|
195
|
+
wakeAt,
|
|
196
|
+
]);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Waits for an event by name and returns its payload; optionally sets a custom step name and timeout.
|
|
200
|
+
* @param eventName Event identifier to wait for.
|
|
201
|
+
* @param options.stepName Optional checkpoint name (defaults to $awaitEvent:<eventName>).
|
|
202
|
+
* @param options.timeout Optional timeout duration.
|
|
203
|
+
* @throws TimeoutError If the event is not received before the timeout.
|
|
204
|
+
*/
|
|
205
|
+
async awaitEvent(eventName, options) {
|
|
206
|
+
const stepName = options?.stepName || `$awaitEvent:${eventName}`;
|
|
207
|
+
let timeout = null;
|
|
208
|
+
if (options?.timeout !== undefined) {
|
|
209
|
+
timeout = Math.floor(options.timeout.total("seconds"));
|
|
210
|
+
}
|
|
211
|
+
const checkpointName = this.getCheckpointName(stepName);
|
|
212
|
+
const cached = await this.lookupCheckpoint(checkpointName);
|
|
213
|
+
if (cached !== undefined) {
|
|
214
|
+
return cached;
|
|
215
|
+
}
|
|
216
|
+
if (this.task.wake_event === eventName &&
|
|
217
|
+
(this.task.event_payload === null ||
|
|
218
|
+
this.task.event_payload === undefined)) {
|
|
219
|
+
this.task.wake_event = null;
|
|
220
|
+
this.task.event_payload = null;
|
|
221
|
+
throw new TimeoutError(`Timed out waiting for event "${eventName}"`);
|
|
222
|
+
}
|
|
223
|
+
const result = await this.con.query(`SELECT should_suspend, payload
|
|
224
|
+
FROM absurd.await_event($1, $2, $3, $4, $5, $6)`, [
|
|
225
|
+
this.queueName,
|
|
226
|
+
this.task.task_id,
|
|
227
|
+
this.task.run_id,
|
|
228
|
+
checkpointName,
|
|
229
|
+
eventName,
|
|
230
|
+
timeout,
|
|
231
|
+
]);
|
|
232
|
+
if (result.rows.length === 0) {
|
|
233
|
+
throw new Error("Failed to await event");
|
|
234
|
+
}
|
|
235
|
+
const { should_suspend, payload } = result.rows[0];
|
|
236
|
+
if (!should_suspend) {
|
|
237
|
+
this.checkpointCache.set(checkpointName, payload);
|
|
238
|
+
this.task.event_payload = null;
|
|
239
|
+
return payload;
|
|
240
|
+
}
|
|
241
|
+
throw new SuspendTask();
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Extends the current run's lease by the given seconds (defaults to the original claim timeout).
|
|
245
|
+
* @param seconds Lease extension in seconds.
|
|
246
|
+
*/
|
|
247
|
+
async heartbeat(seconds) {
|
|
248
|
+
const leaseSeconds = seconds ?? this.claimTimeout;
|
|
249
|
+
await this.con.query(`SELECT absurd.extend_claim($1, $2, $3)`, [
|
|
250
|
+
this.queueName,
|
|
251
|
+
this.task.run_id,
|
|
252
|
+
leaseSeconds,
|
|
253
|
+
]);
|
|
254
|
+
this.recordLeaseExtension(leaseSeconds);
|
|
255
|
+
}
|
|
256
|
+
recordLeaseExtension(leaseSeconds) {
|
|
257
|
+
this.leaseTimer.update(leaseSeconds);
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Emits an event to this task's queue with an optional payload.
|
|
261
|
+
* @param eventName Non-empty event name.
|
|
262
|
+
* @param payload Optional JSON-serializable payload.
|
|
263
|
+
*/
|
|
264
|
+
async emitEvent(eventName, payload) {
|
|
265
|
+
if (!eventName) {
|
|
266
|
+
throw new Error("eventName must be a non-empty string");
|
|
267
|
+
}
|
|
268
|
+
await this.con.query(`SELECT absurd.emit_event($1, $2, $3)`, [
|
|
269
|
+
this.queueName,
|
|
270
|
+
eventName,
|
|
271
|
+
JSON.stringify(payload ?? null),
|
|
272
|
+
]);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* The Absurd SDK client.
|
|
277
|
+
*/
|
|
278
|
+
export class AbsurdImpl {
|
|
279
|
+
con;
|
|
280
|
+
ownedConnection;
|
|
281
|
+
queueName;
|
|
282
|
+
defaultMaxAttempts;
|
|
283
|
+
registry = new Map();
|
|
284
|
+
log;
|
|
285
|
+
worker = null;
|
|
286
|
+
hooks;
|
|
287
|
+
constructor(options) {
|
|
288
|
+
if (isQueryable(options)) {
|
|
289
|
+
options = { db: options };
|
|
290
|
+
}
|
|
291
|
+
if (!options.db) {
|
|
292
|
+
throw new Error("Absurd requires a database connection");
|
|
293
|
+
}
|
|
294
|
+
this.con = options.db;
|
|
295
|
+
this.ownedConnection = options.ownedConnection ?? false;
|
|
296
|
+
this.queueName = options.queueName ?? "default";
|
|
297
|
+
this.defaultMaxAttempts = options.defaultMaxAttempts ?? 5;
|
|
298
|
+
this.log = options.log ?? console;
|
|
299
|
+
this.hooks = options.hooks ?? {};
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Returns a new client that uses the provided connection for queries; set owned=true to close it with close().
|
|
303
|
+
* @param con Connection to bind to.
|
|
304
|
+
* @param owned If true, the bound client will close this connection on close().
|
|
305
|
+
*/
|
|
306
|
+
bindToConnection(con, owned = false) {
|
|
307
|
+
const bound = new AbsurdImpl({
|
|
308
|
+
db: con,
|
|
309
|
+
queueName: this.queueName,
|
|
310
|
+
defaultMaxAttempts: this.defaultMaxAttempts,
|
|
311
|
+
log: this.log,
|
|
312
|
+
hooks: this.hooks,
|
|
313
|
+
ownedConnection: owned,
|
|
314
|
+
});
|
|
315
|
+
bound.registry = this.registry;
|
|
316
|
+
return bound;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Registers a task handler by name (optionally specifying queue, defaultMaxAttempts, and defaultCancellation).
|
|
320
|
+
* @param options.name Task name.
|
|
321
|
+
* @param options.queue Optional queue name (defaults to client queue).
|
|
322
|
+
* @param options.defaultMaxAttempts Optional default max attempts.
|
|
323
|
+
* @param options.defaultCancellation Optional default cancellation policy.
|
|
324
|
+
* @param handler Async task handler.
|
|
325
|
+
*/
|
|
326
|
+
registerTask(options, handler) {
|
|
327
|
+
if (!options?.name) {
|
|
328
|
+
throw new Error("Task registration requires a name");
|
|
329
|
+
}
|
|
330
|
+
if (options.defaultMaxAttempts !== undefined &&
|
|
331
|
+
options.defaultMaxAttempts < 1) {
|
|
332
|
+
throw new Error("defaultMaxAttempts must be at least 1");
|
|
333
|
+
}
|
|
334
|
+
if (options.defaultCancellation) {
|
|
335
|
+
normalizeCancellation(options.defaultCancellation);
|
|
336
|
+
}
|
|
337
|
+
const queue = options.queue ?? this.queueName;
|
|
338
|
+
if (!queue) {
|
|
339
|
+
throw new Error(`Task "${options.name}" must specify a queue or use a client with a default queue`);
|
|
340
|
+
}
|
|
341
|
+
this.registry.set(options.name, {
|
|
342
|
+
name: options.name,
|
|
343
|
+
queue,
|
|
344
|
+
defaultMaxAttempts: options.defaultMaxAttempts,
|
|
345
|
+
defaultCancellation: options.defaultCancellation,
|
|
346
|
+
handler,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Creates a queue (defaults to this client's queue).
|
|
351
|
+
* @param queueName Queue name to create.
|
|
352
|
+
*/
|
|
353
|
+
async createQueue(queueName) {
|
|
354
|
+
const queue = queueName ?? this.queueName;
|
|
355
|
+
await this.con.query(`SELECT absurd.create_queue($1)`, [queue]);
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Drops a queue (defaults to this client's queue).
|
|
359
|
+
* @param queueName Queue name to drop.
|
|
360
|
+
*/
|
|
361
|
+
async dropQueue(queueName) {
|
|
362
|
+
const queue = queueName ?? this.queueName;
|
|
363
|
+
await this.con.query(`SELECT absurd.drop_queue($1)`, [queue]);
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Lists all queue names.
|
|
367
|
+
* @returns Array of queue names.
|
|
368
|
+
*/
|
|
369
|
+
async listQueues() {
|
|
370
|
+
const result = await this.con.query(`SELECT * FROM absurd.list_queues()`);
|
|
371
|
+
return result.rows.map((row) => row.queue_name);
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Spawns a task execution by enqueueing it for processing. The task will be picked up by a worker
|
|
375
|
+
* and executed with the provided parameters. Returns identifiers that can be used to track or cancel the task.
|
|
376
|
+
*
|
|
377
|
+
* For registered tasks, the queue and defaults are inferred from registration. For unregistered tasks,
|
|
378
|
+
* you must provide options.queue.
|
|
379
|
+
*
|
|
380
|
+
* @param taskName Name of the task to spawn (must be registered or provide options.queue).
|
|
381
|
+
* @param params JSON-serializable parameters passed to the task handler.
|
|
382
|
+
* @param options Configure queue, maxAttempts, retryStrategy, headers, and cancellation policies.
|
|
383
|
+
* @returns Object containing taskID (unique task identifier), runID (current attempt identifier), and attempt number.
|
|
384
|
+
* @throws Error If the task is unregistered without a queue, or if the queue mismatches registration.
|
|
385
|
+
*/
|
|
386
|
+
async spawn(taskName, params, options = {}) {
|
|
387
|
+
const registration = this.registry.get(taskName);
|
|
388
|
+
let queue;
|
|
389
|
+
if (registration) {
|
|
390
|
+
queue = registration.queue;
|
|
391
|
+
if (options.queue !== undefined && options.queue !== registration.queue) {
|
|
392
|
+
throw new Error(`Task "${taskName}" is registered for queue "${registration.queue}" but spawn requested queue "${options.queue}".`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
else if (!options.queue) {
|
|
396
|
+
throw new Error(`Task "${taskName}" is not registered. Provide options.queue when spawning unregistered tasks.`);
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
queue = options.queue;
|
|
400
|
+
}
|
|
401
|
+
const effectiveMaxAttempts = options.maxAttempts !== undefined
|
|
402
|
+
? options.maxAttempts
|
|
403
|
+
: registration?.defaultMaxAttempts ?? this.defaultMaxAttempts;
|
|
404
|
+
const effectiveCancellation = options.cancellation !== undefined
|
|
405
|
+
? options.cancellation
|
|
406
|
+
: registration?.defaultCancellation;
|
|
407
|
+
let effectiveOptions = {
|
|
408
|
+
...options,
|
|
409
|
+
maxAttempts: effectiveMaxAttempts,
|
|
410
|
+
cancellation: effectiveCancellation,
|
|
411
|
+
};
|
|
412
|
+
if (this.hooks.beforeSpawn) {
|
|
413
|
+
effectiveOptions = await this.hooks.beforeSpawn(taskName, params, effectiveOptions);
|
|
414
|
+
}
|
|
415
|
+
const normalizedOptions = normalizeSpawnOptions(effectiveOptions);
|
|
416
|
+
const result = await this.con.query(`SELECT task_id, run_id, attempt, created
|
|
417
|
+
FROM absurd.spawn_task($1, $2, $3, $4)`, [
|
|
418
|
+
queue,
|
|
419
|
+
taskName,
|
|
420
|
+
JSON.stringify(params),
|
|
421
|
+
JSON.stringify(normalizedOptions),
|
|
422
|
+
]);
|
|
423
|
+
if (result.rows.length === 0) {
|
|
424
|
+
throw new Error("Failed to spawn task");
|
|
425
|
+
}
|
|
426
|
+
const row = result.rows[0];
|
|
427
|
+
return {
|
|
428
|
+
taskID: row.task_id,
|
|
429
|
+
runID: row.run_id,
|
|
430
|
+
attempt: row.attempt,
|
|
431
|
+
created: row.created,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Emits an event with an optional payload on the specified or default queue.
|
|
436
|
+
* @param eventName Non-empty event name.
|
|
437
|
+
* @param payload Optional JSON-serializable payload.
|
|
438
|
+
* @param queueName Queue to emit to (defaults to this client's queue).
|
|
439
|
+
*/
|
|
440
|
+
async emitEvent(eventName, payload, queueName) {
|
|
441
|
+
if (!eventName) {
|
|
442
|
+
throw new Error("eventName must be a non-empty string");
|
|
443
|
+
}
|
|
444
|
+
await this.con.query(`SELECT absurd.emit_event($1, $2, $3)`, [
|
|
445
|
+
queueName || this.queueName,
|
|
446
|
+
eventName,
|
|
447
|
+
JSON.stringify(payload ?? null),
|
|
448
|
+
]);
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Cancels a task by ID on the specified or default queue; running tasks stop at the next checkpoint/heartbeat.
|
|
452
|
+
* @param taskID Task identifier to cancel.
|
|
453
|
+
* @param queueName Queue name (defaults to this client's queue).
|
|
454
|
+
*/
|
|
455
|
+
async cancelTask(taskID, queueName) {
|
|
456
|
+
await this.con.query(`SELECT absurd.cancel_task($1, $2)`, [
|
|
457
|
+
queueName || this.queueName,
|
|
458
|
+
taskID,
|
|
459
|
+
]);
|
|
460
|
+
}
|
|
461
|
+
async claimTasks(options) {
|
|
462
|
+
const { batchSize: count = 1, claimTimeout = 120, workerId = "worker", } = options ?? {};
|
|
463
|
+
const result = await this.con.query(`SELECT run_id, task_id, attempt, task_name, params, retry_strategy, max_attempts,
|
|
464
|
+
headers, wake_event, event_payload
|
|
465
|
+
FROM absurd.claim_task($1, $2, $3, $4)`, [this.queueName, workerId, claimTimeout, count]);
|
|
466
|
+
return result.rows;
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Claims up to batchSize tasks and processes them sequentially using the given workerId and claimTimeout.
|
|
470
|
+
* @param workerId Worker identifier.
|
|
471
|
+
* @param claimTimeout Lease duration in seconds.
|
|
472
|
+
* @param batchSize Maximum number of tasks to process.
|
|
473
|
+
* Note: For parallel processing, use startWorker().
|
|
474
|
+
*/
|
|
475
|
+
async workBatch(workerId = "worker", claimTimeout = 120, batchSize = 1) {
|
|
476
|
+
const tasks = await this.claimTasks({ batchSize, claimTimeout, workerId });
|
|
477
|
+
for (const task of tasks) {
|
|
478
|
+
await this.executeTask(task, claimTimeout);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Starts a background worker that continuously polls for and processes tasks from the queue.
|
|
483
|
+
* The worker will claim tasks up to the configured concurrency limit and process them in parallel.
|
|
484
|
+
*
|
|
485
|
+
* @param options Configure worker behavior.
|
|
486
|
+
* @returns Worker instance with close() method for graceful shutdown.
|
|
487
|
+
*/
|
|
488
|
+
async startWorker(options = {}) {
|
|
489
|
+
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;
|
|
490
|
+
const effectiveBatchSize = batchSize ?? concurrency;
|
|
491
|
+
let running = true;
|
|
492
|
+
let workerLoopPromise;
|
|
493
|
+
const executing = new Set();
|
|
494
|
+
let availabilityPromise = null;
|
|
495
|
+
let availabilityResolve = null;
|
|
496
|
+
let sleepTimer = null;
|
|
497
|
+
const notifyAvailability = () => {
|
|
498
|
+
if (sleepTimer) {
|
|
499
|
+
clearTimeout(sleepTimer);
|
|
500
|
+
sleepTimer = null;
|
|
501
|
+
}
|
|
502
|
+
if (availabilityResolve) {
|
|
503
|
+
availabilityResolve();
|
|
504
|
+
availabilityResolve = null;
|
|
505
|
+
availabilityPromise = null;
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
const waitForAvailability = async () => {
|
|
509
|
+
if (!availabilityPromise) {
|
|
510
|
+
availabilityPromise = new Promise((resolve) => {
|
|
511
|
+
availabilityResolve = resolve;
|
|
512
|
+
sleepTimer = setTimeout(() => {
|
|
513
|
+
sleepTimer = null;
|
|
514
|
+
availabilityResolve = null;
|
|
515
|
+
availabilityPromise = null;
|
|
516
|
+
resolve();
|
|
517
|
+
}, pollInterval * 1000);
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
await availabilityPromise;
|
|
521
|
+
};
|
|
522
|
+
const worker = {
|
|
523
|
+
close: async () => {
|
|
524
|
+
running = false;
|
|
525
|
+
await workerLoopPromise;
|
|
526
|
+
},
|
|
527
|
+
};
|
|
528
|
+
this.worker = worker;
|
|
529
|
+
workerLoopPromise = (async () => {
|
|
530
|
+
while (running) {
|
|
531
|
+
try {
|
|
532
|
+
if (executing.size >= concurrency) {
|
|
533
|
+
await waitForAvailability();
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
const availableCapacity = Math.max(concurrency - executing.size, 0);
|
|
537
|
+
const toClaim = Math.min(effectiveBatchSize, availableCapacity);
|
|
538
|
+
if (toClaim <= 0) {
|
|
539
|
+
await waitForAvailability();
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
const messages = await this.claimTasks({
|
|
543
|
+
batchSize: toClaim,
|
|
544
|
+
claimTimeout,
|
|
545
|
+
workerId,
|
|
546
|
+
});
|
|
547
|
+
if (messages.length === 0) {
|
|
548
|
+
await waitForAvailability();
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
for (const task of messages) {
|
|
552
|
+
const promise = this.executeTask(task, claimTimeout, {
|
|
553
|
+
fatalOnLeaseTimeout,
|
|
554
|
+
})
|
|
555
|
+
.catch((err) => onError(err))
|
|
556
|
+
.finally(() => {
|
|
557
|
+
executing.delete(promise);
|
|
558
|
+
notifyAvailability();
|
|
559
|
+
});
|
|
560
|
+
executing.add(promise);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
catch (err) {
|
|
564
|
+
onError(err);
|
|
565
|
+
await waitForAvailability();
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
await Promise.allSettled(executing);
|
|
569
|
+
})();
|
|
570
|
+
return worker;
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Stops any running worker and closes the underlying connection if owned.
|
|
574
|
+
*/
|
|
575
|
+
async close() {
|
|
576
|
+
if (this.worker) {
|
|
577
|
+
await this.worker.close();
|
|
578
|
+
}
|
|
579
|
+
if (this.ownedConnection) {
|
|
580
|
+
await closeIfSupported(this.con);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
async executeTask(task, claimTimeout, options) {
|
|
584
|
+
const registration = this.registry.get(task.task_name);
|
|
585
|
+
const taskLabel = `${task.task_name} (${task.task_id})`;
|
|
586
|
+
const leaseTimer = new LeaseTimerManager(this.log, taskLabel, options?.fatalOnLeaseTimeout ?? false);
|
|
587
|
+
const ctx = await TaskContext.create({
|
|
588
|
+
log: this.log,
|
|
589
|
+
taskID: task.task_id,
|
|
590
|
+
con: this.con,
|
|
591
|
+
queueName: registration?.queue ?? "unknown",
|
|
592
|
+
task,
|
|
593
|
+
claimTimeout,
|
|
594
|
+
leaseTimer,
|
|
595
|
+
});
|
|
596
|
+
leaseTimer.update(claimTimeout);
|
|
597
|
+
try {
|
|
598
|
+
if (!registration) {
|
|
599
|
+
throw new Error("Unknown task");
|
|
600
|
+
}
|
|
601
|
+
else if (registration.queue !== this.queueName) {
|
|
602
|
+
throw new Error("Misconfigured task (queue mismatch)");
|
|
603
|
+
}
|
|
604
|
+
const execute = async () => {
|
|
605
|
+
const result = await registration.handler(task.params, ctx);
|
|
606
|
+
await completeTaskRun(this.con, this.queueName, task.run_id, result);
|
|
607
|
+
};
|
|
608
|
+
if (this.hooks.wrapTaskExecution) {
|
|
609
|
+
await this.hooks.wrapTaskExecution(ctx, execute);
|
|
610
|
+
}
|
|
611
|
+
else {
|
|
612
|
+
await execute();
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
catch (err) {
|
|
616
|
+
if (err instanceof SuspendTask || err instanceof CancelledTask) {
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
this.log.error("[absurd] task execution failed:", err);
|
|
620
|
+
await failTaskRun(this.con, this.queueName, task.run_id, err);
|
|
621
|
+
}
|
|
622
|
+
finally {
|
|
623
|
+
leaseTimer.stop();
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
function isQueryable(value) {
|
|
628
|
+
return (typeof value === "object" &&
|
|
629
|
+
value !== null &&
|
|
630
|
+
typeof value.query === "function");
|
|
631
|
+
}
|
|
632
|
+
async function closeIfSupported(con) {
|
|
633
|
+
const maybeClose = con.close;
|
|
634
|
+
if (typeof maybeClose === "function") {
|
|
635
|
+
await maybeClose.call(con);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
function serializeError(err) {
|
|
639
|
+
if (err instanceof Error) {
|
|
640
|
+
return {
|
|
641
|
+
name: err.name,
|
|
642
|
+
message: err.message,
|
|
643
|
+
stack: err.stack ?? null,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
return { message: String(err) };
|
|
647
|
+
}
|
|
648
|
+
async function completeTaskRun(con, queueName, runID, result) {
|
|
649
|
+
await con.query(`SELECT absurd.complete_run($1, $2, $3)`, [
|
|
650
|
+
queueName,
|
|
651
|
+
runID,
|
|
652
|
+
JSON.stringify(result ?? null),
|
|
653
|
+
]);
|
|
654
|
+
}
|
|
655
|
+
async function failTaskRun(con, queueName, runID, err) {
|
|
656
|
+
await con.query(`SELECT absurd.fail_run($1, $2, $3, $4)`, [
|
|
657
|
+
queueName,
|
|
658
|
+
runID,
|
|
659
|
+
JSON.stringify(serializeError(err)),
|
|
660
|
+
null,
|
|
661
|
+
]);
|
|
662
|
+
}
|
|
663
|
+
function normalizeSpawnOptions(options) {
|
|
664
|
+
const normalized = {};
|
|
665
|
+
if (options.headers !== undefined) {
|
|
666
|
+
normalized.headers = options.headers;
|
|
667
|
+
}
|
|
668
|
+
if (options.maxAttempts !== undefined) {
|
|
669
|
+
normalized.max_attempts = options.maxAttempts;
|
|
670
|
+
}
|
|
671
|
+
if (options.retryStrategy) {
|
|
672
|
+
normalized.retry_strategy = serializeRetryStrategy(options.retryStrategy);
|
|
673
|
+
}
|
|
674
|
+
const cancellation = normalizeCancellation(options.cancellation);
|
|
675
|
+
if (cancellation) {
|
|
676
|
+
normalized.cancellation = cancellation;
|
|
677
|
+
}
|
|
678
|
+
if (options.idempotencyKey !== undefined) {
|
|
679
|
+
normalized.idempotency_key = options.idempotencyKey;
|
|
680
|
+
}
|
|
681
|
+
return normalized;
|
|
682
|
+
}
|
|
683
|
+
function serializeRetryStrategy(strategy) {
|
|
684
|
+
const serialized = {
|
|
685
|
+
kind: strategy.kind,
|
|
686
|
+
};
|
|
687
|
+
if (strategy.baseSeconds !== undefined) {
|
|
688
|
+
serialized.base_seconds = strategy.baseSeconds;
|
|
689
|
+
}
|
|
690
|
+
if (strategy.factor !== undefined) {
|
|
691
|
+
serialized.factor = strategy.factor;
|
|
692
|
+
}
|
|
693
|
+
if (strategy.maxSeconds !== undefined) {
|
|
694
|
+
serialized.max_seconds = strategy.maxSeconds;
|
|
695
|
+
}
|
|
696
|
+
return serialized;
|
|
697
|
+
}
|
|
698
|
+
function normalizeCancellation(policy) {
|
|
699
|
+
if (!policy) {
|
|
700
|
+
return undefined;
|
|
701
|
+
}
|
|
702
|
+
const normalized = {};
|
|
703
|
+
if (policy.maxDuration !== undefined) {
|
|
704
|
+
normalized.max_duration = Math.floor(policy.maxDuration.total("seconds"));
|
|
705
|
+
}
|
|
706
|
+
if (policy.maxDelay !== undefined) {
|
|
707
|
+
normalized.max_delay = Math.floor(policy.maxDelay.total("seconds"));
|
|
708
|
+
}
|
|
709
|
+
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
|
710
|
+
}
|
|
711
|
+
//# sourceMappingURL=absurd.js.map
|