@convex-dev/workpool 0.2.0-beta.0 → 0.2.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 (106) hide show
  1. package/README.md +7 -16
  2. package/dist/commonjs/client/index.d.ts +3 -3
  3. package/dist/commonjs/client/index.d.ts.map +1 -1
  4. package/dist/commonjs/client/index.js +10 -5
  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 +80 -0
  9. package/dist/commonjs/component/complete.js.map +1 -0
  10. package/dist/commonjs/component/kick.d.ts +1 -2
  11. package/dist/commonjs/component/kick.d.ts.map +1 -1
  12. package/dist/commonjs/component/kick.js +7 -5
  13. package/dist/commonjs/component/kick.js.map +1 -1
  14. package/dist/commonjs/component/lib.d.ts +3 -3
  15. package/dist/commonjs/component/lib.d.ts.map +1 -1
  16. package/dist/commonjs/component/lib.js +43 -20
  17. package/dist/commonjs/component/lib.js.map +1 -1
  18. package/dist/commonjs/component/logging.d.ts.map +1 -1
  19. package/dist/commonjs/component/logging.js +1 -2
  20. package/dist/commonjs/component/logging.js.map +1 -1
  21. package/dist/commonjs/component/loop.d.ts +1 -14
  22. package/dist/commonjs/component/loop.d.ts.map +1 -1
  23. package/dist/commonjs/component/loop.js +215 -178
  24. package/dist/commonjs/component/loop.js.map +1 -1
  25. package/dist/commonjs/component/recovery.d.ts +16 -0
  26. package/dist/commonjs/component/recovery.d.ts.map +1 -1
  27. package/dist/commonjs/component/recovery.js +64 -44
  28. package/dist/commonjs/component/recovery.js.map +1 -1
  29. package/dist/commonjs/component/schema.d.ts +6 -2
  30. package/dist/commonjs/component/schema.d.ts.map +1 -1
  31. package/dist/commonjs/component/schema.js +5 -3
  32. package/dist/commonjs/component/schema.js.map +1 -1
  33. package/dist/commonjs/component/shared.d.ts +20 -11
  34. package/dist/commonjs/component/shared.d.ts.map +1 -1
  35. package/dist/commonjs/component/shared.js +18 -5
  36. package/dist/commonjs/component/shared.js.map +1 -1
  37. package/dist/commonjs/component/stats.d.ts +21 -13
  38. package/dist/commonjs/component/stats.d.ts.map +1 -1
  39. package/dist/commonjs/component/stats.js +32 -22
  40. package/dist/commonjs/component/stats.js.map +1 -1
  41. package/dist/commonjs/component/worker.d.ts +2 -12
  42. package/dist/commonjs/component/worker.d.ts.map +1 -1
  43. package/dist/commonjs/component/worker.js +23 -36
  44. package/dist/commonjs/component/worker.js.map +1 -1
  45. package/dist/esm/client/index.d.ts +3 -3
  46. package/dist/esm/client/index.d.ts.map +1 -1
  47. package/dist/esm/client/index.js +10 -5
  48. package/dist/esm/client/index.js.map +1 -1
  49. package/dist/esm/component/complete.d.ts +89 -0
  50. package/dist/esm/component/complete.d.ts.map +1 -0
  51. package/dist/esm/component/complete.js +80 -0
  52. package/dist/esm/component/complete.js.map +1 -0
  53. package/dist/esm/component/kick.d.ts +1 -2
  54. package/dist/esm/component/kick.d.ts.map +1 -1
  55. package/dist/esm/component/kick.js +7 -5
  56. package/dist/esm/component/kick.js.map +1 -1
  57. package/dist/esm/component/lib.d.ts +3 -3
  58. package/dist/esm/component/lib.d.ts.map +1 -1
  59. package/dist/esm/component/lib.js +43 -20
  60. package/dist/esm/component/lib.js.map +1 -1
  61. package/dist/esm/component/logging.d.ts.map +1 -1
  62. package/dist/esm/component/logging.js +1 -2
  63. package/dist/esm/component/logging.js.map +1 -1
  64. package/dist/esm/component/loop.d.ts +1 -14
  65. package/dist/esm/component/loop.d.ts.map +1 -1
  66. package/dist/esm/component/loop.js +215 -178
  67. package/dist/esm/component/loop.js.map +1 -1
  68. package/dist/esm/component/recovery.d.ts +16 -0
  69. package/dist/esm/component/recovery.d.ts.map +1 -1
  70. package/dist/esm/component/recovery.js +64 -44
  71. package/dist/esm/component/recovery.js.map +1 -1
  72. package/dist/esm/component/schema.d.ts +6 -2
  73. package/dist/esm/component/schema.d.ts.map +1 -1
  74. package/dist/esm/component/schema.js +5 -3
  75. package/dist/esm/component/schema.js.map +1 -1
  76. package/dist/esm/component/shared.d.ts +20 -11
  77. package/dist/esm/component/shared.d.ts.map +1 -1
  78. package/dist/esm/component/shared.js +18 -5
  79. package/dist/esm/component/shared.js.map +1 -1
  80. package/dist/esm/component/stats.d.ts +21 -13
  81. package/dist/esm/component/stats.d.ts.map +1 -1
  82. package/dist/esm/component/stats.js +32 -22
  83. package/dist/esm/component/stats.js.map +1 -1
  84. package/dist/esm/component/worker.d.ts +2 -12
  85. package/dist/esm/component/worker.d.ts.map +1 -1
  86. package/dist/esm/component/worker.js +23 -36
  87. package/dist/esm/component/worker.js.map +1 -1
  88. package/package.json +7 -6
  89. package/src/client/index.ts +18 -8
  90. package/src/component/README.md +15 -15
  91. package/src/component/_generated/api.d.ts +7 -2
  92. package/src/component/complete.test.ts +508 -0
  93. package/src/component/complete.ts +98 -0
  94. package/src/component/kick.test.ts +13 -13
  95. package/src/component/kick.ts +13 -8
  96. package/src/component/lib.test.ts +262 -17
  97. package/src/component/lib.ts +55 -24
  98. package/src/component/logging.ts +1 -2
  99. package/src/component/loop.test.ts +1158 -0
  100. package/src/component/loop.ts +289 -221
  101. package/src/component/recovery.test.ts +541 -0
  102. package/src/component/recovery.ts +80 -63
  103. package/src/component/schema.ts +6 -4
  104. package/src/component/shared.ts +21 -6
  105. package/src/component/stats.ts +48 -25
  106. package/src/component/worker.ts +25 -38
