@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.
- package/README.md +155 -17
- package/dist/commonjs/client/index.d.ts +123 -35
- package/dist/commonjs/client/index.d.ts.map +1 -1
- package/dist/commonjs/client/index.js +122 -15
- package/dist/commonjs/client/index.js.map +1 -1
- package/dist/commonjs/client/utils.d.ts +16 -0
- package/dist/commonjs/client/utils.d.ts.map +1 -0
- package/dist/commonjs/client/utils.js +2 -0
- package/dist/commonjs/client/utils.js.map +1 -0
- package/dist/commonjs/component/complete.d.ts +89 -0
- package/dist/commonjs/component/complete.d.ts.map +1 -0
- package/dist/commonjs/component/complete.js +80 -0
- package/dist/commonjs/component/complete.js.map +1 -0
- package/dist/commonjs/component/convex.config.d.ts.map +1 -1
- package/dist/commonjs/component/convex.config.js +0 -2
- package/dist/commonjs/component/convex.config.js.map +1 -1
- package/dist/commonjs/component/kick.d.ts +9 -0
- package/dist/commonjs/component/kick.d.ts.map +1 -0
- package/dist/commonjs/component/kick.js +97 -0
- package/dist/commonjs/component/kick.js.map +1 -0
- package/dist/commonjs/component/lib.d.ts +23 -32
- package/dist/commonjs/component/lib.d.ts.map +1 -1
- package/dist/commonjs/component/lib.js +91 -563
- package/dist/commonjs/component/lib.js.map +1 -1
- package/dist/commonjs/component/logging.d.ts +5 -3
- package/dist/commonjs/component/logging.d.ts.map +1 -1
- package/dist/commonjs/component/logging.js +13 -2
- package/dist/commonjs/component/logging.js.map +1 -1
- package/dist/commonjs/component/loop.d.ts +13 -0
- package/dist/commonjs/component/loop.d.ts.map +1 -0
- package/dist/commonjs/component/loop.js +482 -0
- package/dist/commonjs/component/loop.js.map +1 -0
- package/dist/commonjs/component/recovery.d.ts +24 -0
- package/dist/commonjs/component/recovery.d.ts.map +1 -0
- package/dist/commonjs/component/recovery.js +94 -0
- package/dist/commonjs/component/recovery.js.map +1 -0
- package/dist/commonjs/component/schema.d.ts +167 -93
- package/dist/commonjs/component/schema.d.ts.map +1 -1
- package/dist/commonjs/component/schema.js +56 -65
- package/dist/commonjs/component/schema.js.map +1 -1
- package/dist/commonjs/component/shared.d.ts +138 -0
- package/dist/commonjs/component/shared.d.ts.map +1 -0
- package/dist/commonjs/component/shared.js +77 -0
- package/dist/commonjs/component/shared.js.map +1 -0
- package/dist/commonjs/component/stats.d.ts +6 -3
- package/dist/commonjs/component/stats.d.ts.map +1 -1
- package/dist/commonjs/component/stats.js +23 -4
- package/dist/commonjs/component/stats.js.map +1 -1
- package/dist/commonjs/component/worker.d.ts +15 -0
- package/dist/commonjs/component/worker.d.ts.map +1 -0
- package/dist/commonjs/component/worker.js +73 -0
- package/dist/commonjs/component/worker.js.map +1 -0
- package/dist/esm/client/index.d.ts +123 -35
- package/dist/esm/client/index.d.ts.map +1 -1
- package/dist/esm/client/index.js +122 -15
- package/dist/esm/client/index.js.map +1 -1
- package/dist/esm/client/utils.d.ts +16 -0
- package/dist/esm/client/utils.d.ts.map +1 -0
- package/dist/esm/client/utils.js +2 -0
- package/dist/esm/client/utils.js.map +1 -0
- package/dist/esm/component/complete.d.ts +89 -0
- package/dist/esm/component/complete.d.ts.map +1 -0
- package/dist/esm/component/complete.js +80 -0
- package/dist/esm/component/complete.js.map +1 -0
- package/dist/esm/component/convex.config.d.ts.map +1 -1
- package/dist/esm/component/convex.config.js +0 -2
- package/dist/esm/component/convex.config.js.map +1 -1
- package/dist/esm/component/kick.d.ts +9 -0
- package/dist/esm/component/kick.d.ts.map +1 -0
- package/dist/esm/component/kick.js +97 -0
- package/dist/esm/component/kick.js.map +1 -0
- package/dist/esm/component/lib.d.ts +23 -32
- package/dist/esm/component/lib.d.ts.map +1 -1
- package/dist/esm/component/lib.js +91 -563
- package/dist/esm/component/lib.js.map +1 -1
- package/dist/esm/component/logging.d.ts +5 -3
- package/dist/esm/component/logging.d.ts.map +1 -1
- package/dist/esm/component/logging.js +13 -2
- package/dist/esm/component/logging.js.map +1 -1
- package/dist/esm/component/loop.d.ts +13 -0
- package/dist/esm/component/loop.d.ts.map +1 -0
- package/dist/esm/component/loop.js +482 -0
- package/dist/esm/component/loop.js.map +1 -0
- package/dist/esm/component/recovery.d.ts +24 -0
- package/dist/esm/component/recovery.d.ts.map +1 -0
- package/dist/esm/component/recovery.js +94 -0
- package/dist/esm/component/recovery.js.map +1 -0
- package/dist/esm/component/schema.d.ts +167 -93
- package/dist/esm/component/schema.d.ts.map +1 -1
- package/dist/esm/component/schema.js +56 -65
- package/dist/esm/component/schema.js.map +1 -1
- package/dist/esm/component/shared.d.ts +138 -0
- package/dist/esm/component/shared.d.ts.map +1 -0
- package/dist/esm/component/shared.js +77 -0
- package/dist/esm/component/shared.js.map +1 -0
- package/dist/esm/component/stats.d.ts +6 -3
- package/dist/esm/component/stats.d.ts.map +1 -1
- package/dist/esm/component/stats.js +23 -4
- package/dist/esm/component/stats.js.map +1 -1
- package/dist/esm/component/worker.d.ts +15 -0
- package/dist/esm/component/worker.d.ts.map +1 -0
- package/dist/esm/component/worker.js +73 -0
- package/dist/esm/component/worker.js.map +1 -0
- package/package.json +6 -5
- package/src/client/index.ts +232 -68
- package/src/client/utils.ts +45 -0
- package/src/component/README.md +73 -0
- package/src/component/_generated/api.d.ts +38 -66
- package/src/component/complete.test.ts +508 -0
- package/src/component/complete.ts +98 -0
- package/src/component/convex.config.ts +0 -3
- package/src/component/kick.test.ts +285 -0
- package/src/component/kick.ts +118 -0
- package/src/component/lib.test.ts +448 -0
- package/src/component/lib.ts +105 -667
- package/src/component/logging.ts +24 -12
- package/src/component/loop.test.ts +1204 -0
- package/src/component/loop.ts +637 -0
- package/src/component/recovery.test.ts +541 -0
- package/src/component/recovery.ts +96 -0
- package/src/component/schema.ts +61 -77
- package/src/component/setup.test.ts +5 -0
- package/src/component/shared.ts +141 -0
- package/src/component/stats.ts +26 -8
- package/src/component/worker.ts +81 -0
|
@@ -1,602 +1,130 @@
|
|
|
1
1
|
import { v } from "convex/values";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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, {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
fnArgs,
|
|
34
|
-
fnType,
|
|
32
|
+
...workArgs,
|
|
33
|
+
attempts: 0,
|
|
35
34
|
});
|
|
36
|
-
await ctx.db.insert("pendingStart", {
|
|
37
|
-
|
|
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
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
const
|
|
360
|
-
.query("
|
|
361
|
-
.
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
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
|
|
493
|
-
|
|
494
|
-
|
|
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 {
|
|
93
|
+
return { state: "pending", previousAttempts: work.attempts };
|
|
508
94
|
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
543
|
-
const
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|