@convex-dev/workpool 0.2.0-beta.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 (110) hide show
  1. package/README.md +87 -18
  2. package/dist/commonjs/client/index.d.ts +33 -8
  3. package/dist/commonjs/client/index.d.ts.map +1 -1
  4. package/dist/commonjs/client/index.js +37 -7
  5. package/dist/commonjs/client/index.js.map +1 -1
  6. package/dist/commonjs/component/complete.d.ts +89 -0
  7. package/dist/commonjs/component/complete.d.ts.map +1 -0
  8. package/dist/commonjs/component/complete.js +82 -0
  9. package/dist/commonjs/component/complete.js.map +1 -0
  10. package/dist/commonjs/component/kick.d.ts +3 -3
  11. package/dist/commonjs/component/kick.d.ts.map +1 -1
  12. package/dist/commonjs/component/kick.js +17 -12
  13. package/dist/commonjs/component/kick.js.map +1 -1
  14. package/dist/commonjs/component/lib.d.ts +6 -6
  15. package/dist/commonjs/component/lib.d.ts.map +1 -1
  16. package/dist/commonjs/component/lib.js +53 -24
  17. package/dist/commonjs/component/lib.js.map +1 -1
  18. package/dist/commonjs/component/logging.d.ts +3 -2
  19. package/dist/commonjs/component/logging.d.ts.map +1 -1
  20. package/dist/commonjs/component/logging.js +34 -16
  21. package/dist/commonjs/component/logging.js.map +1 -1
  22. package/dist/commonjs/component/loop.d.ts +1 -14
  23. package/dist/commonjs/component/loop.d.ts.map +1 -1
  24. package/dist/commonjs/component/loop.js +216 -179
  25. package/dist/commonjs/component/loop.js.map +1 -1
  26. package/dist/commonjs/component/recovery.d.ts +45 -0
  27. package/dist/commonjs/component/recovery.d.ts.map +1 -1
  28. package/dist/commonjs/component/recovery.js +88 -65
  29. package/dist/commonjs/component/recovery.js.map +1 -1
  30. package/dist/commonjs/component/schema.d.ts +17 -13
  31. package/dist/commonjs/component/schema.d.ts.map +1 -1
  32. package/dist/commonjs/component/schema.js +5 -3
  33. package/dist/commonjs/component/schema.js.map +1 -1
  34. package/dist/commonjs/component/shared.d.ts +24 -15
  35. package/dist/commonjs/component/shared.d.ts.map +1 -1
  36. package/dist/commonjs/component/shared.js +20 -7
  37. package/dist/commonjs/component/shared.js.map +1 -1
  38. package/dist/commonjs/component/stats.d.ts +36 -29
  39. package/dist/commonjs/component/stats.d.ts.map +1 -1
  40. package/dist/commonjs/component/stats.js +110 -52
  41. package/dist/commonjs/component/stats.js.map +1 -1
  42. package/dist/commonjs/component/worker.d.ts +4 -14
  43. package/dist/commonjs/component/worker.d.ts.map +1 -1
  44. package/dist/commonjs/component/worker.js +23 -36
  45. package/dist/commonjs/component/worker.js.map +1 -1
  46. package/dist/esm/client/index.d.ts +33 -8
  47. package/dist/esm/client/index.d.ts.map +1 -1
  48. package/dist/esm/client/index.js +37 -7
  49. package/dist/esm/client/index.js.map +1 -1
  50. package/dist/esm/component/complete.d.ts +89 -0
  51. package/dist/esm/component/complete.d.ts.map +1 -0
  52. package/dist/esm/component/complete.js +82 -0
  53. package/dist/esm/component/complete.js.map +1 -0
  54. package/dist/esm/component/kick.d.ts +3 -3
  55. package/dist/esm/component/kick.d.ts.map +1 -1
  56. package/dist/esm/component/kick.js +17 -12
  57. package/dist/esm/component/kick.js.map +1 -1
  58. package/dist/esm/component/lib.d.ts +6 -6
  59. package/dist/esm/component/lib.d.ts.map +1 -1
  60. package/dist/esm/component/lib.js +53 -24
  61. package/dist/esm/component/lib.js.map +1 -1
  62. package/dist/esm/component/logging.d.ts +3 -2
  63. package/dist/esm/component/logging.d.ts.map +1 -1
  64. package/dist/esm/component/logging.js +34 -16
  65. package/dist/esm/component/logging.js.map +1 -1
  66. package/dist/esm/component/loop.d.ts +1 -14
  67. package/dist/esm/component/loop.d.ts.map +1 -1
  68. package/dist/esm/component/loop.js +216 -179
  69. package/dist/esm/component/loop.js.map +1 -1
  70. package/dist/esm/component/recovery.d.ts +45 -0
  71. package/dist/esm/component/recovery.d.ts.map +1 -1
  72. package/dist/esm/component/recovery.js +88 -65
  73. package/dist/esm/component/recovery.js.map +1 -1
  74. package/dist/esm/component/schema.d.ts +17 -13
  75. package/dist/esm/component/schema.d.ts.map +1 -1
  76. package/dist/esm/component/schema.js +5 -3
  77. package/dist/esm/component/schema.js.map +1 -1
  78. package/dist/esm/component/shared.d.ts +24 -15
  79. package/dist/esm/component/shared.d.ts.map +1 -1
  80. package/dist/esm/component/shared.js +20 -7
  81. package/dist/esm/component/shared.js.map +1 -1
  82. package/dist/esm/component/stats.d.ts +36 -29
  83. package/dist/esm/component/stats.d.ts.map +1 -1
  84. package/dist/esm/component/stats.js +110 -52
  85. package/dist/esm/component/stats.js.map +1 -1
  86. package/dist/esm/component/worker.d.ts +4 -14
  87. package/dist/esm/component/worker.d.ts.map +1 -1
  88. package/dist/esm/component/worker.js +23 -36
  89. package/dist/esm/component/worker.js.map +1 -1
  90. package/package.json +12 -12
  91. package/src/client/index.ts +82 -43
  92. package/src/component/README.md +15 -15
  93. package/src/component/_generated/api.d.ts +10 -5
  94. package/src/component/complete.test.ts +508 -0
  95. package/src/component/complete.ts +109 -0
  96. package/src/component/kick.test.ts +29 -19
  97. package/src/component/kick.ts +25 -17
  98. package/src/component/lib.test.ts +262 -17
  99. package/src/component/lib.ts +68 -30
  100. package/src/component/logging.test.ts +16 -0
  101. package/src/component/logging.ts +45 -24
  102. package/src/component/loop.test.ts +1158 -0
  103. package/src/component/loop.ts +292 -224
  104. package/src/component/recovery.test.ts +536 -0
  105. package/src/component/recovery.ts +100 -75
  106. package/src/component/schema.ts +6 -4
  107. package/src/component/shared.ts +23 -8
  108. package/src/component/stats.test.ts +345 -0
  109. package/src/component/stats.ts +149 -56
  110. package/src/component/worker.ts +25 -38
