@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.
@@ -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 pending state
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: "pending",
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) {
@@ -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: vResult,
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: [],
@@ -53,7 +53,12 @@ export function recordStarted(
53
53
  export function recordCompleted(
54
54
  console: Logger,
55
55
  work: Doc<"work">,
56
- status: "success" | "failed" | "canceled" | "retrying",
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", {
@@ -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
  });