@convex-dev/workpool 0.1.2 → 0.2.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +155 -17
  2. package/dist/commonjs/client/index.d.ts +123 -35
  3. package/dist/commonjs/client/index.d.ts.map +1 -1
  4. package/dist/commonjs/client/index.js +122 -15
  5. package/dist/commonjs/client/index.js.map +1 -1
  6. package/dist/commonjs/client/utils.d.ts +16 -0
  7. package/dist/commonjs/client/utils.d.ts.map +1 -0
  8. package/dist/commonjs/client/utils.js +2 -0
  9. package/dist/commonjs/client/utils.js.map +1 -0
  10. package/dist/commonjs/component/complete.d.ts +89 -0
  11. package/dist/commonjs/component/complete.d.ts.map +1 -0
  12. package/dist/commonjs/component/complete.js +80 -0
  13. package/dist/commonjs/component/complete.js.map +1 -0
  14. package/dist/commonjs/component/convex.config.d.ts.map +1 -1
  15. package/dist/commonjs/component/convex.config.js +0 -2
  16. package/dist/commonjs/component/convex.config.js.map +1 -1
  17. package/dist/commonjs/component/kick.d.ts +9 -0
  18. package/dist/commonjs/component/kick.d.ts.map +1 -0
  19. package/dist/commonjs/component/kick.js +97 -0
  20. package/dist/commonjs/component/kick.js.map +1 -0
  21. package/dist/commonjs/component/lib.d.ts +23 -32
  22. package/dist/commonjs/component/lib.d.ts.map +1 -1
  23. package/dist/commonjs/component/lib.js +91 -563
  24. package/dist/commonjs/component/lib.js.map +1 -1
  25. package/dist/commonjs/component/logging.d.ts +5 -3
  26. package/dist/commonjs/component/logging.d.ts.map +1 -1
  27. package/dist/commonjs/component/logging.js +13 -2
  28. package/dist/commonjs/component/logging.js.map +1 -1
  29. package/dist/commonjs/component/loop.d.ts +13 -0
  30. package/dist/commonjs/component/loop.d.ts.map +1 -0
  31. package/dist/commonjs/component/loop.js +482 -0
  32. package/dist/commonjs/component/loop.js.map +1 -0
  33. package/dist/commonjs/component/recovery.d.ts +24 -0
  34. package/dist/commonjs/component/recovery.d.ts.map +1 -0
  35. package/dist/commonjs/component/recovery.js +94 -0
  36. package/dist/commonjs/component/recovery.js.map +1 -0
  37. package/dist/commonjs/component/schema.d.ts +167 -93
  38. package/dist/commonjs/component/schema.d.ts.map +1 -1
  39. package/dist/commonjs/component/schema.js +56 -65
  40. package/dist/commonjs/component/schema.js.map +1 -1
  41. package/dist/commonjs/component/shared.d.ts +138 -0
  42. package/dist/commonjs/component/shared.d.ts.map +1 -0
  43. package/dist/commonjs/component/shared.js +77 -0
  44. package/dist/commonjs/component/shared.js.map +1 -0
  45. package/dist/commonjs/component/stats.d.ts +6 -3
  46. package/dist/commonjs/component/stats.d.ts.map +1 -1
  47. package/dist/commonjs/component/stats.js +23 -4
  48. package/dist/commonjs/component/stats.js.map +1 -1
  49. package/dist/commonjs/component/worker.d.ts +15 -0
  50. package/dist/commonjs/component/worker.d.ts.map +1 -0
  51. package/dist/commonjs/component/worker.js +73 -0
  52. package/dist/commonjs/component/worker.js.map +1 -0
  53. package/dist/esm/client/index.d.ts +123 -35
  54. package/dist/esm/client/index.d.ts.map +1 -1
  55. package/dist/esm/client/index.js +122 -15
  56. package/dist/esm/client/index.js.map +1 -1
  57. package/dist/esm/client/utils.d.ts +16 -0
  58. package/dist/esm/client/utils.d.ts.map +1 -0
  59. package/dist/esm/client/utils.js +2 -0
  60. package/dist/esm/client/utils.js.map +1 -0
  61. package/dist/esm/component/complete.d.ts +89 -0
  62. package/dist/esm/component/complete.d.ts.map +1 -0
  63. package/dist/esm/component/complete.js +80 -0
  64. package/dist/esm/component/complete.js.map +1 -0
  65. package/dist/esm/component/convex.config.d.ts.map +1 -1
  66. package/dist/esm/component/convex.config.js +0 -2
  67. package/dist/esm/component/convex.config.js.map +1 -1
  68. package/dist/esm/component/kick.d.ts +9 -0
  69. package/dist/esm/component/kick.d.ts.map +1 -0
  70. package/dist/esm/component/kick.js +97 -0
  71. package/dist/esm/component/kick.js.map +1 -0
  72. package/dist/esm/component/lib.d.ts +23 -32
  73. package/dist/esm/component/lib.d.ts.map +1 -1
  74. package/dist/esm/component/lib.js +91 -563
  75. package/dist/esm/component/lib.js.map +1 -1
  76. package/dist/esm/component/logging.d.ts +5 -3
  77. package/dist/esm/component/logging.d.ts.map +1 -1
  78. package/dist/esm/component/logging.js +13 -2
  79. package/dist/esm/component/logging.js.map +1 -1
  80. package/dist/esm/component/loop.d.ts +13 -0
  81. package/dist/esm/component/loop.d.ts.map +1 -0
  82. package/dist/esm/component/loop.js +482 -0
  83. package/dist/esm/component/loop.js.map +1 -0
  84. package/dist/esm/component/recovery.d.ts +24 -0
  85. package/dist/esm/component/recovery.d.ts.map +1 -0
  86. package/dist/esm/component/recovery.js +94 -0
  87. package/dist/esm/component/recovery.js.map +1 -0
  88. package/dist/esm/component/schema.d.ts +167 -93
  89. package/dist/esm/component/schema.d.ts.map +1 -1
  90. package/dist/esm/component/schema.js +56 -65
  91. package/dist/esm/component/schema.js.map +1 -1
  92. package/dist/esm/component/shared.d.ts +138 -0
  93. package/dist/esm/component/shared.d.ts.map +1 -0
  94. package/dist/esm/component/shared.js +77 -0
  95. package/dist/esm/component/shared.js.map +1 -0
  96. package/dist/esm/component/stats.d.ts +6 -3
  97. package/dist/esm/component/stats.d.ts.map +1 -1
  98. package/dist/esm/component/stats.js +23 -4
  99. package/dist/esm/component/stats.js.map +1 -1
  100. package/dist/esm/component/worker.d.ts +15 -0
  101. package/dist/esm/component/worker.d.ts.map +1 -0
  102. package/dist/esm/component/worker.js +73 -0
  103. package/dist/esm/component/worker.js.map +1 -0
  104. package/package.json +6 -5
  105. package/src/client/index.ts +232 -68
  106. package/src/client/utils.ts +45 -0
  107. package/src/component/README.md +73 -0
  108. package/src/component/_generated/api.d.ts +38 -66
  109. package/src/component/complete.test.ts +508 -0
  110. package/src/component/complete.ts +98 -0
  111. package/src/component/convex.config.ts +0 -3
  112. package/src/component/kick.test.ts +285 -0
  113. package/src/component/kick.ts +118 -0
  114. package/src/component/lib.test.ts +448 -0
  115. package/src/component/lib.ts +105 -667
  116. package/src/component/logging.ts +24 -12
  117. package/src/component/loop.test.ts +1204 -0
  118. package/src/component/loop.ts +637 -0
  119. package/src/component/recovery.test.ts +541 -0
  120. package/src/component/recovery.ts +96 -0
  121. package/src/component/schema.ts +61 -77
  122. package/src/component/setup.test.ts +5 -0
  123. package/src/component/shared.ts +141 -0
  124. package/src/component/stats.ts +26 -8
  125. package/src/component/worker.ts +81 -0
