@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.
- package/LICENSE +21 -0
- package/README.md +56 -0
- package/biome.jsonc +20 -0
- package/bunfig.toml +5 -0
- package/dist/activeJobExecution.d.ts +3 -0
- package/dist/activeJobExecution.js +29 -0
- package/dist/claimLock.d.ts +16 -0
- package/dist/claimLock.js +34 -0
- package/dist/dispatchCompensation.d.ts +3 -0
- package/dist/dispatchCompensation.js +89 -0
- package/dist/dispatchError.d.ts +10 -0
- package/dist/dispatchError.js +33 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +15 -0
- package/dist/jobExecutor.d.ts +34 -0
- package/dist/jobExecutor.js +328 -0
- package/dist/jobsApp.d.ts +61 -0
- package/dist/jobsApp.js +308 -0
- package/dist/jobsService.d.ts +38 -0
- package/dist/jobsService.js +505 -0
- package/dist/modelTypes.d.ts +52 -0
- package/dist/modelTypes.js +4 -0
- package/dist/models/job.d.ts +2 -0
- package/dist/models/job.js +124 -0
- package/dist/models/jobSchedule.d.ts +2 -0
- package/dist/models/jobSchedule.js +48 -0
- package/dist/retryBackoff.d.ts +17 -0
- package/dist/retryBackoff.js +22 -0
- package/dist/routes/jobsAdmin.d.ts +15 -0
- package/dist/routes/jobsAdmin.js +596 -0
- package/dist/routes/jobsExecute.d.ts +12 -0
- package/dist/routes/jobsExecute.js +119 -0
- package/dist/runners/gcpCloudTasks.d.ts +78 -0
- package/dist/runners/gcpCloudTasks.js +256 -0
- package/dist/runners/mongoRunner.d.ts +14 -0
- package/dist/runners/mongoRunner.js +204 -0
- package/dist/runners/vercelQueues.d.ts +111 -0
- package/dist/runners/vercelQueues.js +326 -0
- package/dist/scheduleCron.d.ts +15 -0
- package/dist/scheduleCron.js +61 -0
- package/dist/scheduler.d.ts +8 -0
- package/dist/scheduler.js +178 -0
- package/dist/types.d.ts +64 -0
- package/dist/types.js +2 -0
- package/package.json +82 -0
- package/src/activeJobExecution.ts +28 -0
- package/src/claimLock.ts +37 -0
- package/src/dispatchCompensation.ts +39 -0
- package/src/dispatchError.ts +14 -0
- package/src/index.ts +24 -0
- package/src/jobExecutor.ts +333 -0
- package/src/jobsApp.ts +255 -0
- package/src/jobsService.ts +395 -0
- package/src/modelTypes.ts +78 -0
- package/src/models/job.ts +129 -0
- package/src/models/jobSchedule.ts +57 -0
- package/src/retryBackoff.ts +34 -0
- package/src/routes/jobsAdmin.ts +562 -0
- package/src/routes/jobsExecute.ts +95 -0
- package/src/runners/gcpCloudTasks.ts +290 -0
- package/src/runners/mongoRunner.ts +135 -0
- package/src/runners/vercelQueues.ts +394 -0
- package/src/scheduleCron.ts +65 -0
- package/src/scheduler.ts +112 -0
- package/src/tests/bunSetup.ts +9 -0
- package/src/tests/gcpCloudTasksRunner.test.ts +349 -0
- package/src/tests/jobModel.test.ts +62 -0
- package/src/tests/jobScheduleModel.test.ts +44 -0
- package/src/tests/jobsAdmin.test.ts +728 -0
- package/src/tests/jobsApp.test.ts +113 -0
- package/src/tests/jobsCustomRunner.test.ts +367 -0
- package/src/tests/jobsDelayIdempotency.test.ts +255 -0
- package/src/tests/jobsExecute.test.ts +640 -0
- package/src/tests/jobsRetry.test.ts +420 -0
- package/src/tests/jobsSchedule.test.ts +505 -0
- package/src/tests/jobsTracer.test.ts +165 -0
- package/src/tests/jobsWorkerLifecycle.test.ts +509 -0
- package/src/tests/packageExports.test.ts +24 -0
- package/src/tests/retryBackoff.test.ts +76 -0
- package/src/tests/scheduleCron.test.ts +87 -0
- package/src/tests/vercelQueuesRunner.test.ts +524 -0
- package/src/types.ts +74 -0
- package/tsconfig.json +33 -0
|
@@ -0,0 +1,255 @@
|
|
|
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
|
+
|
|
7
|
+
import {JobsApp} from "../jobsApp";
|
|
8
|
+
import {getJobsService} from "../jobsService";
|
|
9
|
+
import {Job} from "../models/job";
|
|
10
|
+
|
|
11
|
+
const typedUserModel = UserModel as unknown as UserModelType;
|
|
12
|
+
|
|
13
|
+
const waitUntil = async (
|
|
14
|
+
predicate: () => Promise<boolean>,
|
|
15
|
+
options?: {intervalMs?: number; timeoutMs?: number}
|
|
16
|
+
): Promise<void> => {
|
|
17
|
+
const intervalMs = options?.intervalMs ?? 25;
|
|
18
|
+
const timeoutMs = options?.timeoutMs ?? 5_000;
|
|
19
|
+
const deadline = DateTime.utc().plus({milliseconds: timeoutMs});
|
|
20
|
+
|
|
21
|
+
while (DateTime.utc() < deadline) {
|
|
22
|
+
if (await predicate()) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
await Bun.sleep(intervalMs);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
throw new Error("waitUntil timed out");
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const registerJobsApp = (jobsApp: JobsApp): void => {
|
|
32
|
+
new TerrenoApp({
|
|
33
|
+
skipListen: true,
|
|
34
|
+
userModel: typedUserModel,
|
|
35
|
+
})
|
|
36
|
+
.register(jobsApp)
|
|
37
|
+
.build();
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
describe("jobs delayed runAt and idempotency", () => {
|
|
41
|
+
beforeEach(async (): Promise<void> => {
|
|
42
|
+
await setupDb();
|
|
43
|
+
await Job.deleteMany({});
|
|
44
|
+
await Job.syncIndexes();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("does not claim a job before runAt while the worker is running", async (): Promise<void> => {
|
|
48
|
+
let handlerRuns = 0;
|
|
49
|
+
const futureRunAt = DateTime.utc().plus({minutes: 5});
|
|
50
|
+
|
|
51
|
+
const jobsApp = new JobsApp({pollIntervalMs: 25});
|
|
52
|
+
jobsApp.define("delayed-job", {
|
|
53
|
+
handler: async () => {
|
|
54
|
+
handlerRuns += 1;
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
registerJobsApp(jobsApp);
|
|
58
|
+
|
|
59
|
+
const enqueued = await getJobsService().enqueue({
|
|
60
|
+
name: "delayed-job",
|
|
61
|
+
payload: {marker: "future"},
|
|
62
|
+
runAt: futureRunAt.toJSDate(),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
assert.equal(enqueued.status, "pending");
|
|
66
|
+
assert.equal(
|
|
67
|
+
DateTime.fromJSDate(enqueued.runAt).toMillis(),
|
|
68
|
+
futureRunAt.toMillis(),
|
|
69
|
+
"enqueue stores the requested runAt"
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
await jobsApp.startWorker();
|
|
73
|
+
let idlePolls = 0;
|
|
74
|
+
await waitUntil(async () => {
|
|
75
|
+
idlePolls += 1;
|
|
76
|
+
const row = await Job.findExactlyOne({_id: enqueued._id});
|
|
77
|
+
return idlePolls >= 6 && row.status === "pending";
|
|
78
|
+
});
|
|
79
|
+
await jobsApp.stopWorker();
|
|
80
|
+
|
|
81
|
+
assert.equal(handlerRuns, 0);
|
|
82
|
+
const stillPending = await Job.findExactlyOne({_id: enqueued._id});
|
|
83
|
+
assert.equal(stillPending.status, "pending");
|
|
84
|
+
assert.isUndefined(stillPending.lockedAt);
|
|
85
|
+
assert.isUndefined(stillPending.lockedBy);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("runs a delayed job once runAt is reached", async (): Promise<void> => {
|
|
89
|
+
let handlerRuns = 0;
|
|
90
|
+
const futureRunAt = DateTime.utc().plus({minutes: 5});
|
|
91
|
+
|
|
92
|
+
const jobsApp = new JobsApp({pollIntervalMs: 25});
|
|
93
|
+
jobsApp.define("delayed-then-run", {
|
|
94
|
+
handler: async () => {
|
|
95
|
+
handlerRuns += 1;
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
registerJobsApp(jobsApp);
|
|
99
|
+
|
|
100
|
+
const enqueued = await getJobsService().enqueue({
|
|
101
|
+
name: "delayed-then-run",
|
|
102
|
+
payload: {},
|
|
103
|
+
runAt: futureRunAt.toJSDate(),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await jobsApp.startWorker();
|
|
107
|
+
let idlePolls = 0;
|
|
108
|
+
await waitUntil(async () => {
|
|
109
|
+
idlePolls += 1;
|
|
110
|
+
const row = await Job.findExactlyOne({_id: enqueued._id});
|
|
111
|
+
return idlePolls >= 4 && row.status === "pending";
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await Job.updateOne({_id: enqueued._id}, {$set: {runAt: DateTime.utc().toJSDate()}});
|
|
115
|
+
await waitUntil(
|
|
116
|
+
async () => (await Job.findExactlyOne({_id: enqueued._id})).status === "completed"
|
|
117
|
+
);
|
|
118
|
+
await jobsApp.stopWorker();
|
|
119
|
+
|
|
120
|
+
assert.equal(handlerRuns, 1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("returns the existing job for duplicate enqueue with the same name and idempotencyKey", async (): Promise<void> => {
|
|
124
|
+
const jobsApp = new JobsApp();
|
|
125
|
+
jobsApp.define("idempotent-job", {
|
|
126
|
+
handler: async () => {},
|
|
127
|
+
});
|
|
128
|
+
registerJobsApp(jobsApp);
|
|
129
|
+
|
|
130
|
+
const first = await getJobsService().enqueue({
|
|
131
|
+
idempotencyKey: "welcome-email:user-1",
|
|
132
|
+
name: "idempotent-job",
|
|
133
|
+
payload: {userId: "user-1"},
|
|
134
|
+
});
|
|
135
|
+
const second = await getJobsService().enqueue({
|
|
136
|
+
idempotencyKey: "welcome-email:user-1",
|
|
137
|
+
name: "idempotent-job",
|
|
138
|
+
payload: {extra: "ignored", userId: "user-1"},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
assert.equal(second._id.toString(), first._id.toString());
|
|
142
|
+
assert.equal(await Job.countDocuments({name: "idempotent-job"}), 1);
|
|
143
|
+
|
|
144
|
+
const stored = await Job.findExactlyOne({_id: first._id});
|
|
145
|
+
assert.deepEqual(stored.payload, {userId: "user-1"});
|
|
146
|
+
assert.equal(stored.idempotencyKey, "welcome-email:user-1");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("deduplicates concurrent enqueues that share an idempotencyKey", async (): Promise<void> => {
|
|
150
|
+
const jobsApp = new JobsApp();
|
|
151
|
+
jobsApp.define("race-idempotent", {
|
|
152
|
+
handler: async () => {},
|
|
153
|
+
});
|
|
154
|
+
registerJobsApp(jobsApp);
|
|
155
|
+
|
|
156
|
+
const results = await Promise.all(
|
|
157
|
+
Array.from({length: 12}, () =>
|
|
158
|
+
getJobsService().enqueue({
|
|
159
|
+
idempotencyKey: "invoice:42",
|
|
160
|
+
name: "race-idempotent",
|
|
161
|
+
payload: {invoiceId: "42"},
|
|
162
|
+
})
|
|
163
|
+
)
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const uniqueIds = new Set(results.map((job) => job._id.toString()));
|
|
167
|
+
assert.equal(uniqueIds.size, 1);
|
|
168
|
+
assert.equal(await Job.countDocuments({name: "race-idempotent"}), 1);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("allows multiple jobs without an idempotencyKey for the same name", async (): Promise<void> => {
|
|
172
|
+
const jobsApp = new JobsApp();
|
|
173
|
+
jobsApp.define("repeatable-job", {
|
|
174
|
+
handler: async () => {},
|
|
175
|
+
});
|
|
176
|
+
registerJobsApp(jobsApp);
|
|
177
|
+
|
|
178
|
+
const first = await getJobsService().enqueue({
|
|
179
|
+
name: "repeatable-job",
|
|
180
|
+
payload: {n: 1},
|
|
181
|
+
});
|
|
182
|
+
const second = await getJobsService().enqueue({
|
|
183
|
+
name: "repeatable-job",
|
|
184
|
+
payload: {n: 2},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
assert.notEqual(second._id.toString(), first._id.toString());
|
|
188
|
+
assert.equal(await Job.countDocuments({name: "repeatable-job"}), 2);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("enforces unique (name, idempotencyKey) at the database level", async (): Promise<void> => {
|
|
192
|
+
await Job.create({
|
|
193
|
+
attemptCount: 0,
|
|
194
|
+
idempotencyKey: "shared-key",
|
|
195
|
+
maxAttempts: 5,
|
|
196
|
+
name: "db-unique",
|
|
197
|
+
payload: {},
|
|
198
|
+
payloadRedacted: false,
|
|
199
|
+
runAt: DateTime.utc().toJSDate(),
|
|
200
|
+
status: "pending",
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
let caught: unknown;
|
|
204
|
+
try {
|
|
205
|
+
await Job.create({
|
|
206
|
+
attemptCount: 0,
|
|
207
|
+
idempotencyKey: "shared-key",
|
|
208
|
+
maxAttempts: 5,
|
|
209
|
+
name: "db-unique",
|
|
210
|
+
payload: {duplicate: true},
|
|
211
|
+
payloadRedacted: false,
|
|
212
|
+
runAt: DateTime.utc().toJSDate(),
|
|
213
|
+
status: "pending",
|
|
214
|
+
});
|
|
215
|
+
} catch (error: unknown) {
|
|
216
|
+
caught = error;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
assert.isDefined(caught);
|
|
220
|
+
assert.equal((caught as {code?: number}).code, 11_000);
|
|
221
|
+
assert.equal(await Job.countDocuments({name: "db-unique"}), 1);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("executes an idempotent job only once when enqueue is retried", async (): Promise<void> => {
|
|
225
|
+
let handlerRuns = 0;
|
|
226
|
+
|
|
227
|
+
const jobsApp = new JobsApp({pollIntervalMs: 25});
|
|
228
|
+
jobsApp.define("idempotent-once", {
|
|
229
|
+
handler: async () => {
|
|
230
|
+
handlerRuns += 1;
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
registerJobsApp(jobsApp);
|
|
234
|
+
|
|
235
|
+
const first = await getJobsService().enqueue({
|
|
236
|
+
idempotencyKey: "sync:user-9",
|
|
237
|
+
name: "idempotent-once",
|
|
238
|
+
payload: {},
|
|
239
|
+
});
|
|
240
|
+
await getJobsService().enqueue({
|
|
241
|
+
idempotencyKey: "sync:user-9",
|
|
242
|
+
name: "idempotent-once",
|
|
243
|
+
payload: {},
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
await jobsApp.startWorker();
|
|
247
|
+
await waitUntil(
|
|
248
|
+
async () => (await Job.findExactlyOne({_id: first._id})).status === "completed"
|
|
249
|
+
);
|
|
250
|
+
await jobsApp.stopWorker();
|
|
251
|
+
|
|
252
|
+
assert.equal(handlerRuns, 1);
|
|
253
|
+
assert.equal(await Job.countDocuments({name: "idempotent-once"}), 1);
|
|
254
|
+
});
|
|
255
|
+
});
|