@ultimat3/jobs 30.9.0 → 31.1.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 (60) hide show
  1. package/CLAUDE.md +72 -107
  2. package/README.md +202 -55
  3. package/package.json +8 -7
  4. package/src/adapter.ts +37 -0
  5. package/src/backfill-ledger.ts +2 -2
  6. package/src/backfill-rate.ts +1 -0
  7. package/src/clock.ts +2 -2
  8. package/src/drain-wait.ts +1 -0
  9. package/src/driver-memory.ts +2 -2
  10. package/src/driver-pg-ddl.ts +19 -0
  11. package/src/driver-pg-event-wake-sql.ts +43 -22
  12. package/src/driver-pg-sql.ts +68 -27
  13. package/src/driver-pg.ts +11 -10
  14. package/src/driver-redis.ts +2 -2
  15. package/src/driver.ts +14 -8
  16. package/src/error-codes.ts +27 -0
  17. package/src/errors-concurrency.ts +1 -1
  18. package/src/errors.ts +1 -1
  19. package/src/events-cursor.ts +31 -0
  20. package/src/events-pg.ts +52 -6
  21. package/src/events.ts +72 -36
  22. package/src/export.ts +2 -2
  23. package/src/gdpr-audit.ts +75 -0
  24. package/src/gdpr-coverage.ts +94 -0
  25. package/src/gdpr-errors.ts +35 -0
  26. package/src/import.ts +2 -2
  27. package/src/index.ts +45 -206
  28. package/src/inspect-operator.ts +2 -2
  29. package/src/inspect.ts +1 -1
  30. package/src/job-instrument.ts +115 -0
  31. package/src/job.ts +41 -4
  32. package/src/jobs-retention.ts +23 -6
  33. package/src/leases.ts +13 -3
  34. package/src/ledger-book.ts +168 -0
  35. package/src/ledger-errors.ts +48 -0
  36. package/src/ledger.ts +145 -0
  37. package/src/limits.ts +57 -19
  38. package/src/lock-errors.ts +54 -0
  39. package/src/lock.ts +176 -0
  40. package/src/outbox-relay.ts +4 -0
  41. package/src/outbox.ts +3 -3
  42. package/src/partitions.ts +119 -0
  43. package/src/reindex-sweep.ts +94 -0
  44. package/src/reindex.ts +147 -0
  45. package/src/retry-classification.ts +3 -2
  46. package/src/retry-jitter.ts +32 -0
  47. package/src/retry.ts +13 -16
  48. package/src/scheduler.ts +10 -6
  49. package/src/steps-api.ts +44 -5
  50. package/src/steps-suspension.ts +34 -3
  51. package/src/steps-wait.ts +52 -4
  52. package/src/steps.ts +1 -0
  53. package/src/subject-jobs.ts +12 -4
  54. package/src/webhook.ts +2 -2
  55. package/src/worker-admit.ts +8 -5
  56. package/src/worker-drain-cutoff.ts +1 -0
  57. package/src/worker-loop.ts +28 -5
  58. package/src/worker-registry.ts +7 -5
  59. package/src/worker-run.ts +53 -9
  60. package/src/worker.ts +8 -8
package/README.md CHANGED
@@ -216,22 +216,31 @@ input. The tenant is a fact about the work, so the job declares it.
216
216
  | a stored event the wait's `schema` refuses | `X_JOB_EVENT_INVALID`, terminal, on a hit or a replay. The error carries the issues, never the payload |
217
217
 
218
218
  A boot's bus runs on the open transaction's connection (`currentTx() ?? client`, as the outbox):
219
- `publishEvent` inside a transaction commits and rolls back with it. `published_at` is the
220
- statement's time, so a wait that begins between that publish and its commit compares later than the
221
- stamp and never matches; the window is the length of the publishing transaction.
222
-
223
- `resetEventBus()` is the test seam beside `resetJobDriver()`: an event is matchable until it
224
- expires, so on a bus shared by one `bun test` process an answer one test published resumes the
219
+ `publishEvent` inside a transaction commits and rolls back with it. **An event committed after a
220
+ wait began is that wait's, whatever its stamp.** `published_at` is the INSERT's time and the row is
221
+ visible at COMMIT, so a new wait records the snapshot it began under (`EventLookup.mark()`,
222
+ `SQL_EVENT_MARK`, persisted on the waiting step) and also matches every event whose transaction
223
+ (`x_job_events.xact`) that snapshot could not see. A rolled-back publish matches nothing. A
224
+ waking bus published inside `withTransaction` wakes again once it COMMITS (`SQL_EVENT_COMMIT_WAKE`,
225
+ run from `Tx.onCommit` on the pool): a run that parked while the transaction was open is resumed
226
+ at the commit, not at the 15 min backstop.
227
+
228
+ Events are consumed in `(published_at, id)` order, stamps truncated to the millisecond; the cursor a
229
+ refused event leaves is that pair, compared and never looked up (`events-cursor.ts`, both buses),
230
+ so a purged cursor still excludes what it excluded. A refusal only moves it FORWARD, under the
231
+ horizon of the search that found the event; an event refused behind it (owed through the horizon)
232
+ is excluded by id. The wait's own line — `startedAt` and the mark's horizon (`EventSince`) — rides
233
+ every search and is never moved by a refusal, so no event committed before the wait is handed out.
234
+
235
+ `resetEventBus()` is the test seam beside `resetJobDriver()`, both on `@ultimat3/jobs/adapter`: an
236
+ event is matchable until it expires, so on a bus shared by one `bun test` process an answer one test published resumes the
225
237
  next test's wait — call it between tests.
226
238
 
227
239
  The event bus has ONE clock. `postgresEventBus({ executor })` stamps `publishedAt` and the expiry
228
240
  with the database's `now()` — it takes no `clock` — and `bus.now()` answers that same clock. A
