@12-apps/jobs 1.19.0 → 2.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 +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,234 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sweep leases — what makes a second queue worker safe.
|
|
5
|
+
*
|
|
6
|
+
* Within ONE worker the scheduled sweeps are already single-flight: they share
|
|
7
|
+
* the sweep queue at concurrency 1, so a tick that outlives its interval
|
|
8
|
+
* queues the next one instead of racing it. Across two workers that guarantee
|
|
9
|
+
* evaporates — both read the same work list and do the same work twice.
|
|
10
|
+
*
|
|
11
|
+
* Correctness should already survive that (every sweep is expected to be
|
|
12
|
+
* idempotent by durable markers in the host's own tables). What does NOT
|
|
13
|
+
* survive is effort and noise — the work doubles, and a user can be told
|
|
14
|
+
* twice. The lease closes that: one named, time-bounded claim per sweep,
|
|
15
|
+
* stored in the host's database, so the guarantee becomes one pass per tick
|
|
16
|
+
* across the whole deployment rather than within a worker.
|
|
17
|
+
*
|
|
18
|
+
* ## Why a lease row and not `pg_advisory_lock`
|
|
19
|
+
*
|
|
20
|
+
* The obvious answer is a Postgres advisory lock, and it is the wrong one here.
|
|
21
|
+
*
|
|
22
|
+
* - A SESSION-level lock (`pg_try_advisory_lock`) must be released on the
|
|
23
|
+
* same connection that took it. ORMs pool connections and promise nothing
|
|
24
|
+
* about which one a later query gets, so the release can land elsewhere,
|
|
25
|
+
* silently fail, and strand the lock — and a stranded lock means that
|
|
26
|
+
* sweep never runs again until the connection recycles. A guard whose
|
|
27
|
+
* failure mode is "the recovery mechanism stops recovering" is worse than
|
|
28
|
+
* no guard, because nothing reports it.
|
|
29
|
+
* - The TRANSACTION-scoped variant (`pg_advisory_xact_lock`) releases
|
|
30
|
+
* correctly, but only holds while the transaction is open. Holding one for
|
|
31
|
+
* the length of a sweep means holding it across the sweep's outbound HTTP
|
|
32
|
+
* calls, which is exactly what long transactions must not do.
|
|
33
|
+
*
|
|
34
|
+
* A lease row has neither problem: it is ordinary typed ORM access, it does
|
|
35
|
+
* not care which connection serves it, and `expiresAt` makes a crashed holder
|
|
36
|
+
* recoverable with no operator action. The claim is a conditional UPDATE, so
|
|
37
|
+
* the DATABASE picks the winner between racing workers — never application
|
|
38
|
+
* code reading and then writing.
|
|
39
|
+
*
|
|
40
|
+
* The table itself ships with this package: `prisma/jobs.prisma` (the
|
|
41
|
+
* `SweepLease` model) plus its migration, synced into the host's schema by the
|
|
42
|
+
* host's sync script. See ADOPTING.md.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The Prisma-shaped delegate for the `sweep_leases` table. A generated Prisma
|
|
47
|
+
* client's `prisma.sweepLease` satisfies it structurally; so does any adapter
|
|
48
|
+
* that speaks the same two calls — with one contract to honour: `create` MUST
|
|
49
|
+
* reject a duplicate primary key with an error carrying `code: "P2002"`,
|
|
50
|
+
* because that is the one error the claim reads as "lost the race" rather
|
|
51
|
+
* than "the store is broken".
|
|
52
|
+
*/
|
|
53
|
+
export interface SweepLeaseDelegate {
|
|
54
|
+
updateMany(args: {
|
|
55
|
+
where: { name: string; expiresAt?: { lte: Date }; holder?: string };
|
|
56
|
+
data: { holder?: string; acquiredAt?: Date; expiresAt: Date };
|
|
57
|
+
}): Promise<{ count: number }>;
|
|
58
|
+
create(args: {
|
|
59
|
+
data: { name: string; holder: string; acquiredAt: Date; expiresAt: Date };
|
|
60
|
+
}): Promise<unknown>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The slice of the host's DB client this module reaches. */
|
|
64
|
+
export interface SweepLeaseDb {
|
|
65
|
+
sweepLease: SweepLeaseDelegate;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* How the database is obtained — lazily, per claim, so the host's client can
|
|
70
|
+
* be created after this module is imported (the same seam report-builder's
|
|
71
|
+
* `db` takes).
|
|
72
|
+
*/
|
|
73
|
+
export type SweepLeaseDbProvider = () => SweepLeaseDb | Promise<SweepLeaseDb>;
|
|
74
|
+
|
|
75
|
+
/** What {@link WithSweepLease} reports back about a pass. */
|
|
76
|
+
export interface SweepLeaseOutcome<T> {
|
|
77
|
+
/** False when another worker held the lease and this pass did nothing. */
|
|
78
|
+
ran: boolean;
|
|
79
|
+
/** The callback's value, or null when it did not run. */
|
|
80
|
+
result: T | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Run `work` only if this worker can claim `name`'s lease. */
|
|
84
|
+
export type WithSweepLease = <T>(
|
|
85
|
+
name: string,
|
|
86
|
+
ttlMs: number,
|
|
87
|
+
work: () => Promise<T>,
|
|
88
|
+
) => Promise<SweepLeaseOutcome<T>>;
|
|
89
|
+
|
|
90
|
+
export interface SweepLeaseConfig {
|
|
91
|
+
/** Where the `sweep_leases` table lives. */
|
|
92
|
+
db: SweepLeaseDbProvider;
|
|
93
|
+
/**
|
|
94
|
+
* Identifies THIS process's claims. Defaults to `pid:uuid`, minted once per
|
|
95
|
+
* factory call — per-process in practice, because a host creates one lease
|
|
96
|
+
* per process. Release matches on it, so a lease may only ever be released
|
|
97
|
+
* by the run that took it: without that, a worker whose lease expired
|
|
98
|
+
* mid-sweep would come back and free the lease a DIFFERENT worker had since
|
|
99
|
+
* taken — handing two workers the same sweep, which is the thing being
|
|
100
|
+
* prevented. Override it only in tests that simulate two workers.
|
|
101
|
+
*/
|
|
102
|
+
holder?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface SweepLease {
|
|
106
|
+
/** The holder id this instance claims and releases under. */
|
|
107
|
+
readonly holder: string;
|
|
108
|
+
withSweepLease: WithSweepLease;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Prisma's unique-constraint violation: here, a lost race for the lease. */
|
|
112
|
+
function isUniqueViolation(error: unknown): boolean {
|
|
113
|
+
return (
|
|
114
|
+
typeof error === "object" &&
|
|
115
|
+
error !== null &&
|
|
116
|
+
(error as { code?: unknown }).code === "P2002"
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Take the lease if it is free, atomically.
|
|
122
|
+
*
|
|
123
|
+
* Two statements, in this order for a reason. The UPDATE handles the common
|
|
124
|
+
* case — the row exists and has expired — and its `where` is the whole race:
|
|
125
|
+
* two workers issuing it concurrently, exactly one reports a row changed.
|
|
126
|
+
* The INSERT covers only the first time a sweep ever runs, and a unique
|
|
127
|
+
* violation there means another worker created it first, which is a lost
|
|
128
|
+
* race, not an error.
|
|
129
|
+
*/
|
|
130
|
+
async function claim(
|
|
131
|
+
config: SweepLeaseConfig,
|
|
132
|
+
holder: string,
|
|
133
|
+
name: string,
|
|
134
|
+
ttlMs: number,
|
|
135
|
+
): Promise<boolean> {
|
|
136
|
+
const prisma = await config.db();
|
|
137
|
+
const now = new Date();
|
|
138
|
+
const expiresAt = new Date(now.getTime() + ttlMs);
|
|
139
|
+
// Deliberately NOT wrapped in a try: a failing UPDATE means the store is
|
|
140
|
+
// unreachable or the table is missing, and that has to surface. Only the
|
|
141
|
+
// INSERT below has an expected failure.
|
|
142
|
+
const { count } = await prisma.sweepLease.updateMany({
|
|
143
|
+
// `lte: now` is the free test: either nobody holds it, or whoever did
|
|
144
|
+
// has run past their TTL and is presumed dead.
|
|
145
|
+
where: { name, expiresAt: { lte: now } },
|
|
146
|
+
data: { holder, acquiredAt: now, expiresAt },
|
|
147
|
+
});
|
|
148
|
+
if (count > 0) return true;
|
|
149
|
+
// No row updated means either the row does not exist yet, or somebody
|
|
150
|
+
// holds an unexpired lease. Only the first is worth an insert; the second
|
|
151
|
+
// makes it fail on the primary key, which is the ONE error that means
|
|
152
|
+
// "not acquired".
|
|
153
|
+
try {
|
|
154
|
+
await prisma.sweepLease.create({
|
|
155
|
+
data: { name, holder, acquiredAt: now, expiresAt },
|
|
156
|
+
});
|
|
157
|
+
return true;
|
|
158
|
+
} catch (error) {
|
|
159
|
+
if (isUniqueViolation(error)) return false;
|
|
160
|
+
// Anything else — no table, no connection, a column that does not match
|
|
161
|
+
// the schema — is not contention and must not be dressed up as it.
|
|
162
|
+
// Rethrow so the job fails and the sweep's silence has a cause attached.
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Give the lease back — but only if we are still the holder. */
|
|
168
|
+
async function release(
|
|
169
|
+
config: SweepLeaseConfig,
|
|
170
|
+
holder: string,
|
|
171
|
+
name: string,
|
|
172
|
+
): Promise<void> {
|
|
173
|
+
try {
|
|
174
|
+
const prisma = await config.db();
|
|
175
|
+
await prisma.sweepLease.updateMany({
|
|
176
|
+
// The holder match is load-bearing. If this pass overran its TTL and
|
|
177
|
+
// another worker has since taken the lease, this must not free theirs.
|
|
178
|
+
where: { name, holder },
|
|
179
|
+
data: { expiresAt: new Date() },
|
|
180
|
+
});
|
|
181
|
+
} catch {
|
|
182
|
+
// Release stays tolerant, unlike the claim, for two reasons. The TTL
|
|
183
|
+
// frees the lease anyway — the same mechanism that covers a crashed
|
|
184
|
+
// holder — so nothing is stuck. And this runs in a `finally`: a throw
|
|
185
|
+
// here would REPLACE whatever the sweep itself threw, hiding the real
|
|
186
|
+
// failure behind a lease error. A store that is genuinely down has
|
|
187
|
+
// already been reported by the claim on the next tick, which is where
|
|
188
|
+
// it belongs.
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Build the lease helper over the host's database.
|
|
194
|
+
*
|
|
195
|
+
* `withSweepLease(name, ttlMs, work)` runs `work` only when this worker can
|
|
196
|
+
* claim `name`'s lease. `ttlMs` must comfortably exceed how long the sweep
|
|
197
|
+
* actually takes: it is the window in which a CRASHED holder blocks the next
|
|
198
|
+
* run, and — more importantly — a TTL shorter than the sweep means the lease
|
|
199
|
+
* expires while the work is still going and a second worker starts a
|
|
200
|
+
* concurrent pass, which is precisely the situation this exists to prevent.
|
|
201
|
+
* Err long; the cost of a long TTL is a delayed recovery after a crash, the
|
|
202
|
+
* cost of a short one is the bug.
|
|
203
|
+
*
|
|
204
|
+
* Losing the claim is silent; anything else THROWS. That distinction is the
|
|
205
|
+
* whole safety of this helper. "Another worker has it" and "the lease table
|
|
206
|
+
* does not exist" are the same shape at the call site — both end with no work
|
|
207
|
+
* done — but the first is the system working and the second means every
|
|
208
|
+
* protected sweep has stopped, indefinitely, with no failed job to notice. A
|
|
209
|
+
* blanket catch here would turn a missing migration or a database outage into
|
|
210
|
+
* a deployment that looks healthy while doing nothing at all, which is a
|
|
211
|
+
* strictly worse failure than the duplicate work this guard exists to prevent.
|
|
212
|
+
*
|
|
213
|
+
* So only a lost race is swallowed. A real store fault propagates, the job is
|
|
214
|
+
* marked failed, and someone finds out.
|
|
215
|
+
*/
|
|
216
|
+
export function createSweepLease(config: SweepLeaseConfig): SweepLease {
|
|
217
|
+
const holder = config.holder ?? `${process.pid}:${randomUUID()}`;
|
|
218
|
+
|
|
219
|
+
const withSweepLease: WithSweepLease = async (name, ttlMs, work) => {
|
|
220
|
+
const acquired = await claim(config, holder, name, ttlMs);
|
|
221
|
+
if (!acquired) return { ran: false, result: null };
|
|
222
|
+
try {
|
|
223
|
+
return { ran: true, result: await work() };
|
|
224
|
+
} finally {
|
|
225
|
+
// Released whether the work threw or returned: a failed sweep must not
|
|
226
|
+
// hold the lease for the rest of its TTL, or one bad pass silently
|
|
227
|
+
// suspends the job for that long. The next tick retries, which is the
|
|
228
|
+
// intended cadence.
|
|
229
|
+
await release(config, holder, name);
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
return { holder, withSweepLease };
|
|
234
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { RegisteredJob } from "../core/registry";
|
|
2
|
+
import type { JobDriver, JobLogger } from "../core/types";
|
|
3
|
+
import type { SweepLeaseDbProvider } from "../lease/sweep-lease";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The factory's config surface, and how it resolves against the environment.
|
|
7
|
+
*
|
|
8
|
+
* Every environment read happens in {@link resolveConfig}, which `start()`
|
|
9
|
+
* calls — NOT the factory. future-pay's `bootstrapJobs()` read the whole
|
|
10
|
+
* matrix (`JOBS_DRIVER`, `REDIS_URL`, `NODE_ENV`, `JOBS_WORKER`,
|
|
11
|
+
* `JOBS_QUEUE_PREFIX`) at the moment it ran, and the recommended host shape
|
|
12
|
+
* is factory-at-module-scope + `await jobsApi.start()` later — so an env var
|
|
13
|
+
* that arrives between the two (a config module loading after the route
|
|
14
|
+
* module) must be honoured for ALL of the matrix, not silently for one
|
|
15
|
+
* variable and not the others.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** What the driver choice may say (`JOBS_DRIVER`, or `config.driver`). */
|
|
19
|
+
export type JobsDriverChoice = "bullmq" | "inline" | "off";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* How the host names its jobs. An import thunk (`() => import("./jobs")`) is
|
|
23
|
+
* the usual form — `defineJob` registers at module scope, so importing the
|
|
24
|
+
* modules IS the registration. An array of already-registered jobs is
|
|
25
|
+
* accepted for hosts (and tests) that hold the references anyway; it forces
|
|
26
|
+
* the modules to have been imported, which is the same guarantee.
|
|
27
|
+
*/
|
|
28
|
+
export type JobsSource =
|
|
29
|
+
| readonly RegisteredJob<never>[]
|
|
30
|
+
| (() => unknown | Promise<unknown>);
|
|
31
|
+
|
|
32
|
+
export interface JobsServerConfig {
|
|
33
|
+
/** Every job this process can enqueue or consume. See {@link JobsSource}. */
|
|
34
|
+
jobs: JobsSource;
|
|
35
|
+
/**
|
|
36
|
+
* A driver INSTANCE (tests, exotic hosts), a choice by name, or unset to
|
|
37
|
+
* resolve one: `JOBS_DRIVER` if set; else `bullmq` when a Redis URL exists;
|
|
38
|
+
* else `off` in production and `inline` everywhere else.
|
|
39
|
+
*/
|
|
40
|
+
driver?: JobDriver | JobsDriverChoice;
|
|
41
|
+
/** `redis://[user:pass@]host:port[/db]`. Defaults to `REDIS_URL`. */
|
|
42
|
+
redisUrl?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Whether THIS process consumes the queue and runs the schedules, not just
|
|
45
|
+
* enqueues. Defaults to `JOBS_WORKER` being `1` or `true`.
|
|
46
|
+
*/
|
|
47
|
+
worker?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Refuses the inline driver and makes "no queue" loud. Defaults to
|
|
50
|
+
* `NODE_ENV === "production"`.
|
|
51
|
+
*/
|
|
52
|
+
production?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Queue key prefix, so one Redis can carry several environments without a
|
|
55
|
+
* staging worker consuming production's jobs. Defaults to
|
|
56
|
+
* `JOBS_QUEUE_PREFIX`.
|
|
57
|
+
*/
|
|
58
|
+
queuePrefix?: string;
|
|
59
|
+
/** The host's logger. Defaults to the console. */
|
|
60
|
+
logger?: JobLogger;
|
|
61
|
+
/**
|
|
62
|
+
* Where the `sweep_leases` table lives — enables `withSweepLease` on the
|
|
63
|
+
* factory's return. Omit it and the lease helper rejects on first use,
|
|
64
|
+
* loudly, because a sweep that silently skipped its lease would be the
|
|
65
|
+
* unprotected overlap the lease exists to prevent.
|
|
66
|
+
*/
|
|
67
|
+
db?: SweepLeaseDbProvider;
|
|
68
|
+
/**
|
|
69
|
+
* Install `SIGTERM`/`SIGINT` handlers that drain in-flight jobs on a deploy
|
|
70
|
+
* (worker processes only). Defaults to true; turn it off in tests, which
|
|
71
|
+
* must not leak process listeners.
|
|
72
|
+
*/
|
|
73
|
+
installShutdownHooks?: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Fallback logger — used until the host passes its own. */
|
|
77
|
+
const consoleLogger: JobLogger = {
|
|
78
|
+
info: (message, ...meta) => console.info(`[jobs] ${message}`, ...meta),
|
|
79
|
+
warn: (message, ...meta) => console.warn(`[jobs] ${message}`, ...meta),
|
|
80
|
+
error: (message, ...meta) => console.error(`[jobs] ${message}`, ...meta),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** `JOBS_WORKER=1|true` is how a deployment marks the consuming process. */
|
|
84
|
+
export function isWorkerProcess(): boolean {
|
|
85
|
+
const flag = process.env.JOBS_WORKER?.trim().toLowerCase();
|
|
86
|
+
return flag === "1" || flag === "true";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The config with every environment default applied, at one single moment. */
|
|
90
|
+
export interface ResolvedConfig {
|
|
91
|
+
redisUrl: string | undefined;
|
|
92
|
+
production: boolean;
|
|
93
|
+
worker: boolean;
|
|
94
|
+
queuePrefix: string | undefined;
|
|
95
|
+
logger: JobLogger;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Apply the environment defaults. Called from `start()` so the whole matrix
|
|
100
|
+
* is read at the same moment — the factory reads nothing, and `JOBS_DRIVER`
|
|
101
|
+
* (read during driver resolution, also under `start()`) cannot disagree with
|
|
102
|
+
* `REDIS_URL` about WHEN the environment was consulted.
|
|
103
|
+
*/
|
|
104
|
+
export function resolveConfig(config: JobsServerConfig): ResolvedConfig {
|
|
105
|
+
return {
|
|
106
|
+
redisUrl: config.redisUrl ?? process.env.REDIS_URL,
|
|
107
|
+
production: config.production ?? process.env.NODE_ENV === "production",
|
|
108
|
+
worker: config.worker ?? isWorkerProcess(),
|
|
109
|
+
queuePrefix: config.queuePrefix ?? process.env.JOBS_QUEUE_PREFIX,
|
|
110
|
+
logger: config.logger ?? consoleLogger,
|
|
111
|
+
};
|
|
112
|
+
}
|