@convex-dev/workpool 0.4.5 → 0.4.7-alpha.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 (52) hide show
  1. package/dist/component/_generated/api.d.ts +2 -0
  2. package/dist/component/_generated/api.d.ts.map +1 -1
  3. package/dist/component/_generated/api.js.map +1 -1
  4. package/dist/component/complete.d.ts.map +1 -1
  5. package/dist/component/complete.js +8 -7
  6. package/dist/component/complete.js.map +1 -1
  7. package/dist/component/danger.js +7 -7
  8. package/dist/component/danger.js.map +1 -1
  9. package/dist/component/future.d.ts +11 -0
  10. package/dist/component/future.d.ts.map +1 -0
  11. package/dist/component/future.js +21 -0
  12. package/dist/component/future.js.map +1 -0
  13. package/dist/component/kick.d.ts +3 -3
  14. package/dist/component/kick.d.ts.map +1 -1
  15. package/dist/component/kick.js +14 -16
  16. package/dist/component/kick.js.map +1 -1
  17. package/dist/component/lib.d.ts.map +1 -1
  18. package/dist/component/lib.js +13 -13
  19. package/dist/component/lib.js.map +1 -1
  20. package/dist/component/loop.d.ts +44 -1
  21. package/dist/component/loop.d.ts.map +1 -1
  22. package/dist/component/loop.js +175 -218
  23. package/dist/component/loop.js.map +1 -1
  24. package/dist/component/recovery.d.ts.map +1 -1
  25. package/dist/component/recovery.js +2 -2
  26. package/dist/component/recovery.js.map +1 -1
  27. package/dist/component/schema.d.ts +5 -2
  28. package/dist/component/schema.d.ts.map +1 -1
  29. package/dist/component/schema.js +3 -1
  30. package/dist/component/schema.js.map +1 -1
  31. package/dist/component/stats.d.ts +1 -0
  32. package/dist/component/stats.d.ts.map +1 -1
  33. package/dist/component/worker.js +1 -1
  34. package/dist/component/worker.js.map +1 -1
  35. package/package.json +8 -12
  36. package/src/component/_generated/api.ts +2 -0
  37. package/src/component/complete.test.ts +13 -13
  38. package/src/component/complete.ts +13 -7
  39. package/src/component/danger.ts +7 -7
  40. package/src/component/future.ts +38 -0
  41. package/src/component/kick.test.ts +17 -20
  42. package/src/component/kick.ts +20 -17
  43. package/src/component/lib.test.ts +7 -7
  44. package/src/component/lib.ts +12 -15
  45. package/src/component/loop.test.ts +695 -1127
  46. package/src/component/loop.ts +216 -285
  47. package/src/component/recovery.test.ts +3 -3
  48. package/src/component/recovery.ts +5 -2
  49. package/src/component/schema.ts +3 -1
  50. package/src/component/stateMachine.test.ts +1246 -0
  51. package/src/component/stats.test.ts +4 -4
  52. package/src/component/worker.ts +1 -1
@@ -16,7 +16,6 @@ import { modules } from "./setup.test.js";
16
16
  import {
17
17
  DEFAULT_MAX_PARALLELISM,
18
18
  fromSegment,
19
- getCurrentSegment,
20
19
  getNextSegment,
21
20
  toSegment,
22
21
  } from "./shared.js";
@@ -58,12 +57,11 @@ describe("kickMainLoop", () => {
58
57
  expect(runStatus.state.kind).toBe("running");
59
58
 
60
59
  // Second kick should not change state
61
- const segment = await kickMainLoop(ctx, "enqueue");
60
+ await kickMainLoop(ctx, "enqueue");
62
61
  const afterStatus = await ctx.db.query("runStatus").unique();
63
62
  assert(afterStatus);
64
63
  expect(afterStatus.state.kind).toBe("running");
65
64
  expect(afterStatus._id).toBe(runStatus._id);
66
- expect(segment).toBe(getNextSegment());
67
65
  });
68
66
  });
69
67
 
@@ -89,7 +87,7 @@ describe("kickMainLoop", () => {
89
87
  segment: futureSegment,
90
88
  },
91
89
  );
