@convex-dev/workpool 0.2.0 → 0.2.1

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 (87) hide show
  1. package/README.md +81 -3
  2. package/dist/commonjs/client/index.d.ts +30 -5
  3. package/dist/commonjs/client/index.d.ts.map +1 -1
  4. package/dist/commonjs/client/index.js +27 -2
  5. package/dist/commonjs/client/index.js.map +1 -1
  6. package/dist/commonjs/component/complete.d.ts.map +1 -1
  7. package/dist/commonjs/component/complete.js +9 -7
  8. package/dist/commonjs/component/complete.js.map +1 -1
  9. package/dist/commonjs/component/kick.d.ts +3 -2
  10. package/dist/commonjs/component/kick.d.ts.map +1 -1
  11. package/dist/commonjs/component/kick.js +12 -9
  12. package/dist/commonjs/component/kick.js.map +1 -1
  13. package/dist/commonjs/component/lib.d.ts +3 -3
  14. package/dist/commonjs/component/lib.d.ts.map +1 -1
  15. package/dist/commonjs/component/lib.js +25 -19
  16. package/dist/commonjs/component/lib.js.map +1 -1
  17. package/dist/commonjs/component/logging.d.ts +3 -2
  18. package/dist/commonjs/component/logging.d.ts.map +1 -1
  19. package/dist/commonjs/component/logging.js +34 -15
  20. package/dist/commonjs/component/logging.js.map +1 -1
  21. package/dist/commonjs/component/loop.js +10 -10
  22. package/dist/commonjs/component/loop.js.map +1 -1
  23. package/dist/commonjs/component/recovery.d.ts +29 -0
  24. package/dist/commonjs/component/recovery.d.ts.map +1 -1
  25. package/dist/commonjs/component/recovery.js +69 -66
  26. package/dist/commonjs/component/recovery.js.map +1 -1
  27. package/dist/commonjs/component/schema.d.ts +11 -11
  28. package/dist/commonjs/component/shared.d.ts +4 -4
  29. package/dist/commonjs/component/shared.d.ts.map +1 -1
  30. package/dist/commonjs/component/shared.js +2 -2
  31. package/dist/commonjs/component/shared.js.map +1 -1
  32. package/dist/commonjs/component/stats.d.ts +20 -21
  33. package/dist/commonjs/component/stats.d.ts.map +1 -1
  34. package/dist/commonjs/component/stats.js +86 -38
  35. package/dist/commonjs/component/stats.js.map +1 -1
  36. package/dist/commonjs/component/worker.d.ts +2 -2
  37. package/dist/esm/client/index.d.ts +30 -5
  38. package/dist/esm/client/index.d.ts.map +1 -1
  39. package/dist/esm/client/index.js +27 -2
  40. package/dist/esm/client/index.js.map +1 -1
  41. package/dist/esm/component/complete.d.ts.map +1 -1
  42. package/dist/esm/component/complete.js +9 -7
  43. package/dist/esm/component/complete.js.map +1 -1
  44. package/dist/esm/component/kick.d.ts +3 -2
  45. package/dist/esm/component/kick.d.ts.map +1 -1
  46. package/dist/esm/component/kick.js +12 -9
  47. package/dist/esm/component/kick.js.map +1 -1
  48. package/dist/esm/component/lib.d.ts +3 -3
  49. package/dist/esm/component/lib.d.ts.map +1 -1
  50. package/dist/esm/component/lib.js +25 -19
  51. package/dist/esm/component/lib.js.map +1 -1
  52. package/dist/esm/component/logging.d.ts +3 -2
  53. package/dist/esm/component/logging.d.ts.map +1 -1
  54. package/dist/esm/component/logging.js +34 -15
  55. package/dist/esm/component/logging.js.map +1 -1
  56. package/dist/esm/component/loop.js +10 -10
  57. package/dist/esm/component/loop.js.map +1 -1
  58. package/dist/esm/component/recovery.d.ts +29 -0
  59. package/dist/esm/component/recovery.d.ts.map +1 -1
  60. package/dist/esm/component/recovery.js +69 -66
  61. package/dist/esm/component/recovery.js.map +1 -1
  62. package/dist/esm/component/schema.d.ts +11 -11
  63. package/dist/esm/component/shared.d.ts +4 -4
  64. package/dist/esm/component/shared.d.ts.map +1 -1
  65. package/dist/esm/component/shared.js +2 -2
  66. package/dist/esm/component/shared.js.map +1 -1
  67. package/dist/esm/component/stats.d.ts +20 -21
  68. package/dist/esm/component/stats.d.ts.map +1 -1
  69. package/dist/esm/component/stats.js +86 -38
  70. package/dist/esm/component/stats.js.map +1 -1
  71. package/dist/esm/component/worker.d.ts +2 -2
  72. package/package.json +6 -7
  73. package/src/client/index.ts +64 -35
  74. package/src/component/_generated/api.d.ts +6 -6
  75. package/src/component/complete.ts +18 -7
  76. package/src/component/kick.test.ts +17 -7
  77. package/src/component/kick.ts +14 -11
  78. package/src/component/lib.ts +33 -26
  79. package/src/component/logging.test.ts +16 -0
  80. package/src/component/logging.ts +45 -23
  81. package/src/component/loop.test.ts +12 -12
  82. package/src/component/loop.ts +11 -11
  83. package/src/component/recovery.test.ts +6 -11
  84. package/src/component/recovery.ts +77 -69
  85. package/src/component/shared.ts +2 -2
  86. package/src/component/stats.test.ts +345 -0
  87. package/src/component/stats.ts +111 -41
