@absurd-sqlite/bun-worker 0.1.0-alpha.0

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.
@@ -0,0 +1,389 @@
1
+ import { describe, test, expect, beforeAll, afterEach } from "bun:test";
2
+ import type { Absurd } from "absurd-sdk";
3
+ import { createTestAbsurd, randomName, type TestContext } from "./setup";
4
+
5
+ describe("Retry and cancellation", () => {
6
+ let ctx: TestContext;
7
+ let absurd: Absurd;
8
+
9
+ beforeAll(async () => {
10
+ ctx = await createTestAbsurd(randomName("retry_queue"));
11
+ absurd = ctx.absurd;
12
+ });
13
+
14
+ afterEach(async () => {
15
+ await ctx.cleanupTasks();
16
+ await ctx.setFakeNow(null);
17
+ });
18
+
19
+ test("fail run without strategy requeues immediately", async () => {
20
+ let attempts = 0;
21
+
22
+ absurd.registerTask(
23
+ { name: "no-strategy", defaultMaxAttempts: 3 },
24
+ async () => {
25
+ attempts++;
26
+ if (attempts < 2) {
27
+ throw new Error("boom");
28
+ }
29
+ return { attempts };
30
+ },
31
+ );
32
+
33
+ const { taskID } = await absurd.spawn("no-strategy", { payload: 1 });
34
+
35
+ // First attempt fails
36
+ await absurd.workBatch("worker1", 60, 1);
37
+ expect((await ctx.getTask(taskID))?.state).toBe("pending");
38
+ expect((await ctx.getTask(taskID))?.attempts).toBe(2);
39
+
40
+ // Second attempt succeeds
41
+ await absurd.workBatch("worker1", 60, 1);
42
+ expect(await ctx.getTask(taskID)).toMatchObject({
43
+ state: "completed",
44
+ attempts: 2,
45
+ completed_payload: { attempts: 2 },
46
+ });
47
+ });
48
+
49
+ test("exponential backoff retry strategy", async () => {
50
+ const baseTime = new Date("2024-05-01T10:00:00Z");
51
+ await ctx.setFakeNow(baseTime);
52
+
53
+ let attempts = 0;
54
+
55
+ absurd.registerTask({ name: "exp-backoff" }, async () => {
56
+ attempts++;
57
+ if (attempts < 3) {
58
+ throw new Error(`fail-${attempts}`);
59
+ }
60
+ return { success: true };
61
+ });
62
+
63
+ const { taskID } = await absurd.spawn("exp-backoff", undefined, {
64
+ maxAttempts: 3,
65
+ retryStrategy: { kind: "exponential", baseSeconds: 40, factor: 2 },
66
+ });
67
+
68
+ // First attempt - fails, schedules retry with 40s backoff
69
+ await absurd.workBatch("worker1", 60, 1);
70
+ let task = await ctx.getTask(taskID);
71
+ expect(task?.state).toBe("sleeping");
72
+ expect(task?.attempts).toBe(2);
73
+
74
+ // Advance time past first backoff (40 seconds)
75
+ await ctx.setFakeNow(new Date(baseTime.getTime() + 40 * 1000));
76
+
77
+ // Second attempt - fails again with 80s backoff (40 * 2)
78
+ await absurd.workBatch("worker1", 60, 1);
79
+ task = await ctx.getTask(taskID);
80
+ expect(task?.state).toBe("sleeping");
81
+ expect(task?.attempts).toBe(3);
82
+
83
+ // Advance time past second backoff (80 seconds from second failure)
84
+ await ctx.setFakeNow(new Date(baseTime.getTime() + 40 * 1000 + 80 * 1000));
85
+
86
+ // Third attempt - succeeds
87
+ await absurd.workBatch("worker1", 60, 1);
88
+ expect(await ctx.getTask(taskID)).toMatchObject({
89
+ state: "completed",
90
+ attempts: 3,
91
+ completed_payload: { success: true },
92
+ });
93
+ });
94
+
95
+ test("fixed backoff retry strategy", async () => {
96
+ const baseTime = new Date("2024-05-01T11:00:00Z");
97
+ await ctx.setFakeNow(baseTime);
98
+
99
+ let attempts = 0;
100
+
101
+ absurd.registerTask({ name: "fixed-backoff" }, async () => {
102
+ attempts++;
103
+ if (attempts < 2) {
104
+ throw new Error("first-fail");
105
+ }
106
+ return { attempts };
107
+ });
108
+
109
+ const { taskID } = await absurd.spawn("fixed-backoff", undefined, {
110
+ maxAttempts: 2,
111
+ retryStrategy: { kind: "fixed", baseSeconds: 10 },
112
+ });
113
+
114
+ // First attempt fails
115
+ await absurd.workBatch("worker1", 60, 1);
116
+ expect((await ctx.getTask(taskID))?.state).toBe("sleeping");
117
+
118
+ // Advance time past fixed backoff (10 seconds)
119
+ await ctx.setFakeNow(new Date(baseTime.getTime() + 10 * 1000));
120
+
121
+ // Second attempt succeeds
122
+ await absurd.workBatch("worker1", 60, 1);
123
+ expect(await ctx.getTask(taskID)).toMatchObject({
124
+ state: "completed",
125
+ attempts: 2,
126
+ });
127
+ });
128
+
129
+ test("task fails permanently after max attempts exhausted", async () => {
130
+ absurd.registerTask(
131
+ { name: "always-fail", defaultMaxAttempts: 2 },
132
+ async () => {
133
+ throw new Error("always fails");
134
+ },
135
+ );
136
+
137
+ const { taskID } = await absurd.spawn("always-fail", undefined);
138
+
139
+ // Attempt 1
140
+ await absurd.workBatch("worker1", 60, 1);
141
+ expect((await ctx.getTask(taskID))?.state).toBe("pending");
142
+
143
+ // Attempt 2 (final)
144
+ await absurd.workBatch("worker1", 60, 1);
145
+ expect(await ctx.getTask(taskID)).toMatchObject({
146
+ state: "failed",
147
+ attempts: 2,
148
+ });
149
+ });
150
+
151
+ test("cancellation by max duration", async () => {
152
+ const baseTime = new Date("2024-05-01T09:00:00Z");
153
+ await ctx.setFakeNow(baseTime);
154
+
155
+ absurd.registerTask({ name: "duration-cancel" }, async () => {
156
+ throw new Error("always fails");
157
+ });
158
+
159
+ const { taskID } = await absurd.spawn("duration-cancel", undefined, {
160
+ maxAttempts: 4,
161
+ retryStrategy: { kind: "fixed", baseSeconds: 30 },
162
+ cancellation: { maxDuration: 90 },
163
+ });
164
+
165
+ await absurd.workBatch("worker1", 60, 1);
166
+
167
+ await ctx.setFakeNow(new Date(baseTime.getTime() + 91 * 1000));
168
+ await absurd.workBatch("worker1", 60, 1);
169
+
170
+ const task = await ctx.getTask(taskID);
171
+ expect(task?.state).toBe("cancelled");
172
+ expect(task?.cancelled_at).not.toBeNull();
173
+
174
+ const runs = await ctx.getRuns(taskID);
175
+ expect(runs.length).toBe(2);
176
+ expect(runs[1].state).toBe("cancelled");
177
+ });
178
+
179
+ test("cancellation by max delay", async () => {
180
+ const baseTime = new Date("2024-05-01T08:00:00Z");
181
+ await ctx.setFakeNow(baseTime);
182
+
183
+ absurd.registerTask({ name: "delay-cancel" }, async () => {
184
+ return { done: true };
185
+ });
186
+
187
+ const { taskID } = await absurd.spawn("delay-cancel", undefined, {
188
+ cancellation: { maxDelay: 60 },
189
+ });
190
+
191
+ await ctx.setFakeNow(new Date(baseTime.getTime() + 61 * 1000));
192
+ await absurd.workBatch("worker1", 60, 1);
193
+
194
+ const task = await ctx.getTask(taskID);
195
+ expect(task?.state).toBe("cancelled");
196
+ expect(task?.cancelled_at).not.toBeNull();
197
+ });
198
+
199
+ test("manual cancel pending task", async () => {
200
+ absurd.registerTask({ name: "pending-cancel" }, async () => {
201
+ return { ok: true };
202
+ });
203
+
204
+ const { taskID } = await absurd.spawn("pending-cancel", { data: 1 });
205
+ expect((await ctx.getTask(taskID))?.state).toBe("pending");
206
+
207
+ await absurd.cancelTask(taskID);
208
+
209
+ const task = await ctx.getTask(taskID);
210
+ expect(task?.state).toBe("cancelled");
211
+ expect(task?.cancelled_at).not.toBeNull();
212
+
213
+ const claims = await absurd.claimTasks({
214
+ workerId: "worker-1",
215
+ claimTimeout: 60,
216
+ });
217
+ expect(claims).toHaveLength(0);
218
+ });
219
+
220
+ test("manual cancel running task", async () => {
221
+ absurd.registerTask({ name: "running-cancel" }, async () => {
222
+ return { ok: true };
223
+ });
224
+
225
+ const { taskID } = await absurd.spawn("running-cancel", { data: 1 });
226
+ const [claim] = await absurd.claimTasks({
227
+ workerId: "worker-1",
228
+ claimTimeout: 60,
229
+ });
230
+ expect(claim.task_id).toBe(taskID);
231
+
232
+ await absurd.cancelTask(taskID);
233
+
234
+ const task = await ctx.getTask(taskID);
235
+ expect(task?.state).toBe("cancelled");
236
+ expect(task?.cancelled_at).not.toBeNull();
237
+ });
238
+
239
+ test("cancel blocks checkpoint writes", async () => {
240
+ absurd.registerTask({ name: "checkpoint-cancel" }, async () => {
241
+ return { ok: true };
242
+ });
243
+
244
+ const { taskID } = await absurd.spawn("checkpoint-cancel", { data: 1 });
245
+ const [claim] = await absurd.claimTasks({
246
+ workerId: "worker-1",
247
+ claimTimeout: 60,
248
+ });
249
+
250
+ await absurd.cancelTask(taskID);
251
+
252
+ await ctx.expectCancelledError(
253
+ ctx.setTaskCheckpointState(
254
+ taskID,
255
+ "step-1",
256
+ { result: "value" },
257
+ claim.run_id,
258
+ 60
259
+ )
260
+ );
261
+ });
262
+
263
+ test("cancel blocks awaitEvent registrations", async () => {
264
+ absurd.registerTask({ name: "await-cancel" }, async () => {
265
+ return { ok: true };
266
+ });
267
+
268
+ const { taskID } = await absurd.spawn("await-cancel", { data: 1 });
269
+ const [claim] = await absurd.claimTasks({
270
+ workerId: "worker-1",
271
+ claimTimeout: 60,
272
+ });
273
+
274
+ await absurd.cancelTask(taskID);
275
+
276
+ await ctx.expectCancelledError(
277
+ ctx.awaitEventInternal(
278
+ taskID,
279
+ claim.run_id,
280
+ "wait-step",
281
+ "some-event",
282
+ null
283
+ )
284
+ );
285
+ });
286
+
287
+ test("cancel blocks extendClaim", async () => {
288
+ absurd.registerTask({ name: "extend-cancel" }, async () => {
289
+ return { ok: true };
290
+ });
291
+
292
+ const { taskID } = await absurd.spawn("extend-cancel", { data: 1 });
293
+ const [claim] = await absurd.claimTasks({
294
+ workerId: "worker-1",
295
+ claimTimeout: 60,
296
+ });
297
+
298
+ await absurd.cancelTask(taskID);
299
+
300
+ await ctx.expectCancelledError(ctx.extendClaim(claim.run_id, 30));
301
+ });
302
+
303
+ test("cancel is idempotent", async () => {
304
+ absurd.registerTask({ name: "idempotent-cancel" }, async () => {
305
+ return { ok: true };
306
+ });
307
+
308
+ const { taskID } = await absurd.spawn("idempotent-cancel", { data: 1 });
309
+ await absurd.cancelTask(taskID);
310
+ const first = await ctx.getTask(taskID);
311
+ expect(first?.cancelled_at).not.toBeNull();
312
+
313
+ await absurd.cancelTask(taskID);
314
+ const second = await ctx.getTask(taskID);
315
+ expect(second?.cancelled_at?.getTime()).toBe(
316
+ first?.cancelled_at?.getTime(),
317
+ );
318
+ });
319
+
320
+ test("cancelling completed task is a no-op", async () => {
321
+ absurd.registerTask({ name: "complete-cancel" }, async () => {
322
+ return { status: "done" };
323
+ });
324
+
325
+ const { taskID } = await absurd.spawn("complete-cancel", { data: 1 });
326
+ await absurd.workBatch("worker-1", 60, 1);
327
+
328
+ await absurd.cancelTask(taskID);
329
+
330
+ const task = await ctx.getTask(taskID);
331
+ expect(task?.state).toBe("completed");
332
+ expect(task?.cancelled_at).toBeNull();
333
+ });
334
+
335
+ test("cancelling failed task is a no-op", async () => {
336
+ absurd.registerTask(
337
+ { name: "failed-cancel", defaultMaxAttempts: 1 },
338
+ async () => {
339
+ throw new Error("boom");
340
+ },
341
+ );
342
+
343
+ const { taskID } = await absurd.spawn("failed-cancel", { data: 1 });
344
+ await absurd.workBatch("worker-1", 60, 1);
345
+
346
+ await absurd.cancelTask(taskID);
347
+
348
+ const task = await ctx.getTask(taskID);
349
+ expect(task?.state).toBe("failed");
350
+ expect(task?.cancelled_at).toBeNull();
351
+ });
352
+
353
+ test("cancel sleeping task transitions run to cancelled", async () => {
354
+ const eventName = randomName("sleep-event");
355
+ absurd.registerTask({ name: "sleep-cancel" }, async () => {
356
+ return { ok: true };
357
+ });
358
+
359
+ const { taskID } = await absurd.spawn("sleep-cancel", { data: 1 });
360
+ const [claim] = await absurd.claimTasks({
361
+ workerId: "worker-1",
362
+ claimTimeout: 60,
363
+ });
364
+
365
+ await ctx.awaitEventInternal(
366
+ taskID,
367
+ claim.run_id,
368
+ "wait-step",
369
+ eventName,
370
+ 300
371
+ );
372
+
373
+ const sleepingTask = await ctx.getTask(taskID);
374
+ expect(sleepingTask?.state).toBe("sleeping");
375
+
376
+ await absurd.cancelTask(taskID);
377
+
378
+ const cancelledTask = await ctx.getTask(taskID);
379
+ expect(cancelledTask?.state).toBe("cancelled");
380
+ const run = await ctx.getRun(claim.run_id);
381
+ expect(run?.state).toBe("cancelled");
382
+ });
383
+
384
+ test("cancel non-existent task errors", async () => {
385
+ await expect(
386
+ absurd.cancelTask("019a32d3-8425-7ae2-a5af-2f17a6707666"),
387
+ ).rejects.toThrow(/task not found/i);
388
+ });
389
+ });
@@ -0,0 +1,101 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ import "./setup";
9
+ import { waitFor } from "./wait-for";
10
+
11
+ const testDir = fileURLToPath(new URL(".", import.meta.url));
12
+ const repoRoot = join(testDir, "../../..");
13
+ const extensionBase = join(repoRoot, "target/release/libabsurd");
14
+
15
+ function resolveExtensionPath(base: string): string {
16
+ const platformExt =
17
+ process.platform === "win32"
18
+ ? ".dll"
19
+ : process.platform === "darwin"
20
+ ? ".dylib"
21
+ : ".so";
22
+ const candidates = [base, `${base}${platformExt}`];
23
+ for (const candidate of candidates) {
24
+ if (existsSync(candidate)) {
25
+ return candidate;
26
+ }
27
+ }
28
+ throw new Error(
29
+ `SQLite extension not found at ${base} (expected ${platformExt})`
30
+ );
31
+ }
32
+
33
+ const extensionPath = resolveExtensionPath(extensionBase);
34
+
35
+ let tempDir: string | null = null;
36
+
37
+ function createDatabaseWithMigrations(): string {
38
+ tempDir = mkdtempSync(join(tmpdir(), "absurd-sqlite-"));
39
+ const dbPath = join(tempDir, "absurd.db");
40
+ const db = new Database(dbPath);
41
+ (db as unknown as { loadExtension(path: string): void }).loadExtension(
42
+ extensionPath
43
+ );
44
+ db.query("select absurd_apply_migrations()").get();
45
+ db.close();
46
+ return dbPath;
47
+ }
48
+
49
+ afterEach(() => {
50
+ if (tempDir) {
51
+ rmSync(tempDir, { recursive: true, force: true });
52
+ tempDir = null;
53
+ }
54
+ delete process.env.ABSURD_DATABASE_PATH;
55
+ delete process.env.ABSURD_DATABASE_EXTENSION_PATH;
56
+ });
57
+
58
+ describe("run", () => {
59
+ it("requires ABSURD_DATABASE_PATH", async () => {
60
+ process.env.ABSURD_DATABASE_EXTENSION_PATH = extensionPath;
61
+ const { default: run } = await import("../src/index");
62
+
63
+ await expect(run(() => {})).rejects.toThrow("ABSURD_DATABASE_PATH is required");
64
+ });
65
+
66
+ it("requires ABSURD_DATABASE_EXTENSION_PATH", async () => {
67
+ process.env.ABSURD_DATABASE_PATH = "/tmp/absurd.db";
68
+ const { default: run } = await import("../src/index");
69
+
70
+ await expect(run(() => {})).rejects.toThrow(
71
+ "ABSURD_DATABASE_EXTENSION_PATH is required"
72
+ );
73
+ });
74
+
75
+ it("runs setup, processes tasks, and shuts down on SIGINT", async () => {
76
+ const dbPath = createDatabaseWithMigrations();
77
+ process.env.ABSURD_DATABASE_PATH = dbPath;
78
+ process.env.ABSURD_DATABASE_EXTENSION_PATH = extensionPath;
79
+
80
+ const { default: run } = await import("../src/index");
81
+
82
+ await run(async (absurd) => {
83
+ await absurd.createQueue("default");
84
+ absurd.registerTask({ name: "ping" }, async () => ({ ok: true }));
85
+ await absurd.spawn("ping", {});
86
+ });
87
+
88
+ const verifier = new Database(dbPath);
89
+ await waitFor(() => {
90
+ const row = verifier
91
+ .query("select state from absurd_tasks where task_name = 'ping'")
92
+ .get() as { state: string } | null;
93
+ expect(row?.state).toBe("completed");
94
+ });
95
+
96
+ process.emit("SIGINT");
97
+ await new Promise((resolve) => setTimeout(resolve, 0));
98
+
99
+ verifier.close();
100
+ });
101
+ });