@convex-dev/workpool 0.2.0 → 0.2.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 (87) hide show
  1. package/README.md +86 -3
  2. package/dist/commonjs/client/index.d.ts +32 -6
  3. package/dist/commonjs/client/index.d.ts.map +1 -1
  4. package/dist/commonjs/client/index.js +28 -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 +32 -6
  38. package/dist/esm/client/index.d.ts.map +1 -1
  39. package/dist/esm/client/index.js +28 -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 +66 -36
  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,8 +1,19 @@
1
1
  import { Infer, v } from "convex/values";
2
- import { internalMutation } from "./_generated/server.js";
2
+ import { internalMutation, MutationCtx } from "./_generated/server.js";
3
3
  import { completeArgs, completeHandler } from "./complete.js";
4
4
  import { createLogger } from "./logging.js";
5
5
 
6
+ const recoveryArgs = v.object({
7
+ jobs: v.array(
8
+ v.object({
9
+ scheduledId: v.id("_scheduled_functions"),
10
+ workId: v.id("work"),
11
+ attempt: v.number(),
12
+ started: v.number(),
13
+ })
14
+ ),
15
+ });
16
+
6
17
  /**
7
18
  * This can run when things fail because of server failures / restarts, or when
8
19
  * the user cancels scheduled jobs (from the dashboard).
@@ -19,78 +30,75 @@ import { createLogger } from "./logging.js";
19
30
  * -> check work.attempts
20
31
  */
