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