@convex-dev/workpool 0.3.1-alpha.1 → 0.3.2

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 (68) hide show
  1. package/README.md +9 -0
  2. package/dist/client/index.d.ts +9 -9
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js +22 -22
  5. package/dist/client/index.js.map +1 -1
  6. package/dist/component/_generated/api.d.ts +2 -0
  7. package/dist/component/_generated/api.d.ts.map +1 -1
  8. package/dist/component/_generated/api.js.map +1 -1
  9. package/dist/component/_generated/component.d.ts +12 -6
  10. package/dist/component/_generated/component.d.ts.map +1 -1
  11. package/dist/component/_generated/dataModel.d.ts +1 -1
  12. package/dist/component/_generated/server.d.ts.map +1 -1
  13. package/dist/component/complete.d.ts.map +1 -1
  14. package/dist/component/complete.js +13 -3
  15. package/dist/component/complete.js.map +1 -1
  16. package/dist/component/config.d.ts +16 -0
  17. package/dist/component/config.d.ts.map +1 -0
  18. package/dist/component/config.js +63 -0
  19. package/dist/component/config.js.map +1 -0
  20. package/dist/component/crons.d.ts +1 -1
  21. package/dist/component/crons.d.ts.map +1 -1
  22. package/dist/component/crons.js +2 -2
  23. package/dist/component/crons.js.map +1 -1
  24. package/dist/component/danger.d.ts.map +1 -1
  25. package/dist/component/danger.js +7 -1
  26. package/dist/component/danger.js.map +1 -1
  27. package/dist/component/kick.d.ts +1 -1
  28. package/dist/component/kick.d.ts.map +1 -1
  29. package/dist/component/kick.js +4 -29
  30. package/dist/component/kick.js.map +1 -1
  31. package/dist/component/lib.d.ts +6 -8
  32. package/dist/component/lib.d.ts.map +1 -1
  33. package/dist/component/lib.js +19 -29
  34. package/dist/component/lib.js.map +1 -1
  35. package/dist/component/loop.d.ts.map +1 -1
  36. package/dist/component/loop.js +10 -5
  37. package/dist/component/loop.js.map +1 -1
  38. package/dist/component/schema.d.ts +15 -6
  39. package/dist/component/schema.d.ts.map +1 -1
  40. package/dist/component/schema.js +13 -5
  41. package/dist/component/schema.js.map +1 -1
  42. package/dist/component/shared.d.ts +6 -9
  43. package/dist/component/shared.d.ts.map +1 -1
  44. package/dist/component/shared.js +3 -4
  45. package/dist/component/shared.js.map +1 -1
  46. package/dist/component/stats.js +1 -1
  47. package/dist/component/stats.js.map +1 -1
  48. package/package.json +33 -30
  49. package/src/client/index.ts +33 -25
  50. package/src/component/_generated/api.ts +2 -0
  51. package/src/component/_generated/component.ts +18 -6
  52. package/src/component/_generated/dataModel.ts +1 -1
  53. package/src/component/_generated/server.ts +0 -5
  54. package/src/component/complete.ts +13 -7
  55. package/src/component/config.test.ts +31 -0
  56. package/src/component/config.ts +69 -0
  57. package/src/component/crons.ts +2 -2
  58. package/src/component/danger.ts +7 -1
  59. package/src/component/kick.test.ts +2 -23
  60. package/src/component/kick.ts +4 -32
  61. package/src/component/lib.test.ts +3 -3
  62. package/src/component/lib.ts +21 -34
  63. package/src/component/loop.ts +9 -5
  64. package/src/component/recovery.test.ts +122 -122
  65. package/src/component/schema.ts +16 -7
  66. package/src/component/shared.ts +5 -7
  67. package/src/component/stats.test.ts +1 -1
  68. package/src/component/stats.ts +1 -1
@@ -1,11 +1,11 @@
1
1
  import { internal } from "./_generated/api.js";
2
2
  import { internalMutation, type MutationCtx } from "./_generated/server.js";
