@ultimat3/jobs 1.2.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/CLAUDE.md +567 -0
- package/README.md +355 -16
- package/package.json +7 -5
- package/src/backfill-gate.ts +97 -0
- package/src/backfill-inspect.ts +73 -0
- package/src/backfill-ledger.ts +183 -0
- package/src/backfill-pass.ts +276 -0
- package/src/backfill-pending.ts +131 -0
- package/src/backfill-rate.ts +109 -0
- package/src/backfill-registry.ts +108 -0
- package/src/backfill-scope.ts +70 -0
- package/src/backfill.ts +213 -0
- package/src/driver-memory.ts +61 -9
- package/src/driver-nats.ts +2 -1
- package/src/driver-pg-ddl.ts +180 -0
- package/src/driver-pg-rows.ts +123 -0
- package/src/driver-pg-sql.ts +251 -55
- package/src/driver-pg.ts +138 -92
- package/src/driver-redis.ts +2 -1
- package/src/driver.ts +91 -7
- package/src/errors.ts +290 -4
- package/src/events-pg.ts +121 -0
- package/src/events.ts +7 -1
- package/src/execute.ts +289 -0
- package/src/heartbeat.ts +146 -0
- package/src/index.ts +121 -27
- package/src/inspect.ts +43 -2
- package/src/job.ts +72 -2
- package/src/leases.ts +90 -0
- package/src/limits.ts +0 -0
- package/src/metrics.ts +35 -0
- package/src/outbox-pg.ts +137 -0
- package/src/outbox.ts +115 -53
- package/src/register.ts +1 -1
- package/src/retry.ts +6 -1
- package/src/run-signal.ts +50 -0
- package/src/scheduler-pg.ts +103 -0
- package/src/scheduler.ts +159 -245
- package/src/steps.ts +155 -31
- package/src/task.ts +229 -0
- package/src/tenant.ts +61 -0
- package/src/worker-fleet-slots.ts +124 -0
- package/src/worker-run.ts +132 -0
- package/src/worker.ts +207 -190
package/src/outbox.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
// The transactional outbox
|
|
2
|
-
//
|
|
3
|
-
// business rows and a relay publishes it after commit. Without it, every enqueue is a
|
|
1
|
+
// The transactional outbox. `ctx.jobs.enqueue()` inside a request writes the job row in the SAME
|
|
2
|
+
// `tx` as the business rows and a relay publishes it after commit. Without it, every enqueue is a
|
|
4
3
|
// distributed-transaction coin flip:
|
|
5
4
|
//
|
|
6
5
|
// enqueue then rollback -> the job runs against rows that never existed
|
|
@@ -9,9 +8,23 @@
|
|
|
9
8
|
// Both are load-dependent, both pass every test, and both are the top source of "the email
|
|
10
9
|
// went out but the order isn't in the database" tickets. Joining the transaction removes the
|
|
11
10
|
// window entirely; the relay's at-least-once delivery is deduped by the job's idempotencyKey.
|
|
11
|
+
//
|
|
12
|
+
// **It is NOT on by default, and this header used to claim it was** (`As of 2026-08`). Three
|
|
13
|
+
// things have to be true in a process for an enqueue to be transactional, and the fallback at
|
|
14
|
+
// `jobsFacade()` is what happens when they are not:
|
|
15
|
+
//
|
|
16
|
+
// 1. `x_outbox` exists — it ships in `SQL_JOBS_TABLE` now, so applying the queue DDL is enough.
|
|
17
|
+
// 2. `setJobsFacade(createJobsFacade({ store, driver }, currentTx))` ran at boot, with a store
|
|
18
|
+
// from `createPgOutboxStore` and a REAL `currentTx` accessor.
|
|
19
|
+
// 3. `createOutboxRelay({ store, driver }).start()` is running somewhere.
|
|
20
|
+
//
|
|
21
|
+
// With none of them, `jobsFacade()` answers the fallback below, whose `currentTx` is
|
|
22
|
+
// `() => undefined`: every enqueue publishes straight to the driver, outside the caller's
|
|
23
|
+
// transaction, and both failure modes above are live. That fallback is deliberate — a script, a
|
|
24
|
+
// test and `x dev` must enqueue with nothing wired — but it is a fallback, not the guarantee.
|
|
12
25
|
|
|
13
26
|
import type { Clock } from '@ultimat3/core';
|
|
14
|
-
import { logger, uuid } from '@ultimat3/core';
|
|
27
|
+
import { currentSpanContext, logger, traceparent, uuid } from '@ultimat3/core';
|
|
15
28
|
import type { Tx } from '@ultimat3/entity';
|
|
16
29
|
import { nowMs } from './clock';
|
|
17
30
|
import type { EnqueueResult, JobDriver } from './driver';
|
|
@@ -29,6 +42,9 @@ export interface OutboxRecord {
|
|
|
29
42
|
readonly runAt: number;
|
|
30
43
|
readonly stagedAt: number;
|
|
31
44
|
readonly tenantId?: string;
|
|
45
|
+
/** The enqueuing request's trace, carried across the relay so the job's span still has a parent. */
|
|
46
|
+
readonly traceparent?: string;
|
|
47
|
+
readonly enqueuedBy?: string;
|
|
32
48
|
readonly publishedAt?: number;
|
|
33
49
|
}
|
|
34
50
|
|
|
@@ -45,12 +61,21 @@ export interface OutboxStore {
|
|
|
45
61
|
pendingCount(): Promise<number>;
|
|
46
62
|
}
|
|
47
63
|
|
|
64
|
+
export interface MemoryOutboxStore extends OutboxStore {
|
|
65
|
+
/**
|
|
66
|
+
* Committed rows this process is still holding. The relay's backlog and nothing else — a
|
|
67
|
+
* published row is dropped, so this is a bound, not a total. `x dev` and the tests are the
|
|
68
|
+
* only readers; a pg deployment reads `x_outbox` instead.
|
|
69
|
+
*/
|
|
70
|
+
retained(): number;
|
|
71
|
+
}
|
|
72
|
+
|
|
48
73
|
/**
|
|
49
74
|
* Default store. Staged rows hang off the `Tx` object itself in a WeakMap, so the "same
|
|
50
75
|
* transaction" guarantee needs no cooperation from the DB layer and rollback is a delete.
|
|
51
76
|
* The pg store swaps this for a real `x_outbox` table written by the same connection.
|
|
52
77
|
*/
|
|
53
|
-
export function createMemoryOutboxStore():
|
|
78
|
+
export function createMemoryOutboxStore(): MemoryOutboxStore {
|
|
54
79
|
const staged = new WeakMap<object, OutboxRecord[]>();
|
|
55
80
|
const committed = new Map<string, OutboxRecord>();
|
|
56
81
|
|
|
@@ -80,9 +105,12 @@ export function createMemoryOutboxStore(): OutboxStore {
|
|
|
80
105
|
.slice(0, limit);
|
|
81
106
|
return Promise.resolve(ready);
|
|
82
107
|
},
|
|
83
|
-
markPublished(id,
|
|
84
|
-
|
|
85
|
-
|
|
108
|
+
markPublished(id, _at) {
|
|
109
|
+
// Deleted, not stamped. A published row is out of the relay's reach either way, and the
|
|
110
|
+
// pg store's `published_at` column is a retained audit trail this map is not: rewriting
|
|
111
|
+
// it in place held every payload ever enqueued — arbitrary job input — for the life of
|
|
112
|
+
// the process, and made `claim()` and `pendingCount()` walk all of them every 200ms.
|
|
113
|
+
committed.delete(id);
|
|
86
114
|
return Promise.resolve();
|
|
87
115
|
},
|
|
88
116
|
pendingCount() {
|
|
@@ -92,6 +120,7 @@ export function createMemoryOutboxStore(): OutboxStore {
|
|
|
92
120
|
}
|
|
93
121
|
return Promise.resolve(count);
|
|
94
122
|
},
|
|
123
|
+
retained: () => committed.size,
|
|
95
124
|
};
|
|
96
125
|
}
|
|
97
126
|
|
|
@@ -102,6 +131,29 @@ export interface EnqueueOptions {
|
|
|
102
131
|
readonly queue?: string;
|
|
103
132
|
/** Escape hatch for enqueues that must fire regardless of the caller's transaction. */
|
|
104
133
|
readonly outbox?: boolean;
|
|
134
|
+
/**
|
|
135
|
+
* Who asked. AUDIT ONLY — the job body still runs with system authority. `handle.as(actor, ...)`
|
|
136
|
+
* fills it from the actor; set it directly only where there is no actor object to hand over.
|
|
137
|
+
*/
|
|
138
|
+
readonly enqueuedBy?: string;
|
|
139
|
+
/** Override the ambient trace. Almost never: the facade stamps the current span for you. */
|
|
140
|
+
readonly traceparent?: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The W3C `traceparent` of the span this enqueue is happening inside, or `undefined` outside a
|
|
145
|
+
* trace. This is the ONE place the link is minted: `docs/idea/04-jobs.md` promises a job trace
|
|
146
|
+
* linked to the enqueuing request, and before this there was no field to carry the link, so a
|
|
147
|
+
* checkout's `chargeCard` opened a fresh root two seconds later with nothing pointing back.
|
|
148
|
+
*
|
|
149
|
+
* A context recovered from a `Ctx` has an empty `spanId` (`currentSpanContext`), which renders as
|
|
150
|
+
* an all-zero parent that every collector rejects — so that case carries no header rather than a
|
|
151
|
+
* malformed one, and the job opens a root as it did before.
|
|
152
|
+
*/
|
|
153
|
+
function ambientTraceparent(): string | undefined {
|
|
154
|
+
const context = currentSpanContext();
|
|
155
|
+
if (context === undefined || context.spanId === '') return undefined;
|
|
156
|
+
return traceparent(context);
|
|
105
157
|
}
|
|
106
158
|
|
|
107
159
|
export interface OutboxDeps {
|
|
@@ -121,6 +173,7 @@ export function enqueueInTx<I>(
|
|
|
121
173
|
options: EnqueueOptions = {},
|
|
122
174
|
): Promise<OutboxRecord> {
|
|
123
175
|
const at = nowMs(deps.clock);
|
|
176
|
+
const trace = options.traceparent ?? ambientTraceparent();
|
|
124
177
|
const record: OutboxRecord = {
|
|
125
178
|
id: uuid(),
|
|
126
179
|
job: handle.name,
|
|
@@ -131,6 +184,10 @@ export function enqueueInTx<I>(
|
|
|
131
184
|
runAt: options.runAt ?? at,
|
|
132
185
|
stagedAt: at,
|
|
133
186
|
...(options.tenantId === undefined ? {} : { tenantId: options.tenantId }),
|
|
187
|
+
// Stamped at STAGE time and not at publish time: the relay runs after commit, in its own
|
|
188
|
+
// timer, with no request span in scope — a trace read there would be nobody's.
|
|
189
|
+
...(trace === undefined ? {} : { traceparent: trace }),
|
|
190
|
+
...(options.enqueuedBy === undefined ? {} : { enqueuedBy: options.enqueuedBy }),
|
|
134
191
|
};
|
|
135
192
|
return deps.store.stage(tx, record).then(() => record);
|
|
136
193
|
}
|
|
@@ -158,6 +215,7 @@ export function createJobsFacade(deps: OutboxDeps, currentTx: () => Tx | undefin
|
|
|
158
215
|
if (deps.mode === 'required' && options.outbox !== false) {
|
|
159
216
|
throw new OutboxNoTxError({ job: handle.name });
|
|
160
217
|
}
|
|
218
|
+
const trace = options.traceparent ?? ambientTraceparent();
|
|
161
219
|
return deps.driver.enqueue({
|
|
162
220
|
name: handle.name,
|
|
163
221
|
queue: options.queue ?? handle.queue,
|
|
@@ -166,6 +224,8 @@ export function createJobsFacade(deps: OutboxDeps, currentTx: () => Tx | undefin
|
|
|
166
224
|
maxAttempts: handle.retry.attempts,
|
|
167
225
|
runAt: options.runAt ?? nowMs(deps.clock),
|
|
168
226
|
...(options.tenantId === undefined ? {} : { tenantId: options.tenantId }),
|
|
227
|
+
...(trace === undefined ? {} : { traceparent: trace }),
|
|
228
|
+
...(options.enqueuedBy === undefined ? {} : { enqueuedBy: options.enqueuedBy }),
|
|
169
229
|
});
|
|
170
230
|
}
|
|
171
231
|
|
|
@@ -233,7 +293,13 @@ export interface OutboxRelay {
|
|
|
233
293
|
/** One pass. Returns how many rows were published. Call it directly in tests. */
|
|
234
294
|
tick(): Promise<number>;
|
|
235
295
|
start(): void;
|
|
236
|
-
|
|
296
|
+
/**
|
|
297
|
+
* Stop polling and WAIT OUT the pass in flight, the way `worker.stop()` waits out its rounds and
|
|
298
|
+
* `scheduler.stop()` its dispatch. A pass is a publish followed by a `markPublished`, and a
|
|
299
|
+
* caller that returned between the two closed the database under the row it was about to mark:
|
|
300
|
+
* re-published next boot at best, a rejection against a closed pool at worst.
|
|
301
|
+
*/
|
|
302
|
+
stop(): Promise<void>;
|
|
237
303
|
pending(): Promise<number>;
|
|
238
304
|
}
|
|
239
305
|
|
|
@@ -246,6 +312,8 @@ export function createOutboxRelay(options: RelayOptions): OutboxRelay {
|
|
|
246
312
|
const intervalMs = options.intervalMs ?? 200;
|
|
247
313
|
let timer: ReturnType<typeof setInterval> | undefined;
|
|
248
314
|
let running = false;
|
|
315
|
+
/** The pass in flight, so `stop()` joins it instead of returning underneath it. */
|
|
316
|
+
let pass: Promise<void> | undefined;
|
|
249
317
|
|
|
250
318
|
const tick = async (): Promise<number> => {
|
|
251
319
|
const batch = await options.store.claim(batchSize);
|
|
@@ -260,16 +328,26 @@ export function createOutboxRelay(options: RelayOptions): OutboxRelay {
|
|
|
260
328
|
maxAttempts: record.maxAttempts,
|
|
261
329
|
runAt: record.runAt,
|
|
262
330
|
...(record.tenantId === undefined ? {} : { tenantId: record.tenantId }),
|
|
331
|
+
...(record.traceparent === undefined ? {} : { traceparent: record.traceparent }),
|
|
332
|
+
...(record.enqueuedBy === undefined ? {} : { enqueuedBy: record.enqueuedBy }),
|
|
263
333
|
});
|
|
264
334
|
await options.store.markPublished(record.id, nowMs(options.clock));
|
|
265
335
|
published += 1;
|
|
266
336
|
} catch (error) {
|
|
267
|
-
//
|
|
337
|
+
// STOP the batch. `claim()` returns rows in `staged_at` order and the loop used to log
|
|
338
|
+
// and continue, which published every LATER row past the one that failed — so an app
|
|
339
|
+
// that stages `createInvoice` then `chargeCard` in one transaction could have the charge
|
|
340
|
+
// run first. The row stays unpublished and the next tick starts again from it; a
|
|
341
|
+
// permanently poisoned row wedges its queue, which is visible in `pending()` and is the
|
|
342
|
+
// correct trade against silently reordering committed work.
|
|
268
343
|
logger.warn('jobs.outbox.publish-failed', {
|
|
269
344
|
job: record.job,
|
|
270
345
|
id: record.id,
|
|
346
|
+
published,
|
|
347
|
+
remaining: batch.length - published,
|
|
271
348
|
error: error instanceof Error ? error.message : String(error),
|
|
272
349
|
});
|
|
350
|
+
break;
|
|
273
351
|
}
|
|
274
352
|
}
|
|
275
353
|
return published;
|
|
@@ -282,55 +360,39 @@ export function createOutboxRelay(options: RelayOptions): OutboxRelay {
|
|
|
282
360
|
timer = setInterval(() => {
|
|
283
361
|
if (running) return;
|
|
284
362
|
running = true;
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
363
|
+
// `.catch` before `.finally`, the shape every other loop in this package uses. `tick()`
|
|
364
|
+
// guards each publish but not `store.claim()` — one pool timeout during a failover
|
|
365
|
+
// rejects here unobserved, and Bun's default for an unhandled rejection is to end the
|
|
366
|
+
// process, taking every staged, unpublished row with it.
|
|
367
|
+
//
|
|
368
|
+
// Kept rather than discarded, because `stop()` awaits exactly this chain: the publish and
|
|
369
|
+
// the `markPublished` behind it are one pass, and a teardown that returned between them
|
|
370
|
+
// closed the database under the row it was about to mark. The chain carries its own
|
|
371
|
+
// `catch`, so a caller that does not await still gets no unhandled rejection.
|
|
372
|
+
pass = tick()
|
|
373
|
+
.then((): void => undefined)
|
|
374
|
+
.catch((error: unknown) => {
|
|
375
|
+
logger.error('jobs.outbox.tick-failed', {
|
|
376
|
+
error: error instanceof Error ? error.message : String(error),
|
|
377
|
+
});
|
|
378
|
+
})
|
|
379
|
+
.finally(() => {
|
|
380
|
+
running = false;
|
|
381
|
+
pass = undefined;
|
|
382
|
+
});
|
|
288
383
|
}, intervalMs);
|
|
289
384
|
},
|
|
290
|
-
stop() {
|
|
385
|
+
async stop() {
|
|
291
386
|
if (timer !== undefined) clearInterval(timer);
|
|
292
387
|
timer = undefined;
|
|
388
|
+
// Awaited AFTER the interval is cleared, so no further pass can start behind this one.
|
|
389
|
+
await pass;
|
|
293
390
|
},
|
|
294
391
|
pending: () => options.store.pendingCount(),
|
|
295
392
|
};
|
|
296
393
|
}
|
|
297
394
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
job text not null,
|
|
303
|
-
queue text not null default 'default',
|
|
304
|
-
input jsonb not null,
|
|
305
|
-
idempotency_key text not null,
|
|
306
|
-
max_attempts int not null default 3,
|
|
307
|
-
run_at timestamptz not null default now(),
|
|
308
|
-
staged_at timestamptz not null default now(),
|
|
309
|
-
tenant_id text,
|
|
310
|
-
published_at timestamptz
|
|
311
|
-
);
|
|
312
|
-
create index if not exists x_outbox_unpublished_idx
|
|
313
|
-
on x_outbox (staged_at) where published_at is null;
|
|
314
|
-
`.trim();
|
|
315
|
-
|
|
316
|
-
export const SQL_OUTBOX_STAGE = `
|
|
317
|
-
insert into x_outbox
|
|
318
|
-
(id, job, queue, input, idempotency_key, max_attempts, run_at, staged_at, tenant_id)
|
|
319
|
-
values ($1, $2, $3, $4::jsonb, $5, $6, to_timestamp($7 / 1000.0), to_timestamp($8 / 1000.0), $9)
|
|
320
|
-
`.trim();
|
|
321
|
-
|
|
322
|
-
export const SQL_OUTBOX_CLAIM = `
|
|
323
|
-
select id, job, queue, input, idempotency_key, max_attempts,
|
|
324
|
-
(extract(epoch from run_at) * 1000)::bigint as run_at,
|
|
325
|
-
(extract(epoch from staged_at) * 1000)::bigint as staged_at,
|
|
326
|
-
tenant_id
|
|
327
|
-
from x_outbox
|
|
328
|
-
where published_at is null
|
|
329
|
-
order by staged_at
|
|
330
|
-
limit $1
|
|
331
|
-
for update skip locked
|
|
332
|
-
`.trim();
|
|
333
|
-
|
|
334
|
-
export const SQL_OUTBOX_MARK_PUBLISHED = `
|
|
335
|
-
update x_outbox set published_at = to_timestamp($2 / 1000.0) where id = $1
|
|
336
|
-
`.trim();
|
|
395
|
+
// The outbox's SQL moved to `driver-pg-sql.ts`, where every statement this package runs lives —
|
|
396
|
+
// and, more to the point, where `SQL_JOBS_TABLE` is: `x_outbox` was declared here, in a constant
|
|
397
|
+
// no boot code applied, which is the whole reason the outbox was documented and never created.
|
|
398
|
+
// `src/index.ts` still re-exports the same four names, from there.
|
package/src/register.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { type RegisteredPrimitive, registerPrimitiveRegistrar } from '@ultimat3/core';
|
|
8
8
|
import { isJobHandle, registerJob } from './job';
|
|
9
|
-
import { isTaskHandle, registerTask } from './
|
|
9
|
+
import { isTaskHandle, registerTask } from './task';
|
|
10
10
|
|
|
11
11
|
/** `registerJobs(await import('./jobs'))` — export names become job names. */
|
|
12
12
|
export function registerJobs(
|
package/src/retry.ts
CHANGED
|
@@ -48,7 +48,12 @@ export function backoffDelayMs(policy: RetryPolicy, attempt: number, random?: Ra
|
|
|
48
48
|
else raw = base * 2 ** (step - 1);
|
|
49
49
|
|
|
50
50
|
const capped = Math.min(raw, cap);
|
|
51
|
-
|
|
51
|
+
// `?? DEFAULT_RETRY.jitter`, like every other option above. It read `!== true`, so an omitted
|
|
52
|
+
// `jitter` meant OFF while the field's own doc says "Equal jitter … by default" — a burst of
|
|
53
|
+
// failures then retried in lockstep, which is the thundering herd the default exists to break.
|
|
54
|
+
// Masked for jobs declared through `job()` (it merges the defaults) and live for every direct
|
|
55
|
+
// caller of this exported function, `retrySchedule` included.
|
|
56
|
+
if ((policy.jitter ?? DEFAULT_RETRY.jitter) !== true) return Math.round(capped);
|
|
52
57
|
const roll = (random ?? Math.random)();
|
|
53
58
|
return Math.round(capped / 2 + (capped / 2) * roll);
|
|
54
59
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// The signal ONE job run is cancelled by: the caller's `Ctx.signal`, the lease heartbeat, and
|
|
2
|
+
// whatever else the worker holds over the run. `AbortSignal.any` composed the same thing and could
|
|
3
|
+
// not be undone — an app wiring a process-lifetime controller into `WorkerOptions.context()` grew
|
|
4
|
+
// one composite per job for the life of the worker, and nothing could abort the result either.
|
|
5
|
+
|
|
6
|
+
export interface RunSignal {
|
|
7
|
+
/** Handed to the run as `ctx.signal`; dies with the run. */
|
|
8
|
+
readonly signal: AbortSignal;
|
|
9
|
+
/** Cancel this run. The first reason wins, exactly as `AbortController.abort` already does. */
|
|
10
|
+
abort(reason: unknown): void;
|
|
11
|
+
/**
|
|
12
|
+
* Stop following the sources. Idempotent, and it never aborts: a run that settled leaves its
|
|
13
|
+
* signal in whatever state it ended in, it just stops being the caller's problem.
|
|
14
|
+
*/
|
|
15
|
+
dispose(): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One controller per run, following every source it was given. A source already aborted aborts the
|
|
20
|
+
* run at composition, carrying its own reason — the same semantics `AbortSignal.any` has, minus
|
|
21
|
+
* the part that cannot be handed back.
|
|
22
|
+
*
|
|
23
|
+
* `undefined` and a non-`AbortSignal` are both skipped rather than refused: `Ctx.signal` is
|
|
24
|
+
* non-optional in the type and still arrives missing across a cast (`@ultimat3/http`'s `asCtx`, a
|
|
25
|
+
* test's `{} as Ctx`), and a job that crashed on a missing field is worse than a job with no
|
|
26
|
+
* caller to follow.
|
|
27
|
+
*/
|
|
28
|
+
export function createRunSignal(sources: readonly (AbortSignal | undefined)[]): RunSignal {
|
|
29
|
+
const controller = new AbortController();
|
|
30
|
+
const detach: (() => void)[] = [];
|
|
31
|
+
|
|
32
|
+
for (const source of sources) {
|
|
33
|
+
if (!(source instanceof AbortSignal)) continue;
|
|
34
|
+
if (source.aborted) {
|
|
35
|
+
controller.abort(source.reason);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const forward = (): void => controller.abort(source.reason);
|
|
39
|
+
source.addEventListener('abort', forward, { once: true });
|
|
40
|
+
detach.push(() => source.removeEventListener('abort', forward));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
signal: controller.signal,
|
|
45
|
+
abort: (reason) => controller.abort(reason),
|
|
46
|
+
dispose: () => {
|
|
47
|
+
for (const off of detach.splice(0)) off();
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// The scheduler's durable halves: the watermark it decides "missed" against, and leader election
|
|
2
|
+
// that survives a pooled connection. Both were `Map`s and a `() => true` in the shipped boot, and
|
|
3
|
+
// both failures are silent — a redeployed pod arms to tomorrow and never reports the 03:00 run the
|
|
4
|
+
// pod it replaced dropped, and two pods in a rolling update both dispatch every task.
|
|
5
|
+
|
|
6
|
+
import type { Clock } from '@ultimat3/core';
|
|
7
|
+
import { uuid } from '@ultimat3/core';
|
|
8
|
+
import { nowMs } from './clock';
|
|
9
|
+
import type { PgExecutor } from './driver-pg';
|
|
10
|
+
import {
|
|
11
|
+
SQL_LEADER_ACQUIRE,
|
|
12
|
+
SQL_LEADER_RELEASE,
|
|
13
|
+
SQL_SCHEDULER_STATE_GET,
|
|
14
|
+
SQL_SCHEDULER_STATE_MARK,
|
|
15
|
+
} from './driver-pg-sql';
|
|
16
|
+
import type { LeaderElection, SchedulerState } from './scheduler';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `x_scheduler_state`, one row per task. The watermark is what makes "missed" a decidable
|
|
20
|
+
* question at all: `catchUp`, `maxCatchUp` and `run-once` are all relative to it, so a scheduler
|
|
21
|
+
* whose watermark dies with its process has none of them — it takes the arming branch on every
|
|
22
|
+
* boot and drops every occurrence between the two processes with nothing logged.
|
|
23
|
+
*/
|
|
24
|
+
export function pgSchedulerState(executor: PgExecutor): SchedulerState {
|
|
25
|
+
return {
|
|
26
|
+
async lastFiredAt(taskName) {
|
|
27
|
+
const rows = await executor.query<{ last_fired_at: number | string | null }>(
|
|
28
|
+
SQL_SCHEDULER_STATE_GET,
|
|
29
|
+
[taskName],
|
|
30
|
+
);
|
|
31
|
+
const value = rows[0]?.last_fired_at;
|
|
32
|
+
return value === null || value === undefined ? undefined : Number(value);
|
|
33
|
+
},
|
|
34
|
+
async markFired(taskName, occurrenceMs) {
|
|
35
|
+
await executor.query(SQL_SCHEDULER_STATE_MARK, [taskName, occurrenceMs]);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface PgLeaseLeaderOptions {
|
|
41
|
+
readonly executor: PgExecutor;
|
|
42
|
+
/** One key per elected role. Default `'scheduler'`. */
|
|
43
|
+
readonly lockKey?: string;
|
|
44
|
+
/** This node's identity. Defaults to a per-process uuid — never a hostname a pod reuses. */
|
|
45
|
+
readonly holder?: string;
|
|
46
|
+
/**
|
|
47
|
+
* How long a grant survives without a renewal. Must be comfortably longer than the scheduler's
|
|
48
|
+
* tick interval, or a slow round loses the lock to a standby mid-dispatch. Default 30s against
|
|
49
|
+
* a 1s tick.
|
|
50
|
+
*/
|
|
51
|
+
readonly ttlMs?: number;
|
|
52
|
+
readonly clock?: Clock;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const DEFAULT_LEADER_TTL_MS = 30_000;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Leader election as an expiring row, which is what makes it correct on the executor this package
|
|
59
|
+
* is actually handed: a POOL. `createPgLeader`'s `pg_try_advisory_lock` is session-scoped, and a
|
|
60
|
+
* session on a pool ends the moment the connection goes back — so every node reads itself as
|
|
61
|
+
* leader and a rolling update double-fires every task. `@ultimat3/realtime`'s `PgAdvisoryLock`
|
|
62
|
+
* solves the same problem by owning its connection; this package holds no wire protocol, so it
|
|
63
|
+
* solves it with a row instead.
|
|
64
|
+
*
|
|
65
|
+
* `acquire()` is also the RENEWAL, so the scheduler calling it every round both keeps the lease
|
|
66
|
+
* alive and learns the round it stops being leader. A crashed node's lease is reclaimed by expiry
|
|
67
|
+
* with nothing to clean up, which is the one property the advisory lock had and a plain
|
|
68
|
+
* `insert ... on conflict do nothing` would not.
|
|
69
|
+
*/
|
|
70
|
+
export function createPgLeaseLeader(options: PgLeaseLeaderOptions): LeaderElection {
|
|
71
|
+
const lockKey = options.lockKey ?? 'scheduler';
|
|
72
|
+
const holder = options.holder ?? `scheduler-${uuid()}`;
|
|
73
|
+
const ttlMs = options.ttlMs ?? DEFAULT_LEADER_TTL_MS;
|
|
74
|
+
return {
|
|
75
|
+
async acquire() {
|
|
76
|
+
const rows = await options.executor.query<{ holder: string }>(SQL_LEADER_ACQUIRE, [
|
|
77
|
+
lockKey,
|
|
78
|
+
holder,
|
|
79
|
+
ttlMs,
|
|
80
|
+
]);
|
|
81
|
+
return rows[0]?.holder === holder;
|
|
82
|
+
},
|
|
83
|
+
async release() {
|
|
84
|
+
await options.executor.query(SQL_LEADER_RELEASE, [lockKey, holder]);
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Exposed so a test — and `x jobs ls` — can say which node currently holds the lease. */
|
|
90
|
+
export async function currentLeader(
|
|
91
|
+
executor: PgExecutor,
|
|
92
|
+
lockKey = 'scheduler',
|
|
93
|
+
clock?: Clock,
|
|
94
|
+
): Promise<string | undefined> {
|
|
95
|
+
const rows = await executor.query<{ holder: string; expires_at: number | string }>(
|
|
96
|
+
`select holder, (extract(epoch from expires_at) * 1000)::bigint as expires_at
|
|
97
|
+
from x_scheduler_leader where lock_key = $1`,
|
|
98
|
+
[lockKey],
|
|
99
|
+
);
|
|
100
|
+
const row = rows[0];
|
|
101
|
+
if (row === undefined) return undefined;
|
|
102
|
+
return Number(row.expires_at) > nowMs(clock) ? row.holder : undefined;
|
|
103
|
+
}
|