21
32
  export const recover = internalMutation({
22
- args: {
23
- jobs: v.array(
24
- v.object({
25
- scheduledId: v.id("_scheduled_functions"),
26
- workId: v.id("work"),
27
- attempt: v.number(),
28
- started: v.number(),
29
- })
30
- ),
31
- },
32
- handler: async (ctx, { jobs }) => {
33
- const globals = await ctx.db.query("globals").unique();
34
- const console = createLogger(globals?.logLevel);
35
- const toComplete: Infer<typeof completeArgs.fields.jobs> = [];
36
- for (let i = 0; i < jobs.length; i++) {
37
- const job = jobs[i];
38
- const preamble = `[recovery] Scheduled job ${job.scheduledId} for work ${job.workId}`;
39
- const pendingCompletion = await ctx.db
40
- .query("pendingCompletion")
41
- .withIndex("workId", (q) => q.eq("workId", job.workId))
42
- .first();
43
- if (pendingCompletion) {
44
- // Completion already pending, no need to do anything.
45
- console.debug(`${preamble} already in pendingCompletion, skipping`);
46
- continue;
47
- }
48
- const work = await ctx.db.get(job.workId);
49
- if (work === null) {
50
- // Completion already executed w/o retries, no need to do anything.
51
- console.warn(`${preamble} work not found, skipping`);
52
- continue;
53
- }
54
- if (work.attempts !== job.attempt) {
55
- // Retry already started, no need to do anything.
56
- console.warn(`${preamble} attempts mismatch, skipping`);
57
- continue;
58
- }
59
- const scheduled = await ctx.db.system.get(job.scheduledId);
60
- if (scheduled === null) {
61
- console.warn(`${preamble} not found in _scheduled_functions`);
33
+ args: recoveryArgs,
34
+ handler: recoveryHandler,
35
+ });
36
+
37
+ // only exported for testing
38
+ export async function recoveryHandler(
39
+ ctx: MutationCtx,
40
+ { jobs }: Infer<typeof recoveryArgs>
41
+ ) {
42
+ const globals = await ctx.db.query("globals").unique();
43
+ const console = createLogger(globals?.logLevel);
44
+ const toComplete: Infer<typeof completeArgs.fields.jobs> = [];
45
+ for (let i = 0; i < jobs.length; i++) {
46
+ const job = jobs[i];
47
+ const preamble = `[recovery] Scheduled job ${job.scheduledId} for work ${job.workId}`;
48
+ const pendingCompletion = await ctx.db
49
+ .query("pendingCompletion")
50
+ .withIndex("workId", (q) => q.eq("workId", job.workId))
51
+ .first();
52
+ if (pendingCompletion) {
53
+ // Completion already pending, no need to do anything.
54
+ console.debug(`${preamble} already in pendingCompletion, skipping`);
55
+ continue;
56
+ }
57
+ const work = await ctx.db.get(job.workId);
58
+ if (work === null) {
59
+ // Completion already executed w/o retries, no need to do anything.
60
+ console.warn(`${preamble} work not found, skipping`);
61
+ continue;
62
+ }
63
+ if (work.attempts !== job.attempt) {
64
+ // Retry already started, no need to do anything.
65
+ console.warn(`${preamble} attempts mismatch, skipping`);
66
+ continue;
67
+ }
68
+ const scheduled = await ctx.db.system.get(job.scheduledId);
69
+ if (scheduled === null) {
70
+ console.warn(`${preamble} not found in _scheduled_functions`);
71
+ toComplete.push({
72
+ workId: job.workId,
73
+ runResult: { kind: "failed", error: `Scheduled job not found` },
74
+ attempt: job.attempt,
75
+ });
76
+ continue;
77
+ }
78
+ // This will find everything that timed out, failed ungracefully, was
79
+ // canceled, or succeeded without a return value.
80
+ switch (scheduled.state.kind) {
81
+ case "failed": {
82
+ console.debug(`${preamble} failed and detected in recovery`);
62
83
  toComplete.push({
63
84
  workId: job.workId,
64
- runResult: { kind: "failed", error: `Scheduled job not found` },
85
+ runResult: scheduled.state,
65
86
  attempt: job.attempt,
66
87
  });
67
- continue;
88
+ break;
68
89
  }
69
- // This will find everything that timed out, failed ungracefully, was
70
- // canceled, or succeeded without a return value.
71
- switch (scheduled.state.kind) {
72
- case "failed": {
73
- console.debug(`${preamble} failed and detected in recovery`);
74
- toComplete.push({
75
- workId: job.workId,
76
- runResult: scheduled.state,
77
- attempt: job.attempt,
78
- });
79
- break;
80
- }
81
- case "canceled": {
82
- console.debug(`${preamble} was canceled and detected in recovery`);
83
- toComplete.push({
84
- workId: job.workId,
85
- runResult: { kind: "failed", error: "Canceled via scheduler" },
86
- attempt: job.attempt,
87
- });
88
- break;
89
- }
90
+ case "canceled": {
91
+ console.debug(`${preamble} was canceled and detected in recovery`);
92
+ toComplete.push({
93
+ workId: job.workId,
94
+ runResult: { kind: "failed", error: "Canceled via scheduler" },
95
+ attempt: job.attempt,
96
+ });
97
+ break;
90
98
  }
91
99
  }
92
- if (toComplete.length > 0) {
93
- await completeHandler(ctx, { jobs: toComplete });
94
- }
95
- },
96
- });
100
+ }
101
+ if (toComplete.length > 0) {
102
+ await completeHandler(ctx, { jobs: toComplete });
103
+ }
104
+ }
@@ -15,11 +15,11 @@ export function toSegment(ms: number): bigint {
15
15
  return BigInt(Math.floor(ms / SEGMENT_MS));
16
16
  }
17
17
 
18
- export function currentSegment(): bigint {
18
+ export function getCurrentSegment(): bigint {
19
19
  return toSegment(Date.now());
20
20
  }
21
21
 
22
- export function nextSegment(): bigint {
22
+ export function getNextSegment(): bigint {
23
23
  return toSegment(Date.now()) + 1n;
24
24
  }
25
25
 
@@ -0,0 +1,345 @@
1
+ import { convexTest } from "convex-test";
2
+ import {
3
+ describe,
4
+ expect,
5
+ it,
6
+ beforeEach,
7
+ afterEach,
8
+ vi,
9
+ assert,
10
+ } from "vitest";
11
+ import schema from "./schema";
12
+ import { internal } from "./_generated/api";
13
+ import { Logger } from "./logging";
14
+ import { getCurrentSegment } from "./shared";
15
+ import { paginator } from "convex-helpers/server/pagination";
16
+
17
+ const modules = import.meta.glob("./**/*.ts");
18
+
19
+ // Create a proper Logger mock
20
+ function createLoggerMock(): Logger {
21
+ return {
22
+ event: vi.fn(),
23
+ debug: vi.fn(),
24
+ info: vi.fn(),
25
+ warn: vi.fn(),
26
+ error: vi.fn(),
27
+ time: vi.fn(),
28
+ timeEnd: vi.fn(),
29
+ };
30
+ }
31
+
32
+ describe("stats", () => {
33
+ async function setupTest() {
34
+ const t = convexTest(schema, modules);
35
+ return t;
36
+ }
37
+
38
+ let t: Awaited<ReturnType<typeof setupTest>>;
39
+
40
+ beforeEach(async () => {
41
+ vi.useFakeTimers();
42
+ t = await setupTest();
43
+ });
44
+
45
+ afterEach(() => {
46
+ vi.useRealTimers();
47
+ });
48
+
49
+ describe("generateReport", () => {
50
+ it("should not generate a report when log level is above REPORT", async () => {
51
+ // Setup internal state
52
+ const stateId = await t.run(async (ctx) => {
53
+ return await ctx.db.insert("internalState", {
54
+ generation: 1n,
55
+ segmentCursors: {
56
+ incoming: 0n,
57
+ completion: 0n,
58
+ cancelation: 0n,
59
+ },
60
+ lastRecovery: 0n,
61
+ report: {
62
+ completed: 0,
63
+ succeeded: 0,
64
+ failed: 0,
65
+ retries: 0,
66
+ canceled: 0,
67
+ lastReportTs: 0,
68
+ },
69
+ running: [],
70
+ });
71
+ });
72
+
73
+ // Mock the console.event function to track if it's called
74
+ const consoleMock = createLoggerMock();
75
+
76
+ // Get the state document
77
+ const state = await t.run(async (ctx) => {
78
+ return await ctx.db.get(stateId);
79
+ });
80
+ assert(state);
81
+
82
+ // Call generateReport with a log level that won't trigger reporting
83
+ await t.run(async (ctx) => {
84
+ const { generateReport } = await import("./stats");
85
+ await generateReport(ctx, consoleMock, state, {
86
+ maxParallelism: 10,
87
+ logLevel: "WARN", // Above REPORT level
88
+ });
89
+ });
90
+
91
+ // Verify that console.event was not called
92
+ expect(consoleMock.event).not.toHaveBeenCalled();
93
+ });
94
+
95
+ it("should generate a report when backlog is small enough", async () => {
96
+ // Setup internal state
97
+ const stateId = await t.run(async (ctx) => {
98
+ return await ctx.db.insert("internalState", {
99
+ generation: 1n,
100
+ segmentCursors: {
101
+ incoming: 0n,
102
+ completion: 0n,
103
+ cancelation: 0n,
104
+ },
105
+ lastRecovery: 0n,
106
+ report: {
107
+ completed: 10,
108
+ succeeded: 6,
109
+ failed: 2,
110
+ retries: 2,
111
+ canceled: 0,
112
+ lastReportTs: 0,
113
+ },
114
+ running: [],
115
+ });
116
+ });
117
+
118
+ // Create a few pending start items
119
+ await t.run(async (ctx) => {
120
+ // Create a work item
121
+ const workId = await ctx.db.insert("work", {
122
+ fnType: "mutation",
123
+ fnHandle: "testHandle",
124
+ fnName: "testFunction",
125
+ fnArgs: { test: true },
126
+ attempts: 0,
127
+ });
128
+
129
+ // Create a pendingStart for the work
130
+ await ctx.db.insert("pendingStart", {
131
+ workId,
132
+ segment: 5n, // Some segment between 0 and currentSegment
133
+ });
134
+ });
135
+
136
+ // Mock the console.event function to track if it's called
137
+ const consoleMock = createLoggerMock();
138
+
139
+ // Get the state document
140
+ const state = await t.run(async (ctx) => {
141
+ return await ctx.db.get(stateId);
142
+ });
143
+ assert(state);
144
+
145
+ // Call generateReport with REPORT log level
146
+ await t.run(async (ctx) => {
147
+ const { generateReport } = await import("./stats");
148
+ await generateReport(ctx, consoleMock, state, {
149
+ maxParallelism: 10,
150
+ logLevel: "REPORT", // This should trigger reporting
151
+ });
152
+ });
153
+
154
+ // Verify that console.event was called with the correct data
155
+ expect(consoleMock.event).toHaveBeenCalledWith("report", {
156
+ backlog: 1, // We created one pendingStart
157
+ running: 0,
158
+ completed: 10,
159
+ succeeded: 6,
160
+ failed: 2,
161
+ retries: 2,
162
+ canceled: 0,
163
+ failureRate: 0.4, // (failed + retries) / completed = (2 + 2) / 10 = 0.4
164
+ permanentFailureRate: 0.25, // failed / (completed - retries) = 2 / (10 - 2) = 2/8
165
+ lastReportTs: expect.any(Number),
166
+ });
167
+ });
168
+
169
+ it("should schedule calculateBacklogAndReport when backlog is large", async () => {
170
+ // Setup internal state
171
+ const stateId = await t.run(async (ctx) => {
172
+ return await ctx.db.insert("internalState", {
173
+ generation: 1n,
174
+ segmentCursors: {
175
+ incoming: 0n,
176
+ completion: 0n,
177
+ cancelation: 0n,
178
+ },
179
+ lastRecovery: 0n,
180
+ report: {
181
+ completed: 10,
182
+ succeeded: 8,
183
+ failed: 1,
184
+ retries: 1,
185
+ canceled: 0,
186
+ lastReportTs: 0,
187
+ },
188
+ running: [],
189
+ });
190
+ });
191
+
192
+ // Create more pending start items than maxParallelism
193
+ const maxParallelism = 5;
194
+
195
+ // Create maxParallelism + 1 work items to trigger pagination
196
+ for (let i = 0; i < maxParallelism + 1; i++) {
197
+ await t.run(async (ctx) => {
198
+ // Create a work item
199
+ const workId = await ctx.db.insert("work", {
200
+ fnType: "mutation",
201
+ fnHandle: "testHandle",
202
+ fnName: `testFunction${i}`,
203
+ fnArgs: { test: i },
204
+ attempts: 0,
205
+ });
206
+
207
+ // Create a pendingStart for the work
208
+ await ctx.db.insert("pendingStart", {
209
+ workId,
210
+ segment: 5n, // Some segment between 0 and currentSegment
211
+ });
212
+ });
213
+ }
214
+
215
+ // Mock the console.event function
216
+ const consoleMock = createLoggerMock();
217
+
218
+ // Get the state document
219
+ const state = await t.run(async (ctx) => {
220
+ return await ctx.db.get(stateId);
221
+ });
222
+ assert(state);
223
+
224
+ // Call generateReport with REPORT log level
225
+ await t.run(async (ctx) => {
226
+ const { generateReport } = await import("./stats");
227
+ await generateReport(ctx, consoleMock, state, {
228
+ maxParallelism,
229
+ logLevel: "REPORT", // This should trigger reporting
230
+ });
231
+ });
232
+
233
+ // Verify that calculateBacklogAndReport was scheduled
234
+ await t.run(async (ctx) => {
235
+ const scheduledFunctions = await ctx.db.system
236
+ .query("_scheduled_functions")
237
+ .collect();
238
+
239
+ expect(scheduledFunctions.length).toBeGreaterThan(0);
240
+
241
+ // Check that one of the scheduled functions is calculateBacklogAndReport
242
+ const calculateBacklogScheduled = scheduledFunctions.find(
243
+ (sf) => sf.name === "stats:calculateBacklogAndReport"
244
+ );
245
+ expect(calculateBacklogScheduled).toBeDefined();
246
+ assert(calculateBacklogScheduled);
247
+
248
+ // Verify console.event was not called yet (will be called by calculateBacklogAndReport)
249
+ expect(consoleMock.event).not.toHaveBeenCalled();
250
+ });
251
+ });
252
+
253
+ it("should calculate backlog and report correctly", async () => {
254
+ // Setup internal state
255
+ const stateId = await t.run(async (ctx) => {
256
+ return await ctx.db.insert("internalState", {
257
+ generation: 1n,
258
+ segmentCursors: {
259
+ incoming: 0n,
260
+ completion: 0n,
261
+ cancelation: 0n,
262
+ },
263
+ lastRecovery: 0n,
264
+ report: {
265
+ completed: 10,
266
+ succeeded: 8,
267
+ failed: 1,
268
+ retries: 1,
269
+ canceled: 0,
270
+ lastReportTs: 0,
271
+ },
272
+ running: [],
273
+ });
274
+ });
275
+
276
+ // Create some pending start items
277
+ const currentSegment = getCurrentSegment();
278
+
279
+ // Create 3 work items
280
+ for (let i = 0; i < 3; i++) {
281
+ await t.run(async (ctx) => {
282
+ // Create a work item
283
+ const workId = await ctx.db.insert("work", {
284
+ fnType: "mutation",
285
+ fnHandle: "testHandle",
286
+ fnName: `testFunction${i}`,
287
+ fnArgs: { test: i },
288
+ attempts: 0,
289
+ });
290
+
291
+ // Create a pendingStart for the work
292
+ await ctx.db.insert("pendingStart", {
293
+ workId,
294
+ segment: 5n, // Some segment between 0 and currentSegment
295
+ });
296
+ });
297
+ }
298
+
299
+ // Get the state document
300
+ const state = await t.run(async (ctx) => {
301
+ return await ctx.db.get(stateId);
302
+ });
303
+ assert(state);
304
+
305
+ const cursor = await t.run(async (ctx) => {
306
+ return await paginator(ctx.db, schema)
307
+ .query("pendingStart")
308
+ .withIndex("segment", (q) =>
309
+ q.gte("segment", 0n).lt("segment", currentSegment)
310
+ )
311
+ .paginate({
312
+ numItems: 1,
313
+ cursor: null,
314
+ });
315
+ });
316
+
317
+ // Call calculateBacklogAndReport directly
318
+ await t.mutation(internal.stats.calculateBacklogAndReport, {
319
+ startSegment: 0n,
320
+ endSegment: currentSegment,
321
+ cursor: cursor.continueCursor,
322
+ report: state.report,
323
+ running: state.running.length,
324
+ logLevel: "REPORT",
325
+ });
326
+
327
+ // Verify that console.event was called with the correct data
328
+ // Note: We can't directly check the mock since it's created inside the mutation
329
+ // Instead, we can check if the function completed successfully
330
+
331
+ // We can verify the function was executed by checking if any scheduled functions were created
332
+ await t.run(async (ctx) => {
333
+ const scheduledFunctions = await ctx.db.system
334
+ .query("_scheduled_functions")
335
+ .collect();
336
+
337
+ // Since our backlog is small, no additional scheduled functions should be created
338
+ const calculateBacklogScheduled = scheduledFunctions.find(
339
+ (sf) => sf.name === "stats:calculateBacklogAndReport"
340
+ );
341
+ expect(calculateBacklogScheduled).toBeUndefined();
342
+ });
343
+ });
344
+ });
345
+ });