@ultimat3/jobs 16.0.0 → 18.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
@@ -107,6 +107,48 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
107
107
  `createWorker().start()` THROW `X_JOB_CONCURRENCY_UNENFORCEABLE` when a registered job declares
108
108
  `concurrency`: a documented guarantee that silently does nothing is the worst of the three
109
109
  options, and refusing is what axiom 3 asks for.
110
+ - **`WorkerOptions.concurrency` is read by OWN key, because a queue NAME is deployment data**
111
+ (`worker.ts`, `As of 2026-08-26`). `options.concurrency?.[queue]` answered
112
+ `Object.prototype.constructor` for a queue called `constructor`, so
113
+ `Math.max(0, <function> - inFlight)` was `NaN`, the `free === 0` guard did not catch it, and the
114
+ pass issued `driver.claim({ limit: NaN })`. `bun run proto-index` cannot reach this one — the
115
+ table is a **parameter**, not an object literal in the file — so `worker-slots.test.ts` is the
116
+ enforcement, over `constructor`, `__proto__` and `toString` at once.
117
+ - **Every numeric knob is refused when it is not a FINITE number** — `@ultimat3/core`'s
118
+ `finiteOption()` (a bound) and `finiteCount()` (a whole number of things, with the caller's
119
+ minimum) are the two refusals, and this package declares none of its own. `worker-options.ts` is
120
+ the one place `createWorker` reads them (`As of 2026-08-26`), and `createOutboxRelay` refuses its
121
+ own two.
122
+ Measured: `visibilityTimeoutMs: NaN` makes `visibleAt` `NaN`, the reclaim scan asks
123
+ `visibleAt <= now`, and a job whose worker DIED is never claimable again — at-least-once becomes
124
+ never, on a row `x jobs ls` still prints as `running`. `concurrency: NaN` slices `(0, NaN)`, so
125
+ the worker claims nothing and reports healthy; `pollIntervalMs: NaN` is `setTimeout(fn, 0)`, so
126
+ the claim loop spins on the database. `??` guards only nullish and `Math.max`/`Math.floor`
127
+ propagate `NaN`: `Number(process.env.X)` on an unset variable arrives intact. Same refusal
128
+ `createLimiter`'s `maxTenants` and `backfill()`'s `batch` already made.
129
+
130
+ **`bun run finite-bounds` is a floor, never the answer, and a pin of zero is not proof**
131
+ (`As of 2026-08-26`). It matches `x.y ?? CONST`, so it never saw `createLimiter`'s four
132
+ ceilings — read as `config.global !== undefined && global >= config.global`, a shape with no
133
+ `??` in it — and this package read as clean at **zero** while every one of them was off.
134
+ Measured: `createLimiter({ global: Number(process.env.WORKER_GLOBAL_CONCURRENCY) })` with the
135
+ variable unset granted **1000 of 1000** acquires where `global: 2` grants 2, and
136
+ `snapshot().config` still reported the ceiling to `/_x`. All five numbers (`perTenant`,
137
+ `perQueue`, `global`, `ratePerTenant.limit`, `ratePerTenant.windowMs` — the window is half the
138
+ same ceiling, since `stamp > at - NaN` empties it on every call) are screened at construction
139
+ beside `maxTenants`, `finiteCount` with **min 0**: zero is a HARD STOP here and one this repo's
140
+ own suite configures, never "unlimited", which is what omitting the option means.
141
+
142
+ **A row count is `finiteCount`, and the reason is driver parity** (`As of 2026-08-26`).
143
+ `finiteOption` accepts `-1` and `2.5`, and both diverge: `introspect.list({ limit: -1 })` sliced
144
+ every row BUT the newest on `driver-memory.ts` and Postgres answers `ERROR: LIMIT must not be
145
+ negative` (probed on pg18), while `2.5` keeps 2 rows here and **3** there with no error on either
146
+ side. `list`, `deadLetters`, the backfill ledger's `list` and `assertClaimBounds` (both drivers'
147
+ `claim`) take the count screen with **min 0** — `limit: 0` is zero rows on both — and
148
+ `claim`'s `visibilityTimeoutMs` takes `finiteOption`, because a lease window is a duration.
149
+ The memory driver's `list`/`deadLetters` are `async` for the reason `claim` is: a refusal must
150
+ REJECT on both, and a synchronous throw out of a method typed `Promise<…>` is itself the
151
+ divergence.
110
152
  - **`enqueuedBy` is ATTRIBUTION, never authority — decided 2026-08, do not re-litigate.** Both
111
153
  answers were defensible. Impersonating the enqueuer at claim time gives correct authz and is
112
154
  rejected because a job that sleeps three days, or dead-letters and is retried next quarter, then
@@ -328,7 +370,7 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
328
370
  renders a dead letter at attempt 1 of 5 as a silent early stop.
329
371
  - **The backoff arithmetic is `@ultimat3/core`'s, and `retry.ts` is the option names over it**
330
372
  (`As of 2026-08-23`). `backoffDelayMs` is `backoffDelay({ curve, jitter, base, max, attempt })`
331
- with this package's spellings applied on the way in — `DurationInput` through `toMs`, the
373
+ with this package's spellings applied on the way in — `DurationInput` through `finiteDurationMs`, the
332
374
  `DEFAULT_RETRY` fallbacks, and `jitter: boolean` mapped to `'equal' | 'none'`. **EQUAL, never
333
375
  `full`**: `jitter: true` here has meant half-fixed-half-random since it shipped, and `full`
334
376
  would hand a job that already failed twice a near-zero wait. The public `RetryPolicy`,
@@ -800,8 +842,30 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
800
842
 
801
843
  `clock.ts` calls `parseDuration(str)` and `scheduler.ts` calls
802
844
  `nextCronOccurrence(cron, { tz, from })`. Both are normalised in one place each