@@ -2,16 +2,21 @@ import { internal } from "./_generated/api.js";
2
2
  import { internalMutation, MutationCtx } from "./_generated/server.js";
3
3
  import { createLogger, DEFAULT_LOG_LEVEL } from "./logging.js";
4
4
  import { INITIAL_STATE } from "./loop.js";
5
- import { Config, nextSegment } from "./shared.js";
5
+ import {
6
+ boundScheduledTime,
7
+ Config,
8
+ DEFAULT_MAX_PARALLELISM,
9
+ fromSegment,
10
+ nextSegment,
11
+ } from "./shared.js";
6
12
 
7
- export const DEFAULT_MAX_PARALLELISM = 10;
8
13
  /**
9
14
  * Called from outside the loop:
10
15
  */
11
16
 
12
17
  export async function kickMainLoop(
13
18
  ctx: MutationCtx,
14
- source: "enqueue" | "cancel" | "saveResult" | "recovery",
19
+ source: "enqueue" | "cancel" | "complete",
15
20
  config?: Partial<Config>
16
21
  ): Promise<void> {
17
22
  const globals = await getOrUpdateGlobals(ctx, config);
@@ -51,12 +56,12 @@ export async function kickMainLoop(
51
56
  `[${source}] main is marked as scheduled, but it's status is ${scheduled?.state.kind}`
52
57
  );
53
58
  }
59
+ } else if (runStatus.state.kind === "idle") {
60
+ console.debug(`[${source}] main was idle, so run it now`);
54
61
  }
55
- console.debug(
56
- `[${source}] main was scheduled later, so reschedule it to run now`
57
- );
58
62
  await ctx.db.patch(runStatus._id, { state: { kind: "running" } });
