@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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +56 -0
  3. package/biome.jsonc +20 -0
  4. package/bunfig.toml +5 -0
  5. package/dist/activeJobExecution.d.ts +3 -0
  6. package/dist/activeJobExecution.js +29 -0
  7. package/dist/claimLock.d.ts +16 -0
  8. package/dist/claimLock.js +34 -0
  9. package/dist/dispatchCompensation.d.ts +3 -0
  10. package/dist/dispatchCompensation.js +89 -0
  11. package/dist/dispatchError.d.ts +10 -0
  12. package/dist/dispatchError.js +33 -0
  13. package/dist/index.d.ts +9 -0
  14. package/dist/index.js +15 -0
  15. package/dist/jobExecutor.d.ts +34 -0
  16. package/dist/jobExecutor.js +328 -0
  17. package/dist/jobsApp.d.ts +61 -0
  18. package/dist/jobsApp.js +308 -0
  19. package/dist/jobsService.d.ts +38 -0
  20. package/dist/jobsService.js +505 -0
  21. package/dist/modelTypes.d.ts +52 -0
  22. package/dist/modelTypes.js +4 -0
  23. package/dist/models/job.d.ts +2 -0
  24. package/dist/models/job.js +124 -0
  25. package/dist/models/jobSchedule.d.ts +2 -0
  26. package/dist/models/jobSchedule.js +48 -0
  27. package/dist/retryBackoff.d.ts +17 -0
  28. package/dist/retryBackoff.js +22 -0
  29. package/dist/routes/jobsAdmin.d.ts +15 -0
  30. package/dist/routes/jobsAdmin.js +596 -0
  31. package/dist/routes/jobsExecute.d.ts +12 -0
  32. package/dist/routes/jobsExecute.js +119 -0
  33. package/dist/runners/gcpCloudTasks.d.ts +78 -0
  34. package/dist/runners/gcpCloudTasks.js +256 -0
  35. package/dist/runners/mongoRunner.d.ts +14 -0
  36. package/dist/runners/mongoRunner.js +204 -0
  37. package/dist/runners/vercelQueues.d.ts +111 -0
  38. package/dist/runners/vercelQueues.js +326 -0
  39. package/dist/scheduleCron.d.ts +15 -0
  40. package/dist/scheduleCron.js +61 -0
  41. package/dist/scheduler.d.ts +8 -0
  42. package/dist/scheduler.js +178 -0
  43. package/dist/types.d.ts +64 -0
  44. package/dist/types.js +2 -0
  45. package/package.json +82 -0
  46. package/src/activeJobExecution.ts +28 -0
  47. package/src/claimLock.ts +37 -0
  48. package/src/dispatchCompensation.ts +39 -0
  49. package/src/dispatchError.ts +14 -0
  50. package/src/index.ts +24 -0
  51. package/src/jobExecutor.ts +333 -0
  52. package/src/jobsApp.ts +255 -0
  53. package/src/jobsService.ts +395 -0
  54. package/src/modelTypes.ts +78 -0
  55. package/src/models/job.ts +129 -0
  56. package/src/models/jobSchedule.ts +57 -0
  57. package/src/retryBackoff.ts +34 -0
  58. package/src/routes/jobsAdmin.ts +562 -0
  59. package/src/routes/jobsExecute.ts +95 -0
  60. package/src/runners/gcpCloudTasks.ts +290 -0
  61. package/src/runners/mongoRunner.ts +135 -0
  62. package/src/runners/vercelQueues.ts +394 -0
  63. package/src/scheduleCron.ts +65 -0
  64. package/src/scheduler.ts +112 -0
  65. package/src/tests/bunSetup.ts +9 -0
  66. package/src/tests/gcpCloudTasksRunner.test.ts +349 -0
  67. package/src/tests/jobModel.test.ts +62 -0
  68. package/src/tests/jobScheduleModel.test.ts +44 -0
  69. package/src/tests/jobsAdmin.test.ts +728 -0
  70. package/src/tests/jobsApp.test.ts +113 -0
  71. package/src/tests/jobsCustomRunner.test.ts +367 -0
  72. package/src/tests/jobsDelayIdempotency.test.ts +255 -0
  73. package/src/tests/jobsExecute.test.ts +640 -0
  74. package/src/tests/jobsRetry.test.ts +420 -0
  75. package/src/tests/jobsSchedule.test.ts +505 -0
  76. package/src/tests/jobsTracer.test.ts +165 -0
  77. package/src/tests/jobsWorkerLifecycle.test.ts +509 -0
  78. package/src/tests/packageExports.test.ts +24 -0
  79. package/src/tests/retryBackoff.test.ts +76 -0
  80. package/src/tests/scheduleCron.test.ts +87 -0
  81. package/src/tests/vercelQueuesRunner.test.ts +524 -0
  82. package/src/types.ts +74 -0
  83. 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();
@@ -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
+ };
@@ -0,0 +1,9 @@
1
+ import {registerSimpleMongoPreload} from "@terreno/test";
2
+
3
+ process.env.TERRENO_TEST_USE_MEMORY_MONGO = "true";
4
+
5
+ registerSimpleMongoPreload({
6
+ testEnv: {
7
+ tokenIssuer: "terreno-jobs.test",
8
+ },
9
+ });