@ultimat3/jobs 23.0.0 → 24.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.
Files changed (45) hide show
  1. package/CLAUDE.md +51 -52
  2. package/README.md +34 -5
  3. package/package.json +6 -6
  4. package/src/backfill-ledger.ts +24 -2
  5. package/src/backfill-pass.ts +22 -5
  6. package/src/claim-exhausted.ts +76 -0
  7. package/src/driver-memory-operator.ts +31 -9
  8. package/src/driver-memory.ts +68 -23
  9. package/src/driver-nats.ts +5 -2
  10. package/src/driver-pg-jobs-sql.ts +27 -19
  11. package/src/driver-pg-operator-sql.ts +32 -12
  12. package/src/driver-pg-operator.ts +19 -3
  13. package/src/driver-pg-rows.ts +3 -3
  14. package/src/driver-pg-sql.ts +98 -29
  15. package/src/driver-pg.ts +86 -81
  16. package/src/driver-redis.ts +5 -2
  17. package/src/driver.ts +37 -1
  18. package/src/errors-operator.ts +12 -4
  19. package/src/errors-requeue.ts +21 -3
  20. package/src/errors.ts +5 -13
  21. package/src/events-pg.ts +8 -18
  22. package/src/events.ts +20 -9
  23. package/src/execute.ts +1 -0
  24. package/src/index.ts +10 -4
  25. package/src/inspect-operator.ts +3 -3
  26. package/src/inspect.ts +4 -3
  27. package/src/introspection.ts +23 -11
  28. package/src/job.ts +5 -6
  29. package/src/limits.ts +33 -7
  30. package/src/outbox-pg.ts +1 -2
  31. package/src/outbox.ts +37 -9
  32. package/src/purge.ts +1 -1
  33. package/src/queue-wake.ts +1 -2
  34. package/src/scheduler-pg.ts +3 -2
  35. package/src/scheduler-state.ts +8 -1
  36. package/src/scheduler.ts +9 -10
  37. package/src/steps-suspension.ts +46 -0
  38. package/src/steps-timeout.ts +33 -0
  39. package/src/steps.ts +55 -81
  40. package/src/worker-admit.ts +7 -3
  41. package/src/worker-fleet-slots.ts +78 -44
  42. package/src/worker-key-busy.ts +38 -10
  43. package/src/worker-loop.ts +35 -11
  44. package/src/worker-registry.ts +16 -1
  45. package/src/worker.ts +52 -9
package/src/steps.ts CHANGED
@@ -13,6 +13,15 @@ import type { DurationInput } from './clock';
13
13
  import { finiteDurationMs, nowMs } from './clock';
14
14
  import { JobAbortedError, JobTimeoutError, StepDuplicateError } from './errors';
15
15
  import { createRunSignal } from './run-signal';
16
+ import {
17
+ isStepSuspension,
18
+ isWaitTimedOut,
19
+ StepSuspension,
20
+ WAIT_TIMED_OUT,
21
+ } from './steps-suspension';
22
+ import { withStepTimeout } from './steps-timeout';
23
+
24
+ export { isStepSuspension, StepSuspension } from './steps-suspension';
16
25
 
