@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,394 @@
|
|
|
1
|
+
import {createRequire} from "node:module";
|
|
2
|
+
|
|
3
|
+
import {DateTime} from "luxon";
|
|
4
|
+
|
|
5
|
+
import {type ExecuteJobOutcome, executeJobById, type JobExecutionHost} from "../jobExecutor";
|
|
6
|
+
import type {JobDocument} from "../modelTypes";
|
|
7
|
+
import type {JobRunner} from "../types";
|
|
8
|
+
|
|
9
|
+
/** Vercel Queues maximum delay and retention window (7 days). */
|
|
10
|
+
export const VERCEL_QUEUE_MAX_DELAY_SECONDS = 604_800;
|
|
11
|
+
export const VERCEL_QUEUE_DEFAULT_RETENTION_SECONDS = 86_400;
|
|
12
|
+
export const VERCEL_QUEUE_MIN_RETENTION_SECONDS = 60;
|
|
13
|
+
export const VERCEL_QUEUE_MIN_VISIBILITY_TIMEOUT_SECONDS = 30;
|
|
14
|
+
export const VERCEL_QUEUE_MAX_VISIBILITY_TIMEOUT_SECONDS = 3_600;
|
|
15
|
+
export const VERCEL_QUEUE_DEFAULT_VISIBILITY_TIMEOUT_SECONDS = 1_800;
|
|
16
|
+
export const VERCEL_QUEUE_LOCKED_RETRY_SECONDS = 30;
|
|
17
|
+
|
|
18
|
+
export interface VercelQueueSendOptions {
|
|
19
|
+
delaySeconds?: number;
|
|
20
|
+
headers?: Record<string, string>;
|
|
21
|
+
idempotencyKey?: string;
|
|
22
|
+
region?: string;
|
|
23
|
+
retentionSeconds?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface VercelQueueSendResult {
|
|
27
|
+
messageId: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Narrow `send` seam — inject in tests; production loads `@vercel/queue`. */
|
|
31
|
+
export type VercelQueueSendFn = (
|
|
32
|
+
topic: string,
|
|
33
|
+
payload: unknown,
|
|
34
|
+
options?: VercelQueueSendOptions
|
|
35
|
+
) => Promise<VercelQueueSendResult>;
|
|
36
|
+
|
|
37
|
+
/** `@vercel/queue` 0.5.1 message metadata (all fields required). */
|
|
38
|
+
export interface VercelQueueMessageMetadata {
|
|
39
|
+
consumerGroup: string;
|
|
40
|
+
createdAt: Date;
|
|
41
|
+
deliveryCount: number;
|
|
42
|
+
expiresAt: Date;
|
|
43
|
+
messageId: string;
|
|
44
|
+
region: string;
|
|
45
|
+
topicName: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type VercelQueueRetryDirective = {acknowledge: true} | {afterSeconds: number};
|
|
49
|
+
|
|
50
|
+
export type VercelQueueMessageHandler = (
|
|
51
|
+
message: unknown,
|
|
52
|
+
metadata: VercelQueueMessageMetadata
|
|
53
|
+
) => Promise<void>;
|
|
54
|
+
|
|
55
|
+
export type VercelQueueRetryHandler = (
|
|
56
|
+
error: unknown,
|
|
57
|
+
metadata: VercelQueueMessageMetadata
|
|
58
|
+
) => VercelQueueRetryDirective | undefined;
|
|
59
|
+
|
|
60
|
+
export type VercelHandleCallbackFn = (
|
|
61
|
+
handler: VercelQueueMessageHandler,
|
|
62
|
+
options?: VercelHandleCallbackOptions
|
|
63
|
+
) => VercelQueueRouteHandler;
|
|
64
|
+
|
|
65
|
+
export interface VercelHandleCallbackOptions {
|
|
66
|
+
retry?: VercelQueueRetryHandler;
|
|
67
|
+
visibilityTimeoutSeconds?: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type VercelQueueRouteRequest = Request | {request: Request};
|
|
71
|
+
|
|
72
|
+
export type VercelQueueRouteHandler = (request: VercelQueueRouteRequest) => Promise<Response>;
|
|
73
|
+
|
|
74
|
+
export interface VercelQueuesRunnerConfig {
|
|
75
|
+
/** Injected `send` for tests. Production wiring loads `@vercel/queue`. */
|
|
76
|
+
send?: VercelQueueSendFn;
|
|
77
|
+
region?: string;
|
|
78
|
+
retentionSeconds?: number;
|
|
79
|
+
/** Queue topic published by {@link VercelQueuesRunner.enqueue}. */
|
|
80
|
+
topic: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface VercelQueuesConsumerOptions {
|
|
84
|
+
handleCallback?: VercelHandleCallbackFn;
|
|
85
|
+
host: JobExecutionHost;
|
|
86
|
+
signal?: AbortSignal;
|
|
87
|
+
visibilityTimeoutSeconds?: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface ResolvedVercelQueuesRunnerConfig {
|
|
91
|
+
region?: string;
|
|
92
|
+
retentionSeconds?: number;
|
|
93
|
+
topic: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const nodeRequire = createRequire(__filename);
|
|
97
|
+
|
|
98
|
+
export class VercelQueueRunAtBeyondLimitError extends Error {
|
|
99
|
+
readonly maxDelaySeconds: number;
|
|
100
|
+
readonly runAt: Date;
|
|
101
|
+
|
|
102
|
+
constructor(runAt: Date, maxDelaySeconds: number) {
|
|
103
|
+
super(
|
|
104
|
+
`Job runAt ${runAt.toISOString()} exceeds Vercel Queue maximum delay of ${maxDelaySeconds} seconds`
|
|
105
|
+
);
|
|
106
|
+
this.name = "VercelQueueRunAtBeyondLimitError";
|
|
107
|
+
this.runAt = runAt;
|
|
108
|
+
this.maxDelaySeconds = maxDelaySeconds;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export class VercelQueueConsumerControl extends Error {
|
|
113
|
+
readonly directive: VercelQueueRetryDirective;
|
|
114
|
+
|
|
115
|
+
constructor(directive: VercelQueueRetryDirective) {
|
|
116
|
+
super("Vercel queue consumer control");
|
|
117
|
+
this.name = "VercelQueueConsumerControl";
|
|
118
|
+
this.directive = directive;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const requireNonEmpty = (value: string | undefined, label: string): string => {
|
|
123
|
+
const trimmed = value?.trim();
|
|
124
|
+
if (!trimmed) {
|
|
125
|
+
throw new Error(`VercelQueuesRunner requires ${label}`);
|
|
126
|
+
}
|
|
127
|
+
return trimmed;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const validateRetentionSeconds = (retentionSeconds: number | undefined): number | undefined => {
|
|
131
|
+
if (retentionSeconds === undefined) {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (
|
|
136
|
+
!Number.isFinite(retentionSeconds) ||
|
|
137
|
+
retentionSeconds < VERCEL_QUEUE_MIN_RETENTION_SECONDS ||
|
|
138
|
+
retentionSeconds > VERCEL_QUEUE_MAX_DELAY_SECONDS
|
|
139
|
+
) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`VercelQueuesRunner retentionSeconds must be between ${VERCEL_QUEUE_MIN_RETENTION_SECONDS} and ${VERCEL_QUEUE_MAX_DELAY_SECONDS} seconds`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return retentionSeconds;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const validateVisibilityTimeoutSeconds = (visibilityTimeoutSeconds: number | undefined): number => {
|
|
149
|
+
if (visibilityTimeoutSeconds === undefined) {
|
|
150
|
+
return VERCEL_QUEUE_DEFAULT_VISIBILITY_TIMEOUT_SECONDS;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (
|
|
154
|
+
!Number.isFinite(visibilityTimeoutSeconds) ||
|
|
155
|
+
visibilityTimeoutSeconds < VERCEL_QUEUE_MIN_VISIBILITY_TIMEOUT_SECONDS ||
|
|
156
|
+
visibilityTimeoutSeconds > VERCEL_QUEUE_MAX_VISIBILITY_TIMEOUT_SECONDS
|
|
157
|
+
) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`VercelQueuesConsumer visibilityTimeoutSeconds must be between ${VERCEL_QUEUE_MIN_VISIBILITY_TIMEOUT_SECONDS} and ${VERCEL_QUEUE_MAX_VISIBILITY_TIMEOUT_SECONDS} seconds`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return visibilityTimeoutSeconds;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const resolveConfig = (config: VercelQueuesRunnerConfig): ResolvedVercelQueuesRunnerConfig => ({
|
|
167
|
+
region: config.region?.trim() || undefined,
|
|
168
|
+
retentionSeconds: validateRetentionSeconds(config.retentionSeconds),
|
|
169
|
+
topic: requireNonEmpty(config.topic, "topic"),
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const loadQueueSend = (): VercelQueueSendFn => {
|
|
173
|
+
try {
|
|
174
|
+
const module = nodeRequire("@vercel/queue") as {send: VercelQueueSendFn};
|
|
175
|
+
return module.send;
|
|
176
|
+
} catch {
|
|
177
|
+
throw new Error(
|
|
178
|
+
"VercelQueuesRunner requires optional peer dependency @vercel/queue. " +
|
|
179
|
+
"Install it with: bun add @vercel/queue"
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const loadHandleCallback = (): VercelHandleCallbackFn => {
|
|
185
|
+
try {
|
|
186
|
+
const module = nodeRequire("@vercel/queue") as {handleCallback: VercelHandleCallbackFn};
|
|
187
|
+
return module.handleCallback;
|
|
188
|
+
} catch {
|
|
189
|
+
throw new Error(
|
|
190
|
+
"createVercelQueuesConsumer requires optional peer dependency @vercel/queue. " +
|
|
191
|
+
"Install it with: bun add @vercel/queue"
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const readJobId = (message: unknown): string | undefined => {
|
|
197
|
+
if (!message || typeof message !== "object" || !("jobId" in message)) {
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const {jobId} = message as {jobId?: unknown};
|
|
202
|
+
if (typeof jobId !== "string") {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const trimmed = jobId.trim();
|
|
207
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
export const computeVercelQueueDelaySeconds = (
|
|
211
|
+
runAt: Date,
|
|
212
|
+
now = DateTime.utc()
|
|
213
|
+
): number | undefined => {
|
|
214
|
+
const scheduled = DateTime.fromJSDate(runAt, {zone: "utc"});
|
|
215
|
+
if (scheduled <= now) {
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const delaySeconds = Math.floor(scheduled.diff(now, "seconds").seconds);
|
|
220
|
+
if (delaySeconds <= 0) {
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (delaySeconds > VERCEL_QUEUE_MAX_DELAY_SECONDS) {
|
|
225
|
+
throw new VercelQueueRunAtBeyondLimitError(runAt, VERCEL_QUEUE_MAX_DELAY_SECONDS);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return delaySeconds;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
export const resolveVercelQueueSendRetentionSeconds = (
|
|
232
|
+
delaySeconds: number | undefined,
|
|
233
|
+
configuredRetention?: number
|
|
234
|
+
): number => {
|
|
235
|
+
const baseRetention = configuredRetention ?? VERCEL_QUEUE_DEFAULT_RETENTION_SECONDS;
|
|
236
|
+
if (delaySeconds === undefined) {
|
|
237
|
+
return baseRetention;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return Math.max(baseRetention, delaySeconds);
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
/** Clamp queue retry visibility changes to the vendor 0.5.1 range (30s–1h). */
|
|
244
|
+
export const clampVercelQueueRetryAfterSeconds = (seconds: number): number =>
|
|
245
|
+
Math.max(
|
|
246
|
+
VERCEL_QUEUE_MIN_VISIBILITY_TIMEOUT_SECONDS,
|
|
247
|
+
Math.min(seconds, VERCEL_QUEUE_MAX_VISIBILITY_TIMEOUT_SECONDS)
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Seconds until {@link runAt}, clamped for `handleCallback` retry visibility (30–3600).
|
|
252
|
+
* not_due jobs more than 1h away retry at 3600s until due.
|
|
253
|
+
*/
|
|
254
|
+
export const computeVercelQueueRetryAfterSeconds = (runAt: Date, now = DateTime.utc()): number => {
|
|
255
|
+
const seconds = Math.ceil(DateTime.fromJSDate(runAt, {zone: "utc"}).diff(now, "seconds").seconds);
|
|
256
|
+
if (seconds <= 0) {
|
|
257
|
+
return VERCEL_QUEUE_MIN_VISIBILITY_TIMEOUT_SECONDS;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return clampVercelQueueRetryAfterSeconds(seconds);
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
export const buildVercelQueueSendOptions = (
|
|
264
|
+
job: JobDocument,
|
|
265
|
+
config: {region?: string; retentionSeconds?: number},
|
|
266
|
+
now = DateTime.utc()
|
|
267
|
+
): VercelQueueSendOptions => {
|
|
268
|
+
const delaySeconds = computeVercelQueueDelaySeconds(job.runAt, now);
|
|
269
|
+
const retentionSeconds = resolveVercelQueueSendRetentionSeconds(
|
|
270
|
+
delaySeconds,
|
|
271
|
+
config.retentionSeconds
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
idempotencyKey: job._id.toString(),
|
|
276
|
+
retentionSeconds,
|
|
277
|
+
...(config.region ? {region: config.region} : {}),
|
|
278
|
+
...(delaySeconds !== undefined ? {delaySeconds} : {}),
|
|
279
|
+
};
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const normalizeRetryDirective = (
|
|
283
|
+
directive: VercelQueueRetryDirective
|
|
284
|
+
): VercelQueueRetryDirective => {
|
|
285
|
+
if ("acknowledge" in directive) {
|
|
286
|
+
return directive;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return {afterSeconds: clampVercelQueueRetryAfterSeconds(directive.afterSeconds)};
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
export const resolveVercelQueueConsumerRetry = (
|
|
293
|
+
error: unknown
|
|
294
|
+
): VercelQueueRetryDirective | undefined => {
|
|
295
|
+
if (error instanceof VercelQueueConsumerControl) {
|
|
296
|
+
return normalizeRetryDirective(error.directive);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return undefined;
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
export const applyVercelQueueExecutionOutcome = (outcome: ExecuteJobOutcome): void => {
|
|
303
|
+
if (outcome.kind === "not_found") {
|
|
304
|
+
throw new VercelQueueConsumerControl({acknowledge: true});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (outcome.kind === "noop") {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (outcome.kind === "conflict") {
|
|
312
|
+
if (outcome.reason === "not_due") {
|
|
313
|
+
throw new VercelQueueConsumerControl({
|
|
314
|
+
afterSeconds: computeVercelQueueRetryAfterSeconds(outcome.runAt),
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
throw new VercelQueueConsumerControl({afterSeconds: VERCEL_QUEUE_LOCKED_RETRY_SECONDS});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (outcome.job.status === "pending") {
|
|
322
|
+
const now = DateTime.utc();
|
|
323
|
+
const afterSeconds =
|
|
324
|
+
outcome.job.runAt > now.toJSDate()
|
|
325
|
+
? computeVercelQueueRetryAfterSeconds(outcome.job.runAt, now)
|
|
326
|
+
: VERCEL_QUEUE_MIN_VISIBILITY_TIMEOUT_SECONDS;
|
|
327
|
+
|
|
328
|
+
throw new VercelQueueConsumerControl({afterSeconds});
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Dispatch-only runner that publishes `{jobId}` to a Vercel Queue topic via `send`.
|
|
334
|
+
*
|
|
335
|
+
* Required `vercel.json` consumer (private route — platform auth only):
|
|
336
|
+
* `"experimentalTriggers": [{ "type": "queue/v2beta", "topic": "<topic>" }]`
|
|
337
|
+
*/
|
|
338
|
+
export class VercelQueuesRunner implements JobRunner {
|
|
339
|
+
readonly id = "vercel-queues";
|
|
340
|
+
/**
|
|
341
|
+
* Vercel push consumers are private `handleCallback` routes, not Express
|
|
342
|
+
* `POST /jobs/execute`. Use {@link createVercelQueuesConsumer} on the consumer route.
|
|
343
|
+
*/
|
|
344
|
+
readonly requiresExecuteRoute = false;
|
|
345
|
+
|
|
346
|
+
private readonly config: ResolvedVercelQueuesRunnerConfig;
|
|
347
|
+
private readonly send: VercelQueueSendFn;
|
|
348
|
+
|
|
349
|
+
constructor(config: VercelQueuesRunnerConfig) {
|
|
350
|
+
this.config = resolveConfig(config);
|
|
351
|
+
this.send = config.send ?? loadQueueSend();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async enqueue(job: JobDocument): Promise<void> {
|
|
355
|
+
const payload = {jobId: job._id.toString()};
|
|
356
|
+
const options = buildVercelQueueSendOptions(job, this.config);
|
|
357
|
+
|
|
358
|
+
await this.send(this.config.topic, payload, options);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Wraps vendor `handleCallback` to execute a queued `{jobId}` through Terreno's
|
|
364
|
+
* Mongo-backed {@link executeJobById} seam. Platform auth stays in the vendor handler.
|
|
365
|
+
*/
|
|
366
|
+
export const createVercelQueuesConsumer = (
|
|
367
|
+
options: VercelQueuesConsumerOptions
|
|
368
|
+
): VercelQueueRouteHandler => {
|
|
369
|
+
const handleCallback = options.handleCallback ?? loadHandleCallback();
|
|
370
|
+
const signal = options.signal ?? AbortSignal.timeout(30 * 60 * 1_000);
|
|
371
|
+
const visibilityTimeoutSeconds = validateVisibilityTimeoutSeconds(
|
|
372
|
+
options.visibilityTimeoutSeconds
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
return handleCallback(
|
|
376
|
+
async (message: unknown) => {
|
|
377
|
+
const jobId = readJobId(message);
|
|
378
|
+
if (!jobId) {
|
|
379
|
+
throw new VercelQueueConsumerControl({acknowledge: true});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const outcome = await executeJobById({
|
|
383
|
+
host: options.host,
|
|
384
|
+
jobId,
|
|
385
|
+
signal,
|
|
386
|
+
});
|
|
387
|
+
applyVercelQueueExecutionOutcome(outcome);
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
retry: resolveVercelQueueConsumerRetry,
|
|
391
|
+
visibilityTimeoutSeconds,
|
|
392
|
+
}
|
|
393
|
+
);
|
|
394
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {APIError} from "@terreno/api";
|
|
2
|
+
import {CronTime} from "cron";
|
|
3
|
+
import {DateTime} from "luxon";
|
|
4
|
+
|
|
5
|
+
const INVALID_CRON_TITLE = "Invalid cron expression";
|
|
6
|
+
const INVALID_TIMEZONE_TITLE = "Invalid IANA timezone";
|
|
7
|
+
|
|
8
|
+
export const resolveScheduleTimezone = ({
|
|
9
|
+
defaultTimezone,
|
|
10
|
+
scheduleTimezone,
|
|
11
|
+
}: {
|
|
12
|
+
defaultTimezone: string;
|
|
13
|
+
scheduleTimezone?: string;
|
|
14
|
+
}): string => scheduleTimezone ?? defaultTimezone;
|
|
15
|
+
|
|
16
|
+
/** @internal */
|
|
17
|
+
export const assertValidIanaTimezone = (timezone: string): void => {
|
|
18
|
+
const zoneCheck = DateTime.now().setZone(timezone);
|
|
19
|
+
if (!zoneCheck.isValid) {
|
|
20
|
+
throw new APIError({
|
|
21
|
+
detail: zoneCheck.invalidReason ?? "Timezone is not a valid IANA identifier",
|
|
22
|
+
status: 400,
|
|
23
|
+
title: INVALID_TIMEZONE_TITLE,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** @internal */
|
|
29
|
+
export const assertValidCronExpression = (cron: string, timezone: string): void => {
|
|
30
|
+
try {
|
|
31
|
+
new CronTime(cron, timezone);
|
|
32
|
+
} catch (error: unknown) {
|
|
33
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
34
|
+
throw new APIError({
|
|
35
|
+
cause: error instanceof Error ? error : undefined,
|
|
36
|
+
detail,
|
|
37
|
+
status: 400,
|
|
38
|
+
title: INVALID_CRON_TITLE,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const validateScheduleDefinition = ({
|
|
44
|
+
cron,
|
|
45
|
+
defaultTimezone,
|
|
46
|
+
scheduleTimezone,
|
|
47
|
+
}: {
|
|
48
|
+
cron: string;
|
|
49
|
+
defaultTimezone: string;
|
|
50
|
+
scheduleTimezone?: string;
|
|
51
|
+
}): string => {
|
|
52
|
+
const timezone = resolveScheduleTimezone({
|
|
53
|
+
defaultTimezone,
|
|
54
|
+
scheduleTimezone,
|
|
55
|
+
});
|
|
56
|
+
assertValidIanaTimezone(timezone);
|
|
57
|
+
assertValidCronExpression(cron, timezone);
|
|
58
|
+
return timezone;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const computeInitialNextRunAt = (cron: string, timezone: string): Date =>
|
|
62
|
+
computeNextRunAtAfter(cron, timezone, DateTime.utc().toJSDate());
|
|
63
|
+
|
|
64
|
+
export const computeNextRunAtAfter = (cron: string, timezone: string, after: Date): Date =>
|
|
65
|
+
new CronTime(cron, timezone).getNextDateFrom(after, timezone).toJSDate();
|
package/src/scheduler.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import {createScopedLogger, logger} from "@terreno/api";
|
|
2
|
+
import {DateTime} from "luxon";
|
|
3
|
+
|
|
4
|
+
import {JobDispatchError} from "./dispatchError";
|
|
5
|
+
import type {JobsService} from "./jobsService";
|
|
6
|
+
import {Job} from "./models/job";
|
|
7
|
+
import {JobSchedule} from "./models/jobSchedule";
|
|
8
|
+
import type {JobScheduleDocument} from "./modelTypes";
|
|
9
|
+
import {IN_FLIGHT_JOB_STATUSES} from "./modelTypes";
|
|
10
|
+
import {computeNextRunAtAfter} from "./scheduleCron";
|
|
11
|
+
|
|
12
|
+
/** @internal */
|
|
13
|
+
export const buildScheduleRunIdempotencyKey = (scheduleId: string, dueRunAt: Date): string =>
|
|
14
|
+
`schedule:${scheduleId}:${dueRunAt.toISOString()}`;
|
|
15
|
+
|
|
16
|
+
const tickOneSchedule = async ({
|
|
17
|
+
jobsService,
|
|
18
|
+
now,
|
|
19
|
+
schedule,
|
|
20
|
+
}: {
|
|
21
|
+
jobsService: JobsService;
|
|
22
|
+
now: Date;
|
|
23
|
+
schedule: JobScheduleDocument;
|
|
24
|
+
}): Promise<void> => {
|
|
25
|
+
const dueRunAt = schedule.nextRunAt;
|
|
26
|
+
const advancedNextRunAt = computeNextRunAtAfter(schedule.cron, schedule.timezone, now);
|
|
27
|
+
|
|
28
|
+
const claimed = await JobSchedule.findOneAndUpdate(
|
|
29
|
+
{
|
|
30
|
+
_id: schedule._id,
|
|
31
|
+
enabled: true,
|
|
32
|
+
nextRunAt: dueRunAt,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
$set: {
|
|
36
|
+
nextRunAt: advancedNextRunAt,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{returnDocument: "before"}
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
if (!claimed) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const inFlight = await Job.findOneOrNone({
|
|
47
|
+
scheduleId: claimed._id,
|
|
48
|
+
status: {$in: IN_FLIGHT_JOB_STATUSES},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (inFlight) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const idempotencyKey = buildScheduleRunIdempotencyKey(claimed._id.toString(), claimed.nextRunAt);
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
await jobsService.enqueue({
|
|
59
|
+
idempotencyKey,
|
|
60
|
+
name: claimed.handlerName,
|
|
61
|
+
payload: {},
|
|
62
|
+
runAt: claimed.nextRunAt,
|
|
63
|
+
scheduleId: claimed._id.toString(),
|
|
64
|
+
});
|
|
65
|
+
} catch (error: unknown) {
|
|
66
|
+
const shouldRollbackSchedule =
|
|
67
|
+
!(error instanceof JobDispatchError) || error.compensationSucceeded;
|
|
68
|
+
|
|
69
|
+
if (shouldRollbackSchedule) {
|
|
70
|
+
await JobSchedule.updateOne(
|
|
71
|
+
{_id: claimed._id, nextRunAt: advancedNextRunAt},
|
|
72
|
+
{$set: {nextRunAt: dueRunAt}}
|
|
73
|
+
);
|
|
74
|
+
} else {
|
|
75
|
+
logger.error(
|
|
76
|
+
`[JobSchedule] Dispatch compensation lost race for "${claimed.name}" (${claimed._id.toString()}); schedule nextRunAt left advanced`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const tickDueSchedules = async ({
|
|
85
|
+
jobsService,
|
|
86
|
+
now = DateTime.utc().toJSDate(),
|
|
87
|
+
}: {
|
|
88
|
+
defaultTimezone?: string;
|
|
89
|
+
jobsService: JobsService;
|
|
90
|
+
now?: Date;
|
|
91
|
+
}): Promise<void> => {
|
|
92
|
+
const dueSchedules = await JobSchedule.find({
|
|
93
|
+
enabled: true,
|
|
94
|
+
nextRunAt: {$lte: now},
|
|
95
|
+
}).sort({nextRunAt: 1});
|
|
96
|
+
|
|
97
|
+
for (const schedule of dueSchedules) {
|
|
98
|
+
const scheduleLog = createScopedLogger({
|
|
99
|
+
labels: {scheduleName: schedule.name},
|
|
100
|
+
prefix: "[JobSchedule]",
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
await tickOneSchedule({jobsService, now, schedule});
|
|
105
|
+
} catch (error: unknown) {
|
|
106
|
+
logger.error(
|
|
107
|
+
`[JobSchedule] Tick failed for "${schedule.name}": ${error instanceof Error ? error.message : String(error)}`
|
|
108
|
+
);
|
|
109
|
+
scheduleLog.error(`Tick failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
};
|