@12-apps/jobs 1.18.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 +17 -5
- 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
package/ADOPTING.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# Adopting @12-apps/jobs
|
|
2
|
+
|
|
3
|
+
This package is a **plug-and-play background-job runtime**: one library,
|
|
4
|
+
reusable across repositories, exposing standardized surfaces. A host repo only
|
|
5
|
+
*points* at these surfaces — when the library updates, every host updates with
|
|
6
|
+
**no app changes**. The contract below is the same one `@12-apps/report-builder`
|
|
7
|
+
and `@12-apps/payments-backend` established.
|
|
8
|
+
|
|
9
|
+
## The standardized plugin surfaces
|
|
10
|
+
|
|
11
|
+
| Surface | Export | What the host does |
|
|
12
|
+
|---|---|---|
|
|
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. |
|
|
14
|
+
| **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
|
+
| **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
|
+
| **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). |
|
|
17
|
+
| **Prisma** | `prisma/jobs.prisma` + `prisma/migrations/*` | Sync BOTH into the host's schema/migrations folders as **copies** (see below). One table: `sweep_leases`. |
|
|
18
|
+
|
|
19
|
+
## Why there is no `createWebJobs`
|
|
20
|
+
|
|
21
|
+
The porting contract asks for both halves — `createApiFoo` and `createWebFoo`
|
|
22
|
+
— and this package deliberately ships only the first. The runtime is headless:
|
|
23
|
+
it has no screens, no flows and no routes a user ever sees. Its API surface is
|
|
24
|
+
the health endpoint (`GET /health` on the mounted routes), which exists for
|
|
25
|
+
probes and dashboards, not for people; its user-visible effects are whatever
|
|
26
|
+
the HOST's job handlers do, and those handlers are exactly the part that stays
|
|
27
|
+
in the host. Inventing a web half here would mean shipping an admin UI for a
|
|
28
|
+
queue this package does not own the semantics of — that is a different
|
|
29
|
+
product (and BullMQ already has several). If a host wants a jobs dashboard,
|
|
30
|
+
it reads the same health endpoint every other probe reads.
|
|
31
|
+
|
|
32
|
+
## Host wiring rules (the ones that bite)
|
|
33
|
+
|
|
34
|
+
1. **The host owns the handlers; this package owns the running of them.**
|
|
35
|
+
Job definitions, their retry policies and their schedules are host domain —
|
|
36
|
+
they stay in the host, declared with `defineJob` next to the code they
|
|
37
|
+
belong to. What moved here is everything operational: which driver runs,
|
|
38
|
+
when workers start, how they drain, who may run a sweep.
|
|
39
|
+
2. **Payloads carry identifiers, never state**, and every handler is
|
|
40
|
+
idempotent — delivery is at-least-once. Both rules are documented on
|
|
41
|
+
`JobDefinition`; they are the design the whole package rests on. Pair every
|
|
42
|
+
job with a durable row a sweep can re-find: the enqueue is only the fast
|
|
43
|
+
path, and `enqueue` never throws.
|
|
44
|
+
3. **Duck-typed DB, never a generated client.** The sweep lease takes the
|
|
45
|
+
host's Prisma client through a structural seam (`SweepLeaseDb`), as a lazy
|
|
46
|
+
provider: `db: () => getPrismaClient()`. A non-Prisma adapter must honour
|
|
47
|
+
one contract: `create` rejects a duplicate primary key with an error
|
|
48
|
+
carrying `code: "P2002"` — that is the one error the claim reads as "lost
|
|
49
|
+
the race" rather than "the store is broken".
|
|
50
|
+
4. **The zero-config default is a real mode, keep it reachable.** A fresh
|
|
51
|
+
host with no `REDIS_URL` must boot green: `createApiJobs` resolves the
|
|
52
|
+
inline driver outside production (handlers in-process, schedules off and
|
|
53
|
+
logged). Do not "fix" that by demanding Redis in dev.
|
|
54
|
+
5. **Fail closed, never fail loud.** In production every misconfiguration
|
|
55
|
+
(no `REDIS_URL`, `inline` requested, a bad URL) resolves to NO driver plus
|
|
56
|
+
a loud error log and a 503 from `/health`. Enqueues then report
|
|
57
|
+
`no-driver`; the durable rows still get written. A queue must never take
|
|
58
|
+
the host app down.
|
|
59
|
+
6. **Auth for the health endpoint is the host's.** Mount `jobsRouter` under
|
|
60
|
+
whatever guard the deployment's internal probes live behind (future-pay
|
|
61
|
+
answers `/api/internal/*` only machine-to-machine). The package holds zero
|
|
62
|
+
authorization logic.
|
|
63
|
+
7. **Sweeps: one queue, one flight, one lease.** Declare scheduled sweeps
|
|
64
|
+
with `queue: SWEEP_QUEUE, concurrency: 1` and take
|
|
65
|
+
`withSweepLease(name, ttlMs, work)` inside the handler. The TTL must
|
|
66
|
+
comfortably exceed the sweep's own duration — a lease expiring mid-sweep
|
|
67
|
+
is the one way two workers can still overlap. Only a lost race is silent;
|
|
68
|
+
a real store fault throws, so a stopped sweep has a failed job to notice.
|
|
69
|
+
|
|
70
|
+
## Configuration
|
|
71
|
+
|
|
72
|
+
`createApiJobs(config)` — every field optional except `jobs`; unset fields
|
|
73
|
+
default from the environment, which is what makes the mount one line:
|
|
74
|
+
|
|
75
|
+
| Config | Env default | Meaning |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| `jobs` | — | An import thunk (`() => import("./jobs")`) or an array of `defineJob` returns. Registration. |
|
|
78
|
+
| `driver` | `JOBS_DRIVER` | `bullmq` \| `inline` \| `off`, or a `JobDriver` instance. Unset → `bullmq` when a Redis URL exists, else `off` in production, `inline` elsewhere. |
|
|
79
|
+
| `redisUrl` | `REDIS_URL` | Setting it turns the queue on. |
|
|
80
|
+
| `worker` | `JOBS_WORKER` (`1`/`true`) | This process consumes and runs schedules, not just enqueues. |
|
|
81
|
+
| `production` | `NODE_ENV === "production"` | Refuses `inline`, makes "no queue" loud. |
|
|
82
|
+
| `queuePrefix` | `JOBS_QUEUE_PREFIX` | Share one Redis across environments. |
|
|
83
|
+
| `logger` | console | Structurally a winston logger or `console`. |
|
|
84
|
+
| `db` | — | `() => SweepLeaseDb` — enables `withSweepLease`. Without it the lease throws on first use (loud, never a silent skip). |
|
|
85
|
+
| `installShutdownHooks` | `true` | `SIGTERM`/`SIGINT` drain in-flight jobs (workers only). Off in tests. |
|
|
86
|
+
|
|
87
|
+
## The endpoints
|
|
88
|
+
|
|
89
|
+
Mounted under whatever prefix the host chooses (recommended: wherever its
|
|
90
|
+
internal probes live, e.g. `/api/internal/jobs`):
|
|
91
|
+
|
|
92
|
+
| Method | Path | Notes |
|
|
93
|
+
|---|---|---|
|
|
94
|
+
| GET | `/health` | 200 `{ status: "ok", checks }` when the runtime is in its intended state; 200 `{ status: "disabled", checks }` when jobs are off **by explicit choice outside production** (`JOBS_DRIVER=off` / `driver: "off"` — a review box or CI must not fail a readiness aggregate forever); 503 `{ status: "degraded", checks }` for everything wrong rather than chosen — a misconfiguration, a `start()` that threw, a worker that stopped consuming, and ANY production with no queue, spelled out or not: production never deliberately wants none, so an explicit off that reaches it through a shared env template still probes red. `checks` reports the resolved driver kind, producer/consumer role, consuming state, and the registered job/schedule counts. |
|
|
95
|
+
|
|
96
|
+
## The Prisma partial — copies, never symlinks
|
|
97
|
+
|
|
98
|
+
The `SweepLease` model and its migration ship in this package:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
packages/jobs/prisma/jobs.prisma # the model partial
|
|
102
|
+
packages/jobs/prisma/migrations/ # its migration
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
A host adopts them by COPY (the entity-lifecycle / shift precedent — in this
|
|
106
|
+
repo, `packages/shared-helpers/scripts/sync-jobs-schema.mjs` plus the
|
|
107
|
+
structural migration sync in `sync-prisma-plugins.mjs`):
|
|
108
|
+
|
|
109
|
+
- **Migrations are copied, never symlinked.** Prisma enumerates the
|
|
110
|
+
migrations folder with `lstat`, so a symlinked migration reports
|
|
111
|
+
`isDirectory() === false` and is silently skipped — a green deploy that
|
|
112
|
+
changed no schema.
|
|
113
|
+
- **The partial is copied too**, and the owning package must be a **declared
|
|
114
|
+
dependency** of whichever host package owns the schema folder: `turbo prune`
|
|
115
|
+
copies only what the dependency graph reaches, and an undeclared owner is
|
|
116
|
+
dropped from the build context.
|
|
117
|
+
- Never edit the synced copy by hand; re-run the sync. The `--check` variant
|
|
118
|
+
is the CI gate against drift.
|
|
119
|
+
- **The migration's timestamp (`20260727190000`) may sort before migrations
|
|
120
|
+
your host has already applied.** That is deliberate: the directory is a
|
|
121
|
+
byte-identical copy of the one future-pay already has in production, so a
|
|
122
|
+
rename would make that host's sync try to create a second table. For every
|
|
123
|
+
other host the out-of-order arrival is safe — `prisma migrate dev` may
|
|
124
|
+
grumble, but the SQL is `CREATE TABLE IF NOT EXISTS`, so even a double
|
|
125
|
+
apply is inert.
|
|
126
|
+
|
|
127
|
+
## Porting to another repo
|
|
128
|
+
|
|
129
|
+
1. Add the package and sync the partial + migration into your schema-owning
|
|
130
|
+
package (adjust the two paths in your copy of the sync script); declare
|
|
131
|
+
`@12-apps/jobs` as that package's dependency; `prisma generate`.
|
|
132
|
+
2. Declare your jobs with `defineJob` and collect the modules behind one
|
|
133
|
+
import (`lib/jobs/index.ts` importing each for its side effect).
|
|
134
|
+
3. Mount: `createApiJobs({ jobs: () => import("./lib/jobs"), db: () => yourClient })`,
|
|
135
|
+
`await jobsApi.start()` at process start, `app.route(prefix, jobsRouter(jobsApi))`.
|
|
136
|
+
4. Deploy shape: the worker is the SAME image with `JOBS_WORKER=1`. Redis
|
|
137
|
+
needs `maxmemory-policy noeviction` (the driver checks and complains) and
|
|
138
|
+
AOF persistence. More than one worker is safe for sweeps that take the
|
|
139
|
+
lease.
|
package/README.md
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
# @12-apps/jobs
|
|
2
2
|
|
|
3
3
|
Typed background jobs — retries, exponential backoff and cron — behind a
|
|
4
|
-
swappable driver. BullMQ/Redis in production, inline execution in tests
|
|
4
|
+
swappable driver. BullMQ/Redis in production, inline execution in tests and
|
|
5
|
+
zero-config development.
|
|
5
6
|
|
|
6
|
-
Framework-free: no Prisma, no Next, no host-app types. The logger is a
|
|
7
|
+
Framework-free: no Prisma import, no Next, no host-app types. The logger is a
|
|
8
|
+
port, the lease's database is a structural seam, and the one web framework in
|
|
9
|
+
sight (`hono`) is an optional peer behind its own subpath.
|
|
7
10
|
|
|
8
11
|
```ts
|
|
9
12
|
// where the domain lives — the import IS the registration
|
|
@@ -24,12 +27,30 @@ defineJob({
|
|
|
24
27
|
// at an emit site
|
|
25
28
|
await dispatchNotification.enqueue({ notificationId }, { dedupeKey: notificationId });
|
|
26
29
|
|
|
27
|
-
// at process start
|
|
28
|
-
import {
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
// at process start — ONE call wires driver, workers, drain, lease and health
|
|
31
|
+
import { createApiJobs } from "@12-apps/jobs/server";
|
|
32
|
+
import { jobsRouter } from "@12-apps/jobs/hono";
|
|
33
|
+
|
|
34
|
+
const jobsApi = createApiJobs({
|
|
35
|
+
jobs: () => import("./lib/jobs"), // the defineJob modules
|
|
36
|
+
db: () => getPrismaClient(), // the sweep_leases table (optional)
|
|
37
|
+
});
|
|
38
|
+
await jobsApi.start();
|
|
39
|
+
app.route("/api/internal/jobs", jobsRouter(jobsApi));
|
|
31
40
|
```
|
|
32
41
|
|
|
42
|
+
With no `REDIS_URL` and no config, `start()` resolves the INLINE driver
|
|
43
|
+
outside production: handlers run in-process, schedules do not fire (and say
|
|
44
|
+
so), and the app starts green with no Redis container. Production with no
|
|
45
|
+
Redis resolves to NO driver plus a loud error and a 503 from `/health` —
|
|
46
|
+
never a crash, never a silent fake. `JOBS_WORKER=1` is what turns a process
|
|
47
|
+
from producer (enqueue only) into consumer (workers + schedules); the worker
|
|
48
|
+
is the same image, not a second build.
|
|
49
|
+
|
|
50
|
+
The full adoption contract — config seam, env variables, the sweep lease, the
|
|
51
|
+
Prisma partial and why there is no `createWebJobs` — is in
|
|
52
|
+
[ADOPTING.md](./ADOPTING.md).
|
|
53
|
+
|
|
33
54
|
## The two rules
|
|
34
55
|
|
|
35
56
|
**Payloads carry identifiers, never state.** `{ notificationId }`, not the
|
|
@@ -55,6 +76,36 @@ constraints, not on the queue.
|
|
|
55
76
|
|
|
56
77
|
The BullMQ driver is exported from `@12-apps/jobs/bullmq`, never the barrel, so
|
|
57
78
|
importing `defineJob` at an emit site does not drag Redis into the bundle.
|
|
79
|
+
`createApiJobs` keeps the same property: it imports the BullMQ driver lazily,
|
|
80
|
+
only in a process whose resolution actually picked it.
|
|
58
81
|
|
|
59
82
|
Redis must run with `maxmemory-policy noeviction` — the driver checks and
|
|
60
|
-
complains.
|
|
83
|
+
complains.
|
|
84
|
+
|
|
85
|
+
## The sweep lease
|
|
86
|
+
|
|
87
|
+
Scheduled sweeps declare `queue: SWEEP_QUEUE, concurrency: 1`, which makes
|
|
88
|
+
them single-flight within one worker. Across replicas that guarantee needs a
|
|
89
|
+
named, time-bounded claim in the DATABASE — `createSweepLease` (or the bound
|
|
90
|
+
`withSweepLease` on the `createApiJobs` return):
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
defineJob({
|
|
94
|
+
name: "billing.tick",
|
|
95
|
+
queue: SWEEP_QUEUE,
|
|
96
|
+
concurrency: 1,
|
|
97
|
+
schedule: { pattern: "0 * * * *" },
|
|
98
|
+
handle: async () => {
|
|
99
|
+
const { ran } = await jobsApi.withSweepLease("billing.tick", 10 * 60_000, () =>
|
|
100
|
+
runBillingTick(),
|
|
101
|
+
);
|
|
102
|
+
if (!ran) return; // another worker holds this tick
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The claim is a conditional UPDATE — the database picks the winner — and only
|
|
108
|
+
a lost race is silent; a missing table or a dead store THROWS, so a stopped
|
|
109
|
+
sweep has a failed job to point at it. The `SweepLease` table ships with this
|
|
110
|
+
package (`prisma/jobs.prisma` + `prisma/migrations/`) and is synced into the
|
|
111
|
+
host's schema; partials and migrations are COPIED, never symlinked.
|
package/package.json
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/jobs",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.20.0",
|
|
4
4
|
"type": "module",
|
|
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).
|
|
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": {
|
|
7
7
|
".": "./src/index.ts",
|
|
8
|
+
"./server": "./src/server/index.ts",
|
|
9
|
+
"./hono": "./src/hono/index.ts",
|
|
8
10
|
"./bullmq": "./src/drivers/bullmq.ts",
|
|
9
|
-
"./inline": "./src/drivers/inline.ts"
|
|
11
|
+
"./inline": "./src/drivers/inline.ts",
|
|
12
|
+
"./package.json": "./package.json"
|
|
10
13
|
},
|
|
11
14
|
"scripts": {
|
|
12
15
|
"clean": "rm -rf node_modules coverage",
|
|
@@ -20,10 +23,11 @@
|
|
|
20
23
|
"bullmq": "^5.81.2"
|
|
21
24
|
},
|
|
22
25
|
"devDependencies": {
|
|
23
|
-
"@12-apps/eslint-config": "^1.
|
|
24
|
-
"@12-apps/typescript-config": "^1.
|
|
26
|
+
"@12-apps/eslint-config": "^1.20.0",
|
|
27
|
+
"@12-apps/typescript-config": "^1.20.0",
|
|
25
28
|
"eslint": "^9.39.1",
|
|
26
29
|
"eslint-plugin-test-flakiness": "^1.4.0",
|
|
30
|
+
"hono": "^4.6.0",
|
|
27
31
|
"typescript": "^5.9.2",
|
|
28
32
|
"vitest": "^3.2.4"
|
|
29
33
|
},
|
|
@@ -31,6 +35,14 @@
|
|
|
31
35
|
"node": ">=22.0.0"
|
|
32
36
|
},
|
|
33
37
|
"license": "MIT",
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"hono": ">=4.0.0"
|
|
40
|
+
},
|
|
41
|
+
"peerDependenciesMeta": {
|
|
42
|
+
"hono": {
|
|
43
|
+
"optional": true
|
|
44
|
+
}
|
|
45
|
+
},
|
|
34
46
|
"publishConfig": {
|
|
35
47
|
"registry": "https://registry.npmjs.org",
|
|
36
48
|
"access": "public"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// @12-apps/jobs — CANONICAL Prisma model partial (plug-and-play).
|
|
3
|
+
//
|
|
4
|
+
// This file is the single source of truth for the sweep-lease table. A host
|
|
5
|
+
// project does NOT copy this model into its main schema by hand: it uses
|
|
6
|
+
// Prisma's multi-file schema folder and SYNCS this file into it (see
|
|
7
|
+
// packages/shared-helpers/scripts/sync-jobs-schema.mjs in this repo for the
|
|
8
|
+
// reference sync step — run before `prisma generate`). The migration ships
|
|
9
|
+
// alongside, in prisma/migrations/, and is COPIED into the host's migrations
|
|
10
|
+
// folder — never symlinked, because Prisma enumerates that folder with lstat
|
|
11
|
+
// and silently skips a linked directory.
|
|
12
|
+
//
|
|
13
|
+
// Host-agnostic by design: the lease names a JOB, not a tenant or a user, so
|
|
14
|
+
// there is no relation to any host table at all.
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/// Named, time-bounded claims on the scheduled sweeps.
|
|
18
|
+
///
|
|
19
|
+
/// Within one worker the sweeps are single-flight (their own queue at
|
|
20
|
+
/// concurrency 1). Across two workers that guarantee evaporates: both read
|
|
21
|
+
/// the same work list and do the same work twice. Correctness is expected to
|
|
22
|
+
/// survive that (sweeps are idempotent by durable markers in the host's own
|
|
23
|
+
/// tables), but the effort and the user-visible notices double.
|
|
24
|
+
///
|
|
25
|
+
/// A lease row rather than `pg_advisory_lock`, deliberately. A session-level
|
|
26
|
+
/// advisory lock has to be released on the SAME connection that took it, and
|
|
27
|
+
/// Prisma pools connections with no such guarantee — a release that lands on
|
|
28
|
+
/// another connection silently fails and strands the lock, after which that
|
|
29
|
+
/// sweep never runs again until the connection recycles. The transaction-
|
|
30
|
+
/// scoped variant releases correctly but only holds for the transaction, and
|
|
31
|
+
/// sweeps make external HTTP calls that have no business inside one.
|
|
32
|
+
///
|
|
33
|
+
/// `expiresAt` is what makes a dead holder recoverable without anyone
|
|
34
|
+
/// intervening: a worker that is OOM-killed mid-sweep leaves the row behind,
|
|
35
|
+
/// and the next tick past the expiry simply takes it. The claim itself is a
|
|
36
|
+
/// conditional UPDATE, so the database decides the winner — two workers
|
|
37
|
+
/// racing cannot both see it as free.
|
|
38
|
+
model SweepLease {
|
|
39
|
+
/// The job name, e.g. "billing.tick". One lease per sweep.
|
|
40
|
+
name String @id
|
|
41
|
+
/// Which worker run holds it — release only ever matches its own holder.
|
|
42
|
+
holder String
|
|
43
|
+
acquiredAt DateTime @default(now()) @map("acquired_at")
|
|
44
|
+
/// Past this instant the lease is free, whether or not it was released.
|
|
45
|
+
expiresAt DateTime @map("expires_at")
|
|
46
|
+
|
|
47
|
+
@@map("sweep_leases")
|
|
48
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
-- Named, time-bounded claims on the scheduled sweeps (FUT-343).
|
|
2
|
+
--
|
|
3
|
+
-- One row per sweep. The claim is a conditional UPDATE on `expires_at`, so the
|
|
4
|
+
-- database picks the winner between racing workers; `expires_at` also makes a
|
|
5
|
+
-- crashed holder recoverable with no operator action.
|
|
6
|
+
CREATE TABLE IF NOT EXISTS "sweep_leases" (
|
|
7
|
+
"name" TEXT NOT NULL,
|
|
8
|
+
"holder" TEXT NOT NULL,
|
|
9
|
+
"acquired_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
10
|
+
"expires_at" TIMESTAMP(3) NOT NULL,
|
|
11
|
+
|
|
12
|
+
CONSTRAINT "sweep_leases_pkey" PRIMARY KEY ("name")
|
|
13
|
+
);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Well-known queue names.
|
|
3
|
+
*
|
|
4
|
+
* The scheduled sweeps run SINGLE-FLIGHT: their own queue at concurrency 1,
|
|
5
|
+
* so a tick that outlives its interval makes the next one WAIT rather than run
|
|
6
|
+
* alongside it.
|
|
7
|
+
*
|
|
8
|
+
* That is a correctness property, not tuning. Every sweep walks a work list
|
|
9
|
+
* and decides "has this already been handled?" by reading a row it is about
|
|
10
|
+
* to write; two ticks interleaved on the same row both read the old answer.
|
|
11
|
+
* Durable markers in the host's tables make that safe rather than corrupting
|
|
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.
|
|
16
|
+
*
|
|
17
|
+
* Keeping them off the DEFAULT queue matters too, in the other direction: a
|
|
18
|
+
* sweep grinding through a few hundred tenants must not occupy the workers
|
|
19
|
+
* that handle a user-facing job.
|
|
20
|
+
*
|
|
21
|
+
* Declare a sweep with `queue: SWEEP_QUEUE, concurrency: 1` and take a
|
|
22
|
+
* {@link ../lease/sweep-lease!createSweepLease | sweep lease} inside the
|
|
23
|
+
* handler when more than one worker can run — the queue serializes ticks
|
|
24
|
+
* within one worker, the lease serializes them across the deployment.
|
|
25
|
+
*/
|
|
26
|
+
export const SWEEP_QUEUE = "sweeps";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
|
|
3
|
+
import type { JobsRoute } from "../server/create-api-jobs";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `@12-apps/jobs/hono` — the jobs endpoints as a mountable router.
|
|
7
|
+
*
|
|
8
|
+
* The framework-neutral descriptors in `/server` are the contract; this is
|
|
9
|
+
* the adapter for the framework we happen to use. It lives behind its own
|
|
10
|
+
* subpath with `hono` as an OPTIONAL peer, so a host on Express — or one that
|
|
11
|
+
* mounts the descriptors itself — never resolves Hono at all. Importing the
|
|
12
|
+
* package root or `/server` does not reach this file.
|
|
13
|
+
*
|
|
14
|
+
* A host writes:
|
|
15
|
+
*
|
|
16
|
+
* const jobsApi = createApiJobs({ jobs: () => import("./jobs") });
|
|
17
|
+
* await jobsApi.start();
|
|
18
|
+
* app.route("/api/internal/jobs", jobsRouter(jobsApi));
|
|
19
|
+
*
|
|
20
|
+
* It takes the API INSTANCE rather than the config, deliberately: the host
|
|
21
|
+
* must call `start()` on the same instance whose routes report health, and a
|
|
22
|
+
* router that built its own would answer for a runtime nobody started.
|
|
23
|
+
*
|
|
24
|
+
* Auth stays the host's: mount this under whatever guard the deployment's
|
|
25
|
+
* internal probes live behind (future-pay's `/api/internal/*` is answered
|
|
26
|
+
* only machine-to-machine; the reverse proxy 404s it from the internet).
|
|
27
|
+
*/
|
|
28
|
+
export function jobsRouter(api: { routes: JobsRoute[] }): Hono {
|
|
29
|
+
const app = new Hono();
|
|
30
|
+
for (const route of api.routes) {
|
|
31
|
+
// Every route today is a GET; a future method would extend this dispatch,
|
|
32
|
+
// and forgetting to would fail the mount loudly rather than 404 quietly.
|
|
33
|
+
if (route.method !== "GET") {
|
|
34
|
+
throw new Error(`jobsRouter cannot mount a ${String(route.method)} route.`);
|
|
35
|
+
}
|
|
36
|
+
app.get(route.path, async (c) => {
|
|
37
|
+
const response = await route.handle();
|
|
38
|
+
// The status travels with the body the handler chose; this adapter
|
|
39
|
+
// never reinterprets either.
|
|
40
|
+
return c.json(response.body as Record<string, unknown>, response.status as 200);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return app;
|
|
44
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -24,6 +24,17 @@
|
|
|
24
24
|
* The BullMQ driver is deliberately NOT re-exported here — it is imported
|
|
25
25
|
* from `@12-apps/jobs/bullmq`, so that pulling in `defineJob` at an emit site
|
|
26
26
|
* never drags Redis into a bundle that only ever enqueues.
|
|
27
|
+
*
|
|
28
|
+
* The OPERATIONAL half — driver resolution with the inline zero-config
|
|
29
|
+
* default, the worker switch, graceful drain and the health endpoint — is
|
|
30
|
+
* `createApiJobs` in `@12-apps/jobs/server` (mount it with
|
|
31
|
+
* `@12-apps/jobs/hono` or your own adapter). What this root adds to it:
|
|
32
|
+
*
|
|
33
|
+
* - `SWEEP_QUEUE` — the single-flight queue the scheduled sweeps share.
|
|
34
|
+
* - `createSweepLease` — the named, time-bounded claim that keeps a sweep
|
|
35
|
+
* to ONE pass per tick across a multi-worker deployment. Its `SweepLease`
|
|
36
|
+
* table ships in `prisma/jobs.prisma` with its migration; the host syncs
|
|
37
|
+
* both (see ADOPTING.md).
|
|
27
38
|
*/
|
|
28
39
|
|
|
29
40
|
export { defineJob, findJob, listJobs, clearJobs, DuplicateJobError } from "./core/registry";
|
|
@@ -58,3 +69,16 @@ export type { InlineJobDriver, InlineJobRun } from "./drivers/inline";
|
|
|
58
69
|
|
|
59
70
|
export { parseRedisUrl, InvalidRedisUrlError } from "./drivers/redis-url";
|
|
60
71
|
export type { RedisConnectionOptions } from "./drivers/redis-url";
|
|
72
|
+
|
|
73
|
+
export { SWEEP_QUEUE } from "./core/queues";
|
|
74
|
+
|
|
75
|
+
export { createSweepLease } from "./lease/sweep-lease";
|
|
76
|
+
export type {
|
|
77
|
+
SweepLease,
|
|
78
|
+
SweepLeaseConfig,
|
|
79
|
+
SweepLeaseDb,
|
|
80
|
+
SweepLeaseDbProvider,
|
|
81
|
+
SweepLeaseDelegate,
|
|
82
|
+
SweepLeaseOutcome,
|
|
83
|
+
WithSweepLease,
|
|
84
|
+
} from "./lease/sweep-lease";
|