@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,290 @@
|
|
|
1
|
+
import {createRequire} from "node:module";
|
|
2
|
+
|
|
3
|
+
import {DateTime} from "luxon";
|
|
4
|
+
|
|
5
|
+
import type {JobDocument} from "../modelTypes";
|
|
6
|
+
import type {JobRunner, JobRunnerStartOptions} from "../types";
|
|
7
|
+
|
|
8
|
+
export interface GcpTimestamp {
|
|
9
|
+
nanos?: number;
|
|
10
|
+
seconds: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** protobuf Duration (`google.protobuf.Duration`) — used by `@google-cloud/tasks`. */
|
|
14
|
+
export interface GcpDuration {
|
|
15
|
+
nanos?: number;
|
|
16
|
+
seconds: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface GcpHttpRequest {
|
|
20
|
+
body?: string;
|
|
21
|
+
headers?: Record<string, string>;
|
|
22
|
+
httpMethod: "POST";
|
|
23
|
+
oidcToken?: {
|
|
24
|
+
audience?: string;
|
|
25
|
+
serviceAccountEmail: string;
|
|
26
|
+
};
|
|
27
|
+
url: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface GcpTask {
|
|
31
|
+
dispatchDeadline?: GcpDuration;
|
|
32
|
+
httpRequest?: GcpHttpRequest;
|
|
33
|
+
scheduleTime?: GcpTimestamp;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface GcpCreateTaskRequest {
|
|
37
|
+
parent: string;
|
|
38
|
+
task: GcpTask;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface GcpCreateTaskResponse {
|
|
42
|
+
name?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Narrow Cloud Tasks client seam — inject in tests; production loads `@google-cloud/tasks`. */
|
|
46
|
+
export interface GcpCloudTasksClient {
|
|
47
|
+
createTask(request: GcpCreateTaskRequest): Promise<[GcpCreateTaskResponse]>;
|
|
48
|
+
queuePath(project: string, location: string, queue: string): string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Cloud Tasks HTTP `dispatchDeadline` bounds (15s–30min). Default 30min matches Cloud Run. */
|
|
52
|
+
export const GCP_HTTP_TASK_MIN_DISPATCH_DEADLINE_SECONDS = 15;
|
|
53
|
+
export const GCP_HTTP_TASK_MAX_DISPATCH_DEADLINE_SECONDS = 1_800;
|
|
54
|
+
export const GCP_HTTP_TASK_DEFAULT_DISPATCH_DEADLINE_SECONDS = 1_800;
|
|
55
|
+
|
|
56
|
+
export interface GcpCloudTasksRunnerConfig {
|
|
57
|
+
basePath?: string;
|
|
58
|
+
/** Injected client for tests. Production wiring loads `@google-cloud/tasks`. */
|
|
59
|
+
client?: GcpCloudTasksClient;
|
|
60
|
+
/**
|
|
61
|
+
* Seconds Cloud Tasks waits for POST /jobs/execute before retrying.
|
|
62
|
+
* HTTP range is 15–1800. Default 1800 so a 30-minute handler is not retried at 10 minutes.
|
|
63
|
+
*/
|
|
64
|
+
dispatchDeadlineSeconds?: number;
|
|
65
|
+
location: string;
|
|
66
|
+
oidcAudience?: string;
|
|
67
|
+
project: string;
|
|
68
|
+
publicUrl: string;
|
|
69
|
+
queue: string;
|
|
70
|
+
serviceAccountEmail: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface ResolvedGcpCloudTasksRunnerConfig {
|
|
74
|
+
basePath: string;
|
|
75
|
+
dispatchDeadline: GcpDuration;
|
|
76
|
+
location: string;
|
|
77
|
+
oidcAudience?: string;
|
|
78
|
+
project: string;
|
|
79
|
+
publicUrl: string;
|
|
80
|
+
queue: string;
|
|
81
|
+
serviceAccountEmail: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const nodeRequire = createRequire(__filename);
|
|
85
|
+
|
|
86
|
+
const requireNonEmpty = (value: string | undefined, label: string): string => {
|
|
87
|
+
const trimmed = value?.trim();
|
|
88
|
+
if (!trimmed) {
|
|
89
|
+
throw new Error(`GcpCloudTasksRunner requires ${label}`);
|
|
90
|
+
}
|
|
91
|
+
return trimmed;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const normalizePublicUrl = (publicUrl: string): string => {
|
|
95
|
+
let parsed: URL;
|
|
96
|
+
try {
|
|
97
|
+
parsed = new URL(publicUrl);
|
|
98
|
+
} catch {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`GcpCloudTasksRunner publicUrl must be an absolute http(s) URL, received: ${publicUrl}`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
105
|
+
throw new Error(`GcpCloudTasksRunner publicUrl must use http or https, received: ${publicUrl}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return parsed.toString().replace(/\/+$/, "");
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const normalizeBasePath = (basePath: string | undefined): string => {
|
|
112
|
+
const trimmed = (basePath ?? "/jobs").trim();
|
|
113
|
+
if (!trimmed.startsWith("/")) {
|
|
114
|
+
throw new Error(`GcpCloudTasksRunner basePath must start with "/", received: ${basePath}`);
|
|
115
|
+
}
|
|
116
|
+
return trimmed.replace(/\/+$/, "") || "/";
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const validateDispatchDeadlineSeconds = (dispatchDeadlineSeconds: number | undefined): number => {
|
|
120
|
+
if (dispatchDeadlineSeconds === undefined) {
|
|
121
|
+
return GCP_HTTP_TASK_DEFAULT_DISPATCH_DEADLINE_SECONDS;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (
|
|
125
|
+
!Number.isFinite(dispatchDeadlineSeconds) ||
|
|
126
|
+
dispatchDeadlineSeconds < GCP_HTTP_TASK_MIN_DISPATCH_DEADLINE_SECONDS ||
|
|
127
|
+
dispatchDeadlineSeconds > GCP_HTTP_TASK_MAX_DISPATCH_DEADLINE_SECONDS
|
|
128
|
+
) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`GcpCloudTasksRunner dispatchDeadlineSeconds must be between ${GCP_HTTP_TASK_MIN_DISPATCH_DEADLINE_SECONDS} and ${GCP_HTTP_TASK_MAX_DISPATCH_DEADLINE_SECONDS} seconds`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return dispatchDeadlineSeconds;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const toProtobufDuration = (dispatchDeadlineSeconds: number): GcpDuration => ({
|
|
138
|
+
seconds: dispatchDeadlineSeconds,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const resolveConfig = (config: GcpCloudTasksRunnerConfig): ResolvedGcpCloudTasksRunnerConfig => ({
|
|
142
|
+
basePath: normalizeBasePath(config.basePath),
|
|
143
|
+
dispatchDeadline: toProtobufDuration(
|
|
144
|
+
validateDispatchDeadlineSeconds(config.dispatchDeadlineSeconds)
|
|
145
|
+
),
|
|
146
|
+
location: requireNonEmpty(config.location, "location"),
|
|
147
|
+
oidcAudience: config.oidcAudience?.trim() || undefined,
|
|
148
|
+
project: requireNonEmpty(config.project, "project"),
|
|
149
|
+
publicUrl: normalizePublicUrl(requireNonEmpty(config.publicUrl, "publicUrl")),
|
|
150
|
+
queue: requireNonEmpty(config.queue, "queue"),
|
|
151
|
+
serviceAccountEmail: requireNonEmpty(config.serviceAccountEmail, "serviceAccountEmail"),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const loadCloudTasksClient = (): (new () => GcpCloudTasksClient) => {
|
|
155
|
+
try {
|
|
156
|
+
const module = nodeRequire("@google-cloud/tasks") as {
|
|
157
|
+
CloudTasksClient: new () => GcpCloudTasksClient;
|
|
158
|
+
};
|
|
159
|
+
return module.CloudTasksClient;
|
|
160
|
+
} catch {
|
|
161
|
+
throw new Error(
|
|
162
|
+
"GcpCloudTasksRunner requires optional peer dependency @google-cloud/tasks. " +
|
|
163
|
+
"Install it with: bun add @google-cloud/tasks"
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const buildExecuteUrl = (publicUrl: string, basePath: string): string =>
|
|
169
|
+
`${publicUrl}${basePath}/execute`;
|
|
170
|
+
|
|
171
|
+
const buildScheduleTime = (runAt: Date): GcpTimestamp | undefined => {
|
|
172
|
+
const scheduled = DateTime.fromJSDate(runAt, {zone: "utc"});
|
|
173
|
+
const now = DateTime.utc();
|
|
174
|
+
if (scheduled <= now) {
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
nanos: (scheduled.toMillis() % 1000) * 1_000_000,
|
|
180
|
+
seconds: Math.floor(scheduled.toSeconds()),
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const waitForAbortOrTimeout = (signal: AbortSignal, timeoutMs: number): Promise<void> =>
|
|
185
|
+
new Promise((resolve) => {
|
|
186
|
+
if (signal.aborted) {
|
|
187
|
+
resolve();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const timer = setTimeout(() => {
|
|
192
|
+
signal.removeEventListener("abort", onAbort);
|
|
193
|
+
resolve();
|
|
194
|
+
}, timeoutMs);
|
|
195
|
+
|
|
196
|
+
const onAbort = (): void => {
|
|
197
|
+
clearTimeout(timer);
|
|
198
|
+
resolve();
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
signal.addEventListener("abort", onAbort, {once: true});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const DEFAULT_SCHEDULE_POLL_INTERVAL_MS = 1_000;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Dispatch-only runner that enqueues Cloud Tasks HTTP POST callbacks to
|
|
208
|
+
* `{publicUrl}{basePath}/execute` with an OIDC token.
|
|
209
|
+
*
|
|
210
|
+
* `start()` ticks Mongo schedules so cron rows enqueue Cloud Tasks. It does not
|
|
211
|
+
* claim job rows — execution happens on POST /jobs/execute.
|
|
212
|
+
*/
|
|
213
|
+
export class GcpCloudTasksRunner implements JobRunner {
|
|
214
|
+
readonly id = "gcp-cloud-tasks";
|
|
215
|
+
readonly requiresExecuteRoute = true;
|
|
216
|
+
|
|
217
|
+
private readonly client: GcpCloudTasksClient;
|
|
218
|
+
private readonly config: ResolvedGcpCloudTasksRunnerConfig;
|
|
219
|
+
private pollIntervalMs = DEFAULT_SCHEDULE_POLL_INTERVAL_MS;
|
|
220
|
+
private running = false;
|
|
221
|
+
private stopResolve: (() => void) | undefined;
|
|
222
|
+
|
|
223
|
+
constructor(config: GcpCloudTasksRunnerConfig) {
|
|
224
|
+
this.config = resolveConfig(config);
|
|
225
|
+
this.client = config.client ?? new (loadCloudTasksClient())();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async enqueue(job: JobDocument): Promise<void> {
|
|
229
|
+
const executeUrl = buildExecuteUrl(this.config.publicUrl, this.config.basePath);
|
|
230
|
+
const body = Buffer.from(JSON.stringify({jobId: job._id.toString()})).toString("base64");
|
|
231
|
+
const task: GcpTask = {
|
|
232
|
+
dispatchDeadline: this.config.dispatchDeadline,
|
|
233
|
+
httpRequest: {
|
|
234
|
+
body,
|
|
235
|
+
headers: {
|
|
236
|
+
"Content-Type": "application/json",
|
|
237
|
+
},
|
|
238
|
+
httpMethod: "POST",
|
|
239
|
+
oidcToken: {
|
|
240
|
+
audience: this.config.oidcAudience ?? executeUrl,
|
|
241
|
+
serviceAccountEmail: this.config.serviceAccountEmail,
|
|
242
|
+
},
|
|
243
|
+
url: executeUrl,
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const scheduleTime = buildScheduleTime(job.runAt);
|
|
248
|
+
if (scheduleTime) {
|
|
249
|
+
task.scheduleTime = scheduleTime;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const parent = this.client.queuePath(
|
|
253
|
+
this.config.project,
|
|
254
|
+
this.config.location,
|
|
255
|
+
this.config.queue
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
await this.client.createTask({parent, task});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async start(options: JobRunnerStartOptions): Promise<void> {
|
|
262
|
+
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_SCHEDULE_POLL_INTERVAL_MS;
|
|
263
|
+
this.running = true;
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
while (!options.signal.aborted) {
|
|
267
|
+
await options.jobs.reconcileSchedulesIfDirty();
|
|
268
|
+
await options.jobs.tickSchedules();
|
|
269
|
+
if (options.signal.aborted) {
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
await waitForAbortOrTimeout(options.signal, this.pollIntervalMs);
|
|
273
|
+
}
|
|
274
|
+
} finally {
|
|
275
|
+
this.running = false;
|
|
276
|
+
this.stopResolve?.();
|
|
277
|
+
this.stopResolve = undefined;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async stop(): Promise<void> {
|
|
282
|
+
if (!this.running) {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
await new Promise<void>((resolve) => {
|
|
287
|
+
this.stopResolve = resolve;
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import {DateTime} from "luxon";
|
|
2
|
+
|
|
3
|
+
import {createClaimLock, type JobClaimLock} from "../claimLock";
|
|
4
|
+
import {runClaimedJob} from "../jobExecutor";
|
|
5
|
+
import {Job} from "../models/job";
|
|
6
|
+
import type {JobDocument} from "../modelTypes";
|
|
7
|
+
import type {JobRunner, JobRunnerStartOptions} from "../types";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_POLL_INTERVAL_MS = 1_000;
|
|
10
|
+
|
|
11
|
+
/** @internal */
|
|
12
|
+
export {getWorkerId, hasWorkerIdPrefix} from "../claimLock";
|
|
13
|
+
|
|
14
|
+
const buildLockExpiry = (lockTtlMs: number): Date =>
|
|
15
|
+
DateTime.utc().minus({milliseconds: lockTtlMs}).toJSDate();
|
|
16
|
+
|
|
17
|
+
const waitForAbortOrTimeout = (signal: AbortSignal, timeoutMs: number): Promise<void> =>
|
|
18
|
+
new Promise((resolve) => {
|
|
19
|
+
if (signal.aborted) {
|
|
20
|
+
resolve();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const timer = setTimeout(() => {
|
|
25
|
+
signal.removeEventListener("abort", onAbort);
|
|
26
|
+
resolve();
|
|
27
|
+
}, timeoutMs);
|
|
28
|
+
|
|
29
|
+
const onAbort = (): void => {
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
resolve();
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
signal.addEventListener("abort", onAbort, {once: true});
|
|
35
|
+
});
|
|
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
|
+
export class MongoJobRunner implements JobRunner {
|
|
49
|
+
readonly id = "mongo";
|
|
50
|
+
private pollIntervalMs = DEFAULT_POLL_INTERVAL_MS;
|
|
51
|
+
private running = false;
|
|
52
|
+
private stopResolve: (() => void) | undefined;
|
|
53
|
+
|
|
54
|
+
async enqueue(_job: JobDocument): Promise<void> {
|
|
55
|
+
// Mongo persistence happens before dispatch; the poller picks up the row.
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async start(options: JobRunnerStartOptions): Promise<void> {
|
|
59
|
+
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
60
|
+
this.running = true;
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
while (!options.signal.aborted) {
|
|
64
|
+
await options.jobs.reconcileSchedulesIfDirty();
|
|
65
|
+
await options.jobs.tickSchedules();
|
|
66
|
+
|
|
67
|
+
let processedAny = false;
|
|
68
|
+
|
|
69
|
+
while (!options.signal.aborted) {
|
|
70
|
+
const processed = await this.processNext(options);
|
|
71
|
+
if (!processed) {
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
processedAny = true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (options.signal.aborted) {
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!processedAny) {
|
|
82
|
+
await waitForAbortOrTimeout(options.signal, this.pollIntervalMs);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} finally {
|
|
86
|
+
this.running = false;
|
|
87
|
+
this.stopResolve?.();
|
|
88
|
+
this.stopResolve = undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async stop(): Promise<void> {
|
|
93
|
+
if (!this.running) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await new Promise<void>((resolve) => {
|
|
98
|
+
this.stopResolve = resolve;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
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
|
+
);
|
|
122
|
+
|
|
123
|
+
if (!claimed) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
await runClaimedJob({
|
|
128
|
+
claim: readClaimLock(claimed),
|
|
129
|
+
host: options.jobs,
|
|
130
|
+
job: claimed,
|
|
131
|
+
signal: options.signal,
|
|
132
|
+
});
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
}
|