92
- await ctx.db.patch(runStatus._id, {
90
+ await ctx.db.patch("runStatus", runStatus._id, {
93
91
  state: {
94
92
  kind: "scheduled",
95
93
  scheduledId,
@@ -100,8 +98,7 @@ describe("kickMainLoop", () => {
100
98
  });
101
99
 
102
100
  // Kick should reschedule to run sooner
103
- const segment = await kickMainLoop(ctx, "enqueue");
104
- expect(segment).toBe(getCurrentSegment());
101
+ await kickMainLoop(ctx, "enqueue");
105
102
 
106
103
  const afterStatus = await ctx.db.query("runStatus").unique();
107
104
  assert(afterStatus);
@@ -131,7 +128,7 @@ describe("kickMainLoop", () => {
131
128
  segment: nearFutureSegment,
132
129
  },
133
130
  );
134
- await ctx.db.patch(runStatus._id, {
131
+ await ctx.db.patch("runStatus", runStatus._id, {
135
132
  state: {
136
133
  kind: "scheduled",
137
134
  scheduledId,
@@ -142,8 +139,7 @@ describe("kickMainLoop", () => {
142
139
  });
143
140
 
144
141
  // Kick should not change state when saturated
145
- const segment = await kickMainLoop(ctx, "enqueue");
146
- expect(segment).toBe(getNextSegment());
142
+ await kickMainLoop(ctx, "enqueue");
147
143
  const afterStatus = await ctx.db.query("runStatus").unique();
148
144
  assert(afterStatus);
149
145
  expect(afterStatus.state.kind).toBe("scheduled");
@@ -161,7 +157,7 @@ describe("kickMainLoop", () => {
161
157
  // Delete runStatus
162
158
  const runStatus = await ctx.db.query("runStatus").unique();
163
159
  assert(runStatus);
164
- await ctx.db.delete(runStatus._id);
160
+ await ctx.db.delete("runStatus", runStatus._id);
165
161
 
166
162
  // Kick should recreate runStatus
167
163
  await kickMainLoop(ctx, "complete");
@@ -181,7 +177,7 @@ describe("kickMainLoop", () => {
181
177
  // Delete globals
182
178
  const globals = await ctx.db.query("globals").unique();
183
179
  assert(globals);
184
- await ctx.db.delete(globals._id);
180
+ await ctx.db.delete("globals", globals._id);
185
181
 
186
182
  // Kick should recreate globals
187
183
  await kickMainLoop(ctx, "complete");
@@ -195,16 +191,14 @@ describe("kickMainLoop", () => {
195
191
 
196
192
  test("handles race conditions between multiple kicks", async () => {
197
193
  const t = convexTest(schema, modules);
198
- // Run kicks in separate transactions to simulate concurrent access
199
- const segments = await Promise.all(
194
+ // Run kicks in separate transactions to simulate concurrent access.
195
+ // None should throw; the loser transactions just observe the winner's
196
+ // running state and return early.
197
+ await Promise.all(
200
198
  Array.from({ length: 10 }, () =>
201
- t.run(async (ctx) => {
202
- const segment = await kickMainLoop(ctx, "enqueue");
203
- return segment;
204
- }),
199
+ t.mutation((ctx) => kickMainLoop(ctx, "enqueue")),
205
200
  ),
206
201
  );
207
- expect(segments.filter((s) => s === getCurrentSegment())).toHaveLength(1);
208
202
 
209
203
  // Check final state in a new transaction
210
204
  await t.run(async (ctx) => {
@@ -259,7 +253,7 @@ describe("kickMainLoop", () => {
259
253
  internal.loop.main,
260
254
  { generation: 0n, segment },
261
255
  );
262
- await ctx.db.patch(runStatus._id, {
256
+ await ctx.db.patch("runStatus", runStatus._id, {
263
257
  state: {
264
258
  generation: 0n,
265
259
  saturated: false,
@@ -274,7 +268,10 @@ describe("kickMainLoop", () => {
274
268
  assert(afterStatus);
275
269
  expect(afterStatus.state.kind).toBe("running");
276
270
  assert(afterStatus.state.kind === "running");
277
- const scheduledJob = await ctx.db.system.get(scheduledId);
271
+ const scheduledJob = await ctx.db.system.get(
272
+ "_scheduled_functions",
273
+ scheduledId,
274
+ );
278
275
  assert(scheduledJob);
279
276
  expect(scheduledJob.state.kind).toBe("canceled");
280
277
  });
@@ -8,31 +8,29 @@ import {
8
8
  type Config,
9
9
  fromSegment,
10
10
  getCurrentSegment,
11
- getNextSegment,
12
11
  SECOND,
13
12
  toSegment,
14
13
  } from "./shared.js";
15
14
 
16
15
  /**
17
- * Called from outside the loop.
18
- * Returns the soonest segment to enqueue work for the main loop.
16
+ * Wakes the main loop if it isn't already running. No-ops when a wake-up
17
+ * wouldn't be productive (e.g. enqueue while saturated).
19
18
  */
20
19
  export async function kickMainLoop(
21
20
  ctx: MutationCtx,
22
21
  source: "enqueue" | "cancel" | "complete" | "kick",
23
22
  config?: Config,
24
- ): Promise<bigint> {
23
+ ): Promise<void> {
25
24
  const globals = config ?? (await getOrUpdateGlobals(ctx, config));
26
25
  const console = createLogger(globals.logLevel);
27
26
  const runStatus = await getOrCreateRunStatus(ctx);
28
- const next = getNextSegment();
29
27
 
30
28
  // Only kick to run now if we're scheduled or idle.
31
29
  if (runStatus.state.kind === "running") {
32
30
  console.debug(
33
31
  `[${source}] main is actively running, so we don't need to kick it`,
34
32
  );
35
- return next;
33
+ return;
36
34
  }
37
35
  // main is scheduled to run later, so we should cancel it and reschedule.
38
36
  if (runStatus.state.kind === "scheduled") {
@@ -40,24 +38,27 @@ export async function kickMainLoop(
40
38
  console.debug(
41
39
  `[${source}] main is saturated, so we don't need to kick it`,
42
40
  );
43
- return next;
41
+ return;
44
42
  }
45
43
  if (source === "complete" && !runStatus.state.saturated) {
46
44
  console.debug(
47
45
  `[${source}] main is not saturated, so kicking for completion isn't necessary`,
48
46
  );
49
- return next;
47
+ return;
50
48
  }
51
49
  if (runStatus.state.segment <= toSegment(Date.now() + SECOND)) {
52
50
  console.debug(
53
51
  `[${source}] main is scheduled to run soon enough, so we don't need to kick it`,
54
52
  );
55
- return next;
53
+ return;
56
54
  }
57
55
  console.debug(
58
56
  `[${source}] main is scheduled to run later, so reschedule it to run now`,
59
57
  );
60
- const scheduled = await ctx.db.system.get(runStatus.state.scheduledId);
58
+ const scheduled = await ctx.db.system.get(
59
+ "_scheduled_functions",
60
+ runStatus.state.scheduledId,
61
+ );
61
62
  if (scheduled && scheduled.state.kind === "pending") {
62
63
  await ctx.scheduler.cancel(runStatus.state.scheduledId);
63
64
  } else {
@@ -68,21 +69,23 @@ export async function kickMainLoop(
68
69
  } else if (runStatus.state.kind === "idle") {
69
70
  console.debug(`[${source}] main was idle, so run it now`);
70
71
  }
71
- await ctx.db.patch(runStatus._id, { state: { kind: "running" } });
72
- const current = getCurrentSegment();
73
- const scheduledTime = boundScheduledTime(fromSegment(current), console);
72
+ await ctx.db.patch("runStatus", runStatus._id, {
73
+ state: { kind: "running" },
74
+ });
75
+ const scheduledTime = boundScheduledTime(
76
+ fromSegment(getCurrentSegment()),
77
+ console,
78
+ );
74
79
  await ctx.scheduler.runAt(scheduledTime, internal.loop.main, {
75
80
  generation: runStatus.state.generation,
76
- segment: current,
77
81
  });
78
- return current;
79
82
  }
80
83
 
81
84
  export const forceKick = internalMutation({
82
85
  args: {},
83
86
  handler: async (ctx) => {
84
87
  const runStatus = await getOrCreateRunStatus(ctx);
85
- await ctx.db.delete(runStatus._id);
88
+ await ctx.db.delete("runStatus", runStatus._id);
86
89
  await kickMainLoop(ctx, "kick");
87
90
  },
88
91
  });
@@ -97,7 +100,7 @@ async function getOrCreateRunStatus(ctx: MutationCtx) {
97
100
  generation: state?.generation ?? INITIAL_STATE.generation,
98
101
  },
99
102
  });
100
- runStatus = (await ctx.db.get(id))!;
103
+ runStatus = (await ctx.db.get("runStatus", id))!;
101
104
  if (!state) {
102
105
  await ctx.db.insert("internalState", INITIAL_STATE);
103
106
  }
@@ -165,7 +165,7 @@ describe("lib", () => {
165
165
 
166
166
  // Delete the work item
167
167
  await t.run(async (ctx) => {
168
- await ctx.db.delete(id);
168
+ await ctx.db.delete("work", id);
169
169
  });
170
170
 
171
171
  // Try to cancel the deleted work
@@ -281,7 +281,7 @@ describe("lib", () => {
281
281
  },
282
282
  });
283
283
  await t.run(async (ctx) => {
284
- await ctx.db.delete(id);
284
+ await ctx.db.delete("work", id);
285
285
  });
286
286
 
287
287
  const status = await t.query(api.lib.status, { id });
@@ -303,7 +303,7 @@ describe("lib", () => {
303
303
 
304
304
  // Verify work item and pending start were created
305
305
  await t.run(async (ctx) => {
306
- const work = await ctx.db.get(id);
306
+ const work = await ctx.db.get("work", id);
307
307
  expect(work).toBeDefined();
308
308
  const pendingStarts = await ctx.db.query("pendingStart").collect();
309
309
  expect(pendingStarts).toHaveLength(1);
@@ -332,7 +332,7 @@ describe("lib", () => {
332
332
  const pendingStart = await ctx.db.query("pendingStart").first();
333
333
  expect(pendingStart).toBeDefined();
334
334
  assert(pendingStart);
335
- await ctx.db.delete(pendingStart._id);
335
+ await ctx.db.delete("pendingStart", pendingStart._id);
336
336
  });
337
337
 
338
338
  const status = await t.query(api.lib.status, { id });
@@ -362,7 +362,7 @@ describe("lib", () => {
362
362
  const pendingStart = await ctx.db.query("pendingStart").first();
363
363
  expect(pendingStart).toBeDefined();
364
364
  assert(pendingStart);
365
- await ctx.db.delete(pendingStart._id);
365
+ await ctx.db.delete("pendingStart", pendingStart._id);
366
366
 
367
367
  // Create a pendingCompletion with retry=true to simulate a failed job that will be retried
368
368
  await ctx.db.insert("pendingCompletion", {
@@ -395,7 +395,7 @@ describe("lib", () => {
395
395
  const pendingStart = await ctx.db.query("pendingStart").first();
396
396
  expect(pendingStart).toBeDefined();
397
397
  assert(pendingStart);
398
- await ctx.db.delete(pendingStart._id);
398
+ await ctx.db.delete("pendingStart", pendingStart._id);
399
399
 
400
400
  // Create a pendingCancelation
401
401
  await ctx.db.insert("pendingCancelation", {
@@ -428,7 +428,7 @@ describe("lib", () => {
428
428
  const pendingStart = await ctx.db.query("pendingStart").first();
429
429
  expect(pendingStart).toBeDefined();
430
430
  assert(pendingStart);
431
- await ctx.db.delete(pendingStart._id);
431
+ await ctx.db.delete("pendingStart", pendingStart._id);
432
432
 
433
433
  // Create a pendingCompletion with retry=false
434
434
  await ctx.db.insert("pendingCompletion", {
@@ -19,7 +19,7 @@ import {
19
19
  boundScheduledTime,
20
20
  vConfig,
21
21
  fnType,
22
- getNextSegment,
22
+ getCurrentSegment,
23
23
  max,
24
24
  vOnCompleteFnContext,
25
25
  retryBehavior,
@@ -53,14 +53,13 @@ export const enqueue = mutation({
53
53
  handler: async (ctx, { config, ...itemArgs }) => {
54
54
  const globals = await getOrUpdateGlobals(ctx, config);
55
55
  const console = createLogger(globals.logLevel);
56
- const kickSegment = await kickMainLoop(ctx, "enqueue", globals);
57
- return await enqueueHandler(ctx, console, kickSegment, itemArgs);
56
+ await kickMainLoop(ctx, "enqueue", globals);
57
+ return await enqueueHandler(ctx, console, itemArgs);
58
58
  },
59
59
  });
60
60
  async function enqueueHandler(
61
61
  ctx: MutationCtx,
62
62
  console: Logger,
63
- kickSegment: bigint,
64
63
  { runAt, ...workArgs }: ObjectType<typeof itemArgs>,
65
64
  ) {
66
65
  runAt = boundScheduledTime(runAt, console);
@@ -115,7 +114,7 @@ async function enqueueHandler(
115
114
 
116
115
  await ctx.db.insert("pendingStart", {
117
116
  workId,
118
- segment: max(toSegment(runAt), kickSegment),
117
+ segment: max(toSegment(runAt), getCurrentSegment()),
119
118
  });
120
119
  recordEnqueued(console, { workId, fnName: workArgs.fnName, runAt });
121
120
  return workId;
@@ -130,10 +129,8 @@ export const enqueueBatch = mutation({
130
129
  handler: async (ctx, { config, items }) => {
131
130
  const globals = await getOrUpdateGlobals(ctx, config);
132
131
  const console = createLogger(globals.logLevel);
133
- const kickSegment = await kickMainLoop(ctx, "enqueue", globals);
134
- return Promise.all(
135
- items.map((item) => enqueueHandler(ctx, console, kickSegment, item)),
136
- );
132
+ await kickMainLoop(ctx, "enqueue", globals);
133
+ return Promise.all(items.map((item) => enqueueHandler(ctx, console, item)));
137
134
  },
138
135
  });
139
136
 
@@ -146,10 +143,10 @@ export const cancel = mutation({
146
143
  const globals = await getOrUpdateGlobals(ctx, { logLevel });
147
144
  const shouldCancel = await shouldCancelWorkItem(ctx, id, globals.logLevel);
148
145
  if (shouldCancel) {
149
- const segment = await kickMainLoop(ctx, "cancel", globals);
146
+ await kickMainLoop(ctx, "cancel", globals);
150
147
  await ctx.db.insert("pendingCancelation", {
151
148
  workId: id,
152
- segment,
149
+ segment: getCurrentSegment(),
153
150
  });
154
151
  }
155
152
  },
@@ -176,10 +173,10 @@ export const cancelAll = mutation({
176
173
  shouldCancelWorkItem(ctx, _id, globals.logLevel),
177
174
  ),
178
175
  );
179
- let segment = getNextSegment();
180
176
  if (shouldCancel.some((c) => c)) {
181
- segment = await kickMainLoop(ctx, "cancel", globals);
177
+ await kickMainLoop(ctx, "cancel", globals);
182
178
  }
179
+ const segment = getCurrentSegment();
183
180
  await Promise.all(
184
181
  pageOfWork.map(({ _id }, index) => {
185
182
  if (shouldCancel[index]) {
@@ -206,7 +203,7 @@ export const status = query({
206
203
  handler: statusHandler,
207
204
  });
208
205
  async function statusHandler(ctx: QueryCtx, { id }: { id: Id<"work"> }) {
209
- const work = await ctx.db.get(id);
206
+ const work = await ctx.db.get("work", id);
210
207
  if (!work) {
211
208
  return { state: "finished" } as const;
212
209
  }
@@ -245,7 +242,7 @@ async function shouldCancelWorkItem(
245
242
  ) {
246
243
  const console = createLogger(logLevel);
247
244
  // No-op if the work doesn't exist or has completed.
248
- const work = await ctx.db.get(workId);
245
+ const work = await ctx.db.get("work", workId);
249
246
  if (!work) {
250
247
  console.warn(`[cancel] work ${workId} doesn't exist`);
251
248
  return false;