@ultimat3/jobs 6.0.0 → 8.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 +21 -1
- package/package.json +5 -5
- package/src/driver-pg-jobs-sql.ts +45 -0
- package/src/driver-pg-sql.ts +30 -1
- package/src/driver-pg.ts +15 -23
- package/src/scheduler.ts +9 -4
- package/src/worker-run.ts +36 -26
- package/src/worker.ts +6 -7
package/CLAUDE.md
CHANGED
|
@@ -567,6 +567,25 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
|
|
|
567
567
|
The shed also wrote `last_error = 'limited: …'`, so `x jobs show` reported a failure for a job
|
|
568
568
|
that never ran — it is a `jobs.worker.shed` log field now, and `worker.ts`'s one `shed()` is
|
|
569
569
|
where both sheds go. `driver-parity.test.ts` pins which bucket each lands in, in both drivers.
|
|
570
|
+
- **No read returns a WHOLE row, and `driver-pg-sql.test.ts` is what enforces it** (`As of
|
|
571
|
+
2026-08-22`). `PgExecutor` is an injected seam over any client that speaks `(text, values)`, and a
|
|
572
|
+
client with no type map decodes `timestamptz` as TEXT — so `toJobRecord`/`toStepRecord` read
|
|
573
|
+
`Number('2026-01-01 00:00:00+00')` and answered `NaN`. Six statements were `select *` /
|
|
574
|
+
`returning *` and shipped that way: `pgStepStore.list`, `introspect.job`, `introspect.list`,
|
|
575
|
+
`introspect.deadLetters`, `introspect.requeue` and `SQL_CANCEL`. Every one of them feeds a
|
|
576
|
+
surface an operator reads — `x jobs ls`, `x jobs show`, `x jobs cancel` — and `SQL_CLAIM` had
|
|
577
|
+
projected epoch ms all along, so the driver disagreed with itself. The guard is a scan of every
|
|
578
|
+
production file in this directory, DISCOVERED rather than listed and with comments stripped: a
|
|
579
|
+
`select *` anywhere in them, in either case, is a failing test rather than a review note. It
|
|
580
|
+
reads the directory because the hand-kept list it replaces had already missed one
|
|
581
|
+
(`driver-pg-ddl.ts`) — a registry a new SQL source opts out of by simply not joining it is a
|
|
582
|
+
guard with the shape of a rule and none of the force.
|
|
583
|
+
- **A run's acquisitions are handed back even when the wiring throws.** `worker-run.ts` starts the
|
|
584
|
+
lease heartbeat first, so every line between it and the `try` can leak an interval renewing the
|
|
585
|
+
lease of a job that never ran, with nothing left holding a reference to stop it. `context()` was
|
|
586
|
+
moved ABOVE the heartbeat for that reason; `createRunSignal` and `fleetSlots.startRenewal` — the
|
|
587
|
+
second an injected seam whose production implementation reaches a lease store — are INSIDE the
|
|
588
|
+
`try`, with `undefined` meaning "never taken" and both handbacks idempotent.
|
|
570
589
|
- Step results are persisted BEFORE the step returns. Keep it that way or replay breaks.
|
|
571
590
|
- All time is epoch ms from an injected `Clock`, read via `nowMs()` in `clock.ts`.
|
|
572
591
|
- Drivers implement exactly the six `JobDriver` methods plus optional `introspect`, `backfills`
|
|
@@ -653,8 +672,9 @@ picture from the other side.
|
|
|
653
672
|
| `scheduler-pg.ts` | `pgSchedulerState` (the durable watermark) + `createPgLeaseLeader` |
|
|
654
673
|
| `events-pg.ts` | `createPgEventBus` — `step.waitForEvent` across processes |
|
|
655
674
|
| `driver.ts` | `JobDriver` contract + wire records |
|
|
656
|
-
| `driver-pg.ts` | default driver, real SQL constants, advisory-lock
|
|
675
|
+
| `driver-pg.ts` | default driver, real SQL constants, and `createPgLeader` — the advisory-lock election that is **not** what a scheduler uses; `scheduler-pg.ts` above owns the lease-row one boot wires |
|
|
657
676
|
| `driver-pg-ddl.ts` | `SQL_JOBS_TABLE` + `SQL_OUTBOX_TABLE` — the schema the driver installs. Whichever file holds the DDL is the one whose comments may carry no `;` and no `'` |
|
|
677
|
+
| `driver-pg-jobs-sql.ts` | every statement returning a whole `x_jobs` row, and the `JOB_ROW_COLUMNS` projection they share. Split off at `driver-pg-sql.ts`'s size ceiling and re-exported from it |
|
|
658
678
|
| `driver-pg-rows.ts` | a Postgres row → a wire record: `JobRow`/`StepRow`/`BackfillRow` and their mappings |
|
|
659
679
|
| `driver-memory.ts` | `x dev` / tests |
|
|
660
680
|
| `driver-redis.ts`, `driver-nats.ts` | honest `X_NOT_IMPLEMENTED` stubs |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/jobs",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "8.0.0",
|
|
4
4
|
"description": "Durable background work: steps, transactional outbox, cron tasks, one driver interface",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
"test": "bun test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/entity": "
|
|
37
|
-
"@ultimat3/schema": "
|
|
38
|
-
"@ultimat3/time": "
|
|
35
|
+
"@ultimat3/core": "8.0.0",
|
|
36
|
+
"@ultimat3/entity": "8.0.0",
|
|
37
|
+
"@ultimat3/schema": "8.0.0",
|
|
38
|
+
"@ultimat3/time": "8.0.0"
|
|
39
39
|
}
|
|
40
40
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Every statement returning a WHOLE `x_jobs` row, and the one column list they share. They ask
|
|
2
|
+
// Postgres for epoch ms because `select *` left the decoding to the client's type map: one with
|
|
3
|
+
// none decodes `timestamptz` as TEXT, so `toJobRecord` read `Number('2026-01-01 00:00:00+00')`
|
|
4
|
+
// and `x jobs ls` / `show` / `cancel` printed `NaN` for every timestamp.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `JobRow` shape as a projection. Asking Postgres for epoch ms is what makes the decoding
|
|
8
|
+
* independent of the client's type map — never `select *`, whose correctness is the driver's
|
|
9
|
+
* opinion about `timestamptz` rather than this statement's.
|
|
10
|
+
*/
|
|
11
|
+
export const JOB_ROW_COLUMNS = `id, name, queue, input, idempotency_key, run_id, attempt,
|
|
12
|
+
max_attempts, state, tenant_id, last_error, claimed_by,
|
|
13
|
+
traceparent, enqueued_by,
|
|
14
|
+
(extract(epoch from run_at) * 1000)::bigint as run_at,
|
|
15
|
+
(extract(epoch from visible_at) * 1000)::bigint as visible_at,
|
|
16
|
+
(extract(epoch from created_at) * 1000)::bigint as created_at,
|
|
17
|
+
(extract(epoch from updated_at) * 1000)::bigint as updated_at`;
|
|
18
|
+
|
|
19
|
+
export const SQL_JOB_GET = `
|
|
20
|
+
select ${JOB_ROW_COLUMNS}
|
|
21
|
+
from x_jobs where id = $1
|
|
22
|
+
`.trim();
|
|
23
|
+
|
|
24
|
+
export const SQL_JOB_LIST = `
|
|
25
|
+
select ${JOB_ROW_COLUMNS}
|
|
26
|
+
from x_jobs
|
|
27
|
+
where ($1::text is null or queue = $1)
|
|
28
|
+
and ($2::text is null or name = $2)
|
|
29
|
+
and ($3::text is null or state = $3)
|
|
30
|
+
order by created_at desc
|
|
31
|
+
limit $4
|
|
32
|
+
`.trim();
|
|
33
|
+
|
|
34
|
+
export const SQL_JOB_DEAD_LETTERS = `
|
|
35
|
+
select ${JOB_ROW_COLUMNS}
|
|
36
|
+
from x_jobs where state = 'dead' order by updated_at desc limit $1
|
|
37
|
+
`.trim();
|
|
38
|
+
|
|
39
|
+
/** `run_at = now()` makes the requeued job due immediately; the attempt counter starts over. */
|
|
40
|
+
export const SQL_JOB_REQUEUE = `
|
|
41
|
+
update x_jobs
|
|
42
|
+
set state = 'ready', attempt = 0, run_at = now(), updated_at = now()
|
|
43
|
+
where id = $1
|
|
44
|
+
returning ${JOB_ROW_COLUMNS}
|
|
45
|
+
`.trim();
|
package/src/driver-pg-sql.ts
CHANGED
|
@@ -8,6 +8,19 @@
|
|
|
8
8
|
// point keeps the ONE import path every caller already uses.
|
|
9
9
|
|
|
10
10
|
export { SQL_JOBS_TABLE, SQL_OUTBOX_TABLE } from './driver-pg-ddl';
|
|
11
|
+
// The whole-`x_jobs`-row reads, split off at this file's size ceiling and re-exported for the
|
|
12
|
+
// same reason the DDL is. `JOB_ROW_COLUMNS` is imported as a value because `SQL_CANCEL` also
|
|
13
|
+
// returns a whole row and must project it identically — two spellings of one row shape is how
|
|
14
|
+
// one of them goes back to `returning *`.
|
|
15
|
+
export {
|
|
16
|
+
JOB_ROW_COLUMNS,
|
|
17
|
+
SQL_JOB_DEAD_LETTERS,
|
|
18
|
+
SQL_JOB_GET,
|
|
19
|
+
SQL_JOB_LIST,
|
|
20
|
+
SQL_JOB_REQUEUE,
|
|
21
|
+
} from './driver-pg-jobs-sql';
|
|
22
|
+
|
|
23
|
+
import { JOB_ROW_COLUMNS } from './driver-pg-jobs-sql';
|
|
11
24
|
|
|
12
25
|
export const SQL_ENQUEUE = `
|
|
13
26
|
insert into x_jobs
|
|
@@ -112,7 +125,7 @@ update x_jobs
|
|
|
112
125
|
set state = 'cancelled', visible_at = null, claimed_by = null,
|
|
113
126
|
last_error = coalesce($2, last_error), updated_at = now()
|
|
114
127
|
where id = $1 and state <> 'done'
|
|
115
|
-
returning
|
|
128
|
+
returning ${JOB_ROW_COLUMNS}
|
|
116
129
|
`.trim();
|
|
117
130
|
|
|
118
131
|
/**
|
|
@@ -370,6 +383,22 @@ select run_id, name, status, output, attempts, error,
|
|
|
370
383
|
from x_job_steps where run_id = $1 and name = $2
|
|
371
384
|
`.trim();
|
|
372
385
|
|
|
386
|
+
/**
|
|
387
|
+
* `list`'s projection is `SQL_STEP_GET`'s, minus the name predicate. It exists as its own constant
|
|
388
|
+
* because `select *` was what `list` issued: a client that decodes `timestamptz` as text — most of
|
|
389
|
+
* them, without a type map — handed `toStepRecord` a date string, `Number()` made it `NaN`, and
|
|
390
|
+
* `x jobs show` printed one per step. Every other statement in this file asks Postgres to do the
|
|
391
|
+
* conversion; this is that rule applied to the one statement that had opted out of it.
|
|
392
|
+
*/
|
|
393
|
+
export const SQL_STEP_LIST = `
|
|
394
|
+
select run_id, name, status, output, attempts, error,
|
|
395
|
+
(extract(epoch from started_at) * 1000)::bigint as started_at,
|
|
396
|
+
(extract(epoch from completed_at) * 1000)::bigint as completed_at,
|
|
397
|
+
(extract(epoch from wake_at) * 1000)::bigint as wake_at,
|
|
398
|
+
event, correlation_key
|
|
399
|
+
from x_job_steps where run_id = $1 order by started_at
|
|
400
|
+
`.trim();
|
|
401
|
+
|
|
373
402
|
export const SQL_STEP_PUT = `
|
|
374
403
|
insert into x_job_steps
|
|
375
404
|
(run_id, name, status, output, started_at, completed_at, wake_at, event,
|
package/src/driver-pg.ts
CHANGED
|
@@ -35,12 +35,17 @@ import {
|
|
|
35
35
|
SQL_ENQUEUE,
|
|
36
36
|
SQL_FIND_LIVE_BY_KEY,
|
|
37
37
|
SQL_HEARTBEAT,
|
|
38
|
+
SQL_JOB_DEAD_LETTERS,
|
|
39
|
+
SQL_JOB_GET,
|
|
40
|
+
SQL_JOB_LIST,
|
|
41
|
+
SQL_JOB_REQUEUE,
|
|
38
42
|
SQL_LEASE_ACQUIRE,
|
|
39
43
|
SQL_LEASE_RELEASE,
|
|
40
44
|
SQL_LEASE_RENEW,
|
|
41
45
|
SQL_NACK,
|
|
42
46
|
SQL_STATS,
|
|
43
47
|
SQL_STEP_GET,
|
|
48
|
+
SQL_STEP_LIST,
|
|
44
49
|
SQL_STEP_PUT,
|
|
45
50
|
SQL_TRY_ADVISORY_LOCK,
|
|
46
51
|
} from './driver-pg-sql';
|
|
@@ -102,10 +107,7 @@ function pgStepStore(exec: () => PgExecutor): StepStore {
|
|
|
102
107
|
]);
|
|
103
108
|
},
|
|
104
109
|
async list(runId) {
|
|
105
|
-
const rows = await exec().query<StepRow>(
|
|
106
|
-
`select * from x_job_steps where run_id = $1 order by started_at`,
|
|
107
|
-
[runId],
|
|
108
|
-
);
|
|
110
|
+
const rows = await exec().query<StepRow>(SQL_STEP_LIST, [runId]);
|
|
109
111
|
return rows.map(toStepRecord);
|
|
110
112
|
},
|
|
111
113
|
async del(runId, name) {
|
|
@@ -189,27 +191,21 @@ export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
|
|
|
189
191
|
|
|
190
192
|
const introspect: JobIntrospection = {
|
|
191
193
|
async job(jobId) {
|
|
192
|
-
const rows = await exec().query<JobRow>(
|
|
194
|
+
const rows = await exec().query<JobRow>(SQL_JOB_GET, [jobId]);
|
|
193
195
|
const row = rows[0];
|
|
194
196
|
return row === undefined ? undefined : toJobRecord(row);
|
|
195
197
|
},
|
|
196
198
|
async list(filter: JobFilter = {}) {
|
|
197
|
-
const rows = await exec().query<JobRow>(
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
limit $4`,
|
|
204
|
-
[filter.queue ?? null, filter.name ?? null, filter.state ?? null, filter.limit ?? 100],
|
|
205
|
-
);
|
|
199
|
+
const rows = await exec().query<JobRow>(SQL_JOB_LIST, [
|
|
200
|
+
filter.queue ?? null,
|
|
201
|
+
filter.name ?? null,
|
|
202
|
+
filter.state ?? null,
|
|
203
|
+
filter.limit ?? 100,
|
|
204
|
+
]);
|
|
206
205
|
return rows.map(toJobRecord);
|
|
207
206
|
},
|
|
208
207
|
async deadLetters(limit = 100) {
|
|
209
|
-
const rows = await exec().query<JobRow>(
|
|
210
|
-
`select * from x_jobs where state = 'dead' order by updated_at desc limit $1`,
|
|
211
|
-
[limit],
|
|
212
|
-
);
|
|
208
|
+
const rows = await exec().query<JobRow>(SQL_JOB_DEAD_LETTERS, [limit]);
|
|
213
209
|
return rows.map(toJobRecord);
|
|
214
210
|
},
|
|
215
211
|
async requeue(jobId, requeueOptions) {
|
|
@@ -222,11 +218,7 @@ export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
|
|
|
222
218
|
]);
|
|
223
219
|
}
|
|
224
220
|
}
|
|
225
|
-
const rows = await exec().query<JobRow>(
|
|
226
|
-
`update x_jobs set state = 'ready', attempt = 0, run_at = now(), updated_at = now()
|
|
227
|
-
where id = $1 returning *`,
|
|
228
|
-
[jobId],
|
|
229
|
-
);
|
|
221
|
+
const rows = await exec().query<JobRow>(SQL_JOB_REQUEUE, [jobId]);
|
|
230
222
|
const row = rows[0];
|
|
231
223
|
if (row === undefined) {
|
|
232
224
|
throw new DriverUnavailableError({
|
package/src/scheduler.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
// The `scheduler` role: one node walks every registered task's cron, dispatches the occurrences
|
|
2
2
|
// it owes and enqueues their jobs. The `task` primitive it reads lives in `task.ts`.
|
|
3
3
|
//
|
|
4
|
-
// Exactly one node dispatches per tick, enforced by
|
|
4
|
+
// Exactly one node dispatches per tick, enforced by leader election. Multi-node that is an
|
|
5
|
+
// EXPIRING LEASE ROW in `x_scheduler_leader` (`createPgLeaseLeader`), never an advisory lock: an
|
|
6
|
+
// advisory lock is held by the SESSION, not by this process — it outlives every transaction and is
|
|
7
|
+
// released only by an explicit unlock, the pool's reset on release, or the connection dying, and
|
|
8
|
+
// the next round may run on a different connection. So a node can neither renew it nor prove it
|
|
9
|
+
// still holds one, and leadership passes to a second node while the first is still dispatching. Two schedulers
|
|
5
10
|
// double-enqueue every task; the idempotency key would absorb it, but leader election means
|
|
6
11
|
// the queue never sees the duplicate at all. One ROUND at a time is the same rule inside one
|
|
7
12
|
// process: the loop re-arms on the round it just finished, and any other caller joins that
|
|
@@ -40,7 +45,7 @@ export interface LeaderElection {
|
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
/** Single-node default: always the leader. Multi-node uses `createPgLeaseLeader()` — never
|
|
43
|
-
* `createPgLeader()`, whose advisory lock
|
|
48
|
+
* `createPgLeader()`, whose advisory lock is owned by a pooled session this process cannot name. */
|
|
44
49
|
export function soleLeader(): LeaderElection {
|
|
45
50
|
return {
|
|
46
51
|
acquire: () => Promise.resolve(true),
|
|
@@ -276,10 +281,10 @@ export function createScheduler(options: SchedulerOptions): Scheduler {
|
|
|
276
281
|
timer = undefined;
|
|
277
282
|
logger.info('jobs.scheduler.draining', { reason, dispatching: round !== undefined });
|
|
278
283
|
try {
|
|
279
|
-
// The round this stop races runs to the end first. Releasing the
|
|
284
|
+
// The round this stop races runs to the end first. Releasing the lease under a
|
|
280
285
|
// live dispatch hands the next node a task this one is still enqueueing for, and both
|
|
281
286
|
// then own the same occurrence — the exact double-fire leader election exists to prevent.
|
|
282
|
-
// Settled, not awaited: a round that failed is its own caller's to see, and the
|
|
287
|
+
// Settled, not awaited: a round that failed is its own caller's to see, and the lease still
|
|
283
288
|
// has to go back.
|
|
284
289
|
await Promise.allSettled([round]);
|
|
285
290
|
if (isLeader) await leader.release();
|
package/src/worker-run.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { JobExecution } from './execute';
|
|
|
11
11
|
import { executeJob } from './execute';
|
|
12
12
|
import { startLeaseHeartbeat } from './heartbeat';
|
|
13
13
|
import { getJob } from './job';
|
|
14
|
+
import type { RunSignal } from './run-signal';
|
|
14
15
|
import { createRunSignal } from './run-signal';
|
|
15
16
|
import type { EventLookup } from './steps';
|
|
16
17
|
import type { FleetSlots } from './worker-fleet-slots';
|
|
@@ -77,33 +78,42 @@ export async function runClaimedJob(options: RunClaimedOptions): Promise<JobExec
|
|
|
77
78
|
...(options.clock === undefined ? {} : { clock: options.clock }),
|
|
78
79
|
});
|
|
79
80
|
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
81
|
+
// Everything from here on is INSIDE the `finally`, because the heartbeat is now running.
|
|
82
|
+
// `createRunSignal` and `fleetSlots.startRenewal` are ordinary calls — the second is an injected
|
|
83
|
+
// seam whose production implementation reaches a lease store — and a throw from either used to
|
|
84
|
+
// land outside the `try`, leaving an interval renewing the lease of a job that never ran with
|
|
85
|
+
// nothing holding a reference to stop it. The same defect `context()` was moved ABOVE the
|
|
86
|
+
// heartbeat for; the acquisitions after it need the `finally` rather than a reordering, since
|
|
87
|
+
// each one depends on the last. `undefined` is "never taken", and both handbacks are idempotent.
|
|
88
|
+
let runSignal: RunSignal | undefined;
|
|
89
|
+
let stopSlotRenewal: (() => void) | undefined;
|
|
89
90
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
);
|
|
99
|
-
|
|
91
|
+
try {
|
|
92
|
+
// The caller's context plus this lease's cancellation, so a job cancelled from outside — or
|
|
93
|
+
// one this worker lost the lease on — stops at the next renewal. `steps.ts` refuses every write
|
|
94
|
+
// past the signal, which is what unwinds a body that never reads it. Composed through a
|
|
95
|
+
// controller this worker owns rather than `AbortSignal.any`, for two reasons: it is handed BACK
|
|
96
|
+
// when the run settles (an app whose `context()` carries a process-lifetime signal was
|
|
97
|
+
// accumulating one composite per job), and the worker can abort it itself — which is the only
|
|
98
|
+
// way a fleet slot taken by somebody else reaches the body running under it.
|
|
99
|
+
runSignal = createRunSignal([base.signal, heartbeat.signal]);
|
|
100
|
+
const signal = runSignal;
|
|
101
|
+
const ctx: Ctx = { ...base, signal: signal.signal };
|
|
100
102
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
// The fleet slot this job already holds, kept alive for as long as the lease is and stopped in
|
|
104
|
+
// the same `finally`: one clock for "this worker still owns the job" and "this worker still
|
|
105
|
+
// owns the slot" is one fewer way for them to disagree. A renewal answering "not yours" means
|
|
106
|
+
// another worker is already running this job under a cap of one, so it CANCELS — discarding
|
|
107
|
+
// that boolean made `job.concurrency` a number the framework prints and does not hold.
|
|
108
|
+
stopSlotRenewal = fleetSlots.startRenewal(claimed.id, (slot) => {
|
|
109
|
+
signal.abort(new JobSlotLostError({ job: claimed.name, jobId: claimed.id, slot: slot.slot }));
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// The job's span is a CHILD of the request that queued it when the row carries a trace. That
|
|
113
|
+
// link is what `04-jobs.md` promised and no column existed to hold: without it a checkout's
|
|
114
|
+
// `chargeCard` opens a fresh root two seconds later with nothing pointing back.
|
|
115
|
+
const parent = parseTraceparent(claimed.traceparent);
|
|
105
116
|
|
|
106
|
-
try {
|
|
107
117
|
return await withSpan(
|
|
108
118
|
`job.${handle.name}`,
|
|
109
119
|
() =>
|
|
@@ -126,10 +136,10 @@ export async function runClaimedJob(options: RunClaimedOptions): Promise<JobExec
|
|
|
126
136
|
},
|
|
127
137
|
);
|
|
128
138
|
} finally {
|
|
129
|
-
stopSlotRenewal();
|
|
139
|
+
stopSlotRenewal?.();
|
|
130
140
|
heartbeat.stop();
|
|
131
141
|
// Nothing of the caller's is held past the run: `dispose` is what makes the composition above
|
|
132
142
|
// reversible, and it is the whole reason this is not `AbortSignal.any`.
|
|
133
|
-
runSignal
|
|
143
|
+
runSignal?.dispose();
|
|
134
144
|
}
|
|
135
145
|
}
|
package/src/worker.ts
CHANGED
|
@@ -31,13 +31,12 @@ const QUEUE_DEPTH_INTERVAL_MS = 15_000;
|
|
|
31
31
|
* is deliberately unmapped: parking a run is control flow, so counting it would make every
|
|
32
32
|
* `step.sleep` read as a finished job and make the failure ratio meaningless.
|
|
33
33
|
*/
|
|
34
|
-
const JOB_OUTCOME_LABELS
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
});
|
|
34
|
+
const JOB_OUTCOME_LABELS = Object.freeze<Record<JobOutcome, 'ok' | 'failed' | 'dead' | null>>({
|
|
35
|
+
completed: 'ok',
|
|
36
|
+
suspended: null,
|
|
37
|
+
retried: 'failed',
|
|
38
|
+
'dead-lettered': 'dead',
|
|
39
|
+
});
|
|
41
40
|
|
|
42
41
|
export interface WorkerOptions {
|
|
43
42
|
readonly driver: JobDriver;
|