@ultimat3/jobs 10.0.0 → 11.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
@@ -194,12 +194,38 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
194
194
  - The scheduler's key is occurrence-scoped (`task:occurrenceMs:jobKey`) and `task.enqueue()`'s
195
195
  is the job's plain key. Deliberate: the first stops two schedulers double-firing a tick, the
196
196
  second is a manual run with no occurrence to scope to.
197
- - **One shutdown hook per worker, and `stop()` is what hands it back.** `start()` keeps the
198
- unregister `onShutdown` returns; the teardown releases it in a `finally`, so a close that threw
199
- still gives it up. Discarding it was a hook per `start()` the `start()` guard reads a
200
- standstill, so start -> stop -> start stacked a second registration retaining a stopped
201
- worker's driver, and the next process-wide drain ran all of them. `start()` refuses while
202
- draining for the same reason: a claim loop back on a driver the drain is about to close.
197
+ - **TWO shutdown hooks per worker — one per PHASE — and `stop()` is what hands both back**
198
+ (`As of 2026-08-23`). `accept` is `stopAccepting()`: flip the state, clear the poll timer, return.
199
+ `close` is the teardown: wait out the rounds and the in-flight jobs, close the driver.
200
+
201
+ It was ONE hook, at `accept`, doing all of it so a single 10-minute job spent the entire drain
202
+ deadline inside the phase whose whole purpose is to be over immediately, and every hook behind it
203
+ was invoked with **0 ms** left: `@ultimat3/http`'s "stop listening" and `listenSyncNode`'s
204
+ `stopAccepting` are both `accept` hooks, so the pod went on serving requests and upgrading
205
+ websockets for the whole of the drain the load balancer had already been told about. Reproduced —
206
+ 4 hooks started, none finished.
207
+
208
+ `start()` keeps both unregisters; the teardown releases them in a `finally`, so a close that threw
209
+ still gives them up. Discarding them was a hook per `start()` — the `start()` guard reads a
210
+ standstill, so start -> stop -> start stacked a second registration retaining a stopped worker's
211
+ driver, and the next process-wide drain ran all of them. `start()` refuses while draining for the
212
+ same reason: a claim loop back on a driver the drain is about to close.
213
+ - **A claimed job is counted with core's `beginWork()`, so the DRAIN does the waiting**
214
+ (`As of 2026-08-23`). The wait for in-flight jobs belongs to the phase between `accept` and
215
+ `inflight`, which exists for exactly this and is where `@ultimat3/http` already puts a request —
216
+ not to a hook, where one role's work starves every other role's teardown. `/readyz`'s `inflight`
217
+ becomes truthful on a worker node as a consequence.
218
+ - **The teardown's wait is BOUNDED on the SIGTERM path and unbounded on a manual `stop()`**
219
+ (`As of 2026-08-23`). The `close` hook is handed `ShutdownReason.deadlineAt` and passes it to
220
+ `settleAllBy` (`drain-wait.ts`); a manual `stop()` passes nothing and waits as long as its
221
+ jobs take, because a caller that asked has no budget to spend. Nothing in JS can kill a body that
222
+ ignores `ctx.signal`, so the unbounded version was a teardown that never returned: the driver was
223
+ never closed, `state` never left `'draining'`, and the memoised `stopping` promise every later
224
+ `stop()` joins never settled — `x dev`'s role rollback awaits that promise. Abandoning the wait
225
+ costs a lapsed lease and a job the queue delivers again, which is what at-least-once already
226
+ promises; `jobs.worker.drain-abandoned` names it, with the `configureLifecycle({ deadlineMs })`
227
+ raise as its fix. **A worker always REACHES `'stopped'`**, which is what makes `stop()`'s
228
+ `state === 'stopped'` early return an answer rather than a wedge.
203
229
  - **One teardown, joined.** `stop()` shares the in-flight teardown promise, so a SIGTERM landing
204
230
  on a manual stop waits out the same in-flight jobs instead of closing the driver underneath
205
231
  it. The promise is cleared as it settles, so a worker that started again tears down again
@@ -223,6 +249,13 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
223
249
  a climbing `queue_depth` as the only symptoms. The `catch` releases the lease and rethrows; the
224
250
  claimed row goes back to the queue by its visibility timeout, as it does for any round that dies.
225
251
 
252
+ `fleetSlots.release(jobId)` is AWAITED in the settle's `.finally`, never `void`ed: the slot is a
253
+ DELETE in `x_job_leases`, so a fire-and-forget one was still on the wire when the teardown's
254
+ `allSettled` returned and `driver.close()` took the connection out from under it — the row then
255
+ held its slot for a full TTL, and a `concurrency: 1` job was unclaimable by the pod replacing this
256
+ one for a whole visibility window after every deploy. `release` swallows its own failures, so
257
+ awaiting it cannot turn a finished job into a rejected one.
258
+
226
259
  `LeaseStore.renew` answering `false` means the row is another holder's — two runs live under a cap
227
260
  of one — and it was discarded by `.catch(noop)`. It now stops the timer, logs
228
261
  `jobs.worker.slot-lost` and aborts the run through `X_JOB_SLOT_LOST`. Its own code, not
@@ -316,10 +349,14 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
316
349
  waits that round out BEFORE `leader.release()` — a lock handed back mid-dispatch promotes a
317
350
  standby onto an occurrence this node is still enqueueing for, which is the double-fire leader
318
351
  election exists to prevent — and the round re-reads the drain state before each task, so "stop
