@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,395 @@
1
+ import {APIError, NotFoundError} from "@terreno/api";
2
+ import {DateTime} from "luxon";
3
+ import mongoose from "mongoose";
4
+ import {abortActiveJobExecution} from "./activeJobExecution";
5
+ import {dispatchJobToRunner} from "./dispatchCompensation";
6
+ import {Job} from "./models/job";
7
+ import {JobSchedule} from "./models/jobSchedule";
8
+ import type {JobDocument, JobScheduleDocument} from "./modelTypes";
9
+ import {DEFAULT_BACKOFF_MS, DEFAULT_MAX_BACKOFF_MS} from "./retryBackoff";
10
+ import {
11
+ computeInitialNextRunAt,
12
+ resolveScheduleTimezone,
13
+ validateScheduleDefinition,
14
+ } from "./scheduleCron";
15
+ import {tickDueSchedules} from "./scheduler";
16
+ import type {EnqueueJobParams, JobDefinition, JobRunner} from "./types";
17
+
18
+ const DEFAULT_MAX_ATTEMPTS = 5;
19
+ const DEFAULT_TIMEZONE = "UTC";
20
+ const UNKNOWN_JOB_NAME_TITLE = "Unknown job name";
21
+
22
+ const isDuplicateKeyError = (error: unknown): boolean =>
23
+ Boolean(
24
+ error &&
25
+ typeof error === "object" &&
26
+ "code" in error &&
27
+ (error as {code: unknown}).code === 11_000
28
+ );
29
+
30
+ export class JobsService {
31
+ private readonly defaultTimezone: string;
32
+ private readonly definitions = new Map<string, JobDefinition>();
33
+ private readonly lockTtlMs: number;
34
+ private readonly runner: JobRunner | undefined;
35
+ private schedulesDirty = false;
36
+
37
+ constructor(options?: {defaultTimezone?: string; lockTtlMs?: number; runner?: JobRunner}) {
38
+ this.defaultTimezone = options?.defaultTimezone ?? DEFAULT_TIMEZONE;
39
+ this.lockTtlMs = options?.lockTtlMs ?? 15 * 60 * 1000;
40
+ this.runner = options?.runner;
41
+ }
42
+
43
+ define(name: string, definition: JobDefinition): void {
44
+ if (definition.schedule) {
45
+ validateScheduleDefinition({
46
+ cron: definition.schedule.cron,
47
+ defaultTimezone: this.defaultTimezone,
48
+ scheduleTimezone: definition.schedule.timezone,
49
+ });
50
+ }
51
+
52
+ this.definitions.set(name, definition);
53
+ }
54
+
55
+ getDefinition(name: string): JobDefinition | undefined {
56
+ return this.definitions.get(name);
57
+ }
58
+
59
+ getDefaultTimezone(): string {
60
+ return this.defaultTimezone;
61
+ }
62
+
63
+ getLockTtlMs(): number {
64
+ return this.lockTtlMs;
65
+ }
66
+
67
+ isSchedulesDirty(): boolean {
68
+ return this.schedulesDirty;
69
+ }
70
+
71
+ markSchedulesDirty(): void {
72
+ this.schedulesDirty = true;
73
+ }
74
+
75
+ async reconcileSchedulesIfDirty(): Promise<void> {
76
+ if (!this.schedulesDirty) {
77
+ return;
78
+ }
79
+
80
+ await this.reconcileSchedules();
81
+ }
82
+
83
+ async reconcileSchedules(): Promise<void> {
84
+ for (const [name, definition] of this.definitions) {
85
+ if (!definition.schedule) {
86
+ continue;
87
+ }
88
+
89
+ const timezone = resolveScheduleTimezone({
90
+ defaultTimezone: this.defaultTimezone,
91
+ scheduleTimezone: definition.schedule.timezone,
92
+ });
93
+ const cron = definition.schedule.cron;
94
+ const existing = await JobSchedule.findOneOrNone({name});
95
+
96
+ if (!existing) {
97
+ await JobSchedule.findOneAndUpdate(
98
+ {name},
99
+ {
100
+ $setOnInsert: {
101
+ cron,
102
+ enabled: true,
103
+ handlerName: name,
104
+ nextRunAt: computeInitialNextRunAt(cron, timezone),
105
+ timezone,
106
+ },
107
+ },
108
+ {upsert: true}
109
+ );
110
+ continue;
111
+ }
112
+
113
+ const cronTimezoneChanged = existing.cron !== cron || existing.timezone !== timezone;
114
+
115
+ const update: {
116
+ cron: string;
117
+ handlerName: string;
118
+ nextRunAt?: Date;
119
+ timezone: string;
120
+ } = {
121
+ cron,
122
+ handlerName: name,
123
+ timezone,
124
+ };
125
+
126
+ if (cronTimezoneChanged) {
127
+ update.nextRunAt = computeInitialNextRunAt(cron, timezone);
128
+ }
129
+
130
+ await JobSchedule.updateOne({_id: existing._id}, {$set: update});
131
+ }
132
+
133
+ this.schedulesDirty = false;
134
+ }
135
+
136
+ async tickSchedules(now?: Date): Promise<void> {
137
+ await tickDueSchedules({
138
+ jobsService: this,
139
+ now,
140
+ });
141
+ }
142
+
143
+ async enqueue(params: EnqueueJobParams): Promise<JobDocument> {
144
+ const definition = this.definitions.get(params.name);
145
+ if (!definition) {
146
+ throw new APIError({status: 400, title: UNKNOWN_JOB_NAME_TITLE});
147
+ }
148
+
149
+ const runAt = params.runAt ?? DateTime.utc().toJSDate();
150
+ const maxAttempts = definition.retry?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
151
+ const backoffMs = definition.retry?.backoffMs ?? DEFAULT_BACKOFF_MS;
152
+ const maxBackoffMs = definition.retry?.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
153
+ const idempotencyKey = params.idempotencyKey?.trim();
154
+
155
+ if (idempotencyKey) {
156
+ const existing = await Job.findOneOrNone({
157
+ idempotencyKey,
158
+ name: params.name,
159
+ });
160
+ if (existing) {
161
+ return existing;
162
+ }
163
+ }
164
+
165
+ const createPayload = {
166
+ attemptCount: 0,
167
+ backoffMs,
168
+ maxAttempts,
169
+ maxBackoffMs,
170
+ name: params.name,
171
+ payload: params.payload,
172
+ payloadRedacted: false,
173
+ runAt,
174
+ status: "pending" as const,
175
+ ...(idempotencyKey ? {idempotencyKey} : {}),
176
+ ...(params.scheduleId ? {scheduleId: new mongoose.Types.ObjectId(params.scheduleId)} : {}),
177
+ };
178
+
179
+ let job: JobDocument;
180
+ try {
181
+ job = await Job.create(createPayload);
182
+ } catch (error: unknown) {
183
+ if (!idempotencyKey || !isDuplicateKeyError(error)) {
184
+ throw error;
185
+ }
186
+
187
+ return Job.findExactlyOne({
188
+ idempotencyKey,
189
+ name: params.name,
190
+ });
191
+ }
192
+
193
+ if (this.runner) {
194
+ await dispatchJobToRunner(this.runner, job);
195
+ }
196
+
197
+ return job;
198
+ }
199
+
200
+ async adminRetryJob(jobId: string): Promise<{original: JobDocument; retry: JobDocument}> {
201
+ if (!mongoose.isValidObjectId(jobId)) {
202
+ throw new NotFoundError("Job not found");
203
+ }
204
+
205
+ const original = await Job.findOneOrNone({_id: jobId});
206
+ if (!original) {
207
+ throw new NotFoundError("Job not found");
208
+ }
209
+
210
+ if (original.retriedById) {
211
+ throw new APIError({status: 409, title: "Job already retried"});
212
+ }
213
+
214
+ if (original.status !== "dead" && original.status !== "failed") {
215
+ throw new APIError({
216
+ detail: `Status is ${original.status}`,
217
+ status: 400,
218
+ title: "Job cannot be retried",
219
+ });
220
+ }
221
+
222
+ const definition = this.definitions.get(original.name);
223
+ if (!definition) {
224
+ throw new APIError({status: 400, title: UNKNOWN_JOB_NAME_TITLE});
225
+ }
226
+
227
+ const now = DateTime.utc().toJSDate();
228
+ const retry = await Job.create({
229
+ attemptCount: 0,
230
+ attempts: [],
231
+ backoffMs: original.backoffMs,
232
+ maxAttempts: original.maxAttempts,
233
+ maxBackoffMs: original.maxBackoffMs,
234
+ name: original.name,
235
+ payload: original.payload,
236
+ payloadRedacted: original.payloadRedacted,
237
+ retriedFromId: original._id,
238
+ runAt: now,
239
+ scheduleId: original.scheduleId,
240
+ status: "pending",
241
+ });
242
+
243
+ const linked = await Job.findOneAndUpdate(
244
+ {
245
+ _id: original._id,
246
+ retriedById: {$exists: false},
247
+ status: {$in: ["dead", "failed"]},
248
+ },
249
+ {$set: {retriedById: retry._id}},
250
+ {returnDocument: "after"}
251
+ );
252
+
253
+ if (!linked) {
254
+ await Job.deleteOne({_id: retry._id});
255
+ const current = await Job.findOneOrNone({_id: original._id});
256
+ if (current?.retriedById) {
257
+ throw new APIError({status: 409, title: "Job already retried"});
258
+ }
259
+
260
+ throw new APIError({status: 409, title: "Job state changed"});
261
+ }
262
+
263
+ if (this.runner) {
264
+ await dispatchJobToRunner(this.runner, retry);
265
+ }
266
+
267
+ return {original: linked, retry};
268
+ }
269
+
270
+ async adminRequeueJob(jobId: string): Promise<JobDocument> {
271
+ if (!mongoose.isValidObjectId(jobId)) {
272
+ throw new NotFoundError("Job not found");
273
+ }
274
+
275
+ const now = DateTime.utc().toJSDate();
276
+ const requeued = await Job.findOneAndUpdate(
277
+ {
278
+ _id: jobId,
279
+ retriedById: {$exists: false},
280
+ status: {$in: ["cancelled", "dead", "failed"]},
281
+ },
282
+ {
283
+ $set: {runAt: now, status: "pending"},
284
+ $unset: {lockedAt: "", lockedBy: ""},
285
+ },
286
+ {returnDocument: "after"}
287
+ );
288
+
289
+ if (!requeued) {
290
+ const existing = await Job.findOneOrNone({_id: jobId});
291
+ if (!existing) {
292
+ throw new NotFoundError("Job not found");
293
+ }
294
+
295
+ if (existing.retriedById) {
296
+ throw new APIError({status: 409, title: "Job already retried"});
297
+ }
298
+
299
+ throw new APIError({
300
+ detail: `Status is ${existing.status}`,
301
+ status: 400,
302
+ title: "Job cannot be requeued",
303
+ });
304
+ }
305
+
306
+ if (this.runner) {
307
+ await dispatchJobToRunner(this.runner, requeued);
308
+ }
309
+
310
+ return requeued;
311
+ }
312
+
313
+ async adminCancelJob(jobId: string): Promise<JobDocument> {
314
+ if (!mongoose.isValidObjectId(jobId)) {
315
+ throw new NotFoundError("Job not found");
316
+ }
317
+
318
+ const cancelled = await Job.findOneAndUpdate(
319
+ {
320
+ _id: jobId,
321
+ status: {$in: ["pending", "running", "scheduled"]},
322
+ },
323
+ {
324
+ $set: {status: "cancelled"},
325
+ $unset: {lockedAt: "", lockedBy: ""},
326
+ },
327
+ {returnDocument: "after"}
328
+ );
329
+
330
+ if (!cancelled) {
331
+ const existing = await Job.findOneOrNone({_id: jobId});
332
+ if (!existing) {
333
+ throw new NotFoundError("Job not found");
334
+ }
335
+
336
+ throw new APIError({
337
+ detail: `Status is ${existing.status}`,
338
+ status: 400,
339
+ title: "Job cannot be cancelled",
340
+ });
341
+ }
342
+
343
+ abortActiveJobExecution(jobId);
344
+ return cancelled;
345
+ }
346
+
347
+ async pauseSchedule(name: string): Promise<JobScheduleDocument> {
348
+ const updated = await JobSchedule.findOneAndUpdate(
349
+ {name},
350
+ {$set: {enabled: false}},
351
+ {returnDocument: "after"}
352
+ );
353
+
354
+ if (!updated) {
355
+ throw new NotFoundError("Schedule not found");
356
+ }
357
+
358
+ return updated;
359
+ }
360
+
361
+ async resumeSchedule(name: string): Promise<JobScheduleDocument> {
362
+ const updated = await JobSchedule.findOneAndUpdate(
363
+ {name},
364
+ {$set: {enabled: true}},
365
+ {returnDocument: "after"}
366
+ );
367
+
368
+ if (!updated) {
369
+ throw new NotFoundError("Schedule not found");
370
+ }
371
+
372
+ return updated;
373
+ }
374
+ }
375
+
376
+ let registeredJobsService: JobsService | undefined;
377
+
378
+ export const getJobsService = (): JobsService => {
379
+ if (!registeredJobsService) {
380
+ throw new APIError({status: 500, title: "JobsApp is not registered"});
381
+ }
382
+ return registeredJobsService;
383
+ };
384
+
385
+ /** Returns the registered jobs service when `JobsApp` is mounted; otherwise `undefined`. */
386
+ export const tryGetJobsService = (): JobsService | undefined => registeredJobsService;
387
+
388
+ export const registerJobsService = (service: JobsService): void => {
389
+ registeredJobsService = service;
390
+ };
391
+
392
+ /** Clears the process-wide jobs service. Tests use this so AdminApp falls back to in-process runs. */
393
+ export const unregisterJobsService = (): void => {
394
+ registeredJobsService = undefined;
395
+ };
@@ -0,0 +1,78 @@
1
+ import type {FindExactlyOnePlugin, FindOneOrNonePlugin} from "@terreno/api";
2
+ import type mongoose from "mongoose";
3
+
4
+ export type JobStatus =
5
+ | "cancelled"
6
+ | "completed"
7
+ | "dead"
8
+ | "failed"
9
+ | "pending"
10
+ | "running"
11
+ | "scheduled";
12
+
13
+ export const IN_FLIGHT_JOB_STATUSES: readonly JobStatus[] = ["pending", "running", "scheduled"];
14
+
15
+ export interface JobAttempt {
16
+ at: Date;
17
+ error?: string;
18
+ errorClass?: string;
19
+ }
20
+
21
+ export type JobMethods = Record<never, never>;
22
+
23
+ export interface JobDocument extends mongoose.Document<mongoose.Types.ObjectId>, JobMethods {
24
+ attemptCount: number;
25
+ attempts: JobAttempt[];
26
+ backoffMs: number;
27
+ created: Date;
28
+ idempotencyKey?: string;
29
+ lastError?: string;
30
+ lockedAt?: Date;
31
+ lockedBy?: string;
32
+ maxAttempts: number;
33
+ maxBackoffMs: number;
34
+ name: string;
35
+ payload?: unknown;
36
+ payloadRedacted: boolean;
37
+ retriedById?: mongoose.Types.ObjectId;
38
+ retriedFromId?: mongoose.Types.ObjectId;
39
+ runAt: Date;
40
+ scheduleId?: mongoose.Types.ObjectId;
41
+ status: JobStatus;
42
+ updated: Date;
43
+ }
44
+
45
+ export interface JobStatics
46
+ extends FindExactlyOnePlugin<JobDocument>,
47
+ FindOneOrNonePlugin<JobDocument> {}
48
+
49
+ export interface JobModel extends mongoose.Model<JobDocument>, JobStatics {}
50
+
51
+ export type JobSchema = mongoose.Schema<JobDocument, JobModel, JobMethods>;
52
+
53
+ export type JobScheduleMethods = Record<never, never>;
54
+
55
+ export interface JobScheduleDocument
56
+ extends mongoose.Document<mongoose.Types.ObjectId>,
57
+ JobScheduleMethods {
58
+ created: Date;
59
+ cron: string;
60
+ enabled: boolean;
61
+ handlerName: string;
62
+ name: string;
63
+ nextRunAt: Date;
64
+ timezone: string;
65
+ updated: Date;
66
+ }
67
+
68
+ export interface JobScheduleStatics
69
+ extends FindExactlyOnePlugin<JobScheduleDocument>,
70
+ FindOneOrNonePlugin<JobScheduleDocument> {}
71
+
72
+ export interface JobScheduleModel extends mongoose.Model<JobScheduleDocument>, JobScheduleStatics {}
73
+
74
+ export type JobScheduleSchema = mongoose.Schema<
75
+ JobScheduleDocument,
76
+ JobScheduleModel,
77
+ JobScheduleMethods
78
+ >;
@@ -0,0 +1,129 @@
1
+ import {createdUpdatedPlugin, findExactlyOne, findOneOrNone} from "@terreno/api";
2
+ import mongoose from "mongoose";
3
+
4
+ import type {JobDocument, JobModel, JobSchema} from "../modelTypes";
5
+
6
+ const jobSchema: JobSchema = new mongoose.Schema<JobDocument, JobModel>(
7
+ {
8
+ attemptCount: {
9
+ default: 0,
10
+ description: "Number of execution attempts recorded for this job",
11
+ type: Number,
12
+ },
13
+ attempts: {
14
+ default: [],
15
+ description: "History of execution attempts including errors",
16
+ type: [
17
+ {
18
+ _id: false,
19
+ at: {
20
+ description: "When this execution attempt ran",
21
+ required: true,
22
+ type: Date,
23
+ },
24
+ error: {
25
+ description: "Error message captured for this attempt",
26
+ type: String,
27
+ },
28
+ errorClass: {
29
+ description: "Classified error category for this attempt",
30
+ type: String,
31
+ },
32
+ },
33
+ ],
34
+ },
35
+ backoffMs: {
36
+ default: 1_000,
37
+ description: "Base delay in milliseconds for exponential retry backoff",
38
+ type: Number,
39
+ },
40
+ idempotencyKey: {
41
+ description: "Optional deduplication key scoped to the job name",
42
+ type: String,
43
+ },
44
+ lastError: {
45
+ description: "Most recent error message from a failed attempt",
46
+ type: String,
47
+ },
48
+ lockedAt: {
49
+ description: "When this job row was claimed by a worker",
50
+ type: Date,
51
+ },
52
+ lockedBy: {
53
+ description: "Worker identity that claimed this job row",
54
+ type: String,
55
+ },
56
+ maxAttempts: {
57
+ default: 5,
58
+ description: "Maximum execution attempts before the job is dead-lettered",
59
+ type: Number,
60
+ },
61
+ maxBackoffMs: {
62
+ default: 15 * 60 * 1_000,
63
+ description: "Maximum retry delay in milliseconds before backoff is capped",
64
+ type: Number,
65
+ },
66
+ name: {
67
+ description: "Registered handler name for this job",
68
+ index: true,
69
+ required: true,
70
+ type: String,
71
+ },
72
+ payload: {
73
+ description: "JSON-serializable input passed to the handler",
74
+ type: mongoose.Schema.Types.Mixed,
75
+ },
76
+ payloadRedacted: {
77
+ default: false,
78
+ description: "Whether the stored payload has been redacted for admin display",
79
+ type: Boolean,
80
+ },
81
+ retriedById: {
82
+ description: "Follow-up job created by an admin retry action",
83
+ ref: "Job",
84
+ type: mongoose.Schema.Types.ObjectId,
85
+ },
86
+ retriedFromId: {
87
+ description: "Original job row that this retry was created from",
88
+ ref: "Job",
89
+ type: mongoose.Schema.Types.ObjectId,
90
+ },
91
+ runAt: {
92
+ description: "Earliest time a worker may claim this job",
93
+ index: true,
94
+ required: true,
95
+ type: Date,
96
+ },
97
+ scheduleId: {
98
+ description: "Recurring schedule that enqueued this job, when applicable",
99
+ ref: "JobSchedule",
100
+ type: mongoose.Schema.Types.ObjectId,
101
+ },
102
+ status: {
103
+ description: "Current lifecycle status of the job row",
104
+ enum: ["cancelled", "completed", "dead", "failed", "pending", "running", "scheduled"],
105
+ index: true,
106
+ required: true,
107
+ type: String,
108
+ },
109
+ },
110
+ {strict: "throw", toJSON: {virtuals: true}, toObject: {virtuals: true}}
111
+ );
112
+
113
+ jobSchema.plugin(createdUpdatedPlugin);
114
+ jobSchema.plugin(findOneOrNone);
115
+ jobSchema.plugin(findExactlyOne);
116
+
117
+ jobSchema.index(
118
+ // biome-ignore assist/source/useSortedKeys: IP specifies (name, idempotencyKey) index field order
119
+ {name: 1, idempotencyKey: 1},
120
+ {
121
+ partialFilterExpression: {idempotencyKey: {$exists: true, $type: "string"}},
122
+ unique: true,
123
+ }
124
+ );
125
+ // biome-ignore assist/source/useSortedKeys: IP specifies (status, runAt) claim index field order
126
+ jobSchema.index({status: 1, runAt: 1});
127
+ jobSchema.index({scheduleId: 1, status: 1});
128
+
129
+ export const Job = mongoose.model<JobDocument, JobModel>("Job", jobSchema);
@@ -0,0 +1,57 @@
1
+ import {createdUpdatedPlugin, findExactlyOne, findOneOrNone} from "@terreno/api";
2
+ import mongoose from "mongoose";
3
+
4
+ import type {JobScheduleDocument, JobScheduleModel, JobScheduleSchema} from "../modelTypes";
5
+
6
+ const jobScheduleSchema: JobScheduleSchema = new mongoose.Schema<
7
+ JobScheduleDocument,
8
+ JobScheduleModel
9
+ >(
10
+ {
11
+ cron: {
12
+ description: "Cron expression that controls when this schedule fires",
13
+ required: true,
14
+ type: String,
15
+ },
16
+ enabled: {
17
+ default: true,
18
+ description: "Whether the worker should enqueue jobs for this schedule",
19
+ type: Boolean,
20
+ },
21
+ handlerName: {
22
+ description: "Registered job handler name invoked for each schedule tick",
23
+ required: true,
24
+ type: String,
25
+ },
26
+ name: {
27
+ description: "Unique schedule name matching the registered job definition",
28
+ required: true,
29
+ type: String,
30
+ unique: true,
31
+ },
32
+ nextRunAt: {
33
+ description: "Next UTC instant when the scheduler should evaluate this schedule",
34
+ index: true,
35
+ required: true,
36
+ type: Date,
37
+ },
38
+ timezone: {
39
+ default: "UTC",
40
+ description: "IANA timezone used to interpret the cron expression",
41
+ required: true,
42
+ type: String,
43
+ },
44
+ },
45
+ {strict: "throw", toJSON: {virtuals: true}, toObject: {virtuals: true}}
46
+ );
47
+
48
+ jobScheduleSchema.plugin(createdUpdatedPlugin);
49
+ jobScheduleSchema.plugin(findOneOrNone);
50
+ jobScheduleSchema.plugin(findExactlyOne);
51
+
52
+ jobScheduleSchema.index({enabled: 1, nextRunAt: 1});
53
+
54
+ export const JobSchedule = mongoose.model<JobScheduleDocument, JobScheduleModel>(
55
+ "JobSchedule",
56
+ jobScheduleSchema
57
+ );
@@ -0,0 +1,34 @@
1
+ import type {DateTime} from "luxon";
2
+
3
+ export const DEFAULT_BACKOFF_MS = 1_000;
4
+ export const DEFAULT_MAX_BACKOFF_MS = 15 * 60 * 1_000;
5
+
6
+ export interface RetryBackoffOptions {
7
+ backoffMs: number;
8
+ maxBackoffMs: number;
9
+ }
10
+
11
+ export interface ComputeRetryDelayMsParams extends RetryBackoffOptions {
12
+ attemptCount: number;
13
+ random?: () => number;
14
+ }
15
+
16
+ export interface ComputeRetryRunAtParams extends ComputeRetryDelayMsParams {
17
+ from: DateTime;
18
+ }
19
+
20
+ const equalJitterFactor = (random: () => number): number => 0.5 + random() * 0.5;
21
+
22
+ /** @internal */
23
+ export const computeRetryDelayMs = (params: ComputeRetryDelayMsParams): number => {
24
+ const random = params.random ?? Math.random;
25
+ const exponent = Math.max(0, params.attemptCount - 1);
26
+ const baseDelayMs = params.backoffMs * 2 ** exponent;
27
+ const cappedDelayMs = Math.min(params.maxBackoffMs, baseDelayMs);
28
+ return Math.round(cappedDelayMs * equalJitterFactor(random));
29
+ };
30
+
31
+ export const computeRetryRunAt = (params: ComputeRetryRunAtParams): DateTime =>
32
+ params.from.plus({
33
+ milliseconds: computeRetryDelayMs(params),
34
+ });