@ultimat3/jobs 29.2.0 → 29.3.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/README.md +30 -1
- package/package.json +6 -6
- package/src/driver-memory-operator.ts +6 -1
- package/src/driver-pg-ddl.ts +6 -0
- package/src/driver-pg-operator-sql.ts +9 -5
- package/src/driver-pg-operator.ts +3 -0
- package/src/heartbeat.ts +65 -5
- package/src/introspection.ts +10 -0
- package/src/worker-registry.ts +18 -1
- package/src/worker-run.ts +25 -1
- package/src/worker.ts +1 -0
package/README.md
CHANGED
|
@@ -1069,6 +1069,28 @@ unlabelled gauges, `job_worker_in_flight` and `job_worker_capacity`, each the SU
|
|
|
1069
1069
|
`ceil((queue_depth + Σ job_worker_in_flight) / job_worker_capacity)`. A fleet-wide ceiling is a
|
|
1070
1070
|
different number: `maxInFlight` × replicas, or `job.concurrency` for one job.
|
|
1071
1071
|
|
|
1072
|
+
**A scaler reading Postgres** (KEDA's `postgresql` trigger) runs the statement below — the
|
|
1073
|
+
package's `scaler-depth-fixture.ts`, proved against a real Postgres (`scaler-depth.job.test.ts`)
|
|
1074
|
+
and pinned to this block (`scaler-depth-readme.test.ts`); KEDA binds
|
|
1075
|
+
nothing, so the queues are inlined as `'{a,b}'::text[]`: running jobs plus due,
|
|
1076
|
+
claimable ones — minus the jobs a **draining** worker still holds. A worker that stops claiming (a
|
|
1077
|
+
retire, a SIGTERM, `stop()`) announces at once with `draining`, and its registry row keeps
|
|
1078
|
+
`draining_at` from that first announce (`workers()` → `drainingAt`). Those jobs are finished by
|
|
1079
|
+
the worker holding them; counted, a deploy scaled the new pods up for the old pods' runs for as
|
|
1080
|
+
long as the longest of them took. A claimer whose row lapsed is counted again — the queue hands the
|
|
1081
|
+
job back. `As of 2026-10-30`.
|
|
1082
|
+
|
|
1083
|
+
```sql
|
|
1084
|
+
select count(*)::int as depth
|
|
1085
|
+
from x_jobs j
|
|
1086
|
+
where j.queue = any('{banks,banks-long}'::text[])
|
|
1087
|
+
and ((j.state = 'running'
|
|
1088
|
+
and not exists (select 1 from x_job_workers w where w.id = j.claimed_by
|
|
1089
|
+
and w.draining_at is not null and w.expires_at > now()))
|
|
1090
|
+
or (j.state in ('ready', 'delayed', 'suspended') and j.run_at <= now()
|
|
1091
|
+
and not exists (select 1 from x_job_pauses p where p.kind = 'queue' and p.name = j.queue)))
|
|
1092
|
+
```
|
|
1093
|
+
|
|
1072
1094
|
### One run per key
|
|
1073
1095
|
|
|
1074
1096
|
A number caps the whole job. `{ key, limit, whenBusy }` caps it **per key** — "at most one run
|
|
@@ -1147,6 +1169,8 @@ driven by the frozen clock. Renewal failures are not swallowed:
|
|
|
1147
1169
|
|---|---|
|
|
1148
1170
|
| a renewal failed, the window still has room | `jobs.heartbeat.failed` (warn) |
|
|
1149
1171
|
| a whole window passed with none landing | `jobs.lease.lost` (error) + `job_leases_lost_total{queue}` |
|
|
1172
|
+
| the row refused the renewal and another worker holds it (or it cannot be read) | `jobs.lease.lost` (error, `reason: not-ours`) + the counter |
|
|
1173
|
+
| the row refused the renewal because it was **cancelled** (`x jobs cancel`, an app's stop) | `jobs.lease.cancelled` (info), the attempt aborted the same way — never counted |
|
|
1150
1174
|
|
|
1151
1175
|
The second one means the queue is free to hand that job to another worker while this one is
|
|
1152
1176
|
still running it — at-least-once turning into twice. Alert on any non-zero rate. The window is
|
|
@@ -1160,6 +1184,11 @@ already **on the wire**, not only for the next one: a clean completion acks the
|
|
|
1160
1184
|
pool was slow enough. The fleet slot's `jobs.worker.slot-lost` had the same shape and the same
|
|
1161
1185
|
fix (`renewal-timer.ts`).
|
|
1162
1186
|
|
|
1187
|
+
Nor for a job being settled (`As of 2026-10-30`): the heartbeat stops when the worker SENDS the
|
|
1188
|
+
`ack` / `nack`, not when `executeJob` returns. In between ran the `onSettled` hook and the step
|
|
1189
|
+
listing, and a renewal there matched no row — `jobs.lease.lost` for a job that had finished, with
|
|
1190
|
+
the hook's `ctx.signal` aborted under it.
|
|
1191
|
+
|
|
1163
1192
|
## The operator surface
|
|
1164
1193
|
|
|
1165
1194
|
Every operator capability is a member of `JobIntrospection` (`driver.introspect`), implemented by
|
|
@@ -1177,7 +1206,7 @@ the pg and the memory driver alike. `x jobs`, `/_x` and a dashboard read nothing
|
|
|
1177
1206
|
| `promote(id)` | a job waiting on its `runAt` — delayed, or backing off — is due now | — |
|
|
1178
1207
|
| `pauseQueue(q)` / `resumeQueue(q)` / `pausedQueues()` | a paused queue is claimed by no worker; enqueues still land | effective at every worker's next claim |
|
|
1179
1208
|
| `pauseTask(t)` / `resumeTask(t)` / `pausedTasks()` | a paused task is not dispatched; on resume its own `catchUp` decides what it missed | — |
|
|
1180
|
-
| `workers()` | every live worker: id, host, started, queues, slots, in-flight job ids, last heartbeat | rows expire one visibility timeout after the last heartbeat; `MAX_WORKER_IN_FLIGHT` = 100 ids |
|
|
1209
|
+
| `workers()` | every live worker: id, host, started, queues, slots, in-flight job ids, last heartbeat, `drainingAt` once it drains | rows expire one visibility timeout after the last heartbeat; `MAX_WORKER_IN_FLIGHT` = 100 ids |
|
|
1181
1210
|
| `counters({ job, sinceMs })` / `counterTotals(sinceMs)` | per-job history: `done`, `retried`, `failed`, `dead`, `durationMs` per bucket | `COUNTER_TIERS`: 1-minute buckets for 24 h, 5-minute for 7 d, 1-hour for 30 d — 4,176 rows per job name at most |
|
|
1182
1211
|
| `rollupCounters()` | fold buckets past their tier into the next; the scheduler leader calls it once a minute | idempotent |
|
|
1183
1212
|
| `recordProgress(id, { workerId, claim }, progress)` | what `run({ progress })` writes | at most one write per `PROGRESS_INTERVAL_MS` (1 s) per run, plus the last before the settle |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/jobs",
|
|
3
|
-
"version": "29.
|
|
3
|
+
"version": "29.3.0",
|
|
4
4
|
"description": "Durable background work: steps, transactional outbox, cron tasks, one driver interface",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -33,10 +33,10 @@
|
|
|
33
33
|
"test": "bun test"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@ultimat3/core": "29.
|
|
37
|
-
"@ultimat3/db": "29.
|
|
38
|
-
"@ultimat3/entity": "29.
|
|
39
|
-
"@ultimat3/schema": "29.
|
|
40
|
-
"@ultimat3/time": "29.
|
|
36
|
+
"@ultimat3/core": "29.3.0",
|
|
37
|
+
"@ultimat3/db": "29.3.0",
|
|
38
|
+
"@ultimat3/entity": "29.3.0",
|
|
39
|
+
"@ultimat3/schema": "29.3.0",
|
|
40
|
+
"@ultimat3/time": "29.3.0"
|
|
41
41
|
}
|
|
42
42
|
}
|
|
@@ -273,8 +273,13 @@ export function memoryOperator(state: MemoryQueueState): MemoryOperator {
|
|
|
273
273
|
|
|
274
274
|
announceWorker(worker, ttlMs) {
|
|
275
275
|
const at = nowMs(clock);
|
|
276
|
+
// The first draining announce stamps it; later ones keep it — `SQL_WORKER_ANNOUNCE`'s rule.
|
|
277
|
+
const drainingAt =
|
|
278
|
+
worker.draining === true ? (workers.get(worker.id)?.drainingAt ?? at) : undefined;
|
|
279
|
+
const { draining: _draining, ...announced } = worker;
|
|
276
280
|
workers.set(worker.id, {
|
|
277
|
-
...
|
|
281
|
+
...announced,
|
|
282
|
+
...(drainingAt === undefined ? {} : { drainingAt }),
|
|
278
283
|
queues: [...worker.queues],
|
|
279
284
|
inFlight: worker.inFlight.slice(0, MAX_WORKER_IN_FLIGHT),
|
|
280
285
|
heartbeatAt: at,
|
package/src/driver-pg-ddl.ts
CHANGED
|
@@ -256,6 +256,12 @@ create table if not exists x_job_workers (
|
|
|
256
256
|
expires_at timestamptz not null
|
|
257
257
|
);
|
|
258
258
|
|
|
259
|
+
-- When the worker began draining (a retire, a SIGTERM, a stop) - set by its first draining
|
|
260
|
+
-- announce and kept by every later one, null while it serves. A queue-depth autoscaler leaves the
|
|
261
|
+
-- jobs such a worker still holds out of its count (SQL_SCALER_DEPTH): its replacement cannot take
|
|
262
|
+
-- them. Added by alter because x_job_workers shipped without it.
|
|
263
|
+
alter table x_job_workers add column if not exists draining_at timestamptz;
|
|
264
|
+
|
|
259
265
|
-- Per-job history, in buckets. A settle adds to the one-minute bucket in the SAME statement that
|
|
260
266
|
-- moves the row, and the scheduler leader folds old buckets into wider ones, so the key count per
|
|
261
267
|
-- job name is fixed whatever the throughput.
|
|
@@ -148,18 +148,21 @@ select name, (extract(epoch from paused_at) * 1000)::bigint as paused_at
|
|
|
148
148
|
|
|
149
149
|
/**
|
|
150
150
|
* A worker's heartbeat: the whole row rewritten, expiry pushed out. $1 id, $2 host, $3 queues,
|
|
151
|
-
* $4 slots, $5 in-flight ids, $6 started (ms), $7 ttl (ms).
|
|
151
|
+
* $4 slots, $5 in-flight ids, $6 started (ms), $7 ttl (ms), $8 draining. `draining_at` is stamped by
|
|
152
|
+
* the first draining announce and KEPT by the later ones — when the drain began, not the last beat.
|
|
152
153
|
*/
|
|
153
154
|
export const SQL_WORKER_ANNOUNCE = `
|
|
154
155
|
insert into x_job_workers
|
|
155
|
-
(id, host, queues, concurrency, in_flight, started_at, heartbeat_at, expires_at)
|
|
156
|
+
(id, host, queues, concurrency, in_flight, started_at, heartbeat_at, expires_at, draining_at)
|
|
156
157
|
values
|
|
157
158
|
($1, $2, $3::jsonb, $4::int, $5::jsonb, to_timestamp($6::bigint / 1000.0), now(),
|
|
158
|
-
now() + ($7::bigint * interval '1 millisecond'))
|
|
159
|
+
now() + ($7::bigint * interval '1 millisecond'), case when $8::boolean then now() end)
|
|
159
160
|
on conflict (id) do update
|
|
160
161
|
set host = excluded.host, queues = excluded.queues, concurrency = excluded.concurrency,
|
|
161
162
|
in_flight = excluded.in_flight, heartbeat_at = excluded.heartbeat_at,
|
|
162
|
-
expires_at = excluded.expires_at
|
|
163
|
+
expires_at = excluded.expires_at,
|
|
164
|
+
draining_at = case when $8::boolean
|
|
165
|
+
then coalesce(x_job_workers.draining_at, excluded.draining_at) end
|
|
163
166
|
`.trim();
|
|
164
167
|
|
|
165
168
|
export const SQL_WORKER_FORGET = `
|
|
@@ -176,7 +179,8 @@ with lapsed as (
|
|
|
176
179
|
)
|
|
177
180
|
select id, host, queues, concurrency, in_flight,
|
|
178
181
|
(extract(epoch from started_at) * 1000)::bigint as started_at,
|
|
179
|
-
(extract(epoch from heartbeat_at) * 1000)::bigint as heartbeat_at
|
|
182
|
+
(extract(epoch from heartbeat_at) * 1000)::bigint as heartbeat_at,
|
|
183
|
+
(extract(epoch from draining_at) * 1000)::bigint as draining_at
|
|
180
184
|
from x_job_workers
|
|
181
185
|
where expires_at > now()
|
|
182
186
|
order by started_at, id
|
|
@@ -251,6 +251,7 @@ export function pgOperator(
|
|
|
251
251
|
JSON.stringify(worker.inFlight.slice(0, MAX_WORKER_IN_FLIGHT)),
|
|
252
252
|
worker.startedAt,
|
|
253
253
|
finiteOption('the pg worker registry', 'ttlMs', ttlMs),
|
|
254
|
+
worker.draining === true,
|
|
254
255
|
]);
|
|
255
256
|
},
|
|
256
257
|
async forgetWorker(workerId) {
|
|
@@ -265,6 +266,7 @@ export function pgOperator(
|
|
|
265
266
|
in_flight: unknown;
|
|
266
267
|
started_at: Count;
|
|
267
268
|
heartbeat_at: Count;
|
|
269
|
+
draining_at: Count | null;
|
|
268
270
|
}>(SQL_WORKERS, []);
|
|
269
271
|
return rows.map((row) => ({
|
|
270
272
|
id: row.id,
|
|
@@ -274,6 +276,7 @@ export function pgOperator(
|
|
|
274
276
|
concurrency: num(row.concurrency),
|
|
275
277
|
inFlight: stringList(row.in_flight),
|
|
276
278
|
heartbeatAt: num(row.heartbeat_at),
|
|
279
|
+
...(row.draining_at === null ? {} : { drainingAt: num(row.draining_at) }),
|
|
277
280
|
}));
|
|
278
281
|
},
|
|
279
282
|
|
package/src/heartbeat.ts
CHANGED
|
@@ -6,13 +6,20 @@
|
|
|
6
6
|
import type { Clock } from '@ultimat3/core';
|
|
7
7
|
import { logger, recordLeaseLost, renderThrowable } from '@ultimat3/core';
|
|
8
8
|
import { nowMs } from './clock';
|
|
9
|
-
import type { ClaimedJob, JobDriver } from './driver';
|
|
9
|
+
import type { ClaimedJob, JobDriver, JobRecord } from './driver';
|
|
10
10
|
import { LeaseLostError } from './errors';
|
|
11
|
+
import type { JobIntrospection } from './introspection';
|
|
11
12
|
import { type IntervalScheduler, startRenewalTimer } from './renewal-timer';
|
|
12
13
|
|
|
13
14
|
export interface LeaseHeartbeatOptions {
|
|
14
|
-
/**
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* `heartbeat` renews; `introspect.job` is read ONLY when a renewal is refused, to tell a cancel
|
|
17
|
+
* (the designed stop) from a lease another worker now holds. A lease renews itself and settles
|
|
18
|
+
* nothing.
|
|
19
|
+
*/
|
|
20
|
+
readonly driver: Pick<JobDriver, 'heartbeat'> & {
|
|
21
|
+
readonly introspect?: Pick<JobIntrospection, 'job'> | undefined;
|
|
22
|
+
};
|
|
16
23
|
readonly claimed: ClaimedJob;
|
|
17
24
|
readonly visibilityTimeoutMs: number;
|
|
18
25
|
readonly intervalMs: number;
|
|
@@ -22,6 +29,9 @@ export interface LeaseHeartbeatOptions {
|
|
|
22
29
|
readonly schedule?: IntervalScheduler;
|
|
23
30
|
}
|
|
24
31
|
|
|
32
|
+
/** The longest a refused renewal waits on the row read that names it a cancel or a loss. */
|
|
33
|
+
export const CLASSIFY_READ_MAX_MS = 5_000;
|
|
34
|
+
|
|
25
35
|
export interface LeaseHeartbeat {
|
|
26
36
|
/** One renewal. The interval drives it; tests drive it instead of the timer. */
|
|
27
37
|
renew(): Promise<void>;
|
|
@@ -70,6 +80,13 @@ export function startLeaseHeartbeat(options: LeaseHeartbeatOptions): LeaseHeartb
|
|
|
70
80
|
if (lost || timer.stopped()) return;
|
|
71
81
|
lost = true;
|
|
72
82
|
timer.stop();
|
|
83
|
+
logLost(error, reason);
|
|
84
|
+
// Cancel LAST, so the loss is logged and counted before the body it unwinds starts throwing.
|
|
85
|
+
gone.abort(new LeaseLostError({ job: claimed.name, jobId: claimed.id }));
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** `jobs.lease.lost` at error, counted: this job may now run twice. */
|
|
89
|
+
const logLost = (error?: unknown, reason?: 'expired' | 'not-ours'): void => {
|
|
73
90
|
logger.error('jobs.lease.lost', {
|
|
74
91
|
workerId,
|
|
75
92
|
job: claimed.name,
|
|
@@ -81,8 +98,51 @@ export function startLeaseHeartbeat(options: LeaseHeartbeatOptions): LeaseHeartb
|
|
|
81
98
|
...(error === undefined ? {} : { error: renderThrowable(error) }),
|
|
82
99
|
});
|
|
83
100
|
recordLeaseLost(claimed.queue);
|
|
84
|
-
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The row said "not yours" — the driver answered, so ownership is GONE whatever the cause: the
|
|
105
|
+
* attempt is aborted now, before anything else is awaited. Only then is the row read once, to say
|
|
106
|
+
* which: a cancel (`x jobs cancel`, an app's own stop) is the designed way a stop reaches a
|
|
107
|
+
* running attempt — `jobs.lease.cancelled` at info, never counted, since `job_leases_lost_total`
|
|
108
|
+
* means "this job may now run twice" and a cancel cannot. Anything else, an unreadable row
|
|
109
|
+
* included, is `jobs.lease.lost`. Read after the abort, a stalled read delays a log line, never
|
|
110
|
+
* the stop (a stalled read used to keep the attempt running until the window lapsed) — and only
|
|
111
|
+
* for one renewal interval (at most `CLASSIFY_READ_MAX_MS`): a read still out then is unreadable,
|
|
112
|
+
* so a real loss is never left uncounted behind a read that never answers.
|
|
113
|
+
*/
|
|
114
|
+
const refused = async (): Promise<void> => {
|
|
115
|
+
if (lost || timer.stopped()) return;
|
|
116
|
+
lost = true;
|
|
117
|
+
timer.stop();
|
|
85
118
|
gone.abort(new LeaseLostError({ job: claimed.name, jobId: claimed.id }));
|
|
119
|
+
const row = await boundedRead();
|
|
120
|
+
if (row?.state !== 'cancelled') {
|
|
121
|
+
logLost(undefined, 'not-ours');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
logger.info('jobs.lease.cancelled', {
|
|
125
|
+
workerId,
|
|
126
|
+
job: claimed.name,
|
|
127
|
+
jobId: claimed.id,
|
|
128
|
+
queue: claimed.queue,
|
|
129
|
+
attempt: claimed.attempt,
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** `introspect.job`, or `undefined` once it rejects or outlasts its bound. */
|
|
134
|
+
const boundedRead = async (): Promise<JobRecord | undefined> => {
|
|
135
|
+
const read = options.driver.introspect?.job(claimed.id);
|
|
136
|
+
if (read === undefined) return undefined;
|
|
137
|
+
let bound: ReturnType<typeof setTimeout> | undefined;
|
|
138
|
+
const outlasted = new Promise<undefined>((resolve) => {
|
|
139
|
+
bound = setTimeout(resolve, Math.min(options.intervalMs, CLASSIFY_READ_MAX_MS), undefined);
|
|
140
|
+
});
|
|
141
|
+
try {
|
|
142
|
+
return await Promise.race([read.catch((): undefined => undefined), outlasted]);
|
|
143
|
+
} finally {
|
|
144
|
+
clearTimeout(bound);
|
|
145
|
+
}
|
|
86
146
|
};
|
|
87
147
|
|
|
88
148
|
const renew = async (): Promise<void> => {
|
|
@@ -121,7 +181,7 @@ export function startLeaseHeartbeat(options: LeaseHeartbeatOptions): LeaseHeartb
|
|
|
121
181
|
// before that — a hand-rolled one, a test double — resolves `undefined`. Reading that as a
|
|
122
182
|
// lost lease would cancel every job on every renewal. Only an explicit "no" is a no.
|
|
123
183
|
if (held === false) {
|
|
124
|
-
|
|
184
|
+
await refused();
|
|
125
185
|
return;
|
|
126
186
|
}
|
|
127
187
|
// Asked again AFTER the call, because a renewal that SUCCEEDS late is still late: an
|
package/src/introspection.ts
CHANGED
|
@@ -139,11 +139,21 @@ export interface WorkerAnnouncement {
|
|
|
139
139
|
readonly concurrency: number;
|
|
140
140
|
/** Job ids it holds right now — the first `MAX_WORKER_IN_FLIGHT` of them. */
|
|
141
141
|
readonly inFlight: readonly string[];
|
|
142
|
+
/**
|
|
143
|
+
* The worker stopped claiming — a retire, a SIGTERM's drain or a `stop()` — and only finishes
|
|
144
|
+
* what it holds. Absent: serving.
|
|
145
|
+
*/
|
|
146
|
+
readonly draining?: boolean;
|
|
142
147
|
}
|
|
143
148
|
|
|
144
149
|
export interface WorkerRecord extends WorkerAnnouncement {
|
|
145
150
|
/** Epoch ms of the announcement that wrote this row. */
|
|
146
151
|
readonly heartbeatAt: number;
|
|
152
|
+
/**
|
|
153
|
+
* Epoch ms of the FIRST announcement that said `draining` — kept by every later one. Absent while
|
|
154
|
+
* the worker serves. What `SQL_SCALER_DEPTH` excludes a worker's held jobs by.
|
|
155
|
+
*/
|
|
156
|
+
readonly drainingAt?: number;
|
|
147
157
|
}
|
|
148
158
|
|
|
149
159
|
/**
|
package/src/worker-registry.ts
CHANGED
|
@@ -29,6 +29,13 @@ export interface WorkerRegistryOptions {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
export interface WorkerRegistration {
|
|
32
|
+
/**
|
|
33
|
+
* The worker stopped claiming and only finishes what it holds: every announce from now on says
|
|
34
|
+
* `draining`, and one goes out NOW rather than at the next heartbeat (a third of the visibility
|
|
35
|
+
* window later) — the row is what a queue-depth autoscaler leaves held jobs out by. Idempotent in
|
|
36
|
+
* effect (the row keeps its first `draining_at`). Never rejects.
|
|
37
|
+
*/
|
|
38
|
+
draining(): Promise<void>;
|
|
32
39
|
/** Stop announcing and hand the row back. Never rejects. */
|
|
33
40
|
stop(): Promise<void>;
|
|
34
41
|
}
|
|
@@ -37,9 +44,13 @@ export interface WorkerRegistration {
|
|
|
37
44
|
export function startWorkerRegistry(options: WorkerRegistryOptions): WorkerRegistration {
|
|
38
45
|
const registry = options.driver.introspect;
|
|
39
46
|
// A driver with no introspection has no registry to appear in.
|
|
40
|
-
if (registry === undefined)
|
|
47
|
+
if (registry === undefined) {
|
|
48
|
+
return { draining: () => Promise.resolve(), stop: () => Promise.resolve() };
|
|
49
|
+
}
|
|
41
50
|
const startedAt = nowMs(options.clock);
|
|
42
51
|
const host = options.host ?? Bun.env['HOSTNAME'] ?? 'unknown';
|
|
52
|
+
/** Sticky for this registration: a worker that started again registers afresh. */
|
|
53
|
+
let draining = false;
|
|
43
54
|
|
|
44
55
|
/**
|
|
45
56
|
* Every announce still on the wire. `stop()` waits them out before it forgets: the row an
|
|
@@ -57,6 +68,7 @@ export function startWorkerRegistry(options: WorkerRegistryOptions): WorkerRegis
|
|
|
57
68
|
queues: options.queues,
|
|
58
69
|
concurrency: options.concurrency,
|
|
59
70
|
inFlight: options.inFlight().slice(0, MAX_WORKER_IN_FLIGHT),
|
|
71
|
+
...(draining ? { draining: true } : {}),
|
|
60
72
|
},
|
|
61
73
|
options.ttlMs,
|
|
62
74
|
)
|
|
@@ -86,6 +98,11 @@ export function startWorkerRegistry(options: WorkerRegistryOptions): WorkerRegis
|
|
|
86
98
|
);
|
|
87
99
|
|
|
88
100
|
return {
|
|
101
|
+
draining() {
|
|
102
|
+
draining = true;
|
|
103
|
+
// Not after `stop()`: the row is being handed back, and an announce landing then relists it.
|
|
104
|
+
return timer.stopped() ? Promise.resolve() : announce();
|
|
105
|
+
},
|
|
89
106
|
async stop() {
|
|
90
107
|
timer.stop();
|
|
91
108
|
// `send` never rejects (it logs), so neither does this.
|
package/src/worker-run.ts
CHANGED
|
@@ -58,6 +58,30 @@ function unknownJob(claimed: ClaimedJob): JobExecution {
|
|
|
58
58
|
};
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The driver `executeJob` settles through, with the lease's heartbeat stopped the moment a settle
|
|
63
|
+
* is SENT. `ack` / `nack` move the row out of `running`, and `executeJob` still has work after
|
|
64
|
+
* them — the `onSettled` hook (an app's settle sink, seconds of its own writes) and the step
|
|
65
|
+
* listing — before the `finally` below stops the heartbeat. A renewal in that window matched no
|
|
66
|
+
* row and was reported as a lease lost under a job that had finished: `jobs.lease.lost` at error,
|
|
67
|
+
* `job_leases_lost_total` counted, and the hook's `ctx.signal` aborted under it. Stopped before the
|
|
68
|
+
* statement, a renewal already on the wire still lands on `heartbeat.ts`'s stopped flag. A settle
|
|
69
|
+
* that rejects leaves the lease to lapse — what `execute.ts` already says it wants then.
|
|
70
|
+
*/
|
|
71
|
+
function settlingStops(driver: JobDriver, stop: () => void): JobDriver {
|
|
72
|
+
return {
|
|
73
|
+
...driver,
|
|
74
|
+
ack: (jobId, by) => {
|
|
75
|
+
stop();
|
|
76
|
+
return driver.ack(jobId, by);
|
|
77
|
+
},
|
|
78
|
+
nack: (jobId, nackOptions) => {
|
|
79
|
+
stop();
|
|
80
|
+
return driver.nack(jobId, nackOptions);
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
61
85
|
/** Run one claimed job under its lease, its slot and its span, and settle it with the driver. */
|
|
62
86
|
export async function runClaimedJob(options: RunClaimedOptions): Promise<JobExecution> {
|
|
63
87
|
const { claimed, driver, fleetSlots, workerId, visibilityTimeoutMs } = options;
|
|
@@ -156,7 +180,7 @@ export async function runClaimedJob(options: RunClaimedOptions): Promise<JobExec
|
|
|
156
180
|
() => {
|
|
157
181
|
started = true;
|
|
158
182
|
return executeJob({
|
|
159
|
-
driver,
|
|
183
|
+
driver: settlingStops(driver, heartbeat.stop),
|
|
160
184
|
claimed,
|
|
161
185
|
handle,
|
|
162
186
|
ctx,
|
package/src/worker.ts
CHANGED
|
@@ -332,6 +332,7 @@ export function jobWorker(options: WorkerOptions): Worker {
|
|
|
332
332
|
if (state === 'stopped') return;
|
|
333
333
|
state = 'draining';
|
|
334
334
|
loop.stop();
|
|
335
|
+
void registration?.draining(); // the row says so NOW: a scaler stops counting what it holds
|
|
335
336
|
if (shutdown === undefined) return;
|
|
336
337
|
budget.bind(shutdown.deadlineAt);
|
|
337
338
|
if (cutoff !== undefined) return;
|