59
- await ctx.scheduler.runAfter(0, internal.loop.main, {
63
+ const scheduledTime = boundScheduledTime(fromSegment(segment), console);
64
+ await ctx.scheduler.runAt(scheduledTime, internal.loop.main, {
60
65
  generation: runStatus.state.generation,
61
66
  segment,
62
67
  });
@@ -67,7 +72,7 @@ export const forceKick = internalMutation({
67
72
  handler: async (ctx) => {
68
73
  const runStatus = await getOrCreateRunStatus(ctx);
69
74
  await ctx.db.delete(runStatus._id);
70
- await kickMainLoop(ctx, "recovery");
75
+ await kickMainLoop(ctx, "complete");
71
76
  },
72
77
  });
73
78
 
@@ -1,5 +1,13 @@
1
1
  import { convexTest } from "convex-test";
2
- import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
2
+ import {
3
+ describe,
4
+ expect,
5
+ it,
6
+ beforeEach,
7
+ afterEach,
8
+ vi,
9
+ assert,
10
+ } from "vitest";
3
11
  import { Id } from "./_generated/dataModel";
4
12
  import schema from "./schema";
5
13
  import { api } from "./_generated/api";
@@ -36,13 +44,13 @@ describe("lib", () => {
36
44
  runAt: Date.now(),
37
45
  config: {
38
46
  maxParallelism: 10,
39
- logLevel: "INFO",
47
+ logLevel: "WARN",
40
48
  },
41
49
  });
42
50
 
43
51
  expect(id).toBeDefined();
44
52
  const status = await t.query(api.lib.status, { id });
45
- expect(status).toEqual({ state: "pending", attempt: 0 });
53
+ expect(status).toEqual({ state: "pending", previousAttempts: 0 });
46
54
  });
47
55
 
48
56
  it("should throw error if maxParallelism is too high", async () => {
@@ -55,11 +63,27 @@ describe("lib", () => {
55
63
  runAt: Date.now(),
56
64
  config: {
57
65
  maxParallelism: 101, // More than MAX_POSSIBLE_PARALLELISM
58
- logLevel: "INFO",
66
+ logLevel: "WARN",
59
67
  },
60
68
  })
61
69
  ).rejects.toThrow("maxParallelism must be <= 100");
62
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: "WARN",
83
+ },
84
+ })
85
+ ).rejects.toThrow("maxParallelism must be >= 1");
86
+ });
63
87
  });
64
88
 
65
89
  describe("cancel", () => {
@@ -72,13 +96,13 @@ describe("lib", () => {
72
96
  runAt: Date.now(),
73
97
  config: {
74
98
  maxParallelism: 10,
75
- logLevel: "INFO",
99
+ logLevel: "WARN",
76
100
  },
77
101
  });
78
102
 
79
103
  await t.mutation(api.lib.cancel, {
80
104
  id,
81
- logLevel: "INFO",
105
+ logLevel: "WARN",
82
106
  });
83
107
 
84
108
  // Verify a pending cancelation was created
@@ -90,6 +114,74 @@ describe("lib", () => {
90
114
  expect(pendingCancelations[0].workId).toBe(id);
91
115
  });
92
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: "WARN",
128
+ },
129
+ });
130
+
131
+ // Cancel the first time
132
+ await t.mutation(api.lib.cancel, {
133
+ id,
134
+ logLevel: "WARN",
135
+ });
136
+
137
+ // Cancel the second time
138
+ await t.mutation(api.lib.cancel, {
139
+ id,
140
+ logLevel: "WARN",
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: "WARN",
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: "WARN",
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
+ });
93
185
  });
94
186
 
95
187
  describe("cancelAll", () => {
@@ -101,17 +193,17 @@ describe("lib", () => {
101
193
  fnName: "testFunction",
102
194
  fnArgs: { test: i },
103
195
  fnType: "mutation",
104
- runAt: Date.now(),
196
+ runAt: Date.now() + 5 * 60 * 1000,
105
197
  config: {
106
198
  maxParallelism: 10,
107
- logLevel: "INFO",
199
+ logLevel: "WARN",
108
200
  },
109
201
  });
110
202
  ids.push(id);
111
203
  }
112
204
 
113
205
  await t.mutation(api.lib.cancelAll, {
114
- logLevel: "INFO",
206
+ logLevel: "WARN",
115
207
  before: Date.now() + 1000,
116
208
  });
117
209
 
@@ -125,6 +217,54 @@ describe("lib", () => {
125
217
  expect(canceledIds).toEqual(expect.arrayContaining(ids));
126
218
  });
127
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: "WARN",
235
+ },
236
+ });
237
+ }
238
+
239
+ await t.mutation(api.lib.cancelAll, {
240
+ logLevel: "WARN",
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
+ });
128
268
  });
