@terreno/jobs 57.16.2 → 57.18.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.
@@ -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;
@@ -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 executionSignal = (0, activeJobExecution_1.beginActiveJobExecution)(job._id.toString(), signal);
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
- return recordHandlerFailure({ claim, error, job, signal: executionSignal });
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
- const claimJobById = async ({ host, jobId, now = luxon_1.DateTime.utc().toJSDate(), }) => {
155
- const lockExpiry = buildLockExpiry(host.getLockTtlMs(), now);
156
- const claim = (0, claimLock_1.createClaimLock)(now);
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
- _id: jobId,
159
- $or: [{ lockedAt: { $exists: false } }, { lockedAt: null }, { lockedAt: { $lte: lockExpiry } }],
263
+ ...filter,
264
+ $or: unlockedOrExpired,
160
265
  runAt: { $lte: now },
161
- status: { $in: ["pending", "running", "scheduled"] },
266
+ status: { $in: ["pending", "scheduled"] },
162
267
  }, {
163
268
  $set: {
164
- lockedAt: claim.lockedAt,
165
- lockedBy: claim.lockedBy,
269
+ lockedAt: claimLock.lockedAt,
270
+ lockedBy: claimLock.lockedBy,
166
271
  status: "running",
167
272
  },
168
- }, { returnDocument: "after" });
169
- return claimed ?? undefined;
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 claimJobById({ host, jobId, now });
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: readClaimLock(claimed),
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 luxon_1 = require("luxon");
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 claimLock_2 = require("../claimLock");
11
- Object.defineProperty(exports, "getWorkerId", { enumerable: true, get: function () { return claimLock_2.getWorkerId; } });
12
- Object.defineProperty(exports, "hasWorkerIdPrefix", { enumerable: true, get: function () { return claimLock_2.hasWorkerIdPrefix; } });
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 now = luxon_1.DateTime.utc().toJSDate();
85
- const lockExpiry = buildLockExpiry(options.jobs.getLockTtlMs());
86
- const claim = (0, claimLock_1.createClaimLock)(now);
87
- const claimed = await job_1.Job.findOneAndUpdate({
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
- await (0, jobExecutor_1.runClaimedJob)({
102
- claim: readClaimLock(claimed),
103
- host: options.jobs,
104
- job: claimed,
105
- signal: options.signal,
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.16.2",
7
+ "@terreno/api": "57.18.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.16.2",
15
+ "@terreno/test": "57.18.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.16.2"
81
+ "version": "57.18.0"
82
82
  }
@@ -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 executionSignal = beginActiveJobExecution(job._id.toString(), signal);
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
- return recordHandlerFailure({claim, error, job, signal: executionSignal});
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
- const claimJobById = async ({
247
- host,
248
- jobId,
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
- host: JobExecutionHost;
252
- jobId: string;
366
+ filter?: Record<string, unknown>;
367
+ lockTtlMs: number;
253
368
  now?: Date;
254
- }): Promise<JobDocument | undefined> => {
255
- const lockExpiry = buildLockExpiry(host.getLockTtlMs(), now);
256
- const claim = createClaimLock(now);
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
- _id: jobId,
261
- $or: [{lockedAt: {$exists: false}}, {lockedAt: null}, {lockedAt: {$lte: lockExpiry}}],
423
+ ...filter,
424
+ $or: unlockedOrExpired,
262
425
  runAt: {$lte: now},
263
- status: {$in: ["pending", "running", "scheduled"]},
426
+ status: {$in: ["pending", "scheduled"]},
264
427
  },
265
428
  {
266
429
  $set: {
267
- lockedAt: claim.lockedAt,
268
- lockedBy: claim.lockedBy,
430
+ lockedAt: claimLock.lockedAt,
431
+ lockedBy: claimLock.lockedBy,
269
432
  status: "running",
270
433
  },
271
434
  },
272
- {returnDocument: "after"}
435
+ queryOptions
273
436
  );
274
437
 
275
- return claimed ?? undefined;
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 claimJobById({host, jobId, now});
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: readClaimLock(claimed),
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 {DateTime} from "luxon";
1
+ import {logger} from "@terreno/api";
2
2
 
3
- import {createClaimLock, type JobClaimLock} from "../claimLock";
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 now = DateTime.utc().toJSDate();
104
- const lockExpiry = buildLockExpiry(options.jobs.getLockTtlMs());
105
- const claim = createClaimLock(now);
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
- await runClaimedJob({
128
- claim: readClaimLock(claimed),
129
- host: options.jobs,
130
- job: claimed,
131
- signal: options.signal,
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
- 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()]);
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> => {