@@ -1,9 +1,10 @@
1
1
  import { FunctionHandle } from "convex/server";
2
2
  import { Infer, v } from "convex/values";
3
+ import { Id } from "./_generated/dataModel.js";
3
4
  import { internalMutation, MutationCtx } from "./_generated/server.js";
4
5
  import { kickMainLoop } from "./kick.js";
5
6
  import { createLogger } from "./logging.js";
6
- import { nextSegment, OnCompleteArgs, runResult } from "./shared.js";
7
+ import { OnCompleteArgs, RunResult, runResult } from "./shared.js";
7
8
  import { recordCompleted } from "./stats.js";
8
9
 
9
10
  export type CompleteJob = Infer<typeof completeArgs.fields.jobs.element>;
@@ -23,7 +24,11 @@ export async function completeHandler(
23
24
  ) {
24
25
  const globals = await ctx.db.query("globals").unique();
25
26
  const console = createLogger(globals?.logLevel);
26
- let anyPendingCompletions = false;
27
+ const pendingCompletions: {
28
+ runResult: RunResult;
29
+ workId: Id<"work">;
30
+ retry: boolean;
31
+ }[] = [];
27
32
  await Promise.all(
28
33
  args.jobs.map(async (job) => {
29
34
  const work = await ctx.db.get(job.workId);
@@ -77,18 +82,24 @@ export async function completeHandler(
77
82
  await ctx.db.delete(job.workId);
78
83
  }
79
84
  if (job.runResult.kind !== "canceled") {
80
- await ctx.db.insert("pendingCompletion", {
85
+ pendingCompletions.push({
81
86
  runResult: job.runResult,
82
87
  workId: job.workId,
83
- segment: nextSegment(),
84
88
  retry,
85
89
  });
86
- anyPendingCompletions = true;
87
90
  }
88
91
  })
89
92
  );
90
- if (anyPendingCompletions) {
91
- await kickMainLoop(ctx, "complete");
93
+ if (pendingCompletions.length > 0) {
94
+ const segment = await kickMainLoop(ctx, "complete");
95
+ await Promise.all(
96
+ pendingCompletions.map((completion) =>
97
+ ctx.db.insert("pendingCompletion", {
98
+ ...completion,
99
+ segment,
100
+ })
101
+ )
102
+ );
92
103
  }
93
104
  }
94
105
 
@@ -14,7 +14,12 @@ import { kickMainLoop } from "./kick.js";
14
14
  import { DEFAULT_LOG_LEVEL } from "./logging.js";
15
15
  import schema from "./schema.js";
16
16
  import { modules } from "./setup.test.js";
17
- import { fromSegment, nextSegment, toSegment } from "./shared";
17
+ import {
18
+ fromSegment,
19
+ getCurrentSegment,
20
+ getNextSegment,
21
+ toSegment,
22
+ } from "./shared";
18
23
  import { DEFAULT_MAX_PARALLELISM } from "./shared.js";
19
24
 
20
25
  describe("kickMainLoop", () => {
@@ -74,11 +79,12 @@ describe("kickMainLoop", () => {
74
79
  expect(runStatus.state.kind).toBe("running");
75
80
 
76
81
  // Second kick should not change state
77
- await kickMainLoop(ctx, "enqueue");
82
+ const segment = await kickMainLoop(ctx, "enqueue");
78
83
  const afterStatus = await ctx.db.query("runStatus").unique();
79
84
  assert(afterStatus);
80
85
  expect(afterStatus.state.kind).toBe("running");
81
86
  expect(afterStatus._id).toBe(runStatus._id);
87
+ expect(segment).toBe(getNextSegment());
82
88
  });
83
89
  });
84
90
 
@@ -115,7 +121,8 @@ describe("kickMainLoop", () => {
115
121
  });
116
122
 
117
123
  // Kick should reschedule to run sooner
118
- await kickMainLoop(ctx, "enqueue");
124
+ const segment = await kickMainLoop(ctx, "enqueue");
125
+ expect(segment).toBe(getCurrentSegment());
119
126
 
120
127
  const afterStatus = await ctx.db.query("runStatus").unique();
121
128
  assert(afterStatus);
@@ -156,7 +163,8 @@ describe("kickMainLoop", () => {
156
163
  });
157
164
 
158
165
  // Kick should not change state when saturated
159
- await kickMainLoop(ctx, "enqueue");
166
+ const segment = await kickMainLoop(ctx, "enqueue");
167
+ expect(segment).toBe(getNextSegment());
160
168
  const afterStatus = await ctx.db.query("runStatus").unique();
161
169
  assert(afterStatus);
162
170
  expect(afterStatus.state.kind).toBe("scheduled");
@@ -209,13 +217,15 @@ describe("kickMainLoop", () => {
209
217
  test("handles race conditions between multiple kicks", async () => {
210
218
  const t = convexTest(schema, modules);
211
219
  // Run kicks in separate transactions to simulate concurrent access
212
- await Promise.all(
220
+ const segments = await Promise.all(
213
221
  Array.from({ length: 10 }, () =>
214
222
  t.run(async (ctx) => {
215
- await kickMainLoop(ctx, "enqueue");
223
+ const segment = await kickMainLoop(ctx, "enqueue");
224
+ return segment;
216
225
  })
217
226
  )
218
227
  );
228
+ expect(segments.filter((s) => s === getCurrentSegment())).toHaveLength(1);
219
229
 
220
230
  // Check final state in a new transaction
221
231
  await t.run(async (ctx) => {
@@ -265,7 +275,7 @@ describe("kickMainLoop", () => {
265
275
  await kickMainLoop(ctx, "enqueue");
266
276
  const runStatus = await ctx.db.query("runStatus").unique();
267
277
  assert(runStatus);
268
- const segment = nextSegment() + 10n;
278
+ const segment = getNextSegment() + 10n;
269
279
  await ctx.db.patch(runStatus._id, {
270
280
  state: {
271
281
  generation: 0n,
@@ -7,43 +7,44 @@ import {
7
7
  Config,
8
8
  DEFAULT_MAX_PARALLELISM,
9
9
  fromSegment,
10
- nextSegment,
10
+ getCurrentSegment,
11
+ getNextSegment,
11
12
  } from "./shared.js";
12
13
 
13
14
  /**
14
- * Called from outside the loop:
15
+ * Called from outside the loop.
16
+ * Returns the soonest segment to enqueue work for the main loop.
15
17
  */
16
-
17
18
  export async function kickMainLoop(
18
19
  ctx: MutationCtx,
19
20
  source: "enqueue" | "cancel" | "complete",
20
21
  config?: Partial<Config>
21
- ): Promise<void> {
22
+ ): Promise<bigint> {
22
23
  const globals = await getOrUpdateGlobals(ctx, config);
23
24
  const console = createLogger(globals.logLevel);
24
25
  const runStatus = await getOrCreateRunStatus(ctx);
26
+ const next = getNextSegment();
25
27
 
26
28
  // Only kick to run now if we're scheduled or idle.
27
29
  if (runStatus.state.kind === "running") {
28
30
  console.debug(
29
31
  `[${source}] main is actively running, so we don't need to kick it`
30
32
  );
31
- return;
33
+ return next;
32
34
  }
33
- const segment = nextSegment();
34
35
  // main is scheduled to run later, so we should cancel it and reschedule.
35
36
  if (runStatus.state.kind === "scheduled") {
36
37
  if (source === "enqueue" && runStatus.state.saturated) {
37
38
  console.debug(
38
39
  `[${source}] main is saturated, so we don't need to kick it`
39
40
  );
40
- return;
41
+ return next;
41
42
  }
42
- if (runStatus.state.segment <= segment) {
43
+ if (runStatus.state.segment <= next) {
43
44
  console.debug(
44
45
  `[${source}] main is scheduled to run soon enough, so we don't need to kick it`
45
46
  );
46
- return;
47
+ return next;
47
48
  }
48
49
  console.debug(
49
50
  `[${source}] main is scheduled to run later, so reschedule it to run now`
@@ -60,11 +61,13 @@ export async function kickMainLoop(
60
61
  console.debug(`[${source}] main was idle, so run it now`);
61
62
  }
62
63
  await ctx.db.patch(runStatus._id, { state: { kind: "running" } });
63
- const scheduledTime = boundScheduledTime(fromSegment(segment), console);
64
+ const current = getCurrentSegment();
65
+ const scheduledTime = boundScheduledTime(fromSegment(current), console);
64
66
  await ctx.scheduler.runAt(scheduledTime, internal.loop.main, {
65
67
  generation: runStatus.state.generation,
66
- segment,
68
+ segment: current,
67
69
  });
70
+ return current;
68
71
  }
69
72
 
70
73
  export const forceKick = internalMutation({
@@ -1,20 +1,19 @@
1
1
  import { v } from "convex/values";
2
+ import { api } from "./_generated/api.js";
3
+ import { Id } from "./_generated/dataModel.js";
2
4
  import { mutation, MutationCtx, query } from "./_generated/server.js";
5
+ import { kickMainLoop } from "./kick.js";
6
+ import { createLogger, LogLevel, logLevel } from "./logging.js";
3
7
  import {
4
- nextSegment,
8
+ boundScheduledTime,
9
+ config,
10
+ getNextSegment,
11
+ max,
5
12
  onComplete,
6
13
  retryBehavior,
7
- config,
8
14
  status as statusValidator,
9
15
  toSegment,
10
- boundScheduledTime,
11
- max,
12
16
  } from "./shared.js";
13
- import { LogLevel, logLevel } from "./logging.js";
14
- import { kickMainLoop } from "./kick.js";
15
- import { api } from "./_generated/api.js";
16
- import { createLogger } from "./logging.js";
17
- import { Id } from "./_generated/dataModel.js";
18
17
  import { recordEnqueued } from "./stats.js";
19
18
 
20
19
  const MAX_POSSIBLE_PARALLELISM = 100;
@@ -45,11 +44,11 @@ export const enqueue = mutation({
45
44
  ...workArgs,
46
45
  attempts: 0,
47
46
  });
47
+ const limit = await kickMainLoop(ctx, "enqueue", config);
48
48
  await ctx.db.insert("pendingStart", {
49
49
  workId,
50
- segment: max(toSegment(runAt), nextSegment()),
50
+ segment: max(toSegment(runAt), limit),
51
51
  });
52
- await kickMainLoop(ctx, "enqueue", config);
53
52
  recordEnqueued(console, { workId, fnName: workArgs.fnName, runAt });
54
53
  return workId;
55
54
  },
@@ -61,11 +60,14 @@ export const cancel = mutation({
61
60
  logLevel,
62
61
  },
63
62
  handler: async (ctx, { id, logLevel }) => {
64
- const canceled = await cancelWorkItem(ctx, id, nextSegment(), logLevel);
65
- if (canceled) {
66
- await kickMainLoop(ctx, "cancel", { logLevel });
63
+ const shouldCancel = await shouldCancelWorkItem(ctx, id, logLevel);
64
+ if (shouldCancel) {
65
+ const segment = await kickMainLoop(ctx, "cancel", { logLevel });
66
+ await ctx.db.insert("pendingCancelation", {
67
+ workId: id,
68
+ segment,
69
+ });
67
70
  }
68
- // TODO: stats event
69
71
  },
70
72
  });
71
73
 
@@ -74,20 +76,30 @@ export const cancelAll = mutation({
74
76
  args: { logLevel, before: v.optional(v.number()) },
75
77
  handler: async (ctx, { logLevel, before }) => {
76
78
  const beforeTime = before ?? Date.now();
77
- const segment = nextSegment();
78
79
  const pageOfWork = await ctx.db
79
80
  .query("work")
80
81
  .withIndex("by_creation_time", (q) => q.lte("_creationTime", beforeTime))
81
82
  .order("desc")
82
83
  .take(PAGE_SIZE);
83
- const canceled = await Promise.all(
84
+ const shouldCancel = await Promise.all(
84
85
  pageOfWork.map(async ({ _id }) =>
85
- cancelWorkItem(ctx, _id, segment, logLevel)
86
+ shouldCancelWorkItem(ctx, _id, logLevel)
86
87
  )
87
88
  );
88
- if (canceled.some((c) => c)) {
89
- await kickMainLoop(ctx, "cancel", { logLevel });
89
+ let segment = getNextSegment();
90
+ if (shouldCancel.some((c) => c)) {
91
+ segment = await kickMainLoop(ctx, "cancel", { logLevel });
90
92
  }
93
+ await Promise.all(
94
+ pageOfWork.map(({ _id }, index) => {
95
+ if (shouldCancel[index]) {
96
+ return ctx.db.insert("pendingCancelation", {
97
+ workId: _id,
98
+ segment,
99
+ });
100
+ }
101
+ })
102
+ );
91
103
  if (pageOfWork.length === PAGE_SIZE) {
92
104
  await ctx.scheduler.runAfter(0, api.lib.cancelAll, {
93
105
  logLevel,
@@ -124,10 +136,9 @@ export const status = query({
124
136
  },
125
137
  });
126
138
 
127
- async function cancelWorkItem(
139
+ async function shouldCancelWorkItem(
128
140
  ctx: MutationCtx,
129
141
  workId: Id<"work">,
130
- segment: bigint,
131
142
  logLevel: LogLevel
132
143
  ) {
133
144
  const console = createLogger(logLevel);
@@ -145,10 +156,6 @@ async function cancelWorkItem(
145
156
  console.warn(`[cancel] work ${workId} has already been canceled`);
146
157
  return false;
147
158
  }
148
- await ctx.db.insert("pendingCancelation", {
149
- workId,
150
- segment,
151
- });
152
159
  return true;
153
160
  }
154
161
 
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { shouldLog } from "./logging";
3
+
4
+ describe("logging", () => {
5
+ describe("shouldLog", () => {
6
+ it("should return true if the log level is above the config level", () => {
7
+ expect(shouldLog("INFO", "DEBUG")).toBe(false);
8
+ });
9
+ it("should return false if the log level is below the config level", () => {
10
+ expect(shouldLog("INFO", "WARN")).toBe(true);
11
+ });
12
+ it("should return true if the log level is equal to the config level", () => {
13
+ expect(shouldLog("INFO", "INFO")).toBe(true);
14
+ });
15
+ });
16
+ });
@@ -1,6 +1,17 @@
1
1
  import { v, Infer } from "convex/values";
2
2
 
3
- export const DEFAULT_LOG_LEVEL: LogLevel = "WARN";
3
+ export const DEFAULT_LOG_LEVEL: LogLevel = "REPORT";
4
+
5
+ // NOTE: the ordering here is important! A config level of "INFO" will log
6
+ // "INFO", "REPORT", "WARN",and "ERROR" events.
7
+ export const logLevel = v.union(
8
+ v.literal("DEBUG"),
9
+ v.literal("INFO"),
10
+ v.literal("REPORT"),
11
+ v.literal("WARN"),
12
+ v.literal("ERROR")
13
+ );
14
+ export type LogLevel = Infer<typeof logLevel>;
4
15
 
5
16
  export type Logger = {
6
17
  debug: (...args: unknown[]) => void;
@@ -12,59 +23,70 @@ export type Logger = {
12
23
  event: (event: string, payload: Record<string, unknown>) => void;
13
24
  };
14
25
 
26
+ const logLevelOrder = logLevel.members.map((l) => l.value);
27
+ const logLevelByName = logLevelOrder.reduce(
28
+ (acc, l, i) => {
29
+ acc[l] = i;
30
+ return acc;
31
+ },
32
+ {} as Record<LogLevel, number>
33
+ );
34
+ export function shouldLog(config: LogLevel, level: LogLevel) {
35
+ return logLevelByName[config] <= logLevelByName[level];
36
+ }
37
+ const DEBUG = logLevelByName["DEBUG"];
38
+ const INFO = logLevelByName["INFO"];
39
+ const REPORT = logLevelByName["REPORT"];
40
+ const WARN = logLevelByName["WARN"];
41
+ const ERROR = logLevelByName["ERROR"];
42
+
15
43
  export function createLogger(level?: LogLevel): Logger {
16
- const levelIndex = ["DEBUG", "INFO", "WARN", "ERROR"].indexOf(
17
- level ?? DEFAULT_LOG_LEVEL
18
- );
19
- if (levelIndex === -1) {
44
+ const levelIndex = logLevelByName[level ?? DEFAULT_LOG_LEVEL];
45
+ if (levelIndex === undefined) {
20
46
  throw new Error(`Invalid log level: ${level}`);
21
47
  }
22
48
  return {
23
49
  debug: (...args: unknown[]) => {
24
- if (levelIndex <= 0) {
50
+ if (levelIndex <= DEBUG) {
25
51
  console.debug(...args);
26
52
  }
27
53
  },
28
54
  info: (...args: unknown[]) => {
29
- if (levelIndex <= 1) {
55
+ if (levelIndex <= INFO) {
30
56
  console.info(...args);
31
57
  }
32
58
  },
33
59
  warn: (...args: unknown[]) => {
34
- if (levelIndex <= 2) {
60
+ if (levelIndex <= WARN) {
35
61
  console.warn(...args);
36
62
  }
37
63
  },
38
64
  error: (...args: unknown[]) => {
39
- if (levelIndex <= 3) {
65
+ if (levelIndex <= ERROR) {
40
66
  console.error(...args);
41
67
  }
42
68
  },
43
69
  time: (label: string) => {
44
- if (levelIndex <= 0) {
70
+ if (levelIndex <= DEBUG) {
45
71
  console.time(label);
46
72
  }
47
73
  },
48
74
  timeEnd: (label: string) => {
49
- if (levelIndex <= 0) {
75
+ if (levelIndex <= DEBUG) {
50
76
  console.timeEnd(label);
51
77
  }
52
78
  },
53
79
  event: (event: string, payload: Record<string, unknown>) => {
54
- if (levelIndex <= 1) {
55
- const fullPayload = {
56
- event,
57
- ...payload,
58
- };
80
+ const fullPayload = {
81
+ component: "workpool",
82
+ event,
83
+ ...payload,
84
+ };
85
+ if (levelIndex === REPORT && event === "report") {
86
+ console.info(JSON.stringify(fullPayload));
87
+ } else if (levelIndex <= INFO) {
59
88
  console.info(JSON.stringify(fullPayload));
60
89
  }
61
90
  },
62
91
  };
63
92
  }
64
- export const logLevel = v.union(
65
- v.literal("DEBUG"),
66
- v.literal("INFO"),
67
- v.literal("WARN"),
68
- v.literal("ERROR")
69
- );
70
- export type LogLevel = Infer<typeof logLevel>;
@@ -14,9 +14,9 @@ import { Doc, Id } from "./_generated/dataModel";
14
14
  import { MutationCtx } from "./_generated/server";
15
15
  import schema from "./schema";
16
16
  import {
17
- currentSegment,
18
17
  DEFAULT_MAX_PARALLELISM,
19
- nextSegment,
18
+ getCurrentSegment,
19
+ getNextSegment,
20
20
  toSegment,
21
21
  } from "./shared";
22
22
 
@@ -72,7 +72,7 @@ describe("loop", () => {
72
72
  await ctx.db.insert("internalState", {
73
73
  generation: 1n,
74
74
  segmentCursors: { incoming: 0n, completion: 0n, cancelation: 0n },
75
- lastRecovery: currentSegment(),
75
+ lastRecovery: getCurrentSegment(),
76
76
  report: {
77
77
  completed: 0,
78
78
  succeeded: 0,
@@ -281,7 +281,7 @@ describe("loop", () => {
281
281
  // Run main loop to process pendingCompletion -> pendingStart
282
282
  await t.mutation(internal.loop.main, {
283
283
  generation: 1n,
284
- segment: nextSegment(),
284
+ segment: getNextSegment(),
285
285
  });
286
286
 
287
287
  // Verify work is now in pendingStart for retry
@@ -364,13 +364,13 @@ describe("loop", () => {
364
364
  // Run main loop to process the work
365
365
  await t.mutation(internal.loop.main, {
366
366
  generation: 1n,
367
- segment: nextSegment(),
367
+ segment: getNextSegment(),
368
368
  });
369
369
 
370
370
  // Run updateRunStatus to transition to scheduled
371
371
  await t.mutation(internal.loop.updateRunStatus, {
372
372
  generation: 2n,
373
- segment: nextSegment(),
373
+ segment: getNextSegment(),
374
374
  });
375
375
 
376
376
  // Verify state transition to scheduled
@@ -387,7 +387,7 @@ describe("loop", () => {
387
387
  it("should transition from running to saturated when maxed out", async () => {
388
388
  // Setup initial running state with max capacity
389
389
  await setMaxParallelism(1);
390
- const segment = currentSegment();
390
+ const segment = getCurrentSegment();
391
391
  await t.run(async (ctx) => {
392
392
  // Create work item
393
393
  const workId = await makeDummyWork(ctx);
@@ -441,14 +441,14 @@ describe("loop", () => {
441
441
  const scheduledId = await ctx.scheduler.runAfter(
442
442
  1000,
443
443
  internal.loop.main,
444
- { generation: 1n, segment: nextSegment() + 10n }
444
+ { generation: 1n, segment: getNextSegment() + 10n }
445
445
  );
446
446
 
447
447
  // Create scheduled runStatus
448
448
  await ctx.db.insert("runStatus", {
449
449
  state: {
450
450
  kind: "scheduled",
451
- segment: nextSegment() + 10n,
451
+ segment: getNextSegment() + 10n,
452
452
  scheduledId,
453
453
  saturated: false,
454
454
  generation: 1n,
@@ -481,7 +481,7 @@ describe("loop", () => {
481
481
  });
482
482
 
483
483
  it("should transition from running to idle when all work is done", async () => {
484
- const segment = nextSegment();
484
+ const segment = getNextSegment();
485
485
  // Setup initial running state with work
486
486
  const workId = await t.run<Id<"work">>(async (ctx) => {
487
487
  // Create internal state
@@ -537,7 +537,7 @@ describe("loop", () => {
537
537
  });
538
538
  });
539
539
  it("should transition from scheduled to running when main loop runs", async () => {
540
- const segment = nextSegment();
540
+ const segment = getNextSegment();
541
541
  await t.run(async (ctx) => {
542
542
  await insertInternalState(ctx);
543
543
 
@@ -1037,7 +1037,7 @@ describe("loop", () => {
1037
1037
 
1038
1038
  it("should set saturated flag when at max capacity", async () => {
1039
1039
  // Setup state with running jobs at max capacity
1040
- const now = currentSegment();
1040
+ const now = getCurrentSegment();
1041
1041
  const later = now + 10n;
1042
1042
  await setMaxParallelism(10);
1043
1043
  await t.run(async (ctx) => {
@@ -13,15 +13,15 @@ import {
13
13
  import {
14
14
  boundScheduledTime,
15
15
  Config,
16
- currentSegment,
17
16
  DEFAULT_MAX_PARALLELISM,
18
17
  fromSegment,
18
+ getCurrentSegment,
19
+ getNextSegment,
19
20
  max,
20
- nextSegment,
21
21
  RunResult,
22
22
  toSegment,
23
23
  } from "./shared.js";
24
- import { recordCompleted, recordReport, recordStarted } from "./stats.js";
24
+ import { recordCompleted, generateReport, recordStarted } from "./stats.js";
25
25
 
26
26
  const CANCELLATION_BATCH_SIZE = 64; // the only queue that can get unbounded.
27
27
  const SECOND = 1000;
@@ -100,7 +100,7 @@ export const main = internalMutation({
100
100
  // It's been a while, let's start fresh.
101
101
  lastReportTs = Date.now();
102
102
  }
103
- recordReport(console, state);
103
+ await generateReport(ctx, console, state, globals);
104
104
  state.report = {
105
105
  completed: 0,
106
106
  succeeded: 0,
@@ -149,22 +149,22 @@ export const updateRunStatus = internalMutation({
149
149
 
150
150
  // TODO: check for current segment (or from args) first, to avoid OCCs.
151
151
  console.time("[updateRunStatus] nextSegmentIsActionable");
152
- const next = max(segment + 1n, currentSegment());
152
+ const nextSegment = max(segment + 1n, getCurrentSegment());
153
153
  const nextIsActionable = await nextSegmentIsActionable(
154
154
  ctx,
155
155
  state,
156
156
  maxParallelism,
157
- next
157
+ nextSegment
158
158
  );
159
159
  console.timeEnd("[updateRunStatus] nextSegmentIsActionable");
160
160
 
161
161
  if (nextIsActionable) {
162
162
  await ctx.scheduler.runAt(
163
- boundScheduledTime(fromSegment(next), console),
163
+ boundScheduledTime(fromSegment(nextSegment), console),
164
164
  internal.loop.main,
165
165
  {
166
166
  generation,
167
- segment: next,
167
+ segment: nextSegment,
168
168
  }
169
169
  );
170
170
  return;
@@ -187,7 +187,7 @@ export const updateRunStatus = internalMutation({
187
187
  });
188
188
  await ctx.scheduler.runAfter(0, internal.loop.main, {
189
189
  generation,
190
- segment: currentSegment(),
190
+ segment: getCurrentSegment(),
191
191
  });
192
192
  return;
193
193
  }
@@ -204,7 +204,7 @@ export const updateRunStatus = internalMutation({
204
204
  }
205
205
  const docs = await Promise.all(
206
206
  actionableTables.map(async (tableName) =>
207
- getNextUp(ctx, tableName, { start: next })
207
+ getNextUp(ctx, tableName, { start: nextSegment })
208
208
  )
209
209
  );
210
210
  console.timeEnd("[updateRunStatus] findNextSegment");
@@ -223,7 +223,7 @@ export const updateRunStatus = internalMutation({
223
223
  internal.loop.main,
224
224
  { generation, segment: targetSegment }
225
225
  );
226
- if (targetSegment > nextSegment()) {
226
+ if (targetSegment > getNextSegment()) {
227
227
  await ctx.db.patch(runStatus._id, {
228
228
  state: {
229
229
  kind: "scheduled",
@@ -13,12 +13,7 @@ import { internal } from "./_generated/api";
13
13
  import { Id, Doc } from "./_generated/dataModel";
14
14
  import { MutationCtx } from "./_generated/server";
15
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;
16
+ import { recoveryHandler } from "./recovery";
22
17
 
23
18
  const modules = import.meta.glob("./**/*.ts");
24
19
 
@@ -209,7 +204,7 @@ describe("recovery", () => {
209
204
  return await originalGet(id);
210
205
  };
211
206
 
212
- await recover(ctx, {
207
+ await recoveryHandler(ctx, {
213
208
  jobs: [
214
209
  {
215
210
  scheduledId,
@@ -275,7 +270,7 @@ describe("recovery", () => {
275
270
  return await originalGet(id);
276
271
  };
277
272
 
278
- await recover(ctx, {
273
+ await recoveryHandler(ctx, {
279
274
  jobs: [
280
275
  {
281
276
  scheduledId,
@@ -340,7 +335,7 @@ describe("recovery", () => {
340
335
  return await originalGet(id);
341
336
  };
342
337
 
343
- await recover(ctx, {
338
+ await recoveryHandler(ctx, {
344
339
  jobs: [
345
340
  {
346
341
  scheduledId,
@@ -429,7 +424,7 @@ describe("recovery", () => {
429
424
  return await originalGet(id);
430
425
  };
431
426
 
432
- await recover(ctx, {
427
+ await recoveryHandler(ctx, {
433
428
  jobs: [
434
429
  {
435
430
  scheduledId: scheduledId1,
@@ -516,7 +511,7 @@ describe("recovery", () => {
516
511
  return await originalGet(id);
517
512
  };
518
513
 
519
- await recover(ctx, {
514
+ await recoveryHandler(ctx, {
520
515
  jobs: [
521
516
  {
522
517
  scheduledId,