319
- dispatching" means this round too. Same hook rules as the worker: one `onShutdown` at the
320
- `accept` phase while it runs, handed back in the teardown's `finally` so a `release()` that
321
- threw still gives it up, `isLeader` cleared there too because a lock this process could not
322
- hand back is never treated as still held.
352
+ dispatching" means this round too. Same hook rules as the worker, `As of 2026-08-23`: **TWO**
353
+ `onShutdown` registrations `accept` stops dispatching and returns, `close` waits the round out
354
+ and releases the lease under the deadline it is handed (`settleAllBy`) both handed back in the
355
+ teardown's `finally` so a `release()` that threw still gives them up, `isLeader` cleared there
356
+ too because a lock this process could not hand back is never treated as still held. **An
357
+ ABANDONED round does not release**: it is still enqueueing, so handing the lock over is that same
358
+ double-fire delivered by the shutdown, and a lease row expiring on its own is the safe end —
359
+ `jobs.scheduler.drain-abandoned` is the line that says so.
323
360
  - **`backfill()` is a FACTORY over `job()`, never a ninth primitive.** Same rule `llm()` follows
324
361
  in `@ultimat3/ai`: a new capability arrives as a factory over an existing primitive, so a
325
362
  backfill inherits `.enqueue()`, the retry policy, the cancellation, the dead-letter path and
@@ -363,6 +400,14 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
363
400
  the CHANGELOG, `CLAUDE.md` and `x g backfill`'s generated source all say the handler must be
364
401
  idempotent. Never invert it: checkpointing first would report a page as swept that nobody wrote,
365
402
  and a lost page is unrecoverable where a repeated one is the handler's problem.
403
+ - **A REPLAYED batch writes no ledger row** (`As of 2026-08-23`). `ledger.progress` sits outside
404
+ `step.run` — it has to, the ledger is not transactional with the steps — so a resumed pass
405
+ re-issued one `x_backfills` UPDATE per already-completed batch before it read a single new row:
406
+ 4,800 statements on a 5M-row sweep killed at batch 4,800, on every attempt, inside the visibility
407
+ lease the heartbeat is renewing. The flag is set INSIDE the step body, because that is the only
408
+ thing that can tell a replay from a run — `step.run` answers the same shape either way. Nothing is
409
+ lost: the value is ABSOLUTE, so the first batch that does run reports every replayed one behind
410
+ it, and `finish` writes the total regardless.
366
411
  - **`x_backfills` is what has already been SWEPT, and the step checkpoints are where a pass
367
412
  resumes. Never the other way round.** The ledger row (`backfill-ledger.ts`) is a report an
368
413
  operator reads and the record that a completed name is done; the checkpoints are written in step
@@ -528,7 +573,11 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
528
573
  branch. `jobs.lease.lost` at error plus `recordLeaseLost(queue)` is the one signal meaning the
529
574
  queue re-delivered a job this process was still running, so a false one is a page for a
530
575
  non-event, and the window widens exactly when the pool is slow. Re-read the flag AFTER every
531
- await and inside the reporter, the way `settleWithin`'s `decided` does in core.
576
+ await and inside the reporter, the way `settleWithin`'s `decided` does in core. **The interval is
577
+ `unref`ed**, like all three of `sync-node.ts`'s: it is armed from inside a job run, so a drain
578
+ that ABANDONS the worker's hook leaves the run — and this timer — with nobody left to call
579
+ `stop()`, and a refed interval is then the one thing holding a drained process open until the
580
+ kubelet's SIGKILL.
532
581
  - **`stepTimeout` and `eventPoll` are DECLARED on the job, and `execute.ts` is the only place they
533
582
  are forwarded** (`As of 2026-08`). `StepRunnerOptions` carried both, `withStepTimeout`
534
583
  implemented the ceiling and `steps.test.ts` exercised it by building a runner BY HAND — while the
@@ -617,11 +666,18 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
617
666
  - **`x jobs cancel` binds to `cancelJob(driver, id, reason?)`, which REFUSES rather than answering
618
667
  a silent no-op.** An operator cancelling a 40M-row sweep has to know whether they stopped it or
619
668
  missed it, so a finished job is `X_JOB_NOT_CANCELLABLE` and a driver with no `cancel` is too.
620
- - **The scheduler asks `leader.acquire()` EVERY round, not only while it thinks it is not the
621
- leader.** A lease-backed election expires, so `acquire()` is its renewal and a cached
622
- `isLeader = true` would keep dispatching past a lease another node already took. `soleLeader`
623
- answers true every time and `createPgLeader` holds its grant behind an internal flag, so the
624
- extra call is a no-op for both the flag also stops Postgres refcounting a second advisory
669
+ - **The scheduler asks `leader.acquire()` EVERY round AND before EVERY task in it, not only while
670
+ it thinks it is not the leader.** A lease-backed election expires, so `acquire()` is its renewal
671
+ and a cached `isLeader = true` would keep dispatching past a lease another node already took.
672
+ Per-task as well as per-round `As of 2026-08-23`: a round walks its tasks serially with an enqueue
673
+ per job, so a 30s lease and a slow queue leave the tail of the walk running under a lease node B
674
+ already holds — and **the occurrence key does not absorb that**, because `SQL_ENQUEUE`'s conflict
675
+ target is the PARTIAL index over the live states, so a second dispatch landing after that
676
+ occurrence's job completed or dead-lettered inserts a new row and the handler runs twice. Argued
677
+ from the index definition, not reproduced — `stillLeading()` is the one place the question is
678
+ asked and `break` is the answer, the same shape the drain's `dispatching()` check already had.
679
+ `soleLeader` answers true every time and `createPgLeader` holds its grant behind an internal flag,
680
+ so the extra calls are a no-op for both — the flag also stops Postgres refcounting a second advisory
625
681
  grant that `release()`'s single unlock would never hand back.
