openworkflow 0.3.0 → 0.4.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/README.md +54 -18
- package/dist/backend-sqlite/backend.d.ts +38 -0
- package/dist/backend-sqlite/backend.d.ts.map +1 -0
- package/dist/backend-sqlite/backend.js +628 -0
- package/dist/backend-sqlite/backend.js.map +1 -0
- package/dist/backend-sqlite/index.d.ts +2 -0
- package/dist/backend-sqlite/index.d.ts.map +1 -0
- package/dist/backend-sqlite/index.js +2 -0
- package/dist/backend-sqlite/index.js.map +1 -0
- package/dist/backend-sqlite/sqlite.d.ts +45 -0
- package/dist/backend-sqlite/sqlite.d.ts.map +1 -0
- package/dist/backend-sqlite/sqlite.js +229 -0
- package/dist/backend-sqlite/sqlite.js.map +1 -0
- package/dist/config/config.d.ts +102 -0
- package/dist/config/config.d.ts.map +1 -0
- package/dist/config/config.js +29 -0
- package/dist/config/config.js.map +1 -0
- package/dist/config/index.d.ts +3 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +2 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config.d.ts +28 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +41 -0
- package/dist/config.js.map +1 -0
- package/dist/core/backend-test-suite.d.ts +22 -0
- package/dist/core/backend-test-suite.d.ts.map +1 -0
- package/dist/core/backend-test-suite.js +960 -0
- package/dist/core/backend-test-suite.js.map +1 -0
- package/dist/core/backend.d.ts +103 -0
- package/dist/core/backend.d.ts.map +1 -0
- package/dist/core/backend.js +2 -0
- package/dist/core/backend.js.map +1 -0
- package/dist/core/backend.testsuite.d.ts +21 -0
- package/dist/core/backend.testsuite.d.ts.map +1 -0
- package/dist/core/backend.testsuite.js +958 -0
- package/dist/core/backend.testsuite.js.map +1 -0
- package/dist/{duration.d.ts → core/duration.d.ts} +2 -1
- package/dist/core/duration.d.ts.map +1 -0
- package/dist/{duration.js → core/duration.js} +6 -5
- package/dist/core/duration.js.map +1 -0
- package/dist/core/duration.test.d.ts +2 -0
- package/dist/core/duration.test.d.ts.map +1 -0
- package/dist/core/duration.test.js +264 -0
- package/dist/core/duration.test.js.map +1 -0
- package/dist/core/json.d.ts +5 -0
- package/dist/core/json.d.ts.map +1 -0
- package/dist/core/json.js +2 -0
- package/dist/core/json.js.map +1 -0
- package/dist/core/result.d.ts +12 -0
- package/dist/core/result.d.ts.map +1 -0
- package/dist/core/result.js +7 -0
- package/dist/core/result.js.map +1 -0
- package/dist/core/result.test.d.ts +2 -0
- package/dist/core/result.test.d.ts.map +1 -0
- package/dist/core/result.test.js +11 -0
- package/dist/core/result.test.js.map +1 -0
- package/dist/core/retry.d.ts +16 -0
- package/dist/core/retry.d.ts.map +1 -0
- package/dist/{backend.js → core/retry.js} +1 -3
- package/dist/core/retry.js.map +1 -0
- package/dist/core/retry.test.d.ts +2 -0
- package/dist/core/retry.test.d.ts.map +1 -0
- package/dist/core/retry.test.js +36 -0
- package/dist/core/retry.test.js.map +1 -0
- package/dist/core/schema.d.ts +57 -0
- package/dist/core/schema.d.ts.map +1 -0
- package/dist/core/schema.js +2 -0
- package/dist/core/schema.js.map +1 -0
- package/dist/core/step.d.ts +120 -0
- package/dist/core/step.d.ts.map +1 -0
- package/dist/core/step.js +101 -0
- package/dist/core/step.js.map +1 -0
- package/dist/core/step.test.d.ts +2 -0
- package/dist/core/step.test.d.ts.map +1 -0
- package/dist/core/step.test.js +340 -0
- package/dist/core/step.test.js.map +1 -0
- package/dist/core/workflow.d.ts +108 -0
- package/dist/core/workflow.d.ts.map +1 -0
- package/dist/core/workflow.js +79 -0
- package/dist/core/workflow.js.map +1 -0
- package/dist/core/workflow.test.d.ts +2 -0
- package/dist/core/workflow.test.d.ts.map +1 -0
- package/dist/core/workflow.test.js +216 -0
- package/dist/core/workflow.test.js.map +1 -0
- package/dist/execution/execution.d.ts +91 -0
- package/dist/execution/execution.d.ts.map +1 -0
- package/dist/execution/execution.js +188 -0
- package/dist/execution/execution.js.map +1 -0
- package/dist/execution/execution.test.d.ts +2 -0
- package/dist/execution/execution.test.d.ts.map +1 -0
- package/dist/execution/execution.test.js +382 -0
- package/dist/execution/execution.test.js.map +1 -0
- package/dist/global.d.ts +62 -0
- package/dist/global.d.ts.map +1 -0
- package/dist/global.js +78 -0
- package/dist/global.js.map +1 -0
- package/dist/index.d.ts +9 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/sdk/sdk.d.ts +182 -0
- package/dist/sdk/sdk.d.ts.map +1 -0
- package/dist/sdk/sdk.js +208 -0
- package/dist/sdk/sdk.js.map +1 -0
- package/dist/sdk/sdk.test.d.ts +2 -0
- package/dist/sdk/sdk.test.d.ts.map +1 -0
- package/dist/sdk/sdk.test.js +195 -0
- package/dist/sdk/sdk.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/{worker.d.ts → worker/worker.d.ts} +4 -4
- package/dist/worker/worker.d.ts.map +1 -0
- package/dist/worker/worker.js +208 -0
- package/dist/worker/worker.js.map +1 -0
- package/dist/worker/worker.test.d.ts +2 -0
- package/dist/worker/worker.test.d.ts.map +1 -0
- package/dist/worker/worker.test.js +786 -0
- package/dist/worker/worker.test.js.map +1 -0
- package/package.json +9 -3
- package/dist/backend.d.ts +0 -159
- package/dist/backend.d.ts.map +0 -1
- package/dist/backend.js.map +0 -1
- package/dist/client.d.ts +0 -141
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js +0 -135
- package/dist/client.js.map +0 -1
- package/dist/duration.d.ts.map +0 -1
- package/dist/duration.js.map +0 -1
- package/dist/worker.d.ts.map +0 -1
- package/dist/worker.js +0 -375
- package/dist/worker.js.map +0 -1
|
@@ -0,0 +1,960 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
3
|
+
/**
|
|
4
|
+
* Runs the Backend compliance test suite.
|
|
5
|
+
*
|
|
6
|
+
* This function wraps all the tests that verify a Backend implementation
|
|
7
|
+
* conforms to the Backend interface contract. It uses the factory to create
|
|
8
|
+
* backend instances and the teardown function to clean them up.
|
|
9
|
+
*
|
|
10
|
+
* @param factory - Creates a new isolated Backend instance
|
|
11
|
+
* @param teardown - Cleans up a Backend instance
|
|
12
|
+
*/
|
|
13
|
+
export function testBackend(factory, teardown) {
|
|
14
|
+
describe("Backend", () => {
|
|
15
|
+
let backend;
|
|
16
|
+
beforeAll(async () => {
|
|
17
|
+
backend = await factory();
|
|
18
|
+
});
|
|
19
|
+
afterAll(async () => {
|
|
20
|
+
await teardown(backend);
|
|
21
|
+
});
|
|
22
|
+
describe("createWorkflowRun()", () => {
|
|
23
|
+
test("creates a workflow run", async () => {
|
|
24
|
+
const expected = {
|
|
25
|
+
namespaceId: "", // -
|
|
26
|
+
id: "", // -
|
|
27
|
+
workflowName: randomUUID(),
|
|
28
|
+
version: randomUUID(),
|
|
29
|
+
status: "pending",
|
|
30
|
+
idempotencyKey: randomUUID(),
|
|
31
|
+
config: { key: "val" },
|
|
32
|
+
context: { key: "val" },
|
|
33
|
+
input: { key: "val" },
|
|
34
|
+
output: null,
|
|
35
|
+
error: null,
|
|
36
|
+
attempts: 0,
|
|
37
|
+
parentStepAttemptNamespaceId: null,
|
|
38
|
+
parentStepAttemptId: null,
|
|
39
|
+
workerId: null,
|
|
40
|
+
availableAt: newDateInOneYear(), // -
|
|
41
|
+
deadlineAt: newDateInOneYear(),
|
|
42
|
+
startedAt: null,
|
|
43
|
+
finishedAt: null,
|
|
44
|
+
createdAt: new Date(), // -
|
|
45
|
+
updatedAt: new Date(), // -
|
|
46
|
+
};
|
|
47
|
+
// Create with all fields
|
|
48
|
+
const created = await backend.createWorkflowRun({
|
|
49
|
+
workflowName: expected.workflowName,
|
|
50
|
+
version: expected.version,
|
|
51
|
+
idempotencyKey: expected.idempotencyKey,
|
|
52
|
+
input: expected.input,
|
|
53
|
+
config: expected.config,
|
|
54
|
+
context: expected.context,
|
|
55
|
+
availableAt: expected.availableAt,
|
|
56
|
+
deadlineAt: expected.deadlineAt,
|
|
57
|
+
});
|
|
58
|
+
expect(created.namespaceId).toHaveLength(36);
|
|
59
|
+
expect(created.id).toHaveLength(36);
|
|
60
|
+
expect(deltaSeconds(created.availableAt)).toBeGreaterThan(1);
|
|
61
|
+
expect(deltaSeconds(created.createdAt)).toBeLessThan(1);
|
|
62
|
+
expect(deltaSeconds(created.updatedAt)).toBeLessThan(1);
|
|
63
|
+
expected.namespaceId = created.namespaceId;
|
|
64
|
+
expected.id = created.id;
|
|
65
|
+
expected.availableAt = created.availableAt;
|
|
66
|
+
expected.createdAt = created.createdAt;
|
|
67
|
+
expected.updatedAt = created.updatedAt;
|
|
68
|
+
expect(created).toEqual(expected);
|
|
69
|
+
// Create with minimal fields
|
|
70
|
+
const createdMin = await backend.createWorkflowRun({
|
|
71
|
+
workflowName: expected.workflowName,
|
|
72
|
+
version: null,
|
|
73
|
+
idempotencyKey: null,
|
|
74
|
+
input: null,
|
|
75
|
+
config: {},
|
|
76
|
+
context: null,
|
|
77
|
+
availableAt: null,
|
|
78
|
+
deadlineAt: null,
|
|
79
|
+
});
|
|
80
|
+
expect(createdMin.version).toBeNull();
|
|
81
|
+
expect(createdMin.idempotencyKey).toBeNull();
|
|
82
|
+
expect(createdMin.input).toBeNull();
|
|
83
|
+
expect(createdMin.context).toBeNull();
|
|
84
|
+
expect(deltaSeconds(createdMin.availableAt)).toBeLessThan(1); // defaults to NOW()
|
|
85
|
+
expect(createdMin.deadlineAt).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
describe("listWorkflowRuns()", () => {
|
|
89
|
+
test("lists workflow runs ordered by creation time", async () => {
|
|
90
|
+
const isolatedBackend = await factory();
|
|
91
|
+
const first = await createPendingWorkflowRun(isolatedBackend);
|
|
92
|
+
await sleep(10); // ensure timestamp difference
|
|
93
|
+
const second = await createPendingWorkflowRun(isolatedBackend);
|
|
94
|
+
const listed = await isolatedBackend.listWorkflowRuns({});
|
|
95
|
+
expect(listed.data.map((run) => run.id)).toEqual([first.id, second.id]);
|
|
96
|
+
await teardown(isolatedBackend);
|
|
97
|
+
});
|
|
98
|
+
test("paginates workflow runs", async () => {
|
|
99
|
+
const isolatedBackend = await factory();
|
|
100
|
+
const runs = [];
|
|
101
|
+
for (let i = 0; i < 5; i++) {
|
|
102
|
+
runs.push(await createPendingWorkflowRun(isolatedBackend));
|
|
103
|
+
await sleep(10);
|
|
104
|
+
}
|
|
105
|
+
// p1
|
|
106
|
+
const page1 = await isolatedBackend.listWorkflowRuns({ limit: 2 });
|
|
107
|
+
expect(page1.data).toHaveLength(2);
|
|
108
|
+
expect(page1.data[0]?.id).toBe(runs[0]?.id);
|
|
109
|
+
expect(page1.data[1]?.id).toBe(runs[1]?.id);
|
|
110
|
+
expect(page1.pagination.next).not.toBeNull();
|
|
111
|
+
expect(page1.pagination.prev).toBeNull();
|
|
112
|
+
// p2
|
|
113
|
+
const page2 = await isolatedBackend.listWorkflowRuns({
|
|
114
|
+
limit: 2,
|
|
115
|
+
after: page1.pagination.next, // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
|
116
|
+
});
|
|
117
|
+
expect(page2.data).toHaveLength(2);
|
|
118
|
+
expect(page2.data[0]?.id).toBe(runs[2]?.id);
|
|
119
|
+
expect(page2.data[1]?.id).toBe(runs[3]?.id);
|
|
120
|
+
expect(page2.pagination.next).not.toBeNull();
|
|
121
|
+
expect(page2.pagination.prev).not.toBeNull();
|
|
122
|
+
// p3
|
|
123
|
+
const page3 = await isolatedBackend.listWorkflowRuns({
|
|
124
|
+
limit: 2,
|
|
125
|
+
after: page2.pagination.next, // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
|
126
|
+
});
|
|
127
|
+
expect(page3.data).toHaveLength(1);
|
|
128
|
+
expect(page3.data[0]?.id).toBe(runs[4]?.id);
|
|
129
|
+
expect(page3.pagination.next).toBeNull();
|
|
130
|
+
expect(page3.pagination.prev).not.toBeNull();
|
|
131
|
+
// p2 again
|
|
132
|
+
const page2Back = await isolatedBackend.listWorkflowRuns({
|
|
133
|
+
limit: 2,
|
|
134
|
+
before: page3.pagination.prev, // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
|
135
|
+
});
|
|
136
|
+
expect(page2Back.data).toHaveLength(2);
|
|
137
|
+
expect(page2Back.data[0]?.id).toBe(runs[2]?.id);
|
|
138
|
+
expect(page2Back.data[1]?.id).toBe(runs[3]?.id);
|
|
139
|
+
expect(page2Back.pagination.next).toEqual(page2.pagination.next);
|
|
140
|
+
expect(page2Back.pagination.prev).toEqual(page2.pagination.prev);
|
|
141
|
+
await teardown(isolatedBackend);
|
|
142
|
+
});
|
|
143
|
+
test("handles empty results", async () => {
|
|
144
|
+
const isolatedBackend = await factory();
|
|
145
|
+
const listed = await isolatedBackend.listWorkflowRuns({});
|
|
146
|
+
expect(listed.data).toHaveLength(0);
|
|
147
|
+
expect(listed.pagination.next).toBeNull();
|
|
148
|
+
expect(listed.pagination.prev).toBeNull();
|
|
149
|
+
await teardown(isolatedBackend);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
describe("claimWorkflowRun()", () => {
|
|
153
|
+
// because claims involve timing and leases, we create and teardown a new
|
|
154
|
+
// namespaced backend instance for each test
|
|
155
|
+
test("claims workflow runs and respects leases, reclaiming if lease expires", async () => {
|
|
156
|
+
const isolatedBackend = await factory();
|
|
157
|
+
await createPendingWorkflowRun(isolatedBackend);
|
|
158
|
+
const firstLeaseMs = 30;
|
|
159
|
+
const firstWorker = randomUUID();
|
|
160
|
+
const claimed = await isolatedBackend.claimWorkflowRun({
|
|
161
|
+
workerId: firstWorker,
|
|
162
|
+
leaseDurationMs: firstLeaseMs,
|
|
163
|
+
});
|
|
164
|
+
expect(claimed?.status).toBe("running");
|
|
165
|
+
expect(claimed?.workerId).toBe(firstWorker);
|
|
166
|
+
expect(claimed?.attempts).toBe(1);
|
|
167
|
+
expect(claimed?.startedAt).not.toBeNull();
|
|
168
|
+
const secondWorker = randomUUID();
|
|
169
|
+
const blocked = await isolatedBackend.claimWorkflowRun({
|
|
170
|
+
workerId: secondWorker,
|
|
171
|
+
leaseDurationMs: 10,
|
|
172
|
+
});
|
|
173
|
+
expect(blocked).toBeNull();
|
|
174
|
+
await sleep(firstLeaseMs);
|
|
175
|
+
const reclaimed = await isolatedBackend.claimWorkflowRun({
|
|
176
|
+
workerId: secondWorker,
|
|
177
|
+
leaseDurationMs: 10,
|
|
178
|
+
});
|
|
179
|
+
expect(reclaimed?.id).toBe(claimed?.id);
|
|
180
|
+
expect(reclaimed?.attempts).toBe(2);
|
|
181
|
+
expect(reclaimed?.workerId).toBe(secondWorker);
|
|
182
|
+
expect(reclaimed?.startedAt?.getTime()).toBe(claimed?.startedAt?.getTime());
|
|
183
|
+
await teardown(isolatedBackend);
|
|
184
|
+
});
|
|
185
|
+
test("prioritizes pending workflow runs over expired running ones", async () => {
|
|
186
|
+
const isolatedBackend = await factory();
|
|
187
|
+
const running = await createPendingWorkflowRun(isolatedBackend);
|
|
188
|
+
const runningClaim = await isolatedBackend.claimWorkflowRun({
|
|
189
|
+
workerId: "worker-running",
|
|
190
|
+
leaseDurationMs: 5,
|
|
191
|
+
});
|
|
192
|
+
if (!runningClaim)
|
|
193
|
+
throw new Error("expected claim");
|
|
194
|
+
expect(runningClaim.id).toBe(running.id);
|
|
195
|
+
await sleep(10); // wait for running's lease to expire
|
|
196
|
+
// pending claimed first, even though running expired
|
|
197
|
+
const pending = await createPendingWorkflowRun(isolatedBackend);
|
|
198
|
+
const claimedFirst = await isolatedBackend.claimWorkflowRun({
|
|
199
|
+
workerId: "worker-second",
|
|
200
|
+
leaseDurationMs: 100,
|
|
201
|
+
});
|
|
202
|
+
expect(claimedFirst?.id).toBe(pending.id);
|
|
203
|
+
// running claimed second
|
|
204
|
+
const claimedSecond = await isolatedBackend.claimWorkflowRun({
|
|
205
|
+
workerId: "worker-third",
|
|
206
|
+
leaseDurationMs: 100,
|
|
207
|
+
});
|
|
208
|
+
expect(claimedSecond?.id).toBe(running.id);
|
|
209
|
+
await teardown(isolatedBackend);
|
|
210
|
+
});
|
|
211
|
+
test("returns null when no workflow runs are available", async () => {
|
|
212
|
+
const isolatedBackend = await factory();
|
|
213
|
+
const claimed = await isolatedBackend.claimWorkflowRun({
|
|
214
|
+
workerId: randomUUID(),
|
|
215
|
+
leaseDurationMs: 10,
|
|
216
|
+
});
|
|
217
|
+
expect(claimed).toBeNull();
|
|
218
|
+
await teardown(isolatedBackend);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
describe("extendWorkflowRunLease()", () => {
|
|
222
|
+
test("extends the lease for running workflow runs", async () => {
|
|
223
|
+
const workerId = randomUUID();
|
|
224
|
+
await createPendingWorkflowRun(backend);
|
|
225
|
+
const claimed = await backend.claimWorkflowRun({
|
|
226
|
+
workerId,
|
|
227
|
+
leaseDurationMs: 20,
|
|
228
|
+
});
|
|
229
|
+
if (!claimed)
|
|
230
|
+
throw new Error("Expected workflow run to be claimed"); // for type narrowing
|
|
231
|
+
const previousExpiry = claimed.availableAt;
|
|
232
|
+
const extended = await backend.extendWorkflowRunLease({
|
|
233
|
+
workflowRunId: claimed.id,
|
|
234
|
+
workerId,
|
|
235
|
+
leaseDurationMs: 200,
|
|
236
|
+
});
|
|
237
|
+
expect(extended.availableAt?.getTime()).toBeGreaterThan(previousExpiry?.getTime() ?? Infinity);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
describe("sleepWorkflowRun()", () => {
|
|
241
|
+
test("sets a running workflow to sleeping status until a future time", async () => {
|
|
242
|
+
const workerId = randomUUID();
|
|
243
|
+
await createPendingWorkflowRun(backend);
|
|
244
|
+
const claimed = await backend.claimWorkflowRun({
|
|
245
|
+
workerId,
|
|
246
|
+
leaseDurationMs: 100,
|
|
247
|
+
});
|
|
248
|
+
if (!claimed)
|
|
249
|
+
throw new Error("Expected workflow run to be claimed");
|
|
250
|
+
const sleepUntil = new Date(Date.now() + 5000); // 5 seconds from now
|
|
251
|
+
await backend.sleepWorkflowRun({
|
|
252
|
+
workflowRunId: claimed.id,
|
|
253
|
+
workerId,
|
|
254
|
+
availableAt: sleepUntil,
|
|
255
|
+
});
|
|
256
|
+
const fetched = await backend.getWorkflowRun({
|
|
257
|
+
workflowRunId: claimed.id,
|
|
258
|
+
});
|
|
259
|
+
expect(fetched).not.toBeNull();
|
|
260
|
+
expect(fetched?.availableAt?.getTime()).toBe(sleepUntil.getTime());
|
|
261
|
+
expect(fetched?.workerId).toBeNull();
|
|
262
|
+
expect(fetched?.status).toBe("sleeping");
|
|
263
|
+
});
|
|
264
|
+
test("fails when trying to sleep a canceled workflow", async () => {
|
|
265
|
+
const isolatedBackend = await factory();
|
|
266
|
+
// completed run
|
|
267
|
+
let claimed = await createClaimedWorkflowRun(isolatedBackend);
|
|
268
|
+
await isolatedBackend.completeWorkflowRun({
|
|
269
|
+
workflowRunId: claimed.id,
|
|
270
|
+
workerId: claimed.workerId ?? "",
|
|
271
|
+
output: null,
|
|
272
|
+
});
|
|
273
|
+
await expect(isolatedBackend.sleepWorkflowRun({
|
|
274
|
+
workflowRunId: claimed.id,
|
|
275
|
+
workerId: claimed.workerId ?? "",
|
|
276
|
+
availableAt: new Date(Date.now() + 60_000),
|
|
277
|
+
})).rejects.toThrow("Failed to sleep workflow run");
|
|
278
|
+
// failed run
|
|
279
|
+
claimed = await createClaimedWorkflowRun(isolatedBackend);
|
|
280
|
+
await isolatedBackend.failWorkflowRun({
|
|
281
|
+
workflowRunId: claimed.id,
|
|
282
|
+
workerId: claimed.workerId ?? "",
|
|
283
|
+
error: null,
|
|
284
|
+
});
|
|
285
|
+
await expect(isolatedBackend.sleepWorkflowRun({
|
|
286
|
+
workflowRunId: claimed.id,
|
|
287
|
+
workerId: claimed.workerId ?? "",
|
|
288
|
+
availableAt: new Date(Date.now() + 60_000),
|
|
289
|
+
})).rejects.toThrow("Failed to sleep workflow run");
|
|
290
|
+
// canceled run
|
|
291
|
+
claimed = await createClaimedWorkflowRun(isolatedBackend);
|
|
292
|
+
await isolatedBackend.cancelWorkflowRun({
|
|
293
|
+
workflowRunId: claimed.id,
|
|
294
|
+
});
|
|
295
|
+
await expect(isolatedBackend.sleepWorkflowRun({
|
|
296
|
+
workflowRunId: claimed.id,
|
|
297
|
+
workerId: claimed.workerId ?? "",
|
|
298
|
+
availableAt: new Date(Date.now() + 60_000),
|
|
299
|
+
})).rejects.toThrow("Failed to sleep workflow run");
|
|
300
|
+
await teardown(isolatedBackend);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
describe("completeWorkflowRun()", () => {
|
|
304
|
+
test("marks running workflow runs as completed", async () => {
|
|
305
|
+
const workerId = randomUUID();
|
|
306
|
+
await createPendingWorkflowRun(backend);
|
|
307
|
+
const claimed = await backend.claimWorkflowRun({
|
|
308
|
+
workerId,
|
|
309
|
+
leaseDurationMs: 20,
|
|
310
|
+
});
|
|
311
|
+
if (!claimed)
|
|
312
|
+
throw new Error("Expected workflow run to be claimed"); // for type narrowing
|
|
313
|
+
const output = { ok: true };
|
|
314
|
+
const completed = await backend.completeWorkflowRun({
|
|
315
|
+
workflowRunId: claimed.id,
|
|
316
|
+
workerId,
|
|
317
|
+
output,
|
|
318
|
+
});
|
|
319
|
+
expect(completed.status).toBe("completed");
|
|
320
|
+
expect(completed.output).toEqual(output);
|
|
321
|
+
expect(completed.error).toBeNull();
|
|
322
|
+
expect(completed.finishedAt).not.toBeNull();
|
|
323
|
+
expect(completed.availableAt).toBeNull();
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
describe("failWorkflowRun()", () => {
|
|
327
|
+
test("reschedules workflow runs with exponential backoff on first failure", async () => {
|
|
328
|
+
const workerId = randomUUID();
|
|
329
|
+
await createPendingWorkflowRun(backend);
|
|
330
|
+
const claimed = await backend.claimWorkflowRun({
|
|
331
|
+
workerId,
|
|
332
|
+
leaseDurationMs: 20,
|
|
333
|
+
});
|
|
334
|
+
if (!claimed)
|
|
335
|
+
throw new Error("Expected workflow run to be claimed");
|
|
336
|
+
const beforeFailTime = Date.now();
|
|
337
|
+
const error = { message: "boom" };
|
|
338
|
+
const failed = await backend.failWorkflowRun({
|
|
339
|
+
workflowRunId: claimed.id,
|
|
340
|
+
workerId,
|
|
341
|
+
error,
|
|
342
|
+
});
|
|
343
|
+
// rescheduled, not permanently failed
|
|
344
|
+
expect(failed.status).toBe("pending");
|
|
345
|
+
expect(failed.error).toEqual(error);
|
|
346
|
+
expect(failed.output).toBeNull();
|
|
347
|
+
expect(failed.finishedAt).toBeNull();
|
|
348
|
+
expect(failed.workerId).toBeNull();
|
|
349
|
+
expect(failed.availableAt).not.toBeNull();
|
|
350
|
+
if (!failed.availableAt)
|
|
351
|
+
throw new Error("Expected availableAt");
|
|
352
|
+
const delayMs = failed.availableAt.getTime() - beforeFailTime;
|
|
353
|
+
expect(delayMs).toBeGreaterThanOrEqual(900); // ~1s with some tolerance
|
|
354
|
+
expect(delayMs).toBeLessThan(1500);
|
|
355
|
+
});
|
|
356
|
+
test("reschedules with increasing backoff on multiple failures (known slow test)", async () => {
|
|
357
|
+
// this test needs isolated namespace
|
|
358
|
+
const isolatedBackend = await factory();
|
|
359
|
+
await createPendingWorkflowRun(isolatedBackend);
|
|
360
|
+
// fail first attempt
|
|
361
|
+
let workerId = randomUUID();
|
|
362
|
+
let claimed = await isolatedBackend.claimWorkflowRun({
|
|
363
|
+
workerId,
|
|
364
|
+
leaseDurationMs: 20,
|
|
365
|
+
});
|
|
366
|
+
if (!claimed)
|
|
367
|
+
throw new Error("Expected workflow run to be claimed");
|
|
368
|
+
expect(claimed.attempts).toBe(1);
|
|
369
|
+
const firstFailed = await isolatedBackend.failWorkflowRun({
|
|
370
|
+
workflowRunId: claimed.id,
|
|
371
|
+
workerId,
|
|
372
|
+
error: { message: "first failure" },
|
|
373
|
+
});
|
|
374
|
+
expect(firstFailed.status).toBe("pending");
|
|
375
|
+
await sleep(1100); // wait for first backoff (~1s)
|
|
376
|
+
// fail second attempt
|
|
377
|
+
workerId = randomUUID();
|
|
378
|
+
claimed = await isolatedBackend.claimWorkflowRun({
|
|
379
|
+
workerId,
|
|
380
|
+
leaseDurationMs: 20,
|
|
381
|
+
});
|
|
382
|
+
if (!claimed)
|
|
383
|
+
throw new Error("Expected workflow run to be claimed");
|
|
384
|
+
expect(claimed.attempts).toBe(2);
|
|
385
|
+
const beforeSecondFail = Date.now();
|
|
386
|
+
const secondFailed = await isolatedBackend.failWorkflowRun({
|
|
387
|
+
workflowRunId: claimed.id,
|
|
388
|
+
workerId,
|
|
389
|
+
error: { message: "second failure" },
|
|
390
|
+
});
|
|
391
|
+
expect(secondFailed.status).toBe("pending");
|
|
392
|
+
// second attempt should have ~2s backoff (1s * 2^1)
|
|
393
|
+
if (!secondFailed.availableAt)
|
|
394
|
+
throw new Error("Expected availableAt");
|
|
395
|
+
const delayMs = secondFailed.availableAt.getTime() - beforeSecondFail;
|
|
396
|
+
expect(delayMs).toBeGreaterThanOrEqual(1900); // ~2s with some tolerance
|
|
397
|
+
expect(delayMs).toBeLessThan(2500);
|
|
398
|
+
await teardown(isolatedBackend);
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
describe("createStepAttempt()", () => {
|
|
402
|
+
test("creates a step attempt", async () => {
|
|
403
|
+
const workflowRun = await createClaimedWorkflowRun(backend);
|
|
404
|
+
const expected = {
|
|
405
|
+
namespaceId: workflowRun.namespaceId,
|
|
406
|
+
id: "", // -
|
|
407
|
+
workflowRunId: workflowRun.id,
|
|
408
|
+
stepName: randomUUID(),
|
|
409
|
+
kind: "function",
|
|
410
|
+
status: "running",
|
|
411
|
+
config: { key: "val" },
|
|
412
|
+
context: null,
|
|
413
|
+
output: null,
|
|
414
|
+
error: null,
|
|
415
|
+
childWorkflowRunNamespaceId: null,
|
|
416
|
+
childWorkflowRunId: null,
|
|
417
|
+
startedAt: null,
|
|
418
|
+
finishedAt: null,
|
|
419
|
+
createdAt: new Date(), // -
|
|
420
|
+
updatedAt: new Date(), // -
|
|
421
|
+
};
|
|
422
|
+
const created = await backend.createStepAttempt({
|
|
423
|
+
workflowRunId: expected.workflowRunId,
|
|
424
|
+
workerId: workflowRun.workerId, // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
|
425
|
+
stepName: expected.stepName,
|
|
426
|
+
kind: expected.kind,
|
|
427
|
+
config: expected.config,
|
|
428
|
+
context: expected.context,
|
|
429
|
+
});
|
|
430
|
+
expect(created.id).toHaveLength(36);
|
|
431
|
+
expect(deltaSeconds(created.startedAt)).toBeLessThan(1);
|
|
432
|
+
expect(deltaSeconds(created.createdAt)).toBeLessThan(1);
|
|
433
|
+
expect(deltaSeconds(created.updatedAt)).toBeLessThan(1);
|
|
434
|
+
expected.id = created.id;
|
|
435
|
+
expected.startedAt = created.startedAt;
|
|
436
|
+
expected.createdAt = created.createdAt;
|
|
437
|
+
expected.updatedAt = created.updatedAt;
|
|
438
|
+
expect(created).toEqual(expected);
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
describe("getStepAttempt()", () => {
|
|
442
|
+
test("returns a persisted step attempt", async () => {
|
|
443
|
+
const claimed = await createClaimedWorkflowRun(backend);
|
|
444
|
+
const created = await backend.createStepAttempt({
|
|
445
|
+
workflowRunId: claimed.id,
|
|
446
|
+
workerId: claimed.workerId, // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
|
447
|
+
stepName: randomUUID(),
|
|
448
|
+
kind: "function",
|
|
449
|
+
config: {},
|
|
450
|
+
context: null,
|
|
451
|
+
});
|
|
452
|
+
const got = await backend.getStepAttempt({
|
|
453
|
+
stepAttemptId: created.id,
|
|
454
|
+
});
|
|
455
|
+
expect(got).toEqual(created);
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
describe("listStepAttempts()", () => {
|
|
459
|
+
test("lists step attempts ordered by creation time", async () => {
|
|
460
|
+
const claimed = await createClaimedWorkflowRun(backend);
|
|
461
|
+
const first = await backend.createStepAttempt({
|
|
462
|
+
workflowRunId: claimed.id,
|
|
463
|
+
workerId: claimed.workerId, // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
|
464
|
+
stepName: randomUUID(),
|
|
465
|
+
kind: "function",
|
|
466
|
+
config: {},
|
|
467
|
+
context: null,
|
|
468
|
+
});
|
|
469
|
+
await backend.completeStepAttempt({
|
|
470
|
+
workflowRunId: claimed.id,
|
|
471
|
+
stepAttemptId: first.id,
|
|
472
|
+
workerId: claimed.workerId, // eslint-disable-line @typescript-eslint/no-non-null-assertion,
|
|
473
|
+
output: { ok: true },
|
|
474
|
+
});
|
|
475
|
+
const second = await backend.createStepAttempt({
|
|
476
|
+
workflowRunId: claimed.id,
|
|
477
|
+
workerId: claimed.workerId, // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
|
478
|
+
stepName: randomUUID(),
|
|
479
|
+
kind: "function",
|
|
480
|
+
config: {},
|
|
481
|
+
context: null,
|
|
482
|
+
});
|
|
483
|
+
const listed = await backend.listStepAttempts({
|
|
484
|
+
workflowRunId: claimed.id,
|
|
485
|
+
});
|
|
486
|
+
expect(listed.data.map((step) => step.stepName)).toEqual([
|
|
487
|
+
first.stepName,
|
|
488
|
+
second.stepName,
|
|
489
|
+
]);
|
|
490
|
+
});
|
|
491
|
+
test("paginates step attempts", async () => {
|
|
492
|
+
const claimed = await createClaimedWorkflowRun(backend);
|
|
493
|
+
for (let i = 0; i < 5; i++) {
|
|
494
|
+
await backend.createStepAttempt({
|
|
495
|
+
workflowRunId: claimed.id,
|
|
496
|
+
workerId: claimed.workerId, // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
|
497
|
+
stepName: `step-${String(i)}`,
|
|
498
|
+
kind: "function",
|
|
499
|
+
config: {},
|
|
500
|
+
context: null,
|
|
501
|
+
});
|
|
502
|
+
await sleep(10); // ensure createdAt differs
|
|
503
|
+
}
|
|
504
|
+
// p1
|
|
505
|
+
const page1 = await backend.listStepAttempts({
|
|
506
|
+
workflowRunId: claimed.id,
|
|
507
|
+
limit: 2,
|
|
508
|
+
});
|
|
509
|
+
expect(page1.data).toHaveLength(2);
|
|
510
|
+
expect(page1.data[0]?.stepName).toBe("step-0");
|
|
511
|
+
expect(page1.data[1]?.stepName).toBe("step-1");
|
|
512
|
+
expect(page1.pagination.next).not.toBeNull();
|
|
513
|
+
expect(page1.pagination.prev).toBeNull();
|
|
514
|
+
// p2
|
|
515
|
+
const page2 = await backend.listStepAttempts({
|
|
516
|
+
workflowRunId: claimed.id,
|
|
517
|
+
limit: 2,
|
|
518
|
+
after: page1.pagination.next, // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
|
519
|
+
});
|
|
520
|
+
expect(page2.data).toHaveLength(2);
|
|
521
|
+
expect(page2.data[0]?.stepName).toBe("step-2");
|
|
522
|
+
expect(page2.data[1]?.stepName).toBe("step-3");
|
|
523
|
+
expect(page2.pagination.next).not.toBeNull();
|
|
524
|
+
expect(page2.pagination.prev).not.toBeNull();
|
|
525
|
+
// p3
|
|
526
|
+
const page3 = await backend.listStepAttempts({
|
|
527
|
+
workflowRunId: claimed.id,
|
|
528
|
+
limit: 2,
|
|
529
|
+
after: page2.pagination.next, // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
|
530
|
+
});
|
|
531
|
+
expect(page3.data).toHaveLength(1);
|
|
532
|
+
expect(page3.data[0]?.stepName).toBe("step-4");
|
|
533
|
+
expect(page3.pagination.next).toBeNull();
|
|
534
|
+
expect(page3.pagination.prev).not.toBeNull();
|
|
535
|
+
// p2 again
|
|
536
|
+
const page2Back = await backend.listStepAttempts({
|
|
537
|
+
workflowRunId: claimed.id,
|
|
538
|
+
limit: 2,
|
|
539
|
+
before: page3.pagination.prev, // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
|
540
|
+
});
|
|
541
|
+
expect(page2Back.data).toHaveLength(2);
|
|
542
|
+
expect(page2Back.data[0]?.stepName).toBe("step-2");
|
|
543
|
+
expect(page2Back.data[1]?.stepName).toBe("step-3");
|
|
544
|
+
expect(page2Back.pagination.next).toEqual(page2.pagination.next);
|
|
545
|
+
expect(page2Back.pagination.prev).toEqual(page2.pagination.prev);
|
|
546
|
+
});
|
|
547
|
+
test("handles empty results", async () => {
|
|
548
|
+
const claimed = await createClaimedWorkflowRun(backend);
|
|
549
|
+
const listed = await backend.listStepAttempts({
|
|
550
|
+
workflowRunId: claimed.id,
|
|
551
|
+
});
|
|
552
|
+
expect(listed.data).toHaveLength(0);
|
|
553
|
+
expect(listed.pagination.next).toBeNull();
|
|
554
|
+
expect(listed.pagination.prev).toBeNull();
|
|
555
|
+
});
|
|
556
|
+
test("handles exact limit match", async () => {
|
|
557
|
+
const claimed = await createClaimedWorkflowRun(backend);
|
|
558
|
+
await backend.createStepAttempt({
|
|
559
|
+
workflowRunId: claimed.id,
|
|
560
|
+
workerId: claimed.workerId, // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
|
561
|
+
stepName: "step-1",
|
|
562
|
+
kind: "function",
|
|
563
|
+
config: {},
|
|
564
|
+
context: null,
|
|
565
|
+
});
|
|
566
|
+
const listed = await backend.listStepAttempts({
|
|
567
|
+
workflowRunId: claimed.id,
|
|
568
|
+
limit: 1,
|
|
569
|
+
});
|
|
570
|
+
expect(listed.data).toHaveLength(1);
|
|
571
|
+
expect(listed.pagination.next).toBeNull();
|
|
572
|
+
expect(listed.pagination.prev).toBeNull();
|
|
573
|
+
});
|
|
574
|
+
});
|
|
575
|
+
describe("getStepAttempt() duplicate", () => {
|
|
576
|
+
test("returns a persisted step attempt", async () => {
|
|
577
|
+
const claimed = await createClaimedWorkflowRun(backend);
|
|
578
|
+
const created = await backend.createStepAttempt({
|
|
579
|
+
workflowRunId: claimed.id,
|
|
580
|
+
workerId: claimed.workerId, // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
|
581
|
+
stepName: randomUUID(),
|
|
582
|
+
kind: "function",
|
|
583
|
+
config: {},
|
|
584
|
+
context: null,
|
|
585
|
+
});
|
|
586
|
+
const got = await backend.getStepAttempt({
|
|
587
|
+
stepAttemptId: created.id,
|
|
588
|
+
});
|
|
589
|
+
expect(got).toEqual(created);
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
describe("completeStepAttempt()", () => {
|
|
593
|
+
test("marks running step attempts as completed", async () => {
|
|
594
|
+
const claimed = await createClaimedWorkflowRun(backend);
|
|
595
|
+
const created = await backend.createStepAttempt({
|
|
596
|
+
workflowRunId: claimed.id,
|
|
597
|
+
workerId: claimed.workerId, // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
|
598
|
+
stepName: randomUUID(),
|
|
599
|
+
kind: "function",
|
|
600
|
+
config: {},
|
|
601
|
+
context: null,
|
|
602
|
+
});
|
|
603
|
+
const output = { foo: "bar" };
|
|
604
|
+
const completed = await backend.completeStepAttempt({
|
|
605
|
+
workflowRunId: claimed.id,
|
|
606
|
+
stepAttemptId: created.id,
|
|
607
|
+
workerId: claimed.workerId, // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
|
608
|
+
output,
|
|
609
|
+
});
|
|
610
|
+
expect(completed.status).toBe("completed");
|
|
611
|
+
expect(completed.output).toEqual(output);
|
|
612
|
+
expect(completed.error).toBeNull();
|
|
613
|
+
expect(completed.finishedAt).not.toBeNull();
|
|
614
|
+
const fetched = await backend.getStepAttempt({
|
|
615
|
+
stepAttemptId: created.id,
|
|
616
|
+
});
|
|
617
|
+
expect(fetched?.status).toBe("completed");
|
|
618
|
+
expect(fetched?.output).toEqual(output);
|
|
619
|
+
expect(fetched?.error).toBeNull();
|
|
620
|
+
expect(fetched?.finishedAt).not.toBeNull();
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
describe("failStepAttempt()", () => {
|
|
624
|
+
test("marks running step attempts as failed", async () => {
|
|
625
|
+
const claimed = await createClaimedWorkflowRun(backend);
|
|
626
|
+
const created = await backend.createStepAttempt({
|
|
627
|
+
workflowRunId: claimed.id,
|
|
628
|
+
workerId: claimed.workerId, // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
|
629
|
+
stepName: randomUUID(),
|
|
630
|
+
kind: "function",
|
|
631
|
+
config: {},
|
|
632
|
+
context: null,
|
|
633
|
+
});
|
|
634
|
+
const error = { message: "nope" };
|
|
635
|
+
const failed = await backend.failStepAttempt({
|
|
636
|
+
workflowRunId: claimed.id,
|
|
637
|
+
stepAttemptId: created.id,
|
|
638
|
+
workerId: claimed.workerId, // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
|
639
|
+
error,
|
|
640
|
+
});
|
|
641
|
+
expect(failed.status).toBe("failed");
|
|
642
|
+
expect(failed.error).toEqual(error);
|
|
643
|
+
expect(failed.output).toBeNull();
|
|
644
|
+
expect(failed.finishedAt).not.toBeNull();
|
|
645
|
+
const fetched = await backend.getStepAttempt({
|
|
646
|
+
stepAttemptId: created.id,
|
|
647
|
+
});
|
|
648
|
+
expect(fetched?.status).toBe("failed");
|
|
649
|
+
expect(fetched?.error).toEqual(error);
|
|
650
|
+
expect(fetched?.output).toBeNull();
|
|
651
|
+
expect(fetched?.finishedAt).not.toBeNull();
|
|
652
|
+
});
|
|
653
|
+
});
|
|
654
|
+
describe("deadline_at", () => {
|
|
655
|
+
test("creates a workflow run with a deadline", async () => {
|
|
656
|
+
const deadline = new Date(Date.now() + 60_000); // in 1 minute
|
|
657
|
+
const created = await backend.createWorkflowRun({
|
|
658
|
+
workflowName: randomUUID(),
|
|
659
|
+
version: null,
|
|
660
|
+
idempotencyKey: null,
|
|
661
|
+
input: null,
|
|
662
|
+
config: {},
|
|
663
|
+
context: null,
|
|
664
|
+
availableAt: null,
|
|
665
|
+
deadlineAt: deadline,
|
|
666
|
+
});
|
|
667
|
+
expect(created.deadlineAt).not.toBeNull();
|
|
668
|
+
expect(created.deadlineAt?.getTime()).toBe(deadline.getTime());
|
|
669
|
+
});
|
|
670
|
+
test("does not claim workflow runs past their deadline", async () => {
|
|
671
|
+
const isolatedBackend = await factory();
|
|
672
|
+
const pastDeadline = new Date(Date.now() - 1000);
|
|
673
|
+
await isolatedBackend.createWorkflowRun({
|
|
674
|
+
workflowName: randomUUID(),
|
|
675
|
+
version: null,
|
|
676
|
+
idempotencyKey: null,
|
|
677
|
+
input: null,
|
|
678
|
+
config: {},
|
|
679
|
+
context: null,
|
|
680
|
+
availableAt: null,
|
|
681
|
+
deadlineAt: pastDeadline,
|
|
682
|
+
});
|
|
683
|
+
const claimed = await isolatedBackend.claimWorkflowRun({
|
|
684
|
+
workerId: randomUUID(),
|
|
685
|
+
leaseDurationMs: 1000,
|
|
686
|
+
});
|
|
687
|
+
expect(claimed).toBeNull();
|
|
688
|
+
await teardown(isolatedBackend);
|
|
689
|
+
});
|
|
690
|
+
test("marks deadline-expired workflow runs as failed when claiming", async () => {
|
|
691
|
+
const isolatedBackend = await factory();
|
|
692
|
+
const pastDeadline = new Date(Date.now() - 1000);
|
|
693
|
+
const created = await isolatedBackend.createWorkflowRun({
|
|
694
|
+
workflowName: randomUUID(),
|
|
695
|
+
version: null,
|
|
696
|
+
idempotencyKey: null,
|
|
697
|
+
input: null,
|
|
698
|
+
config: {},
|
|
699
|
+
context: null,
|
|
700
|
+
availableAt: null,
|
|
701
|
+
deadlineAt: pastDeadline,
|
|
702
|
+
});
|
|
703
|
+
// attempt to claim triggers deadline check
|
|
704
|
+
const claimed = await isolatedBackend.claimWorkflowRun({
|
|
705
|
+
workerId: randomUUID(),
|
|
706
|
+
leaseDurationMs: 1000,
|
|
707
|
+
});
|
|
708
|
+
expect(claimed).toBeNull();
|
|
709
|
+
// verify it was marked as failed
|
|
710
|
+
const failed = await isolatedBackend.getWorkflowRun({
|
|
711
|
+
workflowRunId: created.id,
|
|
712
|
+
});
|
|
713
|
+
expect(failed?.status).toBe("failed");
|
|
714
|
+
expect(failed?.error).toEqual({
|
|
715
|
+
message: "Workflow run deadline exceeded",
|
|
716
|
+
});
|
|
717
|
+
expect(failed?.finishedAt).not.toBeNull();
|
|
718
|
+
expect(failed?.availableAt).toBeNull();
|
|
719
|
+
await teardown(isolatedBackend);
|
|
720
|
+
});
|
|
721
|
+
test("does not reschedule failed workflow runs if next retry would exceed deadline", async () => {
|
|
722
|
+
const isolatedBackend = await factory();
|
|
723
|
+
const deadline = new Date(Date.now() + 500); // 500ms from now
|
|
724
|
+
const created = await isolatedBackend.createWorkflowRun({
|
|
725
|
+
workflowName: randomUUID(),
|
|
726
|
+
version: null,
|
|
727
|
+
idempotencyKey: null,
|
|
728
|
+
input: null,
|
|
729
|
+
config: {},
|
|
730
|
+
context: null,
|
|
731
|
+
availableAt: null,
|
|
732
|
+
deadlineAt: deadline,
|
|
733
|
+
});
|
|
734
|
+
const workerId = randomUUID();
|
|
735
|
+
const claimed = await isolatedBackend.claimWorkflowRun({
|
|
736
|
+
workerId,
|
|
737
|
+
leaseDurationMs: 100,
|
|
738
|
+
});
|
|
739
|
+
expect(claimed).not.toBeNull();
|
|
740
|
+
// should mark as permanently failed since retry backoff (1s) would exceed deadline (500ms)
|
|
741
|
+
const failed = await isolatedBackend.failWorkflowRun({
|
|
742
|
+
workflowRunId: created.id,
|
|
743
|
+
workerId,
|
|
744
|
+
error: { message: "test error" },
|
|
745
|
+
});
|
|
746
|
+
expect(failed.status).toBe("failed");
|
|
747
|
+
expect(failed.availableAt).toBeNull();
|
|
748
|
+
expect(failed.finishedAt).not.toBeNull();
|
|
749
|
+
await teardown(isolatedBackend);
|
|
750
|
+
});
|
|
751
|
+
test("reschedules failed workflow runs if retry would complete before deadline", async () => {
|
|
752
|
+
const isolatedBackend = await factory();
|
|
753
|
+
const deadline = new Date(Date.now() + 5000); // in 5 seconds
|
|
754
|
+
const created = await isolatedBackend.createWorkflowRun({
|
|
755
|
+
workflowName: randomUUID(),
|
|
756
|
+
version: null,
|
|
757
|
+
idempotencyKey: null,
|
|
758
|
+
input: null,
|
|
759
|
+
config: {},
|
|
760
|
+
context: null,
|
|
761
|
+
availableAt: null,
|
|
762
|
+
deadlineAt: deadline,
|
|
763
|
+
});
|
|
764
|
+
const workerId = randomUUID();
|
|
765
|
+
const claimed = await isolatedBackend.claimWorkflowRun({
|
|
766
|
+
workerId,
|
|
767
|
+
leaseDurationMs: 100,
|
|
768
|
+
});
|
|
769
|
+
expect(claimed).not.toBeNull();
|
|
770
|
+
// should reschedule since retry backoff (1s) is before deadline (5s
|
|
771
|
+
const failed = await isolatedBackend.failWorkflowRun({
|
|
772
|
+
workflowRunId: created.id,
|
|
773
|
+
workerId,
|
|
774
|
+
error: { message: "test error" },
|
|
775
|
+
});
|
|
776
|
+
expect(failed.status).toBe("pending");
|
|
777
|
+
expect(failed.availableAt).not.toBeNull();
|
|
778
|
+
expect(failed.finishedAt).toBeNull();
|
|
779
|
+
await teardown(isolatedBackend);
|
|
780
|
+
});
|
|
781
|
+
});
|
|
782
|
+
describe("cancelWorkflowRun()", () => {
|
|
783
|
+
test("cancels a pending workflow run", async () => {
|
|
784
|
+
const isolatedBackend = await factory();
|
|
785
|
+
const created = await createPendingWorkflowRun(isolatedBackend);
|
|
786
|
+
expect(created.status).toBe("pending");
|
|
787
|
+
const canceled = await isolatedBackend.cancelWorkflowRun({
|
|
788
|
+
workflowRunId: created.id,
|
|
789
|
+
});
|
|
790
|
+
expect(canceled.status).toBe("canceled");
|
|
791
|
+
expect(canceled.workerId).toBeNull();
|
|
792
|
+
expect(canceled.availableAt).toBeNull();
|
|
793
|
+
expect(canceled.finishedAt).not.toBeNull();
|
|
794
|
+
expect(deltaSeconds(canceled.finishedAt)).toBeLessThan(1);
|
|
795
|
+
await teardown(isolatedBackend);
|
|
796
|
+
});
|
|
797
|
+
test("cancels a running workflow run", async () => {
|
|
798
|
+
const isolatedBackend = await factory();
|
|
799
|
+
const created = await createClaimedWorkflowRun(isolatedBackend);
|
|
800
|
+
expect(created.status).toBe("running");
|
|
801
|
+
expect(created.workerId).not.toBeNull();
|
|
802
|
+
const canceled = await isolatedBackend.cancelWorkflowRun({
|
|
803
|
+
workflowRunId: created.id,
|
|
804
|
+
});
|
|
805
|
+
expect(canceled.status).toBe("canceled");
|
|
806
|
+
expect(canceled.workerId).toBeNull();
|
|
807
|
+
expect(canceled.availableAt).toBeNull();
|
|
808
|
+
expect(canceled.finishedAt).not.toBeNull();
|
|
809
|
+
await teardown(isolatedBackend);
|
|
810
|
+
});
|
|
811
|
+
test("cancels a sleeping workflow run", async () => {
|
|
812
|
+
const isolatedBackend = await factory();
|
|
813
|
+
const claimed = await createClaimedWorkflowRun(isolatedBackend);
|
|
814
|
+
// put workflow to sleep
|
|
815
|
+
const sleepUntil = new Date(Date.now() + 60_000); // 1 minute from now
|
|
816
|
+
const sleeping = await isolatedBackend.sleepWorkflowRun({
|
|
817
|
+
workflowRunId: claimed.id,
|
|
818
|
+
workerId: claimed.workerId ?? "",
|
|
819
|
+
availableAt: sleepUntil,
|
|
820
|
+
});
|
|
821
|
+
expect(sleeping.status).toBe("sleeping");
|
|
822
|
+
const canceled = await isolatedBackend.cancelWorkflowRun({
|
|
823
|
+
workflowRunId: sleeping.id,
|
|
824
|
+
});
|
|
825
|
+
expect(canceled.status).toBe("canceled");
|
|
826
|
+
expect(canceled.workerId).toBeNull();
|
|
827
|
+
expect(canceled.availableAt).toBeNull();
|
|
828
|
+
expect(canceled.finishedAt).not.toBeNull();
|
|
829
|
+
await teardown(isolatedBackend);
|
|
830
|
+
});
|
|
831
|
+
test("throws error when canceling a completed workflow run", async () => {
|
|
832
|
+
const isolatedBackend = await factory();
|
|
833
|
+
const claimed = await createClaimedWorkflowRun(isolatedBackend);
|
|
834
|
+
// mark as completed
|
|
835
|
+
await isolatedBackend.completeWorkflowRun({
|
|
836
|
+
workflowRunId: claimed.id,
|
|
837
|
+
workerId: claimed.workerId ?? "",
|
|
838
|
+
output: { result: "success" },
|
|
839
|
+
});
|
|
840
|
+
await expect(isolatedBackend.cancelWorkflowRun({
|
|
841
|
+
workflowRunId: claimed.id,
|
|
842
|
+
})).rejects.toThrow(/Cannot cancel workflow run .* with status completed/);
|
|
843
|
+
await teardown(isolatedBackend);
|
|
844
|
+
});
|
|
845
|
+
test("throws error when canceling a failed workflow run", async () => {
|
|
846
|
+
const isolatedBackend = await factory();
|
|
847
|
+
// create with deadline that's already passed to make it fail
|
|
848
|
+
const workflowWithDeadline = await isolatedBackend.createWorkflowRun({
|
|
849
|
+
workflowName: randomUUID(),
|
|
850
|
+
version: null,
|
|
851
|
+
idempotencyKey: null,
|
|
852
|
+
input: null,
|
|
853
|
+
config: {},
|
|
854
|
+
context: null,
|
|
855
|
+
availableAt: null,
|
|
856
|
+
deadlineAt: new Date(Date.now() - 1000), // deadline in the past
|
|
857
|
+
});
|
|
858
|
+
// try to claim it, which should mark it as failed due to deadline
|
|
859
|
+
const claimed = await isolatedBackend.claimWorkflowRun({
|
|
860
|
+
workerId: randomUUID(),
|
|
861
|
+
leaseDurationMs: 100,
|
|
862
|
+
});
|
|
863
|
+
// if claim succeeds, manually fail it
|
|
864
|
+
if (claimed?.workerId) {
|
|
865
|
+
await isolatedBackend.failWorkflowRun({
|
|
866
|
+
workflowRunId: claimed.id,
|
|
867
|
+
workerId: claimed.workerId,
|
|
868
|
+
error: { message: "test error" },
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
// get a workflow that's definitely failed
|
|
872
|
+
const failedRun = await isolatedBackend.getWorkflowRun({
|
|
873
|
+
workflowRunId: workflowWithDeadline.id,
|
|
874
|
+
});
|
|
875
|
+
if (failedRun?.status === "failed") {
|
|
876
|
+
await expect(isolatedBackend.cancelWorkflowRun({
|
|
877
|
+
workflowRunId: failedRun.id,
|
|
878
|
+
})).rejects.toThrow(/Cannot cancel workflow run .* with status failed/);
|
|
879
|
+
}
|
|
880
|
+
await teardown(isolatedBackend);
|
|
881
|
+
});
|
|
882
|
+
test("is idempotent when canceling an already canceled workflow run", async () => {
|
|
883
|
+
const isolatedBackend = await factory();
|
|
884
|
+
const created = await createPendingWorkflowRun(isolatedBackend);
|
|
885
|
+
const firstCancel = await isolatedBackend.cancelWorkflowRun({
|
|
886
|
+
workflowRunId: created.id,
|
|
887
|
+
});
|
|
888
|
+
expect(firstCancel.status).toBe("canceled");
|
|
889
|
+
const secondCancel = await isolatedBackend.cancelWorkflowRun({
|
|
890
|
+
workflowRunId: created.id,
|
|
891
|
+
});
|
|
892
|
+
expect(secondCancel.status).toBe("canceled");
|
|
893
|
+
expect(secondCancel.id).toBe(firstCancel.id);
|
|
894
|
+
await teardown(isolatedBackend);
|
|
895
|
+
});
|
|
896
|
+
test("throws error when canceling a non-existent workflow run", async () => {
|
|
897
|
+
const isolatedBackend = await factory();
|
|
898
|
+
const nonExistentId = randomUUID();
|
|
899
|
+
await expect(isolatedBackend.cancelWorkflowRun({
|
|
900
|
+
workflowRunId: nonExistentId,
|
|
901
|
+
})).rejects.toThrow(`Workflow run ${nonExistentId} does not exist`);
|
|
902
|
+
await teardown(isolatedBackend);
|
|
903
|
+
});
|
|
904
|
+
test("canceled workflow is not claimed by workers", async () => {
|
|
905
|
+
const isolatedBackend = await factory();
|
|
906
|
+
const created = await createPendingWorkflowRun(isolatedBackend);
|
|
907
|
+
// cancel the workflow
|
|
908
|
+
await isolatedBackend.cancelWorkflowRun({
|
|
909
|
+
workflowRunId: created.id,
|
|
910
|
+
});
|
|
911
|
+
// try to claim work
|
|
912
|
+
const claimed = await isolatedBackend.claimWorkflowRun({
|
|
913
|
+
workerId: randomUUID(),
|
|
914
|
+
leaseDurationMs: 100,
|
|
915
|
+
});
|
|
916
|
+
// should not claim the canceled workflow
|
|
917
|
+
expect(claimed).toBeNull();
|
|
918
|
+
await teardown(isolatedBackend);
|
|
919
|
+
});
|
|
920
|
+
});
|
|
921
|
+
// Helper function for creating workflow runs that uses the shared backend
|
|
922
|
+
async function createPendingWorkflowRun(b) {
|
|
923
|
+
return await b.createWorkflowRun({
|
|
924
|
+
workflowName: randomUUID(),
|
|
925
|
+
version: null,
|
|
926
|
+
idempotencyKey: null,
|
|
927
|
+
input: null,
|
|
928
|
+
config: {},
|
|
929
|
+
context: null,
|
|
930
|
+
availableAt: null,
|
|
931
|
+
deadlineAt: null,
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
async function createClaimedWorkflowRun(b) {
|
|
935
|
+
await createPendingWorkflowRun(b);
|
|
936
|
+
const claimed = await b.claimWorkflowRun({
|
|
937
|
+
workerId: randomUUID(),
|
|
938
|
+
leaseDurationMs: 100,
|
|
939
|
+
});
|
|
940
|
+
if (!claimed)
|
|
941
|
+
throw new Error("Failed to claim workflow run");
|
|
942
|
+
return claimed;
|
|
943
|
+
}
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
// Helper functions
|
|
947
|
+
function deltaSeconds(date) {
|
|
948
|
+
if (!date)
|
|
949
|
+
return Infinity;
|
|
950
|
+
return Math.abs((Date.now() - date.getTime()) / 1000);
|
|
951
|
+
}
|
|
952
|
+
function newDateInOneYear() {
|
|
953
|
+
const d = new Date();
|
|
954
|
+
d.setFullYear(d.getFullYear() + 1);
|
|
955
|
+
return d;
|
|
956
|
+
}
|
|
957
|
+
function sleep(ms) {
|
|
958
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
959
|
+
}
|
|
960
|
+
//# sourceMappingURL=backend-test-suite.js.map
|