3
- import { createLogger, DEFAULT_LOG_LEVEL } from "./logging.js";
3
+ import { getOrUpdateGlobals } from "./config.js";
4
+ import { createLogger } from "./logging.js";
4
5
  import { INITIAL_STATE } from "./loop.js";
5
6
  import {
6
7
  boundScheduledTime,
7
8
  type Config,
8
- DEFAULT_MAX_PARALLELISM,
9
9
  fromSegment,
10
10
  getCurrentSegment,
11
11
  getNextSegment,
@@ -20,9 +20,9 @@ import {
20
20
  export async function kickMainLoop(
21
21
  ctx: MutationCtx,
22
22
  source: "enqueue" | "cancel" | "complete" | "kick",
23
- config?: Partial<Config>,
23
+ config?: Config,
24
24
  ): Promise<bigint> {
25
- const globals = await getOrUpdateGlobals(ctx, config);
25
+ const globals = config ?? (await getOrUpdateGlobals(ctx, config));
26
26
  const console = createLogger(globals.logLevel);
27
27
  const runStatus = await getOrCreateRunStatus(ctx);
28
28
  const next = getNextSegment();
@@ -98,31 +98,3 @@ async function getOrCreateRunStatus(ctx: MutationCtx) {
98
98
  }
99
99
  return runStatus;
100
100
  }
101
-
102
- async function getOrUpdateGlobals(ctx: MutationCtx, config?: Partial<Config>) {
103
- const globals = await ctx.db.query("globals").unique();
104
- if (!globals) {
105
- const id = await ctx.db.insert("globals", {
106
- maxParallelism: config?.maxParallelism ?? DEFAULT_MAX_PARALLELISM,
107
- logLevel: config?.logLevel ?? DEFAULT_LOG_LEVEL,
108
- });
109
- return (await ctx.db.get(id))!;
110
- } else if (config) {
111
- let updated = false;
112
- if (
113
- config.maxParallelism &&
114
- config.maxParallelism !== globals.maxParallelism
115
- ) {
116
- globals.maxParallelism = config.maxParallelism;
117
- updated = true;
118
- }
119
- if (config.logLevel && config.logLevel !== globals.logLevel) {
120
- globals.logLevel = config.logLevel;
121
- updated = true;
122
- }
123
- if (updated) {
124
- await ctx.db.replace(globals._id, globals);
125
- }
126
- }
127
- return globals;
128
- }
@@ -66,7 +66,7 @@ describe("lib", () => {
66
66
  logLevel: "WARN",
67
67
  },
68
68
  }),
69
- ).rejects.toThrow("maxParallelism must be <= 100");
69
+ ).rejects.toThrow("maxParallelism must be <= 200");
70
70
  });
71
71
 
72
72
  it("should throw error if maxParallelism is too low", async () => {
@@ -78,11 +78,11 @@ describe("lib", () => {
78
78
  fnType: "mutation",
79
79
  runAt: Date.now(),
80
80
  config: {
81
- maxParallelism: 0, // Less than minimum
81
+ maxParallelism: -1, // Less than minimum
82
82
  logLevel: "WARN",
83
83
  },
84
84
  }),
85
- ).rejects.toThrow("maxParallelism must be >= 1");
85
+ ).rejects.toThrow("maxParallelism must be >= 0");
86
86
  });
87
87
  });
88
88
 
@@ -1,4 +1,4 @@
1
- import { type Infer, type ObjectType, v } from "convex/values";
1
+ import { type ObjectType, v } from "convex/values";
2
2
  import { api } from "./_generated/api.js";
3
3
  import type { Id } from "./_generated/dataModel.js";
