@ultimat3/jobs 1.2.0 → 3.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 (47) hide show
  1. package/CLAUDE.md +660 -0
  2. package/README.md +432 -17
  3. package/package.json +7 -5
  4. package/src/backfill-gate.ts +97 -0
  5. package/src/backfill-inspect.ts +73 -0
  6. package/src/backfill-ledger.ts +183 -0
  7. package/src/backfill-pass.ts +276 -0
  8. package/src/backfill-pending.ts +131 -0
  9. package/src/backfill-rate.ts +109 -0
  10. package/src/backfill-registry.ts +108 -0
  11. package/src/backfill-scope.ts +70 -0
  12. package/src/backfill.ts +213 -0
  13. package/src/driver-memory.ts +61 -9
  14. package/src/driver-nats.ts +2 -1
  15. package/src/driver-pg-ddl.ts +191 -0
  16. package/src/driver-pg-rows.ts +123 -0
  17. package/src/driver-pg-sql.ts +312 -55
  18. package/src/driver-pg.ts +138 -92
  19. package/src/driver-redis.ts +2 -1
  20. package/src/driver.ts +91 -7
  21. package/src/errors.ts +314 -5
  22. package/src/events-pg.ts +121 -0
  23. package/src/events.ts +7 -1
  24. package/src/execute.ts +308 -0
  25. package/src/heartbeat.ts +148 -0
  26. package/src/index.ts +128 -27
  27. package/src/inspect.ts +43 -2
  28. package/src/job.ts +127 -3
  29. package/src/leases.ts +90 -0
  30. package/src/limits.ts +0 -0
  31. package/src/metrics.ts +35 -0
  32. package/src/outbox-lease.ts +29 -0
  33. package/src/outbox-pg.ts +188 -0
  34. package/src/outbox.ts +204 -59
  35. package/src/register.ts +1 -1
  36. package/src/renewal-timer.ts +35 -0
  37. package/src/retry-classification.ts +112 -0
  38. package/src/retry.ts +6 -1
  39. package/src/run-signal.ts +50 -0
  40. package/src/scheduler-pg.ts +103 -0
  41. package/src/scheduler.ts +159 -245
  42. package/src/steps.ts +155 -31
  43. package/src/task.ts +239 -0
  44. package/src/tenant.ts +61 -0
  45. package/src/worker-fleet-slots.ts +129 -0
  46. package/src/worker-run.ts +132 -0
  47. package/src/worker.ts +207 -190
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,28 @@ 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_RELEASE,
111
+ SQL_OUTBOX_STAGE,
112
+ SQL_OUTBOX_TABLE,
113
+ SQL_SCHEDULER_STATE_GET,
114
+ SQL_SCHEDULER_STATE_MARK,
49
115
  SQL_STATS,
50
116
  SQL_STEP_GET,
51
117
  SQL_STEP_PUT,
@@ -55,20 +121,38 @@ export type { RedisDriverOptions } from './driver-redis';
55
121
  export { createRedisDriver } from './driver-redis';
56
122
  export type { JobErrorCode } from './errors';
57
123
  export {
124
+ BackfillAppliedError,
125
+ BackfillEnvironmentError,
126
+ BackfillMigrationPendingError,
127
+ BackfillPendingError,
128
+ BackfillRunningError,
129
+ BackfillStalledError,
130
+ BackfillUnknownError,
131
+ CancelUnsupportedError,
132
+ ConcurrencyUnenforceableError,
58
133
  DriverUnavailableError,
59
134
  IdempotencyRequiredError,
60
135
  JOB_ERROR_CODES,
61
136
  JOB_ERROR_TITLES,
137
+ JobAbortedError,
62
138
  JobDuplicateError,
63
139
  JobMaxAttemptsError,
64
140
  JobNameTakenError,
141
+ JobNotCancellableError,
142
+ JobSlotLostError,
65
143
  JobsNotImplementedError,
144
+ JobTenantRequiredError,
66
145
  JobTimeoutError,
146
+ LeaseLostError,
67
147
  OutboxNoTxError,
68
148
  StepDuplicateError,
69
149
  } from './errors';
70
150
  export type { EventBus, JobEvent, MemoryEventBusOptions, PublishOptions } from './events';
71
151
  export { createMemoryEventBus, eventBus, publishEvent, setEventBus } from './events';
