@convex-dev/workpool 0.4.3-alpha.0 → 0.4.3
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/dist/component/complete.d.ts +30 -2
- package/dist/component/complete.d.ts.map +1 -1
- package/dist/component/complete.js +25 -9
- package/dist/component/complete.js.map +1 -1
- package/dist/component/loop.d.ts +1 -0
- package/dist/component/loop.d.ts.map +1 -1
- package/dist/component/loop.js +45 -14
- package/dist/component/loop.js.map +1 -1
- package/dist/component/recovery.d.ts.map +1 -1
- package/dist/component/recovery.js +17 -0
- package/dist/component/recovery.js.map +1 -1
- package/dist/component/schema.d.ts +62 -2
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +5 -1
- package/dist/component/schema.js.map +1 -1
- package/dist/component/stats.d.ts +2 -1
- package/dist/component/stats.d.ts.map +1 -1
- package/dist/component/stats.js.map +1 -1
- package/dist/component/worker.d.ts.map +1 -1
- package/dist/component/worker.js +12 -0
- package/dist/component/worker.js.map +1 -1
- package/package.json +7 -8
- package/src/component/complete.test.ts +60 -0
- package/src/component/complete.ts +38 -12
- package/src/component/loop.test.ts +87 -3
- package/src/component/loop.ts +54 -15
- package/src/component/recovery.test.ts +115 -2
- package/src/component/recovery.ts +17 -0
- package/src/component/schema.ts +11 -2
- package/src/component/stats.test.ts +5 -0
- package/src/component/stats.ts +6 -1
- package/src/component/worker.ts +13 -0
|
@@ -354,6 +354,119 @@ describe("recovery", () => {
|
|
|
354
354
|
});
|
|
355
355
|
});
|
|
356
356
|
|
|
357
|
+
it("should handle pending scheduled mutations", async () => {
|
|
358
|
+
// Create work and scheduled function
|
|
359
|
+
let workId: Id<"work">;
|
|
360
|
+
let scheduledId: Id<"_scheduled_functions">;
|
|
361
|
+
|
|
362
|
+
await t.run(async (ctx) => {
|
|
363
|
+
workId = await makeDummyWork(ctx, { fnType: "mutation" });
|
|
364
|
+
scheduledId = await makeDummyScheduledFunction(ctx, workId);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// Run recovery with mocked system.get
|
|
368
|
+
await t.run(async (ctx) => {
|
|
369
|
+
// Mock the system.get to return a pending state
|
|
370
|
+
ctx.db.system.get = patchedSystemGet(ctx.db, {
|
|
371
|
+
[scheduledId]: {
|
|
372
|
+
_id: scheduledId,
|
|
373
|
+
_creationTime: Date.now(),
|
|
374
|
+
name: "internal/worker.runMutationWrapper",
|
|
375
|
+
args: [
|
|
376
|
+
{
|
|
377
|
+
workId,
|
|
378
|
+
fnHandle: "test_handle",
|
|
379
|
+
fnArgs: {},
|
|
380
|
+
logLevel: "WARN",
|
|
381
|
+
attempt: 0,
|
|
382
|
+
},
|
|
383
|
+
],
|
|
384
|
+
scheduledTime: Date.now(),
|
|
385
|
+
state: {
|
|
386
|
+
kind: "pending",
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
await recoveryHandler(ctx, {
|
|
392
|
+
jobs: [
|
|
393
|
+
{
|
|
394
|
+
scheduledId,
|
|
395
|
+
workId,
|
|
396
|
+
attempt: 0,
|
|
397
|
+
started: Date.now(),
|
|
398
|
+
},
|
|
399
|
+
],
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// Verify pendingCompletion was created with stuckInScheduler
|
|
404
|
+
await t.run(async (ctx) => {
|
|
405
|
+
const pendingCompletions = await ctx.db
|
|
406
|
+
.query("pendingCompletion")
|
|
407
|
+
.withIndex("workId", (q) => q.eq("workId", workId))
|
|
408
|
+
.collect();
|
|
409
|
+
expect(pendingCompletions).toHaveLength(1);
|
|
410
|
+
expect(pendingCompletions[0].runResult.kind).toBe("stuckInScheduler");
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("should not process pending scheduled actions", async () => {
|
|
415
|
+
// Create work and scheduled function
|
|
416
|
+
let workId: Id<"work">;
|
|
417
|
+
let scheduledId: Id<"_scheduled_functions">;
|
|
418
|
+
|
|
419
|
+
await t.run(async (ctx) => {
|
|
420
|
+
workId = await makeDummyWork(ctx);
|
|
421
|
+
scheduledId = await makeDummyScheduledFunction(ctx, workId);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// Run recovery with mocked system.get
|
|
425
|
+
await t.run(async (ctx) => {
|
|
426
|
+
// Mock the system.get to return a pending state
|
|
427
|
+
ctx.db.system.get = patchedSystemGet(ctx.db, {
|
|
428
|
+
[scheduledId]: {
|
|
429
|
+
_id: scheduledId,
|
|
430
|
+
_creationTime: Date.now(),
|
|
431
|
+
name: "internal/worker.runActionWrapper",
|
|
432
|
+
args: [
|
|
433
|
+
{
|
|
434
|
+
workId,
|
|
435
|
+
fnHandle: "test_handle",
|
|
436
|
+
fnArgs: {},
|
|
437
|
+
logLevel: "WARN",
|
|
438
|
+
attempt: 0,
|
|
439
|
+
},
|
|
440
|
+
],
|
|
441
|
+
scheduledTime: Date.now(),
|
|
442
|
+
state: {
|
|
443
|
+
kind: "pending",
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
await recoveryHandler(ctx, {
|
|
449
|
+
jobs: [
|
|
450
|
+
{
|
|
451
|
+
scheduledId,
|
|
452
|
+
workId,
|
|
453
|
+
attempt: 0,
|
|
454
|
+
started: Date.now(),
|
|
455
|
+
},
|
|
456
|
+
],
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
// Verify no pendingCompletion was created
|
|
461
|
+
await t.run(async (ctx) => {
|
|
462
|
+
const pendingCompletions = await ctx.db
|
|
463
|
+
.query("pendingCompletion")
|
|
464
|
+
.withIndex("workId", (q) => q.eq("workId", workId))
|
|
465
|
+
.collect();
|
|
466
|
+
expect(pendingCompletions).toHaveLength(0);
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
357
470
|
it("should handle multiple jobs in a single call", async () => {
|
|
358
471
|
// Create multiple work items and scheduled functions
|
|
359
472
|
let workId1: Id<"work">;
|
|
@@ -472,7 +585,7 @@ describe("recovery", () => {
|
|
|
472
585
|
|
|
473
586
|
// Run recovery with mocked system.get
|
|
474
587
|
await t.run(async (ctx) => {
|
|
475
|
-
// Mock the system.get to return a
|
|
588
|
+
// Mock the system.get to return a inProgress state
|
|
476
589
|
ctx.db.system.get = patchedSystemGet(ctx.db, {
|
|
477
590
|
[scheduledId]: {
|
|
478
591
|
_id: scheduledId,
|
|
@@ -489,7 +602,7 @@ describe("recovery", () => {
|
|
|
489
602
|
],
|
|
490
603
|
scheduledTime: Date.now(),
|
|
491
604
|
state: {
|
|
492
|
-
kind: "
|
|
605
|
+
kind: "inProgress",
|
|
493
606
|
},
|
|
494
607
|
},
|
|
495
608
|
});
|
|
@@ -96,6 +96,23 @@ export async function recoveryHandler(
|
|
|
96
96
|
});
|
|
97
97
|
break;
|
|
98
98
|
}
|
|
99
|
+
case "pending": {
|
|
100
|
+
if (work.fnType === "action") {
|
|
101
|
+
// We do not cancel and re-enqueue actions. If a scheduled action is still
|
|
102
|
+
// pending, the scheduler is likely backlogged.
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
// It looks like the function has been retried by the scheduler several times.
|
|
106
|
+
// The scheduler backoff is too long, so cancel and re-enqueue the job to
|
|
107
|
+
// free up space for more work.
|
|
108
|
+
await ctx.scheduler.cancel(scheduled._id);
|
|
109
|
+
completionJobs.push({
|
|
110
|
+
workId: job.workId,
|
|
111
|
+
runResult: { kind: "stuckInScheduler" },
|
|
112
|
+
attempt: job.attempt,
|
|
113
|
+
});
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
99
116
|
}
|
|
100
117
|
}
|
|
101
118
|
if (completionJobs.length > 0) {
|
package/src/component/schema.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineSchema, defineTable } from "convex/server";
|
|
2
|
-
import { v } from "convex/values";
|
|
2
|
+
import { v, type Infer } from "convex/values";
|
|
3
3
|
import {
|
|
4
4
|
fnType,
|
|
5
5
|
vConfig,
|
|
@@ -11,6 +11,14 @@ import {
|
|
|
11
11
|
// Represents a slice of time to process work.
|
|
12
12
|
const segment = v.int64();
|
|
13
13
|
|
|
14
|
+
export const vResultInternal = v.union(
|
|
15
|
+
vResult,
|
|
16
|
+
v.object({
|
|
17
|
+
kind: v.literal("stuckInScheduler"),
|
|
18
|
+
}),
|
|
19
|
+
);
|
|
20
|
+
export type RunResultInternal = Infer<typeof vResultInternal>;
|
|
21
|
+
|
|
14
22
|
export default defineSchema({
|
|
15
23
|
// Written from kickLoop, read everywhere.
|
|
16
24
|
globals: defineTable(vConfig),
|
|
@@ -30,6 +38,7 @@ export default defineSchema({
|
|
|
30
38
|
failed: v.number(), // failed after all retries
|
|
31
39
|
retries: v.number(), // failure that turned into a retry
|
|
32
40
|
canceled: v.number(), // cancelations processed
|
|
41
|
+
conflicted: v.optional(v.number()), // mutations conflicted in the scheduler
|
|
33
42
|
lastReportTs: v.number(),
|
|
34
43
|
}),
|
|
35
44
|
running: v.array(
|
|
@@ -83,7 +92,7 @@ export default defineSchema({
|
|
|
83
92
|
// Written by complete, read & deleted by `main`.
|
|
84
93
|
pendingCompletion: defineTable({
|
|
85
94
|
segment,
|
|
86
|
-
runResult:
|
|
95
|
+
runResult: vResultInternal,
|
|
87
96
|
workId: v.id("work"),
|
|
88
97
|
retry: v.boolean(),
|
|
89
98
|
})
|
|
@@ -64,6 +64,7 @@ describe("stats", () => {
|
|
|
64
64
|
failed: 0,
|
|
65
65
|
retries: 0,
|
|
66
66
|
canceled: 0,
|
|
67
|
+
conflicted: 0,
|
|
67
68
|
lastReportTs: 0,
|
|
68
69
|
},
|
|
69
70
|
running: [],
|
|
@@ -109,6 +110,7 @@ describe("stats", () => {
|
|
|
109
110
|
failed: 2,
|
|
110
111
|
retries: 2,
|
|
111
112
|
canceled: 0,
|
|
113
|
+
conflicted: 0,
|
|
112
114
|
lastReportTs: 0,
|
|
113
115
|
},
|
|
114
116
|
running: [],
|
|
@@ -160,6 +162,7 @@ describe("stats", () => {
|
|
|
160
162
|
failed: 2,
|
|
161
163
|
retries: 2,
|
|
162
164
|
canceled: 0,
|
|
165
|
+
conflicted: 0,
|
|
163
166
|
failureRate: 0.4, // (failed + retries) / completed = (2 + 2) / 10 = 0.4
|
|
164
167
|
permanentFailureRate: 0.25, // failed / (completed - retries) = 2 / (10 - 2) = 2/8
|
|
165
168
|
lastReportTs: expect.any(Number),
|
|
@@ -183,6 +186,7 @@ describe("stats", () => {
|
|
|
183
186
|
failed: 1,
|
|
184
187
|
retries: 1,
|
|
185
188
|
canceled: 0,
|
|
189
|
+
conflicted: 0,
|
|
186
190
|
lastReportTs: 0,
|
|
187
191
|
},
|
|
188
192
|
running: [],
|
|
@@ -267,6 +271,7 @@ describe("stats", () => {
|
|
|
267
271
|
failed: 1,
|
|
268
272
|
retries: 1,
|
|
269
273
|
canceled: 0,
|
|
274
|
+
conflicted: 0,
|
|
270
275
|
lastReportTs: 0,
|
|
271
276
|
},
|
|
272
277
|
running: [],
|
package/src/component/stats.ts
CHANGED
|
@@ -53,7 +53,12 @@ export function recordStarted(
|
|
|
53
53
|
export function recordCompleted(
|
|
54
54
|
console: Logger,
|
|
55
55
|
work: Doc<"work">,
|
|
56
|
-
status:
|
|
56
|
+
status:
|
|
57
|
+
| "success"
|
|
58
|
+
| "failed"
|
|
59
|
+
| "canceled"
|
|
60
|
+
| "retrying"
|
|
61
|
+
| "retrying conflicted",
|
|
57
62
|
onCompleteScheduledFunctionId: Id<"_scheduled_functions"> | undefined,
|
|
58
63
|
) {
|
|
59
64
|
console.event("completed", {
|
package/src/component/worker.ts
CHANGED
|
@@ -91,6 +91,19 @@ export const runActionWrapper = internalAction({
|
|
|
91
91
|
// NOTE: we could run `ctx.runMutation`, but we want to guarantee execution,
|
|
92
92
|
// and `ctx.scheduler.runAfter` won't OCC.
|
|
93
93
|
const runResult: RunResult = { kind: "success", returnValue };
|
|
94
|
+
try {
|
|
95
|
+
// Attempt to run complete inline and onComplete inline
|
|
96
|
+
await ctx.runMutation(internal.complete.complete, {
|
|
97
|
+
jobs: [{ workId, runResult, attempt, runOnCompleteInline: true }],
|
|
98
|
+
});
|
|
99
|
+
console.info("[runActionWrapper] onComplete succeeded");
|
|
100
|
+
return;
|
|
101
|
+
} catch (e) {
|
|
102
|
+
console.error(
|
|
103
|
+
`[runActionWrapper] caught error while attempting to run complete inline, scheduling instead: ${e}`,
|
|
104
|
+
);
|
|
105
|
+
// Fall through and schedule complete instead (without running onComplete inline)
|
|
106
|
+
}
|
|
94
107
|
await ctx.scheduler.runAfter(0, internal.complete.complete, {
|
|
95
108
|
jobs: [{ workId, runResult, attempt }],
|
|
96
109
|
});
|