@ultimat3/jobs 2.0.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +95 -2
- package/README.md +80 -4
- package/package.json +5 -5
- package/src/driver-pg-ddl.ts +11 -0
- package/src/driver-pg-sql.ts +68 -7
- package/src/errors.ts +24 -1
- package/src/execute.ts +22 -3
- package/src/heartbeat.ts +15 -13
- package/src/index.ts +7 -0
- package/src/job.ts +55 -1
- package/src/outbox-lease.ts +29 -0
- package/src/outbox-pg.ts +58 -7
- package/src/outbox.ts +91 -8
- package/src/renewal-timer.ts +35 -0
- package/src/retry-classification.ts +112 -0
- package/src/task.ts +11 -1
- package/src/worker-fleet-slots.ts +16 -11
package/CLAUDE.md
CHANGED
|
@@ -237,6 +237,30 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
|
|
|
237
237
|
so, not a job that failed: nacking it re-runs completed work and records `retried` in
|
|
238
238
|
`jobs_total{outcome}` for a failure that never happened. It propagates instead, and the worker's
|
|
239
239
|
`jobs.worker.settle-failed` plus the lapsing lease are the honest answer.
|
|
240
|
+
- **The retry decision reads the ERROR as well as the attempt count — added 2026-08.**
|
|
241
|
+
`executeJob` decided a retry from `nextRetry(handle.retry, attempt)` alone, so every `terminal`
|
|
242
|
+
classification in the framework was decorative on the job path: an `X_SCRAPE_AUTH_FAILED` from a
|
|
243
|
+
rotated password burned the whole policy, which at a site that locks an account after three wrong
|
|
244
|
+
passwords makes the framework's retry the thing that destroys the account.
|
|
245
|
+
`retry-classification.ts` composes AROUND `nextRetry` — the backoff arithmetic stays in one
|
|
246
|
+
place — and `nextRetryForError` is the only caller `execute.ts` has.
|
|
247
|
+
|
|
248
|
+
**`classifyThrown` must never read `error.retry` on its own.** That field is
|
|
249
|
+
`init.retry ?? retryFor(code)` and `retryFor` FAILS CLOSED, so every unclassified `UltimateError`
|
|
250
|
+
already carries `terminal`; reading it would dead-letter the first attempt of every job in every
|
|
251
|
+
app whose codes nobody has classified. Hence core's `declaredErrorRetry(code)`, which answers
|
|
252
|
+
`undefined` where `retryFor` answers the default — and hence the one case that is knowingly
|
|
253
|
+
under-read: an instance `retry: 'terminal'` on an UNREGISTERED code is indistinguishable from the
|
|
254
|
+
default and is treated as unclassified. Register the code; that is the one way.
|
|
255
|
+
`retry-after` reuses the delay the nack already takes (`meta.retryAfterSeconds`, clamped by the
|
|
256
|
+
policy's `maxDelay`) rather than a second suspension mechanism — `StepSuspension` stays the only
|
|
257
|
+
way to park a run, and unlike a suspension a retry-after DOES burn an attempt, because the work
|
|
258
|
+
failed. The ceiling outranks every classification but `terminal`.
|
|
259
|
+
|
|
260
|
+
**The verdict is published, not inferred**: `jobs.attempt.failed` and `reportError` carry
|
|
261
|
+
`stop`, `JobExecution` carries `stopReason`, and `recordedFailure` appends the terminal verdict to
|
|
262
|
+
the nack's `error` — `lastError` is the ONE failure field a row has, so without it `x jobs show`
|
|
263
|
+
renders a dead letter at attempt 1 of 5 as a silent early stop.
|
|
240
264
|
- **The claim loop re-arms on the PASS, never on the jobs.** A slot belongs to its own job and is
|
|
241
265
|
free the moment it settles, so `claimRound` starts what it claimed and returns the promises —
|
|
242
266
|
ending the pass on `Promise.allSettled([...inFlight])` made the pool as slow as its slowest
|
|
@@ -401,8 +425,74 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
|
|
|
401
425
|
A SIGTERM landing between `driver.enqueue` and `markPublished` returned to a caller that then
|
|
402
426
|
closed the database under the row it was about to mark. `OutboxRelay.stop(): Promise<void>` — a
|
|
403
427
|
caller that does not await gets what it always got (the chain carries its own `catch`), so the
|
|
404
|
-
join is only as good as the `await
|
|
405
|
-
|
|
428
|
+
join is only as good as the `await`. Both of `packages/cli/src/dev-roles.ts`'s paths take it
|
|
429
|
+
today (`:306` returns `() => relay.stop()` so the rollback awaits it, `:356` is
|
|
430
|
+
`await relay?.stop()`); this file claimed the opposite until 2026-08.
|
|
431
|
+
- **The outbox claim is a LEASE, and one statement is what makes it one** (`As of 2026-08`).
|
|
432
|
+
`SQL_OUTBOX_CLAIM` was a bare `select ... for update skip locked` run on the POOLED executor, and
|
|
433
|
+
those row locks last only for their own statement — under autocommit they are gone before
|
|
434
|
+
`claim()` resolves, and `x_outbox` had no claimed column, so nothing fenced the batch at all. Two
|
|
435
|
+
relays 200ms apart read the identical rows and both published them. **The idempotency key does
|
|
436
|
+
not collapse that in general**: `SQL_ENQUEUE`'s conflict target is the PARTIAL index over
|
|
437
|
+
`('ready','delayed','running','suspended')`, so a repeat landing after the first job reached a
|
|
438
|
+
terminal state inserts a second row and the handler runs twice. The mechanism is Postgres
|
|
439
|
+
semantics; the second-publish-after-terminal ordering was argued, never reproduced — say it that
|
|
440
|
+
way, in a comment or a changelog. The claim now stamps `claimed_at`/`claimed_by` in the same
|
|
441
|
+
statement that locks (the CTE shape `SQL_CLAIM` already used), and the outer
|
|
442
|
+
`select ... order by staged_at, id` is load-bearing: `update ... returning` has no defined row
|
|
443
|
+
order and the relay publishes in the order it is handed rows. `claimed_at` is a LEASE and not a
|
|
444
|
+
flag — without a reclaim window a relay that died mid-batch strands its rows forever — and
|
|
445
|
+
`OutboxStore.release` (optional, so a store written before this still compiles) hands back the
|
|
446
|
+
batch a failed publish stopped, or one pool blip would park committed work for a whole lease
|
|
447
|
+
window instead of one poll interval. `createMemoryOutboxStore` answers the SAME question, on an
|
|
448
|
+
injected clock, and `outbox-claim.test.ts` pins the two side by side.
|
|
449
|
+
- **The lease is fenced on EVERY outbox mutation, and the sort key is TOTAL** (`As of 2026-08`).
|
|
450
|
+
Two holes the first version of the lease left open, both reachable without a second relay
|
|
451
|
+
process. `SQL_OUTBOX_RELEASE` and `SQL_OUTBOX_MARK_PUBLISHED` matched on `id` alone, so a relay
|
|
452
|
+
that stalled past its own lease still spoke for rows another relay had reclaimed: its late
|
|
453
|
+
`release` unclaimed a batch mid-publish (a third relay claims it, publishes it again — the
|
|
454
|
+
duplicate the lease exists to prevent, reached the long way round) and its late `markPublished`
|
|
455
|
+
retired a row nobody had published, losing the job with nothing to notice. Both now carry
|
|
456
|
+
`and claimed_by = $n`, `claim()` hands the token back as `OutboxRecord.claimedBy`, and the relay
|
|
457
|
+
passes it to both calls. `markPublished` also gained `published_at is null`, so the stamp is
|
|
458
|
+
first-writer-wins rather than a rewrite of an audit timestamp. The memory store fences the same
|
|
459
|
+
way — per CLAIM there rather than per relay, because two relays there are two `claim()` calls on
|
|
460
|
+
ONE store, and a per-store id could not tell them apart. `undefined` is unfenced in both, for the
|
|
461
|
+
reason `release` is optional: a caller holding no token is one written before the fence.
|
|
462
|
+
**`order by staged_at` was not a total order**: every row staged in one transaction shares a
|
|
463
|
+
`staged_at`, so the tie was the planner's to break — which rows the `limit` takes, and in which
|
|
464
|
+
order they publish, differed between two relays and between two runs of one. `, id` fixes it in
|
|
465
|
+
the CTE and in the projection, and needed NO DDL: `id` is a UUIDv7 minted by `uuid()`, monotonic
|
|
466
|
+
and already the primary key, so the tiebreak IS stage order. `byClaimOrder` in `outbox.ts` is the
|
|
467
|
+
memory store's copy of that key.
|
|
468
|
+
- **`claimLeaseMs` is normalised in ONE place — `outbox-lease.ts`** (`As of 2026-08`). Both stores
|
|
469
|
+
call `resolveClaimLeaseMs`, which owns `DEFAULT_OUTBOX_CLAIM_LEASE_MS` and refuses anything that
|
|
470
|
+
is not a positive whole number of ms with `X_INVARIANT` (the generic, no new code: same borrow
|
|
471
|
+
`@ultimat3/db` makes). A memory default and a pg default that could drift are two answers to
|
|
472
|
+
"how long is a claim mine for", and the shorter one publishes a row twice. `0` expires before
|
|
473
|
+
`claim()` resolves and `Infinity` never expires, so both are refused at CONSTRUCTION, not at the
|
|
474
|
+
first tick where the only trace is a log line.
|
|
475
|
+
- **A renewal is decided against `stopped()`, not only against the interval** (`As of 2026-08`).
|
|
476
|
+
`renewal-timer.ts` is the one shape, read by `heartbeat.ts` and `worker-fleet-slots.ts`, and it
|
|
477
|
+
exists because both files reported a LOSS for a job that had finished cleanly: `stop()` cleared
|
|
478
|
+
the interval, which does nothing to the request already on the wire, so the fenced statement came
|
|
479
|
+
back `false` — the row left `running` when `executeJob` acked it — and that answer took the loss
|
|
480
|
+
branch. `jobs.lease.lost` at error plus `recordLeaseLost(queue)` is the one signal meaning the
|
|
481
|
+
queue re-delivered a job this process was still running, so a false one is a page for a
|
|
482
|
+
non-event, and the window widens exactly when the pool is slow. Re-read the flag AFTER every
|
|
483
|
+
await and inside the reporter, the way `settleWithin`'s `decided` does in core.
|
|
484
|
+
- **`stepTimeout` and `eventPoll` are DECLARED on the job, and `execute.ts` is the only place they
|
|
485
|
+
are forwarded** (`As of 2026-08`). `StepRunnerOptions` carried both, `withStepTimeout`
|
|
486
|
+
implemented the ceiling and `steps.test.ts` exercised it by building a runner BY HAND — while the
|
|
487
|
+
only production construction passed neither and `JobDefinition` had no field that could. Same
|
|
488
|
+
verdict as `job.concurrency`: a documented guarantee that silently does nothing is the worst of
|
|
489
|
+
the three options, so it is threaded rather than deleted. Both are refused at declaration when
|
|
490
|
+
non-positive, because `withStepTimeout` reads `<= 0` as "no ceiling at all".
|
|
491
|
+
- **`registeredJobs()`/`registeredTasks()` sort by CODE UNITS, never `localeCompare`.** The list is
|
|
492
|
+
projected by `describeJobs()` into `x.manifest.json`, which both tracked apps commit and
|
|
493
|
+
`x verify`'s `drift` step diffs byte for byte; `localeCompare` with no locale argument answers
|
|
494
|
+
from the runtime's ICU default and collation version. Same rule as `@ultimat3/http`'s
|
|
495
|
+
`describeRoutes`, restated locally rather than imported — `http` is not below this package.
|
|
406
496
|
- **A driver's semantics are pinned in ONE test with the pg statement beside them.**
|
|
407
497
|
`driver-parity.test.ts` asserts the memory driver's behaviour and the SQL that has to mean the
|
|
408
498
|
same thing in a single test, so neither side can move alone. `introspect.list` answered
|
|
@@ -519,6 +609,7 @@ picture from the other side.
|
|
|
519
609
|
| `steps.ts` | `StepStore`, `StepApi`, memoized-replay executor, `StepSuspension` |
|
|
520
610
|
| `outbox.ts` | staging in a `Tx`, the relay, the ambient `JobsFacade` slot |
|
|
521
611
|
| `outbox-pg.ts` | `createPgOutboxStore` — `stage()` on the caller's OWN connection, claim on the pool |
|
|
612
|
+
| `outbox-lease.ts` | the claim lease's one definition and its one normalisation, for both stores |
|
|
522
613
|
| `leases.ts` | `LeaseStore` — fleet-wide slots, the memory one, `jobLeaseKey` |
|
|
523
614
|
| `metrics.ts` | `queue_oldest_ready_seconds` and `queue_dead_jobs`, the two alertable gauges |
|
|
524
615
|
| `scheduler-pg.ts` | `pgSchedulerState` (the durable watermark) + `createPgLeaseLeader` |
|
|
@@ -530,8 +621,10 @@ picture from the other side.
|
|
|
530
621
|
| `driver-memory.ts` | `x dev` / tests |
|
|
531
622
|
| `driver-redis.ts`, `driver-nats.ts` | honest `X_NOT_IMPLEMENTED` stubs |
|
|
532
623
|
| `retry.ts` | backoff arithmetic, dead-letter decision |
|
|
624
|
+
| `retry-classification.ts` | the OTHER half of that decision: what the thrown error says, and the stop reason the row and the log carry |
|
|
533
625
|
| `execute.ts` | `executeJob` — one claimed job run and settled, and the run's deadline/cancel |
|
|
534
626
|
| `heartbeat.ts` | one claimed job's lease: the renewal interval and the loss it reports |
|
|
627
|
+
| `renewal-timer.ts` | the interval a renewal runs on, and the `stopped()` latch every branch after an await re-reads |
|
|
535
628
|
| `worker.ts` | `worker` role, claim loop, drain |
|
|
536
629
|
| `worker-run.ts` | one claimed job, wired: its heartbeat, its slot renewal, its run signal and its span, started together and handed back in one `finally` |
|
|
537
630
|
| `run-signal.ts` | the signal ONE run is cancelled by — composition that can be handed back, and that the worker can abort itself |
|
package/README.md
CHANGED
|
@@ -315,6 +315,20 @@ Nothing can kill a body that ignores the signal, so the durable state is fenced:
|
|
|
315
315
|
cancel every step write is refused with `X_ABORTED`, and a run that finishes anyway is logged
|
|
316
316
|
as `jobs.timeout.abandoned` — the one way to find a handler that never reads `ctx.signal`.
|
|
317
317
|
|
|
318
|
+
Three ceilings, declared on the job and nowhere else (`As of 2026-08` — `stepTimeout` and
|
|
319
|
+
`eventPoll` had been implemented in the step runner since 1.0 with no declaration able to reach
|
|
320
|
+
them, so no `job()` could ask for either):
|
|
321
|
+
|
|
322
|
+
| Field | Bounds | Absent |
|
|
323
|
+
|---|---|---|
|
|
324
|
+
| `timeout` | the whole attempt — aborts `ctx.signal`, then fails it | no attempt deadline |
|
|
325
|
+
| `stepTimeout` | ONE `step.run` — aborts that step's signal, then fails the step | no per-step ceiling |
|
|
326
|
+
| `eventPoll` | how long a `step.waitForEvent` parks between polls | 30s |
|
|
327
|
+
|
|
328
|
+
A zero or negative `stepTimeout` / `eventPoll` is refused at declaration, the way `concurrency: 0`
|
|
329
|
+
is: `withStepTimeout` reads `<= 0` as "no ceiling at all", which is the opposite of what the author
|
|
330
|
+
wrote.
|
|
331
|
+
|
|
318
332
|
## The transactional outbox
|
|
319
333
|
|
|
320
334
|
```ts
|
|
@@ -334,10 +348,15 @@ it after commit. The bug class this removes:
|
|
|
334
348
|
|
|
335
349
|
Both are load-dependent, both pass every test you would write, and both produce "the email
|
|
336
350
|
went out but the order isn't in the database". Joining the transaction closes the window.
|
|
337
|
-
The relay publishes *then* marks published, so a crash re-publishes —
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
351
|
+
The relay publishes *then* marks published, so a crash re-publishes — and **that repeat is
|
|
352
|
+
collapsed only while the first job is still live** (`As of 2026-08`): `SQL_ENQUEUE`'s conflict
|
|
353
|
+
target is a partial index over `ready`/`delayed`/`running`/`suspended`, so a re-publish landing
|
|
354
|
+
after the first job reached a terminal state inserts a second row and the handler runs again.
|
|
355
|
+
**Handlers are at-least-once. Write them idempotent** — that is the standing contract, not a
|
|
356
|
+
caveat on this paragraph. A publish that FAILS stops the batch rather than letting later rows
|
|
357
|
+
overtake it: `claim()` returns rows in `staged_at, id` order — total, so two relays compose the
|
|
358
|
+
same batch in the same order — and an app that stages `createInvoice` then `chargeCard` in one
|
|
359
|
+
transaction must never have the charge run first. Set `mode: 'required'` to
|
|
341
360
|
make an enqueue outside a transaction an `X_OUTBOX_NO_TX` error instead of a direct publish.
|
|
342
361
|
|
|
343
362
|
**It is not on by default, and it is not on until you install it** (`As of 2026-08`). Three
|
|
@@ -355,6 +374,34 @@ transactional: `stage()` runs on the CALLER'S connection, never the pool. With n
|
|
|
355
374
|
publishes straight to the driver — deliberate, so a script and a test enqueue with no wiring, but
|
|
356
375
|
it is a fallback and not the guarantee.
|
|
357
376
|
|
|
377
|
+
`claim()` is a **claim, not a read** (`As of 2026-08`). `for update skip locked` in a bare select
|
|
378
|
+
holds its row locks only until that statement ends — under autocommit, before `claim()` even
|
|
379
|
+
resolves — so two relays polling 200ms apart read the same unpublished rows and both publish them.
|
|
380
|
+
`SQL_ENQUEUE` collapses that repeat only while the first job is still LIVE, because its conflict
|
|
381
|
+
target is a partial index over the live states: a second publish landing after that job finished
|
|
382
|
+
inserts a second row and **the handler runs twice**. So the claim stamps `claimed_at` in the same
|
|
383
|
+
statement that locks the row, and that stamp is a lease — `claimLeaseMs` (a positive whole number
|
|
384
|
+
of ms, default 30s; anything else is `X_INVARIANT` at construction) is how long the rows of a relay
|
|
385
|
+
that DIED mid-batch wait before any relay may take them again. A batch a failed publish stopped is
|
|
386
|
+
handed back at once through `release`, so a pool blip still costs one poll interval and not a lease
|
|
387
|
+
window.
|
|
388
|
+
|
|
389
|
+
**Every outbox mutation is fenced on the claimant, not just the claim** (`As of 2026-08`).
|
|
390
|
+
`release` and `markPublished` both match on `claimed_by`, and `claim()` hands the token back on
|
|
391
|
+
each record as `claimedBy`. A relay that stalls past its lease wakes up owning nothing: its late
|
|
392
|
+
`release` would otherwise unclaim rows the relay that reclaimed them is mid-publish on (a third
|
|
393
|
+
relay claims and republishes them), and its late `markPublished` would retire a row nobody has
|
|
394
|
+
published yet — losing the job outright. Both are no-ops now, in the pg store and in the memory
|
|
395
|
+
store alike.
|
|
396
|
+
|
|
397
|
+
What the lease buys, precisely:
|
|
398
|
+
|
|
399
|
+
| It stops | It does not stop |
|
|
400
|
+
|---|---|
|
|
401
|
+
| two relays holding one batch — a committed row cannot be claimed twice inside its lease | the handler running twice |
|
|
402
|
+
| a lapsed claimant releasing or retiring a newer claimant's rows | a crash between publish and `markPublished` re-publishing after the first job is terminal |
|
|
403
|
+
| a relay that died mid-batch stranding its rows forever | anything a **non-idempotent** handler does on its second run |
|
|
404
|
+
|
|
358
405
|
The memory store (`createMemoryOutboxStore`, `x dev` and tests) **drops** a published row —
|
|
359
406
|
`retained()` is the relay's backlog, not a running total; the pg store keeps `published_at` as
|
|
360
407
|
the audit trail this map is not. A relay pass that throws is logged as `jobs.outbox.tick-failed`
|
|
@@ -447,6 +494,28 @@ retrySchedule({ attempts: 5, backoff: 'exponential', delay: 1000 })
|
|
|
447
494
|
// => [1000, 2000, 4000, 8000]
|
|
448
495
|
```
|
|
449
496
|
|
|
497
|
+
**The error decides too, not only the attempt count** (`As of 2026-08`). `executeJob` reads the
|
|
498
|
+
thrown error's retry classification — `@ultimat3/core`'s `registerErrorRetry`, the same table
|
|
499
|
+
`--json` and an HTTP client read — and a code nobody classified keeps the attempt-count path
|
|
500
|
+
exactly as it had before.
|
|
501
|
+
|
|
502
|
+
| Thrown | What the queue does |
|
|
503
|
+
|---|---|
|
|
504
|
+
| 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 |
|
|
505
|
+
| a `retry-after` code (`X_RATE_LIMITED`, `X_OVERLOADED`) | retried at the time the responder NAMED — `meta.retryAfterSeconds`, clamped by the policy's `maxDelay` — instead of the backoff. Still an attempt, still under the ceiling |
|
|
506
|
+
| a `retryable` code (`X_TIMEOUT`, `X_DRAINING`) | the backoff schedule above, unchanged |
|
|
507
|
+
| 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 |
|
|
508
|
+
|
|
509
|
+
Classify your app's codes beside the module that declares them — that import IS the registration:
|
|
510
|
+
|
|
511
|
+
```
|
|
512
|
+
registerErrorRetry({ X_INVOICE_REJECTED: 'terminal', X_GATEWAY_BUSY: 'retry-after' });
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
Why a job stopped is on the row and in the log, never inferred: `jobs.attempt.failed` carries
|
|
516
|
+
`stop: 'terminal' | 'attempts-exhausted'`, `JobExecution.stopReason` carries the same, and a
|
|
517
|
+
terminal dead letter appends its verdict to `lastError` so `x jobs show` explains an attempt 1 of 5.
|
|
518
|
+
|
|
450
519
|
## Limits
|
|
451
520
|
|
|
452
521
|
Two layers, and the difference matters:
|
|
@@ -486,6 +555,13 @@ still running it — at-least-once turning into twice. Alert on any non-zero rat
|
|
|
486
555
|
measured from the last renewal that **landed**, on this process's clock, so a driver whose
|
|
487
556
|
heartbeat hangs is caught the same as one that rejects.
|
|
488
557
|
|
|
558
|
+
Neither fires for a job that finished (`As of 2026-08`). `stop()` is terminal for the renewal
|
|
559
|
+
already **on the wire**, not only for the next one: a clean completion acks the row out of
|
|
560
|
+
`running`, so the fenced UPDATE already in flight comes back `false` — and reported, that was
|
|
561
|
+
`jobs.lease.lost` at error plus the counter, a page for a non-event, on every completed job whose
|
|
562
|
+
pool was slow enough. The fleet slot's `jobs.worker.slot-lost` had the same shape and the same
|
|
563
|
+
fix (`renewal-timer.ts`).
|
|
564
|
+
|
|
489
565
|
## Introspection
|
|
490
566
|
|
|
491
567
|
`inspectQueues`, `inspectJob` (per-step trace), `inspectDeadLetters`, `retryFromStep`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/jobs",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Durable background work: steps, transactional outbox, cron tasks, one driver interface",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
"test": "bun test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/entity": "
|
|
37
|
-
"@ultimat3/schema": "
|
|
38
|
-
"@ultimat3/time": "
|
|
35
|
+
"@ultimat3/core": "3.0.0",
|
|
36
|
+
"@ultimat3/entity": "3.0.0",
|
|
37
|
+
"@ultimat3/schema": "3.0.0",
|
|
38
|
+
"@ultimat3/time": "3.0.0"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/src/driver-pg-ddl.ts
CHANGED
|
@@ -109,6 +109,15 @@ create table if not exists x_outbox (
|
|
|
109
109
|
create index if not exists x_outbox_unpublished_idx
|
|
110
110
|
on x_outbox (staged_at) where published_at is null;
|
|
111
111
|
|
|
112
|
+
-- The relays claim lease. A claim stamped in the same statement that locks the row is what stops
|
|
113
|
+
-- two relays publishing one row twice: for update skip locked holds its locks only until that
|
|
114
|
+
-- statement ends, which under autocommit is before claim returns. claimed_at is also what gives
|
|
115
|
+
-- back the rows of a relay that died mid-batch, since a claim nothing can expire strands them.
|
|
116
|
+
-- Added by alter because x_outbox shipped without them.
|
|
117
|
+
alter table x_outbox add column if not exists claimed_at timestamptz;
|
|
118
|
+
|
|
119
|
+
alter table x_outbox add column if not exists claimed_by text;
|
|
120
|
+
|
|
112
121
|
-- The scheduler watermark. Without a durable one a redeployed scheduler has no idea what the
|
|
113
122
|
-- pod it replaced already fired, so runRound takes the arming branch and every occurrence
|
|
114
123
|
-- between the two processes is dropped with nothing logged.
|
|
@@ -177,4 +186,6 @@ create table if not exists x_outbox (
|
|
|
177
186
|
);
|
|
178
187
|
create index if not exists x_outbox_unpublished_idx
|
|
179
188
|
on x_outbox (staged_at) where published_at is null;
|
|
189
|
+
alter table x_outbox add column if not exists claimed_at timestamptz;
|
|
190
|
+
alter table x_outbox add column if not exists claimed_by text;
|
|
180
191
|
`.trim();
|
package/src/driver-pg-sql.ts
CHANGED
|
@@ -271,20 +271,81 @@ values ($1, $2, $3, $4::jsonb, $5, $6, to_timestamp($7 / 1000.0), to_timestamp($
|
|
|
271
271
|
$9, $10, $11)
|
|
272
272
|
`.trim();
|
|
273
273
|
|
|
274
|
+
/**
|
|
275
|
+
* The claim, and it has to be ONE statement. `for update skip locked` in a bare select holds its
|
|
276
|
+
* row locks only until that statement ends — under autocommit, before `claim()` even resolves — so
|
|
277
|
+
* two relays polling 200ms apart read the same unpublished rows and both hand them to `enqueue`.
|
|
278
|
+
* `SQL_ENQUEUE` collapses the repeat only while the first job is still LIVE: its conflict target
|
|
279
|
+
* is a partial index over the live states, so a second publish landing after that job reached a
|
|
280
|
+
* terminal state inserts a second row and the handler runs again. (The mechanism is Postgres
|
|
281
|
+
* semantics; how often the two orderings line up in a deployment was never measured.)
|
|
282
|
+
*
|
|
283
|
+
* So the lock and the claim commit together, the CTE shape `SQL_CLAIM` already uses, and
|
|
284
|
+
* `claimed_at` is a LEASE: `$2` is the window after which a row a dead relay was holding is
|
|
285
|
+
* claimable again, because a claim nothing can expire strands its rows forever.
|
|
286
|
+
*
|
|
287
|
+
* The outer `select ... order by staged_at, id` is not cosmetic. `update ... returning` has no
|
|
288
|
+
* defined row order and the relay publishes in the order it is handed rows, so an app staging
|
|
289
|
+
* `createInvoice` then `chargeCard` in one transaction depends on this line.
|
|
290
|
+
*
|
|
291
|
+
* `, id` is what makes that key TOTAL, and the CTE needs it as much as the projection does: every
|
|
292
|
+
* row staged in one transaction shares a `staged_at`, so `staged_at` alone leaves the tie to the
|
|
293
|
+
* planner — which rows a `limit` takes, and in which order they publish, then differ between two
|
|
294
|
+
* relays and between two runs of one relay. No column was added for it: `id` is a UUIDv7 minted by
|
|
295
|
+
* `uuid()`, monotonic and already the primary key, so the tiebreak IS stage order.
|
|
296
|
+
*/
|
|
274
297
|
export const SQL_OUTBOX_CLAIM = `
|
|
298
|
+
with claimable as (
|
|
299
|
+
select id
|
|
300
|
+
from x_outbox
|
|
301
|
+
where published_at is null
|
|
302
|
+
and (claimed_at is null
|
|
303
|
+
or claimed_at <= now() - ($2::bigint * interval '1 millisecond'))
|
|
304
|
+
order by staged_at, id
|
|
305
|
+
limit $1
|
|
306
|
+
for update skip locked
|
|
307
|
+
), claimed as (
|
|
308
|
+
update x_outbox o
|
|
309
|
+
set claimed_at = now(), claimed_by = $3
|
|
310
|
+
from claimable c
|
|
311
|
+
where o.id = c.id
|
|
312
|
+
returning o.id, o.job, o.queue, o.input, o.idempotency_key, o.max_attempts, o.tenant_id,
|
|
313
|
+
o.traceparent, o.enqueued_by, o.claimed_by, o.run_at, o.staged_at
|
|
314
|
+
)
|
|
275
315
|
select id, job, queue, input, idempotency_key, max_attempts, tenant_id,
|
|
276
|
-
traceparent, enqueued_by,
|
|
316
|
+
traceparent, enqueued_by, claimed_by,
|
|
277
317
|
(extract(epoch from run_at) * 1000)::bigint as run_at,
|
|
278
318
|
(extract(epoch from staged_at) * 1000)::bigint as staged_at
|
|
279
|
-
from
|
|
280
|
-
|
|
281
|
-
order by staged_at
|
|
282
|
-
limit $1
|
|
283
|
-
for update skip locked
|
|
319
|
+
from claimed
|
|
320
|
+
order by staged_at, id
|
|
284
321
|
`.trim();
|
|
285
322
|
|
|
323
|
+
/**
|
|
324
|
+
* Hand a claim back early. The relay stops its batch on the first publish that fails, and without
|
|
325
|
+
* this the rows behind it would wait out the whole lease before any relay could retry them — a
|
|
326
|
+
* pool blip during a failover becoming tens of seconds of unpublished, committed work.
|
|
327
|
+
*
|
|
328
|
+
* Fenced on `published_at is null` so it can never unclaim a row some other pass already
|
|
329
|
+
* published, AND on `claimed_by` so it can never unclaim one a NEWER claimant now holds: a relay
|
|
330
|
+
* whose lease lapsed while it stalled wakes into a world where its batch is another relay's, and
|
|
331
|
+
* an unfenced release frees rows that relay is mid-publish on — a third relay claims them and
|
|
332
|
+
* publishes them again, which is the duplicate the lease exists to prevent.
|
|
333
|
+
*/
|
|
334
|
+
export const SQL_OUTBOX_RELEASE = `
|
|
335
|
+
update x_outbox
|
|
336
|
+
set claimed_at = null, claimed_by = null
|
|
337
|
+
where id = any($1::uuid[]) and published_at is null and claimed_by = $2
|
|
338
|
+
`.trim();
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Same fence, and here it is the more expensive one to miss: marking a row published is LOSING it,
|
|
342
|
+
* so a lapsed claimant stamping a row the current one has not published yet drops that job with
|
|
343
|
+
* nothing to notice. `published_at is null` makes the stamp first-writer-wins rather than a
|
|
344
|
+
* rewrite of an audit timestamp.
|
|
345
|
+
*/
|
|
286
346
|
export const SQL_OUTBOX_MARK_PUBLISHED = `
|
|
287
|
-
update x_outbox set published_at = to_timestamp($2 / 1000.0)
|
|
347
|
+
update x_outbox set published_at = to_timestamp($2 / 1000.0)
|
|
348
|
+
where id = $1 and published_at is null and claimed_by = $3
|
|
288
349
|
`.trim();
|
|
289
350
|
|
|
290
351
|
export const SQL_OUTBOX_PENDING_COUNT = `
|
package/src/errors.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The X_* codes owned by @ultimat3/jobs. Every one names the command or code change that
|
|
2
2
|
// fixes it — a job failure an agent cannot act on is a job failure that gets retried forever.
|
|
3
|
-
import { registerErrorCodes, UltimateError } from '@ultimat3/core';
|
|
3
|
+
import { registerErrorCodes, registerErrorRetry, UltimateError } from '@ultimat3/core';
|
|
4
4
|
|
|
5
5
|
/** Codes this package declares and owns. */
|
|
6
6
|
export const JOB_OWNED_ERROR_CODES = [
|
|
@@ -66,6 +66,29 @@ registerErrorCodes(
|
|
|
66
66
|
Object.fromEntries(Object.entries(JOB_ERROR_TITLES).map(([code, title]) => [code, { title }])),
|
|
67
67
|
);
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* The codes of this package's that can be thrown INSIDE a job body, classified — `executeJob`
|
|
71
|
+
* reads this, so a `terminal` one dead-letters on the attempt it happened instead of spending the
|
|
72
|
+
* whole policy on an answer that cannot change. Same rule every package uses: retryable means the
|
|
73
|
+
* same code, run again, has a real chance of a different answer.
|
|
74
|
+
*
|
|
75
|
+
* Two are deliberately absent. `X_JOB_LEASE_LOST` and `X_JOB_SLOT_LOST` mean the row is somebody
|
|
76
|
+
* else's now, so this attempt's verdict is not this attempt's to give: dead-lettering would settle
|
|
77
|
+
* a job another worker is running. They keep the attempt-count path, which ends in the queue
|
|
78
|
+
* re-delivering — the honest outcome for "we stopped owning it".
|
|
79
|
+
*/
|
|
80
|
+
registerErrorRetry({
|
|
81
|
+
X_JOB_TIMEOUT: 'retryable',
|
|
82
|
+
X_DRIVER_UNAVAILABLE: 'retryable',
|
|
83
|
+
// A second `step.run` under one name is a defect in the handler, replayed identically forever.
|
|
84
|
+
X_STEP_DUPLICATE: 'terminal',
|
|
85
|
+
// A sweep whose source ran dry while its own count still matches rows: the next attempt resumes
|
|
86
|
+
// at the cursor that just ran dry and diverges again.
|
|
87
|
+
X_BACKFILL_STALLED: 'terminal',
|
|
88
|
+
X_BACKFILL_ENVIRONMENT: 'terminal',
|
|
89
|
+
X_BACKFILL_APPLIED: 'terminal',
|
|
90
|
+
});
|
|
91
|
+
|
|
69
92
|
const docsFor = (code: JobErrorCode): string => `https://ultimate.dev/errors/${code}`;
|
|
70
93
|
|
|
71
94
|
/** An enqueue collided with a live job holding the same idempotency key under `onConflict: 'error'`. */
|
package/src/execute.ts
CHANGED
|
@@ -18,7 +18,8 @@ import type { ClaimedJob, JobDriver } from './driver';
|
|
|
18
18
|
import { JobAbortedError, JobTimeoutError } from './errors';
|
|
19
19
|
import { eventBus } from './events';
|
|
20
20
|
import type { AnyJobHandle } from './job';
|
|
21
|
-
import {
|
|
21
|
+
import type { JobStopReason } from './retry-classification';
|
|
22
|
+
import { nextRetryForError, recordedFailure } from './retry-classification';
|
|
22
23
|
import type { EventLookup, StepRecord } from './steps';
|
|
23
24
|
import { createStepRunner, isStepSuspension } from './steps';
|
|
24
25
|
import { jobRunActor } from './tenant';
|
|
@@ -69,6 +70,8 @@ export interface JobExecution {
|
|
|
69
70
|
readonly durationMs: number;
|
|
70
71
|
readonly resumeAt?: number;
|
|
71
72
|
readonly error?: string;
|
|
73
|
+
/** Why this attempt was the last. Absent while the job is still being retried. */
|
|
74
|
+
readonly stopReason?: JobStopReason;
|
|
72
75
|
readonly steps: readonly StepRecord[];
|
|
73
76
|
readonly replayed: readonly string[];
|
|
74
77
|
}
|
|
@@ -105,6 +108,11 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
|
|
|
105
108
|
jobName: handle.name,
|
|
106
109
|
store: driver.steps,
|
|
107
110
|
signal,
|
|
111
|
+
// The DECLARED per-step ceiling and event poll. Passed here or nowhere: this is the only
|
|
112
|
+
// production construction of a runner, so a `StepRunnerOptions` field it omits is a feature
|
|
113
|
+
// no `job()` can reach — which both of these were until 2026-08.
|
|
114
|
+
...(handle.stepTimeoutMs === undefined ? {} : { stepTimeoutMs: handle.stepTimeoutMs }),
|
|
115
|
+
...(handle.eventPollMs === undefined ? {} : { eventPollMs: handle.eventPollMs }),
|
|
108
116
|
...(options.clock === undefined ? {} : { clock: options.clock }),
|
|
109
117
|
events: options.events ?? eventBus(),
|
|
110
118
|
});
|
|
@@ -166,10 +174,16 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
|
|
|
166
174
|
}
|
|
167
175
|
|
|
168
176
|
const message = error instanceof Error ? error.message : String(error);
|
|
169
|
-
|
|
177
|
+
// The ERROR decides too, not only the attempt count. A `terminal` code — a rotated password,
|
|
178
|
+
// a schema mismatch, a permission denial — fails identically on every remaining attempt, so
|
|
179
|
+
// spending them is a queue slot, a provider bill and, at a site that locks an account after
|
|
180
|
+
// three wrong passwords, the framework destroying what it was asked to read. A code nobody
|
|
181
|
+
// classified keeps the attempt-count path exactly as it was.
|
|
182
|
+
const decision = nextRetryForError(handle.retry, claimed.attempt, error);
|
|
183
|
+
const stop = decision.stoppedBy;
|
|
170
184
|
await driver.nack(claimed.id, {
|
|
171
185
|
delayMs: decision.delayMs,
|
|
172
|
-
error: message,
|
|
186
|
+
error: recordedFailure(message, decision),
|
|
173
187
|
countsAsAttempt: true,
|
|
174
188
|
deadLetter: !decision.retry && decision.deadLetter,
|
|
175
189
|
});
|
|
@@ -178,6 +192,9 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
|
|
|
178
192
|
jobId: claimed.id,
|
|
179
193
|
attempt: claimed.attempt,
|
|
180
194
|
retry: decision.retry,
|
|
195
|
+
// "stopped because terminal" and "stopped because the attempts ran out" are different
|
|
196
|
+
// incidents with the same `retry: false`, and only one of them is fixed by raising attempts.
|
|
197
|
+
...(stop === undefined ? {} : { stop }),
|
|
181
198
|
error: message,
|
|
182
199
|
});
|
|
183
200
|
// This package's ONE error-reporting call site, and it is here rather than in the loop because
|
|
@@ -195,6 +212,7 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
|
|
|
195
212
|
runId: claimed.runId,
|
|
196
213
|
attempt: claimed.attempt,
|
|
197
214
|
retry: decision.retry,
|
|
215
|
+
...(stop === undefined ? {} : { stop }),
|
|
198
216
|
},
|
|
199
217
|
},
|
|
200
218
|
});
|
|
@@ -205,6 +223,7 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
|
|
|
205
223
|
attempt: claimed.attempt,
|
|
206
224
|
durationMs: nowMs(options.clock) - startedAt,
|
|
207
225
|
error: message,
|
|
226
|
+
...(stop === undefined ? {} : { stopReason: stop }),
|
|
208
227
|
steps: [],
|
|
209
228
|
replayed: [],
|
|
210
229
|
});
|
package/src/heartbeat.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { logger, recordLeaseLost } from '@ultimat3/core';
|
|
|
8
8
|
import { nowMs } from './clock';
|
|
9
9
|
import type { ClaimedJob, JobDriver } from './driver';
|
|
10
10
|
import { LeaseLostError } from './errors';
|
|
11
|
+
import { startRenewalTimer } from './renewal-timer';
|
|
11
12
|
|
|
12
13
|
export interface LeaseHeartbeatOptions {
|
|
13
14
|
/** Only `heartbeat` is used — a lease renews itself and settles nothing. */
|
|
@@ -49,14 +50,8 @@ export function startLeaseHeartbeat(options: LeaseHeartbeatOptions): LeaseHeartb
|
|
|
49
50
|
let renewedAt = now();
|
|
50
51
|
let renewing = false;
|
|
51
52
|
let lost = false;
|
|
52
|
-
let timer: ReturnType<typeof setInterval> | undefined;
|
|
53
53
|
const gone = new AbortController();
|
|
54
54
|
|
|
55
|
-
const stop = (): void => {
|
|
56
|
-
if (timer !== undefined) clearInterval(timer);
|
|
57
|
-
timer = undefined;
|
|
58
|
-
};
|
|
59
|
-
|
|
60
55
|
const lapsed = (): boolean => now() - renewedAt >= visibilityTimeoutMs;
|
|
61
56
|
|
|
62
57
|
/**
|
|
@@ -65,9 +60,14 @@ export function startLeaseHeartbeat(options: LeaseHeartbeatOptions): LeaseHeartb
|
|
|
65
60
|
* ticked once per interval would count intervals, not jobs.
|
|
66
61
|
*/
|
|
67
62
|
const reportLost = (error?: unknown, reason?: 'expired' | 'not-ours'): void => {
|
|
68
|
-
|
|
63
|
+
// `stopped()` as well as `lost`, and it is the difference between a page and a fact: a clean
|
|
64
|
+
// completion acks the row out of `running` and `worker-run.ts` stops the heartbeat, so a
|
|
65
|
+
// renewal already in flight comes back `false` for a job that FINISHED. Reported, that is
|
|
66
|
+
// `jobs.lease.lost` at error plus `recordLeaseLost(queue)` — the one signal meaning the queue
|
|
67
|
+
// re-delivered a job this process was still running — raised for a run nobody re-delivered.
|
|
68
|
+
if (lost || timer.stopped()) return;
|
|
69
69
|
lost = true;
|
|
70
|
-
stop();
|
|
70
|
+
timer.stop();
|
|
71
71
|
logger.error('jobs.lease.lost', {
|
|
72
72
|
workerId,
|
|
73
73
|
job: claimed.name,
|
|
@@ -86,7 +86,7 @@ export function startLeaseHeartbeat(options: LeaseHeartbeatOptions): LeaseHeartb
|
|
|
86
86
|
};
|
|
87
87
|
|
|
88
88
|
const renew = async (): Promise<void> => {
|
|
89
|
-
if (lost) return;
|
|
89
|
+
if (lost || timer.stopped()) return;
|
|
90
90
|
// Expiry is decided BEFORE the driver is asked, because the failure that loses a lease most
|
|
91
91
|
// quietly is the one that never answers: a heartbeat hung on a dead connection neither
|
|
92
92
|
// resolves nor rejects, so a check that ran only on rejection would never run at all.
|
|
@@ -100,6 +100,10 @@ export function startLeaseHeartbeat(options: LeaseHeartbeatOptions): LeaseHeartb
|
|
|
100
100
|
renewing = true;
|
|
101
101
|
try {
|
|
102
102
|
const held = await options.driver.heartbeat(claimed.id, { visibilityTimeoutMs, workerId });
|
|
103
|
+
// Re-read AFTER the await, never only before it: the whole point of the flag is the answer
|
|
104
|
+
// that lands past `stop()`. Every branch below decides something about a lease this process
|
|
105
|
+
// may no longer be running under.
|
|
106
|
+
if (timer.stopped()) return;
|
|
103
107
|
// The driver answered, and it said the row is not ours. That is a DIFFERENT fact from an
|
|
104
108
|
// expired window and the only one an operator can cause on purpose: `x jobs cancel` writes
|
|
105
109
|
// a terminal state, and the renewal that misses it is what tells this attempt to stop. It
|
|
@@ -138,9 +142,7 @@ export function startLeaseHeartbeat(options: LeaseHeartbeatOptions): LeaseHeartb
|
|
|
138
142
|
}
|
|
139
143
|
};
|
|
140
144
|
|
|
141
|
-
timer =
|
|
142
|
-
void renew();
|
|
143
|
-
}, options.intervalMs);
|
|
145
|
+
const timer = startRenewalTimer(options.intervalMs, renew);
|
|
144
146
|
|
|
145
|
-
return { renew, lost: () => lost, signal: gone.signal, stop };
|
|
147
|
+
return { renew, lost: () => lost, signal: gone.signal, stop: () => timer.stop() };
|
|
146
148
|
}
|
package/src/index.ts
CHANGED
|
@@ -107,6 +107,7 @@ export {
|
|
|
107
107
|
SQL_NACK,
|
|
108
108
|
SQL_OUTBOX_CLAIM,
|
|
109
109
|
SQL_OUTBOX_MARK_PUBLISHED,
|
|
110
|
+
SQL_OUTBOX_RELEASE,
|
|
110
111
|
SQL_OUTBOX_STAGE,
|
|
111
112
|
SQL_OUTBOX_TABLE,
|
|
112
113
|
SQL_SCHEDULER_STATE_GET,
|
|
@@ -191,6 +192,7 @@ export {
|
|
|
191
192
|
export type {
|
|
192
193
|
EnqueueOptions,
|
|
193
194
|
JobsFacade,
|
|
195
|
+
MemoryOutboxOptions,
|
|
194
196
|
MemoryOutboxStore,
|
|
195
197
|
OutboxDeps,
|
|
196
198
|
OutboxRecord,
|
|
@@ -207,11 +209,16 @@ export {
|
|
|
207
209
|
resetJobsFacade,
|
|
208
210
|
setJobsFacade,
|
|
209
211
|
} from './outbox';
|
|
212
|
+
// One definition of the lease, consumed by both stores — a memory default and a pg default that
|
|
213
|
+
// could drift are two answers to "how long is a claim mine for", and the shorter one duplicates.
|
|
214
|
+
export { DEFAULT_OUTBOX_CLAIM_LEASE_MS } from './outbox-lease';
|
|
210
215
|
export type { PgOutboxOptions } from './outbox-pg';
|
|
211
216
|
export { createPgOutboxStore } from './outbox-pg';
|
|
212
217
|
|
|
213
218
|
export type { BackoffStrategy, Random, RetryDecision, RetryPolicy } from './retry';
|
|
214
219
|
export { backoffDelayMs, DEFAULT_RETRY, nextRetry, retrySchedule } from './retry';
|
|
220
|
+
export type { JobRetryDecision, JobStopReason } from './retry-classification';
|
|
221
|
+
export { classifyThrown, nextRetryForError } from './retry-classification';
|
|
215
222
|
export type {
|
|
216
223
|
CronResolver,
|
|
217
224
|
DispatchedOccurrence,
|
package/src/job.ts
CHANGED
|
@@ -80,6 +80,22 @@ export interface JobDefinition<I> {
|
|
|
80
80
|
*/
|
|
81
81
|
readonly concurrency?: number;
|
|
82
82
|
readonly timeout?: DurationInput;
|
|
83
|
+
/**
|
|
84
|
+
* Ceiling for ONE `step.run`, where `timeout` is the ceiling for the whole attempt. Folded into
|
|
85
|
+
* the signal the step body is handed, so a body reads one signal and sees whichever deadline
|
|
86
|
+
* lands first — and it ABORTS before it rejects, because the attempt that replaces this one is
|
|
87
|
+
* claimable the moment the nack lands.
|
|
88
|
+
*
|
|
89
|
+
* Declared here and nowhere else: the runner has implemented this ceiling since 1.0 and no
|
|
90
|
+
* declaration could ask for it, which is a documented guarantee that does nothing.
|
|
91
|
+
*/
|
|
92
|
+
readonly stepTimeout?: DurationInput;
|
|
93
|
+
/**
|
|
94
|
+
* How long a `step.waitForEvent` parks between polls. Default 30s. Lower it for a wait a user
|
|
95
|
+
* is watching; the step suspends for exactly this long each time, so it is also the resolution
|
|
96
|
+
* of the resume, never a busy loop.
|
|
97
|
+
*/
|
|
98
|
+
readonly eventPoll?: DurationInput;
|
|
83
99
|
run(args: JobRunArgs<I>): Promise<unknown>;
|
|
84
100
|
}
|
|
85
101
|
|
|
@@ -114,6 +130,10 @@ export interface JobHandle<I = unknown> {
|
|
|
114
130
|
readonly retry: RetryPolicy;
|
|
115
131
|
readonly concurrency: number | undefined;
|
|
116
132
|
readonly timeoutMs: number | undefined;
|
|
133
|
+
/** The declared per-step ceiling in ms; `executeJob` hands it to the step runner. */
|
|
134
|
+
readonly stepTimeoutMs: number | undefined;
|
|
135
|
+
/** The declared event-poll interval in ms; `undefined` leaves the runner's 30s default. */
|
|
136
|
+
readonly eventPollMs: number | undefined;
|
|
117
137
|
readonly input: StandardSchemaV1<unknown, I>;
|
|
118
138
|
parse(raw: unknown): I;
|
|
119
139
|
idempotencyKeyFor(input: I): string;
|
|
@@ -183,6 +203,28 @@ export function job<I>(definition: JobDefinition<I>): JobHandle<I> {
|
|
|
183
203
|
`set a whole concurrency of 1 or more on job("${name}"), or omit the field for no cap at all`,
|
|
184
204
|
);
|
|
185
205
|
|
|
206
|
+
const stepTimeoutMs =
|
|
207
|
+
definition.stepTimeout === undefined ? undefined : toMs(definition.stepTimeout);
|
|
208
|
+
const eventPollMs = definition.eventPoll === undefined ? undefined : toMs(definition.eventPoll);
|
|
209
|
+
// `withStepTimeout` reads `<= 0` as "no ceiling at all" and a poll of zero is a suspension that
|
|
210
|
+
// resumes immediately, forever. Both are an author who asked for a limit and got the opposite,
|
|
211
|
+
// so they are refused where they are written — the same answer `concurrency: 0` gets.
|
|
212
|
+
//
|
|
213
|
+
// FINITE, not merely positive: `> 0` admits `Infinity`, which is the same defect spelled the
|
|
214
|
+
// other way. `eventPoll: Infinity` parks a waiting step and schedules the poll that would wake
|
|
215
|
+
// it for never; `stepTimeout: Infinity` is a ceiling no step can reach. `NaN` fails `> 0` on its
|
|
216
|
+
// own, and is covered here so the predicate says what it means rather than passing by accident.
|
|
217
|
+
assert(
|
|
218
|
+
stepTimeoutMs === undefined || (Number.isFinite(stepTimeoutMs) && stepTimeoutMs > 0),
|
|
219
|
+
`job "${name}" declares stepTimeout ${String(definition.stepTimeout)}, which is no ceiling at all`,
|
|
220
|
+
`set a finite positive stepTimeout on job("${name}") — "30s" or 30_000 — or omit the field for no per-step ceiling`,
|
|
221
|
+
);
|
|
222
|
+
assert(
|
|
223
|
+
eventPollMs === undefined || (Number.isFinite(eventPollMs) && eventPollMs > 0),
|
|
224
|
+
`job "${name}" declares eventPoll ${String(definition.eventPoll)}, which parks a waiting step for no time at all`,
|
|
225
|
+
`set a finite positive eventPoll on job("${name}") — "5s" or 5_000 — or omit the field for the 30s default`,
|
|
226
|
+
);
|
|
227
|
+
|
|
186
228
|
const handle: JobHandle<I> = {
|
|
187
229
|
kind: 'job',
|
|
188
230
|
name,
|
|
@@ -190,6 +232,8 @@ export function job<I>(definition: JobDefinition<I>): JobHandle<I> {
|
|
|
190
232
|
retry: { ...DEFAULT_RETRY, ...definition.retry },
|
|
191
233
|
concurrency: definition.concurrency,
|
|
192
234
|
timeoutMs: definition.timeout === undefined ? undefined : toMs(definition.timeout),
|
|
235
|
+
stepTimeoutMs,
|
|
236
|
+
eventPollMs,
|
|
193
237
|
input: definition.input,
|
|
194
238
|
parse(raw: unknown): I {
|
|
195
239
|
return parse(definition.input, raw) as I;
|
|
@@ -311,8 +355,18 @@ export function getJob(name: string): AnyJobHandle | undefined {
|
|
|
311
355
|
return registry.get(name);
|
|
312
356
|
}
|
|
313
357
|
|
|
358
|
+
/**
|
|
359
|
+
* Code-unit compare, never `localeCompare`. This list is projected into `x.manifest.json`, which
|
|
360
|
+
* both tracked apps COMMIT and `x verify`'s drift step diffs byte for byte — and `localeCompare`
|
|
361
|
+
* with no locale argument answers from the runtime's ICU default and collation version, so the
|
|
362
|
+
* same source could sort two ways on two machines. `@ultimat3/http`'s `describeRoutes` states the
|
|
363
|
+
* same rule; the comparator is restated rather than imported because `http` is not below this
|
|
364
|
+
* package on the tier table.
|
|
365
|
+
*/
|
|
366
|
+
const byName = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
|
367
|
+
|
|
314
368
|
export function registeredJobs(): readonly AnyJobHandle[] {
|
|
315
|
-
return [...registry.values()].sort((a, b) => a.name
|
|
369
|
+
return [...registry.values()].sort((a, b) => byName(a.name, b.name));
|
|
316
370
|
}
|
|
317
371
|
|
|
318
372
|
export function resetJobs(): void {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The claim lease's one definition and its one normalisation. Both outbox stores read it, because
|
|
2
|
+
// a lease the memory store defaults and the pg store validates is two answers to "how long is a
|
|
3
|
+
// claim mine for" — and the shorter of the two is a row published twice.
|
|
4
|
+
|
|
5
|
+
import { assert } from '@ultimat3/core';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* How long a claimed row stays its claimant's. Long enough that no healthy pass loses a batch it
|
|
9
|
+
* is still publishing, short enough that a relay killed mid-batch does not strand one for minutes.
|
|
10
|
+
*/
|
|
11
|
+
export const DEFAULT_OUTBOX_CLAIM_LEASE_MS = 30_000;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Refused where it is written, the way `concurrency: 0` and `stepTimeout: 0` are. A lease of `0`
|
|
15
|
+
* expires before `claim()` resolves, so every relay reclaims every row on every tick and the lease
|
|
16
|
+
* buys nothing; a fractional one is compared against `now()` in Postgres and against whole ms
|
|
17
|
+
* here; `Infinity` never expires, so the rows of a relay that died are stranded forever — the one
|
|
18
|
+
* failure the lease exists to bound. `X_INVARIANT` because this is a caller-argument check with no
|
|
19
|
+
* dedicated code, the generic `@ultimat3/db` already borrows for the same shape.
|
|
20
|
+
*/
|
|
21
|
+
export function resolveClaimLeaseMs(value: number | undefined): number {
|
|
22
|
+
if (value === undefined) return DEFAULT_OUTBOX_CLAIM_LEASE_MS;
|
|
23
|
+
assert(
|
|
24
|
+
Number.isInteger(value) && value > 0,
|
|
25
|
+
`outbox claimLeaseMs is ${String(value)}, which is not a positive whole number of milliseconds`,
|
|
26
|
+
'pass a positive whole claimLeaseMs: 30_000 — createPgOutboxStore({ executor, txExecutor, claimLeaseMs: 30_000 }) — or omit the field for the 30s default',
|
|
27
|
+
);
|
|
28
|
+
return value;
|
|
29
|
+
}
|
package/src/outbox-pg.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// the transaction's connection — so the wiring is one line there and no tier crossing here.
|
|
11
11
|
|
|
12
12
|
import type { Clock } from '@ultimat3/core';
|
|
13
|
+
import { uuid } from '@ultimat3/core';
|
|
13
14
|
import type { Tx } from '@ultimat3/entity';
|
|
14
15
|
import { nowMs } from './clock';
|
|
15
16
|
import type { PgExecutor } from './driver-pg';
|
|
@@ -17,9 +18,11 @@ import {
|
|
|
17
18
|
SQL_OUTBOX_CLAIM,
|
|
18
19
|
SQL_OUTBOX_MARK_PUBLISHED,
|
|
19
20
|
SQL_OUTBOX_PENDING_COUNT,
|
|
21
|
+
SQL_OUTBOX_RELEASE,
|
|
20
22
|
SQL_OUTBOX_STAGE,
|
|
21
23
|
} from './driver-pg-sql';
|
|
22
24
|
import type { OutboxRecord, OutboxStore } from './outbox';
|
|
25
|
+
import { resolveClaimLeaseMs } from './outbox-lease';
|
|
23
26
|
|
|
24
27
|
interface OutboxRow {
|
|
25
28
|
readonly id: string;
|
|
@@ -33,6 +36,7 @@ interface OutboxRow {
|
|
|
33
36
|
readonly tenant_id: string | null;
|
|
34
37
|
readonly traceparent: string | null;
|
|
35
38
|
readonly enqueued_by: string | null;
|
|
39
|
+
readonly claimed_by?: string | null;
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
export interface PgOutboxOptions {
|
|
@@ -47,6 +51,22 @@ export interface PgOutboxOptions {
|
|
|
47
51
|
*/
|
|
48
52
|
readonly txExecutor: (tx: Tx) => PgExecutor;
|
|
49
53
|
readonly clock?: Clock;
|
|
54
|
+
/**
|
|
55
|
+
* How long a claimed row stays this relay's before any relay may take it again. It bounds one
|
|
56
|
+
* thing only: how long the rows of a relay that DIED mid-batch sit unpublished. A pass that is
|
|
57
|
+
* merely slow keeps its rows because it published them; a pass that failed hands them back
|
|
58
|
+
* through `release`.
|
|
59
|
+
*/
|
|
60
|
+
readonly claimLeaseMs?: number;
|
|
61
|
+
/**
|
|
62
|
+
* Written to `claimed_by`, and read back as the FENCE on `release` and `markPublished` — so it
|
|
63
|
+
* must be UNIQUE PER PROCESS. Two replicas passing one literal are one claimant to Postgres, and
|
|
64
|
+
* each can then release or retire the other's live batch. Omit it: the default is
|
|
65
|
+
* `relay-<uuid>`, minted once per store, which is unique by construction. Diagnostics second —
|
|
66
|
+
* it is what an operator reads to see which relay is sitting on a batch, and a value that
|
|
67
|
+
* changed every tick would answer nobody.
|
|
68
|
+
*/
|
|
69
|
+
readonly relayId?: string;
|
|
50
70
|
}
|
|
51
71
|
|
|
52
72
|
function toRecord(row: OutboxRow): OutboxRecord {
|
|
@@ -62,6 +82,7 @@ function toRecord(row: OutboxRow): OutboxRecord {
|
|
|
62
82
|
...(row.tenant_id === null ? {} : { tenantId: row.tenant_id }),
|
|
63
83
|
...(row.traceparent === null ? {} : { traceparent: row.traceparent }),
|
|
64
84
|
...(row.enqueued_by === null ? {} : { enqueuedBy: row.enqueued_by }),
|
|
85
|
+
...(typeof row.claimed_by === 'string' ? { claimedBy: row.claimed_by } : {}),
|
|
65
86
|
};
|
|
66
87
|
}
|
|
67
88
|
|
|
@@ -71,6 +92,13 @@ export function createPgOutboxStore(options: PgOutboxOptions): OutboxStore {
|
|
|
71
92
|
// is neither committed nor rolled back (a process killed mid-request) leaves nothing behind.
|
|
72
93
|
const staged = new WeakMap<object, OutboxRecord[]>();
|
|
73
94
|
const key = (tx: Tx): object => tx as unknown as object;
|
|
95
|
+
// One id per store, minted here rather than per claim: `claimed_by` is read by an operator
|
|
96
|
+
// asking which relay is sitting on a batch, and a value that changed every tick answers nobody.
|
|
97
|
+
// Per-store is also the granularity the fence needs — two relays are two processes, two stores.
|
|
98
|
+
const relayId = options.relayId ?? `relay-${uuid()}`;
|
|
99
|
+
// Resolved once, at construction, so a lease this store could never honour fails where it was
|
|
100
|
+
// written instead of inside a relay tick whose only trace is a log line nobody reads.
|
|
101
|
+
const claimLeaseMs = resolveClaimLeaseMs(options.claimLeaseMs);
|
|
74
102
|
|
|
75
103
|
return {
|
|
76
104
|
async stage(tx, record) {
|
|
@@ -112,18 +140,41 @@ export function createPgOutboxStore(options: PgOutboxOptions): OutboxStore {
|
|
|
112
140
|
},
|
|
113
141
|
|
|
114
142
|
/**
|
|
115
|
-
* `for update skip locked`
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
143
|
+
* A CLAIM, not a read. `for update skip locked` in a bare select held its locks only for that
|
|
144
|
+
* statement — which under autocommit is over before this method resolves — so two relays
|
|
145
|
+
* polling 200ms apart got the identical batch and both published it. The idempotency key
|
|
146
|
+
* collapses that only while the first job is still live, so the repeat that lands after it
|
|
147
|
+
* finished runs the handler a second time. `SQL_OUTBOX_CLAIM` stamps `claimed_at` in the same
|
|
148
|
+
* statement that locks the row; `skip locked` still keeps two relays from serialising.
|
|
119
149
|
*/
|
|
120
150
|
async claim(limit) {
|
|
121
|
-
const rows = await options.executor.query<OutboxRow>(SQL_OUTBOX_CLAIM, [
|
|
151
|
+
const rows = await options.executor.query<OutboxRow>(SQL_OUTBOX_CLAIM, [
|
|
152
|
+
limit,
|
|
153
|
+
claimLeaseMs,
|
|
154
|
+
relayId,
|
|
155
|
+
]);
|
|
122
156
|
return rows.map(toRecord);
|
|
123
157
|
},
|
|
124
158
|
|
|
125
|
-
|
|
126
|
-
|
|
159
|
+
/**
|
|
160
|
+
* Fenced on the CLAIMANT, not only on the ids. A relay that stalled past its lease wakes into
|
|
161
|
+
* a world where its batch belongs to another relay, and an unfenced release frees rows that
|
|
162
|
+
* relay is mid-publish on — a third relay claims them and publishes them again. `relayId` is
|
|
163
|
+
* the fallback because it is what this store stamped: a caller with no token is this store's
|
|
164
|
+
* own relay, and one holding somebody else's token could not have got it from here.
|
|
165
|
+
*/
|
|
166
|
+
async release(ids, claimant) {
|
|
167
|
+
if (ids.length === 0) return;
|
|
168
|
+
await options.executor.query(SQL_OUTBOX_RELEASE, [ids, claimant ?? relayId]);
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
/** Same fence, and worse to miss: marking a row published is losing the job behind it. */
|
|
172
|
+
async markPublished(id, at, claimant) {
|
|
173
|
+
await options.executor.query(SQL_OUTBOX_MARK_PUBLISHED, [
|
|
174
|
+
id,
|
|
175
|
+
at || nowMs(options.clock),
|
|
176
|
+
claimant ?? relayId,
|
|
177
|
+
]);
|
|
127
178
|
},
|
|
128
179
|
|
|
129
180
|
async pendingCount() {
|
package/src/outbox.ts
CHANGED
|
@@ -31,6 +31,7 @@ import type { EnqueueResult, JobDriver } from './driver';
|
|
|
31
31
|
import { DEFAULT_QUEUE, jobDriver } from './driver';
|
|
32
32
|
import { DriverUnavailableError, OutboxNoTxError } from './errors';
|
|
33
33
|
import type { JobHandle } from './job';
|
|
34
|
+
import { resolveClaimLeaseMs } from './outbox-lease';
|
|
34
35
|
|
|
35
36
|
export interface OutboxRecord {
|
|
36
37
|
readonly id: string;
|
|
@@ -46,6 +47,11 @@ export interface OutboxRecord {
|
|
|
46
47
|
readonly traceparent?: string;
|
|
47
48
|
readonly enqueuedBy?: string;
|
|
48
49
|
readonly publishedAt?: number;
|
|
50
|
+
/**
|
|
51
|
+
* Stamped by `claim()`, absent on a staged row. Hand it back to `release`/`markPublished`: it is
|
|
52
|
+
* the FENCE, so a claimant whose lease lapsed cannot touch the rows a newer one is publishing.
|
|
53
|
+
*/
|
|
54
|
+
readonly claimedBy?: string;
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
export interface OutboxStore {
|
|
@@ -55,12 +61,44 @@ export interface OutboxStore {
|
|
|
55
61
|
commit(tx: Tx): Promise<readonly OutboxRecord[]>;
|
|
56
62
|
/** Called by the tx runner after ROLLBACK. Staged rows vanish with the transaction. */
|
|
57
63
|
rollback(tx: Tx): Promise<void>;
|
|
58
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* CLAIM unpublished, committed rows — the relay's work queue, and a lease rather than a read.
|
|
66
|
+
* A store that hands the same rows to two relays hands the same job to two workers, and the
|
|
67
|
+
* idempotency key only collapses that while the first job is still live.
|
|
68
|
+
*/
|
|
59
69
|
claim(limit: number): Promise<readonly OutboxRecord[]>;
|
|
60
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Hand a claim back before its lease runs out, for the batch a failed publish stopped. OPTIONAL
|
|
72
|
+
* so a store written before the claim became a lease still compiles: without it those rows wait
|
|
73
|
+
* out the whole lease, which is slower, never wrong.
|
|
74
|
+
*
|
|
75
|
+
* `claimant` is the `claimedBy` the claim stamped. Passing it is what makes a lapsed relay's
|
|
76
|
+
* late release a no-op instead of an unclaim of somebody else's live batch.
|
|
77
|
+
*/
|
|
78
|
+
release?(ids: readonly string[], claimant?: string): Promise<void>;
|
|
79
|
+
/** `claimant` fences the same way, and here it is worse to miss: this retires the row. */
|
|
80
|
+
markPublished(id: string, at: number, claimant?: string): Promise<void>;
|
|
61
81
|
pendingCount(): Promise<number>;
|
|
62
82
|
}
|
|
63
83
|
|
|
84
|
+
/**
|
|
85
|
+
* The claim's sort key, and it is TOTAL: `id` after `stagedAt`, exactly what `SQL_OUTBOX_CLAIM`
|
|
86
|
+
* orders by. Every row staged in one transaction shares a `stagedAt`, so the key ties for the
|
|
87
|
+
* batch that most depends on order — and a tie leaves both which rows a limit takes and the order
|
|
88
|
+
* they publish in to whatever the store iterated first. Code units, never `localeCompare`, for
|
|
89
|
+
* the reason `registeredJobs()` sorts that way.
|
|
90
|
+
*/
|
|
91
|
+
function byClaimOrder(a: OutboxRecord, b: OutboxRecord): number {
|
|
92
|
+
if (a.stagedAt !== b.stagedAt) return a.stagedAt - b.stagedAt;
|
|
93
|
+
if (a.id === b.id) return 0;
|
|
94
|
+
return a.id < b.id ? -1 : 1;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface MemoryOutboxOptions {
|
|
98
|
+
readonly clock?: Clock;
|
|
99
|
+
readonly claimLeaseMs?: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
64
102
|
export interface MemoryOutboxStore extends OutboxStore {
|
|
65
103
|
/**
|
|
66
104
|
* Committed rows this process is still holding. The relay's backlog and nothing else — a
|
|
@@ -75,11 +113,29 @@ export interface MemoryOutboxStore extends OutboxStore {
|
|
|
75
113
|
* transaction" guarantee needs no cooperation from the DB layer and rollback is a delete.
|
|
76
114
|
* The pg store swaps this for a real `x_outbox` table written by the same connection.
|
|
77
115
|
*/
|
|
78
|
-
export function createMemoryOutboxStore(): MemoryOutboxStore {
|
|
116
|
+
export function createMemoryOutboxStore(options: MemoryOutboxOptions = {}): MemoryOutboxStore {
|
|
79
117
|
const staged = new WeakMap<object, OutboxRecord[]>();
|
|
80
118
|
const committed = new Map<string, OutboxRecord>();
|
|
119
|
+
/** Each claimed row's lease: when it was taken and by whom. Absent is `claimed_at is null`. */
|
|
120
|
+
const claims = new Map<string, { at: number; by: string }>();
|
|
121
|
+
const leaseMs = resolveClaimLeaseMs(options.claimLeaseMs);
|
|
122
|
+
// A token per CLAIM, where the pg store stamps one per RELAY. Two relays there are two stores
|
|
123
|
+
// with two ids; here they are two `claim()` calls on one store, so the claim is the only
|
|
124
|
+
// granularity at which this store can answer "is this mutation from the current holder".
|
|
125
|
+
let claimSeq = 0;
|
|
81
126
|
|
|
82
127
|
const key = (tx: Tx): object => tx as unknown as object;
|
|
128
|
+
const free = (id: string, at: number): boolean => {
|
|
129
|
+
const claim = claims.get(id);
|
|
130
|
+
return claim === undefined || at - claim.at >= leaseMs;
|
|
131
|
+
};
|
|
132
|
+
/**
|
|
133
|
+
* A mutation from a claimant that no longer holds the row is a NO-OP. `undefined` is the caller
|
|
134
|
+
* that holds no token at all — a store-level caller, or one written before the fence — and is
|
|
135
|
+
* left unfenced rather than silently dropped, the way `release` itself is optional.
|
|
136
|
+
*/
|
|
137
|
+
const owns = (id: string, claimant: string | undefined): boolean =>
|
|
138
|
+
claimant === undefined || claims.get(id)?.by === claimant;
|
|
83
139
|
|
|
84
140
|
return {
|
|
85
141
|
stage(tx, record) {
|
|
@@ -98,19 +154,36 @@ export function createMemoryOutboxStore(): MemoryOutboxStore {
|
|
|
98
154
|
staged.delete(key(tx));
|
|
99
155
|
return Promise.resolve();
|
|
100
156
|
},
|
|
157
|
+
/**
|
|
158
|
+
* The same question `SQL_OUTBOX_CLAIM` answers, and it has to stay the same one: a row this
|
|
159
|
+
* store hands back is CLAIMED for `leaseMs`, so a second relay polling the same store gets
|
|
160
|
+
* nothing, and a claim whose holder died is reclaimable once the window passes.
|
|
161
|
+
*/
|
|
101
162
|
claim(limit) {
|
|
163
|
+
const at = nowMs(options.clock);
|
|
164
|
+
claimSeq += 1;
|
|
165
|
+
const by = `claim-${claimSeq}`;
|
|
102
166
|
const ready = [...committed.values()]
|
|
103
|
-
.filter((record) => record.publishedAt === undefined)
|
|
104
|
-
.sort(
|
|
167
|
+
.filter((record) => record.publishedAt === undefined && free(record.id, at))
|
|
168
|
+
.sort(byClaimOrder)
|
|
105
169
|
.slice(0, limit);
|
|
106
|
-
|
|
170
|
+
for (const record of ready) claims.set(record.id, { at, by });
|
|
171
|
+
return Promise.resolve(ready.map((record) => ({ ...record, claimedBy: by })));
|
|
172
|
+
},
|
|
173
|
+
release(ids, claimant) {
|
|
174
|
+
for (const id of ids) {
|
|
175
|
+
if (owns(id, claimant)) claims.delete(id);
|
|
176
|
+
}
|
|
177
|
+
return Promise.resolve();
|
|
107
178
|
},
|
|
108
|
-
markPublished(id, _at) {
|
|
179
|
+
markPublished(id, _at, claimant) {
|
|
180
|
+
if (!owns(id, claimant)) return Promise.resolve();
|
|
109
181
|
// Deleted, not stamped. A published row is out of the relay's reach either way, and the
|
|
110
182
|
// pg store's `published_at` column is a retained audit trail this map is not: rewriting
|
|
111
183
|
// it in place held every payload ever enqueued — arbitrary job input — for the life of
|
|
112
184
|
// the process, and made `claim()` and `pendingCount()` walk all of them every 200ms.
|
|
113
185
|
committed.delete(id);
|
|
186
|
+
claims.delete(id);
|
|
114
187
|
return Promise.resolve();
|
|
115
188
|
},
|
|
116
189
|
pendingCount() {
|
|
@@ -331,7 +404,10 @@ export function createOutboxRelay(options: RelayOptions): OutboxRelay {
|
|
|
331
404
|
...(record.traceparent === undefined ? {} : { traceparent: record.traceparent }),
|
|
332
405
|
...(record.enqueuedBy === undefined ? {} : { enqueuedBy: record.enqueuedBy }),
|
|
333
406
|
});
|
|
334
|
-
|
|
407
|
+
// The claim's own token goes back with the mark. Without it a relay whose lease lapsed
|
|
408
|
+
// mid-stall retires a row the relay that reclaimed it has not published yet — the row is
|
|
409
|
+
// gone and nothing publishes it.
|
|
410
|
+
await options.store.markPublished(record.id, nowMs(options.clock), record.claimedBy);
|
|
335
411
|
published += 1;
|
|
336
412
|
} catch (error) {
|
|
337
413
|
// STOP the batch. `claim()` returns rows in `staged_at` order and the loop used to log
|
|
@@ -347,6 +423,13 @@ export function createOutboxRelay(options: RelayOptions): OutboxRelay {
|
|
|
347
423
|
remaining: batch.length - published,
|
|
348
424
|
error: error instanceof Error ? error.message : String(error),
|
|
349
425
|
});
|
|
426
|
+
// Hand the rest of the batch back rather than sit on a claim nobody is publishing. The
|
|
427
|
+
// claim is a lease now, so without this a single pool timeout parks every committed row
|
|
428
|
+
// behind it for the whole lease window instead of for one poll interval.
|
|
429
|
+
await options.store.release?.(
|
|
430
|
+
batch.slice(published).map((row) => row.id),
|
|
431
|
+
record.claimedBy,
|
|
432
|
+
);
|
|
350
433
|
break;
|
|
351
434
|
}
|
|
352
435
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// A renewal loop that is TERMINAL once stopped, and the one shape two files renew against.
|
|
2
|
+
// `heartbeat.ts` renews a job's lease and `worker-fleet-slots.ts` a fleet slot, and both decided a
|
|
3
|
+
// LOSS from an answer that arrived after the run had already finished cleanly: `stop()` cleared
|
|
4
|
+
// the interval, which does nothing to the request already on the wire. So a flag is what every
|
|
5
|
+
// branch after an `await` re-reads — the shape `settleWithin`'s `decided` uses in core.
|
|
6
|
+
|
|
7
|
+
export interface RenewalTimer {
|
|
8
|
+
/**
|
|
9
|
+
* True once `stop()` has been called. Read AFTER every await in the renewal body: a clean
|
|
10
|
+
* completion settles the row this renewal is fenced on, so the driver answering "not yours"
|
|
11
|
+
* past that point is a finished job, not a lost lease — and reporting it is an error-level page
|
|
12
|
+
* for a non-event, on exactly the signals that mean the queue re-delivered live work.
|
|
13
|
+
*/
|
|
14
|
+
stopped(): boolean;
|
|
15
|
+
/** Stop renewing, for the pass in flight as well as the next one. Idempotent. */
|
|
16
|
+
stop(): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function startRenewalTimer(
|
|
20
|
+
intervalMs: number,
|
|
21
|
+
renew: () => void | Promise<void>,
|
|
22
|
+
): RenewalTimer {
|
|
23
|
+
let stopped = false;
|
|
24
|
+
const timer = setInterval(() => {
|
|
25
|
+
void renew();
|
|
26
|
+
}, intervalMs);
|
|
27
|
+
return {
|
|
28
|
+
stopped: () => stopped,
|
|
29
|
+
stop(): void {
|
|
30
|
+
if (stopped) return;
|
|
31
|
+
stopped = true;
|
|
32
|
+
clearInterval(timer);
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// One retry decision from two questions the executor used to ask only half of: "are there
|
|
2
|
+
// attempts left?" (./retry) and "is this error worth trying again at all?" (core's classification).
|
|
3
|
+
// The backoff arithmetic stays in ./retry — nothing here recomputes a delay `nextRetry` owns.
|
|
4
|
+
|
|
5
|
+
import type { ErrorRetry } from '@ultimat3/core';
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_ERROR_RETRY,
|
|
8
|
+
declaredErrorRetry,
|
|
9
|
+
isErrorRetry,
|
|
10
|
+
isUltimateError,
|
|
11
|
+
} from '@ultimat3/core';
|
|
12
|
+
import { toMs } from './clock';
|
|
13
|
+
import type { Random, RetryDecision, RetryPolicy } from './retry';
|
|
14
|
+
import { DEFAULT_RETRY, nextRetry } from './retry';
|
|
15
|
+
|
|
16
|
+
/** Why this attempt was the last one. Absent while the job is still being retried. */
|
|
17
|
+
export type JobStopReason = 'terminal' | 'attempts-exhausted';
|
|
18
|
+
|
|
19
|
+
export interface JobRetryDecision extends RetryDecision {
|
|
20
|
+
readonly stoppedBy: JobStopReason | undefined;
|
|
21
|
+
/** The classification consulted, or `undefined` when nobody classified the thrown code. */
|
|
22
|
+
readonly classification: ErrorRetry | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The classification that was DECLARED for this throw, or `undefined` when there is none.
|
|
27
|
+
*
|
|
28
|
+
* Deliberately not `error.retry` alone. That field is `init.retry ?? retryFor(code)` and
|
|
29
|
+
* `retryFor` fails closed, so every unclassified `UltimateError` already carries `terminal` —
|
|
30
|
+
* reading it would dead-letter the first attempt of every job in every app whose codes nobody has
|
|
31
|
+
* classified yet. So `terminal` counts only when it can have come from somewhere: an explicit
|
|
32
|
+
* per-instance override is indistinguishable from the default here, which is why an UNCLASSIFIED
|
|
33
|
+
* code carrying an instance `retry: 'terminal'` is read as unclassified. Register the code
|
|
34
|
+
* (`registerErrorRetry({ X_YOUR_CODE: 'terminal' })`) to have it honoured — one way, and the same
|
|
35
|
+
* way every other package declares it.
|
|
36
|
+
*/
|
|
37
|
+
export function classifyThrown(error: unknown): ErrorRetry | undefined {
|
|
38
|
+
if (!isUltimateError(error)) return undefined;
|
|
39
|
+
const retry: unknown = error.retry;
|
|
40
|
+
if (!isErrorRetry(retry)) return undefined;
|
|
41
|
+
// Anything other than the fail-closed default can only have come from the code table or from an
|
|
42
|
+
// explicit override, so it is somebody's answer either way.
|
|
43
|
+
if (retry !== DEFAULT_ERROR_RETRY) return retry;
|
|
44
|
+
return declaredErrorRetry(error.code) === undefined ? undefined : retry;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The delay a `retry-after` error NAMED, in ms. `retryAfterSeconds` on the error's `meta` is the
|
|
49
|
+
* framework's one spelling for it — `@ultimat3/http`'s `rateLimited` writes it and the 429's
|
|
50
|
+
* `Retry-After` header renders it — so a job and an HTTP client read the same number.
|
|
51
|
+
*/
|
|
52
|
+
export function statedDelayMs(error: unknown): number | undefined {
|
|
53
|
+
if (!isUltimateError(error)) return undefined;
|
|
54
|
+
const seconds: unknown = error.meta?.['retryAfterSeconds'];
|
|
55
|
+
if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) return undefined;
|
|
56
|
+
return Math.round(seconds * 1_000);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Retry, dead-letter, and when. `terminal` stops here on the attempt that failed — the same code
|
|
61
|
+
* run again is the same answer, and the attempts left are a queue slot, a provider bill, and (the
|
|
62
|
+
* case that forced this) three more wrong passwords at a site that locks the account after three.
|
|
63
|
+
*
|
|
64
|
+
* Everything else keeps the attempt count in charge: `retry-after` only replaces the delay, never
|
|
65
|
+
* the ceiling, and an unclassified code takes exactly the path it took before this existed.
|
|
66
|
+
*/
|
|
67
|
+
export function nextRetryForError(
|
|
68
|
+
policy: RetryPolicy,
|
|
69
|
+
attempt: number,
|
|
70
|
+
error: unknown,
|
|
71
|
+
random?: Random,
|
|
72
|
+
): JobRetryDecision {
|
|
73
|
+
const classification = classifyThrown(error);
|
|
74
|
+
if (classification === 'terminal') {
|
|
75
|
+
return {
|
|
76
|
+
retry: false,
|
|
77
|
+
delayMs: 0,
|
|
78
|
+
// The policy still decides park-or-drop: `deadLetter: false` means this app does not keep
|
|
79
|
+
// failed jobs, and that is not a preference a classification gets to overturn.
|
|
80
|
+
deadLetter: policy.deadLetter ?? true,
|
|
81
|
+
nextAttempt: attempt,
|
|
82
|
+
stoppedBy: 'terminal',
|
|
83
|
+
classification,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const decision = nextRetry(policy, attempt, random);
|
|
88
|
+
if (!decision.retry) {
|
|
89
|
+
return { ...decision, stoppedBy: 'attempts-exhausted', classification };
|
|
90
|
+
}
|
|
91
|
+
if (classification !== 'retry-after')
|
|
92
|
+
return { ...decision, stoppedBy: undefined, classification };
|
|
93
|
+
|
|
94
|
+
const stated = statedDelayMs(error);
|
|
95
|
+
if (stated === undefined) return { ...decision, stoppedBy: undefined, classification };
|
|
96
|
+
// Clamped by the policy's own ceiling, which is what `maxDelay` is for: a responder naming a
|
|
97
|
+
// day is still a responder this deployment has not agreed to wait a day for.
|
|
98
|
+
const cap = toMs(policy.maxDelay ?? DEFAULT_RETRY.maxDelay);
|
|
99
|
+
return { ...decision, delayMs: Math.min(stated, cap), stoppedBy: undefined, classification };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* What the job ROW records. `lastError` is the one failure field a row carries, so a dead letter
|
|
104
|
+
* that stopped at attempt 1 of 5 has to explain itself there or `x jobs show` reads as a silent
|
|
105
|
+
* early stop. Only the terminal verdict is appended: exhaustion is already legible from
|
|
106
|
+
* `attempt === maxAttempts`.
|
|
107
|
+
*/
|
|
108
|
+
export function recordedFailure(message: string, decision: JobRetryDecision): string {
|
|
109
|
+
return decision.stoppedBy === 'terminal'
|
|
110
|
+
? `${message} — not retried: this code is classified terminal, so every remaining attempt fails the same way`
|
|
111
|
+
: message;
|
|
112
|
+
}
|
package/src/task.ts
CHANGED
|
@@ -215,8 +215,18 @@ export function nameTasks(record: Readonly<Record<string, TaskHandle>>): void {
|
|
|
215
215
|
for (const [exportName, handle] of Object.entries(record)) registerTask(exportName, handle);
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Code-unit compare, never `localeCompare`. This list is projected into `x.manifest.json`, which
|
|
220
|
+
* both tracked apps COMMIT and `x verify`'s drift step diffs byte for byte — and `localeCompare`
|
|
221
|
+
* with no locale argument answers from the runtime's ICU default and collation version, so the
|
|
222
|
+
* same source could sort two ways on two machines. `@ultimat3/http`'s `describeRoutes` states the
|
|
223
|
+
* same rule; the comparator is restated rather than imported because `http` is not below this
|
|
224
|
+
* package on the tier table.
|
|
225
|
+
*/
|
|
226
|
+
const byName = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
|
227
|
+
|
|
218
228
|
export function registeredTasks(): readonly TaskHandle[] {
|
|
219
|
-
return [...registry.values()].sort((a, b) => a.name
|
|
229
|
+
return [...registry.values()].sort((a, b) => byName(a.name, b.name));
|
|
220
230
|
}
|
|
221
231
|
|
|
222
232
|
export function getTask(name: string): TaskHandle | undefined {
|
|
@@ -8,6 +8,7 @@ import type { ClaimedJob } from './driver';
|
|
|
8
8
|
import { getJob } from './job';
|
|
9
9
|
import type { HeldLease, LeaseStore } from './leases';
|
|
10
10
|
import { jobLeaseKey } from './leases';
|
|
11
|
+
import { startRenewalTimer } from './renewal-timer';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* A renewal that REJECTED is not a lost slot: there is a TTL behind it and the interval gets
|
|
@@ -79,21 +80,25 @@ export function createFleetSlots(options: FleetSlotOptions): FleetSlots {
|
|
|
79
80
|
startRenewal(jobId, onLost) {
|
|
80
81
|
const slot = held.get(jobId);
|
|
81
82
|
if (slot === undefined) return noop;
|
|
82
|
-
const stop = (): void => {
|
|
83
|
-
clearInterval(timer);
|
|
84
|
-
};
|
|
85
83
|
// Renewed on the lease heartbeat's own interval and released in the same `finally`: one
|
|
86
84
|
// clock for "this worker still owns the job" and "this worker still owns the slot" is one
|
|
87
|
-
// fewer way for them to disagree.
|
|
88
|
-
|
|
89
|
-
|
|
85
|
+
// fewer way for them to disagree — and `timer.stopped()` is the same latch `heartbeat.ts`
|
|
86
|
+
// reads, for the same reason.
|
|
87
|
+
const timer = startRenewalTimer(options.renewIntervalMs, () =>
|
|
88
|
+
options.leases
|
|
90
89
|
?.renew(slot, options.ttlMs)
|
|
91
90
|
.then((renewed) => {
|
|
92
91
|
// `=== false`, never `!renewed`, for the reason `heartbeat.ts` reads `held` that way:
|
|
93
92
|
// a store written before this return value existed resolves `undefined`, and treating
|
|
94
93
|
// that as a loss would cancel every job on every renewal. Only an explicit no is one.
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
//
|
|
95
|
+
// `stopped()` re-read AFTER the await for the other half: the run settles, this timer
|
|
96
|
+
// is stopped and `worker.ts` releases the slot — so the renewal already on the wire
|
|
97
|
+
// finds the row gone and answers `false` for a job that FINISHED. Reported, that is
|
|
98
|
+
// `jobs.worker.slot-lost` at error and an abort on a controller `runSignal.dispose()`
|
|
99
|
+
// has already torn down: noise about a run nobody lost.
|
|
100
|
+
if (renewed !== false || timer.stopped()) return;
|
|
101
|
+
timer.stop();
|
|
97
102
|
logger.error('jobs.worker.slot-lost', {
|
|
98
103
|
workerId: options.workerId,
|
|
99
104
|
jobId,
|
|
@@ -102,9 +107,9 @@ export function createFleetSlots(options: FleetSlotOptions): FleetSlots {
|
|
|
102
107
|
});
|
|
103
108
|
onLost?.(slot);
|
|
104
109
|
})
|
|
105
|
-
.catch(noop)
|
|
106
|
-
|
|
107
|
-
return stop;
|
|
110
|
+
.catch(noop),
|
|
111
|
+
);
|
|
112
|
+
return () => timer.stop();
|
|
108
113
|
},
|
|
109
114
|
|
|
110
115
|
async release(jobId) {
|