129
269
 
130
270
  describe("status", () => {
@@ -137,7 +277,7 @@ describe("lib", () => {
137
277
  runAt: Date.now(),
138
278
  config: {
139
279
  maxParallelism: 10,
140
- logLevel: "INFO",
280
+ logLevel: "WARN",
141
281
  },
142
282
  });
143
283
  await t.run(async (ctx) => {
@@ -157,7 +297,7 @@ describe("lib", () => {
157
297
  runAt: Date.now(),
158
298
  config: {
159
299
  maxParallelism: 10,
160
- logLevel: "INFO",
300
+ logLevel: "WARN",
161
301
  },
162
302
  });
163
303
 
@@ -171,7 +311,7 @@ describe("lib", () => {
171
311
  });
172
312
 
173
313
  const status = await t.query(api.lib.status, { id });
174
- expect(status).toEqual({ state: "pending", attempt: 0 });
314
+ expect(status).toEqual({ state: "pending", previousAttempts: 0 });
175
315
  });
176
316
 
177
317
  it("should return running state when work is in progress", async () => {
@@ -183,7 +323,37 @@ describe("lib", () => {
183
323
  runAt: Date.now(),
184
324
  config: {
185
325
  maxParallelism: 10,
186
- logLevel: "INFO",
326
+ logLevel: "WARN",
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: "WARN",
187
357
  },
188
358
  });
189
359
 
@@ -191,13 +361,88 @@ describe("lib", () => {
191
361
  await t.run(async (ctx) => {
192
362
  const pendingStart = await ctx.db.query("pendingStart").first();
193
363
  expect(pendingStart).toBeDefined();
194
- if (pendingStart) {
195
- await ctx.db.delete(pendingStart._id);
196
- }
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: "WARN",
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: "WARN",
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
+ });
197
440
  });
198
441
 
442
+ // According to the implementation, a job with pendingCompletion but retry=false
443
+ // is considered "running"
199
444
  const status = await t.query(api.lib.status, { id });
200
- expect(status).toEqual({ state: "running", attempt: 0 });
445
+ expect(status).toEqual({ state: "running", previousAttempts: 0 });
201
446
  });
202
447
  });
203
448
  });
@@ -1,5 +1,5 @@
1
1
  import { v } from "convex/values";
2
- import { mutation, query } from "./_generated/server.js";
2
+ import { mutation, MutationCtx, query } from "./_generated/server.js";
3
3
  import {
4
4
  nextSegment,
5
5
  onComplete,
@@ -8,11 +8,14 @@ import {
8
8
  status as statusValidator,
9
9
  toSegment,
10
10
  boundScheduledTime,
11
+ max,
11
12
  } from "./shared.js";
12
- import { logLevel } from "./logging.js";
13
+ import { LogLevel, logLevel } from "./logging.js";
13
14
  import { kickMainLoop } from "./kick.js";
14
15
  import { api } from "./_generated/api.js";
15
16
  import { createLogger } from "./logging.js";
17
+ import { Id } from "./_generated/dataModel.js";
18
+ import { recordEnqueued } from "./stats.js";
16
19
 
17
20
  const MAX_POSSIBLE_PARALLELISM = 100;
18
21
 
@@ -44,10 +47,10 @@ export const enqueue = mutation({
44
47
  });
45
48
  await ctx.db.insert("pendingStart", {
46
49
  workId,
47
- segment: toSegment(runAt),
50
+ segment: max(toSegment(runAt), nextSegment()),
48
51
  });
49
52
  await kickMainLoop(ctx, "enqueue", config);
50
- // TODO: stats event
53
+ recordEnqueued(console, { workId, fnName: workArgs.fnName, runAt });
51
54
  return workId;
52
55
  },
53
56
  });
