@12-apps/jobs 1.19.0 → 1.20.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 +139 -0
- package/README.md +58 -7
- package/package.json +15 -3
- package/prisma/jobs.prisma +48 -0
- package/prisma/migrations/20260727190000_sweep_leases/migration.sql +13 -0
- package/src/core/queues.ts +26 -0
- package/src/hono/index.ts +44 -0
- package/src/index.ts +24 -0
- package/src/lease/sweep-lease.ts +234 -0
- package/src/server/config.ts +112 -0
- package/src/server/create-api-jobs.ts +363 -0
- package/src/server/index.ts +27 -0
- package/src/server/resolve-driver.ts +202 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { listJobs } from "../core/registry";
|
|
2
|
+
import {
|
|
3
|
+
configureJobs,
|
|
4
|
+
getJobDriver,
|
|
5
|
+
startJobWorkers,
|
|
6
|
+
stopJobs,
|
|
7
|
+
} from "../core/runtime";
|
|
8
|
+
import type { JobLogger } from "../core/types";
|
|
9
|
+
import {
|
|
10
|
+
createSweepLease,
|
|
11
|
+
type SweepLeaseDbProvider,
|
|
12
|
+
type WithSweepLease,
|
|
13
|
+
} from "../lease/sweep-lease";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
isWorkerProcess,
|
|
17
|
+
resolveConfig,
|
|
18
|
+
type JobsServerConfig,
|
|
19
|
+
type JobsSource,
|
|
20
|
+
type ResolvedConfig,
|
|
21
|
+
} from "./config";
|
|
22
|
+
import { resolveDriver } from "./resolve-driver";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `createApiJobs` — the jobs runtime as ONE mountable surface.
|
|
26
|
+
*
|
|
27
|
+
* The bootstrap used to live in the host: a runtime module that read the
|
|
28
|
+
* environment, picked a driver, refused the unsafe one in production,
|
|
29
|
+
* registered every job, started the workers and hooked the drain signals.
|
|
30
|
+
* None of that is host domain — the only decisions a host genuinely owns are
|
|
31
|
+
* WHICH jobs exist and WHERE the lease table lives, and both arrive here as
|
|
32
|
+
* config.
|
|
33
|
+
*
|
|
34
|
+
* ## Two roles, one image
|
|
35
|
+
*
|
|
36
|
+
* A PRODUCER (the web server) configures the driver and enqueues. A CONSUMER
|
|
37
|
+
* (the worker) does that and also starts consuming, which is the only
|
|
38
|
+
* difference between the two — so the worker is the SAME container image with
|
|
39
|
+
* `JOBS_WORKER=1` set, not a second build. Splitting the roles keeps a slow
|
|
40
|
+
* job off the request path; running them in one process (set `JOBS_WORKER=1`
|
|
41
|
+
* on the web service and skip the worker service) also works and is a
|
|
42
|
+
* reasonable choice on a single small box.
|
|
43
|
+
*
|
|
44
|
+
* ## Fail closed, never fail loud
|
|
45
|
+
*
|
|
46
|
+
* A misconfigured queue must never take the host app down. Every problem here
|
|
47
|
+
* (no `REDIS_URL` in production, `inline` requested in production, an
|
|
48
|
+
* unparseable URL, `bullmq` failing to load) resolves to NO driver plus a
|
|
49
|
+
* loud error: enqueues then report `no-driver` instead of throwing, the
|
|
50
|
+
* durable rows still get written, and the sweeps catch up once a worker
|
|
51
|
+
* exists. The health endpoint reports the same fact as a 503, so a probe can
|
|
52
|
+
* see what the log line said.
|
|
53
|
+
*
|
|
54
|
+
* ## Zero config is a real mode
|
|
55
|
+
*
|
|
56
|
+
* `createApiJobs({ jobs })` with nothing else and no `REDIS_URL` in the
|
|
57
|
+
* environment resolves to the INLINE driver outside production: handlers run
|
|
58
|
+
* in-process, schedules do not fire (and say so), and the app starts green
|
|
59
|
+
* with no Redis container. That is the default a fresh host is supposed to
|
|
60
|
+
* boot in.
|
|
61
|
+
*
|
|
62
|
+
* ## The environment is read at `start()`, not at the factory
|
|
63
|
+
*
|
|
64
|
+
* future-pay's `bootstrapJobs()` read the whole matrix — `JOBS_DRIVER`,
|
|
65
|
+
* `REDIS_URL`, `NODE_ENV`, `JOBS_WORKER`, `JOBS_QUEUE_PREFIX` — at the moment
|
|
66
|
+
* it ran, and the recommended host shape is factory-at-module-scope +
|
|
67
|
+
* `await jobsApi.start()` at process start. Reading part of the matrix at
|
|
68
|
+
* factory time would honour a late-arriving `JOBS_DRIVER` and silently
|
|
69
|
+
* ignore a late-arriving `REDIS_URL` (a host whose config module loads after
|
|
70
|
+
* the module that built the api), so the factory reads nothing and `start()`
|
|
71
|
+
* resolves everything at one single moment.
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
/** What a route handler answers; the host maps it onto its response type. */
|
|
75
|
+
export interface JobsResponse {
|
|
76
|
+
status: number;
|
|
77
|
+
body: unknown;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A framework-neutral route descriptor, the same shape report-builder mounts. */
|
|
81
|
+
export interface JobsRoute {
|
|
82
|
+
method: "GET";
|
|
83
|
+
/** Path relative to the host's jobs mount, e.g. `/health`. */
|
|
84
|
+
path: string;
|
|
85
|
+
handle(): Promise<JobsResponse>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The health payload `GET /health` answers with.
|
|
90
|
+
*
|
|
91
|
+
* Three states, two status codes: `ok` (200) is a working runtime; `disabled`
|
|
92
|
+
* (200) is an EXPLICIT `off` — `JOBS_DRIVER=off` or
|
|
93
|
+
* `createApiJobs({ driver: "off" })` — OUTSIDE production: a review box or CI
|
|
94
|
+
* that genuinely wants no queue, which must not fail a readiness aggregate
|
|
95
|
+
* forever; `degraded` (503) is everything that is wrong rather than chosen —
|
|
96
|
+
* a misconfiguration, a failed start, a drained worker, and any production
|
|
97
|
+
* with no queue, spelled out or not (production never deliberately wants
|
|
98
|
+
* none).
|
|
99
|
+
*/
|
|
100
|
+
export interface JobsHealth {
|
|
101
|
+
status: "ok" | "degraded" | "disabled";
|
|
102
|
+
checks: {
|
|
103
|
+
/**
|
|
104
|
+
* `start()` has completed WITHOUT THROWING in this process. A start that
|
|
105
|
+
* rejected (a duplicate job name, a failing registration import) leaves
|
|
106
|
+
* this false and the status degraded — a failed boot must never probe
|
|
107
|
+
* green.
|
|
108
|
+
*/
|
|
109
|
+
configured: boolean;
|
|
110
|
+
/**
|
|
111
|
+
* The resolved driver's kind, or null when jobs are disabled. Reads the
|
|
112
|
+
* process-wide runtime: with two `createApiJobs` instances in one process
|
|
113
|
+
* (never a real host's shape) each reports whichever driver was
|
|
114
|
+
* configured last.
|
|
115
|
+
*/
|
|
116
|
+
driver: string | null;
|
|
117
|
+
/**
|
|
118
|
+
* Whether this process is meant to consume, not just enqueue.
|
|
119
|
+
* Provisional until `start()` has run: before then it is a best-effort
|
|
120
|
+
* read of `config.worker` / `JOBS_WORKER` at probe time (pre-start health
|
|
121
|
+
* is degraded regardless, so nothing is decided from it).
|
|
122
|
+
*/
|
|
123
|
+
worker: boolean;
|
|
124
|
+
/** Whether consuming actually began (always false in a producer). */
|
|
125
|
+
consuming: boolean;
|
|
126
|
+
/** Registered job definitions. */
|
|
127
|
+
jobs: number;
|
|
128
|
+
/** How many of them run on a cron schedule. */
|
|
129
|
+
schedules: number;
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface JobsApi {
|
|
134
|
+
/** The health endpoint. Mount with `@12-apps/jobs/hono` or your own adapter. */
|
|
135
|
+
routes: JobsRoute[];
|
|
136
|
+
/**
|
|
137
|
+
* Configure the driver, register every job, and — in a worker — start
|
|
138
|
+
* consuming and install the cron schedules. Call it once at process start,
|
|
139
|
+
* before the first request; safe to call again (no-op). Never throws for a
|
|
140
|
+
* MISCONFIGURED queue (see "fail closed" above) — only for a programming
|
|
141
|
+
* error such as a duplicate job name.
|
|
142
|
+
*/
|
|
143
|
+
start(): Promise<void>;
|
|
144
|
+
/** Stop consuming and release the driver's connections, draining in-flight jobs. */
|
|
145
|
+
stop(): Promise<void>;
|
|
146
|
+
/**
|
|
147
|
+
* Single-writer lease for cron sweeps, bound to `config.db`. See
|
|
148
|
+
* {@link createSweepLease} for the contract.
|
|
149
|
+
*/
|
|
150
|
+
withSweepLease: WithSweepLease;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Trigger the host's registrations. An array means "already registered". */
|
|
154
|
+
async function registerJobs(jobs: JobsSource): Promise<void> {
|
|
155
|
+
if (typeof jobs === "function") await jobs();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The lease bound to the configured db — or, without one, a loud refusal. */
|
|
159
|
+
function bindSweepLease(db: SweepLeaseDbProvider | undefined): WithSweepLease {
|
|
160
|
+
const lease = db ? createSweepLease({ db }) : null;
|
|
161
|
+
// Async so the refusal is a REJECTION, which is what the type promises: a
|
|
162
|
+
// synchronous throw from a Promise-returning function escapes `.catch()`
|
|
163
|
+
// and `Promise.all`, and a caller that collected the promise before
|
|
164
|
+
// awaiting it would never see the error at all.
|
|
165
|
+
return async (name, ttlMs, work) => {
|
|
166
|
+
if (!lease) {
|
|
167
|
+
// Loud, not silent: a sweep whose lease quietly no-ops is exactly the
|
|
168
|
+
// unprotected concurrent pass the lease exists to prevent. Same rule as
|
|
169
|
+
// the claim itself — only a lost race may be silent.
|
|
170
|
+
throw new Error(
|
|
171
|
+
"withSweepLease needs a `db` provider in createApiJobs({ db }).",
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
return lease.withSweepLease(name, ttlMs, work);
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** What one `start()`/`stop()` cycle has actually done in this process. */
|
|
179
|
+
interface RuntimeState {
|
|
180
|
+
/**
|
|
181
|
+
* `start()` has been ENTERED — the idempotency latch, exactly future-pay's
|
|
182
|
+
* `bootstrapped` flag: a second call is a no-op, and a start that threw is
|
|
183
|
+
* not retried by calling it again.
|
|
184
|
+
*/
|
|
185
|
+
starting: boolean;
|
|
186
|
+
/**
|
|
187
|
+
* `start()` has COMPLETED without throwing — the success flag health keys
|
|
188
|
+
* on. Kept separate from `starting` because a start that rejected must
|
|
189
|
+
* leave the probe red, not report the half-configured runtime as ok.
|
|
190
|
+
*/
|
|
191
|
+
started: boolean;
|
|
192
|
+
consuming: boolean;
|
|
193
|
+
/** Resolution chose "no driver" because it was TOLD to — see {@link JobsHealth}. */
|
|
194
|
+
deliberatelyOff: boolean;
|
|
195
|
+
/**
|
|
196
|
+
* The drain hooks have been installed — once per instance, ever. A
|
|
197
|
+
* stop()/start() cycle must not stack a second `process.once` pair, or one
|
|
198
|
+
* real signal would drain the driver twice.
|
|
199
|
+
*/
|
|
200
|
+
hooksInstalled: boolean;
|
|
201
|
+
/**
|
|
202
|
+
* The env-resolved config, cached by `start()`. Null until then: the
|
|
203
|
+
* factory deliberately reads no environment (see the module header), so a
|
|
204
|
+
* pre-start health probe falls back to reading the worker switch at handle
|
|
205
|
+
* time.
|
|
206
|
+
*/
|
|
207
|
+
resolved: ResolvedConfig | null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** "disabled" is a choice, "ok" is a working runtime, "degraded" is a fault. */
|
|
211
|
+
function statusOf(
|
|
212
|
+
state: RuntimeState,
|
|
213
|
+
driverKind: string | null,
|
|
214
|
+
worker: boolean,
|
|
215
|
+
): JobsHealth["status"] {
|
|
216
|
+
if (state.started && state.deliberatelyOff) return "disabled";
|
|
217
|
+
const ready = state.started && driverKind !== null && (!worker || state.consuming);
|
|
218
|
+
return ready ? "ok" : "degraded";
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function healthOf(config: JobsServerConfig, state: RuntimeState): JobsHealth {
|
|
222
|
+
const definitions = listJobs();
|
|
223
|
+
const driver = getJobDriver()?.kind ?? null;
|
|
224
|
+
const worker = state.resolved
|
|
225
|
+
? state.resolved.worker
|
|
226
|
+
: (config.worker ?? isWorkerProcess());
|
|
227
|
+
return {
|
|
228
|
+
status: statusOf(state, driver, worker),
|
|
229
|
+
checks: {
|
|
230
|
+
configured: state.started,
|
|
231
|
+
driver,
|
|
232
|
+
worker,
|
|
233
|
+
consuming: state.consuming,
|
|
234
|
+
jobs: definitions.length,
|
|
235
|
+
schedules: definitions.filter((job) => job.schedule).length,
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Let in-flight jobs finish on a deploy. Deliberately does NOT call
|
|
242
|
+
* process.exit — the host owns the shutdown, and cutting it short here would
|
|
243
|
+
* kill the very requests we are draining for.
|
|
244
|
+
*
|
|
245
|
+
* The hook clears the instance state BEFORE the async drain: a drained worker
|
|
246
|
+
* is no longer consuming, and the readiness probe must stop saying it is the
|
|
247
|
+
* moment the drain begins — that flip is what tells a rolling deploy to stop
|
|
248
|
+
* routing to this process.
|
|
249
|
+
*/
|
|
250
|
+
function installDrainHooks(logger: JobLogger, state: RuntimeState): void {
|
|
251
|
+
for (const signal of ["SIGTERM", "SIGINT"] as const) {
|
|
252
|
+
process.once(signal, () => {
|
|
253
|
+
state.consuming = false;
|
|
254
|
+
state.started = false;
|
|
255
|
+
void stopJobs().catch((error) => logger.error("shutdown failed:", error));
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The `start()` body: configure, register, and — in a worker — consume.
|
|
262
|
+
*
|
|
263
|
+
* `state.started` is set LAST on every successful path, so a throw anywhere
|
|
264
|
+
* in here (a duplicate job name, a registration import that fails) leaves the
|
|
265
|
+
* health endpoint degraded rather than reporting the half-configured runtime
|
|
266
|
+
* as ok.
|
|
267
|
+
*/
|
|
268
|
+
async function startRuntime(
|
|
269
|
+
config: JobsServerConfig,
|
|
270
|
+
resolved: ResolvedConfig,
|
|
271
|
+
state: RuntimeState,
|
|
272
|
+
): Promise<void> {
|
|
273
|
+
const { logger, worker } = resolved;
|
|
274
|
+
// A stop() that lands while start() is awaiting wins: `stop()` clears
|
|
275
|
+
// `starting`, and each publish point below re-checks it — otherwise the
|
|
276
|
+
// start's continuation would set consuming/started AFTER the stop and a
|
|
277
|
+
// stopped runtime would advertise itself as a healthy consumer (the same
|
|
278
|
+
// untruth the drain-hook fix closed, one window narrower).
|
|
279
|
+
const stopped = (): boolean => !state.starting;
|
|
280
|
+
|
|
281
|
+
const { driver, deliberatelyOff } = await resolveDriver(config, resolved);
|
|
282
|
+
if (stopped()) return;
|
|
283
|
+
state.deliberatelyOff = deliberatelyOff;
|
|
284
|
+
if (!driver) {
|
|
285
|
+
// Fail closed, by design: "no driver" is a completed start (future-pay's
|
|
286
|
+
// bootstrapJobs returned normally here too). Health still reports it —
|
|
287
|
+
// the driver check is null — so the probe sees what the log line said.
|
|
288
|
+
state.started = true;
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
configureJobs({ driver, logger });
|
|
293
|
+
|
|
294
|
+
// Registers every job. Must happen BEFORE `startJobWorkers`, which consumes
|
|
295
|
+
// whatever is registered — a worker cannot consume a job it has never heard
|
|
296
|
+
// of, and a producer cannot enqueue one.
|
|
297
|
+
await registerJobs(config.jobs);
|
|
298
|
+
if (stopped()) return;
|
|
299
|
+
|
|
300
|
+
if (!worker) {
|
|
301
|
+
logger.info("producer mode: jobs will be enqueued, not consumed by this process.");
|
|
302
|
+
state.started = true;
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
await startJobWorkers();
|
|
307
|
+
if (stopped()) return;
|
|
308
|
+
state.consuming = true;
|
|
309
|
+
|
|
310
|
+
if ((config.installShutdownHooks ?? true) && !state.hooksInstalled) {
|
|
311
|
+
installDrainHooks(logger, state);
|
|
312
|
+
state.hooksInstalled = true;
|
|
313
|
+
}
|
|
314
|
+
state.started = true;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function createApiJobs(config: JobsServerConfig): JobsApi {
|
|
318
|
+
const state: RuntimeState = {
|
|
319
|
+
starting: false,
|
|
320
|
+
started: false,
|
|
321
|
+
consuming: false,
|
|
322
|
+
deliberatelyOff: false,
|
|
323
|
+
hooksInstalled: false,
|
|
324
|
+
resolved: null,
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
routes: [
|
|
329
|
+
{
|
|
330
|
+
method: "GET",
|
|
331
|
+
path: "/health",
|
|
332
|
+
handle: async () => {
|
|
333
|
+
const body = healthOf(config, state);
|
|
334
|
+
// Only "degraded" is a failure; "disabled" is a choice and must not
|
|
335
|
+
// fail a readiness aggregate forever.
|
|
336
|
+
return { status: body.status === "degraded" ? 503 : 200, body };
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
],
|
|
340
|
+
|
|
341
|
+
async start(): Promise<void> {
|
|
342
|
+
// The latch, not the success flag: a second call is a no-op, and a
|
|
343
|
+
// start that threw is not silently retried — future-pay's bootstrapJobs
|
|
344
|
+
// latched `bootstrapped` the same way. Success is `startRuntime`'s to
|
|
345
|
+
// declare, as its last statement.
|
|
346
|
+
if (state.starting) return;
|
|
347
|
+
state.starting = true;
|
|
348
|
+
// The whole env matrix is read HERE, at the same moment the driver
|
|
349
|
+
// choice reads JOBS_DRIVER — never at factory time.
|
|
350
|
+
state.resolved = resolveConfig(config);
|
|
351
|
+
await startRuntime(config, state.resolved, state);
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
async stop(): Promise<void> {
|
|
355
|
+
await stopJobs();
|
|
356
|
+
state.consuming = false;
|
|
357
|
+
state.started = false;
|
|
358
|
+
state.starting = false;
|
|
359
|
+
},
|
|
360
|
+
|
|
361
|
+
withSweepLease: bindSweepLease(config.db),
|
|
362
|
+
};
|
|
363
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@12-apps/jobs/server` — the backend factory.
|
|
3
|
+
*
|
|
4
|
+
* One call wires the whole operational half: driver resolution (with the
|
|
5
|
+
* inline zero-config default), job registration, the worker switch, graceful
|
|
6
|
+
* drain, the sweep lease and a health endpoint the host mounts wherever its
|
|
7
|
+
* internal probes live.
|
|
8
|
+
*
|
|
9
|
+
* const jobsApi = createApiJobs({
|
|
10
|
+
* jobs: () => import("./lib/jobs"), // defineJob modules
|
|
11
|
+
* db: () => getPrismaClient(), // the sweep_leases table
|
|
12
|
+
* });
|
|
13
|
+
* await jobsApi.start(); // at process start
|
|
14
|
+
* app.route("/api/internal/jobs", jobsRouter(jobsApi)); // ./hono
|
|
15
|
+
*
|
|
16
|
+
* There is deliberately NO `createWebJobs` half: this runtime has no screens
|
|
17
|
+
* — its API surface is the health endpoint, and its user-visible effects are
|
|
18
|
+
* whatever the host's job handlers do. See ADOPTING.md.
|
|
19
|
+
*/
|
|
20
|
+
export { createApiJobs } from "./create-api-jobs";
|
|
21
|
+
export type {
|
|
22
|
+
JobsApi,
|
|
23
|
+
JobsHealth,
|
|
24
|
+
JobsResponse,
|
|
25
|
+
JobsRoute,
|
|
26
|
+
} from "./create-api-jobs";
|
|
27
|
+
export type { JobsDriverChoice, JobsServerConfig, JobsSource } from "./config";
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import type { JobDriver } from "../core/types";
|
|
2
|
+
import { createInlineJobDriver } from "../drivers/inline";
|
|
3
|
+
|
|
4
|
+
import type { JobsDriverChoice, JobsServerConfig, ResolvedConfig } from "./config";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Driver resolution — future-pay `runtime.ts`'s `readDriverChoice` +
|
|
8
|
+
* `resolveDriver`. The choice can also arrive through `config.driver` (a name
|
|
9
|
+
* or an instance), which future-pay had no seam for — so the resolution
|
|
10
|
+
* tracks WHERE the choice came from, validates the config path exactly as
|
|
11
|
+
* strictly as the env path, and names the right knob in every error message.
|
|
12
|
+
* The `JOBS_DRIVER=…` messages stay byte-identical to future-pay's, because
|
|
13
|
+
* a host greps for them.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A resolved choice, and which knob said so. */
|
|
17
|
+
interface DriverChoice {
|
|
18
|
+
choice: JobsDriverChoice;
|
|
19
|
+
/**
|
|
20
|
+
* `env` is `JOBS_DRIVER`; `config` is `createApiJobs({ driver })`;
|
|
21
|
+
* `default` is the matrix (Redis presence / production). An INVALID value
|
|
22
|
+
* resolves to `off` with source `default`: a misconfiguration is not a
|
|
23
|
+
* deliberate opt-out.
|
|
24
|
+
*/
|
|
25
|
+
source: "config" | "env" | "default";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The allow-list. Anything else is a misconfiguration, never a guess. */
|
|
29
|
+
function asChoice(value: string): JobsDriverChoice | null {
|
|
30
|
+
return value === "bullmq" || value === "inline" || value === "off" ? value : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolveChoice(
|
|
34
|
+
config: JobsServerConfig,
|
|
35
|
+
resolved: ResolvedConfig,
|
|
36
|
+
): DriverChoice {
|
|
37
|
+
if (typeof config.driver === "string") {
|
|
38
|
+
const named = asChoice(config.driver.trim().toLowerCase());
|
|
39
|
+
if (named) return { choice: named, source: "config" };
|
|
40
|
+
// The type forbids this, but a JS consumer can pass anything — and the
|
|
41
|
+
// env path validates, so the config path must too: same value, same
|
|
42
|
+
// loud-and-off outcome, never a silent fallthrough to bullmq.
|
|
43
|
+
resolved.logger.error(
|
|
44
|
+
`createApiJobs({ driver: "${config.driver}" }) is not a driver; jobs are disabled.`,
|
|
45
|
+
);
|
|
46
|
+
return { choice: "off", source: "default" };
|
|
47
|
+
}
|
|
48
|
+
if (config.driver !== undefined) {
|
|
49
|
+
// Not a string, and the caller already peeled off object instances — so
|
|
50
|
+
// this is 42, true, null: junk only a JS consumer can pass. It must not
|
|
51
|
+
// fall through to the env matrix as if nothing had been said.
|
|
52
|
+
resolved.logger.error(
|
|
53
|
+
`createApiJobs({ driver: ${JSON.stringify(config.driver)} }) is not a driver; jobs are disabled.`,
|
|
54
|
+
);
|
|
55
|
+
return { choice: "off", source: "default" };
|
|
56
|
+
}
|
|
57
|
+
const configured = process.env.JOBS_DRIVER?.trim().toLowerCase();
|
|
58
|
+
if (configured) {
|
|
59
|
+
const named = asChoice(configured);
|
|
60
|
+
if (named) return { choice: named, source: "env" };
|
|
61
|
+
resolved.logger.error(
|
|
62
|
+
`JOBS_DRIVER="${configured}" is not a driver; jobs are disabled.`,
|
|
63
|
+
);
|
|
64
|
+
return { choice: "off", source: "default" };
|
|
65
|
+
}
|
|
66
|
+
// Unset: Redis being configured is the signal that a queue exists.
|
|
67
|
+
if (resolved.redisUrl) return { choice: "bullmq", source: "default" };
|
|
68
|
+
return { choice: resolved.production ? "off" : "inline", source: "default" };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Names the knob a choice came from, so an operator debugging the log line is
|
|
73
|
+
* sent to the setting that actually said it — a `JOBS_DRIVER` message for a
|
|
74
|
+
* choice made in code would have them hunting for an env var that is not set.
|
|
75
|
+
*/
|
|
76
|
+
function knob(choice: JobsDriverChoice, source: DriverChoice["source"]): string {
|
|
77
|
+
return source === "config"
|
|
78
|
+
? `createApiJobs({ driver: "${choice}" })`
|
|
79
|
+
: `JOBS_DRIVER=${choice}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* What resolution decided, and whether "no driver" was a deliberate choice.
|
|
84
|
+
* Not exported: `createApiJobs` destructures it, and an exported type nothing
|
|
85
|
+
* imports is exactly what the knip gate is there to stop.
|
|
86
|
+
*/
|
|
87
|
+
interface DriverResolution {
|
|
88
|
+
driver: JobDriver | null;
|
|
89
|
+
/**
|
|
90
|
+
* True only for an EXPLICIT `off` — `JOBS_DRIVER=off` or
|
|
91
|
+
* `createApiJobs({ driver: "off" })` — OUTSIDE production: a review box or
|
|
92
|
+
* a CI environment that genuinely wants no queue. False for every
|
|
93
|
+
* misconfiguration that merely RESOLVES to off, and false in production
|
|
94
|
+
* even when spelled out (production never deliberately wants no queue), so
|
|
95
|
+
* health can tell "disabled by choice" apart from "broken".
|
|
96
|
+
*/
|
|
97
|
+
deliberatelyOff: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const off = (deliberatelyOff: boolean): DriverResolution => ({
|
|
101
|
+
driver: null,
|
|
102
|
+
deliberatelyOff,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* An instance passed in is the host's own decision — for anything except the
|
|
107
|
+
* inline driver in production, which is refused for exactly the reason the
|
|
108
|
+
* NAMED choice is: no retries, no schedules, and work that dies with the
|
|
109
|
+
* request that enqueued it. An instance must not be the quiet way around
|
|
110
|
+
* that guard.
|
|
111
|
+
*/
|
|
112
|
+
function resolveInstance(driver: JobDriver, resolved: ResolvedConfig): DriverResolution {
|
|
113
|
+
if (resolved.production && driver.kind === "inline") {
|
|
114
|
+
resolved.logger.error(
|
|
115
|
+
"an inline driver instance is refused in production; jobs are disabled.",
|
|
116
|
+
);
|
|
117
|
+
return off(false);
|
|
118
|
+
}
|
|
119
|
+
return { driver, deliberatelyOff: false };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The bullmq branch: a Redis URL is required, and the import stays lazy. */
|
|
123
|
+
async function resolveBullMq(
|
|
124
|
+
choice: DriverChoice,
|
|
125
|
+
resolved: ResolvedConfig,
|
|
126
|
+
): Promise<DriverResolution> {
|
|
127
|
+
const { logger, redisUrl } = resolved;
|
|
128
|
+
if (!redisUrl) {
|
|
129
|
+
logger.error(`${knob(choice.choice, choice.source)} needs REDIS_URL; jobs are disabled.`);
|
|
130
|
+
return off(false);
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
// Imported lazily so `bullmq` (and ioredis) are pulled in only by a
|
|
134
|
+
// process that actually talks to Redis — never into a bundle that only
|
|
135
|
+
// enqueues. The specifier is a literal, which is what keeps it loadable
|
|
136
|
+
// once the host bundles this package's published TS source.
|
|
137
|
+
const { createBullMqJobDriver } = await import("../drivers/bullmq");
|
|
138
|
+
return {
|
|
139
|
+
driver: createBullMqJobDriver({ redisUrl, logger, prefix: resolved.queuePrefix }),
|
|
140
|
+
deliberatelyOff: false,
|
|
141
|
+
};
|
|
142
|
+
} catch (error) {
|
|
143
|
+
logger.error("could not create the BullMQ driver; jobs are disabled:", error);
|
|
144
|
+
return off(false);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Pick the driver, or NULL for "jobs are disabled in this process" — which is
|
|
150
|
+
* always accompanied by a loud log line, never silent, unless it was a
|
|
151
|
+
* deliberate `off`.
|
|
152
|
+
*/
|
|
153
|
+
export async function resolveDriver(
|
|
154
|
+
config: JobsServerConfig,
|
|
155
|
+
resolved: ResolvedConfig,
|
|
156
|
+
): Promise<DriverResolution> {
|
|
157
|
+
const { logger, production } = resolved;
|
|
158
|
+
|
|
159
|
+
if (config.driver && typeof config.driver === "object") {
|
|
160
|
+
return resolveInstance(config.driver, resolved);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const named = resolveChoice(config, resolved);
|
|
164
|
+
const { choice, source } = named;
|
|
165
|
+
|
|
166
|
+
if (choice === "off") {
|
|
167
|
+
if (production) {
|
|
168
|
+
// Logged in production even for an explicit off — the BEHAVIOUR is
|
|
169
|
+
// future-pay's runtime.ts, though not the bytes: its message named the
|
|
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.
|
|
173
|
+
logger.error(
|
|
174
|
+
"No job driver: scheduled work will NOT run in this deployment. Set REDIS_URL.",
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
// Deliberate only OUTSIDE production. A review box or CI genuinely wants
|
|
178
|
+
// no queue; production never does — a JOBS_DRIVER=off that reaches it
|
|
179
|
+
// through a shared env template or a promoted staging config is a
|
|
180
|
+
// mistake, and the probe must answer 503 exactly like the unconfigured
|
|
181
|
+
// case rather than green-light the same fact the error line above just
|
|
182
|
+
// reported.
|
|
183
|
+
return off(source !== "default" && !production);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (choice === "inline") {
|
|
187
|
+
if (production) {
|
|
188
|
+
// Inline runs the handler inside whatever request enqueued it and has
|
|
189
|
+
// no retries and no schedules. That is a development convenience, and
|
|
190
|
+
// in production it would silently drop work a crash interrupts.
|
|
191
|
+
logger.error(`${knob(choice, source)} is refused in production; jobs are disabled.`);
|
|
192
|
+
return off(false);
|
|
193
|
+
}
|
|
194
|
+
logger.info("using the inline job driver (no Redis): schedules will not fire.");
|
|
195
|
+
// Detached on purpose: in a dev server the handler must not run inside
|
|
196
|
+
// the request that enqueued it. A test that wants to await the handler
|
|
197
|
+
// passes its own `createInlineJobDriver({ await: true })` instance.
|
|
198
|
+
return { driver: createInlineJobDriver({ logger, await: false }), deliberatelyOff: false };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return resolveBullMq(named, resolved);
|
|
202
|
+
}
|