@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/execute.ts
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// One claimed job run to completion, suspension or failure and settled with the driver — the
|
|
2
|
+
// single execution path the worker loop and `x jobs run` share. It owns the run's deadline, and
|
|
3
|
+
// a deadline here means CANCEL: the nack that follows makes the job claimable again, so a body
|
|
4
|
+
// still running past it would be a second copy of one job, racing the attempt that replaced it.
|
|
5
|
+
|
|
6
|
+
import type { Actor, Clock, Ctx, ServiceBag } from '@ultimat3/core';
|
|
7
|
+
import {
|
|
8
|
+
anonymousActor,
|
|
9
|
+
isUltimateError,
|
|
10
|
+
logger,
|
|
11
|
+
reportError,
|
|
12
|
+
runWithContext,
|
|
13
|
+
useContext,
|
|
14
|
+
withChildContext,
|
|
15
|
+
} from '@ultimat3/core';
|
|
16
|
+
import { nowMs } from './clock';
|
|
17
|
+
import type { ClaimedJob, JobDriver } from './driver';
|
|
18
|
+
import { JobAbortedError, JobTimeoutError } from './errors';
|
|
19
|
+
import { eventBus } from './events';
|
|
20
|
+
import type { AnyJobHandle } from './job';
|
|
21
|
+
import { nextRetry } from './retry';
|
|
22
|
+
import type { EventLookup, StepRecord } from './steps';
|
|
23
|
+
import { createStepRunner, isStepSuspension } from './steps';
|
|
24
|
+
import { jobRunActor } from './tenant';
|
|
25
|
+
|
|
26
|
+
export type JobOutcome = 'completed' | 'suspended' | 'retried' | 'dead-lettered';
|
|
27
|
+
|
|
28
|
+
/** Stands in for a caller with nothing to cancel, so the composition below has one shape. */
|
|
29
|
+
const NEVER_ABORTED = new AbortController().signal;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `Ctx.signal` is non-optional in the type and `createContext` always sets it — but a context can
|
|
33
|
+
* still arrive across a cast (`@ultimat3/http`'s `asCtx`, a test's `{} as Ctx`) without one, and
|
|
34
|
+
* a job that crashed on a missing field would be a far worse answer than a job with no caller to
|
|
35
|
+
* follow. Read it, do not assume it.
|
|
36
|
+
*/
|
|
37
|
+
function callerSignal(ctx: Ctx): AbortSignal {
|
|
38
|
+
return ctx.signal instanceof AbortSignal ? ctx.signal : NEVER_ABORTED;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The same defensive read, for the same reason: `Ctx.actor` is non-optional in the type and
|
|
43
|
+
* `createContext` always sets it, but `WorkerOptions.context()` is the app's own function and a
|
|
44
|
+
* cast context (`{} as Ctx`) reaches here without one. Anonymous is the honest stand-in — it
|
|
45
|
+
* carries no org, so the job's declared tenant is the only thing that can put one on the run.
|
|
46
|
+
*/
|
|
47
|
+
function callerActor(ctx: Ctx): Actor {
|
|
48
|
+
const actor: Actor | undefined = ctx.actor;
|
|
49
|
+
return actor === undefined ? anonymousActor() : actor;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const NO_SERVICES: ServiceBag = Object.freeze({});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The same defensive read a third time, and this one is load-bearing rather than merely kind:
|
|
56
|
+
* `withChildContext` ITERATES the parent's bag to decide what carries forward, so a cast context
|
|
57
|
+
* with no `services` would fail the run with a `TypeError` before the body ever started.
|
|
58
|
+
*/
|
|
59
|
+
function callerServices(ctx: Ctx): ServiceBag {
|
|
60
|
+
const services: ServiceBag | undefined = ctx.services;
|
|
61
|
+
return services === undefined ? NO_SERVICES : services;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface JobExecution {
|
|
65
|
+
readonly outcome: JobOutcome;
|
|
66
|
+
readonly jobId: string;
|
|
67
|
+
readonly job: string;
|
|
68
|
+
readonly attempt: number;
|
|
69
|
+
readonly durationMs: number;
|
|
70
|
+
readonly resumeAt?: number;
|
|
71
|
+
readonly error?: string;
|
|
72
|
+
readonly steps: readonly StepRecord[];
|
|
73
|
+
readonly replayed: readonly string[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ExecuteJobOptions {
|
|
77
|
+
readonly driver: JobDriver;
|
|
78
|
+
readonly claimed: ClaimedJob;
|
|
79
|
+
readonly handle: AnyJobHandle;
|
|
80
|
+
readonly ctx: Ctx;
|
|
81
|
+
readonly clock?: Clock;
|
|
82
|
+
readonly events?: EventLookup;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Run one claimed job to completion, suspension or failure, and settle it with the driver.
|
|
87
|
+
* Shared by the worker loop and `x jobs run` so both take exactly the same code path.
|
|
88
|
+
*/
|
|
89
|
+
export async function executeJob(options: ExecuteJobOptions): Promise<JobExecution> {
|
|
90
|
+
const { driver, claimed, handle } = options;
|
|
91
|
+
const startedAt = nowMs(options.clock);
|
|
92
|
+
// This attempt's cancellation. `ctx.signal` is the framework's ONE cancellation seam — the same
|
|
93
|
+
// one `throwIfAborted` reads in an action — so a job body learns its deadline passed exactly
|
|
94
|
+
// where every other body does, with no jobs-only parameter to know about. Composed with the
|
|
95
|
+
// caller's signal rather than replacing it: a ctx that was already going away still is.
|
|
96
|
+
const cancel = new AbortController();
|
|
97
|
+
const signal = AbortSignal.any([callerSignal(options.ctx), cancel.signal]);
|
|
98
|
+
const ctx: Ctx = Object.freeze({
|
|
99
|
+
...options.ctx,
|
|
100
|
+
signal,
|
|
101
|
+
services: callerServices(options.ctx),
|
|
102
|
+
});
|
|
103
|
+
const runner = createStepRunner({
|
|
104
|
+
runId: claimed.runId,
|
|
105
|
+
jobName: handle.name,
|
|
106
|
+
store: driver.steps,
|
|
107
|
+
signal,
|
|
108
|
+
...(options.clock === undefined ? {} : { clock: options.clock }),
|
|
109
|
+
events: options.events ?? eventBus(),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const settle = async (outcome: JobExecution): Promise<JobExecution> => {
|
|
113
|
+
const steps = await driver.steps.list(claimed.runId);
|
|
114
|
+
return { ...outcome, steps, replayed: runner.replayedNames() };
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const input = handle.parse(claimed.input);
|
|
119
|
+
// The job's DECLARED tenant, on the actor the body runs as. `tenant: 'none'` strips the org
|
|
120
|
+
// rather than inheriting the worker's, so a tenant-scoped read inside such a job fails closed.
|
|
121
|
+
const runActor = jobRunActor(callerActor(ctx), handle.tenantFor(input));
|
|
122
|
+
// Installed as the AMBIENT context and not only handed over as a parameter. This is the whole
|
|
123
|
+
// of the fix: `@ultimat3/entity`'s tenant guard derives from `tryUseContext()`, so a ctx passed
|
|
124
|
+
// as an argument was read by nobody — `actorTenant` answered `undefined`, `scopedPlan` derived
|
|
125
|
+
// no predicate, `verifyScope` returned early, and a row naming another org was written by a
|
|
126
|
+
// job while the identical write over HTTP was refused as `X_TENANCY_ACTOR_MISMATCH`.
|
|
127
|
+
//
|
|
128
|
+
// `withChildContext` and NOT a spread of `ctx`, for the reason it exists: a registered service
|
|
129
|
+
// CLOSES OVER the context it was built for (`defineService`), so the worker's `ctx.posts` would
|
|
130
|
+
// still answer the worker's org while every ambient repository call answered the job's — one
|
|
131
|
+
// run acting as two tenants, which is the same hole one layer up. It rebuilds every managed
|
|
132
|
+
// factory against `runActor` and carries only the services no factory owns.
|
|
133
|
+
const work = runWithContext(ctx, () =>
|
|
134
|
+
withChildContext({ actor: runActor }, () =>
|
|
135
|
+
handle.run({
|
|
136
|
+
input,
|
|
137
|
+
step: runner.step,
|
|
138
|
+
// The child itself, never a rebuilt sibling: the ctx a body is HANDED and the ctx the
|
|
139
|
+
// entity guard READS have to be one object, which is what `tenancy-cross-surface` pins.
|
|
140
|
+
ctx: useContext(),
|
|
141
|
+
attempt: claimed.attempt,
|
|
142
|
+
jobId: claimed.id,
|
|
143
|
+
runId: claimed.runId,
|
|
144
|
+
}),
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
await (handle.timeoutMs === undefined
|
|
149
|
+
? work
|
|
150
|
+
: raceTimeout(work, handle.timeoutMs, handle.name, cancel));
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (isStepSuspension(error)) {
|
|
153
|
+
const delayMs = Math.max(0, error.resumeAt - nowMs(options.clock));
|
|
154
|
+
// countsAsAttempt: false — parking a run is not a failure.
|
|
155
|
+
await driver.nack(claimed.id, { delayMs, countsAsAttempt: false });
|
|
156
|
+
return settle({
|
|
157
|
+
outcome: 'suspended',
|
|
158
|
+
jobId: claimed.id,
|
|
159
|
+
job: handle.name,
|
|
160
|
+
attempt: claimed.attempt,
|
|
161
|
+
durationMs: nowMs(options.clock) - startedAt,
|
|
162
|
+
resumeAt: error.resumeAt,
|
|
163
|
+
steps: [],
|
|
164
|
+
replayed: [],
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
169
|
+
const decision = nextRetry(handle.retry, claimed.attempt);
|
|
170
|
+
await driver.nack(claimed.id, {
|
|
171
|
+
delayMs: decision.delayMs,
|
|
172
|
+
error: message,
|
|
173
|
+
countsAsAttempt: true,
|
|
174
|
+
deadLetter: !decision.retry && decision.deadLetter,
|
|
175
|
+
});
|
|
176
|
+
logger.warn('jobs.attempt.failed', {
|
|
177
|
+
job: handle.name,
|
|
178
|
+
jobId: claimed.id,
|
|
179
|
+
attempt: claimed.attempt,
|
|
180
|
+
retry: decision.retry,
|
|
181
|
+
error: message,
|
|
182
|
+
});
|
|
183
|
+
// This package's ONE error-reporting call site, and it is here rather than in the loop because
|
|
184
|
+
// this is the only frame that still holds the thrown value — the loop sees a message string.
|
|
185
|
+
// A retry is a failure the framework recovered from, so it is a `warning`; a dead letter is
|
|
186
|
+
// one nobody recovered from. `x jobs run` takes this path too, which is the point: one
|
|
187
|
+
// execution path means one place a failed job can become visible.
|
|
188
|
+
reportError(error, {
|
|
189
|
+
source: 'job',
|
|
190
|
+
severity: decision.retry ? 'warning' : 'error',
|
|
191
|
+
scope: {
|
|
192
|
+
operation: handle.name,
|
|
193
|
+
extra: {
|
|
194
|
+
jobId: claimed.id,
|
|
195
|
+
runId: claimed.runId,
|
|
196
|
+
attempt: claimed.attempt,
|
|
197
|
+
retry: decision.retry,
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
return settle({
|
|
202
|
+
outcome: decision.retry ? 'retried' : 'dead-lettered',
|
|
203
|
+
jobId: claimed.id,
|
|
204
|
+
job: handle.name,
|
|
205
|
+
attempt: claimed.attempt,
|
|
206
|
+
durationMs: nowMs(options.clock) - startedAt,
|
|
207
|
+
error: message,
|
|
208
|
+
steps: [],
|
|
209
|
+
replayed: [],
|
|
210
|
+
});
|
|
211
|
+
} finally {
|
|
212
|
+
// The attempt is over however it ended, so nothing from it may still be writing: a step left
|
|
213
|
+
// in flight by a handler that returned without awaiting it settles into a run the next
|
|
214
|
+
// attempt already owns. The runner fences its writes on this signal. A second `abort()` keeps
|
|
215
|
+
// the first reason, so a timed-out run still reports the timeout, not this.
|
|
216
|
+
cancel.abort(new JobAbortedError({ job: handle.name }));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Only reachable when the BODY succeeded, and settlement is deliberately outside the catch
|
|
220
|
+
// above: an `ack` that rejects — a pool timeout, a reset on that one statement — is not an
|
|
221
|
+
// attempt failure. Nacking it would re-queue work that already ran to completion and report
|
|
222
|
+
// the run as `retried`, so `jobs_total{outcome}` would count a failure that never happened.
|
|
223
|
+
// Let it reach the worker instead, which logs `jobs.worker.settle-failed`; the lease then
|
|
224
|
+
// lapses and the queue re-delivers, which is the honest outcome for "we could not say it ended".
|
|
225
|
+
await driver.ack(claimed.id);
|
|
226
|
+
return settle({
|
|
227
|
+
outcome: 'completed',
|
|
228
|
+
jobId: claimed.id,
|
|
229
|
+
job: handle.name,
|
|
230
|
+
attempt: claimed.attempt,
|
|
231
|
+
durationMs: nowMs(options.clock) - startedAt,
|
|
232
|
+
steps: [],
|
|
233
|
+
replayed: [],
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The run's deadline. It CANCELS before it rejects, and the order is the whole point: the caller
|
|
239
|
+
* nacks on this rejection and the queue hands the job straight to another worker, so the body has
|
|
240
|
+
* to have been told to stop before that becomes possible.
|
|
241
|
+
*
|
|
242
|
+
* Nothing in JS can kill a body that ignores the signal, so what is left is to say so: a run that
|
|
243
|
+
* settles after its deadline logs `jobs.timeout.abandoned`, which is how an app finds the handler
|
|
244
|
+
* that never reads `ctx.signal`. A body that stopped BECAUSE it was cancelled is the intended end
|
|
245
|
+
* and stays quiet.
|
|
246
|
+
*/
|
|
247
|
+
function raceTimeout(
|
|
248
|
+
work: Promise<unknown>,
|
|
249
|
+
timeoutMs: number,
|
|
250
|
+
job: string,
|
|
251
|
+
cancel: AbortController,
|
|
252
|
+
): Promise<unknown> {
|
|
253
|
+
return new Promise((resolve, reject) => {
|
|
254
|
+
let expired = false;
|
|
255
|
+
const timer = setTimeout(() => {
|
|
256
|
+
expired = true;
|
|
257
|
+
const failure = new JobTimeoutError({ job, timeoutMs });
|
|
258
|
+
cancel.abort(failure);
|
|
259
|
+
reject(failure);
|
|
260
|
+
}, timeoutMs);
|
|
261
|
+
const abandoned = (ended: 'resolved' | 'rejected', error?: unknown): void => {
|
|
262
|
+
logger.warn('jobs.timeout.abandoned', {
|
|
263
|
+
job,
|
|
264
|
+
timeoutMs,
|
|
265
|
+
ended,
|
|
266
|
+
...(error === undefined
|
|
267
|
+
? {}
|
|
268
|
+
: { error: error instanceof Error ? error.message : String(error) }),
|
|
269
|
+
});
|
|
270
|
+
};
|
|
271
|
+
work.then(
|
|
272
|
+
(value) => {
|
|
273
|
+
clearTimeout(timer);
|
|
274
|
+
if (expired) abandoned('resolved');
|
|
275
|
+
else resolve(value);
|
|
276
|
+
},
|
|
277
|
+
(error) => {
|
|
278
|
+
clearTimeout(timer);
|
|
279
|
+
if (!expired) reject(error);
|
|
280
|
+
else if (!isCancellation(error, cancel.signal.reason)) abandoned('rejected', error);
|
|
281
|
+
},
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** The body stopped because we cancelled it: our own reason back, or a fenced step write. */
|
|
287
|
+
function isCancellation(error: unknown, reason: unknown): boolean {
|
|
288
|
+
return error === reason || (isUltimateError(error) && error.code === 'X_ABORTED');
|
|
289
|
+
}
|
package/src/heartbeat.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// One claimed job's lease: the heartbeat that renews it, and the fact no driver reports — that
|
|
2
|
+
// renewals stopped landing long enough for the queue to hand this job to another worker. A lease
|
|
3
|
+
// that lapses in silence is one job running twice with nothing in the log saying so, which is the
|
|
4
|
+
// hardest bug in a queue to see from the outside and the easiest to name from in here.
|
|
5
|
+
|
|
6
|
+
import type { Clock } from '@ultimat3/core';
|
|
7
|
+
import { logger, recordLeaseLost } from '@ultimat3/core';
|
|
8
|
+
import { nowMs } from './clock';
|
|
9
|
+
import type { ClaimedJob, JobDriver } from './driver';
|
|
10
|
+
import { LeaseLostError } from './errors';
|
|
11
|
+
|
|
12
|
+
export interface LeaseHeartbeatOptions {
|
|
13
|
+
/** Only `heartbeat` is used — a lease renews itself and settles nothing. */
|
|
14
|
+
readonly driver: Pick<JobDriver, 'heartbeat'>;
|
|
15
|
+
readonly claimed: ClaimedJob;
|
|
16
|
+
readonly visibilityTimeoutMs: number;
|
|
17
|
+
readonly intervalMs: number;
|
|
18
|
+
readonly workerId: string;
|
|
19
|
+
readonly clock?: Clock;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface LeaseHeartbeat {
|
|
23
|
+
/** One renewal. The interval drives it; tests drive it instead of the timer. */
|
|
24
|
+
renew(): Promise<void>;
|
|
25
|
+
/** True once the lease lapsed: the queue may already have delivered this job again. */
|
|
26
|
+
lost(): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Aborts when the lease is gone. The worker folds it into the `Ctx` it hands `executeJob`, so a
|
|
29
|
+
* job cancelled from outside — or one whose lease lapsed and whose row another worker
|
|
30
|
+
* re-claimed — stops at the next renewal instead of running to the end beside its replacement.
|
|
31
|
+
* `steps.ts` refuses every write past it, which is what unwinds a body that ignores the signal.
|
|
32
|
+
*/
|
|
33
|
+
readonly signal: AbortSignal;
|
|
34
|
+
/** Stop renewing. Idempotent. */
|
|
35
|
+
stop(): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Start renewing the lease `claim()` bought, and say so out loud when renewal stops working.
|
|
40
|
+
*
|
|
41
|
+
* The lease is measured on THIS process's clock, not on `claimed.visibleAt`: that timestamp comes
|
|
42
|
+
* from the driver's clock, and comparing the two makes every lease decision a function of clock
|
|
43
|
+
* skew. Measured from the moment renewal started, the window can only ever be read as shorter than
|
|
44
|
+
* it really is — reporting late is safe, reporting early would cry loss over a live lease.
|
|
45
|
+
*/
|
|
46
|
+
export function startLeaseHeartbeat(options: LeaseHeartbeatOptions): LeaseHeartbeat {
|
|
47
|
+
const { claimed, visibilityTimeoutMs, workerId } = options;
|
|
48
|
+
const now = (): number => nowMs(options.clock);
|
|
49
|
+
let renewedAt = now();
|
|
50
|
+
let renewing = false;
|
|
51
|
+
let lost = false;
|
|
52
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
53
|
+
const gone = new AbortController();
|
|
54
|
+
|
|
55
|
+
const stop = (): void => {
|
|
56
|
+
if (timer !== undefined) clearInterval(timer);
|
|
57
|
+
timer = undefined;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const lapsed = (): boolean => now() - renewedAt >= visibilityTimeoutMs;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Reported once, then renewal stops. Once the window is gone this job is claimable by anyone,
|
|
64
|
+
* so a further renewal would extend a lease this process no longer holds — and a counter that
|
|
65
|
+
* ticked once per interval would count intervals, not jobs.
|
|
66
|
+
*/
|
|
67
|
+
const reportLost = (error?: unknown, reason?: 'expired' | 'not-ours'): void => {
|
|
68
|
+
if (lost) return;
|
|
69
|
+
lost = true;
|
|
70
|
+
stop();
|
|
71
|
+
logger.error('jobs.lease.lost', {
|
|
72
|
+
workerId,
|
|
73
|
+
job: claimed.name,
|
|
74
|
+
jobId: claimed.id,
|
|
75
|
+
queue: claimed.queue,
|
|
76
|
+
attempt: claimed.attempt,
|
|
77
|
+
visibilityTimeoutMs,
|
|
78
|
+
reason: reason ?? 'expired',
|
|
79
|
+
...(error === undefined
|
|
80
|
+
? {}
|
|
81
|
+
: { error: error instanceof Error ? error.message : String(error) }),
|
|
82
|
+
});
|
|
83
|
+
recordLeaseLost(claimed.queue);
|
|
84
|
+
// Cancel LAST, so the loss is logged and counted before the body it unwinds starts throwing.
|
|
85
|
+
gone.abort(new LeaseLostError({ job: claimed.name, jobId: claimed.id }));
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const renew = async (): Promise<void> => {
|
|
89
|
+
if (lost) return;
|
|
90
|
+
// Expiry is decided BEFORE the driver is asked, because the failure that loses a lease most
|
|
91
|
+
// quietly is the one that never answers: a heartbeat hung on a dead connection neither
|
|
92
|
+
// resolves nor rejects, so a check that ran only on rejection would never run at all.
|
|
93
|
+
if (lapsed()) {
|
|
94
|
+
reportLost();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
// One renewal in flight at a time. A driver slower than the interval would otherwise stack a
|
|
98
|
+
// request per tick onto the connection that is already the thing failing.
|
|
99
|
+
if (renewing) return;
|
|
100
|
+
renewing = true;
|
|
101
|
+
try {
|
|
102
|
+
const held = await options.driver.heartbeat(claimed.id, { visibilityTimeoutMs, workerId });
|
|
103
|
+
// The driver answered, and it said the row is not ours. That is a DIFFERENT fact from an
|
|
104
|
+
// expired window and the only one an operator can cause on purpose: `x jobs cancel` writes
|
|
105
|
+
// a terminal state, and the renewal that misses it is what tells this attempt to stop. It
|
|
106
|
+
// is not a failure of the queue, so it is reported once and the attempt is unwound.
|
|
107
|
+
// `=== false`, not `!held`: `heartbeat` only recently began answering, and a driver from
|
|
108
|
+
// before that — a hand-rolled one, a test double — resolves `undefined`. Reading that as a
|
|
109
|
+
// lost lease would cancel every job on every renewal. Only an explicit "no" is a no.
|
|
110
|
+
if (held === false) {
|
|
111
|
+
reportLost(undefined, 'not-ours');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// Asked again AFTER the call, because a renewal that SUCCEEDS late is still late: an
|
|
115
|
+
// event-loop stall or a driver that answered at the end of a connect timeout can land this
|
|
116
|
+
// past the window it was renewing, and `renewedAt = now()` there would restart the clock on
|
|
117
|
+
// a lease the queue has already re-delivered — the loss hidden by the very call meant to
|
|
118
|
+
// prevent it.
|
|
119
|
+
if (lapsed()) {
|
|
120
|
+
reportLost();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
renewedAt = now();
|
|
124
|
+
} catch (error) {
|
|
125
|
+
// One failed renewal is not a lost lease: there is a whole visibility window left to land
|
|
126
|
+
// the next one, and the default interval gives three tries inside it. Say it at warn and
|
|
127
|
+
// let the window decide.
|
|
128
|
+
logger.warn('jobs.heartbeat.failed', {
|
|
129
|
+
workerId,
|
|
130
|
+
job: claimed.name,
|
|
131
|
+
jobId: claimed.id,
|
|
132
|
+
attempt: claimed.attempt,
|
|
133
|
+
error: error instanceof Error ? error.message : String(error),
|
|
134
|
+
});
|
|
135
|
+
if (lapsed()) reportLost(error);
|
|
136
|
+
} finally {
|
|
137
|
+
renewing = false;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
timer = setInterval(() => {
|
|
142
|
+
void renew();
|
|
143
|
+
}, options.intervalMs);
|
|
144
|
+
|
|
145
|
+
return { renew, lost: () => lost, signal: gone.signal, stop };
|
|
146
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,54 @@ import './register';
|
|
|
10
10
|
/** Re-exported so a `job`/`task` file needs one import, not two. Same object as schema's. */
|
|
11
11
|
export type { Infer } from '@ultimat3/schema';
|
|
12
12
|
export { t } from '@ultimat3/schema';
|
|
13
|
+
export type {
|
|
14
|
+
BackfillBatch,
|
|
15
|
+
BackfillDefinition,
|
|
16
|
+
BackfillInput,
|
|
17
|
+
BackfillReport,
|
|
18
|
+
} from './backfill';
|
|
19
|
+
export { backfill, DEFAULT_BACKFILL_BATCH } from './backfill';
|
|
20
|
+
export type { BackfillGate, BackfillGateInput } from './backfill-gate';
|
|
21
|
+
export { checkBackfillEnvironment, gateBackfill } from './backfill-gate';
|
|
22
|
+
export type { BackfillProgress } from './backfill-inspect';
|
|
23
|
+
export { backfillForRun, inspectBackfills, toBackfillProgress } from './backfill-inspect';
|
|
24
|
+
export type {
|
|
25
|
+
BackfillFilter,
|
|
26
|
+
BackfillLedger,
|
|
27
|
+
BackfillRun,
|
|
28
|
+
BackfillStatus,
|
|
29
|
+
BackfillVerdict,
|
|
30
|
+
} from './backfill-ledger';
|
|
31
|
+
export {
|
|
32
|
+
BACKFILL_STATUSES,
|
|
33
|
+
backfillChecksum,
|
|
34
|
+
createMemoryBackfillLedger,
|
|
35
|
+
decideBackfill,
|
|
36
|
+
isBackfillStatus,
|
|
37
|
+
} from './backfill-ledger';
|
|
38
|
+
export type {
|
|
39
|
+
BackfillPendingReport,
|
|
40
|
+
BackfillState,
|
|
41
|
+
BackfillStateRow,
|
|
42
|
+
} from './backfill-pending';
|
|
43
|
+
export {
|
|
44
|
+
BACKFILL_STATES,
|
|
45
|
+
isPendingBackfillState,
|
|
46
|
+
PENDING_BACKFILL_STATES,
|
|
47
|
+
pendingBackfills,
|
|
48
|
+
} from './backfill-pending';
|
|
49
|
+
export type { Pacer, PacerOptions } from './backfill-rate';
|
|
50
|
+
export { createPacer, DEFAULT_BACKFILL_RATE } from './backfill-rate';
|
|
51
|
+
export type { BackfillCount, BackfillDeclaration, BackfillOrigin } from './backfill-registry';
|
|
52
|
+
// `stampBackfill` is deliberately absent, for the reason `registerJob` is: a second way to make a
|
|
53
|
+
// handle claim it is a backfill would let a plain `job()` inherit the pending diff and the gate.
|
|
54
|
+
export {
|
|
55
|
+
backfillOrigin,
|
|
56
|
+
declarationOf,
|
|
57
|
+
getBackfill,
|
|
58
|
+
isBackfill,
|
|
59
|
+
registeredBackfills,
|
|
60
|
+
} from './backfill-registry';
|
|
13
61
|
export type { JobDescriptor } from './describe';
|
|
14
62
|
export type {
|
|
15
63
|
ClaimedJob,
|
|
@@ -17,6 +65,7 @@ export type {
|
|
|
17
65
|
ConflictPolicy,
|
|
18
66
|
EnqueueRequest,
|
|
19
67
|
EnqueueResult,
|
|
68
|
+
HeartbeatOptions,
|
|
20
69
|
JobDriver,
|
|
21
70
|
JobFilter,
|
|
22
71
|
JobIntrospection,
|
|
@@ -41,11 +90,27 @@ export { createPgDriver, createPgLeader } from './driver-pg';
|
|
|
41
90
|
export {
|
|
42
91
|
SQL_ACK,
|
|
43
92
|
SQL_ADVISORY_UNLOCK,
|
|
93
|
+
SQL_BACKFILL_FINISH,
|
|
94
|
+
SQL_BACKFILL_LIST,
|
|
95
|
+
SQL_BACKFILL_PROGRESS,
|
|
96
|
+
SQL_BACKFILL_START,
|
|
97
|
+
SQL_CANCEL,
|
|
44
98
|
SQL_CLAIM,
|
|
45
99
|
SQL_ENQUEUE,
|
|
46
100
|
SQL_HEARTBEAT,
|
|
47
101
|
SQL_JOBS_TABLE,
|
|
102
|
+
SQL_LEADER_ACQUIRE,
|
|
103
|
+
SQL_LEADER_RELEASE,
|
|
104
|
+
SQL_LEASE_ACQUIRE,
|
|
105
|
+
SQL_LEASE_RELEASE,
|
|
106
|
+
SQL_LEASE_RENEW,
|
|
48
107
|
SQL_NACK,
|
|
108
|
+
SQL_OUTBOX_CLAIM,
|
|
109
|
+
SQL_OUTBOX_MARK_PUBLISHED,
|
|
110
|
+
SQL_OUTBOX_STAGE,
|
|
111
|
+
SQL_OUTBOX_TABLE,
|
|
112
|
+
SQL_SCHEDULER_STATE_GET,
|
|
113
|
+
SQL_SCHEDULER_STATE_MARK,
|
|
49
114
|
SQL_STATS,
|
|
50
115
|
SQL_STEP_GET,
|
|
51
116
|
SQL_STEP_PUT,
|
|
@@ -55,20 +120,38 @@ export type { RedisDriverOptions } from './driver-redis';
|
|
|
55
120
|
export { createRedisDriver } from './driver-redis';
|
|
56
121
|
export type { JobErrorCode } from './errors';
|
|
57
122
|
export {
|
|
123
|
+
BackfillAppliedError,
|
|
124
|
+
BackfillEnvironmentError,
|
|
125
|
+
BackfillMigrationPendingError,
|
|
126
|
+
BackfillPendingError,
|
|
127
|
+
BackfillRunningError,
|
|
128
|
+
BackfillStalledError,
|
|
129
|
+
BackfillUnknownError,
|
|
130
|
+
CancelUnsupportedError,
|
|
131
|
+
ConcurrencyUnenforceableError,
|
|
58
132
|
DriverUnavailableError,
|
|
59
133
|
IdempotencyRequiredError,
|
|
60
134
|
JOB_ERROR_CODES,
|
|
61
135
|
JOB_ERROR_TITLES,
|
|
136
|
+
JobAbortedError,
|
|
62
137
|
JobDuplicateError,
|
|
63
138
|
JobMaxAttemptsError,
|
|
64
139
|
JobNameTakenError,
|
|
140
|
+
JobNotCancellableError,
|
|
141
|
+
JobSlotLostError,
|
|
65
142
|
JobsNotImplementedError,
|
|
143
|
+
JobTenantRequiredError,
|
|
66
144
|
JobTimeoutError,
|
|
145
|
+
LeaseLostError,
|
|
67
146
|
OutboxNoTxError,
|
|
68
147
|
StepDuplicateError,
|
|
69
148
|
} from './errors';
|
|
70
149
|
export type { EventBus, JobEvent, MemoryEventBusOptions, PublishOptions } from './events';
|
|
71
150
|
export { createMemoryEventBus, eventBus, publishEvent, setEventBus } from './events';
|
|
151
|
+
export type { PgEventBusOptions } from './events-pg';
|
|
152
|
+
export { createPgEventBus } from './events-pg';
|
|
153
|
+
export type { ExecuteJobOptions, JobExecution, JobOutcome } from './execute';
|
|
154
|
+
export { executeJob } from './execute';
|
|
72
155
|
export type {
|
|
73
156
|
DeadLetterEntry,
|
|
74
157
|
JobsManifest,
|
|
@@ -77,6 +160,7 @@ export type {
|
|
|
77
160
|
StepTrace,
|
|
78
161
|
} from './inspect';
|
|
79
162
|
export {
|
|
163
|
+
cancelJob,
|
|
80
164
|
inspectDeadLetters,
|
|
81
165
|
inspectJob,
|
|
82
166
|
inspectJobList,
|
|
@@ -86,6 +170,8 @@ export {
|
|
|
86
170
|
} from './inspect';
|
|
87
171
|
export type { AnyJobHandle, JobActor, JobDefinition, JobHandle, JobRunArgs } from './job';
|
|
88
172
|
export { describeJobs, getJob, isJobHandle, job, registeredJobs, resetJobs } from './job';
|
|
173
|
+
export type { HeldLease, LeaseStore, MemoryLeaseStoreOptions } from './leases';
|
|
174
|
+
export { createMemoryLeaseStore, jobLeaseKey } from './leases';
|
|
89
175
|
export type {
|
|
90
176
|
Lease,
|
|
91
177
|
LimitConfig,
|
|
@@ -96,9 +182,16 @@ export type {
|
|
|
96
182
|
RateLimit,
|
|
97
183
|
} from './limits';
|
|
98
184
|
export { createLimiter, NO_TENANT, tenantKeyFrom } from './limits';
|
|
185
|
+
export {
|
|
186
|
+
queueDeadJobs,
|
|
187
|
+
queueOldestReady,
|
|
188
|
+
recordQueueDeadJobs,
|
|
189
|
+
recordQueueOldestReady,
|
|
190
|
+
} from './metrics';
|
|
99
191
|
export type {
|
|
100
192
|
EnqueueOptions,
|
|
101
193
|
JobsFacade,
|
|
194
|
+
MemoryOutboxStore,
|
|
102
195
|
OutboxDeps,
|
|
103
196
|
OutboxRecord,
|
|
104
197
|
OutboxRelay,
|
|
@@ -112,39 +205,29 @@ export {
|
|
|
112
205
|
enqueueInTx,
|
|
113
206
|
jobsFacade,
|
|
114
207
|
resetJobsFacade,
|
|
115
|
-
SQL_OUTBOX_CLAIM,
|
|
116
|
-
SQL_OUTBOX_MARK_PUBLISHED,
|
|
117
|
-
SQL_OUTBOX_STAGE,
|
|
118
|
-
SQL_OUTBOX_TABLE,
|
|
119
208
|
setJobsFacade,
|
|
120
209
|
} from './outbox';
|
|
210
|
+
export type { PgOutboxOptions } from './outbox-pg';
|
|
211
|
+
export { createPgOutboxStore } from './outbox-pg';
|
|
121
212
|
|
|
122
213
|
export type { BackoffStrategy, Random, RetryDecision, RetryPolicy } from './retry';
|
|
123
214
|
export { backoffDelayMs, DEFAULT_RETRY, nextRetry, retrySchedule } from './retry';
|
|
124
215
|
export type {
|
|
125
|
-
CatchUpPolicy,
|
|
126
216
|
CronResolver,
|
|
127
217
|
DispatchedOccurrence,
|
|
128
218
|
LeaderElection,
|
|
129
219
|
Scheduler,
|
|
130
220
|
SchedulerOptions,
|
|
131
221
|
SchedulerState,
|
|
132
|
-
TaskDefinition,
|
|
133
|
-
TaskDescriptor,
|
|
134
|
-
TaskEnqueueEntry,
|
|
135
|
-
TaskHandle,
|
|
136
|
-
TaskJobResult,
|
|
137
222
|
} from './scheduler';
|
|
223
|
+
export { createMemorySchedulerState, createScheduler, soleLeader } from './scheduler';
|
|
224
|
+
export type { PgLeaseLeaderOptions } from './scheduler-pg';
|
|
138
225
|
export {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
resetTasks,
|
|
145
|
-
soleLeader,
|
|
146
|
-
task,
|
|
147
|
-
} from './scheduler';
|
|
226
|
+
createPgLeaseLeader,
|
|
227
|
+
currentLeader,
|
|
228
|
+
DEFAULT_LEADER_TTL_MS,
|
|
229
|
+
pgSchedulerState,
|
|
230
|
+
} from './scheduler-pg';
|
|
148
231
|
export type {
|
|
149
232
|
EventLookup,
|
|
150
233
|
StepApi,
|
|
@@ -159,14 +242,25 @@ export {
|
|
|
159
242
|
createMemoryStepStore,
|
|
160
243
|
createStepRunner,
|
|
161
244
|
isStepSuspension,
|
|
245
|
+
MAX_TRACE_NAMES,
|
|
162
246
|
StepSuspension,
|
|
163
247
|
} from './steps';
|
|
164
248
|
export type {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
} from './
|
|
172
|
-
export {
|
|
249
|
+
CatchUpPolicy,
|
|
250
|
+
TaskDefinition,
|
|
251
|
+
TaskDescriptor,
|
|
252
|
+
TaskEnqueueEntry,
|
|
253
|
+
TaskHandle,
|
|
254
|
+
TaskJobResult,
|
|
255
|
+
} from './task';
|
|
256
|
+
export { getTask, isTaskHandle, registeredTasks, resetTasks, task } from './task';
|
|
257
|
+
/**
|
|
258
|
+
* The tenant a job's body runs under. The TYPE only: `NO_JOB_TENANT`, `jobRunActor` and
|
|
259
|
+
* `jobTenantFor` stay unexported. The first would be a second spelling of `'none'` (axiom 1 — the
|
|
260
|
+
* literal is what the type says and what a declaration reads as), and the other two are
|
|
261
|
+
* `executeJob`'s and `job()`'s: a second caller deriving a run's org would be a second answer to
|
|
262
|
+
* "whose tenant is this", which is the thing this declaration exists to make singular.
|
|
263
|
+
*/
|
|
264
|
+
export type { JobTenant } from './tenant';
|
|
265
|
+
export type { Worker, WorkerOptions, WorkerStats } from './worker';
|
|
266
|
+
export { createWorker } from './worker';
|