803
- (`toMs`, `defaultCronResolver`) and the scheduler's resolver is injectable, so a signature
804
- change in `@ultimat3/time` is a one-line fix, not a sweep.
845
+ (`finiteDurationMs`, `defaultCronResolver`) and the scheduler's resolver is injectable, so a
846
+ signature change in `@ultimat3/time` is a one-line fix, not a sweep.
847
+
848
+ **`clock.ts`'s conversion is `finiteDurationMs(duration, subject, option)`, never a bare `toMs`**
849
+ (`As of 2026-08-26`). It was `toMs` — a THIRD implementation of duration→ms with no finiteness
850
+ screen at all, and the one every scheduling decision in this package goes through:
851
+ `step.sleep(Number(process.env.DELAY))` on an unset variable made `wakeAt = at + NaN`, and every
852
+ `wakeAt <= now` against a `NaN` is false forever — a sleep that never ends, on a row `x jobs show`
853
+ prints as `sleeping`. Partial screening is what hid it: `events.ts`, `events-pg.ts` and `steps.ts`
854
+ wrapped the RESULT in `finiteOption` while `retry.ts` and `step.sleep` did not, so a reader saw
855
+ `finiteOption` in the file and concluded the package was screened.
856
+
857
+ Three things about the shape are load-bearing. The floor is `finiteOption` and NOT `finiteCount`,
858
+ measured rather than assumed: `retry-core-parity.test.ts` pins `maxDelay: -5` at `0` and four
859
+ `.job.test.ts` suites configure `retry: { delay: 0 }`, so a negative and a zero duration are
860
+ shipped behaviour here — only a non-finite one is refused, and a caller wanting a positive whole
861
+ number narrows on top, which is what `job()` does for `stepTimeout` and `eventPoll`. `subject` and
862
+ `option` are REQUIRED, so a call site that does not name the app author's own key
863
+ (`retry.delay`, `job("x") timeout`, `step.sleep`'s argument) is `TS2554` at the call rather than a
864
+ review note — `@ultimat3/time`'s `toMs` screens under the subject `toMs`, which names a framework
865
+ internal. And the callee CARRIES `Finite`, because `bun run finite-bounds` reads a repair off the
866
+ callee's name: that is what lets `finiteDurationMs(options.defaultTtl ?? 604_800_000, …)` be
867
+ recognised as screened with no second wrapper around it. `duration-bounds.test.ts` is the
868
+ enforcement `finite-bounds` cannot be — a `typeof duration === 'number'` arm has no `??` in it.
805
869
 
806
870
  `driver-pg.ts`'s `PgExecutor` (`:62-64`) is a one-method duck-typed interface — this package still
807
871
  has no `@ultimat3/db` dependency, and nothing here knows what an observer, a span or
@@ -850,7 +914,7 @@ picture from the other side.
850
914
  | `events-pg.ts` | `createPgEventBus` — `step.waitForEvent` across processes |
851
915
  | `driver.ts` | `JobDriver` contract + wire records |
852
916
  | `driver-pg.ts` | default driver, real SQL constants, and `createPgLeader` — the advisory-lock election that is **not** what a scheduler uses; `scheduler-pg.ts` above owns the lease-row one boot wires |
853
- | `driver-pg-ddl.ts` | `SQL_JOBS_TABLE` + `SQL_OUTBOX_TABLE` — the schema the driver installs. Whichever file holds the DDL is the one whose comments may carry no `;` and no `'` |
917
+ | `driver-pg-ddl.ts` | `SQL_JOBS_TABLE` — the schema the driver installs, and the ONE install point: every durable table this package owns, `x_outbox` included, is declared in it. Whichever file holds the DDL is the one whose comments may carry no `;` and no `'` |
854
918
  | `driver-pg-jobs-sql.ts` | every statement returning a whole `x_jobs` row, and the `JOB_ROW_COLUMNS` projection they share. Split off at `driver-pg-sql.ts`'s size ceiling and re-exported from it |
855
919
  | `driver-pg-rows.ts` | a Postgres row → a wire record: `JobRow`/`StepRow`/`BackfillRow` and their mappings |
856
920
  | `driver-memory.ts` | `x dev` / tests |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/jobs",
3
- "version": "16.0.0",
3
+ "version": "18.0.0",
4
4
  "description": "Durable background work: steps, transactional outbox, cron tasks, one driver interface",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,16 +25,16 @@
25
25
  "LICENSE"
26
26
  ],
27
27
  "engines": {
28
- "bun": ">=1.3.0"
28
+ "bun": ">=1.4.0"
29
29
  },
30
30
  "scripts": {
31
31
  "typecheck": "tsc --noEmit -p tsconfig.json",
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/core": "16.0.0",
36
- "@ultimat3/entity": "16.0.0",
37
- "@ultimat3/schema": "16.0.0",
38
- "@ultimat3/time": "16.0.0"
35
+ "@ultimat3/core": "18.0.0",
36
+ "@ultimat3/entity": "18.0.0",
37
+ "@ultimat3/schema": "18.0.0",
38
+ "@ultimat3/time": "18.0.0"
39
39
  }
40
40
  }
@@ -10,7 +10,7 @@
10
10
  // become — the checkpoints are transactional with the work, and this row is not.
11
11
 
12
12
  import type { Clock } from '@ultimat3/core';
13
- import { systemClock } from '@ultimat3/core';
13
+ import { finiteCount, systemClock } from '@ultimat3/core';
14
14
  import { nowMs } from './clock';
15
15
 
