@ultimat3/jobs 23.0.0 → 25.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 (70) hide show
  1. package/CLAUDE.md +72 -75
  2. package/README.md +83 -38
  3. package/package.json +7 -7
  4. package/src/backfill-errors.ts +3 -2
  5. package/src/backfill-ledger.ts +25 -3
  6. package/src/backfill-pass.ts +22 -5
  7. package/src/backfill-rate.ts +2 -2
  8. package/src/backfill.ts +2 -2
  9. package/src/claim-exhausted.ts +76 -0
  10. package/src/counters.ts +1 -1
  11. package/src/driver-memory-operator.ts +34 -12
  12. package/src/driver-memory.ts +80 -38
  13. package/src/driver-pg-jobs-sql.ts +27 -19
  14. package/src/driver-pg-operator-sql.ts +32 -12
  15. package/src/driver-pg-operator.ts +19 -3
  16. package/src/driver-pg-rows.ts +3 -3
  17. package/src/driver-pg-settle-sql.ts +2 -3
  18. package/src/driver-pg-sql.ts +99 -30
  19. package/src/driver-pg.ts +103 -98
  20. package/src/driver.ts +46 -16
  21. package/src/errors-concurrency.ts +1 -1
  22. package/src/errors-operator.ts +12 -4
  23. package/src/errors-requeue.ts +21 -3
  24. package/src/errors.ts +9 -17
  25. package/src/events-pg.ts +11 -21
  26. package/src/events.ts +26 -15
  27. package/src/execute.ts +47 -5
  28. package/src/export.ts +2 -2
  29. package/src/heartbeat.ts +7 -2
  30. package/src/index.ts +45 -99
  31. package/src/inspect-operator.ts +4 -4
  32. package/src/inspect.ts +5 -4
  33. package/src/introspection.ts +23 -11
  34. package/src/job.ts +6 -7
  35. package/src/leases.ts +1 -1
  36. package/src/limits.ts +46 -15
  37. package/src/outbox-lease.ts +1 -1
  38. package/src/outbox-pg.ts +6 -7
  39. package/src/outbox-relay.ts +6 -6
  40. package/src/outbox.ts +47 -19
  41. package/src/purge.ts +1 -1
  42. package/src/queue-wake.ts +1 -2
  43. package/src/renewal-timer.ts +1 -1
  44. package/src/retry-classification.ts +52 -15
  45. package/src/scheduler-leader.ts +2 -2
  46. package/src/scheduler-pg.ts +19 -16
  47. package/src/scheduler-state.ts +10 -3
  48. package/src/scheduler.ts +14 -15
  49. package/src/steps-memory.ts +2 -2
  50. package/src/steps-suspension.ts +46 -0
  51. package/src/steps-timeout.ts +33 -0
  52. package/src/steps.ts +90 -88
  53. package/src/task.ts +1 -1
  54. package/src/webhook-attempt.ts +206 -0
  55. package/src/webhook-ledger.ts +15 -4
  56. package/src/webhook.ts +64 -118
  57. package/src/worker-admit.ts +14 -6
  58. package/src/worker-drain-cutoff.ts +110 -0
  59. package/src/worker-fleet-slots.ts +82 -45
  60. package/src/worker-held.ts +100 -0
  61. package/src/worker-key-busy.ts +38 -10
  62. package/src/worker-loop.ts +35 -11
  63. package/src/worker-options.ts +19 -15
  64. package/src/worker-registry.ts +16 -1
  65. package/src/worker-run.ts +38 -8
  66. package/src/worker-tally.ts +53 -0
  67. package/src/worker-types.ts +7 -4
  68. package/src/worker.ts +124 -73
  69. package/src/driver-nats.ts +0 -86
  70. package/src/driver-redis.ts +0 -89
