@ultimat3/jobs 23.0.0 → 25.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +72 -75
- package/README.md +83 -38
- package/package.json +7 -7
- package/src/backfill-errors.ts +3 -2
- package/src/backfill-ledger.ts +25 -3
- package/src/backfill-pass.ts +22 -5
- package/src/backfill-rate.ts +2 -2
- package/src/backfill.ts +2 -2
- package/src/claim-exhausted.ts +76 -0
- package/src/counters.ts +1 -1
- package/src/driver-memory-operator.ts +34 -12
- package/src/driver-memory.ts +80 -38
- package/src/driver-pg-jobs-sql.ts +27 -19
- package/src/driver-pg-operator-sql.ts +32 -12
- package/src/driver-pg-operator.ts +19 -3
- package/src/driver-pg-rows.ts +3 -3
- package/src/driver-pg-settle-sql.ts +2 -3
- package/src/driver-pg-sql.ts +99 -30
- package/src/driver-pg.ts +103 -98
- package/src/driver.ts +46 -16
- package/src/errors-concurrency.ts +1 -1
- package/src/errors-operator.ts +12 -4
- package/src/errors-requeue.ts +21 -3
- package/src/errors.ts +9 -17
- package/src/events-pg.ts +11 -21
- package/src/events.ts +26 -15
- package/src/execute.ts +47 -5
- package/src/export.ts +2 -2
- package/src/heartbeat.ts +7 -2
- package/src/index.ts +45 -99
- package/src/inspect-operator.ts +4 -4
- package/src/inspect.ts +5 -4
- package/src/introspection.ts +23 -11
- package/src/job.ts +6 -7
- package/src/leases.ts +1 -1
- package/src/limits.ts +46 -15
- package/src/outbox-lease.ts +1 -1
- package/src/outbox-pg.ts +6 -7
- package/src/outbox-relay.ts +6 -6
- package/src/outbox.ts +47 -19
- package/src/purge.ts +1 -1
- package/src/queue-wake.ts +1 -2
- package/src/renewal-timer.ts +1 -1
- package/src/retry-classification.ts +52 -15
- package/src/scheduler-leader.ts +2 -2
- package/src/scheduler-pg.ts +19 -16
- package/src/scheduler-state.ts +10 -3
- package/src/scheduler.ts +14 -15
- package/src/steps-memory.ts +2 -2
- package/src/steps-suspension.ts +46 -0
- package/src/steps-timeout.ts +33 -0
- package/src/steps.ts +90 -88
- package/src/task.ts +1 -1
- package/src/webhook-attempt.ts +206 -0
- package/src/webhook-ledger.ts +15 -4
- package/src/webhook.ts +64 -118
- package/src/worker-admit.ts +14 -6
- package/src/worker-drain-cutoff.ts +110 -0
- package/src/worker-fleet-slots.ts +82 -45
- package/src/worker-held.ts +100 -0
- package/src/worker-key-busy.ts +38 -10
- package/src/worker-loop.ts +35 -11
- package/src/worker-options.ts +19 -15
- package/src/worker-registry.ts +16 -1
- package/src/worker-run.ts +38 -8
- package/src/worker-tally.ts +53 -0
- package/src/worker-types.ts +7 -4
- package/src/worker.ts +124 -73
- package/src/driver-nats.ts +0 -86
- package/src/driver-redis.ts +0 -89
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// Apart from `worker.ts` because the claim loop's question is "may I start this one?" — which job
|
|
4
4
|
// holds which slot, and who gives it back, is bookkeeping of its own.
|
|
5
5
|
|
|
6
|
+
import type { Clock } from '@ultimat3/core';
|
|
6
7
|
import { logger, renderThrowable } from '@ultimat3/core';
|
|
8
|
+
import { nowMs } from './clock';
|
|
7
9
|
import type { ClaimedJob, JobDriver } from './driver';
|
|
8
10
|
import { ConcurrencyUnenforceableError } from './errors-concurrency';
|
|
9
11
|
import { getJob, registeredJobs } from './job';
|
|
@@ -11,12 +13,6 @@ import type { HeldLease, LeaseStore } from './leases';
|
|
|
11
13
|
import { jobLeaseKey } from './leases';
|
|
12
14
|
import { type IntervalScheduler, startRenewalTimer } from './renewal-timer';
|
|
13
15
|
|
|
14
|
-
/**
|
|
15
|
-
* A renewal that REJECTED is not a lost slot: there is a TTL behind it and the interval gets
|
|
16
|
-
* several tries inside it, exactly as `heartbeat.ts` treats a failed `driver.heartbeat`. The
|
|
17
|
-
* heartbeat cannot cover for this one either way — it renews `x_jobs.visible_at`, a different row
|
|
18
|
-
* on a different clock, and knows nothing about `x_job_leases`.
|
|
19
|
-
*/
|
|
20
16
|
const noop = (): void => undefined;
|
|
21
17
|
|
|
22
18
|
export interface FleetSlotOptions {
|
|
@@ -33,6 +29,8 @@ export interface FleetSlotOptions {
|
|
|
33
29
|
readonly renewIntervalMs: number;
|
|
34
30
|
/** What every renewal runs on (`renewal-timer.ts`). Default: a real, unrefed interval. */
|
|
35
31
|
readonly schedule?: IntervalScheduler;
|
|
32
|
+
/** What "a whole TTL without a renewal landing" is measured on. Default: the system clock. */
|
|
33
|
+
readonly clock?: Clock;
|
|
36
34
|
}
|
|
37
35
|
|
|
38
36
|
/**
|
|
@@ -75,7 +73,7 @@ export interface FleetSlots {
|
|
|
75
73
|
* to do without one. `granted` is also the answer for "no cap declared": a job with no
|
|
76
74
|
* `concurrency` never touches the lease table.
|
|
77
75
|
*
|
|
78
|
-
* A driver with no lease store cannot reach here: `
|
|
76
|
+
* A driver with no lease store cannot reach here: `jobWorker().start()` refuses to boot when
|
|
79
77
|
* a registered job declares `concurrency` and the driver has none, because a cap that silently
|
|
80
78
|
* holds per process is the documented-guarantee-that-does-nothing axiom 3 exists to make
|
|
81
79
|
* impossible.
|
|
@@ -97,8 +95,14 @@ export interface FleetSlots {
|
|
|
97
95
|
}
|
|
98
96
|
|
|
99
97
|
export function createFleetSlots(options: FleetSlotOptions): FleetSlots {
|
|
100
|
-
/**
|
|
101
|
-
|
|
98
|
+
/**
|
|
99
|
+
* The fleet slots each in-flight job holds, OLDEST FIRST, so the renewal finds one and the
|
|
100
|
+
* settle frees one. A list, because one worker can hold the same job twice: a claim that lapsed
|
|
101
|
+
* while its body kept running, and this worker's own re-claim of the row. One entry per job id
|
|
102
|
+
* let the second overwrite the first — the first run's settle then released the second's slot,
|
|
103
|
+
* and the first's was never released at all.
|
|
104
|
+
*/
|
|
105
|
+
const held = new Map<string, HeldLease[]>();
|
|
102
106
|
|
|
103
107
|
return {
|
|
104
108
|
async acquire(claimed) {
|
|
@@ -127,7 +131,7 @@ export function createFleetSlots(options: FleetSlotOptions): FleetSlots {
|
|
|
127
131
|
slotHolder(options.workerId, claimed.id),
|
|
128
132
|
);
|
|
129
133
|
if (slot !== undefined) {
|
|
130
|
-
held.set(claimed.id, slot);
|
|
134
|
+
held.set(claimed.id, [...(held.get(claimed.id) ?? []), slot]);
|
|
131
135
|
return GRANTED;
|
|
132
136
|
}
|
|
133
137
|
if (handle.whenBusy !== 'fail' || key === undefined) return { outcome: 'wait', limit, key };
|
|
@@ -144,47 +148,80 @@ export function createFleetSlots(options: FleetSlotOptions): FleetSlots {
|
|
|
144
148
|
},
|
|
145
149
|
|
|
146
150
|
startRenewal(jobId, onLost) {
|
|
147
|
-
|
|
151
|
+
// The NEWEST: a run arms its renewal right behind its own acquire.
|
|
152
|
+
const slot = held.get(jobId)?.at(-1);
|
|
148
153
|
if (slot === undefined) return noop;
|
|
149
|
-
// Renewed on the lease heartbeat's own interval and released in the same `finally
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
154
|
+
// Renewed on the lease heartbeat's own interval and released in the same `finally` — and
|
|
155
|
+
// LOST by the same two facts `heartbeat.ts` loses a job's lease by: the store saying "not
|
|
156
|
+
// yours", or a whole TTL on this process's clock with no renewal landing. The heartbeat
|
|
157
|
+
// cannot cover for this one: it renews `x_jobs.visible_at`, a different row.
|
|
158
|
+
const now = (): number => nowMs(options.clock);
|
|
159
|
+
let renewedAt = now();
|
|
160
|
+
let renewing = false;
|
|
161
|
+
let lost = false;
|
|
162
|
+
const lapsed = (): boolean => now() - renewedAt >= options.ttlMs;
|
|
163
|
+
|
|
164
|
+
/** Once, then renewal stops: extending a slot this worker no longer holds pushes out another's. */
|
|
165
|
+
const reportLost = (reason: 'expired' | 'not-ours', error?: unknown): void => {
|
|
166
|
+
// `stopped()` for the run that FINISHED: its release deletes the row, so the renewal
|
|
167
|
+
// already on the wire answers `false` for a slot nobody lost.
|
|
168
|
+
if (lost || timer.stopped()) return;
|
|
169
|
+
lost = true;
|
|
170
|
+
timer.stop();
|
|
171
|
+
logger.error('jobs.worker.slot-lost', {
|
|
172
|
+
workerId: options.workerId,
|
|
173
|
+
jobId,
|
|
174
|
+
leaseKey: slot.key,
|
|
175
|
+
slot: slot.slot,
|
|
176
|
+
reason,
|
|
177
|
+
...(error === undefined ? {} : { error: renderThrowable(error) }),
|
|
178
|
+
});
|
|
179
|
+
onLost?.(slot);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const renew = async (): Promise<void> => {
|
|
183
|
+
if (lost || timer.stopped() || options.leases === undefined) return;
|
|
184
|
+
// Decided BEFORE the store is asked: a renewal hung on a dead connection never rejects.
|
|
185
|
+
if (lapsed()) return reportLost('expired');
|
|
186
|
+
// One in flight at a time, on the connection that is already the thing failing.
|
|
187
|
+
if (renewing) return;
|
|
188
|
+
renewing = true;
|
|
189
|
+
// Stamped BEFORE the call, as `heartbeat.ts` does: the store dates the new expiry at the
|
|
190
|
+
// statement, so a slow reply must not stretch this worker's view past it.
|
|
191
|
+
const sentAt = now();
|
|
192
|
+
try {
|
|
193
|
+
const renewed = await options.leases.renew(slot, options.ttlMs);
|
|
194
|
+
if (timer.stopped()) return;
|
|
195
|
+
// `=== false`, never `!renewed`: a store written before this answer existed resolves
|
|
196
|
+
// `undefined`, and reading that as a loss would cancel every job on every renewal.
|
|
197
|
+
if (renewed === false) return reportLost('not-ours');
|
|
198
|
+
// A renewal that lands LATE is still late: the slot may already be another worker's.
|
|
199
|
+
if (lapsed()) return reportLost('expired');
|
|
200
|
+
renewedAt = sentAt;
|
|
201
|
+
} catch (error) {
|
|
202
|
+
// One failed renewal is not a lost slot: the TTL gives the interval several tries.
|
|
203
|
+
logger.warn('jobs.worker.slot-renewal-failed', {
|
|
204
|
+
workerId: options.workerId,
|
|
205
|
+
jobId,
|
|
206
|
+
leaseKey: slot.key,
|
|
207
|
+
error: renderThrowable(error),
|
|
208
|
+
});
|
|
209
|
+
if (lapsed()) reportLost('expired', error);
|
|
210
|
+
} finally {
|
|
211
|
+
renewing = false;
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
const timer = startRenewalTimer(options.renewIntervalMs, renew, options.schedule);
|
|
181
215
|
return () => timer.stop();
|
|
182
216
|
},
|
|
183
217
|
|
|
184
218
|
async release(jobId) {
|
|
185
|
-
|
|
219
|
+
// The OLDEST: the run that settles first is, bar a race nobody loses by, the superseded one
|
|
220
|
+
// — and if it is not, the superseded run's next renewal answers `false` and cancels it.
|
|
221
|
+
const [slot, ...rest] = held.get(jobId) ?? [];
|
|
186
222
|
if (slot === undefined) return;
|
|
187
|
-
held.delete(jobId);
|
|
223
|
+
if (rest.length === 0) held.delete(jobId);
|
|
224
|
+
else held.set(jobId, rest);
|
|
188
225
|
// Never lets a settle fail over bookkeeping: an unreleased slot expires on its own TTL.
|
|
189
226
|
await options.leases?.release(slot).catch((error: unknown) => {
|
|
190
227
|
logger.warn('jobs.worker.lease-release-failed', {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// The runs one worker holds right now, each with the claim it runs under — so a drain that reaches
|
|
2
|
+
// its cut-off can hand back every row still held instead of leaving it under a lease another
|
|
3
|
+
// worker waits a whole visibility timeout to see lapse. Split from `worker.ts`, whose job is the
|
|
4
|
+
// claim loop; this one is "what am I holding, and give it back".
|
|
5
|
+
|
|
6
|
+
import type { ClaimedJob, JobDriver } from './driver';
|
|
7
|
+
import { handBack } from './worker-hand-back';
|
|
8
|
+
|
|
9
|
+
export interface HeldRuns {
|
|
10
|
+
/** How many runs are held — what `stats().inFlight` reports. */
|
|
11
|
+
readonly size: number;
|
|
12
|
+
/**
|
|
13
|
+
* The job ids held, each once — what the registry row reports. Per RUN underneath, because one
|
|
14
|
+
* worker can hold a job twice (a claim that lapsed under a body still running, then its own
|
|
15
|
+
* re-claim): keyed by id, the first run to end dropped an id the second still held.
|
|
16
|
+
*/
|
|
17
|
+
ids(): string[];
|
|
18
|
+
/**
|
|
19
|
+
* A run started. `release` is what the run's own `finally` would do for the process — the
|
|
20
|
+
* in-flight count core's drain waits on, the fleet slot — run early when the run is handed back.
|
|
21
|
+
*/
|
|
22
|
+
add(run: Promise<unknown>, claimed: ClaimedJob, release: () => Promise<void>): void;
|
|
23
|
+
/** The run settled on its own: no longer held. */
|
|
24
|
+
settle(run: Promise<unknown>): void;
|
|
25
|
+
/** Resolves the moment nothing is held — settled or handed back. */
|
|
26
|
+
empty(): Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* Every run still held goes back to the queue — `countsAsAttempt: false`, no delay — and stops
|
|
29
|
+
* being this worker's. Answers how many. The bodies themselves cannot be stopped; what they
|
|
30
|
+
* write after this is fenced out by the claim that just moved (`StepFence`, `SQL_ACK`).
|
|
31
|
+
*/
|
|
32
|
+
handBackAll(): Promise<number>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface Holding {
|
|
36
|
+
readonly claimed: ClaimedJob;
|
|
37
|
+
readonly release: () => Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createHeldRuns(options: {
|
|
41
|
+
readonly driver: JobDriver;
|
|
42
|
+
readonly workerId: string;
|
|
43
|
+
}): HeldRuns {
|
|
44
|
+
const held = new Map<Promise<unknown>, Holding>();
|
|
45
|
+
let waiters: (() => void)[] = [];
|
|
46
|
+
|
|
47
|
+
/** Pending hand-backs: `empty()` answers only once their nacks have LANDED, never mid-wire. */
|
|
48
|
+
let handingBack = 0;
|
|
49
|
+
const wakeIfEmpty = (): void => {
|
|
50
|
+
if (held.size > 0 || handingBack > 0 || waiters.length === 0) return;
|
|
51
|
+
const woken = waiters;
|
|
52
|
+
waiters = [];
|
|
53
|
+
for (const wake of woken) wake();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
get size() {
|
|
58
|
+
return held.size;
|
|
59
|
+
},
|
|
60
|
+
ids() {
|
|
61
|
+
return [...new Set([...held.values()].map((holding) => holding.claimed.id))];
|
|
62
|
+
},
|
|
63
|
+
add(run, claimed, release) {
|
|
64
|
+
held.set(run, { claimed, release });
|
|
65
|
+
},
|
|
66
|
+
settle(run) {
|
|
67
|
+
held.delete(run);
|
|
68
|
+
wakeIfEmpty();
|
|
69
|
+
},
|
|
70
|
+
empty() {
|
|
71
|
+
if (held.size === 0 && handingBack === 0) return Promise.resolve();
|
|
72
|
+
return new Promise<void>((resolve) => {
|
|
73
|
+
waiters.push(resolve);
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
async handBackAll() {
|
|
77
|
+
// Taken out of the set BEFORE the first await: a run settling while the nacks are on the
|
|
78
|
+
// wire is a run the hand-back already owns, and must not be counted twice. And `empty()`
|
|
79
|
+
// stays unanswered until the nacks land — the teardown waiting on it closes the driver next.
|
|
80
|
+
const stranded = [...held.values()];
|
|
81
|
+
held.clear();
|
|
82
|
+
if (stranded.length === 0) return 0;
|
|
83
|
+
handingBack += 1;
|
|
84
|
+
try {
|
|
85
|
+
await handBack(
|
|
86
|
+
options.driver,
|
|
87
|
+
stranded.map((holding) => holding.claimed),
|
|
88
|
+
{ delayMs: 0, workerId: options.workerId },
|
|
89
|
+
);
|
|
90
|
+
// After the nacks, never before: the slot is what lets the replacement run a
|
|
91
|
+
// `concurrency: 1` job, and freed first it could be taken while the row was still ours.
|
|
92
|
+
for (const holding of stranded) await holding.release();
|
|
93
|
+
} finally {
|
|
94
|
+
handingBack -= 1;
|
|
95
|
+
wakeIfEmpty();
|
|
96
|
+
}
|
|
97
|
+
return stranded.length;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
package/src/worker-key-busy.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// would be counted, reported and parked as a fault the author declared was an answer.
|
|
5
5
|
|
|
6
6
|
import type { Clock, Ctx } from '@ultimat3/core';
|
|
7
|
-
import { logger } from '@ultimat3/core';
|
|
7
|
+
import { logger, renderThrowable } from '@ultimat3/core';
|
|
8
8
|
import { nowMs } from './clock';
|
|
9
9
|
import type { ClaimedJob, JobDriver } from './driver';
|
|
10
10
|
import { claimOf } from './driver';
|
|
@@ -33,7 +33,9 @@ export interface RefuseKeyBusyOptions {
|
|
|
33
33
|
* Not `reportError`ed: the error tracker is for failures somebody has to look at, and this one
|
|
34
34
|
* was asked for by the declaration. The row and this log line carry it.
|
|
35
35
|
*/
|
|
36
|
-
export async function refuseKeyBusy(
|
|
36
|
+
export async function refuseKeyBusy(
|
|
37
|
+
options: RefuseKeyBusyOptions,
|
|
38
|
+
): Promise<JobExecution | undefined> {
|
|
37
39
|
const { driver, claimed, key, limit } = options;
|
|
38
40
|
const startedAt = nowMs(options.clock);
|
|
39
41
|
const refusal = new JobKeyBusyError({ job: claimed.name, key, limit });
|
|
@@ -47,6 +49,17 @@ export async function refuseKeyBusy(options: RefuseKeyBusyOptions): Promise<JobE
|
|
|
47
49
|
countsAsAttempt: false,
|
|
48
50
|
fail: true,
|
|
49
51
|
});
|
|
52
|
+
if (!settled) {
|
|
53
|
+
// The claim lapsed and the row is another worker's now, or it was cancelled: nothing was
|
|
54
|
+
// refused HERE, so there is no execution to count — `executeJob`'s own line for the same miss.
|
|
55
|
+
logger.warn('jobs.settle.unowned', {
|
|
56
|
+
job: claimed.name,
|
|
57
|
+
jobId: claimed.id,
|
|
58
|
+
workerId: options.workerId,
|
|
59
|
+
settling: 'refused',
|
|
60
|
+
});
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
50
63
|
logger.info('jobs.attempt.refused', {
|
|
51
64
|
workerId: options.workerId,
|
|
52
65
|
job: claimed.name,
|
|
@@ -55,15 +68,30 @@ export async function refuseKeyBusy(options: RefuseKeyBusyOptions): Promise<JobE
|
|
|
55
68
|
limit,
|
|
56
69
|
});
|
|
57
70
|
// The one ending no body is there to record: "a second run was refused" exists for the app only
|
|
58
|
-
// if the job is told. After the settle
|
|
71
|
+
// if the job is told. After the settle, like every other announcement.
|
|
59
72
|
const handle = getJob(claimed.name);
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
73
|
+
if (handle !== undefined) {
|
|
74
|
+
let ctx: Ctx | undefined;
|
|
75
|
+
try {
|
|
76
|
+
ctx = options.context();
|
|
77
|
+
} catch (raised) {
|
|
78
|
+
// `context()` is the app's own function. The row is already settled `failed`: a throw from
|
|
79
|
+
// here failed the whole claim ROUND over a refusal that had landed.
|
|
80
|
+
logger.error('jobs.attempt.refused-unannounced', {
|
|
81
|
+
workerId: options.workerId,
|
|
82
|
+
job: claimed.name,
|
|
83
|
+
jobId: claimed.id,
|
|
84
|
+
error: renderThrowable(raised),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
if (ctx !== undefined) {
|
|
88
|
+
await announceSettled({
|
|
89
|
+
handle,
|
|
90
|
+
claimed,
|
|
91
|
+
ctx,
|
|
92
|
+
settlement: { outcome: 'refused', error, code: refusal.code },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
67
95
|
}
|
|
68
96
|
return {
|
|
69
97
|
outcome: 'refused',
|
package/src/worker-loop.ts
CHANGED
|
@@ -15,8 +15,28 @@ export interface ClaimLoopOptions {
|
|
|
15
15
|
/** One pass. It reports what it found through `passed()`, so a manual `tick()` counts too. */
|
|
16
16
|
readonly round: () => Promise<unknown>;
|
|
17
17
|
readonly onError: (error: unknown) => void;
|
|
18
|
+
/** What every wait is armed on. Default: real, unrefed timeouts; a test hands in its own. */
|
|
19
|
+
readonly timers?: LoopTimers;
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
/** The two calls the loop makes on a timer. The handle is whatever `set` answered. */
|
|
23
|
+
export interface LoopTimers {
|
|
24
|
+
set(run: () => void, ms: number): unknown;
|
|
25
|
+
clear(handle: unknown): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Unrefed: never the thing keeping a drained process alive — the hooks stop this loop. */
|
|
29
|
+
const REAL_TIMERS: LoopTimers = {
|
|
30
|
+
set(run, ms) {
|
|
31
|
+
const handle = setTimeout(run, ms);
|
|
32
|
+
handle.unref?.();
|
|
33
|
+
return handle;
|
|
34
|
+
},
|
|
35
|
+
clear(handle) {
|
|
36
|
+
clearTimeout(handle as ReturnType<typeof setTimeout>);
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
20
40
|
export interface ClaimLoop {
|
|
21
41
|
start(): void;
|
|
22
42
|
stop(): void;
|
|
@@ -47,23 +67,30 @@ export function createClaimLoop(options: ClaimLoopOptions): ClaimLoop {
|
|
|
47
67
|
floorMs: floor,
|
|
48
68
|
...(options.ceilingMs === undefined ? {} : { ceilingMs: options.ceilingMs }),
|
|
49
69
|
});
|
|
70
|
+
const timers = options.timers ?? REAL_TIMERS;
|
|
50
71
|
let delayMs = floor;
|
|
51
72
|
let active = false;
|
|
52
|
-
let timer:
|
|
73
|
+
let timer: unknown;
|
|
53
74
|
/** The timer's own pass is in flight. A wake landing now asks for ONE more, never a second chain. */
|
|
54
75
|
let passing = false;
|
|
55
76
|
let again = false;
|
|
56
77
|
let unsubscribe: (() => void) | undefined;
|
|
57
78
|
/** One timer per floor-wide bucket of due times, so a burst of retries holds a bounded few. */
|
|
58
|
-
const due = new Map<number,
|
|
79
|
+
const due = new Map<number, unknown>();
|
|
59
80
|
|
|
60
81
|
const arm = (): void => {
|
|
61
|
-
timer =
|
|
82
|
+
timer = timers.set(() => {
|
|
62
83
|
timer = undefined;
|
|
63
84
|
passing = true;
|
|
64
85
|
void options
|
|
65
86
|
.round()
|
|
66
|
-
.catch(
|
|
87
|
+
.catch((error: unknown) => {
|
|
88
|
+
// A failed pass never reached `passed()`, so the wait in hand is whatever cut the last
|
|
89
|
+
// one short — 0 after a wake — and re-arming on it is a loop that spins for as long as
|
|
90
|
+
// the database is down. Not an idle pass either: try again at the floor.
|
|
91
|
+
delayMs = backoff.next(true);
|
|
92
|
+
options.onError(error);
|
|
93
|
+
})
|
|
67
94
|
.finally(() => {
|
|
68
95
|
passing = false;
|
|
69
96
|
if (!active) return;
|
|
@@ -72,8 +99,6 @@ export function createClaimLoop(options: ClaimLoopOptions): ClaimLoop {
|
|
|
72
99
|
arm();
|
|
73
100
|
});
|
|
74
101
|
}, delayMs);
|
|
75
|
-
// Never the thing keeping a drained process alive: the hooks stop this loop.
|
|
76
|
-
timer.unref?.();
|
|
77
102
|
};
|
|
78
103
|
|
|
79
104
|
/** The next pass, now — or right behind the one in flight, whose snapshot may predate the news. */
|
|
@@ -84,7 +109,7 @@ export function createClaimLoop(options: ClaimLoopOptions): ClaimLoop {
|
|
|
84
109
|
return;
|
|
85
110
|
}
|
|
86
111
|
if (timer === undefined) return;
|
|
87
|
-
|
|
112
|
+
timers.clear(timer);
|
|
88
113
|
delayMs = 0;
|
|
89
114
|
arm();
|
|
90
115
|
};
|
|
@@ -115,9 +140,9 @@ export function createClaimLoop(options: ClaimLoopOptions): ClaimLoop {
|
|
|
115
140
|
stop() {
|
|
116
141
|
active = false;
|
|
117
142
|
again = false;
|
|
118
|
-
if (timer !== undefined)
|
|
143
|
+
if (timer !== undefined) timers.clear(timer);
|
|
119
144
|
timer = undefined;
|
|
120
|
-
for (const hint of due.values())
|
|
145
|
+
for (const hint of due.values()) timers.clear(hint);
|
|
121
146
|
due.clear();
|
|
122
147
|
unsubscribe?.();
|
|
123
148
|
unsubscribe = undefined;
|
|
@@ -134,11 +159,10 @@ export function createClaimLoop(options: ClaimLoopOptions): ClaimLoop {
|
|
|
134
159
|
const step = Math.max(floor, DUE_MARGIN_MS);
|
|
135
160
|
const bucket = Math.ceil((performance.now() + wait) / step);
|
|
136
161
|
if (due.has(bucket)) return;
|
|
137
|
-
const hint =
|
|
162
|
+
const hint = timers.set(() => {
|
|
138
163
|
due.delete(bucket);
|
|
139
164
|
kick();
|
|
140
165
|
}, wait);
|
|
141
|
-
hint.unref?.();
|
|
142
166
|
due.set(bucket, hint);
|
|
143
167
|
},
|
|
144
168
|
};
|
package/src/worker-options.ts
CHANGED
|
@@ -1,22 +1,19 @@
|
|
|
1
|
-
// Every numeric knob `
|
|
1
|
+
// Every numeric knob `jobWorker` accepts, read and REFUSED in one place — the slot table
|
|
2
2
|
// included, because a queue name is data and a slot count is a bound, and both arrive from the
|
|
3
3
|
// same deployment config.
|
|
4
4
|
//
|
|
5
5
|
// WHY A REFUSAL AND NOT A CLAMP. `Number(process.env.JOB_VISIBILITY_MS)` on an unset variable is
|
|
6
6
|
// `NaN`; `??` guards only nullish, and `Math.max`/`Math.min`/`Math.floor` PROPAGATE it. So the
|
|
7
7
|
// value arrives at a lease deadline, a claim limit and a timer interval intact, and every
|
|
8
|
-
// comparison against it reads FALSE — measured on `
|
|
8
|
+
// comparison against it reads FALSE — measured on `memoryJobDriver`: `visibleAt = at + NaN`,
|
|
9
9
|
// the reclaim scan asks `visibleAt <= at`, and a job whose worker died is never claimable again.
|
|
10
10
|
// At-least-once becomes never, with no error and a row `x jobs ls` still prints as `running`.
|
|
11
11
|
// `slice(0, NaN)` is `[]`, so a `concurrency: NaN` worker claims nothing and reports healthy.
|
|
12
|
-
// Same shape as `
|
|
12
|
+
// Same shape as `concurrencyLimiter`'s `maxTenants` and `backfill()`'s `batch`, refused the same way.
|
|
13
13
|
|
|
14
|
-
import { finiteOption } from '@ultimat3/core';
|
|
14
|
+
import { finiteOption, JOBS_CONCURRENCY_DEFAULT } from '@ultimat3/core';
|
|
15
15
|
import { DEFAULT_VISIBILITY_TIMEOUT_MS } from './driver';
|
|
16
16
|
|
|
17
|
-
/** Slots a queue gets when `concurrency` names no number for it. */
|
|
18
|
-
const DEFAULT_SLOTS = 5;
|
|
19
|
-
|
|
20
17
|
/** The subset of `WorkerOptions` this module reads. Structural, so `WorkerOptions` satisfies it. */
|
|
21
18
|
export interface WorkerNumericOptions {
|
|
22
19
|
readonly concurrency?: number | Readonly<Record<string, number>> | undefined;
|
|
@@ -34,7 +31,10 @@ export interface WorkerTimings {
|
|
|
34
31
|
}
|
|
35
32
|
|
|
36
33
|
/**
|
|
37
|
-
* Slots for one queue
|
|
34
|
+
* Slots for one queue — `JOBS_CONCURRENCY_DEFAULT` (core's, the same number `app.config.ts`'s
|
|
35
|
+
* `jobs.concurrency` defaults to) for a queue `concurrency` names no number for. This package kept
|
|
36
|
+
* its own 5 until 25.0.0, so a table `{ banks: 4 }` left `default` at 5 beside an unset config's 8.
|
|
37
|
+
* A queue NAME is deployment data, so the table is read by OWN keys:
|
|
38
38
|
* `concurrency['constructor']` answers `Object.prototype.constructor`, and
|
|
39
39
|
* `Math.max(0, <function> - inFlight)` is `NaN`, which the `free === 0` guard does not catch.
|
|
40
40
|
* `bun run proto-index` cannot see this one — the table is a parameter, not a literal in a file.
|
|
@@ -43,25 +43,29 @@ const slotTable =
|
|
|
43
43
|
(declared: number | Readonly<Record<string, number>> | undefined) =>
|
|
44
44
|
(queue: string): number => {
|
|
45
45
|
if (typeof declared === 'number') return declared;
|
|
46
|
-
if (declared === undefined || !Object.hasOwn(declared, queue)) return
|
|
47
|
-
return declared[queue] ??
|
|
46
|
+
if (declared === undefined || !Object.hasOwn(declared, queue)) return JOBS_CONCURRENCY_DEFAULT;
|
|
47
|
+
return declared[queue] ?? JOBS_CONCURRENCY_DEFAULT;
|
|
48
48
|
};
|
|
49
49
|
|
|
50
50
|
export function resolveWorkerTimings(options: WorkerNumericOptions): WorkerTimings {
|
|
51
51
|
const visibilityTimeoutMs = options.visibilityTimeoutMs ?? DEFAULT_VISIBILITY_TIMEOUT_MS;
|
|
52
|
-
finiteOption('
|
|
52
|
+
finiteOption('jobWorker', 'visibilityTimeoutMs', visibilityTimeoutMs);
|
|
53
53
|
const pollIntervalMs = options.pollIntervalMs ?? 250;
|
|
54
54
|
// `setTimeout(fn, NaN)` coerces the delay to 0, so the claim loop stops being a poll and becomes
|
|
55
55
|
// a spin: one round trip to Postgres per event-loop turn, from every worker replica.
|
|
56
|
-
finiteOption('
|
|
56
|
+
finiteOption('jobWorker', 'pollIntervalMs', pollIntervalMs);
|
|
57
57
|
const heartbeatIntervalMs = options.heartbeatIntervalMs ?? Math.floor(visibilityTimeoutMs / 3);
|
|
58
|
-
finiteOption('
|
|
58
|
+
finiteOption('jobWorker', 'heartbeatIntervalMs', heartbeatIntervalMs);
|
|
59
59
|
const declared = options.concurrency;
|
|
60
|
-
if (typeof declared === 'number') finiteOption('
|
|
60
|
+
if (typeof declared === 'number') finiteOption('jobWorker', 'concurrency', declared);
|
|
61
61
|
else if (declared !== undefined) {
|
|
62
62
|
// Per queue, by own key: an inherited member is not this table's to answer with either.
|
|
63
63
|
for (const queue of Object.keys(declared)) {
|
|
64
|
-
finiteOption(
|
|
64
|
+
finiteOption(
|
|
65
|
+
'jobWorker',
|
|
66
|
+
`concurrency.${queue}`,
|
|
67
|
+
declared[queue] ?? JOBS_CONCURRENCY_DEFAULT,
|
|
68
|
+
);
|
|
65
69
|
}
|
|
66
70
|
}
|
|
67
71
|
return {
|
package/src/worker-registry.ts
CHANGED
|
@@ -41,7 +41,13 @@ export function startWorkerRegistry(options: WorkerRegistryOptions): WorkerRegis
|
|
|
41
41
|
const startedAt = nowMs(options.clock);
|
|
42
42
|
const host = options.host ?? Bun.env['HOSTNAME'] ?? 'unknown';
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Every announce still on the wire. `stop()` waits them out before it forgets: the row an
|
|
46
|
+
* announce writes after the forget is a stopped worker listed as serving for a whole TTL.
|
|
47
|
+
*/
|
|
48
|
+
const announcing = new Set<Promise<void>>();
|
|
49
|
+
|
|
50
|
+
const send = (): Promise<void> =>
|
|
45
51
|
registry
|
|
46
52
|
.announceWorker(
|
|
47
53
|
{
|
|
@@ -63,6 +69,13 @@ export function startWorkerRegistry(options: WorkerRegistryOptions): WorkerRegis
|
|
|
63
69
|
});
|
|
64
70
|
});
|
|
65
71
|
|
|
72
|
+
const announce = (): Promise<void> => {
|
|
73
|
+
const sent = send().finally(() => announcing.delete(sent));
|
|
74
|
+
announcing.add(sent);
|
|
75
|
+
return sent;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// Not awaited — a worker serves whether or not its row has landed — and held in `announcing`.
|
|
66
79
|
void announce();
|
|
67
80
|
const timer = startRenewalTimer(
|
|
68
81
|
options.intervalMs,
|
|
@@ -75,6 +88,8 @@ export function startWorkerRegistry(options: WorkerRegistryOptions): WorkerRegis
|
|
|
75
88
|
return {
|
|
76
89
|
async stop() {
|
|
77
90
|
timer.stop();
|
|
91
|
+
// `send` never rejects (it logs), so neither does this.
|
|
92
|
+
await Promise.all(announcing);
|
|
78
93
|
await registry.forgetWorker(options.workerId).catch((error: unknown) => {
|
|
79
94
|
logger.warn('jobs.worker.forget-failed', {
|
|
80
95
|
workerId: options.workerId,
|