@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/ADOPTING.md
CHANGED
|
@@ -3,14 +3,17 @@
|
|
|
3
3
|
This package is a **plug-and-play background-job runtime**: one library,
|
|
4
4
|
reusable across repositories, exposing standardized surfaces. A host repo only
|
|
5
5
|
*points* at these surfaces — when the library updates, every host updates with
|
|
6
|
-
**no app changes**.
|
|
7
|
-
|
|
6
|
+
**no app changes**.
|
|
7
|
+
|
|
8
|
+
It contains no job of its own. Not one name, not one schedule, not one retry
|
|
9
|
+
policy, not one queue tuned to a product's SLA — those are the host's, and they
|
|
10
|
+
arrive as config. What the package owns is the RUNNING of them.
|
|
8
11
|
|
|
9
12
|
## The standardized plugin surfaces
|
|
10
13
|
|
|
11
14
|
| Surface | Export | What the host does |
|
|
12
15
|
|---|---|---|
|
|
13
|
-
| **Core library** | `@12-apps/jobs` | `defineJob` next to the domain each job belongs to (the import IS the registration), `enqueue` at emit sites, `SWEEP_QUEUE` + `createSweepLease` for scheduled sweeps. Never drags Redis into a bundle. |
|
|
16
|
+
| **Core library** | `@12-apps/jobs` | `defineJob` next to the domain each job belongs to (the import IS the registration), `enqueue` at emit sites, `DEFAULT_QUEUE` / `SWEEP_QUEUE` + `createSweepLease` for scheduled sweeps. Never drags Redis into a bundle. |
|
|
14
17
|
| **Server** | `@12-apps/jobs/server` | Call `createApiJobs(config)` once at process start: driver resolution (with the inline zero-config default and the production refusals), job registration, the `JOBS_WORKER` producer/consumer switch, graceful drain on `SIGTERM`/`SIGINT`, the bound sweep lease, and the health endpoint as framework-neutral route descriptors. |
|
|
15
18
|
| **Hono** | `@12-apps/jobs/hono` | `app.route(prefix, jobsRouter(jobsApi))`. A one-call mount for hosts on Hono; `hono` is an OPTIONAL peer, so importing the root or `/server` never resolves it. |
|
|
16
19
|
| **Drivers** | `@12-apps/jobs/bullmq`, `@12-apps/jobs/inline` | Nothing, usually — `createApiJobs` resolves them. Import directly only to hand a pre-built instance in (`driver: createInlineJobDriver({ await: true })` in a test). |
|
|
@@ -34,7 +37,7 @@ it reads the same health endpoint every other probe reads.
|
|
|
34
37
|
1. **The host owns the handlers; this package owns the running of them.**
|
|
35
38
|
Job definitions, their retry policies and their schedules are host domain —
|
|
36
39
|
they stay in the host, declared with `defineJob` next to the code they
|
|
37
|
-
belong to. What
|
|
40
|
+
belong to. What lives here is everything operational: which driver runs,
|
|
38
41
|
when workers start, how they drain, who may run a sweep.
|
|
39
42
|
2. **Payloads carry identifiers, never state**, and every handler is
|
|
40
43
|
idempotent — delivery is at-least-once. Both rules are documented on
|
|
@@ -51,22 +54,39 @@ it reads the same health endpoint every other probe reads.
|
|
|
51
54
|
host with no `REDIS_URL` must boot green: `createApiJobs` resolves the
|
|
52
55
|
inline driver outside production (handlers in-process, schedules off and
|
|
53
56
|
logged). Do not "fix" that by demanding Redis in dev.
|
|
54
|
-
5. **Fail closed, never fail loud.** In production every
|
|
55
|
-
(no `REDIS_URL`, `inline` requested, a bad URL) resolves to
|
|
56
|
-
a loud error log and a 503 from `/health`. Enqueues then
|
|
57
|
-
`no-driver`; the durable rows still get written. A queue must never
|
|
58
|
-
the host app down.
|
|
59
|
-
6. **
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
57
|
+
5. **Fail closed, never fail loud — about the QUEUE.** In production every
|
|
58
|
+
misconfiguration (no `REDIS_URL`, `inline` requested, a bad URL) resolves to
|
|
59
|
+
NO driver plus a loud error log and a 503 from `/health`. Enqueues then
|
|
60
|
+
report `no-driver`; the durable rows still get written. A queue must never
|
|
61
|
+
take the host app down.
|
|
62
|
+
6. **But the WIRING fails loud, on purpose.** A `jobs` that names nothing, a
|
|
63
|
+
definition that cannot run, an enqueue of a job the registry does not hold:
|
|
64
|
+
each of those is refused with a throw or a refused result. They are not
|
|
65
|
+
queue outages, they are programming errors, and every one of them is
|
|
66
|
+
otherwise completely silent. See "What it refuses" below.
|
|
67
|
+
7. **Auth for the health endpoint is the host's.** Mount `jobsRouter` under
|
|
68
|
+
whatever guard the deployment's internal probes live behind. The package
|
|
69
|
+
holds zero authorization logic.
|
|
70
|
+
8. **Sweeps: one queue, one flight, one lease.** Declare scheduled sweeps
|
|
64
71
|
with `queue: SWEEP_QUEUE, concurrency: 1` and take
|
|
65
72
|
`withSweepLease(name, ttlMs, work)` inside the handler. The TTL must
|
|
66
73
|
comfortably exceed the sweep's own duration — a lease expiring mid-sweep
|
|
67
74
|
is the one way two workers can still overlap. Only a lost race is silent;
|
|
68
75
|
a real store fault throws, so a stopped sweep has a failed job to notice.
|
|
69
76
|
|
|
77
|
+
## What it refuses
|
|
78
|
+
|
|
79
|
+
Each of these produces, if let through, a deployment that starts, probes green
|
|
80
|
+
and quietly does nothing.
|
|
81
|
+
|
|
82
|
+
| Refused | Where | Why it is not "a choice" |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `defineJob({ name: "" })`, `queue: ""`, `attempts: 0`, `concurrency: 0`, `backoff.delayMs: 0`, `schedule.pattern: "@daily"` / `""` / `"0 *"` | `defineJob`, at declaration | `InvalidJobDefinitionError`. `queue: ""` is not nullish, so it never falls back to `DEFAULT_QUEUE` — it makes a queue no worker is started for. A non-5/6-field cron installs a scheduler that never fires. |
|
|
85
|
+
| `createApiJobs({ jobs: [] })` | the factory, at assembly | `JobsConfigError`. Declaring nothing is not how jobs are turned off — `JOBS_DRIVER=off` is, and it reports `status: "disabled"` rather than a green `ok` over an empty runtime. |
|
|
86
|
+
| a `jobs` thunk that registers nothing | `start()` | `NoJobsRegisteredError`. This is the common one: a dropped import in the host's jobs barrel. Both roles are checked — a producer that registered nothing enqueues nothing. |
|
|
87
|
+
| `startJobWorkers()` with an empty registry | the root entry point | `NoJobsRegisteredError`, the same guard. A host that wires the runtime by hand is not a host with fewer checks. |
|
|
88
|
+
| `enqueue` of a definition the registry does not hold under that name | `enqueueJob`, and the BullMQ driver's own `enqueue` | `{ enqueued: false, reason: "unregistered" }`. It is the SAME `resolveRegisteredJob` call the worker's dispatch makes, so a write cannot be accepted that the run side then refuses. Identity, not just the name: an impostor object would ship a payload the real handler never agreed to. |
|
|
89
|
+
|
|
70
90
|
## Configuration
|
|
71
91
|
|
|
72
92
|
`createApiJobs(config)` — every field optional except `jobs`; unset fields
|
|
@@ -74,16 +94,72 @@ default from the environment, which is what makes the mount one line:
|
|
|
74
94
|
|
|
75
95
|
| Config | Env default | Meaning |
|
|
76
96
|
|---|---|---|
|
|
77
|
-
| `jobs` | — | An import thunk (`() => import("./jobs")`) or
|
|
97
|
+
| `jobs` | — | **Required, and may not be empty.** An import thunk (`() => import("./jobs")`) or a non-empty array of `defineJob` returns. Registration. |
|
|
78
98
|
| `driver` | `JOBS_DRIVER` | `bullmq` \| `inline` \| `off`, or a `JobDriver` instance. Unset → `bullmq` when a Redis URL exists, else `off` in production, `inline` elsewhere. |
|
|
79
99
|
| `redisUrl` | `REDIS_URL` | Setting it turns the queue on. |
|
|
80
100
|
| `worker` | `JOBS_WORKER` (`1`/`true`) | This process consumes and runs schedules, not just enqueues. |
|
|
81
101
|
| `production` | `NODE_ENV === "production"` | Refuses `inline`, makes "no queue" loud. |
|
|
82
102
|
| `queuePrefix` | `JOBS_QUEUE_PREFIX` | Share one Redis across environments. |
|
|
83
|
-
| `logger` | console | Structurally a winston logger or `console`. |
|
|
103
|
+
| `logger` | console | Structurally a winston-style logger or `console`. |
|
|
104
|
+
| `events` | — | `JobEvents` — dead-letters, completions and removed schedules, for the host to wire to its own notifier / audit / realtime. See below. |
|
|
105
|
+
| `retention` | package default | How long finished jobs are kept: a day of successes, a week of failures. Override for a longer support window. All four numbers must be positive and finite — see the warning below. |
|
|
106
|
+
| `defaultConcurrency` | `5` | Per-queue concurrency when no job on the queue states one. A stated `concurrency: 1` still wins. |
|
|
84
107
|
| `db` | — | `() => SweepLeaseDb` — enables `withSweepLease`. Without it the lease throws on first use (loud, never a silent skip). |
|
|
85
108
|
| `installShutdownHooks` | `true` | `SIGTERM`/`SIGINT` drain in-flight jobs (workers only). Off in tests. |
|
|
86
109
|
|
|
110
|
+
### ⚠️ A non-positive `retention` does not shrink retention
|
|
111
|
+
|
|
112
|
+
It stops bounding the backend altogether. BullMQ derives its count trim from
|
|
113
|
+
the number it is handed, so a NEGATIVE `count` removes one job per completion
|
|
114
|
+
instead of holding the set at a ceiling — which is the unbounded completed-set
|
|
115
|
+
the default exists to prevent, arrived at through a config knob. Nothing
|
|
116
|
+
throws at the queue, no probe reddens, and the symptom is a Redis that fills up
|
|
117
|
+
weeks later and starts refusing writes.
|
|
118
|
+
|
|
119
|
+
`NaN` is the likelier way in:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
// ✗ With JOBS_KEEP_H unset this is NaN, and every comparison in the trim
|
|
123
|
+
// silently answers false.
|
|
124
|
+
retention: { completed: { ageSeconds: Number(process.env.JOBS_KEEP_H), count: 1_000 }, … }
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
So all four numbers are validated — `createApiJobs` refuses at ASSEMBLY with
|
|
128
|
+
`InvalidJobRetentionError`, and `createBullMqJobDriver` refuses again for a
|
|
129
|
+
host that builds the driver off `@12-apps/jobs/bullmq` itself. Omitting
|
|
130
|
+
`retention` is always fine and means "use the package default".
|
|
131
|
+
|
|
132
|
+
### `events` — what this package will not do for you
|
|
133
|
+
|
|
134
|
+
It notifies nobody, audits nothing and publishes no realtime event. It owns
|
|
135
|
+
the MOMENT and exports it; the consequence is the host's, wired with the
|
|
136
|
+
host's own packages:
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
createApiJobs({
|
|
140
|
+
jobs: () => import("./lib/jobs"),
|
|
141
|
+
events: {
|
|
142
|
+
onJobFailed: ({ name, error, terminal }) => {
|
|
143
|
+
// `terminal` is the whole point: no attempt is left. A non-terminal
|
|
144
|
+
// failure is ordinary retry noise and must not page anyone.
|
|
145
|
+
if (terminal) void notifyOperators(name, error);
|
|
146
|
+
},
|
|
147
|
+
onJobCompleted: ({ name, runId }) => void publishRealtime(name, runId),
|
|
148
|
+
onScheduleRemoved: ({ name, queue }) =>
|
|
149
|
+
void audit("schedule.removed", { name, queue }),
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
An observer that throws or rejects is logged and swallowed — somebody else's
|
|
155
|
+
code must never be able to fail the job it is watching, nor the reconcile that
|
|
156
|
+
was cleaning up a stale schedule.
|
|
157
|
+
|
|
158
|
+
`onScheduleRemoved` fires only for REMOVAL, not installation. Installation is
|
|
159
|
+
an idempotent upsert that runs on every boot of every worker, so auditing it
|
|
160
|
+
would write one row per schedule per deploy; removal is a deploy permanently
|
|
161
|
+
cancelling a recurring job, which is the half worth a record.
|
|
162
|
+
|
|
87
163
|
## The endpoints
|
|
88
164
|
|
|
89
165
|
Mounted under whatever prefix the host chooses (recommended: wherever its
|
|
@@ -102,9 +178,8 @@ packages/jobs/prisma/jobs.prisma # the model partial
|
|
|
102
178
|
packages/jobs/prisma/migrations/ # its migration
|
|
103
179
|
```
|
|
104
180
|
|
|
105
|
-
A host adopts them by COPY
|
|
106
|
-
|
|
107
|
-
structural migration sync in `sync-prisma-plugins.mjs`):
|
|
181
|
+
A host adopts them by COPY, from a sync script in whichever of its packages
|
|
182
|
+
owns the schema folder:
|
|
108
183
|
|
|
109
184
|
- **Migrations are copied, never symlinked.** Prisma enumerates the
|
|
110
185
|
migrations folder with `lstat`, so a symlinked migration reports
|
|
@@ -118,17 +193,21 @@ structural migration sync in `sync-prisma-plugins.mjs`):
|
|
|
118
193
|
is the CI gate against drift.
|
|
119
194
|
- **The migration's timestamp (`20260727190000`) may sort before migrations
|
|
120
195
|
your host has already applied.** That is deliberate: the directory is a
|
|
121
|
-
byte-identical copy of
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
196
|
+
byte-identical copy of one already applied in production, so a rename would
|
|
197
|
+
make that host's sync try to create a second table. For every other host the
|
|
198
|
+
out-of-order arrival is safe — `prisma migrate dev` may grumble, but the SQL
|
|
199
|
+
is `CREATE TABLE IF NOT EXISTS`, so even a double apply is inert.
|
|
200
|
+
- **Those bytes are frozen, comment and all.** Prisma checksums an applied
|
|
201
|
+
migration; changing so much as a comment makes `migrate deploy` refuse the
|
|
202
|
+
next deploy of every host that already ran it. That is why the packed-artifact
|
|
203
|
+
sweep carries exactly one exemption, scoped to one literal on that one path
|
|
204
|
+
(`src/__tests__/packed-artifact.test.ts`), rather than editing the file.
|
|
126
205
|
|
|
127
206
|
## Porting to another repo
|
|
128
207
|
|
|
129
208
|
1. Add the package and sync the partial + migration into your schema-owning
|
|
130
|
-
package
|
|
131
|
-
|
|
209
|
+
package; declare `@12-apps/jobs` as that package's dependency;
|
|
210
|
+
`prisma generate`.
|
|
132
211
|
2. Declare your jobs with `defineJob` and collect the modules behind one
|
|
133
212
|
import (`lib/jobs/index.ts` importing each for its side effect).
|
|
134
213
|
3. Mount: `createApiJobs({ jobs: () => import("./lib/jobs"), db: () => yourClient })`,
|
|
@@ -137,3 +216,97 @@ structural migration sync in `sync-prisma-plugins.mjs`):
|
|
|
137
216
|
needs `maxmemory-policy noeviction` (the driver checks and complains) and
|
|
138
217
|
AOF persistence. More than one worker is safe for sweeps that take the
|
|
139
218
|
lease.
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
# Migrating to 3.0.0 — the app-agnostic release
|
|
223
|
+
|
|
224
|
+
3.0.0 does two things: it removes the origin application from a package
|
|
225
|
+
published as generic, and it closes three fail-open paths that were invisible
|
|
226
|
+
by construction.
|
|
227
|
+
|
|
228
|
+
## What changed in behaviour
|
|
229
|
+
|
|
230
|
+
Nothing was **removed** from the API — every 2.0.0 export still exists with the
|
|
231
|
+
same signature. What changed is that four things that used to be accepted are
|
|
232
|
+
now refused, and one union grew a member.
|
|
233
|
+
|
|
234
|
+
| Was accepted | Now | Where |
|
|
235
|
+
|---|---|---|
|
|
236
|
+
| a definition with a blank `name`, an empty `queue`, `attempts: 0`, `concurrency: 0`, `backoff.delayMs: 0`, or a cron pattern that is not 5/6 fields | throws `InvalidJobDefinitionError` | `defineJob` |
|
|
237
|
+
| `createApiJobs({ jobs: [] })` | throws `JobsConfigError` at the factory | `@12-apps/jobs/server` |
|
|
238
|
+
| a `jobs` thunk that registers nothing | `start()` rejects with `NoJobsRegisteredError`; `/health` stays 503 | `@12-apps/jobs/server` |
|
|
239
|
+
| `startJobWorkers()` with an empty registry | rejects with `NoJobsRegisteredError` | `@12-apps/jobs` (root) |
|
|
240
|
+
| `enqueue` of an unregistered definition — written to the backend, then dead-lettered by the consumer | `{ enqueued: false, reason: "unregistered" }`, nothing written | `enqueueJob`, `RegisteredJob.enqueue`, and the BullMQ driver |
|
|
241
|
+
|
|
242
|
+
**`EnqueueSkipReason` gained `"unregistered"`.** An exhaustive `switch` over it
|
|
243
|
+
will not compile until the new arm is handled. That is the intended failure:
|
|
244
|
+
the reason exists precisely because the outcome used to be indistinguishable
|
|
245
|
+
from success.
|
|
246
|
+
|
|
247
|
+
## What is new
|
|
248
|
+
|
|
249
|
+
| Added | What for |
|
|
250
|
+
|---|---|
|
|
251
|
+
| `events?: JobEvents` on `createApiJobs`, and on both driver factories | Dead-letters (`onJobFailed` with `terminal`), completions (`onJobCompleted`) and removed schedules (`onScheduleRemoved`). The package still notifies/audits/publishes nothing itself. |
|
|
252
|
+
| `retention?: JobRetention`, `defaultConcurrency?: number` | The two operational numbers that were hardcoded. The defaults are unchanged (a day / a week; concurrency 5), so omitting them is a no-op. `retention` is validated at assembly and again in the driver — a non-positive window stops bounding the backend rather than shrinking it. |
|
|
253
|
+
| `assertValidRetention`, `InvalidJobRetentionError` | The retention check, exported so a host that assembles its own window can run it. Lives in `core`, so importing it never pulls `bullmq` into a bundle that only enqueues. |
|
|
254
|
+
| `DEFAULT_QUEUE` | The queue name a definition falls back to, exported instead of duplicated as a literal in every host. |
|
|
255
|
+
| `resolveRegisteredJob`, `InvalidJobDefinitionError`, `NoJobsRegisteredError`, `JobsConfigError` | The gate and the three refusals, so a host can catch them by type. |
|
|
256
|
+
|
|
257
|
+
## Host vocabulary that left the tarball
|
|
258
|
+
|
|
259
|
+
`files` publishes `src` (minus tests), `prisma` and every top-level `*.md`, so
|
|
260
|
+
all of this was shipping to every adopter:
|
|
261
|
+
|
|
262
|
+
- the origin application was named 19 times, across four published source files
|
|
263
|
+
and this one;
|
|
264
|
+
- **its job identifiers were the examples** — in the README, the root module
|
|
265
|
+
header, the payload rule on `JobDefinition`, the inline driver's docs, the
|
|
266
|
+
runtime's own comments and the `SweepLease` schema annotation. A jobs
|
|
267
|
+
package's examples ARE job identifiers, which is exactly what made this the
|
|
268
|
+
easiest leak to read straight past: they look like documentation and they are
|
|
269
|
+
another product's schedule names.
|
|
270
|
+
- its billing vocabulary and its own timezone sat in the payload rule and in
|
|
271
|
+
the `JobSchedule` docs, so an adopter's night job inherited a zone chosen for
|
|
272
|
+
somebody else.
|
|
273
|
+
|
|
274
|
+
`src/__tests__/packed-artifact.test.ts` now asks `npm pack --dry-run --json`
|
|
275
|
+
what would be uploaded, reads every entry off disk and greps it, with a plant
|
|
276
|
+
test so a green run means something.
|
|
277
|
+
|
|
278
|
+
### The two strings that have NOT left, and why
|
|
279
|
+
|
|
280
|
+
Both are in files whose BYTES are pinned to something outside this package, so
|
|
281
|
+
neither is fixable by editing the string. The sweep exempts exactly those two
|
|
282
|
+
literals on exactly those two paths — never the file, never the word
|
|
283
|
+
elsewhere — and the suite proves the scoping.
|
|
284
|
+
|
|
285
|
+
- `prisma/migrations/.../migration.sql` keeps a ticket reference in its header
|
|
286
|
+
comment. Prisma checksums an applied migration; changing a comment makes
|
|
287
|
+
`migrate deploy` refuse the next deploy of every host that already ran it.
|
|
288
|
+
This one is permanent.
|
|
289
|
+
- `prisma/jobs.prisma` still uses a host's job name as the example value of the
|
|
290
|
+
`name` column, and still names a pre-2.0.0 path for the sync script. That
|
|
291
|
+
file is byte-compared against a **committed copy in the schema-host package**
|
|
292
|
+
(`sync-jobs-schema.mjs --check`, wired into that package's `build` and
|
|
293
|
+
`prisma:generate`), so a one-byte edit here turns the workspace's
|
|
294
|
+
`check-types` red until the copy is re-synced — a write in another package's
|
|
295
|
+
directory, which the release tooling reads as a release of that package. It
|
|
296
|
+
is a two-line comment fix that must ride the commit which can also carry
|
|
297
|
+
`prisma:sync-jobs`, and it is the one item this release deliberately left.
|
|
298
|
+
|
|
299
|
+
## Upgrading a host that is on 1.20.0
|
|
300
|
+
|
|
301
|
+
Two majors, and 2.0.0's break is not in this package's own API — it was cut by
|
|
302
|
+
the commit that moved the Prisma host out of `@12-apps/shared-helpers` into
|
|
303
|
+
`@12-apps/prisma`, so `@12-apps/shared-helpers/prisma` is gone and the
|
|
304
|
+
reference sync script moved with it. `packages/jobs`'s own diff for that
|
|
305
|
+
release was a single path in this file.
|
|
306
|
+
|
|
307
|
+
The concrete, anchored upgrade for this repo's own consumer — the pins, the
|
|
308
|
+
partial re-sync and the generated copy it invalidates, the health endpoint that
|
|
309
|
+
was never mounted, and the cross-package seams now available — is in
|
|
310
|
+
`packages/jobs/docs/host-upgrade.md`. It is deliberately NOT published: it
|
|
311
|
+
names one application throughout, which is the whole thing this release was
|
|
312
|
+
about.
|
package/README.md
CHANGED
|
@@ -4,36 +4,38 @@ Typed background jobs — retries, exponential backoff and cron — behind a
|
|
|
4
4
|
swappable driver. BullMQ/Redis in production, inline execution in tests and
|
|
5
5
|
zero-config development.
|
|
6
6
|
|
|
7
|
-
Framework-free: no
|
|
8
|
-
port, the lease's database is a structural seam,
|
|
9
|
-
sight (`hono`) is an
|
|
7
|
+
Framework-free and domain-free: no ORM import, no host-app types, no job names
|
|
8
|
+
of its own. The logger is a port, the lease's database is a structural seam,
|
|
9
|
+
observation is a port, and the one web framework in sight (`hono`) is an
|
|
10
|
+
optional peer behind its own subpath.
|
|
10
11
|
|
|
11
12
|
```ts
|
|
12
13
|
// where the domain lives — the import IS the registration
|
|
13
|
-
export const
|
|
14
|
-
name: "
|
|
14
|
+
export const renderReport = defineJob<{ reportId: string }>({
|
|
15
|
+
name: "reports.render",
|
|
15
16
|
attempts: 5,
|
|
16
17
|
backoff: { type: "exponential", delayMs: 5_000 },
|
|
17
|
-
handle: async ({
|
|
18
|
+
handle: async ({ reportId }) => renderAndStore(reportId),
|
|
18
19
|
});
|
|
19
20
|
|
|
20
21
|
// a job that only ever runs on a schedule needs no binding at all
|
|
21
22
|
defineJob({
|
|
22
|
-
name: "
|
|
23
|
+
name: "reports.purge",
|
|
23
24
|
schedule: { pattern: "*/5 * * * *", timezone: "UTC" },
|
|
24
|
-
handle: async () =>
|
|
25
|
+
handle: async () => purgeExpired(),
|
|
25
26
|
});
|
|
26
27
|
|
|
27
28
|
// at an emit site
|
|
28
|
-
await
|
|
29
|
+
await renderReport.enqueue({ reportId }, { dedupeKey: reportId });
|
|
29
30
|
|
|
30
31
|
// at process start — ONE call wires driver, workers, drain, lease and health
|
|
31
32
|
import { createApiJobs } from "@12-apps/jobs/server";
|
|
32
33
|
import { jobsRouter } from "@12-apps/jobs/hono";
|
|
33
34
|
|
|
34
35
|
const jobsApi = createApiJobs({
|
|
35
|
-
jobs: () => import("./lib/jobs"), // the defineJob modules
|
|
36
|
+
jobs: () => import("./lib/jobs"), // the defineJob modules — required
|
|
36
37
|
db: () => getPrismaClient(), // the sweep_leases table (optional)
|
|
38
|
+
events: { onJobFailed: pageOnDeadLetter }, // yours, if you want one
|
|
37
39
|
});
|
|
38
40
|
await jobsApi.start();
|
|
39
41
|
app.route("/api/internal/jobs", jobsRouter(jobsApi));
|
|
@@ -47,32 +49,63 @@ never a crash, never a silent fake. `JOBS_WORKER=1` is what turns a process
|
|
|
47
49
|
from producer (enqueue only) into consumer (workers + schedules); the worker
|
|
48
50
|
is the same image, not a second build.
|
|
49
51
|
|
|
50
|
-
The full adoption contract — config seam, env variables, the
|
|
51
|
-
Prisma partial and why there is no `createWebJobs` — is in
|
|
52
|
+
The full adoption contract — config seam, env variables, the events port, the
|
|
53
|
+
sweep lease, the Prisma partial and why there is no `createWebJobs` — is in
|
|
52
54
|
[ADOPTING.md](./ADOPTING.md).
|
|
53
55
|
|
|
54
56
|
## The two rules
|
|
55
57
|
|
|
56
|
-
**Payloads carry identifiers, never state.** `{
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
it
|
|
58
|
+
**Payloads carry identifiers, never state.** `{ reportId }`, not the rendered
|
|
59
|
+
report. The database is the source of truth; the queue only decides *when*. A
|
|
60
|
+
payload that duplicates a row is a second copy that can disagree with it, and
|
|
61
|
+
it is the copy that gets acted on days later.
|
|
60
62
|
|
|
61
63
|
**Handlers are idempotent.** Delivery is at-least-once — a worker can die
|
|
62
64
|
between the side effect and the acknowledgement. Lean on the database's unique
|
|
63
65
|
constraints, not on the queue.
|
|
64
66
|
|
|
67
|
+
## What it refuses
|
|
68
|
+
|
|
69
|
+
Three wiring mistakes are refused rather than absorbed, because each of them
|
|
70
|
+
is otherwise **silent** — the process starts, the probe is green, and work
|
|
71
|
+
simply stops happening:
|
|
72
|
+
|
|
73
|
+
- **A job that cannot run.** `defineJob` throws on a blank name, an empty
|
|
74
|
+
`queue` (which never falls back to the default — it creates a queue nobody
|
|
75
|
+
consumes), a non-positive `attempts`/`concurrency`, a backoff with no delay,
|
|
76
|
+
and a cron pattern that is not 5 or 6 fields. `"@daily"` and `""` both
|
|
77
|
+
install a scheduler that never fires.
|
|
78
|
+
- **A deployment with no jobs.** `createApiJobs({ jobs: [] })` throws at the
|
|
79
|
+
factory; a `jobs` thunk that registers nothing throws from `start()`; and
|
|
80
|
+
`startJobWorkers()` throws on an empty registry, so the hand-wired root path
|
|
81
|
+
is guarded identically. Declaring nothing is not a way to turn jobs off —
|
|
82
|
+
`JOBS_DRIVER=off` is.
|
|
83
|
+
- **An enqueue no worker could ever claim.** `enqueue` returns
|
|
84
|
+
`{ enqueued: false, reason: "unregistered" }` for a definition the registry
|
|
85
|
+
does not hold under that name (identity, not just the name). The alternative
|
|
86
|
+
is a write the backend accepts, no handler claims, and the consumer
|
|
87
|
+
dead-letters — after the caller was told `enqueued: true`.
|
|
88
|
+
- **A `retention` window that bounds nothing.** Every number must be positive
|
|
89
|
+
and finite. A negative `count` does not keep fewer jobs, it trims one per
|
|
90
|
+
completion instead of holding a ceiling; `NaN` (an unset env var read through
|
|
91
|
+
`Number()`) disables the comparison entirely. Refused at the factory and
|
|
92
|
+
again in the driver.
|
|
93
|
+
|
|
65
94
|
## Guarantees and non-guarantees
|
|
66
95
|
|
|
67
96
|
- `enqueue` **never throws**. A queue outage returns `{ enqueued: false }` and
|
|
68
97
|
logs; it does not fail the request that was deferring work.
|
|
69
|
-
- Bounded
|
|
98
|
+
- Bounded backend memory: completed jobs are kept a day, failed ones a week,
|
|
99
|
+
and a host with a different support window passes its own `retention` — whose
|
|
100
|
+
four numbers are validated, because a negative or `NaN` window stops bounding
|
|
101
|
+
the backend rather than shrinking it.
|
|
70
102
|
- Schedules are **reconciled** on start — a cron job deleted from code has its
|
|
71
103
|
scheduler removed from Redis, instead of firing forever at a handler that no
|
|
72
|
-
longer exists.
|
|
104
|
+
longer exists. That removal is reported to `events.onScheduleRemoved`,
|
|
105
|
+
because it is destructive and a deploy did it.
|
|
73
106
|
- The `inline` driver honours `attempts` but not delays or schedules, and both
|
|
74
|
-
omissions are logged rather than silent. It is refused in production
|
|
75
|
-
|
|
107
|
+
omissions are logged rather than silent. It is refused in production —
|
|
108
|
+
by name, by env var and by instance.
|
|
76
109
|
|
|
77
110
|
The BullMQ driver is exported from `@12-apps/jobs/bullmq`, never the barrel, so
|
|
78
111
|
importing `defineJob` at an emit site does not drag Redis into the bundle.
|
|
@@ -82,6 +115,30 @@ only in a process whose resolution actually picked it.
|
|
|
82
115
|
Redis must run with `maxmemory-policy noeviction` — the driver checks and
|
|
83
116
|
complains.
|
|
84
117
|
|
|
118
|
+
## Telling somebody else what happened
|
|
119
|
+
|
|
120
|
+
This package notifies nobody, audits nothing and publishes no events. What it
|
|
121
|
+
owns is the MOMENT, and the moments are a port:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
createApiJobs({
|
|
125
|
+
jobs: () => import("./lib/jobs"),
|
|
126
|
+
events: {
|
|
127
|
+
// The dead-letter. `terminal` is the whole point — a non-terminal failure
|
|
128
|
+
// is ordinary retry noise.
|
|
129
|
+
onJobFailed: ({ name, error, terminal }) => {
|
|
130
|
+
if (terminal) void notifyOperators(name, error);
|
|
131
|
+
},
|
|
132
|
+
onJobCompleted: ({ name, runId }) => void publishRealtime(name, runId),
|
|
133
|
+
// A deploy just cancelled a recurring job, permanently.
|
|
134
|
+
onScheduleRemoved: ({ name, queue }) => void audit("schedule.removed", { name, queue }),
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
An observer that throws or rejects is logged and swallowed: somebody else's
|
|
140
|
+
code must not be able to fail the job it is watching.
|
|
141
|
+
|
|
85
142
|
## The sweep lease
|
|
86
143
|
|
|
87
144
|
Scheduled sweeps declare `queue: SWEEP_QUEUE, concurrency: 1`, which makes
|
|
@@ -91,13 +148,13 @@ named, time-bounded claim in the DATABASE — `createSweepLease` (or the bound
|
|
|
91
148
|
|
|
92
149
|
```ts
|
|
93
150
|
defineJob({
|
|
94
|
-
name: "
|
|
151
|
+
name: "reports.purge",
|
|
95
152
|
queue: SWEEP_QUEUE,
|
|
96
153
|
concurrency: 1,
|
|
97
154
|
schedule: { pattern: "0 * * * *" },
|
|
98
155
|
handle: async () => {
|
|
99
|
-
const { ran } = await jobsApi.withSweepLease("
|
|
100
|
-
|
|
156
|
+
const { ran } = await jobsApi.withSweepLease("reports.purge", 10 * 60_000, () =>
|
|
157
|
+
purgeExpired(),
|
|
101
158
|
);
|
|
102
159
|
if (!ran) return; // another worker holds this tick
|
|
103
160
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/jobs",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Generic background-job library: a typed job registry with retries, exponential backoff and cron schedules, behind a swappable driver port (BullMQ/Redis in production, inline execution in tests and zero-config dev). The runtime half (./server) is one factory — createApiJobs: driver resolution, worker bootstrap, graceful drain, the single-writer sweep lease and a health endpoint — with a Hono adapter (./hono) and the package-owned SweepLease Prisma partial + migrations. Knows nothing about the host app's domain, ORM or transport.",
|
|
6
6
|
"exports": {
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
"*.js",
|
|
60
60
|
"*.mjs",
|
|
61
61
|
"*.md",
|
|
62
|
+
"!docs/**",
|
|
62
63
|
"!eslint.config.js",
|
|
63
64
|
"!**/__tests__/**",
|
|
64
65
|
"!**/tests/**",
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { JobEvents, JobLogger } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The observer plumbing, in ONE place because both drivers need it and the
|
|
5
|
+
* rule is easy to get subtly wrong twice: a `JobEvents` hook is somebody
|
|
6
|
+
* else's code, and it must never be able to fail the job it is watching, nor
|
|
7
|
+
* the reconcile that was cleaning up a stale schedule.
|
|
8
|
+
*
|
|
9
|
+
* Both failure modes are covered, and they are not the same one: a hook can
|
|
10
|
+
* THROW synchronously, and an async hook can REJECT later. Catching only the
|
|
11
|
+
* first leaves an unhandled rejection that some Node configurations turn into
|
|
12
|
+
* a process exit — a notifier outage taking the worker down with it, which is
|
|
13
|
+
* exactly backwards.
|
|
14
|
+
*
|
|
15
|
+
* Fire-and-forget by design: the job's own latency must not include whatever
|
|
16
|
+
* a host's notifier decides to do.
|
|
17
|
+
*/
|
|
18
|
+
export type EmitJobEvent = (emit: (events: JobEvents) => void | Promise<void>) => void;
|
|
19
|
+
|
|
20
|
+
export function createEventEmitter(
|
|
21
|
+
events: JobEvents | undefined,
|
|
22
|
+
logger: JobLogger,
|
|
23
|
+
): EmitJobEvent {
|
|
24
|
+
if (!events) return () => undefined;
|
|
25
|
+
return (emit) => {
|
|
26
|
+
try {
|
|
27
|
+
void Promise.resolve(emit(events)).catch((error: unknown) =>
|
|
28
|
+
logger.error("a jobs event observer rejected:", error),
|
|
29
|
+
);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
logger.error("a jobs event observer threw:", error);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
}
|
package/src/core/queues.ts
CHANGED
|
@@ -1,26 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Well-known queue names
|
|
2
|
+
* Well-known queue names — this package's own vocabulary, exported so a host
|
|
3
|
+
* composes them rather than re-typing string literals that have to match.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* `DEFAULT_QUEUE` is where a definition lands when it names no queue. One
|
|
6
|
+
* queue for everything is the right shape at most scales: one worker, one pair
|
|
7
|
+
* of connections, one dashboard.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* — the worst case is duplicate work, and the ledger is protected by the
|
|
13
|
-
* database — but single-flight is what keeps it from happening at all, and it
|
|
14
|
-
* costs nothing: sweeps run hourly and daily, and none of them is
|
|
15
|
-
* latency-sensitive.
|
|
9
|
+
* `SWEEP_QUEUE` is the other one, and it exists for a correctness property
|
|
10
|
+
* rather than for tuning. The scheduled sweeps run SINGLE-FLIGHT — their own
|
|
11
|
+
* queue at concurrency 1 — so a tick that outlives its interval makes the next
|
|
12
|
+
* one WAIT rather than run alongside it.
|
|
16
13
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
14
|
+
* Every sweep walks a work list and decides "has this already been handled?"
|
|
15
|
+
* by reading a row it is about to write; two ticks interleaved on the same row
|
|
16
|
+
* both read the old answer. Durable markers in the host's tables make that
|
|
17
|
+
* safe rather than corrupting — the worst case is duplicate work, and the
|
|
18
|
+
* ledger is protected by the database — but single-flight is what keeps it
|
|
19
|
+
* from happening at all, and it costs nothing: sweeps are periodic and none of
|
|
20
|
+
* them is latency-sensitive.
|
|
21
|
+
*
|
|
22
|
+
* Keeping them off the default queue matters too, in the other direction: a
|
|
23
|
+
* sweep grinding through a long work list must not occupy the workers that
|
|
24
|
+
* handle a user-facing job.
|
|
20
25
|
*
|
|
21
26
|
* Declare a sweep with `queue: SWEEP_QUEUE, concurrency: 1` and take a
|
|
22
27
|
* {@link ../lease/sweep-lease!createSweepLease | sweep lease} inside the
|
|
23
28
|
* handler when more than one worker can run — the queue serializes ticks
|
|
24
29
|
* within one worker, the lease serializes them across the deployment.
|
|
25
30
|
*/
|
|
31
|
+
|
|
32
|
+
/** The queue a definition lands on when it names none. */
|
|
33
|
+
export const DEFAULT_QUEUE = "default";
|
|
34
|
+
|
|
35
|
+
/** The single-flight queue the scheduled sweeps share. */
|
|
26
36
|
export const SWEEP_QUEUE = "sweeps";
|