@12-apps/jobs 1.20.0 → 3.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/server/config.ts
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
import type { RegisteredJob } from "../core/registry";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
JobDriver,
|
|
4
|
+
JobEvents,
|
|
5
|
+
JobLogger,
|
|
6
|
+
JobRetention,
|
|
7
|
+
} from "../core/types";
|
|
3
8
|
import type { SweepLeaseDbProvider } from "../lease/sweep-lease";
|
|
4
9
|
|
|
5
10
|
/**
|
|
6
11
|
* The factory's config surface, and how it resolves against the environment.
|
|
7
12
|
*
|
|
8
13
|
* Every environment read happens in {@link resolveConfig}, which `start()`
|
|
9
|
-
* calls — NOT the factory.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* variable and not the others.
|
|
14
|
+
* calls — NOT the factory. The whole matrix (`JOBS_DRIVER`, `REDIS_URL`,
|
|
15
|
+
* `NODE_ENV`, `JOBS_WORKER`, `JOBS_QUEUE_PREFIX`) has to be read at ONE
|
|
16
|
+
* moment, because the recommended host shape is factory-at-module-scope +
|
|
17
|
+
* `await jobsApi.start()` later: an env var that arrives between the two (a
|
|
18
|
+
* config module loading after the module that built the api) must be honoured
|
|
19
|
+
* for all of the matrix, not silently for one variable and not the others.
|
|
16
20
|
*/
|
|
17
21
|
|
|
18
22
|
/** What the driver choice may say (`JOBS_DRIVER`, or `config.driver`). */
|
|
@@ -21,16 +25,38 @@ export type JobsDriverChoice = "bullmq" | "inline" | "off";
|
|
|
21
25
|
/**
|
|
22
26
|
* How the host names its jobs. An import thunk (`() => import("./jobs")`) is
|
|
23
27
|
* the usual form — `defineJob` registers at module scope, so importing the
|
|
24
|
-
* modules IS the registration. An array of already-registered jobs is
|
|
25
|
-
*
|
|
26
|
-
*
|
|
28
|
+
* modules IS the registration. An array of already-registered jobs is accepted
|
|
29
|
+
* for hosts (and tests) that hold the references anyway; it forces the modules
|
|
30
|
+
* to have been imported, which is the same guarantee.
|
|
31
|
+
*
|
|
32
|
+
* Neither form may be EMPTY. See {@link JobsConfigError}.
|
|
27
33
|
*/
|
|
28
34
|
export type JobsSource =
|
|
29
35
|
| readonly RegisteredJob<never>[]
|
|
30
36
|
| (() => unknown | Promise<unknown>);
|
|
31
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Raised at ASSEMBLY for a config that cannot work — today, a `jobs` that
|
|
40
|
+
* names nothing.
|
|
41
|
+
*
|
|
42
|
+
* A required option that is never checked is still fail-open. `jobs: []` type-
|
|
43
|
+
* checks, starts, resolves a driver, installs no schedule, consumes no queue
|
|
44
|
+
* and answers `/health` with `status: "ok"` — every scheduled job in the
|
|
45
|
+
* deployment silently stops and the probe stays green. Refusing at the factory
|
|
46
|
+
* puts the failure at the line that wrote it.
|
|
47
|
+
*/
|
|
48
|
+
export class JobsConfigError extends Error {
|
|
49
|
+
constructor(detail: string) {
|
|
50
|
+
super(`createApiJobs: ${detail}`);
|
|
51
|
+
this.name = "JobsConfigError";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
32
55
|
export interface JobsServerConfig {
|
|
33
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* Every job this process can enqueue or consume. See {@link JobsSource}.
|
|
58
|
+
* Required, and refused when it names nothing.
|
|
59
|
+
*/
|
|
34
60
|
jobs: JobsSource;
|
|
35
61
|
/**
|
|
36
62
|
* A driver INSTANCE (tests, exotic hosts), a choice by name, or unset to
|
|
@@ -58,6 +84,22 @@ export interface JobsServerConfig {
|
|
|
58
84
|
queuePrefix?: string;
|
|
59
85
|
/** The host's logger. Defaults to the console. */
|
|
60
86
|
logger?: JobLogger;
|
|
87
|
+
/**
|
|
88
|
+
* Where completions, dead-letters and removed schedules are reported. This
|
|
89
|
+
* package never notifies, audits or publishes anything itself — it exports
|
|
90
|
+
* the moment and the host wires the consequence.
|
|
91
|
+
*/
|
|
92
|
+
events?: JobEvents;
|
|
93
|
+
/**
|
|
94
|
+
* How long finished jobs are kept. Defaults to the package's bounded
|
|
95
|
+
* default (a day of successes, a week of failures).
|
|
96
|
+
*/
|
|
97
|
+
retention?: JobRetention;
|
|
98
|
+
/**
|
|
99
|
+
* Per-queue worker concurrency when no job on the queue states one. A job
|
|
100
|
+
* that states `concurrency: 1` still gets 1 — a stated value always wins.
|
|
101
|
+
*/
|
|
102
|
+
defaultConcurrency?: number;
|
|
61
103
|
/**
|
|
62
104
|
* Where the `sweep_leases` table lives — enables `withSweepLease` on the
|
|
63
105
|
* factory's return. Omit it and the lease helper rejects on first use,
|
|
@@ -80,6 +122,29 @@ const consoleLogger: JobLogger = {
|
|
|
80
122
|
error: (message, ...meta) => console.error(`[jobs] ${message}`, ...meta),
|
|
81
123
|
};
|
|
82
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Refuse a `jobs` that names nothing, at assembly.
|
|
127
|
+
*
|
|
128
|
+
* The array form is decidable here and is refused here. The thunk form is not
|
|
129
|
+
* — running it is the only way to know what it registers — so it is checked
|
|
130
|
+
* again after registration, inside `start()`. Both paths end at the same
|
|
131
|
+
* refusal, which is why neither is optional.
|
|
132
|
+
*/
|
|
133
|
+
export function assertJobsDeclared(jobs: JobsSource | undefined): void {
|
|
134
|
+
if (typeof jobs === "function") return;
|
|
135
|
+
if (!Array.isArray(jobs)) {
|
|
136
|
+
throw new JobsConfigError(
|
|
137
|
+
"`jobs` is required — an import thunk (() => import('./jobs')) or a non-empty array of defineJob() results.",
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (jobs.length === 0) {
|
|
141
|
+
throw new JobsConfigError(
|
|
142
|
+
"`jobs: []` declares no work. A process with no jobs consumes no queue, installs no " +
|
|
143
|
+
"schedule and still reports itself healthy — pass the jobs, or do not mount this package.",
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
83
148
|
/** `JOBS_WORKER=1|true` is how a deployment marks the consuming process. */
|
|
84
149
|
export function isWorkerProcess(): boolean {
|
|
85
150
|
const flag = process.env.JOBS_WORKER?.trim().toLowerCase();
|
|
@@ -93,6 +158,9 @@ export interface ResolvedConfig {
|
|
|
93
158
|
worker: boolean;
|
|
94
159
|
queuePrefix: string | undefined;
|
|
95
160
|
logger: JobLogger;
|
|
161
|
+
events: JobEvents | undefined;
|
|
162
|
+
retention: JobRetention | undefined;
|
|
163
|
+
defaultConcurrency: number | undefined;
|
|
96
164
|
}
|
|
97
165
|
|
|
98
166
|
/**
|
|
@@ -108,5 +176,8 @@ export function resolveConfig(config: JobsServerConfig): ResolvedConfig {
|
|
|
108
176
|
worker: config.worker ?? isWorkerProcess(),
|
|
109
177
|
queuePrefix: config.queuePrefix ?? process.env.JOBS_QUEUE_PREFIX,
|
|
110
178
|
logger: config.logger ?? consoleLogger,
|
|
179
|
+
events: config.events,
|
|
180
|
+
retention: config.retention,
|
|
181
|
+
defaultConcurrency: config.defaultConcurrency,
|
|
111
182
|
};
|
|
112
183
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { listJobs } from "../core/registry";
|
|
1
|
+
import { assertJobsRegistered, listJobs } from "../core/registry";
|
|
2
|
+
import { assertValidRetention } from "../core/retention";
|
|
2
3
|
import {
|
|
3
4
|
configureJobs,
|
|
4
5
|
getJobDriver,
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
} from "../lease/sweep-lease";
|
|
14
15
|
|
|
15
16
|
import {
|
|
17
|
+
assertJobsDeclared,
|
|
16
18
|
isWorkerProcess,
|
|
17
19
|
resolveConfig,
|
|
18
20
|
type JobsServerConfig,
|
|
@@ -59,16 +61,23 @@ import { resolveDriver } from "./resolve-driver";
|
|
|
59
61
|
* with no Redis container. That is the default a fresh host is supposed to
|
|
60
62
|
* boot in.
|
|
61
63
|
*
|
|
64
|
+
* ## `jobs` is refused when it names nothing
|
|
65
|
+
*
|
|
66
|
+
* "Fail closed, never fail loud" is about the QUEUE. It does not extend to the
|
|
67
|
+
* wiring: `jobs: []`, or a thunk that registers nothing because its import was
|
|
68
|
+
* dropped, produces a process that resolves a driver, consumes no queue,
|
|
69
|
+
* installs no schedule and answers `/health` with `ok`. Every scheduled job in
|
|
70
|
+
* the deployment stops and nothing reports it. So an empty `jobs` throws — at
|
|
71
|
+
* the factory for the array form, and after registration for the thunk form.
|
|
72
|
+
*
|
|
62
73
|
* ## The environment is read at `start()`, not at the factory
|
|
63
74
|
*
|
|
64
|
-
*
|
|
65
|
-
* `
|
|
66
|
-
*
|
|
67
|
-
* `
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
* the module that built the api), so the factory reads nothing and `start()`
|
|
71
|
-
* resolves everything at one single moment.
|
|
75
|
+
* The recommended host shape is factory-at-module-scope + `await
|
|
76
|
+
* jobsApi.start()` at process start. Reading part of the matrix at factory
|
|
77
|
+
* time would honour a late-arriving `JOBS_DRIVER` and silently ignore a
|
|
78
|
+
* late-arriving `REDIS_URL` (a host whose config module loads after the module
|
|
79
|
+
* that built the api), so the factory reads nothing and `start()` resolves
|
|
80
|
+
* everything at one single moment.
|
|
72
81
|
*/
|
|
73
82
|
|
|
74
83
|
/** What a route handler answers; the host maps it onto its response type. */
|
|
@@ -77,7 +86,7 @@ export interface JobsResponse {
|
|
|
77
86
|
body: unknown;
|
|
78
87
|
}
|
|
79
88
|
|
|
80
|
-
/** A framework-neutral route descriptor
|
|
89
|
+
/** A framework-neutral route descriptor. */
|
|
81
90
|
export interface JobsRoute {
|
|
82
91
|
method: "GET";
|
|
83
92
|
/** Path relative to the host's jobs mount, e.g. `/health`. */
|
|
@@ -178,9 +187,8 @@ function bindSweepLease(db: SweepLeaseDbProvider | undefined): WithSweepLease {
|
|
|
178
187
|
/** What one `start()`/`stop()` cycle has actually done in this process. */
|
|
179
188
|
interface RuntimeState {
|
|
180
189
|
/**
|
|
181
|
-
* `start()` has been ENTERED — the idempotency latch
|
|
182
|
-
*
|
|
183
|
-
* not retried by calling it again.
|
|
190
|
+
* `start()` has been ENTERED — the idempotency latch: a second call is a
|
|
191
|
+
* no-op, and a start that threw is not retried by calling it again.
|
|
184
192
|
*/
|
|
185
193
|
starting: boolean;
|
|
186
194
|
/**
|
|
@@ -282,9 +290,11 @@ async function startRuntime(
|
|
|
282
290
|
if (stopped()) return;
|
|
283
291
|
state.deliberatelyOff = deliberatelyOff;
|
|
284
292
|
if (!driver) {
|
|
285
|
-
// Fail closed, by design: "no driver" is a completed start
|
|
286
|
-
//
|
|
287
|
-
//
|
|
293
|
+
// Fail closed, by design: "no driver" is a completed start. Health still
|
|
294
|
+
// reports it — the driver check is null — so the probe sees what the log
|
|
295
|
+
// line said. Registration is deliberately skipped: a process with jobs
|
|
296
|
+
// disabled must not install seams whose enqueues would all report
|
|
297
|
+
// `no-driver`.
|
|
288
298
|
state.started = true;
|
|
289
299
|
return;
|
|
290
300
|
}
|
|
@@ -295,6 +305,11 @@ async function startRuntime(
|
|
|
295
305
|
// whatever is registered — a worker cannot consume a job it has never heard
|
|
296
306
|
// of, and a producer cannot enqueue one.
|
|
297
307
|
await registerJobs(config.jobs);
|
|
308
|
+
// The thunk form's half of the empty-`jobs` refusal (the array form was
|
|
309
|
+
// refused at the factory). It runs for BOTH roles: a producer that
|
|
310
|
+
// registered nothing enqueues nothing, which is the same silent stop as a
|
|
311
|
+
// worker that consumes nothing.
|
|
312
|
+
assertJobsRegistered("createApiJobs().start()");
|
|
298
313
|
if (stopped()) return;
|
|
299
314
|
|
|
300
315
|
if (!worker) {
|
|
@@ -315,6 +330,16 @@ async function startRuntime(
|
|
|
315
330
|
}
|
|
316
331
|
|
|
317
332
|
export function createApiJobs(config: JobsServerConfig): JobsApi {
|
|
333
|
+
// At ASSEMBLY, before anything else: a `jobs` that names nothing is a wiring
|
|
334
|
+
// error, and the factory is where the stack trace still points at the host
|
|
335
|
+
// module that wrote it.
|
|
336
|
+
assertJobsDeclared(config?.jobs);
|
|
337
|
+
// The same class of failure, one knob over: a retention window that is NaN
|
|
338
|
+
// or negative does not shrink retention, it stops bounding the backend at
|
|
339
|
+
// all — silently, weeks before anyone notices. The driver re-checks it, for
|
|
340
|
+
// a host that builds one directly off `@12-apps/jobs/bullmq`.
|
|
341
|
+
assertValidRetention(config?.retention);
|
|
342
|
+
|
|
318
343
|
const state: RuntimeState = {
|
|
319
344
|
starting: false,
|
|
320
345
|
started: false,
|
|
@@ -340,9 +365,8 @@ export function createApiJobs(config: JobsServerConfig): JobsApi {
|
|
|
340
365
|
|
|
341
366
|
async start(): Promise<void> {
|
|
342
367
|
// The latch, not the success flag: a second call is a no-op, and a
|
|
343
|
-
// start that threw is not silently retried
|
|
344
|
-
//
|
|
345
|
-
// declare, as its last statement.
|
|
368
|
+
// start that threw is not silently retried. Success is `startRuntime`'s
|
|
369
|
+
// to declare, as its last statement.
|
|
346
370
|
if (state.starting) return;
|
|
347
371
|
state.starting = true;
|
|
348
372
|
// The whole env matrix is read HERE, at the same moment the driver
|
package/src/server/index.ts
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
* internal probes live.
|
|
8
8
|
*
|
|
9
9
|
* const jobsApi = createApiJobs({
|
|
10
|
-
* jobs: () => import("./lib/jobs"), // defineJob modules
|
|
10
|
+
* jobs: () => import("./lib/jobs"), // defineJob modules — required,
|
|
11
|
+
* // and refused if it names nothing
|
|
11
12
|
* db: () => getPrismaClient(), // the sweep_leases table
|
|
13
|
+
* events: { onJobFailed: … }, // dead-letters, for YOUR notifier
|
|
12
14
|
* });
|
|
13
15
|
* await jobsApi.start(); // at process start
|
|
14
16
|
* app.route("/api/internal/jobs", jobsRouter(jobsApi)); // ./hono
|
|
@@ -24,4 +26,10 @@ export type {
|
|
|
24
26
|
JobsResponse,
|
|
25
27
|
JobsRoute,
|
|
26
28
|
} from "./create-api-jobs";
|
|
29
|
+
export { JobsConfigError } from "./config";
|
|
27
30
|
export type { JobsDriverChoice, JobsServerConfig, JobsSource } from "./config";
|
|
31
|
+
// Re-exported so a host that only imports `/server` can catch what the factory
|
|
32
|
+
// and `start()` throw without also reaching for the root entry point.
|
|
33
|
+
export { NoJobsRegisteredError } from "../core/registry";
|
|
34
|
+
export { InvalidJobRetentionError } from "../core/retention";
|
|
35
|
+
export type { JobEvents, JobRetention } from "../core/types";
|
|
@@ -4,13 +4,14 @@ import { createInlineJobDriver } from "../drivers/inline";
|
|
|
4
4
|
import type { JobsDriverChoice, JobsServerConfig, ResolvedConfig } from "./config";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Driver resolution
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* a
|
|
7
|
+
* Driver resolution.
|
|
8
|
+
*
|
|
9
|
+
* The choice can arrive from the environment (`JOBS_DRIVER`) or through
|
|
10
|
+
* `config.driver` — a name or a ready-made instance. The resolution tracks
|
|
11
|
+
* WHERE the choice came from, validates the config path exactly as strictly as
|
|
12
|
+
* the env path, and names the right knob in every error message: an operator
|
|
13
|
+
* reading a `JOBS_DRIVER` message for a choice made in code would hunt for an
|
|
14
|
+
* env var that is not set.
|
|
14
15
|
*/
|
|
15
16
|
|
|
16
17
|
/** A resolved choice, and which knob said so. */
|
|
@@ -136,7 +137,14 @@ async function resolveBullMq(
|
|
|
136
137
|
// once the host bundles this package's published TS source.
|
|
137
138
|
const { createBullMqJobDriver } = await import("../drivers/bullmq");
|
|
138
139
|
return {
|
|
139
|
-
driver: createBullMqJobDriver({
|
|
140
|
+
driver: createBullMqJobDriver({
|
|
141
|
+
redisUrl,
|
|
142
|
+
logger,
|
|
143
|
+
prefix: resolved.queuePrefix,
|
|
144
|
+
events: resolved.events,
|
|
145
|
+
retention: resolved.retention,
|
|
146
|
+
defaultConcurrency: resolved.defaultConcurrency,
|
|
147
|
+
}),
|
|
140
148
|
deliberatelyOff: false,
|
|
141
149
|
};
|
|
142
150
|
} catch (error) {
|
|
@@ -165,11 +173,8 @@ export async function resolveDriver(
|
|
|
165
173
|
|
|
166
174
|
if (choice === "off") {
|
|
167
175
|
if (production) {
|
|
168
|
-
// Logged in production even for an explicit off
|
|
169
|
-
//
|
|
170
|
-
// domain sweeps, and the examples were dropped when this moved into
|
|
171
|
-
// the package. Production with no queue is worth a line in the log
|
|
172
|
-
// however it came about.
|
|
176
|
+
// Logged in production even for an explicit off: production with no
|
|
177
|
+
// queue is worth a line in the log however it came about.
|
|
173
178
|
logger.error(
|
|
174
179
|
"No job driver: scheduled work will NOT run in this deployment. Set REDIS_URL.",
|
|
175
180
|
);
|
|
@@ -195,7 +200,10 @@ export async function resolveDriver(
|
|
|
195
200
|
// Detached on purpose: in a dev server the handler must not run inside
|
|
196
201
|
// the request that enqueued it. A test that wants to await the handler
|
|
197
202
|
// passes its own `createInlineJobDriver({ await: true })` instance.
|
|
198
|
-
return {
|
|
203
|
+
return {
|
|
204
|
+
driver: createInlineJobDriver({ logger, await: false, events: resolved.events }),
|
|
205
|
+
deliberatelyOff: false,
|
|
206
|
+
};
|
|
199
207
|
}
|
|
200
208
|
|
|
201
209
|
return resolveBullMq(named, resolved);
|