152
+ export type { PgEventBusOptions } from './events-pg';
153
+ export { createPgEventBus } from './events-pg';
154
+ export type { ExecuteJobOptions, JobExecution, JobOutcome } from './execute';
155
+ export { executeJob } from './execute';
72
156
  export type {
73
157
  DeadLetterEntry,
74
158
  JobsManifest,
@@ -77,6 +161,7 @@ export type {
77
161
  StepTrace,
78
162
  } from './inspect';
79
163
  export {
164
+ cancelJob,
80
165
  inspectDeadLetters,
81
166
  inspectJob,
82
167
  inspectJobList,
@@ -86,6 +171,8 @@ export {
86
171
  } from './inspect';
87
172
  export type { AnyJobHandle, JobActor, JobDefinition, JobHandle, JobRunArgs } from './job';
88
173
  export { describeJobs, getJob, isJobHandle, job, registeredJobs, resetJobs } from './job';
174
+ export type { HeldLease, LeaseStore, MemoryLeaseStoreOptions } from './leases';
175
+ export { createMemoryLeaseStore, jobLeaseKey } from './leases';
89
176
  export type {
90
177
  Lease,
91
178
  LimitConfig,
@@ -96,9 +183,17 @@ export type {
96
183
  RateLimit,
97
184
  } from './limits';
98
185
  export { createLimiter, NO_TENANT, tenantKeyFrom } from './limits';
186
+ export {
187
+ queueDeadJobs,
188
+ queueOldestReady,
189
+ recordQueueDeadJobs,
190
+ recordQueueOldestReady,
191
+ } from './metrics';
99
192
  export type {
100
193
  EnqueueOptions,
101
194
  JobsFacade,
195
+ MemoryOutboxOptions,
196
+ MemoryOutboxStore,
102
197
  OutboxDeps,
103
198
  OutboxRecord,
104
199
  OutboxRelay,
@@ -112,39 +207,34 @@ export {
112
207
  enqueueInTx,
113
208
  jobsFacade,
114
209
  resetJobsFacade,
115
- SQL_OUTBOX_CLAIM,
116
- SQL_OUTBOX_MARK_PUBLISHED,
117
- SQL_OUTBOX_STAGE,
118
- SQL_OUTBOX_TABLE,
119
210
  setJobsFacade,
120
211
  } from './outbox';
212
+ // One definition of the lease, consumed by both stores — a memory default and a pg default that
213
+ // could drift are two answers to "how long is a claim mine for", and the shorter one duplicates.
214
+ export { DEFAULT_OUTBOX_CLAIM_LEASE_MS } from './outbox-lease';
215
+ export type { PgOutboxOptions } from './outbox-pg';
216
+ export { createPgOutboxStore } from './outbox-pg';
121
217
 
122
218
  export type { BackoffStrategy, Random, RetryDecision, RetryPolicy } from './retry';
123
219
  export { backoffDelayMs, DEFAULT_RETRY, nextRetry, retrySchedule } from './retry';
220
+ export type { JobRetryDecision, JobStopReason } from './retry-classification';
221
+ export { classifyThrown, nextRetryForError } from './retry-classification';
124
222
  export type {
125
- CatchUpPolicy,
126
223
  CronResolver,
127
224
  DispatchedOccurrence,
128
225
  LeaderElection,
129
226
  Scheduler,
130
227
  SchedulerOptions,
131
228
  SchedulerState,
132
- TaskDefinition,
133
- TaskDescriptor,
134
- TaskEnqueueEntry,
135
- TaskHandle,
136
- TaskJobResult,
137
229
  } from './scheduler';
230
+ export { createMemorySchedulerState, createScheduler, soleLeader } from './scheduler';
231
+ export type { PgLeaseLeaderOptions } from './scheduler-pg';
138
232
  export {
139
- createMemorySchedulerState,
140
- createScheduler,
141
- getTask,
142
- isTaskHandle,
143
- registeredTasks,
144
- resetTasks,
145
- soleLeader,
146
- task,
147
- } from './scheduler';
233
+ createPgLeaseLeader,
234
+ currentLeader,
235
+ DEFAULT_LEADER_TTL_MS,
236
+ pgSchedulerState,
237
+ } from './scheduler-pg';
148
238
  export type {
149
239
  EventLookup,
150
240
  StepApi,
@@ -159,14 +249,25 @@ export {
159
249
  createMemoryStepStore,
160
250
  createStepRunner,
161
251
  isStepSuspension,
252
+ MAX_TRACE_NAMES,
162
253
  StepSuspension,
163
254
  } from './steps';
164
255
  export type {
165
- ExecuteJobOptions,
166
- JobExecution,
167
- JobOutcome,
168
- Worker,
169
- WorkerOptions,
170
- WorkerStats,
171
- } from './worker';
172
- export { createWorker, executeJob } from './worker';
256
+ CatchUpPolicy,
257
+ TaskDefinition,
258
+ TaskDescriptor,
259
+ TaskEnqueueEntry,
260
+ TaskHandle,
261
+ TaskJobResult,
262
+ } from './task';
263
+ export { getTask, isTaskHandle, registeredTasks, resetTasks, task } from './task';
264
+ /**
265
+ * The tenant a job's body runs under. The TYPE only: `NO_JOB_TENANT`, `jobRunActor` and
266
+ * `jobTenantFor` stay unexported. The first would be a second spelling of `'none'` (axiom 1 — the
267
+ * literal is what the type says and what a declaration reads as), and the other two are
268
+ * `executeJob`'s and `job()`'s: a second caller deriving a run's org would be a second answer to
269
+ * "whose tenant is this", which is the thing this declaration exists to make singular.
270
+ */
271
+ export type { JobTenant } from './tenant';
272
+ export type { Worker, WorkerOptions, WorkerStats } from './worker';
273
+ export { createWorker } from './worker';
package/src/inspect.ts CHANGED
@@ -2,13 +2,15 @@
2
2
  // object so `x jobs ... --json` and the MCP tool share one shape — an agent debugging a stuck
3
3
  // queue reads exactly what the dashboard renders.
4
4
 
5
+ import type { BackfillProgress } from './backfill-inspect';
6
+ import { backfillForRun } from './backfill-inspect';
5
7
  import type { JobDriver, JobFilter, JobRecord, QueueStats } from './driver';
6
- import { JobsNotImplementedError } from './errors';
8
+ import { CancelUnsupportedError, JobNotCancellableError, JobsNotImplementedError } from './errors';
7
9
  import { registeredJobs } from './job';
8
10
  import { retrySchedule } from './retry';
9
11
  import type { Scheduler } from './scheduler';
10
- import { registeredTasks } from './scheduler';
11
12
  import type { StepRecord } from './steps';
13
+ import { registeredTasks } from './task';
12
14
 
13
15
  export interface QueueDepthReport {
14
16
  readonly driver: string;
@@ -67,9 +69,19 @@ export interface JobTrace {
67
69
  readonly runAt: string;
68
70
  readonly lastError: string | null;
69
71
  readonly tenantId: string | null;
72
+ /** W3C `traceparent` of the request that queued it — paste it into the trace viewer. */
73
+ readonly traceparent: string | null;
74
+ /** Actor id of whoever asked. Audit only: the body ran with system authority. */
75
+ readonly enqueuedBy: string | null;
70
76
  readonly steps: readonly StepTrace[];
71
77
  /** Remaining retry delays in ms, jitter excluded. */
72
78
  readonly retryDelaysMs: readonly number[];
79
+ /**
80
+ * The `x_backfills` row this run wrote, when the job is a `backfill()`. `null` for every other
81
+ * job and for a driver with no ledger — a step trace says which batch is next, and this says how
82
+ * many rows the pass has actually put behind it.
83
+ */
84
+ readonly backfill: BackfillProgress | null;
73
85
  }
74
86
 
75
87
  const iso = (ms: number | undefined): string | null =>
@@ -103,6 +115,7 @@ export async function inspectJob(driver: JobDriver, jobId: string): Promise<JobT
103
115
  if (record === undefined) return undefined;
104
116
  const steps = await driver.steps.list(record.runId);
105
117
  const handle = registeredJobs().find((candidate) => candidate.name === record.name);
118
+ const backfill = await backfillForRun(driver, record.runId);
106
119
  return {
107
120
  id: record.id,
108
121
  name: record.name,
@@ -115,8 +128,11 @@ export async function inspectJob(driver: JobDriver, jobId: string): Promise<JobT
115
128
  runAt: new Date(record.runAt).toISOString(),
116
129
  lastError: record.lastError ?? null,
117
130
  tenantId: record.tenantId ?? null,
131
+ traceparent: record.traceparent ?? null,
132
+ enqueuedBy: record.enqueuedBy ?? null,
118
133
  steps: steps.map(toStepTrace),
119
134
  retryDelaysMs: handle === undefined ? [] : [...retrySchedule(handle.retry)],
135
+ backfill: backfill ?? null,
120
136
  };
121
137
  }
122
138
 
@@ -169,6 +185,31 @@ export async function retryFromStep(
169
185
  return inspectJob(driver, jobId);
170
186
  }
171
187
 
188
+ /**
189
+ * Stop a job from outside — the surface `x jobs cancel <id>` binds to. The hard half was already
190
+ * built: `execute.ts` cancels the attempt and `steps.ts` fences every write behind that signal.
191
+ * What was missing was anything that could TRIGGER it, so a runaway backfill against production
192
+ * left two options: scale the worker to zero (stopping every job) or `UPDATE x_jobs` by hand,
193
+ * which the worker's next ack overwrote because `SQL_ACK` had no state guard.
194
+ *
195
+ * Refuses loudly rather than answering "nothing happened": an operator cancelling a 40M-row sweep
196
+ * has to know whether they stopped it or missed it.
197
+ */
198
+ export async function cancelJob(
199
+ driver: JobDriver,
200
+ jobId: string,
201
+ reason?: string,
202
+ ): Promise<JobTrace | undefined> {
203
+ const introspect = requireIntrospection(driver);
204
+ if (introspect.cancel === undefined) throw new CancelUnsupportedError({ driver: driver.name });
205
+ const record = await introspect.cancel(jobId, reason);
206
+ if (record === undefined) {
207
+ const current = await introspect.job(jobId);
208
+ throw new JobNotCancellableError({ jobId, state: current?.state ?? 'missing' });
209
+ }
210
+ return inspectJob(driver, jobId);
211
+ }
212
+
172
213
  export interface JobsManifest {
173
214
  readonly jobs: readonly {
174
215
  readonly name: string;
package/src/job.ts CHANGED
@@ -24,10 +24,18 @@ import { jobsFacade } from './outbox';
24
24
  import type { RetryPolicy } from './retry';
25
25
  import { DEFAULT_RETRY } from './retry';
26
26
  import type { StepApi } from './steps';
27
+ import type { JobTenant } from './tenant';
28
+ import { assertJobTenant, jobTenantFor } from './tenant';
27
29
 
28
30
  export interface JobRunArgs<I> {
29
31
  readonly input: I;
30
32
  readonly step: StepApi;
33
+ /**
34
+ * `ctx.signal` aborts when this attempt's `timeout` passes — the same seam an action reads, so
35
+ * `throwIfAborted(ctx)` and `fetch(url, { signal: ctx.signal })` work here unchanged. Past it
36
+ * the run belongs to whoever claims it next and `step.run` refuses to write, so a loop that
37
+ * never checks it is a body running beside its own retry.
38
+ */
31
39
  readonly ctx: Ctx;
32
40
  /** 1-based. Assume at-least-once: never branch on `attempt === 1` for correctness. */
33
41
  readonly attempt: number;
@@ -44,11 +52,50 @@ export interface JobDefinition<I> {
44
52
  readonly input: StandardSchemaV1<unknown, I>;
45
53
  /** REQUIRED. See the file header — this is the whole point. */
46
54
  readonly idempotencyKey: (input: I) => string;
55
+ /**
56
+ * REQUIRED, and the org this job's body runs under. `tenant: (input) => input.orgId` derives it
57
+ * from the payload; `tenant: 'none'` says this job belongs to no tenant, and then every
58
+ * tenant-scoped read inside it fails closed with `X_TENANCY_ACTOR_ORG_REQUIRED`.
59
+ *
60
+ * There is no default, because both candidates are wrong. Until this field existed the worker ran
61
+ * a body with no ambient context at all, so `@ultimat3/entity`'s tenant guard — which derives
62
+ * from `tryUseContext()` and not from the ctx it is handed — read no actor, added no predicate
63
+ * and accepted a caller-named `orgId` unchecked: the same write refused over HTTP as
64
+ * `X_TENANCY_ACTOR_MISMATCH` was ACCEPTED through the job surface. A boot-supplied service actor
65
+ * would close that with ONE identity for every job, which is a cross-tenant read waiting for the
66
+ * first job that takes an org id in its input. So the job declares it, per job, from its own
67
+ * payload — the value an author already had to pass anyway.
68
+ */
69
+ readonly tenant: JobTenant<I>;
47
70
  readonly retry: RetryPolicy;
48
71
  readonly queue?: string;
49
- /** Max in-flight runs of THIS job across the fleet. Omit for the queue-wide cap. */
72
+ /**
73
+ * Max in-flight runs of THIS job across the fleet. Omit for the queue-wide cap.
74
+ *
75
+ * Enforced by `JobDriver.leases` — a row every replica can see — and NOT by `limits.ts`, which
76
+ * counts one process's heap. A driver with no lease store cannot hold this cap, so
77
+ * `createWorker().start()` refuses to boot rather than let it pass silently
78
+ * (`X_JOB_CONCURRENCY_UNENFORCEABLE`): this field was declared, documented and in the manifest
79
+ * while nothing read it, which is exactly what axiom 3 exists to prevent.
80
+ */
50
81
  readonly concurrency?: number;
51
82
  readonly timeout?: DurationInput;
83
+ /**
84
+ * Ceiling for ONE `step.run`, where `timeout` is the ceiling for the whole attempt. Folded into
85
+ * the signal the step body is handed, so a body reads one signal and sees whichever deadline
86
+ * lands first — and it ABORTS before it rejects, because the attempt that replaces this one is
87
+ * claimable the moment the nack lands.
88
+ *
89
+ * Declared here and nowhere else: the runner has implemented this ceiling since 1.0 and no
90
+ * declaration could ask for it, which is a documented guarantee that does nothing.
91
+ */
92
+ readonly stepTimeout?: DurationInput;
93
+ /**
94
+ * How long a `step.waitForEvent` parks between polls. Default 30s. Lower it for a wait a user
95
+ * is watching; the step suspends for exactly this long each time, so it is also the resolution
96
+ * of the resume, never a busy loop.
97
+ */
98
+ readonly eventPoll?: DurationInput;
52
99
  run(args: JobRunArgs<I>): Promise<unknown>;
53
100
  }
54
101
 
@@ -58,6 +105,18 @@ export interface JobDefinition<I> {
58
105
  */
59
106
  export interface JobActor {
60
107
  readonly orgId?: string | undefined;
108
+ /**
109
+ * The actor's id, recorded as `enqueuedBy` — ATTRIBUTION, never authority.
110
+ *
111
+ * The framework picks one answer to "whose permissions does a job run with" (axiom 1) and it is
112
+ * this: a job body runs with SYSTEM authority and this is an audit column. Impersonating the
113
+ * enqueuer at claim time is the defensible alternative and is rejected for one reason — a job
114
+ * that sleeps three days, or dead-letters and is retried next quarter, would then act as
115
+ * somebody whose role, org membership or employment has changed since. `02-primitives.md`
116
+ * already frames a job as server-authoritative work. A job that must act FOR a user takes that
117
+ * user's id in its input and re-authorises it in the body, where the check is visible in review.
118
+ */
119
+ readonly id?: string | undefined;
61
120
  }
62
121
 
63
122
  /**
@@ -71,9 +130,20 @@ export interface JobHandle<I = unknown> {
71
130
  readonly retry: RetryPolicy;
72
131
  readonly concurrency: number | undefined;
73
132
  readonly timeoutMs: number | undefined;
133
+ /** The declared per-step ceiling in ms; `executeJob` hands it to the step runner. */
134
+ readonly stepTimeoutMs: number | undefined;
135
+ /** The declared event-poll interval in ms; `undefined` leaves the runner's 30s default. */
136
+ readonly eventPollMs: number | undefined;
74
137
  readonly input: StandardSchemaV1<unknown, I>;
75
138
  parse(raw: unknown): I;
76
139
  idempotencyKeyFor(input: I): string;
140
+ /**
141
+ * The org THIS payload's run acts under — `undefined` for `tenant: 'none'`. A method and never a
142
+ * `readonly tenant: JobTenant<I>` field: a function-typed property is contravariant in its
143
+ * parameter, so `JobHandle<OrgInput>` would stop being assignable to `AnyJobHandle` and the
144
+ * registry, the worker and a task's enqueue list could no longer hold heterogeneous handles.
145
+ */
146
+ tenantFor(input: I): string | undefined;
77
147
  run(args: JobRunArgs<I>): Promise<unknown>;
78
148
  /**
79
149
  * Put this job on the queue. Joins the caller's transaction when the app installed the
@@ -112,15 +182,48 @@ export function job<I>(definition: JobDefinition<I>): JobHandle<I> {
112
182
  anonymous += 1;
113
183
  const name = definition.name ?? `anonymous-job-${anonymous}`;
114
184
 
115
- // Runtime backstop for generated code and JS callers; TS already forbids omitting it.
185
+ // Runtime backstops for generated code and JS callers; TS already forbids omitting either.
116
186
  if (typeof definition.idempotencyKey !== 'function') {
117
187
  throw new IdempotencyRequiredError({ job: name });
118
188
  }
189
+ assertJobTenant(name, definition.tenant);
119
190
  assert(
120
191
  definition.retry.attempts >= 1,
121
192
  `job "${name}" needs retry.attempts >= 1, got ${String(definition.retry.attempts)}`,
122
193
  `set retry: { attempts: 1 } or higher on job("${name}") — 0 attempts means the job is never executed at all, not that it never retries`,
123
194
  );
195
+ // `concurrency: 0` is not "no cap" — it is a fleet slot table that grants nothing.
196
+ // `createFleetSlots.acquire` reads `limit === undefined` as uncapped, so a declared `0` reaches
197
+ // `leases.acquire(key, 0, …)`, answers `false` forever with no log line, and the job is
198
+ // permanently unrunnable. Refused where it is written, the way `createPacer` refuses `rate: 0`.
199
+ assert(
200
+ definition.concurrency === undefined ||
201
+ (Number.isInteger(definition.concurrency) && definition.concurrency >= 1),
202
+ `job "${name}" declares concurrency ${String(definition.concurrency)}, which no worker can ever fill`,
203
+ `set a whole concurrency of 1 or more on job("${name}"), or omit the field for no cap at all`,
204
+ );
205
+
206
+ const stepTimeoutMs =
207
+ definition.stepTimeout === undefined ? undefined : toMs(definition.stepTimeout);
208
+ const eventPollMs = definition.eventPoll === undefined ? undefined : toMs(definition.eventPoll);
209
+ // `withStepTimeout` reads `<= 0` as "no ceiling at all" and a poll of zero is a suspension that
210
+ // resumes immediately, forever. Both are an author who asked for a limit and got the opposite,
211
+ // so they are refused where they are written — the same answer `concurrency: 0` gets.
212
+ //
213
+ // FINITE, not merely positive: `> 0` admits `Infinity`, which is the same defect spelled the
214
+ // other way. `eventPoll: Infinity` parks a waiting step and schedules the poll that would wake
215
+ // it for never; `stepTimeout: Infinity` is a ceiling no step can reach. `NaN` fails `> 0` on its
216
+ // own, and is covered here so the predicate says what it means rather than passing by accident.
217
+ assert(
218
+ stepTimeoutMs === undefined || (Number.isFinite(stepTimeoutMs) && stepTimeoutMs > 0),
219
+ `job "${name}" declares stepTimeout ${String(definition.stepTimeout)}, which is no ceiling at all`,
220
+ `set a finite positive stepTimeout on job("${name}") — "30s" or 30_000 — or omit the field for no per-step ceiling`,
221
+ );
222
+ assert(
223
+ eventPollMs === undefined || (Number.isFinite(eventPollMs) && eventPollMs > 0),
224
+ `job "${name}" declares eventPoll ${String(definition.eventPoll)}, which parks a waiting step for no time at all`,
225
+ `set a finite positive eventPoll on job("${name}") — "5s" or 5_000 — or omit the field for the 30s default`,
226
+ );
124
227
 
125
228
  const handle: JobHandle<I> = {
126
229
  kind: 'job',
@@ -129,6 +232,8 @@ export function job<I>(definition: JobDefinition<I>): JobHandle<I> {
129
232
  retry: { ...DEFAULT_RETRY, ...definition.retry },
130
233
  concurrency: definition.concurrency,
131
234
  timeoutMs: definition.timeout === undefined ? undefined : toMs(definition.timeout),
235
+ stepTimeoutMs,
236
+ eventPollMs,
132
237
  input: definition.input,
133
238
  parse(raw: unknown): I {
134
239
  return parse(definition.input, raw) as I;
@@ -142,6 +247,9 @@ export function job<I>(definition: JobDefinition<I>): JobHandle<I> {
142
247
  );
143
248
  return key;
144
249
  },
250
+ tenantFor(input: I): string | undefined {
251
+ return jobTenantFor(name, definition.tenant, input);
252
+ },
145
253
  run(args: JobRunArgs<I>): Promise<unknown> {
146
254
  return definition.run(args);
147
255
  },
@@ -152,9 +260,15 @@ export function job<I>(definition: JobDefinition<I>): JobHandle<I> {
152
260
  const tenantId = options.tenantId ?? tenantFor(actor);
153
261
  // `NO_TENANT` is the limiter's own bucket for an absent tenant, so leaving the column
154
262
  // empty is the same limit and one less fake org id on the row.
263
+ //
264
+ // `enqueuedBy` is the actor's id and NOTHING ELSE crosses: the body runs with system
265
+ // authority, so this is an audit column, not a principal. See `JobActor.id` for why the
266
+ // framework chose attribution over impersonation.
267
+ const enqueuedBy = options.enqueuedBy ?? actor?.id;
155
268
  return handle.enqueue(input, {
156
269
  ...options,
157
270
  ...(tenantId === NO_TENANT ? {} : { tenantId }),
271
+ ...(enqueuedBy === undefined ? {} : { enqueuedBy }),
158
272
  });
159
273
  },
160
274
  // Reads `handle`, never the captured `name`: `nameJobs()` rebinds the property in place.
@@ -241,8 +355,18 @@ export function getJob(name: string): AnyJobHandle | undefined {
241
355
  return registry.get(name);
242
356
  }
243
357
 
358
+ /**
359
+ * Code-unit compare, never `localeCompare`. This list is projected into `x.manifest.json`, which
360
+ * both tracked apps COMMIT and `x verify`'s drift step diffs byte for byte — and `localeCompare`
361
+ * with no locale argument answers from the runtime's ICU default and collation version, so the
362
+ * same source could sort two ways on two machines. `@ultimat3/http`'s `describeRoutes` states the
363
+ * same rule; the comparator is restated rather than imported because `http` is not below this
364
+ * package on the tier table.
365
+ */
366
+ const byName = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
367
+
244
368
  export function registeredJobs(): readonly AnyJobHandle[] {
245
- return [...registry.values()].sort((a, b) => a.name.localeCompare(b.name));
369
+ return [...registry.values()].sort((a, b) => byName(a.name, b.name));
246
370
  }
247
371
 
248
372
  export function resetJobs(): void {
package/src/leases.ts ADDED
@@ -0,0 +1,90 @@
1
+ // Fleet-wide slot counting: the seam that makes `job.concurrency` true. `limits.ts` counts slots
2
+ // in ONE process's heap, so `perTenant: 2` on twenty pods is forty concurrent runs — the number a
3
+ // downstream API rate-limits you for. A lease is a row somewhere every replica can see, held for
4
+ // a TTL and renewed by the same heartbeat that renews the visibility lease, so a killed worker
5
+ // gives its slot back by expiry rather than by cleanup nobody runs.
6
+
7
+ import type { Clock } from '@ultimat3/core';
8
+ import { systemClock } from '@ultimat3/core';
9
+ import { nowMs } from './clock';
10
+
11
+ /** A granted slot. `slot` plus `holder` is what renew and release are addressed by. */
12
+ export interface HeldLease {
13
+ readonly key: string;
14
+ readonly slot: number;
15
+ readonly holder: string;
16
+ }
17
+
18
+ export interface LeaseStore {
19
+ /**
20
+ * Take a slot under `limit` for `key`, or answer `undefined`. Never over-grants; under
21
+ * contention it may refuse a slot that is genuinely free, which costs one poll interval.
22
+ */
23
+ acquire(
24
+ key: string,
25
+ limit: number,
26
+ ttlMs: number,
27
+ holder: string,
28
+ ): Promise<HeldLease | undefined>;
29
+ /** Push the expiry out. `false` means the slot is no longer this holder's. */
30
+ renew(lease: HeldLease, ttlMs: number): Promise<boolean>;
31
+ release(lease: HeldLease): Promise<void>;
32
+ /** Live slots for `key`. Diagnostics only — never the acquire decision, which must be atomic. */
33
+ held(key: string): Promise<number>;
34
+ }
35
+
36
+ export interface MemoryLeaseStoreOptions {
37
+ readonly clock?: Clock;
38
+ }
39
+
40
+ /**
41
+ * The `x dev` / test implementation. One heap, so it is not a fleet gate — it exists so the
42
+ * memory driver enforces `concurrency` with the same code path the pg driver does, and so the
43
+ * "a second worker is refused" test is a real test rather than a pg-only one.
44
+ */
45
+ export function createMemoryLeaseStore(options: MemoryLeaseStoreOptions = {}): LeaseStore {
46
+ const clock = options.clock ?? systemClock;
47
+ const slots = new Map<string, Map<number, { holder: string; expiresAt: number }>>();
48
+
49
+ const live = (key: string): Map<number, { holder: string; expiresAt: number }> => {
50
+ const at = nowMs(clock);
51
+ const held = slots.get(key) ?? new Map();
52
+ for (const [slot, entry] of held) if (entry.expiresAt <= at) held.delete(slot);
53
+ slots.set(key, held);
54
+ return held;
55
+ };
56
+
57
+ return {
58
+ acquire(key, limit, ttlMs, holder) {
59
+ const held = live(key);
60
+ for (let slot = 0; slot < limit; slot += 1) {
61
+ if (held.has(slot)) continue;
62
+ held.set(slot, { holder, expiresAt: nowMs(clock) + ttlMs });
63
+ return Promise.resolve({ key, slot, holder });
64
+ }
65
+ return Promise.resolve(undefined);
66
+ },
67
+ renew(lease, ttlMs) {
68
+ const entry = live(lease.key).get(lease.slot);
69
+ if (entry === undefined || entry.holder !== lease.holder) return Promise.resolve(false);
70
+ entry.expiresAt = nowMs(clock) + ttlMs;
71
+ return Promise.resolve(true);
72
+ },
73
+ release(lease) {
74
+ const held = slots.get(lease.key);
75
+ const entry = held?.get(lease.slot);
76
+ // Only the holder gives it back: releasing a slot the TTL already handed to someone else
77
+ // would let two runs share it, which is the whole failure this store exists to prevent.
78
+ if (entry !== undefined && entry.holder === lease.holder) held?.delete(lease.slot);
79
+ return Promise.resolve();
80
+ },
81
+ held(key) {
82
+ return Promise.resolve(live(key).size);
83
+ },
84
+ };
85
+ }
86
+
87
+ /** The lease key for a job's own fleet-wide cap. One shape, so pg and memory agree on it. */
88
+ export function jobLeaseKey(jobName: string): string {
89
+ return `job:${jobName}`;
90
+ }
package/src/limits.ts CHANGED
Binary file
package/src/metrics.ts ADDED
@@ -0,0 +1,35 @@
1
+ // The two queue gauges an alert can actually be written against. `queue_depth` and `jobs_total`
2
+ // live in `@ultimat3/core`'s `runtime-metrics.ts` because the deploy chart scales on them; these
3
+ // two are not scaling signals, they are the ones every queue team pages on, and neither existed:
4
+ //
5
+ // "page if the oldest job in payments is older than 5 minutes" — no series carried
6
+ // `oldestReadyMs`, which `QueueStats` computes, `inspectQueues` renders and nothing published.
7
+ // `queue_depth` cannot tell 10 jobs stuck for an hour from 10 enqueued a second ago.
8
+ // "page if the dead-letter queue is not empty" — `jobs_total{outcome="dead"}` is a COUNTER, so
9
+ // a DLQ that filled overnight and stopped growing has a flat rate and alerts on nothing.
10
+ //
11
+ // Declared here rather than in core because they are this package's facts and core is another
12
+ // package's file; if they move next to `queueDepth` later, these two functions are the only
13
+ // callers to redirect.
14
+
15
+ import type { Gauge } from '@ultimat3/core';
16
+ import { gauge } from '@ultimat3/core';
17
+
18
+ /** Seconds and not milliseconds: every Prometheus duration is seconds, and the alert is `> 300`. */
19
+ export const queueOldestReady: Gauge = gauge('queue_oldest_ready_seconds', {
20
+ unit: 's',
21
+ description: 'Age of the oldest claimable job, by queue — 0 when the queue is empty',
22
+ });
23
+
24
+ export const queueDeadJobs: Gauge = gauge('queue_dead_jobs', {
25
+ unit: '{job}',
26
+ description: 'Jobs sitting in the dead-letter state, by queue',
27
+ });
28
+
29
+ export function recordQueueOldestReady(queue: string, oldestReadyMs: number): void {
30
+ queueOldestReady.record(oldestReadyMs / 1000, { queue });
31
+ }
32
+
33
+ export function recordQueueDeadJobs(queue: string, dead: number): void {
34
+ queueDeadJobs.record(dead, { queue });
35
+ }