@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,259 @@
1
+ import { describe, test, expect, beforeAll, afterEach, vi } from "vitest";
2
+ import { createTestAbsurd, randomName, type TestContext } from "./setup.js";
3
+ import type { Absurd } from "../src/index.js";
4
+
5
+ describe("Step functionality", () => {
6
+ let ctx: TestContext;
7
+ let absurd: Absurd;
8
+
9
+ beforeAll(async () => {
10
+ ctx = await createTestAbsurd(randomName("step_queue"));
11
+ absurd = ctx.absurd;
12
+ });
13
+
14
+ afterEach(async () => {
15
+ await ctx.cleanupTasks();
16
+ await ctx.setFakeNow(null);
17
+ vi.useRealTimers();
18
+ });
19
+
20
+ test("step executes and returns value", async () => {
21
+ absurd.registerTask<{ value: number }, { result: string }>(
22
+ { name: "basic" },
23
+ async (params, ctx) => {
24
+ const result = await ctx.step("process", async () => {
25
+ return `processed-${params.value}`;
26
+ });
27
+ return { result };
28
+ }
29
+ );
30
+
31
+ const { taskID } = await absurd.spawn("basic", { value: 42 });
32
+ await absurd.workBatch(randomName("w"), 60, 1);
33
+
34
+ expect(await ctx.getTask(taskID)).toMatchObject({
35
+ state: "completed",
36
+ completed_payload: { result: "processed-42" },
37
+ });
38
+ });
39
+
40
+ test("step result is cached and not re-executed on retry", async () => {
41
+ let executionCount = 0;
42
+ let attemptCount = 0;
43
+
44
+ absurd.registerTask<void, { random: number; count: number }>(
45
+ { name: "cache", defaultMaxAttempts: 2 },
46
+ async (params, ctx) => {
47
+ attemptCount++;
48
+
49
+ const cached = await ctx.step("generate-random", async () => {
50
+ executionCount++;
51
+ return Math.random();
52
+ });
53
+
54
+ if (attemptCount === 1) {
55
+ throw new Error("Intentional failure");
56
+ }
57
+
58
+ return { random: cached, count: executionCount };
59
+ }
60
+ );
61
+
62
+ const { taskID } = await absurd.spawn("cache", undefined);
63
+
64
+ const workerID = randomName("w");
65
+ await absurd.workBatch(workerID, 60, 1);
66
+ expect(executionCount).toBe(1);
67
+
68
+ await absurd.workBatch(workerID, 60, 1);
69
+ expect(executionCount).toBe(1);
70
+ expect(attemptCount).toBe(2);
71
+
72
+ expect(await ctx.getTask(taskID)).toMatchObject({
73
+ state: "completed",
74
+ completed_payload: { count: 1 },
75
+ attempts: 2,
76
+ });
77
+ });
78
+
79
+ test("task with multiple steps only re-executes uncompleted steps on retry", async () => {
80
+ const executed: string[] = [];
81
+ let attemptCount = 0;
82
+
83
+ absurd.registerTask<void, { steps: string[]; attemptNum: number }>(
84
+ { name: "multistep-retry", defaultMaxAttempts: 2 },
85
+ async (params, ctx) => {
86
+ attemptCount++;
87
+
88
+ const step1 = await ctx.step("step1", async () => {
89
+ executed.push("step1");
90
+ return "result1";
91
+ });
92
+
93
+ const step2 = await ctx.step("step2", async () => {
94
+ executed.push("step2");
95
+ return "result2";
96
+ });
97
+
98
+ if (attemptCount === 1) {
99
+ throw new Error("Fail before step3");
100
+ }
101
+
102
+ const step3 = await ctx.step("step3", async () => {
103
+ executed.push("step3");
104
+ return "result3";
105
+ });
106
+
107
+ return { steps: [step1, step2, step3], attemptNum: attemptCount };
108
+ }
109
+ );
110
+
111
+ const { taskID } = await absurd.spawn("multistep-retry", undefined);
112
+
113
+ const workerID = randomName("w");
114
+ await absurd.workBatch(workerID, 60, 1);
115
+ expect(executed).toEqual(["step1", "step2"]);
116
+
117
+ await absurd.workBatch(workerID, 60, 1);
118
+ expect(executed).toEqual(["step1", "step2", "step3"]);
119
+
120
+ expect(await ctx.getTask(taskID)).toMatchObject({
121
+ state: "completed",
122
+ completed_payload: {
123
+ steps: ["result1", "result2", "result3"],
124
+ attemptNum: 2,
125
+ },
126
+ attempts: 2,
127
+ });
128
+ });
129
+
130
+ test("repeated step names work correctly", async () => {
131
+ absurd.registerTask<void, { results: number[] }>(
132
+ { name: "deduplicate" },
133
+ async (params, ctx) => {
134
+ const results: number[] = [];
135
+ for (let i = 0; i < 3; i++) {
136
+ const result = await ctx.step("loop-step", async () => {
137
+ return i * 10;
138
+ });
139
+ results.push(result);
140
+ }
141
+ return { results };
142
+ }
143
+ );
144
+
145
+ const { taskID } = await absurd.spawn("deduplicate", undefined);
146
+ await absurd.workBatch(randomName("w"), 60, 1);
147
+
148
+ expect(await ctx.getTask(taskID)).toMatchObject({
149
+ state: "completed",
150
+ completed_payload: { results: [0, 10, 20] },
151
+ });
152
+ });
153
+
154
+ test("failed step does not save checkpoint and re-executes on retry", async () => {
155
+ let attemptCount = 0;
156
+
157
+ absurd.registerTask<void, { result: string }>(
158
+ { name: "fail", defaultMaxAttempts: 2 },
159
+ async (_, ctx) => {
160
+ const result = await ctx.step("fail", async () => {
161
+ attemptCount++;
162
+ if (attemptCount === 1) {
163
+ throw new Error("Step fails on first attempt");
164
+ }
165
+ return "success";
166
+ });
167
+
168
+ return { result };
169
+ }
170
+ );
171
+
172
+ const { taskID } = await absurd.spawn("fail", undefined);
173
+
174
+ const workerID = randomName("w");
175
+ await absurd.workBatch(workerID, 60, 1);
176
+ expect(attemptCount).toBe(1);
177
+
178
+ await absurd.workBatch(workerID, 60, 1);
179
+ expect(attemptCount).toBe(2);
180
+
181
+ expect(await ctx.getTask(taskID)).toMatchObject({
182
+ state: "completed",
183
+ completed_payload: { result: "success" },
184
+ attempts: 2,
185
+ });
186
+ });
187
+
188
+ test("sleepFor suspends until duration elapses", async () => {
189
+ vi.useFakeTimers();
190
+ const base = new Date("2024-05-05T10:00:00Z");
191
+ vi.setSystemTime(base);
192
+ await ctx.setFakeNow(base);
193
+
194
+ const durationSeconds = 60;
195
+ absurd.registerTask({ name: "sleep-for" }, async (_params, ctx) => {
196
+ await ctx.sleepFor("wait-for", durationSeconds);
197
+ return { resumed: true };
198
+ });
199
+
200
+ const { taskID, runID } = await absurd.spawn("sleep-for", undefined);
201
+ await absurd.workBatch("worker-sleep", 120, 1);
202
+
203
+ const sleepingRun = await ctx.getRun(runID);
204
+ expect(sleepingRun).toMatchObject({
205
+ state: "sleeping",
206
+ });
207
+ const wakeTime = new Date(base.getTime() + durationSeconds * 1000);
208
+ expect(sleepingRun?.available_at?.getTime()).toBe(wakeTime.getTime());
209
+
210
+ const resumeTime = new Date(wakeTime.getTime() + 5 * 1000);
211
+ vi.setSystemTime(resumeTime);
212
+ await ctx.setFakeNow(resumeTime);
213
+ await absurd.workBatch("worker-sleep", 120, 1);
214
+
215
+ expect(await ctx.getTask(taskID)).toMatchObject({
216
+ state: "completed",
217
+ completed_payload: { resumed: true },
218
+ });
219
+ });
220
+
221
+ test("sleepUntil checkpoint prevents re-scheduling when wake time passed", async () => {
222
+ vi.useFakeTimers();
223
+ const base = new Date("2024-05-06T09:00:00Z");
224
+ vi.setSystemTime(base);
225
+ await ctx.setFakeNow(base);
226
+
227
+ const wakeTime = new Date(base.getTime() + 5 * 60 * 1000);
228
+ let executions = 0;
229
+
230
+ absurd.registerTask({ name: "sleep-until" }, async (_params, ctx) => {
231
+ executions++;
232
+ await ctx.sleepUntil("sleep-step", wakeTime);
233
+ return { executions };
234
+ });
235
+
236
+ const { taskID, runID } = await absurd.spawn("sleep-until", undefined);
237
+ await absurd.workBatch("worker-sleep", 120, 1);
238
+
239
+ const checkpointRow = await ctx.getCheckpoint(taskID, "sleep-step");
240
+ expect(checkpointRow).toMatchObject({
241
+ checkpoint_name: "sleep-step",
242
+ owner_run_id: runID,
243
+ state: wakeTime.toISOString(),
244
+ });
245
+
246
+ const sleepingRun = await ctx.getRun(runID);
247
+ expect(sleepingRun?.state).toBe("sleeping");
248
+
249
+ vi.setSystemTime(wakeTime);
250
+ await ctx.setFakeNow(wakeTime);
251
+ await absurd.workBatch("worker-sleep", 120, 1);
252
+
253
+ expect(await ctx.getTask(taskID)).toMatchObject({
254
+ state: "completed",
255
+ completed_payload: { executions: 2 },
256
+ });
257
+ expect(executions).toBe(2);
258
+ });
259
+ });
@@ -0,0 +1,193 @@
1
+ import { describe, it, expect, beforeAll, afterEach, vi } from "vitest";
2
+ import { EventEmitter, once } from "events";
3
+ import type { TestContext } from "./setup";
4
+ import { createTestAbsurd, randomName } from "./setup";
5
+ import type { Absurd } from "../src/index";
6
+
7
+ describe("Worker", () => {
8
+ let ctx: TestContext;
9
+ let absurd: Absurd;
10
+
11
+ beforeAll(async () => {
12
+ ctx = await createTestAbsurd(randomName("worker"));
13
+ absurd = ctx.absurd;
14
+ });
15
+
16
+ afterEach(async () => {
17
+ await ctx.cleanupTasks();
18
+ vi.restoreAllMocks();
19
+ });
20
+
21
+ it("respects concurrency limit and skips claims when at capacity", async () => {
22
+ const gate = new EventEmitter();
23
+ const atGate = new Set<number>();
24
+
25
+ gate.on("arrived", (id: number) => atGate.add(id));
26
+ gate.on("left", (id: number) => atGate.delete(id));
27
+
28
+ absurd.registerTask<{ id: number }, void>(
29
+ { name: "gated-task" },
30
+ async (params) => {
31
+ gate.emit("arrived", params.id);
32
+ await once(gate, "release");
33
+ gate.emit("left", params.id);
34
+ },
35
+ );
36
+
37
+ for (let i = 1; i <= 5; i++) {
38
+ await absurd.spawn("gated-task", { id: i });
39
+ }
40
+
41
+ const claimSpy = vi.spyOn(absurd, "claimTasks");
42
+
43
+ const concurrency = 2;
44
+ const worker = await absurd.startWorker({
45
+ concurrency,
46
+ pollInterval: 0.01,
47
+ });
48
+
49
+ // Wait until we are at capacity
50
+ await vi.waitFor(() => expect(atGate.size).toBe(concurrency), {
51
+ timeout: 100,
52
+ });
53
+
54
+ expect(claimSpy).toHaveBeenCalledTimes(1);
55
+ expect(claimSpy).toHaveBeenCalledWith(
56
+ expect.objectContaining({ batchSize: concurrency }),
57
+ );
58
+
59
+ // Wait for ~5 poll cycles
60
+ await ctx.sleep(50);
61
+
62
+ // Things should still be the same, no more claims
63
+ expect(atGate.size).toBe(concurrency);
64
+ expect(claimSpy).toHaveBeenCalledTimes(1);
65
+
66
+ gate.emit("release");
67
+ await worker.close();
68
+ expect(atGate.size).toBe(0);
69
+ });
70
+
71
+ it("polls again immediately when capacity frees up", async () => {
72
+ const gate = new EventEmitter();
73
+ const atGate = new Set<number>();
74
+
75
+ gate.on("arrived", (id: number) => atGate.add(id));
76
+ gate.on("left", (id: number) => atGate.delete(id));
77
+
78
+ absurd.registerTask<{ id: number }, void>(
79
+ { name: "responsive-task" },
80
+ async (params) => {
81
+ gate.emit("arrived", params.id);
82
+ await once(gate, "release");
83
+ gate.emit("left", params.id);
84
+ },
85
+ );
86
+
87
+ for (let i = 1; i <= 3; i++) {
88
+ await absurd.spawn("responsive-task", { id: i });
89
+ }
90
+
91
+ const claimSpy = vi.spyOn(absurd, "claimTasks");
92
+
93
+ const worker = await absurd.startWorker({
94
+ concurrency: 2,
95
+ pollInterval: 3600, // once every hour
96
+ });
97
+
98
+ await vi.waitFor(() => expect(atGate.size).toBe(2), {
99
+ timeout: 100,
100
+ });
101
+
102
+ expect(claimSpy).toHaveBeenCalledTimes(1);
103
+ const initialCallCount = claimSpy.mock.calls.length;
104
+
105
+ // Free current tasks so the worker can immediately claim again
106
+ gate.emit("release");
107
+
108
+ // Polls again without waiting for pollInterval
109
+ await vi.waitFor(() => {
110
+ expect(claimSpy.mock.calls.length).toBeGreaterThan(initialCallCount);
111
+ });
112
+
113
+ // Release any remaining tasks so the worker can shut down
114
+ gate.emit("release");
115
+ await worker.close();
116
+ });
117
+
118
+ it("shuts down gracefully", async () => {
119
+ const gate = new EventEmitter();
120
+ const started: number[] = [];
121
+
122
+ gate.on("started", (id: number) => started.push(id));
123
+
124
+ absurd.registerTask<{ id: number }, void>(
125
+ { name: "shutdown-task" },
126
+ async (params) => {
127
+ gate.emit("started", params.id);
128
+ await once(gate, "release");
129
+ },
130
+ );
131
+
132
+ await absurd.spawn("shutdown-task", { id: 1 });
133
+ await absurd.spawn("shutdown-task", { id: 2 });
134
+
135
+ const worker = await absurd.startWorker({ concurrency: 1 });
136
+ await once(gate, "started");
137
+
138
+ const closePromise = worker.close();
139
+ const claimSpy = vi.spyOn(absurd, "claimTasks");
140
+
141
+ let resolved = false;
142
+ closePromise.then(() => {
143
+ resolved = true;
144
+ });
145
+
146
+ // Does not resolve until the task finishes
147
+ await ctx.sleep(50);
148
+ expect(resolved).toBe(false);
149
+
150
+ gate.emit("release");
151
+
152
+ await vi.waitFor(() => expect(resolved).toBe(true), {
153
+ timeout: 200,
154
+ });
155
+
156
+ expect(started).toEqual([1]);
157
+ expect(claimSpy).not.toHaveBeenCalled();
158
+ });
159
+
160
+ it("calls onError for unexpected failures", async () => {
161
+ const errors: string[] = [];
162
+
163
+ absurd.registerTask<void, void>({ name: "test-task" }, async () => {});
164
+ await absurd.spawn("test-task", undefined);
165
+
166
+ vi.spyOn(absurd, "executeTask").mockRejectedValueOnce(
167
+ new Error("Execute failed, PG error"),
168
+ );
169
+
170
+ const worker = await absurd.startWorker({
171
+ pollInterval: 0.01,
172
+ onError: (err) => errors.push(err.message),
173
+ });
174
+
175
+ await vi.waitFor(
176
+ () => expect(errors).toContain("Execute failed, PG error"),
177
+ {
178
+ timeout: 200,
179
+ },
180
+ );
181
+
182
+ vi.spyOn(absurd, "claimTasks").mockRejectedValue(new Error("Claim failed"));
183
+
184
+ await vi.waitFor(() => expect(errors).toContain("Claim failed"), {
185
+ timeout: 200,
186
+ });
187
+
188
+ await worker.close();
189
+
190
+ expect(errors).toContain("Execute failed, PG error");
191
+ expect(errors).toContain("Claim failed");
192
+ });
193
+ });
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "./src",
5
+ "moduleResolution": "node"
6
+ },
7
+ "include": ["src/**/*.ts"],
8
+ "exclude": ["node_modules", "dist", "test/**/*.ts"]
9
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "./tsconfig.build.json",
3
+ "compilerOptions": {
4
+ "module": "CommonJS",
5
+ "moduleResolution": "node",
6
+ "outDir": "./dist/cjs",
7
+ "declaration": false,
8
+ "declarationMap": false,
9
+ "sourceMap": false
10
+ }
11
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "lib": ["ES2022"],
6
+ "moduleResolution": "bundler",
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "resolveJsonModule": true,
12
+ "declaration": true,
13
+ "declarationMap": true,
14
+ "sourceMap": true,
15
+ "outDir": "./dist",
16
+ "types": ["node"]
17
+ },
18
+ "include": ["src/**/*.ts", "test/**/*.ts"],
19
+ "exclude": ["node_modules", "dist"]
20
+ }
package/typedoc.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "entryPoints": ["src/index.ts"],
3
+ "out": "docs",
4
+ "plugin": [],
5
+ "exclude": ["**/*.test.ts", "**/__tests__/**"],
6
+ "excludePrivate": true,
7
+ "excludeProtected": false,
8
+ "skipErrorChecking": false,
9
+ "includeVersion": true,
10
+ "readme": "README.md"
11
+ }
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: "node",
6
+ pool: "forks",
7
+ include: ["test/**/*.test.ts"],
8
+ testTimeout: 30000,
9
+ setupFiles: ["./test/setup.ts"],
10
+ },
11
+ });