@terreno/jobs 57.4.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/LICENSE +21 -0
- package/README.md +56 -0
- package/biome.jsonc +20 -0
- package/bunfig.toml +5 -0
- package/dist/activeJobExecution.d.ts +3 -0
- package/dist/activeJobExecution.js +29 -0
- package/dist/claimLock.d.ts +16 -0
- package/dist/claimLock.js +34 -0
- package/dist/dispatchCompensation.d.ts +3 -0
- package/dist/dispatchCompensation.js +89 -0
- package/dist/dispatchError.d.ts +10 -0
- package/dist/dispatchError.js +33 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +15 -0
- package/dist/jobExecutor.d.ts +34 -0
- package/dist/jobExecutor.js +328 -0
- package/dist/jobsApp.d.ts +61 -0
- package/dist/jobsApp.js +308 -0
- package/dist/jobsService.d.ts +38 -0
- package/dist/jobsService.js +505 -0
- package/dist/modelTypes.d.ts +52 -0
- package/dist/modelTypes.js +4 -0
- package/dist/models/job.d.ts +2 -0
- package/dist/models/job.js +124 -0
- package/dist/models/jobSchedule.d.ts +2 -0
- package/dist/models/jobSchedule.js +48 -0
- package/dist/retryBackoff.d.ts +17 -0
- package/dist/retryBackoff.js +22 -0
- package/dist/routes/jobsAdmin.d.ts +15 -0
- package/dist/routes/jobsAdmin.js +596 -0
- package/dist/routes/jobsExecute.d.ts +12 -0
- package/dist/routes/jobsExecute.js +119 -0
- package/dist/runners/gcpCloudTasks.d.ts +78 -0
- package/dist/runners/gcpCloudTasks.js +256 -0
- package/dist/runners/mongoRunner.d.ts +14 -0
- package/dist/runners/mongoRunner.js +204 -0
- package/dist/runners/vercelQueues.d.ts +111 -0
- package/dist/runners/vercelQueues.js +326 -0
- package/dist/scheduleCron.d.ts +15 -0
- package/dist/scheduleCron.js +61 -0
- package/dist/scheduler.d.ts +8 -0
- package/dist/scheduler.js +178 -0
- package/dist/types.d.ts +64 -0
- package/dist/types.js +2 -0
- package/package.json +82 -0
- package/src/activeJobExecution.ts +28 -0
- package/src/claimLock.ts +37 -0
- package/src/dispatchCompensation.ts +39 -0
- package/src/dispatchError.ts +14 -0
- package/src/index.ts +24 -0
- package/src/jobExecutor.ts +333 -0
- package/src/jobsApp.ts +255 -0
- package/src/jobsService.ts +395 -0
- package/src/modelTypes.ts +78 -0
- package/src/models/job.ts +129 -0
- package/src/models/jobSchedule.ts +57 -0
- package/src/retryBackoff.ts +34 -0
- package/src/routes/jobsAdmin.ts +562 -0
- package/src/routes/jobsExecute.ts +95 -0
- package/src/runners/gcpCloudTasks.ts +290 -0
- package/src/runners/mongoRunner.ts +135 -0
- package/src/runners/vercelQueues.ts +394 -0
- package/src/scheduleCron.ts +65 -0
- package/src/scheduler.ts +112 -0
- package/src/tests/bunSetup.ts +9 -0
- package/src/tests/gcpCloudTasksRunner.test.ts +349 -0
- package/src/tests/jobModel.test.ts +62 -0
- package/src/tests/jobScheduleModel.test.ts +44 -0
- package/src/tests/jobsAdmin.test.ts +728 -0
- package/src/tests/jobsApp.test.ts +113 -0
- package/src/tests/jobsCustomRunner.test.ts +367 -0
- package/src/tests/jobsDelayIdempotency.test.ts +255 -0
- package/src/tests/jobsExecute.test.ts +640 -0
- package/src/tests/jobsRetry.test.ts +420 -0
- package/src/tests/jobsSchedule.test.ts +505 -0
- package/src/tests/jobsTracer.test.ts +165 -0
- package/src/tests/jobsWorkerLifecycle.test.ts +509 -0
- package/src/tests/packageExports.test.ts +24 -0
- package/src/tests/retryBackoff.test.ts +76 -0
- package/src/tests/scheduleCron.test.ts +87 -0
- package/src/tests/vercelQueuesRunner.test.ts +524 -0
- package/src/types.ts +74 -0
- package/tsconfig.json +33 -0
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
import {beforeEach, describe, it} from "bun:test";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import {TerrenoApp, type UserModel as UserModelType} from "@terreno/api";
|
|
4
|
+
import {setupDb, UserModel} from "@terreno/api/testing";
|
|
5
|
+
import {assert} from "chai";
|
|
6
|
+
import {DateTime} from "luxon";
|
|
7
|
+
|
|
8
|
+
import {JobsApp} from "../jobsApp";
|
|
9
|
+
import {getJobsService} from "../jobsService";
|
|
10
|
+
import {Job} from "../models/job";
|
|
11
|
+
import {JobSchedule} from "../models/jobSchedule";
|
|
12
|
+
import {getWorkerId, hasWorkerIdPrefix} from "../runners/mongoRunner";
|
|
13
|
+
|
|
14
|
+
const typedUserModel = UserModel as unknown as UserModelType;
|
|
15
|
+
|
|
16
|
+
const waitUntil = async (
|
|
17
|
+
predicate: () => Promise<boolean>,
|
|
18
|
+
options?: {intervalMs?: number; timeoutMs?: number}
|
|
19
|
+
): Promise<void> => {
|
|
20
|
+
const intervalMs = options?.intervalMs ?? 25;
|
|
21
|
+
const timeoutMs = options?.timeoutMs ?? 5_000;
|
|
22
|
+
const deadline = DateTime.utc().plus({milliseconds: timeoutMs});
|
|
23
|
+
|
|
24
|
+
while (DateTime.utc() < deadline) {
|
|
25
|
+
if (await predicate()) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
await Bun.sleep(intervalMs);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
throw new Error("waitUntil timed out");
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
describe("jobs worker lifecycle", () => {
|
|
35
|
+
beforeEach(async (): Promise<void> => {
|
|
36
|
+
await setupDb();
|
|
37
|
+
await Job.deleteMany({});
|
|
38
|
+
await JobSchedule.deleteMany({});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("does not poll until startWorker is called", async (): Promise<void> => {
|
|
42
|
+
const jobsApp = new JobsApp({pollIntervalMs: 50});
|
|
43
|
+
jobsApp.define("idle-check", {
|
|
44
|
+
handler: async () => {},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
new TerrenoApp({
|
|
48
|
+
skipListen: true,
|
|
49
|
+
userModel: typedUserModel,
|
|
50
|
+
})
|
|
51
|
+
.register(jobsApp)
|
|
52
|
+
.build();
|
|
53
|
+
|
|
54
|
+
const enqueued = await getJobsService().enqueue({
|
|
55
|
+
name: "idle-check",
|
|
56
|
+
payload: {},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
assert.equal(jobsApp.isWorkerActive(), false);
|
|
60
|
+
|
|
61
|
+
let pendingChecks = 0;
|
|
62
|
+
await waitUntil(async () => {
|
|
63
|
+
pendingChecks += 1;
|
|
64
|
+
const row = await Job.findExactlyOne({_id: enqueued._id});
|
|
65
|
+
return pendingChecks >= 3 && row.status === "pending";
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const stillPending = await Job.findExactlyOne({_id: enqueued._id});
|
|
69
|
+
assert.equal(stillPending.status, "pending");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("reclaims a running job when lockedAt exceeds lockTtlMs", async (): Promise<void> => {
|
|
73
|
+
let executionCount = 0;
|
|
74
|
+
|
|
75
|
+
const jobsApp = new JobsApp({lockTtlMs: 100, pollIntervalMs: 25});
|
|
76
|
+
jobsApp.define("reclaim-me", {
|
|
77
|
+
handler: async () => {
|
|
78
|
+
executionCount += 1;
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
new TerrenoApp({
|
|
83
|
+
skipListen: true,
|
|
84
|
+
userModel: typedUserModel,
|
|
85
|
+
})
|
|
86
|
+
.register(jobsApp)
|
|
87
|
+
.build();
|
|
88
|
+
|
|
89
|
+
const staleJob = await Job.create({
|
|
90
|
+
attemptCount: 0,
|
|
91
|
+
lockedAt: DateTime.utc().minus({milliseconds: 500}).toJSDate(),
|
|
92
|
+
lockedBy: "dead-worker:99999",
|
|
93
|
+
maxAttempts: 5,
|
|
94
|
+
name: "reclaim-me",
|
|
95
|
+
payload: {},
|
|
96
|
+
payloadRedacted: false,
|
|
97
|
+
runAt: DateTime.utc().minus({milliseconds: 500}).toJSDate(),
|
|
98
|
+
status: "running",
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await jobsApp.startWorker();
|
|
102
|
+
await waitUntil(
|
|
103
|
+
async () => (await Job.findExactlyOne({_id: staleJob._id})).status === "completed"
|
|
104
|
+
);
|
|
105
|
+
await jobsApp.stopWorker();
|
|
106
|
+
|
|
107
|
+
assert.equal(executionCount, 1);
|
|
108
|
+
const reclaimed = await Job.findExactlyOne({_id: staleJob._id});
|
|
109
|
+
assert.equal(reclaimed.status, "completed");
|
|
110
|
+
assert.isTrue(hasWorkerIdPrefix(reclaimed.lockedBy));
|
|
111
|
+
assert.notEqual(reclaimed.lockedBy, getWorkerId());
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("executes a reclaimed job exactly once when two workers race", async (): Promise<void> => {
|
|
115
|
+
let executionCount = 0;
|
|
116
|
+
|
|
117
|
+
const workerA = new JobsApp({pollIntervalMs: 25});
|
|
118
|
+
const workerB = new JobsApp({pollIntervalMs: 25});
|
|
119
|
+
const handler = async (): Promise<void> => {
|
|
120
|
+
executionCount += 1;
|
|
121
|
+
await Bun.sleep(50);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
workerA.define("race-once", {handler});
|
|
125
|
+
workerB.define("race-once", {handler});
|
|
126
|
+
|
|
127
|
+
new TerrenoApp({
|
|
128
|
+
skipListen: true,
|
|
129
|
+
userModel: typedUserModel,
|
|
130
|
+
})
|
|
131
|
+
.register(workerA)
|
|
132
|
+
.build();
|
|
133
|
+
|
|
134
|
+
const enqueued = await getJobsService().enqueue({
|
|
135
|
+
name: "race-once",
|
|
136
|
+
payload: {},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
await Promise.all([workerA.startWorker(), workerB.startWorker()]);
|
|
140
|
+
await waitUntil(
|
|
141
|
+
async () => (await Job.findExactlyOne({_id: enqueued._id})).status === "completed"
|
|
142
|
+
);
|
|
143
|
+
await Promise.all([workerA.stopWorker(), workerB.stopWorker()]);
|
|
144
|
+
|
|
145
|
+
assert.equal(executionCount, 1);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("records hostname and pid in lockedBy when claiming", async (): Promise<void> => {
|
|
149
|
+
const jobsApp = new JobsApp({pollIntervalMs: 25});
|
|
150
|
+
jobsApp.define("lock-identity", {
|
|
151
|
+
handler: async () => {},
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
new TerrenoApp({
|
|
155
|
+
skipListen: true,
|
|
156
|
+
userModel: typedUserModel,
|
|
157
|
+
})
|
|
158
|
+
.register(jobsApp)
|
|
159
|
+
.build();
|
|
160
|
+
|
|
161
|
+
const enqueued = await getJobsService().enqueue({
|
|
162
|
+
name: "lock-identity",
|
|
163
|
+
payload: {},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await jobsApp.startWorker();
|
|
167
|
+
await waitUntil(
|
|
168
|
+
async () => (await Job.findExactlyOne({_id: enqueued._id})).status === "completed"
|
|
169
|
+
);
|
|
170
|
+
await jobsApp.stopWorker();
|
|
171
|
+
|
|
172
|
+
const completed = await Job.findExactlyOne({_id: enqueued._id});
|
|
173
|
+
assert.isTrue(hasWorkerIdPrefix(completed.lockedBy));
|
|
174
|
+
assert.match(completed.lockedBy ?? "", new RegExp(`^${os.hostname()}:${process.pid}:`));
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("stopWorker aborts the poll loop and resolves cleanly", async (): Promise<void> => {
|
|
178
|
+
const jobsApp = new JobsApp({pollIntervalMs: 50});
|
|
179
|
+
jobsApp.define("stop-check", {
|
|
180
|
+
handler: async () => {},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
new TerrenoApp({
|
|
184
|
+
skipListen: true,
|
|
185
|
+
userModel: typedUserModel,
|
|
186
|
+
})
|
|
187
|
+
.register(jobsApp)
|
|
188
|
+
.build();
|
|
189
|
+
|
|
190
|
+
await jobsApp.startWorker();
|
|
191
|
+
assert.isTrue(jobsApp.isWorkerActive());
|
|
192
|
+
|
|
193
|
+
await jobsApp.stopWorker();
|
|
194
|
+
assert.isFalse(jobsApp.isWorkerActive());
|
|
195
|
+
|
|
196
|
+
await jobsApp.stopWorker();
|
|
197
|
+
assert.isFalse(jobsApp.isWorkerActive());
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("stopWorker aborts an in-flight handler via ctx.signal", async (): Promise<void> => {
|
|
201
|
+
let sawAbort = false;
|
|
202
|
+
|
|
203
|
+
const jobsApp = new JobsApp({pollIntervalMs: 25});
|
|
204
|
+
jobsApp.define("slow-job", {
|
|
205
|
+
handler: async (_payload, ctx) => {
|
|
206
|
+
await waitUntil(() => Promise.resolve(ctx.signal.aborted), {
|
|
207
|
+
intervalMs: 25,
|
|
208
|
+
timeoutMs: 5_000,
|
|
209
|
+
});
|
|
210
|
+
sawAbort = ctx.signal.aborted;
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
new TerrenoApp({
|
|
215
|
+
skipListen: true,
|
|
216
|
+
userModel: typedUserModel,
|
|
217
|
+
})
|
|
218
|
+
.register(jobsApp)
|
|
219
|
+
.build();
|
|
220
|
+
|
|
221
|
+
const enqueued = await getJobsService().enqueue({
|
|
222
|
+
name: "slow-job",
|
|
223
|
+
payload: {},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
await jobsApp.startWorker();
|
|
227
|
+
await waitUntil(
|
|
228
|
+
async () => (await Job.findExactlyOne({_id: enqueued._id})).status === "running"
|
|
229
|
+
);
|
|
230
|
+
await jobsApp.stopWorker();
|
|
231
|
+
await waitUntil(() => Promise.resolve(sawAbort));
|
|
232
|
+
|
|
233
|
+
assert.isTrue(sawAbort);
|
|
234
|
+
assert.isFalse(jobsApp.isWorkerActive());
|
|
235
|
+
|
|
236
|
+
const completed = await Job.findExactlyOne({_id: enqueued._id});
|
|
237
|
+
assert.equal(completed.status, "completed");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("persists completed when the handler finishes after stopWorker aborts", async (): Promise<void> => {
|
|
241
|
+
let handlerStarted = false;
|
|
242
|
+
let handlerFinished = false;
|
|
243
|
+
|
|
244
|
+
const jobsApp = new JobsApp({pollIntervalMs: 25});
|
|
245
|
+
jobsApp.define("finish-after-abort", {
|
|
246
|
+
handler: async (_payload, ctx) => {
|
|
247
|
+
handlerStarted = true;
|
|
248
|
+
await waitUntil(() => Promise.resolve(ctx.signal.aborted));
|
|
249
|
+
handlerFinished = true;
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
new TerrenoApp({
|
|
254
|
+
skipListen: true,
|
|
255
|
+
userModel: typedUserModel,
|
|
256
|
+
})
|
|
257
|
+
.register(jobsApp)
|
|
258
|
+
.build();
|
|
259
|
+
|
|
260
|
+
const enqueued = await getJobsService().enqueue({
|
|
261
|
+
name: "finish-after-abort",
|
|
262
|
+
payload: {},
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
await jobsApp.startWorker();
|
|
266
|
+
await waitUntil(() => Promise.resolve(handlerStarted));
|
|
267
|
+
const stopPromise = jobsApp.stopWorker();
|
|
268
|
+
await waitUntil(() => Promise.resolve(handlerFinished));
|
|
269
|
+
await stopPromise;
|
|
270
|
+
|
|
271
|
+
const completed = await Job.findExactlyOne({_id: enqueued._id});
|
|
272
|
+
assert.equal(completed.status, "completed");
|
|
273
|
+
assert.isDefined(completed.lockedBy);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("requeues and unlocks when abort throws in-flight work", async (): Promise<void> => {
|
|
277
|
+
const jobsApp = new JobsApp({pollIntervalMs: 25});
|
|
278
|
+
jobsApp.define("abort-throw", {
|
|
279
|
+
handler: async (_payload, ctx) => {
|
|
280
|
+
await waitUntil(() => Promise.resolve(ctx.signal.aborted));
|
|
281
|
+
const abortError = new Error("Aborted");
|
|
282
|
+
abortError.name = "AbortError";
|
|
283
|
+
throw abortError;
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
new TerrenoApp({
|
|
288
|
+
skipListen: true,
|
|
289
|
+
userModel: typedUserModel,
|
|
290
|
+
})
|
|
291
|
+
.register(jobsApp)
|
|
292
|
+
.build();
|
|
293
|
+
|
|
294
|
+
const enqueued = await getJobsService().enqueue({
|
|
295
|
+
name: "abort-throw",
|
|
296
|
+
payload: {},
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
await jobsApp.startWorker();
|
|
300
|
+
await waitUntil(
|
|
301
|
+
async () => (await Job.findExactlyOne({_id: enqueued._id})).status === "running"
|
|
302
|
+
);
|
|
303
|
+
await jobsApp.stopWorker();
|
|
304
|
+
await waitUntil(async () => {
|
|
305
|
+
const row = await Job.findExactlyOne({_id: enqueued._id});
|
|
306
|
+
return row.status === "pending" && row.lockedAt == null && row.lockedBy == null;
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("restarts a pending job on a later startWorker after stopWorker", async (): Promise<void> => {
|
|
311
|
+
let handlerRuns = 0;
|
|
312
|
+
let throwOnAbort = true;
|
|
313
|
+
|
|
314
|
+
const jobsApp = new JobsApp({pollIntervalMs: 25});
|
|
315
|
+
jobsApp.define("restart-me", {
|
|
316
|
+
handler: async (_payload, ctx) => {
|
|
317
|
+
handlerRuns += 1;
|
|
318
|
+
if (throwOnAbort) {
|
|
319
|
+
await waitUntil(() => Promise.resolve(ctx.signal.aborted));
|
|
320
|
+
const abortError = new Error("Aborted");
|
|
321
|
+
abortError.name = "AbortError";
|
|
322
|
+
throw abortError;
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
new TerrenoApp({
|
|
328
|
+
skipListen: true,
|
|
329
|
+
userModel: typedUserModel,
|
|
330
|
+
})
|
|
331
|
+
.register(jobsApp)
|
|
332
|
+
.build();
|
|
333
|
+
|
|
334
|
+
const enqueued = await getJobsService().enqueue({
|
|
335
|
+
name: "restart-me",
|
|
336
|
+
payload: {},
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
await jobsApp.startWorker();
|
|
340
|
+
await waitUntil(
|
|
341
|
+
async () => (await Job.findExactlyOne({_id: enqueued._id})).status === "running"
|
|
342
|
+
);
|
|
343
|
+
await jobsApp.stopWorker();
|
|
344
|
+
await waitUntil(async () => {
|
|
345
|
+
const row = await Job.findExactlyOne({_id: enqueued._id});
|
|
346
|
+
return row.status === "pending" && row.lockedAt == null && row.lockedBy == null;
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
throwOnAbort = false;
|
|
350
|
+
await jobsApp.startWorker();
|
|
351
|
+
await waitUntil(
|
|
352
|
+
async () => (await Job.findExactlyOne({_id: enqueued._id})).status === "completed"
|
|
353
|
+
);
|
|
354
|
+
await jobsApp.stopWorker();
|
|
355
|
+
|
|
356
|
+
assert.equal(handlerRuns, 2);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("does not rerun a job that completed during stopWorker", async (): Promise<void> => {
|
|
360
|
+
let handlerRuns = 0;
|
|
361
|
+
|
|
362
|
+
const jobsApp = new JobsApp({pollIntervalMs: 25});
|
|
363
|
+
jobsApp.define("complete-once", {
|
|
364
|
+
handler: async (_payload, ctx) => {
|
|
365
|
+
handlerRuns += 1;
|
|
366
|
+
await waitUntil(() => Promise.resolve(ctx.signal.aborted));
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
new TerrenoApp({
|
|
371
|
+
skipListen: true,
|
|
372
|
+
userModel: typedUserModel,
|
|
373
|
+
})
|
|
374
|
+
.register(jobsApp)
|
|
375
|
+
.build();
|
|
376
|
+
|
|
377
|
+
const enqueued = await getJobsService().enqueue({
|
|
378
|
+
name: "complete-once",
|
|
379
|
+
payload: {},
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
await jobsApp.startWorker();
|
|
383
|
+
await waitUntil(
|
|
384
|
+
async () => (await Job.findExactlyOne({_id: enqueued._id})).status === "running"
|
|
385
|
+
);
|
|
386
|
+
await jobsApp.stopWorker();
|
|
387
|
+
await waitUntil(
|
|
388
|
+
async () => (await Job.findExactlyOne({_id: enqueued._id})).status === "completed"
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
await jobsApp.startWorker();
|
|
392
|
+
let idlePolls = 0;
|
|
393
|
+
await waitUntil(async () => {
|
|
394
|
+
idlePolls += 1;
|
|
395
|
+
return idlePolls >= 5;
|
|
396
|
+
});
|
|
397
|
+
await jobsApp.stopWorker();
|
|
398
|
+
|
|
399
|
+
assert.equal(handlerRuns, 1);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("startWorker returns without awaiting the poll loop", async (): Promise<void> => {
|
|
403
|
+
let handlerStarted = false;
|
|
404
|
+
let releaseHandler!: () => void;
|
|
405
|
+
const handlerBlocked = new Promise<void>((resolve) => {
|
|
406
|
+
releaseHandler = resolve;
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
const jobsApp = new JobsApp({pollIntervalMs: 25});
|
|
410
|
+
jobsApp.define("async-start", {
|
|
411
|
+
handler: async () => {
|
|
412
|
+
handlerStarted = true;
|
|
413
|
+
await handlerBlocked;
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
new TerrenoApp({
|
|
418
|
+
skipListen: true,
|
|
419
|
+
userModel: typedUserModel,
|
|
420
|
+
})
|
|
421
|
+
.register(jobsApp)
|
|
422
|
+
.build();
|
|
423
|
+
|
|
424
|
+
const enqueued = await getJobsService().enqueue({
|
|
425
|
+
name: "async-start",
|
|
426
|
+
payload: {},
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
await jobsApp.startWorker();
|
|
430
|
+
assert.isTrue(jobsApp.isWorkerActive());
|
|
431
|
+
assert.isFalse(handlerStarted);
|
|
432
|
+
|
|
433
|
+
await waitUntil(() => Promise.resolve(handlerStarted));
|
|
434
|
+
releaseHandler();
|
|
435
|
+
await waitUntil(
|
|
436
|
+
async () => (await Job.findExactlyOne({_id: enqueued._id})).status === "completed"
|
|
437
|
+
);
|
|
438
|
+
await jobsApp.stopWorker();
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("startWorker rejects runners that do not implement start", async (): Promise<void> => {
|
|
442
|
+
const jobsApp = new JobsApp({
|
|
443
|
+
runner: {
|
|
444
|
+
enqueue: async () => {},
|
|
445
|
+
id: "noop",
|
|
446
|
+
},
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
try {
|
|
450
|
+
await jobsApp.startWorker();
|
|
451
|
+
assert.fail("expected startWorker to throw");
|
|
452
|
+
} catch (error: unknown) {
|
|
453
|
+
assert.match(String(error), /does not support start/);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
assert.isFalse(jobsApp.isWorkerActive());
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("resets worker state when the runner start promise rejects", async (): Promise<void> => {
|
|
460
|
+
const jobsApp = new JobsApp({
|
|
461
|
+
runner: {
|
|
462
|
+
enqueue: async () => {},
|
|
463
|
+
id: "broken",
|
|
464
|
+
start: async () => {
|
|
465
|
+
throw new Error("start failed");
|
|
466
|
+
},
|
|
467
|
+
},
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
await jobsApp.startWorker();
|
|
471
|
+
await waitUntil(() => Promise.resolve(!jobsApp.isWorkerActive()));
|
|
472
|
+
|
|
473
|
+
assert.isFalse(jobsApp.isWorkerActive());
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it("marks handler failures as pending retries without stopping the worker", async (): Promise<void> => {
|
|
477
|
+
const jobsApp = new JobsApp({pollIntervalMs: 25});
|
|
478
|
+
jobsApp.define("boom", {
|
|
479
|
+
handler: async () => {
|
|
480
|
+
throw new Error("handler exploded");
|
|
481
|
+
},
|
|
482
|
+
retry: {backoffMs: 500, maxAttempts: 5},
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
new TerrenoApp({
|
|
486
|
+
skipListen: true,
|
|
487
|
+
userModel: typedUserModel,
|
|
488
|
+
})
|
|
489
|
+
.register(jobsApp)
|
|
490
|
+
.build();
|
|
491
|
+
|
|
492
|
+
const enqueued = await getJobsService().enqueue({
|
|
493
|
+
name: "boom",
|
|
494
|
+
payload: {},
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
await jobsApp.startWorker();
|
|
498
|
+
await waitUntil(async () => {
|
|
499
|
+
const row = await Job.findExactlyOne({_id: enqueued._id});
|
|
500
|
+
return row.status === "pending" && row.attemptCount === 1;
|
|
501
|
+
});
|
|
502
|
+
assert.isTrue(jobsApp.isWorkerActive());
|
|
503
|
+
await jobsApp.stopWorker();
|
|
504
|
+
|
|
505
|
+
const pending = await Job.findExactlyOne({_id: enqueued._id});
|
|
506
|
+
assert.equal(pending.status, "pending");
|
|
507
|
+
assert.match(pending.lastError ?? "", /handler exploded/);
|
|
508
|
+
});
|
|
509
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import {describe, it} from "bun:test";
|
|
2
|
+
import {assert} from "chai";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
getJobsService,
|
|
6
|
+
Job,
|
|
7
|
+
JobSchedule,
|
|
8
|
+
JobsApp,
|
|
9
|
+
MongoJobRunner,
|
|
10
|
+
tryGetJobsService,
|
|
11
|
+
unregisterJobsService,
|
|
12
|
+
} from "../index";
|
|
13
|
+
|
|
14
|
+
describe("package public exports", () => {
|
|
15
|
+
it("re-exports JobsApp, models, MongoJobRunner, and getJobsService", (): void => {
|
|
16
|
+
assert.equal(JobsApp.name, "JobsApp");
|
|
17
|
+
assert.equal(MongoJobRunner.name, "MongoJobRunner");
|
|
18
|
+
assert.equal(Job.modelName, "Job");
|
|
19
|
+
assert.equal(JobSchedule.modelName, "JobSchedule");
|
|
20
|
+
assert.equal(typeof getJobsService, "function");
|
|
21
|
+
assert.equal(typeof tryGetJobsService, "function");
|
|
22
|
+
assert.equal(typeof unregisterJobsService, "function");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import {describe, it} from "bun:test";
|
|
2
|
+
import {assert} from "chai";
|
|
3
|
+
import {DateTime} from "luxon";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
computeRetryDelayMs,
|
|
7
|
+
computeRetryRunAt,
|
|
8
|
+
DEFAULT_BACKOFF_MS,
|
|
9
|
+
DEFAULT_MAX_BACKOFF_MS,
|
|
10
|
+
} from "../retryBackoff";
|
|
11
|
+
|
|
12
|
+
describe("retryBackoff", () => {
|
|
13
|
+
it("defaults backoffMs to 1000 and maxBackoffMs to 15 minutes", (): void => {
|
|
14
|
+
assert.equal(DEFAULT_BACKOFF_MS, 1_000);
|
|
15
|
+
assert.equal(DEFAULT_MAX_BACKOFF_MS, 15 * 60 * 1_000);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("uses exponential backoff capped by maxBackoffMs with injected random", (): void => {
|
|
19
|
+
const retry = {backoffMs: 1_000, maxBackoffMs: 10_000};
|
|
20
|
+
const random = (): number => 0;
|
|
21
|
+
|
|
22
|
+
assert.equal(
|
|
23
|
+
computeRetryDelayMs({
|
|
24
|
+
attemptCount: 1,
|
|
25
|
+
backoffMs: retry.backoffMs,
|
|
26
|
+
maxBackoffMs: retry.maxBackoffMs,
|
|
27
|
+
random,
|
|
28
|
+
}),
|
|
29
|
+
500,
|
|
30
|
+
"first failure uses backoffMs * 2^0 with equal jitter floor"
|
|
31
|
+
);
|
|
32
|
+
assert.equal(
|
|
33
|
+
computeRetryDelayMs({
|
|
34
|
+
attemptCount: 2,
|
|
35
|
+
backoffMs: retry.backoffMs,
|
|
36
|
+
maxBackoffMs: retry.maxBackoffMs,
|
|
37
|
+
random,
|
|
38
|
+
}),
|
|
39
|
+
1_000,
|
|
40
|
+
"second failure uses backoffMs * 2^1 with equal jitter floor"
|
|
41
|
+
);
|
|
42
|
+
assert.equal(
|
|
43
|
+
computeRetryDelayMs({
|
|
44
|
+
attemptCount: 5,
|
|
45
|
+
backoffMs: retry.backoffMs,
|
|
46
|
+
maxBackoffMs: retry.maxBackoffMs,
|
|
47
|
+
random,
|
|
48
|
+
}),
|
|
49
|
+
5_000,
|
|
50
|
+
"attemptCount 5 applies backoffMs * 2^4 before cap"
|
|
51
|
+
);
|
|
52
|
+
assert.equal(
|
|
53
|
+
computeRetryDelayMs({
|
|
54
|
+
attemptCount: 10,
|
|
55
|
+
backoffMs: retry.backoffMs,
|
|
56
|
+
maxBackoffMs: retry.maxBackoffMs,
|
|
57
|
+
random,
|
|
58
|
+
}),
|
|
59
|
+
5_000,
|
|
60
|
+
"delay is capped at half of maxBackoffMs with zero random"
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("computes runAt from Luxon with deterministic jitter", (): void => {
|
|
65
|
+
const from = DateTime.utc(2026, 9, 10, 12, 0, 0);
|
|
66
|
+
const runAt = computeRetryRunAt({
|
|
67
|
+
attemptCount: 2,
|
|
68
|
+
backoffMs: 1_000,
|
|
69
|
+
from,
|
|
70
|
+
maxBackoffMs: 60_000,
|
|
71
|
+
random: () => 1,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
assert.equal(runAt.toMillis(), from.plus({milliseconds: 2_000}).toMillis());
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import {describe, it} from "bun:test";
|
|
2
|
+
import {isAPIError} from "@terreno/api";
|
|
3
|
+
import {assert} from "chai";
|
|
4
|
+
import {DateTime} from "luxon";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
assertValidCronExpression,
|
|
8
|
+
assertValidIanaTimezone,
|
|
9
|
+
computeInitialNextRunAt,
|
|
10
|
+
computeNextRunAtAfter,
|
|
11
|
+
resolveScheduleTimezone,
|
|
12
|
+
validateScheduleDefinition,
|
|
13
|
+
} from "../scheduleCron";
|
|
14
|
+
|
|
15
|
+
describe("scheduleCron", () => {
|
|
16
|
+
it("computes different next runs for America/New_York vs UTC", (): void => {
|
|
17
|
+
const cron = "0 9 * * *";
|
|
18
|
+
const utcNext = computeInitialNextRunAt(cron, "UTC");
|
|
19
|
+
const newYorkNext = computeInitialNextRunAt(cron, "America/New_York");
|
|
20
|
+
|
|
21
|
+
assert.notEqual(utcNext.toISOString(), newYorkNext.toISOString());
|
|
22
|
+
assert.equal(
|
|
23
|
+
DateTime.fromJSDate(newYorkNext).setZone("America/New_York").hour,
|
|
24
|
+
9,
|
|
25
|
+
"New York schedule fires at 9am local"
|
|
26
|
+
);
|
|
27
|
+
assert.equal(
|
|
28
|
+
DateTime.fromJSDate(utcNext).setZone("UTC").hour,
|
|
29
|
+
9,
|
|
30
|
+
"UTC schedule fires at 9am UTC"
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("advances nextRunAt after a tick using the schedule timezone", (): void => {
|
|
35
|
+
const cron = "0 9 * * *";
|
|
36
|
+
const after = new Date("2026-09-10T14:00:00.000Z");
|
|
37
|
+
const utcNext = computeNextRunAtAfter(cron, "UTC", after);
|
|
38
|
+
const newYorkNext = computeNextRunAtAfter(cron, "America/New_York", after);
|
|
39
|
+
|
|
40
|
+
assert.equal(utcNext.toISOString(), "2026-09-11T09:00:00.000Z");
|
|
41
|
+
assert.equal(newYorkNext.toISOString(), "2026-09-11T13:00:00.000Z");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("defaults schedule timezone resolution to JobsApp timezone then UTC", (): void => {
|
|
45
|
+
assert.equal(
|
|
46
|
+
resolveScheduleTimezone({defaultTimezone: "America/Chicago", scheduleTimezone: undefined}),
|
|
47
|
+
"America/Chicago"
|
|
48
|
+
);
|
|
49
|
+
assert.equal(resolveScheduleTimezone({defaultTimezone: "America/Chicago"}), "America/Chicago");
|
|
50
|
+
assert.equal(
|
|
51
|
+
resolveScheduleTimezone({
|
|
52
|
+
defaultTimezone: "America/Chicago",
|
|
53
|
+
scheduleTimezone: "America/New_York",
|
|
54
|
+
}),
|
|
55
|
+
"America/New_York"
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("rejects invalid cron expressions at define time", (): void => {
|
|
60
|
+
try {
|
|
61
|
+
assertValidCronExpression("not-a-cron", "UTC");
|
|
62
|
+
assert.fail("expected invalid cron to throw");
|
|
63
|
+
} catch (error: unknown) {
|
|
64
|
+
assert.isTrue(isAPIError(error));
|
|
65
|
+
assert.equal((error as {status: number}).status, 400);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("rejects invalid IANA timezones at define time", (): void => {
|
|
70
|
+
try {
|
|
71
|
+
assertValidIanaTimezone("Not/A_Zone");
|
|
72
|
+
assert.fail("expected invalid timezone to throw");
|
|
73
|
+
} catch (error: unknown) {
|
|
74
|
+
assert.isTrue(isAPIError(error));
|
|
75
|
+
assert.equal((error as {status: number}).status, 400);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("validateScheduleDefinition applies definition timezone over JobsApp default", (): void => {
|
|
80
|
+
const timezone = validateScheduleDefinition({
|
|
81
|
+
cron: "0 9 * * *",
|
|
82
|
+
defaultTimezone: "UTC",
|
|
83
|
+
scheduleTimezone: "America/New_York",
|
|
84
|
+
});
|
|
85
|
+
assert.equal(timezone, "America/New_York");
|
|
86
|
+
});
|
|
87
|
+
});
|