@absurd-sqlite/sdk 0.2.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 "vitest";
2
+ import { createTestAbsurd, randomName, type TestContext } from "./setup.js";
3
+ import type { Absurd } from "../src/index.js";
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
+ });