@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,333 @@
|
|
|
1
|
+
import {createScopedLogger, runWithRequestContext} from "@terreno/api";
|
|
2
|
+
import {DateTime} from "luxon";
|
|
3
|
+
import mongoose from "mongoose";
|
|
4
|
+
|
|
5
|
+
import {beginActiveJobExecution, endActiveJobExecution} from "./activeJobExecution";
|
|
6
|
+
import {
|
|
7
|
+
buildClaimOwnershipFilter,
|
|
8
|
+
createClaimLock,
|
|
9
|
+
isAbortError,
|
|
10
|
+
type JobClaimLock,
|
|
11
|
+
} from "./claimLock";
|
|
12
|
+
import {Job} from "./models/job";
|
|
13
|
+
import type {JobAttempt, JobDocument} from "./modelTypes";
|
|
14
|
+
import {computeRetryRunAt} from "./retryBackoff";
|
|
15
|
+
import type {JobDefinition} from "./types";
|
|
16
|
+
|
|
17
|
+
export interface JobExecutionHost {
|
|
18
|
+
getDefinition(name: string): JobDefinition | undefined;
|
|
19
|
+
getLockTtlMs(): number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ExecuteJobOutcome =
|
|
23
|
+
| {job: JobDocument; kind: "executed"}
|
|
24
|
+
| {job: JobDocument; kind: "noop"}
|
|
25
|
+
| {kind: "conflict"; reason: "locked"}
|
|
26
|
+
| {kind: "conflict"; reason: "not_due"; runAt: Date}
|
|
27
|
+
| {kind: "not_found"};
|
|
28
|
+
|
|
29
|
+
const TERMINAL_NOOP_STATUSES = new Set(["cancelled", "completed", "dead", "failed"]);
|
|
30
|
+
|
|
31
|
+
const buildLockExpiry = (lockTtlMs: number, now: Date): Date =>
|
|
32
|
+
DateTime.fromJSDate(now, {zone: "utc"}).minus({milliseconds: lockTtlMs}).toJSDate();
|
|
33
|
+
|
|
34
|
+
const isLiveLock = (lockedAt: Date | undefined, lockExpiry: Date): boolean =>
|
|
35
|
+
Boolean(lockedAt && lockedAt > lockExpiry);
|
|
36
|
+
|
|
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
|
+
const classifyConflict = (job: JobDocument, lockExpiry: Date, now: Date): ExecuteJobOutcome => {
|
|
49
|
+
if (job.runAt > now) {
|
|
50
|
+
return {kind: "conflict", reason: "not_due", runAt: job.runAt};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (job.status === "running" && isLiveLock(job.lockedAt, lockExpiry)) {
|
|
54
|
+
return {kind: "conflict", reason: "locked"};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {kind: "conflict", reason: "locked"};
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const recordHandlerSuccess = async (
|
|
61
|
+
jobId: JobDocument["_id"],
|
|
62
|
+
claim: JobClaimLock
|
|
63
|
+
): Promise<JobDocument> => {
|
|
64
|
+
const updated = await Job.findOneAndUpdate(
|
|
65
|
+
{
|
|
66
|
+
...buildClaimOwnershipFilter(jobId, claim),
|
|
67
|
+
status: "running",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
$set: {status: "completed"},
|
|
71
|
+
$unset: {lastError: ""},
|
|
72
|
+
},
|
|
73
|
+
{returnDocument: "after"}
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
if (!updated) {
|
|
77
|
+
const current = await Job.findOneOrNone({_id: jobId});
|
|
78
|
+
if (current?.status === "cancelled") {
|
|
79
|
+
return current;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
throw new Error(`Failed to record success for job ${jobId.toString()}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return updated;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const recordMissingHandlerFailure = async (
|
|
89
|
+
job: JobDocument,
|
|
90
|
+
message: string,
|
|
91
|
+
claim: JobClaimLock
|
|
92
|
+
): Promise<JobDocument> => {
|
|
93
|
+
const updated = await Job.findOneAndUpdate(
|
|
94
|
+
buildClaimOwnershipFilter(job._id, claim),
|
|
95
|
+
{
|
|
96
|
+
$set: {
|
|
97
|
+
lastError: message,
|
|
98
|
+
status: "failed",
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
{returnDocument: "after"}
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
if (!updated) {
|
|
105
|
+
throw new Error(`Failed to record missing handler for job ${job._id.toString()}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return updated;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const recordHandlerFailure = async ({
|
|
112
|
+
claim,
|
|
113
|
+
error,
|
|
114
|
+
job,
|
|
115
|
+
signal,
|
|
116
|
+
}: {
|
|
117
|
+
claim: JobClaimLock;
|
|
118
|
+
error: unknown;
|
|
119
|
+
job: JobDocument;
|
|
120
|
+
signal: AbortSignal;
|
|
121
|
+
}): Promise<JobDocument> => {
|
|
122
|
+
if (signal.aborted && isAbortError(error)) {
|
|
123
|
+
const current = await Job.findOneOrNone({_id: job._id});
|
|
124
|
+
if (current?.status === "cancelled") {
|
|
125
|
+
return current;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const released = await Job.findOneAndUpdate(
|
|
129
|
+
buildClaimOwnershipFilter(job._id, claim),
|
|
130
|
+
{
|
|
131
|
+
$set: {status: "pending"},
|
|
132
|
+
$unset: {lockedAt: "", lockedBy: ""},
|
|
133
|
+
},
|
|
134
|
+
{returnDocument: "after"}
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
if (!released) {
|
|
138
|
+
throw new Error(`Failed to release lock for job ${job._id.toString()}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return released;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
145
|
+
const errorClass = error instanceof Error ? error.name : "Error";
|
|
146
|
+
const attemptedAt = DateTime.utc().toJSDate();
|
|
147
|
+
const attemptEntry: JobAttempt = {
|
|
148
|
+
at: attemptedAt,
|
|
149
|
+
error: errorMessage,
|
|
150
|
+
errorClass,
|
|
151
|
+
};
|
|
152
|
+
const nextAttemptCount = job.attemptCount + 1;
|
|
153
|
+
const ownershipFilter = buildClaimOwnershipFilter(job._id, claim);
|
|
154
|
+
|
|
155
|
+
if (nextAttemptCount >= job.maxAttempts) {
|
|
156
|
+
const dead = await Job.findOneAndUpdate(
|
|
157
|
+
ownershipFilter,
|
|
158
|
+
{
|
|
159
|
+
$push: {attempts: attemptEntry},
|
|
160
|
+
$set: {
|
|
161
|
+
attemptCount: nextAttemptCount,
|
|
162
|
+
lastError: errorMessage,
|
|
163
|
+
status: "dead",
|
|
164
|
+
},
|
|
165
|
+
$unset: {lockedAt: "", lockedBy: ""},
|
|
166
|
+
},
|
|
167
|
+
{returnDocument: "after"}
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
if (!dead) {
|
|
171
|
+
throw new Error(`Failed to record dead status for job ${job._id.toString()}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return dead;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const runAt = computeRetryRunAt({
|
|
178
|
+
attemptCount: nextAttemptCount,
|
|
179
|
+
backoffMs: job.backoffMs,
|
|
180
|
+
from: DateTime.utc(),
|
|
181
|
+
maxBackoffMs: job.maxBackoffMs,
|
|
182
|
+
}).toJSDate();
|
|
183
|
+
|
|
184
|
+
const pending = await Job.findOneAndUpdate(
|
|
185
|
+
ownershipFilter,
|
|
186
|
+
{
|
|
187
|
+
$push: {attempts: attemptEntry},
|
|
188
|
+
$set: {
|
|
189
|
+
attemptCount: nextAttemptCount,
|
|
190
|
+
lastError: errorMessage,
|
|
191
|
+
runAt,
|
|
192
|
+
status: "pending",
|
|
193
|
+
},
|
|
194
|
+
$unset: {lockedAt: "", lockedBy: ""},
|
|
195
|
+
},
|
|
196
|
+
{returnDocument: "after"}
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
if (!pending) {
|
|
200
|
+
throw new Error(`Failed to record retry for job ${job._id.toString()}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return pending;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
export const runClaimedJob = async ({
|
|
207
|
+
claim,
|
|
208
|
+
host,
|
|
209
|
+
job,
|
|
210
|
+
signal,
|
|
211
|
+
}: {
|
|
212
|
+
claim: JobClaimLock;
|
|
213
|
+
host: JobExecutionHost;
|
|
214
|
+
job: JobDocument;
|
|
215
|
+
signal: AbortSignal;
|
|
216
|
+
}): Promise<JobDocument> => {
|
|
217
|
+
const definition = host.getDefinition(job.name);
|
|
218
|
+
if (!definition) {
|
|
219
|
+
const message = `No handler registered for job name "${job.name}"`;
|
|
220
|
+
return recordMissingHandlerFailure(job, message, claim);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const log = createScopedLogger({
|
|
224
|
+
labels: {jobId: job._id.toString(), jobName: job.name},
|
|
225
|
+
prefix: "[Job]",
|
|
226
|
+
});
|
|
227
|
+
const executionSignal = beginActiveJobExecution(job._id.toString(), signal);
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
await runWithRequestContext({jobId: job._id.toString()}, async () => {
|
|
231
|
+
await definition.handler(job.payload, {
|
|
232
|
+
jobId: job._id.toString(),
|
|
233
|
+
log,
|
|
234
|
+
signal: executionSignal,
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
return recordHandlerSuccess(job._id, claim);
|
|
239
|
+
} catch (error: unknown) {
|
|
240
|
+
return recordHandlerFailure({claim, error, job, signal: executionSignal});
|
|
241
|
+
} finally {
|
|
242
|
+
endActiveJobExecution(job._id.toString());
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const claimJobById = async ({
|
|
247
|
+
host,
|
|
248
|
+
jobId,
|
|
249
|
+
now = DateTime.utc().toJSDate(),
|
|
250
|
+
}: {
|
|
251
|
+
host: JobExecutionHost;
|
|
252
|
+
jobId: string;
|
|
253
|
+
now?: Date;
|
|
254
|
+
}): Promise<JobDocument | undefined> => {
|
|
255
|
+
const lockExpiry = buildLockExpiry(host.getLockTtlMs(), now);
|
|
256
|
+
const claim = createClaimLock(now);
|
|
257
|
+
|
|
258
|
+
const claimed = await Job.findOneAndUpdate(
|
|
259
|
+
{
|
|
260
|
+
_id: jobId,
|
|
261
|
+
$or: [{lockedAt: {$exists: false}}, {lockedAt: null}, {lockedAt: {$lte: lockExpiry}}],
|
|
262
|
+
runAt: {$lte: now},
|
|
263
|
+
status: {$in: ["pending", "running", "scheduled"]},
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
$set: {
|
|
267
|
+
lockedAt: claim.lockedAt,
|
|
268
|
+
lockedBy: claim.lockedBy,
|
|
269
|
+
status: "running",
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
{returnDocument: "after"}
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
return claimed ?? undefined;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
export const executeJobById = async ({
|
|
279
|
+
host,
|
|
280
|
+
jobId,
|
|
281
|
+
signal,
|
|
282
|
+
}: {
|
|
283
|
+
host: JobExecutionHost;
|
|
284
|
+
jobId: string;
|
|
285
|
+
signal: AbortSignal;
|
|
286
|
+
}): Promise<ExecuteJobOutcome> => {
|
|
287
|
+
if (!mongoose.isValidObjectId(jobId)) {
|
|
288
|
+
return {kind: "not_found"};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const existing = await Job.findOneOrNone({_id: jobId});
|
|
292
|
+
if (!existing) {
|
|
293
|
+
return {kind: "not_found"};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const now = DateTime.utc().toJSDate();
|
|
297
|
+
const lockExpiry = buildLockExpiry(host.getLockTtlMs(), now);
|
|
298
|
+
|
|
299
|
+
if (TERMINAL_NOOP_STATUSES.has(existing.status)) {
|
|
300
|
+
return {job: existing, kind: "noop"};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (existing.runAt > now) {
|
|
304
|
+
return {kind: "conflict", reason: "not_due", runAt: existing.runAt};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (existing.status === "running" && isLiveLock(existing.lockedAt, lockExpiry)) {
|
|
308
|
+
return {kind: "conflict", reason: "locked"};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const claimed = await claimJobById({host, jobId, now});
|
|
312
|
+
if (!claimed) {
|
|
313
|
+
const refreshed = await Job.findOneOrNone({_id: jobId});
|
|
314
|
+
if (!refreshed) {
|
|
315
|
+
return {kind: "not_found"};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (TERMINAL_NOOP_STATUSES.has(refreshed.status)) {
|
|
319
|
+
return {job: refreshed, kind: "noop"};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return classifyConflict(refreshed, lockExpiry, now);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const executed = await runClaimedJob({
|
|
326
|
+
claim: readClaimLock(claimed),
|
|
327
|
+
host,
|
|
328
|
+
job: claimed,
|
|
329
|
+
signal,
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
return {job: executed, kind: "executed"};
|
|
333
|
+
};
|
package/src/jobsApp.ts
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AdminContribution,
|
|
3
|
+
type AnyTerrenoAccess,
|
|
4
|
+
logger,
|
|
5
|
+
type TerrenoPlugin,
|
|
6
|
+
} from "@terreno/api";
|
|
7
|
+
import type express from "express";
|
|
8
|
+
|
|
9
|
+
import {type ExecuteJobOutcome, executeJobById} from "./jobExecutor";
|
|
10
|
+
import {JobsService, registerJobsService} from "./jobsService";
|
|
11
|
+
import {Job} from "./models/job";
|
|
12
|
+
import {JobSchedule} from "./models/jobSchedule";
|
|
13
|
+
import {registerJobsAdminRoutes} from "./routes/jobsAdmin";
|
|
14
|
+
import {type ExecuteAuthVerifier, registerJobsExecuteRoute} from "./routes/jobsExecute";
|
|
15
|
+
import {MongoJobRunner} from "./runners/mongoRunner";
|
|
16
|
+
import type {JobDefinition, JobRunner, JobsAdminRedactPayload, JobsRunnerHost} from "./types";
|
|
17
|
+
|
|
18
|
+
export interface JobsAppOptions {
|
|
19
|
+
accessControl?: AnyTerrenoAccess;
|
|
20
|
+
basePath?: string;
|
|
21
|
+
/** Verifies inbound execute HTTP requests (required when the execute route is mounted). */
|
|
22
|
+
executeAuth?: ExecuteAuthVerifier;
|
|
23
|
+
lockTtlMs?: number;
|
|
24
|
+
/** Mount internal `POST {basePath}/execute` for cloud/custom dispatch. */
|
|
25
|
+
mountExecuteRoute?: boolean;
|
|
26
|
+
pollIntervalMs?: number;
|
|
27
|
+
/** Admin list/detail payload projection. Default omits payload; identity hook may expose raw payload. */
|
|
28
|
+
redactPayload?: JobsAdminRedactPayload;
|
|
29
|
+
runner?: JobRunner;
|
|
30
|
+
timezone?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* TerrenoPlugin for durable background jobs.
|
|
35
|
+
*
|
|
36
|
+
* Registering `JobsApp` wires the plugin into the Express app but does **not**
|
|
37
|
+
* start a worker poll loop. Call {@link JobsApp.startWorker} explicitly from the
|
|
38
|
+
* API process or a dedicated worker entrypoint.
|
|
39
|
+
*/
|
|
40
|
+
export class JobsApp implements JobsRunnerHost, TerrenoPlugin {
|
|
41
|
+
private readonly options: JobsAppOptions;
|
|
42
|
+
private readonly runner: JobRunner;
|
|
43
|
+
private readonly service: JobsService;
|
|
44
|
+
private abortController: AbortController | undefined;
|
|
45
|
+
private workerActive = false;
|
|
46
|
+
private workerPromise: Promise<void> | undefined;
|
|
47
|
+
private workerStartPromise: Promise<void> | undefined;
|
|
48
|
+
|
|
49
|
+
constructor(options?: JobsAppOptions) {
|
|
50
|
+
this.options = options ?? {};
|
|
51
|
+
this.runner = this.options.runner ?? new MongoJobRunner();
|
|
52
|
+
this.service = new JobsService({
|
|
53
|
+
defaultTimezone: this.options.timezone ?? "UTC",
|
|
54
|
+
lockTtlMs: this.options.lockTtlMs,
|
|
55
|
+
runner: this.runner,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
define(name: string, definition: JobDefinition): this {
|
|
60
|
+
this.service.define(name, definition);
|
|
61
|
+
if (this.workerActive && definition.schedule) {
|
|
62
|
+
this.service.markSchedulesDirty();
|
|
63
|
+
}
|
|
64
|
+
return this;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
getBasePath(): string {
|
|
68
|
+
return this.options.basePath ?? "/jobs";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
getDefinition(name: string): JobDefinition | undefined {
|
|
72
|
+
return this.service.getDefinition(name);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
getDefaultTimezone(): string {
|
|
76
|
+
return this.service.getDefaultTimezone();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
getLockTtlMs(): number {
|
|
80
|
+
return this.service.getLockTtlMs();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Execute a persisted job by id from an external dispatch surface (queue consumer, execute HTTP). */
|
|
84
|
+
async executeQueuedJob(
|
|
85
|
+
jobId: string,
|
|
86
|
+
signal: AbortSignal = AbortSignal.timeout(30 * 60 * 1_000)
|
|
87
|
+
): Promise<ExecuteJobOutcome> {
|
|
88
|
+
return executeJobById({
|
|
89
|
+
host: this,
|
|
90
|
+
jobId,
|
|
91
|
+
signal,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
getPollIntervalMs(): number {
|
|
96
|
+
return this.options.pollIntervalMs ?? 1_000;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async reconcileSchedules(): Promise<void> {
|
|
100
|
+
await this.service.reconcileSchedules();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async reconcileSchedulesIfDirty(): Promise<void> {
|
|
104
|
+
await this.service.reconcileSchedulesIfDirty();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async tickSchedules(now?: Date): Promise<void> {
|
|
108
|
+
await this.service.tickSchedules(now);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
register(app: express.Application, openApi?: unknown): void {
|
|
112
|
+
registerJobsService(this.service);
|
|
113
|
+
|
|
114
|
+
registerJobsAdminRoutes({
|
|
115
|
+
app,
|
|
116
|
+
options: {
|
|
117
|
+
accessControl: this.options.accessControl,
|
|
118
|
+
basePath: this.getBasePath(),
|
|
119
|
+
jobsService: this.service,
|
|
120
|
+
openApi,
|
|
121
|
+
redactPayload: this.options.redactPayload,
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (this.shouldMountExecuteRoute()) {
|
|
126
|
+
if (!this.options.executeAuth) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
"JobsApp execute route is enabled but executeAuth is missing. Provide executeAuth when mountExecuteRoute is true or the runner requires the execute route."
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
registerJobsExecuteRoute({
|
|
133
|
+
app,
|
|
134
|
+
options: {
|
|
135
|
+
basePath: this.getBasePath(),
|
|
136
|
+
executeAuth: this.options.executeAuth,
|
|
137
|
+
host: this.service,
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
void Job.init().catch((error: unknown) => {
|
|
143
|
+
logger.error(`[jobs] Failed to build Job indexes: ${String(error)}`);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
void JobSchedule.init().catch((error: unknown) => {
|
|
147
|
+
logger.error(`[jobs] Failed to build JobSchedule indexes: ${String(error)}`);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
adminContribution(): AdminContribution {
|
|
152
|
+
return {
|
|
153
|
+
customScreens: [
|
|
154
|
+
{
|
|
155
|
+
displayName: "Jobs",
|
|
156
|
+
icon: "clock",
|
|
157
|
+
name: "jobs",
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
homeWidgets: [{displayName: "Jobs", icon: "clock", id: "jobs"}],
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private shouldMountExecuteRoute(): boolean {
|
|
165
|
+
if (this.options.mountExecuteRoute) {
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return this.runner.requiresExecuteRoute === true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
isWorkerActive(): boolean {
|
|
173
|
+
return this.workerActive;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Starts the background worker poll loop without awaiting it.
|
|
178
|
+
*
|
|
179
|
+
* Fire-and-forget: resolves once the runner has been scheduled. Runner errors are
|
|
180
|
+
* logged and worker state is reset; they are not thrown from this method.
|
|
181
|
+
*/
|
|
182
|
+
async startWorker(): Promise<void> {
|
|
183
|
+
if (this.workerActive) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (this.workerStartPromise !== undefined) {
|
|
188
|
+
await this.workerStartPromise;
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
this.workerStartPromise = this.beginWorkerStart();
|
|
193
|
+
try {
|
|
194
|
+
await this.workerStartPromise;
|
|
195
|
+
} finally {
|
|
196
|
+
this.workerStartPromise = undefined;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async stopWorker(): Promise<void> {
|
|
201
|
+
if (!this.workerActive) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const stopPromise = this.runner.stop?.();
|
|
206
|
+
this.abortController?.abort();
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
await this.workerPromise;
|
|
210
|
+
} catch {
|
|
211
|
+
// startWorker is fire-and-forget; runner errors are logged in workerPromise.catch.
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
await stopPromise;
|
|
215
|
+
|
|
216
|
+
this.resetWorkerState();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private resetWorkerState(): void {
|
|
220
|
+
this.workerActive = false;
|
|
221
|
+
this.abortController = undefined;
|
|
222
|
+
this.workerPromise = undefined;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private async beginWorkerStart(): Promise<void> {
|
|
226
|
+
if (this.workerActive) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!this.runner.start) {
|
|
231
|
+
throw new Error(`Job runner "${this.runner.id}" does not support start()`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
this.workerActive = true;
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
await this.service.reconcileSchedules();
|
|
238
|
+
|
|
239
|
+
this.abortController = new AbortController();
|
|
240
|
+
this.workerPromise = this.runner
|
|
241
|
+
.start({
|
|
242
|
+
jobs: this,
|
|
243
|
+
pollIntervalMs: this.getPollIntervalMs(),
|
|
244
|
+
signal: this.abortController.signal,
|
|
245
|
+
})
|
|
246
|
+
.catch((error: unknown) => {
|
|
247
|
+
this.resetWorkerState();
|
|
248
|
+
logger.error(`[jobs] Worker stopped with error: ${String(error)}`);
|
|
249
|
+
});
|
|
250
|
+
} catch (error: unknown) {
|
|
251
|
+
this.resetWorkerState();
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|