@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,420 @@
|
|
|
1
|
+
import {beforeEach, describe, it} from "bun:test";
|
|
2
|
+
import {TerrenoApp, type UserModel as UserModelType} from "@terreno/api";
|
|
3
|
+
import {setupDb, UserModel} from "@terreno/api/testing";
|
|
4
|
+
import {assert} from "chai";
|
|
5
|
+
import {DateTime} from "luxon";
|
|
6
|
+
|
|
7
|
+
import {JobsApp} from "../jobsApp";
|
|
8
|
+
import {getJobsService} from "../jobsService";
|
|
9
|
+
import {Job} from "../models/job";
|
|
10
|
+
import {DEFAULT_BACKOFF_MS, DEFAULT_MAX_BACKOFF_MS} from "../retryBackoff";
|
|
11
|
+
import {hasWorkerIdPrefix} from "../runners/mongoRunner";
|
|
12
|
+
|
|
13
|
+
const typedUserModel = UserModel as unknown as UserModelType;
|
|
14
|
+
|
|
15
|
+
const waitUntil = async (
|
|
16
|
+
predicate: () => Promise<boolean>,
|
|
17
|
+
options?: {intervalMs?: number; timeoutMs?: number}
|
|
18
|
+
): Promise<void> => {
|
|
19
|
+
const intervalMs = options?.intervalMs ?? 25;
|
|
20
|
+
const timeoutMs = options?.timeoutMs ?? 5_000;
|
|
21
|
+
const deadline = DateTime.utc().plus({milliseconds: timeoutMs});
|
|
22
|
+
|
|
23
|
+
while (DateTime.utc() < deadline) {
|
|
24
|
+
if (await predicate()) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
await Bun.sleep(intervalMs);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
throw new Error("waitUntil timed out");
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe("jobs retry and dead-letter", () => {
|
|
34
|
+
beforeEach(async (): Promise<void> => {
|
|
35
|
+
await setupDb();
|
|
36
|
+
await Job.deleteMany({});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("copies definition retry options onto the enqueued row", async (): Promise<void> => {
|
|
40
|
+
const jobsApp = new JobsApp();
|
|
41
|
+
jobsApp.define("retry-options", {
|
|
42
|
+
handler: async () => {},
|
|
43
|
+
retry: {backoffMs: 2_000, maxAttempts: 7, maxBackoffMs: 120_000},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
new TerrenoApp({
|
|
47
|
+
skipListen: true,
|
|
48
|
+
userModel: typedUserModel,
|
|
49
|
+
})
|
|
50
|
+
.register(jobsApp)
|
|
51
|
+
.build();
|
|
52
|
+
|
|
53
|
+
const enqueued = await getJobsService().enqueue({
|
|
54
|
+
name: "retry-options",
|
|
55
|
+
payload: {},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
assert.equal(enqueued.maxAttempts, 7);
|
|
59
|
+
assert.equal(enqueued.backoffMs, 2_000);
|
|
60
|
+
assert.equal(enqueued.maxBackoffMs, 120_000);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("uses default retry options when definition omits retry", async (): Promise<void> => {
|
|
64
|
+
const jobsApp = new JobsApp();
|
|
65
|
+
jobsApp.define("default-retry", {
|
|
66
|
+
handler: async () => {},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
new TerrenoApp({
|
|
70
|
+
skipListen: true,
|
|
71
|
+
userModel: typedUserModel,
|
|
72
|
+
})
|
|
73
|
+
.register(jobsApp)
|
|
74
|
+
.build();
|
|
75
|
+
|
|
76
|
+
const enqueued = await getJobsService().enqueue({
|
|
77
|
+
name: "default-retry",
|
|
78
|
+
payload: {},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
assert.equal(enqueued.maxAttempts, 5);
|
|
82
|
+
assert.equal(enqueued.backoffMs, DEFAULT_BACKOFF_MS);
|
|
83
|
+
assert.equal(enqueued.maxBackoffMs, DEFAULT_MAX_BACKOFF_MS);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("marks success as completed without incrementing attemptCount", async (): Promise<void> => {
|
|
87
|
+
const jobsApp = new JobsApp({pollIntervalMs: 25});
|
|
88
|
+
jobsApp.define("succeed-once", {
|
|
89
|
+
handler: async () => {},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
new TerrenoApp({
|
|
93
|
+
skipListen: true,
|
|
94
|
+
userModel: typedUserModel,
|
|
95
|
+
})
|
|
96
|
+
.register(jobsApp)
|
|
97
|
+
.build();
|
|
98
|
+
|
|
99
|
+
const enqueued = await getJobsService().enqueue({
|
|
100
|
+
name: "succeed-once",
|
|
101
|
+
payload: {},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await jobsApp.startWorker();
|
|
105
|
+
await waitUntil(
|
|
106
|
+
async () => (await Job.findExactlyOne({_id: enqueued._id})).status === "completed"
|
|
107
|
+
);
|
|
108
|
+
await jobsApp.stopWorker();
|
|
109
|
+
|
|
110
|
+
const completed = await Job.findExactlyOne({_id: enqueued._id});
|
|
111
|
+
assert.equal(completed.status, "completed");
|
|
112
|
+
assert.equal(completed.attemptCount, 0);
|
|
113
|
+
assert.lengthOf(completed.attempts, 0);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("schedules pending with a future runAt between retryable failures", async (): Promise<void> => {
|
|
117
|
+
const jobsApp = new JobsApp({pollIntervalMs: 25});
|
|
118
|
+
jobsApp.define("retryable-failure", {
|
|
119
|
+
handler: async () => {
|
|
120
|
+
throw new Error("transient");
|
|
121
|
+
},
|
|
122
|
+
retry: {backoffMs: 500, maxAttempts: 5, maxBackoffMs: 60_000},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
new TerrenoApp({
|
|
126
|
+
skipListen: true,
|
|
127
|
+
userModel: typedUserModel,
|
|
128
|
+
})
|
|
129
|
+
.register(jobsApp)
|
|
130
|
+
.build();
|
|
131
|
+
|
|
132
|
+
const enqueued = await getJobsService().enqueue({
|
|
133
|
+
name: "retryable-failure",
|
|
134
|
+
payload: {},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await jobsApp.startWorker();
|
|
138
|
+
await waitUntil(async () => {
|
|
139
|
+
const row = await Job.findExactlyOne({_id: enqueued._id});
|
|
140
|
+
return row.status === "pending" && row.attemptCount === 1;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const afterFirstFailure = await Job.findExactlyOne({_id: enqueued._id});
|
|
144
|
+
assert.equal(afterFirstFailure.status, "pending");
|
|
145
|
+
assert.isUndefined(afterFirstFailure.lockedAt);
|
|
146
|
+
assert.isUndefined(afterFirstFailure.lockedBy);
|
|
147
|
+
assert.isAbove(
|
|
148
|
+
DateTime.fromJSDate(afterFirstFailure.runAt).toMillis(),
|
|
149
|
+
DateTime.utc().toMillis()
|
|
150
|
+
);
|
|
151
|
+
assert.lengthOf(afterFirstFailure.attempts, 1);
|
|
152
|
+
assert.equal(afterFirstFailure.attempts[0]?.error, "transient");
|
|
153
|
+
assert.isDefined(afterFirstFailure.attempts[0]?.at);
|
|
154
|
+
assert.isTrue(jobsApp.isWorkerActive());
|
|
155
|
+
|
|
156
|
+
await jobsApp.stopWorker();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("clears lastError when a previously failed job completes", async (): Promise<void> => {
|
|
160
|
+
let handlerRuns = 0;
|
|
161
|
+
|
|
162
|
+
const jobsApp = new JobsApp({pollIntervalMs: 25});
|
|
163
|
+
jobsApp.define("fail-then-succeed", {
|
|
164
|
+
handler: async () => {
|
|
165
|
+
handlerRuns += 1;
|
|
166
|
+
if (handlerRuns === 1) {
|
|
167
|
+
throw new Error("transient");
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
retry: {backoffMs: 500, maxAttempts: 5, maxBackoffMs: 60_000},
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
new TerrenoApp({
|
|
174
|
+
skipListen: true,
|
|
175
|
+
userModel: typedUserModel,
|
|
176
|
+
})
|
|
177
|
+
.register(jobsApp)
|
|
178
|
+
.build();
|
|
179
|
+
|
|
180
|
+
const enqueued = await getJobsService().enqueue({
|
|
181
|
+
name: "fail-then-succeed",
|
|
182
|
+
payload: {},
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await jobsApp.startWorker();
|
|
186
|
+
await waitUntil(async () => {
|
|
187
|
+
const row = await Job.findExactlyOne({_id: enqueued._id});
|
|
188
|
+
return row.status === "pending" && row.attemptCount === 1;
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const afterFailure = await Job.findExactlyOne({_id: enqueued._id});
|
|
192
|
+
assert.equal(afterFailure.lastError, "transient");
|
|
193
|
+
|
|
194
|
+
await Job.updateOne({_id: enqueued._id}, {$set: {runAt: DateTime.utc().toJSDate()}});
|
|
195
|
+
await waitUntil(
|
|
196
|
+
async () => (await Job.findExactlyOne({_id: enqueued._id})).status === "completed"
|
|
197
|
+
);
|
|
198
|
+
await jobsApp.stopWorker();
|
|
199
|
+
|
|
200
|
+
const completed = await Job.findExactlyOne({_id: enqueued._id});
|
|
201
|
+
assert.equal(completed.status, "completed");
|
|
202
|
+
assert.isUndefined(completed.lastError);
|
|
203
|
+
assert.equal(handlerRuns, 2);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("lands dead after maxAttempts failures", async (): Promise<void> => {
|
|
207
|
+
let handlerRuns = 0;
|
|
208
|
+
|
|
209
|
+
const jobsApp = new JobsApp({pollIntervalMs: 25});
|
|
210
|
+
jobsApp.define("always-fail", {
|
|
211
|
+
handler: async () => {
|
|
212
|
+
handlerRuns += 1;
|
|
213
|
+
throw new Error(`fail-${handlerRuns}`);
|
|
214
|
+
},
|
|
215
|
+
retry: {backoffMs: 25, maxAttempts: 3, maxBackoffMs: 60_000},
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
new TerrenoApp({
|
|
219
|
+
skipListen: true,
|
|
220
|
+
userModel: typedUserModel,
|
|
221
|
+
})
|
|
222
|
+
.register(jobsApp)
|
|
223
|
+
.build();
|
|
224
|
+
|
|
225
|
+
const enqueued = await getJobsService().enqueue({
|
|
226
|
+
name: "always-fail",
|
|
227
|
+
payload: {},
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
await jobsApp.startWorker();
|
|
231
|
+
|
|
232
|
+
for (let attempt = 1; attempt < 3; attempt += 1) {
|
|
233
|
+
await waitUntil(async () => {
|
|
234
|
+
const row = await Job.findExactlyOne({_id: enqueued._id});
|
|
235
|
+
return row.status === "pending" && row.attemptCount === attempt;
|
|
236
|
+
});
|
|
237
|
+
await Job.updateOne({_id: enqueued._id}, {$set: {runAt: DateTime.utc().toJSDate()}});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
await waitUntil(async () => (await Job.findExactlyOne({_id: enqueued._id})).status === "dead");
|
|
241
|
+
await jobsApp.stopWorker();
|
|
242
|
+
|
|
243
|
+
const dead = await Job.findExactlyOne({_id: enqueued._id});
|
|
244
|
+
assert.equal(dead.status, "dead");
|
|
245
|
+
assert.equal(dead.attemptCount, 3);
|
|
246
|
+
assert.lengthOf(dead.attempts, 3);
|
|
247
|
+
assert.equal(dead.attempts[2]?.error, "fail-3");
|
|
248
|
+
assert.equal(dead.attempts[2]?.errorClass, "Error");
|
|
249
|
+
assert.match(dead.lastError ?? "", /fail-3/);
|
|
250
|
+
assert.isUndefined(dead.lockedAt);
|
|
251
|
+
assert.isUndefined(dead.lockedBy);
|
|
252
|
+
assert.equal(handlerRuns, 3);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("does not increment attemptCount when abort throws AbortError in-flight", async (): Promise<void> => {
|
|
256
|
+
const jobsApp = new JobsApp({pollIntervalMs: 25});
|
|
257
|
+
jobsApp.define("abort-no-retry", {
|
|
258
|
+
handler: async (_payload, ctx) => {
|
|
259
|
+
await waitUntil(() => Promise.resolve(ctx.signal.aborted));
|
|
260
|
+
const abortError = new Error("Aborted");
|
|
261
|
+
abortError.name = "AbortError";
|
|
262
|
+
throw abortError;
|
|
263
|
+
},
|
|
264
|
+
retry: {maxAttempts: 3},
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
new TerrenoApp({
|
|
268
|
+
skipListen: true,
|
|
269
|
+
userModel: typedUserModel,
|
|
270
|
+
})
|
|
271
|
+
.register(jobsApp)
|
|
272
|
+
.build();
|
|
273
|
+
|
|
274
|
+
const enqueued = await getJobsService().enqueue({
|
|
275
|
+
name: "abort-no-retry",
|
|
276
|
+
payload: {},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
await jobsApp.startWorker();
|
|
280
|
+
await waitUntil(
|
|
281
|
+
async () => (await Job.findExactlyOne({_id: enqueued._id})).status === "running"
|
|
282
|
+
);
|
|
283
|
+
await jobsApp.stopWorker();
|
|
284
|
+
await waitUntil(async () => {
|
|
285
|
+
const row = await Job.findExactlyOne({_id: enqueued._id});
|
|
286
|
+
return row.status === "pending" && row.lockedAt == null;
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const requeued = await Job.findExactlyOne({_id: enqueued._id});
|
|
290
|
+
assert.equal(requeued.attemptCount, 0);
|
|
291
|
+
assert.lengthOf(requeued.attempts, 0);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("counts a real handler error that coincides with stopWorker", async (): Promise<void> => {
|
|
295
|
+
const jobsApp = new JobsApp({pollIntervalMs: 25});
|
|
296
|
+
jobsApp.define("stop-real-error", {
|
|
297
|
+
handler: async (_payload, ctx) => {
|
|
298
|
+
await waitUntil(() => Promise.resolve(ctx.signal.aborted));
|
|
299
|
+
throw new Error("database unavailable");
|
|
300
|
+
},
|
|
301
|
+
retry: {backoffMs: 500, maxAttempts: 5},
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
new TerrenoApp({
|
|
305
|
+
skipListen: true,
|
|
306
|
+
userModel: typedUserModel,
|
|
307
|
+
})
|
|
308
|
+
.register(jobsApp)
|
|
309
|
+
.build();
|
|
310
|
+
|
|
311
|
+
const enqueued = await getJobsService().enqueue({
|
|
312
|
+
name: "stop-real-error",
|
|
313
|
+
payload: {},
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
await jobsApp.startWorker();
|
|
317
|
+
await waitUntil(
|
|
318
|
+
async () => (await Job.findExactlyOne({_id: enqueued._id})).status === "running"
|
|
319
|
+
);
|
|
320
|
+
await jobsApp.stopWorker();
|
|
321
|
+
await waitUntil(async () => {
|
|
322
|
+
const row = await Job.findExactlyOne({_id: enqueued._id});
|
|
323
|
+
return row.status === "pending" && row.attemptCount === 1;
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const retried = await Job.findExactlyOne({_id: enqueued._id});
|
|
327
|
+
assert.equal(retried.attemptCount, 1);
|
|
328
|
+
assert.match(retried.lastError ?? "", /database unavailable/);
|
|
329
|
+
assert.lengthOf(retried.attempts, 1);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("does not let a stale worker overwrite a reclaimed claim result or retry budget", async (): Promise<void> => {
|
|
333
|
+
let releaseStaleHandler!: () => void;
|
|
334
|
+
const staleHandlerBlocked = new Promise<void>((resolve) => {
|
|
335
|
+
releaseStaleHandler = resolve;
|
|
336
|
+
});
|
|
337
|
+
let staleHandlerRunning = false;
|
|
338
|
+
|
|
339
|
+
const staleWorker = new JobsApp({lockTtlMs: 75, pollIntervalMs: 25});
|
|
340
|
+
const reclaimWorker = new JobsApp({lockTtlMs: 75, pollIntervalMs: 25});
|
|
341
|
+
|
|
342
|
+
staleWorker.define("stale-claim-race", {
|
|
343
|
+
handler: async () => {
|
|
344
|
+
staleHandlerRunning = true;
|
|
345
|
+
await staleHandlerBlocked;
|
|
346
|
+
throw new Error("stale failure must not apply");
|
|
347
|
+
},
|
|
348
|
+
retry: {backoffMs: 500, maxAttempts: 5},
|
|
349
|
+
});
|
|
350
|
+
reclaimWorker.define("stale-claim-race", {
|
|
351
|
+
handler: async () => {},
|
|
352
|
+
retry: {backoffMs: 500, maxAttempts: 5},
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
new TerrenoApp({
|
|
356
|
+
skipListen: true,
|
|
357
|
+
userModel: typedUserModel,
|
|
358
|
+
})
|
|
359
|
+
.register(staleWorker)
|
|
360
|
+
.build();
|
|
361
|
+
|
|
362
|
+
const enqueued = await getJobsService().enqueue({
|
|
363
|
+
name: "stale-claim-race",
|
|
364
|
+
payload: {},
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
await staleWorker.startWorker();
|
|
368
|
+
await waitUntil(() => Promise.resolve(staleHandlerRunning));
|
|
369
|
+
|
|
370
|
+
await reclaimWorker.startWorker();
|
|
371
|
+
await waitUntil(
|
|
372
|
+
async () => (await Job.findExactlyOne({_id: enqueued._id})).status === "completed"
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
releaseStaleHandler();
|
|
376
|
+
await Bun.sleep(150);
|
|
377
|
+
|
|
378
|
+
const finalRow = await Job.findExactlyOne({_id: enqueued._id});
|
|
379
|
+
assert.equal(finalRow.status, "completed");
|
|
380
|
+
assert.equal(finalRow.attemptCount, 0);
|
|
381
|
+
assert.lengthOf(finalRow.attempts, 0);
|
|
382
|
+
assert.isTrue(hasWorkerIdPrefix(finalRow.lockedBy));
|
|
383
|
+
|
|
384
|
+
await Promise.all([staleWorker.stopWorker(), reclaimWorker.stopWorker()]);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("keeps the worker running after a retryable handler failure", async (): Promise<void> => {
|
|
388
|
+
const jobsApp = new JobsApp({pollIntervalMs: 25});
|
|
389
|
+
jobsApp.define("boom", {
|
|
390
|
+
handler: async () => {
|
|
391
|
+
throw new Error("handler exploded");
|
|
392
|
+
},
|
|
393
|
+
retry: {backoffMs: 500, maxAttempts: 5},
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
new TerrenoApp({
|
|
397
|
+
skipListen: true,
|
|
398
|
+
userModel: typedUserModel,
|
|
399
|
+
})
|
|
400
|
+
.register(jobsApp)
|
|
401
|
+
.build();
|
|
402
|
+
|
|
403
|
+
const enqueued = await getJobsService().enqueue({
|
|
404
|
+
name: "boom",
|
|
405
|
+
payload: {},
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
await jobsApp.startWorker();
|
|
409
|
+
await waitUntil(async () => {
|
|
410
|
+
const row = await Job.findExactlyOne({_id: enqueued._id});
|
|
411
|
+
return row.status === "pending" && row.attemptCount === 1;
|
|
412
|
+
});
|
|
413
|
+
assert.isTrue(jobsApp.isWorkerActive());
|
|
414
|
+
await jobsApp.stopWorker();
|
|
415
|
+
|
|
416
|
+
const pending = await Job.findExactlyOne({_id: enqueued._id});
|
|
417
|
+
assert.equal(pending.status, "pending");
|
|
418
|
+
assert.match(pending.lastError ?? "", /handler exploded/);
|
|
419
|
+
});
|
|
420
|
+
});
|