@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.
Files changed (63) hide show
  1. package/CLAUDE.md +129 -87
  2. package/README.md +329 -19
  3. package/package.json +6 -6
  4. package/src/concurrency.ts +144 -0
  5. package/src/counters.ts +105 -0
  6. package/src/describe.ts +29 -0
  7. package/src/driver-memory-operator.ts +297 -0
  8. package/src/driver-memory.ts +79 -37
  9. package/src/driver-nats.ts +2 -2
  10. package/src/driver-pg-ddl.ts +71 -0
  11. package/src/driver-pg-jobs-sql.ts +71 -8
  12. package/src/driver-pg-operator-sql.ts +303 -0
  13. package/src/driver-pg-operator.ts +300 -0
  14. package/src/driver-pg-outbox-sql.ts +110 -0
  15. package/src/driver-pg-rows.ts +31 -0
  16. package/src/driver-pg-settle-sql.ts +71 -0
  17. package/src/driver-pg-sql.ts +80 -129
  18. package/src/driver-pg-wake-sql.ts +76 -0
  19. package/src/driver-pg.ts +74 -34
  20. package/src/driver-redis.ts +2 -2
  21. package/src/driver.ts +96 -34
  22. package/src/enqueue-signal.ts +50 -0
  23. package/src/errors-concurrency.ts +91 -0
  24. package/src/errors-operator.ts +94 -0
  25. package/src/errors.ts +13 -17
  26. package/src/events-pg.ts +38 -28
  27. package/src/events.ts +25 -0
  28. package/src/execute.ts +160 -81
  29. package/src/export.ts +23 -4
  30. package/src/heartbeat.ts +9 -3
  31. package/src/idle-backoff.ts +74 -0
  32. package/src/index.ts +105 -12
  33. package/src/inspect-operator.ts +49 -0
  34. package/src/inspect.ts +42 -2
  35. package/src/introspection.ts +262 -0
  36. package/src/job.ts +78 -15
  37. package/src/leases.ts +49 -9
  38. package/src/metrics.ts +6 -1
  39. package/src/outbox-pg.ts +3 -0
  40. package/src/outbox-relay.ts +114 -39
  41. package/src/outbox.ts +29 -6
  42. package/src/progress.ts +78 -0
  43. package/src/queue-wake.ts +220 -0
  44. package/src/redact-input.ts +26 -0
  45. package/src/renewal-timer.ts +28 -10
  46. package/src/retry-classification.ts +13 -1
  47. package/src/retry.ts +10 -2
  48. package/src/run-deadline.ts +57 -0
  49. package/src/scheduler-leader.ts +26 -0
  50. package/src/scheduler-occurrences.ts +76 -0
  51. package/src/scheduler-pg.ts +55 -2
  52. package/src/scheduler-state.ts +66 -0
  53. package/src/scheduler.ts +143 -103
  54. package/src/settled.ts +161 -0
  55. package/src/worker-admit.ts +163 -0
  56. package/src/worker-fleet-slots.ts +107 -38
  57. package/src/worker-hand-back.ts +6 -1
  58. package/src/worker-key-busy.ts +79 -0
  59. package/src/worker-loop.ts +145 -0
  60. package/src/worker-registry.ts +103 -0
  61. package/src/worker-run.ts +9 -0
  62. package/src/worker-types.ts +23 -0
  63. 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
- /** Reuse an existing run id when resuming, so step history is preserved. */
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
- ack(jobId: string): Promise<void>;
220
- nack(jobId: string, options: NackOptions): Promise<void>;
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 required field',
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 type { Clock } from '@ultimat3/core';
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, nowMs } from './clock';
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, JobEvent } from './events';
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 at = nowMs(clock);
73
- const event: JobEvent = {
74
- id: uuid(),
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
- publishedAt: at,
78
- expiresAt:
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
- return event;
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,