@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 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.2.0",
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.2.0",
37
- "@ultimat3/db": "29.2.0",
38
- "@ultimat3/entity": "29.2.0",
39
- "@ultimat3/schema": "29.2.0",
40
- "@ultimat3/time": "29.2.0"
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
- ...worker,
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,
@@ -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
- /** Only `heartbeat` is used — a lease renews itself and settles nothing. */
15
- readonly driver: Pick<JobDriver, 'heartbeat'>;
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
- // Cancel LAST, so the loss is logged and counted before the body it unwinds starts throwing.
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
- reportLost(undefined, 'not-ours');
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
@@ -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
  /**
@@ -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) return { stop: () => Promise.resolve() };
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;