@terreno/jobs 57.16.1 → 57.17.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/dist/jobExecutor.d.ts +20 -0
- package/dist/jobExecutor.js +142 -26
- package/dist/runners/mongoRunner.js +23 -36
- package/package.json +3 -3
- package/src/jobExecutor.ts +209 -34
- package/src/runners/mongoRunner.ts +21 -43
- package/src/tests/jobsLease.test.ts +266 -0
- package/src/tests/jobsRetry.test.ts +30 -18
package/dist/jobExecutor.d.ts
CHANGED
|
@@ -21,12 +21,32 @@ export type ExecuteJobOutcome = {
|
|
|
21
21
|
} | {
|
|
22
22
|
kind: "not_found";
|
|
23
23
|
};
|
|
24
|
+
/** Result of an atomic claim: a row to run, or a reclaimed row dead-lettered with no attempts left. */
|
|
25
|
+
export type JobClaimResult = {
|
|
26
|
+
claim: JobClaimLock;
|
|
27
|
+
job: JobDocument;
|
|
28
|
+
kind: "claimed";
|
|
29
|
+
} | {
|
|
30
|
+
job: JobDocument;
|
|
31
|
+
kind: "dead";
|
|
32
|
+
};
|
|
24
33
|
export declare const runClaimedJob: ({ claim, host, job, signal, }: {
|
|
25
34
|
claim: JobClaimLock;
|
|
26
35
|
host: JobExecutionHost;
|
|
27
36
|
job: JobDocument;
|
|
28
37
|
signal: AbortSignal;
|
|
29
38
|
}) => Promise<JobDocument>;
|
|
39
|
+
/**
|
|
40
|
+
* Atomically claims one due job matching `filter`. An expired `running` row is reclaimed first and
|
|
41
|
+
* the lost attempt is counted; once that exhausts `maxAttempts` the row is dead-lettered instead of
|
|
42
|
+
* run again.
|
|
43
|
+
*/
|
|
44
|
+
export declare const claimDueJob: ({ filter, lockTtlMs, now, sort, }: {
|
|
45
|
+
filter?: Record<string, unknown>;
|
|
46
|
+
lockTtlMs: number;
|
|
47
|
+
now?: Date;
|
|
48
|
+
sort?: Record<string, 1 | -1>;
|
|
49
|
+
}) => Promise<JobClaimResult | undefined>;
|
|
30
50
|
export declare const executeJobById: ({ host, jobId, signal, }: {
|
|
31
51
|
host: JobExecutionHost;
|
|
32
52
|
jobId: string;
|
package/dist/jobExecutor.js
CHANGED
|
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.executeJobById = exports.runClaimedJob = void 0;
|
|
6
|
+
exports.executeJobById = exports.claimDueJob = exports.runClaimedJob = void 0;
|
|
7
7
|
const api_1 = require("@terreno/api");
|
|
8
8
|
const luxon_1 = require("luxon");
|
|
9
9
|
const mongoose_1 = __importDefault(require("mongoose"));
|
|
@@ -12,17 +12,10 @@ const claimLock_1 = require("./claimLock");
|
|
|
12
12
|
const job_1 = require("./models/job");
|
|
13
13
|
const retryBackoff_1 = require("./retryBackoff");
|
|
14
14
|
const TERMINAL_NOOP_STATUSES = new Set(["cancelled", "completed", "dead", "failed"]);
|
|
15
|
+
const LEASE_EXPIRED_ERROR = "Lease expired before the previous attempt finished";
|
|
16
|
+
const LEASE_EXPIRED_ERROR_CLASS = "LeaseExpired";
|
|
15
17
|
const buildLockExpiry = (lockTtlMs, now) => luxon_1.DateTime.fromJSDate(now, { zone: "utc" }).minus({ milliseconds: lockTtlMs }).toJSDate();
|
|
16
18
|
const isLiveLock = (lockedAt, lockExpiry) => Boolean(lockedAt && lockedAt > lockExpiry);
|
|
17
|
-
const readClaimLock = (job) => {
|
|
18
|
-
if (!job.lockedAt || !job.lockedBy) {
|
|
19
|
-
throw new Error(`Job ${job._id.toString()} is missing claim lock metadata`);
|
|
20
|
-
}
|
|
21
|
-
return {
|
|
22
|
-
lockedAt: job.lockedAt,
|
|
23
|
-
lockedBy: job.lockedBy,
|
|
24
|
-
};
|
|
25
|
-
};
|
|
26
19
|
const classifyConflict = (job, lockExpiry, now) => {
|
|
27
20
|
if (job.runAt > now) {
|
|
28
21
|
return { kind: "conflict", reason: "not_due", runAt: job.runAt };
|
|
@@ -122,6 +115,54 @@ const recordHandlerFailure = async ({ claim, error, job, signal, }) => {
|
|
|
122
115
|
}
|
|
123
116
|
return pending;
|
|
124
117
|
};
|
|
118
|
+
const buildLeaseRenewalIntervalMs = (lockTtlMs) => Math.max(1, Math.floor(lockTtlMs / 3));
|
|
119
|
+
/**
|
|
120
|
+
* Renews the claim's lockedAt while a handler runs so no other consumer treats the live job as
|
|
121
|
+
* abandoned. Renewals run one at a time; when a renewal no longer matches the claim, the lease is
|
|
122
|
+
* lost and the handler signal is aborted.
|
|
123
|
+
*/
|
|
124
|
+
const startLeaseHeartbeat = ({ claim, jobId, leaseController, lockTtlMs, }) => {
|
|
125
|
+
let currentClaim = claim;
|
|
126
|
+
let leaseLost = false;
|
|
127
|
+
let stopped = false;
|
|
128
|
+
let renewal = Promise.resolve();
|
|
129
|
+
const renew = async () => {
|
|
130
|
+
if (stopped || leaseLost) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const renewedAt = luxon_1.DateTime.utc().toJSDate();
|
|
134
|
+
const renewed = await job_1.Job.findOneAndUpdate({ ...(0, claimLock_1.buildClaimOwnershipFilter)(jobId, currentClaim), status: "running" }, { $set: { lockedAt: renewedAt } }, { returnDocument: "after" });
|
|
135
|
+
if (!renewed) {
|
|
136
|
+
leaseLost = true;
|
|
137
|
+
clearInterval(timer);
|
|
138
|
+
api_1.logger.warn(`[jobs] Lost the claim lease for job ${jobId.toString()}; aborting handler`);
|
|
139
|
+
leaseController.abort();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
currentClaim = { lockedAt: renewedAt, lockedBy: currentClaim.lockedBy };
|
|
143
|
+
};
|
|
144
|
+
const timer = setInterval(() => {
|
|
145
|
+
renewal = renewal.then(renew).catch((error) => {
|
|
146
|
+
api_1.logger.warn(`[jobs] Failed to renew the claim lease for job ${jobId.toString()}: ${String(error)}`);
|
|
147
|
+
});
|
|
148
|
+
}, buildLeaseRenewalIntervalMs(lockTtlMs));
|
|
149
|
+
return {
|
|
150
|
+
getClaim: () => currentClaim,
|
|
151
|
+
isLeaseLost: () => leaseLost,
|
|
152
|
+
stop: async () => {
|
|
153
|
+
stopped = true;
|
|
154
|
+
clearInterval(timer);
|
|
155
|
+
await renewal;
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
const linkAbortSignal = (parent, controller) => {
|
|
160
|
+
if (parent.aborted) {
|
|
161
|
+
controller.abort();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
parent.addEventListener("abort", () => controller.abort(), { once: true });
|
|
165
|
+
};
|
|
125
166
|
const runClaimedJob = async ({ claim, host, job, signal, }) => {
|
|
126
167
|
const definition = host.getDefinition(job.name);
|
|
127
168
|
if (!definition) {
|
|
@@ -132,7 +173,17 @@ const runClaimedJob = async ({ claim, host, job, signal, }) => {
|
|
|
132
173
|
labels: { jobId: job._id.toString(), jobName: job.name },
|
|
133
174
|
prefix: "[Job]",
|
|
134
175
|
});
|
|
135
|
-
const
|
|
176
|
+
const leaseController = new AbortController();
|
|
177
|
+
linkAbortSignal(signal, leaseController);
|
|
178
|
+
const executionSignal = (0, activeJobExecution_1.beginActiveJobExecution)(job._id.toString(), leaseController.signal);
|
|
179
|
+
const heartbeat = startLeaseHeartbeat({
|
|
180
|
+
claim,
|
|
181
|
+
jobId: job._id,
|
|
182
|
+
leaseController,
|
|
183
|
+
lockTtlMs: host.getLockTtlMs(),
|
|
184
|
+
});
|
|
185
|
+
let handlerError;
|
|
186
|
+
let handlerFailed = false;
|
|
136
187
|
try {
|
|
137
188
|
await (0, api_1.runWithRequestContext)({ jobId: job._id.toString() }, async () => {
|
|
138
189
|
await definition.handler(job.payload, {
|
|
@@ -141,33 +192,91 @@ const runClaimedJob = async ({ claim, host, job, signal, }) => {
|
|
|
141
192
|
signal: executionSignal,
|
|
142
193
|
});
|
|
143
194
|
});
|
|
144
|
-
return recordHandlerSuccess(job._id, claim);
|
|
145
195
|
}
|
|
146
196
|
catch (error) {
|
|
147
|
-
|
|
197
|
+
handlerFailed = true;
|
|
198
|
+
handlerError = error;
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
await heartbeat.stop();
|
|
202
|
+
// Another consumer owns the row now; its result is the one that counts.
|
|
203
|
+
if (heartbeat.isLeaseLost()) {
|
|
204
|
+
return job_1.Job.findExactlyOne({ _id: job._id });
|
|
205
|
+
}
|
|
206
|
+
const currentClaim = heartbeat.getClaim();
|
|
207
|
+
if (handlerFailed) {
|
|
208
|
+
return await recordHandlerFailure({
|
|
209
|
+
claim: currentClaim,
|
|
210
|
+
error: handlerError,
|
|
211
|
+
job,
|
|
212
|
+
signal: executionSignal,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
return await recordHandlerSuccess(job._id, currentClaim);
|
|
148
216
|
}
|
|
149
217
|
finally {
|
|
150
218
|
(0, activeJobExecution_1.endActiveJobExecution)(job._id.toString());
|
|
151
219
|
}
|
|
152
220
|
};
|
|
153
221
|
exports.runClaimedJob = runClaimedJob;
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
222
|
+
/**
|
|
223
|
+
* Atomically claims one due job matching `filter`. An expired `running` row is reclaimed first and
|
|
224
|
+
* the lost attempt is counted; once that exhausts `maxAttempts` the row is dead-lettered instead of
|
|
225
|
+
* run again.
|
|
226
|
+
*/
|
|
227
|
+
const claimDueJob = async ({ filter = {}, lockTtlMs, now = luxon_1.DateTime.utc().toJSDate(), sort, }) => {
|
|
228
|
+
const lockExpiry = buildLockExpiry(lockTtlMs, now);
|
|
229
|
+
const unlockedOrExpired = [
|
|
230
|
+
{ lockedAt: { $exists: false } },
|
|
231
|
+
{ lockedAt: null },
|
|
232
|
+
{ lockedAt: { $lte: lockExpiry } },
|
|
233
|
+
];
|
|
234
|
+
const queryOptions = { returnDocument: "after", ...(sort ? { sort } : {}) };
|
|
235
|
+
const reclaimLock = (0, claimLock_1.createClaimLock)(now);
|
|
236
|
+
const reclaimed = await job_1.Job.findOneAndUpdate({ ...filter, $or: unlockedOrExpired, runAt: { $lte: now }, status: "running" }, {
|
|
237
|
+
$inc: { attemptCount: 1 },
|
|
238
|
+
$push: {
|
|
239
|
+
attempts: { at: now, error: LEASE_EXPIRED_ERROR, errorClass: LEASE_EXPIRED_ERROR_CLASS },
|
|
240
|
+
},
|
|
241
|
+
$set: {
|
|
242
|
+
lastError: LEASE_EXPIRED_ERROR,
|
|
243
|
+
lockedAt: reclaimLock.lockedAt,
|
|
244
|
+
lockedBy: reclaimLock.lockedBy,
|
|
245
|
+
},
|
|
246
|
+
}, queryOptions);
|
|
247
|
+
if (reclaimed) {
|
|
248
|
+
if (reclaimed.attemptCount < reclaimed.maxAttempts) {
|
|
249
|
+
return { claim: reclaimLock, job: reclaimed, kind: "claimed" };
|
|
250
|
+
}
|
|
251
|
+
const dead = await job_1.Job.findOneAndUpdate((0, claimLock_1.buildClaimOwnershipFilter)(reclaimed._id, reclaimLock), {
|
|
252
|
+
$set: { status: "dead" },
|
|
253
|
+
$unset: { lockedAt: "", lockedBy: "" },
|
|
254
|
+
}, { returnDocument: "after" });
|
|
255
|
+
if (!dead) {
|
|
256
|
+
throw new Error(`Failed to record dead status for job ${reclaimed._id.toString()}`);
|
|
257
|
+
}
|
|
258
|
+
api_1.logger.warn(`[jobs] Job ${reclaimed._id.toString()} (${reclaimed.name}) lost its lease with no attempts left; marked dead`);
|
|
259
|
+
return { job: dead, kind: "dead" };
|
|
260
|
+
}
|
|
261
|
+
const claimLock = (0, claimLock_1.createClaimLock)(now);
|
|
157
262
|
const claimed = await job_1.Job.findOneAndUpdate({
|
|
158
|
-
|
|
159
|
-
$or:
|
|
263
|
+
...filter,
|
|
264
|
+
$or: unlockedOrExpired,
|
|
160
265
|
runAt: { $lte: now },
|
|
161
|
-
status: { $in: ["pending", "
|
|
266
|
+
status: { $in: ["pending", "scheduled"] },
|
|
162
267
|
}, {
|
|
163
268
|
$set: {
|
|
164
|
-
lockedAt:
|
|
165
|
-
lockedBy:
|
|
269
|
+
lockedAt: claimLock.lockedAt,
|
|
270
|
+
lockedBy: claimLock.lockedBy,
|
|
166
271
|
status: "running",
|
|
167
272
|
},
|
|
168
|
-
},
|
|
169
|
-
|
|
273
|
+
}, queryOptions);
|
|
274
|
+
if (!claimed) {
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
return { claim: claimLock, job: claimed, kind: "claimed" };
|
|
170
278
|
};
|
|
279
|
+
exports.claimDueJob = claimDueJob;
|
|
171
280
|
const executeJobById = async ({ host, jobId, signal, }) => {
|
|
172
281
|
if (!mongoose_1.default.isValidObjectId(jobId)) {
|
|
173
282
|
return { kind: "not_found" };
|
|
@@ -187,7 +296,11 @@ const executeJobById = async ({ host, jobId, signal, }) => {
|
|
|
187
296
|
if (existing.status === "running" && isLiveLock(existing.lockedAt, lockExpiry)) {
|
|
188
297
|
return { kind: "conflict", reason: "locked" };
|
|
189
298
|
}
|
|
190
|
-
const claimed = await
|
|
299
|
+
const claimed = await (0, exports.claimDueJob)({
|
|
300
|
+
filter: { _id: jobId },
|
|
301
|
+
lockTtlMs: host.getLockTtlMs(),
|
|
302
|
+
now,
|
|
303
|
+
});
|
|
191
304
|
if (!claimed) {
|
|
192
305
|
const refreshed = await job_1.Job.findOneOrNone({ _id: jobId });
|
|
193
306
|
if (!refreshed) {
|
|
@@ -198,10 +311,13 @@ const executeJobById = async ({ host, jobId, signal, }) => {
|
|
|
198
311
|
}
|
|
199
312
|
return classifyConflict(refreshed, lockExpiry, now);
|
|
200
313
|
}
|
|
314
|
+
if (claimed.kind === "dead") {
|
|
315
|
+
return { job: claimed.job, kind: "executed" };
|
|
316
|
+
}
|
|
201
317
|
const executed = await (0, exports.runClaimedJob)({
|
|
202
|
-
claim:
|
|
318
|
+
claim: claimed.claim,
|
|
203
319
|
host,
|
|
204
|
-
job: claimed,
|
|
320
|
+
job: claimed.job,
|
|
205
321
|
signal,
|
|
206
322
|
});
|
|
207
323
|
return { job: executed, kind: "executed" };
|
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.MongoJobRunner = exports.hasWorkerIdPrefix = exports.getWorkerId = void 0;
|
|
4
|
-
const
|
|
5
|
-
const claimLock_1 = require("../claimLock");
|
|
4
|
+
const api_1 = require("@terreno/api");
|
|
6
5
|
const jobExecutor_1 = require("../jobExecutor");
|
|
7
|
-
const job_1 = require("../models/job");
|
|
8
6
|
const DEFAULT_POLL_INTERVAL_MS = 1_000;
|
|
9
7
|
/** @internal */
|
|
10
|
-
var
|
|
11
|
-
Object.defineProperty(exports, "getWorkerId", { enumerable: true, get: function () { return
|
|
12
|
-
Object.defineProperty(exports, "hasWorkerIdPrefix", { enumerable: true, get: function () { return
|
|
13
|
-
const buildLockExpiry = (lockTtlMs) => luxon_1.DateTime.utc().minus({ milliseconds: lockTtlMs }).toJSDate();
|
|
8
|
+
var claimLock_1 = require("../claimLock");
|
|
9
|
+
Object.defineProperty(exports, "getWorkerId", { enumerable: true, get: function () { return claimLock_1.getWorkerId; } });
|
|
10
|
+
Object.defineProperty(exports, "hasWorkerIdPrefix", { enumerable: true, get: function () { return claimLock_1.hasWorkerIdPrefix; } });
|
|
14
11
|
const waitForAbortOrTimeout = (signal, timeoutMs) => new Promise((resolve) => {
|
|
15
12
|
if (signal.aborted) {
|
|
16
13
|
resolve();
|
|
@@ -26,15 +23,6 @@ const waitForAbortOrTimeout = (signal, timeoutMs) => new Promise((resolve) => {
|
|
|
26
23
|
};
|
|
27
24
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
28
25
|
});
|
|
29
|
-
const readClaimLock = (job) => {
|
|
30
|
-
if (!job.lockedAt || !job.lockedBy) {
|
|
31
|
-
throw new Error(`Job ${job._id.toString()} is missing claim lock metadata`);
|
|
32
|
-
}
|
|
33
|
-
return {
|
|
34
|
-
lockedAt: job.lockedAt,
|
|
35
|
-
lockedBy: job.lockedBy,
|
|
36
|
-
};
|
|
37
|
-
};
|
|
38
26
|
class MongoJobRunner {
|
|
39
27
|
id = "mongo";
|
|
40
28
|
pollIntervalMs = DEFAULT_POLL_INTERVAL_MS;
|
|
@@ -81,29 +69,28 @@ class MongoJobRunner {
|
|
|
81
69
|
});
|
|
82
70
|
}
|
|
83
71
|
async processNext(options) {
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
$or: [{ lockedAt: { $exists: false } }, { lockedAt: null }, { lockedAt: { $lte: lockExpiry } }],
|
|
89
|
-
runAt: { $lte: now },
|
|
90
|
-
status: { $in: ["pending", "running", "scheduled"] },
|
|
91
|
-
}, {
|
|
92
|
-
$set: {
|
|
93
|
-
lockedAt: claim.lockedAt,
|
|
94
|
-
lockedBy: claim.lockedBy,
|
|
95
|
-
status: "running",
|
|
96
|
-
},
|
|
97
|
-
}, { returnDocument: "after", sort: { runAt: 1 } });
|
|
72
|
+
const claimed = await (0, jobExecutor_1.claimDueJob)({
|
|
73
|
+
lockTtlMs: options.jobs.getLockTtlMs(),
|
|
74
|
+
sort: { runAt: 1 },
|
|
75
|
+
});
|
|
98
76
|
if (!claimed) {
|
|
99
77
|
return false;
|
|
100
78
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
79
|
+
if (claimed.kind === "dead") {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
// A failure to finalize one job (for example a lost lease) must not end the poll loop.
|
|
83
|
+
try {
|
|
84
|
+
await (0, jobExecutor_1.runClaimedJob)({
|
|
85
|
+
claim: claimed.claim,
|
|
86
|
+
host: options.jobs,
|
|
87
|
+
job: claimed.job,
|
|
88
|
+
signal: options.signal,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
api_1.logger.error(`[jobs] Failed to finalize job ${claimed.job._id.toString()}: ${String(error)}`);
|
|
93
|
+
}
|
|
107
94
|
return true;
|
|
108
95
|
}
|
|
109
96
|
}
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"url": "https://github.com/TerrenoLabs/terreno/issues"
|
|
5
5
|
},
|
|
6
6
|
"dependencies": {
|
|
7
|
-
"@terreno/api": "57.
|
|
7
|
+
"@terreno/api": "57.17.0",
|
|
8
8
|
"cron": "^4.3.4",
|
|
9
9
|
"express": "^5.2.1",
|
|
10
10
|
"luxon": "^3.7.2"
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"description": "Durable background jobs plugin for @terreno/api — part of Terreno, the batteries-included TypeScript framework for universal apps.",
|
|
13
13
|
"devDependencies": {
|
|
14
14
|
"@biomejs/biome": "^2.3.6",
|
|
15
|
-
"@terreno/test": "57.
|
|
15
|
+
"@terreno/test": "57.17.0",
|
|
16
16
|
"@types/bun": "^1.4.0",
|
|
17
17
|
"@types/express": "^5.0.6",
|
|
18
18
|
"@types/luxon": "^3.7.1",
|
|
@@ -78,5 +78,5 @@
|
|
|
78
78
|
"test:coverage": "bun run ../scripts/check-coverage.ts"
|
|
79
79
|
},
|
|
80
80
|
"types": "dist/index.d.ts",
|
|
81
|
-
"version": "57.
|
|
81
|
+
"version": "57.17.0"
|
|
82
82
|
}
|
package/src/jobExecutor.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {createScopedLogger, runWithRequestContext} from "@terreno/api";
|
|
1
|
+
import {createScopedLogger, logger, runWithRequestContext} from "@terreno/api";
|
|
2
2
|
import {DateTime} from "luxon";
|
|
3
3
|
import mongoose from "mongoose";
|
|
4
4
|
|
|
@@ -28,23 +28,20 @@ export type ExecuteJobOutcome =
|
|
|
28
28
|
|
|
29
29
|
const TERMINAL_NOOP_STATUSES = new Set(["cancelled", "completed", "dead", "failed"]);
|
|
30
30
|
|
|
31
|
+
const LEASE_EXPIRED_ERROR = "Lease expired before the previous attempt finished";
|
|
32
|
+
const LEASE_EXPIRED_ERROR_CLASS = "LeaseExpired";
|
|
33
|
+
|
|
34
|
+
/** Result of an atomic claim: a row to run, or a reclaimed row dead-lettered with no attempts left. */
|
|
35
|
+
export type JobClaimResult =
|
|
36
|
+
| {claim: JobClaimLock; job: JobDocument; kind: "claimed"}
|
|
37
|
+
| {job: JobDocument; kind: "dead"};
|
|
38
|
+
|
|
31
39
|
const buildLockExpiry = (lockTtlMs: number, now: Date): Date =>
|
|
32
40
|
DateTime.fromJSDate(now, {zone: "utc"}).minus({milliseconds: lockTtlMs}).toJSDate();
|
|
33
41
|
|
|
34
42
|
const isLiveLock = (lockedAt: Date | undefined, lockExpiry: Date): boolean =>
|
|
35
43
|
Boolean(lockedAt && lockedAt > lockExpiry);
|
|
36
44
|
|
|
37
|
-
const readClaimLock = (job: JobDocument): JobClaimLock => {
|
|
38
|
-
if (!job.lockedAt || !job.lockedBy) {
|
|
39
|
-
throw new Error(`Job ${job._id.toString()} is missing claim lock metadata`);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
return {
|
|
43
|
-
lockedAt: job.lockedAt,
|
|
44
|
-
lockedBy: job.lockedBy,
|
|
45
|
-
};
|
|
46
|
-
};
|
|
47
|
-
|
|
48
45
|
const classifyConflict = (job: JobDocument, lockExpiry: Date, now: Date): ExecuteJobOutcome => {
|
|
49
46
|
if (job.runAt > now) {
|
|
50
47
|
return {kind: "conflict", reason: "not_due", runAt: job.runAt};
|
|
@@ -203,6 +200,87 @@ const recordHandlerFailure = async ({
|
|
|
203
200
|
return pending;
|
|
204
201
|
};
|
|
205
202
|
|
|
203
|
+
const buildLeaseRenewalIntervalMs = (lockTtlMs: number): number =>
|
|
204
|
+
Math.max(1, Math.floor(lockTtlMs / 3));
|
|
205
|
+
|
|
206
|
+
interface LeaseHeartbeat {
|
|
207
|
+
getClaim: () => JobClaimLock;
|
|
208
|
+
isLeaseLost: () => boolean;
|
|
209
|
+
stop: () => Promise<void>;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Renews the claim's lockedAt while a handler runs so no other consumer treats the live job as
|
|
214
|
+
* abandoned. Renewals run one at a time; when a renewal no longer matches the claim, the lease is
|
|
215
|
+
* lost and the handler signal is aborted.
|
|
216
|
+
*/
|
|
217
|
+
const startLeaseHeartbeat = ({
|
|
218
|
+
claim,
|
|
219
|
+
jobId,
|
|
220
|
+
leaseController,
|
|
221
|
+
lockTtlMs,
|
|
222
|
+
}: {
|
|
223
|
+
claim: JobClaimLock;
|
|
224
|
+
jobId: JobDocument["_id"];
|
|
225
|
+
leaseController: AbortController;
|
|
226
|
+
lockTtlMs: number;
|
|
227
|
+
}): LeaseHeartbeat => {
|
|
228
|
+
let currentClaim = claim;
|
|
229
|
+
let leaseLost = false;
|
|
230
|
+
let stopped = false;
|
|
231
|
+
let renewal: Promise<void> = Promise.resolve();
|
|
232
|
+
|
|
233
|
+
const renew = async (): Promise<void> => {
|
|
234
|
+
if (stopped || leaseLost) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const renewedAt = DateTime.utc().toJSDate();
|
|
239
|
+
const renewed = await Job.findOneAndUpdate(
|
|
240
|
+
{...buildClaimOwnershipFilter(jobId, currentClaim), status: "running"},
|
|
241
|
+
{$set: {lockedAt: renewedAt}},
|
|
242
|
+
{returnDocument: "after"}
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
if (!renewed) {
|
|
246
|
+
leaseLost = true;
|
|
247
|
+
clearInterval(timer);
|
|
248
|
+
logger.warn(`[jobs] Lost the claim lease for job ${jobId.toString()}; aborting handler`);
|
|
249
|
+
leaseController.abort();
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
currentClaim = {lockedAt: renewedAt, lockedBy: currentClaim.lockedBy};
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const timer = setInterval(() => {
|
|
257
|
+
renewal = renewal.then(renew).catch((error: unknown) => {
|
|
258
|
+
logger.warn(
|
|
259
|
+
`[jobs] Failed to renew the claim lease for job ${jobId.toString()}: ${String(error)}`
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
}, buildLeaseRenewalIntervalMs(lockTtlMs));
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
getClaim: (): JobClaimLock => currentClaim,
|
|
266
|
+
isLeaseLost: (): boolean => leaseLost,
|
|
267
|
+
stop: async (): Promise<void> => {
|
|
268
|
+
stopped = true;
|
|
269
|
+
clearInterval(timer);
|
|
270
|
+
await renewal;
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const linkAbortSignal = (parent: AbortSignal, controller: AbortController): void => {
|
|
276
|
+
if (parent.aborted) {
|
|
277
|
+
controller.abort();
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
parent.addEventListener("abort", () => controller.abort(), {once: true});
|
|
282
|
+
};
|
|
283
|
+
|
|
206
284
|
export const runClaimedJob = async ({
|
|
207
285
|
claim,
|
|
208
286
|
host,
|
|
@@ -224,7 +302,18 @@ export const runClaimedJob = async ({
|
|
|
224
302
|
labels: {jobId: job._id.toString(), jobName: job.name},
|
|
225
303
|
prefix: "[Job]",
|
|
226
304
|
});
|
|
227
|
-
const
|
|
305
|
+
const leaseController = new AbortController();
|
|
306
|
+
linkAbortSignal(signal, leaseController);
|
|
307
|
+
const executionSignal = beginActiveJobExecution(job._id.toString(), leaseController.signal);
|
|
308
|
+
const heartbeat = startLeaseHeartbeat({
|
|
309
|
+
claim,
|
|
310
|
+
jobId: job._id,
|
|
311
|
+
leaseController,
|
|
312
|
+
lockTtlMs: host.getLockTtlMs(),
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
let handlerError: unknown;
|
|
316
|
+
let handlerFailed = false;
|
|
228
317
|
|
|
229
318
|
try {
|
|
230
319
|
await runWithRequestContext({jobId: job._id.toString()}, async () => {
|
|
@@ -234,45 +323,123 @@ export const runClaimedJob = async ({
|
|
|
234
323
|
signal: executionSignal,
|
|
235
324
|
});
|
|
236
325
|
});
|
|
237
|
-
|
|
238
|
-
return recordHandlerSuccess(job._id, claim);
|
|
239
326
|
} catch (error: unknown) {
|
|
240
|
-
|
|
327
|
+
handlerFailed = true;
|
|
328
|
+
handlerError = error;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
await heartbeat.stop();
|
|
333
|
+
|
|
334
|
+
// Another consumer owns the row now; its result is the one that counts.
|
|
335
|
+
if (heartbeat.isLeaseLost()) {
|
|
336
|
+
return Job.findExactlyOne({_id: job._id});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const currentClaim = heartbeat.getClaim();
|
|
340
|
+
if (handlerFailed) {
|
|
341
|
+
return await recordHandlerFailure({
|
|
342
|
+
claim: currentClaim,
|
|
343
|
+
error: handlerError,
|
|
344
|
+
job,
|
|
345
|
+
signal: executionSignal,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return await recordHandlerSuccess(job._id, currentClaim);
|
|
241
350
|
} finally {
|
|
242
351
|
endActiveJobExecution(job._id.toString());
|
|
243
352
|
}
|
|
244
353
|
};
|
|
245
354
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
355
|
+
/**
|
|
356
|
+
* Atomically claims one due job matching `filter`. An expired `running` row is reclaimed first and
|
|
357
|
+
* the lost attempt is counted; once that exhausts `maxAttempts` the row is dead-lettered instead of
|
|
358
|
+
* run again.
|
|
359
|
+
*/
|
|
360
|
+
export const claimDueJob = async ({
|
|
361
|
+
filter = {},
|
|
362
|
+
lockTtlMs,
|
|
249
363
|
now = DateTime.utc().toJSDate(),
|
|
364
|
+
sort,
|
|
250
365
|
}: {
|
|
251
|
-
|
|
252
|
-
|
|
366
|
+
filter?: Record<string, unknown>;
|
|
367
|
+
lockTtlMs: number;
|
|
253
368
|
now?: Date;
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const
|
|
369
|
+
sort?: Record<string, 1 | -1>;
|
|
370
|
+
}): Promise<JobClaimResult | undefined> => {
|
|
371
|
+
const lockExpiry = buildLockExpiry(lockTtlMs, now);
|
|
372
|
+
const unlockedOrExpired = [
|
|
373
|
+
{lockedAt: {$exists: false}},
|
|
374
|
+
{lockedAt: null},
|
|
375
|
+
{lockedAt: {$lte: lockExpiry}},
|
|
376
|
+
];
|
|
377
|
+
const queryOptions = {returnDocument: "after" as const, ...(sort ? {sort} : {})};
|
|
378
|
+
|
|
379
|
+
const reclaimLock = createClaimLock(now);
|
|
380
|
+
const reclaimed = await Job.findOneAndUpdate(
|
|
381
|
+
{...filter, $or: unlockedOrExpired, runAt: {$lte: now}, status: "running"},
|
|
382
|
+
{
|
|
383
|
+
$inc: {attemptCount: 1},
|
|
384
|
+
$push: {
|
|
385
|
+
attempts: {at: now, error: LEASE_EXPIRED_ERROR, errorClass: LEASE_EXPIRED_ERROR_CLASS},
|
|
386
|
+
},
|
|
387
|
+
$set: {
|
|
388
|
+
lastError: LEASE_EXPIRED_ERROR,
|
|
389
|
+
lockedAt: reclaimLock.lockedAt,
|
|
390
|
+
lockedBy: reclaimLock.lockedBy,
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
queryOptions
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
if (reclaimed) {
|
|
397
|
+
if (reclaimed.attemptCount < reclaimed.maxAttempts) {
|
|
398
|
+
return {claim: reclaimLock, job: reclaimed, kind: "claimed"};
|
|
399
|
+
}
|
|
257
400
|
|
|
401
|
+
const dead = await Job.findOneAndUpdate(
|
|
402
|
+
buildClaimOwnershipFilter(reclaimed._id, reclaimLock),
|
|
403
|
+
{
|
|
404
|
+
$set: {status: "dead"},
|
|
405
|
+
$unset: {lockedAt: "", lockedBy: ""},
|
|
406
|
+
},
|
|
407
|
+
{returnDocument: "after"}
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
if (!dead) {
|
|
411
|
+
throw new Error(`Failed to record dead status for job ${reclaimed._id.toString()}`);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
logger.warn(
|
|
415
|
+
`[jobs] Job ${reclaimed._id.toString()} (${reclaimed.name}) lost its lease with no attempts left; marked dead`
|
|
416
|
+
);
|
|
417
|
+
return {job: dead, kind: "dead"};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const claimLock = createClaimLock(now);
|
|
258
421
|
const claimed = await Job.findOneAndUpdate(
|
|
259
422
|
{
|
|
260
|
-
|
|
261
|
-
$or:
|
|
423
|
+
...filter,
|
|
424
|
+
$or: unlockedOrExpired,
|
|
262
425
|
runAt: {$lte: now},
|
|
263
|
-
status: {$in: ["pending", "
|
|
426
|
+
status: {$in: ["pending", "scheduled"]},
|
|
264
427
|
},
|
|
265
428
|
{
|
|
266
429
|
$set: {
|
|
267
|
-
lockedAt:
|
|
268
|
-
lockedBy:
|
|
430
|
+
lockedAt: claimLock.lockedAt,
|
|
431
|
+
lockedBy: claimLock.lockedBy,
|
|
269
432
|
status: "running",
|
|
270
433
|
},
|
|
271
434
|
},
|
|
272
|
-
|
|
435
|
+
queryOptions
|
|
273
436
|
);
|
|
274
437
|
|
|
275
|
-
|
|
438
|
+
if (!claimed) {
|
|
439
|
+
return undefined;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return {claim: claimLock, job: claimed, kind: "claimed"};
|
|
276
443
|
};
|
|
277
444
|
|
|
278
445
|
export const executeJobById = async ({
|
|
@@ -308,7 +475,11 @@ export const executeJobById = async ({
|
|
|
308
475
|
return {kind: "conflict", reason: "locked"};
|
|
309
476
|
}
|
|
310
477
|
|
|
311
|
-
const claimed = await
|
|
478
|
+
const claimed = await claimDueJob({
|
|
479
|
+
filter: {_id: jobId},
|
|
480
|
+
lockTtlMs: host.getLockTtlMs(),
|
|
481
|
+
now,
|
|
482
|
+
});
|
|
312
483
|
if (!claimed) {
|
|
313
484
|
const refreshed = await Job.findOneOrNone({_id: jobId});
|
|
314
485
|
if (!refreshed) {
|
|
@@ -322,10 +493,14 @@ export const executeJobById = async ({
|
|
|
322
493
|
return classifyConflict(refreshed, lockExpiry, now);
|
|
323
494
|
}
|
|
324
495
|
|
|
496
|
+
if (claimed.kind === "dead") {
|
|
497
|
+
return {job: claimed.job, kind: "executed"};
|
|
498
|
+
}
|
|
499
|
+
|
|
325
500
|
const executed = await runClaimedJob({
|
|
326
|
-
claim:
|
|
501
|
+
claim: claimed.claim,
|
|
327
502
|
host,
|
|
328
|
-
job: claimed,
|
|
503
|
+
job: claimed.job,
|
|
329
504
|
signal,
|
|
330
505
|
});
|
|
331
506
|
|
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {logger} from "@terreno/api";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {runClaimedJob} from "../jobExecutor";
|
|
5
|
-
import {Job} from "../models/job";
|
|
3
|
+
import {claimDueJob, runClaimedJob} from "../jobExecutor";
|
|
6
4
|
import type {JobDocument} from "../modelTypes";
|
|
7
5
|
import type {JobRunner, JobRunnerStartOptions} from "../types";
|
|
8
6
|
|
|
@@ -11,9 +9,6 @@ const DEFAULT_POLL_INTERVAL_MS = 1_000;
|
|
|
11
9
|
/** @internal */
|
|
12
10
|
export {getWorkerId, hasWorkerIdPrefix} from "../claimLock";
|
|
13
11
|
|
|
14
|
-
const buildLockExpiry = (lockTtlMs: number): Date =>
|
|
15
|
-
DateTime.utc().minus({milliseconds: lockTtlMs}).toJSDate();
|
|
16
|
-
|
|
17
12
|
const waitForAbortOrTimeout = (signal: AbortSignal, timeoutMs: number): Promise<void> =>
|
|
18
13
|
new Promise((resolve) => {
|
|
19
14
|
if (signal.aborted) {
|
|
@@ -34,17 +29,6 @@ const waitForAbortOrTimeout = (signal: AbortSignal, timeoutMs: number): Promise<
|
|
|
34
29
|
signal.addEventListener("abort", onAbort, {once: true});
|
|
35
30
|
});
|
|
36
31
|
|
|
37
|
-
const readClaimLock = (job: JobDocument): JobClaimLock => {
|
|
38
|
-
if (!job.lockedAt || !job.lockedBy) {
|
|
39
|
-
throw new Error(`Job ${job._id.toString()} is missing claim lock metadata`);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
return {
|
|
43
|
-
lockedAt: job.lockedAt,
|
|
44
|
-
lockedBy: job.lockedBy,
|
|
45
|
-
};
|
|
46
|
-
};
|
|
47
|
-
|
|
48
32
|
export class MongoJobRunner implements JobRunner {
|
|
49
33
|
readonly id = "mongo";
|
|
50
34
|
private pollIntervalMs = DEFAULT_POLL_INTERVAL_MS;
|
|
@@ -100,36 +84,30 @@ export class MongoJobRunner implements JobRunner {
|
|
|
100
84
|
}
|
|
101
85
|
|
|
102
86
|
private async processNext(options: JobRunnerStartOptions): Promise<boolean> {
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const claimed = await Job.findOneAndUpdate(
|
|
108
|
-
{
|
|
109
|
-
$or: [{lockedAt: {$exists: false}}, {lockedAt: null}, {lockedAt: {$lte: lockExpiry}}],
|
|
110
|
-
runAt: {$lte: now},
|
|
111
|
-
status: {$in: ["pending", "running", "scheduled"]},
|
|
112
|
-
},
|
|
113
|
-
{
|
|
114
|
-
$set: {
|
|
115
|
-
lockedAt: claim.lockedAt,
|
|
116
|
-
lockedBy: claim.lockedBy,
|
|
117
|
-
status: "running",
|
|
118
|
-
},
|
|
119
|
-
},
|
|
120
|
-
{returnDocument: "after", sort: {runAt: 1}}
|
|
121
|
-
);
|
|
87
|
+
const claimed = await claimDueJob({
|
|
88
|
+
lockTtlMs: options.jobs.getLockTtlMs(),
|
|
89
|
+
sort: {runAt: 1},
|
|
90
|
+
});
|
|
122
91
|
|
|
123
92
|
if (!claimed) {
|
|
124
93
|
return false;
|
|
125
94
|
}
|
|
126
95
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
96
|
+
if (claimed.kind === "dead") {
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// A failure to finalize one job (for example a lost lease) must not end the poll loop.
|
|
101
|
+
try {
|
|
102
|
+
await runClaimedJob({
|
|
103
|
+
claim: claimed.claim,
|
|
104
|
+
host: options.jobs,
|
|
105
|
+
job: claimed.job,
|
|
106
|
+
signal: options.signal,
|
|
107
|
+
});
|
|
108
|
+
} catch (error: unknown) {
|
|
109
|
+
logger.error(`[jobs] Failed to finalize job ${claimed.job._id.toString()}: ${String(error)}`);
|
|
110
|
+
}
|
|
133
111
|
return true;
|
|
134
112
|
}
|
|
135
113
|
}
|
|
@@ -0,0 +1,266 @@
|
|
|
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 {JobSchedule} from "../models/jobSchedule";
|
|
11
|
+
|
|
12
|
+
const typedUserModel = UserModel as unknown as UserModelType;
|
|
13
|
+
|
|
14
|
+
const waitUntil = async (
|
|
15
|
+
predicate: () => Promise<boolean>,
|
|
16
|
+
options?: {intervalMs?: number; timeoutMs?: number}
|
|
17
|
+
): Promise<void> => {
|
|
18
|
+
const intervalMs = options?.intervalMs ?? 10;
|
|
19
|
+
const timeoutMs = options?.timeoutMs ?? 5_000;
|
|
20
|
+
const deadline = DateTime.utc().plus({milliseconds: timeoutMs});
|
|
21
|
+
|
|
22
|
+
while (DateTime.utc() < deadline) {
|
|
23
|
+
if (await predicate()) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
await Bun.sleep(intervalMs);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
throw new Error("waitUntil timed out");
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const registerJobsApp = (jobsApp: JobsApp): void => {
|
|
33
|
+
new TerrenoApp({
|
|
34
|
+
skipListen: true,
|
|
35
|
+
userModel: typedUserModel,
|
|
36
|
+
})
|
|
37
|
+
.register(jobsApp)
|
|
38
|
+
.build();
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const createStaleRunningJob = async ({
|
|
42
|
+
maxAttempts,
|
|
43
|
+
name,
|
|
44
|
+
}: {
|
|
45
|
+
maxAttempts: number;
|
|
46
|
+
name: string;
|
|
47
|
+
}): Promise<string> => {
|
|
48
|
+
const stale = await Job.create({
|
|
49
|
+
attemptCount: 0,
|
|
50
|
+
lockedAt: DateTime.utc().minus({seconds: 5}).toJSDate(),
|
|
51
|
+
lockedBy: "crashed-worker:1:stale-token",
|
|
52
|
+
maxAttempts,
|
|
53
|
+
name,
|
|
54
|
+
payload: {},
|
|
55
|
+
payloadRedacted: false,
|
|
56
|
+
runAt: DateTime.utc().minus({seconds: 5}).toJSDate(),
|
|
57
|
+
status: "running",
|
|
58
|
+
});
|
|
59
|
+
return stale._id.toString();
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
describe("jobs claim lease", () => {
|
|
63
|
+
beforeEach(async (): Promise<void> => {
|
|
64
|
+
await setupDb();
|
|
65
|
+
await Job.deleteMany({});
|
|
66
|
+
await JobSchedule.deleteMany({});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("keeps the lease of a live handler so a later execute dispatch is rejected as locked", async (): Promise<void> => {
|
|
70
|
+
let invocations = 0;
|
|
71
|
+
let concurrent = 0;
|
|
72
|
+
let maxConcurrent = 0;
|
|
73
|
+
|
|
74
|
+
const jobsApp = new JobsApp({lockTtlMs: 100});
|
|
75
|
+
jobsApp.define("long-once", {
|
|
76
|
+
handler: async () => {
|
|
77
|
+
invocations += 1;
|
|
78
|
+
concurrent += 1;
|
|
79
|
+
maxConcurrent = Math.max(maxConcurrent, concurrent);
|
|
80
|
+
await Bun.sleep(400);
|
|
81
|
+
concurrent -= 1;
|
|
82
|
+
},
|
|
83
|
+
retry: {maxAttempts: 1},
|
|
84
|
+
});
|
|
85
|
+
registerJobsApp(jobsApp);
|
|
86
|
+
|
|
87
|
+
const enqueued = await getJobsService().enqueue({name: "long-once", payload: {}});
|
|
88
|
+
const jobId = enqueued._id.toString();
|
|
89
|
+
|
|
90
|
+
const first = jobsApp.executeQueuedJob(jobId);
|
|
91
|
+
await Bun.sleep(30);
|
|
92
|
+
const duplicateWithinTtl = await jobsApp.executeQueuedJob(jobId);
|
|
93
|
+
await Bun.sleep(220);
|
|
94
|
+
const duplicateAfterTtl = await jobsApp.executeQueuedJob(jobId);
|
|
95
|
+
const firstOutcome = await first;
|
|
96
|
+
|
|
97
|
+
assert.equal(duplicateWithinTtl.kind, "conflict");
|
|
98
|
+
assert.equal(duplicateAfterTtl.kind, "conflict");
|
|
99
|
+
assert.equal(firstOutcome.kind, "executed");
|
|
100
|
+
assert.equal(invocations, 1);
|
|
101
|
+
assert.equal(maxConcurrent, 1);
|
|
102
|
+
|
|
103
|
+
const row = await Job.findExactlyOne({_id: jobId});
|
|
104
|
+
assert.equal(row.status, "completed");
|
|
105
|
+
assert.equal(row.attemptCount, 0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("runs a long handler once across two poll workers and keeps both workers active", async (): Promise<void> => {
|
|
109
|
+
let invocations = 0;
|
|
110
|
+
let concurrent = 0;
|
|
111
|
+
let maxConcurrent = 0;
|
|
112
|
+
const handler = async (): Promise<void> => {
|
|
113
|
+
invocations += 1;
|
|
114
|
+
concurrent += 1;
|
|
115
|
+
maxConcurrent = Math.max(maxConcurrent, concurrent);
|
|
116
|
+
await Bun.sleep(600);
|
|
117
|
+
concurrent -= 1;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const workerA = new JobsApp({lockTtlMs: 150, pollIntervalMs: 20});
|
|
121
|
+
const workerB = new JobsApp({lockTtlMs: 150, pollIntervalMs: 20});
|
|
122
|
+
workerA.define("long-poll-once", {handler, retry: {maxAttempts: 1}});
|
|
123
|
+
workerB.define("long-poll-once", {handler, retry: {maxAttempts: 1}});
|
|
124
|
+
registerJobsApp(workerA);
|
|
125
|
+
|
|
126
|
+
const enqueued = await getJobsService().enqueue({name: "long-poll-once", payload: {}});
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
await Promise.all([workerA.startWorker(), workerB.startWorker()]);
|
|
130
|
+
await waitUntil(
|
|
131
|
+
async () => (await Job.findExactlyOne({_id: enqueued._id})).status === "completed"
|
|
132
|
+
);
|
|
133
|
+
await Bun.sleep(200);
|
|
134
|
+
|
|
135
|
+
assert.equal(invocations, 1);
|
|
136
|
+
assert.equal(maxConcurrent, 1);
|
|
137
|
+
assert.isTrue(workerA.isWorkerActive());
|
|
138
|
+
assert.isTrue(workerB.isWorkerActive());
|
|
139
|
+
|
|
140
|
+
const row = await Job.findExactlyOne({_id: enqueued._id});
|
|
141
|
+
assert.equal(row.status, "completed");
|
|
142
|
+
assert.equal(row.attemptCount, 0);
|
|
143
|
+
} finally {
|
|
144
|
+
await Promise.all([workerA.stopWorker(), workerB.stopWorker()]);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("dead-letters an expired running job on execute once its attempts are exhausted", async (): Promise<void> => {
|
|
149
|
+
let invocations = 0;
|
|
150
|
+
const jobsApp = new JobsApp({lockTtlMs: 100});
|
|
151
|
+
jobsApp.define("crashed-once", {
|
|
152
|
+
handler: async () => {
|
|
153
|
+
invocations += 1;
|
|
154
|
+
},
|
|
155
|
+
retry: {maxAttempts: 1},
|
|
156
|
+
});
|
|
157
|
+
registerJobsApp(jobsApp);
|
|
158
|
+
|
|
159
|
+
const jobId = await createStaleRunningJob({maxAttempts: 1, name: "crashed-once"});
|
|
160
|
+
const outcome = await jobsApp.executeQueuedJob(jobId);
|
|
161
|
+
|
|
162
|
+
assert.equal(outcome.kind, "executed");
|
|
163
|
+
assert.equal(invocations, 0);
|
|
164
|
+
const row = await Job.findExactlyOne({_id: jobId});
|
|
165
|
+
assert.equal(row.status, "dead");
|
|
166
|
+
assert.equal(row.attemptCount, 1);
|
|
167
|
+
assert.lengthOf(row.attempts, 1);
|
|
168
|
+
assert.isUndefined(row.lockedAt);
|
|
169
|
+
assert.isUndefined(row.lockedBy);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("dead-letters an expired running job in the poll loop once its attempts are exhausted", async (): Promise<void> => {
|
|
173
|
+
let invocations = 0;
|
|
174
|
+
const jobsApp = new JobsApp({lockTtlMs: 100, pollIntervalMs: 20});
|
|
175
|
+
jobsApp.define("crashed-poll-once", {
|
|
176
|
+
handler: async () => {
|
|
177
|
+
invocations += 1;
|
|
178
|
+
},
|
|
179
|
+
retry: {maxAttempts: 1},
|
|
180
|
+
});
|
|
181
|
+
registerJobsApp(jobsApp);
|
|
182
|
+
|
|
183
|
+
const jobId = await createStaleRunningJob({maxAttempts: 1, name: "crashed-poll-once"});
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
await jobsApp.startWorker();
|
|
187
|
+
await waitUntil(async () => (await Job.findExactlyOne({_id: jobId})).status === "dead");
|
|
188
|
+
} finally {
|
|
189
|
+
await jobsApp.stopWorker();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
assert.equal(invocations, 0);
|
|
193
|
+
const row = await Job.findExactlyOne({_id: jobId});
|
|
194
|
+
assert.equal(row.attemptCount, 1);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("counts a reclaim as an attempt and still runs the job when attempts remain", async (): Promise<void> => {
|
|
198
|
+
let invocations = 0;
|
|
199
|
+
const jobsApp = new JobsApp({lockTtlMs: 100});
|
|
200
|
+
jobsApp.define("crashed-retryable", {
|
|
201
|
+
handler: async () => {
|
|
202
|
+
invocations += 1;
|
|
203
|
+
},
|
|
204
|
+
retry: {maxAttempts: 3},
|
|
205
|
+
});
|
|
206
|
+
registerJobsApp(jobsApp);
|
|
207
|
+
|
|
208
|
+
const jobId = await createStaleRunningJob({maxAttempts: 3, name: "crashed-retryable"});
|
|
209
|
+
const outcome = await jobsApp.executeQueuedJob(jobId);
|
|
210
|
+
|
|
211
|
+
assert.equal(outcome.kind, "executed");
|
|
212
|
+
assert.equal(invocations, 1);
|
|
213
|
+
const row = await Job.findExactlyOne({_id: jobId});
|
|
214
|
+
assert.equal(row.status, "completed");
|
|
215
|
+
assert.equal(row.attemptCount, 1);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("aborts a handler that loses its lease and keeps the poll loop alive", async (): Promise<void> => {
|
|
219
|
+
let sawAbort = false;
|
|
220
|
+
let handlerStarted = false;
|
|
221
|
+
|
|
222
|
+
const jobsApp = new JobsApp({lockTtlMs: 90, pollIntervalMs: 20});
|
|
223
|
+
jobsApp.define("lease-lost", {
|
|
224
|
+
handler: async (_payload, ctx) => {
|
|
225
|
+
handlerStarted = true;
|
|
226
|
+
await waitUntil(() => Promise.resolve(ctx.signal.aborted), {timeoutMs: 2_000});
|
|
227
|
+
sawAbort = true;
|
|
228
|
+
},
|
|
229
|
+
retry: {maxAttempts: 1},
|
|
230
|
+
});
|
|
231
|
+
jobsApp.define("after-lease-lost", {
|
|
232
|
+
handler: async () => {},
|
|
233
|
+
});
|
|
234
|
+
registerJobsApp(jobsApp);
|
|
235
|
+
|
|
236
|
+
const enqueued = await getJobsService().enqueue({name: "lease-lost", payload: {}});
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
await jobsApp.startWorker();
|
|
240
|
+
await waitUntil(() => Promise.resolve(handlerStarted));
|
|
241
|
+
// Another consumer takes the row over (for example after this worker stalled past the TTL).
|
|
242
|
+
await Job.updateOne(
|
|
243
|
+
{_id: enqueued._id},
|
|
244
|
+
{
|
|
245
|
+
$set: {
|
|
246
|
+
lockedAt: DateTime.utc().plus({minutes: 5}).toJSDate(),
|
|
247
|
+
lockedBy: "other-worker:2:other-token",
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
);
|
|
251
|
+
await waitUntil(() => Promise.resolve(sawAbort));
|
|
252
|
+
|
|
253
|
+
const followUp = await getJobsService().enqueue({name: "after-lease-lost", payload: {}});
|
|
254
|
+
await waitUntil(
|
|
255
|
+
async () => (await Job.findExactlyOne({_id: followUp._id})).status === "completed"
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
assert.isTrue(jobsApp.isWorkerActive());
|
|
259
|
+
const row = await Job.findExactlyOne({_id: enqueued._id});
|
|
260
|
+
assert.equal(row.status, "running");
|
|
261
|
+
assert.equal(row.lockedBy, "other-worker:2:other-token");
|
|
262
|
+
} finally {
|
|
263
|
+
await jobsApp.stopWorker();
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -364,24 +364,36 @@ describe("jobs retry and dead-letter", () => {
|
|
|
364
364
|
payload: {},
|
|
365
365
|
});
|
|
366
366
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
367
|
+
try {
|
|
368
|
+
await staleWorker.startWorker();
|
|
369
|
+
await waitUntil(() => Promise.resolve(staleHandlerRunning));
|
|
370
|
+
|
|
371
|
+
// The stale worker's lease renewals stop landing (for example a stalled process), so its
|
|
372
|
+
// lock ages past the TTL and another worker reclaims the row.
|
|
373
|
+
await Job.updateOne(
|
|
374
|
+
{_id: enqueued._id},
|
|
375
|
+
{$set: {lockedAt: DateTime.utc().minus({seconds: 5}).toJSDate()}}
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
await reclaimWorker.startWorker();
|
|
379
|
+
await waitUntil(
|
|
380
|
+
async () => (await Job.findExactlyOne({_id: enqueued._id})).status === "completed"
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
releaseStaleHandler();
|
|
384
|
+
await Bun.sleep(150);
|
|
385
|
+
|
|
386
|
+
const finalRow = await Job.findExactlyOne({_id: enqueued._id});
|
|
387
|
+
assert.equal(finalRow.status, "completed");
|
|
388
|
+
assert.equal(finalRow.attemptCount, 1);
|
|
389
|
+
assert.lengthOf(finalRow.attempts, 1);
|
|
390
|
+
assert.equal(finalRow.attempts[0].errorClass, "LeaseExpired");
|
|
391
|
+
assert.isTrue(hasWorkerIdPrefix(finalRow.lockedBy));
|
|
392
|
+
assert.isTrue(staleWorker.isWorkerActive());
|
|
393
|
+
} finally {
|
|
394
|
+
releaseStaleHandler();
|
|
395
|
+
await Promise.all([staleWorker.stopWorker(), reclaimWorker.stopWorker()]);
|
|
396
|
+
}
|
|
385
397
|
});
|
|
386
398
|
|
|
387
399
|
it("keeps the worker running after a retryable handler failure", async (): Promise<void> => {
|