16
16
  /**
@@ -167,7 +167,8 @@ export function createMemoryBackfillLedger(clock: Clock = systemClock): Backfill
167
167
  });
168
168
  return Promise.resolve();
169
169
  },
170
- list(filter = {}) {
170
+ // `async`, so a refused limit REJECTS here as it does on the pg ledger — see `driver-memory.ts`.
171
+ async list(filter = {}) {
171
172
  // Reversed BEFORE the sort: the test clock is frozen, so two rows share a `startedAt` and a
172
173
  // stable sort would hand back the oldest of them first under a "newest first" contract.
173
174
  const rows = [...runs.values()]
@@ -176,8 +177,8 @@ export function createMemoryBackfillLedger(clock: Clock = systemClock): Backfill
176
177
  .filter((run) => filter.name === undefined || run.name === filter.name)
177
178
  .filter((run) => filter.status === undefined || run.status === filter.status)
178
179
  .filter((run) => filter.runId === undefined || run.runId === filter.runId)
179
- .slice(0, filter.limit ?? 100);
180
- return Promise.resolve(rows);
180
+ .slice(0, finiteCount('the backfill ledger list', 'limit', filter.limit ?? 100));
181
+ return rows;
181
182
  },
182
183
  };
183
184
  }
package/src/clock.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // injected Clock, so tests never sleep and a frozen clock cannot be bypassed.
3
3
 
4
4
  import type { Clock } from '@ultimat3/core';
5
- import { systemClock } from '@ultimat3/core';
5
+ import { finiteOption, systemClock } from '@ultimat3/core';
6
6
  import { parseDuration } from '@ultimat3/time';
7
7
 
8
8
  export type DurationInput = string | number;
@@ -14,10 +14,38 @@ export function nowMs(clock: Clock = systemClock): number {
14
14
  return Number(reading);
15
15
  }
16
16
 
17
- /** `'3d'` | `'30s'` | `1500` -> ms. Numbers pass through so callers may stay explicit. */
18
- export function toMs(duration: DurationInput): number {
19
- if (typeof duration === 'number') return duration;
20
- const parsed: unknown = parseDuration(duration);
21
- if (parsed instanceof Date) return parsed.getTime();
22
- return Number(parsed);
17
+ /**
18
+ * `'3d'` | `'30s'` | `1500` -> ms, refused under the name the CALLER wrote.
19
+ *
20
+ * NOT a copy of `@ultimat3/time`'s `toMs`, and the name says which one this is — three functions
21
+ * spelled `toMs`, `toMs` and `toDurationMs` are exactly the trap a rule spelled by NAME falls into.
22
+ * The STRING arm delegates to `parseDuration`, the one duration vocabulary, which refuses
23
+ * everything it cannot read (an overflowing amount included). What this adds is the NUMBER arm and
24
+ * the two names on it.
25
+ *
26
+ * The number arm passed straight through, and this is the conversion every scheduling decision in
27
+ * this package goes through: `step.sleep(Number(process.env.DELAY))` on an unset variable made
28
+ * `wakeAt = at + NaN`, and every `wakeAt <= now` against a `NaN` is false forever — a sleep that
29
+ * never ends, a retry ceiling that is not one, an event that never expires, with no error
30
+ * anywhere. `??` does not guard it, because `NaN` is not nullish.
31
+ *
32
+ * `finiteOption`, not `finiteCount`, and the floor is measured rather than assumed:
33
+ * `backoffDelayMs({ ...policy, maxDelay: -5 }, 1) === 0` is pinned by `retry-core-parity.test.ts`
34
+ * and `retry: { delay: 0 }` is what four of this package's own `.job.test.ts` suites configure, so
35
+ * a negative and a zero duration are shipped behaviour here. Only a non-finite one is refused. A
36
+ * caller needing a positive whole number narrows ON TOP — `job()` does exactly that for
37
+ * `stepTimeout` and `eventPoll`.
38
+ *
39
+ * `subject` and `option` are REQUIRED, and that is the enforcement rather than a convention: a new
40
+ * call site that does not name the key the app author actually wrote is `TS2554: Expected 3
41
+ * arguments` at the call. `@ultimat3/time`'s screen names the subject `toMs`, a framework internal
42
+ * that tells an app author nothing about which knob of theirs is wrong — the shape
43
+ * `@ultimat3/notify`'s `toDurationMs` already has. The name no longer has to carry `Finite`:
44
+ * `bun run finite-bounds` read a repair off the callee's NAME until 2026-08-26 — which is what
45
+ * forced this rename — and now reads `SCREENING_CALLEES` in `scripts/lib/finite-screens.ts`.
46
+ * Rename it freely; move the row with it.
47
+ */
48
+ export function finiteDurationMs(duration: DurationInput, subject: string, option: string): number {
49
+ if (typeof duration === 'number') return finiteOption(subject, option, duration);
50
+ return parseDuration(duration);
23
51
  }
@@ -3,7 +3,7 @@
3
3
  // real claim/ack/nack paths rather than a mock that always succeeds.
4
4
 
5
5
  import type { Clock } from '@ultimat3/core';
6
- import { assert, systemClock, uuid } from '@ultimat3/core';
6
+ import { assert, finiteCount, systemClock, uuid } from '@ultimat3/core';
7
7
  import type { BackfillLedger } from './backfill-ledger';
8
8
  import { createMemoryBackfillLedger } from './backfill-ledger';
9
9
  import { nowMs } from './clock';
@@ -20,7 +20,7 @@ import type {
20
20
  NackOptions,
21
21
  QueueStats,
22
22
  } from './driver';
23
- import { assertClaimQueues, DEFAULT_QUEUE } from './driver';
23
+ import { assertClaimBounds, assertClaimQueues, DEFAULT_QUEUE } from './driver';
24
24
  import { JobDuplicateError } from './errors';
25
25
  import type { LeaseStore } from './leases';
26
26
  import { createMemoryLeaseStore } from './leases';
@@ -103,7 +103,10 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
103
103
  job(jobId) {
104
104
  return Promise.resolve(jobs.get(jobId));
105
105
  },
106
- list(filter: JobFilter = {}) {
106
+ // `async` for the reason `claim` is: a refused bound must REJECT here exactly as it does on the
107
+ // pg driver, and a synchronous throw out of a method typed `Promise<…>` is a second answer to
108
+ // one question.
109
+ async list(filter: JobFilter = {}) {
107
110
  const rows = [...jobs.values()]
108
111
  .filter((record) => filter.queue === undefined || record.queue === filter.queue)
109
112
  .filter((record) => filter.name === undefined || record.name === filter.name)
@@ -112,15 +115,15 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
112
115
  // `x jobs ls` answered one thing against `x dev` and the opposite in production — and,
113
116
  // because the limit is applied after the sort, a default page of the hundred OLDEST rows.
114
117
  .sort((a, b) => b.createdAt - a.createdAt)
115
- .slice(0, filter.limit ?? 100);
116
- return Promise.resolve(rows);
118
+ .slice(0, finiteCount('the memory driver list', 'limit', filter.limit ?? 100));
119
+ return rows;
117
120
  },
118
- deadLetters(limit = 100) {
121
+ async deadLetters(limit = 100) {
119
122
  const rows = [...jobs.values()]
120
123
  .filter((record) => record.state === 'dead')
121
124
  .sort((a, b) => b.updatedAt - a.updatedAt)
122
- .slice(0, limit);
123
- return Promise.resolve(rows);
125
+ .slice(0, finiteCount('the memory driver dead letters', 'limit', limit));
126
+ return rows;
124
127
  },
125
128
  async requeue(jobId, requeueOptions) {
126
129
  const existing = jobs.get(jobId);
@@ -199,6 +202,7 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
199
202
  // question, which is the class of divergence this pair is checked for.
200
203
  async claim(claimOptions: ClaimOptions): Promise<readonly ClaimedJob[]> {
201
204
  assertClaimQueues('memory', claimOptions);
205
+ assertClaimBounds('memory', claimOptions);
202
206
  const at = nowMs(clock);
203
207
  const wanted = new Set(claimOptions.queues);
204
208
  const claimable = [...jobs.values()]
@@ -177,29 +177,3 @@ create table if not exists x_job_events (
177
177
  create index if not exists x_job_events_lookup_idx
178
178
  on x_job_events (name, published_at);
179
179
  `.trim();
180
-
181
- /**
182
- * Kept as its own constant because it is a public export and `x_outbox` is a table an operator
183
- * may need to create alone. It is ALSO inside `SQL_JOBS_TABLE`, which is the one boot applies —
184
- * two install points for one table is how the outbox came to be documented and never created.
185
- */
186
- export const SQL_OUTBOX_TABLE = `
187
- create table if not exists x_outbox (
188
- id uuid primary key,
189
- job text not null,
190
- queue text not null default 'default',
191
- input jsonb not null,
192
- idempotency_key text not null,
193
- max_attempts int not null default 3,
194
- run_at timestamptz not null default now(),
195
- staged_at timestamptz not null default now(),
196
- tenant_id text,
197
- traceparent text,
198
- enqueued_by text,
199
- published_at timestamptz
200
- );
201
- create index if not exists x_outbox_unpublished_idx
202
- on x_outbox (staged_at) where published_at is null;
203
- alter table x_outbox add column if not exists claimed_at timestamptz;
204
- alter table x_outbox add column if not exists claimed_by text;
205
- `.trim();
@@ -7,7 +7,7 @@
7
7
  // it is applied once at boot and never by a driver method. Re-exported from here so the install
8
8
  // point keeps the ONE import path every caller already uses.
9
9
 
10
- export { SQL_JOBS_TABLE, SQL_OUTBOX_TABLE } from './driver-pg-ddl';
10
+ export { SQL_JOBS_TABLE } from './driver-pg-ddl';
11
11
  // The whole-`x_jobs`-row reads, split off at this file's size ceiling and re-exported for the
12
12
  // same reason the DDL is. `JOB_ROW_COLUMNS` is imported as a value because `SQL_CANCEL` also
13
13
  // returns a whole row and must project it identically — two spellings of one row shape is how
package/src/driver-pg.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // they run against in `driver-pg-ddl.ts`, and the row-to-record decoding in `driver-pg-rows.ts`.
6
6
 
7
7
  import type { Clock } from '@ultimat3/core';
8
- import { systemClock, uuid } from '@ultimat3/core';
8
+ import { finiteCount, systemClock, uuid } from '@ultimat3/core';
9
9
  import type { BackfillLedger } from './backfill-ledger';
10
10
  import { nowMs } from './clock';
11
11
  import type {
@@ -20,7 +20,7 @@ import type {
20
20
  NackOptions,
21
21
  QueueStats,
22
22
  } from './driver';
23
- import { assertClaimQueues, DEFAULT_QUEUE } from './driver';
23
+ import { assertClaimBounds, assertClaimQueues, DEFAULT_QUEUE } from './driver';
24
24
  import type { BackfillRow, JobRow, StepRow } from './driver-pg-rows';
25
25
  import { num, toBackfillRun, toJobRecord, toStepRecord } from './driver-pg-rows';
26
26
  import {
@@ -135,7 +135,7 @@ function pgBackfillLedger(exec: () => PgExecutor): BackfillLedger {
135
135
  filter.name ?? null,
136
136
  filter.status ?? null,
137
137
  filter.runId ?? null,
138
- filter.limit ?? 100,
138
+ finiteCount('the pg driver list', 'limit', filter.limit ?? 100),
139
139
  ]);
140
140
  return rows.map(toBackfillRun);
141
141
  },
@@ -200,12 +200,14 @@ export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
200
200
  filter.queue ?? null,
201
201
  filter.name ?? null,
202
202
  filter.state ?? null,
203
- filter.limit ?? 100,
203
+ finiteCount('the pg driver list', 'limit', filter.limit ?? 100),
204
204
  ]);
205
205
  return rows.map(toJobRecord);
206
206
  },
207
207
  async deadLetters(limit = 100) {
208
- const rows = await exec().query<JobRow>(SQL_JOB_DEAD_LETTERS, [limit]);
208
+ const rows = await exec().query<JobRow>(SQL_JOB_DEAD_LETTERS, [
209
+ finiteCount('the pg driver dead letters', 'limit', limit),
210
+ ]);
209
211
  return rows.map(toJobRecord);
210
212
  },
211
213
  async requeue(jobId, requeueOptions) {
@@ -293,6 +295,7 @@ export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
293
295
 
294
296
  async claim(claimOptions: ClaimOptions): Promise<readonly ClaimedJob[]> {
295
297
  assertClaimQueues('pg', claimOptions);
298
+ assertClaimBounds('pg', claimOptions);
296
299
  const rows = await exec().query<JobRow>(SQL_CLAIM, [
297
300
  claimOptions.queues,
298
301
  claimOptions.limit,
package/src/driver.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  // `X_NOT_IMPLEMENTED` stubs. What IS true is the second half — swapping the driver is
9
9
  // `setJobDriver(other)` and ZERO job-code change — and that is what the interface buys.
10
10
 
11
+ import { finiteCount, finiteOption } from '@ultimat3/core';
11
12
  import type { BackfillLedger } from './backfill-ledger';
12
13
  import { ClaimQueuesEmptyError } from './errors';
13
14
  import type { LeaseStore } from './leases';
@@ -270,3 +271,20 @@ export function resetJobDriver(): void {
270
271
  export const assertClaimQueues = (driver: string, options: ClaimOptions): void => {
271
272
  if (options.queues.length === 0) throw new ClaimQueuesEmptyError(driver);
272
273
  };
274
+
275
+ /**
276
+ * The two NUMBERS of a claim, screened for both drivers in one place for the reason above: a value
277
+ * neither of them refuses is answered two ways. `limit: -1` sliced every ready row but the newest
278
+ * into a lease on the memory driver, where Postgres answers `LIMIT must not be negative`; `2.5`
279
+ * claims 2 rows here and 3 there, with no error on either side.
280
+ *
281
+ * `limit` is a COUNT of rows and takes zero — claiming nothing is what a full worker asks for, and
282
+ * both drivers already answer it identically. `visibilityTimeoutMs` is a DURATION, so it is
283
+ * screened for finiteness alone, the same rule `worker-options.ts` applies to the same knob: it is
284
+ * on this list because `visibleAt = at + NaN` is never `<= now`, which turns at-least-once into
285
+ * never on a row `x jobs ls` still prints as `running`.
286
+ */
287
+ export const assertClaimBounds = (driver: string, options: ClaimOptions): void => {
288
+ finiteCount(`the ${driver} driver claim`, 'limit', options.limit);
289
+ finiteOption(`the ${driver} driver claim`, 'visibilityTimeoutMs', options.visibilityTimeoutMs);
290
+ };
package/src/events-pg.ts CHANGED
@@ -7,9 +7,9 @@
7
7
  // resumes at 12:00:30 must still see an event published at 12:00:10.
8
8
 
9
9
  import type { Clock } from '@ultimat3/core';
10
- import { logger, renderThrowable, systemClock, uuid } from '@ultimat3/core';
10
+ import { finiteOption, logger, renderThrowable, systemClock, uuid } from '@ultimat3/core';
11
11
  import type { DurationInput } from './clock';
12
- import { nowMs, toMs } from './clock';
12
+ import { finiteDurationMs, nowMs } from './clock';
13
13
  import type { PgExecutor } from './driver-pg';
14
14
  import {
15
15
  SQL_EVENT_FIND,
@@ -46,8 +46,15 @@ export interface PgEventBusOptions {
46
46
  */
47
47
  export function createPgEventBus(options: PgEventBusOptions): EventBus {
48
48
  const clock = options.clock ?? systemClock;
49
- const defaultTtl = options.defaultTtl ?? 604_800_000;
50
- const listLimit = options.listLimit ?? 1_000;
49
+ // TWO screens, for the reason `events.ts` states: `defaultTtl` is the constructor's knob and
50
+ // `ttl` is the publish call's, so one screen over `ttl ?? defaultTtl` names the wrong one for
51
+ // whichever value actually arrived.
52
+ const defaultTtlMs = finiteDurationMs(
53
+ options.defaultTtl ?? 604_800_000,
54
+ 'the pg event bus',
55
+ 'defaultTtl',
56
+ );
57
+ const listLimit = finiteOption('the pg event bus', 'listLimit', options.listLimit ?? 1_000);
51
58
  const exec = options.executor;
52
59
 
53
60
  const purgeExpired = (): number => {
@@ -68,7 +75,11 @@ export function createPgEventBus(options: PgEventBusOptions): EventBus {
68
75
  name,
69
76
  payload,
70
77
  publishedAt: at,
71
- expiresAt: at + toMs(publishOptions.ttl ?? defaultTtl),
78
+ expiresAt:
79
+ at +
80
+ (publishOptions.ttl === undefined
81
+ ? defaultTtlMs
82
+ : finiteDurationMs(publishOptions.ttl, 'the pg event bus', 'ttl')),
72
83
  ...(publishOptions.correlationKey === undefined
73
84
  ? {}
74
85
  : { correlationKey: publishOptions.correlationKey }),
package/src/events.ts CHANGED
@@ -3,9 +3,9 @@
3
3
  // 12:00:10, so a fire-and-forget emitter would silently strand every waiting run.
4
4
 
5
5
  import type { Clock } from '@ultimat3/core';
6
- import { logger, systemClock, uuid } from '@ultimat3/core';
6
+ import { finiteOption, logger, systemClock, uuid } from '@ultimat3/core';
7
7
  import type { DurationInput } from './clock';
8
- import { nowMs, toMs } from './clock';
8
+ import { finiteDurationMs, nowMs } from './clock';
9
9
  import type { EventLookup } from './steps';
10
10
 
11
11
  export interface JobEvent {
@@ -39,8 +39,17 @@ export interface MemoryEventBusOptions {
39
39
 
40
40
  export function createMemoryEventBus(options: MemoryEventBusOptions = {}): EventBus {
41
41
  const clock = options.clock ?? systemClock;
42
- const defaultTtl = options.defaultTtl ?? 604_800_000;
43
- const maxEvents = options.maxEvents ?? 10_000;
42
+ // TWO screens, because these are two knobs: the default is declared at construction and belongs
43
+ // to whoever built the bus, `ttl` rides the publish CALL. One screen over `ttl ?? defaultTtl`
44
+ // told a caller who wrote `{ ttl: NaN }` to "pass a finite defaultTtl" — an instruction naming an
45
+ // option they never set, on a constructor usually in another file. `steps.ts` names `timeout` for
46
+ // the same value shape.
47
+ const defaultTtlMs = finiteDurationMs(
48
+ options.defaultTtl ?? 604_800_000,
49
+ 'the memory event bus',
50
+ 'defaultTtl',
51
+ );
52
+ const maxEvents = finiteOption('the memory event bus', 'maxEvents', options.maxEvents ?? 10_000);
44
53
  const events = new Map<string, JobEvent>();
45
54
 
46
55
  const purgeExpired = (): number => {
@@ -64,7 +73,11 @@ export function createMemoryEventBus(options: MemoryEventBusOptions = {}): Event
64
73
  name,
65
74
  payload,
66
75
  publishedAt: at,
67
- expiresAt: at + toMs(publishOptions.ttl ?? defaultTtl),
76
+ expiresAt:
77
+ at +
78
+ (publishOptions.ttl === undefined
79
+ ? defaultTtlMs
80
+ : finiteDurationMs(publishOptions.ttl, 'the memory event bus', 'ttl')),
68
81
  ...(publishOptions.correlationKey === undefined
69
82
  ? {}
70
83
  : { correlationKey: publishOptions.correlationKey }),
package/src/index.ts CHANGED
@@ -101,6 +101,7 @@ export type {
101
101
  QueueStats,
102
102
  } from './driver';
103
103
  export {
104
+ assertClaimBounds,
104
105
  assertClaimQueues,
105
106
  DEFAULT_QUEUE,
106
107
  DEFAULT_VISIBILITY_TIMEOUT_MS,
@@ -138,7 +139,6 @@ export {
138
139
  SQL_OUTBOX_MARK_PUBLISHED,
139
140
  SQL_OUTBOX_RELEASE,
140
141
  SQL_OUTBOX_STAGE,
141
- SQL_OUTBOX_TABLE,
142
142
  SQL_SCHEDULER_STATE_GET,
143
143
  SQL_SCHEDULER_STATE_MARK,
144
144
  SQL_STATS,
package/src/job.ts CHANGED
@@ -12,7 +12,7 @@ import { assert } from '@ultimat3/core';
12
12
  import type { StandardSchemaV1 } from '@ultimat3/schema';
13
13
  import { parse } from '@ultimat3/schema';
14
14
  import type { DurationInput } from './clock';
15
- import { toMs } from './clock';
15
+ import { finiteDurationMs } from './clock';
16
16
  import type { JobDescriptor } from './describe';
17
17
  import { describeJob } from './describe';
18
18
  import type { EnqueueResult } from './driver';
@@ -204,8 +204,13 @@ export function job<I>(definition: JobDefinition<I>): JobHandle<I> {
204
204
  );
205
205
 
206
206
  const stepTimeoutMs =
207
- definition.stepTimeout === undefined ? undefined : toMs(definition.stepTimeout);
208
- const eventPollMs = definition.eventPoll === undefined ? undefined : toMs(definition.eventPoll);
207
+ definition.stepTimeout === undefined
208
+ ? undefined
209
+ : finiteDurationMs(definition.stepTimeout, `job "${name}"`, 'stepTimeout');
210
+ const eventPollMs =
211
+ definition.eventPoll === undefined
212
+ ? undefined
213
+ : finiteDurationMs(definition.eventPoll, `job "${name}"`, 'eventPoll');
209
214
  // `withStepTimeout` reads `<= 0` as "no ceiling at all" and a poll of zero is a suspension that
210
215
  // resumes immediately, forever. Both are an author who asked for a limit and got the opposite,
211
216
  // so they are refused where they are written — the same answer `concurrency: 0` gets.
@@ -231,7 +236,10 @@ export function job<I>(definition: JobDefinition<I>): JobHandle<I> {
231
236
  queue: definition.queue ?? DEFAULT_QUEUE,
232
237
  retry: { ...DEFAULT_RETRY, ...definition.retry },
233
238
  concurrency: definition.concurrency,
234
- timeoutMs: definition.timeout === undefined ? undefined : toMs(definition.timeout),
239
+ timeoutMs:
240
+ definition.timeout === undefined
241
+ ? undefined
242
+ : finiteDurationMs(definition.timeout, `job "${name}"`, 'timeout'),
235
243
  stepTimeoutMs,
236
244
  eventPollMs,
237
245
  input: definition.input,
package/src/limits.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  // what `job.concurrency` is enforced with.
13
13
 
14
14
  import type { Clock } from '@ultimat3/core';
15
- import { assert, systemClock } from '@ultimat3/core';
15
+ import { assert, finiteCount, systemClock } from '@ultimat3/core';
16
16
  import { nowMs } from './clock';
17
17
 
18
18
  export interface RateLimit {
@@ -126,6 +126,27 @@ export function createLimiter(
126
126
  );
127
127
  const maxTenants = Math.max(1, Math.floor(requested));
128
128
  const evictTo = Math.max(1, Math.floor(maxTenants * 0.9));
129
+ // The ceilings this limiter ENFORCES, screened where they are declared — the refusal `maxTenants`
130
+ // above already makes, for a sharper reason: every one of them is read as
131
+ // `config.x !== undefined && count >= config.x`, so a `NaN` leaves the option PRESENT and the
132
+ // comparison false forever. Measured: `global: Number(process.env.WORKER_GLOBAL_CONCURRENCY)`
133
+ // with the variable unset granted 1000 of 1000 acquires while `snapshot().config` still reported
134
+ // a configured ceiling. `ratePerTenant.windowMs` is on the list because it is half the same
135
+ // ceiling: `stamp > at - NaN` is false for every stamp, so the window reads empty on every call.
136
+ //
137
+ // `min` is 0 on all five, deliberately: zero is a HARD STOP here and one this repo's own suite
138
+ // configures (`limits-bound.test.ts`'s `{ perTenant: 0 }`), never "unlimited" — omitting the
139
+ // option is what means that. A count is whole because these are SLOTS: `global: 2.5` granted 3,
140
+ // which is a ceiling nobody wrote.
141
+ for (const [option, value] of [
142
+ ['perTenant', config.perTenant],
143
+ ['perQueue', config.perQueue],
144
+ ['global', config.global],
145
+ ['ratePerTenant.limit', config.ratePerTenant?.limit],
146
+ ['ratePerTenant.windowMs', config.ratePerTenant?.windowMs],
147
+ ] as const) {
148
+ if (value !== undefined) finiteCount('createLimiter', option, value);
149
+ }
129
150
  const byQueue = new Map<string, number>();
130
151
  const byTenant = new Map<string, number>();
131
152
  // `{queue, tenantId}` is ONE key — `blockedBy` has always read it that way. Without this counter
package/src/outbox.ts CHANGED
@@ -24,7 +24,14 @@
24
24
  // test and `x dev` must enqueue with nothing wired — but it is a fallback, not the guarantee.
25
25
 
26
26
  import type { Clock } from '@ultimat3/core';
27
- import { currentSpanContext, logger, renderThrowable, traceparent, uuid } from '@ultimat3/core';
27
+ import {
28
+ assert,
29
+ currentSpanContext,
30
+ logger,
31
+ renderThrowable,
32
+ traceparent,
33
+ uuid,
34
+ } from '@ultimat3/core';
28
35
  import type { Tx } from '@ultimat3/entity';
29
36
  import { nowMs } from './clock';
30
37
  import type { EnqueueResult, JobDriver } from './driver';
@@ -198,7 +205,7 @@ export function createMemoryOutboxStore(options: MemoryOutboxOptions = {}): Memo
198
205
  }
199
206
 
200
207
  export interface EnqueueOptions {
201
- /** Epoch ms, or a delay via `runAt: nowMs() + toMs('5m')`. */
208
+ /** Epoch ms, or a delay off the caller's own clock: `clock.now().getTime() + toMs('5m')`. */
202
209
  readonly runAt?: number;
203
210
  readonly tenantId?: string;
204
211
  readonly queue?: string;
@@ -383,6 +390,18 @@ export interface OutboxRelay {
383
390
  export function createOutboxRelay(options: RelayOptions): OutboxRelay {
384
391
  const batchSize = options.batchSize ?? 100;
385
392
  const intervalMs = options.intervalMs ?? 200;
393
+ // Refused, never clamped — `worker-options.ts` carries the reason. `setInterval(fn, NaN)` reads
394
+ // the delay as 0 and `claim(NaN)` slices `(0, NaN)`: a relay that spins and publishes nothing.
395
+ assert(
396
+ Number.isSafeInteger(batchSize) && batchSize >= 1,
397
+ `createOutboxRelay batchSize is ${String(batchSize)} — a batch is a whole number of staged rows, at least one`,
398
+ 'pass a finite batchSize to createOutboxRelay(...), or omit it for the default 100',
399
+ );
400
+ assert(
401
+ Number.isFinite(intervalMs) && intervalMs >= 0,
402
+ `createOutboxRelay intervalMs is ${String(intervalMs)}, which setInterval reads as 0 — the relay would spin, not poll`,
403
+ 'pass a finite intervalMs to createOutboxRelay(...), or omit it for the default 200',
404
+ );
386
405
  let timer: ReturnType<typeof setInterval> | undefined;
387
406
  let running = false;
388
407
  /** The pass in flight, so `stop()` joins it instead of returning underneath it. */
@@ -4,7 +4,7 @@
4
4
 
5
5
  import type { ErrorRetry } from '@ultimat3/core';
6
6
  import { classifyThrown, statedDelayMs } from '@ultimat3/core';
7
- import { toMs } from './clock';
7
+ import { finiteDurationMs } from './clock';
8
8
  import type { Random, RetryDecision, RetryPolicy } from './retry';
9
9
  import { DEFAULT_RETRY, nextRetry } from './retry';
10
10
 
@@ -67,7 +67,7 @@ export function nextRetryForError(
67
67
  if (stated === undefined) return { ...decision, stoppedBy: undefined, classification };
68
68
  // Clamped by the policy's own ceiling, which is what `maxDelay` is for: a responder naming a
69
69
  // day is still a responder this deployment has not agreed to wait a day for.
70
- const cap = toMs(policy.maxDelay ?? DEFAULT_RETRY.maxDelay);
70
+ const cap = finiteDurationMs(policy.maxDelay ?? DEFAULT_RETRY.maxDelay, 'retry', 'maxDelay');
71
71
  return { ...decision, delayMs: Math.min(stated, cap), stoppedBy: undefined, classification };
72
72
  }
73
73
 
package/src/retry.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  import type { BackoffCurve, Random } from '@ultimat3/core';
7
7
  import { backoffDelay } from '@ultimat3/core';
8
8
  import type { DurationInput } from './clock';
9
- import { toMs } from './clock';
9
+ import { finiteDurationMs } from './clock';
10
10
 
11
11
  /**
12
12
  * This package's name for core's curve, and an ALIAS rather than a second union: two spellings of
@@ -60,8 +60,8 @@ export type { Random };
60
60
  export function backoffDelayMs(policy: RetryPolicy, attempt: number, random?: Random): number {
61
61
  return backoffDelay({
62
62
  attempt,
63
- base: toMs(policy.delay ?? DEFAULT_RETRY.delay),
64
- max: toMs(policy.maxDelay ?? DEFAULT_RETRY.maxDelay),
63
+ base: finiteDurationMs(policy.delay ?? DEFAULT_RETRY.delay, 'retry', 'delay'),
64
+ max: finiteDurationMs(policy.maxDelay ?? DEFAULT_RETRY.maxDelay, 'retry', 'maxDelay'),
65
65
  curve: policy.backoff ?? DEFAULT_RETRY.backoff,
66
66
  // EQUAL, not full: a job that has already failed twice must not be handed a near-zero wait,
67
67
  // and this package's `jitter: true` has meant "half fixed, half random" since it shipped.
@@ -4,7 +4,7 @@
4
4
  // pod it replaced dropped, and two pods in a rolling update both dispatch every task.
5
5
 
6
6
  import type { Clock } from '@ultimat3/core';
7
- import { uuid } from '@ultimat3/core';
7
+ import { finiteOption, uuid } from '@ultimat3/core';
8
8
  import { nowMs } from './clock';
9
9
  import type { PgExecutor } from './driver-pg';
10
10
  import {
@@ -70,7 +70,11 @@ export const DEFAULT_LEADER_TTL_MS = 30_000;
70
70
  export function createPgLeaseLeader(options: PgLeaseLeaderOptions): LeaderElection {
71
71
  const lockKey = options.lockKey ?? 'scheduler';
72
72
  const holder = options.holder ?? `scheduler-${uuid()}`;
73
- const ttlMs = options.ttlMs ?? DEFAULT_LEADER_TTL_MS;
73
+ const ttlMs = finiteOption(
74
+ 'the pg scheduler lease',
75
+ 'ttlMs',
76
+ options.ttlMs ?? DEFAULT_LEADER_TTL_MS,
77
+ );
74
78
  return {
75
79
  async acquire() {
76
80
  const rows = await options.executor.query<{ holder: string }>(SQL_LEADER_ACQUIRE, [
package/src/scheduler.ts CHANGED
@@ -21,7 +21,7 @@
21
21
  // over the same `lastFiredAt`.
22
22
 
23
23
  import type { Clock } from '@ultimat3/core';
24
- import { isUltimateError, logger, onShutdown, renderThrowable } from '@ultimat3/core';
24
+ import { finiteOption, isUltimateError, logger, onShutdown, renderThrowable } from '@ultimat3/core';
25
25
  import { instant, nextCronOccurrence } from '@ultimat3/time';
26
26
  import { nowMs } from './clock';
27
27
  import { settleAllBy } from './drain-wait';
@@ -115,7 +115,11 @@ export interface Scheduler {
115
115
  export function createScheduler(options: SchedulerOptions): Scheduler {
116
116
  const schedulerState = options.state ?? createMemorySchedulerState();
117
117
  const resolveCron = options.cron ?? defaultCronResolver;
118
- const tickIntervalMs = options.tickIntervalMs ?? 1_000;
118
+ const tickIntervalMs = finiteOption(
119
+ 'createScheduler',
120
+ 'tickIntervalMs',
121
+ options.tickIntervalMs ?? 1_000,
122
+ );
119
123
  const leader = options.leader ?? soleLeader();
120
124
  let timer: ReturnType<typeof setTimeout> | undefined;
121
125
  let isLeader = false;
package/src/steps.ts CHANGED
@@ -7,9 +7,9 @@
7
7
  // catches it and re-queues the job for `resumeAt` instead of holding a process for three days.
8
8
 
9
9
  import type { Clock } from '@ultimat3/core';
10
- import { logger, renderThrowable } from '@ultimat3/core';
10
+ import { finiteOption, logger, renderThrowable } from '@ultimat3/core';
11
11
  import type { DurationInput } from './clock';
12
- import { nowMs, toMs } from './clock';
12
+ import { finiteDurationMs, nowMs } from './clock';
13
13
  import { JobAbortedError, JobTimeoutError, StepDuplicateError } from './errors';
14
14
  import { createRunSignal } from './run-signal';
15
15
 
@@ -199,7 +199,7 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
199
199
  const used: string[] = [];
200
200
  const replayed: string[] = [];
201
201
  const clock = options.clock;
202
- const pollMs = options.eventPollMs ?? 30_000;
202
+ const pollMs = finiteOption('step.waitForEvent', 'eventPollMs', options.eventPollMs ?? 30_000);
203
203
  const runSignal = options.signal ?? NEVER_ABORTED;
204
204
 
205
205
  /**
@@ -361,7 +361,8 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
361
361
  throw new StepSuspension({ step: name, resumeAt: existing.wakeAt, reason: 'sleep' });
362
362
  }
363
363
 
364
- const wakeAt = at + toMs(duration);
364
+ const wakeAt =
365
+ at + finiteDurationMs(duration, `job "${jobName}" step.sleep("${name}")`, 'duration');
365
366
  await put({
366
367
  runId,
367
368
  name,
@@ -387,7 +388,9 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
387
388
 
388
389
  const at = now();
389
390
  const startedAt = existing?.startedAt ?? at;
390
- const deadline = startedAt + toMs(waitOptions.timeout ?? 86_400_000);
391
+ const deadline =
392
+ startedAt +
393
+ finiteDurationMs(waitOptions.timeout ?? 86_400_000, 'step.waitForEvent', 'timeout');
391
394
  const correlationKey = waitOptions.match;
392
395
 
393
396
  const hit = await options.events?.find(event, correlationKey, startedAt);
@@ -411,7 +414,11 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
411
414
  throw new JobTimeoutError({
412
415
  job: jobName,
413
416
  step: name,
414
- timeoutMs: toMs(waitOptions.timeout ?? 86_400_000),
417
+ timeoutMs: finiteDurationMs(
418
+ waitOptions.timeout ?? 86_400_000,
419
+ 'step.waitForEvent',
420
+ 'timeout',
421
+ ),
415
422
  });
416
423
  }
417
424
  logger.warn('jobs.step.wait-timeout', { job: jobName, step: name, event });
package/src/webhook.ts CHANGED
@@ -17,6 +17,7 @@
17
17
 
18
18
  import type { Clock, Ctx } from '@ultimat3/core';
19
19
  import {
20
+ finiteOption,
20
21
  isCanonicalWebhookField,
21
22
  renderThrowable,
22
23
  systemClock,
@@ -169,7 +170,11 @@ const isRetryableStatus = (status: number): boolean =>
169
170
 
170
171
  export function webhook(definition: WebhookDefinition): JobHandle<WebhookDeliveryInput> {
171
172
  const clock = definition.clock ?? systemClock;
172
- const disableAfter = definition.disableAfter ?? DEFAULT_WEBHOOK_DISABLE_AFTER;
173
+ const disableAfter = finiteOption(
174
+ 'webhook()',
175
+ 'disableAfter',
176
+ definition.disableAfter ?? DEFAULT_WEBHOOK_DISABLE_AFTER,
177
+ );
173
178
  const send = definition.fetch ?? ((url, init) => fetch(url, init));
174
179
 
175
180
  return job<WebhookDeliveryInput>({
@@ -0,0 +1,73 @@
1
+ // Every numeric knob `createWorker` accepts, read and REFUSED in one place — the slot table
2
+ // included, because a queue name is data and a slot count is a bound, and both arrive from the
3
+ // same deployment config.
4
+ //
5
+ // WHY A REFUSAL AND NOT A CLAMP. `Number(process.env.JOB_VISIBILITY_MS)` on an unset variable is
6
+ // `NaN`; `??` guards only nullish, and `Math.max`/`Math.min`/`Math.floor` PROPAGATE it. So the
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`,
9
+ // the reclaim scan asks `visibleAt <= at`, and a job whose worker died is never claimable again.
10
+ // At-least-once becomes never, with no error and a row `x jobs ls` still prints as `running`.
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.
13
+
14
+ import { finiteOption } from '@ultimat3/core';
15
+ import { DEFAULT_VISIBILITY_TIMEOUT_MS } from './driver';
16
+
17
+ /** Slots a queue gets when `concurrency` names no number for it. */
18
+ const DEFAULT_SLOTS = 5;
19
+
20
+ /** The subset of `WorkerOptions` this module reads. Structural, so `WorkerOptions` satisfies it. */
21
+ export interface WorkerNumericOptions {
22
+ readonly concurrency?: number | Readonly<Record<string, number>> | undefined;
23
+ readonly visibilityTimeoutMs?: number | undefined;
24
+ readonly pollIntervalMs?: number | undefined;
25
+ readonly heartbeatIntervalMs?: number | undefined;
26
+ }
27
+
28
+ export interface WorkerTimings {
29
+ readonly visibilityTimeoutMs: number;
30
+ readonly pollIntervalMs: number;
31
+ readonly heartbeatIntervalMs: number;
32
+ /** Slots for one queue, by OWN key — see `slotsFor` below. */
33
+ readonly slotsFor: (queue: string) => number;
34
+ }
35
+
36
+ /**
37
+ * Slots for one queue. A queue NAME is deployment data, so the table is read by OWN keys:
38
+ * `concurrency['constructor']` answers `Object.prototype.constructor`, and
39
+ * `Math.max(0, <function> - inFlight)` is `NaN`, which the `free === 0` guard does not catch.
40
+ * `bun run proto-index` cannot see this one — the table is a parameter, not a literal in a file.
41
+ */
42
+ const slotTable =
43
+ (declared: number | Readonly<Record<string, number>> | undefined) =>
44
+ (queue: string): number => {
45
+ if (typeof declared === 'number') return declared;
46
+ if (declared === undefined || !Object.hasOwn(declared, queue)) return DEFAULT_SLOTS;
47
+ return declared[queue] ?? DEFAULT_SLOTS;
48
+ };
49
+
50
+ export function resolveWorkerTimings(options: WorkerNumericOptions): WorkerTimings {
51
+ const visibilityTimeoutMs = options.visibilityTimeoutMs ?? DEFAULT_VISIBILITY_TIMEOUT_MS;
52
+ finiteOption('createWorker', 'visibilityTimeoutMs', visibilityTimeoutMs);
53
+ const pollIntervalMs = options.pollIntervalMs ?? 250;
54
+ // `setTimeout(fn, NaN)` coerces the delay to 0, so the claim loop stops being a poll and becomes
55
+ // a spin: one round trip to Postgres per event-loop turn, from every worker replica.
56
+ finiteOption('createWorker', 'pollIntervalMs', pollIntervalMs);
57
+ const heartbeatIntervalMs = options.heartbeatIntervalMs ?? Math.floor(visibilityTimeoutMs / 3);
58
+ finiteOption('createWorker', 'heartbeatIntervalMs', heartbeatIntervalMs);
59
+ const declared = options.concurrency;
60
+ if (typeof declared === 'number') finiteOption('createWorker', 'concurrency', declared);
61
+ else if (declared !== undefined) {
62
+ // Per queue, by own key: an inherited member is not this table's to answer with either.
63
+ for (const queue of Object.keys(declared)) {
64
+ finiteOption('createWorker', `concurrency.${queue}`, declared[queue] ?? DEFAULT_SLOTS);
65
+ }
66
+ }
67
+ return {
68
+ visibilityTimeoutMs,
69
+ pollIntervalMs,
70
+ heartbeatIntervalMs,
71
+ slotsFor: slotTable(declared),
72
+ };
73
+ }
package/src/worker.ts CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  import { nowMs } from './clock';
17
17
  import { settleAllBy } from './drain-wait';
18
18
  import type { ClaimedJob, JobDriver, QueueStats } from './driver';
19
- import { DEFAULT_QUEUE, DEFAULT_VISIBILITY_TIMEOUT_MS } from './driver';
19
+ import { DEFAULT_QUEUE } from './driver';
20
20
  import { ConcurrencyUnenforceableError } from './errors';
21
21
  import type { JobExecution, JobOutcome } from './execute';
22
22
  import { getJob, registeredJobs } from './job';
@@ -25,6 +25,7 @@ import { createLimiter } from './limits';
25
25
  import { recordQueueDeadJobs, recordQueueOldestReady } from './metrics';
26
26
  import type { EventLookup } from './steps';
27
27
  import { createFleetSlots } from './worker-fleet-slots';
28
+ import { resolveWorkerTimings } from './worker-options';
28
29
  import { runClaimedJob } from './worker-run';
29
30
 
30
31
  /**
@@ -89,13 +90,10 @@ export interface Worker {
89
90
  export function createWorker(options: WorkerOptions): Worker {
90
91
  const workerId = options.workerId ?? `worker-${uuid()}`;
91
92
  const queues = options.queues ?? [DEFAULT_QUEUE];
92
- const visibilityTimeoutMs = options.visibilityTimeoutMs ?? DEFAULT_VISIBILITY_TIMEOUT_MS;
93
- const pollIntervalMs = options.pollIntervalMs ?? 250;
94
- const heartbeatIntervalMs = options.heartbeatIntervalMs ?? Math.floor(visibilityTimeoutMs / 3);
95
- const slotsFor = (queue: string): number =>
96
- typeof options.concurrency === 'number'
97
- ? options.concurrency
98
- : (options.concurrency?.[queue] ?? 5);
93
+ // Every numeric knob, read and refused in one place — `worker-options.ts` says why a non-finite
94
+ // one is a refusal rather than a clamp, and carries the slot table's own-key read with it.
95
+ const { visibilityTimeoutMs, pollIntervalMs, heartbeatIntervalMs, slotsFor } =
96
+ resolveWorkerTimings(options);
99
97
  const limiter = options.limiter ?? createLimiter({});
100
98
  const driverLeases = options.driver.leases;
101
99
  // `job.concurrency`, held as a row every replica sees. The TTL is the visibility timeout and the