@12-apps/jobs 2.0.0 → 4.0.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/ADOPTING.md +199 -26
- package/README.md +80 -23
- package/package.json +2 -1
- package/src/core/events.ts +34 -0
- package/src/core/queues.ts +25 -15
- package/src/core/registry.ts +168 -6
- package/src/core/retention.ts +68 -0
- package/src/core/runtime.ts +35 -10
- package/src/core/types.ts +120 -27
- package/src/drivers/bullmq-policy.ts +86 -0
- package/src/drivers/bullmq.ts +101 -46
- package/src/drivers/inline.ts +76 -41
- package/src/hono/index.ts +4 -3
- package/src/index.ts +35 -9
- package/src/server/config.ts +83 -12
- package/src/server/create-api-jobs.ts +43 -19
- package/src/server/index.ts +9 -1
- package/src/server/resolve-driver.ts +22 -14
package/src/drivers/bullmq.ts
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
* never the ledger. Payloads carry ids (see `JobDefinition`'s payload rule),
|
|
8
8
|
* every handler re-reads its row, and every job is paired with a durable table
|
|
9
9
|
* a sweep can re-derive the work from. Losing Redis therefore costs a delayed
|
|
10
|
-
* run — not a lost
|
|
10
|
+
* run — not a lost write, and not a double one.
|
|
11
11
|
*
|
|
12
12
|
* That split is what makes the enqueue-outside-the-transaction problem
|
|
13
|
-
* survivable. A
|
|
13
|
+
* survivable. A database commit followed by an enqueue is not atomic: crash in
|
|
14
14
|
* between and the job never lands. The paired sweep is the answer — the row is
|
|
15
15
|
* committed, so the next tick finds it.
|
|
16
16
|
*
|
|
@@ -25,32 +25,29 @@
|
|
|
25
25
|
|
|
26
26
|
import { Queue, UnrecoverableError, Worker, type JobsOptions } from "bullmq";
|
|
27
27
|
|
|
28
|
+
import { createEventEmitter, type EmitJobEvent } from "../core/events";
|
|
29
|
+
import { DEFAULT_QUEUE } from "../core/queues";
|
|
30
|
+
import { resolveRegisteredJob } from "../core/registry";
|
|
28
31
|
import type {
|
|
29
32
|
AnyJobDefinition,
|
|
30
33
|
EnqueueOptions,
|
|
31
34
|
EnqueueResult,
|
|
32
35
|
JobContext,
|
|
33
36
|
JobDriver,
|
|
37
|
+
JobEvents,
|
|
34
38
|
JobLogger,
|
|
39
|
+
JobRetention,
|
|
35
40
|
} from "../core/types";
|
|
36
41
|
|
|
42
|
+
import {
|
|
43
|
+
DEFAULT_CONCURRENCY,
|
|
44
|
+
DEFAULT_JOB_RETENTION,
|
|
45
|
+
isTerminalFailure,
|
|
46
|
+
resolveConcurrency,
|
|
47
|
+
retentionOptions,
|
|
48
|
+
} from "./bullmq-policy";
|
|
37
49
|
import { parseRedisUrl, type RedisConnectionOptions } from "./redis-url";
|
|
38
50
|
|
|
39
|
-
/** The queue a definition lands on when it does not name one. */
|
|
40
|
-
const DEFAULT_QUEUE = "default";
|
|
41
|
-
/** Worker concurrency when no definition in the queue asks for more. */
|
|
42
|
-
const DEFAULT_CONCURRENCY = 5;
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Retention. Bounded on purpose: an unbounded completed-set is the classic way
|
|
46
|
-
* a small Redis fills up and starts refusing writes. A day of successes is
|
|
47
|
-
* enough to answer "did it run?"; a week of failures is enough to debug one.
|
|
48
|
-
*/
|
|
49
|
-
const RETENTION: Pick<JobsOptions, "removeOnComplete" | "removeOnFail"> = {
|
|
50
|
-
removeOnComplete: { age: 24 * 3600, count: 1_000 },
|
|
51
|
-
removeOnFail: { age: 7 * 24 * 3600, count: 5_000 },
|
|
52
|
-
};
|
|
53
|
-
|
|
54
51
|
/** The one ioredis command this driver reads outside BullMQ's own surface. */
|
|
55
52
|
interface RedisConfigReader {
|
|
56
53
|
config(command: "GET", parameter: string): Promise<unknown>;
|
|
@@ -65,6 +62,12 @@ export interface BullMqJobDriverOptions {
|
|
|
65
62
|
* worker consuming production's jobs.
|
|
66
63
|
*/
|
|
67
64
|
prefix?: string;
|
|
65
|
+
/** Defaults to {@link DEFAULT_JOB_RETENTION}. */
|
|
66
|
+
retention?: JobRetention;
|
|
67
|
+
/** Per-queue concurrency when no definition on the queue states one. */
|
|
68
|
+
defaultConcurrency?: number;
|
|
69
|
+
/** Where completions, dead-letters and removed schedules are reported. */
|
|
70
|
+
events?: JobEvents;
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
/** Everything the driver's helpers need, threaded instead of closed over. */
|
|
@@ -72,18 +75,23 @@ interface DriverState {
|
|
|
72
75
|
connection: RedisConnectionOptions;
|
|
73
76
|
logger: JobLogger;
|
|
74
77
|
prefix?: string;
|
|
78
|
+
retention: Pick<JobsOptions, "removeOnComplete" | "removeOnFail">;
|
|
79
|
+
defaultConcurrency: number;
|
|
80
|
+
/** Reports to the host's observer; see `core/events`. Never throws. */
|
|
81
|
+
emit: EmitJobEvent;
|
|
75
82
|
queues: Map<string, Queue>;
|
|
76
83
|
workers: Worker[];
|
|
77
84
|
}
|
|
78
85
|
|
|
79
86
|
/** Turn a definition's retry policy into BullMQ's per-job options. */
|
|
80
87
|
function jobOptionsFor(
|
|
88
|
+
state: DriverState,
|
|
81
89
|
definition: AnyJobDefinition,
|
|
82
90
|
enqueueOptions: EnqueueOptions,
|
|
83
91
|
): JobsOptions {
|
|
84
92
|
const options: JobsOptions = {
|
|
85
93
|
attempts: Math.max(1, definition.attempts ?? 1),
|
|
86
|
-
...
|
|
94
|
+
...state.retention,
|
|
87
95
|
};
|
|
88
96
|
if (definition.backoff) {
|
|
89
97
|
options.backoff = {
|
|
@@ -108,7 +116,7 @@ function queueFor(state: DriverState, name: string): Queue {
|
|
|
108
116
|
const queue = new Queue(name, {
|
|
109
117
|
connection: state.connection,
|
|
110
118
|
...(state.prefix ? { prefix: state.prefix } : {}),
|
|
111
|
-
defaultJobOptions:
|
|
119
|
+
defaultJobOptions: state.retention,
|
|
112
120
|
});
|
|
113
121
|
// Without a listener an emitted 'error' is an unhandled exception that takes
|
|
114
122
|
// the process down — a Redis blip must not kill the web server.
|
|
@@ -165,7 +173,7 @@ async function reconcileSchedules(
|
|
|
165
173
|
await queue.upsertJobScheduler(
|
|
166
174
|
name,
|
|
167
175
|
{ pattern: schedule.pattern, tz: schedule.timezone ?? "UTC" },
|
|
168
|
-
{ name, data: {}, opts: jobOptionsFor(job, {}) },
|
|
176
|
+
{ name, data: {}, opts: jobOptionsFor(state, job, {}) },
|
|
169
177
|
);
|
|
170
178
|
state.logger.info(
|
|
171
179
|
`schedule installed: ${name} (${schedule.pattern} ${schedule.timezone ?? "UTC"})`,
|
|
@@ -178,41 +186,37 @@ async function reconcileSchedules(
|
|
|
178
186
|
state.logger.warn(
|
|
179
187
|
`removed stale schedule "${scheduler.key}" (no longer defined in code).`,
|
|
180
188
|
);
|
|
189
|
+
// The destructive half of the reconcile, and the one worth auditing: a
|
|
190
|
+
// deploy just cancelled a recurring job, permanently, from the queue's
|
|
191
|
+
// point of view.
|
|
192
|
+
state.emit((events) =>
|
|
193
|
+
events.onScheduleRemoved?.({ name: scheduler.key, queue: queue.name }),
|
|
194
|
+
);
|
|
181
195
|
}
|
|
182
196
|
}
|
|
183
197
|
|
|
184
|
-
/**
|
|
185
|
-
* A queue's worker concurrency.
|
|
186
|
-
*
|
|
187
|
-
* The default applies only when NO definition on the queue asked for a
|
|
188
|
-
* specific number. A stated `concurrency: 1` means single-flight and must be
|
|
189
|
-
* honoured — taking `max(default, …)` would quietly raise it back to the
|
|
190
|
-
* default and undo the very property the caller asked for.
|
|
191
|
-
*/
|
|
192
|
-
function resolveConcurrency(group: readonly AnyJobDefinition[]): number {
|
|
193
|
-
const stated = group
|
|
194
|
-
.map((definition) => definition.concurrency)
|
|
195
|
-
.filter((value): value is number => typeof value === "number" && value > 0);
|
|
196
|
-
return stated.length > 0 ? Math.max(...stated) : DEFAULT_CONCURRENCY;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
198
|
/** Consume one queue, dispatching by job name to the definitions it carries. */
|
|
200
199
|
function startWorker(
|
|
201
200
|
state: DriverState,
|
|
202
201
|
queueName: string,
|
|
203
202
|
group: readonly AnyJobDefinition[],
|
|
204
203
|
): Worker {
|
|
205
|
-
const
|
|
206
|
-
const concurrency = resolveConcurrency(group);
|
|
204
|
+
const onThisQueue = new Set(group.map((definition) => definition.name));
|
|
205
|
+
const concurrency = resolveConcurrency(group, state.defaultConcurrency);
|
|
207
206
|
|
|
208
207
|
const worker = new Worker(
|
|
209
208
|
queueName,
|
|
210
209
|
async (job) => {
|
|
211
|
-
|
|
210
|
+
// THE GATE, execution side — `resolveRegisteredJob` is the same function
|
|
211
|
+
// the enqueue path calls, so the two cannot come to disagree about what
|
|
212
|
+
// a runnable job is. The queue membership check on top of it keeps a job
|
|
213
|
+
// from being run by another queue's worker.
|
|
214
|
+
const registered = resolveRegisteredJob(job.name);
|
|
215
|
+
const definition = registered && onThisQueue.has(job.name) ? registered : undefined;
|
|
212
216
|
if (!definition) {
|
|
213
|
-
// A job left in
|
|
214
|
-
// Retrying cannot help, so fail it terminally instead of burning
|
|
215
|
-
// attempt on it.
|
|
217
|
+
// A job left in the backend by a previous deploy whose handler is
|
|
218
|
+
// gone. Retrying cannot help, so fail it terminally instead of burning
|
|
219
|
+
// every attempt on it.
|
|
216
220
|
throw new UnrecoverableError(`No handler registered for job "${job.name}".`);
|
|
217
221
|
}
|
|
218
222
|
const context: JobContext = {
|
|
@@ -222,6 +226,15 @@ function startWorker(
|
|
|
222
226
|
logger: state.logger,
|
|
223
227
|
};
|
|
224
228
|
await definition.handle(job.data as never, context);
|
|
229
|
+
state.emit((events) =>
|
|
230
|
+
events.onJobCompleted?.({
|
|
231
|
+
name: job.name,
|
|
232
|
+
queue: queueName,
|
|
233
|
+
runId: context.runId,
|
|
234
|
+
attempt: context.attempt,
|
|
235
|
+
maxAttempts: context.maxAttempts,
|
|
236
|
+
}),
|
|
237
|
+
);
|
|
225
238
|
},
|
|
226
239
|
{
|
|
227
240
|
connection: state.connection,
|
|
@@ -231,11 +244,25 @@ function startWorker(
|
|
|
231
244
|
);
|
|
232
245
|
|
|
233
246
|
worker.on("failed", (job, error) => {
|
|
234
|
-
const
|
|
247
|
+
const maxAttempts = job?.opts.attempts ?? 1;
|
|
248
|
+
const attempts = job ? `${job.attemptsMade}/${maxAttempts}` : "?";
|
|
235
249
|
state.logger.error(
|
|
236
250
|
`job "${job?.name ?? queueName}" failed (attempt ${attempts}):`,
|
|
237
251
|
error,
|
|
238
252
|
);
|
|
253
|
+
if (!job) return;
|
|
254
|
+
const terminal = isTerminalFailure(job.attemptsMade, maxAttempts, error);
|
|
255
|
+
state.emit((events) =>
|
|
256
|
+
events.onJobFailed?.({
|
|
257
|
+
name: job.name,
|
|
258
|
+
queue: queueName,
|
|
259
|
+
runId: job.id ?? `${job.name}:unknown`,
|
|
260
|
+
attempt: job.attemptsMade,
|
|
261
|
+
maxAttempts,
|
|
262
|
+
error,
|
|
263
|
+
terminal,
|
|
264
|
+
}),
|
|
265
|
+
);
|
|
239
266
|
});
|
|
240
267
|
worker.on("error", (error) =>
|
|
241
268
|
state.logger.error(`worker "${queueName}" error:`, error),
|
|
@@ -262,6 +289,12 @@ export function createBullMqJobDriver(options: BullMqJobDriverOptions): JobDrive
|
|
|
262
289
|
connection: parseRedisUrl(options.redisUrl),
|
|
263
290
|
logger: options.logger,
|
|
264
291
|
prefix: options.prefix,
|
|
292
|
+
retention: retentionOptions(options.retention ?? DEFAULT_JOB_RETENTION),
|
|
293
|
+
defaultConcurrency:
|
|
294
|
+
typeof options.defaultConcurrency === "number" && options.defaultConcurrency > 0
|
|
295
|
+
? options.defaultConcurrency
|
|
296
|
+
: DEFAULT_CONCURRENCY,
|
|
297
|
+
emit: createEventEmitter(options.events, options.logger),
|
|
265
298
|
queues: new Map(),
|
|
266
299
|
workers: [],
|
|
267
300
|
};
|
|
@@ -270,8 +303,22 @@ export function createBullMqJobDriver(options: BullMqJobDriverOptions): JobDrive
|
|
|
270
303
|
kind: "bullmq",
|
|
271
304
|
|
|
272
305
|
async enqueue(definition, payload, enqueueOptions): Promise<EnqueueResult> {
|
|
306
|
+
// The same gate as the worker's dispatch, at the other end of the wire:
|
|
307
|
+
// a name this deployment cannot run must not be written to Redis, where
|
|
308
|
+
// it would sit until a consumer dead-lettered it. Reached directly only
|
|
309
|
+
// by a host that built the driver itself — `enqueueJob` refuses first.
|
|
310
|
+
if (!resolveRegisteredJob(definition)) {
|
|
311
|
+
state.logger.error(
|
|
312
|
+
`refused to enqueue "${definition.name}": it is not the job registered under that name.`,
|
|
313
|
+
);
|
|
314
|
+
return { enqueued: false, reason: "unregistered" };
|
|
315
|
+
}
|
|
273
316
|
const queue = queueFor(state, definition.queue ?? DEFAULT_QUEUE);
|
|
274
|
-
await queue.add(
|
|
317
|
+
await queue.add(
|
|
318
|
+
definition.name,
|
|
319
|
+
payload,
|
|
320
|
+
jobOptionsFor(state, definition, enqueueOptions),
|
|
321
|
+
);
|
|
275
322
|
return { enqueued: true };
|
|
276
323
|
},
|
|
277
324
|
|
|
@@ -296,7 +343,15 @@ export function createBullMqJobDriver(options: BullMqJobDriverOptions): JobDrive
|
|
|
296
343
|
}
|
|
297
344
|
|
|
298
345
|
/**
|
|
299
|
-
*
|
|
300
|
-
*
|
|
346
|
+
* The policy rules, re-exposed for tests through the driver's own subpath.
|
|
347
|
+
*
|
|
348
|
+
* Both are SILENT when wrong — a queue that quietly runs at the default
|
|
349
|
+
* instead of single-flight, and a dead-letter that quietly reports itself as
|
|
350
|
+
* one more retry — so they are pinned directly rather than inferred from a
|
|
351
|
+
* live Worker, which would need a Redis to exist.
|
|
301
352
|
*/
|
|
302
|
-
export const __testables = {
|
|
353
|
+
export const __testables = {
|
|
354
|
+
resolveConcurrency,
|
|
355
|
+
isTerminalFailure,
|
|
356
|
+
DEFAULT_CONCURRENCY,
|
|
357
|
+
};
|
package/src/drivers/inline.ts
CHANGED
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The INLINE driver: run the handler in the calling process, immediately.
|
|
3
3
|
*
|
|
4
|
-
* For unit and integration tests (which
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* For unit and integration tests (which must not need a queue backend running)
|
|
5
|
+
* and for local development with no container. It is a real driver, not a stub
|
|
6
|
+
* — `attempts` are honoured so retry behaviour stays testable — but three
|
|
7
|
+
* things it deliberately does NOT do:
|
|
8
8
|
*
|
|
9
9
|
* - **No delay.** `delayMs` is ignored and logged. A test asserting a
|
|
10
|
-
* three-day
|
|
10
|
+
* three-day gap should assert on the enqueue, not on the clock.
|
|
11
11
|
* - **No schedules.** A cron job is registered but never fires; drive it
|
|
12
12
|
* directly in a test by calling the handler.
|
|
13
13
|
* - **No backoff waits.** Retries happen back-to-back so suites stay fast.
|
|
14
14
|
*
|
|
15
15
|
* Which is why it must never reach production: work would run inside the
|
|
16
|
-
* request that scheduled it, and a crash between the two would lose it.
|
|
17
|
-
*
|
|
16
|
+
* request that scheduled it, and a crash between the two would lose it.
|
|
17
|
+
* `createApiJobs` refuses it there, by name and by instance.
|
|
18
|
+
*
|
|
19
|
+
* It also does no registry lookup, so it has no write/run gate of its own to
|
|
20
|
+
* keep in step: it runs the definition OBJECT it was handed, not a name it
|
|
21
|
+
* resolves. `enqueueJob` applies the registry gate before the driver is
|
|
22
|
+
* reached; a test calling `driver.enqueue(definition, …)` directly is
|
|
23
|
+
* deliberately below that line.
|
|
18
24
|
*/
|
|
19
25
|
|
|
20
26
|
import type {
|
|
@@ -23,9 +29,13 @@ import type {
|
|
|
23
29
|
EnqueueResult,
|
|
24
30
|
JobContext,
|
|
25
31
|
JobDriver,
|
|
32
|
+
JobEvents,
|
|
26
33
|
JobLogger,
|
|
27
34
|
} from "../core/types";
|
|
28
35
|
|
|
36
|
+
import { createEventEmitter, type EmitJobEvent } from "../core/events";
|
|
37
|
+
import { DEFAULT_QUEUE } from "../core/queues";
|
|
38
|
+
|
|
29
39
|
export interface InlineJobDriverOptions {
|
|
30
40
|
logger?: JobLogger;
|
|
31
41
|
/**
|
|
@@ -34,6 +44,12 @@ export interface InlineJobDriverOptions {
|
|
|
34
44
|
* sees the side effect.
|
|
35
45
|
*/
|
|
36
46
|
await?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Where completions and dead-letters are reported. The same port the BullMQ
|
|
49
|
+
* driver takes, so a host's observer is exercised by its test suite instead
|
|
50
|
+
* of only in production.
|
|
51
|
+
*/
|
|
52
|
+
events?: JobEvents;
|
|
37
53
|
}
|
|
38
54
|
|
|
39
55
|
/** A record of what ran, for assertions. */
|
|
@@ -51,45 +67,64 @@ export interface InlineJobDriver extends JobDriver {
|
|
|
51
67
|
clearRuns(): void;
|
|
52
68
|
}
|
|
53
69
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
70
|
+
/** What one inline execution needs from the driver that owns it. */
|
|
71
|
+
interface InlineRunner {
|
|
72
|
+
logger: JobLogger;
|
|
73
|
+
runs: InlineJobRun[];
|
|
74
|
+
emit: EmitJobEvent;
|
|
75
|
+
}
|
|
60
76
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Run one job to its conclusion, retrying up to `attempts` with no waits.
|
|
79
|
+
*
|
|
80
|
+
* Hoisted out of the factory so the factory stays a wiring function: the
|
|
81
|
+
* attempt loop is the part with the rules in it, and it reads better with its
|
|
82
|
+
* inputs named than closed over.
|
|
83
|
+
*/
|
|
84
|
+
async function runInline(
|
|
85
|
+
runner: InlineRunner,
|
|
86
|
+
definition: AnyJobDefinition,
|
|
87
|
+
payload: unknown,
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
const { logger, runs, emit } = runner;
|
|
90
|
+
const maxAttempts = Math.max(1, definition.attempts ?? 1);
|
|
91
|
+
const queue = definition.queue ?? DEFAULT_QUEUE;
|
|
92
|
+
const record: InlineJobRun = { name: definition.name, payload, attempts: 0 };
|
|
93
|
+
runs.push(record);
|
|
68
94
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
95
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
96
|
+
record.attempts = attempt;
|
|
97
|
+
const runId = `inline:${definition.name}:${runs.length}:${attempt}`;
|
|
98
|
+
const context: JobContext = { runId, attempt, maxAttempts, logger };
|
|
99
|
+
const event = { name: definition.name, queue, runId, attempt, maxAttempts };
|
|
100
|
+
try {
|
|
101
|
+
await definition.handle(payload as never, context);
|
|
102
|
+
record.error = undefined;
|
|
103
|
+
emit((events) => events.onJobCompleted?.(event));
|
|
104
|
+
return;
|
|
105
|
+
} catch (error) {
|
|
106
|
+
record.error = error;
|
|
107
|
+
const terminal = attempt === maxAttempts;
|
|
108
|
+
emit((events) => events.onJobFailed?.({ ...event, error, terminal }));
|
|
109
|
+
if (terminal) {
|
|
110
|
+
logger.error(`"${definition.name}" failed after ${attempt} attempt(s):`, error);
|
|
80
111
|
return;
|
|
81
|
-
} catch (error) {
|
|
82
|
-
record.error = error;
|
|
83
|
-
if (attempt === maxAttempts) {
|
|
84
|
-
logger.error(
|
|
85
|
-
`"${definition.name}" failed after ${attempt} attempt(s):`,
|
|
86
|
-
error,
|
|
87
|
-
);
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
112
|
}
|
|
91
113
|
}
|
|
92
114
|
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function createInlineJobDriver(
|
|
118
|
+
options: InlineJobDriverOptions = {},
|
|
119
|
+
): InlineJobDriver {
|
|
120
|
+
const logger = options.logger ?? console;
|
|
121
|
+
const awaitHandlers = options.await ?? true;
|
|
122
|
+
const runs: InlineJobRun[] = [];
|
|
123
|
+
const runner: InlineRunner = {
|
|
124
|
+
logger,
|
|
125
|
+
runs,
|
|
126
|
+
emit: createEventEmitter(options.events, logger),
|
|
127
|
+
};
|
|
93
128
|
|
|
94
129
|
return {
|
|
95
130
|
kind: "inline",
|
|
@@ -107,7 +142,7 @@ export function createInlineJobDriver(
|
|
|
107
142
|
`inline driver ignored a ${enqueueOptions.delayMs}ms delay on "${definition.name}".`,
|
|
108
143
|
);
|
|
109
144
|
}
|
|
110
|
-
const execution =
|
|
145
|
+
const execution = runInline(runner, definition, payload);
|
|
111
146
|
if (awaitHandlers) await execution;
|
|
112
147
|
else void execution;
|
|
113
148
|
return { enqueued: true };
|
package/src/hono/index.ts
CHANGED
|
@@ -21,9 +21,10 @@ import type { JobsRoute } from "../server/create-api-jobs";
|
|
|
21
21
|
* must call `start()` on the same instance whose routes report health, and a
|
|
22
22
|
* router that built its own would answer for a runtime nobody started.
|
|
23
23
|
*
|
|
24
|
-
* Auth stays the host's: mount
|
|
25
|
-
* internal probes live behind
|
|
26
|
-
*
|
|
24
|
+
* Auth stays the host's: mount it under whatever guard the deployment's
|
|
25
|
+
* internal probes live behind — a prefix answered only machine-to-machine,
|
|
26
|
+
* with the reverse proxy refusing it from the internet, is the usual shape.
|
|
27
|
+
* This package holds zero authorization logic.
|
|
27
28
|
*/
|
|
28
29
|
export function jobsRouter(api: { routes: JobsRoute[] }): Hono {
|
|
29
30
|
const app = new Hono();
|
package/src/index.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@12-apps/jobs` — typed background jobs with retries, backoff and cron,
|
|
3
|
-
* a swappable driver.
|
|
2
|
+
* `@12-apps/jobs` — typed background jobs with retries, backoff and cron,
|
|
3
|
+
* behind a swappable driver.
|
|
4
4
|
*
|
|
5
5
|
* // where the domain lives
|
|
6
|
-
* export const
|
|
7
|
-
* name: "
|
|
6
|
+
* export const renderReport = defineJob<{ reportId: string }>({
|
|
7
|
+
* name: "reports.render",
|
|
8
8
|
* attempts: 5,
|
|
9
9
|
* backoff: { type: "exponential", delayMs: 5_000 },
|
|
10
|
-
* handle: async ({
|
|
10
|
+
* handle: async ({ reportId }) => renderAndStore(reportId),
|
|
11
11
|
* });
|
|
12
12
|
*
|
|
13
13
|
* // at an emit site
|
|
14
|
-
* await
|
|
14
|
+
* await renderReport.enqueue({ reportId }, { dedupeKey: reportId });
|
|
15
15
|
*
|
|
16
16
|
* // at process start
|
|
17
17
|
* configureJobs({ driver: createBullMqJobDriver({ redisUrl, logger }), logger });
|
|
@@ -30,14 +30,29 @@
|
|
|
30
30
|
* `createApiJobs` in `@12-apps/jobs/server` (mount it with
|
|
31
31
|
* `@12-apps/jobs/hono` or your own adapter). What this root adds to it:
|
|
32
32
|
*
|
|
33
|
-
* - `SWEEP_QUEUE` —
|
|
33
|
+
* - `DEFAULT_QUEUE` / `SWEEP_QUEUE` — this package's own queue vocabulary,
|
|
34
|
+
* `SWEEP_QUEUE` being the single-flight queue the scheduled sweeps share.
|
|
34
35
|
* - `createSweepLease` — the named, time-bounded claim that keeps a sweep
|
|
35
36
|
* to ONE pass per tick across a multi-worker deployment. Its `SweepLease`
|
|
36
37
|
* table ships in `prisma/jobs.prisma` with its migration; the host syncs
|
|
37
38
|
* both (see ADOPTING.md).
|
|
39
|
+
*
|
|
40
|
+
* Every guard this package has lives on THIS path as well as on the factory:
|
|
41
|
+
* `defineJob` refuses a definition that cannot run, `startJobWorkers` refuses
|
|
42
|
+
* an empty registry, and `enqueueJob` refuses a job the registry does not
|
|
43
|
+
* hold. A host that wires the runtime by hand is not a host with fewer checks.
|
|
38
44
|
*/
|
|
39
45
|
|
|
40
|
-
export {
|
|
46
|
+
export {
|
|
47
|
+
defineJob,
|
|
48
|
+
findJob,
|
|
49
|
+
listJobs,
|
|
50
|
+
clearJobs,
|
|
51
|
+
resolveRegisteredJob,
|
|
52
|
+
DuplicateJobError,
|
|
53
|
+
InvalidJobDefinitionError,
|
|
54
|
+
NoJobsRegisteredError,
|
|
55
|
+
} from "./core/registry";
|
|
41
56
|
export type { RegisteredJob } from "./core/registry";
|
|
42
57
|
|
|
43
58
|
export {
|
|
@@ -56,21 +71,32 @@ export type {
|
|
|
56
71
|
EnqueueResult,
|
|
57
72
|
EnqueueSkipReason,
|
|
58
73
|
JobBackoff,
|
|
74
|
+
JobCompletedEvent,
|
|
59
75
|
JobContext,
|
|
60
76
|
JobDefinition,
|
|
61
77
|
JobDriver,
|
|
78
|
+
JobEvents,
|
|
79
|
+
JobFailedEvent,
|
|
62
80
|
JobHandler,
|
|
63
81
|
JobLogger,
|
|
82
|
+
JobRetention,
|
|
83
|
+
JobRetentionWindow,
|
|
84
|
+
JobRunEvent,
|
|
64
85
|
JobSchedule,
|
|
86
|
+
ScheduleRemovedEvent,
|
|
65
87
|
} from "./core/types";
|
|
66
88
|
|
|
89
|
+
// Retention validation lives in `core` so this barrel can carry it without
|
|
90
|
+
// pulling `bullmq` (and ioredis) into a bundle that only ever enqueues.
|
|
91
|
+
export { assertValidRetention, InvalidJobRetentionError } from "./core/retention";
|
|
92
|
+
|
|
67
93
|
export { createInlineJobDriver } from "./drivers/inline";
|
|
68
94
|
export type { InlineJobDriver, InlineJobRun } from "./drivers/inline";
|
|
69
95
|
|
|
70
96
|
export { parseRedisUrl, InvalidRedisUrlError } from "./drivers/redis-url";
|
|
71
97
|
export type { RedisConnectionOptions } from "./drivers/redis-url";
|
|
72
98
|
|
|
73
|
-
export { SWEEP_QUEUE } from "./core/queues";
|
|
99
|
+
export { DEFAULT_QUEUE, SWEEP_QUEUE } from "./core/queues";
|
|
74
100
|
|
|
75
101
|
export { createSweepLease } from "./lease/sweep-lease";
|
|
76
102
|
export type {
|