@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.
Files changed (106) hide show
  1. package/README.md +7 -16
  2. package/dist/commonjs/client/index.d.ts +3 -3
  3. package/dist/commonjs/client/index.d.ts.map +1 -1
  4. package/dist/commonjs/client/index.js +10 -5
  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 +80 -0
  9. package/dist/commonjs/component/complete.js.map +1 -0
  10. package/dist/commonjs/component/kick.d.ts +1 -2
  11. package/dist/commonjs/component/kick.d.ts.map +1 -1
  12. package/dist/commonjs/component/kick.js +7 -5
  13. package/dist/commonjs/component/kick.js.map +1 -1
  14. package/dist/commonjs/component/lib.d.ts +3 -3
  15. package/dist/commonjs/component/lib.d.ts.map +1 -1
  16. package/dist/commonjs/component/lib.js +43 -20
  17. package/dist/commonjs/component/lib.js.map +1 -1
  18. package/dist/commonjs/component/logging.d.ts.map +1 -1
  19. package/dist/commonjs/component/logging.js +1 -2
  20. package/dist/commonjs/component/logging.js.map +1 -1
  21. package/dist/commonjs/component/loop.d.ts +1 -14
  22. package/dist/commonjs/component/loop.d.ts.map +1 -1
  23. package/dist/commonjs/component/loop.js +215 -178
  24. package/dist/commonjs/component/loop.js.map +1 -1
  25. package/dist/commonjs/component/recovery.d.ts +16 -0
  26. package/dist/commonjs/component/recovery.d.ts.map +1 -1
  27. package/dist/commonjs/component/recovery.js +64 -44
  28. package/dist/commonjs/component/recovery.js.map +1 -1
  29. package/dist/commonjs/component/schema.d.ts +6 -2
  30. package/dist/commonjs/component/schema.d.ts.map +1 -1
  31. package/dist/commonjs/component/schema.js +5 -3
  32. package/dist/commonjs/component/schema.js.map +1 -1
  33. package/dist/commonjs/component/shared.d.ts +20 -11
  34. package/dist/commonjs/component/shared.d.ts.map +1 -1
  35. package/dist/commonjs/component/shared.js +18 -5
  36. package/dist/commonjs/component/shared.js.map +1 -1
  37. package/dist/commonjs/component/stats.d.ts +21 -13
  38. package/dist/commonjs/component/stats.d.ts.map +1 -1
  39. package/dist/commonjs/component/stats.js +32 -22
  40. package/dist/commonjs/component/stats.js.map +1 -1
  41. package/dist/commonjs/component/worker.d.ts +2 -12
  42. package/dist/commonjs/component/worker.d.ts.map +1 -1
  43. package/dist/commonjs/component/worker.js +23 -36
  44. package/dist/commonjs/component/worker.js.map +1 -1
  45. package/dist/esm/client/index.d.ts +3 -3
  46. package/dist/esm/client/index.d.ts.map +1 -1
  47. package/dist/esm/client/index.js +10 -5
  48. package/dist/esm/client/index.js.map +1 -1
  49. package/dist/esm/component/complete.d.ts +89 -0
  50. package/dist/esm/component/complete.d.ts.map +1 -0
  51. package/dist/esm/component/complete.js +80 -0
  52. package/dist/esm/component/complete.js.map +1 -0
  53. package/dist/esm/component/kick.d.ts +1 -2
  54. package/dist/esm/component/kick.d.ts.map +1 -1
  55. package/dist/esm/component/kick.js +7 -5
  56. package/dist/esm/component/kick.js.map +1 -1
  57. package/dist/esm/component/lib.d.ts +3 -3
  58. package/dist/esm/component/lib.d.ts.map +1 -1
  59. package/dist/esm/component/lib.js +43 -20
  60. package/dist/esm/component/lib.js.map +1 -1
  61. package/dist/esm/component/logging.d.ts.map +1 -1
  62. package/dist/esm/component/logging.js +1 -2
  63. package/dist/esm/component/logging.js.map +1 -1
  64. package/dist/esm/component/loop.d.ts +1 -14
  65. package/dist/esm/component/loop.d.ts.map +1 -1
  66. package/dist/esm/component/loop.js +215 -178
  67. package/dist/esm/component/loop.js.map +1 -1
  68. package/dist/esm/component/recovery.d.ts +16 -0
  69. package/dist/esm/component/recovery.d.ts.map +1 -1
  70. package/dist/esm/component/recovery.js +64 -44
  71. package/dist/esm/component/recovery.js.map +1 -1
  72. package/dist/esm/component/schema.d.ts +6 -2
  73. package/dist/esm/component/schema.d.ts.map +1 -1
  74. package/dist/esm/component/schema.js +5 -3
  75. package/dist/esm/component/schema.js.map +1 -1
  76. package/dist/esm/component/shared.d.ts +20 -11
  77. package/dist/esm/component/shared.d.ts.map +1 -1
  78. package/dist/esm/component/shared.js +18 -5
  79. package/dist/esm/component/shared.js.map +1 -1
  80. package/dist/esm/component/stats.d.ts +21 -13
  81. package/dist/esm/component/stats.d.ts.map +1 -1
  82. package/dist/esm/component/stats.js +32 -22
  83. package/dist/esm/component/stats.js.map +1 -1
  84. package/dist/esm/component/worker.d.ts +2 -12
  85. package/dist/esm/component/worker.d.ts.map +1 -1
  86. package/dist/esm/component/worker.js +23 -36
  87. package/dist/esm/component/worker.js.map +1 -1
  88. package/package.json +7 -6
  89. package/src/client/index.ts +18 -8
  90. package/src/component/README.md +15 -15
  91. package/src/component/_generated/api.d.ts +7 -2
  92. package/src/component/complete.test.ts +508 -0
  93. package/src/component/complete.ts +98 -0
  94. package/src/component/kick.test.ts +13 -13
  95. package/src/component/kick.ts +13 -8
  96. package/src/component/lib.test.ts +262 -17
  97. package/src/component/lib.ts +55 -24
  98. package/src/component/logging.ts +1 -2
  99. package/src/component/loop.test.ts +1158 -0
  100. package/src/component/loop.ts +289 -221
  101. package/src/component/recovery.test.ts +541 -0
  102. package/src/component/recovery.ts +80 -63
  103. package/src/component/schema.ts +6 -4
  104. package/src/component/shared.ts +21 -6
  105. package/src/component/stats.ts +48 -25
  106. 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 { Id } from "./_generated/dataModel.js";
1
+ import { Infer, v } from "convex/values";
2
2
  import { internalMutation } from "./_generated/server.js";
3
- import { kickMainLoop } from "./kick.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
+ /**
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: schema.tables.internalState.validator.fields.running,
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 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({
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: { kind: "failed", error: `Scheduled job not found` },
76
+ runResult: scheduled.state,
77
+ attempt: job.attempt,
27
78
  });
28
- return;
79
+ break;
29
80
  }
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
- }
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 (didAnything) {
76
- await kickMainLoop(ctx, "recovery");
90
+ }
91
+ }
92
+ if (toComplete.length > 0) {
93
+ await completeHandler(ctx, { jobs: toComplete });
77
94
  }
78
95
  },
79
96
  });