@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
@@ -1,602 +1,130 @@
1
1
  import { v } from "convex/values";
2
- import { internalAction, internalMutation, mutation, query, } from "./_generated/server";
3
- import { api, internal } from "./_generated/api";
4
- import { createLogger, logLevel } from "./logging";
5
- import { components } from "./_generated/api";
6
- import { Crons } from "@convex-dev/crons";
7
- import { recordCompleted, recordStarted } from "./stats";
8
- import { completionStatus } from "./schema";
9
- const crons = new Crons(components.crons);
10
- const ACTION_TIMEOUT_MS = 15 * 60 * 1000;
2
+ import { mutation, query } from "./_generated/server.js";
3
+ import { nextSegment, onComplete, retryBehavior, config, status as statusValidator, toSegment, boundScheduledTime, } from "./shared.js";
4
+ import { logLevel } from "./logging.js";
5
+ import { kickMainLoop } from "./kick.js";
6
+ import { api } from "./_generated/api.js";
7
+ import { createLogger } from "./logging.js";
8
+ const MAX_POSSIBLE_PARALLELISM = 100;
11
9
  export const enqueue = mutation({
12
10
  args: {
13
11
  fnHandle: v.string(),
14
12
  fnName: v.string(),
15
13
  fnArgs: v.any(),
16
14
  fnType: v.union(v.literal("action"), v.literal("mutation")),
17
- options: v.object({
18
- maxParallelism: v.number(),
19
- logLevel: v.optional(logLevel),
20
- statusTtl: v.optional(v.number()),
21
- }),
15
+ runAt: v.number(),
16
+ // TODO: annotation?
17
+ onComplete: v.optional(onComplete),
18
+ retryBehavior: v.optional(retryBehavior),
19
+ config,
22
20
  },
23
21
  returns: v.id("work"),
24
- handler: async (ctx, { fnHandle, fnName, options, fnArgs, fnType }) => {
25
- await ensurePoolAndLoopExist(ctx, {
26
- maxParallelism: options.maxParallelism,
27
- statusTtl: options.statusTtl ?? 24 * 60 * 60 * 1000,
28
- logLevel: options.logLevel ?? "WARN",
29
- }, "enqueue");
22
+ handler: async (ctx, { config, runAt, ...workArgs }) => {
23
+ const console = createLogger(config.logLevel);
24
+ if (config.maxParallelism > MAX_POSSIBLE_PARALLELISM) {
25
+ throw new Error(`maxParallelism must be <= ${MAX_POSSIBLE_PARALLELISM}`);
26
+ }
27
+ if (config.maxParallelism < 1) {
28
+ throw new Error("maxParallelism must be >= 1");
29
+ }
30
+ runAt = boundScheduledTime(runAt, console);
30
31
  const workId = await ctx.db.insert("work", {
31
- fnHandle,
32
- fnName,
33
- fnArgs,
34
- fnType,
32
+ ...workArgs,
33
+ attempts: 0,
35
34
  });
36
- await ctx.db.insert("pendingStart", { workId });
37
- await kickMainLoop(ctx, "enqueue");
35
+ await ctx.db.insert("pendingStart", {
36
+ workId,
37
+ segment: toSegment(runAt),
38
+ });
39
+ await kickMainLoop(ctx, "enqueue", config);
40
+ // TODO: stats event
38
41
  return workId;
39
42
  },
40
43
  });
41
44
  export const cancel = mutation({
42
45
  args: {
43
46
  id: v.id("work"),
47
+ logLevel,
44
48
  },
45
- handler: async (ctx, { id }) => {
46
- await ctx.db.insert("pendingCancelation", { workId: id });
47
- },
48
- });
49
- async function console(ctx) {
50
- if ("runAction" in ctx) {
51
- return globalThis.console;
52
- }
53
- const pool = await ctx.db.query("pool").unique();
54
- if (!pool) {
55
- return globalThis.console;
56
- }
57
- return createLogger(pool.logLevel);
58
- }
59
- const BATCH_SIZE = 10;
60
- // There should only ever be at most one of these scheduled or running.
61
- // The scheduled one is in the "mainLoop" table.
62
- export const mainLoop = internalMutation({
63
- args: {
64
- generation: v.number(),
65
- },
66
- handler: async (ctx, args) => {
67
- const console_ = await console(ctx);
68
- const options = (await ctx.db.query("pool").unique());
69
- if (!options) {
70
- throw new Error("no pool in mainLoop");
71
- }
72
- const { maxParallelism } = options;
73
- let didSomething = false;
74
- let inProgressCountChange = 0;
75
- // Move from pendingCompletion to completedWork, deleting from inProgressWork.
76
- // Generation is used to avoid OCCs with work completing.
77
- console_.time("[mainLoop] pendingCompletion");
78
- const generation = await ctx.db.query("completionGeneration").unique();
79
- const generationNumber = generation?.generation ?? 0;
80
- if (generationNumber !== args.generation) {
81
- throw new Error(`generation mismatch: ${generationNumber} !== ${args.generation}`);
82
- }
83
- // Collect all pending completions for the previous generation.
84
- // This won't be too many because the jobs all correspond to being scheduled
85
- // by a single mainLoop (the previous one), so they're limited by MAX_PARALLELISM.
86
- const completed = await ctx.db
87
- .query("pendingCompletion")
88
- .withIndex("generation", (q) => q.eq("generation", generationNumber - 1))
89
- .collect();
90
- console_.debug(`[mainLoop] completing ${completed.length}`);
91
- await Promise.all(completed.map(async (pendingCompletion) => {
92
- const inProgressWork = await ctx.db
93
- .query("inProgressWork")
94
- .withIndex("workId", (q) => q.eq("workId", pendingCompletion.workId))
95
- .unique();
96
- if (inProgressWork) {
97
- await ctx.db.delete(inProgressWork._id);
98
- inProgressCountChange--;
99
- await ctx.db.insert("completedWork", {
100
- completionStatus: pendingCompletion.completionStatus,
101
- workId: pendingCompletion.workId,
102
- });
103
- const work = (await ctx.db.get(pendingCompletion.workId));
104
- console_.info(recordCompleted(work, pendingCompletion.completionStatus));
105
- await ctx.db.delete(work._id);
106
- }
107
- await ctx.db.delete(pendingCompletion._id);
108
- didSomething = true;
109
- }));
110
- console_.timeEnd("[mainLoop] pendingCompletion");
111
- console_.time("[mainLoop] inProgress count");
112
- // This is the only function reading and writing inProgressWork,
113
- // and it's bounded by MAX_POSSIBLE_PARALLELISM, so we can
114
- // read it all into memory. BUT we don't have to -- we can just read
115
- // the count from the inProgressCount table.
116
- const inProgressCount = await ctx.db.query("inProgressCount").unique();
117
- const inProgressBefore = (inProgressCount?.count ?? 0) + inProgressCountChange;
118
- console_.debug(`[mainLoop] ${inProgressBefore} in progress`);
119
- console_.timeEnd("[mainLoop] inProgress count");
120
- // Move from pendingStart to inProgressWork.
121
- console_.time("[mainLoop] pendingStart");
122
- // Start reading from the latest cursor _creationTime, which allows us to
123
- // skip over tombstones of pendingStart documents which haven't been cleaned up yet.
124
- // WARNING: this might skip over pendingStart documents if their _creationTime
125
- // was assigned out of order. We handle that below.
126
- const pendingStartCursorDoc = await ctx.db
127
- .query("pendingStartCursor")
128
- .unique();
129
- const pendingStartCursor = pendingStartCursorDoc?.cursor ?? 0;
130
- // Schedule as many as needed to reach maxParallelism.
131
- const toSchedule = maxParallelism - inProgressBefore;
132
- const pending = await ctx.db
133
- .query("pendingStart")
134
- .withIndex("by_creation_time", (q) => q.gt("_creationTime", pendingStartCursor))
135
- .take(toSchedule);
136
- console_.debug(`[mainLoop] scheduling ${pending.length} pending work`);
137
- await Promise.all(pending.map(async (pendingWork) => {
138
- const { scheduledId, timeoutMs } = await beginWork(ctx, pendingWork);
139
- await ctx.db.insert("inProgressWork", {
140
- running: scheduledId,
141
- timeoutMs,
142
- workId: pendingWork.workId,
143
- });
144
- inProgressCountChange++;
145
- await ctx.db.delete(pendingWork._id);
146
- didSomething = true;
147
- }));
148
- const newPendingStartCursor = pending.length > 0
149
- ? pending[pending.length - 1]._creationTime
150
- : pendingStartCursor;
151
- if (!pendingStartCursorDoc) {
152
- await ctx.db.insert("pendingStartCursor", {
153
- cursor: newPendingStartCursor,
154
- });
155
- }
156
- else {
157
- await ctx.db.patch(pendingStartCursorDoc._id, {
158
- cursor: newPendingStartCursor,
159
- });
160
- }
161
- console_.timeEnd("[mainLoop] pendingStart");
162
- console_.time("[mainLoop] pendingCancelation");
163
- const canceled = await ctx.db.query("pendingCancelation").take(BATCH_SIZE);
164
- console_.debug(`[mainLoop] canceling ${canceled.length}`);
165
- await Promise.all(canceled.map(async (pendingCancelation) => {
166
- const inProgressWork = await ctx.db
167
- .query("inProgressWork")
168
- .withIndex("workId", (q) => q.eq("workId", pendingCancelation.workId))
169
- .unique();
170
- if (inProgressWork) {
171
- await ctx.scheduler.cancel(inProgressWork.running);
172
- await ctx.db.delete(inProgressWork._id);
173
- inProgressCountChange--;
174
- await ctx.db.insert("completedWork", {
175
- workId: pendingCancelation.workId,
176
- completionStatus: "canceled",
177
- });
178
- const work = (await ctx.db.get(pendingCancelation.workId));
179
- console_.info(recordCompleted(work, "canceled"));
180
- await ctx.db.delete(work._id);
181
- }
182
- await ctx.db.delete(pendingCancelation._id);
183
- didSomething = true;
184
- }));
185
- console_.timeEnd("[mainLoop] pendingCancelation");
186
- // In case there are more pending completions at higher generation numbers,
187
- // there's more to do.
188
- if (!didSomething) {
189
- const nextPendingCompletion = await ctx.db
190
- .query("pendingCompletion")
191
- .withIndex("generation", (q) => q.eq("generation", generationNumber))
192
- .first();
193
- didSomething = nextPendingCompletion !== null;
194
- }
195
- // In case there are more "pendingStart" items we missed due to out-of-order
196
- // _creationTime, we need to check for them. Do that here, when we're
197
- // otherwise idle so it doesn't matter if this function takes a while walking
198
- // tombstones.
199
- if (!didSomething && pendingStartCursorDoc) {
200
- const pendingStartDoc = await ctx.db
201
- .query("pendingStart")
202
- .order("desc")
203
- .first();
204
- if (pendingStartDoc) {
205
- console_.warn(`[mainLoop] missed pendingStart docs; discarding cursor`);
206
- await ctx.db.delete(pendingStartCursorDoc._id);
207
- didSomething = true;
208
- }
209
- }
210
- if (!didSomething) {
211
- console_.time("[mainLoop] inProgressWork check for unclean exits");
212
- // If all completions are handled, check everything in inProgressWork.
213
- // This will find everything that timed out, failed ungracefully, was
214
- // cancelled, or succeeded without a return value.
215
- const inProgress = await ctx.db.query("inProgressWork").collect();
216
- await Promise.all(inProgress.map(async (inProgressWork) => {
217
- const result = await checkInProgressWork(ctx, inProgressWork);
218
- if (result !== null) {
219
- console_.warn("[mainLoop] inProgressWork finished uncleanly", inProgressWork.workId, result);
220
- inProgressCountChange--;
221
- await ctx.db.delete(inProgressWork._id);
222
- await ctx.db.insert("completedWork", {
223
- workId: inProgressWork.workId,
224
- completionStatus: result.completionStatus,
225
- });
226
- const work = (await ctx.db.get(inProgressWork.workId));
227
- console_.info(recordCompleted(work, result.completionStatus));
228
- didSomething = true;
229
- }
230
- }));
231
- console_.timeEnd("[mainLoop] inProgressWork check for unclean exits");
232
- }
233
- if (inProgressCountChange !== 0) {
234
- if (inProgressCount) {
235
- await ctx.db.patch(inProgressCount._id, {
236
- count: inProgressCount.count + inProgressCountChange,
237
- });
238
- }
239
- else {
240
- await ctx.db.insert("inProgressCount", {
241
- count: inProgressCountChange,
242
- });
243
- }
244
- }
245
- console_.time("[mainLoop] kickMainLoop");
246
- if (didSomething) {
247
- // There might be more to do.
248
- await loopFromMainLoop(ctx, 0);
249
- }
250
- else {
251
- // Decide when to wake up.
252
- const allInProgressWork = await ctx.db.query("inProgressWork").collect();
253
- const nextPending = await ctx.db.query("pendingStart").first();
254
- const nextPendingTime = nextPending
255
- ? nextPending._creationTime
256
- : Number.POSITIVE_INFINITY;
257
- const nextInProgress = allInProgressWork.length
258
- ? Math.min(...allInProgressWork
259
- .filter((w) => w.timeoutMs !== null)
260
- .map((w) => w._creationTime + w.timeoutMs))
261
- : Number.POSITIVE_INFINITY;
262
- const nextTime = Math.min(nextPendingTime, nextInProgress);
263
- await loopFromMainLoop(ctx, nextTime - Date.now());
264
- }
265
- console_.timeEnd("[mainLoop] kickMainLoop");
266
- },
267
- });
268
- async function beginWork(ctx, pendingStart) {
269
- const console_ = await console(ctx);
270
- const work = await ctx.db.get(pendingStart.workId);
271
- if (!work) {
272
- throw new Error("work not found");
273
- }
274
- console_.info(recordStarted(work));
275
- if (work.fnType === "action") {
276
- return {
277
- scheduledId: await ctx.scheduler.runAfter(0, internal.lib.runActionWrapper, {
278
- workId: work._id,
279
- fnHandle: work.fnHandle,
280
- fnArgs: work.fnArgs,
281
- }),
282
- timeoutMs: ACTION_TIMEOUT_MS,
283
- };
284
- }
285
- else if (work.fnType === "mutation") {
286
- return {
287
- scheduledId: await ctx.scheduler.runAfter(0, internal.lib.runMutationWrapper, {
288
- workId: work._id,
289
- fnHandle: work.fnHandle,
290
- fnArgs: work.fnArgs,
291
- }),
292
- timeoutMs: null, // Mutations cannot timeout
293
- };
294
- }
295
- else {
296
- throw new Error(`Unexpected fnType ${work.fnType}`);
297
- }
298
- }
299
- async function checkInProgressWork(ctx, doc) {
300
- const workStatus = await ctx.db.system.get(doc.running);
301
- if (workStatus === null) {
302
- return { completionStatus: "timeout" };
303
- }
304
- else if (workStatus.state.kind === "pending" ||
305
- workStatus.state.kind === "inProgress") {
306
- if (doc.timeoutMs !== null &&
307
- Date.now() - workStatus._creationTime > doc.timeoutMs) {
308
- await ctx.scheduler.cancel(doc.running);
309
- return { completionStatus: "timeout" };
310
- }
311
- }
312
- else if (workStatus.state.kind === "success") {
313
- // Usually this would be handled by pendingCompletion, but for "unknown"
314
- // functions, this is how we know that they're done, and we can't get their
315
- // return values.
316
- return { completionStatus: "success" };
317
- }
318
- else if (workStatus.state.kind === "canceled") {
319
- return { completionStatus: "canceled" };
320
- }
321
- else if (workStatus.state.kind === "failed") {
322
- return { completionStatus: "error" };
323
- }
324
- return null;
325
- }
326
- export const runActionWrapper = internalAction({
327
- args: {
328
- workId: v.id("work"),
329
- fnHandle: v.string(),
330
- fnArgs: v.any(),
331
- },
332
- handler: async (ctx, { workId, fnHandle: handleStr, fnArgs }) => {
333
- const console_ = await console(ctx);
334
- const fnHandle = handleStr;
335
- try {
336
- await ctx.runAction(fnHandle, fnArgs);
337
- // NOTE: we could run `ctx.runMutation`, but we want to guarantee execution,
338
- // and `ctx.scheduler.runAfter` won't OCC.
339
- await ctx.scheduler.runAfter(0, internal.lib.saveResult, {
340
- workId,
341
- completionStatus: "success",
342
- });
343
- }
344
- catch (e) {
345
- console_.error(e);
346
- await ctx.scheduler.runAfter(0, internal.lib.saveResult, {
347
- workId,
348
- completionStatus: "error",
349
- });
49
+ handler: async (ctx, { id, logLevel }) => {
50
+ const canceled = await cancelWorkItem(ctx, id, nextSegment(), logLevel);
51
+ if (canceled) {
52
+ await kickMainLoop(ctx, "cancel", { logLevel });
350
53
  }
54
+ // TODO: stats event
351
55
  },
352
56
  });
353
- export const saveResult = internalMutation({
354
- args: {
355
- workId: v.id("work"),
356
- completionStatus,
357
- },
358
- handler: async (ctx, args) => {
359
- const currentGeneration = await ctx.db
360
- .query("completionGeneration")
361
- .unique();
362
- const generation = currentGeneration?.generation ?? 0;
363
- await ctx.db.insert("pendingCompletion", {
364
- completionStatus: args.completionStatus,
365
- workId: args.workId,
366
- generation,
367
- });
368
- await kickMainLoop(ctx, "saveResult");
369
- },
370
- });
371
- export const bumpGeneration = internalMutation({
372
- args: {},
373
- handler: async (ctx) => {
374
- const currentGeneration = await ctx.db
375
- .query("completionGeneration")
376
- .unique();
377
- const generation = (currentGeneration?.generation ?? 0) + 1;
378
- if (!currentGeneration) {
379
- await ctx.db.insert("completionGeneration", { generation });
380
- }
381
- else {
382
- await ctx.db.patch(currentGeneration._id, { generation });
383
- }
384
- await ctx.scheduler.runAfter(0, internal.lib.mainLoop, { generation });
385
- },
386
- });
387
- export const runMutationWrapper = internalMutation({
388
- args: {
389
- workId: v.id("work"),
390
- fnHandle: v.string(),
391
- fnArgs: v.any(),
392
- },
393
- handler: async (ctx, { workId, fnHandle: handleStr, fnArgs }) => {
394
- const console_ = await console(ctx);
395
- const fnHandle = handleStr;
396
- try {
397
- await ctx.runMutation(fnHandle, fnArgs);
398
- // NOTE: we could run the `saveResult` handler here, or call `ctx.runMutation`,
399
- // but we want the mutation to be a separate transaction to reduce the window for OCCs.
400
- await ctx.scheduler.runAfter(0, internal.lib.saveResult, {
401
- workId,
402
- completionStatus: "success",
403
- });
404
- }
405
- catch (e) {
406
- console_.error(e);
407
- await ctx.scheduler.runAfter(0, internal.lib.saveResult, {
408
- workId,
409
- completionStatus: "error",
57
+ const PAGE_SIZE = 64;
58
+ export const cancelAll = mutation({
59
+ args: { logLevel, before: v.optional(v.number()) },
60
+ handler: async (ctx, { logLevel, before }) => {
61
+ const beforeTime = before ?? Date.now();
62
+ const segment = nextSegment();
63
+ const pageOfWork = await ctx.db
64
+ .query("work")
65
+ .withIndex("by_creation_time", (q) => q.lte("_creationTime", beforeTime))
66
+ .order("desc")
67
+ .take(PAGE_SIZE);
68
+ const canceled = await Promise.all(pageOfWork.map(async ({ _id }) => cancelWorkItem(ctx, _id, segment, logLevel)));
69
+ if (canceled.some((c) => c)) {
70
+ await kickMainLoop(ctx, "cancel", { logLevel });
71
+ }
72
+ if (pageOfWork.length === PAGE_SIZE) {
73
+ await ctx.scheduler.runAfter(0, api.lib.cancelAll, {
74
+ logLevel,
75
+ before: pageOfWork[pageOfWork.length - 1]._creationTime,
410
76
  });
411
77
  }
412
78
  },
413
79
  });
414
- async function getMainLoop(ctx) {
415
- const mainLoop = await ctx.db.query("mainLoop").unique();
416
- if (!mainLoop) {
417
- throw new Error("mainLoop doesn't exist");
418
- }
419
- return mainLoop;
420
- }
421
- export const stopCleanup = mutation({
422
- args: {},
423
- handler: async (ctx) => {
424
- const cron = await crons.get(ctx, { name: CLEANUP_CRON_NAME });
425
- if (cron) {
426
- await crons.delete(ctx, { id: cron.id });
427
- }
428
- },
429
- });
430
- async function loopFromMainLoop(ctx, delayMs) {
431
- const console_ = await console(ctx);
432
- const mainLoop = await getMainLoop(ctx);
433
- if (mainLoop.state.kind === "idle") {
434
- throw new Error("mainLoop is idle but `loopFromMainLoop` was called");
435
- }
436
- if (delayMs <= 0) {
437
- console_.debug("[mainLoop] mainLoop is actively running and wants to keep running");
438
- await ctx.scheduler.runAfter(0, internal.lib.bumpGeneration, {});
439
- if (mainLoop.state.kind !== "running") {
440
- await ctx.db.patch(mainLoop._id, { state: { kind: "running" } });
441
- }
442
- }
443
- else if (delayMs < Number.POSITIVE_INFINITY) {
444
- console_.debug(`[mainLoop] mainLoop wants to run after ${delayMs}ms`);
445
- const runAtTime = Date.now() + delayMs;
446
- const fn = await ctx.scheduler.runAt(runAtTime, internal.lib.bumpGeneration, {});
447
- await ctx.db.patch(mainLoop._id, {
448
- state: {
449
- kind: "scheduled",
450
- fn,
451
- runAtTime,
452
- },
453
- });
454
- }
455
- else {
456
- console_.debug("[mainLoop] mainLoop wants to become idle");
457
- await ctx.db.patch(mainLoop._id, { state: { kind: "idle" } });
458
- }
459
- }
460
- async function kickMainLoop(ctx, source) {
461
- const console_ = await console(ctx);
462
- // Look for mainLoop documents that we want to reschedule.
463
- // Only kick to run now if we're scheduled or idle.
464
- const mainLoop = await getMainLoop(ctx);
465
- if (mainLoop.state.kind === "running") {
466
- console_.debug(`[${source}] mainLoop is actively running, so we don't need to do anything`);
467
- return;
468
- }
469
- // mainLoop is scheduled to run later, so we should cancel it and reschedule.
470
- if (mainLoop.state.kind === "scheduled") {
471
- await ctx.scheduler.cancel(mainLoop.state.fn);
472
- }
473
- const currentGeneration = await ctx.db.query("completionGeneration").unique();
474
- const generation = currentGeneration?.generation ?? 0;
475
- await ctx.scheduler.runAfter(0, internal.lib.mainLoop, { generation });
476
- console_.debug(`[${source}] mainLoop was scheduled later, so reschedule it to run now`);
477
- await ctx.db.patch(mainLoop._id, { state: { kind: "running" } });
478
- }
479
80
  export const status = query({
480
- args: {
481
- id: v.id("work"),
482
- },
483
- returns: v.union(v.object({
484
- kind: v.literal("pending"),
485
- }), v.object({
486
- kind: v.literal("inProgress"),
487
- }), v.object({
488
- kind: v.literal("completed"),
489
- completionStatus,
490
- })),
81
+ args: { id: v.id("work") },
82
+ returns: statusValidator,
491
83
  handler: async (ctx, { id }) => {
492
- const completedWork = await ctx.db
493
- .query("completedWork")
494
- .withIndex("workId", (q) => q.eq("workId", id))
495
- .unique();
496
- if (completedWork) {
497
- return {
498
- kind: "completed",
499
- completionStatus: completedWork.completionStatus,
500
- };
84
+ const work = await ctx.db.get(id);
85
+ if (!work) {
86
+ return { state: "finished" };
501
87
  }
502
88
  const pendingStart = await ctx.db
503
89
  .query("pendingStart")
504
90
  .withIndex("workId", (q) => q.eq("workId", id))
505
91
  .unique();
506
92
  if (pendingStart) {
507
- return { kind: "pending" };
93
+ return { state: "pending", previousAttempts: work.attempts };
508
94
  }
509
- // If it's not pending or completed, it must be in progress.
510
- // Note we do not check inProgressWork, because we don't want to intersect
511
- // mainLoop.
512
- return { kind: "inProgress" };
513
- },
514
- });
515
- export const MAX_CLEANUP_DOCS = 1000;
516
- export const cleanup = mutation({
517
- args: {
518
- maxAgeMs: v.number(),
519
- },
520
- handler: async (ctx, { maxAgeMs }) => {
521
- const old = Date.now() - maxAgeMs;
522
- const docs = await ctx.db
523
- .query("completedWork")
524
- .withIndex("by_creation_time", (q) => q.lte("_creationTime", old))
525
- .order("desc")
526
- .take(MAX_CLEANUP_DOCS);
527
- await Promise.all(docs.map(async (doc) => {
528
- await ctx.db.delete(doc._id);
529
- const work = await ctx.db.get(doc.workId);
530
- if (work) {
531
- await ctx.db.delete(work._id);
532
- }
533
- }));
534
- if (docs.length === MAX_CLEANUP_DOCS) {
535
- // Schedule the next cleanup to run starting from the oldest document.
536
- await ctx.scheduler.runAfter(0, api.lib.cleanup, {
537
- maxAgeMs: docs[docs.length - 1]._creationTime,
538
- });
95
+ const pendingCompletion = await ctx.db
96
+ .query("pendingCompletion")
97
+ .withIndex("workId", (q) => q.eq("workId", id))
98
+ .unique();
99
+ if (pendingCompletion?.retry) {
100
+ return { state: "pending", previousAttempts: work.attempts };
539
101
  }
102
+ // Assume it's in progress. It could be pending cancelation
103
+ return { state: "running", previousAttempts: work.attempts };
540
104
  },
541
105
  });
542
- const MAX_POSSIBLE_PARALLELISM = 300;
543
- const CLEANUP_CRON_NAME = "cleanup";
544
- async function ensurePoolAndLoopExist(ctx, opts, source) {
545
- if (opts.maxParallelism > MAX_POSSIBLE_PARALLELISM) {
546
- throw new Error(`maxParallelism must be <= ${MAX_POSSIBLE_PARALLELISM}`);
547
- }
548
- if (opts.maxParallelism < 1) {
549
- throw new Error("maxParallelism must be >= 1");
550
- }
551
- const pool = await ctx.db.query("pool").unique();
552
- if (pool) {
553
- let update = false;
554
- for (const key in opts) {
555
- if (pool[key] !== opts[key]) {
556
- update = true;
557
- }
558
- }
559
- if (update) {
560
- await ctx.db.patch(pool._id, opts);
561
- }
562
- }
563
- else {
564
- const console_ = await console(ctx);
565
- await ctx.db.insert("pool", opts);
566
- console_.debug(`[${source}] starting mainLoop`);
567
- const exists = await ctx.db.query("mainLoop").unique();
568
- if (exists) {
569
- throw new Error("mainLoop already exists");
570
- }
571
- await ctx.db.insert("mainLoop", { state: { kind: "running" } });
572
- const currentGeneration = await ctx.db
573
- .query("completionGeneration")
574
- .unique();
575
- if (currentGeneration) {
576
- throw new Error("completionGeneration already exists");
577
- }
578
- await ctx.db.insert("completionGeneration", { generation: 0 });
579
- await ctx.scheduler.runAfter(0, internal.lib.mainLoop, { generation: 0 });
580
- }
581
- await ensureCleanupCron(ctx, opts.statusTtl);
582
- }
583
- async function ensureCleanupCron(ctx, ttl) {
584
- let cleanupCron = await crons.get(ctx, { name: CLEANUP_CRON_NAME });
585
- if (ttl === Number.POSITIVE_INFINITY) {
586
- if (cleanupCron) {
587
- await crons.delete(ctx, { id: cleanupCron.id });
588
- }
589
- return;
590
- }
591
- const cronFrequencyMs = Math.min(ttl, 24 * 60 * 60 * 1000);
592
- if (cleanupCron !== null &&
593
- !(cleanupCron.schedule.kind === "interval" &&
594
- cleanupCron.schedule.ms === cronFrequencyMs)) {
595
- await crons.delete(ctx, { id: cleanupCron.id });
596
- cleanupCron = null;
597
- }
598
- if (cleanupCron === null) {
599
- await crons.register(ctx, { kind: "interval", ms: ttl }, api.lib.cleanup, { maxAgeMs: ttl }, CLEANUP_CRON_NAME);
600
- }
106
+ async function cancelWorkItem(ctx, workId, segment, logLevel) {
107
+ const console = createLogger(logLevel);
108
+ // No-op if the work doesn't exist or has completed.
109
+ const work = await ctx.db.get(workId);
110
+ if (!work) {
111
+ console.warn(`[cancel] work ${workId} doesn't exist`);
112
+ return false;
113
+ }
114
+ const pendingCancelation = await ctx.db
115
+ .query("pendingCancelation")
116
+ .withIndex("workId", (q) => q.eq("workId", workId))
117
+ .unique();
118
+ if (pendingCancelation) {
119
+ console.warn(`[cancel] work ${workId} has already been canceled`);
120
+ return false;
121
+ }
122
+ await ctx.db.insert("pendingCancelation", {
123
+ workId,
124
+ segment,
125
+ });
126
+ return true;
601
127
  }
128
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
129
+ const console = "THIS IS A REMINDER TO USE createLogger";
602
130
  //# sourceMappingURL=lib.js.map