@ultimat3/jobs 30.8.0 → 31.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +72 -107
- package/README.md +202 -55
- package/package.json +8 -7
- package/src/adapter.ts +37 -0
- package/src/backfill-ledger.ts +2 -2
- package/src/backfill-pass.ts +2 -1
- package/src/backfill-rate.ts +34 -5
- package/src/clock.ts +2 -2
- package/src/drain-wait.ts +1 -0
- package/src/driver-memory.ts +2 -2
- package/src/driver-pg-ddl.ts +19 -0
- package/src/driver-pg-event-wake-sql.ts +43 -22
- package/src/driver-pg-sql.ts +68 -27
- package/src/driver-pg.ts +11 -10
- package/src/driver-redis.ts +2 -2
- package/src/driver.ts +14 -8
- package/src/error-codes.ts +27 -0
- package/src/errors-concurrency.ts +1 -1
- package/src/errors.ts +1 -1
- package/src/events-cursor.ts +31 -0
- package/src/events-pg.ts +52 -6
- package/src/events.ts +72 -36
- package/src/export.ts +2 -2
- package/src/gdpr-audit.ts +75 -0
- package/src/gdpr-coverage.ts +94 -0
- package/src/gdpr-errors.ts +35 -0
- package/src/import.ts +2 -2
- package/src/index.ts +45 -206
- package/src/inspect-operator.ts +2 -2
- package/src/inspect.ts +1 -1
- package/src/job-instrument.ts +115 -0
- package/src/job.ts +41 -4
- package/src/jobs-retention.ts +23 -6
- package/src/leases.ts +13 -3
- package/src/ledger-book.ts +168 -0
- package/src/ledger-errors.ts +48 -0
- package/src/ledger.ts +145 -0
- package/src/limits.ts +57 -19
- package/src/lock-errors.ts +54 -0
- package/src/lock.ts +176 -0
- package/src/outbox-relay.ts +4 -0
- package/src/outbox.ts +3 -3
- package/src/partitions.ts +119 -0
- package/src/reindex-sweep.ts +94 -0
- package/src/reindex.ts +147 -0
- package/src/retry-classification.ts +3 -2
- package/src/retry-jitter.ts +32 -0
- package/src/retry.ts +13 -16
- package/src/scheduler.ts +10 -6
- package/src/steps-api.ts +44 -5
- package/src/steps-suspension.ts +34 -3
- package/src/steps-wait.ts +52 -4
- package/src/steps.ts +1 -0
- package/src/subject-jobs.ts +12 -4
- package/src/webhook.ts +2 -2
- package/src/worker-admit.ts +8 -5
- package/src/worker-drain-cutoff.ts +1 -0
- package/src/worker-loop.ts +28 -5
- package/src/worker-registry.ts +7 -5
- package/src/worker-run.ts +53 -9
- 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.
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
`
|
|
224
|
-
|
|
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
|
|
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 `
|
|
1036
|
-
`JobsConfig.driver` has no reader and boot always builds
|
|
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
|
-
`
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
1589
|
-
span
|
|
1590
|
-
that
|
|
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
|
-
| `
|
|
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
|
-
| `
|
|
1659
|
-
| `
|
|
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": "
|
|
3
|
+
"version": "31.0.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": "
|
|
48
|
-
"@ultimat3/db": "
|
|
49
|
-
"@ultimat3/entity": "
|
|
50
|
-
"@ultimat3/http": "
|
|
51
|
-
"@ultimat3/schema": "
|
|
52
|
-
"@ultimat3/time": "
|
|
48
|
+
"@ultimat3/core": "31.0.0",
|
|
49
|
+
"@ultimat3/db": "31.0.0",
|
|
50
|
+
"@ultimat3/entity": "31.0.0",
|
|
51
|
+
"@ultimat3/http": "31.0.0",
|
|
52
|
+
"@ultimat3/schema": "31.0.0",
|
|
53
|
+
"@ultimat3/time": "31.0.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';
|
package/src/backfill-ledger.ts
CHANGED
|
@@ -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,
|
|
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 =
|
|
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);
|
package/src/backfill-pass.ts
CHANGED
|
@@ -248,7 +248,8 @@ export async function backfillPass<Row>(
|
|
|
248
248
|
//
|
|
249
249
|
// The signal is the run's cancellation composed with this step's ceiling, so a cancelled
|
|
250
250
|
// pass unwinds out of the wait instead of sitting in a timer nobody is waiting for.
|
|
251
|
-
|
|
251
|
+
// The clock is the run's, so a pass under a frozen one waits on it, never in real time.
|
|
252
|
+
await pace.wait({ signal, step: stepName, clock: args.ctx.clock });
|
|
252
253
|
// A backfill visits every row once, in key order: the scan is the point, and the query-plan
|
|
253
254
|
// sweep is told so rather than asked to index a column no read filters on.
|
|
254
255
|
const page = await expectedSeqScan(BACKFILL_SCAN, () => reader.read(cursor, signal));
|
package/src/backfill-rate.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// serving, so a backfill that runs flat out is an outage of its own making — the pacer is what
|
|
4
4
|
// makes "one pass over the table" background work rather than a load test.
|
|
5
5
|
|
|
6
|
-
import type { Clock } from '@ultimat3/core';
|
|
6
|
+
import type { Clock, FrozenClock } from '@ultimat3/core';
|
|
7
7
|
import { assert, systemClock } from '@ultimat3/core';
|
|
8
8
|
import { nowMs } from './clock';
|
|
9
9
|
import { JobAbortedError } from './errors';
|
|
@@ -33,10 +33,12 @@ export interface PacerOptions {
|
|
|
33
33
|
readonly rate: number;
|
|
34
34
|
/** The name a cancellation is reported under — one pacer belongs to exactly one backfill. */
|
|
35
35
|
readonly job: string;
|
|
36
|
+
/** Overrides the run's clock for every wait. Unset: each wait reads the clock it is handed. */
|
|
36
37
|
readonly clock?: Clock | undefined;
|
|
37
38
|
/**
|
|
38
39
|
* The wait itself, injectable for the same reason the clock is: a test asserts what was ASKED
|
|
39
40
|
* for instead of spending it. Must settle when `signal` aborts rather than waiting the rest out.
|
|
41
|
+
* Unset: a frozen clock is advanced by the wait, any other is waited out on a real timer.
|
|
40
42
|
*/
|
|
41
43
|
readonly sleep?: ((ms: number, signal: AbortSignal) => Promise<void>) | undefined;
|
|
42
44
|
}
|
|
@@ -48,7 +50,15 @@ export interface Pacer {
|
|
|
48
50
|
* Hold the caller until this batch's slot comes round. Rejects with `JobAbortedError` when the
|
|
49
51
|
* attempt was cancelled — before the wait or during it, never after sitting the timer out.
|
|
50
52
|
*/
|
|
51
|
-
wait(args: {
|
|
53
|
+
wait(args: {
|
|
54
|
+
readonly signal: AbortSignal;
|
|
55
|
+
readonly step?: string | undefined;
|
|
56
|
+
/**
|
|
57
|
+
* The clock the RUN measures by — `ctx.clock`. The pacer is built once per declaration, so
|
|
58
|
+
* the run's clock can only reach it here; a pass under a frozen clock is paced on that clock.
|
|
59
|
+
*/
|
|
60
|
+
readonly clock?: Clock | undefined;
|
|
61
|
+
}): Promise<void>;
|
|
52
62
|
}
|
|
53
63
|
|
|
54
64
|
/** Real timers, cleared on abort so a cancelled pass leaves nothing pending behind it. */
|
|
@@ -64,6 +74,24 @@ function timerSleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
|
64
74
|
});
|
|
65
75
|
}
|
|
66
76
|
|
|
77
|
+
/** Structural, as `Clock` itself is: a clock that only moves when told to has `advance`. */
|
|
78
|
+
const isFrozenClock = (clock: Clock): clock is FrozenClock =>
|
|
79
|
+
typeof (clock as { readonly advance?: unknown }).advance === 'function';
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The default wait, on the clock the batches are measured by. A frozen clock never moves on its
|
|
83
|
+
* own, so a real timer under one spent wall time the pacer could not even see — every batch read
|
|
84
|
+
* as instant and paid the whole interval for real. Moving the clock IS the wait there: the sweep
|
|
85
|
+
* finishes at once and the clock shows the time the throttle cost.
|
|
86
|
+
*/
|
|
87
|
+
function clockSleep(clock: Clock): (ms: number, signal: AbortSignal) => Promise<void> {
|
|
88
|
+
if (!isFrozenClock(clock)) return timerSleep;
|
|
89
|
+
return (ms) => {
|
|
90
|
+
clock.advance(ms);
|
|
91
|
+
return Promise.resolve();
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
67
95
|
/**
|
|
68
96
|
* Built once, at declaration, and shared by every attempt of that backfill in this process: the
|
|
69
97
|
* rate is a property of the table being swept and the pool it is swept through, not of whichever
|
|
@@ -79,14 +107,15 @@ export function backfillPacer(options: PacerOptions): Pacer {
|
|
|
79
107
|
`backfillPacer({ rate: ${String(options.rate)} }) for "${options.job}" — a rate is batches per second, greater than zero`,
|
|
80
108
|
`pass rate: ${DEFAULT_BACKFILL_RATE} — to sweep faster raise the number, there is no unthrottled mode`,
|
|
81
109
|
);
|
|
82
|
-
const clock = options.clock ?? systemClock;
|
|
83
|
-
const sleep = options.sleep ?? timerSleep;
|
|
84
110
|
const intervalMs = 1000 / options.rate;
|
|
85
111
|
let lastAt: number | undefined;
|
|
86
112
|
|
|
87
113
|
return {
|
|
88
114
|
rate: options.rate,
|
|
89
|
-
async wait({ signal, step }) {
|
|
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.
|
|
117
|
+
const clock = options.clock ?? runClock ?? systemClock;
|
|
118
|
+
const sleep = options.sleep ?? clockSleep(clock);
|
|
90
119
|
const aborted = (): JobAbortedError =>
|
|
91
120
|
new JobAbortedError({ job: options.job, ...(step === undefined ? {} : { step }) });
|
|
92
121
|
if (signal.aborted) throw aborted();
|
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,
|
|
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 =
|
|
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
|
});
|
package/src/driver-memory.ts
CHANGED
|
@@ -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,
|
|
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 ??
|
|
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 = {
|
package/src/driver-pg-ddl.ts
CHANGED
|
@@ -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,
|