@@ -0,0 +1,448 @@
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 { Id } from "./_generated/dataModel";
12
+ import schema from "./schema";
13
+ import { api } from "./_generated/api";
14
+
15
+ const modules = import.meta.glob("./**/*.ts");
16
+
17
+ // Mock Id type
18
+ type WorkId = Id<"work">;
19
+
20
+ describe("lib", () => {
21
+ async function setupTest() {
22
+ const t = convexTest(schema, modules);
23
+ return t;
24
+ }
25
+
26
+ let t: Awaited<ReturnType<typeof setupTest>>;
27
+
28
+ beforeEach(async () => {
29
+ vi.useFakeTimers();
30
+ t = await setupTest();
31
+ });
32
+
33
+ afterEach(() => {
34
+ vi.useRealTimers();
35
+ });
36
+
37
+ describe("enqueue", () => {
38
+ it("should successfully enqueue a work item", async () => {
39
+ const id = await t.mutation(api.lib.enqueue, {
40
+ fnHandle: "testHandle",
41
+ fnName: "testFunction",
42
+ fnArgs: { test: true },
43
+ fnType: "mutation",
44
+ runAt: Date.now(),
45
+ config: {
46
+ maxParallelism: 10,
47
+ logLevel: "INFO",
48
+ },
49
+ });
50
+
51
+ expect(id).toBeDefined();
52
+ const status = await t.query(api.lib.status, { id });
53
+ expect(status).toEqual({ state: "pending", previousAttempts: 0 });
54
+ });
55
+
56
+ it("should throw error if maxParallelism is too high", async () => {
57
+ await expect(
58
+ t.mutation(api.lib.enqueue, {
59
+ fnHandle: "testHandle",
60
+ fnName: "testFunction",
61
+ fnArgs: { test: true },
62
+ fnType: "mutation",
63
+ runAt: Date.now(),
64
+ config: {
65
+ maxParallelism: 101, // More than MAX_POSSIBLE_PARALLELISM
66
+ logLevel: "INFO",
67
+ },
68
+ })
69
+ ).rejects.toThrow("maxParallelism must be <= 100");
70
+ });
71
+
72
+ it("should throw error if maxParallelism is too low", async () => {
73
+ await expect(
74
+ t.mutation(api.lib.enqueue, {
75
+ fnHandle: "testHandle",
76
+ fnName: "testFunction",
77
+ fnArgs: { test: true },
78
+ fnType: "mutation",
79
+ runAt: Date.now(),
80
+ config: {
81
+ maxParallelism: 0, // Less than minimum
82
+ logLevel: "INFO",
83
+ },
84
+ })
85
+ ).rejects.toThrow("maxParallelism must be >= 1");
86
+ });
87
+ });
88
+
89
+ describe("cancel", () => {
90
+ it("should successfully queue a work item for cancelation", async () => {
91
+ const id = await t.mutation(api.lib.enqueue, {
92
+ fnHandle: "testHandle",
93
+ fnName: "testFunction",
94
+ fnArgs: { test: true },
95
+ fnType: "mutation",
96
+ runAt: Date.now(),
97
+ config: {
98
+ maxParallelism: 10,
99
+ logLevel: "INFO",
100
+ },
101
+ });
102
+
103
+ await t.mutation(api.lib.cancel, {
104
+ id,
105
+ logLevel: "INFO",
106
+ });
107
+
108
+ // Verify a pending cancelation was created
109
+ await t.run(async (ctx) => {
110
+ const pendingCancelations = await ctx.db
111
+ .query("pendingCancelation")
112
+ .collect();
113
+ expect(pendingCancelations).toHaveLength(1);
114
+ expect(pendingCancelations[0].workId).toBe(id);
115
+ });
116
+ });
117
+
118
+ it("should not create duplicate cancelation requests", async () => {
119
+ const id = await t.mutation(api.lib.enqueue, {
120
+ fnHandle: "testHandle",
121
+ fnName: "testFunction",
122
+ fnArgs: { test: true },
123
+ fnType: "mutation",
124
+ runAt: Date.now(),
125
+ config: {
126
+ maxParallelism: 10,
127
+ logLevel: "INFO",
128
+ },
129
+ });
130
+
131
+ // Cancel the first time
132
+ await t.mutation(api.lib.cancel, {
133
+ id,
134
+ logLevel: "INFO",
135
+ });
136
+
137
+ // Cancel the second time
138
+ await t.mutation(api.lib.cancel, {
139
+ id,
140
+ logLevel: "INFO",
141
+ });
142
+
143
+ // Verify only one pending cancelation was created
144
+ await t.run(async (ctx) => {
145
+ const pendingCancelations = await ctx.db
146
+ .query("pendingCancelation")
147
+ .collect();
148
+ expect(pendingCancelations).toHaveLength(1);
149
+ expect(pendingCancelations[0].workId).toBe(id);
150
+ });
151
+ });
152
+
153
+ it("should not create cancelation for non-existent work", async () => {
154
+ const id = await t.mutation(api.lib.enqueue, {
155
+ fnHandle: "testHandle",
156
+ fnName: "testFunction",
157
+ fnArgs: { test: true },
158
+ fnType: "mutation",
159
+ runAt: Date.now(),
160
+ config: {
161
+ maxParallelism: 10,
162
+ logLevel: "INFO",
163
+ },
164
+ });
165
+
166
+ // Delete the work item
167
+ await t.run(async (ctx) => {
168
+ await ctx.db.delete(id);
169
+ });
170
+
171
+ // Try to cancel the deleted work
172
+ await t.mutation(api.lib.cancel, {
173
+ id,
174
+ logLevel: "INFO",
175
+ });
176
+
177
+ // Verify no pending cancelation was created
178
+ await t.run(async (ctx) => {
179
+ const pendingCancelations = await ctx.db
180
+ .query("pendingCancelation")
181
+ .collect();
182
+ expect(pendingCancelations).toHaveLength(0);
183
+ });
184
+ });
185
+ });
186
+
187
+ describe("cancelAll", () => {
188
+ it("should queue multiple work items for cancelation", async () => {
189
+ const ids: WorkId[] = [];
190
+ for (let i = 0; i < 3; i++) {
191
+ const id = await t.mutation(api.lib.enqueue, {
192
+ fnHandle: "testHandle",
193
+ fnName: "testFunction",
194
+ fnArgs: { test: i },
195
+ fnType: "mutation",
196
+ runAt: Date.now() + 5 * 60 * 1000,
197
+ config: {
198
+ maxParallelism: 10,
199
+ logLevel: "INFO",
200
+ },
201
+ });
202
+ ids.push(id);
203
+ }
204
+
205
+ await t.mutation(api.lib.cancelAll, {
206
+ logLevel: "INFO",
207
+ before: Date.now() + 1000,
208
+ });
209
+
210
+ // Verify pending cancelations were created
211
+ await t.run(async (ctx) => {
212
+ const pendingCancelations = await ctx.db
213
+ .query("pendingCancelation")
214
+ .collect();
215
+ expect(pendingCancelations).toHaveLength(3);
216
+ const canceledIds = pendingCancelations.map((pc) => pc.workId);
217
+ expect(canceledIds).toEqual(expect.arrayContaining(ids));
218
+ });
219
+ });
220
+
221
+ it("should process work items in batches for cancelAll", async () => {
222
+ const PAGE_SIZE = 64; // Same as in lib.ts
223
+
224
+ // Create PAGE_SIZE + 1 work items to trigger pagination
225
+ for (let i = 0; i < PAGE_SIZE + 1; i++) {
226
+ await t.mutation(api.lib.enqueue, {
227
+ fnHandle: "testHandle",
228
+ fnName: "testFunction",
229
+ fnArgs: { test: i },
230
+ fnType: "mutation",
231
+ runAt: Date.now(),
232
+ config: {
233
+ maxParallelism: 10,
234
+ logLevel: "INFO",
235
+ },
236
+ });
237
+ }
238
+
239
+ await t.mutation(api.lib.cancelAll, {
240
+ logLevel: "INFO",
241
+ before: Date.now() + 1000,
242
+ });
243
+
244
+ // assert that cancelAll was scheduled
245
+ await t.run(async (ctx) => {
246
+ const scheduledFunctions = await ctx.db.system
247
+ .query("_scheduled_functions")
248
+ .collect();
249
+ expect(scheduledFunctions.length).toBeGreaterThan(0);
250
+ // check that one of the scheduled functions is cancelAll
251
+ const cancelAllScheduledFunction = scheduledFunctions.find(
252
+ (sf) => sf.name === "lib:cancelAll"
253
+ );
254
+ expect(cancelAllScheduledFunction).toBeDefined();
255
+ assert(cancelAllScheduledFunction);
256
+ });
257
+
258
+ // Verify the first page of cancelations was created
259
+ await t.run(async (ctx) => {
260
+ const pendingCancelations = await ctx.db
261
+ .query("pendingCancelation")
262
+ .collect();
263
+
264
+ // We should have at least PAGE_SIZE cancelations
265
+ expect(pendingCancelations.length).toEqual(PAGE_SIZE);
266
+ });
267
+ });
268
+ });
269
+
270
+ describe("status", () => {
271
+ it("should return finished state for non-existent work", async () => {
272
+ const id = await t.mutation(api.lib.enqueue, {
273
+ fnHandle: "testHandle",
274
+ fnName: "testFunction",
275
+ fnArgs: { test: true },
276
+ fnType: "mutation",
277
+ runAt: Date.now(),
278
+ config: {
279
+ maxParallelism: 10,
280
+ logLevel: "INFO",
281
+ },
282
+ });
283
+ await t.run(async (ctx) => {
284
+ await ctx.db.delete(id);
285
+ });
286
+
287
+ const status = await t.query(api.lib.status, { id });
288
+ expect(status).toEqual({ state: "finished" });
289
+ });
290
+
291
+ it("should return pending state for newly enqueued work", async () => {
292
+ const id = await t.mutation(api.lib.enqueue, {
293
+ fnHandle: "testHandle",
294
+ fnName: "testFunction",
295
+ fnArgs: { test: true },
296
+ fnType: "mutation",
297
+ runAt: Date.now(),
298
+ config: {
299
+ maxParallelism: 10,
300
+ logLevel: "INFO",
301
+ },
302
+ });
303
+
304
+ // Verify work item and pending start were created
305
+ await t.run(async (ctx) => {
306
+ const work = await ctx.db.get(id);
307
+ expect(work).toBeDefined();
308
+ const pendingStarts = await ctx.db.query("pendingStart").collect();
309
+ expect(pendingStarts).toHaveLength(1);
310
+ expect(pendingStarts[0].workId).toBe(id);
311
+ });
312
+
313
+ const status = await t.query(api.lib.status, { id });
314
+ expect(status).toEqual({ state: "pending", previousAttempts: 0 });
315
+ });
316
+
317
+ it("should return running state when work is in progress", async () => {
318
+ const id = await t.mutation(api.lib.enqueue, {
319
+ fnHandle: "testHandle",
320
+ fnName: "testFunction",
321
+ fnArgs: { test: true },
322
+ fnType: "mutation",
323
+ runAt: Date.now(),
324
+ config: {
325
+ maxParallelism: 10,
326
+ logLevel: "INFO",
327
+ },
328
+ });
329
+
330
+ // Delete the pendingStart to simulate work in progress
331
+ await t.run(async (ctx) => {
332
+ const pendingStart = await ctx.db.query("pendingStart").first();
333
+ expect(pendingStart).toBeDefined();
334
+ assert(pendingStart);
335
+ await ctx.db.delete(pendingStart._id);
336
+ });
337
+
338
+ const status = await t.query(api.lib.status, { id });
339
+ expect(status).toEqual({ state: "running", previousAttempts: 0 });
340
+ });
341
+
342
+ it("should return pending state for work pending retry", async () => {
343
+ const id = await t.mutation(api.lib.enqueue, {
344
+ fnHandle: "testHandle",
345
+ fnName: "testFunction",
346
+ fnArgs: { test: true },
347
+ fnType: "mutation",
348
+ runAt: Date.now(),
349
+ retryBehavior: {
350
+ maxAttempts: 3,
351
+ initialBackoffMs: 100,
352
+ base: 2,
353
+ },
354
+ config: {
355
+ maxParallelism: 10,
356
+ logLevel: "INFO",
357
+ },
358
+ });
359
+
360
+ // Delete the pendingStart to simulate work in progress
361
+ await t.run(async (ctx) => {
362
+ const pendingStart = await ctx.db.query("pendingStart").first();
363
+ expect(pendingStart).toBeDefined();
364
+ assert(pendingStart);
365
+ await ctx.db.delete(pendingStart._id);
366
+
367
+ // Create a pendingCompletion with retry=true to simulate a failed job that will be retried
368
+ await ctx.db.insert("pendingCompletion", {
369
+ workId: id,
370
+ segment: 1n, // Using a simple segment value for testing
371
+ runResult: { kind: "failed", error: "Test error" },
372
+ retry: true,
373
+ });
374
+ });
375
+
376
+ const status = await t.query(api.lib.status, { id });
377
+ expect(status).toEqual({ state: "pending", previousAttempts: 0 });
378
+ });
379
+
380
+ it("should return running state for work with pendingCancelation", async () => {
381
+ const id = await t.mutation(api.lib.enqueue, {
382
+ fnHandle: "testHandle",
383
+ fnName: "testFunction",
384
+ fnArgs: { test: true },
385
+ fnType: "mutation",
386
+ runAt: Date.now(),
387
+ config: {
388
+ maxParallelism: 10,
389
+ logLevel: "INFO",
390
+ },
391
+ });
392
+
393
+ // Delete the pendingStart and add pendingCancelation to simulate cancellation in progress
394
+ await t.run(async (ctx) => {
395
+ const pendingStart = await ctx.db.query("pendingStart").first();
396
+ expect(pendingStart).toBeDefined();
397
+ assert(pendingStart);
398
+ await ctx.db.delete(pendingStart._id);
399
+
400
+ // Create a pendingCancelation
401
+ await ctx.db.insert("pendingCancelation", {
402
+ workId: id,
403
+ segment: 1n, // Using a simple segment value for testing
404
+ });
405
+ });
406
+
407
+ // According to the implementation, a job with pendingCancelation but no pendingStart
408
+ // or pendingCompletion with retry=true is considered "running"
409
+ const status = await t.query(api.lib.status, { id });
410
+ expect(status).toEqual({ state: "running", previousAttempts: 0 });
411
+ });
412
+
413
+ it("should return running state for work with pendingCompletion but retry=false", async () => {
414
+ const id = await t.mutation(api.lib.enqueue, {
415
+ fnHandle: "testHandle",
416
+ fnName: "testFunction",
417
+ fnArgs: { test: true },
418
+ fnType: "mutation",
419
+ runAt: Date.now(),
420
+ config: {
421
+ maxParallelism: 10,
422
+ logLevel: "INFO",
423
+ },
424
+ });
425
+
426
+ // Delete the pendingStart and add pendingCompletion with retry=false
427
+ await t.run(async (ctx) => {
428
+ const pendingStart = await ctx.db.query("pendingStart").first();
429
+ expect(pendingStart).toBeDefined();
430
+ assert(pendingStart);
431
+ await ctx.db.delete(pendingStart._id);
432
+
433
+ // Create a pendingCompletion with retry=false
434
+ await ctx.db.insert("pendingCompletion", {
435
+ workId: id,
436
+ segment: 1n, // Using a simple segment value for testing
437
+ runResult: { kind: "failed", error: "Test error" },
438
+ retry: false,
439
+ });
440
+ });
441
+
442
+ // According to the implementation, a job with pendingCompletion but retry=false
443
+ // is considered "running"
444
+ const status = await t.query(api.lib.status, { id });
445
+ expect(status).toEqual({ state: "running", previousAttempts: 0 });
446
+ });
447
+ });
448
+ });