@ultimat3/jobs 1.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @ultimat3/jobs ⚙️
2
2
 
3
- Durable background work. Steps that replay, a transactional outbox that is on by default,
4
- and one driver interface so Postgres → Redis → NATS is a config line.
3
+ Durable background work. Steps that replay, a transactional outbox, and one driver interface so
4
+ a job's code never names a backend.
5
5
 
6
6
  ```ts
7
7
  import { job, t } from '@ultimat3/jobs';
@@ -9,6 +9,7 @@ import { job, t } from '@ultimat3/jobs';
9
9
  export const onboardOrg = job({
10
10
  input: t.object({ orgId: t.uuid }),
11
11
  idempotencyKey: ({ orgId }) => `onboard:${orgId}`, // REQUIRED by the type
12
+ tenant: ({ orgId }) => orgId, // REQUIRED by the type — or tenant: 'none'
12
13
  retry: { attempts: 5, backoff: 'exponential' },
13
14
  async run({ input, step, ctx }) {
14
15
  const org = await step.run('provision', () => ctx.orgs.provision(input.orgId));
@@ -90,11 +91,37 @@ usually "nobody thought about it", and the bug — two charges, two welcome emai
90
91
  provisioned orgs — shows up in production under load and never in a test. There is no way
91
92
  to define a job in Ultimate that cannot be deduped.
92
93
 
94
+ ## `tenant` is required by the type
95
+
96
+ The same shape, for the same class of bug. A job body runs with no request behind it, so nothing
97
+ can read the acting org off a caller — and `@ultimat3/entity`'s tenant guard derives from the
98
+ **ambient** context, so a job that declared nothing used to run with no tenant at all: a row naming
99
+ another org was refused over HTTP as `X_TENANCY_ACTOR_MISMATCH` and accepted through the queue.
100
+
101
+ ```ts
102
+ tenant: (input) => input.orgId // the run acts under this org
103
+ tenant: 'none' // this job belongs to no tenant
104
+ ```
105
+
106
+ `executeJob` derives the org, puts it on the run's actor and installs that context, so
107
+ `ctx.actor.orgId` inside the body is the tenant the job declared — and every tenant-scoped read and
108
+ write is checked against it, exactly as it is on every other surface. `'none'` carries **no** org,
109
+ so a tenant-scoped read inside such a `job()` is `X_TENANCY_ACTOR_ORG_REQUIRED`: a job body that
110
+ genuinely spans tenants says so with `crossTenant(reason, fn)` around its own reads. (A
111
+ `backfill()` is the one exception, and it is not a loophole — its `source` is a lazy chain, so the
112
+ author has nothing to wrap and the pass opens the scope itself. See the backfill section below.)
113
+ Omitting the field is a type error, and
114
+ `X_JOB_TENANT_REQUIRED` for generated code and JS callers.
115
+
116
+ A single boot-supplied service actor would have closed the same hole with one identity for every
117
+ job in the app — which is a cross-tenant read waiting for the first job that takes an org id in its
118
+ input. The tenant is a fact about the work, so the job declares it.
119
+
93
120
  ## Durable steps
94
121
 
95
122
  | Call | Behaviour |
96
123
  |---|---|
97
- | `step.run(name, fn)` | runs once ever; result persisted before the next step starts |
124
+ | `step.run(name, fn)` | runs once ever; result persisted before the next step starts. `fn` receives an `AbortSignal` |
98
125
  | `step.sleep(name, '3d')` | suspends the run, requeues it for the wake time |
99
126
  | `step.sleep('3d')` | same, step name derived from the duration |
100
127
  | `step.waitForEvent(name, event, { match, timeout })` | suspends until `publishEvent()` matches |
@@ -103,12 +130,197 @@ Step names are the replay key, so they must be deterministic and unique in a run
103
130
  duplicate is `X_STEP_DUPLICATE`, not a silent overwrite. Suspension is control flow
104
131
  (`StepSuspension`), never a failure: it does not burn a retry attempt.
105
132
 
106
- ## The transactional outbox (on by default)
133
+ ## Backfills are jobs
134
+
135
+ `backfill()` is a **factory over `job()`**, not a ninth primitive — one pass over every row a
136
+ chain matches, with the retry policy, the queue, the cancellation and the dead-letter path a job
137
+ already has.
138
+
139
+ ```ts
140
+ import { backfill } from '@ultimat3/jobs';
141
+ import { db } from '@postly/db';
142
+
143
+ export const rewriteSlugs = backfill({
144
+ name: 'rewrite-slugs', // REQUIRED: a durable key
145
+ batch: 1_000, // rows per statement and per step
146
+ rate: 5, // batches/sec — the default
147
+ tenant: 'none', // every tenant — the pass scopes itself
148
+ source: () => db.posts.where({ published: true }),
149
+ async handle({ rows }) {
150
+ await db.posts.upsertAll(rows.map(slugged), { onConflict: ['id'] });
151
+ },
152
+ });
153
+
154
+ await rewriteSlugs.enqueue({}); // it is a JobHandle
155
+ ```
156
+
157
+ The source is read through `inBatches()` — one statement per page, keyset, never OFFSET — and
158
+ each page is handled inside its own `step.run`, named `batch:0`, `batch:1`, … A run killed
159
+ mid-pass therefore **resumes on the page it stopped at**: completed steps replay from storage
160
+ without a statement, and the iteration reopens at the cursor they left behind.
161
+
162
+ | Rule | Why |
163
+ |---|---|
164
+ | the checkpoint is a cursor and a count, never the page | a completed step's output is retained for the whole run — checkpointing rows would hold every processed row until the job ends |
165
+ | `handle` is given no `step` | a step name minted inside it collides with itself on batch 2 (`X_STEP_DUPLICATE`) |
166
+ | `handle` is given a `signal` | the run's deadline composed with the batch's own ceiling — hand it to whatever the body calls |
167
+ | `handle` runs at least once per page | an attempt cancelled between the last row and the checkpoint replays it — write through `upsertAll` / `updateWhere`, never `count + 1` |
168
+ | `idempotencyKey` is the backfill's name | re-enqueueing a live pass is the same pass, not a second writer on one table |
169
+ | `tenant` is required, exactly as on `job()` | a backfill IS a job. `tenant: () => orgId` scopes every page to one org; `tenant: 'none'` sweeps every tenant, and the PASS opens that cross-tenant scope — `source` is a lazy chain, so the author has nothing to wrap |
170
+ | `batch` is refused at declaration | `0`, `1.5` and a `NaN` from an env var fail the build, not the fourth attempt |
171
+ | `rate` throttles, and there is no way off | a sweep shares its pool with the requests the app is still serving; to go faster raise the number |
172
+ | the pause is spent **inside** the step | a resumed attempt replays 500 checkpoints and re-pays none of their pauses |
173
+
174
+ ### The throttle
175
+
176
+ `rate` is batches per second, defaulting to `DEFAULT_BACKFILL_RATE` (5) — 5,000 rows/sec at the
177
+ default batch, one statement every 200ms, so the pool spends the rest of each interval on the
178
+ app. A rate above what the batches can actually achieve produces no wait, which is why there is
179
+ no unthrottled mode to reach for: `rate: 200` is the fast sweep. Fractions are rates too
180
+ (`rate: 0.5` is one batch every two seconds), a rate that is not finite and positive is refused
181
+ where it was written, and the wait unwinds on the run's cancellation (`X_ABORTED`) rather than
182
+ sitting in a timer nobody is waiting for. `rate` is **not** part of the definition checksum, for
183
+ the reason `batch` is not: pacing is tuning, and changing it does not make a completed sweep a
184
+ different sweep.
185
+
186
+ ### The `x_backfills` ledger
187
+
188
+ What has already been swept, the twin of `x_migrations`. It ships in the same DDL as `x_jobs`,
189
+ hangs off the queue driver as `driver.backfills`, and carries one row per **pass**: name,
190
+ definition checksum, status, app version, rows processed, last cursor, started/completed.
191
+
192
+ ```ts
193
+ // The PASS is the no-op, never the enqueue. The one-live-run index covers `ready`/`delayed`/
194
+ // `running`/`suspended`, and a completed job is in none of them — so this creates a real job row,
195
+ // a worker runs it, it reads the ledger and reports what it found.
196
+ const again = await rewriteSlugs.enqueue({}); // → { deduped: false, … }
197
+ // the run's own result:
198
+ // → { name: 'rewrite-slugs', batches: 0, rows: 0, skipped: true, previousRunId: '…' }
199
+
200
+ await rewriteSlugs.enqueue({ force: true }); // sweeps again, into a NEW row
201
+ ```
202
+
203
+ | Rule | Why |
204
+ |---|---|
205
+ | only a **completed** row blocks | a `running` row is this pass resuming, a `failed` one is an attempt the queue is about to retry |
206
+ | `force` writes a new row | reruns are history, never an edit of the row they rerun |
207
+ | a moved checksum **warns** | it hashes function source text, which a bundler moves without behaviour changing — `@ultimat3/db` throws on the same fact because SQL text is what it applied |
208
+ | the row is a report, never a resume source | where a resumed pass restarts is the step checkpoints' answer, and there is only one |
209
+ | a retry adopts its own row | `started_at` is when the pass began, not when this attempt did |
210
+ | a driver without a ledger runs the pass anyway | the same degradation `introspect` has — no bookkeeping, never a refusal |
211
+
212
+ Three surfaces read it, all through one projection (`inspectBackfills()`), so none of them can
213
+ report a different number:
214
+
215
+ | Surface | Shows |
216
+ |---|---|
217
+ | `x db backfill --list` | the whole ledger, `--name` / `--status` / `--limit`, and `--json` |
218
+ | `x jobs ls` | the passes **in flight** — rows so far and cursor, beside the queue depth |
219
+ | `x jobs show <id>` | the ledger row for that run, under `backfill` (`null` for any other job) |
220
+ | `/_x` → jobs | the whole ledger plus a live count, alongside the queues and the step traces |
221
+
222
+ ### What was DECLARED — the other half of the ledger
223
+
224
+ `As of 2026-08`.
225
+
226
+ The ledger says which passes have run. `registeredBackfills()` says which ones **exist**, and the
227
+ diff between them is the alarm: a sweep merged, deployed and never enqueued had no row and showed
228
+ up on no surface at all.
229
+
230
+ ```ts
231
+ import { isBackfill, pendingBackfills, registeredBackfills } from '@ultimat3/jobs';
232
+
233
+ registeredBackfills(); // [{ kind: 'backfill', name, checksum, requires, environments, counts }]
234
+ isBackfill(rewriteSlugs); // true — a plain job() is false, and so is a look-alike
235
+
236
+ pendingBackfills({ declarations, runs, environment });
237
+ // → { environment, rows, pending, orphaned }
238
+ ```
239
+
240
+ `backfill()` stamps its own handle through the `origin` WeakMap `task()` already uses — an app
241
+ registers nothing, because a declaration that has to be registered a second time is a declaration
242
+ half the apps will forget. `x db backfill --pending` is the command, and it exits **non-zero** when
243
+ anything is unswept so a cron or a deploy check can read the exit code alone.
244
+
245
+ | State | Means | In `pending` |
246
+ |---|---|---|
247
+ | `pending` | declared, no row under this name | yes |
248
+ | `failed` | the newest pass failed — the queue may have dead-lettered it | yes |
249
+ | `running` | a pass is in flight | no — a check red for the whole of every sweep gets muted |
250
+ | `completed` | a completed row exists, so nothing re-runs without `--force` | no |
251
+ | `excluded` | `environments` does not include this one | no |
252
+
253
+ ### Three optional declarations, all of them DATA
254
+
255
+ ```ts
256
+ export const dropLegacy = backfill({
257
+ name: 'drop-legacy',
258
+ tenant: 'none', // required, exactly as on job() — see the table above
259
+ requires: '20260814120000_add_publish_at', // a migration id, checked against x_migrations
260
+ environments: ['staging', 'production'], // omitted = every environment
261
+ count: ({ ctx }) => db.posts.where({ publishedAt: null }).count(),
262
+ source: ({ ctx }) => db.posts.where({ publishedAt: null }),
263
+ handle: async ({ rows }) => { /* … */ },
264
+ });
265
+ ```
266
+
267
+ | Field | Rail | Enforced where |
268
+ |---|---|---|
269
+ | `requires` | `X_BACKFILL_MIGRATION_PENDING` — the migration is not applied | `x db backfill`, which is where `x_migrations` is readable; this package holds no `@ultimat3/db` dependency and will not grow one to read a ledger |
270
+ | `environments` | `X_BACKFILL_ENVIRONMENT` | **the pass** (`backfillPass`) — the rail, because `.enqueue()` from app code reaches no command — and again in `gateBackfill()` as the CLI's pre-check, so `x db backfill` refuses before it queues work that would only dead-letter |
271
+ | `count` | `X_BACKFILL_STALLED` — the source ran out and this still matches rows | the pass, once, after the last batch |
272
+
273
+ `environments` ships as declared data and never as a hardcoded "cleanups are production": a staging
274
+ rehearsal is correct practice, so which deploys a sweep belongs to is the app's convention and this
275
+ is only the mechanism carrying it. There is deliberately **no** `dependsOn` graph over other
276
+ backfills — the real dependency is almost always "after code tolerating both shapes is serving",
277
+ which the framework cannot observe.
278
+
279
+ `count` is the same predicate `source` selects on, counted. It is what makes a dry run honest and
280
+ "did it converge" arithmetic: a pass that exhausts its source while `count()` still answers above
281
+ zero has two predicates that disagree, which is an authoring bug and not something a retry fixes.
282
+ Its result is parsed rather than trusted — a non-negative safe integer or `X_INVARIANT`, because
283
+ `NaN > 0` and `-1 > 0` are both false and would complete the sweep the detector exists to fail.
284
+
285
+ ### Running one
286
+
287
+ ```
288
+ x db backfill --pending --json # declared minus completed; non-zero when anything is unswept
289
+ x db backfill drop-legacy # DRY RUN — --write is never implied
290
+ x db backfill drop-legacy --write # gate, then enqueue; the workers sweep
291
+ x db backfill --all --write # every pending one, isolated per name
292
+ x db backfill drop-legacy --write --force # a completed name, again, into a NEW ledger row
293
+ ```
294
+
295
+ `--write` **enqueues**; it never runs the pass inline, because the queue is a job's execution
296
+ surface. That is what makes the `backfill` deploy role a trigger rather than a gate: it runs after
297
+ the new pods serve, puts the sweeps on the queue and exits, and a slow UPDATE never holds a release
298
+ open against a database still serving the previous build. `--all` isolates per name and continues
299
+ past a failure, so one wedged cleanup cannot block every later one forever.
300
+
301
+ ## The deadline cancels
302
+
303
+ A job's `timeout` aborts `ctx.signal` **before** it fails the attempt, because the nack that
304
+ follows makes the job claimable by another worker — a body still running past it is a second
305
+ copy of one job.
306
+
307
+ ```ts
308
+ run: async ({ input, ctx, step }) => {
309
+ const res = await fetch(url, { signal: ctx.signal }); // stops at the deadline
310
+ await step.run('save', (signal) => save(res, { signal })); // the step's own ceiling too
311
+ },
312
+ ```
313
+
314
+ Nothing can kill a body that ignores the signal, so the durable state is fenced: past the
315
+ cancel every step write is refused with `X_ABORTED`, and a run that finishes anyway is logged
316
+ as `jobs.timeout.abandoned` — the one way to find a handler that never reads `ctx.signal`.
317
+
318
+ ## The transactional outbox
107
319
 
108
320
  ```ts