@@ -58,11 +61,10 @@ export const cancel = mutation({
58
61
  logLevel,
59
62
  },
60
63
  handler: async (ctx, { id, logLevel }) => {
61
- await ctx.db.insert("pendingCancelation", {
62
- workId: id,
63
- segment: nextSegment(),
64
- });
65
- await kickMainLoop(ctx, "cancel", { logLevel });
64
+ const canceled = await cancelWorkItem(ctx, id, nextSegment(), logLevel);
65
+ if (canceled) {
66
+ await kickMainLoop(ctx, "cancel", { logLevel });
67
+ }
66
68
  // TODO: stats event
67
69
  },
68
70
  });
@@ -78,26 +80,20 @@ export const cancelAll = mutation({
78
80
  .withIndex("by_creation_time", (q) => q.lte("_creationTime", beforeTime))
79
81
  .order("desc")
80
82
  .take(PAGE_SIZE);
81
- await Promise.all(
82
- pageOfWork.map(async ({ _id }) => {
83
- if (
84
- await ctx.db
85
- .query("pendingCancelation")
86
- .withIndex("workId", (q) => q.eq("workId", _id))
87
- .first()
88
- ) {
89
- return;
90
- }
91
- await ctx.db.insert("pendingCancelation", { workId: _id, segment });
92
- })
83
+ const canceled = await Promise.all(
84
+ pageOfWork.map(async ({ _id }) =>
85
+ cancelWorkItem(ctx, _id, segment, logLevel)
86
+ )
93
87
  );
88
+ if (canceled.some((c) => c)) {
89
+ await kickMainLoop(ctx, "cancel", { logLevel });
90
+ }
94
91
  if (pageOfWork.length === PAGE_SIZE) {
95
92
  await ctx.scheduler.runAfter(0, api.lib.cancelAll, {
96
93
  logLevel,
97
94
  before: pageOfWork[pageOfWork.length - 1]._creationTime,
98
95
  });
99
96
  }
100
- await kickMainLoop(ctx, "cancel", { logLevel });
101
97
  },
102
98
  });
103
99
 
@@ -114,12 +110,47 @@ export const status = query({
114
110
  .withIndex("workId", (q) => q.eq("workId", id))
115
111
  .unique();
116
112
  if (pendingStart) {
117
- return { state: "pending", attempt: work.attempts } as const;
113
+ return { state: "pending", previousAttempts: work.attempts } as const;
114
+ }
115
+ const pendingCompletion = await ctx.db
116
+ .query("pendingCompletion")
117
+ .withIndex("workId", (q) => q.eq("workId", id))
118
+ .unique();
119
+ if (pendingCompletion?.retry) {
120
+ return { state: "pending", previousAttempts: work.attempts } as const;
118
121
  }
119
122
  // Assume it's in progress. It could be pending cancelation
120
- return { state: "running", attempt: work.attempts } as const;
123
+ return { state: "running", previousAttempts: work.attempts } as const;
121
124
  },
122
125
  });
123
126
 
127
+ async function cancelWorkItem(
128
+ ctx: MutationCtx,
129
+ workId: Id<"work">,
130
+ segment: bigint,
131
+ logLevel: LogLevel
132
+ ) {
133
+ const console = createLogger(logLevel);
134
+ // No-op if the work doesn't exist or has completed.
135
+ const work = await ctx.db.get(workId);
136
+ if (!work) {
137
+ console.warn(`[cancel] work ${workId} doesn't exist`);
138
+ return false;
139
+ }
140
+ const pendingCancelation = await ctx.db
141
+ .query("pendingCancelation")
142
+ .withIndex("workId", (q) => q.eq("workId", workId))
143
+ .unique();
144
+ if (pendingCancelation) {
145
+ console.warn(`[cancel] work ${workId} has already been canceled`);
146
+ return false;
147
+ }
148
+ await ctx.db.insert("pendingCancelation", {
149
+ workId,
150
+ segment,
151
+ });
152
+ return true;
153
+ }
154
+
124
155
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
125
156
  const console = "THIS IS A REMINDER TO USE createLogger";
@@ -53,9 +53,8 @@ export function createLogger(level?: LogLevel): Logger {
53
53
  event: (event: string, payload: Record<string, unknown>) => {
54
54
  if (levelIndex <= 1) {
55
55
  const fullPayload = {
56
- system: "idempotent-workpool-component",
57
56
  event,
58
- payload,
57
+ ...payload,
59
58
  };
60
59
  console.info(JSON.stringify(fullPayload));
61
60
  }