@@ -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
  /**
@@ -75,7 +73,7 @@ export interface FleetSlots {
75
73
  * to do without one. `granted` is also the answer for "no cap declared": a job with no
76
74
  * `concurrency` never touches the lease table.
77
75
  *
78
- * A driver with no lease store cannot reach here: `createWorker().start()` refuses to boot when
76
+ * A driver with no lease store cannot reach here: `jobWorker().start()` refuses to boot when
79
77
  * a registered job declares `concurrency` and the driver has none, because a cap that silently
80
78
  * holds per process is the documented-guarantee-that-does-nothing axiom 3 exists to make
81
79
  * impossible.
@@ -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,80 @@ 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
+ // Stamped BEFORE the call, as `heartbeat.ts` does: the store dates the new expiry at the
190
+ // statement, so a slow reply must not stretch this worker's view past it.
191
+ const sentAt = now();
192
+ try {
193
+ const renewed = await options.leases.renew(slot, options.ttlMs);
194
+ if (timer.stopped()) return;
195
+ // `=== false`, never `!renewed`: a store written before this answer existed resolves
196
+ // `undefined`, and reading that as a loss would cancel every job on every renewal.
197
+ if (renewed === false) return reportLost('not-ours');
198
+ // A renewal that lands LATE is still late: the slot may already be another worker's.
199
+ if (lapsed()) return reportLost('expired');
200
+ renewedAt = sentAt;
201
+ } catch (error) {
202
+ // One failed renewal is not a lost slot: the TTL gives the interval several tries.
203
+ logger.warn('jobs.worker.slot-renewal-failed', {
204
+ workerId: options.workerId,
205
+ jobId,
206
+ leaseKey: slot.key,
207
+ error: renderThrowable(error),
208
+ });
209
+ if (lapsed()) reportLost('expired', error);
210
+ } finally {
211
+ renewing = false;
212
+ }
213
+ };
214
+ const timer = startRenewalTimer(options.renewIntervalMs, renew, options.schedule);
181
215
  return () => timer.stop();
182
216
  },
183
217
 
184
218
  async release(jobId) {
185
- const slot = held.get(jobId);
219
+ // The OLDEST: the run that settles first is, bar a race nobody loses by, the superseded one
220
+ // — and if it is not, the superseded run's next renewal answers `false` and cancels it.
221
+ const [slot, ...rest] = held.get(jobId) ?? [];
186
222
  if (slot === undefined) return;
187
- held.delete(jobId);
223
+ if (rest.length === 0) held.delete(jobId);
224
+ else held.set(jobId, rest);
188
225
  // Never lets a settle fail over bookkeeping: an unreleased slot expires on its own TTL.
189
226
  await options.leases?.release(slot).catch((error: unknown) => {
190
227
  logger.warn('jobs.worker.lease-release-failed', {
@@ -0,0 +1,100 @@
1
+ // The runs one worker holds right now, each with the claim it runs under — so a drain that reaches
2
+ // its cut-off can hand back every row still held instead of leaving it under a lease another
3
+ // worker waits a whole visibility timeout to see lapse. Split from `worker.ts`, whose job is the
4
+ // claim loop; this one is "what am I holding, and give it back".
5
+
6
+ import type { ClaimedJob, JobDriver } from './driver';
7
+ import { handBack } from './worker-hand-back';
8
+
9
+ export interface HeldRuns {
10
+ /** How many runs are held — what `stats().inFlight` reports. */
11
+ readonly size: number;
12
+ /**
13
+ * The job ids held, each once — what the registry row reports. Per RUN underneath, because one
14
+ * worker can hold a job twice (a claim that lapsed under a body still running, then its own
15
+ * re-claim): keyed by id, the first run to end dropped an id the second still held.
16
+ */
17
+ ids(): string[];
18
+ /**
19
+ * A run started. `release` is what the run's own `finally` would do for the process — the
20
+ * in-flight count core's drain waits on, the fleet slot — run early when the run is handed back.
21
+ */
22
+ add(run: Promise<unknown>, claimed: ClaimedJob, release: () => Promise<void>): void;
23
+ /** The run settled on its own: no longer held. */
24
+ settle(run: Promise<unknown>): void;
25
+ /** Resolves the moment nothing is held — settled or handed back. */
26
+ empty(): Promise<void>;
27
+ /**
28
+ * Every run still held goes back to the queue — `countsAsAttempt: false`, no delay — and stops
29
+ * being this worker's. Answers how many. The bodies themselves cannot be stopped; what they
30
+ * write after this is fenced out by the claim that just moved (`StepFence`, `SQL_ACK`).
31
+ */
32
+ handBackAll(): Promise<number>;
33
+ }
34
+
35
+ interface Holding {
36
+ readonly claimed: ClaimedJob;
37
+ readonly release: () => Promise<void>;
38
+ }
39
+
40
+ export function createHeldRuns(options: {
41
+ readonly driver: JobDriver;
42
+ readonly workerId: string;
43
+ }): HeldRuns {
44
+ const held = new Map<Promise<unknown>, Holding>();
45
+ let waiters: (() => void)[] = [];
46
+
47
+ /** Pending hand-backs: `empty()` answers only once their nacks have LANDED, never mid-wire. */
48
+ let handingBack = 0;
49
+ const wakeIfEmpty = (): void => {
50
+ if (held.size > 0 || handingBack > 0 || waiters.length === 0) return;
51
+ const woken = waiters;
52
+ waiters = [];
53
+ for (const wake of woken) wake();
54
+ };
55
+
56
+ return {
57
+ get size() {
58
+ return held.size;
59
+ },
60
+ ids() {
61
+ return [...new Set([...held.values()].map((holding) => holding.claimed.id))];
62
+ },
63
+ add(run, claimed, release) {
64
+ held.set(run, { claimed, release });
65
+ },
66
+ settle(run) {
67
+ held.delete(run);
68
+ wakeIfEmpty();
69
+ },
70
+ empty() {
71
+ if (held.size === 0 && handingBack === 0) return Promise.resolve();
72
+ return new Promise<void>((resolve) => {
73
+ waiters.push(resolve);
74
+ });
75
+ },
76
+ async handBackAll() {
77
+ // Taken out of the set BEFORE the first await: a run settling while the nacks are on the
78
+ // wire is a run the hand-back already owns, and must not be counted twice. And `empty()`
79
+ // stays unanswered until the nacks land — the teardown waiting on it closes the driver next.
80
+ const stranded = [...held.values()];
81
+ held.clear();
82
+ if (stranded.length === 0) return 0;
83
+ handingBack += 1;
84
+ try {
85
+ await handBack(
86
+ options.driver,
87
+ stranded.map((holding) => holding.claimed),
88
+ { delayMs: 0, workerId: options.workerId },
89
+ );
90
+ // After the nacks, never before: the slot is what lets the replacement run a
91
+ // `concurrency: 1` job, and freed first it could be taken while the row was still ours.
92
+ for (const holding of stranded) await holding.release();
93
+ } finally {
94
+ handingBack -= 1;
95
+ wakeIfEmpty();
96
+ }
97
+ return stranded.length;
98
+ },
99
+ };
100
+ }
@@ -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
  };