229
241
  consumer that wants "published after I asked" (`@ultimat3/scraping`'s `eventPrompt`) reads its
230
242
  "asked at" from `bus.now()`, never from its own process: a skew between two pods must not decide
231
- which answers count. `step.waitForEvent` stamps a new wait from the same clock
232
- (`EventLookup.now()`), so a worker running ahead of the database still matches an event published
233
- after it asked.
234
- `step.waitForEvent` stamps a new wait from that clock (`EventLookup.now()`, required) and asks it
243
+ which answers count. `step.waitForEvent` stamps a new wait from that clock (`EventLookup.now()`, required) and asks it
235
244
  again before declaring a timeout, so a worker running ahead of the database still matches an event
236
245
  published after it asked and does not give up early. `eventsPurgeTarget(bus)` is the bus as a
237
246
  `purge()` target: `purgeExpired()` is awaited and answers the rows it removed.
@@ -473,6 +482,63 @@ the new pods serve, puts the sweeps on the queue and exits, and a slow UPDATE ne
473
482
  open against a database still serving the previous build. `--all` isolates per name and continues
474
483
  past a failure, so one wedged cleanup cannot block every later one forever.
475
484
 
485
+ ### Rebuilding a search index: `reindex()`
486
+
487
+ `reindex({ name, entity, source, tenant, index? })` is a `backfill()` in two phases, one durable
488
+ step per page. **Rows**: each row's document goes to the `searchIndex` port — entity's one builder,
489
+ `searchDocumentOf`, so only `.searchable()` columns leave and each document keeps its tenant.
490
+ **Sweep**: every document the index `list`s whose row `source` no longer reads — deleted while the
491
+ index was down, moved to another tenant — is removed, tombstoned one millisecond above the version
492
+ listed, so an id written again meanwhile keeps its newer document. `source` is therefore the WHOLE
493
+ table: a narrowed one empties the index of the rest. `tenant: 'none'` sweeps every tenant's
494
+ documents; a declared tenant, its own. Run it after moving from the built-in Postgres index to
495
+ Meilisearch or Typesense, or after an index lost data. An entity with no searchable column is
496
+ refused at declaration. The index keeps only the NEWER version of each id, so neither a replayed
497
+ batch nor a stale read can overwrite a newer write or bring a deleted row back.
498
+
499
+ ```ts
500
+ import { database, entity, text, timestamp, uuid } from '@ultimat3/entity';
501
+ import { reindex } from '@ultimat3/jobs';
502
+
503
+ const posts = entity('posts', {
504
+ columns: {
505
+ id: uuid().primaryKey(),
506
+ title: text().searchable(),
507
+ updatedAt: timestamp().defaultNow().onUpdateNow(), // each document's version
508
+ },
509
+ });
510
+ const db = database({ posts });
511
+
512
+ export const reindexPosts = reindex({
513
+ name: 'reindex-posts',
514
+ entity: posts,
515
+ tenant: 'none',
516
+ source: () => db.posts,
517
+ });
518
+ ```
519
+
520
+ ## Partition maintenance is a job too
521
+
522
+ `partitions({ name?, cron?, tz?, clock? })` keeps every `entity({ partitionBy })` table ready: one
523
+ step per partitioned entity runs `@ultimat3/db`'s `partitionMaintenance(description, now)` — the
524
+ function the migration also calls — creating `ahead` partitions past the current one and
525
+ dropping those `retain` expired. It is a job plus a `.schedule` `task()` (`<name>.schedule`,
526
+ default `DEFAULT_PARTITIONS_CRON`, 03:17 UTC daily), the shape `consumer()`'s `.supervisor` has.
527
+ `now` is the job's clock, never the server's, so a test moves it; a second run at one instant
528
+ changes nothing (`create … if not exists`). Entities are read per run, so one declared later is
529
+ maintained from its first tick.
530
+
531
+ ```ts
532
+ import { partitions } from '@ultimat3/jobs';
533
+
534
+ export const maintainPartitions = partitions(); // list it in defineApi({ jobs })
535
+ ```
536
+
537
+ Before a `backfill()` copies rows older than the first partition, enqueue it once with `{ from:
538
+ '<oldest row ISO>' }`: that run creates every partition back to that row's period and drops
539
+ nothing. Every DDL statement runs under a `lock_timeout`, failing fast with `X_DB_LOCK_TIMEOUT`
540
+ (retryable — the job's retry absorbs it) rather than queueing behind a long transaction.
541
+
476
542
  ## Consumers are jobs too
477
543
 
478
544
  `consumer()` reads a queue, a change stream or a vendor's cursor: a factory over `job()` plus a
@@ -723,6 +789,29 @@ throws before a row is touched (`X_SUBJECT_TABLE_UNDECLARED`, `X_SUBJECT_TREATME
723
789
  `authSubjectTarget`, `auditSubjectTarget` and `storageSubjectTarget` satisfy it from their own
724
790
  packages. `{ target, retain: '<why>' }` exports a target and never erases it.
725
791
 
792
+ ### `personalData()` coverage
793
+
794
+ `.personalData(kind)` on a column (`@ultimat3/entity`) marks it as a person's data. It adds no
795
+ second export/erase path: it is the proof that the declared subjects cover every marked column.
796
+
797
+ | Check | Refuses |
798
+ |---|---|
799
+ | `unerasedPersonalData(plan)` | a reached table whose `{ anonymize }` names fewer than every marked column (`X_PERSONAL_DATA_UNERASED`). `'delete'` clears the row; `{ retain: '<why>' }` is a stated reason |
800
+ | `unreachedPersonalData(plans)` | a marked entity no subject's walk reaches (`X_PERSONAL_DATA_UNREACHED`) |
801
+ | `personalDataProblems()` | both, over `registeredSubjects()` and every marked entity — what `x exercise` lists |
802
+
803
+ Enforced twice: `plannedSubject()` throws the first `X_PERSONAL_DATA_UNERASED` when a run starts, so
804
+ `subjectExport`/`subjectErase` refuse before a row is touched, and `x exercise` lists both codes
805
+ as findings. **An erase is audited**: with an `auditSink` installed, `subjectErase` writes one
806
+ `allowed` record per completed erase and a `failed` record per attempt that threw: `name` the job,
807
+ `primitive: 'action'`, `surface: 'job'`, `input: { subject, run }` — the entity and the run id,
808
+ never the subject's key, which an app may make an email. At least once: a sink dedupes on
809
+ `idempotencyKey` (`<job>:<runId>`). A sink that refuses fails the run; one refusing every attempt
810
+ can hold no record, and the dead-lettered run is the trail.
811
+
812
+ `gdpr.job.test.ts` proves the property, not the shape: a covered erase leaves no marked value of the
813
+ person in any marked table, and a declaration missing one column is named and does leak it.
814
+
726
815
  ## Outbound webhooks are jobs too
727
816
 
728
817
  `webhook()` is a factory over `job()` and delivers **one event to one endpoint**. Retry with
@@ -856,6 +945,53 @@ export const vendorEvents = webhookReceiver({
856
945
  | `.routes()` is set on the job handle in place, never on a copy | `isJobHandle` proves the object `job()` built, so `defineApi({ jobs })` registers it like any job |
857
946
  | the path is absolute and outside `/api` and `/_x` | `X_WEBHOOK_RECEIVER_INVALID` at declaration, beside a missing `verify`/`handle` or a bad `maxBytes` |
858
947
 
948
+ ## Double-entry postings are jobs too
949
+
950
+ `ledger({ name, tenant, book })` is a factory over `job()`. Its input is a posting — `{ key,
951
+ entries: [{ account, amount: Money }], orgId? }` — and its run refuses, before a line is written,
952
+ anything that is not balanced Money; then it writes every line in ONE durable step through the
953
+ app's `LedgerBook`. No provider, account plan or currency policy ships (axiom 8): the accounts
954
+ are your strings, the table is your entity.
955
+
956
+ ```ts
957
+ import { defaultDriver, entity, integer, money, text } from '@ultimat3/entity';
958
+ import { entityLedgerBook, ledger } from '@ultimat3/jobs';
959
+
960
+ // The app's lines: keyed (postingKey, line) — (orgId, postingKey, line) when tenanted, where a key
961
+ // is one posting PER TENANT and the posting's `orgId` fills the tenant column.
962
+ export const ledgerLines = entity('ledger_lines', {
963
+ columns: { postingKey: text(), line: integer(), account: text(), amount: money() },
964
+ primaryKey: ['postingKey', 'line'],
965
+ });
966
+
967
+ export const postPayment = ledger({
968
+ name: 'payments.post',
969
+ tenant: (posting) => posting.orgId ?? 'none',
970
+ book: () => entityLedgerBook({ driver: defaultDriver(), entity: ledgerLines }),
971
+ });
972
+
973
+ await postPayment.enqueue({
974
+ key: 'pay_123',
975
+ entries: [
976
+ { account: 'cash', amount: { minor: 1299, currency: 'EUR' } },
977
+ { account: 'revenue', amount: { minor: -1099, currency: 'EUR' } },
978
+ { account: 'vat-payable', amount: { minor: -200, currency: 'EUR' } },
979
+ ],
980
+ });
981
+ ```
982
+
983
+ | Rule | Why |
984
+ |---|---|
985
+ | every currency's entries sum to zero, each on its own (`X_LEDGER_UNBALANCED`) | an FX posting balances in EUR and in USD separately; netting across currencies is the error |
986
+ | `minor` a safe integer, the currency ISO 4217, one `scale` per currency, a non-blank account, at least two entries (`X_LEDGER_ENTRY_INVALID`) | no float money; a sum across two scales is not a sum |
987
+ | both refusals TERMINAL | the posting is the input: attempt five sees the same entries |
988
+ | idempotent by key: the job's live key is `<name>:<key>`; the book writes a key once per tenant, `(tenant, postingKey, line)` | a retry, a replay or a second enqueue of `pay_123` posts it once — `entityLedgerBook` is one `insert … on conflict do nothing` |
989
+ | `assertBalancedPosting(posting)` is exported | the action that enqueues refuses at the request, before a row is queued |
990
+ | a key already stored with other lines — another count, another line — is `X_LEDGER_POSTING_CONFLICT` (409, terminal), nothing written | a superset under one key would store only the new lines beside the old: an unbalanced book. A correction is a new key. The Postgres book inserts and reads the key back in ONE transaction |
991
+
992
+ A payment provider's webhook is a `webhookReceiver()` whose `handle` enqueues the posting, keyed
993
+ by the provider's own id — the receiver dedupes the delivery, the ledger dedupes the posting.
994
+
859
995
  ## The deadline cancels
860
996
 
861
997
  A job's `timeout` aborts `ctx.signal` **before** it fails the attempt, because the nack that
@@ -1032,8 +1168,11 @@ One interface: `enqueue`, `claim` (visibility timeout), `ack(jobId, { workerId,
1032
1168
  `nack(jobId, { workerId, claim, … })` (backoff), `heartbeat`, `stats`, plus optional `introspect`,
1033
1169
  `backfills` and `leases`. Both settles are fenced on the CLAIM — `claimOf(claimed)` is the
1034
1170
  `{ workerId, claim }` a `ClaimedJob` carries — and answer whether they landed. Zero job-code change
1035
- between them — swapping is `setJobDriver(other)`, and there is **no `jobs.driver` config line**:
1036
- `JobsConfig.driver` has no reader and boot always builds `postgresJobDriver`.
1171
+ between them — swapping is `jobs: other` in `apps/<app>/runtime.ts`, and there is **no
1172
+ `jobs.driver` config line**: `JobsConfig.driver` has no reader and boot always builds
1173
+ `postgresJobDriver`. A driver's author imports the port (`JobDriver`, `EventBus` and their types),
1174
+ the errors a driver throws and the installers from **`@ultimat3/jobs/adapter`** — never the `.`
1175
+ barrel, which carries no installer (`adapter.test.ts`).
1037
1176
 
1038
1177
  | Driver | Status | Backing | Use |
1039
1178
  |---|---|---|---|
@@ -1044,7 +1183,7 @@ between them — swapping is `setJobDriver(other)`, and there is **no `jobs.driv
1044
1183
  **`redisJobDriver({ client?, prefix?, clock?, doneTtlMs? })`** passes `@ultimat3/testing`'s
1045
1184
  `jobDriverConformance`, the suite the other two pass, and `driver-redis.live.test.ts` runs one
1046
1185
  script of operations on it and on the memory driver and compares every answer. Installed with
1047
- `setJobDriver(redisJobDriver())` (or `ServeOptions.runtime.jobs`), imported from its own entry
1186
+ `jobs: redisJobDriver()` in `apps/<app>/runtime.ts`, imported from its own entry
1048
1187
  `@ultimat3/jobs/redis` so the barrel every role boots carries none of it; the client reads `REDIS_URL`.
1049
1188
  What it does not carry: `introspect` (so `x jobs show`/`retry`/`cancel` and queue pauses answer
1050
1189
  for Postgres only), `backfills`, and `leases` — a job declaring `concurrency` refuses
@@ -1259,7 +1398,7 @@ export const wake = startQueueWake({ listener: client, executor });
1259
1398
  | a pause is read by the claim itself | `pauseQueue` stops claims at each worker's NEXT claim — no poll to wait for. `resumeQueue` announces the queue |
1260
1399
  | a paused task | checked when it is due, so the pause holds for the very next occurrence; a task found paused is looked at again after `PAUSE_RECHECK_MS` (5 s), which is the resume's latency |
1261
1400
  | a standby scheduler | asks for the lease every `renewEveryMs`, not every round |
1262
- | a queue no registered job names | still polled — a per-call `enqueue({ queue })` may land there — and reported once at boot as `jobs.worker.queue-unregistered`; idle, it adds nothing to the one claim |
1401
+ | a queue no registered job names | still polled — a per-call `enqueue({ queue })` may land there — and reported once at boot as `jobs.worker.queue-unregistered`, `default` excepted (where a `job()` naming no queue lands); idle, it adds nothing to the one claim |
1263
1402
  | an enqueuer whose clock runs ahead of the database's | its "immediate" jobs are due a moment from now (`run_at` is the enqueuer's clock); up to 1 s ahead they are still announced, past that they start on the poll |
1264
1403
 
1265
1404
  Lower `idlePollMaxMs` to trade idle load for the no-wake bound; there is no flag for the old fixed interval.
@@ -1267,7 +1406,8 @@ Lower `idlePollMaxMs` to trade idle load for the no-wake bound; there is no flag
1267
1406
  ## Retries
1268
1407
 
1269
1408
  `{ attempts, backoff: 'exponential' | 'linear' | 'fixed', delay, maxDelay, jitter }`.
1270
- Equal jitter is on by default so a burst of failures does not retry in lockstep.
1409
+ `jitter` is core's `JitterMode` (`'full' | 'equal' | 'none'`); `'equal'` is the default so a burst of
1410
+ failures does not retry in lockstep.
1271
1411
  Exhausted jobs are dead-lettered, never dropped: `x jobs retry <id>`. Only a finished job — dead,
1272
1412
  cancelled, failed or done — can be requeued: a live one is `X_JOB_NOT_REQUEUEABLE` (requeueing a
1273
1413
  running job used to run it twice), and one whose idempotency key a live job now holds is
@@ -1286,7 +1426,7 @@ exactly as it had before.
1286
1426
  | Thrown | What the queue does |
1287
1427
  |---|---|
1288
1428
  | a `terminal` code (`X_SCRAPE_AUTH_FAILED`, a validation fault, a permission denial) | dead-lettered on the attempt it happened, `attempt` recorded, remaining attempts unspent — a rotated password retried five times is five more wrong passwords at a site that locks the account after three |
1289
- | a `retry-after` code (`X_RATE_LIMITED`, `X_OVERLOADED`) | retried at the time the responder NAMED — `meta.retryAfterSeconds`, clamped by the policy's `maxDelay`, plus core's `jitterStatedDelay` spread unless `jitter: false` — instead of the backoff. Still an attempt, still under the ceiling |
1429
+ | a `retry-after` code (`X_RATE_LIMITED`, `X_OVERLOADED`) | retried at the time the responder NAMED — `meta.retryAfterSeconds`, clamped by the policy's `maxDelay`, plus core's `jitterStatedDelay` spread unless `jitter: 'none'` — instead of the backoff. Still an attempt, still under the ceiling |
1290
1430
  | a `retryable` code (`X_TIMEOUT`, `X_DRAINING`) | the backoff schedule above, unchanged |
1291
1431
  | an **unclassified** code, or anything that is not an `UltimateError` | the backoff schedule above, unchanged. Most codes are unclassified and `retryFor` answers `terminal` for all of them, so reading that would have stopped every transient retry in every app |
1292
1432
 
@@ -1466,6 +1606,38 @@ Nor for a job being settled (`As of 2026-10-30`): the heartbeat stops when the w
1466
1606
  listing, and a renewal there matched no row — `jobs.lease.lost` for a job that had finished, with
1467
1607
  the hook's `ctx.signal` aborted under it.
1468
1608
 
1609
+ ### `withLock(key, fn, { ttl })`
1610
+
1611
+ One holder fleet-wide for `fn`, held in the job driver's `LeaseStore` as one slot under
1612
+ `lock:<key>` — the rows `job.concurrency` already counts, so no table, no Redis, no second
1613
+ mechanism. Not a primitive: a helper a job or action body calls.
1614
+
1615
+ ```ts
1616
+ import { withLock } from '@ultimat3/jobs';
1617
+
1618
+ // The app's two halves: the slow read, and a write its store refuses under an older token.
1619
+ declare function recompute(id: string, options: { signal: AbortSignal }): Promise<number>;
1620
+ declare function saveTotal(id: string, total: number, token: number): Promise<void>;
1621
+
1622
+ export const recount = (id: string): Promise<void> =>
1623
+ withLock(
1624
+ `invoice:${id}`,
1625
+ async ({ token, signal }) => {
1626
+ const total = await recompute(id, { signal });
1627
+ await saveTotal(id, total, token);
1628
+ },
1629
+ { ttl: '30s', wait: '5s' },
1630
+ );
1631
+ ```
1632
+
1633
+ | Option / fact | Meaning |
1634
+ |---|---|
1635
+ | `ttl` (required) | how long the key outlives a dead holder; renewed at a third of it while `fn` runs |
1636
+ | `wait` (default one `ttl`, `0` = one try) | then `X_LOCK_NOT_ACQUIRED` (409, retryable), naming the holder |
1637
+ | `token` | a NUMBER the lease store assigns per grant (`HeldLease.fence`; Postgres: `x_job_leases.fence` from one sequence), greater than every earlier holder's — never a client clock. Write it beside the data and refuse a smaller one. A store answering no fence is `X_LOCK_STORE_MISSING` |
1638
+ | a renewal refused, or none landing within `ttl` — decided before each call, one in flight, a late `true` still late | `fn`'s `signal` aborts with `X_LOCK_LOST`, and the call rejects with it even if `fn` resolved — its work was not exclusive. A renew hung on a dead socket is caught by the next tick |
1639
+ | `store` | default the installed driver's `leases`; none is `X_LOCK_STORE_MISSING` |
1640
+
1469
1641
  ## The operator surface
1470
1642
 
1471
1643
  Every operator capability is a member of `JobIntrospection` (`driver.introspect`), implemented by
@@ -1585,9 +1757,16 @@ aborts the attempt — `steps.ts` then refuses every write. `ack` and `nack` are
1585
1757
 
1586
1758
  ## Trace and actor
1587
1759
 
1588
- An enqueue stamps the current span's `traceparent` onto the row, and the worker opens the job's
1589
- span as a **child** of it: a checkout trace shows the HTTP span, the action span and the charge
1590
- that ran two seconds later as one trace.
1760
+ Every `enqueue` / `enqueueMany` opens a `job.<name>.enqueue` **producer** span (a child of the
1761
+ current span, or a new root) and stamps ITS `traceparent` onto the row; the worker opens the run's
1762
+ `job.<name>` **consumer** span as a child of that: a checkout trace shows the HTTP span, the action
1763
+ span, the enqueue and the charge that ran two seconds later as one trace. Both carry
1764
+ OpenTelemetry's messaging attributes (`messaging.system=ultimate`, `messaging.operation.type`,
1765
+ `messaging.destination.name` = the queue, `messaging.message.id`) beside `ultimate.job.*`.
1766
+
1767
+ On core's instrumentation bus, sampled or not: `job.enqueue` (`{ job, queue, id }`) per enqueued
1768
+ job, `job.perform` (`{ job, queue, id, attempt, durationMs, error? }`) per attempt that ran — a
1769
+ failed attempt carries its error. `subscribe('job.perform', …)` from `@ultimat3/core`.
1591
1770
 
1592
1771
  `handle.as(actor, input)` also records `enqueuedBy` — the actor's id. **Attribution, never
1593
1772
  authority.** A job body runs with system authority; the framework does not impersonate the
@@ -1625,59 +1804,27 @@ FOR a user takes that user's id in its input and re-authorises it in the body.
1625
1804
  ### Error classes
1626
1805
 
1627
1806
  Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
1628
- a job boundary the class is gone and the `code` is what survives — match on that.
1807
+ a job boundary the class is gone and the `code` is what survives — match on that. Every other refusal (as of 31.0.0) has no exported class: match its `code`. (`./adapter`) marks the ones a driver author throws, exported from `@ultimat3/jobs/adapter` only.
1629
1808
 
1630
1809
  | Class | Code | Declared in |
1631
1810
  |---|---|---|
1632
- | `ActionJobUnbridgedError` | `X_ACTION_JOB_UNBRIDGED` | `src/errors.ts` |
1633
- | `BackfillAppliedError` | `X_BACKFILL_APPLIED` | `src/backfill-errors.ts` |
1634
- | `BackfillEnvironmentError` | `X_BACKFILL_ENVIRONMENT` | `src/backfill-errors.ts` |
1635
- | `BackfillMigrationPendingError` | `X_BACKFILL_MIGRATION_PENDING` | `src/backfill-errors.ts` |
1636
1811
  | `BackfillPendingError` | `X_BACKFILL_PENDING` | `src/backfill-errors.ts` |
1637
1812
  | `BackfillRunningError` | `X_BACKFILL_RUNNING` | `src/backfill-errors.ts` |
1638
- | `BackfillStalledError` | `X_BACKFILL_STALLED` | `src/backfill-errors.ts` |
1639
1813
  | `BackfillUnknownError` | `X_BACKFILL_UNKNOWN` | `src/backfill-errors.ts` |
1640
- | `CancelUnsupportedError` | `X_JOB_NOT_CANCELLABLE` | `src/errors.ts` |
1641
- | `ClaimQueuesEmptyError` | `X_JOB_CLAIM_QUEUES_EMPTY` | `src/errors.ts` |
1814
+ | `ClaimQueuesEmptyError` (`./adapter`) | `X_JOB_CLAIM_QUEUES_EMPTY` | `src/errors.ts` |
1642
1815
  | `ConcurrencyUnenforceableError` | `X_JOB_CONCURRENCY_UNENFORCEABLE` | `src/errors-concurrency.ts` |
1643
1816
  | `DriverUnavailableError` | `X_DRIVER_UNAVAILABLE` | `src/errors.ts` |
1644
- | `ExportPartTooLargeError` | `X_EXPORT_PART_TOO_LARGE` | `src/export-errors.ts` |
1645
- | `ExportRowInvalidError` | `X_EXPORT_ROW_INVALID` | `src/export-errors.ts` |
1646
- | `IdempotencyRequiredError` | `X_IDEMPOTENCY_REQUIRED` | `src/errors.ts` |
1647
1817
  | `JobAbortedError` | `X_ABORTED` | `src/errors.ts` |
1648
- | `JobConcurrencyInvalidError` | `X_JOB_DECLARATION_INVALID` | `src/errors-concurrency.ts` |
1649
- | `JobConcurrencyKeyInvalidError` | `X_JOB_DECLARATION_INVALID` | `src/errors-concurrency.ts` |
1650
- | `JobDeclarationInvalidError` | `X_JOB_DECLARATION_INVALID` | `src/errors-declaration.ts` |
1651
1818
  | `JobDrainedError` | `X_DRAINING` | `src/errors.ts` |
1652
1819
  | `JobDuplicateError` | `X_JOB_DUPLICATE` | `src/errors.ts` |
1653
1820
  | `JobKeyBusyError` | `X_JOB_KEY_BUSY` | `src/errors-concurrency.ts` |
1654
- | `JobMaxAttemptsError` | `X_JOB_MAX_ATTEMPTS` | `src/errors.ts` |
1655
- | `JobNameTakenError` | `X_JOB_DUPLICATE` | `src/errors.ts` |
1656
1821
  | `JobNotCancellableError` | `X_JOB_NOT_CANCELLABLE` | `src/errors.ts` |
1657
1822
  | `JobNotFoundError` | `X_JOB_NOT_FOUND` | `src/errors-requeue.ts` |
1658
- | `JobNotPromotableError` | `X_JOB_NOT_PROMOTABLE` | `src/errors-operator.ts` |
1659
- | `JobNotRemovableError` | `X_JOB_NOT_REMOVABLE` | `src/errors-operator.ts` |
1660
- | `JobNotRequeueableError` | `X_JOB_NOT_REQUEUEABLE` | `src/errors-requeue.ts` |
1661
- | `JobOnSettledFailedError` | `X_JOB_ON_SETTLED_FAILED` | `src/errors-operator.ts` |
1662
- | `JobPageInvalidError` | `X_JOB_PAGE_INVALID` | `src/errors-operator.ts` |
1663
- | `JobRowStatusUnknownError` | `X_JOB_ROW_STATUS_UNKNOWN` | `src/errors.ts` |
1664
- | `JobSlotLostError` | `X_JOB_SLOT_LOST` | `src/errors.ts` |
1665
- | `JobTenantRequiredError` | `X_JOB_TENANT_REQUIRED` | `src/errors.ts` |
1666
- | `JobEventInvalidError` | `X_JOB_EVENT_INVALID` | `src/errors-steps.ts` |
1823
+ | `JobNotRequeueableError` (`./adapter`) | `X_JOB_NOT_REQUEUEABLE` | `src/errors-requeue.ts` |
1824
+ | `JobRowStatusUnknownError` (`./adapter`) | `X_JOB_ROW_STATUS_UNKNOWN` | `src/errors.ts` |
1667
1825
  | `JobTenantMismatchError` | `X_JOB_TENANT_MISMATCH` | `src/errors-tenant.ts` |
1668
1826
  | `JobTimeoutError` | `X_JOB_TIMEOUT` | `src/errors.ts` |
1669
1827
  | `LeaseLostError` | `X_JOB_LEASE_LOST` | `src/errors.ts` |
1670
- | `OutboxNoTxError` | `X_OUTBOX_NO_TX` | `src/errors.ts` |
1671
- | `StepDuplicateError` | `X_STEP_DUPLICATE` | `src/errors.ts` |
1672
- | `StepOutputUnserializableError` | `X_STEP_OUTPUT_UNSERIALIZABLE` | `src/errors-steps.ts` |
1673
- | `WebhookDeliveryFailedError` | `X_WEBHOOK_DELIVERY_FAILED` | `src/webhook-errors.ts` |
1674
- | `WebhookDeliveryRejectedError` | `X_WEBHOOK_DELIVERY_REJECTED` | `src/webhook-errors.ts` |
1675
- | `WebhookDeliveryThrottledError` | `X_WEBHOOK_DELIVERY_THROTTLED` | `src/webhook-errors.ts` |
1676
- | `WebhookEndpointDisabledError` | `X_WEBHOOK_ENDPOINT_DISABLED` | `src/webhook-errors.ts` |
1677
- | `WebhookEndpointInvalidError` | `X_WEBHOOK_ENDPOINT_INVALID` | `src/webhook-errors.ts` |
1678
- | `WebhookEndpointUnknownError` | `X_WEBHOOK_ENDPOINT_UNKNOWN` | `src/webhook-errors.ts` |
1679
- | `WebhookEventInvalidError` | `X_WEBHOOK_EVENT_INVALID` | `src/webhook-errors.ts` |
1680
- | `WebhookEventUnknownError` | `X_WEBHOOK_EVENT_UNKNOWN` | `src/webhook-errors.ts` |
1681
1828
 
1682
1829
  ## Boundary
1683
1830
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/jobs",
3
- "version": "30.9.0",
3
+ "version": "31.1.0",
4
4
  "description": "Durable background work: steps, transactional outbox, cron tasks, one driver interface",
5
5
  "keywords": [
6
6
  "ultimate",
@@ -26,6 +26,7 @@
26
26
  },
27
27
  "exports": {
28
28
  ".": "./src/index.ts",
29
+ "./adapter": "./src/adapter.ts",
29
30
  "./redis": "./src/driver-redis.ts"
30
31
  },
31
32
  "files": [
@@ -44,11 +45,11 @@
44
45
  "test": "bun test"
45
46
  },
46
47
  "dependencies": {
47
- "@ultimat3/core": "30.9.0",
48
- "@ultimat3/db": "30.9.0",
49
- "@ultimat3/entity": "30.9.0",
50
- "@ultimat3/http": "30.9.0",
51
- "@ultimat3/schema": "30.9.0",
52
- "@ultimat3/time": "30.9.0"
48
+ "@ultimat3/core": "31.1.0",
49
+ "@ultimat3/db": "31.1.0",
50
+ "@ultimat3/entity": "31.1.0",
51
+ "@ultimat3/http": "31.1.0",
52
+ "@ultimat3/schema": "31.1.0",
53
+ "@ultimat3/time": "31.1.0"
53
54
  }
54
55
  }
package/src/adapter.ts ADDED
@@ -0,0 +1,37 @@
1
+ // `@ultimat3/jobs/adapter` — what a queue, an event bus or a durable store is built and installed
2
+ // from: the port interfaces (outbox, step and lease stores too), a driver's errors, the installers
3
+ // and their resets. Never the `.` barrel: an app swaps a backend in `apps/<app>/runtime.ts`, so
4
+ // the callers here are the cli boot, `@ultimat3/testing` and a driver's own author.
5
+
6
+ export type {
7
+ AckOptions,
8
+ ClaimedJob,
9
+ ClaimIdentity,
10
+ ClaimOptions,
11
+ ConflictPolicy,
12
+ EnqueueRequest,
13
+ EnqueueResult,
14
+ HeartbeatOptions,
15
+ JobDriver,
16
+ JobRecord,
17
+ JobState,
18
+ NackOptions,
19
+ QueueDemand,
20
+ QueueStats,
21
+ SettleBy,
22
+ } from './driver';
23
+ export { installJobDriver, resetJobDriver } from './driver';
24
+ export {
25
+ ClaimQueuesEmptyError,
26
+ DriverUnavailableError,
27
+ JobDuplicateError,
28
+ JobRowStatusUnknownError,
29
+ LeaseLostError,
30
+ } from './errors';
31
+ export { JobNotFoundError, JobNotRequeueableError } from './errors-requeue';
32
+ export type { EventBus, JobEvent, PublishOptions } from './events';
33
+ export { freshDefaultEventBus, installEventBus, resetEventBus } from './events';
34
+ export type { LeaseStore } from './leases';
35
+ export type { OutboxRecord, OutboxStore } from './outbox';
36
+ export { fireThroughDriver } from './scheduler-state';
37
+ export type { StepStore } from './steps-store';
@@ -10,7 +10,7 @@
10
10
  // become — the checkpoints are transactional with the work, and this row is not.
11
11
 
12
12
  import type { Clock } from '@ultimat3/core';
13
- import { describeValue, finiteCount, systemClock, UltimateError } from '@ultimat3/core';
13
+ import { describeValue, finiteCount, liveClock, UltimateError } from '@ultimat3/core';
14
14
  import { nowMs } from './clock';
15
15
 
16
16
  /**
@@ -153,7 +153,7 @@ export function decideBackfill(
153
153
  return { run: force, previous: completed, changed: completed.checksum !== checksum };
154
154
  }
155
155
 
156
- export function memoryBackfillLedger(clock: Clock = systemClock): BackfillLedger {
156
+ export function memoryBackfillLedger(clock: Clock = liveClock): BackfillLedger {
157
157
  const runs = new Map<string, BackfillRun>();
158
158
  const patch = (runId: string, fields: Partial<BackfillRun>): void => {
159
159
  const existing = runs.get(runId);
@@ -113,6 +113,7 @@ export function backfillPacer(options: PacerOptions): Pacer {
113
113
  return {
114
114
  rate: options.rate,
115
115
  async wait({ signal, step, clock: runClock }) {
116
+ // Real time by default: the pacer's wait is a real timer between batches, not a reading of now.
116
117
  const clock = options.clock ?? runClock ?? systemClock;
117
118
  const sleep = options.sleep ?? clockSleep(clock);
118
119
  const aborted = (): JobAbortedError =>
package/src/clock.ts CHANGED
@@ -2,13 +2,13 @@
2
2
  // injected Clock, so tests never sleep and a frozen clock cannot be bypassed.
3
3
 
4
4
  import type { Clock } from '@ultimat3/core';
5
- import { finiteOption, systemClock } from '@ultimat3/core';
5
+ import { finiteOption, liveClock } from '@ultimat3/core';
6
6
  import { parseDuration } from '@ultimat3/time';
7
7
 
8
8
  export type DurationInput = string | number;
9
9
 
10
10
  /** A Clock may hand back a `Date` or epoch ms; scheduling needs one comparable number. */
11
- export function nowMs(clock: Clock = systemClock): number {
11
+ export function nowMs(clock: Clock = liveClock): number {
12
12
  const reading: unknown = clock.now();
13
13
  if (reading instanceof Date) return reading.getTime();
14
14
  return Number(reading);
package/src/drain-wait.ts CHANGED
@@ -92,6 +92,7 @@ export async function settleAllBy(
92
92
  // timer on a macrotask.
93
93
  const unwatch = budget.watch((deadlineAt) => {
94
94
  if (timer !== undefined) clearTimeout(timer);
95
+ // Real time, not `liveClock`: this races a real `setTimeout`, and a frozen clock under it never fires.
95
96
  timer = setTimeout(() => decide(false), Math.max(0, deadlineAt - systemClock.monotonic()));
96
97
  timer.unref?.();
97
98
  });
@@ -3,7 +3,7 @@
3
3
  // real claim/ack/nack paths rather than a mock that always succeeds.
4
4
 
5
5
  import type { Clock } from '@ultimat3/core';
6
- import { finiteCount, systemClock, uuidV7 } from '@ultimat3/core';
6
+ import { finiteCount, liveClock, uuidV7 } from '@ultimat3/core';
7
7
  import type { BackfillLedger } from './backfill-ledger';
8
8
  import { memoryBackfillLedger } from './backfill-ledger';
9
9
  import { nowMs } from './clock';
@@ -66,7 +66,7 @@ export interface MemoryJobDriverOptions {
66
66
  export type MemoryJobDriver = JobDriver & { close(): Promise<void> };
67
67
 
68
68
  export function memoryJobDriver(options: MemoryJobDriverOptions = {}): MemoryJobDriver {
69
- const clock = options.clock ?? systemClock;
69
+ const clock = options.clock ?? liveClock;
70
70
  const stepStore = options.steps ?? memoryStepStore();
71
71
  // `SQL_STEP_PUT`'s fence: a write made under a claim lands only while that claim holds the row.
72
72
  const steps: StepStore = {
@@ -180,6 +180,11 @@ create table if not exists x_job_leases (
180
180
 
181
181
  create index if not exists x_job_leases_expiry_idx on x_job_leases (expires_at);
182
182
 
183
+ -- The fencing number every grant takes, from ONE sequence: increasing across holders and keys,
184
+ -- and a count the database keeps, never a client clock. A table created before it gains the column.
185
+ create sequence if not exists x_job_lease_fence;
186
+ alter table x_job_leases add column if not exists fence bigint;
187
+
183
188
  -- Events step.waitForEvent consumes. Stored and not broadcast: the publisher is a web pod and
184
189
  -- the resumer is a worker pod, so an in-heap bus strands every waiting run in a real deployment.
185
190
  create table if not exists x_job_events (
@@ -194,6 +199,14 @@ create table if not exists x_job_events (
194
199
  create index if not exists x_job_events_lookup_idx
195
200
  on x_job_events (name, published_at);
196
201
 
202
+ -- The transaction that published the event. A wait records what it could see when it began and
203
+ -- also matches every event that snapshot could not, since a publish is stamped at insert and seen
204
+ -- at commit. Null on a row from before the column, which is matched by its stamp alone. Added by
205
+ -- alter because x_job_events shipped without it.
206
+ alter table x_job_events add column if not exists xact xid8;
207
+
208
+ create index if not exists x_job_events_xact_idx on x_job_events (name, xact);
209
+
197
210
  -- The steps parked on an event, which is what a waking publish reads. Partial, so it holds only
198
211
  -- the open waits: without it each publish is a scan of every step ever written.
199
212
  create index if not exists x_job_steps_waiting_idx
@@ -222,6 +235,12 @@ create index if not exists x_jobs_finished_idx
222
235
  on x_jobs (updated_at)
223
236
  where state in ('done', 'failed', 'dead', 'cancelled');
224
237
 
238
+ -- The done half of the retention sweep, on its own. Done rows are kept a shorter window than
239
+ -- failed, dead and cancelled ones, so a walk of the index above in updated_at order stepped over
240
+ -- every younger dead row between the old done ones on every batch. Partial over the one state, so
241
+ -- the sweep seeks to its cutoff and every row it reads is a row it deletes.
242
+ create index if not exists x_jobs_done_idx on x_jobs (updated_at) where state = 'done';
243
+
225
244
  -- What the queue depth read (SQL_STATS) reads, every 15 seconds from every worker. Partial over
226
245
  -- the six states it counts, so the read is a range over the counted rows and never a pass over the
227
246
  -- finished ones the retention window keeps. Its predicate is the same list SQL_STATS filters on,
@@ -5,31 +5,15 @@
5
5
  import { JOBS_WAKE_CHANNEL } from './driver-pg-wake-sql';
6
6
 
7
7
  /**
8
- * `SQL_EVENT_PUBLISH`, plus the wake. A waiter is a `waiting` step on this event whose key is this
9
- * event's or absent — `SQL_EVENT_FIND`'s match, read from the step's side — over the partial
10
- * `x_job_steps_waiting_idx`, so a publish with no waiter reads no step at all.
11
- *
12
- * Woken means `run_at` moved to now on a `suspended` row that was not already due: a running row,
13
- * a finished one, or one a concurrent publish already woke (the row lock re-checks `run_at` after
14
- * the first commit) is left alone. The poll stays the guarantee; this only brings it forward.
15
- *
16
- * One `pg_notify` per DISTINCT queue of the woken rows, and none when nothing woke: never
17
- * unconditional, for the commit lock `driver-pg-wake-sql.ts` describes. The payload is the queue.
18
- *
19
- * $1 id, $2 name, $3 payload, $4 correlation key, $5 ttl (ms).
8
+ * The waiters of event `$name` keyed `$key`, made due, and their queues told: the half of a waking
9
+ * publish after its insert. Shared, so the publish and the commit-time wake are one rule.
20
10
  */
21
- export const SQL_EVENT_PUBLISH_WAKE = `
22
- with ev as (
23
- insert into x_job_events (id, name, payload, correlation_key, published_at, expires_at)
24
- values ($1, $2, $3::jsonb, $4, statement_timestamp(),
25
- statement_timestamp() + ($5::bigint * interval '1 millisecond'))
26
- returning published_at, expires_at
27
- ), waiters as (
11
+ const wakeWaiters = (name: string, key: string): string => `waiters as (
28
12
  select distinct s.run_id
29
13
  from x_job_steps s
30
14
  where s.status = 'waiting'
31
- and s.event = $2
32
- and (s.correlation_key is null or s.correlation_key = $4::text)
15
+ and s.event = ${name}
16
+ and (s.correlation_key is null or s.correlation_key = ${key}::text)
33
17
  ), woken as (
34
18
  update x_jobs j
35
19
  set run_at = statement_timestamp(),
@@ -42,10 +26,47 @@ with ev as (
42
26
  ), told as (
43
27
  select count(pg_notify('${JOBS_WAKE_CHANNEL}', q.queue))::int as told
44
28
  from (select distinct queue from woken) q
45
- )
29
+ )`;
30
+
31
+ /**
32
+ * `SQL_EVENT_PUBLISH`, plus the wake. A waiter is a `waiting` step on this event whose key is this
33
+ * event's or absent — `SQL_EVENT_FIND`'s match, read from the step's side — over the partial
34
+ * `x_job_steps_waiting_idx`, so a publish with no waiter reads no step at all.
35
+ *
36
+ * Woken means `run_at` moved to now on a `suspended` row that was not already due: a running row,
37
+ * a finished one, or one a concurrent publish already woke (the row lock re-checks `run_at` after
38
+ * the first commit) is left alone. The poll stays the guarantee; this only brings it forward.
39
+ *
40
+ * One `pg_notify` per DISTINCT queue of the woken rows, and none when nothing woke: never
41
+ * unconditional, for the commit lock `driver-pg-wake-sql.ts` describes. The payload is the queue.
42
+ *
43
+ * $1 id, $2 name, $3 payload, $4 correlation key, $5 ttl (ms).
44
+ */
45
+ export const SQL_EVENT_PUBLISH_WAKE = `
46
+ with ev as (
47
+ insert into x_job_events (id, name, payload, correlation_key, published_at, expires_at, xact)
48
+ values ($1, $2, $3::jsonb, $4, date_trunc('milliseconds', statement_timestamp()),
49
+ statement_timestamp() + ($5::bigint * interval '1 millisecond'),
50
+ pg_current_xact_id())
51
+ returning published_at, expires_at
52
+ ), ${wakeWaiters('$2', '$4')}
46
53
  select floor(extract(epoch from ev.published_at) * 1000)::bigint as published_at,
47
54
  floor(extract(epoch from ev.expires_at) * 1000)::bigint as expires_at,
48
55
  (select count(*) from woken)::int as woken,
49
56
  (select told from told) as told
50
57
  from ev
51
58
  `.trim();
59
+
60
+ /**
61
+ * The same wake, run on the pool once the PUBLISHING transaction has committed (`Tx.onCommit`):
62
+ * the publish's own wake reads the waiters parked before its statement, and a run that parked
63
+ * while the transaction was still open — its `find` could not see the uncommitted event — woke
64
+ * nobody and waited out the 15 min backstop. After the commit every waiter is either parked, and
65
+ * woken here, or parks later and sees the event in its second `find`.
66
+ *
67
+ * $1 name, $2 correlation key.
68
+ */
69
+ export const SQL_EVENT_COMMIT_WAKE = `
70
+ with ${wakeWaiters('$1', '$2')}
71
+ select (select count(*) from woken)::int as woken, (select told from told) as told
72
+ `.trim();