@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.
@@ -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
+ }