@@ -51,18 +51,19 @@ export default defineSchema({
51
51
  ),
52
52
  }),
53
53
 
54
- // Written on enqueue. Safe to read. Deleted by `complete`.
54
+ // Written on enqueue. Deleted by `complete` for success, failure, canceled.
55
55
  work: defineTable({
56
56
  fnType: v.union(v.literal("action"), v.literal("mutation")),
57
57
  fnHandle: v.string(),
58
58
  fnName: v.string(),
59
59
  fnArgs: v.any(),
60
- attempts: v.number(),
60
+ attempts: v.number(), // number of completed attempts
61
61
  onComplete: v.optional(onComplete),
62
62
  retryBehavior: v.optional(retryBehavior),
63
+ canceled: v.optional(v.boolean()),
63
64
  }),
64
65
 
65
- // Written on enqueue, read & deleted by `main`.
66
+ // Written on enqueue & rescheduled for retry, read & deleted by `main`.
66
67
  pendingStart: defineTable({
67
68
  workId: v.id("work"),
68
69
  segment,
@@ -70,11 +71,12 @@ export default defineSchema({
70
71
  .index("workId", ["workId"])
71
72
  .index("segment", ["segment"]),
72
73
 
73
- // Written by job, read & deleted by `main`.
74
+ // Written by complete, read & deleted by `main`.
74
75
  pendingCompletion: defineTable({
75
76
  segment,
76
77
  runResult,
77
78
  workId: v.id("work"),
79
+ retry: v.boolean(),
78
80
  })
79
81
  .index("workId", ["workId"])
80
82
  .index("segment", ["segment"]),
@@ -3,7 +3,8 @@ import { Infer } from "convex/values";
3
3
  import { v } from "convex/values";
4
4
  import { Logger, logLevel } from "./logging.js";
5
5
 
6
- const SEGMENT_MS = 250;
6
+ export const DEFAULT_MAX_PARALLELISM = 10;
7
+ const SEGMENT_MS = 100;
7
8
  export const SECOND = 1000;
8
9
  export const MINUTE = 60 * SECOND;
9
10
  export const HOUR = 60 * MINUTE;
@@ -14,11 +15,11 @@ export function toSegment(ms: number): bigint {
14
15
  return BigInt(Math.floor(ms / SEGMENT_MS));
15
16
  }
16
17
 
17
- export function currentSegment(): bigint {
18
+ export function getCurrentSegment(): bigint {
18
19
  return toSegment(Date.now());
19
20
  }
20
21
 
21
- export function nextSegment(): bigint {
22
+ export function getNextSegment(): bigint {
22
23
  return toSegment(Date.now()) + 1n;
23
24
  }
24
25
 
@@ -98,11 +99,11 @@ export const status = v.union(
98
99
  v.union(
99
100
  v.object({
100
101
  state: v.literal("pending"),
101
- attempt: v.number(),
102
+ previousAttempts: v.number(),
102
103
  }),
103
104
  v.object({
104
105
  state: v.literal("running"),
105
- attempt: v.number(),
106
+ previousAttempts: v.number(),
106
107
  }),
107
108
  v.object({
108
109
  state: v.literal("finished"),
@@ -113,15 +114,29 @@ export type Status = Infer<typeof status>;
113
114
 
114
115
  export function boundScheduledTime(ms: number, console: Logger): number {
115
116
  if (ms < Date.now() - YEAR) {
116
- console.warn("runAt is too far in the past, defaulting to now", ms);
117
+ console.error("scheduled time is too old, defaulting to now", ms);
117
118
  return Date.now();
118
119
  }
119
120
  if (ms > Date.now() + 4 * YEAR) {
120
- console.warn(
121
- "runAt is too far in the future, defaulting to 1 year from now",
121
+ console.error(
122
+ "scheduled time is too far in the future, defaulting to 1 year from now",
122
123
  ms
123
124
  );
124
125
  return Date.now() + YEAR;
125
126
  }
126
127
  return ms;
127
128
  }
129
+
130
+ /**
131
+ * Returns the smaller of two bigint values.
132
+ */
133
+ export function min<T extends bigint>(a: T, b: T): T {
134
+ return a > b ? b : a;
135
+ }
136
+
137
+ /**
138
+ * Returns the larger of two bigint values.
139
+ */
140
+ export function max<T extends bigint>(a: T, b: T): T {
141
+ return a < b ? b : a;
142
+ }
@@ -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
+ });