@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/core/registry.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The job registry: `defineJob` at module scope, jobs collected by import.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* An open/closed seam — a domain module declares its jobs next to the code
|
|
5
|
+
* they belong to, and the worker bootstrap only has to import those modules.
|
|
6
|
+
* Nothing central lists them.
|
|
7
|
+
*
|
|
8
|
+
* The registry is also THE GATE. `resolveRegisteredJob` is the one question
|
|
9
|
+
* both halves of the system ask — the enqueue side before it writes, the
|
|
10
|
+
* execution side before it runs — so the two cannot drift into a state where
|
|
11
|
+
* a write is accepted that the run side will refuse.
|
|
7
12
|
*/
|
|
8
13
|
|
|
9
14
|
import type {
|
|
@@ -31,18 +36,140 @@ export class DuplicateJobError extends Error {
|
|
|
31
36
|
}
|
|
32
37
|
}
|
|
33
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Raised for a definition that cannot run as written — a blank name, an empty
|
|
41
|
+
* queue, a cron pattern no scheduler will ever fire.
|
|
42
|
+
*
|
|
43
|
+
* Every one of these fails SILENTLY if it is let through: an empty queue name
|
|
44
|
+
* creates a queue nobody consumes, `attempts: 0` means one attempt in one
|
|
45
|
+
* driver and none in another, and a blank pattern installs a scheduler that
|
|
46
|
+
* never fires — a sweep that simply stops happening, with nothing logged and
|
|
47
|
+
* nothing failed. So they are refused at assembly, where the stack trace still
|
|
48
|
+
* points at the declaration.
|
|
49
|
+
*/
|
|
50
|
+
export class InvalidJobDefinitionError extends Error {
|
|
51
|
+
constructor(name: string, detail: string) {
|
|
52
|
+
super(`Job "${name}" is not valid: ${detail}`);
|
|
53
|
+
this.name = "InvalidJobDefinitionError";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Raised when a process is asked to run jobs and NOT ONE is registered.
|
|
59
|
+
*
|
|
60
|
+
* "Declare nothing" must not resolve to "accept anything": with an empty
|
|
61
|
+
* registry a worker starts, consumes no queue, installs no schedule and
|
|
62
|
+
* reports itself healthy — every scheduled sweep in the deployment silently
|
|
63
|
+
* stops, and nothing fails to show it. A host with no jobs has no use for this
|
|
64
|
+
* package at all, so an empty registry is always a wiring mistake.
|
|
65
|
+
*/
|
|
66
|
+
export class NoJobsRegisteredError extends Error {
|
|
67
|
+
constructor(where: string) {
|
|
68
|
+
super(
|
|
69
|
+
`${where}: no jobs are registered. Import the modules that call defineJob ` +
|
|
70
|
+
"(the import IS the registration), or list them in `jobs`.",
|
|
71
|
+
);
|
|
72
|
+
this.name = "NoJobsRegisteredError";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function assertText(name: string, field: string, value: unknown): void {
|
|
77
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
78
|
+
throw new InvalidJobDefinitionError(name, `\`${field}\` must be a non-empty string.`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function assertPositiveInteger(name: string, field: string, value: unknown): void {
|
|
83
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
|
84
|
+
throw new InvalidJobDefinitionError(
|
|
85
|
+
name,
|
|
86
|
+
`\`${field}\` must be a positive integer, got ${JSON.stringify(value)}.`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Structural cron validation: five fields (or six, with seconds), none blank.
|
|
93
|
+
*
|
|
94
|
+
* Deliberately not a full cron parser — the driver's scheduler owns what a
|
|
95
|
+
* field may contain. What this catches is the class that is invisible at
|
|
96
|
+
* runtime: `""`, `" "`, `"@daily"` and `"0 *"` all satisfy a `string` type
|
|
97
|
+
* and all produce a schedule that never fires.
|
|
98
|
+
*/
|
|
99
|
+
function assertCron(name: string, pattern: string): void {
|
|
100
|
+
assertText(name, "schedule.pattern", pattern);
|
|
101
|
+
const fields = pattern.trim().split(/\s+/);
|
|
102
|
+
if (fields.length !== 5 && fields.length !== 6) {
|
|
103
|
+
throw new InvalidJobDefinitionError(
|
|
104
|
+
name,
|
|
105
|
+
`\`schedule.pattern\` needs 5 fields (or 6 with seconds), got ${fields.length} in "${pattern}".`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function assertBackoff(name: string, backoff: JobDefinition<never>["backoff"]): void {
|
|
111
|
+
if (!backoff) return;
|
|
112
|
+
const { type, delayMs } = backoff;
|
|
113
|
+
if (type !== "exponential" && type !== "fixed") {
|
|
114
|
+
throw new InvalidJobDefinitionError(
|
|
115
|
+
name,
|
|
116
|
+
`\`backoff.type\` must be "exponential" or "fixed", got ${JSON.stringify(type)}.`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
if (typeof delayMs !== "number" || !Number.isFinite(delayMs) || delayMs < 1) {
|
|
120
|
+
throw new InvalidJobDefinitionError(
|
|
121
|
+
name,
|
|
122
|
+
`\`backoff.delayMs\` must be a positive number of milliseconds, got ${JSON.stringify(delayMs)}.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Refuse a definition that cannot run. Every check here guards a failure that
|
|
129
|
+
* is otherwise silent — see {@link InvalidJobDefinitionError}.
|
|
130
|
+
*/
|
|
131
|
+
function assertValidDefinition(definition: JobDefinition<never>): void {
|
|
132
|
+
if (typeof definition !== "object" || definition === null) {
|
|
133
|
+
throw new InvalidJobDefinitionError("<unnamed>", "a definition object is required.");
|
|
134
|
+
}
|
|
135
|
+
assertText(String(definition.name), "name", definition.name);
|
|
136
|
+
const { name } = definition;
|
|
137
|
+
if (typeof definition.handle !== "function") {
|
|
138
|
+
throw new InvalidJobDefinitionError(name, "`handle` must be a function.");
|
|
139
|
+
}
|
|
140
|
+
// `queue: ""` is the sharp one: it is not nullish, so it never falls back to
|
|
141
|
+
// the default queue — it creates a queue named "" that no worker consumes.
|
|
142
|
+
if (definition.queue !== undefined) assertText(name, "queue", definition.queue);
|
|
143
|
+
if (definition.attempts !== undefined) {
|
|
144
|
+
assertPositiveInteger(name, "attempts", definition.attempts);
|
|
145
|
+
}
|
|
146
|
+
if (definition.concurrency !== undefined) {
|
|
147
|
+
assertPositiveInteger(name, "concurrency", definition.concurrency);
|
|
148
|
+
}
|
|
149
|
+
assertBackoff(name, definition.backoff);
|
|
150
|
+
if (definition.schedule !== undefined) {
|
|
151
|
+
assertCron(name, definition.schedule.pattern);
|
|
152
|
+
if (definition.schedule.timezone !== undefined) {
|
|
153
|
+
assertText(name, "schedule.timezone", definition.schedule.timezone);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
34
158
|
/**
|
|
35
159
|
* Declare a job and register it under its name.
|
|
36
160
|
*
|
|
37
161
|
* Throws on a duplicate name rather than replacing: two modules quietly
|
|
38
162
|
* claiming one name means one of them never runs, and a schedule installed
|
|
39
|
-
* under that name would fire the wrong handler.
|
|
40
|
-
*
|
|
41
|
-
* for
|
|
163
|
+
* under that name would fire the wrong handler.
|
|
164
|
+
*
|
|
165
|
+
* Throws on an unusable definition too, for the same reason — both are silent
|
|
166
|
+
* if let through, and both are programming errors a deployment cannot recover
|
|
167
|
+
* from on its own.
|
|
42
168
|
*/
|
|
43
169
|
export function defineJob<TPayload = void>(
|
|
44
170
|
definition: JobDefinition<TPayload>,
|
|
45
171
|
): RegisteredJob<TPayload> {
|
|
172
|
+
assertValidDefinition(definition as unknown as JobDefinition<never>);
|
|
46
173
|
if (registry.has(definition.name)) throw new DuplicateJobError(definition.name);
|
|
47
174
|
registry.set(definition.name, definition as unknown as AnyJobDefinition);
|
|
48
175
|
|
|
@@ -68,6 +195,41 @@ export function findJob(name: string): AnyJobDefinition | undefined {
|
|
|
68
195
|
return registry.get(name);
|
|
69
196
|
}
|
|
70
197
|
|
|
198
|
+
/**
|
|
199
|
+
* THE GATE — "may this job run in this deployment?" — and the answer both
|
|
200
|
+
* sides of the queue key on.
|
|
201
|
+
*
|
|
202
|
+
* The enqueue path asks it before a write; the execution path asks it before a
|
|
203
|
+
* run. One function on purpose: restating the rule on each side is how a write
|
|
204
|
+
* comes to be accepted that the run side then refuses, which is the shape of a
|
|
205
|
+
* silent drop — the job lands in the backend, no handler claims it, it
|
|
206
|
+
* dead-letters, and the caller was told `enqueued: true`.
|
|
207
|
+
*
|
|
208
|
+
* Passing a DEFINITION checks identity, not just the name: an impostor object
|
|
209
|
+
* carrying a registered name would ship a payload the real handler never
|
|
210
|
+
* agreed to, and it is not the object a worker holds. Passing a NAME is what
|
|
211
|
+
* the execution side has — a wire key read back off the queue.
|
|
212
|
+
*/
|
|
213
|
+
export function resolveRegisteredJob(
|
|
214
|
+
job: AnyJobDefinition | string,
|
|
215
|
+
): AnyJobDefinition | undefined {
|
|
216
|
+
const name = typeof job === "string" ? job : job.name;
|
|
217
|
+
const registered = registry.get(name);
|
|
218
|
+
if (!registered) return undefined;
|
|
219
|
+
if (typeof job !== "string" && registered !== job) return undefined;
|
|
220
|
+
return registered;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Refuse an empty registry at the point of use. It takes the caller's name so
|
|
225
|
+
* the message says WHICH entry point was asked — the root `startJobWorkers`
|
|
226
|
+
* and the server factory both reach this, and an operator reading the log
|
|
227
|
+
* needs to know which of the two they wired.
|
|
228
|
+
*/
|
|
229
|
+
export function assertJobsRegistered(where: string): void {
|
|
230
|
+
if (registry.size === 0) throw new NoJobsRegisteredError(where);
|
|
231
|
+
}
|
|
232
|
+
|
|
71
233
|
/** Test-only: drop every registration (isolation between suites). */
|
|
72
234
|
export function clearJobs(): void {
|
|
73
235
|
registry.clear();
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { JobRetention, JobRetentionWindow } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validation for the retention window a host may now configure.
|
|
5
|
+
*
|
|
6
|
+
* It lives in `core` rather than beside the BullMQ driver that consumes it so
|
|
7
|
+
* that the root entry point can re-export the error without dragging `bullmq`
|
|
8
|
+
* — and therefore ioredis — into a bundle that only ever enqueues. Nothing
|
|
9
|
+
* here imports a driver; it is arithmetic on four numbers.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Raised for a retention window that cannot bound anything.
|
|
14
|
+
*
|
|
15
|
+
* The negative case is the one worth the class. BullMQ's count trim runs
|
|
16
|
+
* `ZREMRANGEBYRANK` derived from the number it is given, so a NEGATIVE count
|
|
17
|
+
* removes one job per completion instead of holding the set at a ceiling —
|
|
18
|
+
* which is not "less retention", it is the UNBOUNDED completed-set that the
|
|
19
|
+
* default exists to prevent, arrived at by way of a config knob. Nothing
|
|
20
|
+
* throws, no probe reddens, and the symptom is a Redis that fills up weeks
|
|
21
|
+
* later and starts refusing writes.
|
|
22
|
+
*
|
|
23
|
+
* `NaN` is the likelier way in: `ageSeconds: Number(process.env.JOBS_KEEP_H)`
|
|
24
|
+
* with the variable unset is `NaN`, which every comparison in the trim silently
|
|
25
|
+
* answers false for.
|
|
26
|
+
*/
|
|
27
|
+
export class InvalidJobRetentionError extends Error {
|
|
28
|
+
constructor(field: string, value: unknown) {
|
|
29
|
+
super(
|
|
30
|
+
`retention.${field} must be a positive finite number, got ${JSON.stringify(value)}. ` +
|
|
31
|
+
"A non-positive or NaN window does not shrink retention — it stops bounding the set.",
|
|
32
|
+
);
|
|
33
|
+
this.name = "InvalidJobRetentionError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function assertWindow(half: string, window: JobRetentionWindow | undefined): void {
|
|
38
|
+
if (typeof window !== "object" || window === null) {
|
|
39
|
+
throw new InvalidJobRetentionError(half, window);
|
|
40
|
+
}
|
|
41
|
+
for (const field of ["ageSeconds", "count"] as const) {
|
|
42
|
+
const value = window[field];
|
|
43
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
44
|
+
throw new InvalidJobRetentionError(`${half}.${field}`, value);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Refuse a retention that cannot bound the backend.
|
|
51
|
+
*
|
|
52
|
+
* Held to the same standard as every sibling numeric input — `attempts` and
|
|
53
|
+
* `concurrency` go through `assertPositiveInteger`, `backoff.delayMs` through
|
|
54
|
+
* a finite check, and the driver's `defaultConcurrency` is sanitised with a
|
|
55
|
+
* `> 0` guard. Retention was the one configurable number with no check on any
|
|
56
|
+
* path, and it is the only one whose failure is invisible.
|
|
57
|
+
*
|
|
58
|
+
* `undefined` is fine and means "use the package default"; it is the values a
|
|
59
|
+
* host actually spells out that are checked.
|
|
60
|
+
*/
|
|
61
|
+
export function assertValidRetention(retention: JobRetention | undefined): void {
|
|
62
|
+
if (retention === undefined) return;
|
|
63
|
+
if (typeof retention !== "object" || retention === null) {
|
|
64
|
+
throw new InvalidJobRetentionError("<root>", retention);
|
|
65
|
+
}
|
|
66
|
+
assertWindow("completed", retention.completed);
|
|
67
|
+
assertWindow("failed", retention.failed);
|
|
68
|
+
}
|
package/src/core/runtime.ts
CHANGED
|
@@ -6,9 +6,15 @@
|
|
|
6
6
|
* consume), a CONSUMER (the worker: it does both), or neither (a unit test).
|
|
7
7
|
* `configureJobs` installs the driver; `startJobWorkers` is the extra step a
|
|
8
8
|
* consumer takes.
|
|
9
|
+
*
|
|
10
|
+
* This is a PUBLIC entry point, not merely `createApiJobs`'s plumbing: the
|
|
11
|
+
* package root exports all of it, and a host that wires the runtime by hand
|
|
12
|
+
* reaches every hazard the factory does. So the guards live HERE and the
|
|
13
|
+
* factory inherits them, rather than the newest surface owning a second copy
|
|
14
|
+
* the older path never got.
|
|
9
15
|
*/
|
|
10
16
|
|
|
11
|
-
import { listJobs } from "./registry";
|
|
17
|
+
import { assertJobsRegistered, listJobs, resolveRegisteredJob } from "./registry";
|
|
12
18
|
import type {
|
|
13
19
|
AnyJobDefinition,
|
|
14
20
|
EnqueueOptions,
|
|
@@ -51,18 +57,31 @@ export function getJobLogger(): JobLogger {
|
|
|
51
57
|
/**
|
|
52
58
|
* Defer one run of `definition`.
|
|
53
59
|
*
|
|
54
|
-
* **Never throws.** Every caller of this has just committed a durable row
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
+
* **Never throws.** Every caller of this has just committed a durable row, and
|
|
61
|
+
* the enqueue is only the fast path to acting on it. The backend being down
|
|
62
|
+
* must degrade that to "a sweep will pick it up in a few minutes", not fail
|
|
63
|
+
* the request that triggered it. The failure is logged loudly and reported in
|
|
64
|
+
* the result for callers that care.
|
|
65
|
+
*
|
|
66
|
+
* It does REFUSE an unregistered definition, through the same
|
|
67
|
+
* `resolveRegisteredJob` gate the execution side calls. Without that, a write
|
|
68
|
+
* is accepted no worker can ever claim: the job lands in the backend, the
|
|
69
|
+
* consumer finds no handler for the name, the run dead-letters, and the caller
|
|
70
|
+
* was told `enqueued: true`. Refusing at the write is louder, and loses
|
|
71
|
+
* nothing that was not already lost.
|
|
60
72
|
*/
|
|
61
73
|
export async function enqueueJob(
|
|
62
74
|
definition: AnyJobDefinition,
|
|
63
75
|
payload: unknown,
|
|
64
76
|
options: EnqueueOptions = {},
|
|
65
77
|
): Promise<EnqueueResult> {
|
|
78
|
+
if (!resolveRegisteredJob(definition)) {
|
|
79
|
+
logger.error(
|
|
80
|
+
`"${definition.name}" was not enqueued: it is not the job registered under that ` +
|
|
81
|
+
"name. Enqueue the value defineJob() returned, and import its module first.",
|
|
82
|
+
);
|
|
83
|
+
return { enqueued: false, reason: "unregistered" };
|
|
84
|
+
}
|
|
66
85
|
if (!driver) {
|
|
67
86
|
logger.warn(
|
|
68
87
|
`"${definition.name}" was not enqueued: no driver is configured in this process.`,
|
|
@@ -80,9 +99,14 @@ export async function enqueueJob(
|
|
|
80
99
|
/**
|
|
81
100
|
* Start consuming every registered job and install their cron schedules.
|
|
82
101
|
*
|
|
83
|
-
* Idempotent: a second call is a no-op, so a bootstrap that runs twice
|
|
84
|
-
*
|
|
85
|
-
*
|
|
102
|
+
* Idempotent: a second call is a no-op, so a bootstrap that runs twice cannot
|
|
103
|
+
* double-consume.
|
|
104
|
+
*
|
|
105
|
+
* Refuses an EMPTY registry. A worker that consumes nothing is not a degraded
|
|
106
|
+
* deployment but a broken one that looks fine: no queue is read, no schedule
|
|
107
|
+
* is installed, and the process reports itself started. "The host declared no
|
|
108
|
+
* jobs" and "the host's job modules were never imported" are the same fact
|
|
109
|
+
* from here, and the second is the common one.
|
|
86
110
|
*/
|
|
87
111
|
export async function startJobWorkers(): Promise<void> {
|
|
88
112
|
if (!driver) throw new Error("startJobWorkers(): no driver configured.");
|
|
@@ -90,6 +114,7 @@ export async function startJobWorkers(): Promise<void> {
|
|
|
90
114
|
logger.warn("startJobWorkers() called twice; ignoring the second call.");
|
|
91
115
|
return;
|
|
92
116
|
}
|
|
117
|
+
assertJobsRegistered("startJobWorkers()");
|
|
93
118
|
started = true;
|
|
94
119
|
|
|
95
120
|
const definitions = listJobs();
|
package/src/core/types.ts
CHANGED
|
@@ -4,20 +4,18 @@
|
|
|
4
4
|
* Three layers, each replaceable without touching the others:
|
|
5
5
|
* - DEFINITIONS describe a unit of deferred work: its name, its retry
|
|
6
6
|
* policy, its optional cron schedule, and the handler that runs it.
|
|
7
|
-
* - The REGISTRY collects definitions at import time
|
|
8
|
-
*
|
|
9
|
-
* every job by importing the modules that define them.
|
|
7
|
+
* - The REGISTRY collects definitions at import time so a worker process can
|
|
8
|
+
* start every job by importing the modules that define them.
|
|
10
9
|
* - A DRIVER executes them. BullMQ/Redis in production, inline in tests.
|
|
11
10
|
*
|
|
12
|
-
* Nothing here imports Redis,
|
|
13
|
-
* a plain payload and is expected to re-read whatever it
|
|
14
|
-
* database — see the payload rule below.
|
|
11
|
+
* Nothing here imports Redis, an ORM, or an application's domain. A job
|
|
12
|
+
* handler receives a plain payload and is expected to re-read whatever it
|
|
13
|
+
* needs from the database — see the payload rule below.
|
|
15
14
|
*/
|
|
16
15
|
|
|
17
16
|
/**
|
|
18
|
-
* The logging port. Structurally satisfied by a winston logger
|
|
19
|
-
*
|
|
20
|
-
* logging dependency of its own.
|
|
17
|
+
* The logging port. Structurally satisfied by a winston-style logger and by
|
|
18
|
+
* `console`, so the library needs no logging dependency of its own.
|
|
21
19
|
*/
|
|
22
20
|
export interface JobLogger {
|
|
23
21
|
info(message: string, ...meta: unknown[]): void;
|
|
@@ -37,12 +35,17 @@ export interface JobBackoff {
|
|
|
37
35
|
|
|
38
36
|
/** A cron schedule for a repeatable job. */
|
|
39
37
|
export interface JobSchedule {
|
|
40
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* Standard 5-field cron expression (a 6-field form carrying seconds is
|
|
40
|
+
* accepted too). Validated when the job is defined: a blank or malformed
|
|
41
|
+
* pattern installs a scheduler that never fires, which is a sweep that
|
|
42
|
+
* silently never runs.
|
|
43
|
+
*/
|
|
41
44
|
pattern: string;
|
|
42
45
|
/**
|
|
43
46
|
* IANA timezone the pattern is evaluated in. Defaults to UTC — a schedule
|
|
44
|
-
* that means "03:00
|
|
45
|
-
*
|
|
47
|
+
* that means "03:00 local" must say WHICH local, because a server's own zone
|
|
48
|
+
* is not a product decision.
|
|
46
49
|
*/
|
|
47
50
|
timezone?: string;
|
|
48
51
|
}
|
|
@@ -68,17 +71,17 @@ export type JobHandler<TPayload> = (
|
|
|
68
71
|
*
|
|
69
72
|
* ## The payload rule
|
|
70
73
|
*
|
|
71
|
-
* A payload carries IDENTIFIERS, never state. `{
|
|
72
|
-
* rendered
|
|
73
|
-
*
|
|
74
|
+
* A payload carries IDENTIFIERS, never state. `{ documentId }`, not the
|
|
75
|
+
* rendered document; `{ accountId, periodStart }`, not the amount to charge.
|
|
76
|
+
* Two reasons, and both are load-bearing:
|
|
74
77
|
*
|
|
75
|
-
* 1. **
|
|
76
|
-
* duplicates a row's contents is a second copy that can disagree
|
|
77
|
-
* — and the copy is the one that gets acted on, days later, after
|
|
78
|
-
* changed. Re-reading inside the handler is always correct.
|
|
79
|
-
* 2. **
|
|
80
|
-
* run, not a lost or corrupted business fact.
|
|
81
|
-
*
|
|
78
|
+
* 1. **The queue is not the source of truth.** The database is. A payload
|
|
79
|
+
* that duplicates a row's contents is a second copy that can disagree
|
|
80
|
+
* with it — and the copy is the one that gets acted on, days later, after
|
|
81
|
+
* the row changed. Re-reading inside the handler is always correct.
|
|
82
|
+
* 2. **The queue can be lost.** A flushed or evicted backend must cost a
|
|
83
|
+
* delayed run, not a lost or corrupted business fact. Pair every job with
|
|
84
|
+
* a durable row a sweep can find again.
|
|
82
85
|
*
|
|
83
86
|
* ## Idempotency
|
|
84
87
|
*
|
|
@@ -92,8 +95,8 @@ export interface JobDefinition<TPayload = void> {
|
|
|
92
95
|
/** Dot-namespaced and stable: it is the wire key and the scheduler id. */
|
|
93
96
|
name: string;
|
|
94
97
|
/**
|
|
95
|
-
* Which queue carries it. One queue (
|
|
96
|
-
* shape at
|
|
98
|
+
* Which queue carries it. One queue (`DEFAULT_QUEUE`) for everything is the
|
|
99
|
+
* right shape at most scales — one worker, one pair of connections, one
|
|
97
100
|
* dashboard. Move a noisy or slow job onto its own queue when it starts
|
|
98
101
|
* starving the others; no call site changes when you do.
|
|
99
102
|
*/
|
|
@@ -121,7 +124,8 @@ export interface EnqueueOptions {
|
|
|
121
124
|
/**
|
|
122
125
|
* Collapses duplicates: while a job with this key is waiting, delayed or
|
|
123
126
|
* active, enqueueing it again is a no-op. Scope it to the work, not the
|
|
124
|
-
* caller —
|
|
127
|
+
* caller — `<job>:<entity id>`, or `<job>:<entity id>:<date>` for a job that
|
|
128
|
+
* may legitimately repeat daily.
|
|
125
129
|
*
|
|
126
130
|
* NOT a durability mechanism. The key is forgotten once the job completes
|
|
127
131
|
* and is cleaned up, so it stops accidental double-sends within a window,
|
|
@@ -133,8 +137,15 @@ export interface EnqueueOptions {
|
|
|
133
137
|
delayMs?: number;
|
|
134
138
|
}
|
|
135
139
|
|
|
136
|
-
/**
|
|
137
|
-
|
|
140
|
+
/**
|
|
141
|
+
* Why an enqueue did not reach a queue.
|
|
142
|
+
*
|
|
143
|
+
* `unregistered` is the one that is a PROGRAMMING error rather than an
|
|
144
|
+
* operational one: the definition handed in is not the one the registry holds
|
|
145
|
+
* under that name, so no worker in this deployment could ever run it. See
|
|
146
|
+
* `enqueueJob`.
|
|
147
|
+
*/
|
|
148
|
+
export type EnqueueSkipReason = "no-driver" | "duplicate" | "error" | "unregistered";
|
|
138
149
|
|
|
139
150
|
/**
|
|
140
151
|
* The result of an enqueue.
|
|
@@ -148,6 +159,88 @@ export interface EnqueueResult {
|
|
|
148
159
|
reason?: EnqueueSkipReason;
|
|
149
160
|
}
|
|
150
161
|
|
|
162
|
+
/** What happened to one attempt of one job. */
|
|
163
|
+
export interface JobRunEvent {
|
|
164
|
+
name: string;
|
|
165
|
+
/** The queue that carried it, already resolved — never `undefined`. */
|
|
166
|
+
queue: string;
|
|
167
|
+
/** The driver's id for the run, matching {@link JobContext.runId}. */
|
|
168
|
+
runId: string;
|
|
169
|
+
/** 1-based attempt number. */
|
|
170
|
+
attempt: number;
|
|
171
|
+
maxAttempts: number;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** A job that finished successfully. */
|
|
175
|
+
export type JobCompletedEvent = JobRunEvent;
|
|
176
|
+
|
|
177
|
+
/** A job attempt that threw. */
|
|
178
|
+
export interface JobFailedEvent extends JobRunEvent {
|
|
179
|
+
error: unknown;
|
|
180
|
+
/**
|
|
181
|
+
* No further attempt will be made — the attempt budget is spent, or the
|
|
182
|
+
* failure was unrecoverable. This is the DEAD-LETTER signal, and the one
|
|
183
|
+
* moment a host may want to notify someone, open a ticket or page an
|
|
184
|
+
* operator. A non-terminal failure is ordinary retry noise.
|
|
185
|
+
*/
|
|
186
|
+
terminal: boolean;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** A cron schedule that existed in the backend and no longer exists in code. */
|
|
190
|
+
export interface ScheduleRemovedEvent {
|
|
191
|
+
/** The scheduler key, which is the name of the job that installed it. */
|
|
192
|
+
name: string;
|
|
193
|
+
queue: string;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The observation port — how the rest of a system finds out what the queue did.
|
|
198
|
+
*
|
|
199
|
+
* This package deliberately does NOT notify, audit or publish anything itself:
|
|
200
|
+
* it has no opinion about who should hear that a job dead-lettered, and
|
|
201
|
+
* reaching for a notification package here would make every host inherit that
|
|
202
|
+
* opinion (and that dependency). What it owns is the MOMENT, so the moment is
|
|
203
|
+
* exported and the host wires the consequence.
|
|
204
|
+
*
|
|
205
|
+
* Every hook is optional, and a hook that throws is logged and swallowed — an
|
|
206
|
+
* observer must never fail the job it is observing, nor the reconcile that
|
|
207
|
+
* removed a stale schedule.
|
|
208
|
+
*/
|
|
209
|
+
export interface JobEvents {
|
|
210
|
+
/** A job finished successfully. The seam for a realtime "it is done" event. */
|
|
211
|
+
onJobCompleted?(event: JobCompletedEvent): void | Promise<void>;
|
|
212
|
+
/**
|
|
213
|
+
* An attempt failed. Check `terminal` — that is the dead-letter, and the
|
|
214
|
+
* only one of the two most hosts want to act on.
|
|
215
|
+
*/
|
|
216
|
+
onJobFailed?(event: JobFailedEvent): void | Promise<void>;
|
|
217
|
+
/**
|
|
218
|
+
* A schedule was removed from the backend because no code declares it any
|
|
219
|
+
* more. Destructive and unrecoverable from the queue's side, which is why it
|
|
220
|
+
* is the schedule event worth auditing; installation is an idempotent upsert
|
|
221
|
+
* that happens on every boot, so auditing that would write noise per deploy.
|
|
222
|
+
*/
|
|
223
|
+
onScheduleRemoved?(event: ScheduleRemovedEvent): void | Promise<void>;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** How long finished jobs are kept in the backend before they are trimmed. */
|
|
227
|
+
export interface JobRetentionWindow {
|
|
228
|
+
ageSeconds: number;
|
|
229
|
+
count: number;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Retention for finished jobs. Bounded on purpose: an unbounded completed-set
|
|
234
|
+
* is the classic way a small queue backend fills up and starts refusing
|
|
235
|
+
* writes. The package default keeps a day of successes (enough to answer "did
|
|
236
|
+
* it run?") and a week of failures (enough to debug one); a host whose support
|
|
237
|
+
* window is longer says so rather than forking the driver.
|
|
238
|
+
*/
|
|
239
|
+
export interface JobRetention {
|
|
240
|
+
completed: JobRetentionWindow;
|
|
241
|
+
failed: JobRetentionWindow;
|
|
242
|
+
}
|
|
243
|
+
|
|
151
244
|
/**
|
|
152
245
|
* The driver port. `inline` and `bullmq` implement it; a third (SQS, pg-boss)
|
|
153
246
|
* would need no change above this line.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { UnrecoverableError, type JobsOptions } from "bullmq";
|
|
2
|
+
|
|
3
|
+
import { assertValidRetention } from "../core/retention";
|
|
4
|
+
import type { AnyJobDefinition, JobRetention } from "../core/types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The BullMQ driver's three policy decisions, kept together and out of the
|
|
8
|
+
* driver body — retention, concurrency and what counts as a dead-letter.
|
|
9
|
+
*
|
|
10
|
+
* All three share a property that makes them worth their own file and their
|
|
11
|
+
* own tests: each is SILENT when it is wrong. Nothing throws, no probe turns
|
|
12
|
+
* red, and the only symptom is behaviour nobody is watching — a queue that
|
|
13
|
+
* filled up, a sweep that quietly stopped being single-flight, a pager that
|
|
14
|
+
* never went off.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Worker concurrency when no definition on the queue asks for a specific one. */
|
|
18
|
+
export const DEFAULT_CONCURRENCY = 5;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The package's retention default: a day of successes is enough to answer "did
|
|
22
|
+
* it run?", a week of failures is enough to debug one. Bounded on purpose — an
|
|
23
|
+
* unbounded completed-set is the classic way a small Redis fills up and starts
|
|
24
|
+
* refusing writes. A host with a different support window passes its own
|
|
25
|
+
* {@link JobRetention} rather than forking the driver.
|
|
26
|
+
*/
|
|
27
|
+
export const DEFAULT_JOB_RETENTION: JobRetention = {
|
|
28
|
+
completed: { ageSeconds: 24 * 3600, count: 1_000 },
|
|
29
|
+
failed: { ageSeconds: 7 * 24 * 3600, count: 5_000 },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Validated HERE, at the single funnel both paths cross, rather than only at
|
|
34
|
+
* the server factory: `@12-apps/jobs/bullmq` is a published entry point, so a
|
|
35
|
+
* host that builds the driver itself would otherwise reach BullMQ with an
|
|
36
|
+
* unchecked window. `createApiJobs` checks the same thing at assembly so the
|
|
37
|
+
* failure lands on the line that wrote it, and this is the backstop that makes
|
|
38
|
+
* the check a property of the driver rather than of the newest caller.
|
|
39
|
+
*/
|
|
40
|
+
export function retentionOptions(
|
|
41
|
+
retention: JobRetention,
|
|
42
|
+
): Pick<JobsOptions, "removeOnComplete" | "removeOnFail"> {
|
|
43
|
+
assertValidRetention(retention);
|
|
44
|
+
return {
|
|
45
|
+
removeOnComplete: {
|
|
46
|
+
age: retention.completed.ageSeconds,
|
|
47
|
+
count: retention.completed.count,
|
|
48
|
+
},
|
|
49
|
+
removeOnFail: { age: retention.failed.ageSeconds, count: retention.failed.count },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A queue's worker concurrency.
|
|
55
|
+
*
|
|
56
|
+
* The default applies only when NO definition on the queue asked for a
|
|
57
|
+
* specific number. A stated `concurrency: 1` means single-flight and must be
|
|
58
|
+
* honoured — taking `max(default, …)` would quietly raise it back to the
|
|
59
|
+
* default and undo the very property the caller asked for.
|
|
60
|
+
*/
|
|
61
|
+
export function resolveConcurrency(
|
|
62
|
+
group: readonly AnyJobDefinition[],
|
|
63
|
+
fallback: number = DEFAULT_CONCURRENCY,
|
|
64
|
+
): number {
|
|
65
|
+
const stated = group
|
|
66
|
+
.map((definition) => definition.concurrency)
|
|
67
|
+
.filter((value): value is number => typeof value === "number" && value > 0);
|
|
68
|
+
return stated.length > 0 ? Math.max(...stated) : fallback;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Is this the last thing that will happen to the job?
|
|
73
|
+
*
|
|
74
|
+
* The whole value of `onJobFailed` rests on this bit: a host pages someone on
|
|
75
|
+
* a dead-letter and must not page them on every retry. Two ways to be
|
|
76
|
+
* terminal — the attempt budget is spent, or the failure was declared
|
|
77
|
+
* unrecoverable (a name no handler claims is the common one, and BullMQ never
|
|
78
|
+
* retries it).
|
|
79
|
+
*/
|
|
80
|
+
export function isTerminalFailure(
|
|
81
|
+
attemptsMade: number,
|
|
82
|
+
maxAttempts: number,
|
|
83
|
+
error: unknown,
|
|
84
|
+
): boolean {
|
|
85
|
+
return attemptsMade >= maxAttempts || error instanceof UnrecoverableError;
|
|
86
|
+
}
|