@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 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`: **`packages/cli/src/dev-roles.ts` still calls it without
405
- one**, in both teardown paths.
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 — collapsed by the
338
- idempotency key. A publish that FAILS stops the batch rather than letting later rows overtake it:
339
- `claim()` returns rows in `staged_at` order, so an app that stages `createInvoice` then
340
- `chargeCard` in one transaction must never have the charge run first. Set `mode: 'required'` to
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": "2.0.0",
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": "2.0.0",
36
- "@ultimat3/entity": "2.0.0",
37
- "@ultimat3/schema": "2.0.0",
38
- "@ultimat3/time": "2.0.0"
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
  }
@@ -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();
@@ -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 x_outbox
280
- where published_at is null
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) where id = $1
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 { nextRetry } from './retry';
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
- const decision = nextRetry(handle.retry, claimed.attempt);
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
- if (lost) return;
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 = setInterval(() => {
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.localeCompare(b.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` is in the statement, and under autocommit its row locks last only
116
- * for that statement — so two relays can hand the same row to `enqueue`. That is the
117
- * at-least-once the whole design already assumes and the idempotency key already collapses;
118
- * what the clause buys is that two relays running side by side do not serialise on each other.
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, [limit]);
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
- async markPublished(id, at) {
126
- await options.executor.query(SQL_OUTBOX_MARK_PUBLISHED, [id, at || nowMs(options.clock)]);
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
- /** Unpublished, committed rows — the relay's work queue. */
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
- markPublished(id: string, at: number): Promise<void>;
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((a, b) => a.stagedAt - b.stagedAt)
167
+ .filter((record) => record.publishedAt === undefined && free(record.id, at))
168
+ .sort(byClaimOrder)
105
169
  .slice(0, limit);
106
- return Promise.resolve(ready);
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
- await options.store.markPublished(record.id, nowMs(options.clock));
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.localeCompare(b.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
- const timer = setInterval(() => {
89
- void options.leases
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
- if (renewed !== false) return;
96
- stop();
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
- }, options.renewIntervalMs);
107
- return stop;
110
+ .catch(noop),
111
+ );
112
+ return () => timer.stop();
108
113
  },
109
114
 
110
115
  async release(jobId) {