@ultimat3/jobs 9.0.0 → 11.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 +73 -16
- package/README.md +18 -0
- package/package.json +5 -5
- package/src/backfill-errors.ts +0 -8
- package/src/backfill-pass.ts +17 -4
- package/src/drain-wait.ts +50 -0
- package/src/driver-memory.ts +18 -2
- package/src/errors.ts +6 -20
- package/src/events-pg.ts +2 -2
- package/src/execute.ts +15 -5
- package/src/heartbeat.ts +3 -5
- package/src/outbox.ts +3 -3
- package/src/renewal-timer.ts +23 -1
- package/src/scheduler.ts +94 -37
- package/src/steps.ts +17 -6
- package/src/worker-fleet-slots.ts +2 -2
- package/src/worker.ts +85 -21
package/CLAUDE.md
CHANGED
|
@@ -194,12 +194,38 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
|
|
|
194
194
|
- The scheduler's key is occurrence-scoped (`task:occurrenceMs:jobKey`) and `task.enqueue()`'s
|
|
195
195
|
is the job's plain key. Deliberate: the first stops two schedulers double-firing a tick, the
|
|
196
196
|
second is a manual run with no occurrence to scope to.
|
|
197
|
-
- **
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
197
|
+
- **TWO shutdown hooks per worker — one per PHASE — and `stop()` is what hands both back**
|
|
198
|
+
(`As of 2026-08-23`). `accept` is `stopAccepting()`: flip the state, clear the poll timer, return.
|
|
199
|
+
`close` is the teardown: wait out the rounds and the in-flight jobs, close the driver.
|
|
200
|
+
|
|
201
|
+
It was ONE hook, at `accept`, doing all of it — so a single 10-minute job spent the entire drain
|
|
202
|
+
deadline inside the phase whose whole purpose is to be over immediately, and every hook behind it
|
|
203
|
+
was invoked with **0 ms** left: `@ultimat3/http`'s "stop listening" and `listenSyncNode`'s
|
|
204
|
+
`stopAccepting` are both `accept` hooks, so the pod went on serving requests and upgrading
|
|
205
|
+
websockets for the whole of the drain the load balancer had already been told about. Reproduced —
|
|
206
|
+
4 hooks started, none finished.
|
|
207
|
+
|
|
208
|
+
`start()` keeps both unregisters; the teardown releases them in a `finally`, so a close that threw
|
|
209
|
+
still gives them up. Discarding them was a hook per `start()` — the `start()` guard reads a
|
|
210
|
+
standstill, so start -> stop -> start stacked a second registration retaining a stopped worker's
|
|
211
|
+
driver, and the next process-wide drain ran all of them. `start()` refuses while draining for the
|
|
212
|
+
same reason: a claim loop back on a driver the drain is about to close.
|
|
213
|
+
- **A claimed job is counted with core's `beginWork()`, so the DRAIN does the waiting**
|
|
214
|
+
(`As of 2026-08-23`). The wait for in-flight jobs belongs to the phase between `accept` and
|
|
215
|
+
`inflight`, which exists for exactly this and is where `@ultimat3/http` already puts a request —
|
|
216
|
+
not to a hook, where one role's work starves every other role's teardown. `/readyz`'s `inflight`
|
|
217
|
+
becomes truthful on a worker node as a consequence.
|
|
218
|
+
- **The teardown's wait is BOUNDED on the SIGTERM path and unbounded on a manual `stop()`**
|
|
219
|
+
(`As of 2026-08-23`). The `close` hook is handed `ShutdownReason.deadlineAt` and passes it to
|
|
220
|
+
`settleAllBy` (`drain-wait.ts`); a manual `stop()` passes nothing and waits as long as its
|
|
221
|
+
jobs take, because a caller that asked has no budget to spend. Nothing in JS can kill a body that
|
|
222
|
+
ignores `ctx.signal`, so the unbounded version was a teardown that never returned: the driver was
|
|
223
|
+
never closed, `state` never left `'draining'`, and the memoised `stopping` promise every later
|
|
224
|
+
`stop()` joins never settled — `x dev`'s role rollback awaits that promise. Abandoning the wait
|
|
225
|
+
costs a lapsed lease and a job the queue delivers again, which is what at-least-once already
|
|
226
|
+
promises; `jobs.worker.drain-abandoned` names it, with the `configureLifecycle({ deadlineMs })`
|
|
227
|
+
raise as its fix. **A worker always REACHES `'stopped'`**, which is what makes `stop()`'s
|
|
228
|
+
`state === 'stopped'` early return an answer rather than a wedge.
|
|
203
229
|
- **One teardown, joined.** `stop()` shares the in-flight teardown promise, so a SIGTERM landing
|
|
204
230
|
on a manual stop waits out the same in-flight jobs instead of closing the driver underneath
|
|
205
231
|
it. The promise is cleared as it settles, so a worker that started again tears down again
|
|
@@ -223,6 +249,13 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
|
|
|
223
249
|
a climbing `queue_depth` as the only symptoms. The `catch` releases the lease and rethrows; the
|
|
224
250
|
claimed row goes back to the queue by its visibility timeout, as it does for any round that dies.
|
|
225
251
|
|
|
252
|
+
`fleetSlots.release(jobId)` is AWAITED in the settle's `.finally`, never `void`ed: the slot is a
|
|
253
|
+
DELETE in `x_job_leases`, so a fire-and-forget one was still on the wire when the teardown's
|
|
254
|
+
`allSettled` returned and `driver.close()` took the connection out from under it — the row then
|
|
255
|
+
held its slot for a full TTL, and a `concurrency: 1` job was unclaimable by the pod replacing this
|
|
256
|
+
one for a whole visibility window after every deploy. `release` swallows its own failures, so
|
|
257
|
+
awaiting it cannot turn a finished job into a rejected one.
|
|
258
|
+
|
|
226
259
|
`LeaseStore.renew` answering `false` means the row is another holder's — two runs live under a cap
|
|
227
260
|
of one — and it was discarded by `.catch(noop)`. It now stops the timer, logs
|
|
228
261
|
`jobs.worker.slot-lost` and aborts the run through `X_JOB_SLOT_LOST`. Its own code, not
|
|
@@ -316,10 +349,14 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
|
|
|
316
349
|
waits that round out BEFORE `leader.release()` — a lock handed back mid-dispatch promotes a
|
|
317
350
|
standby onto an occurrence this node is still enqueueing for, which is the double-fire leader
|
|
318
351
|
election exists to prevent — and the round re-reads the drain state before each task, so "stop
|
|
319
|
-
dispatching" means this round too. Same hook rules as the worker
|
|
320
|
-
`
|
|
321
|
-
|
|
322
|
-
|
|
352
|
+
dispatching" means this round too. Same hook rules as the worker, `As of 2026-08-23`: **TWO**
|
|
353
|
+
`onShutdown` registrations — `accept` stops dispatching and returns, `close` waits the round out
|
|
354
|
+
and releases the lease under the deadline it is handed (`settleAllBy`) — both handed back in the
|
|
355
|
+
teardown's `finally` so a `release()` that threw still gives them up, `isLeader` cleared there
|
|
356
|
+
too because a lock this process could not hand back is never treated as still held. **An
|
|
357
|
+
ABANDONED round does not release**: it is still enqueueing, so handing the lock over is that same
|
|
358
|
+
double-fire delivered by the shutdown, and a lease row expiring on its own is the safe end —
|
|
359
|
+
`jobs.scheduler.drain-abandoned` is the line that says so.
|
|
323
360
|
- **`backfill()` is a FACTORY over `job()`, never a ninth primitive.** Same rule `llm()` follows
|
|
324
361
|
in `@ultimat3/ai`: a new capability arrives as a factory over an existing primitive, so a
|
|
325
362
|
backfill inherits `.enqueue()`, the retry policy, the cancellation, the dead-letter path and
|
|
@@ -363,6 +400,14 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
|
|
|
363
400
|
the CHANGELOG, `CLAUDE.md` and `x g backfill`'s generated source all say the handler must be
|
|
364
401
|
idempotent. Never invert it: checkpointing first would report a page as swept that nobody wrote,
|
|
365
402
|
and a lost page is unrecoverable where a repeated one is the handler's problem.
|
|
403
|
+
- **A REPLAYED batch writes no ledger row** (`As of 2026-08-23`). `ledger.progress` sits outside
|
|
404
|
+
`step.run` — it has to, the ledger is not transactional with the steps — so a resumed pass
|
|
405
|
+
re-issued one `x_backfills` UPDATE per already-completed batch before it read a single new row:
|
|
406
|
+
4,800 statements on a 5M-row sweep killed at batch 4,800, on every attempt, inside the visibility
|
|
407
|
+
lease the heartbeat is renewing. The flag is set INSIDE the step body, because that is the only
|
|
408
|
+
thing that can tell a replay from a run — `step.run` answers the same shape either way. Nothing is
|
|
409
|
+
lost: the value is ABSOLUTE, so the first batch that does run reports every replayed one behind
|
|
410
|
+
it, and `finish` writes the total regardless.
|
|
366
411
|
- **`x_backfills` is what has already been SWEPT, and the step checkpoints are where a pass
|
|
367
412
|
resumes. Never the other way round.** The ledger row (`backfill-ledger.ts`) is a report an
|
|
368
413
|
operator reads and the record that a completed name is done; the checkpoints are written in step
|
|
@@ -528,7 +573,11 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
|
|
|
528
573
|
branch. `jobs.lease.lost` at error plus `recordLeaseLost(queue)` is the one signal meaning the
|
|
529
574
|
queue re-delivered a job this process was still running, so a false one is a page for a
|
|
530
575
|
non-event, and the window widens exactly when the pool is slow. Re-read the flag AFTER every
|
|
531
|
-
await and inside the reporter, the way `settleWithin`'s `decided` does in core.
|
|
576
|
+
await and inside the reporter, the way `settleWithin`'s `decided` does in core. **The interval is
|
|
577
|
+
`unref`ed**, like all three of `sync-node.ts`'s: it is armed from inside a job run, so a drain
|
|
578
|
+
that ABANDONS the worker's hook leaves the run — and this timer — with nobody left to call
|
|
579
|
+
`stop()`, and a refed interval is then the one thing holding a drained process open until the
|
|
580
|
+
kubelet's SIGKILL.
|
|
532
581
|
- **`stepTimeout` and `eventPoll` are DECLARED on the job, and `execute.ts` is the only place they
|
|
533
582
|
are forwarded** (`As of 2026-08`). `StepRunnerOptions` carried both, `withStepTimeout`
|
|
534
583
|
implemented the ceiling and `steps.test.ts` exercised it by building a runner BY HAND — while the
|
|
@@ -617,11 +666,18 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
|
|
|
617
666
|
- **`x jobs cancel` binds to `cancelJob(driver, id, reason?)`, which REFUSES rather than answering
|
|
618
667
|
a silent no-op.** An operator cancelling a 40M-row sweep has to know whether they stopped it or
|
|
619
668
|
missed it, so a finished job is `X_JOB_NOT_CANCELLABLE` and a driver with no `cancel` is too.
|
|
620
|
-
- **The scheduler asks `leader.acquire()` EVERY round
|
|
621
|
-
leader.** A lease-backed election expires, so `acquire()` is its renewal
|
|
622
|
-
`isLeader = true` would keep dispatching past a lease another node already took.
|
|
623
|
-
|
|
624
|
-
|
|
669
|
+
- **The scheduler asks `leader.acquire()` EVERY round AND before EVERY task in it, not only while
|
|
670
|
+
it thinks it is not the leader.** A lease-backed election expires, so `acquire()` is its renewal
|
|
671
|
+
and a cached `isLeader = true` would keep dispatching past a lease another node already took.
|
|
672
|
+
Per-task as well as per-round `As of 2026-08-23`: a round walks its tasks serially with an enqueue
|
|
673
|
+
per job, so a 30s lease and a slow queue leave the tail of the walk running under a lease node B
|
|
674
|
+
already holds — and **the occurrence key does not absorb that**, because `SQL_ENQUEUE`'s conflict
|
|
675
|
+
target is the PARTIAL index over the live states, so a second dispatch landing after that
|
|
676
|
+
occurrence's job completed or dead-lettered inserts a new row and the handler runs twice. Argued
|
|
677
|
+
from the index definition, not reproduced — `stillLeading()` is the one place the question is
|
|
678
|
+
asked and `break` is the answer, the same shape the drain's `dispatching()` check already had.
|
|
679
|
+
`soleLeader` answers true every time and `createPgLeader` holds its grant behind an internal flag,
|
|
680
|
+
so the extra calls are a no-op for both — the flag also stops Postgres refcounting a second advisory
|
|
625
681
|
grant that `release()`'s single unlock would never hand back.
|
|
626
682
|
- **`createPgLeader` is correct only on a DEDICATED connection and boot has a pool.** A
|
|
627
683
|
session-level `pg_try_advisory_lock` is released when its connection returns to the pool, so
|
|
@@ -707,6 +763,7 @@ picture from the other side.
|
|
|
707
763
|
| `heartbeat.ts` | one claimed job's lease: the renewal interval and the loss it reports |
|
|
708
764
|
| `renewal-timer.ts` | the interval a renewal runs on, and the `stopped()` latch every branch after an await re-reads |
|
|
709
765
|
| `worker.ts` | `worker` role, claim loop, drain |
|
|
766
|
+
| `drain-wait.ts` | the drain's wait, shared by both roles: everything a teardown holds, settled — or abandoned at the budget the `close` hook was handed |
|
|
710
767
|
| `worker-run.ts` | one claimed job, wired: its heartbeat, its slot renewal, its run signal and its span, started together and handed back in one `finally` |
|
|
711
768
|
| `run-signal.ts` | the signal ONE run is cancelled by — composition that can be handed back, and that the worker can abort itself |
|
|
712
769
|
| `worker-fleet-slots.ts` | the fleet slot an in-flight job holds — take, renew, hand back. The claim loop asks "may I start this one?"; this answers it across the fleet |
|
package/README.md
CHANGED
|
@@ -475,6 +475,24 @@ debugging a stuck queue can read and run the exact statement.
|
|
|
475
475
|
| `worker` | `createWorker({ driver, context, queues, concurrency })` | per-queue pools, lease heartbeat, SIGTERM drain: stop claiming → finish in-flight → close |
|
|
476
476
|
| `scheduler` | `createScheduler({ driver, leader, state })` | one dispatch round at a time, catch-up policy, SIGTERM drain: stop dispatching → finish the round → release the lock |
|
|
477
477
|
|
|
478
|
+
Both roles register the same **pair** of hooks and bound the `close` half the same way. The
|
|
479
|
+
scheduler's abandoned case differs in one respect: a round still enqueueing keeps the lease rather
|
|
480
|
+
than handing it back, because promoting a standby onto an occurrence this node is mid-dispatch for
|
|
481
|
+
is the double-fire leader election exists to prevent. The lease row expires on its own.
|
|
482
|
+
|
|
483
|
+
The worker's drain is **two shutdown hooks**, one per phase: `accept` stops claiming and returns
|
|
484
|
+
immediately, `close` waits out the in-flight jobs and closes the driver. A running job is counted
|
|
485
|
+
with core's `beginWork()`, so the wait for it happens in the drain's own in-flight phase rather than
|
|
486
|
+
inside a hook — one hook doing all of it spends the whole `configureLifecycle({ deadlineMs })`
|
|
487
|
+
budget in `accept`, where every other role's "stop taking work" is still queued behind it.
|
|
488
|
+
|
|
489
|
+
**The `close` hook's wait is bounded by that same budget**, because nothing can kill a handler that
|
|
490
|
+
ignores `ctx.signal`: at the deadline the worker logs `jobs.worker.drain-abandoned`, closes the
|
|
491
|
+
driver and reaches `stopped`, and the job's lease lapses so the queue delivers it again — which is
|
|
492
|
+
what at-least-once already promises. Raise the budget past your slowest job rather than relying on
|
|
493
|
+
the wait: `configureLifecycle({ deadlineMs: 600_000 })`, with a `terminationGracePeriodSeconds` at
|
|
494
|
+
least as large. A manual `await worker.stop()` has no budget and waits as long as its jobs take.
|
|
495
|
+
|
|
478
496
|
`driver` and `context` are the two required keys on `WorkerOptions`; everything else defaults.
|
|
479
497
|
`context: () => Ctx` supplies the ambient `Ctx` a job run executes under — the app wires its ALS
|
|
480
498
|
and its tenant there, and a worker with no way to build one would run every handler as nobody.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/jobs",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "11.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": "11.0.0",
|
|
36
|
+
"@ultimat3/entity": "11.0.0",
|
|
37
|
+
"@ultimat3/schema": "11.0.0",
|
|
38
|
+
"@ultimat3/time": "11.0.0"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/src/backfill-errors.ts
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
// `backfill-pending.ts` and `backfill-registry.ts`.
|
|
7
7
|
|
|
8
8
|
import { UltimateError } from '@ultimat3/core';
|
|
9
|
-
import { docsFor } from './errors';
|
|
10
9
|
|
|
11
10
|
/**
|
|
12
11
|
* The seven backfill codes below all answer one question — "why is this sweep not running?" — and
|
|
@@ -31,7 +30,6 @@ export class BackfillPendingError extends UltimateError {
|
|
|
31
30
|
code: 'X_BACKFILL_PENDING',
|
|
32
31
|
cause: `backfill "${input.backfill}" is declared and x_backfills holds no completed pass for it in ${input.environment}`,
|
|
33
32
|
fix: `x db backfill ${input.backfill} --write --json`,
|
|
34
|
-
docs: docsFor('X_BACKFILL_PENDING'),
|
|
35
33
|
});
|
|
36
34
|
}
|
|
37
35
|
}
|
|
@@ -43,7 +41,6 @@ export class BackfillAppliedError extends UltimateError {
|
|
|
43
41
|
code: 'X_BACKFILL_APPLIED',
|
|
44
42
|
cause: `backfill "${input.backfill}" completed as run ${input.runId} at ${input.completedAt}; a forced rerun writes a NEW ledger row and never edits that one`,
|
|
45
43
|
fix: `x db backfill ${input.backfill} --write --force --json`,
|
|
46
|
-
docs: docsFor('X_BACKFILL_APPLIED'),
|
|
47
44
|
});
|
|
48
45
|
}
|
|
49
46
|
}
|
|
@@ -68,7 +65,6 @@ export class BackfillEnvironmentError extends UltimateError {
|
|
|
68
65
|
target === undefined
|
|
69
66
|
? 'x db backfill --pending --json'
|
|
70
67
|
: `ULTIMATE_ENV=${target} x db backfill ${input.backfill} --write --json`,
|
|
71
|
-
docs: docsFor('X_BACKFILL_ENVIRONMENT'),
|
|
72
68
|
});
|
|
73
69
|
}
|
|
74
70
|
}
|
|
@@ -84,7 +80,6 @@ export class BackfillMigrationPendingError extends UltimateError {
|
|
|
84
80
|
code: 'X_BACKFILL_MIGRATION_PENDING',
|
|
85
81
|
cause: `backfill "${input.backfill}" requires migration ${input.migration}, which x_migrations does not record as applied`,
|
|
86
82
|
fix: 'x db migrate --json',
|
|
87
|
-
docs: docsFor('X_BACKFILL_MIGRATION_PENDING'),
|
|
88
83
|
});
|
|
89
84
|
}
|
|
90
85
|
}
|
|
@@ -100,7 +95,6 @@ export class BackfillRunningError extends UltimateError {
|
|
|
100
95
|
code: 'X_BACKFILL_RUNNING',
|
|
101
96
|
cause: `backfill "${input.backfill}" already has a live pass queued as ${input.jobId}, and one name holds one live pass; its step trace names the batch it is on, and a pass that is not advancing is a worker that lost its lease`,
|
|
102
97
|
fix: `x jobs show ${input.jobId} --json`,
|
|
103
|
-
docs: docsFor('X_BACKFILL_RUNNING'),
|
|
104
98
|
});
|
|
105
99
|
}
|
|
106
100
|
}
|
|
@@ -116,7 +110,6 @@ export class BackfillStalledError extends UltimateError {
|
|
|
116
110
|
code: 'X_BACKFILL_STALLED',
|
|
117
111
|
cause: `backfill "${input.backfill}" swept ${input.swept} rows, exhausted its source, and count() still matches ${input.remaining} — a WHERE the sweep narrows and the count does not is what leaves rows behind`,
|
|
118
112
|
fix: `make count() select on exactly what source() selects on in backfill("${input.backfill}")`,
|
|
119
|
-
docs: docsFor('X_BACKFILL_STALLED'),
|
|
120
113
|
});
|
|
121
114
|
}
|
|
122
115
|
}
|
|
@@ -131,7 +124,6 @@ export class BackfillUnknownError extends UltimateError {
|
|
|
131
124
|
? `no backfill named "${input.backfill}" is declared, and this app declares none at all`
|
|
132
125
|
: `no backfill named "${input.backfill}" is declared (declared: ${input.known.join(', ')})`,
|
|
133
126
|
fix: 'x db backfill --pending --json',
|
|
134
|
-
docs: docsFor('X_BACKFILL_UNKNOWN'),
|
|
135
127
|
});
|
|
136
128
|
}
|
|
137
129
|
}
|
package/src/backfill-pass.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// in step with the work and decides where a resumed pass restarts, while the `x_backfills` row is
|
|
14
14
|
// a report an operator reads and the record that a completed name has already been swept.
|
|
15
15
|
|
|
16
|
-
import { appVersion, assert, logger, resolveEnvironment } from '@ultimat3/core';
|
|
16
|
+
import { appVersion, assert, logger, renderThrowable, resolveEnvironment } from '@ultimat3/core';
|
|
17
17
|
import type { BatchIterator } from '@ultimat3/entity';
|
|
18
18
|
import type { BackfillDefinition, BackfillInput, BackfillReport } from './backfill';
|
|
19
19
|
import { BackfillStalledError } from './backfill-errors';
|
|
@@ -101,7 +101,7 @@ async function markFailed(
|
|
|
101
101
|
} catch (error) {
|
|
102
102
|
logger.warn('jobs.backfill.ledger-failed', {
|
|
103
103
|
runId,
|
|
104
|
-
error:
|
|
104
|
+
error: renderThrowable(error),
|
|
105
105
|
});
|
|
106
106
|
}
|
|
107
107
|
}
|
|
@@ -223,6 +223,12 @@ export async function backfillPass<Row>(
|
|
|
223
223
|
try {
|
|
224
224
|
for (let index = 0; ; index += 1) {
|
|
225
225
|
const stepName = `${STEP_PREFIX}${index}`;
|
|
226
|
+
/**
|
|
227
|
+
* Whether this batch's BODY ran, or whether the runner served its checkpoint from
|
|
228
|
+
* storage. Only the body can be asked: `step.run` answers the same shape either way,
|
|
229
|
+
* which is the whole point of a replay — so the flag is set inside it.
|
|
230
|
+
*/
|
|
231
|
+
let swept = false;
|
|
226
232
|
const checkpoint = asCheckpoint(
|
|
227
233
|
await step.run(stepName, async (signal): Promise<Checkpoint> => {
|
|
228
234
|
// INSIDE the body, which is the whole of it: a completed step is served from storage
|
|
@@ -237,6 +243,7 @@ export async function backfillPass<Row>(
|
|
|
237
243
|
const next = await iteration.pull.next();
|
|
238
244
|
if (next.done === true) return { cursor: null, rows: 0 };
|
|
239
245
|
await definition.handle({ rows: next.value, ctx, signal, index });
|
|
246
|
+
swept = true;
|
|
240
247
|
return { cursor: iteration.batches.cursor, rows: next.value.length };
|
|
241
248
|
}),
|
|
242
249
|
stepName,
|
|
@@ -248,8 +255,14 @@ export async function backfillPass<Row>(
|
|
|
248
255
|
// whose last write is `finish` either way.
|
|
249
256
|
if (checkpoint.rows > 0) {
|
|
250
257
|
batches += 1;
|
|
251
|
-
//
|
|
252
|
-
|
|
258
|
+
// Only for a batch this attempt actually SWEPT. A replayed checkpoint runs no body and
|
|
259
|
+
// touches no row, so writing here re-issued one `x_backfills` UPDATE per completed
|
|
260
|
+
// batch before a resumed pass read a single new row — 4,800 statements on a 5M-row
|
|
261
|
+
// sweep killed at batch 4,800, on every attempt, inside the visibility lease the
|
|
262
|
+
// heartbeat is renewing. Nothing is lost by skipping them: the value is ABSOLUTE, so
|
|
263
|
+
// the first batch that does run reports every replayed one behind it, and `finish`
|
|
264
|
+
// writes the total whatever happens.
|
|
265
|
+
if (swept) await ledger?.progress(runId, { rows, cursor });
|
|
253
266
|
}
|
|
254
267
|
if (cursor === null) break;
|
|
255
268
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// The wait a role's teardown makes, under the drain's own budget — the worker's in-flight jobs,
|
|
2
|
+
// the scheduler's dispatch round. One shape for both, because the race closes over nothing either
|
|
3
|
+
// of them holds: the same seam `lifecycle-deadline.ts` takes in core, and for the same reason —
|
|
4
|
+
// an unbounded wait inside a shutdown hook is a process the kubelet ends with SIGKILL.
|
|
5
|
+
|
|
6
|
+
import { systemClock } from '@ultimat3/core';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Everything in `pending`, settled — or abandoned once `deadlineAt` (real monotonic ms, the clock
|
|
10
|
+
* `ShutdownReason.deadlineAt` is measured on) has passed. Answers `true` when everything settled.
|
|
11
|
+
*
|
|
12
|
+
* `deadlineAt` is `undefined` for a MANUAL `stop()`, which waits as long as its work takes: a
|
|
13
|
+
* caller that asked a role to stop has no budget to spend, and closing the queue under a live job
|
|
14
|
+
* — or handing the lease back under a live dispatch — is exactly what draining exists to prevent. The bound belongs to the SIGTERM path, where the
|
|
15
|
+
* budget is real and a handler that ignores `ctx.signal` would otherwise hold the teardown — and
|
|
16
|
+
* with it the memoized `stopping` promise every later `stop()` joins — open forever.
|
|
17
|
+
*
|
|
18
|
+
* `allSettled`, so work that rejected is work that finished: each caller observes its own failures
|
|
19
|
+
* already, and a teardown that rethrew here would skip the close behind it.
|
|
20
|
+
*/
|
|
21
|
+
export async function settleAllBy(
|
|
22
|
+
pending: readonly Promise<unknown>[],
|
|
23
|
+
deadlineAt: number | undefined,
|
|
24
|
+
): Promise<boolean> {
|
|
25
|
+
if (pending.length === 0) return true;
|
|
26
|
+
const settled = Promise.allSettled(pending);
|
|
27
|
+
if (deadlineAt === undefined) {
|
|
28
|
+
await settled;
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
const remainingMs = Math.max(0, deadlineAt - systemClock.monotonic());
|
|
32
|
+
return await new Promise<boolean>((resolve) => {
|
|
33
|
+
let decided = false;
|
|
34
|
+
const timer = setTimeout(() => {
|
|
35
|
+
if (decided) return;
|
|
36
|
+
decided = true;
|
|
37
|
+
resolve(false);
|
|
38
|
+
}, remainingMs);
|
|
39
|
+
// Never the thing keeping a drained process alive — the rule `lifecycle-deadline.ts` states
|
|
40
|
+
// for its own timer. A spent budget still gives the already-settled case its turn, because a
|
|
41
|
+
// resolved promise settles on a microtask and this timer on a macrotask.
|
|
42
|
+
timer.unref?.();
|
|
43
|
+
void settled.then(() => {
|
|
44
|
+
if (decided) return;
|
|
45
|
+
decided = true;
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
resolve(true);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
package/src/driver-memory.ts
CHANGED
|
@@ -83,6 +83,22 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
83
83
|
jobs.set(id, { ...existing, ...patch, updatedAt: nowMs(clock) });
|
|
84
84
|
};
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* `update`, plus the lease columns `SQL_ACK`/`SQL_NACK` set to `null`.
|
|
88
|
+
*
|
|
89
|
+
* A settlement RELEASES the claim, and a `Partial<JobRecord>` cannot say so: both fields are
|
|
90
|
+
* optional, so `visibleAt: undefined` would keep the key and `{ ...existing }` keeps the value.
|
|
91
|
+
* Left stamped, a `done` row still named the worker that finished it and carried that attempt's
|
|
92
|
+
* lease deadline — which `x jobs show` prints, and which is the very pair the claim scan's
|
|
93
|
+
* lease-expiry branch reads to decide a row was abandoned.
|
|
94
|
+
*/
|
|
95
|
+
const settle = (id: string, patch: Partial<JobRecord>): void => {
|
|
96
|
+
const existing = jobs.get(id);
|
|
97
|
+
if (existing === undefined) return;
|
|
98
|
+
const { visibleAt: _visibleAt, claimedBy: _claimedBy, ...released } = existing;
|
|
99
|
+
jobs.set(id, { ...released, ...patch, updatedAt: nowMs(clock) });
|
|
100
|
+
};
|
|
101
|
+
|
|
86
102
|
const introspect: JobIntrospection = {
|
|
87
103
|
job(jobId) {
|
|
88
104
|
return Promise.resolve(jobs.get(jobId));
|
|
@@ -215,7 +231,7 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
215
231
|
// would otherwise overwrite a row it no longer owns.
|
|
216
232
|
ack(jobId: string): Promise<void> {
|
|
217
233
|
if (jobs.get(jobId)?.state !== 'running') return Promise.resolve();
|
|
218
|
-
|
|
234
|
+
settle(jobId, { state: 'done' });
|
|
219
235
|
return Promise.resolve();
|
|
220
236
|
},
|
|
221
237
|
|
|
@@ -241,7 +257,7 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
241
257
|
attempt: counts ? record.attempt : Math.max(0, record.attempt - 1),
|
|
242
258
|
...(nackOptions.error === undefined ? {} : { lastError: nackOptions.error }),
|
|
243
259
|
};
|
|
244
|
-
|
|
260
|
+
settle(jobId, patch);
|
|
245
261
|
return Promise.resolve();
|
|
246
262
|
},
|
|
247
263
|
|
package/src/errors.ts
CHANGED
|
@@ -93,8 +93,12 @@ registerErrorRetry({
|
|
|
93
93
|
X_BACKFILL_APPLIED: 'terminal',
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
// No `docs:` on any class below, here or in `backfill-errors.ts`. `UltimateError` fills it from
|
|
97
|
+
// `describeErrorCode(code).docs`, which is `@ultimat3/core`'s `ERROR_DOCS_URL` — one page for every
|
|
98
|
+
// code, never one per code, because `wiki/` is the framework's only public documentation surface
|
|
99
|
+
// and a code lives there in a TABLE ROW, which has no anchor. The `docsFor` that stood here built
|
|
100
|
+
// `https://ultimate.dev/errors/<code>`, which answered 404, host included, on every job failure
|
|
101
|
+
// this package has ever put in a dead-letter row.
|
|
98
102
|
|
|
99
103
|
/** An enqueue collided with a live job holding the same idempotency key under `onConflict: 'error'`. */
|
|
100
104
|
export class JobDuplicateError extends UltimateError {
|
|
@@ -103,7 +107,6 @@ export class JobDuplicateError extends UltimateError {
|
|
|
103
107
|
code: 'X_JOB_DUPLICATE',
|
|
104
108
|
cause: `job "${input.job}" already queued as ${input.existingId} with idempotencyKey "${input.idempotencyKey}"`,
|
|
105
109
|
fix: 'pass onConflict: "dedupe" to enqueue, or make idempotencyKey narrower',
|
|
106
|
-
docs: docsFor('X_JOB_DUPLICATE'),
|
|
107
110
|
});
|
|
108
111
|
}
|
|
109
112
|
}
|
|
@@ -124,7 +127,6 @@ export class JobNameTakenError extends UltimateError {
|
|
|
124
127
|
code: 'X_JOB_DUPLICATE',
|
|
125
128
|
cause: `two ${input.kind}s claim the name "${input.name}"`,
|
|
126
129
|
fix: `x jobs ls --json names the one already seated; rename the other's export, or its "name:" if it declares one — a ${input.kind} name is a durable queue key and is globally unique`,
|
|
127
|
-
docs: docsFor('X_JOB_DUPLICATE'),
|
|
128
130
|
});
|
|
129
131
|
}
|
|
130
132
|
}
|
|
@@ -151,7 +153,6 @@ export class JobRowStatusUnknownError extends UltimateError {
|
|
|
151
153
|
`${input.table}.${input.column} holds "${input.value}", which this build does not know — ` +
|
|
152
154
|
`it reads ${input.known.join(', ')}`,
|
|
153
155
|
fix: `x jobs show --json # then drain the older workers: a status this build cannot read was almost certainly written by a newer deploy`,
|
|
154
|
-
docs: docsFor('X_JOB_ROW_STATUS_UNKNOWN'),
|
|
155
156
|
});
|
|
156
157
|
}
|
|
157
158
|
}
|
|
@@ -175,7 +176,6 @@ export class ActionJobUnbridgedError extends UltimateError {
|
|
|
175
176
|
code: 'X_ACTION_JOB_UNBRIDGED',
|
|
176
177
|
cause: `export "${input.export}" is the action projection "${input.job}", which is not a job handle and cannot be registered as one`,
|
|
177
178
|
fix: `wrap it: agentJob(${input.export}, { name: '${input.export}', tenant, retry }) from @ultimat3/ai — that composes job() and returns a handle the queue accepts`,
|
|
178
|
-
docs: docsFor('X_ACTION_JOB_UNBRIDGED'),
|
|
179
179
|
});
|
|
180
180
|
}
|
|
181
181
|
}
|
|
@@ -187,7 +187,6 @@ export class StepDuplicateError extends UltimateError {
|
|
|
187
187
|
code: 'X_STEP_DUPLICATE',
|
|
188
188
|
cause: `job "${input.job}" used step name "${input.step}" twice in one run`,
|
|
189
189
|
fix: `rename one of them, e.g. step.run('${input.step}-2', ...) — step names are the replay key`,
|
|
190
|
-
docs: docsFor('X_STEP_DUPLICATE'),
|
|
191
190
|
});
|
|
192
191
|
}
|
|
193
192
|
}
|
|
@@ -201,7 +200,6 @@ export class JobTimeoutError extends UltimateError {
|
|
|
201
200
|
? `job "${input.job}" exceeded its ${input.timeoutMs}ms timeout`
|
|
202
201
|
: `job "${input.job}" step "${input.step}" exceeded its ${input.timeoutMs}ms timeout`,
|
|
203
202
|
fix: `raise timeout on the job definition, or split the work into step.run() calls`,
|
|
204
|
-
docs: docsFor('X_JOB_TIMEOUT'),
|
|
205
203
|
});
|
|
206
204
|
}
|
|
207
205
|
}
|
|
@@ -225,7 +223,6 @@ export class JobAbortedError extends UltimateError {
|
|
|
225
223
|
? `job "${input.job}" was cancelled — this attempt no longer owns the run`
|
|
226
224
|
: `job "${input.job}" was cancelled before step "${input.step}" could be recorded`,
|
|
227
225
|
fix: 'add throwIfAborted(ctx) before expensive work, or pass fetch(url, { signal: ctx.signal }) — the queue re-runs the job, so stop at the deadline instead of running past it',
|
|
228
|
-
docs: docsFor('X_ABORTED'),
|
|
229
226
|
});
|
|
230
227
|
}
|
|
231
228
|
}
|
|
@@ -237,7 +234,6 @@ export class JobMaxAttemptsError extends UltimateError {
|
|
|
237
234
|
code: 'X_JOB_MAX_ATTEMPTS',
|
|
238
235
|
cause: `job "${input.job}" failed ${input.attempts} times, last error: ${input.lastError}`,
|
|
239
236
|
fix: `x jobs retry ${input.jobId}`,
|
|
240
|
-
docs: docsFor('X_JOB_MAX_ATTEMPTS'),
|
|
241
237
|
});
|
|
242
238
|
}
|
|
243
239
|
}
|
|
@@ -248,7 +244,6 @@ export class DriverUnavailableError extends UltimateError {
|
|
|
248
244
|
code: 'X_DRIVER_UNAVAILABLE',
|
|
249
245
|
cause: `jobs driver "${input.driver}" is unavailable: ${input.cause}`,
|
|
250
246
|
fix: input.fix,
|
|
251
|
-
docs: docsFor('X_DRIVER_UNAVAILABLE'),
|
|
252
247
|
});
|
|
253
248
|
}
|
|
254
249
|
}
|
|
@@ -263,7 +258,6 @@ export class IdempotencyRequiredError extends UltimateError {
|
|
|
263
258
|
code: 'X_IDEMPOTENCY_REQUIRED',
|
|
264
259
|
cause: `job "${input.job}" has no idempotencyKey — at-least-once delivery would run it twice`,
|
|
265
260
|
fix: `add idempotencyKey: (input) => \`${input.job}:\${input.id}\` to the job definition`,
|
|
266
|
-
docs: docsFor('X_IDEMPOTENCY_REQUIRED'),
|
|
267
261
|
});
|
|
268
262
|
}
|
|
269
263
|
}
|
|
@@ -290,7 +284,6 @@ export class JobTenantRequiredError extends UltimateError {
|
|
|
290
284
|
// tenant, and the pass opens the cross-tenant scope for exactly that declaration. Half the
|
|
291
285
|
// callers of this code arrive through `backfill()`, which forwards its `tenant` to `job()`.
|
|
292
286
|
fix: `add tenant: (input) => input.orgId to job("${input.job}") — or tenant: 'none', which declares NO org: right for a job that touches no tenant-scoped table, and the spelling a backfill() uses to sweep every tenant`,
|
|
293
|
-
docs: docsFor('X_JOB_TENANT_REQUIRED'),
|
|
294
287
|
});
|
|
295
288
|
}
|
|
296
289
|
}
|
|
@@ -307,7 +300,6 @@ export class LeaseLostError extends UltimateError {
|
|
|
307
300
|
code: 'X_JOB_LEASE_LOST',
|
|
308
301
|
cause: `job "${input.job}" (${input.jobId}) is no longer claimed by this worker — it was cancelled, or its visibility lease lapsed and the queue re-delivered it`,
|
|
309
302
|
fix: `x jobs show ${input.jobId} --json`,
|
|
310
|
-
docs: docsFor('X_JOB_LEASE_LOST'),
|
|
311
303
|
});
|
|
312
304
|
}
|
|
313
305
|
}
|
|
@@ -325,7 +317,6 @@ export class JobSlotLostError extends UltimateError {
|
|
|
325
317
|
code: 'X_JOB_SLOT_LOST',
|
|
326
318
|
cause: `job "${input.job}" (${input.jobId}) no longer holds fleet concurrency slot ${input.slot} — its lease expired and another worker took it`,
|
|
327
319
|
fix: `x jobs show ${input.jobId} --json`,
|
|
328
|
-
docs: docsFor('X_JOB_SLOT_LOST'),
|
|
329
320
|
});
|
|
330
321
|
}
|
|
331
322
|
}
|
|
@@ -344,7 +335,6 @@ export class JobNotCancellableError extends UltimateError {
|
|
|
344
335
|
? `no job ${input.jobId} exists in this queue`
|
|
345
336
|
: `job ${input.jobId} is "${input.state}" and only a job that has not finished can be cancelled`,
|
|
346
337
|
fix: `x jobs ls --state running --json`,
|
|
347
|
-
docs: docsFor('X_JOB_NOT_CANCELLABLE'),
|
|
348
338
|
});
|
|
349
339
|
}
|
|
350
340
|
}
|
|
@@ -356,7 +346,6 @@ export class CancelUnsupportedError extends UltimateError {
|
|
|
356
346
|
code: 'X_JOB_NOT_CANCELLABLE',
|
|
357
347
|
cause: `the "${input.driver}" jobs driver cannot cancel a single job`,
|
|
358
348
|
fix: 'call setJobDriver(createPgDriver()) at boot — only the pg driver implements introspect.cancel — then: x jobs cancel <id> --json',
|
|
359
|
-
docs: docsFor('X_JOB_NOT_CANCELLABLE'),
|
|
360
349
|
});
|
|
361
350
|
}
|
|
362
351
|
}
|
|
@@ -373,7 +362,6 @@ export class ConcurrencyUnenforceableError extends UltimateError {
|
|
|
373
362
|
code: 'X_JOB_CONCURRENCY_UNENFORCEABLE',
|
|
374
363
|
cause: `${input.jobs.join(', ')} declare concurrency and the "${input.driver}" jobs driver has no lease store, so the cap would hold per process and the fleet would run concurrency x replicas`,
|
|
375
364
|
fix: `remove concurrency from job("${input.jobs[0] ?? 'the job'}"), or call setJobDriver(createPgDriver()) at boot — the pg driver is the one with a lease store`,
|
|
376
|
-
docs: docsFor('X_JOB_CONCURRENCY_UNENFORCEABLE'),
|
|
377
365
|
});
|
|
378
366
|
}
|
|
379
367
|
}
|
|
@@ -385,7 +373,6 @@ export class OutboxNoTxError extends UltimateError {
|
|
|
385
373
|
code: 'X_OUTBOX_NO_TX',
|
|
386
374
|
cause: `ctx.jobs.enqueue(${input.job}) ran outside a transaction with outbox: 'required'`,
|
|
387
375
|
fix: 'wrap the call in ctx.tx(async (tx) => ...), or enqueue with { outbox: false }',
|
|
388
|
-
docs: docsFor('X_OUTBOX_NO_TX'),
|
|
389
376
|
});
|
|
390
377
|
}
|
|
391
378
|
}
|
|
@@ -396,7 +383,6 @@ export class JobsNotImplementedError extends UltimateError {
|
|
|
396
383
|
code: 'X_NOT_IMPLEMENTED',
|
|
397
384
|
cause: `${input.feature} is declared but not implemented in @ultimat3/jobs`,
|
|
398
385
|
fix: input.fix,
|
|
399
|
-
docs: docsFor('X_NOT_IMPLEMENTED'),
|
|
400
386
|
});
|
|
401
387
|
}
|
|
402
388
|
}
|
package/src/events-pg.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// resumes at 12:00:30 must still see an event published at 12:00:10.
|
|
8
8
|
|
|
9
9
|
import type { Clock } from '@ultimat3/core';
|
|
10
|
-
import { logger, systemClock, uuid } from '@ultimat3/core';
|
|
10
|
+
import { logger, renderThrowable, systemClock, uuid } from '@ultimat3/core';
|
|
11
11
|
import type { DurationInput } from './clock';
|
|
12
12
|
import { nowMs, toMs } from './clock';
|
|
13
13
|
import type { PgExecutor } from './driver-pg';
|
|
@@ -54,7 +54,7 @@ export function createPgEventBus(options: PgEventBusOptions): EventBus {
|
|
|
54
54
|
void exec.query(SQL_EVENT_PURGE, []).catch((error: unknown) => {
|
|
55
55
|
// Housekeeping never costs a publish: an unpurged row is filtered out of every read.
|
|
56
56
|
logger.warn('jobs.event.purge-failed', {
|
|
57
|
-
error:
|
|
57
|
+
error: renderThrowable(error),
|
|
58
58
|
});
|
|
59
59
|
});
|
|
60
60
|
return 0;
|
package/src/execute.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
anonymousActor,
|
|
11
11
|
isUltimateError,
|
|
12
12
|
logger,
|
|
13
|
+
renderThrowable,
|
|
13
14
|
reportError,
|
|
14
15
|
runWithContext,
|
|
15
16
|
useContext,
|
|
@@ -22,6 +23,7 @@ import { eventBus } from './events';
|
|
|
22
23
|
import type { AnyJobHandle } from './job';
|
|
23
24
|
import type { JobStopReason } from './retry-classification';
|
|
24
25
|
import { nextRetryForError, recordedFailure } from './retry-classification';
|
|
26
|
+
import { createRunSignal } from './run-signal';
|
|
25
27
|
import type { EventLookup, StepRecord } from './steps';
|
|
26
28
|
import { createStepRunner, isStepSuspension } from './steps';
|
|
27
29
|
import { jobRunActor } from './tenant';
|
|
@@ -100,7 +102,14 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
|
|
|
100
102
|
// where every other body does, with no jobs-only parameter to know about. Composed with the
|
|
101
103
|
// caller's signal rather than replacing it: a ctx that was already going away still is.
|
|
102
104
|
const cancel = new AbortController();
|
|
103
|
-
|
|
105
|
+
// `createRunSignal` and never `AbortSignal.any` — the second of the two sites this package's
|
|
106
|
+
// `CLAUDE.md` states the rule as absolute for. In the worker path `callerSignal` is per-run and
|
|
107
|
+
// nothing leaks; on `@ultimat3/testing`'s job-fixture path, which calls `executeJob` directly,
|
|
108
|
+
// the caller's `ctx.signal` may be process-lifetime, and a composite cannot be undone — so every
|
|
109
|
+
// job a fixture ran left a dependent signal on it for the life of the process. Disposed in the
|
|
110
|
+
// `finally` at the bottom of the try, beside `cancel.abort`.
|
|
111
|
+
const runSignal = createRunSignal([callerSignal(options.ctx), cancel.signal]);
|
|
112
|
+
const signal = runSignal.signal;
|
|
104
113
|
const ctx: Ctx = Object.freeze({
|
|
105
114
|
...options.ctx,
|
|
106
115
|
signal,
|
|
@@ -178,7 +187,7 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
|
|
|
178
187
|
});
|
|
179
188
|
}
|
|
180
189
|
|
|
181
|
-
const message =
|
|
190
|
+
const message = renderThrowable(error);
|
|
182
191
|
// The ERROR decides too, not only the attempt count. A `terminal` code — a rotated password,
|
|
183
192
|
// a schema mismatch, a permission denial — fails identically on every remaining attempt, so
|
|
184
193
|
// spending them is a queue slot, a provider bill and, at a site that locks an account after
|
|
@@ -238,6 +247,9 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
|
|
|
238
247
|
// attempt already owns. The runner fences its writes on this signal. A second `abort()` keeps
|
|
239
248
|
// the first reason, so a timed-out run still reports the timeout, not this.
|
|
240
249
|
cancel.abort(new JobAbortedError({ job: handle.name }));
|
|
250
|
+
// AFTER the abort, so the runner's fence still sees it: `dispose` stops following the sources,
|
|
251
|
+
// it never aborts, and the signal keeps whatever state the abort above left it in.
|
|
252
|
+
runSignal.dispose();
|
|
241
253
|
}
|
|
242
254
|
|
|
243
255
|
// Only reachable when the BODY succeeded, and settlement is deliberately outside the catch
|
|
@@ -287,9 +299,7 @@ function raceTimeout(
|
|
|
287
299
|
job,
|
|
288
300
|
timeoutMs,
|
|
289
301
|
ended,
|
|
290
|
-
...(error === undefined
|
|
291
|
-
? {}
|
|
292
|
-
: { error: error instanceof Error ? error.message : String(error) }),
|
|
302
|
+
...(error === undefined ? {} : { error: renderThrowable(error) }),
|
|
293
303
|
});
|
|
294
304
|
};
|
|
295
305
|
work.then(
|
package/src/heartbeat.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// hardest bug in a queue to see from the outside and the easiest to name from in here.
|
|
5
5
|
|
|
6
6
|
import type { Clock } from '@ultimat3/core';
|
|
7
|
-
import { logger, recordLeaseLost } from '@ultimat3/core';
|
|
7
|
+
import { logger, recordLeaseLost, renderThrowable } from '@ultimat3/core';
|
|
8
8
|
import { nowMs } from './clock';
|
|
9
9
|
import type { ClaimedJob, JobDriver } from './driver';
|
|
10
10
|
import { LeaseLostError } from './errors';
|
|
@@ -76,9 +76,7 @@ export function startLeaseHeartbeat(options: LeaseHeartbeatOptions): LeaseHeartb
|
|
|
76
76
|
attempt: claimed.attempt,
|
|
77
77
|
visibilityTimeoutMs,
|
|
78
78
|
reason: reason ?? 'expired',
|
|
79
|
-
...(error === undefined
|
|
80
|
-
? {}
|
|
81
|
-
: { error: error instanceof Error ? error.message : String(error) }),
|
|
79
|
+
...(error === undefined ? {} : { error: renderThrowable(error) }),
|
|
82
80
|
});
|
|
83
81
|
recordLeaseLost(claimed.queue);
|
|
84
82
|
// Cancel LAST, so the loss is logged and counted before the body it unwinds starts throwing.
|
|
@@ -134,7 +132,7 @@ export function startLeaseHeartbeat(options: LeaseHeartbeatOptions): LeaseHeartb
|
|
|
134
132
|
job: claimed.name,
|
|
135
133
|
jobId: claimed.id,
|
|
136
134
|
attempt: claimed.attempt,
|
|
137
|
-
error:
|
|
135
|
+
error: renderThrowable(error),
|
|
138
136
|
});
|
|
139
137
|
if (lapsed()) reportLost(error);
|
|
140
138
|
} finally {
|
package/src/outbox.ts
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
// test and `x dev` must enqueue with nothing wired — but it is a fallback, not the guarantee.
|
|
25
25
|
|
|
26
26
|
import type { Clock } from '@ultimat3/core';
|
|
27
|
-
import { currentSpanContext, logger, traceparent, uuid } from '@ultimat3/core';
|
|
27
|
+
import { currentSpanContext, logger, renderThrowable, traceparent, uuid } from '@ultimat3/core';
|
|
28
28
|
import type { Tx } from '@ultimat3/entity';
|
|
29
29
|
import { nowMs } from './clock';
|
|
30
30
|
import type { EnqueueResult, JobDriver } from './driver';
|
|
@@ -421,7 +421,7 @@ export function createOutboxRelay(options: RelayOptions): OutboxRelay {
|
|
|
421
421
|
id: record.id,
|
|
422
422
|
published,
|
|
423
423
|
remaining: batch.length - published,
|
|
424
|
-
error:
|
|
424
|
+
error: renderThrowable(error),
|
|
425
425
|
});
|
|
426
426
|
// Hand the rest of the batch back rather than sit on a claim nobody is publishing. The
|
|
427
427
|
// claim is a lease now, so without this a single pool timeout parks every committed row
|
|
@@ -456,7 +456,7 @@ export function createOutboxRelay(options: RelayOptions): OutboxRelay {
|
|
|
456
456
|
.then((): void => undefined)
|
|
457
457
|
.catch((error: unknown) => {
|
|
458
458
|
logger.error('jobs.outbox.tick-failed', {
|
|
459
|
-
error:
|
|
459
|
+
error: renderThrowable(error),
|
|
460
460
|
});
|
|
461
461
|
})
|
|
462
462
|
.finally(() => {
|
package/src/renewal-timer.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// the interval, which does nothing to the request already on the wire. So a flag is what every
|
|
5
5
|
// branch after an `await` re-reads — the shape `settleWithin`'s `decided` uses in core.
|
|
6
6
|
|
|
7
|
+
import { logger, renderThrowable } from '@ultimat3/core';
|
|
8
|
+
|
|
7
9
|
export interface RenewalTimer {
|
|
8
10
|
/**
|
|
9
11
|
* True once `stop()` has been called. Read AFTER every await in the renewal body: a clean
|
|
@@ -22,8 +24,28 @@ export function startRenewalTimer(
|
|
|
22
24
|
): RenewalTimer {
|
|
23
25
|
let stopped = false;
|
|
24
26
|
const timer = setInterval(() => {
|
|
25
|
-
void renew()
|
|
27
|
+
// `Promise.resolve().then(renew)` and never `void renew()`: a `renew` that throws SYNCHRONOUSLY
|
|
28
|
+
// escapes before any `.catch` its body chained exists. `worker-fleet-slots.ts` guards the
|
|
29
|
+
// promise chain and cannot guard this — `LeaseStore.renew` is an injected seam, and a store
|
|
30
|
+
// that throws on a closed pool throws on the call, not in the chain. Nothing sits above a
|
|
31
|
+
// `setInterval` callback, so that throw is an uncaught exception in the timer that was going
|
|
32
|
+
// to keep the lease alive. The shape `outbox.ts`'s tick loop already uses.
|
|
33
|
+
void Promise.resolve()
|
|
34
|
+
.then(renew)
|
|
35
|
+
.catch((error: unknown) => {
|
|
36
|
+
// A renewal that FAILS is each caller's own business and both handle it. Reaching here
|
|
37
|
+
// means the seam broke its contract, which is a different fact and is worth its own line —
|
|
38
|
+
// swallowed, it would be a lease that stops renewing with nothing anywhere saying so.
|
|
39
|
+
logger.error('jobs.renewal.raised', { error: renderThrowable(error) });
|
|
40
|
+
});
|
|
26
41
|
}, intervalMs);
|
|
42
|
+
// Never the thing keeping a drained process alive. This interval is armed from inside a job run,
|
|
43
|
+
// so a drain that ABANDONS the worker's hook leaves the run — and this timer — with nobody left
|
|
44
|
+
// to call `stop()`: refed, it holds the event loop open past every phase of the shutdown, and
|
|
45
|
+
// the kubelet's SIGKILL becomes the exit. `sync-node.ts` unrefs all three of its timers and
|
|
46
|
+
// `lifecycle-deadline.ts` its own for the same reason. A renewal is bookkeeping for work that is
|
|
47
|
+
// already over by then; nothing is lost by letting the process go.
|
|
48
|
+
timer.unref?.();
|
|
27
49
|
return {
|
|
28
50
|
stopped: () => stopped,
|
|
29
51
|
stop(): void {
|
package/src/scheduler.ts
CHANGED
|
@@ -6,16 +6,25 @@
|
|
|
6
6
|
// advisory lock is held by the SESSION, not by this process — it outlives every transaction and is
|
|
7
7
|
// released only by an explicit unlock, the pool's reset on release, or the connection dying, and
|
|
8
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.
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
9
|
+
// still holds one, and leadership passes to a second node while the first is still dispatching.
|
|
10
|
+
//
|
|
11
|
+
// **The occurrence key is not a second line of defence, and the lease is therefore re-asserted
|
|
12
|
+
// before EVERY task, not once per round.** `SQL_ENQUEUE`'s conflict target is the PARTIAL index
|
|
13
|
+
// over the live states (`ready`, `delayed`, `running`, `suspended`), so a duplicate enqueue is
|
|
14
|
+
// absorbed only while the first job is still one of those: a second dispatcher landing after that
|
|
15
|
+
// occurrence's job completed, dead-lettered or was retried past its row inserts a NEW row, and the
|
|
16
|
+
// handler runs twice. A round walks its tasks serially with an enqueue per job, so a 30s lease and
|
|
17
|
+
// a slow queue leave the tail of the walk running under a lease another node already took. Argued
|
|
18
|
+
// from the index definition, not reproduced — say it that way, as `outbox.ts` does for the same
|
|
19
|
+
// mechanism. One ROUND at a time is the same rule inside one process: the loop re-arms on the
|
|
20
|
+
// round it just finished, and any other caller joins that round rather than opening a second one
|
|
21
|
+
// over the same `lastFiredAt`.
|
|
14
22
|
|
|
15
23
|
import type { Clock } from '@ultimat3/core';
|
|
16
|
-
import { isUltimateError, logger, onShutdown } from '@ultimat3/core';
|
|
24
|
+
import { isUltimateError, logger, onShutdown, renderThrowable } from '@ultimat3/core';
|
|
17
25
|
import { instant, nextCronOccurrence } from '@ultimat3/time';
|
|
18
26
|
import { nowMs } from './clock';
|
|
27
|
+
import { settleAllBy } from './drain-wait';
|
|
19
28
|
import type { JobDriver } from './driver';
|
|
20
29
|
import type { TaskHandle, TaskJobResult } from './task';
|
|
21
30
|
import { registeredTasks } from './task';
|
|
@@ -26,7 +35,7 @@ import { registeredTasks } from './task';
|
|
|
26
35
|
* stable code to search on and the `fix:` to run, not a sentence.
|
|
27
36
|
*/
|
|
28
37
|
function failureFields(error: unknown): Record<string, unknown> {
|
|
29
|
-
const message =
|
|
38
|
+
const message = renderThrowable(error);
|
|
30
39
|
return isUltimateError(error)
|
|
31
40
|
? { error: message, code: error.code, cause: error.cause, fix: error.fix }
|
|
32
41
|
: { error: message };
|
|
@@ -114,8 +123,11 @@ export function createScheduler(options: SchedulerOptions): Scheduler {
|
|
|
114
123
|
let state: 'idle' | 'running' | 'draining' | 'stopped' = 'idle';
|
|
115
124
|
/** The dispatch round in flight — what a second caller joins and what `stop()` waits out. */
|
|
116
125
|
let round: Promise<readonly DispatchedOccurrence[]> | undefined;
|
|
117
|
-
/**
|
|
118
|
-
|
|
126
|
+
/**
|
|
127
|
+
* The two `onShutdown` registrations this scheduler holds while it runs, both handed back by
|
|
128
|
+
* `stop()`. Two, for the phases they answer — the worker's rule, and `listenSyncNode`'s.
|
|
129
|
+
*/
|
|
130
|
+
let releaseShutdownHooks: (() => void)[] = [];
|
|
119
131
|
/** The teardown in flight, so a SIGTERM landing on a manual stop joins it. */
|
|
120
132
|
let stopping: Promise<void> | undefined;
|
|
121
133
|
|
|
@@ -176,24 +188,31 @@ export function createScheduler(options: SchedulerOptions): Scheduler {
|
|
|
176
188
|
/** The drain's one question: may this scheduler still dispatch an occurrence? */
|
|
177
189
|
const dispatching = (): boolean => state !== 'draining' && state !== 'stopped';
|
|
178
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Asked EVERY round AND before every task in it, never only while `isLeader` is false. A
|
|
193
|
+
* lease-backed election (the one a pooled executor can use — `createPgLeaseLeader`) expires on a
|
|
194
|
+
* wall clock, so `acquire()` is also its renewal and a node that cached `isLeader = true` keeps
|
|
195
|
+
* dispatching past a lease another node has already taken. `soleLeader` answers true every time
|
|
196
|
+
* and `createPgLeader` holds its grant behind an internal flag, so the extra calls are a no-op
|
|
197
|
+
* for both.
|
|
198
|
+
*/
|
|
199
|
+
const stillLeading = async (): Promise<boolean> => {
|
|
200
|
+
if (await leader.acquire()) {
|
|
201
|
+
isLeader = true;
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
// Demoted, or never elected. Nothing to release — a lease we no longer hold is not ours to
|
|
205
|
+
// hand back, and `teardown` reads this same flag before it calls `release()`.
|
|
206
|
+
if (isLeader) logger.warn('jobs.scheduler.leadership-lost', { at: nowMs(options.clock) });
|
|
207
|
+
isLeader = false;
|
|
208
|
+
return false;
|
|
209
|
+
};
|
|
210
|
+
|
|
179
211
|
const runRound = async (): Promise<readonly DispatchedOccurrence[]> => {
|
|
180
212
|
// Never take the lock a drain is on its way to releasing: a round that acquired it here
|
|
181
213
|
// would still be enqueueing after `stop()` handed the occurrence to the next node.
|
|
182
214
|
if (!dispatching()) return [];
|
|
183
|
-
|
|
184
|
-
// pooled executor can use — `createPgLeaseLeader`) expires, so `acquire()` is also its
|
|
185
|
-
// renewal, and a node that cached `isLeader = true` would keep dispatching past a lease
|
|
186
|
-
// another node has already taken. `soleLeader` answers true every time, and `createPgLeader`
|
|
187
|
-
// holds its grant internally, so this is a no-op for both.
|
|
188
|
-
const held = await leader.acquire();
|
|
189
|
-
if (!held) {
|
|
190
|
-
// Demoted, or never elected. Nothing to release — a lease we no longer hold is not ours to
|
|
191
|
-
// hand back, and `teardown` reads this same flag before it calls `release()`.
|
|
192
|
-
if (isLeader) logger.warn('jobs.scheduler.leadership-lost', { at: nowMs(options.clock) });
|
|
193
|
-
isLeader = false;
|
|
194
|
-
return [];
|
|
195
|
-
}
|
|
196
|
-
isLeader = true;
|
|
215
|
+
if (!(await stillLeading())) return [];
|
|
197
216
|
|
|
198
217
|
const at = nowMs(options.clock);
|
|
199
218
|
const tasks = options.tasks ?? registeredTasks();
|
|
@@ -204,6 +223,10 @@ export function createScheduler(options: SchedulerOptions): Scheduler {
|
|
|
204
223
|
// at the next round. A task not reached simply fires next time — its `lastFiredAt` is
|
|
205
224
|
// untouched — while the occurrence this round already began is the one `stop()` waits for.
|
|
206
225
|
if (!dispatching()) break;
|
|
226
|
+
// The lease, on the same rule and for the same reason the drain state is re-read: it expires
|
|
227
|
+
// on a wall clock in the middle of this walk, not between rounds. See the file header for
|
|
228
|
+
// what a second dispatcher costs — the occurrence key does NOT absorb it in general.
|
|
229
|
+
if (!(await stillLeading())) break;
|
|
207
230
|
const last = await schedulerState.lastFiredAt(handle.name);
|
|
208
231
|
if (last === undefined) {
|
|
209
232
|
// First sight of this task: arm it, never fire retroactively for all of history.
|
|
@@ -275,10 +298,20 @@ export function createScheduler(options: SchedulerOptions): Scheduler {
|
|
|
275
298
|
}, tickIntervalMs);
|
|
276
299
|
};
|
|
277
300
|
|
|
278
|
-
|
|
301
|
+
/**
|
|
302
|
+
* The whole of the `accept` phase: stop dispatching, and nothing else. Synchronous on purpose —
|
|
303
|
+
* the hook behind this one is somebody else's "stop taking work", and a phase that waits is a
|
|
304
|
+
* phase that spends the budget those hooks were going to need.
|
|
305
|
+
*/
|
|
306
|
+
const stopDispatching = (): void => {
|
|
307
|
+
if (state === 'stopped') return;
|
|
279
308
|
state = 'draining';
|
|
280
309
|
if (timer !== undefined) clearTimeout(timer);
|
|
281
310
|
timer = undefined;
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const teardown = async (reason: string, deadlineAt?: number): Promise<void> => {
|
|
314
|
+
stopDispatching();
|
|
282
315
|
logger.info('jobs.scheduler.draining', { reason, dispatching: round !== undefined });
|
|
283
316
|
try {
|
|
284
317
|
// The round this stop races runs to the end first. Releasing the lease under a
|
|
@@ -286,8 +319,24 @@ export function createScheduler(options: SchedulerOptions): Scheduler {
|
|
|
286
319
|
// then own the same occurrence — the exact double-fire leader election exists to prevent.
|
|
287
320
|
// Settled, not awaited: a round that failed is its own caller's to see, and the lease still
|
|
288
321
|
// has to go back.
|
|
289
|
-
|
|
290
|
-
|
|
322
|
+
//
|
|
323
|
+
// Bounded on the SIGTERM path, `undefined` on a manual stop: a round parked in `enqueue` on
|
|
324
|
+
// a queue that is not answering cannot be cancelled from here, and an unbounded wait is a
|
|
325
|
+
// teardown that never ends — hooks never handed back, `state` never past 'draining', and the
|
|
326
|
+
// memoised `stopping` every later `stop()` joins never settling.
|
|
327
|
+
const dispatched = await settleAllBy(round === undefined ? [] : [round], deadlineAt);
|
|
328
|
+
// A round we ABANDONED is a round still enqueueing, so the lock is deliberately NOT handed
|
|
329
|
+
// back: promoting a standby onto an occurrence this process is mid-dispatch for is the
|
|
330
|
+
// double-fire above, delivered by the shutdown. A lease row expires on its own, which is
|
|
331
|
+
// what an expiry is for — and `isLeader` is cleared below either way.
|
|
332
|
+
if (!dispatched) {
|
|
333
|
+
logger.warn('jobs.scheduler.drain-abandoned', {
|
|
334
|
+
reason,
|
|
335
|
+
fix: 'raise the drain budget past a dispatch round — configureLifecycle({ deadlineMs: 60_000 }) — and set terminationGracePeriodSeconds to at least as many seconds',
|
|
336
|
+
});
|
|
337
|
+
} else if (isLeader) {
|
|
338
|
+
await leader.release();
|
|
339
|
+
}
|
|
291
340
|
} finally {
|
|
292
341
|
// Whatever the release did, this scheduler is done. `isLeader` false because a lock this
|
|
293
342
|
// process no longer holds — or failed to hand back — must never be re-used as if it did,
|
|
@@ -295,17 +344,20 @@ export function createScheduler(options: SchedulerOptions): Scheduler {
|
|
|
295
344
|
// the next process-wide drain, and keeps this closure and its driver alive with it.
|
|
296
345
|
isLeader = false;
|
|
297
346
|
state = 'stopped';
|
|
298
|
-
|
|
299
|
-
|
|
347
|
+
for (const release of releaseShutdownHooks) release();
|
|
348
|
+
releaseShutdownHooks = [];
|
|
300
349
|
}
|
|
301
350
|
};
|
|
302
351
|
|
|
303
|
-
const stop = async (reason = 'stop'): Promise<void> => {
|
|
352
|
+
const stop = async (reason = 'stop', deadlineAt?: number): Promise<void> => {
|
|
353
|
+
// Answered immediately once this scheduler is done: the teardown always REACHES 'stopped', so
|
|
354
|
+
// a caller landing after an abandoned drain gets an answer rather than joining a promise that
|
|
355
|
+
// never settles.
|
|
304
356
|
if (state === 'stopped') return;
|
|
305
357
|
// One teardown, joined rather than repeated — the worker's rule, for the same reason: a
|
|
306
358
|
// SIGTERM landing on a manual stop must wait out the same round, not release the lock a
|
|
307
359
|
// second time behind it. Cleared as it settles, so a scheduler started again stops again.
|
|
308
|
-
stopping ??= teardown(reason).finally(() => {
|
|
360
|
+
stopping ??= teardown(reason, deadlineAt).finally(() => {
|
|
309
361
|
stopping = undefined;
|
|
310
362
|
});
|
|
311
363
|
await stopping;
|
|
@@ -317,14 +369,19 @@ export function createScheduler(options: SchedulerOptions): Scheduler {
|
|
|
317
369
|
// about to release, and stack a second shutdown hook on the one still running.
|
|
318
370
|
if (state !== 'idle' && state !== 'stopped') return;
|
|
319
371
|
state = 'running';
|
|
320
|
-
//
|
|
321
|
-
// enqueued during the drain is work nothing in this process is left to run
|
|
322
|
-
//
|
|
323
|
-
//
|
|
372
|
+
// TWO hooks, for the two phases. `accept` stops dispatching and returns — an occurrence
|
|
373
|
+
// enqueued during the drain is work nothing in this process is left to run, and every hook
|
|
374
|
+
// behind this one still has the whole budget. `close` waits the round out and hands the
|
|
375
|
+
// lease back, bounded by the deadline it is given. Both unregisters are kept, never
|
|
376
|
+
// discarded: `stop()` hands them back, so start -> stop -> start holds one pair rather
|
|
377
|
+
// than one per start.
|
|
324
378
|
if (options.drainOnShutdown !== false) {
|
|
325
|
-
|
|
326
|
-
phase: 'accept',
|
|
327
|
-
|
|
379
|
+
releaseShutdownHooks = [
|
|
380
|
+
onShutdown('jobs.scheduler.accept', stopDispatching, { phase: 'accept' }),
|
|
381
|
+
onShutdown('jobs.scheduler', (reason) => stop('SIGTERM', reason.deadlineAt), {
|
|
382
|
+
phase: 'close',
|
|
383
|
+
}),
|
|
384
|
+
];
|
|
328
385
|
}
|
|
329
386
|
schedule();
|
|
330
387
|
logger.info('jobs.scheduler.started', { tasks: (options.tasks ?? registeredTasks()).length });
|
package/src/steps.ts
CHANGED
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
// catches it and re-queues the job for `resumeAt` instead of holding a process for three days.
|
|
8
8
|
|
|
9
9
|
import type { Clock } from '@ultimat3/core';
|
|
10
|
-
import { logger } from '@ultimat3/core';
|
|
10
|
+
import { logger, renderThrowable } from '@ultimat3/core';
|
|
11
11
|
import type { DurationInput } from './clock';
|
|
12
12
|
import { nowMs, toMs } from './clock';
|
|
13
13
|
import { JobAbortedError, JobTimeoutError, StepDuplicateError } from './errors';
|
|
14
|
+
import { createRunSignal } from './run-signal';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* The runtime list is the declaration and `StepStatus` is derived from it, the shape
|
|
@@ -283,10 +284,16 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
|
|
|
283
284
|
// The step's own ceiling, folded into the run's cancellation so the body reads ONE signal and
|
|
284
285
|
// sees whichever deadline lands first. Composed only when there is a second one to compose.
|
|
285
286
|
const deadline = new AbortController();
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
287
|
+
// `createRunSignal` and never `AbortSignal.any`, which is the rule this package's `CLAUDE.md`
|
|
288
|
+
// states as absolute: a composite cannot be undone, so ONE dependent signal per STEP stayed on
|
|
289
|
+
// the run's signal for the whole attempt. A `backfill()` at `batch: 1000` over 5M rows is
|
|
290
|
+
// 5,000 of them held at once, and an app whose `WorkerOptions.context()` carries a
|
|
291
|
+
// process-lifetime signal keeps them past the run. Composed only when there is a second signal
|
|
292
|
+
// to compose, and DISPOSED in the `finally` below, which is the whole reason `run-signal.ts`
|
|
293
|
+
// exists — `worker-run.ts` disposes the run's own the same way.
|
|
294
|
+
const composed =
|
|
295
|
+
options.stepTimeoutMs === undefined ? null : createRunSignal([runSignal, deadline.signal]);
|
|
296
|
+
const signal = composed?.signal ?? runSignal;
|
|
290
297
|
try {
|
|
291
298
|
const output = await withStepTimeout(
|
|
292
299
|
fn(signal),
|
|
@@ -320,12 +327,16 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
|
|
|
320
327
|
status: 'failed',
|
|
321
328
|
startedAt,
|
|
322
329
|
attempts,
|
|
323
|
-
error:
|
|
330
|
+
error: renderThrowable(error),
|
|
324
331
|
};
|
|
325
332
|
await store.put(failure);
|
|
326
333
|
remember(failure);
|
|
327
334
|
}
|
|
328
335
|
throw error;
|
|
336
|
+
} finally {
|
|
337
|
+
// Nothing of the run's is held past the step. Idempotent, and it never aborts: a step that
|
|
338
|
+
// settled leaves its signal in whatever state it ended in.
|
|
339
|
+
composed?.dispose();
|
|
329
340
|
}
|
|
330
341
|
}
|
|
331
342
|
|
|
@@ -3,7 +3,7 @@
|
|
|
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 { logger } from '@ultimat3/core';
|
|
6
|
+
import { logger, renderThrowable } from '@ultimat3/core';
|
|
7
7
|
import type { ClaimedJob } from './driver';
|
|
8
8
|
import { getJob } from './job';
|
|
9
9
|
import type { HeldLease, LeaseStore } from './leases';
|
|
@@ -121,7 +121,7 @@ export function createFleetSlots(options: FleetSlotOptions): FleetSlots {
|
|
|
121
121
|
logger.warn('jobs.worker.lease-release-failed', {
|
|
122
122
|
workerId: options.workerId,
|
|
123
123
|
jobId,
|
|
124
|
-
error:
|
|
124
|
+
error: renderThrowable(error),
|
|
125
125
|
});
|
|
126
126
|
});
|
|
127
127
|
},
|
package/src/worker.ts
CHANGED
|
@@ -4,8 +4,17 @@
|
|
|
4
4
|
// deploy turns "at least once" into "always twice", so draining is on by default.
|
|
5
5
|
|
|
6
6
|
import type { Clock, Ctx } from '@ultimat3/core';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
beginWork,
|
|
9
|
+
logger,
|
|
10
|
+
onShutdown,
|
|
11
|
+
recordJob,
|
|
12
|
+
recordQueueDepth,
|
|
13
|
+
renderThrowable,
|
|
14
|
+
uuid,
|
|
15
|
+
} from '@ultimat3/core';
|
|
8
16
|
import { nowMs } from './clock';
|
|
17
|
+
import { settleAllBy } from './drain-wait';
|
|
9
18
|
import type { ClaimedJob, JobDriver, QueueStats } from './driver';
|
|
10
19
|
import { DEFAULT_QUEUE, DEFAULT_VISIBILITY_TIMEOUT_MS } from './driver';
|
|
11
20
|
import { ConcurrencyUnenforceableError } from './errors';
|
|
@@ -106,8 +115,12 @@ export function createWorker(options: WorkerOptions): Worker {
|
|
|
106
115
|
const rounds = new Set<Promise<unknown>>();
|
|
107
116
|
let state: WorkerStats['state'] = 'idle';
|
|
108
117
|
let loop: ReturnType<typeof setTimeout> | undefined;
|
|
109
|
-
/**
|
|
110
|
-
|
|
118
|
+
/**
|
|
119
|
+
* The two `onShutdown` registrations this worker holds while it runs, both handed back by
|
|
120
|
+
* `stop()`. Two, because they answer different questions in different PHASES — the split
|
|
121
|
+
* `listenSyncNode` and `@ultimat3/http` already have.
|
|
122
|
+
*/
|
|
123
|
+
let releaseShutdownHooks: (() => void)[] = [];
|
|
111
124
|
/** The teardown in flight, so a second `stop()` joins it instead of running a second one. */
|
|
112
125
|
let stopping: Promise<void> | undefined;
|
|
113
126
|
let processed = 0;
|
|
@@ -141,7 +154,7 @@ export function createWorker(options: WorkerOptions): Worker {
|
|
|
141
154
|
// Instrumentation never costs a tick: a queue that cannot be measured must still be worked.
|
|
142
155
|
logger.warn('jobs.worker.depth-failed', {
|
|
143
156
|
workerId,
|
|
144
|
-
error:
|
|
157
|
+
error: renderThrowable(error),
|
|
145
158
|
});
|
|
146
159
|
}
|
|
147
160
|
};
|
|
@@ -255,6 +268,10 @@ export function createWorker(options: WorkerOptions): Worker {
|
|
|
255
268
|
continue;
|
|
256
269
|
}
|
|
257
270
|
|
|
271
|
+
// The claimed job is the process's in-flight work, counted where the DRAIN can see it:
|
|
272
|
+
// core's own in-flight wait sits between `accept` and `inflight` and exists for exactly
|
|
273
|
+
// this. Counted nowhere, the worker had to wait for its own jobs inside a hook.
|
|
274
|
+
const finishWork = beginWork();
|
|
258
275
|
const running = runClaimed(job)
|
|
259
276
|
.then((execution) => {
|
|
260
277
|
if (execution.outcome === 'completed') processed += 1;
|
|
@@ -269,9 +286,20 @@ export function createWorker(options: WorkerOptions): Worker {
|
|
|
269
286
|
if (label !== null) recordJob(queue, label);
|
|
270
287
|
return execution;
|
|
271
288
|
})
|
|
272
|
-
.finally(() => {
|
|
289
|
+
.finally(async () => {
|
|
273
290
|
lease.release();
|
|
274
|
-
void
|
|
291
|
+
// AWAITED, never `void`: the slot is a row in `x_job_leases`, so the DELETE was still
|
|
292
|
+
// on the wire when the teardown's `allSettled` returned and `driver.close()` took the
|
|
293
|
+
// connection out from under it — a `concurrency: 1` job unclaimable by the pod
|
|
294
|
+
// replacing this one for a whole visibility window after every deploy. `release`
|
|
295
|
+
// swallows its own failures, so awaiting it cannot reject a job that finished; the
|
|
296
|
+
// `finally` is for the one that could, because a lost `finishWork()` is an in-flight
|
|
297
|
+
// count that never returns to zero and a drain that waits out its whole budget.
|
|
298
|
+
try {
|
|
299
|
+
await fleetSlots.release(job.id);
|
|
300
|
+
} finally {
|
|
301
|
+
finishWork();
|
|
302
|
+
}
|
|
275
303
|
});
|
|
276
304
|
|
|
277
305
|
started.push(running);
|
|
@@ -290,7 +318,7 @@ export function createWorker(options: WorkerOptions): Worker {
|
|
|
290
318
|
workerId,
|
|
291
319
|
job: job.name,
|
|
292
320
|
jobId: job.id,
|
|
293
|
-
error:
|
|
321
|
+
error: renderThrowable(error),
|
|
294
322
|
});
|
|
295
323
|
},
|
|
296
324
|
);
|
|
@@ -332,7 +360,7 @@ export function createWorker(options: WorkerOptions): Worker {
|
|
|
332
360
|
.catch((error: unknown) => {
|
|
333
361
|
logger.error('jobs.worker.tick-failed', {
|
|
334
362
|
workerId,
|
|
335
|
-
error:
|
|
363
|
+
error: renderThrowable(error),
|
|
336
364
|
});
|
|
337
365
|
})
|
|
338
366
|
.finally(() => {
|
|
@@ -341,18 +369,42 @@ export function createWorker(options: WorkerOptions): Worker {
|
|
|
341
369
|
}, pollIntervalMs);
|
|
342
370
|
};
|
|
343
371
|
|
|
344
|
-
|
|
372
|
+
/**
|
|
373
|
+
* The whole of the `accept` phase: stop taking work, and nothing else. Synchronous on purpose —
|
|
374
|
+
* a phase whose job is to be over before the load balancer's next health check must not contain
|
|
375
|
+
* a wait, and the hook behind this one is somebody else's "stop listening".
|
|
376
|
+
*/
|
|
377
|
+
const stopAccepting = (): void => {
|
|
378
|
+
if (state === 'stopped') return;
|
|
345
379
|
state = 'draining';
|
|
346
380
|
if (loop !== undefined) clearTimeout(loop);
|
|
347
381
|
loop = undefined;
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const teardown = async (reason: string, deadlineAt?: number): Promise<void> => {
|
|
385
|
+
stopAccepting();
|
|
348
386
|
logger.info('jobs.worker.draining', { workerId, reason, inFlight: inFlight.size });
|
|
349
387
|
try {
|
|
350
388
|
// Stop claiming, finish what we hold, then close. Anything else re-runs work on deploy.
|
|
351
389
|
// Rounds first: one that passed the guard before the flag flipped is still awaiting its
|
|
352
390
|
// `claim()`, and the jobs it starts join `inFlight` after any snapshot taken here — so a
|
|
353
391
|
// drain that waited on `inFlight` alone closed the driver under a job that had just begun.
|
|
354
|
-
|
|
355
|
-
|
|
392
|
+
//
|
|
393
|
+
// Both waits share ONE deadline on the SIGTERM path (`undefined` on a manual stop, which
|
|
394
|
+
// waits as long as its jobs take). Nothing can kill a body that ignores `ctx.signal`, so an
|
|
395
|
+
// unbounded wait here is a teardown that never ends: driver never closed, state never past
|
|
396
|
+
// 'draining', and the memoized `stopping` every later `stop()` joins never settling.
|
|
397
|
+
// Abandoning costs a lapsed lease and a redelivered job — at-least-once, as promised.
|
|
398
|
+
const rounded = await settleAllBy([...rounds], deadlineAt);
|
|
399
|
+
const drained = (await settleAllBy([...inFlight], deadlineAt)) && rounded;
|
|
400
|
+
if (!drained) {
|
|
401
|
+
logger.warn('jobs.worker.drain-abandoned', {
|
|
402
|
+
workerId,
|
|
403
|
+
reason,
|
|
404
|
+
inFlight: inFlight.size,
|
|
405
|
+
fix: 'raise the drain budget past the slowest job — configureLifecycle({ deadlineMs: 600_000 }) — and set terminationGracePeriodSeconds to at least as many seconds',
|
|
406
|
+
});
|
|
407
|
+
}
|
|
356
408
|
await options.driver.close?.();
|
|
357
409
|
} finally {
|
|
358
410
|
// Whatever the close did, this worker is done: a state left at 'draining' is a drain that
|
|
@@ -362,19 +414,22 @@ export function createWorker(options: WorkerOptions): Worker {
|
|
|
362
414
|
// through a driver already closed — and keeps this closure, its driver and its in-flight
|
|
363
415
|
// set alive with it.
|
|
364
416
|
state = 'stopped';
|
|
365
|
-
|
|
366
|
-
|
|
417
|
+
for (const release of releaseShutdownHooks) release();
|
|
418
|
+
releaseShutdownHooks = [];
|
|
367
419
|
}
|
|
368
420
|
};
|
|
369
421
|
|
|
370
|
-
const stop = async (reason = 'stop'): Promise<void> => {
|
|
422
|
+
const stop = async (reason = 'stop', deadlineAt?: number): Promise<void> => {
|
|
423
|
+
// Answered immediately once this worker is done: the teardown always REACHES 'stopped' (its
|
|
424
|
+
// waits are bounded and the state is set in a `finally`), so a caller landing after an
|
|
425
|
+
// abandoned drain gets an answer rather than joining a promise that never settles.
|
|
371
426
|
if (state === 'stopped') return;
|
|
372
427
|
// One teardown, joined rather than repeated: a SIGTERM landing on a manual stop must wait out
|
|
373
428
|
// the same in-flight work, not close the driver a second time underneath it. Cleared as it
|
|
374
429
|
// settles, so a worker that started again tears down again instead of joining a promise that
|
|
375
430
|
// settled a lifetime ago. A close that threw still stopped this worker — the failure is the
|
|
376
431
|
// caller's to see on the promise it awaited, not a teardown to run twice.
|
|
377
|
-
stopping ??= teardown(reason).finally(() => {
|
|
432
|
+
stopping ??= teardown(reason, deadlineAt).finally(() => {
|
|
378
433
|
stopping = undefined;
|
|
379
434
|
});
|
|
380
435
|
await stopping;
|
|
@@ -400,13 +455,22 @@ export function createWorker(options: WorkerOptions): Worker {
|
|
|
400
455
|
}
|
|
401
456
|
state = 'running';
|
|
402
457
|
logger.info('jobs.worker.started', { workerId, queues });
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
458
|
+
// TWO hooks, for the two phases that answer two questions. `accept` stops claiming and
|
|
459
|
+
// returns, so every hook behind it — the HTTP server's "stop listening", the sync node's
|
|
460
|
+
// "stop upgrading" — runs while the budget is still whole; one hook doing both spent all of
|
|
461
|
+
// it in the phase whose whole purpose is to be quick. `close` waits out what this worker
|
|
462
|
+
// holds and closes the driver, bounded by the deadline the hook is handed.
|
|
463
|
+
//
|
|
464
|
+
// Both unregisters are kept, never discarded: `stop()` hands them back, so
|
|
465
|
+
// start -> stop -> start holds one pair rather than one per start, each retaining the
|
|
466
|
+
// driver of a worker that is already gone.
|
|
406
467
|
if (options.drainOnShutdown !== false) {
|
|
407
|
-
|
|
408
|
-
phase: 'accept',
|
|
409
|
-
|
|
468
|
+
releaseShutdownHooks = [
|
|
469
|
+
onShutdown(`jobs.worker.${workerId}.accept`, stopAccepting, { phase: 'accept' }),
|
|
470
|
+
onShutdown(`jobs.worker.${workerId}`, (reason) => stop('SIGTERM', reason.deadlineAt), {
|
|
471
|
+
phase: 'close',
|
|
472
|
+
}),
|
|
473
|
+
];
|
|
410
474
|
}
|
|
411
475
|
schedule();
|
|
412
476
|
},
|