@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,113 @@
1
+ import {beforeEach, describe, expect, it} from "bun:test";
2
+ import {TerrenoApp, type UserModel as UserModelType} from "@terreno/api";
3
+ import {setupDb, UserModel} from "@terreno/api/testing";
4
+
5
+ import {JobsApp} from "../jobsApp";
6
+ import {tryGetJobsService, unregisterJobsService} from "../jobsService";
7
+ import {Job} from "../models/job";
8
+ import {JobSchedule} from "../models/jobSchedule";
9
+
10
+ const typedUserModel = UserModel as unknown as UserModelType;
11
+
12
+ describe("JobsApp", () => {
13
+ beforeEach(async (): Promise<void> => {
14
+ await setupDb();
15
+ await Job.deleteMany({});
16
+ await JobSchedule.deleteMany({});
17
+ });
18
+ it("registers with TerrenoApp and builds without starting a worker", () => {
19
+ const jobsApp = new JobsApp();
20
+
21
+ const expressApp = new TerrenoApp({
22
+ skipListen: true,
23
+ userModel: typedUserModel,
24
+ })
25
+ .register(jobsApp)
26
+ .build();
27
+
28
+ expect(expressApp).toBeDefined();
29
+ expect(jobsApp.isWorkerActive()).toBe(false);
30
+ });
31
+
32
+ it("exposes tryGetJobsService only after register", () => {
33
+ unregisterJobsService();
34
+ expect(tryGetJobsService()).toBeUndefined();
35
+ const jobsApp = new JobsApp();
36
+ new TerrenoApp({
37
+ skipListen: true,
38
+ userModel: typedUserModel,
39
+ })
40
+ .register(jobsApp)
41
+ .build();
42
+ expect(tryGetJobsService()).toBeDefined();
43
+ unregisterJobsService();
44
+ expect(tryGetJobsService()).toBeUndefined();
45
+ });
46
+
47
+ it("uses the configured base path", () => {
48
+ expect(new JobsApp({basePath: "/background"}).getBasePath()).toBe("/background");
49
+ expect(new JobsApp().getBasePath()).toBe("/jobs");
50
+ });
51
+
52
+ it("starts and stops a worker only when explicitly requested", async () => {
53
+ const jobsApp = new JobsApp({pollIntervalMs: 50});
54
+
55
+ expect(jobsApp.isWorkerActive()).toBe(false);
56
+ await jobsApp.startWorker();
57
+ expect(jobsApp.isWorkerActive()).toBe(true);
58
+ await jobsApp.startWorker();
59
+ expect(jobsApp.isWorkerActive()).toBe(true);
60
+ await jobsApp.stopWorker();
61
+ expect(jobsApp.isWorkerActive()).toBe(false);
62
+ await jobsApp.stopWorker();
63
+ expect(jobsApp.isWorkerActive()).toBe(false);
64
+ });
65
+
66
+ it("exposes defaults, executes queued jobs, and marks schedules dirty while a worker is active", async () => {
67
+ const jobsApp = new JobsApp({lockTtlMs: 60_000, pollIntervalMs: 50});
68
+ jobsApp.define("taste/defaults", {
69
+ handler: async () => {},
70
+ });
71
+
72
+ expect(jobsApp.getDefaultTimezone()).toBe("UTC");
73
+ expect(jobsApp.getLockTtlMs()).toBe(60_000);
74
+ expect(jobsApp.getPollIntervalMs()).toBe(50);
75
+ expect(new JobsApp().getPollIntervalMs()).toBe(1_000);
76
+
77
+ await jobsApp.startWorker();
78
+ jobsApp.define("taste/heartbeat", {
79
+ handler: async () => {},
80
+ schedule: {cron: "*/5 * * * *", timezone: "UTC"},
81
+ });
82
+ await jobsApp.reconcileSchedulesIfDirty();
83
+
84
+ const missing = await jobsApp.executeQueuedJob("000000000000000000000000");
85
+ expect(missing.kind).toBe("not_found");
86
+ await jobsApp.stopWorker();
87
+ });
88
+
89
+ it("logs when Job or JobSchedule index builds fail during register", async () => {
90
+ const jobsApp = new JobsApp();
91
+ const originalJobInit = Job.init.bind(Job);
92
+ const originalScheduleInit = JobSchedule.init.bind(JobSchedule);
93
+ Job.init = (async () => {
94
+ throw new Error("job index failed");
95
+ }) as typeof Job.init;
96
+ JobSchedule.init = (async () => {
97
+ throw "schedule index failed";
98
+ }) as typeof JobSchedule.init;
99
+
100
+ try {
101
+ new TerrenoApp({
102
+ skipListen: true,
103
+ userModel: typedUserModel,
104
+ })
105
+ .register(jobsApp)
106
+ .build();
107
+ await Bun.sleep(20);
108
+ } finally {
109
+ Job.init = originalJobInit;
110
+ JobSchedule.init = originalScheduleInit;
111
+ }
112
+ });
113
+ });
@@ -0,0 +1,367 @@
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 {DateTime} from "luxon";
6
+ import {JobDispatchError} from "../dispatchError";
7
+ import {JobsApp} from "../jobsApp";
8
+ import {getJobsService} from "../jobsService";
9
+ import {Job} from "../models/job";
10
+ import {JobSchedule} from "../models/jobSchedule";
11
+ import type {JobDocument} from "../modelTypes";
12
+ import {buildScheduleRunIdempotencyKey, tickDueSchedules} from "../scheduler";
13
+ import type {JobRunner, JobRunnerStartOptions, JobsRunnerHost} from "../types";
14
+
15
+ const typedUserModel = UserModel as unknown as UserModelType;
16
+
17
+ class FakeJobRunner implements JobRunner {
18
+ readonly id = "fake";
19
+ readonly enqueuedJobs: JobDocument[] = [];
20
+ readonly startCalls: JobRunnerStartOptions[] = [];
21
+ stopCalls = 0;
22
+
23
+ async enqueue(job: JobDocument): Promise<void> {
24
+ this.enqueuedJobs.push(job);
25
+ }
26
+
27
+ async start(options: JobRunnerStartOptions): Promise<void> {
28
+ this.startCalls.push(options);
29
+
30
+ await new Promise<void>((resolve) => {
31
+ if (options.signal.aborted) {
32
+ resolve();
33
+ return;
34
+ }
35
+
36
+ const onAbort = (): void => {
37
+ options.signal.removeEventListener("abort", onAbort);
38
+ resolve();
39
+ };
40
+
41
+ options.signal.addEventListener("abort", onAbort, {once: true});
42
+ });
43
+ }
44
+
45
+ async stop(): Promise<void> {
46
+ this.stopCalls += 1;
47
+ }
48
+ }
49
+
50
+ const registerJobsApp = (jobsApp: JobsApp): void => {
51
+ new TerrenoApp({
52
+ skipListen: true,
53
+ userModel: typedUserModel,
54
+ })
55
+ .register(jobsApp)
56
+ .build();
57
+ };
58
+
59
+ const waitForAbort = async (signal: AbortSignal): Promise<void> => {
60
+ await new Promise<void>((resolve) => {
61
+ if (signal.aborted) {
62
+ resolve();
63
+ return;
64
+ }
65
+
66
+ signal.addEventListener("abort", () => resolve(), {once: true});
67
+ });
68
+ };
69
+
70
+ class FailingThenSucceedingRunner implements JobRunner {
71
+ readonly id = "fail-then-succeed";
72
+ dispatchAttempts = 0;
73
+ readonly enqueuedJobs: JobDocument[] = [];
74
+
75
+ async enqueue(job: JobDocument): Promise<void> {
76
+ this.dispatchAttempts += 1;
77
+ if (this.dispatchAttempts === 1) {
78
+ throw new Error("dispatch failed");
79
+ }
80
+ this.enqueuedJobs.push(job);
81
+ }
82
+
83
+ async start(options: JobRunnerStartOptions): Promise<void> {
84
+ await waitForAbort(options.signal);
85
+ }
86
+
87
+ async stop(): Promise<void> {}
88
+ }
89
+
90
+ class CountingDispatchRunner implements JobRunner {
91
+ readonly id = "count-dispatch";
92
+ dispatchCount = 0;
93
+
94
+ async enqueue(_job: JobDocument): Promise<void> {
95
+ this.dispatchCount += 1;
96
+ }
97
+
98
+ async start(options: JobRunnerStartOptions): Promise<void> {
99
+ await waitForAbort(options.signal);
100
+ }
101
+
102
+ async stop(): Promise<void> {}
103
+ }
104
+
105
+ describe("JobRunner custom runner seam", () => {
106
+ beforeEach(async (): Promise<void> => {
107
+ await setupDb();
108
+ await Job.deleteMany({});
109
+ await Job.syncIndexes();
110
+ });
111
+
112
+ it("calls runner.enqueue once per newly persisted job", async (): Promise<void> => {
113
+ const fake = new FakeJobRunner();
114
+ const jobsApp = new JobsApp({runner: fake});
115
+ jobsApp.define("dispatch-me", {
116
+ handler: async () => {},
117
+ });
118
+ registerJobsApp(jobsApp);
119
+
120
+ const first = await getJobsService().enqueue({
121
+ name: "dispatch-me",
122
+ payload: {n: 1},
123
+ });
124
+ const second = await getJobsService().enqueue({
125
+ name: "dispatch-me",
126
+ payload: {n: 2},
127
+ });
128
+
129
+ assert.equal(fake.enqueuedJobs.length, 2);
130
+ assert.sameMembers(
131
+ fake.enqueuedJobs.map((job) => job._id.toString()),
132
+ [first._id.toString(), second._id.toString()]
133
+ );
134
+ });
135
+
136
+ it("does not call runner.enqueue when idempotent enqueue dedupes", async (): Promise<void> => {
137
+ const fake = new FakeJobRunner();
138
+ const jobsApp = new JobsApp({runner: fake});
139
+ jobsApp.define("dedupe-dispatch", {
140
+ handler: async () => {},
141
+ });
142
+ registerJobsApp(jobsApp);
143
+
144
+ const first = await getJobsService().enqueue({
145
+ idempotencyKey: "welcome:user-1",
146
+ name: "dedupe-dispatch",
147
+ payload: {userId: "user-1"},
148
+ });
149
+ const second = await getJobsService().enqueue({
150
+ idempotencyKey: "welcome:user-1",
151
+ name: "dedupe-dispatch",
152
+ payload: {ignored: true},
153
+ });
154
+
155
+ assert.equal(second._id.toString(), first._id.toString());
156
+ assert.equal(fake.enqueuedJobs.length, 1);
157
+ assert.equal(fake.enqueuedJobs[0]._id.toString(), first._id.toString());
158
+ });
159
+
160
+ it("passes JobsRunnerHost context and lifecycle signal to runner.start", async (): Promise<void> => {
161
+ const fake = new FakeJobRunner();
162
+ const jobsApp = new JobsApp({pollIntervalMs: 321, runner: fake});
163
+ jobsApp.define("start-context", {
164
+ handler: async () => {},
165
+ });
166
+ registerJobsApp(jobsApp);
167
+
168
+ await jobsApp.startWorker();
169
+
170
+ assert.equal(fake.startCalls.length, 1);
171
+ const startOptions = fake.startCalls[0];
172
+ assert.equal(startOptions.pollIntervalMs, 321);
173
+ assert.isFalse(startOptions.signal.aborted);
174
+
175
+ const host: JobsRunnerHost = startOptions.jobs;
176
+ assert.equal(host.getDefaultTimezone(), jobsApp.getDefaultTimezone());
177
+ assert.isDefined(host.getDefinition("start-context"));
178
+ assert.isAbove(host.getLockTtlMs(), 0);
179
+
180
+ await jobsApp.stopWorker();
181
+
182
+ assert.isTrue(startOptions.signal.aborted);
183
+ assert.equal(fake.stopCalls, 1);
184
+ assert.isFalse(jobsApp.isWorkerActive());
185
+ });
186
+
187
+ it("defaults to MongoJobRunner when no runner is injected", async (): Promise<void> => {
188
+ const jobsApp = new JobsApp();
189
+ jobsApp.define("mongo-default", {
190
+ handler: async () => {},
191
+ });
192
+ registerJobsApp(jobsApp);
193
+
194
+ await getJobsService().enqueue({
195
+ name: "mongo-default",
196
+ payload: {},
197
+ });
198
+
199
+ await jobsApp.startWorker();
200
+ await jobsApp.stopWorker();
201
+
202
+ assert.isFalse(jobsApp.isWorkerActive());
203
+ });
204
+
205
+ it("compensates failed dispatch so idempotent retry creates and dispatches one row", async (): Promise<void> => {
206
+ const runner = new FailingThenSucceedingRunner();
207
+ const jobsApp = new JobsApp({runner});
208
+ jobsApp.define("retry-dispatch", {
209
+ handler: async () => {},
210
+ });
211
+ registerJobsApp(jobsApp);
212
+
213
+ let firstError: unknown;
214
+ try {
215
+ await getJobsService().enqueue({
216
+ idempotencyKey: "retry:1",
217
+ name: "retry-dispatch",
218
+ payload: {attempt: 1},
219
+ });
220
+ } catch (error: unknown) {
221
+ firstError = error;
222
+ }
223
+
224
+ assert.instanceOf(firstError, JobDispatchError);
225
+ assert.isTrue((firstError as JobDispatchError).compensationSucceeded);
226
+ assert.equal(await Job.countDocuments({name: "retry-dispatch"}), 0);
227
+ assert.equal(runner.dispatchAttempts, 1);
228
+ assert.equal(runner.enqueuedJobs.length, 0);
229
+
230
+ const retried = await getJobsService().enqueue({
231
+ idempotencyKey: "retry:1",
232
+ name: "retry-dispatch",
233
+ payload: {attempt: 2},
234
+ });
235
+
236
+ assert.equal(runner.dispatchAttempts, 2);
237
+ assert.equal(runner.enqueuedJobs.length, 1);
238
+ assert.equal(runner.enqueuedJobs[0]._id.toString(), retried._id.toString());
239
+ assert.equal(await Job.countDocuments({name: "retry-dispatch"}), 1);
240
+ });
241
+
242
+ it("reports compensation lost when dispatch fails after the row is claimed", async (): Promise<void> => {
243
+ const runner: JobRunner = {
244
+ enqueue: async (job: JobDocument): Promise<void> => {
245
+ await Job.updateOne(
246
+ {_id: job._id},
247
+ {$set: {lockedAt: new Date(), lockedBy: "other-worker"}}
248
+ );
249
+ throw new Error("cloud dispatch failed after claim");
250
+ },
251
+ id: "claim-then-fail",
252
+ };
253
+ const jobsApp = new JobsApp({runner});
254
+ jobsApp.define("claimed-dispatch", {
255
+ handler: async () => {},
256
+ });
257
+ registerJobsApp(jobsApp);
258
+
259
+ let caught: unknown;
260
+ try {
261
+ await getJobsService().enqueue({
262
+ name: "claimed-dispatch",
263
+ payload: {n: 1},
264
+ });
265
+ } catch (error: unknown) {
266
+ caught = error;
267
+ }
268
+
269
+ assert.instanceOf(caught, JobDispatchError);
270
+ assert.isFalse((caught as JobDispatchError).compensationSucceeded);
271
+ assert.equal(await Job.countDocuments({name: "claimed-dispatch"}), 1);
272
+ });
273
+
274
+ it("compensates failed dispatch for non-keyed enqueue and leaves no orphan row", async (): Promise<void> => {
275
+ const runner = new FailingThenSucceedingRunner();
276
+ const jobsApp = new JobsApp({runner});
277
+ jobsApp.define("orphan-free", {
278
+ handler: async () => {},
279
+ });
280
+ registerJobsApp(jobsApp);
281
+
282
+ let caught: unknown;
283
+ try {
284
+ await getJobsService().enqueue({
285
+ name: "orphan-free",
286
+ payload: {n: 1},
287
+ });
288
+ } catch (error: unknown) {
289
+ caught = error;
290
+ }
291
+
292
+ assert.instanceOf(caught, JobDispatchError);
293
+ assert.equal(await Job.countDocuments({name: "orphan-free"}), 0);
294
+ assert.equal(runner.dispatchAttempts, 1);
295
+ });
296
+
297
+ it("dispatches exactly once when concurrent enqueues share an idempotencyKey", async (): Promise<void> => {
298
+ const runner = new CountingDispatchRunner();
299
+ const jobsApp = new JobsApp({runner});
300
+ jobsApp.define("concurrent-dispatch", {
301
+ handler: async () => {},
302
+ });
303
+ registerJobsApp(jobsApp);
304
+
305
+ const results = await Promise.all(
306
+ Array.from({length: 12}, () =>
307
+ getJobsService().enqueue({
308
+ idempotencyKey: "shared:42",
309
+ name: "concurrent-dispatch",
310
+ payload: {invoiceId: "42"},
311
+ })
312
+ )
313
+ );
314
+
315
+ const uniqueIds = new Set(results.map((job) => job._id.toString()));
316
+ assert.equal(uniqueIds.size, 1);
317
+ assert.equal(runner.dispatchCount, 1);
318
+ assert.equal(await Job.countDocuments({name: "concurrent-dispatch"}), 1);
319
+ });
320
+
321
+ it("invokes runner.start only once for concurrent startWorker calls", async (): Promise<void> => {
322
+ const fake = new FakeJobRunner();
323
+ const jobsApp = new JobsApp({runner: fake});
324
+ jobsApp.define("concurrent-start", {
325
+ handler: async () => {},
326
+ });
327
+ registerJobsApp(jobsApp);
328
+
329
+ await Promise.all([jobsApp.startWorker(), jobsApp.startWorker(), jobsApp.startWorker()]);
330
+
331
+ assert.equal(fake.startCalls.length, 1);
332
+ assert.isTrue(jobsApp.isWorkerActive());
333
+ await jobsApp.stopWorker();
334
+ });
335
+
336
+ it("retries schedule tick after failed custom dispatch and creates exactly one child", async (): Promise<void> => {
337
+ const runner = new FailingThenSucceedingRunner();
338
+ const jobsApp = new JobsApp({pollIntervalMs: 25, runner});
339
+ jobsApp.define("sched-dispatch-retry", {
340
+ handler: async () => {},
341
+ schedule: {cron: "0 9 * * *", timezone: "UTC"},
342
+ });
343
+ registerJobsApp(jobsApp);
344
+
345
+ await getJobsService().reconcileSchedules();
346
+ const schedule = await JobSchedule.findExactlyOne({name: "sched-dispatch-retry"});
347
+ const dueRunAt = DateTime.utc().minus({minutes: 1}).toJSDate();
348
+ await JobSchedule.updateOne({_id: schedule._id}, {$set: {nextRunAt: dueRunAt}});
349
+
350
+ await tickDueSchedules({jobsService: getJobsService()});
351
+
352
+ const rolledBack = await JobSchedule.findExactlyOne({_id: schedule._id});
353
+ assert.equal(rolledBack.nextRunAt.toISOString(), dueRunAt.toISOString());
354
+ assert.equal(await Job.countDocuments({name: "sched-dispatch-retry"}), 0);
355
+
356
+ await tickDueSchedules({jobsService: getJobsService()});
357
+
358
+ const childJobs = await Job.find({name: "sched-dispatch-retry"});
359
+ assert.equal(childJobs.length, 1);
360
+ assert.equal(
361
+ childJobs[0]?.idempotencyKey,
362
+ buildScheduleRunIdempotencyKey(schedule._id.toString(), dueRunAt)
363
+ );
364
+ assert.equal(runner.dispatchAttempts, 2);
365
+ assert.equal(runner.enqueuedJobs.length, 1);
366
+ });
367
+ });