@@ -1,22 +1,19 @@
1
- // Every numeric knob `createWorker` accepts, read and REFUSED in one place — the slot table
1
+ // Every numeric knob `jobWorker` accepts, read and REFUSED in one place — the slot table
2
2
  // included, because a queue name is data and a slot count is a bound, and both arrive from the
3
3
  // same deployment config.
4
4
  //
5
5
  // WHY A REFUSAL AND NOT A CLAMP. `Number(process.env.JOB_VISIBILITY_MS)` on an unset variable is
6
6
  // `NaN`; `??` guards only nullish, and `Math.max`/`Math.min`/`Math.floor` PROPAGATE it. So the
7
7
  // value arrives at a lease deadline, a claim limit and a timer interval intact, and every
8
- // comparison against it reads FALSE — measured on `createMemoryDriver`: `visibleAt = at + NaN`,
8
+ // comparison against it reads FALSE — measured on `memoryJobDriver`: `visibleAt = at + NaN`,
9
9
  // the reclaim scan asks `visibleAt <= at`, and a job whose worker died is never claimable again.
10
10
  // At-least-once becomes never, with no error and a row `x jobs ls` still prints as `running`.
11
11
  // `slice(0, NaN)` is `[]`, so a `concurrency: NaN` worker claims nothing and reports healthy.
12
- // Same shape as `createLimiter`'s `maxTenants` and `backfill()`'s `batch`, refused the same way.
12
+ // Same shape as `concurrencyLimiter`'s `maxTenants` and `backfill()`'s `batch`, refused the same way.
13
13
 
14
- import { finiteOption } from '@ultimat3/core';
14
+ import { finiteOption, JOBS_CONCURRENCY_DEFAULT } from '@ultimat3/core';
15
15
  import { DEFAULT_VISIBILITY_TIMEOUT_MS } from './driver';
