@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,349 @@
1
+ import {beforeEach, describe, it} from "bun:test";
2
+ import {createRequire} from "node:module";
3
+ import {TerrenoApp, type UserModel as UserModelType} from "@terreno/api";
4
+ import {setupDb, UserModel} from "@terreno/api/testing";
5
+ import {assert} from "chai";
6
+ import {DateTime} from "luxon";
7
+ import mongoose from "mongoose";
8
+
9
+ import {JobsApp} from "../jobsApp";
10
+ import {Job} from "../models/job";
11
+ import {JobSchedule} from "../models/jobSchedule";
12
+ import type {JobDocument} from "../modelTypes";
13
+ import {
14
+ type GcpCloudTasksClient,
15
+ GcpCloudTasksRunner,
16
+ type GcpCloudTasksRunnerConfig,
17
+ type GcpCreateTaskRequest,
18
+ } from "../runners/gcpCloudTasks";
19
+
20
+ const typedUserModel = UserModel as unknown as UserModelType;
21
+
22
+ interface RecordedCreateTaskCall {
23
+ request: GcpCreateTaskRequest;
24
+ }
25
+
26
+ const createFakeClient = (): {
27
+ calls: RecordedCreateTaskCall[];
28
+ client: GcpCloudTasksClient;
29
+ } => {
30
+ const calls: RecordedCreateTaskCall[] = [];
31
+
32
+ const client: GcpCloudTasksClient = {
33
+ createTask: async (request: GcpCreateTaskRequest) => {
34
+ calls.push({request});
35
+ return [{name: "projects/p/locations/l/queues/q/tasks/t1"}];
36
+ },
37
+ queuePath: (project: string, location: string, queue: string): string =>
38
+ `projects/${project}/locations/${location}/queues/${queue}`,
39
+ };
40
+
41
+ return {calls, client};
42
+ };
43
+
44
+ const baseConfig = (overrides?: Partial<GcpCloudTasksRunnerConfig>): GcpCloudTasksRunnerConfig => {
45
+ const {client} = createFakeClient();
46
+
47
+ return {
48
+ client,
49
+ location: "us-central1",
50
+ project: "my-project",
51
+ publicUrl: "https://api.example.com",
52
+ queue: "jobs",
53
+ serviceAccountEmail: "tasks@my-project.iam.gserviceaccount.com",
54
+ ...overrides,
55
+ };
56
+ };
57
+
58
+ const buildJob = (overrides?: Partial<JobDocument>): JobDocument =>
59
+ ({
60
+ _id: new mongoose.Types.ObjectId(),
61
+ runAt: DateTime.utc().toJSDate(),
62
+ ...overrides,
63
+ }) as JobDocument;
64
+
65
+ describe("GcpCloudTasksRunner", () => {
66
+ beforeEach(async (): Promise<void> => {
67
+ await setupDb();
68
+ await Job.deleteMany({});
69
+ await JobSchedule.deleteMany({});
70
+ });
71
+
72
+ it("requires the execute route and ticks schedules from start()", (): void => {
73
+ const runner = new GcpCloudTasksRunner(baseConfig());
74
+
75
+ assert.equal(runner.requiresExecuteRoute, true);
76
+ assert.isFunction(runner.start);
77
+ });
78
+
79
+ it("throws when required config is missing or blank", (): void => {
80
+ const cases: Array<{field: keyof GcpCloudTasksRunnerConfig; value: string}> = [
81
+ {field: "project", value: ""},
82
+ {field: "location", value: " "},
83
+ {field: "queue", value: ""},
84
+ {field: "publicUrl", value: ""},
85
+ {field: "serviceAccountEmail", value: ""},
86
+ ];
87
+
88
+ for (const {field, value} of cases) {
89
+ assert.throws(
90
+ () =>
91
+ new GcpCloudTasksRunner(
92
+ baseConfig({
93
+ [field]: value,
94
+ })
95
+ ),
96
+ /GcpCloudTasksRunner/i
97
+ );
98
+ }
99
+ });
100
+
101
+ it("throws when publicUrl uses a non-http protocol", (): void => {
102
+ assert.throws(
103
+ () =>
104
+ new GcpCloudTasksRunner(
105
+ baseConfig({
106
+ publicUrl: "ftp://api.example.com",
107
+ })
108
+ ),
109
+ /http or https/i
110
+ );
111
+ });
112
+
113
+ it("throws when basePath does not start with a slash", (): void => {
114
+ assert.throws(
115
+ () =>
116
+ new GcpCloudTasksRunner(
117
+ baseConfig({
118
+ basePath: "jobs",
119
+ })
120
+ ),
121
+ /basePath must start with "\/"/
122
+ );
123
+ });
124
+
125
+ it("normalizes a root basePath of slash-only to /", async (): Promise<void> => {
126
+ const fake = createFakeClient();
127
+ const runner = new GcpCloudTasksRunner(
128
+ baseConfig({
129
+ basePath: "/",
130
+ client: fake.client,
131
+ })
132
+ );
133
+ const job = buildJob();
134
+
135
+ await runner.enqueue(job);
136
+
137
+ assert.equal(fake.calls[0].request.task.httpRequest?.url, "https://api.example.com//execute");
138
+ });
139
+
140
+ it("loads the optional peer when installed or reports how to install it", (): void => {
141
+ const {client: _client, ...configWithoutClient} = baseConfig();
142
+ let isPeerAvailable = true;
143
+ try {
144
+ createRequire(import.meta.url)("@google-cloud/tasks");
145
+ } catch {
146
+ isPeerAvailable = false;
147
+ }
148
+
149
+ if (isPeerAvailable) {
150
+ const runner = new GcpCloudTasksRunner(configWithoutClient);
151
+ assert.equal(runner.requiresExecuteRoute, true);
152
+ return;
153
+ }
154
+
155
+ assert.throws(
156
+ () => new GcpCloudTasksRunner(configWithoutClient),
157
+ /optional peer dependency @google-cloud\/tasks/
158
+ );
159
+ });
160
+
161
+ it("throws when publicUrl is not an absolute http(s) URL", (): void => {
162
+ assert.throws(
163
+ () =>
164
+ new GcpCloudTasksRunner(
165
+ baseConfig({
166
+ publicUrl: "/relative/path",
167
+ })
168
+ ),
169
+ /publicUrl/i
170
+ );
171
+
172
+ assert.throws(
173
+ () =>
174
+ new GcpCloudTasksRunner(
175
+ baseConfig({
176
+ publicUrl: "not-a-url",
177
+ })
178
+ ),
179
+ /publicUrl/i
180
+ );
181
+ });
182
+
183
+ it("creates an immediate HTTP task with queue path, execute URL, JSON body, and OIDC audience defaulting to the execute URL", async (): Promise<void> => {
184
+ const fake = createFakeClient();
185
+ const runner = new GcpCloudTasksRunner(
186
+ baseConfig({
187
+ client: fake.client,
188
+ })
189
+ );
190
+ const job = buildJob();
191
+
192
+ await runner.enqueue(job);
193
+
194
+ assert.equal(fake.calls.length, 1);
195
+ const {request} = fake.calls[0];
196
+ const executeUrl = "https://api.example.com/jobs/execute";
197
+
198
+ assert.equal(request.parent, "projects/my-project/locations/us-central1/queues/jobs");
199
+ assert.equal(request.task.httpRequest?.httpMethod, "POST");
200
+ assert.equal(request.task.httpRequest?.url, executeUrl);
201
+ assert.deepEqual(request.task.httpRequest?.headers, {
202
+ "Content-Type": "application/json",
203
+ });
204
+
205
+ const decodedBody = JSON.parse(
206
+ Buffer.from(request.task.httpRequest?.body ?? "", "base64").toString("utf8")
207
+ );
208
+ assert.deepEqual(decodedBody, {jobId: job._id.toString()});
209
+ assert.deepEqual(request.task.httpRequest?.oidcToken, {
210
+ audience: executeUrl,
211
+ serviceAccountEmail: "tasks@my-project.iam.gserviceaccount.com",
212
+ });
213
+ assert.deepEqual(request.task.dispatchDeadline, {seconds: 1800});
214
+ assert.isUndefined(request.task.scheduleTime);
215
+ });
216
+
217
+ it("honors a custom dispatchDeadlineSeconds within the HTTP task range", async (): Promise<void> => {
218
+ const fake = createFakeClient();
219
+ const runner = new GcpCloudTasksRunner(
220
+ baseConfig({
221
+ client: fake.client,
222
+ dispatchDeadlineSeconds: 900,
223
+ })
224
+ );
225
+
226
+ await runner.enqueue(buildJob());
227
+
228
+ assert.deepEqual(fake.calls[0].request.task.dispatchDeadline, {seconds: 900});
229
+ });
230
+
231
+ it("throws when dispatchDeadlineSeconds is outside the HTTP range of 15 to 1800", (): void => {
232
+ assert.throws(
233
+ () =>
234
+ new GcpCloudTasksRunner(
235
+ baseConfig({
236
+ dispatchDeadlineSeconds: 14,
237
+ })
238
+ ),
239
+ /dispatchDeadlineSeconds/i
240
+ );
241
+
242
+ assert.throws(
243
+ () =>
244
+ new GcpCloudTasksRunner(
245
+ baseConfig({
246
+ dispatchDeadlineSeconds: 1801,
247
+ })
248
+ ),
249
+ /dispatchDeadlineSeconds/i
250
+ );
251
+ });
252
+
253
+ it("honors basePath and a custom OIDC audience", async (): Promise<void> => {
254
+ const fake = createFakeClient();
255
+ const runner = new GcpCloudTasksRunner(
256
+ baseConfig({
257
+ basePath: "/background",
258
+ client: fake.client,
259
+ oidcAudience: "https://api.example.com",
260
+ })
261
+ );
262
+ const job = buildJob();
263
+
264
+ await runner.enqueue(job);
265
+
266
+ const executeUrl = "https://api.example.com/background/execute";
267
+ const {request} = fake.calls[0];
268
+
269
+ assert.equal(request.task.httpRequest?.url, executeUrl);
270
+ assert.equal(request.task.httpRequest?.oidcToken?.audience, "https://api.example.com");
271
+ });
272
+
273
+ it("sets scheduleTime when runAt is in the future", async (): Promise<void> => {
274
+ const fake = createFakeClient();
275
+ const runner = new GcpCloudTasksRunner(
276
+ baseConfig({
277
+ client: fake.client,
278
+ })
279
+ );
280
+ const runAt = DateTime.utc().plus({minutes: 15});
281
+ const job = buildJob({runAt: runAt.toJSDate()});
282
+
283
+ await runner.enqueue(job);
284
+
285
+ const {request} = fake.calls[0];
286
+ assert.isDefined(request.task.scheduleTime);
287
+ assert.equal(request.task.scheduleTime?.seconds, Math.floor(runAt.toSeconds()));
288
+ assert.equal(request.task.scheduleTime?.nanos, (runAt.toMillis() % 1000) * 1_000_000);
289
+ });
290
+
291
+ it("strips trailing slashes from publicUrl before building the execute URL", async (): Promise<void> => {
292
+ const fake = createFakeClient();
293
+ const runner = new GcpCloudTasksRunner(
294
+ baseConfig({
295
+ client: fake.client,
296
+ publicUrl: "https://api.example.com/",
297
+ })
298
+ );
299
+ const job = buildJob();
300
+
301
+ await runner.enqueue(job);
302
+
303
+ assert.equal(
304
+ fake.calls[0].request.task.httpRequest?.url,
305
+ "https://api.example.com/jobs/execute"
306
+ );
307
+ });
308
+
309
+ it("startWorker ticks due schedules into Cloud Tasks without claiming Mongo jobs", async (): Promise<void> => {
310
+ const fake = createFakeClient();
311
+ const jobsApp = new JobsApp({
312
+ executeAuth: async () => true,
313
+ pollIntervalMs: 25,
314
+ runner: new GcpCloudTasksRunner(baseConfig({client: fake.client})),
315
+ });
316
+ jobsApp.define("cloud-tasks-cron", {
317
+ handler: async () => {},
318
+ schedule: {cron: "0 9 * * *", timezone: "UTC"},
319
+ });
320
+ new TerrenoApp({
321
+ skipListen: true,
322
+ userModel: typedUserModel,
323
+ })
324
+ .register(jobsApp)
325
+ .build();
326
+
327
+ await jobsApp.startWorker();
328
+ const schedule = await JobSchedule.findExactlyOne({name: "cloud-tasks-cron"});
329
+ await JobSchedule.updateOne(
330
+ {_id: schedule._id},
331
+ {$set: {nextRunAt: DateTime.utc().minus({minutes: 1}).toJSDate()}}
332
+ );
333
+
334
+ const deadline = DateTime.utc().plus({milliseconds: 5_000});
335
+ while (DateTime.utc() < deadline) {
336
+ if (fake.calls.length >= 1 && (await Job.countDocuments({name: "cloud-tasks-cron"})) >= 1) {
337
+ break;
338
+ }
339
+ await Bun.sleep(25);
340
+ }
341
+
342
+ await jobsApp.stopWorker();
343
+
344
+ assert.isAtLeast(fake.calls.length, 1);
345
+ const childJobs = await Job.find({name: "cloud-tasks-cron"});
346
+ assert.equal(childJobs.length, 1);
347
+ assert.equal(childJobs[0]?.status, "pending");
348
+ });
349
+ });
@@ -0,0 +1,62 @@
1
+ import {beforeEach, describe, it} from "bun:test";
2
+ import {setupDb} from "@terreno/api/testing";
3
+ import {assert} from "chai";
4
+ import {DateTime} from "luxon";
5
+
6
+ import {Job} from "../models/job";
7
+
8
+ const JOB_FIELDS = [
9
+ "attemptCount",
10
+ "attempts",
11
+ "backoffMs",
12
+ "idempotencyKey",
13
+ "lastError",
14
+ "lockedAt",
15
+ "lockedBy",
16
+ "maxAttempts",
17
+ "maxBackoffMs",
18
+ "name",
19
+ "payload",
20
+ "payloadRedacted",
21
+ "retriedById",
22
+ "retriedFromId",
23
+ "runAt",
24
+ "scheduleId",
25
+ "status",
26
+ ] as const;
27
+
28
+ describe("Job model", () => {
29
+ beforeEach(async (): Promise<void> => {
30
+ await setupDb();
31
+ await Job.deleteMany({});
32
+ });
33
+
34
+ it("persists a pending row with documented schema fields", async (): Promise<void> => {
35
+ const job = await Job.create({
36
+ attemptCount: 0,
37
+ maxAttempts: 5,
38
+ name: "demo",
39
+ payload: {userId: "abc"},
40
+ payloadRedacted: true,
41
+ runAt: DateTime.utc().toJSDate(),
42
+ status: "pending",
43
+ });
44
+
45
+ assert.equal(job.status, "pending");
46
+ assert.equal(await Job.countDocuments(), 1);
47
+
48
+ for (const field of JOB_FIELDS) {
49
+ assert.isString(
50
+ Job.schema.path(field).options.description,
51
+ `missing description on ${field}`
52
+ );
53
+ }
54
+
55
+ const attemptAtPath = Job.schema.path("attempts") as {
56
+ schema?: {path: (name: string) => {options: {description?: string}}};
57
+ };
58
+ assert.isString(attemptAtPath.schema?.path("at").options.description);
59
+ assert.isString(attemptAtPath.schema?.path("error").options.description);
60
+ assert.isString(attemptAtPath.schema?.path("errorClass").options.description);
61
+ });
62
+ });
@@ -0,0 +1,44 @@
1
+ import {beforeEach, describe, it} from "bun:test";
2
+ import {setupDb} from "@terreno/api/testing";
3
+ import {assert} from "chai";
4
+ import {DateTime} from "luxon";
5
+
6
+ import {JobSchedule} from "../models/jobSchedule";
7
+
8
+ const SCHEDULE_FIELDS = [
9
+ "cron",
10
+ "enabled",
11
+ "handlerName",
12
+ "name",
13
+ "nextRunAt",
14
+ "timezone",
15
+ ] as const;
16
+
17
+ describe("JobSchedule model", () => {
18
+ beforeEach(async (): Promise<void> => {
19
+ await setupDb();
20
+ await JobSchedule.deleteMany({});
21
+ });
22
+
23
+ it("persists a schedule row with documented schema fields", async (): Promise<void> => {
24
+ const nextRunAt = DateTime.utc().plus({hours: 1}).toJSDate();
25
+ const schedule = await JobSchedule.create({
26
+ cron: "0 9 * * *",
27
+ enabled: true,
28
+ handlerName: "daily-report",
29
+ name: "daily-report",
30
+ nextRunAt,
31
+ timezone: "UTC",
32
+ });
33
+
34
+ assert.equal(schedule.handlerName, "daily-report");
35
+ assert.equal(await JobSchedule.countDocuments(), 1);
36
+
37
+ for (const field of SCHEDULE_FIELDS) {
38
+ assert.isString(
39
+ JobSchedule.schema.path(field).options.description,
40
+ `missing description on ${field}`
41
+ );
42
+ }
43
+ });
44
+ });