@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/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
+ }
@@ -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
- createMemorySchedulerState,
140
- createScheduler,
141
- getTask,
142
- isTaskHandle,
143
- registeredTasks,
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
- ExecuteJobOptions,
166
- JobExecution,
167
- JobOutcome,
168
- Worker,
169
- WorkerOptions,
170
- WorkerStats,
171
- } from './worker';
172
- export { createWorker, executeJob } from './worker';
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';