16
16
 
17
- /** Slots a queue gets when `concurrency` names no number for it. */
18
- const DEFAULT_SLOTS = 5;
19
-
20
17
  /** The subset of `WorkerOptions` this module reads. Structural, so `WorkerOptions` satisfies it. */
21
18
  export interface WorkerNumericOptions {
22
19
  readonly concurrency?: number | Readonly<Record<string, number>> | undefined;
@@ -34,7 +31,10 @@ export interface WorkerTimings {
34
31
  }
35
32
 
36
33
  /**
37
- * Slots for one queue. A queue NAME is deployment data, so the table is read by OWN keys:
34
+ * Slots for one queue — `JOBS_CONCURRENCY_DEFAULT` (core's, the same number `app.config.ts`'s
35
+ * `jobs.concurrency` defaults to) for a queue `concurrency` names no number for. This package kept
36
+ * its own 5 until 25.0.0, so a table `{ banks: 4 }` left `default` at 5 beside an unset config's 8.
37
+ * A queue NAME is deployment data, so the table is read by OWN keys:
38
38
  * `concurrency['constructor']` answers `Object.prototype.constructor`, and
39
39
  * `Math.max(0, <function> - inFlight)` is `NaN`, which the `free === 0` guard does not catch.
40
40
  * `bun run proto-index` cannot see this one — the table is a parameter, not a literal in a file.
@@ -43,25 +43,29 @@ const slotTable =
43
43
  (declared: number | Readonly<Record<string, number>> | undefined) =>
44
44
  (queue: string): number => {
45
45
  if (typeof declared === 'number') return declared;
46
- if (declared === undefined || !Object.hasOwn(declared, queue)) return DEFAULT_SLOTS;
47
- return declared[queue] ?? DEFAULT_SLOTS;
46
+ if (declared === undefined || !Object.hasOwn(declared, queue)) return JOBS_CONCURRENCY_DEFAULT;
47
+ return declared[queue] ?? JOBS_CONCURRENCY_DEFAULT;
48
48
  };
49
49
 
50
50
  export function resolveWorkerTimings(options: WorkerNumericOptions): WorkerTimings {
51
51
  const visibilityTimeoutMs = options.visibilityTimeoutMs ?? DEFAULT_VISIBILITY_TIMEOUT_MS;
52
- finiteOption('createWorker', 'visibilityTimeoutMs', visibilityTimeoutMs);
52
+ finiteOption('jobWorker', 'visibilityTimeoutMs', visibilityTimeoutMs);
53
53
  const pollIntervalMs = options.pollIntervalMs ?? 250;
54
54
  // `setTimeout(fn, NaN)` coerces the delay to 0, so the claim loop stops being a poll and becomes
55
55
  // a spin: one round trip to Postgres per event-loop turn, from every worker replica.
56
- finiteOption('createWorker', 'pollIntervalMs', pollIntervalMs);
56
+ finiteOption('jobWorker', 'pollIntervalMs', pollIntervalMs);
57
57
  const heartbeatIntervalMs = options.heartbeatIntervalMs ?? Math.floor(visibilityTimeoutMs / 3);
58
- finiteOption('createWorker', 'heartbeatIntervalMs', heartbeatIntervalMs);
58
+ finiteOption('jobWorker', 'heartbeatIntervalMs', heartbeatIntervalMs);
59
59
  const declared = options.concurrency;
60
- if (typeof declared === 'number') finiteOption('createWorker', 'concurrency', declared);
60
+ if (typeof declared === 'number') finiteOption('jobWorker', 'concurrency', declared);
61
61
  else if (declared !== undefined) {
62
62
  // Per queue, by own key: an inherited member is not this table's to answer with either.
63
63
  for (const queue of Object.keys(declared)) {
64
- finiteOption('createWorker', `concurrency.${queue}`, declared[queue] ?? DEFAULT_SLOTS);
64
+ finiteOption(
65
+ 'jobWorker',
66
+ `concurrency.${queue}`,
67
+ declared[queue] ?? JOBS_CONCURRENCY_DEFAULT,
68
+ );
65
69
  }
66
70
  }
67
71
  return {
@@ -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,