@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,640 @@
1
+ import {beforeEach, describe, it} from "bun:test";
2
+ import {TerrenoApp, type UserModel as UserModelType} from "@terreno/api";
3
+ import {setupDb, UserModel} from "@terreno/api/testing";
4
+ import {assert} from "chai";
5
+ import type {Application, Request} from "express";
6
+ import {DateTime} from "luxon";
7
+ import mongoose from "mongoose";
8
+ import supertest from "supertest";
9
+
10
+ import {JobsApp} from "../jobsApp";
11
+ import {getJobsService} from "../jobsService";
12
+ import {Job} from "../models/job";
13
+ import {JobSchedule} from "../models/jobSchedule";
14
+ import type {JobDocument} from "../modelTypes";
15
+ import type {ExecuteAuthVerifier} from "../routes/jobsExecute";
16
+ import {getWorkerId} from "../runners/mongoRunner";
17
+ import type {JobRunner, JobRunnerStartOptions} from "../types";
18
+
19
+ const typedUserModel = UserModel as unknown as UserModelType;
20
+
21
+ const validExecuteAuth = (req: Request): boolean =>
22
+ req.headers.authorization === "Bearer execute-token";
23
+
24
+ class ExecuteRouteRunner implements JobRunner {
25
+ readonly id = "execute-route-runner";
26
+ readonly requiresExecuteRoute = true;
27
+
28
+ async enqueue(_job: JobDocument): Promise<void> {}
29
+
30
+ async start(options: JobRunnerStartOptions): Promise<void> {
31
+ await new Promise<void>((resolve) => {
32
+ if (options.signal.aborted) {
33
+ resolve();
34
+ return;
35
+ }
36
+
37
+ options.signal.addEventListener("abort", () => resolve(), {once: true});
38
+ });
39
+ }
40
+
41
+ async stop(): Promise<void> {}
42
+ }
43
+
44
+ const buildApp = (
45
+ jobsApp: JobsApp,
46
+ options?: {logRequests?: boolean; rateLimit?: {limits: {apiMax: number; authMax: number}}}
47
+ ): Application =>
48
+ new TerrenoApp({
49
+ logRequests: options?.logRequests ?? false,
50
+ rateLimit: options?.rateLimit,
51
+ skipListen: true,
52
+ userModel: typedUserModel,
53
+ })
54
+ .register(jobsApp)
55
+ .build();
56
+
57
+ const executePath = (basePath: string): string => `${basePath}/execute`;
58
+
59
+ const authHeaders = {Authorization: "Bearer execute-token"};
60
+
61
+ const waitUntil = async (
62
+ predicate: () => Promise<boolean>,
63
+ options?: {intervalMs?: number; timeoutMs?: number}
64
+ ): Promise<void> => {
65
+ const intervalMs = options?.intervalMs ?? 10;
66
+ const timeoutMs = options?.timeoutMs ?? 2_000;
67
+ const deadline = Date.now() + timeoutMs;
68
+
69
+ while (Date.now() < deadline) {
70
+ if (await predicate()) {
71
+ return;
72
+ }
73
+ await Bun.sleep(intervalMs);
74
+ }
75
+
76
+ throw new Error("waitUntil timed out");
77
+ };
78
+
79
+ describe("POST /jobs/execute", () => {
80
+ beforeEach(async (): Promise<void> => {
81
+ await setupDb();
82
+ await Job.deleteMany({});
83
+ await JobSchedule.deleteMany({});
84
+ });
85
+
86
+ it("does not mount the execute route by default for MongoJobRunner", async (): Promise<void> => {
87
+ const jobsApp = new JobsApp();
88
+ jobsApp.define("default-mongo", {
89
+ handler: async () => {},
90
+ });
91
+
92
+ const app = buildApp(jobsApp);
93
+ const response = await supertest(app)
94
+ .post(executePath(jobsApp.getBasePath()))
95
+ .set("Authorization", "Bearer execute-token")
96
+ .send({jobId: new mongoose.Types.ObjectId().toString()});
97
+
98
+ assert.equal(response.status, 404);
99
+ });
100
+
101
+ it("mounts the execute route when mountExecuteRoute is true", async (): Promise<void> => {
102
+ const jobsApp = new JobsApp({
103
+ executeAuth: validExecuteAuth,
104
+ mountExecuteRoute: true,
105
+ });
106
+ jobsApp.define("mounted", {
107
+ handler: async () => {},
108
+ });
109
+
110
+ const app = buildApp(jobsApp);
111
+ const response = await supertest(app)
112
+ .post(executePath(jobsApp.getBasePath()))
113
+ .set("Authorization", "Bearer execute-token")
114
+ .send({jobId: new mongoose.Types.ObjectId().toString()});
115
+
116
+ assert.equal(response.status, 404);
117
+ assert.notEqual(response.status, 401);
118
+ });
119
+
120
+ it("mounts the execute route when the runner requires it", async (): Promise<void> => {
121
+ const jobsApp = new JobsApp({
122
+ executeAuth: validExecuteAuth,
123
+ runner: new ExecuteRouteRunner(),
124
+ });
125
+ jobsApp.define("runner-mounted", {
126
+ handler: async () => {},
127
+ });
128
+
129
+ const app = buildApp(jobsApp);
130
+ const response = await supertest(app)
131
+ .post(executePath(jobsApp.getBasePath()))
132
+ .set("Authorization", "Bearer execute-token")
133
+ .send({jobId: new mongoose.Types.ObjectId().toString()});
134
+
135
+ assert.equal(response.status, 404);
136
+ });
137
+
138
+ it("uses the configured basePath for the execute route", async (): Promise<void> => {
139
+ const jobsApp = new JobsApp({
140
+ basePath: "/background",
141
+ executeAuth: validExecuteAuth,
142
+ mountExecuteRoute: true,
143
+ });
144
+ jobsApp.define("custom-base", {
145
+ handler: async () => {},
146
+ });
147
+
148
+ const app = buildApp(jobsApp);
149
+ const wrongPath = await supertest(app)
150
+ .post("/jobs/execute")
151
+ .set("Authorization", "Bearer execute-token")
152
+ .send({jobId: new mongoose.Types.ObjectId().toString()});
153
+ const rightPath = await supertest(app)
154
+ .post("/background/execute")
155
+ .set("Authorization", "Bearer execute-token")
156
+ .send({jobId: new mongoose.Types.ObjectId().toString()});
157
+
158
+ assert.equal(wrongPath.status, 404);
159
+ assert.equal(rightPath.status, 404);
160
+ });
161
+
162
+ it("throws during register when the execute route is enabled without executeAuth", (): void => {
163
+ const jobsApp = new JobsApp({mountExecuteRoute: true});
164
+
165
+ assert.throws(() => buildApp(jobsApp), /executeAuth/i);
166
+ });
167
+
168
+ it("returns 401 when execute auth is missing or invalid", async (): Promise<void> => {
169
+ const jobsApp = new JobsApp({
170
+ executeAuth: validExecuteAuth,
171
+ mountExecuteRoute: true,
172
+ });
173
+ jobsApp.define("auth-gate", {
174
+ handler: async () => {},
175
+ });
176
+
177
+ const app = buildApp(jobsApp);
178
+ const missing = await supertest(app)
179
+ .post(executePath(jobsApp.getBasePath()))
180
+ .send({jobId: new mongoose.Types.ObjectId().toString()});
181
+ const invalid = await supertest(app)
182
+ .post(executePath(jobsApp.getBasePath()))
183
+ .set("Authorization", "Bearer wrong")
184
+ .send({jobId: new mongoose.Types.ObjectId().toString()});
185
+
186
+ assert.equal(missing.status, 401);
187
+ assert.equal(invalid.status, 401);
188
+ });
189
+
190
+ it("returns 400 when jobId is missing or not a non-empty string", async (): Promise<void> => {
191
+ const jobsApp = new JobsApp({
192
+ executeAuth: validExecuteAuth,
193
+ mountExecuteRoute: true,
194
+ });
195
+ jobsApp.define("body-validation", {
196
+ handler: async () => {},
197
+ });
198
+
199
+ const app = buildApp(jobsApp);
200
+ const missing = await supertest(app)
201
+ .post(executePath(jobsApp.getBasePath()))
202
+ .set(authHeaders)
203
+ .send({});
204
+ const numeric = await supertest(app)
205
+ .post(executePath(jobsApp.getBasePath()))
206
+ .set(authHeaders)
207
+ .send({jobId: 123});
208
+ const empty = await supertest(app)
209
+ .post(executePath(jobsApp.getBasePath()))
210
+ .set(authHeaders)
211
+ .send({jobId: " "});
212
+
213
+ assert.equal(missing.status, 400);
214
+ assert.equal(numeric.status, 400);
215
+ assert.equal(empty.status, 400);
216
+ assert.equal(missing.body.title, "jobId is required");
217
+ });
218
+
219
+ it("returns 404 for unknown or invalid object-id jobId without leaking details", async (): Promise<void> => {
220
+ const jobsApp = new JobsApp({
221
+ executeAuth: validExecuteAuth,
222
+ mountExecuteRoute: true,
223
+ });
224
+ jobsApp.define("missing-job", {
225
+ handler: async () => {},
226
+ });
227
+
228
+ const app = buildApp(jobsApp);
229
+ const unknown = await supertest(app)
230
+ .post(executePath(jobsApp.getBasePath()))
231
+ .set(authHeaders)
232
+ .send({jobId: new mongoose.Types.ObjectId().toString()});
233
+ const malformed = await supertest(app)
234
+ .post(executePath(jobsApp.getBasePath()))
235
+ .set(authHeaders)
236
+ .send({jobId: "not-an-object-id"});
237
+
238
+ assert.equal(unknown.status, 404);
239
+ assert.equal(malformed.status, 404);
240
+ assert.equal(unknown.body.title, malformed.body.title);
241
+ });
242
+
243
+ it("returns 401 for terminal jobs when execute auth is missing", async (): Promise<void> => {
244
+ const jobsApp = new JobsApp({
245
+ executeAuth: validExecuteAuth,
246
+ mountExecuteRoute: true,
247
+ });
248
+ jobsApp.define("terminal-unauth", {
249
+ handler: async () => {},
250
+ });
251
+
252
+ const job = await Job.create({
253
+ attemptCount: 0,
254
+ backoffMs: 1_000,
255
+ maxAttempts: 3,
256
+ maxBackoffMs: 60_000,
257
+ name: "terminal-unauth",
258
+ payload: {},
259
+ payloadRedacted: false,
260
+ runAt: DateTime.utc().toJSDate(),
261
+ status: "completed",
262
+ });
263
+
264
+ const app = buildApp(jobsApp);
265
+ const response = await supertest(app)
266
+ .post(executePath(jobsApp.getBasePath()))
267
+ .send({jobId: job._id.toString()});
268
+
269
+ assert.equal(response.status, 401);
270
+ });
271
+
272
+ it("returns 401 when executeAuth throws or rejects without running the handler", async (): Promise<void> => {
273
+ const verifierCases: ExecuteAuthVerifier[] = [
274
+ () => {
275
+ throw new Error("sync throw");
276
+ },
277
+ async () => {
278
+ throw new Error("async throw");
279
+ },
280
+ async () => false,
281
+ ];
282
+
283
+ for (const executeAuth of verifierCases) {
284
+ let handlerCalls = 0;
285
+ const jobsApp = new JobsApp({
286
+ executeAuth,
287
+ mountExecuteRoute: true,
288
+ });
289
+ jobsApp.define("auth-fail-closed", {
290
+ handler: async () => {
291
+ handlerCalls += 1;
292
+ },
293
+ });
294
+ buildApp(jobsApp);
295
+
296
+ const job = await getJobsService().enqueue({
297
+ name: "auth-fail-closed",
298
+ payload: {case: executeAuth},
299
+ });
300
+
301
+ const response = await supertest(buildApp(jobsApp))
302
+ .post(executePath(jobsApp.getBasePath()))
303
+ .set(authHeaders)
304
+ .send({jobId: job._id.toString()});
305
+
306
+ assert.equal(response.status, 401);
307
+ assert.equal(handlerCalls, 0);
308
+
309
+ const unchanged = await Job.findExactlyOne({_id: job._id});
310
+ assert.equal(unchanged.status, "pending");
311
+ await Job.deleteMany({name: "auth-fail-closed"});
312
+ }
313
+ });
314
+
315
+ it("returns 200 no-op for completed, cancelled, dead, and failed terminal jobs", async (): Promise<void> => {
316
+ const jobsApp = new JobsApp({
317
+ executeAuth: validExecuteAuth,
318
+ mountExecuteRoute: true,
319
+ });
320
+ jobsApp.define("terminal", {
321
+ handler: async () => {},
322
+ });
323
+
324
+ for (const status of ["completed", "cancelled", "dead", "failed"] as const) {
325
+ const job = await Job.create({
326
+ attemptCount: status === "dead" ? 3 : 0,
327
+ backoffMs: 1_000,
328
+ lastError: status === "failed" ? "missing handler" : undefined,
329
+ maxAttempts: 3,
330
+ maxBackoffMs: 60_000,
331
+ name: "terminal",
332
+ payload: {status},
333
+ payloadRedacted: false,
334
+ runAt: DateTime.utc().toJSDate(),
335
+ status,
336
+ });
337
+
338
+ const app = buildApp(jobsApp);
339
+ const response = await supertest(app)
340
+ .post(executePath(jobsApp.getBasePath()))
341
+ .set("Authorization", "Bearer execute-token")
342
+ .send({jobId: job._id.toString()});
343
+
344
+ assert.equal(response.status, 200);
345
+ assert.equal(response.body.data.status, status);
346
+ assert.equal(response.body.data.id, job._id.toString());
347
+ }
348
+ });
349
+
350
+ it("returns 409 when the job is running under a live lock", async (): Promise<void> => {
351
+ const jobsApp = new JobsApp({
352
+ executeAuth: validExecuteAuth,
353
+ lockTtlMs: 60_000,
354
+ mountExecuteRoute: true,
355
+ });
356
+ jobsApp.define("live-lock", {
357
+ handler: async () => {},
358
+ });
359
+
360
+ const job = await Job.create({
361
+ attemptCount: 0,
362
+ backoffMs: 1_000,
363
+ lockedAt: DateTime.utc().toJSDate(),
364
+ lockedBy: `${getWorkerId()}:live-lock-token`,
365
+ maxAttempts: 3,
366
+ maxBackoffMs: 60_000,
367
+ name: "live-lock",
368
+ payload: {},
369
+ payloadRedacted: false,
370
+ runAt: DateTime.utc().toJSDate(),
371
+ status: "running",
372
+ });
373
+
374
+ const app = buildApp(jobsApp);
375
+ const response = await supertest(app)
376
+ .post(executePath(jobsApp.getBasePath()))
377
+ .set("Authorization", "Bearer execute-token")
378
+ .send({jobId: job._id.toString()});
379
+
380
+ assert.equal(response.status, 409);
381
+ const stillRunning = await Job.findExactlyOne({_id: job._id});
382
+ assert.equal(stillRunning.status, "running");
383
+ });
384
+
385
+ it("returns 409 when runAt is still in the future", async (): Promise<void> => {
386
+ const jobsApp = new JobsApp({
387
+ executeAuth: validExecuteAuth,
388
+ mountExecuteRoute: true,
389
+ });
390
+ jobsApp.define("future-run", {
391
+ handler: async () => {},
392
+ });
393
+ buildApp(jobsApp);
394
+
395
+ const job = await getJobsService().enqueue({
396
+ name: "future-run",
397
+ payload: {n: 1},
398
+ runAt: DateTime.utc().plus({hours: 1}).toJSDate(),
399
+ });
400
+
401
+ const app = buildApp(jobsApp);
402
+ const response = await supertest(app)
403
+ .post(executePath(jobsApp.getBasePath()))
404
+ .set("Authorization", "Bearer execute-token")
405
+ .send({jobId: job._id.toString()});
406
+
407
+ assert.equal(response.status, 409);
408
+ const unchanged = await Job.findExactlyOne({_id: job._id});
409
+ assert.equal(unchanged.status, "pending");
410
+ });
411
+
412
+ it("claims and executes a due pending job without polling another row", async (): Promise<void> => {
413
+ let executedJobId: string | undefined;
414
+ const jobsApp = new JobsApp({
415
+ executeAuth: validExecuteAuth,
416
+ mountExecuteRoute: true,
417
+ });
418
+ jobsApp.define("due-pending", {
419
+ handler: async (_payload, ctx) => {
420
+ executedJobId = ctx.jobId;
421
+ },
422
+ });
423
+ buildApp(jobsApp);
424
+
425
+ const target = await getJobsService().enqueue({
426
+ name: "due-pending",
427
+ payload: {target: true},
428
+ });
429
+ const other = await getJobsService().enqueue({
430
+ name: "due-pending",
431
+ payload: {other: true},
432
+ runAt: DateTime.utc().minus({minutes: 5}).toJSDate(),
433
+ });
434
+
435
+ const app = buildApp(jobsApp);
436
+ const response = await supertest(app)
437
+ .post(executePath(jobsApp.getBasePath()))
438
+ .set("Authorization", "Bearer execute-token")
439
+ .send({jobId: target._id.toString()});
440
+
441
+ assert.equal(response.status, 200);
442
+ assert.equal(response.body.data.status, "completed");
443
+ assert.equal(response.body.data.id, target._id.toString());
444
+ assert.equal(executedJobId, target._id.toString());
445
+
446
+ const otherRow = await Job.findExactlyOne({_id: other._id});
447
+ assert.equal(otherRow.status, "pending");
448
+ });
449
+
450
+ it("records handler failures with retry semantics on execute", async (): Promise<void> => {
451
+ const jobsApp = new JobsApp({
452
+ executeAuth: validExecuteAuth,
453
+ mountExecuteRoute: true,
454
+ });
455
+ jobsApp.define("retry-on-execute", {
456
+ handler: async () => {
457
+ throw new Error("execute boom");
458
+ },
459
+ retry: {maxAttempts: 3},
460
+ });
461
+ buildApp(jobsApp);
462
+
463
+ const job = await getJobsService().enqueue({
464
+ name: "retry-on-execute",
465
+ payload: {},
466
+ });
467
+
468
+ const app = buildApp(jobsApp);
469
+ const response = await supertest(app)
470
+ .post(executePath(jobsApp.getBasePath()))
471
+ .set("Authorization", "Bearer execute-token")
472
+ .send({jobId: job._id.toString()});
473
+
474
+ assert.equal(response.status, 200);
475
+ assert.equal(response.body.data.status, "pending");
476
+ assert.equal(response.body.data.attemptCount, 1);
477
+ assert.isAbove(new Date(response.body.data.runAt).getTime(), Date.now() - 5_000);
478
+ });
479
+
480
+ it("reclaims and executes a job with a stale running lock", async (): Promise<void> => {
481
+ let handlerCalls = 0;
482
+ const jobsApp = new JobsApp({
483
+ executeAuth: validExecuteAuth,
484
+ lockTtlMs: 100,
485
+ mountExecuteRoute: true,
486
+ });
487
+ jobsApp.define("stale-lock", {
488
+ handler: async () => {
489
+ handlerCalls += 1;
490
+ },
491
+ });
492
+
493
+ const job = await Job.create({
494
+ attemptCount: 0,
495
+ backoffMs: 1_000,
496
+ lockedAt: DateTime.utc().minus({seconds: 1}).toJSDate(),
497
+ lockedBy: `${getWorkerId()}:stale-token`,
498
+ maxAttempts: 3,
499
+ maxBackoffMs: 60_000,
500
+ name: "stale-lock",
501
+ payload: {},
502
+ payloadRedacted: false,
503
+ runAt: DateTime.utc().toJSDate(),
504
+ status: "running",
505
+ });
506
+
507
+ const app = buildApp(jobsApp);
508
+ const response = await supertest(app)
509
+ .post(executePath(jobsApp.getBasePath()))
510
+ .set(authHeaders)
511
+ .send({jobId: job._id.toString()});
512
+
513
+ assert.equal(response.status, 200);
514
+ assert.equal(response.body.data.status, "completed");
515
+ assert.equal(handlerCalls, 1);
516
+ });
517
+
518
+ it("runs the handler once when two execute requests race and returns 200 then 409", async (): Promise<void> => {
519
+ let handlerCalls = 0;
520
+
521
+ const jobsApp = new JobsApp({
522
+ executeAuth: validExecuteAuth,
523
+ lockTtlMs: 60_000,
524
+ mountExecuteRoute: true,
525
+ });
526
+ jobsApp.define("concurrent-execute", {
527
+ handler: async () => {
528
+ handlerCalls += 1;
529
+ await Bun.sleep(500);
530
+ },
531
+ });
532
+ buildApp(jobsApp);
533
+
534
+ const job = await getJobsService().enqueue({
535
+ name: "concurrent-execute",
536
+ payload: {},
537
+ });
538
+
539
+ const app = buildApp(jobsApp);
540
+ const path = executePath(jobsApp.getBasePath());
541
+ const firstPromise = supertest(app)
542
+ .post(path)
543
+ .set(authHeaders)
544
+ .send({jobId: job._id.toString()})
545
+ .then((response) => response);
546
+
547
+ await waitUntil(async () => {
548
+ const row = await Job.findExactlyOne({_id: job._id});
549
+ return row.status === "running";
550
+ });
551
+ const second = await supertest(app)
552
+ .post(path)
553
+ .set(authHeaders)
554
+ .send({jobId: job._id.toString()});
555
+ const first = await firstPromise;
556
+
557
+ const statuses = [first.status, second.status].sort();
558
+ assert.deepEqual(statuses, [200, 409]);
559
+ assert.equal(handlerCalls, 1);
560
+
561
+ const completed = await Job.findExactlyOne({_id: job._id});
562
+ assert.equal(completed.status, "completed");
563
+ });
564
+
565
+ it("returns 200 with dead status when the final attempt exhausts maxAttempts", async (): Promise<void> => {
566
+ const jobsApp = new JobsApp({
567
+ executeAuth: validExecuteAuth,
568
+ mountExecuteRoute: true,
569
+ });
570
+ jobsApp.define("final-dead", {
571
+ handler: async () => {
572
+ throw new Error("terminal failure");
573
+ },
574
+ retry: {maxAttempts: 1},
575
+ });
576
+ buildApp(jobsApp);
577
+
578
+ const job = await getJobsService().enqueue({
579
+ name: "final-dead",
580
+ payload: {},
581
+ });
582
+
583
+ const app = buildApp(jobsApp);
584
+ const response = await supertest(app)
585
+ .post(executePath(jobsApp.getBasePath()))
586
+ .set(authHeaders)
587
+ .send({jobId: job._id.toString()});
588
+
589
+ assert.equal(response.status, 200);
590
+ assert.equal(response.body.data.status, "dead");
591
+ assert.equal(response.body.data.attemptCount, 1);
592
+ });
593
+
594
+ it("is subject to TerrenoApp API rate limits and is not on the skip list", async (): Promise<void> => {
595
+ const jobsApp = new JobsApp({
596
+ executeAuth: validExecuteAuth,
597
+ mountExecuteRoute: true,
598
+ });
599
+ jobsApp.define("rate-limited", {
600
+ handler: async () => {},
601
+ });
602
+ buildApp(jobsApp);
603
+
604
+ const job = await getJobsService().enqueue({
605
+ name: "rate-limited",
606
+ payload: {},
607
+ });
608
+
609
+ const app = buildApp(jobsApp, {rateLimit: {limits: {apiMax: 1, authMax: 20}}});
610
+ const path = executePath(jobsApp.getBasePath());
611
+
612
+ const first = await supertest(app)
613
+ .post(path)
614
+ .set(authHeaders)
615
+ .send({jobId: job._id.toString()});
616
+ const second = await supertest(app)
617
+ .post(path)
618
+ .set(authHeaders)
619
+ .send({jobId: new mongoose.Types.ObjectId().toString()});
620
+
621
+ assert.equal(first.status, 200);
622
+ assert.equal(second.status, 429);
623
+ assert.equal(second.body.code, "rate-limit-exceeded");
624
+ });
625
+
626
+ it("does not appear in the public OpenAPI document", async (): Promise<void> => {
627
+ const jobsApp = new JobsApp({
628
+ executeAuth: validExecuteAuth,
629
+ mountExecuteRoute: true,
630
+ });
631
+ jobsApp.define("openapi-hidden", {
632
+ handler: async () => {},
633
+ });
634
+
635
+ const app = buildApp(jobsApp);
636
+ const response = await supertest(app).get("/openapi.json").expect(200);
637
+
638
+ assert.notProperty(response.body.paths, "/jobs/execute");
639
+ });
640
+ });