// Every numeric knob `jobWorker` accepts, read and REFUSED in one place — the slot table // included, because a queue name is data and a slot count is a bound, and both arrive from the // same deployment config. // // WHY A REFUSAL AND NOT A CLAMP. `Number(process.env.JOB_VISIBILITY_MS)` on an unset variable is // `NaN`; `??` guards only nullish, and `Math.max`/`Math.min`/`Math.floor` PROPAGATE it. So the // value arrives at a lease deadline, a claim limit and a timer interval intact, and every // comparison against it reads FALSE — measured on `memoryJobDriver`: `visibleAt = at + NaN`, // the reclaim scan asks `visibleAt <= at`, and a job whose worker died is never claimable again. // At-least-once becomes never, with no error and a row `x jobs list` still prints as `running`. // `slice(0, NaN)` is `[]`, so a `concurrency: NaN` worker claims nothing and reports healthy. // Same shape as `concurrencyLimiter`'s `maxTenants` and `backfill()`'s `batch`, refused the same way. import { finiteOption, JOBS_CONCURRENCY_DEFAULT } from '@ultimat3/core'; import { DEFAULT_VISIBILITY_TIMEOUT_MS } from './driver'; /** The subset of `WorkerOptions` this module reads. Structural, so `WorkerOptions` satisfies it. */ export interface WorkerNumericOptions { readonly concurrency?: number | Readonly> | undefined; readonly visibilityTimeoutMs?: number | undefined; readonly pollIntervalMs?: number | undefined; readonly heartbeatIntervalMs?: number | undefined; } export interface WorkerTimings { readonly visibilityTimeoutMs: number; readonly pollIntervalMs: number; readonly heartbeatIntervalMs: number; /** Slots for one queue, by OWN key — see `slotsFor` below. */ readonly slotsFor: (queue: string) => number; } /** * Slots for one queue — `JOBS_CONCURRENCY_DEFAULT` (core's, the same number `app.config.ts`'s * `jobs.concurrency` defaults to) for a queue `concurrency` names no number for. This package kept * its own 5 until 25.0.0, so a table `{ banks: 4 }` left `default` at 5 beside an unset config's 8. * A queue NAME is deployment data, so the table is read by OWN keys: * `concurrency['constructor']` answers `Object.prototype.constructor`, and * `Math.max(0, - inFlight)` is `NaN`, which the `free === 0` guard does not catch. * `bun run proto-index` cannot see this one — the table is a parameter, not a literal in a file. */ const slotTable = (declared: number | Readonly> | undefined) => (queue: string): number => { if (typeof declared === 'number') return declared; if (declared === undefined || !Object.hasOwn(declared, queue)) return JOBS_CONCURRENCY_DEFAULT; return declared[queue] ?? JOBS_CONCURRENCY_DEFAULT; }; export function resolveWorkerTimings(options: WorkerNumericOptions): WorkerTimings { const visibilityTimeoutMs = options.visibilityTimeoutMs ?? DEFAULT_VISIBILITY_TIMEOUT_MS; finiteOption('jobWorker', 'visibilityTimeoutMs', visibilityTimeoutMs); const pollIntervalMs = options.pollIntervalMs ?? 250; // `setTimeout(fn, NaN)` coerces the delay to 0, so the claim loop stops being a poll and becomes // a spin: one round trip to Postgres per event-loop turn, from every worker replica. finiteOption('jobWorker', 'pollIntervalMs', pollIntervalMs); const heartbeatIntervalMs = options.heartbeatIntervalMs ?? Math.floor(visibilityTimeoutMs / 3); finiteOption('jobWorker', 'heartbeatIntervalMs', heartbeatIntervalMs); const declared = options.concurrency; if (typeof declared === 'number') finiteOption('jobWorker', 'concurrency', declared); else if (declared !== undefined) { // Per queue, by own key: an inherited member is not this table's to answer with either. for (const queue of Object.keys(declared)) { finiteOption( 'jobWorker', `concurrency.${queue}`, declared[queue] ?? JOBS_CONCURRENCY_DEFAULT, ); } } return { visibilityTimeoutMs, pollIntervalMs, heartbeatIntervalMs, slotsFor: slotTable(declared), }; }