@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.
- package/README.md +22 -0
- package/bun.lock +134 -0
- package/package.json +51 -0
- package/src/index.ts +63 -0
- package/src/sqlite.ts +169 -0
- package/test/basic.test.ts +505 -0
- package/test/events.test.ts +207 -0
- package/test/hooks.test.ts +350 -0
- package/test/idempotent.test.ts +195 -0
- package/test/index.test.ts +63 -0
- package/test/retry.test.ts +389 -0
- package/test/run.test.ts +101 -0
- package/test/setup.ts +650 -0
- package/test/sqlite.test.ts +89 -0
- package/test/step.test.ts +259 -0
- package/test/wait-for.ts +21 -0
- package/test/worker.test.ts +194 -0
- package/tsconfig.build.json +12 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { describe, expect, it } from "bun:test";
|
|
3
|
+
|
|
4
|
+
import { BunSqliteConnection } from "../src/sqlite";
|
|
5
|
+
|
|
6
|
+
describe("BunSqliteConnection", () => {
|
|
7
|
+
it("rewrites postgres-style params and absurd schema names", async () => {
|
|
8
|
+
const db = new Database(":memory:");
|
|
9
|
+
const conn = new BunSqliteConnection(db);
|
|
10
|
+
|
|
11
|
+
await conn.exec("CREATE TABLE absurd_tasks (id, name)");
|
|
12
|
+
await conn.exec("INSERT INTO absurd.tasks (id, name) VALUES ($1, $2)", [
|
|
13
|
+
1,
|
|
14
|
+
"alpha",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const { rows } = await conn.query<{ id: number; name: string }>(
|
|
18
|
+
"SELECT id, name FROM absurd.tasks WHERE id = $1",
|
|
19
|
+
[1]
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
expect(rows).toEqual([{ id: 1, name: "alpha" }]);
|
|
23
|
+
db.close();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("returns empty rows for non-reader statements", async () => {
|
|
27
|
+
const db = new Database(":memory:");
|
|
28
|
+
const conn = new BunSqliteConnection(db);
|
|
29
|
+
|
|
30
|
+
const { rows } = await conn.query("CREATE TABLE t (id)");
|
|
31
|
+
expect(rows).toEqual([]);
|
|
32
|
+
|
|
33
|
+
await conn.exec("INSERT INTO t (id) VALUES ($1)", [1]);
|
|
34
|
+
const { rows: inserted } = await conn.query<{ id: number }>(
|
|
35
|
+
"SELECT id FROM t"
|
|
36
|
+
);
|
|
37
|
+
expect(inserted).toEqual([{ id: 1 }]);
|
|
38
|
+
db.close();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("decodes JSON from typeless columns", async () => {
|
|
42
|
+
const db = new Database(":memory:");
|
|
43
|
+
const conn = new BunSqliteConnection(db);
|
|
44
|
+
|
|
45
|
+
await conn.exec("CREATE TABLE t (payload)");
|
|
46
|
+
await conn.exec("INSERT INTO t (payload) VALUES ($1)", ['{"a":1}']);
|
|
47
|
+
|
|
48
|
+
const { rows } = await conn.query<{ payload: { a: number } }>(
|
|
49
|
+
"SELECT payload FROM t"
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
expect(rows[0]?.payload).toEqual({ a: 1 });
|
|
53
|
+
db.close();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("decodes JSON from blob columns", async () => {
|
|
57
|
+
const db = new Database(":memory:");
|
|
58
|
+
const conn = new BunSqliteConnection(db);
|
|
59
|
+
|
|
60
|
+
await conn.exec("CREATE TABLE t_blob (payload BLOB)");
|
|
61
|
+
await conn.exec("INSERT INTO t_blob (payload) VALUES ($1)", [
|
|
62
|
+
Buffer.from(JSON.stringify({ b: 2 })),
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
const { rows } = await conn.query<{ payload: { b: number } }>(
|
|
66
|
+
"SELECT payload FROM t_blob"
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
expect(rows[0]?.payload).toEqual({ b: 2 });
|
|
70
|
+
db.close();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("decodes datetime columns into Date objects", async () => {
|
|
74
|
+
const db = new Database(":memory:");
|
|
75
|
+
const conn = new BunSqliteConnection(db);
|
|
76
|
+
const now = Date.now();
|
|
77
|
+
|
|
78
|
+
await conn.exec("CREATE TABLE t_date (created_at DATETIME)");
|
|
79
|
+
await conn.exec("INSERT INTO t_date (created_at) VALUES ($1)", [now]);
|
|
80
|
+
|
|
81
|
+
const { rows } = await conn.query<{ created_at: Date }>(
|
|
82
|
+
"SELECT created_at FROM t_date"
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
expect(rows[0]?.created_at).toBeInstanceOf(Date);
|
|
86
|
+
expect(rows[0]?.created_at.getTime()).toBe(now);
|
|
87
|
+
db.close();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterEach, jest } from "bun:test";
|
|
2
|
+
import type { Absurd } from "absurd-sdk";
|
|
3
|
+
import { createTestAbsurd, randomName, type TestContext } from "./setup";
|
|
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
|
+
jest.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
|
+
jest.useFakeTimers();
|
|
190
|
+
const base = new Date("2024-05-05T10:00:00Z");
|
|
191
|
+
jest.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
|
+
jest.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
|
+
jest.useFakeTimers();
|
|
223
|
+
const base = new Date("2024-05-06T09:00:00Z");
|
|
224
|
+
jest.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
|
+
jest.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
|
+
});
|
package/test/wait-for.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export async function waitFor(
|
|
2
|
+
fn: () => void | Promise<void>,
|
|
3
|
+
options?: { timeout?: number; interval?: number }
|
|
4
|
+
): Promise<void> {
|
|
5
|
+
const timeout = options?.timeout ?? 1000;
|
|
6
|
+
const interval = options?.interval ?? 10;
|
|
7
|
+
const start = Date.now();
|
|
8
|
+
|
|
9
|
+
// Poll until the expectation stops throwing or we time out.
|
|
10
|
+
while (true) {
|
|
11
|
+
try {
|
|
12
|
+
await fn();
|
|
13
|
+
return;
|
|
14
|
+
} catch (err) {
|
|
15
|
+
if (Date.now() - start >= timeout) {
|
|
16
|
+
throw err;
|
|
17
|
+
}
|
|
18
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterEach, jest } from "bun:test";
|
|
2
|
+
import { EventEmitter, once } from "events";
|
|
3
|
+
import type { TestContext } from "./setup";
|
|
4
|
+
import { createTestAbsurd, randomName } from "./setup";
|
|
5
|
+
import type { Absurd } from "absurd-sdk";
|
|
6
|
+
import { waitFor } from "./wait-for";
|
|
7
|
+
|
|
8
|
+
describe("Worker", () => {
|
|
9
|
+
let ctx: TestContext;
|
|
10
|
+
let absurd: Absurd;
|
|
11
|
+
|
|
12
|
+
beforeAll(async () => {
|
|
13
|
+
ctx = await createTestAbsurd(randomName("worker"));
|
|
14
|
+
absurd = ctx.absurd;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
await ctx.cleanupTasks();
|
|
19
|
+
jest.restoreAllMocks();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("respects concurrency limit and skips claims when at capacity", async () => {
|
|
23
|
+
const gate = new EventEmitter();
|
|
24
|
+
const atGate = new Set<number>();
|
|
25
|
+
|
|
26
|
+
gate.on("arrived", (id: number) => atGate.add(id));
|
|
27
|
+
gate.on("left", (id: number) => atGate.delete(id));
|
|
28
|
+
|
|
29
|
+
absurd.registerTask<{ id: number }, void>(
|
|
30
|
+
{ name: "gated-task" },
|
|
31
|
+
async (params) => {
|
|
32
|
+
gate.emit("arrived", params.id);
|
|
33
|
+
await once(gate, "release");
|
|
34
|
+
gate.emit("left", params.id);
|
|
35
|
+
},
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
for (let i = 1; i <= 5; i++) {
|
|
39
|
+
await absurd.spawn("gated-task", { id: i });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const claimSpy = jest.spyOn(absurd, "claimTasks");
|
|
43
|
+
|
|
44
|
+
const concurrency = 2;
|
|
45
|
+
const worker = await absurd.startWorker({
|
|
46
|
+
concurrency,
|
|
47
|
+
pollInterval: 0.01,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Wait until we are at capacity
|
|
51
|
+
await waitFor(() => expect(atGate.size).toBe(concurrency), {
|
|
52
|
+
timeout: 100,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(claimSpy).toHaveBeenCalledTimes(1);
|
|
56
|
+
expect(claimSpy).toHaveBeenCalledWith(
|
|
57
|
+
expect.objectContaining({ batchSize: concurrency }),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// Wait for ~5 poll cycles
|
|
61
|
+
await ctx.sleep(50);
|
|
62
|
+
|
|
63
|
+
// Things should still be the same, no more claims
|
|
64
|
+
expect(atGate.size).toBe(concurrency);
|
|
65
|
+
expect(claimSpy).toHaveBeenCalledTimes(1);
|
|
66
|
+
|
|
67
|
+
gate.emit("release");
|
|
68
|
+
await worker.close();
|
|
69
|
+
expect(atGate.size).toBe(0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("polls again immediately when capacity frees up", async () => {
|
|
73
|
+
const gate = new EventEmitter();
|
|
74
|
+
const atGate = new Set<number>();
|
|
75
|
+
|
|
76
|
+
gate.on("arrived", (id: number) => atGate.add(id));
|
|
77
|
+
gate.on("left", (id: number) => atGate.delete(id));
|
|
78
|
+
|
|
79
|
+
absurd.registerTask<{ id: number }, void>(
|
|
80
|
+
{ name: "responsive-task" },
|
|
81
|
+
async (params) => {
|
|
82
|
+
gate.emit("arrived", params.id);
|
|
83
|
+
await once(gate, "release");
|
|
84
|
+
gate.emit("left", params.id);
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
for (let i = 1; i <= 3; i++) {
|
|
89
|
+
await absurd.spawn("responsive-task", { id: i });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const claimSpy = jest.spyOn(absurd, "claimTasks");
|
|
93
|
+
|
|
94
|
+
const worker = await absurd.startWorker({
|
|
95
|
+
concurrency: 2,
|
|
96
|
+
pollInterval: 3600, // once every hour
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await waitFor(() => expect(atGate.size).toBe(2), {
|
|
100
|
+
timeout: 100,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(claimSpy).toHaveBeenCalledTimes(1);
|
|
104
|
+
const initialCallCount = claimSpy.mock.calls.length;
|
|
105
|
+
|
|
106
|
+
// Free current tasks so the worker can immediately claim again
|
|
107
|
+
gate.emit("release");
|
|
108
|
+
|
|
109
|
+
// Polls again without waiting for pollInterval
|
|
110
|
+
await waitFor(() => {
|
|
111
|
+
expect(claimSpy.mock.calls.length).toBeGreaterThan(initialCallCount);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Release any remaining tasks so the worker can shut down
|
|
115
|
+
gate.emit("release");
|
|
116
|
+
await worker.close();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("shuts down gracefully", async () => {
|
|
120
|
+
const gate = new EventEmitter();
|
|
121
|
+
const started: number[] = [];
|
|
122
|
+
|
|
123
|
+
gate.on("started", (id: number) => started.push(id));
|
|
124
|
+
|
|
125
|
+
absurd.registerTask<{ id: number }, void>(
|
|
126
|
+
{ name: "shutdown-task" },
|
|
127
|
+
async (params) => {
|
|
128
|
+
gate.emit("started", params.id);
|
|
129
|
+
await once(gate, "release");
|
|
130
|
+
},
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
await absurd.spawn("shutdown-task", { id: 1 });
|
|
134
|
+
await absurd.spawn("shutdown-task", { id: 2 });
|
|
135
|
+
|
|
136
|
+
const worker = await absurd.startWorker({ concurrency: 1 });
|
|
137
|
+
await once(gate, "started");
|
|
138
|
+
|
|
139
|
+
const closePromise = worker.close();
|
|
140
|
+
const claimSpy = jest.spyOn(absurd, "claimTasks");
|
|
141
|
+
|
|
142
|
+
let resolved = false;
|
|
143
|
+
closePromise.then(() => {
|
|
144
|
+
resolved = true;
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Does not resolve until the task finishes
|
|
148
|
+
await ctx.sleep(50);
|
|
149
|
+
expect(resolved).toBe(false);
|
|
150
|
+
|
|
151
|
+
gate.emit("release");
|
|
152
|
+
|
|
153
|
+
await waitFor(() => expect(resolved).toBe(true), {
|
|
154
|
+
timeout: 200,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(started).toEqual([1]);
|
|
158
|
+
expect(claimSpy).not.toHaveBeenCalled();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("calls onError for unexpected failures", async () => {
|
|
162
|
+
const errors: string[] = [];
|
|
163
|
+
|
|
164
|
+
absurd.registerTask<void, void>({ name: "test-task" }, async () => {});
|
|
165
|
+
await absurd.spawn("test-task", undefined);
|
|
166
|
+
|
|
167
|
+
jest.spyOn(absurd, "executeTask").mockRejectedValueOnce(
|
|
168
|
+
new Error("Execute failed, PG error"),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const worker = await absurd.startWorker({
|
|
172
|
+
pollInterval: 0.01,
|
|
173
|
+
onError: (err) => errors.push(err.message),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
await waitFor(
|
|
177
|
+
() => expect(errors).toContain("Execute failed, PG error"),
|
|
178
|
+
{
|
|
179
|
+
timeout: 200,
|
|
180
|
+
},
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
jest.spyOn(absurd, "claimTasks").mockRejectedValue(new Error("Claim failed"));
|
|
184
|
+
|
|
185
|
+
await waitFor(() => expect(errors).toContain("Claim failed"), {
|
|
186
|
+
timeout: 200,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
await worker.close();
|
|
190
|
+
|
|
191
|
+
expect(errors).toContain("Execute failed, PG error");
|
|
192
|
+
expect(errors).toContain("Claim failed");
|
|
193
|
+
});
|
|
194
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"forceConsistentCasingInFileNames": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"types": ["bun-types"]
|
|
11
|
+
},
|
|
12
|
+
"include": ["src", "test"]
|
|
13
|
+
}
|