@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
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// The fleet slot an in-flight job holds: `job.concurrency` as a row per HELD SLOT in the driver's
|
|
2
|
+
// lease store, taken at claim time, renewed while the job runs and handed back when it settles.
|
|
3
|
+
// Apart from `worker.ts` because the claim loop's question is "may I start this one?" — which job
|
|
4
|
+
// holds which slot, and who gives it back, is bookkeeping of its own.
|
|
5
|
+
|
|
6
|
+
import { logger } from '@ultimat3/core';
|
|
7
|
+
import type { ClaimedJob } from './driver';
|
|
8
|
+
import { getJob } from './job';
|
|
9
|
+
import type { HeldLease, LeaseStore } from './leases';
|
|
10
|
+
import { jobLeaseKey } from './leases';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A renewal that REJECTED is not a lost slot: there is a TTL behind it and the interval gets
|
|
14
|
+
* several tries inside it, exactly as `heartbeat.ts` treats a failed `driver.heartbeat`. The
|
|
15
|
+
* heartbeat cannot cover for this one either way — it renews `x_jobs.visible_at`, a different row
|
|
16
|
+
* on a different clock, and knows nothing about `x_job_leases`.
|
|
17
|
+
*/
|
|
18
|
+
const noop = (): void => undefined;
|
|
19
|
+
|
|
20
|
+
export interface FleetSlotOptions {
|
|
21
|
+
/** The driver's lease store, or `undefined` for a driver that ships none. */
|
|
22
|
+
readonly leases: LeaseStore | undefined;
|
|
23
|
+
readonly workerId: string;
|
|
24
|
+
/**
|
|
25
|
+
* How long a fleet slot survives without renewal. The worker passes its visibility timeout, so
|
|
26
|
+
* a worker that is SIGKILLed gives its slot back on exactly the schedule the queue gives its job
|
|
27
|
+
* back — a longer TTL would leave `concurrency: 1` unfillable while the job it guarded is
|
|
28
|
+
* already re-delivered.
|
|
29
|
+
*/
|
|
30
|
+
readonly ttlMs: number;
|
|
31
|
+
readonly renewIntervalMs: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface FleetSlots {
|
|
35
|
+
/**
|
|
36
|
+
* A fleet slot for this job's declared `concurrency`, or `false` when the fleet is full.
|
|
37
|
+
* `true` means "no cap declared" — a job with no `concurrency` never touches the lease table.
|
|
38
|
+
*
|
|
39
|
+
* A driver with no lease store cannot reach here: `createWorker().start()` refuses to boot when
|
|
40
|
+
* a registered job declares `concurrency` and the driver has none, because a cap that silently
|
|
41
|
+
* holds per process is the documented-guarantee-that-does-nothing axiom 3 exists to make
|
|
42
|
+
* impossible.
|
|
43
|
+
*/
|
|
44
|
+
acquire(claimed: ClaimedJob): Promise<boolean>;
|
|
45
|
+
/**
|
|
46
|
+
* Keeps this job's slot alive until the returned stop is called. A no-op when it holds none.
|
|
47
|
+
*
|
|
48
|
+
* `onLost` fires once, when a renewal comes back `false` — this worker no longer holds the slot,
|
|
49
|
+
* either because another holder took it (this run and that one are both live under a cap of one)
|
|
50
|
+
* or because it lapsed and is now free for anyone's next `acquire`. Both drivers answer `false`
|
|
51
|
+
* to both cases; the pg statement fenced only on the holder until 2026-08, so a lapsed slot
|
|
52
|
+
* revived itself there and cancelled the run under `x dev`. The caller cancels the run on it;
|
|
53
|
+
* renewal stops here either way, because extending a slot this worker no longer holds would push
|
|
54
|
+
* out somebody else's expiry.
|
|
55
|
+
*/
|
|
56
|
+
startRenewal(jobId: string, onLost?: (slot: HeldLease) => void): () => void;
|
|
57
|
+
release(jobId: string): Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createFleetSlots(options: FleetSlotOptions): FleetSlots {
|
|
61
|
+
/** The fleet slot each in-flight job holds, so the renewal finds it and the drain frees it. */
|
|
62
|
+
const held = new Map<string, HeldLease>();
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
async acquire(claimed) {
|
|
66
|
+
const limit = getJob(claimed.name)?.concurrency;
|
|
67
|
+
if (limit === undefined || options.leases === undefined) return true;
|
|
68
|
+
const slot = await options.leases.acquire(
|
|
69
|
+
jobLeaseKey(claimed.name),
|
|
70
|
+
limit,
|
|
71
|
+
options.ttlMs,
|
|
72
|
+
`${options.workerId}:${claimed.id}`,
|
|
73
|
+
);
|
|
74
|
+
if (slot === undefined) return false;
|
|
75
|
+
held.set(claimed.id, slot);
|
|
76
|
+
return true;
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
startRenewal(jobId, onLost) {
|
|
80
|
+
const slot = held.get(jobId);
|
|
81
|
+
if (slot === undefined) return noop;
|
|
82
|
+
const stop = (): void => {
|
|
83
|
+
clearInterval(timer);
|
|
84
|
+
};
|
|
85
|
+
// Renewed on the lease heartbeat's own interval and released in the same `finally`: one
|
|
86
|
+
// clock for "this worker still owns the job" and "this worker still owns the slot" is one
|
|
87
|
+
// fewer way for them to disagree.
|
|
88
|
+
const timer = setInterval(() => {
|
|
89
|
+
void options.leases
|
|
90
|
+
?.renew(slot, options.ttlMs)
|
|
91
|
+
.then((renewed) => {
|
|
92
|
+
// `=== false`, never `!renewed`, for the reason `heartbeat.ts` reads `held` that way:
|
|
93
|
+
// a store written before this return value existed resolves `undefined`, and treating
|
|
94
|
+
// that as a loss would cancel every job on every renewal. Only an explicit no is one.
|
|
95
|
+
if (renewed !== false) return;
|
|
96
|
+
stop();
|
|
97
|
+
logger.error('jobs.worker.slot-lost', {
|
|
98
|
+
workerId: options.workerId,
|
|
99
|
+
jobId,
|
|
100
|
+
leaseKey: slot.key,
|
|
101
|
+
slot: slot.slot,
|
|
102
|
+
});
|
|
103
|
+
onLost?.(slot);
|
|
104
|
+
})
|
|
105
|
+
.catch(noop);
|
|
106
|
+
}, options.renewIntervalMs);
|
|
107
|
+
return stop;
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
async release(jobId) {
|
|
111
|
+
const slot = held.get(jobId);
|
|
112
|
+
if (slot === undefined) return;
|
|
113
|
+
held.delete(jobId);
|
|
114
|
+
// Never lets a settle fail over bookkeeping: an unreleased slot expires on its own TTL.
|
|
115
|
+
await options.leases?.release(slot).catch((error: unknown) => {
|
|
116
|
+
logger.warn('jobs.worker.lease-release-failed', {
|
|
117
|
+
workerId: options.workerId,
|
|
118
|
+
jobId,
|
|
119
|
+
error: error instanceof Error ? error.message : String(error),
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// One claimed job, wired: its lease heartbeat, the fleet slot it holds, the signal the run is
|
|
2
|
+
// cancelled by, and the span it is executed under — all started together and handed back in one
|
|
3
|
+
// `finally`. Apart from `worker.ts` because that file's job is the claim loop and the drain; this
|
|
4
|
+
// one is everything that has to be true for the duration of a single run.
|
|
5
|
+
|
|
6
|
+
import type { Clock, Ctx } from '@ultimat3/core';
|
|
7
|
+
import { parseTraceparent, withSpan } from '@ultimat3/core';
|
|
8
|
+
import type { ClaimedJob, JobDriver } from './driver';
|
|
9
|
+
import { JobSlotLostError } from './errors';
|
|
10
|
+
import type { JobExecution } from './execute';
|
|
11
|
+
import { executeJob } from './execute';
|
|
12
|
+
import { startLeaseHeartbeat } from './heartbeat';
|
|
13
|
+
import { getJob } from './job';
|
|
14
|
+
import { createRunSignal } from './run-signal';
|
|
15
|
+
import type { EventLookup } from './steps';
|
|
16
|
+
import type { FleetSlots } from './worker-fleet-slots';
|
|
17
|
+
|
|
18
|
+
export interface RunClaimedOptions {
|
|
19
|
+
readonly driver: JobDriver;
|
|
20
|
+
readonly claimed: ClaimedJob;
|
|
21
|
+
/** Supplies the ambient Ctx for this run; the app wires ALS + tenant here. */
|
|
22
|
+
readonly context: () => Ctx;
|
|
23
|
+
readonly fleetSlots: FleetSlots;
|
|
24
|
+
readonly workerId: string;
|
|
25
|
+
readonly visibilityTimeoutMs: number;
|
|
26
|
+
readonly heartbeatIntervalMs: number;
|
|
27
|
+
readonly clock?: Clock;
|
|
28
|
+
readonly events?: EventLookup;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A name this deploy does not know, parked rather than failed — almost always a deploy skew. */
|
|
32
|
+
function unknownJob(claimed: ClaimedJob): JobExecution {
|
|
33
|
+
return {
|
|
34
|
+
outcome: 'suspended',
|
|
35
|
+
jobId: claimed.id,
|
|
36
|
+
job: claimed.name,
|
|
37
|
+
attempt: claimed.attempt,
|
|
38
|
+
durationMs: 0,
|
|
39
|
+
error: `no job registered as "${claimed.name}"`,
|
|
40
|
+
steps: [],
|
|
41
|
+
replayed: [],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Run one claimed job under its lease, its slot and its span, and settle it with the driver. */
|
|
46
|
+
export async function runClaimedJob(options: RunClaimedOptions): Promise<JobExecution> {
|
|
47
|
+
const { claimed, driver, fleetSlots, workerId, visibilityTimeoutMs } = options;
|
|
48
|
+
const handle = getJob(claimed.name);
|
|
49
|
+
if (handle === undefined) {
|
|
50
|
+
// Park it, do not burn attempts: the job may well be registered by the pod next to this one.
|
|
51
|
+
await driver.nack(claimed.id, {
|
|
52
|
+
delayMs: 30_000,
|
|
53
|
+
error: `no job registered as "${claimed.name}"`,
|
|
54
|
+
countsAsAttempt: false,
|
|
55
|
+
});
|
|
56
|
+
return unknownJob(claimed);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Read BEFORE the timers start. `context()` is the app's own function and it can throw; started
|
|
60
|
+
// first, a heartbeat interval and a slot renewal were left running for a job that never ran,
|
|
61
|
+
// with nothing left holding a reference to stop them.
|
|
62
|
+
const base = options.context();
|
|
63
|
+
|
|
64
|
+
// The lease, kept alive and NOT kept quiet: a renewal that stops landing means the queue hands
|
|
65
|
+
// this job to another worker while this one is still running it, and `.catch(() => undefined)`
|
|
66
|
+
// made that — the one failure a queue cannot recover from on its own — indistinguishable from a
|
|
67
|
+
// healthy run.
|
|
68
|
+
const heartbeat = startLeaseHeartbeat({
|
|
69
|
+
driver,
|
|
70
|
+
claimed,
|
|
71
|
+
visibilityTimeoutMs,
|
|
72
|
+
intervalMs: options.heartbeatIntervalMs,
|
|
73
|
+
workerId,
|
|
74
|
+
...(options.clock === undefined ? {} : { clock: options.clock }),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// The caller's context plus this lease's cancellation, so a job cancelled from outside — or one
|
|
78
|
+
// this worker lost the lease on — stops at the next renewal. `steps.ts` refuses every write past
|
|
79
|
+
// the signal, which is what unwinds a body that never reads it. Composed through a controller
|
|
80
|
+
// this worker owns rather than `AbortSignal.any`, for two reasons: it is handed BACK when the run
|
|
81
|
+
// settles (an app whose `context()` carries a process-lifetime signal was accumulating one
|
|
82
|
+
// composite per job), and the worker can abort it itself — which is the only way a fleet slot
|
|
83
|
+
// taken by somebody else reaches the body running under it.
|
|
84
|
+
const runSignal = createRunSignal([base.signal, heartbeat.signal]);
|
|
85
|
+
const ctx: Ctx = { ...base, signal: runSignal.signal };
|
|
86
|
+
|
|
87
|
+
// The fleet slot this job already holds, kept alive for as long as the lease is and stopped in
|
|
88
|
+
// the same `finally`: one clock for "this worker still owns the job" and "this worker still owns
|
|
89
|
+
// the slot" is one fewer way for them to disagree. A renewal answering "not yours" means another
|
|
90
|
+
// worker is already running this job under a cap of one, so it CANCELS — discarding that boolean
|
|
91
|
+
// made `job.concurrency` a number the framework prints and does not hold.
|
|
92
|
+
const stopSlotRenewal = fleetSlots.startRenewal(claimed.id, (slot) => {
|
|
93
|
+
runSignal.abort(
|
|
94
|
+
new JobSlotLostError({ job: claimed.name, jobId: claimed.id, slot: slot.slot }),
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// The job's span is a CHILD of the request that queued it when the row carries a trace. That
|
|
99
|
+
// link is what `04-jobs.md` promised and no column existed to hold: without it a checkout's
|
|
100
|
+
// `chargeCard` opens a fresh root two seconds later with nothing pointing back.
|
|
101
|
+
const parent = parseTraceparent(claimed.traceparent);
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
return await withSpan(
|
|
105
|
+
`job.${handle.name}`,
|
|
106
|
+
() =>
|
|
107
|
+
executeJob({
|
|
108
|
+
driver,
|
|
109
|
+
claimed,
|
|
110
|
+
handle,
|
|
111
|
+
ctx,
|
|
112
|
+
...(options.clock === undefined ? {} : { clock: options.clock }),
|
|
113
|
+
...(options.events === undefined ? {} : { events: options.events }),
|
|
114
|
+
}),
|
|
115
|
+
{
|
|
116
|
+
...(parent === undefined ? {} : { parent }),
|
|
117
|
+
attributes: {
|
|
118
|
+
'job.name': handle.name,
|
|
119
|
+
'job.id': claimed.id,
|
|
120
|
+
'job.attempt': claimed.attempt,
|
|
121
|
+
...(claimed.enqueuedBy === undefined ? {} : { 'job.enqueued_by': claimed.enqueuedBy }),
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
);
|
|
125
|
+
} finally {
|
|
126
|
+
stopSlotRenewal();
|
|
127
|
+
heartbeat.stop();
|
|
128
|
+
// Nothing of the caller's is held past the run: `dispose` is what makes the composition above
|
|
129
|
+
// reversible, and it is the whole reason this is not `AbortSignal.any`.
|
|
130
|
+
runSignal.dispose();
|
|
131
|
+
}
|
|
132
|
+
}
|