4
4
  import {
@@ -16,19 +16,17 @@ import {
16
16
  } from "./logging.js";
17
17
  import {
18
18
  boundScheduledTime,
19
- config,
19
+ vConfig,
20
20
  fnType,
21
21
  getNextSegment,
22
22
  max,
23
- onComplete,
23
+ vOnCompleteFnContext,
24
24
  retryBehavior,
25
25
  status as statusValidator,
26
26
  toSegment,
27
27
  } from "./shared.js";
28
28
  import { recordEnqueued } from "./stats.js";
29
-
30
- const MAX_POSSIBLE_PARALLELISM = 200;
31
- const MAX_PARALLELISM_SOFT_LIMIT = 100;
29
+ import { getOrUpdateGlobals } from "./config.js";
32
30
 
33
31
  const itemArgs = {
34
32
  fnHandle: v.string(),
@@ -37,20 +35,20 @@ const itemArgs = {
37
35
  fnType,
38
36
  runAt: v.number(),
39
37
  // TODO: annotation?
40
- onComplete: v.optional(onComplete),
38
+ onComplete: v.optional(vOnCompleteFnContext),
41
39
  retryBehavior: v.optional(retryBehavior),
42
40
  };
43
41
  const enqueueArgs = {
44
42
  ...itemArgs,
45
- config,
43
+ config: vConfig.partial(),
46
44
  };
47
45
  export const enqueue = mutation({
48
46
  args: enqueueArgs,
49
47
  returns: v.id("work"),
50
48
  handler: async (ctx, { config, ...itemArgs }) => {
51
- validateConfig(config);
52
- const console = createLogger(config.logLevel);
53
- const kickSegment = await kickMainLoop(ctx, "enqueue", config);
49
+ const globals = await getOrUpdateGlobals(ctx, config);
50
+ const console = createLogger(globals.logLevel);
51
+ const kickSegment = await kickMainLoop(ctx, "enqueue", globals);
54
52
  return await enqueueHandler(ctx, console, kickSegment, itemArgs);
55
53
  },
56
54
  });
@@ -73,29 +71,16 @@ async function enqueueHandler(
73
71
  return workId;
74
72
  }
75
73
 
76
- type Config = Infer<typeof config>;
77
- function validateConfig(config: Config) {
78
- if (config.maxParallelism > MAX_POSSIBLE_PARALLELISM) {
79
- throw new Error(`maxParallelism must be <= ${MAX_PARALLELISM_SOFT_LIMIT}`);
80
- } else if (config.maxParallelism > MAX_PARALLELISM_SOFT_LIMIT) {
81
- createLogger(config.logLevel).warn(
82
- `maxParallelism should be <= ${MAX_PARALLELISM_SOFT_LIMIT}, but is set to ${config.maxParallelism}. This will be an error in a future version.`,
83
- );
84
- } else if (config.maxParallelism < 1) {
85
- throw new Error("maxParallelism must be >= 1");
86
- }
87
- }
88
-
89
74
  export const enqueueBatch = mutation({
90
75
  args: {
91
76
  items: v.array(v.object(itemArgs)),
92
- config,
77
+ config: vConfig.partial(),
93
78
  },
94
79
  returns: v.array(v.id("work")),
95
80
  handler: async (ctx, { config, items }) => {
96
- validateConfig(config);
97
- const console = createLogger(config.logLevel);
98
- const kickSegment = await kickMainLoop(ctx, "enqueue", config);
81
+ const globals = await getOrUpdateGlobals(ctx, config);
82
+ const console = createLogger(globals.logLevel);
83
+ const kickSegment = await kickMainLoop(ctx, "enqueue", globals);
99
84
  return Promise.all(
100
85
  items.map((item) => enqueueHandler(ctx, console, kickSegment, item)),
101
86
  );
@@ -105,12 +90,13 @@ export const enqueueBatch = mutation({
105
90
  export const cancel = mutation({
106
91
  args: {
107
92
  id: v.id("work"),
108
- logLevel,
93
+ logLevel: v.optional(logLevel),
109
94
  },
110
95
  handler: async (ctx, { id, logLevel }) => {
111
- const shouldCancel = await shouldCancelWorkItem(ctx, id, logLevel);
96
+ const globals = await getOrUpdateGlobals(ctx, { logLevel });
97
+ const shouldCancel = await shouldCancelWorkItem(ctx, id, globals.logLevel);
112
98
  if (shouldCancel) {
113
- const segment = await kickMainLoop(ctx, "cancel", { logLevel });
99
+ const segment = await kickMainLoop(ctx, "cancel", globals);
114
100
  await ctx.db.insert("pendingCancelation", {
115
101
  workId: id,
116
102
  segment,
@@ -122,7 +108,7 @@ export const cancel = mutation({
122
108
  const PAGE_SIZE = 64;
123
109
  export const cancelAll = mutation({
124
110
  args: {
125
- logLevel,
111
+ logLevel: v.optional(logLevel),
126
112
  before: v.optional(v.number()),
127
113
  limit: v.optional(v.number()),
128
114
  },
@@ -134,14 +120,15 @@ export const cancelAll = mutation({
134
120
  .withIndex("by_creation_time", (q) => q.lte("_creationTime", beforeTime))
135
121
  .order("desc")
136
122
  .take(pageSize);
123
+ const globals = await getOrUpdateGlobals(ctx, { logLevel });
137
124
  const shouldCancel = await Promise.all(
138
125
  pageOfWork.map(async ({ _id }) =>
139
- shouldCancelWorkItem(ctx, _id, logLevel),
126
+ shouldCancelWorkItem(ctx, _id, globals.logLevel),
140
127
  ),
141
128
  );
142
129
  let segment = getNextSegment();
143
130
  if (shouldCancel.some((c) => c)) {
144
- segment = await kickMainLoop(ctx, "cancel", { logLevel });
131
+ segment = await kickMainLoop(ctx, "cancel", globals);
145
132
  }
146
133
  await Promise.all(
147
134
  pageOfWork.map(({ _id }, index) => {
@@ -23,7 +23,7 @@ import {
23
23
  } from "./shared.js";
24
24
  import { generateReport, recordCompleted, recordStarted } from "./stats.js";
25
25
 
26
- const CANCELATION_BATCH_SIZE = 64; // the only queue that can get unbounded.
26
+ const CANCELLATION_BATCH_SIZE = 64; // the only queue that can get unbounded.
27
27
  const SECOND = 1000;
28
28
  const MINUTE = 60 * SECOND;
29
29
  const RECOVERY_THRESHOLD_MS = 5 * MINUTE; // attempt to recover jobs this old.
@@ -75,7 +75,7 @@ export const main = internalMutation({
75
75
 
76
76
  // Read pendingCancelation, deleting from pendingStart. If it's still running, queue to cancel.
77
77
  console.time("[main] pendingCancelation");
78
- await handleCancelation(ctx, state, segment, console, toCancel, globals);
78
+ await handleCancelation(ctx, state, segment, console, toCancel);
79
79
  console.timeEnd("[main] pendingCancelation");
80
80
 
81
81
  if (state.running.length === 0) {
@@ -415,7 +415,6 @@ async function handleCancelation(
415
415
  segment: bigint,
416
416
  console: Logger,
417
417
  toCancel: CompleteJob[],
418
- { cancelationBatchSize }: Config,
419
418
  ) {
420
419
  const start = state.segmentCursors.cancelation - CURSOR_BUFFER_SEGMENTS;
421
420
  const canceled = await ctx.db
@@ -423,7 +422,7 @@ async function handleCancelation(
423
422
  .withIndex("segment", (q) =>
424
423
  q.gte("segment", start).lte("segment", segment),
425
424
  )
426
- .take(cancelationBatchSize ?? CANCELATION_BATCH_SIZE);
425
+ .take(CANCELLATION_BATCH_SIZE);
427
426
  state.segmentCursors.cancelation = canceled.at(-1)?.segment ?? segment;
428
427
  if (canceled.length) {
429
428
  console.debug(`[main] attempting to cancel ${canceled.length}`);
@@ -562,7 +561,12 @@ async function beginWork(
562
561
  throw new Error("work not found");
563
562
  }
564
563
  recordStarted(console, work, lagMs);
565
- const { attempts: attempt, fnHandle, fnArgs } = work;
564
+ let fnArgs = work.fnArgs;
565
+ if (fnArgs === undefined && work.payloadId) {
566
+ const payload = await ctx.db.get(work.payloadId);
567
+ fnArgs = payload?.args ?? {};
568
+ }
569
+ const { attempts: attempt, fnHandle } = work;
566
570
  const args = { workId, fnHandle, fnArgs, logLevel, attempt };
567
571
  if (work.fnType === "action") {
568
572
  return ctx.scheduler.runAfter(0, internal.worker.runActionWrapper, args);
@@ -1,5 +1,12 @@
1
1
  import { convexTest } from "convex-test";
2
- import type { WithoutSystemFields } from "convex/server";
2
+ import type {
3
+ DocumentByName,
4
+ GenericDatabaseReader,
5
+ GenericDataModel,
6
+ SystemDataModel,
7
+ SystemTableNames,
8
+ WithoutSystemFields,
9
+ } from "convex/server";
3
10
  import {
4
11
  afterEach,
5
12
  assert,
@@ -14,8 +21,7 @@ import type { Doc, Id } from "./_generated/dataModel.js";
14
21
  import type { MutationCtx } from "./_generated/server.js";
15
22
  import { recoveryHandler } from "./recovery.js";
16
23
  import schema from "./schema.js";
17
-
18
- const modules = import.meta.glob("./**/*.ts");
24
+ import { modules } from "./setup.test.js";
19
25
 
20
26
  describe("recovery", () => {
21
27
  async function setupTest() {
@@ -196,13 +202,7 @@ describe("recovery", () => {
196
202
  // Run recovery with mocked system.get
197
203
  await t.run(async (ctx) => {
198
204
  // 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
- };
205
+ ctx.db.system.get = patchedSystemGet(ctx.db, { [scheduledId]: null });
206
206
 
207
207
  await recoveryHandler(ctx, {
208
208
  jobs: [
@@ -244,31 +244,27 @@ describe("recovery", () => {
244
244
  // Run recovery with mocked failed state
245
245
  await t.run(async (ctx) => {
246
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",
247
+ ctx.db.system.get = patchedSystemGet(ctx.db, {
248
+ [scheduledId]: {
249
+ _id: scheduledId,
250
+ _creationTime: Date.now(),
251
+ name: "internal/worker.runActionWrapper",
252
+ args: [
253
+ {
254
+ workId,
255
+ fnHandle: "test_handle",
256
+ fnArgs: {},
257
+ logLevel: "WARN",
258
+ attempt: 0,
267
259
  },
268
- };
269
- }
270
- return await originalGet(id);
271
- };
260
+ ],
261
+ scheduledTime: Date.now(),
262
+ state: {
263
+ kind: "failed",
264
+ error: "Function execution failed",
265
+ },
266
+ },
267
+ });
272
268
 
273
269
  await recoveryHandler(ctx, {
274
270
  jobs: [
@@ -310,30 +306,26 @@ describe("recovery", () => {
310
306
  // Run recovery with mocked system.get
311
307
  await t.run(async (ctx) => {
312
308
  // 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",
309
+ ctx.db.system.get = patchedSystemGet(ctx.db, {
310
+ [scheduledId]: {
311
+ _id: scheduledId,
312
+ _creationTime: Date.now(),
313
+ name: "internal/worker.runActionWrapper",
314
+ args: [
315
+ {
316
+ workId,
317
+ fnHandle: "test_handle",
318
+ fnArgs: {},
319
+ logLevel: "WARN",
320
+ attempt: 0,
332
321
  },
333
- };
334
- }
335
- return await originalGet(id);
336
- };
322
+ ],
323
+ scheduledTime: Date.now(),
324
+ state: {
325
+ kind: "canceled",
326
+ },
327
+ },
328
+ });
337
329
 
338
330
  await recoveryHandler(ctx, {
339
331
  jobs: [
@@ -379,50 +371,45 @@ describe("recovery", () => {
379
371
  // Run recovery with mocked system.get
380
372
  await t.run(async (ctx) => {
381
373
  // 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",
374
+ ctx.db.system.get = patchedSystemGet(ctx.db, {
375
+ [scheduledId1]: {
376
+ _id: scheduledId1,
377
+ _creationTime: Date.now(),
378
+ name: "internal/worker.runActionWrapper",
379
+ args: [
380
+ {
381
+ workId: workId1,
382
+ fnHandle: "test_handle",
383
+ fnArgs: { test: 1 },
384
+ logLevel: "WARN",
385
+ attempt: 0,
402
386
  },
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",
387
+ ],
388
+ scheduledTime: Date.now(),
389
+ state: {
390
+ kind: "failed",
391
+ error: "Function 1 failed",
392
+ },
393
+ },
394
+ [scheduledId2]: {
395
+ _id: scheduledId2,
396
+ _creationTime: Date.now(),
397
+ name: "internal/worker.runActionWrapper",
398
+ args: [
399
+ {
400
+ workId: workId2,
401
+ fnHandle: "test_handle",
402
+ fnArgs: { test: 2 },
403
+ logLevel: "WARN",
404
+ attempt: 0,
421
405
  },
422
- };
423
- }
424
- return await originalGet(id);
425
- };
406
+ ],
407
+ scheduledTime: Date.now(),
408
+ state: {
409
+ kind: "canceled",
410
+ },
411
+ },
412
+ });
426
413
 
427
414
  await recoveryHandler(ctx, {
428
415
  jobs: [
@@ -486,30 +473,26 @@ describe("recovery", () => {
486
473
  // Run recovery with mocked system.get
487
474
  await t.run(async (ctx) => {
488
475
  // 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",
476
+ ctx.db.system.get = patchedSystemGet(ctx.db, {
477
+ [scheduledId]: {
478
+ _id: scheduledId,
479
+ _creationTime: Date.now(),
480
+ name: "internal/worker.runActionWrapper",
481
+ args: [
482
+ {
483
+ workId,
484
+ fnHandle: "test_handle",
485
+ fnArgs: {},
486
+ logLevel: "WARN",
487
+ attempt: 0,
508
488
  },
509
- };
510
- }
511
- return await originalGet(id);
512
- };
489
+ ],
490
+ scheduledTime: Date.now(),
491
+ state: {
492
+ kind: "pending",
493
+ },
494
+ },
495
+ });
513
496
 
514
497
  await recoveryHandler(ctx, {
515
498
  jobs: [
@@ -534,3 +517,20 @@ describe("recovery", () => {
534
517
  });
535
518
  });
536
519
  });
520
+
521
+ function patchedSystemGet(
522
+ db: GenericDatabaseReader<GenericDataModel>,
523
+ overrides: Record<
524
+ string,
525
+ DocumentByName<SystemDataModel, "_scheduled_functions"> | null
526
+ >,
527
+ ) {
528
+ const originalGet = db.system.get;
529
+ return async (
530
+ tableOrId: SystemTableNames | Id<SystemTableNames>,
531
+ maybeId?: Id<SystemTableNames>,
532
+ ) => {
533
+ const id = (maybeId ?? tableOrId) as Id<"_scheduled_functions">;
534
+ return id in overrides ? overrides[id] : await originalGet(id);
535
+ };
536
+ }
@@ -2,10 +2,10 @@ import { defineSchema, defineTable } from "convex/server";
2
2
  import { v } from "convex/values";
3
3
  import {
4
4
  fnType,
5
- config,
6
- onComplete,
5
+ vConfig,
6
+ vOnCompleteFnContext,
7
7
  retryBehavior,
8
- vResultValidator,
8
+ vResult,
9
9
  } from "./shared.js";
10
10
 
11
11
  // Represents a slice of time to process work.
@@ -13,7 +13,7 @@ const segment = v.int64();
13
13
 
14
14
  export default defineSchema({
15
15
  // Written from kickLoop, read everywhere.
16
- globals: defineTable(config),
16
+ globals: defineTable(vConfig),
17
17
  // Singleton, only read & written by `main`.
18
18
  internalState: defineTable({
19
19
  // Ensure that only one main is running at a time.
@@ -62,9 +62,12 @@ export default defineSchema({
62
62
  fnType,
63
63
  fnHandle: v.string(),
64
64
  fnName: v.string(),
65
- fnArgs: v.any(),
65
+ fnArgs: v.optional(v.any()),
66
+ // Reference to large args/onComplete context if stored separately
67
+ payloadId: v.optional(v.id("payload")),
68
+ payloadSize: v.optional(v.number()),
66
69
  attempts: v.number(), // number of completed attempts
67
- onComplete: v.optional(onComplete),
70
+ onComplete: v.optional(vOnCompleteFnContext),
68
71
  retryBehavior: v.optional(retryBehavior),
69
72
  canceled: v.optional(v.boolean()),
70
73
  }),
@@ -80,7 +83,7 @@ export default defineSchema({
80
83
  // Written by complete, read & deleted by `main`.
81
84
  pendingCompletion: defineTable({
82
85
  segment,
83
- runResult: vResultValidator,
86
+ runResult: vResult,
84
87
  workId: v.id("work"),
85
88
  retry: v.boolean(),
86
89
  })
@@ -94,4 +97,10 @@ export default defineSchema({
94
97
  })
95
98
  .index("workId", ["workId"])
96
99
  .index("segment", ["segment"]),
100
+
101
+ // Store large data separately to avoid document size limits
102
+ payload: defineTable({
103
+ args: v.optional(v.record(v.string(), v.any())),
104
+ context: v.optional(v.any()),
105
+ }),
97
106
  });
@@ -33,12 +33,11 @@ export function fromSegment(segment: bigint): number {
33
33
  return Number(segment) * SEGMENT_MS;
34
34
  }
35
35
 
36
- export const config = v.object({
36
+ export const vConfig = v.object({
37
37
  maxParallelism: v.number(),
38
38
  logLevel,
39
- cancelationBatchSize: v.optional(v.number()),
40
39
  });
41
- export type Config = Infer<typeof config>;
40
+ export type Config = Infer<typeof vConfig>;
42
41
 
43
42
  export const retryBehavior = v.object({
44
43
  maxAttempts: v.number(),
@@ -71,7 +70,7 @@ export const DEFAULT_RETRY_BEHAVIOR: RetryBehavior = {
71
70
  // This ensures that the type satisfies the schema.
72
71
  const _ = {} as RetryBehavior satisfies Infer<typeof retryBehavior>;
73
72
 
74
- export const vResultValidator = v.union(
73
+ export const vResult = v.union(
75
74
  v.object({
76
75
  kind: v.literal("success"),
77
76
  returnValue: v.any(),
@@ -84,13 +83,12 @@ export const vResultValidator = v.union(
84
83
  kind: v.literal("canceled"),
85
84
  }),
86
85
  );
87
- export type RunResult = Infer<typeof vResultValidator>;
86
+ export type RunResult = Infer<typeof vResult>;
88
87
 
89
- export const onComplete = v.object({
88
+ export const vOnCompleteFnContext = v.object({
90
89
  fnHandle: v.string(), // mutation
91
90
  context: v.optional(v.any()),
92
91
  });
93
- export type OnComplete = Infer<typeof onComplete>;
94
92
 
95
93
  export type OnCompleteArgs = {
96
94
  /**
@@ -190,7 +190,7 @@ describe("stats", () => {
190
190
  });
191
191
 
192
192
  // Create more pending start items than maxParallelism
193
- const maxParallelism = 5;
193
+ const maxParallelism = 50;
194
194
 
195
195
  // Create maxParallelism + 1 work items to trigger pagination
196
196
  for (let i = 0; i < maxParallelism + 1; i++) {
@@ -81,7 +81,7 @@ export async function generateReport(
81
81
  .lt("segment", currentSegment),
82
82
  )
83
83
  .paginate({
84
- numItems: maxParallelism,
84
+ numItems: Math.max(maxParallelism, 10),
85
85
  cursor: null,
86
86
  });
87
87
  if (pendingStart.isDone) {