@convex-dev/workpool 0.2.0-beta.0 → 0.2.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 +7 -16
- package/dist/commonjs/client/index.d.ts +3 -3
- package/dist/commonjs/client/index.d.ts.map +1 -1
- package/dist/commonjs/client/index.js +10 -5
- package/dist/commonjs/client/index.js.map +1 -1
- package/dist/commonjs/component/complete.d.ts +89 -0
- package/dist/commonjs/component/complete.d.ts.map +1 -0
- package/dist/commonjs/component/complete.js +80 -0
- package/dist/commonjs/component/complete.js.map +1 -0
- package/dist/commonjs/component/kick.d.ts +1 -2
- package/dist/commonjs/component/kick.d.ts.map +1 -1
- package/dist/commonjs/component/kick.js +7 -5
- package/dist/commonjs/component/kick.js.map +1 -1
- package/dist/commonjs/component/lib.d.ts +3 -3
- package/dist/commonjs/component/lib.d.ts.map +1 -1
- package/dist/commonjs/component/lib.js +43 -20
- package/dist/commonjs/component/lib.js.map +1 -1
- package/dist/commonjs/component/logging.d.ts.map +1 -1
- package/dist/commonjs/component/logging.js +1 -2
- package/dist/commonjs/component/logging.js.map +1 -1
- package/dist/commonjs/component/loop.d.ts +1 -14
- package/dist/commonjs/component/loop.d.ts.map +1 -1
- package/dist/commonjs/component/loop.js +215 -178
- package/dist/commonjs/component/loop.js.map +1 -1
- package/dist/commonjs/component/recovery.d.ts +16 -0
- package/dist/commonjs/component/recovery.d.ts.map +1 -1
- package/dist/commonjs/component/recovery.js +64 -44
- package/dist/commonjs/component/recovery.js.map +1 -1
- package/dist/commonjs/component/schema.d.ts +6 -2
- package/dist/commonjs/component/schema.d.ts.map +1 -1
- package/dist/commonjs/component/schema.js +5 -3
- package/dist/commonjs/component/schema.js.map +1 -1
- package/dist/commonjs/component/shared.d.ts +20 -11
- package/dist/commonjs/component/shared.d.ts.map +1 -1
- package/dist/commonjs/component/shared.js +18 -5
- package/dist/commonjs/component/shared.js.map +1 -1
- package/dist/commonjs/component/stats.d.ts +21 -13
- package/dist/commonjs/component/stats.d.ts.map +1 -1
- package/dist/commonjs/component/stats.js +32 -22
- package/dist/commonjs/component/stats.js.map +1 -1
- package/dist/commonjs/component/worker.d.ts +2 -12
- package/dist/commonjs/component/worker.d.ts.map +1 -1
- package/dist/commonjs/component/worker.js +23 -36
- package/dist/commonjs/component/worker.js.map +1 -1
- package/dist/esm/client/index.d.ts +3 -3
- package/dist/esm/client/index.d.ts.map +1 -1
- package/dist/esm/client/index.js +10 -5
- package/dist/esm/client/index.js.map +1 -1
- package/dist/esm/component/complete.d.ts +89 -0
- package/dist/esm/component/complete.d.ts.map +1 -0
- package/dist/esm/component/complete.js +80 -0
- package/dist/esm/component/complete.js.map +1 -0
- package/dist/esm/component/kick.d.ts +1 -2
- package/dist/esm/component/kick.d.ts.map +1 -1
- package/dist/esm/component/kick.js +7 -5
- package/dist/esm/component/kick.js.map +1 -1
- package/dist/esm/component/lib.d.ts +3 -3
- package/dist/esm/component/lib.d.ts.map +1 -1
- package/dist/esm/component/lib.js +43 -20
- package/dist/esm/component/lib.js.map +1 -1
- package/dist/esm/component/logging.d.ts.map +1 -1
- package/dist/esm/component/logging.js +1 -2
- package/dist/esm/component/logging.js.map +1 -1
- package/dist/esm/component/loop.d.ts +1 -14
- package/dist/esm/component/loop.d.ts.map +1 -1
- package/dist/esm/component/loop.js +215 -178
- package/dist/esm/component/loop.js.map +1 -1
- package/dist/esm/component/recovery.d.ts +16 -0
- package/dist/esm/component/recovery.d.ts.map +1 -1
- package/dist/esm/component/recovery.js +64 -44
- package/dist/esm/component/recovery.js.map +1 -1
- package/dist/esm/component/schema.d.ts +6 -2
- package/dist/esm/component/schema.d.ts.map +1 -1
- package/dist/esm/component/schema.js +5 -3
- package/dist/esm/component/schema.js.map +1 -1
- package/dist/esm/component/shared.d.ts +20 -11
- package/dist/esm/component/shared.d.ts.map +1 -1
- package/dist/esm/component/shared.js +18 -5
- package/dist/esm/component/shared.js.map +1 -1
- package/dist/esm/component/stats.d.ts +21 -13
- package/dist/esm/component/stats.d.ts.map +1 -1
- package/dist/esm/component/stats.js +32 -22
- package/dist/esm/component/stats.js.map +1 -1
- package/dist/esm/component/worker.d.ts +2 -12
- package/dist/esm/component/worker.d.ts.map +1 -1
- package/dist/esm/component/worker.js +23 -36
- package/dist/esm/component/worker.js.map +1 -1
- package/package.json +7 -6
- package/src/client/index.ts +18 -8
- package/src/component/README.md +15 -15
- package/src/component/_generated/api.d.ts +7 -2
- package/src/component/complete.test.ts +508 -0
- package/src/component/complete.ts +98 -0
- package/src/component/kick.test.ts +13 -13
- package/src/component/kick.ts +13 -8
- package/src/component/lib.test.ts +262 -17
- package/src/component/lib.ts +55 -24
- package/src/component/logging.ts +1 -2
- package/src/component/loop.test.ts +1158 -0
- package/src/component/loop.ts +289 -221
- package/src/component/recovery.test.ts +541 -0
- package/src/component/recovery.ts +80 -63
- package/src/component/schema.ts +6 -4
- package/src/component/shared.ts +21 -6
- package/src/component/stats.ts +48 -25
- package/src/component/worker.ts +25 -38
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
import { convexTest } from "convex-test";
|
|
2
|
+
import {
|
|
3
|
+
describe,
|
|
4
|
+
expect,
|
|
5
|
+
it,
|
|
6
|
+
beforeEach,
|
|
7
|
+
afterEach,
|
|
8
|
+
vi,
|
|
9
|
+
assert,
|
|
10
|
+
} from "vitest";
|
|
11
|
+
import schema from "./schema";
|
|
12
|
+
import { internal } from "./_generated/api";
|
|
13
|
+
import { Id, Doc } from "./_generated/dataModel";
|
|
14
|
+
import { MutationCtx } from "./_generated/server";
|
|
15
|
+
import { WithoutSystemFields } from "convex/server";
|
|
16
|
+
import { recover as recoverMutation } from "./recovery";
|
|
17
|
+
|
|
18
|
+
// We call it directly so we can mock the system.get. A hack for now.
|
|
19
|
+
// But this avoids the warning about calling directly.
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
21
|
+
const recover = (recoverMutation as any)._handler as typeof recoverMutation;
|
|
22
|
+
|
|
23
|
+
const modules = import.meta.glob("./**/*.ts");
|
|
24
|
+
|
|
25
|
+
describe("recovery", () => {
|
|
26
|
+
async function setupTest() {
|
|
27
|
+
const t = convexTest(schema, modules);
|
|
28
|
+
return t;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let t: Awaited<ReturnType<typeof setupTest>>;
|
|
32
|
+
|
|
33
|
+
// Helper function to create a work item
|
|
34
|
+
async function makeDummyWork(
|
|
35
|
+
ctx: MutationCtx,
|
|
36
|
+
overrides: Partial<WithoutSystemFields<Doc<"work">>> = {}
|
|
37
|
+
) {
|
|
38
|
+
return ctx.db.insert("work", {
|
|
39
|
+
fnType: "action",
|
|
40
|
+
fnHandle: "test_handle",
|
|
41
|
+
fnName: "test_handle",
|
|
42
|
+
fnArgs: {},
|
|
43
|
+
attempts: 0,
|
|
44
|
+
...overrides,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Helper function to create a scheduled function
|
|
49
|
+
async function makeDummyScheduledFunction(
|
|
50
|
+
ctx: MutationCtx,
|
|
51
|
+
workId: Id<"work">
|
|
52
|
+
) {
|
|
53
|
+
return ctx.scheduler.runAfter(0, internal.worker.runActionWrapper, {
|
|
54
|
+
workId,
|
|
55
|
+
fnHandle: "test_handle",
|
|
56
|
+
fnArgs: {},
|
|
57
|
+
logLevel: "WARN",
|
|
58
|
+
attempt: 0,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
beforeEach(async () => {
|
|
63
|
+
vi.useFakeTimers();
|
|
64
|
+
t = await setupTest();
|
|
65
|
+
|
|
66
|
+
// Set up globals for logging
|
|
67
|
+
await t.run(async (ctx) => {
|
|
68
|
+
await ctx.db.insert("globals", {
|
|
69
|
+
maxParallelism: 10,
|
|
70
|
+
logLevel: "WARN",
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
vi.useRealTimers();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("recover", () => {
|
|
80
|
+
it("should skip jobs that already have a pendingCompletion", async () => {
|
|
81
|
+
// Create work and scheduled function
|
|
82
|
+
|
|
83
|
+
const [workId, scheduledId] = await t.run(async (ctx) => {
|
|
84
|
+
const workId = await makeDummyWork(ctx);
|
|
85
|
+
const scheduledId = await makeDummyScheduledFunction(ctx, workId);
|
|
86
|
+
|
|
87
|
+
// Create a pendingCompletion for this work
|
|
88
|
+
await ctx.db.insert("pendingCompletion", {
|
|
89
|
+
segment: BigInt(1),
|
|
90
|
+
workId,
|
|
91
|
+
runResult: { kind: "failed", error: "test error" },
|
|
92
|
+
retry: true,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return [workId, scheduledId];
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Run recovery
|
|
99
|
+
await t.mutation(internal.recovery.recover, {
|
|
100
|
+
jobs: [
|
|
101
|
+
{
|
|
102
|
+
scheduledId,
|
|
103
|
+
workId,
|
|
104
|
+
attempt: 0,
|
|
105
|
+
started: Date.now(),
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Verify no additional pendingCompletion was created
|
|
111
|
+
await t.run(async (ctx) => {
|
|
112
|
+
const pendingCompletions = await ctx.db
|
|
113
|
+
.query("pendingCompletion")
|
|
114
|
+
.withIndex("workId", (q) => q.eq("workId", workId))
|
|
115
|
+
.collect();
|
|
116
|
+
expect(pendingCompletions).toHaveLength(1);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("should skip jobs where work is not found", async () => {
|
|
121
|
+
// Create a non-existent work ID and a valid scheduled function ID
|
|
122
|
+
const [workId, scheduledId] = await t.run(async (ctx) => {
|
|
123
|
+
// Create a temporary work ID that we'll delete
|
|
124
|
+
const workId = await makeDummyWork(ctx);
|
|
125
|
+
const scheduledId = await makeDummyScheduledFunction(ctx, workId);
|
|
126
|
+
|
|
127
|
+
// Delete the work to simulate it not being found
|
|
128
|
+
await ctx.db.delete(workId);
|
|
129
|
+
|
|
130
|
+
return [workId, scheduledId];
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Run recovery
|
|
134
|
+
await t.mutation(internal.recovery.recover, {
|
|
135
|
+
jobs: [
|
|
136
|
+
{
|
|
137
|
+
scheduledId,
|
|
138
|
+
workId,
|
|
139
|
+
attempt: 0,
|
|
140
|
+
started: Date.now(),
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Verify no pendingCompletion was created
|
|
146
|
+
await t.run(async (ctx) => {
|
|
147
|
+
const pendingCompletions = await ctx.db
|
|
148
|
+
.query("pendingCompletion")
|
|
149
|
+
.withIndex("workId", (q) => q.eq("workId", workId))
|
|
150
|
+
.collect();
|
|
151
|
+
expect(pendingCompletions).toHaveLength(0);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("should skip jobs where work attempts mismatch", async () => {
|
|
156
|
+
// Create work and scheduled function
|
|
157
|
+
const [workId, scheduledId] = await t.run(async (ctx) => {
|
|
158
|
+
const workId = await makeDummyWork(ctx);
|
|
159
|
+
const scheduledId = await makeDummyScheduledFunction(ctx, workId);
|
|
160
|
+
|
|
161
|
+
// Update the work to have a different attempt number
|
|
162
|
+
const work = await ctx.db.get(workId);
|
|
163
|
+
if (work) {
|
|
164
|
+
await ctx.db.patch(work._id, { attempts: 5 });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return [workId, scheduledId];
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Run recovery
|
|
171
|
+
await t.mutation(internal.recovery.recover, {
|
|
172
|
+
jobs: [
|
|
173
|
+
{
|
|
174
|
+
scheduledId,
|
|
175
|
+
workId,
|
|
176
|
+
attempt: 0, // Mismatched with the work's attempt number (5)
|
|
177
|
+
started: Date.now(),
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Verify no pendingCompletion was created
|
|
183
|
+
await t.run(async (ctx) => {
|
|
184
|
+
const pendingCompletions = await ctx.db
|
|
185
|
+
.query("pendingCompletion")
|
|
186
|
+
.withIndex("workId", (q) => q.eq("workId", workId))
|
|
187
|
+
.collect();
|
|
188
|
+
expect(pendingCompletions).toHaveLength(0);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("should handle scheduled job not found", async () => {
|
|
193
|
+
// Create work but use a non-existent scheduled ID
|
|
194
|
+
const [workId, scheduledId] = await t.run(async (ctx) => {
|
|
195
|
+
const workId = await makeDummyWork(ctx);
|
|
196
|
+
const scheduledId = await makeDummyScheduledFunction(ctx, workId);
|
|
197
|
+
|
|
198
|
+
return [workId, scheduledId];
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// Run recovery with mocked system.get
|
|
202
|
+
await t.run(async (ctx) => {
|
|
203
|
+
// Mock the system.get to return null for our scheduledId
|
|
204
|
+
const originalGet = ctx.db.system.get;
|
|
205
|
+
ctx.db.system.get = async (id) => {
|
|
206
|
+
if (id === scheduledId) {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
return await originalGet(id);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
await recover(ctx, {
|
|
213
|
+
jobs: [
|
|
214
|
+
{
|
|
215
|
+
scheduledId,
|
|
216
|
+
workId,
|
|
217
|
+
attempt: 0,
|
|
218
|
+
started: Date.now(),
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// Verify pendingCompletion was created with failure
|
|
225
|
+
await t.run(async (ctx) => {
|
|
226
|
+
const pendingCompletions = await ctx.db
|
|
227
|
+
.query("pendingCompletion")
|
|
228
|
+
.withIndex("workId", (q) => q.eq("workId", workId))
|
|
229
|
+
.collect();
|
|
230
|
+
expect(pendingCompletions).toHaveLength(1);
|
|
231
|
+
expect(pendingCompletions[0].runResult.kind).toBe("failed");
|
|
232
|
+
assert(pendingCompletions[0].runResult.kind === "failed");
|
|
233
|
+
expect(pendingCompletions[0].runResult.error).toContain(
|
|
234
|
+
"Scheduled job not found"
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("should handle failed scheduled jobs", async () => {
|
|
240
|
+
// Create work and scheduled function
|
|
241
|
+
|
|
242
|
+
const [workId, scheduledId] = await t.run(async (ctx) => {
|
|
243
|
+
const workId = await makeDummyWork(ctx);
|
|
244
|
+
const scheduledId = await makeDummyScheduledFunction(ctx, workId);
|
|
245
|
+
|
|
246
|
+
return [workId, scheduledId];
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Run recovery with mocked failed state
|
|
250
|
+
await t.run(async (ctx) => {
|
|
251
|
+
// Mock the system.get to return a failed state
|
|
252
|
+
const originalGet = ctx.db.system.get;
|
|
253
|
+
ctx.db.system.get = async (id) => {
|
|
254
|
+
if (id === scheduledId) {
|
|
255
|
+
return {
|
|
256
|
+
_id: scheduledId,
|
|
257
|
+
_creationTime: Date.now(),
|
|
258
|
+
name: "internal/worker.runActionWrapper",
|
|
259
|
+
args: [
|
|
260
|
+
{
|
|
261
|
+
workId,
|
|
262
|
+
fnHandle: "test_handle",
|
|
263
|
+
fnArgs: {},
|
|
264
|
+
logLevel: "WARN",
|
|
265
|
+
attempt: 0,
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
scheduledTime: Date.now(),
|
|
269
|
+
state: {
|
|
270
|
+
kind: "failed",
|
|
271
|
+
error: "Function execution failed",
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
return await originalGet(id);
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
await recover(ctx, {
|
|
279
|
+
jobs: [
|
|
280
|
+
{
|
|
281
|
+
scheduledId,
|
|
282
|
+
workId,
|
|
283
|
+
attempt: 0,
|
|
284
|
+
started: Date.now(),
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// Verify pendingCompletion was created with the same failure
|
|
291
|
+
await t.run(async (ctx) => {
|
|
292
|
+
const pendingCompletions = await ctx.db
|
|
293
|
+
.query("pendingCompletion")
|
|
294
|
+
.withIndex("workId", (q) => q.eq("workId", workId))
|
|
295
|
+
.collect();
|
|
296
|
+
expect(pendingCompletions).toHaveLength(1);
|
|
297
|
+
expect(pendingCompletions[0].runResult.kind).toBe("failed");
|
|
298
|
+
assert(pendingCompletions[0].runResult.kind === "failed");
|
|
299
|
+
expect(pendingCompletions[0].runResult.error).toBe(
|
|
300
|
+
"Function execution failed"
|
|
301
|
+
);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("should handle canceled scheduled jobs", async () => {
|
|
306
|
+
// Create work and scheduled function
|
|
307
|
+
let workId: Id<"work">;
|
|
308
|
+
let scheduledId: Id<"_scheduled_functions">;
|
|
309
|
+
|
|
310
|
+
await t.run(async (ctx) => {
|
|
311
|
+
workId = await makeDummyWork(ctx);
|
|
312
|
+
scheduledId = await makeDummyScheduledFunction(ctx, workId);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// Run recovery with mocked system.get
|
|
316
|
+
await t.run(async (ctx) => {
|
|
317
|
+
// Mock the system.get to return a canceled state
|
|
318
|
+
const originalGet = ctx.db.system.get;
|
|
319
|
+
ctx.db.system.get = async (id) => {
|
|
320
|
+
if (id === scheduledId) {
|
|
321
|
+
return {
|
|
322
|
+
_id: scheduledId,
|
|
323
|
+
_creationTime: Date.now(),
|
|
324
|
+
name: "internal/worker.runActionWrapper",
|
|
325
|
+
args: [
|
|
326
|
+
{
|
|
327
|
+
workId,
|
|
328
|
+
fnHandle: "test_handle",
|
|
329
|
+
fnArgs: {},
|
|
330
|
+
logLevel: "WARN",
|
|
331
|
+
attempt: 0,
|
|
332
|
+
},
|
|
333
|
+
],
|
|
334
|
+
scheduledTime: Date.now(),
|
|
335
|
+
state: {
|
|
336
|
+
kind: "canceled",
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
return await originalGet(id);
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
await recover(ctx, {
|
|
344
|
+
jobs: [
|
|
345
|
+
{
|
|
346
|
+
scheduledId,
|
|
347
|
+
workId,
|
|
348
|
+
attempt: 0,
|
|
349
|
+
started: Date.now(),
|
|
350
|
+
},
|
|
351
|
+
],
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// Verify pendingCompletion was created with failure due to cancelation
|
|
356
|
+
await t.run(async (ctx) => {
|
|
357
|
+
const pendingCompletions = await ctx.db
|
|
358
|
+
.query("pendingCompletion")
|
|
359
|
+
.withIndex("workId", (q) => q.eq("workId", workId))
|
|
360
|
+
.collect();
|
|
361
|
+
expect(pendingCompletions).toHaveLength(1);
|
|
362
|
+
expect(pendingCompletions[0].runResult.kind).toBe("failed");
|
|
363
|
+
assert(pendingCompletions[0].runResult.kind === "failed");
|
|
364
|
+
expect(pendingCompletions[0].runResult.error).toBe(
|
|
365
|
+
"Canceled via scheduler"
|
|
366
|
+
);
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("should handle multiple jobs in a single call", async () => {
|
|
371
|
+
// Create multiple work items and scheduled functions
|
|
372
|
+
let workId1: Id<"work">;
|
|
373
|
+
let workId2: Id<"work">;
|
|
374
|
+
let scheduledId1: Id<"_scheduled_functions">;
|
|
375
|
+
let scheduledId2: Id<"_scheduled_functions">;
|
|
376
|
+
|
|
377
|
+
await t.run(async (ctx) => {
|
|
378
|
+
workId1 = await makeDummyWork(ctx, { fnArgs: { test: 1 } });
|
|
379
|
+
workId2 = await makeDummyWork(ctx, { fnArgs: { test: 2 } });
|
|
380
|
+
scheduledId1 = await makeDummyScheduledFunction(ctx, workId1);
|
|
381
|
+
scheduledId2 = await makeDummyScheduledFunction(ctx, workId2);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// Run recovery with mocked system.get
|
|
385
|
+
await t.run(async (ctx) => {
|
|
386
|
+
// Mock the system.get to return different states for each scheduled function
|
|
387
|
+
const originalGet = ctx.db.system.get;
|
|
388
|
+
ctx.db.system.get = async (id) => {
|
|
389
|
+
if (id === scheduledId1) {
|
|
390
|
+
return {
|
|
391
|
+
_id: scheduledId1,
|
|
392
|
+
_creationTime: Date.now(),
|
|
393
|
+
name: "internal/worker.runActionWrapper",
|
|
394
|
+
args: [
|
|
395
|
+
{
|
|
396
|
+
workId: workId1,
|
|
397
|
+
fnHandle: "test_handle",
|
|
398
|
+
fnArgs: { test: 1 },
|
|
399
|
+
logLevel: "WARN",
|
|
400
|
+
attempt: 0,
|
|
401
|
+
},
|
|
402
|
+
],
|
|
403
|
+
scheduledTime: Date.now(),
|
|
404
|
+
state: {
|
|
405
|
+
kind: "failed",
|
|
406
|
+
error: "Function 1 failed",
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
} else if (id === scheduledId2) {
|
|
410
|
+
return {
|
|
411
|
+
_id: scheduledId2,
|
|
412
|
+
_creationTime: Date.now(),
|
|
413
|
+
name: "internal/worker.runActionWrapper",
|
|
414
|
+
args: [
|
|
415
|
+
{
|
|
416
|
+
workId: workId2,
|
|
417
|
+
fnHandle: "test_handle",
|
|
418
|
+
fnArgs: { test: 2 },
|
|
419
|
+
logLevel: "WARN",
|
|
420
|
+
attempt: 0,
|
|
421
|
+
},
|
|
422
|
+
],
|
|
423
|
+
scheduledTime: Date.now(),
|
|
424
|
+
state: {
|
|
425
|
+
kind: "canceled",
|
|
426
|
+
},
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
return await originalGet(id);
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
await recover(ctx, {
|
|
433
|
+
jobs: [
|
|
434
|
+
{
|
|
435
|
+
scheduledId: scheduledId1,
|
|
436
|
+
workId: workId1,
|
|
437
|
+
attempt: 0,
|
|
438
|
+
started: Date.now(),
|
|
439
|
+
},
|
|
440
|
+
{
|
|
441
|
+
scheduledId: scheduledId2,
|
|
442
|
+
workId: workId2,
|
|
443
|
+
attempt: 0,
|
|
444
|
+
started: Date.now(),
|
|
445
|
+
},
|
|
446
|
+
],
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
// Verify both jobs were processed correctly
|
|
451
|
+
await t.run(async (ctx) => {
|
|
452
|
+
const pendingCompletions = await ctx.db
|
|
453
|
+
.query("pendingCompletion")
|
|
454
|
+
.collect();
|
|
455
|
+
expect(pendingCompletions).toHaveLength(2);
|
|
456
|
+
|
|
457
|
+
// Find completions for each work ID
|
|
458
|
+
const completion1 = pendingCompletions.find(
|
|
459
|
+
(pc) => pc.workId === workId1
|
|
460
|
+
);
|
|
461
|
+
const completion2 = pendingCompletions.find(
|
|
462
|
+
(pc) => pc.workId === workId2
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
expect(completion1).toBeDefined();
|
|
466
|
+
expect(completion2).toBeDefined();
|
|
467
|
+
|
|
468
|
+
if (completion1) {
|
|
469
|
+
expect(completion1.runResult.kind).toBe("failed");
|
|
470
|
+
assert(completion1.runResult.kind === "failed");
|
|
471
|
+
expect(completion1.runResult.error).toBe("Function 1 failed");
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (completion2) {
|
|
475
|
+
expect(completion2.runResult.kind).toBe("failed");
|
|
476
|
+
assert(completion2.runResult.kind === "failed");
|
|
477
|
+
expect(completion2.runResult.error).toBe("Canceled via scheduler");
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("should not process jobs with other scheduled states", async () => {
|
|
483
|
+
// Create work and scheduled function
|
|
484
|
+
const [workId, scheduledId] = await t.run(async (ctx) => {
|
|
485
|
+
const workId = await makeDummyWork(ctx);
|
|
486
|
+
const scheduledId = await makeDummyScheduledFunction(ctx, workId);
|
|
487
|
+
|
|
488
|
+
return [workId, scheduledId];
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// Run recovery with mocked system.get
|
|
492
|
+
await t.run(async (ctx) => {
|
|
493
|
+
// Mock the system.get to return a pending state
|
|
494
|
+
const originalGet = ctx.db.system.get;
|
|
495
|
+
ctx.db.system.get = async (id) => {
|
|
496
|
+
if (id === scheduledId) {
|
|
497
|
+
return {
|
|
498
|
+
_id: scheduledId,
|
|
499
|
+
_creationTime: Date.now(),
|
|
500
|
+
name: "internal/worker.runActionWrapper",
|
|
501
|
+
args: [
|
|
502
|
+
{
|
|
503
|
+
workId,
|
|
504
|
+
fnHandle: "test_handle",
|
|
505
|
+
fnArgs: {},
|
|
506
|
+
logLevel: "WARN",
|
|
507
|
+
attempt: 0,
|
|
508
|
+
},
|
|
509
|
+
],
|
|
510
|
+
scheduledTime: Date.now(),
|
|
511
|
+
state: {
|
|
512
|
+
kind: "pending",
|
|
513
|
+
},
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
return await originalGet(id);
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
await recover(ctx, {
|
|
520
|
+
jobs: [
|
|
521
|
+
{
|
|
522
|
+
scheduledId,
|
|
523
|
+
workId,
|
|
524
|
+
attempt: 0,
|
|
525
|
+
started: Date.now(),
|
|
526
|
+
},
|
|
527
|
+
],
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// Verify no pendingCompletion was created
|
|
532
|
+
await t.run(async (ctx) => {
|
|
533
|
+
const pendingCompletions = await ctx.db
|
|
534
|
+
.query("pendingCompletion")
|
|
535
|
+
.withIndex("workId", (q) => q.eq("workId", workId))
|
|
536
|
+
.collect();
|
|
537
|
+
expect(pendingCompletions).toHaveLength(0);
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
});
|
|
@@ -1,79 +1,96 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Infer, v } from "convex/values";
|
|
2
2
|
import { internalMutation } from "./_generated/server.js";
|
|
3
|
-
import {
|
|
3
|
+
import { completeArgs, completeHandler } from "./complete.js";
|
|
4
4
|
import { createLogger } from "./logging.js";
|
|
5
|
-
import schema from "./schema.js";
|
|
6
|
-
import { RunResult, nextSegment } from "./shared.js";
|
|
7
5
|
|
|
6
|
+
/**
|
|
7
|
+
* This can run when things fail because of server failures / restarts, or when
|
|
8
|
+
* the user cancels scheduled jobs (from the dashboard).
|
|
9
|
+
* Possible states it could be in at the moment this executes:
|
|
10
|
+
* - in internalState.running and complete was never called
|
|
11
|
+
* -> we should call completeHandler with failure.
|
|
12
|
+
* - complete already called, no action needed (only possible for actions):
|
|
13
|
+
* - In pendingCompletion still and internalState.running.
|
|
14
|
+
* -> check for pendingCompletion.
|
|
15
|
+
* - pendingCompletion already processed.
|
|
16
|
+
* - No retry: work was deleted, not in internalState.running.
|
|
17
|
+
* -> check for work.
|
|
18
|
+
* - Retry: attempts will mismatch
|
|
19
|
+
* -> check work.attempts
|
|
20
|
+
*/
|
|
8
21
|
export const recover = internalMutation({
|
|
9
22
|
args: {
|
|
10
|
-
jobs:
|
|
23
|
+
jobs: v.array(
|
|
24
|
+
v.object({
|
|
25
|
+
scheduledId: v.id("_scheduled_functions"),
|
|
26
|
+
workId: v.id("work"),
|
|
27
|
+
attempt: v.number(),
|
|
28
|
+
started: v.number(),
|
|
29
|
+
})
|
|
30
|
+
),
|
|
11
31
|
},
|
|
12
32
|
handler: async (ctx, { jobs }) => {
|
|
13
33
|
const globals = await ctx.db.query("globals").unique();
|
|
14
34
|
const console = createLogger(globals?.logLevel);
|
|
15
|
-
const
|
|
16
|
-
let
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
35
|
+
const toComplete: Infer<typeof completeArgs.fields.jobs> = [];
|
|
36
|
+
for (let i = 0; i < jobs.length; i++) {
|
|
37
|
+
const job = jobs[i];
|
|
38
|
+
const preamble = `[recovery] Scheduled job ${job.scheduledId} for work ${job.workId}`;
|
|
39
|
+
const pendingCompletion = await ctx.db
|
|
40
|
+
.query("pendingCompletion")
|
|
41
|
+
.withIndex("workId", (q) => q.eq("workId", job.workId))
|
|
42
|
+
.first();
|
|
43
|
+
if (pendingCompletion) {
|
|
44
|
+
// Completion already pending, no need to do anything.
|
|
45
|
+
console.debug(`${preamble} already in pendingCompletion, skipping`);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const work = await ctx.db.get(job.workId);
|
|
49
|
+
if (work === null) {
|
|
50
|
+
// Completion already executed w/o retries, no need to do anything.
|
|
51
|
+
console.warn(`${preamble} work not found, skipping`);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (work.attempts !== job.attempt) {
|
|
55
|
+
// Retry already started, no need to do anything.
|
|
56
|
+
console.warn(`${preamble} attempts mismatch, skipping`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const scheduled = await ctx.db.system.get(job.scheduledId);
|
|
60
|
+
if (scheduled === null) {
|
|
61
|
+
console.warn(`${preamble} not found in _scheduled_functions`);
|
|
62
|
+
toComplete.push({
|
|
63
|
+
workId: job.workId,
|
|
64
|
+
runResult: { kind: "failed", error: `Scheduled job not found` },
|
|
65
|
+
attempt: job.attempt,
|
|
66
|
+
});
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
// This will find everything that timed out, failed ungracefully, was
|
|
70
|
+
// canceled, or succeeded without a return value.
|
|
71
|
+
switch (scheduled.state.kind) {
|
|
72
|
+
case "failed": {
|
|
73
|
+
console.debug(`${preamble} failed and detected in recovery`);
|
|
74
|
+
toComplete.push({
|
|
25
75
|
workId: job.workId,
|
|
26
|
-
runResult:
|
|
76
|
+
runResult: scheduled.state,
|
|
77
|
+
attempt: job.attempt,
|
|
27
78
|
});
|
|
28
|
-
|
|
79
|
+
break;
|
|
29
80
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
.first();
|
|
39
|
-
if (pendingCompletion) {
|
|
40
|
-
console.debug(
|
|
41
|
-
`${preamble} already in pendingCompletion, not reporting`
|
|
42
|
-
);
|
|
43
|
-
} else {
|
|
44
|
-
await ctx.db.insert("pendingCompletion", {
|
|
45
|
-
runResult: scheduled.state,
|
|
46
|
-
workId: job.workId,
|
|
47
|
-
segment,
|
|
48
|
-
});
|
|
49
|
-
didAnything = true;
|
|
50
|
-
}
|
|
51
|
-
break;
|
|
52
|
-
}
|
|
53
|
-
case "canceled": {
|
|
54
|
-
console.debug(`${preamble} was canceled and detected in recovery`);
|
|
55
|
-
const pendingCancelation = await ctx.db
|
|
56
|
-
.query("pendingCancelation")
|
|
57
|
-
.withIndex("workId", (q) => q.eq("workId", job.workId))
|
|
58
|
-
.first();
|
|
59
|
-
if (pendingCancelation) {
|
|
60
|
-
console.debug(
|
|
61
|
-
`${preamble} already in pendingCancelation, not reporting`
|
|
62
|
-
);
|
|
63
|
-
} else {
|
|
64
|
-
await ctx.db.insert("pendingCancelation", {
|
|
65
|
-
workId: job.workId,
|
|
66
|
-
segment,
|
|
67
|
-
});
|
|
68
|
-
didAnything = true;
|
|
69
|
-
}
|
|
70
|
-
break;
|
|
71
|
-
}
|
|
81
|
+
case "canceled": {
|
|
82
|
+
console.debug(`${preamble} was canceled and detected in recovery`);
|
|
83
|
+
toComplete.push({
|
|
84
|
+
workId: job.workId,
|
|
85
|
+
runResult: { kind: "failed", error: "Canceled via scheduler" },
|
|
86
|
+
attempt: job.attempt,
|
|
87
|
+
});
|
|
88
|
+
break;
|
|
72
89
|
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
if (
|
|
76
|
-
await
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (toComplete.length > 0) {
|
|
93
|
+
await completeHandler(ctx, { jobs: toComplete });
|
|
77
94
|
}
|
|
78
95
|
},
|
|
79
96
|
});
|