@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/src/absurd.ts
ADDED
|
@@ -0,0 +1,1030 @@
|
|
|
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
|
+
/**
|
|
14
|
+
* Minimal query interface compatible with Absurd's database operations.
|
|
15
|
+
*/
|
|
16
|
+
export interface Queryable {
|
|
17
|
+
query<R extends object = Record<string, any>>(
|
|
18
|
+
sql: string,
|
|
19
|
+
params?: unknown[] | Record<string, unknown>
|
|
20
|
+
): Promise<{ rows: R[] }>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type JsonValue =
|
|
24
|
+
| string
|
|
25
|
+
| number
|
|
26
|
+
| boolean
|
|
27
|
+
| null
|
|
28
|
+
| JsonValue[]
|
|
29
|
+
| { [key: string]: JsonValue };
|
|
30
|
+
|
|
31
|
+
export type JsonObject = {
|
|
32
|
+
[key: string]: JsonValue;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export interface RetryStrategy {
|
|
36
|
+
kind: "fixed" | "exponential" | "none";
|
|
37
|
+
baseSeconds?: number;
|
|
38
|
+
factor?: number;
|
|
39
|
+
maxSeconds?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CancellationPolicy {
|
|
43
|
+
maxDuration?: Temporal.Duration;
|
|
44
|
+
maxDelay?: Temporal.Duration;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SpawnOptions {
|
|
48
|
+
maxAttempts?: number;
|
|
49
|
+
retryStrategy?: RetryStrategy;
|
|
50
|
+
headers?: JsonObject;
|
|
51
|
+
queue?: string;
|
|
52
|
+
cancellation?: CancellationPolicy;
|
|
53
|
+
idempotencyKey?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ClaimedTask {
|
|
57
|
+
run_id: string;
|
|
58
|
+
task_id: string;
|
|
59
|
+
task_name: string;
|
|
60
|
+
attempt: number;
|
|
61
|
+
params: JsonValue;
|
|
62
|
+
retry_strategy: JsonValue;
|
|
63
|
+
max_attempts: number | null;
|
|
64
|
+
headers: JsonObject | null;
|
|
65
|
+
wake_event: string | null;
|
|
66
|
+
event_payload: JsonValue | null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface WorkerOptions {
|
|
70
|
+
workerId?: string;
|
|
71
|
+
claimTimeout?: number;
|
|
72
|
+
batchSize?: number;
|
|
73
|
+
concurrency?: number;
|
|
74
|
+
pollInterval?: number;
|
|
75
|
+
onError?: (error: Error) => void;
|
|
76
|
+
fatalOnLeaseTimeout?: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface Worker {
|
|
80
|
+
close(): Promise<void>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface SpawnResult {
|
|
84
|
+
taskID: string;
|
|
85
|
+
runID: string;
|
|
86
|
+
attempt: number;
|
|
87
|
+
created: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type TaskHandler<P = any, R = any> = (
|
|
91
|
+
params: P,
|
|
92
|
+
ctx: TaskContext
|
|
93
|
+
) => Promise<R>;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Internal exception that is thrown to suspend a run.
|
|
97
|
+
*/
|
|
98
|
+
export class SuspendTask extends Error {
|
|
99
|
+
constructor() {
|
|
100
|
+
super("Task suspended");
|
|
101
|
+
this.name = "SuspendTask";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Internal exception that is thrown to cancel a run.
|
|
107
|
+
*/
|
|
108
|
+
export class CancelledTask extends Error {
|
|
109
|
+
constructor() {
|
|
110
|
+
super("Task cancelled");
|
|
111
|
+
this.name = "CancelledTask";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* This error is thrown when awaiting an event ran into a timeout.
|
|
117
|
+
*/
|
|
118
|
+
export class TimeoutError extends Error {
|
|
119
|
+
constructor(message: string) {
|
|
120
|
+
super(message);
|
|
121
|
+
this.name = "TimeoutError";
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface TaskRegistrationOptions {
|
|
126
|
+
name: string;
|
|
127
|
+
queue?: string;
|
|
128
|
+
defaultMaxAttempts?: number;
|
|
129
|
+
defaultCancellation?: CancellationPolicy;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface Log {
|
|
133
|
+
log(...args: any[]): void;
|
|
134
|
+
info(...args: any[]): void;
|
|
135
|
+
warn(...args: any[]): void;
|
|
136
|
+
error(...args: any[]): void;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Hooks for customizing Absurd behavior.
|
|
141
|
+
*/
|
|
142
|
+
export interface AbsurdHooks {
|
|
143
|
+
/**
|
|
144
|
+
* Called before spawning a task. Can modify spawn options (including headers).
|
|
145
|
+
* Use this to inject trace IDs or correlation IDs into headers.
|
|
146
|
+
*/
|
|
147
|
+
beforeSpawn?: (
|
|
148
|
+
taskName: string,
|
|
149
|
+
params: JsonValue,
|
|
150
|
+
options: SpawnOptions
|
|
151
|
+
) => SpawnOptions | Promise<SpawnOptions>;
|
|
152
|
+
/**
|
|
153
|
+
* Wraps task execution. Must call and return the result of execute().
|
|
154
|
+
* Use this to restore context before the task handler runs.
|
|
155
|
+
*/
|
|
156
|
+
wrapTaskExecution?: <T>(
|
|
157
|
+
ctx: TaskContext,
|
|
158
|
+
execute: () => Promise<T>
|
|
159
|
+
) => Promise<T>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Configuration for the Absurd client.
|
|
164
|
+
*/
|
|
165
|
+
export interface AbsurdOptions {
|
|
166
|
+
db: Queryable;
|
|
167
|
+
queueName?: string;
|
|
168
|
+
defaultMaxAttempts?: number;
|
|
169
|
+
log?: Log;
|
|
170
|
+
hooks?: AbsurdHooks;
|
|
171
|
+
ownedConnection?: boolean;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
class LeaseTimerManager {
|
|
175
|
+
private warnTimer: NodeJS.Timeout | null = null;
|
|
176
|
+
private fatalTimer: NodeJS.Timeout | null = null;
|
|
177
|
+
|
|
178
|
+
constructor(
|
|
179
|
+
private readonly log: Log,
|
|
180
|
+
private readonly taskLabel: string,
|
|
181
|
+
private readonly fatalOnLeaseTimeout: boolean
|
|
182
|
+
) {}
|
|
183
|
+
|
|
184
|
+
update(leaseSeconds: number): void {
|
|
185
|
+
this.clear();
|
|
186
|
+
if (leaseSeconds <= 0) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
this.warnTimer = setTimeout(() => {
|
|
190
|
+
this.log.warn(
|
|
191
|
+
`task ${this.taskLabel} exceeded claim timeout of ${leaseSeconds}s`
|
|
192
|
+
);
|
|
193
|
+
}, leaseSeconds * 1000);
|
|
194
|
+
if (this.fatalOnLeaseTimeout) {
|
|
195
|
+
this.fatalTimer = setTimeout(() => {
|
|
196
|
+
this.log.error(
|
|
197
|
+
`task ${this.taskLabel} exceeded claim timeout of ${leaseSeconds}s by more than 100%; terminating process`
|
|
198
|
+
);
|
|
199
|
+
process.exit(1);
|
|
200
|
+
}, leaseSeconds * 1000 * 2);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
stop(): void {
|
|
205
|
+
this.clear();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private clear(): void {
|
|
209
|
+
if (this.warnTimer) {
|
|
210
|
+
clearTimeout(this.warnTimer);
|
|
211
|
+
this.warnTimer = null;
|
|
212
|
+
}
|
|
213
|
+
if (this.fatalTimer) {
|
|
214
|
+
clearTimeout(this.fatalTimer);
|
|
215
|
+
this.fatalTimer = null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Execution context passed to task handlers.
|
|
222
|
+
*/
|
|
223
|
+
export class TaskContext {
|
|
224
|
+
private readonly log: Log;
|
|
225
|
+
readonly taskID: string;
|
|
226
|
+
private readonly con: Queryable;
|
|
227
|
+
private readonly queueName: string;
|
|
228
|
+
private readonly task: ClaimedTask;
|
|
229
|
+
private readonly checkpointCache: Map<string, JsonValue>;
|
|
230
|
+
private readonly claimTimeout: number;
|
|
231
|
+
private readonly leaseTimer: LeaseTimerManager;
|
|
232
|
+
private stepNameCounter = new Map<string, number>();
|
|
233
|
+
|
|
234
|
+
private constructor(
|
|
235
|
+
log: Log,
|
|
236
|
+
taskID: string,
|
|
237
|
+
con: Queryable,
|
|
238
|
+
queueName: string,
|
|
239
|
+
task: ClaimedTask,
|
|
240
|
+
checkpointCache: Map<string, JsonValue>,
|
|
241
|
+
claimTimeout: number,
|
|
242
|
+
leaseTimer: LeaseTimerManager
|
|
243
|
+
) {
|
|
244
|
+
this.log = log;
|
|
245
|
+
this.taskID = taskID;
|
|
246
|
+
this.con = con;
|
|
247
|
+
this.queueName = queueName;
|
|
248
|
+
this.task = task;
|
|
249
|
+
this.checkpointCache = checkpointCache;
|
|
250
|
+
this.claimTimeout = claimTimeout;
|
|
251
|
+
this.leaseTimer = leaseTimer;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Returns all headers attached to this task.
|
|
256
|
+
*/
|
|
257
|
+
get headers(): Readonly<JsonObject> {
|
|
258
|
+
return this.task.headers ?? {};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
static async create(args: {
|
|
262
|
+
log: Log;
|
|
263
|
+
taskID: string;
|
|
264
|
+
con: Queryable;
|
|
265
|
+
queueName: string;
|
|
266
|
+
task: ClaimedTask;
|
|
267
|
+
claimTimeout: number;
|
|
268
|
+
leaseTimer: LeaseTimerManager;
|
|
269
|
+
}): Promise<TaskContext> {
|
|
270
|
+
const { log, taskID, con, queueName, task, claimTimeout, leaseTimer } =
|
|
271
|
+
args;
|
|
272
|
+
const result = await con.query<{
|
|
273
|
+
checkpoint_name: string;
|
|
274
|
+
state: JsonValue;
|
|
275
|
+
status: string;
|
|
276
|
+
owner_run_id: string;
|
|
277
|
+
updated_at: string;
|
|
278
|
+
}>(
|
|
279
|
+
`SELECT checkpoint_name, state, status, owner_run_id, updated_at
|
|
280
|
+
FROM absurd.get_task_checkpoint_states($1, $2, $3)`,
|
|
281
|
+
[queueName, task.task_id, task.run_id]
|
|
282
|
+
);
|
|
283
|
+
const cache = new Map<string, JsonValue>();
|
|
284
|
+
for (const row of result.rows) {
|
|
285
|
+
cache.set(row.checkpoint_name, row.state);
|
|
286
|
+
}
|
|
287
|
+
return new TaskContext(
|
|
288
|
+
log,
|
|
289
|
+
taskID,
|
|
290
|
+
con,
|
|
291
|
+
queueName,
|
|
292
|
+
task,
|
|
293
|
+
cache,
|
|
294
|
+
claimTimeout,
|
|
295
|
+
leaseTimer
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Runs an idempotent step identified by name; caches and reuses its result across retries.
|
|
301
|
+
* @param name Unique checkpoint name for this step.
|
|
302
|
+
* @param fn Async function computing the step result (must be JSON-serializable).
|
|
303
|
+
*/
|
|
304
|
+
async step<T extends JsonValue>(
|
|
305
|
+
name: string,
|
|
306
|
+
fn: () => Promise<T>
|
|
307
|
+
): Promise<T> {
|
|
308
|
+
const checkpointName = this.getCheckpointName(name);
|
|
309
|
+
const state = await this.lookupCheckpoint(checkpointName);
|
|
310
|
+
if (state !== undefined) {
|
|
311
|
+
return state as T;
|
|
312
|
+
}
|
|
313
|
+
const rv = await fn();
|
|
314
|
+
await this.persistCheckpoint(checkpointName, rv);
|
|
315
|
+
return rv;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Suspends the task until the given duration elapses.
|
|
320
|
+
* @param stepName Checkpoint name for this wait.
|
|
321
|
+
* @param duration Duration to wait.
|
|
322
|
+
*/
|
|
323
|
+
async sleepFor(stepName: string, duration: Temporal.Duration): Promise<void> {
|
|
324
|
+
const now = Temporal.Now.instant();
|
|
325
|
+
const wakeAt = now.add(duration);
|
|
326
|
+
return await this.sleepUntil(stepName, wakeAt);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Suspends the task until the specified time.
|
|
331
|
+
* @param stepName Checkpoint name for this wait.
|
|
332
|
+
* @param wakeAt Absolute time when the task should resume.
|
|
333
|
+
*/
|
|
334
|
+
async sleepUntil(stepName: string, wakeAt: Temporal.Instant): Promise<void> {
|
|
335
|
+
const checkpointName = this.getCheckpointName(stepName);
|
|
336
|
+
const state = await this.lookupCheckpoint(checkpointName);
|
|
337
|
+
const actualWakeAt = typeof state === "string" ? Temporal.Instant.from(state) : wakeAt;
|
|
338
|
+
if (!state) {
|
|
339
|
+
await this.persistCheckpoint(checkpointName, wakeAt.toString());
|
|
340
|
+
}
|
|
341
|
+
if (Temporal.Instant.compare(Temporal.Now.instant(), actualWakeAt) < 0) {
|
|
342
|
+
await this.scheduleRun(actualWakeAt);
|
|
343
|
+
throw new SuspendTask();
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private getCheckpointName(name: string): string {
|
|
348
|
+
const count = (this.stepNameCounter.get(name) ?? 0) + 1;
|
|
349
|
+
this.stepNameCounter.set(name, count);
|
|
350
|
+
return count === 1 ? name : `${name}#${count}`;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private async lookupCheckpoint(
|
|
354
|
+
checkpointName: string
|
|
355
|
+
): Promise<JsonValue | undefined> {
|
|
356
|
+
const cached = this.checkpointCache.get(checkpointName);
|
|
357
|
+
if (cached !== undefined) {
|
|
358
|
+
return cached;
|
|
359
|
+
}
|
|
360
|
+
const result = await this.con.query<{
|
|
361
|
+
checkpoint_name: string;
|
|
362
|
+
state: JsonValue;
|
|
363
|
+
status: string;
|
|
364
|
+
owner_run_id: string;
|
|
365
|
+
updated_at: string;
|
|
366
|
+
}>(
|
|
367
|
+
`SELECT checkpoint_name, state, status, owner_run_id, updated_at
|
|
368
|
+
FROM absurd.get_task_checkpoint_state($1, $2, $3)`,
|
|
369
|
+
[this.queueName, this.task.task_id, checkpointName]
|
|
370
|
+
);
|
|
371
|
+
if (result.rows.length > 0) {
|
|
372
|
+
const state = result.rows[0].state;
|
|
373
|
+
this.checkpointCache.set(checkpointName, state);
|
|
374
|
+
return state;
|
|
375
|
+
}
|
|
376
|
+
return undefined;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private async persistCheckpoint(
|
|
380
|
+
checkpointName: string,
|
|
381
|
+
value: JsonValue
|
|
382
|
+
): Promise<void> {
|
|
383
|
+
await this.con.query(`SELECT absurd.set_task_checkpoint_state($1, $2, $3, $4, $5, $6)`, [
|
|
384
|
+
this.queueName,
|
|
385
|
+
this.task.task_id,
|
|
386
|
+
checkpointName,
|
|
387
|
+
JSON.stringify(value),
|
|
388
|
+
this.task.run_id,
|
|
389
|
+
this.claimTimeout,
|
|
390
|
+
]);
|
|
391
|
+
this.checkpointCache.set(checkpointName, value);
|
|
392
|
+
this.recordLeaseExtension(this.claimTimeout);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private async scheduleRun(wakeAt: Temporal.Instant): Promise<void> {
|
|
396
|
+
await this.con.query(`SELECT absurd.schedule_run($1, $2, $3)`, [
|
|
397
|
+
this.queueName,
|
|
398
|
+
this.task.run_id,
|
|
399
|
+
wakeAt,
|
|
400
|
+
]);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Waits for an event by name and returns its payload; optionally sets a custom step name and timeout.
|
|
405
|
+
* @param eventName Event identifier to wait for.
|
|
406
|
+
* @param options.stepName Optional checkpoint name (defaults to $awaitEvent:<eventName>).
|
|
407
|
+
* @param options.timeout Optional timeout duration.
|
|
408
|
+
* @throws TimeoutError If the event is not received before the timeout.
|
|
409
|
+
*/
|
|
410
|
+
async awaitEvent(
|
|
411
|
+
eventName: string,
|
|
412
|
+
options?: { stepName?: string; timeout?: Temporal.Duration }
|
|
413
|
+
): Promise<JsonValue> {
|
|
414
|
+
const stepName = options?.stepName || `$awaitEvent:${eventName}`;
|
|
415
|
+
let timeout: number | null = null;
|
|
416
|
+
if (options?.timeout !== undefined) {
|
|
417
|
+
timeout = Math.floor(options.timeout.total("seconds"));
|
|
418
|
+
}
|
|
419
|
+
const checkpointName = this.getCheckpointName(stepName);
|
|
420
|
+
const cached = await this.lookupCheckpoint(checkpointName);
|
|
421
|
+
if (cached !== undefined) {
|
|
422
|
+
return cached;
|
|
423
|
+
}
|
|
424
|
+
if (
|
|
425
|
+
this.task.wake_event === eventName &&
|
|
426
|
+
(this.task.event_payload === null ||
|
|
427
|
+
this.task.event_payload === undefined)
|
|
428
|
+
) {
|
|
429
|
+
this.task.wake_event = null;
|
|
430
|
+
this.task.event_payload = null;
|
|
431
|
+
throw new TimeoutError(`Timed out waiting for event "${eventName}"`);
|
|
432
|
+
}
|
|
433
|
+
const result = await this.con.query<{ should_suspend: number; payload: JsonValue }>(
|
|
434
|
+
`SELECT should_suspend, payload
|
|
435
|
+
FROM absurd.await_event($1, $2, $3, $4, $5, $6)`,
|
|
436
|
+
[
|
|
437
|
+
this.queueName,
|
|
438
|
+
this.task.task_id,
|
|
439
|
+
this.task.run_id,
|
|
440
|
+
checkpointName,
|
|
441
|
+
eventName,
|
|
442
|
+
timeout,
|
|
443
|
+
]
|
|
444
|
+
);
|
|
445
|
+
if (result.rows.length === 0) {
|
|
446
|
+
throw new Error("Failed to await event");
|
|
447
|
+
}
|
|
448
|
+
const { should_suspend, payload } = result.rows[0];
|
|
449
|
+
if (!should_suspend) {
|
|
450
|
+
this.checkpointCache.set(checkpointName, payload);
|
|
451
|
+
this.task.event_payload = null;
|
|
452
|
+
return payload;
|
|
453
|
+
}
|
|
454
|
+
throw new SuspendTask();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Extends the current run's lease by the given seconds (defaults to the original claim timeout).
|
|
459
|
+
* @param seconds Lease extension in seconds.
|
|
460
|
+
*/
|
|
461
|
+
async heartbeat(seconds?: number): Promise<void> {
|
|
462
|
+
const leaseSeconds = seconds ?? this.claimTimeout;
|
|
463
|
+
await this.con.query(`SELECT absurd.extend_claim($1, $2, $3)`, [
|
|
464
|
+
this.queueName,
|
|
465
|
+
this.task.run_id,
|
|
466
|
+
leaseSeconds,
|
|
467
|
+
]);
|
|
468
|
+
this.recordLeaseExtension(leaseSeconds);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private recordLeaseExtension(leaseSeconds: number): void {
|
|
472
|
+
this.leaseTimer.update(leaseSeconds);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Emits an event to this task's queue with an optional payload.
|
|
477
|
+
* @param eventName Non-empty event name.
|
|
478
|
+
* @param payload Optional JSON-serializable payload.
|
|
479
|
+
*/
|
|
480
|
+
async emitEvent(eventName: string, payload?: JsonValue): Promise<void> {
|
|
481
|
+
if (!eventName) {
|
|
482
|
+
throw new Error("eventName must be a non-empty string");
|
|
483
|
+
}
|
|
484
|
+
await this.con.query(`SELECT absurd.emit_event($1, $2, $3)`, [
|
|
485
|
+
this.queueName,
|
|
486
|
+
eventName,
|
|
487
|
+
JSON.stringify(payload ?? null),
|
|
488
|
+
]);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* The Absurd SDK client.
|
|
494
|
+
*/
|
|
495
|
+
export class AbsurdImpl {
|
|
496
|
+
private readonly con: Queryable;
|
|
497
|
+
private readonly ownedConnection: boolean;
|
|
498
|
+
private readonly queueName: string;
|
|
499
|
+
private readonly defaultMaxAttempts: number;
|
|
500
|
+
private registry = new Map<string, RegisteredTask>();
|
|
501
|
+
private readonly log: Log;
|
|
502
|
+
private worker: Worker | null = null;
|
|
503
|
+
private readonly hooks: AbsurdHooks;
|
|
504
|
+
|
|
505
|
+
constructor(options: AbsurdOptions | Queryable) {
|
|
506
|
+
if (isQueryable(options)) {
|
|
507
|
+
options = { db: options };
|
|
508
|
+
}
|
|
509
|
+
if (!options.db) {
|
|
510
|
+
throw new Error("Absurd requires a database connection");
|
|
511
|
+
}
|
|
512
|
+
this.con = options.db;
|
|
513
|
+
this.ownedConnection = options.ownedConnection ?? false;
|
|
514
|
+
this.queueName = options.queueName ?? "default";
|
|
515
|
+
this.defaultMaxAttempts = options.defaultMaxAttempts ?? 5;
|
|
516
|
+
this.log = options.log ?? console;
|
|
517
|
+
this.hooks = options.hooks ?? {};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Returns a new client that uses the provided connection for queries; set owned=true to close it with close().
|
|
522
|
+
* @param con Connection to bind to.
|
|
523
|
+
* @param owned If true, the bound client will close this connection on close().
|
|
524
|
+
*/
|
|
525
|
+
bindToConnection(con: Queryable, owned = false): AbsurdImpl {
|
|
526
|
+
const bound = new AbsurdImpl({
|
|
527
|
+
db: con,
|
|
528
|
+
queueName: this.queueName,
|
|
529
|
+
defaultMaxAttempts: this.defaultMaxAttempts,
|
|
530
|
+
log: this.log,
|
|
531
|
+
hooks: this.hooks,
|
|
532
|
+
ownedConnection: owned,
|
|
533
|
+
});
|
|
534
|
+
bound.registry = this.registry;
|
|
535
|
+
return bound;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Registers a task handler by name (optionally specifying queue, defaultMaxAttempts, and defaultCancellation).
|
|
540
|
+
* @param options.name Task name.
|
|
541
|
+
* @param options.queue Optional queue name (defaults to client queue).
|
|
542
|
+
* @param options.defaultMaxAttempts Optional default max attempts.
|
|
543
|
+
* @param options.defaultCancellation Optional default cancellation policy.
|
|
544
|
+
* @param handler Async task handler.
|
|
545
|
+
*/
|
|
546
|
+
registerTask<P = any, R = any>(
|
|
547
|
+
options: TaskRegistrationOptions,
|
|
548
|
+
handler: TaskHandler<P, R>
|
|
549
|
+
): void {
|
|
550
|
+
if (!options?.name) {
|
|
551
|
+
throw new Error("Task registration requires a name");
|
|
552
|
+
}
|
|
553
|
+
if (
|
|
554
|
+
options.defaultMaxAttempts !== undefined &&
|
|
555
|
+
options.defaultMaxAttempts < 1
|
|
556
|
+
) {
|
|
557
|
+
throw new Error("defaultMaxAttempts must be at least 1");
|
|
558
|
+
}
|
|
559
|
+
if (options.defaultCancellation) {
|
|
560
|
+
normalizeCancellation(options.defaultCancellation);
|
|
561
|
+
}
|
|
562
|
+
const queue = options.queue ?? this.queueName;
|
|
563
|
+
if (!queue) {
|
|
564
|
+
throw new Error(
|
|
565
|
+
`Task "${options.name}" must specify a queue or use a client with a default queue`
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
this.registry.set(options.name, {
|
|
569
|
+
name: options.name,
|
|
570
|
+
queue,
|
|
571
|
+
defaultMaxAttempts: options.defaultMaxAttempts,
|
|
572
|
+
defaultCancellation: options.defaultCancellation,
|
|
573
|
+
handler,
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Creates a queue (defaults to this client's queue).
|
|
579
|
+
* @param queueName Queue name to create.
|
|
580
|
+
*/
|
|
581
|
+
async createQueue(queueName?: string): Promise<void> {
|
|
582
|
+
const queue = queueName ?? this.queueName;
|
|
583
|
+
await this.con.query(`SELECT absurd.create_queue($1)`, [queue]);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Drops a queue (defaults to this client's queue).
|
|
588
|
+
* @param queueName Queue name to drop.
|
|
589
|
+
*/
|
|
590
|
+
async dropQueue(queueName?: string): Promise<void> {
|
|
591
|
+
const queue = queueName ?? this.queueName;
|
|
592
|
+
await this.con.query(`SELECT absurd.drop_queue($1)`, [queue]);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Lists all queue names.
|
|
597
|
+
* @returns Array of queue names.
|
|
598
|
+
*/
|
|
599
|
+
async listQueues(): Promise<Array<string>> {
|
|
600
|
+
const result = await this.con.query<{ queue_name: string }>(
|
|
601
|
+
`SELECT * FROM absurd.list_queues()`
|
|
602
|
+
);
|
|
603
|
+
return result.rows.map((row) => row.queue_name);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Spawns a task execution by enqueueing it for processing. The task will be picked up by a worker
|
|
608
|
+
* and executed with the provided parameters. Returns identifiers that can be used to track or cancel the task.
|
|
609
|
+
*
|
|
610
|
+
* For registered tasks, the queue and defaults are inferred from registration. For unregistered tasks,
|
|
611
|
+
* you must provide options.queue.
|
|
612
|
+
*
|
|
613
|
+
* @param taskName Name of the task to spawn (must be registered or provide options.queue).
|
|
614
|
+
* @param params JSON-serializable parameters passed to the task handler.
|
|
615
|
+
* @param options Configure queue, maxAttempts, retryStrategy, headers, and cancellation policies.
|
|
616
|
+
* @returns Object containing taskID (unique task identifier), runID (current attempt identifier), and attempt number.
|
|
617
|
+
* @throws Error If the task is unregistered without a queue, or if the queue mismatches registration.
|
|
618
|
+
*/
|
|
619
|
+
async spawn<P = any>(
|
|
620
|
+
taskName: string,
|
|
621
|
+
params: P,
|
|
622
|
+
options: SpawnOptions = {}
|
|
623
|
+
): Promise<SpawnResult> {
|
|
624
|
+
const registration = this.registry.get(taskName);
|
|
625
|
+
let queue: string;
|
|
626
|
+
if (registration) {
|
|
627
|
+
queue = registration.queue;
|
|
628
|
+
if (options.queue !== undefined && options.queue !== registration.queue) {
|
|
629
|
+
throw new Error(
|
|
630
|
+
`Task "${taskName}" is registered for queue "${registration.queue}" but spawn requested queue "${options.queue}".`
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
} else if (!options.queue) {
|
|
634
|
+
throw new Error(
|
|
635
|
+
`Task "${taskName}" is not registered. Provide options.queue when spawning unregistered tasks.`
|
|
636
|
+
);
|
|
637
|
+
} else {
|
|
638
|
+
queue = options.queue;
|
|
639
|
+
}
|
|
640
|
+
const effectiveMaxAttempts =
|
|
641
|
+
options.maxAttempts !== undefined
|
|
642
|
+
? options.maxAttempts
|
|
643
|
+
: registration?.defaultMaxAttempts ?? this.defaultMaxAttempts;
|
|
644
|
+
const effectiveCancellation =
|
|
645
|
+
options.cancellation !== undefined
|
|
646
|
+
? options.cancellation
|
|
647
|
+
: registration?.defaultCancellation;
|
|
648
|
+
let effectiveOptions: SpawnOptions = {
|
|
649
|
+
...options,
|
|
650
|
+
maxAttempts: effectiveMaxAttempts,
|
|
651
|
+
cancellation: effectiveCancellation,
|
|
652
|
+
};
|
|
653
|
+
if (this.hooks.beforeSpawn) {
|
|
654
|
+
effectiveOptions = await this.hooks.beforeSpawn(
|
|
655
|
+
taskName,
|
|
656
|
+
params as JsonValue,
|
|
657
|
+
effectiveOptions
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
const normalizedOptions = normalizeSpawnOptions(effectiveOptions);
|
|
661
|
+
const result = await this.con.query<{
|
|
662
|
+
task_id: string;
|
|
663
|
+
run_id: string;
|
|
664
|
+
attempt: number;
|
|
665
|
+
created: boolean;
|
|
666
|
+
}>(
|
|
667
|
+
`SELECT task_id, run_id, attempt, created
|
|
668
|
+
FROM absurd.spawn_task($1, $2, $3, $4)`,
|
|
669
|
+
[
|
|
670
|
+
queue,
|
|
671
|
+
taskName,
|
|
672
|
+
JSON.stringify(params),
|
|
673
|
+
JSON.stringify(normalizedOptions),
|
|
674
|
+
]
|
|
675
|
+
);
|
|
676
|
+
if (result.rows.length === 0) {
|
|
677
|
+
throw new Error("Failed to spawn task");
|
|
678
|
+
}
|
|
679
|
+
const row = result.rows[0];
|
|
680
|
+
return {
|
|
681
|
+
taskID: row.task_id,
|
|
682
|
+
runID: row.run_id,
|
|
683
|
+
attempt: row.attempt,
|
|
684
|
+
created: row.created,
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Emits an event with an optional payload on the specified or default queue.
|
|
690
|
+
* @param eventName Non-empty event name.
|
|
691
|
+
* @param payload Optional JSON-serializable payload.
|
|
692
|
+
* @param queueName Queue to emit to (defaults to this client's queue).
|
|
693
|
+
*/
|
|
694
|
+
async emitEvent(
|
|
695
|
+
eventName: string,
|
|
696
|
+
payload?: JsonValue,
|
|
697
|
+
queueName?: string
|
|
698
|
+
): Promise<void> {
|
|
699
|
+
if (!eventName) {
|
|
700
|
+
throw new Error("eventName must be a non-empty string");
|
|
701
|
+
}
|
|
702
|
+
await this.con.query(`SELECT absurd.emit_event($1, $2, $3)`, [
|
|
703
|
+
queueName || this.queueName,
|
|
704
|
+
eventName,
|
|
705
|
+
JSON.stringify(payload ?? null),
|
|
706
|
+
]);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Cancels a task by ID on the specified or default queue; running tasks stop at the next checkpoint/heartbeat.
|
|
711
|
+
* @param taskID Task identifier to cancel.
|
|
712
|
+
* @param queueName Queue name (defaults to this client's queue).
|
|
713
|
+
*/
|
|
714
|
+
async cancelTask(taskID: string, queueName?: string): Promise<void> {
|
|
715
|
+
await this.con.query(`SELECT absurd.cancel_task($1, $2)`, [
|
|
716
|
+
queueName || this.queueName,
|
|
717
|
+
taskID,
|
|
718
|
+
]);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
async claimTasks(options?: {
|
|
722
|
+
batchSize?: number;
|
|
723
|
+
claimTimeout?: number;
|
|
724
|
+
workerId?: string;
|
|
725
|
+
}): Promise<ClaimedTask[]> {
|
|
726
|
+
const {
|
|
727
|
+
batchSize: count = 1,
|
|
728
|
+
claimTimeout = 120,
|
|
729
|
+
workerId = "worker",
|
|
730
|
+
} = options ?? {};
|
|
731
|
+
const result = await this.con.query<ClaimedTask>(
|
|
732
|
+
`SELECT run_id, task_id, attempt, task_name, params, retry_strategy, max_attempts,
|
|
733
|
+
headers, wake_event, event_payload
|
|
734
|
+
FROM absurd.claim_task($1, $2, $3, $4)`,
|
|
735
|
+
[this.queueName, workerId, claimTimeout, count]
|
|
736
|
+
);
|
|
737
|
+
return result.rows;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Claims up to batchSize tasks and processes them sequentially using the given workerId and claimTimeout.
|
|
742
|
+
* @param workerId Worker identifier.
|
|
743
|
+
* @param claimTimeout Lease duration in seconds.
|
|
744
|
+
* @param batchSize Maximum number of tasks to process.
|
|
745
|
+
* Note: For parallel processing, use startWorker().
|
|
746
|
+
*/
|
|
747
|
+
async workBatch(
|
|
748
|
+
workerId = "worker",
|
|
749
|
+
claimTimeout = 120,
|
|
750
|
+
batchSize = 1
|
|
751
|
+
): Promise<void> {
|
|
752
|
+
const tasks = await this.claimTasks({ batchSize, claimTimeout, workerId });
|
|
753
|
+
for (const task of tasks) {
|
|
754
|
+
await this.executeTask(task, claimTimeout);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Starts a background worker that continuously polls for and processes tasks from the queue.
|
|
760
|
+
* The worker will claim tasks up to the configured concurrency limit and process them in parallel.
|
|
761
|
+
*
|
|
762
|
+
* @param options Configure worker behavior.
|
|
763
|
+
* @returns Worker instance with close() method for graceful shutdown.
|
|
764
|
+
*/
|
|
765
|
+
async startWorker(options: WorkerOptions = {}): Promise<Worker> {
|
|
766
|
+
const {
|
|
767
|
+
workerId = `${os.hostname?.() || "host"}:${process.pid}`,
|
|
768
|
+
claimTimeout = 120,
|
|
769
|
+
concurrency = 1,
|
|
770
|
+
batchSize,
|
|
771
|
+
pollInterval = 0.25,
|
|
772
|
+
onError = (err) => this.log.error("Worker error:", err),
|
|
773
|
+
fatalOnLeaseTimeout = true,
|
|
774
|
+
} = options;
|
|
775
|
+
const effectiveBatchSize = batchSize ?? concurrency;
|
|
776
|
+
let running = true;
|
|
777
|
+
let workerLoopPromise: Promise<void>;
|
|
778
|
+
const executing = new Set<Promise<void>>();
|
|
779
|
+
let availabilityPromise: Promise<void> | null = null;
|
|
780
|
+
let availabilityResolve: (() => void) | null = null;
|
|
781
|
+
let sleepTimer: NodeJS.Timeout | null = null;
|
|
782
|
+
const notifyAvailability = () => {
|
|
783
|
+
if (sleepTimer) {
|
|
784
|
+
clearTimeout(sleepTimer);
|
|
785
|
+
sleepTimer = null;
|
|
786
|
+
}
|
|
787
|
+
if (availabilityResolve) {
|
|
788
|
+
availabilityResolve();
|
|
789
|
+
availabilityResolve = null;
|
|
790
|
+
availabilityPromise = null;
|
|
791
|
+
}
|
|
792
|
+
};
|
|
793
|
+
const waitForAvailability = async () => {
|
|
794
|
+
if (!availabilityPromise) {
|
|
795
|
+
availabilityPromise = new Promise((resolve) => {
|
|
796
|
+
availabilityResolve = resolve;
|
|
797
|
+
sleepTimer = setTimeout(() => {
|
|
798
|
+
sleepTimer = null;
|
|
799
|
+
availabilityResolve = null;
|
|
800
|
+
availabilityPromise = null;
|
|
801
|
+
resolve();
|
|
802
|
+
}, pollInterval * 1000);
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
await availabilityPromise;
|
|
806
|
+
};
|
|
807
|
+
const worker: Worker = {
|
|
808
|
+
close: async () => {
|
|
809
|
+
running = false;
|
|
810
|
+
await workerLoopPromise;
|
|
811
|
+
},
|
|
812
|
+
};
|
|
813
|
+
this.worker = worker;
|
|
814
|
+
workerLoopPromise = (async () => {
|
|
815
|
+
while (running) {
|
|
816
|
+
try {
|
|
817
|
+
if (executing.size >= concurrency) {
|
|
818
|
+
await waitForAvailability();
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
821
|
+
const availableCapacity = Math.max(concurrency - executing.size, 0);
|
|
822
|
+
const toClaim = Math.min(effectiveBatchSize, availableCapacity);
|
|
823
|
+
if (toClaim <= 0) {
|
|
824
|
+
await waitForAvailability();
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
const messages = await this.claimTasks({
|
|
828
|
+
batchSize: toClaim,
|
|
829
|
+
claimTimeout,
|
|
830
|
+
workerId,
|
|
831
|
+
});
|
|
832
|
+
if (messages.length === 0) {
|
|
833
|
+
await waitForAvailability();
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
for (const task of messages) {
|
|
837
|
+
const promise = this.executeTask(task, claimTimeout, {
|
|
838
|
+
fatalOnLeaseTimeout,
|
|
839
|
+
})
|
|
840
|
+
.catch((err) => onError(err))
|
|
841
|
+
.finally(() => {
|
|
842
|
+
executing.delete(promise);
|
|
843
|
+
notifyAvailability();
|
|
844
|
+
});
|
|
845
|
+
executing.add(promise);
|
|
846
|
+
}
|
|
847
|
+
} catch (err) {
|
|
848
|
+
onError(err as Error);
|
|
849
|
+
await waitForAvailability();
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
await Promise.allSettled(executing);
|
|
853
|
+
})();
|
|
854
|
+
return worker;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Stops any running worker and closes the underlying connection if owned.
|
|
859
|
+
*/
|
|
860
|
+
async close(): Promise<void> {
|
|
861
|
+
if (this.worker) {
|
|
862
|
+
await this.worker.close();
|
|
863
|
+
}
|
|
864
|
+
if (this.ownedConnection) {
|
|
865
|
+
await closeIfSupported(this.con);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
async executeTask(
|
|
870
|
+
task: ClaimedTask,
|
|
871
|
+
claimTimeout: number,
|
|
872
|
+
options?: { fatalOnLeaseTimeout?: boolean }
|
|
873
|
+
): Promise<void> {
|
|
874
|
+
const registration = this.registry.get(task.task_name);
|
|
875
|
+
const taskLabel = `${task.task_name} (${task.task_id})`;
|
|
876
|
+
const leaseTimer = new LeaseTimerManager(
|
|
877
|
+
this.log,
|
|
878
|
+
taskLabel,
|
|
879
|
+
options?.fatalOnLeaseTimeout ?? false
|
|
880
|
+
);
|
|
881
|
+
const ctx = await TaskContext.create({
|
|
882
|
+
log: this.log,
|
|
883
|
+
taskID: task.task_id,
|
|
884
|
+
con: this.con,
|
|
885
|
+
queueName: registration?.queue ?? "unknown",
|
|
886
|
+
task,
|
|
887
|
+
claimTimeout,
|
|
888
|
+
leaseTimer,
|
|
889
|
+
});
|
|
890
|
+
leaseTimer.update(claimTimeout);
|
|
891
|
+
try {
|
|
892
|
+
if (!registration) {
|
|
893
|
+
throw new Error("Unknown task");
|
|
894
|
+
} else if (registration.queue !== this.queueName) {
|
|
895
|
+
throw new Error("Misconfigured task (queue mismatch)");
|
|
896
|
+
}
|
|
897
|
+
const execute = async () => {
|
|
898
|
+
const result = await registration.handler(task.params, ctx);
|
|
899
|
+
await completeTaskRun(this.con, this.queueName, task.run_id, result);
|
|
900
|
+
};
|
|
901
|
+
if (this.hooks.wrapTaskExecution) {
|
|
902
|
+
await this.hooks.wrapTaskExecution(ctx, execute);
|
|
903
|
+
} else {
|
|
904
|
+
await execute();
|
|
905
|
+
}
|
|
906
|
+
} catch (err) {
|
|
907
|
+
if (err instanceof SuspendTask || err instanceof CancelledTask) {
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
this.log.error("[absurd] task execution failed:", err);
|
|
911
|
+
await failTaskRun(this.con, this.queueName, task.run_id, err as Error);
|
|
912
|
+
} finally {
|
|
913
|
+
leaseTimer.stop();
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
type RegisteredTask = {
|
|
919
|
+
name: string;
|
|
920
|
+
queue: string;
|
|
921
|
+
defaultMaxAttempts?: number;
|
|
922
|
+
defaultCancellation?: CancellationPolicy;
|
|
923
|
+
handler: TaskHandler;
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
function isQueryable(value: AbsurdOptions | Queryable): value is Queryable {
|
|
927
|
+
return (
|
|
928
|
+
typeof value === "object" &&
|
|
929
|
+
value !== null &&
|
|
930
|
+
typeof (value as Queryable).query === "function"
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
async function closeIfSupported(con: Queryable): Promise<void> {
|
|
935
|
+
const maybeClose = (con as { close?: () => void | Promise<void> }).close;
|
|
936
|
+
if (typeof maybeClose === "function") {
|
|
937
|
+
await maybeClose.call(con);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function serializeError(err: unknown): JsonObject {
|
|
942
|
+
if (err instanceof Error) {
|
|
943
|
+
return {
|
|
944
|
+
name: err.name,
|
|
945
|
+
message: err.message,
|
|
946
|
+
stack: err.stack ?? null,
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
return { message: String(err) };
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
async function completeTaskRun(
|
|
953
|
+
con: Queryable,
|
|
954
|
+
queueName: string,
|
|
955
|
+
runID: string,
|
|
956
|
+
result: JsonValue
|
|
957
|
+
): Promise<void> {
|
|
958
|
+
await con.query(`SELECT absurd.complete_run($1, $2, $3)`, [
|
|
959
|
+
queueName,
|
|
960
|
+
runID,
|
|
961
|
+
JSON.stringify(result ?? null),
|
|
962
|
+
]);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
async function failTaskRun(
|
|
966
|
+
con: Queryable,
|
|
967
|
+
queueName: string,
|
|
968
|
+
runID: string,
|
|
969
|
+
err: Error
|
|
970
|
+
): Promise<void> {
|
|
971
|
+
await con.query(`SELECT absurd.fail_run($1, $2, $3, $4)`, [
|
|
972
|
+
queueName,
|
|
973
|
+
runID,
|
|
974
|
+
JSON.stringify(serializeError(err)),
|
|
975
|
+
null,
|
|
976
|
+
]);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function normalizeSpawnOptions(options: SpawnOptions): JsonObject {
|
|
980
|
+
const normalized: JsonObject = {};
|
|
981
|
+
if (options.headers !== undefined) {
|
|
982
|
+
normalized.headers = options.headers;
|
|
983
|
+
}
|
|
984
|
+
if (options.maxAttempts !== undefined) {
|
|
985
|
+
normalized.max_attempts = options.maxAttempts;
|
|
986
|
+
}
|
|
987
|
+
if (options.retryStrategy) {
|
|
988
|
+
normalized.retry_strategy = serializeRetryStrategy(options.retryStrategy);
|
|
989
|
+
}
|
|
990
|
+
const cancellation = normalizeCancellation(options.cancellation);
|
|
991
|
+
if (cancellation) {
|
|
992
|
+
normalized.cancellation = cancellation;
|
|
993
|
+
}
|
|
994
|
+
if (options.idempotencyKey !== undefined) {
|
|
995
|
+
normalized.idempotency_key = options.idempotencyKey;
|
|
996
|
+
}
|
|
997
|
+
return normalized;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function serializeRetryStrategy(strategy: RetryStrategy): JsonObject {
|
|
1001
|
+
const serialized: JsonObject = {
|
|
1002
|
+
kind: strategy.kind,
|
|
1003
|
+
};
|
|
1004
|
+
if (strategy.baseSeconds !== undefined) {
|
|
1005
|
+
serialized.base_seconds = strategy.baseSeconds;
|
|
1006
|
+
}
|
|
1007
|
+
if (strategy.factor !== undefined) {
|
|
1008
|
+
serialized.factor = strategy.factor;
|
|
1009
|
+
}
|
|
1010
|
+
if (strategy.maxSeconds !== undefined) {
|
|
1011
|
+
serialized.max_seconds = strategy.maxSeconds;
|
|
1012
|
+
}
|
|
1013
|
+
return serialized;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function normalizeCancellation(
|
|
1017
|
+
policy?: CancellationPolicy
|
|
1018
|
+
): JsonObject | undefined {
|
|
1019
|
+
if (!policy) {
|
|
1020
|
+
return undefined;
|
|
1021
|
+
}
|
|
1022
|
+
const normalized: JsonObject = {};
|
|
1023
|
+
if (policy.maxDuration !== undefined) {
|
|
1024
|
+
normalized.max_duration = Math.floor(policy.maxDuration.total("seconds"));
|
|
1025
|
+
}
|
|
1026
|
+
if (policy.maxDelay !== undefined) {
|
|
1027
|
+
normalized.max_delay = Math.floor(policy.maxDelay.total("seconds"));
|
|
1028
|
+
}
|
|
1029
|
+
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
|
1030
|
+
}
|