109
321
  await ctx.tx(async (tx) => {
110
322
  const post = await ctx.posts.publish(input.postId, tx);
111
- await notifySubscribers.enqueue({ postId: post.id }); // joins `tx`
323
+ await notifySubscribers.enqueue({ postId: post.id, orgId: input.orgId }); // joins `tx`
112
324
  });
113
325
  ```
114
326
 
@@ -123,17 +335,46 @@ it after commit. The bug class this removes:
123
335
  Both are load-dependent, both pass every test you would write, and both produce "the email
124
336
  went out but the order isn't in the database". Joining the transaction closes the window.
125
337
  The relay publishes *then* marks published, so a crash re-publishes — collapsed by the
126
- idempotency key. Set `mode: 'required'` to make an enqueue outside a transaction an
127
- `X_OUTBOX_NO_TX` error instead of a direct publish.
338
+ idempotency key. A publish that FAILS stops the batch rather than letting later rows overtake it:
339
+ `claim()` returns rows in `staged_at` order, so an app that stages `createInvoice` then
340
+ `chargeCard` in one transaction must never have the charge run first. Set `mode: 'required'` to
341
+ make an enqueue outside a transaction an `X_OUTBOX_NO_TX` error instead of a direct publish.
342
+
343
+ **It is not on by default, and it is not on until you install it** (`As of 2026-08`). Three
344
+ things have to be true in a process:
345
+
346
+ | Step | Call |
347
+ |---|---|
348
+ | the table exists | ships in `SQL_JOBS_TABLE` — applying the queue DDL is enough |
349
+ | the facade is installed | `setJobsFacade(createJobsFacade({ store, driver }, currentTx))` |
350
+ | the relay is running | `createOutboxRelay({ store, driver }).start()` |
351
+
352
+ with `store = createPgOutboxStore({ executor, txExecutor })`. `txExecutor` is what makes it
353
+ transactional: `stage()` runs on the CALLER'S connection, never the pool. With nothing installed,
354
+ `jobsFacade()` answers a fallback whose `currentTx` is `() => undefined` and every enqueue
355
+ publishes straight to the driver — deliberate, so a script and a test enqueue with no wiring, but
356
+ it is a fallback and not the guarantee.
357
+
358
+ The memory store (`createMemoryOutboxStore`, `x dev` and tests) **drops** a published row —
359
+ `retained()` is the relay's backlog, not a running total; the pg store keeps `published_at` as
360
+ the audit trail this map is not. A relay pass that throws is logged as `jobs.outbox.tick-failed`
361
+ and the loop re-arms: an unobserved rejection would end the process with rows still staged.
362
+
363
+ `relay.stop()` is **async and joins the pass in flight** — `await` it before closing the database,
364
+ the way `worker.stop()` and `scheduler.stop()` are awaited. A pass is a publish followed by a
365
+ `markPublished`, and a caller that returned between the two closed the pool under the row it was
366
+ about to mark.
128
367
 
129
368
  ## Drivers
130
369
 
131
370
  One interface: `enqueue`, `claim` (visibility timeout), `ack`, `nack` (backoff),
132
- `heartbeat`, `stats`. Zero job-code change between them.
371
+ `heartbeat`, `stats`, plus optional `introspect`, `backfills` and `leases`. Zero job-code change
372
+ between them — swapping is `setJobDriver(other)`, and there is **no `jobs.driver` config line**:
373
+ `JobsConfig.driver` has no reader and boot always builds `createPgDriver`.
133
374
 
134
375
  | Driver | Status | Backing | Use |
135
376
  |---|---|---|---|
136
- | `pg` | **default** | `SELECT ... FOR UPDATE SKIP LOCKED`, partial unique index, advisory-lock leader | zero-infra start, most apps |
377
+ | `pg` | **default** | `SELECT ... FOR UPDATE SKIP LOCKED`, a partial unique index on `(name, idempotency_key)`, lease-based leader, `x_job_leases` | zero-infra start, most apps |
137
378
  | `memory` | complete | in-process maps | `x dev`, tests |
138
379
  | `redis` | interface-complete, `X_NOT_IMPLEMENTED` | Streams + consumer groups | planned |
139
380
  | `nats` | interface-complete, `X_NOT_IMPLEMENTED` | JetStream work queue | planned |
@@ -145,8 +386,32 @@ debugging a stuck queue can read and run the exact statement.
145
386
 
146
387
  | Role | Entry | Behaviour |
147
388
  |---|---|---|
148
- | `worker` | `createWorker({ driver, queues, concurrency })` | per-queue pools, heartbeat, SIGTERM drain: stop claiming → finish in-flight → close |
149
- | `scheduler` | `createScheduler({ driver, leader })` | advisory-lock leader, one dispatcher per tick, catch-up policy |
389
+ | `worker` | `createWorker({ driver, context, queues, concurrency })` | per-queue pools, lease heartbeat, SIGTERM drain: stop claiming → finish in-flight → close |
390
+ | `scheduler` | `createScheduler({ driver, leader, state })` | one dispatch round at a time, catch-up policy, SIGTERM drain: stop dispatching → finish the round → release the lock |
391
+
392
+ `driver` and `context` are the two required keys on `WorkerOptions`; everything else defaults.
393
+ `context: () => Ctx` supplies the ambient `Ctx` a job run executes under — the app wires its ALS
394
+ and its tenant there, and a worker with no way to build one would run every handler as nobody.
395
+
396
+ **Pass `state` and `leader` in any real deployment.** The defaults are a `Map` and "always the
397
+ leader", and both fail silently: with no durable watermark a redeployed scheduler arms to tomorrow
398
+ and never detects the occurrence the pod it replaced dropped (`catchUp` and `maxCatchUp` are inert
399
+ — "missed" is relative to a watermark that no longer exists), and with no election a rolling
400
+ update runs two leaders.
401
+
402
+ ```ts
403
+ createScheduler({
404
+ driver,
405
+ state: pgSchedulerState(executor),
406
+ leader: createPgLeaseLeader({ executor }),
407
+ });
408
+ ```
409
+
410
+ `createPgLeaseLeader` and not `createPgLeader`: `pg_try_advisory_lock` is scoped to a Postgres
411
+ *session*, and the executor this package is handed is a **pool** — the lock is released the moment
412
+ that connection goes back to it, so every node reads itself as leader. The lease is a row with an
413
+ expiry and needs no connection affinity. `acquire()` is also the renewal, called every round, which
414
+ is how a demoted node finds out.
150
415
 
151
416
  ```ts