626
682
  - **`createPgLeader` is correct only on a DEDICATED connection and boot has a pool.** A
627
683
  session-level `pg_try_advisory_lock` is released when its connection returns to the pool, so
@@ -707,6 +763,7 @@ picture from the other side.
707
763
  | `heartbeat.ts` | one claimed job's lease: the renewal interval and the loss it reports |
708
764
  | `renewal-timer.ts` | the interval a renewal runs on, and the `stopped()` latch every branch after an await re-reads |
709
765
  | `worker.ts` | `worker` role, claim loop, drain |
766
+ | `drain-wait.ts` | the drain's wait, shared by both roles: everything a teardown holds, settled — or abandoned at the budget the `close` hook was handed |
710
767
  | `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` |
711
768
  | `run-signal.ts` | the signal ONE run is cancelled by — composition that can be handed back, and that the worker can abort itself |
712
769
  | `worker-fleet-slots.ts` | the fleet slot an in-flight job holds — take, renew, hand back. The claim loop asks "may I start this one?"; this answers it across the fleet |
package/README.md CHANGED
@@ -475,6 +475,24 @@ debugging a stuck queue can read and run the exact statement.
475
475
  | `worker` | `createWorker({ driver, context, queues, concurrency })` | per-queue pools, lease heartbeat, SIGTERM drain: stop claiming → finish in-flight → close |
476
476
  | `scheduler` | `createScheduler({ driver, leader, state })` | one dispatch round at a time, catch-up policy, SIGTERM drain: stop dispatching → finish the round → release the lock |
477
477
 
478
+ Both roles register the same **pair** of hooks and bound the `close` half the same way. The
479
+ scheduler's abandoned case differs in one respect: a round still enqueueing keeps the lease rather
480
+ than handing it back, because promoting a standby onto an occurrence this node is mid-dispatch for
481
+ is the double-fire leader election exists to prevent. The lease row expires on its own.
482
+
483
+ The worker's drain is **two shutdown hooks**, one per phase: `accept` stops claiming and returns
484
+ immediately, `close` waits out the in-flight jobs and closes the driver. A running job is counted
485
+ with core's `beginWork()`, so the wait for it happens in the drain's own in-flight phase rather than
486
+ inside a hook — one hook doing all of it spends the whole `configureLifecycle({ deadlineMs })`
487
+ budget in `accept`, where every other role's "stop taking work" is still queued behind it.
488
+
489
+ **The `close` hook's wait is bounded by that same budget**, because nothing can kill a handler that
490
+ ignores `ctx.signal`: at the deadline the worker logs `jobs.worker.drain-abandoned`, closes the
491
+ driver and reaches `stopped`, and the job's lease lapses so the queue delivers it again — which is
492
+ what at-least-once already promises. Raise the budget past your slowest job rather than relying on
493
+ the wait: `configureLifecycle({ deadlineMs: 600_000 })`, with a `terminationGracePeriodSeconds` at
494
+ least as large. A manual `await worker.stop()` has no budget and waits as long as its jobs take.
495
+
478
496
  `driver` and `context` are the two required keys on `WorkerOptions`; everything else defaults.
479
497
  `context: () => Ctx` supplies the ambient `Ctx` a job run executes under — the app wires its ALS
480
498
  and its tenant there, and a worker with no way to build one would run every handler as nobody.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/jobs",
3
- "version": "10.0.0",
3
+ "version": "11.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": "10.0.0",
36
- "@ultimat3/entity": "10.0.0",
37
- "@ultimat3/schema": "10.0.0",
38
- "@ultimat3/time": "10.0.0"
35
+ "@ultimat3/core": "11.0.0",
36
+ "@ultimat3/entity": "11.0.0",
37
+ "@ultimat3/schema": "11.0.0",
38
+ "@ultimat3/time": "11.0.0"
39
39
  }
40
40
  }
