@ultimat3/jobs 15.0.0 → 17.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 +43 -1
- package/package.json +5 -5
- package/src/backfill-ledger.ts +5 -4
- package/src/driver-memory.ts +12 -8
- package/src/driver-pg-ddl.ts +0 -26
- package/src/driver-pg-sql.ts +1 -1
- package/src/driver-pg.ts +8 -5
- package/src/driver.ts +18 -0
- package/src/events-pg.ts +15 -4
- package/src/events.ts +17 -4
- package/src/index.ts +1 -1
- package/src/limits.ts +22 -1
- package/src/outbox.ts +20 -1
- package/src/scheduler-pg.ts +6 -2
- package/src/scheduler.ts +6 -2
- package/src/steps.ts +10 -4
- package/src/webhook.ts +6 -1
- package/src/worker-options.ts +73 -0
- package/src/worker.ts +6 -8
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
|
|
@@ -850,7 +892,7 @@ picture from the other side.
|
|
|
850
892
|
| `events-pg.ts` | `createPgEventBus` — `step.waitForEvent` across processes |
|
|
851
893
|
| `driver.ts` | `JobDriver` contract + wire records |
|
|
852
894
|
| `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`
|
|
895
|
+
| `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
896
|
| `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
897
|
| `driver-pg-rows.ts` | a Postgres row → a wire record: `JobRow`/`StepRow`/`BackfillRow` and their mappings |
|
|
856
898
|
| `driver-memory.ts` | `x dev` / tests |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/jobs",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "17.0.0",
|
|
4
4
|
"description": "Durable background work: steps, transactional outbox, cron tasks, one driver interface",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
"test": "bun test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/entity": "
|
|
37
|
-
"@ultimat3/schema": "
|
|
38
|
-
"@ultimat3/time": "
|
|
35
|
+
"@ultimat3/core": "17.0.0",
|
|
36
|
+
"@ultimat3/entity": "17.0.0",
|
|
37
|
+
"@ultimat3/schema": "17.0.0",
|
|
38
|
+
"@ultimat3/time": "17.0.0"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/src/backfill-ledger.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
180
|
+
.slice(0, finiteCount('the backfill ledger list', 'limit', filter.limit ?? 100));
|
|
181
|
+
return rows;
|
|
181
182
|
},
|
|
182
183
|
};
|
|
183
184
|
}
|
package/src/driver-memory.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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()]
|
package/src/driver-pg-ddl.ts
CHANGED
|
@@ -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();
|
package/src/driver-pg-sql.ts
CHANGED
|
@@ -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
|
|
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, [
|
|
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,7 +7,7 @@
|
|
|
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
12
|
import { nowMs, toMs } from './clock';
|
|
13
13
|
import type { PgExecutor } from './driver-pg';
|
|
@@ -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
|
-
|
|
50
|
-
|
|
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 = finiteOption(
|
|
53
|
+
'the pg event bus',
|
|
54
|
+
'defaultTtl',
|
|
55
|
+
toMs(options.defaultTtl ?? 604_800_000),
|
|
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:
|
|
78
|
+
expiresAt:
|
|
79
|
+
at +
|
|
80
|
+
(publishOptions.ttl === undefined
|
|
81
|
+
? defaultTtlMs
|
|
82
|
+
: finiteOption('the pg event bus', 'ttl', toMs(publishOptions.ttl))),
|
|
72
83
|
...(publishOptions.correlationKey === undefined
|
|
73
84
|
? {}
|
|
74
85
|
: { correlationKey: publishOptions.correlationKey }),
|
package/src/events.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
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
8
|
import { nowMs, toMs } from './clock';
|
|
9
9
|
import type { EventLookup } from './steps';
|
|
@@ -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
|
-
|
|
43
|
-
|
|
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 = finiteOption(
|
|
48
|
+
'the memory event bus',
|
|
49
|
+
'defaultTtl',
|
|
50
|
+
toMs(options.defaultTtl ?? 604_800_000),
|
|
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:
|
|
76
|
+
expiresAt:
|
|
77
|
+
at +
|
|
78
|
+
(publishOptions.ttl === undefined
|
|
79
|
+
? defaultTtlMs
|
|
80
|
+
: finiteOption('the memory event bus', 'ttl', toMs(publishOptions.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/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 {
|
|
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';
|
|
@@ -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. */
|
package/src/scheduler-pg.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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,7 +7,7 @@
|
|
|
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
12
|
import { nowMs, toMs } from './clock';
|
|
13
13
|
import { JobAbortedError, JobTimeoutError, StepDuplicateError } from './errors';
|
|
@@ -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
|
/**
|
|
@@ -387,7 +387,9 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
|
|
|
387
387
|
|
|
388
388
|
const at = now();
|
|
389
389
|
const startedAt = existing?.startedAt ?? at;
|
|
390
|
-
const deadline =
|
|
390
|
+
const deadline =
|
|
391
|
+
startedAt +
|
|
392
|
+
finiteOption('step.waitForEvent', 'timeout', toMs(waitOptions.timeout ?? 86_400_000));
|
|
391
393
|
const correlationKey = waitOptions.match;
|
|
392
394
|
|
|
393
395
|
const hit = await options.events?.find(event, correlationKey, startedAt);
|
|
@@ -411,7 +413,11 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
|
|
|
411
413
|
throw new JobTimeoutError({
|
|
412
414
|
job: jobName,
|
|
413
415
|
step: name,
|
|
414
|
-
timeoutMs:
|
|
416
|
+
timeoutMs: finiteOption(
|
|
417
|
+
'step.waitForEvent',
|
|
418
|
+
'timeout',
|
|
419
|
+
toMs(waitOptions.timeout ?? 86_400_000),
|
|
420
|
+
),
|
|
415
421
|
});
|
|
416
422
|
}
|
|
417
423
|
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 =
|
|
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
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
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
|