@ultimat3/jobs 23.0.0 → 24.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 +51 -52
- package/README.md +34 -5
- package/package.json +6 -6
- package/src/backfill-ledger.ts +24 -2
- package/src/backfill-pass.ts +22 -5
- package/src/claim-exhausted.ts +76 -0
- package/src/driver-memory-operator.ts +31 -9
- package/src/driver-memory.ts +68 -23
- package/src/driver-nats.ts +5 -2
- 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-sql.ts +98 -29
- package/src/driver-pg.ts +86 -81
- package/src/driver-redis.ts +5 -2
- package/src/driver.ts +37 -1
- package/src/errors-operator.ts +12 -4
- package/src/errors-requeue.ts +21 -3
- package/src/errors.ts +5 -13
- package/src/events-pg.ts +8 -18
- package/src/events.ts +20 -9
- package/src/execute.ts +1 -0
- package/src/index.ts +10 -4
- package/src/inspect-operator.ts +3 -3
- package/src/inspect.ts +4 -3
- package/src/introspection.ts +23 -11
- package/src/job.ts +5 -6
- package/src/limits.ts +33 -7
- package/src/outbox-pg.ts +1 -2
- package/src/outbox.ts +37 -9
- package/src/purge.ts +1 -1
- package/src/queue-wake.ts +1 -2
- package/src/scheduler-pg.ts +3 -2
- package/src/scheduler-state.ts +8 -1
- package/src/scheduler.ts +9 -10
- package/src/steps-suspension.ts +46 -0
- package/src/steps-timeout.ts +33 -0
- package/src/steps.ts +55 -81
- package/src/worker-admit.ts +7 -3
- package/src/worker-fleet-slots.ts +78 -44
- package/src/worker-key-busy.ts +38 -10
- package/src/worker-loop.ts +35 -11
- package/src/worker-registry.ts +16 -1
- package/src/worker.ts +52 -9
package/src/steps.ts
CHANGED
|
@@ -13,6 +13,15 @@ import type { DurationInput } from './clock';
|
|
|
13
13
|
import { finiteDurationMs, nowMs } from './clock';
|
|
14
14
|
import { JobAbortedError, JobTimeoutError, StepDuplicateError } from './errors';
|
|
15
15
|
import { createRunSignal } from './run-signal';
|
|
16
|
+
import {
|
|
17
|
+
isStepSuspension,
|
|
18
|
+
isWaitTimedOut,
|
|
19
|
+
StepSuspension,
|
|
20
|
+
WAIT_TIMED_OUT,
|
|
21
|
+
} from './steps-suspension';
|
|
22
|
+
import { withStepTimeout } from './steps-timeout';
|
|
23
|
+
|
|
24
|
+
export { isStepSuspension, StepSuspension } from './steps-suspension';
|
|
16
25
|
|
|
17
26
|
/**
|
|
18
27
|
* The runtime list is the declaration and `StepStatus` is derived from it, the shape
|
|
@@ -45,9 +54,23 @@ export interface StepRecord {
|
|
|
45
54
|
readonly error?: string;
|
|
46
55
|
}
|
|
47
56
|
|
|
57
|
+
/** The claim a step write is made under: `claimOf(claimed)`, plus the row it names. */
|
|
58
|
+
export interface StepFence {
|
|
59
|
+
readonly job: string;
|
|
60
|
+
readonly jobId: string;
|
|
61
|
+
readonly workerId: string;
|
|
62
|
+
readonly claim: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
48
65
|
export interface StepStore {
|
|
49
66
|
get(runId: string, name: string): Promise<StepRecord | undefined>;
|
|
50
|
-
|
|
67
|
+
/**
|
|
68
|
+
* With `by`, the write lands only while that claim still holds the row (`running`, same worker,
|
|
69
|
+
* same claim ordinal — `SQL_ACK`'s fence) and is `X_JOB_LEASE_LOST` otherwise: the in-process
|
|
70
|
+
* signal cannot stop a body in ANOTHER process whose lease lapsed. Without it the write is
|
|
71
|
+
* unfenced — a transfer (`x jobs drain`) or a runner driven by hand, with no row to hold.
|
|
72
|
+
*/
|
|
73
|
+
put(record: StepRecord, by?: StepFence): Promise<void>;
|
|
51
74
|
list(runId: string): Promise<readonly StepRecord[]>;
|
|
52
75
|
del(runId: string, name: string): Promise<void>;
|
|
53
76
|
clear(runId: string): Promise<void>;
|
|
@@ -55,6 +78,13 @@ export interface StepStore {
|
|
|
55
78
|
|
|
56
79
|
/** The slice of the event bus a waiting step needs. Implemented by `events.ts`. */
|
|
57
80
|
export interface EventLookup {
|
|
81
|
+
/**
|
|
82
|
+
* The bus's OWN clock, in epoch ms: what a publish issued now would be stamped with. A consumer
|
|
83
|
+
* that wants "published after I asked" takes its "asked at" from here and never from its own
|
|
84
|
+
* process — `publishedAt` is this clock's, and two clocks compared is a skew between pods
|
|
85
|
+
* deciding which answers count. `waitForEvent` stamps a NEW wait with it.
|
|
86
|
+
*/
|
|
87
|
+
now(): Promise<number>;
|
|
58
88
|
find(
|
|
59
89
|
event: string,
|
|
60
90
|
correlationKey: string | undefined,
|
|
@@ -90,53 +120,12 @@ export interface StepApi {
|
|
|
90
120
|
): Promise<T | undefined>;
|
|
91
121
|
}
|
|
92
122
|
|
|
93
|
-
/**
|
|
94
|
-
* Control flow, not a failure: it must never be logged as an error or counted as an attempt.
|
|
95
|
-
* Branded by string rather than `instanceof` so two copies of this module still agree.
|
|
96
|
-
*/
|
|
97
|
-
export class StepSuspension extends Error {
|
|
98
|
-
static readonly brand = 'ultimate.jobs.suspension';
|
|
99
|
-
readonly brand: string = StepSuspension.brand;
|
|
100
|
-
readonly step: string;
|
|
101
|
-
readonly resumeAt: number;
|
|
102
|
-
readonly reason: 'sleep' | 'event';
|
|
103
|
-
|
|
104
|
-
constructor(input: { step: string; resumeAt: number; reason: 'sleep' | 'event' }) {
|
|
105
|
-
super(`step "${input.step}" suspended until ${new Date(input.resumeAt).toISOString()}`);
|
|
106
|
-
this.name = 'StepSuspension';
|
|
107
|
-
this.step = input.step;
|
|
108
|
-
this.resumeAt = input.resumeAt;
|
|
109
|
-
this.reason = input.reason;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* What a timed-out `waitForEvent` persists, and maps back to `undefined` on replay. `undefined`
|
|
115
|
-
* itself cannot survive a store — the pg driver writes `JSON.stringify(output ?? null)` — so the
|
|
116
|
-
* timeout replayed as `null` and `evt === undefined` read as "an event arrived". A namespaced key
|
|
117
|
-
* rather than a bare `{ timedOut: true }`, so no event payload can be mistaken for the marker.
|
|
118
|
-
*/
|
|
119
|
-
const WAIT_TIMED_OUT = Object.freeze<Record<string, true>>({
|
|
120
|
-
'~ultimate.waitTimedOut': true,
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
function isWaitTimedOut(output: unknown): boolean {
|
|
124
|
-
return (
|
|
125
|
-
typeof output === 'object' &&
|
|
126
|
-
output !== null &&
|
|
127
|
-
Object.keys(output).length === 1 &&
|
|
128
|
-
(output as Record<string, unknown>)['~ultimate.waitTimedOut'] === true
|
|
129
|
-
);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
export function isStepSuspension(error: unknown): error is StepSuspension {
|
|
133
|
-
return error instanceof Error && (error as { brand?: unknown }).brand === StepSuspension.brand;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
123
|
export interface StepRunnerOptions {
|
|
137
124
|
readonly runId: string;
|
|
138
125
|
readonly jobName: string;
|
|
139
126
|
readonly store: StepStore;
|
|
127
|
+
/** The claim this attempt runs under — every write is fenced on it. `executeJob` passes it. */
|
|
128
|
+
readonly fence?: StepFence;
|
|
140
129
|
readonly clock?: Clock;
|
|
141
130
|
readonly events?: EventLookup;
|
|
142
131
|
/** How long a waiting step stays parked between event polls. Default 30s. */
|
|
@@ -256,7 +245,7 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
|
|
|
256
245
|
const persist = (record: StepRecord): Promise<void> =>
|
|
257
246
|
expectedQueryLoop(
|
|
258
247
|
'a durable step is written the instant it completes, one statement per step by design',
|
|
259
|
-
() => store.put(record),
|
|
248
|
+
() => store.put(record, options.fence),
|
|
260
249
|
);
|
|
261
250
|
|
|
262
251
|
/**
|
|
@@ -331,8 +320,16 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
|
|
|
331
320
|
attempts,
|
|
332
321
|
error: renderThrowable(error),
|
|
333
322
|
};
|
|
334
|
-
|
|
335
|
-
|
|
323
|
+
try {
|
|
324
|
+
await persist(failure);
|
|
325
|
+
remember(failure);
|
|
326
|
+
} catch (unwritten) {
|
|
327
|
+
logger.warn('jobs.step.failure-unrecorded', {
|
|
328
|
+
job: jobName,
|
|
329
|
+
step: name,
|
|
330
|
+
error: renderThrowable(unwritten),
|
|
331
|
+
});
|
|
332
|
+
}
|
|
336
333
|
}
|
|
337
334
|
throw error;
|
|
338
335
|
} finally {
|
|
@@ -389,7 +386,9 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
|
|
|
389
386
|
}
|
|
390
387
|
|
|
391
388
|
const at = now();
|
|
392
|
-
|
|
389
|
+
// A NEW wait is stamped by the bus, whose clock stamps every `publishedAt` it is compared
|
|
390
|
+
// with; the runner's own only when there is no bus to ask. A re-poll keeps what it persisted.
|
|
391
|
+
const startedAt = existing?.startedAt ?? (await options.events?.now()) ?? at;
|
|
393
392
|
const deadline =
|
|
394
393
|
startedAt +
|
|
395
394
|
finiteDurationMs(waitOptions.timeout ?? 86_400_000, 'step.waitForEvent', 'timeout');
|
|
@@ -411,7 +410,13 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
|
|
|
411
410
|
return hit.payload as T;
|
|
412
411
|
}
|
|
413
412
|
|
|
414
|
-
|
|
413
|
+
// `deadline` is on the BUS's clock, so the bus calls the timeout: asked only once this runner
|
|
414
|
+
// believes the time is up, which costs a wait one statement at its end. A runner AHEAD of the
|
|
415
|
+
// bus used to give up early by the skew — on an answer still inside its window. (A runner
|
|
416
|
+
// BEHIND waits the skew longer; knowing that sooner would cost a round trip on every poll.)
|
|
417
|
+
let remaining = deadline - at;
|
|
418
|
+
if (remaining <= 0) remaining = deadline - ((await options.events?.now()) ?? at);
|
|
419
|
+
if (remaining <= 0) {
|
|
415
420
|
if (waitOptions.required === true) {
|
|
416
421
|
throw new JobTimeoutError({
|
|
417
422
|
job: jobName,
|
|
@@ -437,7 +442,7 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
|
|
|
437
442
|
return undefined;
|
|
438
443
|
}
|
|
439
444
|
|
|
440
|
-
const resumeAt = Math.min(
|
|
445
|
+
const resumeAt = at + Math.min(remaining, pollMs);
|
|
441
446
|
await put({
|
|
442
447
|
runId,
|
|
443
448
|
name,
|
|
@@ -463,34 +468,3 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
|
|
|
463
468
|
replayedNames: () => [...replayed],
|
|
464
469
|
};
|
|
465
470
|
}
|
|
466
|
-
|
|
467
|
-
/**
|
|
468
|
-
* A step's own ceiling. It ABORTS before it rejects, in that order: `run()` fails the step on this
|
|
469
|
-
* rejection and the job retries, so a body still holding a socket open past the deadline would be
|
|
470
|
-
* racing the attempt that replaced it. Cancelling first is the only thing that can stop it.
|
|
471
|
-
*/
|
|
472
|
-
function withStepTimeout<T>(
|
|
473
|
-
work: Promise<T> | T,
|
|
474
|
-
timeoutMs: number | undefined,
|
|
475
|
-
deadline: AbortController,
|
|
476
|
-
error: () => Error,
|
|
477
|
-
): Promise<T> {
|
|
478
|
-
if (timeoutMs === undefined || timeoutMs <= 0) return Promise.resolve(work);
|
|
479
|
-
return new Promise<T>((resolve, reject) => {
|
|
480
|
-
const timer = setTimeout(() => {
|
|
481
|
-
const failure = error();
|
|
482
|
-
deadline.abort(failure);
|
|
483
|
-
reject(failure);
|
|
484
|
-
}, timeoutMs);
|
|
485
|
-
Promise.resolve(work).then(
|
|
486
|
-
(value) => {
|
|
487
|
-
clearTimeout(timer);
|
|
488
|
-
resolve(value);
|
|
489
|
-
},
|
|
490
|
-
(cause) => {
|
|
491
|
-
clearTimeout(timer);
|
|
492
|
-
reject(cause);
|
|
493
|
-
},
|
|
494
|
-
);
|
|
495
|
-
});
|
|
496
|
-
}
|
package/src/worker-admit.ts
CHANGED
|
@@ -95,7 +95,8 @@ export function createAdmission(options: AdmissionOptions): Admit {
|
|
|
95
95
|
try {
|
|
96
96
|
grant = await fleetSlots.acquire(job);
|
|
97
97
|
} catch (error) {
|
|
98
|
-
|
|
98
|
+
// Never started: the slot AND the rate stamp go back (`Lease.abandon`).
|
|
99
|
+
lease.abandon();
|
|
99
100
|
// This job and every one behind it go BACK, unburned — rethrowing alone stranded them in
|
|
100
101
|
// `running` with an attempt spent on work that never started.
|
|
101
102
|
await handBack(driver, [job, ...behind], back);
|
|
@@ -106,7 +107,9 @@ export function createAdmission(options: AdmissionOptions): Admit {
|
|
|
106
107
|
// refusal before the body: it fails as an attempt, never as a round.
|
|
107
108
|
if (grant.outcome === 'undecidable') return { kind: 'run', lease, refusal: grant.error };
|
|
108
109
|
|
|
109
|
-
|
|
110
|
+
// Over the fleet cap, so this run does not start: `abandon`, never `release`, or a tenant
|
|
111
|
+
// waiting behind a full `job.concurrency` pays one `ratePerTenant` start per poll interval.
|
|
112
|
+
lease.abandon();
|
|
110
113
|
try {
|
|
111
114
|
// The two `whenBusy` answers. `'fail'` settles the run here, its body never started.
|
|
112
115
|
if (grant.outcome === 'fail') {
|
|
@@ -119,7 +122,8 @@ export function createAdmission(options: AdmissionOptions): Admit {
|
|
|
119
122
|
context: options.context,
|
|
120
123
|
...(options.clock === undefined ? {} : { clock: options.clock }),
|
|
121
124
|
});
|
|
122
|
-
|
|
125
|
+
// No execution: the nack did not land, so the row is not this worker's to have refused.
|
|
126
|
+
return execution === undefined ? { kind: 'waiting' } : { kind: 'refused', execution };
|
|
123
127
|
}
|
|
124
128
|
const keyed = grant.key === undefined ? '' : `, key ${grant.key}`;
|
|
125
129
|
await shed(job, queue, `job concurrency (${grant.limit}${keyed})`);
|
|
@@ -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
|
/**
|
|
@@ -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,77 @@ 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
|
+
try {
|
|
190
|
+
const renewed = await options.leases.renew(slot, options.ttlMs);
|
|
191
|
+
if (timer.stopped()) return;
|
|
192
|
+
// `=== false`, never `!renewed`: a store written before this answer existed resolves
|
|
193
|
+
// `undefined`, and reading that as a loss would cancel every job on every renewal.
|
|
194
|
+
if (renewed === false) return reportLost('not-ours');
|
|
195
|
+
// A renewal that lands LATE is still late: the slot may already be another worker's.
|
|
196
|
+
if (lapsed()) return reportLost('expired');
|
|
197
|
+
renewedAt = now();
|
|
198
|
+
} catch (error) {
|
|
199
|
+
// One failed renewal is not a lost slot: the TTL gives the interval several tries.
|
|
200
|
+
logger.warn('jobs.worker.slot-renewal-failed', {
|
|
201
|
+
workerId: options.workerId,
|
|
202
|
+
jobId,
|
|
203
|
+
leaseKey: slot.key,
|
|
204
|
+
error: renderThrowable(error),
|
|
205
|
+
});
|
|
206
|
+
if (lapsed()) reportLost('expired', error);
|
|
207
|
+
} finally {
|
|
208
|
+
renewing = false;
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
const timer = startRenewalTimer(options.renewIntervalMs, renew, options.schedule);
|
|
181
212
|
return () => timer.stop();
|
|
182
213
|
},
|
|
183
214
|
|
|
184
215
|
async release(jobId) {
|
|
185
|
-
|
|
216
|
+
// The OLDEST: the run that settles first is, bar a race nobody loses by, the superseded one
|
|
217
|
+
// — and if it is not, the superseded run's next renewal answers `false` and cancels it.
|
|
218
|
+
const [slot, ...rest] = held.get(jobId) ?? [];
|
|
186
219
|
if (slot === undefined) return;
|
|
187
|
-
held.delete(jobId);
|
|
220
|
+
if (rest.length === 0) held.delete(jobId);
|
|
221
|
+
else held.set(jobId, rest);
|
|
188
222
|
// Never lets a settle fail over bookkeeping: an unreleased slot expires on its own TTL.
|
|
189
223
|
await options.leases?.release(slot).catch((error: unknown) => {
|
|
190
224
|
logger.warn('jobs.worker.lease-release-failed', {
|
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-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,
|