@@ -223,6 +223,12 @@ export async function backfillPass<Row>(
223
223
  try {
224
224
  for (let index = 0; ; index += 1) {
225
225
  const stepName = `${STEP_PREFIX}${index}`;
226
+ /**
227
+ * Whether this batch's BODY ran, or whether the runner served its checkpoint from
228
+ * storage. Only the body can be asked: `step.run` answers the same shape either way,
229
+ * which is the whole point of a replay — so the flag is set inside it.
230
+ */
231
+ let swept = false;
226
232
  const checkpoint = asCheckpoint(
227
233
  await step.run(stepName, async (signal): Promise<Checkpoint> => {
228
234
  // INSIDE the body, which is the whole of it: a completed step is served from storage
@@ -237,6 +243,7 @@ export async function backfillPass<Row>(
237
243
  const next = await iteration.pull.next();
238
244
  if (next.done === true) return { cursor: null, rows: 0 };
239
245
  await definition.handle({ rows: next.value, ctx, signal, index });
246
+ swept = true;
240
247
  return { cursor: iteration.batches.cursor, rows: next.value.length };
241
248
  }),
242
249
  stepName,
@@ -248,8 +255,14 @@ export async function backfillPass<Row>(
248
255
  // whose last write is `finish` either way.
249
256
  if (checkpoint.rows > 0) {
250
257
  batches += 1;
251
- // Absolute, so a replayed batch reports the position it reported the first time.
252
- await ledger?.progress(runId, { rows, cursor });
258
+ // Only for a batch this attempt actually SWEPT. A replayed checkpoint runs no body and
259
+ // touches no row, so writing here re-issued one `x_backfills` UPDATE per completed
260
+ // batch before a resumed pass read a single new row — 4,800 statements on a 5M-row
261
+ // sweep killed at batch 4,800, on every attempt, inside the visibility lease the
262
+ // heartbeat is renewing. Nothing is lost by skipping them: the value is ABSOLUTE, so
263
+ // the first batch that does run reports every replayed one behind it, and `finish`
264
+ // writes the total whatever happens.
265
+ if (swept) await ledger?.progress(runId, { rows, cursor });
253
266
  }
254
267
  if (cursor === null) break;
255
268
  }
@@ -0,0 +1,50 @@
1
+ // The wait a role's teardown makes, under the drain's own budget — the worker's in-flight jobs,
2
+ // the scheduler's dispatch round. One shape for both, because the race closes over nothing either
3
+ // of them holds: the same seam `lifecycle-deadline.ts` takes in core, and for the same reason —
4
+ // an unbounded wait inside a shutdown hook is a process the kubelet ends with SIGKILL.
5
+
6
+ import { systemClock } from '@ultimat3/core';
7
+
8
+ /**
9
+ * Everything in `pending`, settled — or abandoned once `deadlineAt` (real monotonic ms, the clock
10
+ * `ShutdownReason.deadlineAt` is measured on) has passed. Answers `true` when everything settled.
11
+ *
12
+ * `deadlineAt` is `undefined` for a MANUAL `stop()`, which waits as long as its work takes: a
13
+ * caller that asked a role to stop has no budget to spend, and closing the queue under a live job
14
+ * — or handing the lease back under a live dispatch — is exactly what draining exists to prevent. The bound belongs to the SIGTERM path, where the
15
+ * budget is real and a handler that ignores `ctx.signal` would otherwise hold the teardown — and
16
+ * with it the memoized `stopping` promise every later `stop()` joins — open forever.
17
+ *
18
+ * `allSettled`, so work that rejected is work that finished: each caller observes its own failures
19
+ * already, and a teardown that rethrew here would skip the close behind it.
20
+ */
21
+ export async function settleAllBy(
22
+ pending: readonly Promise<unknown>[],
23
+ deadlineAt: number | undefined,
24
+ ): Promise<boolean> {
25
+ if (pending.length === 0) return true;
26
+ const settled = Promise.allSettled(pending);
27
+ if (deadlineAt === undefined) {
28
+ await settled;
29
+ return true;
30
+ }
31
+ const remainingMs = Math.max(0, deadlineAt - systemClock.monotonic());
32
+ return await new Promise<boolean>((resolve) => {
33
+ let decided = false;
34
+ const timer = setTimeout(() => {
35
+ if (decided) return;
36
+ decided = true;
37
+ resolve(false);
38
+ }, remainingMs);
39
+ // Never the thing keeping a drained process alive — the rule `lifecycle-deadline.ts` states
40
+ // for its own timer. A spent budget still gives the already-settled case its turn, because a
41
+ // resolved promise settles on a microtask and this timer on a macrotask.
42
+ timer.unref?.();
43
+ void settled.then(() => {
44
+ if (decided) return;
45
+ decided = true;
46
+ clearTimeout(timer);
47
+ resolve(true);
48
+ });
49
+ });
50
+ }
@@ -83,6 +83,22 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
83
83
  jobs.set(id, { ...existing, ...patch, updatedAt: nowMs(clock) });
84
84
  };
85
85
 
86
+ /**
87
+ * `update`, plus the lease columns `SQL_ACK`/`SQL_NACK` set to `null`.
88
+ *
89
+ * A settlement RELEASES the claim, and a `Partial<JobRecord>` cannot say so: both fields are
90
+ * optional, so `visibleAt: undefined` would keep the key and `{ ...existing }` keeps the value.
91
+ * Left stamped, a `done` row still named the worker that finished it and carried that attempt's
92
+ * lease deadline — which `x jobs show` prints, and which is the very pair the claim scan's
93
+ * lease-expiry branch reads to decide a row was abandoned.
94
+ */
95
+ const settle = (id: string, patch: Partial<JobRecord>): void => {
96
+ const existing = jobs.get(id);
97
+ if (existing === undefined) return;
98
+ const { visibleAt: _visibleAt, claimedBy: _claimedBy, ...released } = existing;
99
+ jobs.set(id, { ...released, ...patch, updatedAt: nowMs(clock) });
100
+ };
101
+
86
102
  const introspect: JobIntrospection = {
87
103
  job(jobId) {
88
104
  return Promise.resolve(jobs.get(jobId));
@@ -215,7 +231,7 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
215
231
  // would otherwise overwrite a row it no longer owns.
216
232
  ack(jobId: string): Promise<void> {
217
233
  if (jobs.get(jobId)?.state !== 'running') return Promise.resolve();
218
- update(jobId, { state: 'done' });
234
+ settle(jobId, { state: 'done' });
219
235
  return Promise.resolve();
220
236
  },
221
237
 
@@ -241,7 +257,7 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
241
257
  attempt: counts ? record.attempt : Math.max(0, record.attempt - 1),
242
258
  ...(nackOptions.error === undefined ? {} : { lastError: nackOptions.error }),
243
259
  };
244
- update(jobId, patch);
260
+ settle(jobId, patch);
245
261
  return Promise.resolve();
246
262
  },
247
263
 
@@ -39,6 +39,13 @@ export function startRenewalTimer(
39
39
  logger.error('jobs.renewal.raised', { error: renderThrowable(error) });
40
40
  });
41
41
  }, intervalMs);
42
+ // Never the thing keeping a drained process alive. This interval is armed from inside a job run,
43
+ // so a drain that ABANDONS the worker's hook leaves the run — and this timer — with nobody left
44
+ // to call `stop()`: refed, it holds the event loop open past every phase of the shutdown, and
45
+ // the kubelet's SIGKILL becomes the exit. `sync-node.ts` unrefs all three of its timers and
46
+ // `lifecycle-deadline.ts` its own for the same reason. A renewal is bookkeeping for work that is
47
+ // already over by then; nothing is lost by letting the process go.
48
+ timer.unref?.();
42
49
  return {
43
50
  stopped: () => stopped,
44
51
  stop(): void {
package/src/scheduler.ts CHANGED
@@ -6,16 +6,25 @@
6
6
  // advisory lock is held by the SESSION, not by this process — it outlives every transaction and is
7
7
  // released only by an explicit unlock, the pool's reset on release, or the connection dying, and
8
8
  // the next round may run on a different connection. So a node can neither renew it nor prove it
9
- // still holds one, and leadership passes to a second node while the first is still dispatching. Two schedulers
10
- // double-enqueue every task; the idempotency key would absorb it, but leader election means
11
- // the queue never sees the duplicate at all. One ROUND at a time is the same rule inside one
12
- // process: the loop re-arms on the round it just finished, and any other caller joins that
13
- // round rather than opening a second one over the same `lastFiredAt`.
9
+ // still holds one, and leadership passes to a second node while the first is still dispatching.
10
+ //
11
+ // **The occurrence key is not a second line of defence, and the lease is therefore re-asserted
12
+ // before EVERY task, not once per round.** `SQL_ENQUEUE`'s conflict target is the PARTIAL index
13
+ // over the live states (`ready`, `delayed`, `running`, `suspended`), so a duplicate enqueue is
14
+ // absorbed only while the first job is still one of those: a second dispatcher landing after that
15
+ // occurrence's job completed, dead-lettered or was retried past its row inserts a NEW row, and the
16
+ // handler runs twice. A round walks its tasks serially with an enqueue per job, so a 30s lease and
17
+ // a slow queue leave the tail of the walk running under a lease another node already took. Argued
18
+ // from the index definition, not reproduced — say it that way, as `outbox.ts` does for the same
19
+ // mechanism. One ROUND at a time is the same rule inside one process: the loop re-arms on the
20
+ // round it just finished, and any other caller joins that round rather than opening a second one
21
+ // over the same `lastFiredAt`.
14
22
 
15
23
  import type { Clock } from '@ultimat3/core';
16
24
  import { isUltimateError, logger, onShutdown, renderThrowable } from '@ultimat3/core';
17
25
  import { instant, nextCronOccurrence } from '@ultimat3/time';
18
26
  import { nowMs } from './clock';
27
+ import { settleAllBy } from './drain-wait';
19
28
  import type { JobDriver } from './driver';
20
29
  import type { TaskHandle, TaskJobResult } from './task';
21
30
  import { registeredTasks } from './task';
@@ -114,8 +123,11 @@ export function createScheduler(options: SchedulerOptions): Scheduler {
114
123
  let state: 'idle' | 'running' | 'draining' | 'stopped' = 'idle';
115
124
  /** The dispatch round in flight — what a second caller joins and what `stop()` waits out. */
116
125
  let round: Promise<readonly DispatchedOccurrence[]> | undefined;
117
- /** The `onShutdown` registration this scheduler holds while it runs. Handed back by `stop()`. */
118
- let releaseShutdownHook: (() => void) | undefined;
126
+ /**
127
+ * The two `onShutdown` registrations this scheduler holds while it runs, both handed back by
128
+ * `stop()`. Two, for the phases they answer — the worker's rule, and `listenSyncNode`'s.
129
+ */
130
+ let releaseShutdownHooks: (() => void)[] = [];
119
131
  /** The teardown in flight, so a SIGTERM landing on a manual stop joins it. */
120
132
  let stopping: Promise<void> | undefined;
121
133
 
@@ -176,24 +188,31 @@ export function createScheduler(options: SchedulerOptions): Scheduler {
176
188
  /** The drain's one question: may this scheduler still dispatch an occurrence? */
177
189
  const dispatching = (): boolean => state !== 'draining' && state !== 'stopped';
178
190
 
191
+ /**
192
+ * Asked EVERY round AND before every task in it, never only while `isLeader` is false. A
193
+ * lease-backed election (the one a pooled executor can use — `createPgLeaseLeader`) expires on a
194
+ * wall clock, so `acquire()` is also its renewal and a node that cached `isLeader = true` keeps
195
+ * dispatching past a lease another node has already taken. `soleLeader` answers true every time
196
+ * and `createPgLeader` holds its grant behind an internal flag, so the extra calls are a no-op
197
+ * for both.
198
+ */
199
+ const stillLeading = async (): Promise<boolean> => {
200
+ if (await leader.acquire()) {
201
+ isLeader = true;
202
+ return true;
203
+ }
204
+ // Demoted, or never elected. Nothing to release — a lease we no longer hold is not ours to
205
+ // hand back, and `teardown` reads this same flag before it calls `release()`.
206
+ if (isLeader) logger.warn('jobs.scheduler.leadership-lost', { at: nowMs(options.clock) });
207
+ isLeader = false;
208
+ return false;
209
+ };
210
+
179
211
  const runRound = async (): Promise<readonly DispatchedOccurrence[]> => {
180
212
  // Never take the lock a drain is on its way to releasing: a round that acquired it here
181
213
  // would still be enqueueing after `stop()` handed the occurrence to the next node.
182
214
  if (!dispatching()) return [];
183
- // Asked EVERY round, not only while `isLeader` is false. A lease-backed election (the one a
184
- // pooled executor can use — `createPgLeaseLeader`) expires, so `acquire()` is also its
185
- // renewal, and a node that cached `isLeader = true` would keep dispatching past a lease
186
- // another node has already taken. `soleLeader` answers true every time, and `createPgLeader`
187
- // holds its grant internally, so this is a no-op for both.
188
- const held = await leader.acquire();
189
- if (!held) {
190
- // Demoted, or never elected. Nothing to release — a lease we no longer hold is not ours to
191
- // hand back, and `teardown` reads this same flag before it calls `release()`.
192
- if (isLeader) logger.warn('jobs.scheduler.leadership-lost', { at: nowMs(options.clock) });
193
- isLeader = false;
194
- return [];
195
- }
196
- isLeader = true;
215
+ if (!(await stillLeading())) return [];
197
216
 
198
217
  const at = nowMs(options.clock);
199
218
  const tasks = options.tasks ?? registeredTasks();
@@ -204,6 +223,10 @@ export function createScheduler(options: SchedulerOptions): Scheduler {
204
223
  // at the next round. A task not reached simply fires next time — its `lastFiredAt` is
205
224
  // untouched — while the occurrence this round already began is the one `stop()` waits for.
206
225
  if (!dispatching()) break;
226
+ // The lease, on the same rule and for the same reason the drain state is re-read: it expires
227
+ // on a wall clock in the middle of this walk, not between rounds. See the file header for
228
+ // what a second dispatcher costs — the occurrence key does NOT absorb it in general.
229
+ if (!(await stillLeading())) break;
207
230
  const last = await schedulerState.lastFiredAt(handle.name);
208
231
  if (last === undefined) {
209
232
  // First sight of this task: arm it, never fire retroactively for all of history.
@@ -275,10 +298,20 @@ export function createScheduler(options: SchedulerOptions): Scheduler {
275
298
  }, tickIntervalMs);
276
299
  };
277
300
 
278
- const teardown = async (reason: string): Promise<void> => {
301
+ /**
302
+ * The whole of the `accept` phase: stop dispatching, and nothing else. Synchronous on purpose —
303
+ * the hook behind this one is somebody else's "stop taking work", and a phase that waits is a
304
+ * phase that spends the budget those hooks were going to need.
305
+ */
306
+ const stopDispatching = (): void => {
307
+ if (state === 'stopped') return;
279
308
  state = 'draining';
280
309
  if (timer !== undefined) clearTimeout(timer);
281
310
  timer = undefined;
311
+ };
312
+
313
+ const teardown = async (reason: string, deadlineAt?: number): Promise<void> => {
314
+ stopDispatching();
282
315
  logger.info('jobs.scheduler.draining', { reason, dispatching: round !== undefined });
283
316
  try {
284
317
  // The round this stop races runs to the end first. Releasing the lease under a
@@ -286,8 +319,24 @@ export function createScheduler(options: SchedulerOptions): Scheduler {
286
319
  // then own the same occurrence — the exact double-fire leader election exists to prevent.
287
320
  // Settled, not awaited: a round that failed is its own caller's to see, and the lease still
288
321
  // has to go back.
289
- await Promise.allSettled([round]);
290
- if (isLeader) await leader.release();
322
+ //
323
+ // Bounded on the SIGTERM path, `undefined` on a manual stop: a round parked in `enqueue` on
324
+ // a queue that is not answering cannot be cancelled from here, and an unbounded wait is a
325
+ // teardown that never ends — hooks never handed back, `state` never past 'draining', and the
326
+ // memoised `stopping` every later `stop()` joins never settling.
327
+ const dispatched = await settleAllBy(round === undefined ? [] : [round], deadlineAt);
328
+ // A round we ABANDONED is a round still enqueueing, so the lock is deliberately NOT handed
329
+ // back: promoting a standby onto an occurrence this process is mid-dispatch for is the
330
+ // double-fire above, delivered by the shutdown. A lease row expires on its own, which is
331
+ // what an expiry is for — and `isLeader` is cleared below either way.
332
+ if (!dispatched) {
333
+ logger.warn('jobs.scheduler.drain-abandoned', {
334
+ reason,
335
+ fix: 'raise the drain budget past a dispatch round — configureLifecycle({ deadlineMs: 60_000 }) — and set terminationGracePeriodSeconds to at least as many seconds',
336
+ });
337
+ } else if (isLeader) {
338
+ await leader.release();
339
+ }
291
340
  } finally {
292
341
  // Whatever the release did, this scheduler is done. `isLeader` false because a lock this
293
342
  // process no longer holds — or failed to hand back — must never be re-used as if it did,
@@ -295,17 +344,20 @@ export function createScheduler(options: SchedulerOptions): Scheduler {
295
344
  // the next process-wide drain, and keeps this closure and its driver alive with it.
296
345
  isLeader = false;
297
346
  state = 'stopped';
298
- releaseShutdownHook?.();
299
- releaseShutdownHook = undefined;
347
+ for (const release of releaseShutdownHooks) release();
348
+ releaseShutdownHooks = [];
300
349
  }
301
350
  };
302
351
 
303
- const stop = async (reason = 'stop'): Promise<void> => {
352
+ const stop = async (reason = 'stop', deadlineAt?: number): Promise<void> => {
353
+ // Answered immediately once this scheduler is done: the teardown always REACHES 'stopped', so
354
+ // a caller landing after an abandoned drain gets an answer rather than joining a promise that
355
+ // never settles.
304
356
  if (state === 'stopped') return;
305
357
  // One teardown, joined rather than repeated — the worker's rule, for the same reason: a
306
358
  // SIGTERM landing on a manual stop must wait out the same round, not release the lock a
307
359
  // second time behind it. Cleared as it settles, so a scheduler started again stops again.
308
- stopping ??= teardown(reason).finally(() => {
360
+ stopping ??= teardown(reason, deadlineAt).finally(() => {
309
361
  stopping = undefined;
310
362
  });
311
363
  await stopping;
@@ -317,14 +369,19 @@ export function createScheduler(options: SchedulerOptions): Scheduler {
317
369
  // about to release, and stack a second shutdown hook on the one still running.
318
370
  if (state !== 'idle' && state !== 'stopped') return;
319
371
  state = 'running';
320
- // 'accept' phase: stop dispatching before core waits on in-flight work — an occurrence
321
- // enqueued during the drain is work nothing in this process is left to run. The
322
- // unregister is kept, never discarded: `stop()` hands it back, so start -> stop -> start
323
- // holds ONE hook rather than one per start.
372
+ // TWO hooks, for the two phases. `accept` stops dispatching and returns — an occurrence
373
+ // enqueued during the drain is work nothing in this process is left to run, and every hook
374
+ // behind this one still has the whole budget. `close` waits the round out and hands the
375
+ // lease back, bounded by the deadline it is given. Both unregisters are kept, never
376
+ // discarded: `stop()` hands them back, so start -> stop -> start holds one pair rather
377
+ // than one per start.
324
378
  if (options.drainOnShutdown !== false) {
325
- releaseShutdownHook = onShutdown('jobs.scheduler', () => stop('SIGTERM'), {
326
- phase: 'accept',
327
- });
379
+ releaseShutdownHooks = [
380
+ onShutdown('jobs.scheduler.accept', stopDispatching, { phase: 'accept' }),
381
+ onShutdown('jobs.scheduler', (reason) => stop('SIGTERM', reason.deadlineAt), {
382
+ phase: 'close',
383
+ }),
384
+ ];
328
385
  }
329
386
  schedule();
330
387
  logger.info('jobs.scheduler.started', { tasks: (options.tasks ?? registeredTasks()).length });
package/src/worker.ts CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  import type { Clock, Ctx } from '@ultimat3/core';
7
7
  import {
8
+ beginWork,
8
9
  logger,
9
10
  onShutdown,
10
11
  recordJob,
@@ -13,6 +14,7 @@ import {
13
14
  uuid,
14
15
  } from '@ultimat3/core';
15
16
  import { nowMs } from './clock';
17
+ import { settleAllBy } from './drain-wait';
16
18
  import type { ClaimedJob, JobDriver, QueueStats } from './driver';
17
19
  import { DEFAULT_QUEUE, DEFAULT_VISIBILITY_TIMEOUT_MS } from './driver';
18
20
  import { ConcurrencyUnenforceableError } from './errors';
@@ -113,8 +115,12 @@ export function createWorker(options: WorkerOptions): Worker {
113
115
  const rounds = new Set<Promise<unknown>>();
114
116
  let state: WorkerStats['state'] = 'idle';
115
117
  let loop: ReturnType<typeof setTimeout> | undefined;
116
- /** The `onShutdown` registration this worker holds while it runs. Handed back by `stop()`. */
117
- let releaseShutdownHook: (() => void) | undefined;
118
+ /**
119
+ * The two `onShutdown` registrations this worker holds while it runs, both handed back by
120
+ * `stop()`. Two, because they answer different questions in different PHASES — the split
121
+ * `listenSyncNode` and `@ultimat3/http` already have.
122
+ */
123
+ let releaseShutdownHooks: (() => void)[] = [];
118
124
  /** The teardown in flight, so a second `stop()` joins it instead of running a second one. */
119
125
  let stopping: Promise<void> | undefined;
120
126
  let processed = 0;
@@ -262,6 +268,10 @@ export function createWorker(options: WorkerOptions): Worker {
262
268
  continue;
263
269
  }
264
270
 
271
+ // The claimed job is the process's in-flight work, counted where the DRAIN can see it:
272
+ // core's own in-flight wait sits between `accept` and `inflight` and exists for exactly
273
+ // this. Counted nowhere, the worker had to wait for its own jobs inside a hook.
274
+ const finishWork = beginWork();
265
275
  const running = runClaimed(job)
266
276
  .then((execution) => {
267
277
  if (execution.outcome === 'completed') processed += 1;
@@ -276,9 +286,20 @@ export function createWorker(options: WorkerOptions): Worker {
276
286
  if (label !== null) recordJob(queue, label);
277
287
  return execution;
278
288
  })
279
- .finally(() => {
289
+ .finally(async () => {
280
290
  lease.release();
281
- void fleetSlots.release(job.id);
291
+ // AWAITED, never `void`: the slot is a row in `x_job_leases`, so the DELETE was still
292
+ // on the wire when the teardown's `allSettled` returned and `driver.close()` took the
293
+ // connection out from under it — a `concurrency: 1` job unclaimable by the pod
294
+ // replacing this one for a whole visibility window after every deploy. `release`
295
+ // swallows its own failures, so awaiting it cannot reject a job that finished; the
296
+ // `finally` is for the one that could, because a lost `finishWork()` is an in-flight
297
+ // count that never returns to zero and a drain that waits out its whole budget.
298
+ try {
299
+ await fleetSlots.release(job.id);
300
+ } finally {
301
+ finishWork();
302
+ }
282
303
  });
283
304
 
284
305
  started.push(running);
@@ -348,18 +369,42 @@ export function createWorker(options: WorkerOptions): Worker {
348
369
  }, pollIntervalMs);
349
370
  };
350
371
 
351
- const teardown = async (reason: string): Promise<void> => {
372
+ /**
373
+ * The whole of the `accept` phase: stop taking work, and nothing else. Synchronous on purpose —
374
+ * a phase whose job is to be over before the load balancer's next health check must not contain
375
+ * a wait, and the hook behind this one is somebody else's "stop listening".
376
+ */
377
+ const stopAccepting = (): void => {
378
+ if (state === 'stopped') return;
352
379
  state = 'draining';
353
380
  if (loop !== undefined) clearTimeout(loop);
354
381
  loop = undefined;
382
+ };
383
+
384
+ const teardown = async (reason: string, deadlineAt?: number): Promise<void> => {
385
+ stopAccepting();
355
386
  logger.info('jobs.worker.draining', { workerId, reason, inFlight: inFlight.size });
356
387
  try {
357
388
  // Stop claiming, finish what we hold, then close. Anything else re-runs work on deploy.
358
389
  // Rounds first: one that passed the guard before the flag flipped is still awaiting its
359
390
  // `claim()`, and the jobs it starts join `inFlight` after any snapshot taken here — so a
360
391
  // drain that waited on `inFlight` alone closed the driver under a job that had just begun.
361
- await Promise.allSettled([...rounds]);
362
- await Promise.allSettled([...inFlight]);
392
+ //
393
+ // Both waits share ONE deadline on the SIGTERM path (`undefined` on a manual stop, which
394
+ // waits as long as its jobs take). Nothing can kill a body that ignores `ctx.signal`, so an
395
+ // unbounded wait here is a teardown that never ends: driver never closed, state never past
396
+ // 'draining', and the memoized `stopping` every later `stop()` joins never settling.
397
+ // Abandoning costs a lapsed lease and a redelivered job — at-least-once, as promised.
398
+ const rounded = await settleAllBy([...rounds], deadlineAt);
399
+ const drained = (await settleAllBy([...inFlight], deadlineAt)) && rounded;
400
+ if (!drained) {
401
+ logger.warn('jobs.worker.drain-abandoned', {
402
+ workerId,
403
+ reason,
404
+ inFlight: inFlight.size,
405
+ fix: 'raise the drain budget past the slowest job — configureLifecycle({ deadlineMs: 600_000 }) — and set terminationGracePeriodSeconds to at least as many seconds',
406
+ });
407
+ }
363
408
  await options.driver.close?.();
364
409
  } finally {
365
410
  // Whatever the close did, this worker is done: a state left at 'draining' is a drain that
@@ -369,19 +414,22 @@ export function createWorker(options: WorkerOptions): Worker {
369
414
  // through a driver already closed — and keeps this closure, its driver and its in-flight
370
415
  // set alive with it.
371
416
  state = 'stopped';
372
- releaseShutdownHook?.();
373
- releaseShutdownHook = undefined;
417
+ for (const release of releaseShutdownHooks) release();
418
+ releaseShutdownHooks = [];
374
419
  }
375
420
  };
376
421
 
377
- const stop = async (reason = 'stop'): Promise<void> => {
422
+ const stop = async (reason = 'stop', deadlineAt?: number): Promise<void> => {
423
+ // Answered immediately once this worker is done: the teardown always REACHES 'stopped' (its
424
+ // waits are bounded and the state is set in a `finally`), so a caller landing after an
425
+ // abandoned drain gets an answer rather than joining a promise that never settles.
378
426
  if (state === 'stopped') return;
379
427
  // One teardown, joined rather than repeated: a SIGTERM landing on a manual stop must wait out
380
428
  // the same in-flight work, not close the driver a second time underneath it. Cleared as it
381
429
  // settles, so a worker that started again tears down again instead of joining a promise that
382
430
  // settled a lifetime ago. A close that threw still stopped this worker — the failure is the
383
431
  // caller's to see on the promise it awaited, not a teardown to run twice.
384
- stopping ??= teardown(reason).finally(() => {
432
+ stopping ??= teardown(reason, deadlineAt).finally(() => {
385
433
  stopping = undefined;
386
434
  });
387
435
  await stopping;
@@ -407,13 +455,22 @@ export function createWorker(options: WorkerOptions): Worker {
407
455
  }
408
456
  state = 'running';
409
457
  logger.info('jobs.worker.started', { workerId, queues });
410
- // 'accept' phase: stop claiming before core waits on in-flight jobs. The unregister is
411
- // kept, never discarded: `stop()` hands it back, so start -> stop -> start holds ONE hook
412
- // rather than one per start, each retaining the driver of a worker that is already gone.
458
+ // TWO hooks, for the two phases that answer two questions. `accept` stops claiming and
459
+ // returns, so every hook behind it the HTTP server's "stop listening", the sync node's
460
+ // "stop upgrading" runs while the budget is still whole; one hook doing both spent all of
461
+ // it in the phase whose whole purpose is to be quick. `close` waits out what this worker
462
+ // holds and closes the driver, bounded by the deadline the hook is handed.
463
+ //
464
+ // Both unregisters are kept, never discarded: `stop()` hands them back, so
465
+ // start -> stop -> start holds one pair rather than one per start, each retaining the
466
+ // driver of a worker that is already gone.
413
467
  if (options.drainOnShutdown !== false) {
414
- releaseShutdownHook = onShutdown(`jobs.worker.${workerId}`, () => stop('SIGTERM'), {
415
- phase: 'accept',
416
- });
468
+ releaseShutdownHooks = [
469
+ onShutdown(`jobs.worker.${workerId}.accept`, stopAccepting, { phase: 'accept' }),
470
+ onShutdown(`jobs.worker.${workerId}`, (reason) => stop('SIGTERM', reason.deadlineAt), {
471
+ phase: 'close',
472
+ }),
473
+ ];
417
474
  }
418
475
  schedule();
419
476
  },