@ultimat3/jobs 22.14.0 → 23.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 +129 -87
- package/README.md +329 -19
- package/package.json +6 -6
- package/src/concurrency.ts +144 -0
- package/src/counters.ts +105 -0
- package/src/describe.ts +29 -0
- package/src/driver-memory-operator.ts +297 -0
- package/src/driver-memory.ts +79 -37
- package/src/driver-nats.ts +2 -2
- package/src/driver-pg-ddl.ts +71 -0
- package/src/driver-pg-jobs-sql.ts +71 -8
- package/src/driver-pg-operator-sql.ts +303 -0
- package/src/driver-pg-operator.ts +300 -0
- package/src/driver-pg-outbox-sql.ts +110 -0
- package/src/driver-pg-rows.ts +31 -0
- package/src/driver-pg-settle-sql.ts +71 -0
- package/src/driver-pg-sql.ts +80 -129
- package/src/driver-pg-wake-sql.ts +76 -0
- package/src/driver-pg.ts +74 -34
- package/src/driver-redis.ts +2 -2
- package/src/driver.ts +96 -34
- package/src/enqueue-signal.ts +50 -0
- package/src/errors-concurrency.ts +91 -0
- package/src/errors-operator.ts +94 -0
- package/src/errors.ts +13 -17
- package/src/events-pg.ts +38 -28
- package/src/events.ts +25 -0
- package/src/execute.ts +160 -81
- package/src/export.ts +23 -4
- package/src/heartbeat.ts +9 -3
- package/src/idle-backoff.ts +74 -0
- package/src/index.ts +105 -12
- package/src/inspect-operator.ts +49 -0
- package/src/inspect.ts +42 -2
- package/src/introspection.ts +262 -0
- package/src/job.ts +78 -15
- package/src/leases.ts +49 -9
- package/src/metrics.ts +6 -1
- package/src/outbox-pg.ts +3 -0
- package/src/outbox-relay.ts +114 -39
- package/src/outbox.ts +29 -6
- package/src/progress.ts +78 -0
- package/src/queue-wake.ts +220 -0
- package/src/redact-input.ts +26 -0
- package/src/renewal-timer.ts +28 -10
- package/src/retry-classification.ts +13 -1
- package/src/retry.ts +10 -2
- package/src/run-deadline.ts +57 -0
- package/src/scheduler-leader.ts +26 -0
- package/src/scheduler-occurrences.ts +76 -0
- package/src/scheduler-pg.ts +55 -2
- package/src/scheduler-state.ts +66 -0
- package/src/scheduler.ts +143 -103
- package/src/settled.ts +161 -0
- package/src/worker-admit.ts +163 -0
- package/src/worker-fleet-slots.ts +107 -38
- package/src/worker-hand-back.ts +6 -1
- package/src/worker-key-busy.ts +79 -0
- package/src/worker-loop.ts +145 -0
- package/src/worker-registry.ts +103 -0
- package/src/worker-run.ts +9 -0
- package/src/worker-types.ts +23 -0
- package/src/worker.ts +103 -134
package/src/driver.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { finiteCount, finiteOption } from '@ultimat3/core';
|
|
12
12
|
import type { BackfillLedger } from './backfill-ledger';
|
|
13
13
|
import { ClaimQueuesEmptyError } from './errors';
|
|
14
|
+
import type { JobIntrospection, JobProgress } from './introspection';
|
|
14
15
|
import type { LeaseStore } from './leases';
|
|
15
16
|
import type { StepStore } from './steps';
|
|
16
17
|
|
|
@@ -66,7 +67,13 @@ export interface JobRecord {
|
|
|
66
67
|
/** Actor's orgId, for per-tenant limits. */
|
|
67
68
|
readonly tenantId?: string;
|
|
68
69
|
readonly lastError?: string;
|
|
70
|
+
/** The thrown value's stack for `lastError`, bounded (`MAX_ERROR_STACK_LENGTH`). */
|
|
71
|
+
readonly lastErrorStack?: string;
|
|
72
|
+
/** What the body last reported through `progress()` — see `introspection.ts`. */
|
|
73
|
+
readonly progress?: JobProgress;
|
|
69
74
|
readonly claimedBy?: string;
|
|
75
|
+
/** How many times this row has been claimed. Absent until the first claim. */
|
|
76
|
+
readonly claim?: number;
|
|
70
77
|
readonly visibleAt?: number;
|
|
71
78
|
/**
|
|
72
79
|
* W3C `traceparent` of the request that queued this job. The job's span is opened as a CHILD of
|
|
@@ -81,6 +88,13 @@ export interface JobRecord {
|
|
|
81
88
|
export type ConflictPolicy = 'dedupe' | 'error';
|
|
82
89
|
|
|
83
90
|
export interface EnqueueRequest {
|
|
91
|
+
/**
|
|
92
|
+
* The row's id, when the caller already allocated one. The outbox does, at STAGE time, so the
|
|
93
|
+
* enqueue that staged it can name the job before it exists — and so a row published twice
|
|
94
|
+
* meets the job its first publish made (`deduped: true`), live or finished, instead of
|
|
95
|
+
* inserting a second one. Omit it everywhere else: the driver mints one.
|
|
96
|
+
*/
|
|
97
|
+
readonly id?: string;
|
|
84
98
|
readonly name: string;
|
|
85
99
|
readonly queue: string;
|
|
86
100
|
readonly input: unknown;
|
|
@@ -89,7 +103,10 @@ export interface EnqueueRequest {
|
|
|
89
103
|
/** Epoch ms. Omit for "now". */
|
|
90
104
|
readonly runAt?: number;
|
|
91
105
|
readonly tenantId?: string;
|
|
92
|
-
/**
|
|
106
|
+
/**
|
|
107
|
+
* The run's id — the key its steps, its events and its prompts hang off. Allocated by the
|
|
108
|
+
* outbox at stage time or named by the caller (`EnqueueOptions.runId`); minted here otherwise.
|
|
109
|
+
*/
|
|
93
110
|
readonly runId?: string;
|
|
94
111
|
readonly onConflict?: ConflictPolicy;
|
|
95
112
|
/** W3C `traceparent` of the enqueuing request. The facade stamps it; callers rarely set it. */
|
|
@@ -132,12 +149,57 @@ export interface ClaimOptions {
|
|
|
132
149
|
export interface ClaimedJob extends JobRecord {
|
|
133
150
|
readonly claimedAt: number;
|
|
134
151
|
readonly visibleAt: number;
|
|
152
|
+
/** The worker this claim was made for. */
|
|
153
|
+
readonly claimedBy: string;
|
|
154
|
+
/**
|
|
155
|
+
* THIS claim, as the row's claim ordinal: 1 for the first, one more for each after it, never
|
|
156
|
+
* reset. With `claimedBy` it is the identity every settle, renewal and progress write of this
|
|
157
|
+
* claim is fenced on — `claimOf(claimed)` is how a caller carries the pair.
|
|
158
|
+
*/
|
|
159
|
+
readonly claim: number;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* WHICH claim is acting. `workerId` alone was the fence, and it names a worker, not a claim: a
|
|
164
|
+
* worker that takes back its own lapsed job is the same worker, so the body still unwinding from
|
|
165
|
+
* the first claim settled the second. The ordinal is what tells the two apart.
|
|
166
|
+
*/
|
|
167
|
+
export interface ClaimIdentity {
|
|
168
|
+
readonly workerId: string;
|
|
169
|
+
readonly claim: number;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export const claimOf = (claimed: Pick<ClaimedJob, 'claimedBy' | 'claim'>): ClaimIdentity => ({
|
|
173
|
+
workerId: claimed.claimedBy,
|
|
174
|
+
claim: claimed.claim,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Who settles a claim, and how long the attempt ran. The identity is the FENCE: a settle from a
|
|
179
|
+
* claim the row no longer carries matches nothing. The lease lapses, the row is claimed again —
|
|
180
|
+
* by another worker or by this one — and the first body unwinds: its ack would mark the second
|
|
181
|
+
* run `done`, its nack would hand a running job to a third. `durationMs` feeds the per-minute
|
|
182
|
+
* counters the same statement writes.
|
|
183
|
+
*/
|
|
184
|
+
export interface SettleBy extends ClaimIdentity {
|
|
185
|
+
readonly durationMs?: number;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface AckOptions extends SettleBy {
|
|
189
|
+
/**
|
|
190
|
+
* False when the row is finished WITHOUT its body having run here — `x jobs drain` moving it to
|
|
191
|
+
* another driver. It is settled `done` and adds nothing to the job's counters: a job that was
|
|
192
|
+
* moved is not a job that completed.
|
|
193
|
+
*/
|
|
194
|
+
readonly counted?: boolean;
|
|
135
195
|
}
|
|
136
196
|
|
|
137
|
-
export interface NackOptions {
|
|
197
|
+
export interface NackOptions extends SettleBy {
|
|
138
198
|
/** Delay before the job becomes claimable again. */
|
|
139
199
|
readonly delayMs: number;
|
|
140
200
|
readonly error?: string;
|
|
201
|
+
/** The thrown value's stack, kept beside `error` for `x jobs show`. */
|
|
202
|
+
readonly stack?: string;
|
|
141
203
|
/**
|
|
142
204
|
* The ATTEMPT COUNTER, and nothing else. False for a suspension and for a shed alike: neither is
|
|
143
205
|
* a failure, and a 3-day sleep that burned an attempt would dead-letter the job.
|
|
@@ -157,14 +219,37 @@ export interface NackOptions {
|
|
|
157
219
|
*/
|
|
158
220
|
readonly park?: boolean;
|
|
159
221
|
readonly deadLetter?: boolean;
|
|
222
|
+
/**
|
|
223
|
+
* Terminal WITHOUT the dead-letter queue: the row settles `failed` — finished, never claimed
|
|
224
|
+
* again, requeueable by `x jobs retry`, and absent from `queue_dead_jobs`. For an ending that is
|
|
225
|
+
* an answer rather than a fault: `whenBusy: 'fail'` over a busy concurrency key
|
|
226
|
+
* (`X_JOB_KEY_BUSY`). `deadLetter` wins when both are set, and `park` loses to both.
|
|
227
|
+
*/
|
|
228
|
+
readonly fail?: boolean;
|
|
160
229
|
}
|
|
161
230
|
|
|
231
|
+
/**
|
|
232
|
+
* The state a nack leaves its row in — ONE reading, for every driver. It was a three-way written
|
|
233
|
+
* out in each, which is two places for a fourth branch to land in one and not the other; a
|
|
234
|
+
* `fail` the pg driver filed as `ready` would re-claim a refused run forever.
|
|
235
|
+
*/
|
|
236
|
+
export const nackState = (options: NackOptions): JobState =>
|
|
237
|
+
options.deadLetter === true
|
|
238
|
+
? 'dead'
|
|
239
|
+
: options.fail === true
|
|
240
|
+
? 'failed'
|
|
241
|
+
: options.park === true
|
|
242
|
+
? 'suspended'
|
|
243
|
+
: 'ready';
|
|
244
|
+
|
|
162
245
|
export interface QueueStats {
|
|
163
246
|
readonly queue: string;
|
|
164
247
|
readonly ready: number;
|
|
165
248
|
readonly delayed: number;
|
|
166
249
|
readonly running: number;
|
|
167
250
|
readonly suspended: number;
|
|
251
|
+
/** Rows that ended `failed` — refused by a busy key, or exhausted with `deadLetter: false`. */
|
|
252
|
+
readonly failed: number;
|
|
168
253
|
readonly dead: number;
|
|
169
254
|
/**
|
|
170
255
|
* Age in ms of the oldest job that is READY and due — the number that decides autoscaling.
|
|
@@ -174,40 +259,12 @@ export interface QueueStats {
|
|
|
174
259
|
readonly oldestReadyMs: number;
|
|
175
260
|
}
|
|
176
261
|
|
|
177
|
-
export interface JobFilter {
|
|
178
|
-
readonly queue?: string;
|
|
179
|
-
readonly name?: string;
|
|
180
|
-
readonly state?: JobState;
|
|
181
|
-
readonly limit?: number;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/** Optional: powers `/_x` and the MCP tools. A minimal driver may omit it. */
|
|
185
|
-
export interface JobIntrospection {
|
|
186
|
-
job(jobId: string): Promise<JobRecord | undefined>;
|
|
187
|
-
list(filter?: JobFilter): Promise<readonly JobRecord[]>;
|
|
188
|
-
deadLetters(limit?: number): Promise<readonly JobRecord[]>;
|
|
189
|
-
/**
|
|
190
|
-
* Re-queue a finished job (`REQUEUEABLE_STATES`); a live one is `X_JOB_NOT_REQUEUEABLE`, and a
|
|
191
|
-
* key a live job holds is `X_JOB_DUPLICATE`. `fromStep` drops that step and every step that
|
|
192
|
-
* started after it.
|
|
193
|
-
*/
|
|
194
|
-
requeue(jobId: string, options?: { readonly fromStep?: string }): Promise<JobRecord>;
|
|
195
|
-
/**
|
|
196
|
-
* Stop a job from outside. The only answer to a runaway pass that was otherwise "scale the
|
|
197
|
-
* worker to zero" (which stops every job) or a hand-written `UPDATE` (which the running
|
|
198
|
-
* worker's next ack overwrote). Terminal for a queued row immediately; a RUNNING one stops at
|
|
199
|
-
* its next heartbeat, which no longer matches its own row and cancels the attempt.
|
|
200
|
-
*
|
|
201
|
-
* Optional on the interface for the reason `requeue` is not: a driver may have no way to
|
|
202
|
-
* address a single row. Answers `undefined` for a job id it does not hold.
|
|
203
|
-
*/
|
|
204
|
-
cancel?(jobId: string, reason?: string): Promise<JobRecord | undefined>;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
262
|
export interface HeartbeatOptions {
|
|
208
263
|
readonly visibilityTimeoutMs: number;
|
|
209
264
|
/** Renew only if this worker is still the claimant. Omit and any claimant matches. */
|
|
210
265
|
readonly workerId?: string;
|
|
266
|
+
/** Renew only if the row still carries THIS claim (`ClaimedJob.claim`). Omit and any does. */
|
|
267
|
+
readonly claim?: number;
|
|
211
268
|
}
|
|
212
269
|
|
|
213
270
|
export interface JobDriver {
|
|
@@ -216,8 +273,13 @@ export interface JobDriver {
|
|
|
216
273
|
readonly steps: StepStore;
|
|
217
274
|
enqueue(request: EnqueueRequest): Promise<EnqueueResult>;
|
|
218
275
|
claim(options: ClaimOptions): Promise<readonly ClaimedJob[]>;
|
|
219
|
-
|
|
220
|
-
|
|
276
|
+
/**
|
|
277
|
+
* Both settles are fenced on `state = 'running'` AND on the claimer, and both answer whether
|
|
278
|
+
* they LANDED. `false` is a settle from a worker that no longer owns the row — logged by the
|
|
279
|
+
* caller, never thrown: the row is somebody else's now and theirs is the verdict.
|
|
280
|
+
*/
|
|
281
|
+
ack(jobId: string, by: AckOptions): Promise<boolean>;
|
|
282
|
+
nack(jobId: string, options: NackOptions): Promise<boolean>;
|
|
221
283
|
/**
|
|
222
284
|
* Extends the lease of a long-running job so it is not double-claimed.
|
|
223
285
|
*
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// "Work just arrived." A worker or relay that has backed off (`idle-backoff.ts`) is woken by it at
|
|
2
|
+
// once, so the backoff is never the latency of a job. Two signals, because they wake two loops: a
|
|
3
|
+
// job on the queue wakes workers, a row staged in the outbox wakes the relay. Raised in-process by
|
|
4
|
+
// whatever queued the work here, and by `queue-wake.ts` for work ANOTHER process committed — it
|
|
5
|
+
// holds the `LISTEN` this file knows nothing about.
|
|
6
|
+
|
|
7
|
+
function channel<A extends readonly unknown[]>(): {
|
|
8
|
+
on(listener: (...args: A) => void): () => void;
|
|
9
|
+
signal(...args: A): void;
|
|
10
|
+
} {
|
|
11
|
+
const listeners = new Set<(...args: A) => void>();
|
|
12
|
+
return {
|
|
13
|
+
on(listener) {
|
|
14
|
+
listeners.add(listener);
|
|
15
|
+
return () => {
|
|
16
|
+
listeners.delete(listener);
|
|
17
|
+
};
|
|
18
|
+
},
|
|
19
|
+
signal(...args) {
|
|
20
|
+
for (const listener of listeners) listener(...args);
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const enqueued = channel<[queue?: string]>();
|
|
26
|
+
const staged = channel<[committed?: boolean]>();
|
|
27
|
+
|
|
28
|
+
/** A job landed on `queue` — on any queue, when none is named. Returns the unsubscribe. */
|
|
29
|
+
export const onEnqueued = enqueued.on;
|
|
30
|
+
export const signalEnqueued = enqueued.signal;
|
|
31
|
+
/**
|
|
32
|
+
* A row was staged in the outbox. `committed` is what tells the relay whether a pass NOW can find
|
|
33
|
+
* it: a stage signalled by this process is still inside its transaction, one announced by
|
|
34
|
+
* Postgres has committed.
|
|
35
|
+
*/
|
|
36
|
+
export const onStaged = staged.on;
|
|
37
|
+
export const signalStaged = staged.signal;
|
|
38
|
+
|
|
39
|
+
let live = false;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Whether work committed by ANOTHER process reaches the two signals above — a `LISTEN` is held
|
|
43
|
+
* and a notification has crossed it (`queue-wake.ts`). The idle loops read it to choose their
|
|
44
|
+
* ceiling: with no wake, the poll is the only thing bounding a job's start.
|
|
45
|
+
*/
|
|
46
|
+
export const wakeIsLive = (): boolean => live;
|
|
47
|
+
|
|
48
|
+
export function setWakeLive(value: boolean): void {
|
|
49
|
+
live = value;
|
|
50
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Single responsibility: the refusals `job.concurrency` earns — a cap mis-declared, a key that
|
|
2
|
+
// cannot name a lease, a key already holding its limit, and a driver that cannot hold the cap.
|
|
3
|
+
// Its own module because `errors.ts` sits at the size ceiling; the CODES are registered there,
|
|
4
|
+
// beside every other jobs code.
|
|
5
|
+
|
|
6
|
+
import { isFixShellSafe, renderCauseValue, renderFixShellArg, UltimateError } from '@ultimat3/core';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `concurrency: { key, limit, whenBusy }` declared with a member no worker can honour. The shipped
|
|
10
|
+
* `X_JOB_DECLARATION_INVALID` and not a code of its own: it is the same statement — this `job()`
|
|
11
|
+
* call cannot be seated as written — and the same repair, an edit to the declaration.
|
|
12
|
+
*
|
|
13
|
+
* `declared` is rendered by core's total reader, never interpolated: it arrives from generated
|
|
14
|
+
* code and JS callers, which is the only way a value the type forbids reaches here at all.
|
|
15
|
+
*/
|
|
16
|
+
export class JobConcurrencyInvalidError extends UltimateError {
|
|
17
|
+
constructor(input: { job: string; field: string; declared: unknown; why: string; edit: string }) {
|
|
18
|
+
super({
|
|
19
|
+
code: 'X_JOB_DECLARATION_INVALID',
|
|
20
|
+
cause: `job "${input.job}" declares ${input.field} ${renderCauseValue(input.declared)}, ${input.why}`,
|
|
21
|
+
fix: `${input.edit} on job('${input.job}')`,
|
|
22
|
+
meta: { job: input.job, field: input.field },
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Why a key cannot name a lease. `unreadable` is a key function that answered a non-string. */
|
|
28
|
+
export type ConcurrencyKeyDefect = 'empty' | 'unreadable' | 'too-long';
|
|
29
|
+
|
|
30
|
+
const KEY_DEFECT_CAUSE = Object.freeze<Record<ConcurrencyKeyDefect, string>>({
|
|
31
|
+
empty:
|
|
32
|
+
'answered an empty string for this input — an empty key is one lock shared by every run, which nobody declared',
|
|
33
|
+
unreadable: 'answered something that is not a string, and a lease is keyed by text',
|
|
34
|
+
'too-long': 'answered a key longer than a lease row can be keyed by',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `concurrency.key(input)` answered a value that cannot key a lease — refused where the run is
|
|
39
|
+
* ENQUEUED, because that is the first moment an input exists to ask the function about.
|
|
40
|
+
*
|
|
41
|
+
* The key itself never reaches the cause or the meta: it is computed from an input, so it is app
|
|
42
|
+
* data, and this error is logged by whatever caught the enqueue.
|
|
43
|
+
*/
|
|
44
|
+
export class JobConcurrencyKeyInvalidError extends UltimateError {
|
|
45
|
+
constructor(input: { job: string; defect: ConcurrencyKeyDefect; maxLength: number }) {
|
|
46
|
+
super({
|
|
47
|
+
code: 'X_JOB_DECLARATION_INVALID',
|
|
48
|
+
cause: `job "${input.job}" concurrency.key ${KEY_DEFECT_CAUSE[input.defect]}`,
|
|
49
|
+
fix: `return a non-empty string of at most ${input.maxLength} characters from concurrency.key on job('${input.job}') — key: (input) => input.accountId — or declare concurrency: 1 for one cap across every run`,
|
|
50
|
+
meta: { job: input.job, defect: input.defect },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A run claimed under `whenBusy: 'fail'` while its key already held `limit` runs. The run settles
|
|
57
|
+
* `failed` with this as its `lastError`, its body never runs and nothing retries it — the declared
|
|
58
|
+
* answer to "a second run of this key", which is why it is neither a dead letter nor a fault.
|
|
59
|
+
*/
|
|
60
|
+
export class JobKeyBusyError extends UltimateError {
|
|
61
|
+
constructor(input: { job: string; key: string; limit: number }) {
|
|
62
|
+
super({
|
|
63
|
+
code: 'X_JOB_KEY_BUSY',
|
|
64
|
+
cause: `job "${input.job}" key ${renderCauseValue(input.key)} already holds ${input.limit} run(s)`,
|
|
65
|
+
// A name the shell would not read verbatim drops the filter rather than travelling as a
|
|
66
|
+
// placeholder: `--name <job>` is a redirection, and a fix that fails is no fix. The splice
|
|
67
|
+
// still goes through `renderFixShellArg` — the screen `bun run error-render` looks for —
|
|
68
|
+
// whose placeholder the guard above makes unreachable.
|
|
69
|
+
fix: isFixShellSafe(input.job)
|
|
70
|
+
? `x jobs ls --name ${renderFixShellArg(input.job, 'job')} --state running --json`
|
|
71
|
+
: 'x jobs ls --state running --json',
|
|
72
|
+
meta: { job: input.job, key: input.key, limit: input.limit },
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* `job.concurrency` is declared and this driver has no `leases`, so the cap is per PROCESS and the
|
|
79
|
+
* fleet runs `concurrency x replicas`. Thrown at worker start rather than logged, because a
|
|
80
|
+
* documented guarantee that silently does nothing is exactly what axiom 3 exists to refuse — the
|
|
81
|
+
* worker refuses to start instead of running with the wrong number.
|
|
82
|
+
*/
|
|
83
|
+
export class ConcurrencyUnenforceableError extends UltimateError {
|
|
84
|
+
constructor(input: { driver: string; jobs: readonly string[] }) {
|
|
85
|
+
super({
|
|
86
|
+
code: 'X_JOB_CONCURRENCY_UNENFORCEABLE',
|
|
87
|
+
cause: `${input.jobs.join(', ')} declare concurrency and the "${input.driver}" jobs driver has no lease store, so the cap would hold per process and the fleet would run concurrency x replicas`,
|
|
88
|
+
fix: `remove concurrency from job("${input.jobs[0] ?? 'the job'}"), or call setJobDriver(createPgDriver()) at boot — the pg driver is the one with a lease store`,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Single responsibility: the refusals the operator surface gives — a job that cannot be removed
|
|
2
|
+
// or promoted, a page it cannot answer — and the one failure it only ever reports, a settled
|
|
3
|
+
// run's `onSettled` hook. Its own
|
|
4
|
+
// module because `errors.ts` sits at the size ceiling; the codes are registered there.
|
|
5
|
+
|
|
6
|
+
import { isFixShellSafe, renderFixShellArg, renderThrowable, UltimateError } from '@ultimat3/core';
|
|
7
|
+
|
|
8
|
+
/** A command over one job id, or the listing when the id would not survive a shell verbatim. */
|
|
9
|
+
const jobCommand = (verb: string, jobId: string): string =>
|
|
10
|
+
isFixShellSafe(jobId)
|
|
11
|
+
? `x jobs ${verb} ${renderFixShellArg(jobId, 'id')} --json`
|
|
12
|
+
: 'x jobs ls --state running --json';
|
|
13
|
+
|
|
14
|
+
/** `remove()` / `removeMany()` reached a job a worker holds. Cancel stops the body; then remove. */
|
|
15
|
+
export class JobNotRemovableError extends UltimateError {
|
|
16
|
+
constructor(input: { jobId?: string }) {
|
|
17
|
+
super({
|
|
18
|
+
code: 'X_JOB_NOT_REMOVABLE',
|
|
19
|
+
cause:
|
|
20
|
+
input.jobId === undefined
|
|
21
|
+
? 'a bulk remove named the running state, and a row a worker holds cannot be deleted under its body'
|
|
22
|
+
: `job ${input.jobId} is running, and deleting the row under its body would not stop the body`,
|
|
23
|
+
fix:
|
|
24
|
+
input.jobId === undefined
|
|
25
|
+
? 'x jobs ls --state running --json'
|
|
26
|
+
: jobCommand('cancel', input.jobId),
|
|
27
|
+
meta: { ...(input.jobId === undefined ? {} : { jobId: input.jobId }) },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `promote()` reached a job that is not waiting on its `runAt`. */
|
|
33
|
+
export class JobNotPromotableError extends UltimateError {
|
|
34
|
+
constructor(input: { jobId: string; state: string }) {
|
|
35
|
+
super({
|
|
36
|
+
code: 'X_JOB_NOT_PROMOTABLE',
|
|
37
|
+
cause:
|
|
38
|
+
input.state === 'missing'
|
|
39
|
+
? `no job ${input.jobId} exists in this queue`
|
|
40
|
+
: `job ${input.jobId} is ${input.state} and is not waiting on its run time — only a delayed job, or one backing off before a retry, can be promoted`,
|
|
41
|
+
fix: jobCommand('show', input.jobId),
|
|
42
|
+
meta: { jobId: input.jobId, state: input.state },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Built to be LOGGED and reported, never thrown at a caller: the run is already settled, the hook
|
|
49
|
+
* is the app's reaction to that, and a reaction that fails is not a reason to settle it again.
|
|
50
|
+
*/
|
|
51
|
+
export class JobOnSettledFailedError extends UltimateError {
|
|
52
|
+
constructor(input: {
|
|
53
|
+
job: string;
|
|
54
|
+
jobId: string;
|
|
55
|
+
outcome: string;
|
|
56
|
+
attempts: number;
|
|
57
|
+
error: unknown;
|
|
58
|
+
}) {
|
|
59
|
+
super({
|
|
60
|
+
code: 'X_JOB_ON_SETTLED_FAILED',
|
|
61
|
+
cause: `onSettled for job "${input.job}" (${input.jobId}, ${input.outcome}) failed ${input.attempts} time(s): ${renderThrowable(input.error)}`,
|
|
62
|
+
fix: jobCommand('show', input.jobId),
|
|
63
|
+
meta: {
|
|
64
|
+
job: input.job,
|
|
65
|
+
jobId: input.jobId,
|
|
66
|
+
outcome: input.outcome,
|
|
67
|
+
attempts: input.attempts,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* `list()` was asked for a page it does not answer: more rows than one page holds, or a cursor no
|
|
75
|
+
* page produced. Its own code, never the bare invariant it was: the reader is a dashboard author
|
|
76
|
+
* with a `limit` in a query string, and what they need is the bound by name and the call that
|
|
77
|
+
* walks past it. `MAX_JOB_PAGE` is passed in — `introspection.ts` owns it and imports this file.
|
|
78
|
+
*/
|
|
79
|
+
export class JobPageInvalidError extends UltimateError {
|
|
80
|
+
constructor(input: { subject: string; maxPage: number; limit?: number }) {
|
|
81
|
+
super({
|
|
82
|
+
code: 'X_JOB_PAGE_INVALID',
|
|
83
|
+
cause:
|
|
84
|
+
input.limit === undefined
|
|
85
|
+
? `${input.subject} was handed an after cursor that no previous page produced`
|
|
86
|
+
: `${input.subject} limit is ${input.limit}, over MAX_JOB_PAGE (${input.maxPage}) — the rows one page may answer`,
|
|
87
|
+
fix: `list({ limit: ${input.maxPage}, after: jobCursor(lastRow) }) # lastRow is the last row of the page before; omit after for the first page, and stop at a page shorter than its limit`,
|
|
88
|
+
meta: {
|
|
89
|
+
maxPage: input.maxPage,
|
|
90
|
+
...(input.limit === undefined ? {} : { limit: input.limit }),
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -38,6 +38,11 @@ export const JOB_OWNED_ERROR_CODES = [
|
|
|
38
38
|
'X_EXPORT_PART_TOO_LARGE',
|
|
39
39
|
'X_JOB_DECLARATION_INVALID',
|
|
40
40
|
'X_JOB_NOT_REQUEUEABLE',
|
|
41
|
+
'X_JOB_KEY_BUSY',
|
|
42
|
+
'X_JOB_NOT_REMOVABLE',
|
|
43
|
+
'X_JOB_NOT_PROMOTABLE',
|
|
44
|
+
'X_JOB_PAGE_INVALID',
|
|
45
|
+
'X_JOB_ON_SETTLED_FAILED',
|
|
41
46
|
] as const;
|
|
42
47
|
|
|
43
48
|
/**
|
|
@@ -88,8 +93,13 @@ export const JOB_ERROR_TITLES: Readonly<Record<JobOwnedErrorCode, string>> = {
|
|
|
88
93
|
X_WEBHOOK_DELIVERY_REJECTED: 'the endpoint refused the delivery in a way a retry cannot change',
|
|
89
94
|
X_EXPORT_ROW_INVALID: 'row() answered something the declared columns do not carry',
|
|
90
95
|
X_EXPORT_PART_TOO_LARGE: 'one page encoded to more bytes than a part may hold',
|
|
91
|
-
X_JOB_DECLARATION_INVALID: 'the job declaration is missing a
|
|
96
|
+
X_JOB_DECLARATION_INVALID: 'the job declaration is missing or mis-declares a field',
|
|
92
97
|
X_JOB_NOT_REQUEUEABLE: 'the job is still live and cannot be requeued',
|
|
98
|
+
X_JOB_KEY_BUSY: 'the concurrency key already holds its limit of runs',
|
|
99
|
+
X_JOB_NOT_REMOVABLE: 'the job is running and cannot be removed',
|
|
100
|
+
X_JOB_NOT_PROMOTABLE: 'the job is not waiting on its run time',
|
|
101
|
+
X_JOB_PAGE_INVALID: 'a job list page was asked for outside its bounds',
|
|
102
|
+
X_JOB_ON_SETTLED_FAILED: 'the onSettled hook failed after the run was settled',
|
|
93
103
|
};
|
|
94
104
|
|
|
95
105
|
// One unconditional call, so a second package claiming one of jobs' codes throws
|
|
@@ -141,6 +151,8 @@ registerErrorRetry({
|
|
|
141
151
|
// the whole retry policy proving it.
|
|
142
152
|
X_EXPORT_ROW_INVALID: 'terminal',
|
|
143
153
|
X_EXPORT_PART_TOO_LARGE: 'terminal',
|
|
154
|
+
// The worker settles a busy key itself; listed so a body that RETHROWS one it was handed stops.
|
|
155
|
+
X_JOB_KEY_BUSY: 'terminal',
|
|
144
156
|
});
|
|
145
157
|
|
|
146
158
|
// No `docs:` on any class below, here or in `backfill-errors.ts`. `UltimateError` fills it from
|
|
@@ -431,22 +443,6 @@ export class CancelUnsupportedError extends UltimateError {
|
|
|
431
443
|
}
|
|
432
444
|
}
|
|
433
445
|
|
|
434
|
-
/**
|
|
435
|
-
* `job.concurrency` is declared and this driver has no `leases`, so the cap is per PROCESS and the
|
|
436
|
-
* fleet runs `concurrency x replicas`. Thrown at worker start rather than logged, because a
|
|
437
|
-
* documented guarantee that silently does nothing is exactly what axiom 3 exists to refuse — the
|
|
438
|
-
* worker refuses to start instead of running with the wrong number.
|
|
439
|
-
*/
|
|
440
|
-
export class ConcurrencyUnenforceableError extends UltimateError {
|
|
441
|
-
constructor(input: { driver: string; jobs: readonly string[] }) {
|
|
442
|
-
super({
|
|
443
|
-
code: 'X_JOB_CONCURRENCY_UNENFORCEABLE',
|
|
444
|
-
cause: `${input.jobs.join(', ')} declare concurrency and the "${input.driver}" jobs driver has no lease store, so the cap would hold per process and the fleet would run concurrency x replicas`,
|
|
445
|
-
fix: `remove concurrency from job("${input.jobs[0] ?? 'the job'}"), or call setJobDriver(createPgDriver()) at boot — the pg driver is the one with a lease store`,
|
|
446
|
-
});
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
|
|
450
446
|
/** An outbox enqueue happened with no ambient transaction to join. */
|
|
451
447
|
export class OutboxNoTxError extends UltimateError {
|
|
452
448
|
constructor(input: { job: string }) {
|
package/src/events-pg.ts
CHANGED
|
@@ -6,18 +6,18 @@
|
|
|
6
6
|
// Stored and not broadcast, exactly as the memory bus is: a step that suspends at 12:00 and
|
|
7
7
|
// resumes at 12:00:30 must still see an event published at 12:00:10.
|
|
8
8
|
|
|
9
|
-
import
|
|
10
|
-
import { finiteOption, logger, renderThrowable, systemClock, uuid } from '@ultimat3/core';
|
|
9
|
+
import { finiteOption, logger, renderThrowable, uuid } from '@ultimat3/core';
|
|
11
10
|
import type { DurationInput } from './clock';
|
|
12
|
-
import { finiteDurationMs
|
|
11
|
+
import { finiteDurationMs } from './clock';
|
|
13
12
|
import type { PgExecutor } from './driver-pg';
|
|
14
13
|
import {
|
|
15
14
|
SQL_EVENT_FIND,
|
|
16
15
|
SQL_EVENT_LIST,
|
|
16
|
+
SQL_EVENT_NOW,
|
|
17
17
|
SQL_EVENT_PUBLISH,
|
|
18
18
|
SQL_EVENT_PURGE,
|
|
19
19
|
} from './driver-pg-sql';
|
|
20
|
-
import type { EventBus
|
|
20
|
+
import type { EventBus } from './events';
|
|
21
21
|
|
|
22
22
|
interface EventRow {
|
|
23
23
|
readonly id: string;
|
|
@@ -29,8 +29,11 @@ interface EventRow {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
export interface PgEventBusOptions {
|
|
32
|
+
/**
|
|
33
|
+
* The only thing this bus is built from. There is no `clock`: every instant it writes or
|
|
34
|
+
* compares is the database's (`SQL_EVENT_PUBLISH`), and a process clock here was the defect.
|
|
35
|
+
*/
|
|
32
36
|
readonly executor: PgExecutor;
|
|
33
|
-
readonly clock?: Clock;
|
|
34
37
|
/** How long an event stays matchable. Default 7d — longer than any sane wait. */
|
|
35
38
|
readonly defaultTtl?: DurationInput;
|
|
36
39
|
/** Rows returned by `list()`. Diagnostics only; `find()` is what a step uses. */
|
|
@@ -45,7 +48,6 @@ export interface PgEventBusOptions {
|
|
|
45
48
|
* plus `expires_at > now()` in `find` means an unpurged row costs a filter, never a wrong answer.
|
|
46
49
|
*/
|
|
47
50
|
export function createPgEventBus(options: PgEventBusOptions): EventBus {
|
|
48
|
-
const clock = options.clock ?? systemClock;
|
|
49
51
|
// TWO screens, for the reason `events.ts` states: `defaultTtl` is the constructor's knob and
|
|
50
52
|
// `ttl` is the publish call's, so one screen over `ttl ?? defaultTtl` names the wrong one for
|
|
51
53
|
// whichever value actually arrived.
|
|
@@ -68,35 +70,43 @@ export function createPgEventBus(options: PgEventBusOptions): EventBus {
|
|
|
68
70
|
};
|
|
69
71
|
|
|
70
72
|
return {
|
|
73
|
+
stored: true,
|
|
74
|
+
async now() {
|
|
75
|
+
const rows = await exec.query<{ now: number | string }>(SQL_EVENT_NOW, []);
|
|
76
|
+
return Number(rows[0]?.now);
|
|
77
|
+
},
|
|
78
|
+
|
|
71
79
|
async publish(name, payload, publishOptions = {}) {
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
|
|
80
|
+
const id = uuid();
|
|
81
|
+
const ttlMs =
|
|
82
|
+
publishOptions.ttl === undefined
|
|
83
|
+
? defaultTtlMs
|
|
84
|
+
: finiteDurationMs(publishOptions.ttl, 'the pg event bus', 'ttl');
|
|
85
|
+
const rows = await exec.query<{
|
|
86
|
+
published_at: number | string;
|
|
87
|
+
expires_at: number | string;
|
|
88
|
+
}>(SQL_EVENT_PUBLISH, [
|
|
89
|
+
id,
|
|
75
90
|
name,
|
|
76
|
-
payload,
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
at +
|
|
80
|
-
(publishOptions.ttl === undefined
|
|
81
|
-
? defaultTtlMs
|
|
82
|
-
: finiteDurationMs(publishOptions.ttl, 'the pg event bus', 'ttl')),
|
|
83
|
-
...(publishOptions.correlationKey === undefined
|
|
84
|
-
? {}
|
|
85
|
-
: { correlationKey: publishOptions.correlationKey }),
|
|
86
|
-
};
|
|
87
|
-
await exec.query(SQL_EVENT_PUBLISH, [
|
|
88
|
-
event.id,
|
|
89
|
-
event.name,
|
|
90
|
-
JSON.stringify(event.payload ?? null),
|
|
91
|
-
event.correlationKey ?? null,
|
|
92
|
-
event.publishedAt,
|
|
93
|
-
event.expiresAt,
|
|
91
|
+
JSON.stringify(payload ?? null),
|
|
92
|
+
publishOptions.correlationKey ?? null,
|
|
93
|
+
ttlMs,
|
|
94
94
|
]);
|
|
95
95
|
logger.debug('jobs.event.published', {
|
|
96
96
|
event: name,
|
|
97
97
|
correlationKey: publishOptions.correlationKey ?? null,
|
|
98
98
|
});
|
|
99
|
-
|
|
99
|
+
// The stamps the statement wrote, read back: what a consumer will compare against.
|
|
100
|
+
return {
|
|
101
|
+
id,
|
|
102
|
+
name,
|
|
103
|
+
payload,
|
|
104
|
+
publishedAt: Number(rows[0]?.published_at),
|
|
105
|
+
expiresAt: Number(rows[0]?.expires_at),
|
|
106
|
+
...(publishOptions.correlationKey === undefined
|
|
107
|
+
? {}
|
|
108
|
+
: { correlationKey: publishOptions.correlationKey }),
|
|
109
|
+
};
|
|
100
110
|
},
|
|
101
111
|
|
|
102
112
|
/** Earliest match at or after `afterMs`, so a resumed step consumes events in order. */
|
package/src/events.ts
CHANGED
|
@@ -25,6 +25,19 @@ export interface PublishOptions {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export interface EventBus extends EventLookup {
|
|
28
|
+
/**
|
|
29
|
+
* Whether an event published by ONE process is found by another. `true` for a bus over a table,
|
|
30
|
+
* `false` for the in-memory one — which is the whole question a wait across the web / worker
|
|
31
|
+
* split asks, and the reason `eventPrompt()` refuses the in-memory bus outside development.
|
|
32
|
+
*/
|
|
33
|
+
readonly stored: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* The bus's OWN clock, in epoch ms: what a publish issued now would be stamped with. A consumer
|
|
36
|
+
* that wants "published after I asked" takes its "asked at" from here and never from its own
|
|
37
|
+
* process — `publishedAt` is this clock's, and two clocks compared is a skew between pods
|
|
38
|
+
* deciding which answers count.
|
|
39
|
+
*/
|
|
40
|
+
now(): Promise<number>;
|
|
28
41
|
publish(name: string, payload: unknown, options?: PublishOptions): Promise<JobEvent>;
|
|
29
42
|
list(name?: string): Promise<readonly JobEvent[]>;
|
|
30
43
|
purgeExpired(): number;
|
|
@@ -65,6 +78,8 @@ export function createMemoryEventBus(options: MemoryEventBusOptions = {}): Event
|
|
|
65
78
|
};
|
|
66
79
|
|
|
67
80
|
return {
|
|
81
|
+
stored: false,
|
|
82
|
+
now: () => Promise.resolve(nowMs(clock)),
|
|
68
83
|
publish(name, payload, publishOptions = {}) {
|
|
69
84
|
purgeExpired();
|
|
70
85
|
const at = nowMs(clock);
|
|
@@ -143,6 +158,16 @@ export function eventBus(): EventBus {
|
|
|
143
158
|
return ambientBus;
|
|
144
159
|
}
|
|
145
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Test seam, the counterpart of `resetJobDriver()` and `resetJobsFacade()`: back to a FRESH
|
|
163
|
+
* in-process bus, the state a process boots in. An event is matchable until it expires, so on a
|
|
164
|
+
* bus shared by a whole `bun test` process an answer one test published resumes the next test's
|
|
165
|
+
* wait before that test has asked anything — call this between tests.
|
|
166
|
+
*/
|
|
167
|
+
export function resetEventBus(): void {
|
|
168
|
+
ambientBus = createMemoryEventBus();
|
|
169
|
+
}
|
|
170
|
+
|
|
146
171
|
/** The one function app code calls to unblock a waiting step. */
|
|
147
172
|
export function publishEvent(
|
|
148
173
|
name: string,
|