17
26
  /**
18
27
  * The runtime list is the declaration and `StepStatus` is derived from it, the shape
@@ -45,9 +54,23 @@ export interface StepRecord {
45
54
  readonly error?: string;
46
55
  }
47
56
 
57
+ /** The claim a step write is made under: `claimOf(claimed)`, plus the row it names. */
58
+ export interface StepFence {
59
+ readonly job: string;
60
+ readonly jobId: string;
61
+ readonly workerId: string;
62
+ readonly claim: number;
63
+ }
64
+
48
65
  export interface StepStore {
49
66
  get(runId: string, name: string): Promise<StepRecord | undefined>;
50
- put(record: StepRecord): Promise<void>;
67
+ /**
68
+ * With `by`, the write lands only while that claim still holds the row (`running`, same worker,
69
+ * same claim ordinal — `SQL_ACK`'s fence) and is `X_JOB_LEASE_LOST` otherwise: the in-process
70
+ * signal cannot stop a body in ANOTHER process whose lease lapsed. Without it the write is
71
+ * unfenced — a transfer (`x jobs drain`) or a runner driven by hand, with no row to hold.
72
+ */
73
+ put(record: StepRecord, by?: StepFence): Promise<void>;
51
74
  list(runId: string): Promise<readonly StepRecord[]>;
52
75
  del(runId: string, name: string): Promise<void>;
53
76
  clear(runId: string): Promise<void>;
@@ -55,6 +78,13 @@ export interface StepStore {
55
78
 
56
79
  /** The slice of the event bus a waiting step needs. Implemented by `events.ts`. */
57
80
  export interface EventLookup {
81
+ /**
82
+ * The bus's OWN clock, in epoch ms: what a publish issued now would be stamped with. A consumer
83
+ * that wants "published after I asked" takes its "asked at" from here and never from its own
84
+ * process — `publishedAt` is this clock's, and two clocks compared is a skew between pods
85
+ * deciding which answers count. `waitForEvent` stamps a NEW wait with it.
86
+ */
87
+ now(): Promise<number>;
58
88
  find(
59
89
  event: string,
60
90
  correlationKey: string | undefined,
@@ -90,53 +120,12 @@ export interface StepApi {
90
120
  ): Promise<T | undefined>;
91
121
  }
92
122
 
93
- /**
94
- * Control flow, not a failure: it must never be logged as an error or counted as an attempt.
95
- * Branded by string rather than `instanceof` so two copies of this module still agree.
96
- */
97
- export class StepSuspension extends Error {
98
- static readonly brand = 'ultimate.jobs.suspension';
99
- readonly brand: string = StepSuspension.brand;
100
- readonly step: string;
101
- readonly resumeAt: number;
102
- readonly reason: 'sleep' | 'event';
103
-
104
- constructor(input: { step: string; resumeAt: number; reason: 'sleep' | 'event' }) {
105
- super(`step "${input.step}" suspended until ${new Date(input.resumeAt).toISOString()}`);
106
- this.name = 'StepSuspension';
107
- this.step = input.step;
108
- this.resumeAt = input.resumeAt;
109
- this.reason = input.reason;
110
- }
111
- }
112
-
113
- /**
114
- * What a timed-out `waitForEvent` persists, and maps back to `undefined` on replay. `undefined`
115
- * itself cannot survive a store — the pg driver writes `JSON.stringify(output ?? null)` — so the
116
- * timeout replayed as `null` and `evt === undefined` read as "an event arrived". A namespaced key
117
- * rather than a bare `{ timedOut: true }`, so no event payload can be mistaken for the marker.
118
- */
119
- const WAIT_TIMED_OUT = Object.freeze<Record<string, true>>({
120
- '~ultimate.waitTimedOut': true,
121
- });
122
-
123
- function isWaitTimedOut(output: unknown): boolean {
124
- return (
125
- typeof output === 'object' &&
126
- output !== null &&
127
- Object.keys(output).length === 1 &&
128
- (output as Record<string, unknown>)['~ultimate.waitTimedOut'] === true
129
- );
130
- }
131
-
132
- export function isStepSuspension(error: unknown): error is StepSuspension {
133
- return error instanceof Error && (error as { brand?: unknown }).brand === StepSuspension.brand;
134
- }
135
-
136
123
  export interface StepRunnerOptions {
137
124
  readonly runId: string;
138
125
  readonly jobName: string;
139
126
  readonly store: StepStore;
127
+ /** The claim this attempt runs under — every write is fenced on it. `executeJob` passes it. */
128
+ readonly fence?: StepFence;
140
129
  readonly clock?: Clock;
141
130
  readonly events?: EventLookup;
142
131
  /** How long a waiting step stays parked between event polls. Default 30s. */
@@ -256,7 +245,7 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
256
245
  const persist = (record: StepRecord): Promise<void> =>
257
246
  expectedQueryLoop(
258
247
  'a durable step is written the instant it completes, one statement per step by design',
259
- () => store.put(record),
248
+ () => store.put(record, options.fence),
260
249
  );
261
250
 
262
251
  /**
@@ -331,8 +320,16 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
331
320
  attempts,
332
321
  error: renderThrowable(error),
333
322
  };
334
- await persist(failure);
335
- remember(failure);
323
+ try {
324
+ await persist(failure);
325
+ remember(failure);
326
+ } catch (unwritten) {
327
+ logger.warn('jobs.step.failure-unrecorded', {
328
+ job: jobName,
329
+ step: name,
330
+ error: renderThrowable(unwritten),
331
+ });
332
+ }
336
333
  }
337
334
  throw error;
338
335
  } finally {
@@ -389,7 +386,9 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
389
386
  }
390
387
 
391
388
  const at = now();
392
- const startedAt = existing?.startedAt ?? at;
389
+ // A NEW wait is stamped by the bus, whose clock stamps every `publishedAt` it is compared
390
+ // with; the runner's own only when there is no bus to ask. A re-poll keeps what it persisted.
391
+ const startedAt = existing?.startedAt ?? (await options.events?.now()) ?? at;
393
392
  const deadline =
394
393
  startedAt +
395
394
  finiteDurationMs(waitOptions.timeout ?? 86_400_000, 'step.waitForEvent', 'timeout');
@@ -411,7 +410,13 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
411
410
  return hit.payload as T;
412
411
  }
413
412
 
414
- if (at >= deadline) {
413
+ // `deadline` is on the BUS's clock, so the bus calls the timeout: asked only once this runner
414
+ // believes the time is up, which costs a wait one statement at its end. A runner AHEAD of the
415
+ // bus used to give up early by the skew — on an answer still inside its window. (A runner
416
+ // BEHIND waits the skew longer; knowing that sooner would cost a round trip on every poll.)
417
+ let remaining = deadline - at;
418
+ if (remaining <= 0) remaining = deadline - ((await options.events?.now()) ?? at);
419
+ if (remaining <= 0) {
415
420
  if (waitOptions.required === true) {
416
421
  throw new JobTimeoutError({
417
422
  job: jobName,
@@ -437,7 +442,7 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
437
442
  return undefined;
438
443
  }
439
444
 
440
- const resumeAt = Math.min(deadline, at + pollMs);
445
+ const resumeAt = at + Math.min(remaining, pollMs);
441
446
  await put({
442
447
  runId,
443
448
  name,
@@ -463,34 +468,3 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
463
468
  replayedNames: () => [...replayed],
464
469
  };
465
470
  }
466
-
467
- /**
468
- * A step's own ceiling. It ABORTS before it rejects, in that order: `run()` fails the step on this
469
- * rejection and the job retries, so a body still holding a socket open past the deadline would be
470
- * racing the attempt that replaced it. Cancelling first is the only thing that can stop it.
471
- */
472
- function withStepTimeout<T>(
473
- work: Promise<T> | T,
474
- timeoutMs: number | undefined,
475
- deadline: AbortController,
476
- error: () => Error,
477
- ): Promise<T> {
478
- if (timeoutMs === undefined || timeoutMs <= 0) return Promise.resolve(work);
479
- return new Promise<T>((resolve, reject) => {
480
- const timer = setTimeout(() => {
481
- const failure = error();
482
- deadline.abort(failure);
483
- reject(failure);
484
- }, timeoutMs);
485
- Promise.resolve(work).then(
486
- (value) => {
487
- clearTimeout(timer);
488
- resolve(value);
489
- },
490
- (cause) => {
491
- clearTimeout(timer);
492
- reject(cause);
493
- },
494
- );
495
- });
496
- }
@@ -95,7 +95,8 @@ export function createAdmission(options: AdmissionOptions): Admit {
95
95
  try {
96
96
  grant = await fleetSlots.acquire(job);
97
97
  } catch (error) {
98
- lease.release();
98
+ // Never started: the slot AND the rate stamp go back (`Lease.abandon`).
99
+ lease.abandon();
99
100
  // This job and every one behind it go BACK, unburned — rethrowing alone stranded them in
100
101
  // `running` with an attempt spent on work that never started.
101
102
  await handBack(driver, [job, ...behind], back);
@@ -106,7 +107,9 @@ export function createAdmission(options: AdmissionOptions): Admit {
106
107
  // refusal before the body: it fails as an attempt, never as a round.
107
108
  if (grant.outcome === 'undecidable') return { kind: 'run', lease, refusal: grant.error };
108
109
 
109
- lease.release();
110
+ // Over the fleet cap, so this run does not start: `abandon`, never `release`, or a tenant
111
+ // waiting behind a full `job.concurrency` pays one `ratePerTenant` start per poll interval.
112
+ lease.abandon();
110
113
  try {
111
114
  // The two `whenBusy` answers. `'fail'` settles the run here, its body never started.
112
115
  if (grant.outcome === 'fail') {
@@ -119,7 +122,8 @@ export function createAdmission(options: AdmissionOptions): Admit {
119
122
  context: options.context,
120
123
  ...(options.clock === undefined ? {} : { clock: options.clock }),
121
124
  });
122
- return { kind: 'refused', execution };
125
+ // No execution: the nack did not land, so the row is not this worker's to have refused.
126
+ return execution === undefined ? { kind: 'waiting' } : { kind: 'refused', execution };
123
127
  }
124
128
  const keyed = grant.key === undefined ? '' : `, key ${grant.key}`;
125
129
  await shed(job, queue, `job concurrency (${grant.limit}${keyed})`);
@@ -3,7 +3,9 @@
3
3
  // Apart from `worker.ts` because the claim loop's question is "may I start this one?" — which job
4
4
  // holds which slot, and who gives it back, is bookkeeping of its own.
5
5
 
6
+ import type { Clock } from '@ultimat3/core';
6
7
  import { logger, renderThrowable } from '@ultimat3/core';
8
+ import { nowMs } from './clock';
7
9
  import type { ClaimedJob, JobDriver } from './driver';
8
10
  import { ConcurrencyUnenforceableError } from './errors-concurrency';
9
11
  import { getJob, registeredJobs } from './job';
@@ -11,12 +13,6 @@ import type { HeldLease, LeaseStore } from './leases';
11
13
  import { jobLeaseKey } from './leases';
12
14
  import { type IntervalScheduler, startRenewalTimer } from './renewal-timer';
13
15
 
14
- /**
15
- * A renewal that REJECTED is not a lost slot: there is a TTL behind it and the interval gets
16
- * several tries inside it, exactly as `heartbeat.ts` treats a failed `driver.heartbeat`. The
17
- * heartbeat cannot cover for this one either way — it renews `x_jobs.visible_at`, a different row
18
- * on a different clock, and knows nothing about `x_job_leases`.
19
- */
20
16
  const noop = (): void => undefined;
21
17
 
22
18
  export interface FleetSlotOptions {
@@ -33,6 +29,8 @@ export interface FleetSlotOptions {
33
29
  readonly renewIntervalMs: number;
34
30
  /** What every renewal runs on (`renewal-timer.ts`). Default: a real, unrefed interval. */
35
31
  readonly schedule?: IntervalScheduler;
32
+ /** What "a whole TTL without a renewal landing" is measured on. Default: the system clock. */
33
+ readonly clock?: Clock;
36
34
  }
37
35
 
38
36
  /**
@@ -97,8 +95,14 @@ export interface FleetSlots {
97
95
  }
98
96
 
99
97
  export function createFleetSlots(options: FleetSlotOptions): FleetSlots {
100
- /** The fleet slot each in-flight job holds, so the renewal finds it and the drain frees it. */
101
- const held = new Map<string, HeldLease>();
98
+ /**
99
+ * The fleet slots each in-flight job holds, OLDEST FIRST, so the renewal finds one and the
100
+ * settle frees one. A list, because one worker can hold the same job twice: a claim that lapsed
101
+ * while its body kept running, and this worker's own re-claim of the row. One entry per job id
102
+ * let the second overwrite the first — the first run's settle then released the second's slot,
103
+ * and the first's was never released at all.
104
+ */
105
+ const held = new Map<string, HeldLease[]>();
102
106
 
103
107
  return {
104
108
  async acquire(claimed) {
@@ -127,7 +131,7 @@ export function createFleetSlots(options: FleetSlotOptions): FleetSlots {
127
131
  slotHolder(options.workerId, claimed.id),
128
132
  );
129
133
  if (slot !== undefined) {
130
- held.set(claimed.id, slot);
134
+ held.set(claimed.id, [...(held.get(claimed.id) ?? []), slot]);
131
135
  return GRANTED;
132
136
  }
133
137
  if (handle.whenBusy !== 'fail' || key === undefined) return { outcome: 'wait', limit, key };
@@ -144,47 +148,77 @@ export function createFleetSlots(options: FleetSlotOptions): FleetSlots {
144
148
  },
145
149
 
146
150
  startRenewal(jobId, onLost) {
147
- const slot = held.get(jobId);
151
+ // The NEWEST: a run arms its renewal right behind its own acquire.
152
+ const slot = held.get(jobId)?.at(-1);
148
153
  if (slot === undefined) return noop;
149
- // Renewed on the lease heartbeat's own interval and released in the same `finally`: one
150
- // clock for "this worker still owns the job" and "this worker still owns the slot" is one
151
- // fewer way for them to disagree — and `timer.stopped()` is the same latch `heartbeat.ts`
152
- // reads, for the same reason.
153
- const timer = startRenewalTimer(
154
- options.renewIntervalMs,
155
- () =>
156
- options.leases
157
- ?.renew(slot, options.ttlMs)
158
- .then((renewed) => {
159
- // `=== false`, never `!renewed`, for the reason `heartbeat.ts` reads `held` that way:
160
- // a store written before this return value existed resolves `undefined`, and treating
161
- // that as a loss would cancel every job on every renewal. Only an explicit no is one.
162
- //
163
- // `stopped()` re-read AFTER the await for the other half: the run settles, this timer
164
- // is stopped and `worker.ts` releases the slot — so the renewal already on the wire
165
- // finds the row gone and answers `false` for a job that FINISHED. Reported, that is
166
- // `jobs.worker.slot-lost` at error and an abort on a controller `runSignal.dispose()`
167
- // has already torn down: noise about a run nobody lost.
168
- if (renewed !== false || timer.stopped()) return;
169
- timer.stop();
170
- logger.error('jobs.worker.slot-lost', {
171
- workerId: options.workerId,
172
- jobId,
173
- leaseKey: slot.key,
174
- slot: slot.slot,
175
- });
176
- onLost?.(slot);
177
- })
178
- .catch(noop),
179
- options.schedule,
180
- );
154
+ // Renewed on the lease heartbeat's own interval and released in the same `finally` — and
155
+ // LOST by the same two facts `heartbeat.ts` loses a job's lease by: the store saying "not
156
+ // yours", or a whole TTL on this process's clock with no renewal landing. The heartbeat
157
+ // cannot cover for this one: it renews `x_jobs.visible_at`, a different row.
158
+ const now = (): number => nowMs(options.clock);
159
+ let renewedAt = now();
160
+ let renewing = false;
161
+ let lost = false;
162
+ const lapsed = (): boolean => now() - renewedAt >= options.ttlMs;
163
+
164
+ /** Once, then renewal stops: extending a slot this worker no longer holds pushes out another's. */
165
+ const reportLost = (reason: 'expired' | 'not-ours', error?: unknown): void => {
166
+ // `stopped()` for the run that FINISHED: its release deletes the row, so the renewal
167
+ // already on the wire answers `false` for a slot nobody lost.
168
+ if (lost || timer.stopped()) return;
169
+ lost = true;
170
+ timer.stop();
171
+ logger.error('jobs.worker.slot-lost', {
172
+ workerId: options.workerId,
173
+ jobId,
174
+ leaseKey: slot.key,
175
+ slot: slot.slot,
176
+ reason,
177
+ ...(error === undefined ? {} : { error: renderThrowable(error) }),
178
+ });
179
+ onLost?.(slot);
180
+ };
181
+
182
+ const renew = async (): Promise<void> => {
183
+ if (lost || timer.stopped() || options.leases === undefined) return;
184
+ // Decided BEFORE the store is asked: a renewal hung on a dead connection never rejects.
185
+ if (lapsed()) return reportLost('expired');
186
+ // One in flight at a time, on the connection that is already the thing failing.
187
+ if (renewing) return;
188
+ renewing = true;
189
+ try {
190
+ const renewed = await options.leases.renew(slot, options.ttlMs);
191
+ if (timer.stopped()) return;
192
+ // `=== false`, never `!renewed`: a store written before this answer existed resolves
193
+ // `undefined`, and reading that as a loss would cancel every job on every renewal.
194
+ if (renewed === false) return reportLost('not-ours');
195
+ // A renewal that lands LATE is still late: the slot may already be another worker's.
196
+ if (lapsed()) return reportLost('expired');
197
+ renewedAt = now();
198
+ } catch (error) {
199
+ // One failed renewal is not a lost slot: the TTL gives the interval several tries.
200
+ logger.warn('jobs.worker.slot-renewal-failed', {
201
+ workerId: options.workerId,
202
+ jobId,
203
+ leaseKey: slot.key,
204
+ error: renderThrowable(error),
205
+ });
206
+ if (lapsed()) reportLost('expired', error);
207
+ } finally {
208
+ renewing = false;
209
+ }
210
+ };
211
+ const timer = startRenewalTimer(options.renewIntervalMs, renew, options.schedule);
181
212
  return () => timer.stop();
182
213
  },
183
214
 
184
215
  async release(jobId) {
185
- const slot = held.get(jobId);
216
+ // The OLDEST: the run that settles first is, bar a race nobody loses by, the superseded one
217
+ // — and if it is not, the superseded run's next renewal answers `false` and cancels it.
218
+ const [slot, ...rest] = held.get(jobId) ?? [];
186
219
  if (slot === undefined) return;
187
- held.delete(jobId);
220
+ if (rest.length === 0) held.delete(jobId);
221
+ else held.set(jobId, rest);
188
222
  // Never lets a settle fail over bookkeeping: an unreleased slot expires on its own TTL.
189
223
  await options.leases?.release(slot).catch((error: unknown) => {
190
224
  logger.warn('jobs.worker.lease-release-failed', {
@@ -4,7 +4,7 @@
4
4
  // would be counted, reported and parked as a fault the author declared was an answer.
5
5
 
6
6
  import type { Clock, Ctx } from '@ultimat3/core';
7
- import { logger } from '@ultimat3/core';
7
+ import { logger, renderThrowable } from '@ultimat3/core';
8
8
  import { nowMs } from './clock';
9
9
  import type { ClaimedJob, JobDriver } from './driver';
10
10
  import { claimOf } from './driver';
@@ -33,7 +33,9 @@ export interface RefuseKeyBusyOptions {
33
33
  * Not `reportError`ed: the error tracker is for failures somebody has to look at, and this one
34
34
  * was asked for by the declaration. The row and this log line carry it.
35
35
  */
36
- export async function refuseKeyBusy(options: RefuseKeyBusyOptions): Promise<JobExecution> {
36
+ export async function refuseKeyBusy(
37
+ options: RefuseKeyBusyOptions,
38
+ ): Promise<JobExecution | undefined> {
37
39
  const { driver, claimed, key, limit } = options;
38
40
  const startedAt = nowMs(options.clock);
39
41
  const refusal = new JobKeyBusyError({ job: claimed.name, key, limit });
@@ -47,6 +49,17 @@ export async function refuseKeyBusy(options: RefuseKeyBusyOptions): Promise<JobE
47
49
  countsAsAttempt: false,
48
50
  fail: true,
49
51
  });
52
+ if (!settled) {
53
+ // The claim lapsed and the row is another worker's now, or it was cancelled: nothing was
54
+ // refused HERE, so there is no execution to count — `executeJob`'s own line for the same miss.
55
+ logger.warn('jobs.settle.unowned', {
56
+ job: claimed.name,
57
+ jobId: claimed.id,
58
+ workerId: options.workerId,
59
+ settling: 'refused',
60
+ });
61
+ return undefined;
62
+ }
50
63
  logger.info('jobs.attempt.refused', {
51
64
  workerId: options.workerId,
52
65
  job: claimed.name,
@@ -55,15 +68,30 @@ export async function refuseKeyBusy(options: RefuseKeyBusyOptions): Promise<JobE
55
68
  limit,
56
69
  });
57
70
  // The one ending no body is there to record: "a second run was refused" exists for the app only
58
- // if the job is told. After the settle and only when it landed, like every other announcement.
71
+ // if the job is told. After the settle, like every other announcement.
59
72
  const handle = getJob(claimed.name);
60
- if (settled && handle !== undefined) {
61
- await announceSettled({
62
- handle,
63
- claimed,
64
- ctx: options.context(),
65
- settlement: { outcome: 'refused', error, code: refusal.code },
66
- });
73
+ if (handle !== undefined) {
74
+ let ctx: Ctx | undefined;
75
+ try {
76
+ ctx = options.context();
77
+ } catch (raised) {
78
+ // `context()` is the app's own function. The row is already settled `failed`: a throw from
79
+ // here failed the whole claim ROUND over a refusal that had landed.
80
+ logger.error('jobs.attempt.refused-unannounced', {
81
+ workerId: options.workerId,
82
+ job: claimed.name,
83
+ jobId: claimed.id,
84
+ error: renderThrowable(raised),
85
+ });
86
+ }
87
+ if (ctx !== undefined) {
88
+ await announceSettled({
89
+ handle,
90
+ claimed,
91
+ ctx,
92
+ settlement: { outcome: 'refused', error, code: refusal.code },
93
+ });
94
+ }
67
95
  }
68
96
  return {
69
97
  outcome: 'refused',
@@ -15,8 +15,28 @@ export interface ClaimLoopOptions {
15
15
  /** One pass. It reports what it found through `passed()`, so a manual `tick()` counts too. */
16
16
  readonly round: () => Promise<unknown>;
17
17
  readonly onError: (error: unknown) => void;
18
+ /** What every wait is armed on. Default: real, unrefed timeouts; a test hands in its own. */
19
+ readonly timers?: LoopTimers;
18
20
  }
19
21
 
22
+ /** The two calls the loop makes on a timer. The handle is whatever `set` answered. */
23
+ export interface LoopTimers {
24
+ set(run: () => void, ms: number): unknown;
25
+ clear(handle: unknown): void;
26
+ }
27
+
28
+ /** Unrefed: never the thing keeping a drained process alive — the hooks stop this loop. */
29
+ const REAL_TIMERS: LoopTimers = {
30
+ set(run, ms) {
31
+ const handle = setTimeout(run, ms);
32
+ handle.unref?.();
33
+ return handle;
34
+ },
35
+ clear(handle) {
36
+ clearTimeout(handle as ReturnType<typeof setTimeout>);
37
+ },
38
+ };
39
+
20
40
  export interface ClaimLoop {
21
41
  start(): void;
22
42
  stop(): void;
@@ -47,23 +67,30 @@ export function createClaimLoop(options: ClaimLoopOptions): ClaimLoop {
47
67
  floorMs: floor,
48
68
  ...(options.ceilingMs === undefined ? {} : { ceilingMs: options.ceilingMs }),
49
69
  });
70
+ const timers = options.timers ?? REAL_TIMERS;
50
71
  let delayMs = floor;
51
72
  let active = false;
52
- let timer: ReturnType<typeof setTimeout> | undefined;
73
+ let timer: unknown;
53
74
  /** The timer's own pass is in flight. A wake landing now asks for ONE more, never a second chain. */
54
75
  let passing = false;
55
76
  let again = false;
56
77
  let unsubscribe: (() => void) | undefined;
57
78
  /** One timer per floor-wide bucket of due times, so a burst of retries holds a bounded few. */
58
- const due = new Map<number, ReturnType<typeof setTimeout>>();
79
+ const due = new Map<number, unknown>();
59
80
 
60
81
  const arm = (): void => {
61
- timer = setTimeout(() => {
82
+ timer = timers.set(() => {
62
83
  timer = undefined;
63
84
  passing = true;
64
85
  void options
65
86
  .round()
66
- .catch(options.onError)
87
+ .catch((error: unknown) => {
88
+ // A failed pass never reached `passed()`, so the wait in hand is whatever cut the last
89
+ // one short — 0 after a wake — and re-arming on it is a loop that spins for as long as
90
+ // the database is down. Not an idle pass either: try again at the floor.
91
+ delayMs = backoff.next(true);
92
+ options.onError(error);
93
+ })
67
94
  .finally(() => {
68
95
  passing = false;
69
96
  if (!active) return;
@@ -72,8 +99,6 @@ export function createClaimLoop(options: ClaimLoopOptions): ClaimLoop {
72
99
  arm();
73
100
  });
74
101
  }, delayMs);
75
- // Never the thing keeping a drained process alive: the hooks stop this loop.
76
- timer.unref?.();
77
102
  };
78
103
 
79
104
  /** The next pass, now — or right behind the one in flight, whose snapshot may predate the news. */
@@ -84,7 +109,7 @@ export function createClaimLoop(options: ClaimLoopOptions): ClaimLoop {
84
109
  return;
85
110
  }
86
111
  if (timer === undefined) return;
87
- clearTimeout(timer);
112
+ timers.clear(timer);
88
113
  delayMs = 0;
89
114
  arm();
90
115
  };
@@ -115,9 +140,9 @@ export function createClaimLoop(options: ClaimLoopOptions): ClaimLoop {
115
140
  stop() {
116
141
  active = false;
117
142
  again = false;
118
- if (timer !== undefined) clearTimeout(timer);
143
+ if (timer !== undefined) timers.clear(timer);
119
144
  timer = undefined;
120
- for (const hint of due.values()) clearTimeout(hint);
145
+ for (const hint of due.values()) timers.clear(hint);
121
146
  due.clear();
122
147
  unsubscribe?.();
123
148
  unsubscribe = undefined;
@@ -134,11 +159,10 @@ export function createClaimLoop(options: ClaimLoopOptions): ClaimLoop {
134
159
  const step = Math.max(floor, DUE_MARGIN_MS);
135
160
  const bucket = Math.ceil((performance.now() + wait) / step);
136
161
  if (due.has(bucket)) return;
137
- const hint = setTimeout(() => {
162
+ const hint = timers.set(() => {
138
163
  due.delete(bucket);
139
164
  kick();
140
165
  }, wait);
141
- hint.unref?.();
142
166
  due.set(bucket, hint);
143
167
  },
144
168
  };
@@ -41,7 +41,13 @@ export function startWorkerRegistry(options: WorkerRegistryOptions): WorkerRegis
41
41
  const startedAt = nowMs(options.clock);
42
42
  const host = options.host ?? Bun.env['HOSTNAME'] ?? 'unknown';
43
43
 
44
- const announce = (): Promise<void> =>
44
+ /**
45
+ * Every announce still on the wire. `stop()` waits them out before it forgets: the row an
46
+ * announce writes after the forget is a stopped worker listed as serving for a whole TTL.
47
+ */
48
+ const announcing = new Set<Promise<void>>();
49
+
50
+ const send = (): Promise<void> =>
45
51
  registry
46
52
  .announceWorker(
47
53
  {
@@ -63,6 +69,13 @@ export function startWorkerRegistry(options: WorkerRegistryOptions): WorkerRegis
63
69
  });
64
70
  });
65
71
 
72
+ const announce = (): Promise<void> => {
73
+ const sent = send().finally(() => announcing.delete(sent));
74
+ announcing.add(sent);
75
+ return sent;
76
+ };
77
+
78
+ // Not awaited — a worker serves whether or not its row has landed — and held in `announcing`.
66
79
  void announce();
67
80
  const timer = startRenewalTimer(
68
81
  options.intervalMs,
@@ -75,6 +88,8 @@ export function startWorkerRegistry(options: WorkerRegistryOptions): WorkerRegis
75
88
  return {
76
89
  async stop() {
77
90
  timer.stop();
91
+ // `send` never rejects (it logs), so neither does this.
92
+ await Promise.all(announcing);
78
93
  await registry.forgetWorker(options.workerId).catch((error: unknown) => {
79
94
  logger.warn('jobs.worker.forget-failed', {
80
95
  workerId: options.workerId,