152
417
  export const nightlyDigest = task({
@@ -159,7 +424,17 @@ export const nightlyDigest = task({
159
424
  `tz` is required by the type *and* validated against the runtime's IANA database, because a
160
425
  non-empty string is not a timezone: `tz: 'Bogota'` would resolve every occurrence in UTC and
161
426
  run five hours off, silently, forever. `0 3 * * *` in a DST zone runs twice or zero times on
162
- the switch day. Catch-up after downtime is explicit: `skip` (default), `run-once`, `run-all`.
427
+ the switch day. Catch-up after downtime is explicit: `skip` (default) fires the latest missed
428
+ occurrence and drops the older ones, `run-once` fires the earliest missed one, `run-all` fires
429
+ every one of them. `maxCatchUp` (default 10) bounds the WALK for every mode, not just `run-all`:
430
+ one tick walks at most that many occurrences forward from the last fire, and the policy then
431
+ picks from what that walk found — so after a long outage `skip` fires the latest occurrence
432
+ *within the cap*, not the true latest missed.
433
+
434
+ `run-once` fires **once**, not once per tick. Dropping the rest means the watermark passes them
435
+ too, so a scheduler back up after a day down enqueues one catch-up and then waits for the next
436
+ real occurrence. It used to leave the watermark on the occurrence it had just run, which made an
437
+ hourly task fire 24 catch-ups a second apart.
163
438
 
164
439
  ## Retries
165
440
 
@@ -174,14 +449,73 @@ retrySchedule({ attempts: 5, backoff: 'exponential', delay: 1000 })
174
449
 
175
450
  ## Limits
176
451
 
177
- Per-tenant concurrency (`tenantId` = the actor's `orgId`, carried on the queue row), a
178
- per-queue cap and a global cap. Over a cap, the claim is handed straight back without
179
- burning an attempt one org's 50k-row import cannot starve the fleet.
452
+ Two layers, and the difference matters:
453
+
454
+ | Layer | Scope | Where |
455
+ |---|---|---|
456
+ | `LimitConfig` — `perTenant`, `perQueue`, `global`, `ratePerTenant` | **this process only** | `limits.ts`, three `Map`s in one heap |
457
+ | `job.concurrency` | **the fleet** | `JobDriver.leases` over `x_job_leases` |
458
+
459
+ `LimitConfig` is the fast path and is multiplied by your replica count: `perTenant: 2` on twenty
460
+ pods is forty concurrent runs, and `ratePerTenant`'s window is in memory, so a rolling restart
461
+ grants every tenant a fresh full allowance. Size it as a per-pod budget, never as a partner's
462
+ contractual rate.
463
+
464
+ `job.concurrency` is the one that is fleet-wide, and it is enforced by a row every replica can
465
+ see — one per held slot, keyed `job:<name>`, renewed by the same heartbeat that renews the
466
+ visibility lease and reclaimed by TTL when a worker is SIGKILLed. A driver with no lease store
467
+ cannot hold the cap, so `createWorker().start()` **refuses to boot**
468
+ (`X_JOB_CONCURRENCY_UNENFORCEABLE`) rather than let a documented guarantee do nothing.
469
+
470
+ Over any cap the claim is handed straight back without burning an attempt — one org's 50k-row
471
+ import cannot starve the fleet.
472
+
473
+ ## Leases
474
+
475
+ A claim buys `visibilityTimeoutMs` of invisibility; the worker renews it every
476
+ `heartbeatIntervalMs` (default a third of the window) for as long as the job runs. Renewal
477
+ failures are not swallowed:
478
+
479
+ | Fact | Signal |
480
+ |---|---|
481
+ | a renewal failed, the window still has room | `jobs.heartbeat.failed` (warn) |
482
+ | a whole window passed with none landing | `jobs.lease.lost` (error) + `job_leases_lost_total{queue}` |
483
+
484
+ The second one means the queue is free to hand that job to another worker while this one is
485
+ still running it — at-least-once turning into twice. Alert on any non-zero rate. The window is
486
+ measured from the last renewal that **landed**, on this process's clock, so a driver whose
487
+ heartbeat hangs is caught the same as one that rejects.
180
488
 
181
489
  ## Introspection
182
490
 
183
491
  `inspectQueues`, `inspectJob` (per-step trace), `inspectDeadLetters`, `retryFromStep`,
184
- `inspectManifest` — all `--json`-shaped, shared by `/_x`, the CLI and the MCP tools.
492
+ `cancelJob`, `inspectManifest` — all `--json`-shaped, shared by `/_x`, the CLI and the MCP tools.
493
+
494
+ `cancelJob(driver, id, reason?)` is the answer to a runaway pass. A queued row becomes `cancelled`
495
+ immediately; a RUNNING one stops at its next heartbeat, which no longer matches its own row and
496
+ aborts the attempt — `steps.ts` then refuses every write. `ack` and `nack` are fenced on
497
+ `state = 'running'`, so the worker that was cancelled cannot un-cancel it on the way out.
498
+
499
+ ## Metrics
500
+
501
+ | Series | Kind | Answers |
502
+ |---|---|---|
503
+ | `queue_depth{queue}` | gauge | how much work is waiting — the HPA's signal |
504
+ | `jobs_total{queue,outcome}` | counter | is any of it succeeding |
505
+ | `queue_oldest_ready_seconds{queue}` | gauge | *"page if the oldest job in `payments` is older than 5 minutes"* |
506
+ | `queue_dead_jobs{queue}` | gauge | a dead-letter queue that filled overnight and stopped growing — a counter's rate is flat there |
507
+
508
+ ## Trace and actor
509
+
510
+ An enqueue stamps the current span's `traceparent` onto the row, and the worker opens the job's
511
+ span as a **child** of it: a checkout trace shows the HTTP span, the action span and the charge
512
+ that ran two seconds later as one trace.
513
+
514
+ `handle.as(actor, input)` also records `enqueuedBy` — the actor's id. **Attribution, never
515
+ authority.** A job body runs with system authority; the framework does not impersonate the
516
+ enqueuer, because a job that sleeps three days or dead-letters and is retried next quarter would
517
+ act as somebody whose role, org membership or employment has changed since. A job that must act
518
+ FOR a user takes that user's id in its input and re-authorises it in the body.
185
519
 
186
520
  ## Errors
187
521
 
@@ -194,6 +528,11 @@ burning an attempt — one org's 50k-row import cannot starve the fleet.
194
528
  | `X_JOB_MAX_ATTEMPTS` | retries exhausted, job dead-lettered |
195
529
  | `X_OUTBOX_NO_TX` | enqueue outside a transaction with `mode: 'required'` |
196
530
  | `X_DRIVER_UNAVAILABLE` | no `DATABASE_URL` / executor for the pg driver |
531
+ | `X_ABORTED` | a cancelled attempt tried to write a step — core's code, not a second name for it |
532
+ | `X_JOB_LEASE_LOST` | the job was cancelled, or its lease lapsed and the queue re-delivered it, while this worker was still running it |
533
+ | `X_JOB_SLOT_LOST` | the fleet `concurrency` slot this run held was taken by another worker — a different row on a different clock from the lease above |
534
+ | `X_JOB_NOT_CANCELLABLE` | `cancelJob` reached a job that already finished, or a driver with no `cancel` |
535
+ | `X_JOB_CONCURRENCY_UNENFORCEABLE` | a registered job declares `concurrency` and the driver has no lease store |
197
536
  | `X_NOT_IMPLEMENTED` | redis / nats driver |
198
537
 
199
538
  ## Boundary
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/jobs",
3
- "version": "1.2.0",
3
+ "version": "2.0.0",
4
4
  "description": "Durable background work: steps, transactional outbox, cron tasks, one driver interface",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,8 @@
19
19
  "files": [
20
20
  "src",
21
21
  "!src/**/*.test.ts",
22
+ "!src/**/*-fixture.ts",
23
+ "CLAUDE.md",
22
24
  "README.md",
23
25
  "LICENSE"
24
26
  ],
@@ -30,9 +32,9 @@
30
32
  "test": "bun test"
31
33
  },
32
34
  "dependencies": {
33
- "@ultimat3/core": "1.2.0",
34
- "@ultimat3/entity": "1.2.0",
35
- "@ultimat3/schema": "1.2.0",
36
- "@ultimat3/time": "1.2.0"
35
+ "@ultimat3/core": "2.0.0",
36
+ "@ultimat3/entity": "2.0.0",
37
+ "@ultimat3/schema": "2.0.0",
38
+ "@ultimat3/time": "2.0.0"
37
39
  }
38
40
  }
@@ -0,0 +1,97 @@
1
+ // May this named sweep run here, now? One decision, pure, so the pass, the CLI, a test and a
2
+ // deploy container all read the same verdict instead of four almost-identical `if`s. It RETURNS
3
+ // the refusal rather than throwing it: `x db backfill --all` isolates per name and continues past
4
+ // a failure, and a thrown verdict would let one wedged cleanup block every later one forever.
5
+ //
6
+ // The environment half is enforced inside the pass as well, because a backfill enqueued by app
7
+ // code never passes through the CLI — the check has to sit where the work is, or it is a
8
+ // convention rather than a rail (axiom 3).
9
+
10
+ import type { Environment, UltimateError } from '@ultimat3/core';
11
+ // `BackfillProgress`, the one ledger projection every surface already reads — never the driver's
12
+ // own row shape, which would make this a second reader of `x_backfills`.
13
+ import type { BackfillProgress } from './backfill-inspect';
14
+ import type { BackfillDeclaration } from './backfill-registry';
15
+ import {
16
+ BackfillAppliedError,
17
+ BackfillEnvironmentError,
18
+ BackfillMigrationPendingError,
19
+ } from './errors';
20
+
21
+ export type BackfillGate =
22
+ | { readonly run: true }
23
+ | { readonly run: false; readonly error: UltimateError };
24
+
25
+ const ALLOWED: BackfillGate = { run: true };
26
+
27
+ /**
28
+ * An absent `environments` means EVERY environment. Never an implied "production only": a staging
29
+ * rehearsal is correct practice, and a framework that guessed which deploys a cleanup belongs to
30
+ * would be shipping one business's convention to every app (axiom 8).
31
+ */
32
+ export function checkBackfillEnvironment(
33
+ backfill: string,
34
+ declared: readonly Environment[] | null,
35
+ environment: Environment,
36
+ ): BackfillEnvironmentError | undefined {
37
+ if (declared === null || declared.length === 0) return undefined;
38
+ if (declared.includes(environment)) return undefined;
39
+ return new BackfillEnvironmentError({ backfill, environment, declared });
40
+ }
41
+
42
+ export interface BackfillGateInput {
43
+ readonly declaration: BackfillDeclaration;
44
+ readonly environment: Environment;
45
+ /**
46
+ * Migration ids `x_migrations` records as applied. `undefined` means the caller could not read
47
+ * the ledger, and an unreadable ledger is deliberately NOT a refusal — a `requires` that blocked
48
+ * on "I could not check" would make every driver with no database an unrunnable backfill.
49
+ */
50
+ readonly appliedMigrations: readonly string[] | undefined;
51
+ /** The newest COMPLETED pass under this name, when the ledger holds one. */
52
+ readonly completed: BackfillProgress | undefined;
53
+ readonly force: boolean;
54
+ }
55
+
56
+ /**
57
+ * Order is the order an operator can act in: environment first (nothing else matters if this
58
+ * process may not run it at all), then the migration it waits on, then whether it already ran.
59
+ * A live pass is NOT judged here — that verdict belongs to the enqueue, which is the only thing
60
+ * that can see the one live idempotency key without racing it.
61
+ */
62
+ export function gateBackfill(input: BackfillGateInput): BackfillGate {
63
+ const { declaration } = input;
64
+ const environment = checkBackfillEnvironment(
65
+ declaration.name,
66
+ declaration.environments,
67
+ input.environment,
68
+ );
69
+ if (environment !== undefined) return { run: false, error: environment };
70
+
71
+ const requires = declaration.requires;
72
+ if (
73
+ requires !== null &&
74
+ input.appliedMigrations !== undefined &&
75
+ !input.appliedMigrations.includes(requires)
76
+ ) {
77
+ return {
78
+ run: false,
79
+ error: new BackfillMigrationPendingError({ backfill: declaration.name, migration: requires }),
80
+ };
81
+ }
82
+
83
+ const completed = input.completed;
84
+ if (completed !== undefined && !input.force) {
85
+ return {
86
+ run: false,
87
+ error: new BackfillAppliedError({
88
+ backfill: declaration.name,
89
+ runId: completed.runId,
90
+ // Verbatim: the projection already rendered it as ISO, and the repo forbids a date
91
+ // formatted without an explicit zone — not formatting at all is the one render with none.
92
+ completedAt: completed.completedAt ?? completed.startedAt,
93
+ }),
94
+ };
95
+ }
96
+ return ALLOWED;
97
+ }
@@ -0,0 +1,73 @@
1
+ // The `x_backfills` ledger, projected for the surfaces that report it: `x db backfill --list`,
2
+ // `x jobs`, `/_x`'s jobs panel and MCP. Plain JSON-serialisable objects, the same contract
3
+ // `inspect.ts` holds for the queue — one shape, so the dashboard renders what `--json` prints.
4
+ //
5
+ // Nothing here reads a clock. A running row's elapsed time would need one, and "how long has this
6
+ // been going" computed against the reader's wall clock is a different number in every process
7
+ // that asks — so `durationMs` is the pass's own completed span or nothing at all.
8
+
9
+ import type { BackfillFilter, BackfillRun, BackfillStatus } from './backfill-ledger';
10
+ import type { JobDriver } from './driver';
11
+
12
+ /** One ledger row as a surface reports it: epochs become ISO, absent becomes `null`. */
13
+ export interface BackfillProgress {
14
+ /** The pass, and the run id of the job that is sweeping — `x jobs show <id>` joins on it. */
15
+ readonly runId: string;
16
+ readonly name: string;
17
+ readonly status: BackfillStatus;
18
+ readonly checksum: string;
19
+ readonly appVersion: string;
20
+ /** Rows the pass has handled so far. Absolute, so a replayed batch reports the same number. */
21
+ readonly rows: number;
22
+ /** Where the pass had got to. `null` before the first batch and once it is over. */
23
+ readonly cursor: string | null;
24
+ readonly startedAt: string;
25
+ readonly completedAt: string | null;
26
+ /** How long the pass took. `null` while it is still running — see the file header. */
27
+ readonly durationMs: number | null;
28
+ }
29
+
30
+ export function toBackfillProgress(run: BackfillRun): BackfillProgress {
31
+ return {
32
+ runId: run.runId,
33
+ name: run.name,
34
+ status: run.status,
35
+ checksum: run.checksum,
36
+ appVersion: run.appVersion,
37
+ rows: run.rows,
38
+ cursor: run.cursor,
39
+ startedAt: new Date(run.startedAt).toISOString(),
40
+ completedAt: run.completedAt === undefined ? null : new Date(run.completedAt).toISOString(),
41
+ durationMs: run.completedAt === undefined ? null : run.completedAt - run.startedAt,
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Every pass the ledger holds, newest first. An EMPTY list on a driver that ships no ledger, and
47
+ * deliberately not a throw: `x jobs ls` and the jobs panel report the queue, and a queue that
48
+ * answers everything except "no backfills recorded" is a broken command for a fact nobody asked
49
+ * about. `x db backfill --list` says so in its own summary instead, where it IS the question.
50
+ */
51
+ export async function inspectBackfills(
52
+ driver: JobDriver,
53
+ // `BackfillFilter` itself, never its fields restated: `ledger.list` takes that type, so a field
54
+ // added to it reaches this surface instead of being silently dropped one call short of it.
55
+ filter: BackfillFilter = {},
56
+ ): Promise<readonly BackfillProgress[]> {
57
+ const ledger = driver.backfills;
58
+ if (ledger === undefined) return [];
59
+ return (await ledger.list(filter)).map(toBackfillProgress);
60
+ }
61
+
62
+ /**
63
+ * The pass one job run is sweeping, or nothing. Keyed by run rather than by name because `force`
64
+ * writes a NEW row for a name that already has one — the row this job wrote is the only one that
65
+ * describes this job.
66
+ */
67
+ export async function backfillForRun(
68
+ driver: JobDriver,
69
+ runId: string,
70
+ ): Promise<BackfillProgress | undefined> {
71
+ const [row] = await inspectBackfills(driver, { runId, limit: 1 });
72
+ return row;
73
+ }