@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,524 @@
|
|
|
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
|
+
import mongoose from "mongoose";
|
|
6
|
+
|
|
7
|
+
import {JobDispatchError} from "../dispatchError";
|
|
8
|
+
import {JobsApp} from "../jobsApp";
|
|
9
|
+
import {getJobsService} from "../jobsService";
|
|
10
|
+
import {Job} from "../models/job";
|
|
11
|
+
import type {JobDocument} from "../modelTypes";
|
|
12
|
+
import {
|
|
13
|
+
applyVercelQueueExecutionOutcome,
|
|
14
|
+
buildVercelQueueSendOptions,
|
|
15
|
+
clampVercelQueueRetryAfterSeconds,
|
|
16
|
+
computeVercelQueueDelaySeconds,
|
|
17
|
+
computeVercelQueueRetryAfterSeconds,
|
|
18
|
+
createVercelQueuesConsumer,
|
|
19
|
+
resolveVercelQueueConsumerRetry,
|
|
20
|
+
resolveVercelQueueSendRetentionSeconds,
|
|
21
|
+
VERCEL_QUEUE_DEFAULT_RETENTION_SECONDS,
|
|
22
|
+
VERCEL_QUEUE_LOCKED_RETRY_SECONDS,
|
|
23
|
+
VERCEL_QUEUE_MAX_DELAY_SECONDS,
|
|
24
|
+
VERCEL_QUEUE_MAX_VISIBILITY_TIMEOUT_SECONDS,
|
|
25
|
+
VERCEL_QUEUE_MIN_VISIBILITY_TIMEOUT_SECONDS,
|
|
26
|
+
type VercelHandleCallbackFn,
|
|
27
|
+
type VercelHandleCallbackOptions,
|
|
28
|
+
VercelQueueConsumerControl,
|
|
29
|
+
type VercelQueueMessageMetadata,
|
|
30
|
+
type VercelQueueRetryDirective,
|
|
31
|
+
VercelQueueRunAtBeyondLimitError,
|
|
32
|
+
type VercelQueueSendFn,
|
|
33
|
+
type VercelQueueSendOptions,
|
|
34
|
+
VercelQueuesRunner,
|
|
35
|
+
type VercelQueuesRunnerConfig,
|
|
36
|
+
} from "../runners/vercelQueues";
|
|
37
|
+
|
|
38
|
+
interface RecordedSendCall {
|
|
39
|
+
options?: VercelQueueSendOptions;
|
|
40
|
+
payload: unknown;
|
|
41
|
+
topic: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface RecordedHandleCallback {
|
|
45
|
+
handler: (message: unknown, metadata: VercelQueueMessageMetadata) => Promise<void>;
|
|
46
|
+
options?: VercelHandleCallbackOptions;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const sampleMetadata = (): VercelQueueMessageMetadata => ({
|
|
50
|
+
consumerGroup: "default",
|
|
51
|
+
createdAt: DateTime.utc().toJSDate(),
|
|
52
|
+
deliveryCount: 1,
|
|
53
|
+
expiresAt: DateTime.utc().plus({hours: 1}).toJSDate(),
|
|
54
|
+
messageId: "msg-1",
|
|
55
|
+
region: "iad1",
|
|
56
|
+
topicName: "terreno-jobs",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const createFakeSend = (): {calls: RecordedSendCall[]; send: VercelQueueSendFn} => {
|
|
60
|
+
const calls: RecordedSendCall[] = [];
|
|
61
|
+
|
|
62
|
+
const send: VercelQueueSendFn = async (
|
|
63
|
+
topic: string,
|
|
64
|
+
payload: unknown,
|
|
65
|
+
options?: VercelQueueSendOptions
|
|
66
|
+
) => {
|
|
67
|
+
calls.push({options, payload, topic});
|
|
68
|
+
return {messageId: "msg-1"};
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return {calls, send};
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const createFakeHandleCallback = (): {
|
|
75
|
+
getRecorded: () => RecordedHandleCallback | undefined;
|
|
76
|
+
handleCallback: VercelHandleCallbackFn;
|
|
77
|
+
} => {
|
|
78
|
+
let recorded: RecordedHandleCallback | undefined;
|
|
79
|
+
|
|
80
|
+
const handleCallback: VercelHandleCallbackFn = (handler, options) => {
|
|
81
|
+
recorded = {handler, options};
|
|
82
|
+
return async (requestOrEvent: Request | {request: Request}) => {
|
|
83
|
+
const request = requestOrEvent instanceof Request ? requestOrEvent : requestOrEvent.request;
|
|
84
|
+
const message = await request.json();
|
|
85
|
+
try {
|
|
86
|
+
await handler(message, sampleMetadata());
|
|
87
|
+
return new Response("ok", {status: 200});
|
|
88
|
+
} catch (error: unknown) {
|
|
89
|
+
const directive = options?.retry?.(error, sampleMetadata());
|
|
90
|
+
if (directive?.acknowledge === true) {
|
|
91
|
+
return new Response("acknowledged", {status: 200});
|
|
92
|
+
}
|
|
93
|
+
if (directive && "afterSeconds" in directive) {
|
|
94
|
+
return Response.json(directive, {status: 202});
|
|
95
|
+
}
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
getRecorded: () => recorded,
|
|
103
|
+
handleCallback,
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const baseConfig = (overrides?: Partial<VercelQueuesRunnerConfig>): VercelQueuesRunnerConfig => {
|
|
108
|
+
const {send} = createFakeSend();
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
send,
|
|
112
|
+
topic: "terreno-jobs",
|
|
113
|
+
...overrides,
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const buildJob = (overrides?: Partial<JobDocument>): JobDocument =>
|
|
118
|
+
({
|
|
119
|
+
_id: new mongoose.Types.ObjectId(),
|
|
120
|
+
runAt: DateTime.utc().toJSDate(),
|
|
121
|
+
...overrides,
|
|
122
|
+
}) as JobDocument;
|
|
123
|
+
|
|
124
|
+
const registerJobsApp = (jobsApp: JobsApp): void => {
|
|
125
|
+
const app = {
|
|
126
|
+
get: (): typeof app => app,
|
|
127
|
+
post: (): typeof app => app,
|
|
128
|
+
};
|
|
129
|
+
jobsApp.register(app as never);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
describe("VercelQueuesRunner", () => {
|
|
133
|
+
beforeEach(async (): Promise<void> => {
|
|
134
|
+
await setupDb();
|
|
135
|
+
await Job.deleteMany({});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("does not require the execute route and does not implement start", (): void => {
|
|
139
|
+
const runner = new VercelQueuesRunner(baseConfig());
|
|
140
|
+
|
|
141
|
+
assert.equal(runner.requiresExecuteRoute, false);
|
|
142
|
+
assert.isUndefined(runner.start);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("throws when topic is missing or blank", (): void => {
|
|
146
|
+
assert.throws(() => new VercelQueuesRunner(baseConfig({topic: ""})), /topic/i);
|
|
147
|
+
assert.throws(() => new VercelQueuesRunner(baseConfig({topic: " "})), /topic/i);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("throws when retentionSeconds is outside the Vercel 7-day bounds", (): void => {
|
|
151
|
+
assert.throws(
|
|
152
|
+
() => new VercelQueuesRunner(baseConfig({retentionSeconds: 30})),
|
|
153
|
+
/retentionSeconds/i
|
|
154
|
+
);
|
|
155
|
+
assert.throws(
|
|
156
|
+
() =>
|
|
157
|
+
new VercelQueuesRunner(baseConfig({retentionSeconds: VERCEL_QUEUE_MAX_DELAY_SECONDS + 1})),
|
|
158
|
+
/retentionSeconds/i
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("publishes only {jobId} with topic, idempotency key, region, and explicit retention", async (): Promise<void> => {
|
|
163
|
+
const fake = createFakeSend();
|
|
164
|
+
const runner = new VercelQueuesRunner(
|
|
165
|
+
baseConfig({
|
|
166
|
+
region: "sfo1",
|
|
167
|
+
retentionSeconds: 3600,
|
|
168
|
+
send: fake.send,
|
|
169
|
+
topic: "background-jobs",
|
|
170
|
+
})
|
|
171
|
+
);
|
|
172
|
+
const job = buildJob();
|
|
173
|
+
|
|
174
|
+
await runner.enqueue(job);
|
|
175
|
+
|
|
176
|
+
assert.equal(fake.calls.length, 1);
|
|
177
|
+
const call = fake.calls[0];
|
|
178
|
+
assert.equal(call.topic, "background-jobs");
|
|
179
|
+
assert.deepEqual(call.payload, {jobId: job._id.toString()});
|
|
180
|
+
assert.deepEqual(call.options, {
|
|
181
|
+
idempotencyKey: job._id.toString(),
|
|
182
|
+
region: "sfo1",
|
|
183
|
+
retentionSeconds: 3600,
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("defaults retention to 86400 for immediate jobs", async (): Promise<void> => {
|
|
188
|
+
const fake = createFakeSend();
|
|
189
|
+
const runner = new VercelQueuesRunner(baseConfig({send: fake.send}));
|
|
190
|
+
await runner.enqueue(buildJob());
|
|
191
|
+
|
|
192
|
+
assert.equal(fake.calls[0].options?.retentionSeconds, VERCEL_QUEUE_DEFAULT_RETENTION_SECONDS);
|
|
193
|
+
assert.isUndefined(fake.calls[0].options?.delaySeconds);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("bumps retention to cover delaySeconds beyond 24h with a fixed now", (): void => {
|
|
197
|
+
const now = DateTime.fromISO("2026-06-01T12:00:00.000Z", {zone: "utc"});
|
|
198
|
+
const runAt = now.plus({hours: 30});
|
|
199
|
+
const job = buildJob({runAt: runAt.toJSDate()});
|
|
200
|
+
const options = buildVercelQueueSendOptions(job, {}, now);
|
|
201
|
+
|
|
202
|
+
assert.equal(options.delaySeconds, computeVercelQueueDelaySeconds(runAt.toJSDate(), now));
|
|
203
|
+
assert.equal(options.retentionSeconds, options.delaySeconds);
|
|
204
|
+
assert.isAbove(options.retentionSeconds ?? 0, VERCEL_QUEUE_DEFAULT_RETENTION_SECONDS);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("rejects runAt beyond the 7-day Vercel delay window instead of capping delivery", (): void => {
|
|
208
|
+
const now = DateTime.utc();
|
|
209
|
+
assert.throws(
|
|
210
|
+
() => computeVercelQueueDelaySeconds(now.plus({days: 8}).toJSDate(), now),
|
|
211
|
+
VercelQueueRunAtBeyondLimitError
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("compensates dispatch when runAt exceeds the Vercel delay window", async (): Promise<void> => {
|
|
216
|
+
const jobsApp = new JobsApp({
|
|
217
|
+
runner: new VercelQueuesRunner(baseConfig()),
|
|
218
|
+
});
|
|
219
|
+
jobsApp.define("future", {handler: async () => {}});
|
|
220
|
+
registerJobsApp(jobsApp);
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
await getJobsService().enqueue({
|
|
224
|
+
name: "future",
|
|
225
|
+
payload: {x: 1},
|
|
226
|
+
runAt: DateTime.utc().plus({days: 8}).toJSDate(),
|
|
227
|
+
});
|
|
228
|
+
assert.fail("expected enqueue to throw");
|
|
229
|
+
} catch (error: unknown) {
|
|
230
|
+
assert.instanceOf(error, JobDispatchError);
|
|
231
|
+
assert.equal((error as JobDispatchError).compensationSucceeded, true);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
assert.equal(await Job.countDocuments({}), 0);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("propagates send failures with dispatch compensation", async (): Promise<void> => {
|
|
238
|
+
const send: VercelQueueSendFn = async () => {
|
|
239
|
+
throw new Error("queue unavailable");
|
|
240
|
+
};
|
|
241
|
+
const jobsApp = new JobsApp({
|
|
242
|
+
runner: new VercelQueuesRunner(baseConfig({send})),
|
|
243
|
+
});
|
|
244
|
+
jobsApp.define("fail-send", {handler: async () => {}});
|
|
245
|
+
registerJobsApp(jobsApp);
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
await getJobsService().enqueue({name: "fail-send", payload: {}});
|
|
249
|
+
assert.fail("expected enqueue to throw");
|
|
250
|
+
} catch (error: unknown) {
|
|
251
|
+
assert.instanceOf(error, JobDispatchError);
|
|
252
|
+
assert.equal((error as JobDispatchError).compensationSucceeded, true);
|
|
253
|
+
assert.match((error as Error).message, /queue unavailable/i);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
assert.equal(await Job.countDocuments({}), 0);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("computeVercelQueueRetryAfterSeconds", () => {
|
|
261
|
+
const now = DateTime.fromISO("2026-06-01T12:00:00.000Z", {zone: "utc"});
|
|
262
|
+
|
|
263
|
+
it("clamps retry visibility to the vendor 30..3600 second range", (): void => {
|
|
264
|
+
assert.equal(
|
|
265
|
+
clampVercelQueueRetryAfterSeconds(10),
|
|
266
|
+
VERCEL_QUEUE_MIN_VISIBILITY_TIMEOUT_SECONDS
|
|
267
|
+
);
|
|
268
|
+
assert.equal(clampVercelQueueRetryAfterSeconds(30), 30);
|
|
269
|
+
assert.equal(clampVercelQueueRetryAfterSeconds(300), 300);
|
|
270
|
+
assert.equal(clampVercelQueueRetryAfterSeconds(3600), 3600);
|
|
271
|
+
assert.equal(
|
|
272
|
+
clampVercelQueueRetryAfterSeconds(7200),
|
|
273
|
+
VERCEL_QUEUE_MAX_VISIBILITY_TIMEOUT_SECONDS
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("uses seconds until runAt when due within one hour", (): void => {
|
|
278
|
+
assert.equal(computeVercelQueueRetryAfterSeconds(now.plus({minutes: 5}).toJSDate(), now), 300);
|
|
279
|
+
assert.equal(
|
|
280
|
+
computeVercelQueueRetryAfterSeconds(now.plus({seconds: 10}).toJSDate(), now),
|
|
281
|
+
VERCEL_QUEUE_MIN_VISIBILITY_TIMEOUT_SECONDS
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("caps not_due retries at one hour until the job is due", (): void => {
|
|
286
|
+
assert.equal(
|
|
287
|
+
computeVercelQueueRetryAfterSeconds(now.plus({hours: 2}).toJSDate(), now),
|
|
288
|
+
VERCEL_QUEUE_MAX_VISIBILITY_TIMEOUT_SECONDS
|
|
289
|
+
);
|
|
290
|
+
assert.equal(
|
|
291
|
+
computeVercelQueueRetryAfterSeconds(now.plus({hours: 1, seconds: 1}).toJSDate(), now),
|
|
292
|
+
VERCEL_QUEUE_MAX_VISIBILITY_TIMEOUT_SECONDS
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe("resolveVercelQueueSendRetentionSeconds", () => {
|
|
298
|
+
it("keeps retention at or above delaySeconds", (): void => {
|
|
299
|
+
assert.equal(resolveVercelQueueSendRetentionSeconds(undefined, 3600), 3600);
|
|
300
|
+
assert.equal(resolveVercelQueueSendRetentionSeconds(7200, 3600), 7200);
|
|
301
|
+
assert.equal(
|
|
302
|
+
resolveVercelQueueSendRetentionSeconds(7200),
|
|
303
|
+
VERCEL_QUEUE_DEFAULT_RETENTION_SECONDS
|
|
304
|
+
);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
describe("createVercelQueuesConsumer", () => {
|
|
309
|
+
beforeEach(async (): Promise<void> => {
|
|
310
|
+
await setupDb();
|
|
311
|
+
await Job.deleteMany({});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("delegates to the injected handleCallback and returns a Web Response", async (): Promise<void> => {
|
|
315
|
+
const jobsApp = new JobsApp({
|
|
316
|
+
runner: new VercelQueuesRunner(baseConfig()),
|
|
317
|
+
});
|
|
318
|
+
jobsApp.define("noop", {handler: async () => {}});
|
|
319
|
+
registerJobsApp(jobsApp);
|
|
320
|
+
|
|
321
|
+
const fake = createFakeHandleCallback();
|
|
322
|
+
const routeHandler = createVercelQueuesConsumer({
|
|
323
|
+
handleCallback: fake.handleCallback,
|
|
324
|
+
host: jobsApp,
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
assert.isDefined(fake.getRecorded());
|
|
328
|
+
const response = await routeHandler(
|
|
329
|
+
new Request("https://example.com/api/queues/terreno-jobs", {
|
|
330
|
+
body: JSON.stringify({jobId: new mongoose.Types.ObjectId().toString()}),
|
|
331
|
+
headers: {"content-type": "application/json"},
|
|
332
|
+
method: "POST",
|
|
333
|
+
})
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
assert.equal(response.status, 200);
|
|
337
|
+
assert.equal(await response.text(), "acknowledged");
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("ACKs malformed queue payloads through the vendor retry handler", async (): Promise<void> => {
|
|
341
|
+
const jobsApp = new JobsApp({
|
|
342
|
+
runner: new VercelQueuesRunner(baseConfig()),
|
|
343
|
+
});
|
|
344
|
+
jobsApp.define("noop", {handler: async () => {}});
|
|
345
|
+
registerJobsApp(jobsApp);
|
|
346
|
+
|
|
347
|
+
const fake = createFakeHandleCallback();
|
|
348
|
+
const routeHandler = createVercelQueuesConsumer({
|
|
349
|
+
handleCallback: fake.handleCallback,
|
|
350
|
+
host: jobsApp,
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
const response = await routeHandler(
|
|
354
|
+
new Request("https://example.com/api/queues/terreno-jobs", {
|
|
355
|
+
body: JSON.stringify({notJobId: "x"}),
|
|
356
|
+
headers: {"content-type": "application/json"},
|
|
357
|
+
method: "POST",
|
|
358
|
+
})
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
assert.equal(response.status, 200);
|
|
362
|
+
assert.equal(await response.text(), "acknowledged");
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("throws when visibilityTimeoutSeconds is outside the vendor range", (): void => {
|
|
366
|
+
const fake = createFakeHandleCallback();
|
|
367
|
+
assert.throws(
|
|
368
|
+
() =>
|
|
369
|
+
createVercelQueuesConsumer({
|
|
370
|
+
handleCallback: fake.handleCallback,
|
|
371
|
+
host: {
|
|
372
|
+
getDefinition: () => undefined,
|
|
373
|
+
getLockTtlMs: () => 15 * 60 * 1_000,
|
|
374
|
+
},
|
|
375
|
+
visibilityTimeoutSeconds: 10,
|
|
376
|
+
}),
|
|
377
|
+
/visibilityTimeoutSeconds/i
|
|
378
|
+
);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("forwards visibilityTimeoutSeconds to handleCallback", (): void => {
|
|
382
|
+
const fake = createFakeHandleCallback();
|
|
383
|
+
createVercelQueuesConsumer({
|
|
384
|
+
handleCallback: fake.handleCallback,
|
|
385
|
+
host: {
|
|
386
|
+
getDefinition: () => undefined,
|
|
387
|
+
getLockTtlMs: () => 15 * 60 * 1_000,
|
|
388
|
+
},
|
|
389
|
+
visibilityTimeoutSeconds: 900,
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
assert.equal(fake.getRecorded()?.options?.visibilityTimeoutSeconds, 900);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("defaults visibilityTimeoutSeconds to 1800 for the 30-minute execution abort", (): void => {
|
|
396
|
+
const fake = createFakeHandleCallback();
|
|
397
|
+
createVercelQueuesConsumer({
|
|
398
|
+
handleCallback: fake.handleCallback,
|
|
399
|
+
host: {
|
|
400
|
+
getDefinition: () => undefined,
|
|
401
|
+
getLockTtlMs: () => 15 * 60 * 1_000,
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
assert.equal(fake.getRecorded()?.options?.visibilityTimeoutSeconds, 1800);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("executes the referenced job through the JobsApp seam", async (): Promise<void> => {
|
|
409
|
+
let executedPayload: unknown;
|
|
410
|
+
const jobsApp = new JobsApp({
|
|
411
|
+
runner: new VercelQueuesRunner(baseConfig()),
|
|
412
|
+
});
|
|
413
|
+
jobsApp.define("deliver", {
|
|
414
|
+
handler: async (payload) => {
|
|
415
|
+
executedPayload = payload;
|
|
416
|
+
},
|
|
417
|
+
});
|
|
418
|
+
registerJobsApp(jobsApp);
|
|
419
|
+
|
|
420
|
+
const job = await getJobsService().enqueue({
|
|
421
|
+
name: "deliver",
|
|
422
|
+
payload: {userId: "u1"},
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
const fake = createFakeHandleCallback();
|
|
426
|
+
const routeHandler = createVercelQueuesConsumer({
|
|
427
|
+
handleCallback: fake.handleCallback,
|
|
428
|
+
host: jobsApp,
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
const response = await routeHandler(
|
|
432
|
+
new Request("https://example.com/api/queues/terreno-jobs", {
|
|
433
|
+
body: JSON.stringify({jobId: job._id.toString()}),
|
|
434
|
+
headers: {"content-type": "application/json"},
|
|
435
|
+
method: "POST",
|
|
436
|
+
})
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
assert.equal(response.status, 200);
|
|
440
|
+
assert.equal(await response.text(), "ok");
|
|
441
|
+
const stored = await Job.findExactlyOne({_id: job._id});
|
|
442
|
+
assert.equal(stored.status, "completed");
|
|
443
|
+
assert.deepEqual(executedPayload, {userId: "u1"});
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
describe("Vercel queue consumer retry directives", () => {
|
|
448
|
+
it("ACKs malformed payloads and not_found jobs", (): void => {
|
|
449
|
+
assert.deepEqual(
|
|
450
|
+
resolveVercelQueueConsumerRetry(new VercelQueueConsumerControl({acknowledge: true})),
|
|
451
|
+
{acknowledge: true}
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
try {
|
|
455
|
+
applyVercelQueueExecutionOutcome({kind: "not_found"});
|
|
456
|
+
assert.fail("expected control throw");
|
|
457
|
+
} catch (error: unknown) {
|
|
458
|
+
assert.deepEqual(resolveVercelQueueConsumerRetry(error), {acknowledge: true});
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it("retries live-lock conflicts after a bounded delay", (): void => {
|
|
463
|
+
try {
|
|
464
|
+
applyVercelQueueExecutionOutcome({kind: "conflict", reason: "locked"});
|
|
465
|
+
assert.fail("expected control throw");
|
|
466
|
+
} catch (error: unknown) {
|
|
467
|
+
assert.deepEqual(resolveVercelQueueConsumerRetry(error), {
|
|
468
|
+
afterSeconds: VERCEL_QUEUE_LOCKED_RETRY_SECONDS,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("retries not_due conflicts aligned to runAt within visibility bounds", (): void => {
|
|
474
|
+
const now = DateTime.utc();
|
|
475
|
+
const runAt = now.plus({minutes: 5}).toJSDate();
|
|
476
|
+
try {
|
|
477
|
+
applyVercelQueueExecutionOutcome({kind: "conflict", reason: "not_due", runAt});
|
|
478
|
+
assert.fail("expected control throw");
|
|
479
|
+
} catch (error: unknown) {
|
|
480
|
+
const directive = resolveVercelQueueConsumerRetry(error) as VercelQueueRetryDirective;
|
|
481
|
+
const expected = computeVercelQueueRetryAfterSeconds(runAt, now);
|
|
482
|
+
assert.equal((directive as {afterSeconds: number}).afterSeconds, expected);
|
|
483
|
+
assert.isAtMost((directive as {afterSeconds: number}).afterSeconds, 300);
|
|
484
|
+
assert.isAtLeast((directive as {afterSeconds: number}).afterSeconds, 290);
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("retries handler failures that leave the job pending at Terreno runAt", (): void => {
|
|
489
|
+
const now = DateTime.utc();
|
|
490
|
+
const runAt = now.plus({minutes: 2}).toJSDate();
|
|
491
|
+
const job = buildJob({runAt, status: "pending"});
|
|
492
|
+
|
|
493
|
+
try {
|
|
494
|
+
applyVercelQueueExecutionOutcome({job, kind: "executed"});
|
|
495
|
+
assert.fail("expected control throw");
|
|
496
|
+
} catch (error: unknown) {
|
|
497
|
+
const directive = resolveVercelQueueConsumerRetry(error) as VercelQueueRetryDirective;
|
|
498
|
+
assert.equal(
|
|
499
|
+
(directive as {afterSeconds: number}).afterSeconds,
|
|
500
|
+
computeVercelQueueRetryAfterSeconds(runAt, now)
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("retries executed pending jobs with runAt in the past using the vendor minimum", (): void => {
|
|
506
|
+
const runAt = DateTime.utc().minus({minutes: 1}).toJSDate();
|
|
507
|
+
const job = buildJob({runAt, status: "pending"});
|
|
508
|
+
|
|
509
|
+
try {
|
|
510
|
+
applyVercelQueueExecutionOutcome({job, kind: "executed"});
|
|
511
|
+
assert.fail("expected control throw");
|
|
512
|
+
} catch (error: unknown) {
|
|
513
|
+
assert.deepEqual(resolveVercelQueueConsumerRetry(error), {
|
|
514
|
+
afterSeconds: VERCEL_QUEUE_MIN_VISIBILITY_TIMEOUT_SECONDS,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it("ACKs terminal duplicate deliveries without retry directives", (): void => {
|
|
520
|
+
const completedJob = buildJob({status: "completed"});
|
|
521
|
+
applyVercelQueueExecutionOutcome({job: completedJob, kind: "noop"});
|
|
522
|
+
applyVercelQueueExecutionOutcome({job: buildJob({status: "dead"}), kind: "noop"});
|
|
523
|
+
});
|
|
524
|
+
});
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type {ScopedLogger} from "@terreno/api";
|
|
2
|
+
|
|
3
|
+
import type {JobDocument} from "./modelTypes";
|
|
4
|
+
|
|
5
|
+
export interface JobsAdminPayloadViewInput {
|
|
6
|
+
name: string;
|
|
7
|
+
payload: unknown;
|
|
8
|
+
payloadRedacted: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Projects job payload for admin list/detail responses. Omit/return undefined to hide payload. */
|
|
12
|
+
export type JobsAdminRedactPayload = (input: JobsAdminPayloadViewInput) => unknown | undefined;
|
|
13
|
+
|
|
14
|
+
export interface JobHandlerContext {
|
|
15
|
+
jobId: string;
|
|
16
|
+
log: ScopedLogger;
|
|
17
|
+
signal: AbortSignal;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface JobScheduleDefinition {
|
|
21
|
+
cron: string;
|
|
22
|
+
timezone?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface JobDefinition {
|
|
26
|
+
handler: (payload: unknown, ctx: JobHandlerContext) => Promise<void>;
|
|
27
|
+
retry?: {
|
|
28
|
+
backoffMs?: number;
|
|
29
|
+
maxAttempts?: number;
|
|
30
|
+
maxBackoffMs?: number;
|
|
31
|
+
};
|
|
32
|
+
schedule?: JobScheduleDefinition;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface EnqueueJobParams {
|
|
36
|
+
idempotencyKey?: string;
|
|
37
|
+
name: string;
|
|
38
|
+
payload: unknown;
|
|
39
|
+
runAt?: Date;
|
|
40
|
+
scheduleId?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface JobRunnerStartOptions {
|
|
44
|
+
/** Host surface passed to runners so they can resolve handlers and schedules without importing internals. */
|
|
45
|
+
jobs: JobsRunnerHost;
|
|
46
|
+
pollIntervalMs?: number;
|
|
47
|
+
signal: AbortSignal;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Public host surface for {@link JobRunner.start} — implemented by {@link JobsApp}. */
|
|
51
|
+
export interface JobsRunnerHost {
|
|
52
|
+
getDefaultTimezone(): string;
|
|
53
|
+
getDefinition(name: string): JobDefinition | undefined;
|
|
54
|
+
getLockTtlMs(): number;
|
|
55
|
+
reconcileSchedules(): Promise<void>;
|
|
56
|
+
reconcileSchedulesIfDirty(): Promise<void>;
|
|
57
|
+
tickSchedules(now?: Date): Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Pluggable job dispatch and worker lifecycle.
|
|
62
|
+
*
|
|
63
|
+
* {@link enqueue} is invoked only after a **new** job row is persisted; idempotent
|
|
64
|
+
* dedupe returns the existing row without redispatching. {@link MongoJobRunner} is
|
|
65
|
+
* the default — its {@link enqueue} is a no-op because the poll loop claims Mongo rows.
|
|
66
|
+
*/
|
|
67
|
+
export interface JobRunner {
|
|
68
|
+
readonly id: string;
|
|
69
|
+
/** When true, {@link JobsApp} mounts the internal execute HTTP route (requires {@link JobsAppOptions.executeAuth}). */
|
|
70
|
+
readonly requiresExecuteRoute?: boolean;
|
|
71
|
+
enqueue(job: JobDocument): Promise<void>;
|
|
72
|
+
start?(options: JobRunnerStartOptions): Promise<void>;
|
|
73
|
+
stop?(): Promise<void>;
|
|
74
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"allowJs": true,
|
|
4
|
+
"allowSyntheticDefaultImports": true,
|
|
5
|
+
"baseUrl": "./",
|
|
6
|
+
"declaration": true,
|
|
7
|
+
"downlevelIteration": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"ignoreDeprecations": "6.0",
|
|
11
|
+
"jsx": "react",
|
|
12
|
+
"lib": ["es2017", "dom", "es2017.object"],
|
|
13
|
+
"module": "commonjs",
|
|
14
|
+
"moduleResolution": "node",
|
|
15
|
+
"noEmit": false,
|
|
16
|
+
"noImplicitAny": false,
|
|
17
|
+
"noImplicitReturns": true,
|
|
18
|
+
"noImplicitThis": true,
|
|
19
|
+
"noUnusedLocals": false,
|
|
20
|
+
"noUnusedParameters": false,
|
|
21
|
+
"outDir": "./dist",
|
|
22
|
+
"rootDir": "./src",
|
|
23
|
+
"preserveConstEnums": true,
|
|
24
|
+
"removeComments": false,
|
|
25
|
+
"skipLibCheck": true,
|
|
26
|
+
"strict": true,
|
|
27
|
+
"strictFunctionTypes": true,
|
|
28
|
+
"strictNullChecks": true,
|
|
29
|
+
"target": "es5"
|
|
30
|
+
},
|
|
31
|
+
"exclude": ["node_modules", "src/tests", "**/*.test.ts"],
|
|
32
|
+
"include": ["src/**/*.ts*", "src/*.ts"]
|
|
33
|
+
}
|