@ultimat3/jobs 23.0.0 → 24.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +51 -52
- package/README.md +34 -5
- package/package.json +6 -6
- package/src/backfill-ledger.ts +24 -2
- package/src/backfill-pass.ts +22 -5
- package/src/claim-exhausted.ts +76 -0
- package/src/driver-memory-operator.ts +31 -9
- package/src/driver-memory.ts +68 -23
- package/src/driver-nats.ts +5 -2
- package/src/driver-pg-jobs-sql.ts +27 -19
- package/src/driver-pg-operator-sql.ts +32 -12
- package/src/driver-pg-operator.ts +19 -3
- package/src/driver-pg-rows.ts +3 -3
- package/src/driver-pg-sql.ts +98 -29
- package/src/driver-pg.ts +86 -81
- package/src/driver-redis.ts +5 -2
- package/src/driver.ts +37 -1
- package/src/errors-operator.ts +12 -4
- package/src/errors-requeue.ts +21 -3
- package/src/errors.ts +5 -13
- package/src/events-pg.ts +8 -18
- package/src/events.ts +20 -9
- package/src/execute.ts +1 -0
- package/src/index.ts +10 -4
- package/src/inspect-operator.ts +3 -3
- package/src/inspect.ts +4 -3
- package/src/introspection.ts +23 -11
- package/src/job.ts +5 -6
- package/src/limits.ts +33 -7
- package/src/outbox-pg.ts +1 -2
- package/src/outbox.ts +37 -9
- package/src/purge.ts +1 -1
- package/src/queue-wake.ts +1 -2
- package/src/scheduler-pg.ts +3 -2
- package/src/scheduler-state.ts +8 -1
- package/src/scheduler.ts +9 -10
- package/src/steps-suspension.ts +46 -0
- package/src/steps-timeout.ts +33 -0
- package/src/steps.ts +55 -81
- package/src/worker-admit.ts +7 -3
- package/src/worker-fleet-slots.ts +78 -44
- package/src/worker-key-busy.ts +38 -10
- package/src/worker-loop.ts +35 -11
- package/src/worker-registry.ts +16 -1
- package/src/worker.ts +52 -9
package/CLAUDE.md
CHANGED
|
@@ -8,7 +8,8 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
|
|
|
8
8
|
`expectedQueryLoop` ONLY: `steps.ts` declares the per-step write one-per-step to the N+1
|
|
9
9
|
detector there, and no client is ever taken from it. Never `http`, `render`, `ui`.
|
|
10
10
|
- Consumers: `action` (`<job>.enqueue`, via the ambient jobs facade), `cli`, `mcp`, `admin`.
|
|
11
|
-
- External deps: none. Postgres access goes through the injected `PgExecutor
|
|
11
|
+
- External deps: none. Postgres access goes through the injected `PgExecutor` (`@ultimat3/core`'s,
|
|
12
|
+
re-exported from `index.ts`).
|
|
12
13
|
|
|
13
14
|
## Rules — registration and declarations
|
|
14
15
|
|
|
@@ -29,7 +30,7 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
|
|
|
29
30
|
- **JOBS DECLARE THEIR TENANT, and `executeJob` INSTALLS the context — do not re-litigate.**
|
|
30
31
|
`tenant` is REQUIRED (`(input) => input.orgId` or `'none'`; `X_JOB_TENANT_REQUIRED` backstop).
|
|
31
32
|
`executeJob` puts the derived org on the ctx's actor (`jobRunActor`, org only) and runs the body in
|
|
32
|
-
`runWithContext`; `'none'` STRIPS the org (fail-closed).
|
|
33
|
+
`runWithContext`; `'none'` STRIPS the org (fail-closed).
|
|
33
34
|
`tenantFor` is a METHOD on `JobHandle` (variance). The queue row's `tenantId` stays the ENQUEUER's
|
|
34
35
|
(the limiter's bucket). `tenancy-cross-surface.test.ts` asserts HTTP and job verdicts are EQUAL.
|
|
35
36
|
- **`enqueuedBy` is ATTRIBUTION, never authority** — do not re-litigate. A job acting FOR a user takes
|
|
@@ -43,39 +44,37 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
|
|
|
43
44
|
|
|
44
45
|
## Rules — the driver and SQL
|
|
45
46
|
|
|
46
|
-
- **`SQL_JOBS_TABLE` is the ONE install point** for every durable table (`x_jobs`, `
|
|
47
|
-
`
|
|
48
|
-
`x_job_events`, `x_job_pauses`, `x_job_workers`, `x_job_counters`). A NEW table is also a name in
|
|
47
|
+
- **`SQL_JOBS_TABLE` is the ONE install point** for every durable table (`x_jobs`, `x_job_*`, `x_backfills`,
|
|
48
|
+
`x_outbox`, `x_scheduler_*`). A NEW table is also a name in
|
|
49
49
|
`packages/cli/src/framework-schema.ts`. A shipped table grows by `alter table ... add column if
|
|
50
50
|
not exists`. Its comments carry NO apostrophes and NO semicolons (`dev-queue.ts` splits on `;`; `driver-pg-sql.test.ts` checks
|
|
51
51
|
quote parity).
|
|
52
52
|
- **`claim({ queues: [] })` is REFUSED by every driver** (`assertClaimQueues`,
|
|
53
|
-
`X_JOB_CLAIM_QUEUES_EMPTY`); the memory driver's
|
|
54
|
-
refusal rejects on both.
|
|
53
|
+
`X_JOB_CLAIM_QUEUES_EMPTY`); the memory driver's reads are `async` so a refusal rejects on both.
|
|
55
54
|
- **`ack`, `nack`, `heartbeat` and `recordProgress` are FENCED on `state = 'running'` AND the
|
|
56
55
|
CLAIM** — `{ workerId, claim }`, `claimOf(claimed)`; `x_jobs.claims` is moved by the claim and
|
|
57
|
-
never reset.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
`
|
|
56
|
+
never reset. `heartbeat` answers a boolean, read as `held === false` (never `!held`).
|
|
57
|
+
- **A driver's semantics are pinned beside the pg statement** (`driver-parity*.test.ts`);
|
|
58
|
+
`driver-*-fixture.ts` scenarios run on memory AND Postgres.
|
|
59
|
+
- **The claim BURIES a lease that lapsed on the final attempt** (`attempt >= max_attempts`): `dead`
|
|
60
|
+
in `SQL_CLAIM` — `failed` for a name in `ClaimOptions.dropExhausted` (`retry.deadLetter: false`)
|
|
61
|
+
— counted, never returned; the worker's round hears `onExhausted` and calls `announceExhausted`.
|
|
62
|
+
- **`cancel` is fenced on `LIVE_STATES`.** **`requeue`** refuses (`X_JOB_NOT_FOUND`, not requeueable,
|
|
63
|
+
duplicate), then ONE statement. **`enqueue` inserts twice at most.** **`BulkResult.remaining` is
|
|
64
|
+
what a second call would move.**
|
|
62
65
|
- **No read returns a WHOLE row** — `driver-pg-sql.test.ts` scans every production file here
|
|
63
66
|
(discovered, comments stripped) for `select *`/`returning *`.
|
|
64
67
|
- Drivers implement the six `JobDriver` methods plus optional `introspect`, `backfills`, `leases`. New
|
|
65
68
|
capabilities go behind the interface. `inspect.ts` returns plain JSON for CLI, `/_x` and MCP.
|
|
66
|
-
- Step results are persisted BEFORE the step returns. All time is epoch ms
|
|
67
|
-
(`nowMs()` in `clock.ts`).
|
|
69
|
+
- Step results are persisted BEFORE the step returns. All time is epoch ms (`nowMs()`, `clock.ts`).
|
|
68
70
|
- **`createPgLeader` is correct only on a DEDICATED connection**; boot uses `createPgLeaseLeader`.
|
|
69
|
-
- `driver-redis.ts` / `driver-nats.ts` are honest `X_NOT_IMPLEMENTED` stubs.
|
|
70
71
|
|
|
71
72
|
## Rules — numbers and limits
|
|
72
73
|
|
|
73
74
|
- **Every numeric knob is refused when not FINITE** — core's `finiteOption()` (a bound) and
|
|
74
75
|
`finiteCount()` (a count, caller's minimum); `worker-options.ts` is where `createWorker` reads them.
|
|
75
|
-
**`bun run finite-bounds` is a floor, never proof**: `createLimiter`'s five numbers
|
|
76
|
-
|
|
77
|
-
STOP). **A row count is `finiteCount` (min 0)** for driver parity (`list`, `deadLetters`, the
|
|
78
|
-
backfill ledger's `list`, `assertClaimBounds`).
|
|
76
|
+
**`bun run finite-bounds` is a floor, never proof**: `createLimiter`'s five numbers are screened
|
|
77
|
+
with min 0 (zero is a HARD STOP). **A row count is `finiteCount` (min 0)**; `retry.attempts` is one with min 1.
|
|
79
78
|
- **`WorkerOptions.concurrency` is read by OWN key** — a queue named `constructor`
|
|
80
79
|
(`worker-slots.test.ts`).
|
|
81
80
|
- **`job.concurrency` is enforced by `JobDriver.leases`** (one row per held slot in `x_job_leases`);
|
|
@@ -111,12 +110,11 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
|
|
|
111
110
|
letter), read off the SIGNAL. `WorkerStats.interrupted`; `worker-drain-signal.test.ts`.
|
|
112
111
|
- **The drain waits out the claim round it races** — `tick()` registers its round in `rounds`
|
|
113
112
|
synchronously with its guard.
|
|
114
|
-
- **A fleet slot is taken INSIDE a `try`, released AWAITED, and
|
|
115
|
-
run
|
|
113
|
+
- **A fleet slot is taken INSIDE a `try`, released AWAITED, and HELD, not owned**: `false`, or a TTL
|
|
114
|
+
with no renewal landing, CANCELS the run (`X_JOB_SLOT_LOST`); held per job id as a LIST.
|
|
116
115
|
- **The run's signal is a controller this worker owns** (`run-signal.ts`), never `AbortSignal.any`.
|
|
117
|
-
- **A lease is HELD, not owned** (`heartbeat.ts`): one failed renewal
|
|
118
|
-
|
|
119
|
-
process's clock, asked both sides of the call.
|
|
116
|
+
- **A lease is HELD, not owned** (`heartbeat.ts`): one failed renewal warns; a whole window without
|
|
117
|
+
one landing is `jobs.lease.lost`, on this process's clock, asked both sides of the call.
|
|
120
118
|
- **A renewal is decided against `stopped()`** (`renewal-timer.ts`, re-read after every await); the
|
|
121
119
|
interval is `unref`ed; every renewal is armed through `WorkerOptions.schedule`.
|
|
122
120
|
- **Settlement is not part of the retry decision**: `driver.ack` sits after the `try`.
|
|
@@ -125,15 +123,19 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
|
|
|
125
123
|
- **The claim loop re-arms on the PASS, never the jobs**; `tick()` resolves with that pass's executions.
|
|
126
124
|
- **A deadline CANCELS, then fails the attempt**, never the reverse (`raceTimeout`, `withStepTimeout`);
|
|
127
125
|
the attempt is cancelled in `executeJob`'s `finally`.
|
|
128
|
-
- **A cancelled runner writes NOTHING** — `put()` in `steps.ts`
|
|
129
|
-
`
|
|
126
|
+
- **A cancelled runner writes NOTHING** — `put()` in `steps.ts` (`X_ABORTED`), and the STORE fences
|
|
127
|
+
the claim (`StepFence`, `SQL_STEP_PUT` as `SQL_ACK`: `X_JOB_LEASE_LOST`). `executeJob` passes it.
|
|
130
128
|
- **`traceparent` is stamped at ENQUEUE time, in `outbox.ts`**, and an empty `spanId` sends none.
|
|
131
129
|
- **The scheduler runs one dispatch round at a time**; every other `tick()` joins it; `stop()` waits it
|
|
132
130
|
out before `leader.release()`. Same two-hook rule; an ABANDONED round does not release
|
|
133
131
|
(`jobs.scheduler.drain-abandoned`). `stillLeading()` is asked every round and before every task,
|
|
134
132
|
and answers from memory inside `renewEveryMs`.
|
|
135
|
-
- **`run-once` fires ONE catch-up
|
|
136
|
-
|
|
133
|
+
- **`run-once` fires ONE catch-up**: the watermark rides the fire (`ScheduledFire.watermarkMs`,
|
|
134
|
+
`SQL_SCHEDULER_FIRE` `$4`), never a `markFired` behind it; `skip` fires the true latest missed
|
|
135
|
+
occurrence (`latestOccurrenceBy`, bisection).
|
|
136
|
+
- **A lease for a run that never STARTED is `abandon()`ed, never `release()`d** — the rate stamp
|
|
137
|
+
goes with it (`worker-admit.ts`). **A failed pass re-arms at the floor** (`worker-loop.ts`
|
|
138
|
+
catch); `timers` is the test seam.
|
|
137
139
|
- **Every timer body catches before it finalises** (`void work().catch(log).finally(...)`).
|
|
138
140
|
- **Suspension is control flow, and a SHED is not a suspension**: `StepSuspension` →
|
|
139
141
|
`nack({ countsAsAttempt: false, park: true })`; a shed is `countsAsAttempt: false` without `park`,
|
|
@@ -166,10 +168,9 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
|
|
|
166
168
|
- **An occurrence fires in ONE statement** (`SchedulerState.fire`, `SQL_SCHEDULER_FIRE`): the
|
|
167
169
|
watermark is the fence, jobs insert only `where exists (select 1 from moved)`.
|
|
168
170
|
- **`progress()` is throttled in `progress.ts`** and flushed before every settle. **`onSettled`
|
|
169
|
-
(`settled.ts`) is the ONE ending hook** — `completed`
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
crash. Never for a cancel, a retry or a suspension; no second hook beside it.
|
|
171
|
+
(`settled.ts`) is the ONE ending hook** — `completed`, `dead-lettered`, `dropped`, `refused` —
|
|
172
|
+
AFTER a settle that LANDED, in the body's tenant scope, `ON_SETTLED_ATTEMPTS` tries, never
|
|
173
|
+
rethrown, at most once across a crash. Never for a cancel, a retry or a suspension.
|
|
173
174
|
- **Failed for good + `deadLetter: false` is DROPPED**: `nack({ fail })`, outcome `dropped`.
|
|
174
175
|
- **Worker registry rows expire** (`worker-registry.ts`, TTL = visibility timeout); `stop()` forgets.
|
|
175
176
|
|
|
@@ -196,7 +197,9 @@ Long form: `docs/history/jobs.md`, "the cross-process wake".
|
|
|
196
197
|
- **`idle-cost.test.ts` / `queue-wake.test.ts` count statements per idle minute** — scheduler 6,
|
|
197
198
|
worker and relay 30 with no wake, 12 with. Raising one is a regression.
|
|
198
199
|
- **The memory driver stores a payload's JSON form**, as pg binds it (`driver-settle-parity.test.ts`).
|
|
199
|
-
- **The event bus has ONE clock**: `createPgEventBus` takes no `clock`; `
|
|
200
|
+
- **The event bus has ONE clock**: `createPgEventBus` takes no `clock`; `EventLookup.now()` is
|
|
201
|
+
REQUIRED, stamps a NEW wait, and calls the TIMEOUT (asked once the runner thinks time is up).
|
|
202
|
+
`purgeExpired()` is awaited and counted; `eventsPurgeTarget(bus)` is its `PurgeTarget`.
|
|
200
203
|
|
|
201
204
|
## Rules — the outbox
|
|
202
205
|
|
|
@@ -238,7 +241,7 @@ Long form: [`docs/history/jobs.md`](../../docs/history/jobs.md), "Moved 2026-10-
|
|
|
238
241
|
| `tenant.ts` | what tenant a run acts under: `JobTenant`, the declaration's backstop, the actor `executeJob` installs |
|
|
239
242
|
| `backfill.ts` | `backfill()` — a factory over `job()`: the declaration, its checksum and its input |
|
|
240
243
|
| `backfill-pass.ts` | one pass: the batched iteration, its cursor checkpoints and its ledger row |
|
|
241
|
-
| `backfill-scope.ts` | which sweeps run across tenants
|
|
244
|
+
| `backfill-scope.ts` | which sweeps run across tenants (`tenant: 'none'` only) |
|
|
242
245
|
| `backfill-ledger.ts` | `x_backfills` — the contract, `BACKFILL_STATUSES`, the checksum, the verdict, the memory ledger |
|
|
243
246
|
| `backfill-registry.ts` | what was DECLARED: the `origin` stamp, `isBackfill`, `registeredBackfills` |
|
|
244
247
|
| `backfill-gate.ts` | may this sweep run here and now — environment, `requires`, already-applied |
|
|
@@ -249,38 +252,39 @@ Long form: [`docs/history/jobs.md`](../../docs/history/jobs.md), "Moved 2026-10-
|
|
|
249
252
|
| `register.ts` | `registerJobs`/`registerTasks` over a module namespace + the registrar announcements; an `action-job` projection is `X_ACTION_JOB_UNBRIDGED` |
|
|
250
253
|
| `describe.ts` | the JSON projection one handle emits; `describeJobs()` is a map over it |
|
|
251
254
|
| `steps.ts` | `StepStore`, `StepApi`, memoized-replay executor, `StepSuspension` |
|
|
255
|
+
| `steps-timeout.ts` / `steps-suspension.ts` | a step's ceiling; `StepSuspension` and the timed-out marker |
|
|
252
256
|
| `outbox.ts` | staging in a `Tx`, the store seam, the ambient `JobsFacade` slot |
|
|
253
257
|
| `outbox-relay.ts` | the relay: the poll timer, one pass, and its TWO shutdown hooks |
|
|
254
258
|
| `outbox-pg.ts` | `createPgOutboxStore` — `stage()` on the caller's OWN connection, claim on the pool |
|
|
255
259
|
| `outbox-lease.ts` | the claim lease's one definition and its one normalisation, for both stores |
|
|
256
|
-
| `leases.ts` | `LeaseStore` — fleet-wide slots, the memory one
|
|
260
|
+
| `leases.ts` | `LeaseStore` — fleet-wide slots, the memory one, `jobLeaseKey` |
|
|
257
261
|
| `concurrency.ts` | `job.concurrency` as declared, resolved once: `KeyedConcurrency`, `WhenBusy`, the declaration and key refusals |
|
|
258
|
-
| `errors-concurrency.ts` | every concurrency refusal's class
|
|
262
|
+
| `errors-concurrency.ts` | every concurrency refusal's class; codes stay in `errors.ts` |
|
|
259
263
|
| `worker-key-busy.ts` | one run refused by its key: settled `failed`, body never run |
|
|
260
264
|
| `metrics.ts` | `queue_oldest_ready_seconds` and `queue_dead_jobs`, the two alertable gauges |
|
|
261
265
|
| `scheduler-pg.ts` | `pgSchedulerState` (the durable watermark, the atomic fire) + `createPgLeaseLeader` |
|
|
262
266
|
| `events-pg.ts` | `createPgEventBus` — `step.waitForEvent` across processes |
|
|
263
267
|
| `driver.ts` | `JobDriver` contract + wire records |
|
|
264
|
-
| `driver-pg.ts` | default driver, and `createPgLeader`
|
|
268
|
+
| `driver-pg.ts` | default driver, and `createPgLeader` |
|
|
265
269
|
| `driver-pg-ddl.ts` | `SQL_JOBS_TABLE` — the ONE install point. Its comments carry no `;` and no `'` |
|
|
266
270
|
| `driver-pg-jobs-sql.ts` | every statement returning a whole `x_jobs` row, and `JOB_ROW_COLUMNS`; re-exported from `driver-pg-sql.ts` |
|
|
267
271
|
| `driver-pg-rows.ts` | a Postgres row → a wire record: `JobRow`/`StepRow`/`BackfillRow` and their mappings |
|
|
268
|
-
| `driver-memory.ts` | `x dev` / tests |
|
|
272
|
+
| `driver-memory.ts` / `claim-exhausted.ts` | `x dev` / tests; a buried row's log line and `onSettled` |
|
|
269
273
|
| `driver-redis.ts`, `driver-nats.ts` | honest `X_NOT_IMPLEMENTED` stubs |
|
|
270
274
|
| `retry.ts` | the dead-letter decision, and this package's option names over core's `backoffDelay` — no curve of its own |
|
|
271
|
-
| `retry-classification.ts` | the
|
|
275
|
+
| `retry-classification.ts` | the other half: what the thrown error says, and the stop reason |
|
|
272
276
|
| `execute.ts` / `run-deadline.ts` | `executeJob` — one claimed job run and settled; the run's deadline: cancel, then fail |
|
|
273
277
|
| `heartbeat.ts` | one claimed job's lease: the renewal interval and the loss it reports |
|
|
274
|
-
| `renewal-timer.ts` | the
|
|
275
|
-
| `worker.ts` | `worker` role, claim
|
|
278
|
+
| `renewal-timer.ts` | the renewal interval and its `stopped()` latch |
|
|
279
|
+
| `worker.ts` | `worker` role, claim round, drain |
|
|
276
280
|
| `worker-admit.ts` | may this claimed job start: the limiter, the fleet slot, and the shed / refusal |
|
|
277
281
|
| `worker-registry.ts` | one worker's registry row: announce, heartbeat, forget |
|
|
278
282
|
| `worker-types.ts` | the worker's public contract: `WorkerOptions`, `WorkerStats`, `Worker` |
|
|
279
|
-
| `drain-wait.ts` | the drain's wait, shared by both roles
|
|
280
|
-
| `worker-run.ts` | one claimed job, wired: heartbeat, slot renewal, run signal, span
|
|
281
|
-
| `run-signal.ts` | the signal ONE run is cancelled by
|
|
283
|
+
| `drain-wait.ts` | the drain's wait, shared by both roles |
|
|
284
|
+
| `worker-run.ts` | one claimed job, wired: heartbeat, slot renewal, run signal, span |
|
|
285
|
+
| `run-signal.ts` | the signal ONE run is cancelled by |
|
|
282
286
|
| `worker-fleet-slots.ts` | the fleet slot an in-flight job holds — take, renew, hand back — and the boot refusal |
|
|
283
|
-
| `purge.ts` | `purge()` — a factory over `job()`: the retention sweep
|
|
287
|
+
| `purge.ts` | `purge()` — a factory over `job()`: the retention sweep and its target seam |
|
|
284
288
|
| `task.ts` | the `task()` primitive + registry + the handle's surface + `registerTask` |
|
|
285
289
|
| `scheduler.ts` | `scheduler` role: the dispatch round, catch-up, leader election, the drain |
|
|
286
290
|
| `limits.ts` | per-tenant / per-queue / global concurrency + rate |
|
|
@@ -298,16 +302,11 @@ Long form: [`docs/history/jobs.md`](../../docs/history/jobs.md), "Moved 2026-10-
|
|
|
298
302
|
| `queue-wake.ts` / `driver-pg-wake-sql.ts` | the `LISTEN` session and its proof; which statement notifies |
|
|
299
303
|
| `worker-loop.ts` | the claim loop's timer: wake, `again`, the kicks |
|
|
300
304
|
| `driver-pg-outbox-sql.ts` | the `x_outbox` statements |
|
|
301
|
-
| `errors-operator.ts`
|
|
305
|
+
| `errors-operator.ts` / `errors-requeue.ts` | the operator verbs' refusals; `requeue`'s |
|
|
302
306
|
|
|
303
307
|
Which `-fixture.ts` harness each suite shares, and what each `.job.` suite proves:
|
|
304
308
|
`docs/history/jobs.md`, "Moved 2026-10-01 — test harnesses".
|
|
305
309
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
```
|
|
309
|
-
bun test packages/jobs
|
|
310
|
-
bun run --filter @ultimat3/jobs typecheck
|
|
311
|
-
```
|
|
310
|
+
Commands: `bun test packages/jobs` · `bun run --filter @ultimat3/jobs typecheck`.
|
|
312
311
|
|
|
313
312
|
Why each rule above is shaped the way it is: [`docs/history/jobs.md`](../../docs/history/jobs.md).
|
package/README.md
CHANGED
|
@@ -196,7 +196,13 @@ The event bus has ONE clock. `createPgEventBus({ executor })` stamps `publishedA
|
|
|
196
196
|
with the database's `now()` — it takes no `clock` — and `bus.now()` answers that same clock. A
|
|
197
197
|
consumer that wants "published after I asked" (`@ultimat3/scraping`'s `eventPrompt`) reads its
|
|
198
198
|
"asked at" from `bus.now()`, never from its own process: a skew between two pods must not decide
|
|
199
|
-
which answers count.
|
|
199
|
+
which answers count. `step.waitForEvent` stamps a new wait from the same clock
|
|
200
|
+
(`EventLookup.now()`), so a worker running ahead of the database still matches an event published
|
|
201
|
+
after it asked.
|
|
202
|
+
`step.waitForEvent` stamps a new wait from that clock (`EventLookup.now()`, required) and asks it
|
|
203
|
+
again before declaring a timeout, so a worker running ahead of the database still matches an event
|
|
204
|
+
published after it asked and does not give up early. `eventsPurgeTarget(bus)` is the bus as a
|
|
205
|
+
`purge()` target: `purgeExpired()` is awaited and answers the rows it removed.
|
|
200
206
|
|
|
201
207
|
Step names are the replay key, so they must be deterministic and unique in a run — a
|
|
202
208
|
duplicate is `X_STEP_DUPLICATE`, not a silent overwrite. Suspension is control flow
|
|
@@ -405,6 +411,11 @@ export const hourly = task({
|
|
|
405
411
|
| at least once is safe here | a replayed delete removes rows that are already gone, and a row a purge deleted answers exactly as one that was never there |
|
|
406
412
|
| two targets under one name | `X_INVARIANT`, before the first delete — `step.run` would raise `X_STEP_DUPLICATE` after one table was already empty |
|
|
407
413
|
|
|
414
|
+
The stored event bus is a target too: `eventsPurgeTarget(bus)` answers
|
|
415
|
+
`{ name: EVENTS_PURGE_TARGET, purgeExpired }` over `bus.purgeExpired()`, which is awaited and
|
|
416
|
+
answers the rows it removed — `x_job_events` only grew while nothing called it. Add it to
|
|
417
|
+
`targets`: `targets: () => [eventsPurgeTarget(eventBus())]`.
|
|
418
|
+
|
|
408
419
|
`@ultimat3/cli`'s boot declares both halves over the three tables it owns, so an app gets the sweep
|
|
409
420
|
without writing any of the above. It needs a `worker` to run it and a `scheduler` to fire it: a
|
|
410
421
|
deployment with neither has no background work at all, and this is one more thing it does not do.
|
|
@@ -731,6 +742,20 @@ between them — swapping is `setJobDriver(other)`, and there is **no `jobs.driv
|
|
|
731
742
|
The pg SQL is exported verbatim (`SQL_CLAIM`, `SQL_ENQUEUE`, `SQL_NACK`, …) so an agent
|
|
732
743
|
debugging a stuck queue can read and run the exact statement.
|
|
733
744
|
|
|
745
|
+
**A lease that lapses on a row's final attempt is buried by the claim.** `claim` re-takes a
|
|
746
|
+
`running` row whose lease lapsed — the worker died — but one already at `attempt >= maxAttempts`
|
|
747
|
+
is settled `dead` in the claim statement itself (`SQL_CLAIM`'s second arm; `isFinalAttempt` on the
|
|
748
|
+
memory driver), with `lastError` set to `LEASE_LAPSED_FINAL_ATTEMPT`, counted `dead`, and never
|
|
749
|
+
handed out: a job that kills its worker reaches the dead-letter queue after `retry.attempts`
|
|
750
|
+
claims instead of taking a worker per visibility timeout, forever. The pass is told through
|
|
751
|
+
`ClaimOptions.onExhausted(rows)` — at most once per pass, once per row — and `announceExhausted`
|
|
752
|
+
(`claim-exhausted.ts`) is what a claim round does with them: `jobs.claim.exhausted` at `error`, and
|
|
753
|
+
the job's `onSettled`, and the worker's claim round does exactly that. The row carries
|
|
754
|
+
`maxAttempts` and not the policy, so the CALLER names the jobs declaring `retry.deadLetter: false`
|
|
755
|
+
(`ClaimOptions.dropExhausted` — the worker reads them off its registry): those rows are buried
|
|
756
|
+
`failed` and announced `dropped`, every other one `dead` / `dead-lettered`. A caller that names
|
|
757
|
+
none (`x jobs drain`) buries every such row `dead`.
|
|
758
|
+
|
|
734
759
|
A driver you write reads a nack's target state through `nackState(options)` — `dead`
|
|
735
760
|
(`deadLetter`), `failed` (`fail`, terminal and out of the dead-letter queue), `suspended` (`park`)
|
|
736
761
|
or `ready` — so a new branch cannot land in one driver and not another. Its lease store implements
|
|
@@ -1071,7 +1096,9 @@ the pg and the memory driver alike. `x jobs`, `/_x` and a dashboard read nothing
|
|
|
1071
1096
|
|---|---|---|
|
|
1072
1097
|
| `list(filter)` | one page, newest first; `filter.after = jobCursor(lastRow)` for the next, `filter.before = jobCursor(firstRow)` for the previous (the rows nearest the cursor, still newest first; never both). Filters: `queue`, `name`, `state`, `tenantId`, `idPrefix`, `createdFrom`, `createdTo` | `MAX_JOB_PAGE` = 200 rows; `DEFAULT_JOB_PAGE` = 100. A page shorter than its limit is the last. A larger `limit`, or a cursor no page produced, is `X_JOB_PAGE_INVALID` |
|
|
1073
1098
|
| `remove(id)` | delete one job and its steps. A running one is `X_JOB_NOT_REMOVABLE` | — |
|
|
1074
|
-
| `
|
|
1099
|
+
| `requeue(id, { fromStep? })` | re-queue ONE finished job (`REQUEUEABLE_STATES`). A live one is `X_JOB_NOT_REQUEUEABLE`, a key a live job holds `X_JOB_DUPLICATE`, an id nobody queued `X_JOB_NOT_FOUND` — on both drivers. `fromStep` drops that step and every later one in the statement that moves the row | — |
|
|
1100
|
+
| `cancel(id, reason?)` | stop a LIVE job (`LIVE_STATES`: ready, delayed, running, suspended). A done, failed, dead or cancelled row is left exactly as it ended and answers `undefined` — `cancelJob` turns that into `X_JOB_NOT_CANCELLABLE` | — |
|
|
1101
|
+
| `requeueMany({ state, queue?, name?, tenantId? })` | re-queue finished rows; a row whose key a live job holds stays, and of several finished rows sharing one key the oldest is requeued | `MAX_BULK_ROWS` = 1,000 per call, spent only on rows it can move; answers `{ affected, remaining }`, where `remaining` is what a second call could still move — a held row is not counted, so "call until zero" ends |
|
|
1075
1102
|
| `removeMany({ state, queue?, name?, tenantId? })` | delete rows in any state but `running` | the same |
|
|
1076
1103
|
| `promoteMany({ state, queue?, name?, tenantId? })` | `promote` over a set: every row still waiting on its `runAt` is due now. `state` is `delayed` or `ready` (`PROMOTABLE_STATES`) | the same; `remaining` is the rows still waiting |
|
|
1077
1104
|
| `promote(id)` | a job waiting on its `runAt` — delayed, or backing off — is due now | — |
|
|
@@ -1133,6 +1160,7 @@ export const syncAccountWatched = job({
|
|
|
1133
1160
|
|---|---|---|---|
|
|
1134
1161
|
| the body returned and the ack landed | `completed` | `input`, `result` — what `run` returned, typed from it | `done` |
|
|
1135
1162
|
| failed for good, parked in the dead-letter queue | `dead-lettered` | `input` (or `undefined`: the stored payload no longer parses), `error`, `code` | `dead` |
|
|
1163
|
+
| its lease lapsed on the final attempt and the claim buried it — every worker that took it died | `dead-lettered` | `input`, `error` (`LEASE_LAPSED_FINAL_ATTEMPT`), `code: undefined` — nothing was thrown | `dead` |
|
|
1136
1164
|
| failed for good on `retry.deadLetter: false` | `dropped` | the same | `failed` |
|
|
1137
1165
|
| `whenBusy: 'fail'` over a busy key — the body never ran | `refused` | the same, `code: 'X_JOB_KEY_BUSY'` | `failed` |
|
|
1138
1166
|
|
|
@@ -1198,13 +1226,14 @@ FOR a user takes that user's id in its input and re-authorises it in the body.
|
|
|
1198
1226
|
| `X_ABORTED` | a cancelled attempt tried to write a step — core's code, not a second name for it |
|
|
1199
1227
|
| `X_JOB_LEASE_LOST` | the job was cancelled, or its lease lapsed and the queue re-delivered it, while this worker was still running it |
|
|
1200
1228
|
| `X_JOB_SLOT_LOST` | the fleet `concurrency` slot this run held was taken by another worker — a different row on a different clock from the lease above |
|
|
1201
|
-
| `X_JOB_NOT_CANCELLABLE` | `cancelJob` reached a job that already
|
|
1229
|
+
| `X_JOB_NOT_CANCELLABLE` | `cancelJob` reached a job that is not live — done, failed, dead or already cancelled — an id no queue holds, or a driver with no `cancel` |
|
|
1230
|
+
| `X_JOB_NOT_FOUND` | `requeue` was asked for an id the queue does not hold — one answer on both drivers. Fix: `x jobs ls --json` |
|
|
1202
1231
|
| `X_JOB_CONCURRENCY_UNENFORCEABLE` | a registered job declares `concurrency`, plain or keyed, and the driver has no lease store |
|
|
1203
1232
|
| `X_JOB_KEY_BUSY` | a run claimed under `whenBusy: 'fail'` while its key already held `limit` runs. Classified `terminal`. Fix: `x jobs ls --name <job> --state running --json` |
|
|
1204
1233
|
| `X_JOB_DECLARATION_INVALID` | `job()` is missing a required field, declares a `concurrency` no worker can honour (plain or keyed), or its `concurrency.key` answered an empty, non-string or over-long key at enqueue |
|
|
1205
1234
|
| `X_JOB_NOT_REMOVABLE` | `remove` / `removeMany` reached a running job. Fix: `x jobs cancel <id> --json` |
|
|
1206
1235
|
| `X_JOB_NOT_PROMOTABLE` | `promote` reached a job not waiting on its run time |
|
|
1207
|
-
| `X_JOB_PAGE_INVALID` | `list()` was asked for more than `MAX_JOB_PAGE` rows, or handed a cursor no page produced. Fix: `
|
|
1236
|
+
| `X_JOB_PAGE_INVALID` | `list()` was asked for more than `MAX_JOB_PAGE` rows, or handed a cursor no page produced. The cause names which cursor (`after` / `before`). Fix: `x jobs ls --limit 200 --json` |
|
|
1208
1237
|
| `X_JOB_ON_SETTLED_FAILED` | a declared `onSettled` threw on every one of its tries; logged and reported, never thrown |
|
|
1209
1238
|
| `X_NOT_IMPLEMENTED` | redis / nats driver |
|
|
1210
1239
|
|
|
@@ -1240,6 +1269,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
1240
1269
|
| `JobMaxAttemptsError` | `X_JOB_MAX_ATTEMPTS` | `src/errors.ts` |
|
|
1241
1270
|
| `JobNameTakenError` | `X_JOB_DUPLICATE` | `src/errors.ts` |
|
|
1242
1271
|
| `JobNotCancellableError` | `X_JOB_NOT_CANCELLABLE` | `src/errors.ts` |
|
|
1272
|
+
| `JobNotFoundError` | `X_JOB_NOT_FOUND` | `src/errors-requeue.ts` |
|
|
1243
1273
|
| `JobNotPromotableError` | `X_JOB_NOT_PROMOTABLE` | `src/errors-operator.ts` |
|
|
1244
1274
|
| `JobNotRemovableError` | `X_JOB_NOT_REMOVABLE` | `src/errors-operator.ts` |
|
|
1245
1275
|
| `JobNotRequeueableError` | `X_JOB_NOT_REQUEUEABLE` | `src/errors-requeue.ts` |
|
|
@@ -1247,7 +1277,6 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
1247
1277
|
| `JobPageInvalidError` | `X_JOB_PAGE_INVALID` | `src/errors-operator.ts` |
|
|
1248
1278
|
| `JobRowStatusUnknownError` | `X_JOB_ROW_STATUS_UNKNOWN` | `src/errors.ts` |
|
|
1249
1279
|
| `JobSlotLostError` | `X_JOB_SLOT_LOST` | `src/errors.ts` |
|
|
1250
|
-
| `JobsNotImplementedError` | `X_NOT_IMPLEMENTED` | `src/errors.ts` |
|
|
1251
1280
|
| `JobTenantRequiredError` | `X_JOB_TENANT_REQUIRED` | `src/errors.ts` |
|
|
1252
1281
|
| `JobTimeoutError` | `X_JOB_TIMEOUT` | `src/errors.ts` |
|
|
1253
1282
|
| `LeaseLostError` | `X_JOB_LEASE_LOST` | `src/errors.ts` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/jobs",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "24.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,10 +32,10 @@
|
|
|
32
32
|
"test": "bun test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/db": "
|
|
37
|
-
"@ultimat3/entity": "
|
|
38
|
-
"@ultimat3/schema": "
|
|
39
|
-
"@ultimat3/time": "
|
|
35
|
+
"@ultimat3/core": "24.0.0",
|
|
36
|
+
"@ultimat3/db": "24.0.0",
|
|
37
|
+
"@ultimat3/entity": "24.0.0",
|
|
38
|
+
"@ultimat3/schema": "24.0.0",
|
|
39
|
+
"@ultimat3/time": "24.0.0"
|
|
40
40
|
}
|
|
41
41
|
}
|
package/src/backfill-ledger.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// become — the checkpoints are transactional with the work, and this row is not.
|
|
11
11
|
|
|
12
12
|
import type { Clock } from '@ultimat3/core';
|
|
13
|
-
import { finiteCount, systemClock } from '@ultimat3/core';
|
|
13
|
+
import { describeValue, finiteCount, systemClock, UltimateError } from '@ultimat3/core';
|
|
14
14
|
import { nowMs } from './clock';
|
|
15
15
|
|
|
16
16
|
/**
|
|
@@ -56,6 +56,27 @@ export interface BackfillFilter {
|
|
|
56
56
|
readonly limit?: number | undefined;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/** What Postgres prints for a `uuid` — the one spelling both ledgers store identically. */
|
|
60
|
+
const RUN_ID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `BackfillFilter.runId`, refused by BOTH ledgers' `list` when it is not a uuid. `run_id` is a
|
|
64
|
+
* `uuid` column: Postgres raised a raw `22P02` where the memory ledger answered an empty page, so
|
|
65
|
+
* a mistyped id read as "no such pass" under `x dev` and as a database fault in production. The
|
|
66
|
+
* shape `outbox.ts` holds an enqueue's `runId` to — lowercase, so the two never disagree on case.
|
|
67
|
+
*/
|
|
68
|
+
export function listedRunId(subject: string, runId: unknown): string | undefined {
|
|
69
|
+
if (runId === undefined) return undefined;
|
|
70
|
+
if (typeof runId === 'string' && RUN_ID_SHAPE.test(runId)) return runId;
|
|
71
|
+
throw new UltimateError({
|
|
72
|
+
code: 'X_ID_INVALID',
|
|
73
|
+
// `describeValue`, never the value: what arrives here wrong is as often a key as a typo.
|
|
74
|
+
cause: `${subject} backfills.list was filtered by runId ${describeValue(runId)}, and a run id is a lowercase uuid (8-4-4-4-12 hex) — x_backfills.run_id is a uuid column`,
|
|
75
|
+
fix: 'x db backfill --list --json',
|
|
76
|
+
meta: { option: 'runId' },
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
59
80
|
/**
|
|
60
81
|
* Durable, and therefore behind the queue driver: `x_backfills` ships in the same DDL as `x_jobs`
|
|
61
82
|
* and `x_job_steps` (`driver-pg-sql.ts`), so a ledger a pass cannot write is a queue it could not
|
|
@@ -169,6 +190,7 @@ export function createMemoryBackfillLedger(clock: Clock = systemClock): Backfill
|
|
|
169
190
|
},
|
|
170
191
|
// `async`, so a refused limit REJECTS here as it does on the pg ledger — see `driver-memory.ts`.
|
|
171
192
|
async list(filter = {}) {
|
|
193
|
+
const runId = listedRunId('the memory ledger', filter.runId);
|
|
172
194
|
// Reversed BEFORE the sort: the test clock is frozen, so two rows share a `startedAt` and a
|
|
173
195
|
// stable sort would hand back the oldest of them first under a "newest first" contract.
|
|
174
196
|
const rows = [...runs.values()]
|
|
@@ -176,7 +198,7 @@ export function createMemoryBackfillLedger(clock: Clock = systemClock): Backfill
|
|
|
176
198
|
.sort((a, b) => b.startedAt - a.startedAt)
|
|
177
199
|
.filter((run) => filter.name === undefined || run.name === filter.name)
|
|
178
200
|
.filter((run) => filter.status === undefined || run.status === filter.status)
|
|
179
|
-
.filter((run) =>
|
|
201
|
+
.filter((run) => runId === undefined || run.runId === runId)
|
|
180
202
|
.slice(0, finiteCount('the backfill ledger list', 'limit', filter.limit ?? 100));
|
|
181
203
|
return rows;
|
|
182
204
|
},
|
package/src/backfill-pass.ts
CHANGED
|
@@ -164,7 +164,13 @@ export async function backfillPass<Row>(
|
|
|
164
164
|
// refusing it — the same degradation `introspect` already has.
|
|
165
165
|
const ledger = jobDriver()?.backfills;
|
|
166
166
|
|
|
167
|
-
const
|
|
167
|
+
const completed = await completedRun(ledger, name);
|
|
168
|
+
// Its OWN row is not a previous pass: a worker that died between `ledger.finish` and the ack is
|
|
169
|
+
// handed the same run again, and skipping there reported `{ skipped: true, previousRunId:
|
|
170
|
+
// <itself> }` for a pass that swept. It falls through and replays — every batch is served from
|
|
171
|
+
// its checkpoint, nothing is read or handled twice, and the report is the one it first made.
|
|
172
|
+
const redelivered = completed?.runId === runId;
|
|
173
|
+
const previous = redelivered ? undefined : completed;
|
|
168
174
|
const verdict = decideBackfill(previous, checksum, args.input.force === true);
|
|
169
175
|
if (!verdict.run) {
|
|
170
176
|
if (verdict.changed) {
|
|
@@ -196,7 +202,14 @@ export async function backfillPass<Row>(
|
|
|
196
202
|
definition.tenant,
|
|
197
203
|
args.ctx,
|
|
198
204
|
async (ctx): Promise<BackfillReport> => {
|
|
199
|
-
|
|
205
|
+
// A redelivered pass leaves its completed row ALONE until a batch body actually runs (an
|
|
206
|
+
// operator's `retryFromStep` re-opens one): replaying checkpoints sweeps nothing, so there is
|
|
207
|
+
// nothing to record and no convergence to re-ask — rows that started matching since would
|
|
208
|
+
// otherwise turn a finished pass into a failed one.
|
|
209
|
+
let recording = !redelivered;
|
|
210
|
+
const open = (): Promise<void> | undefined =>
|
|
211
|
+
ledger?.start({ runId, name, checksum, appVersion: appVersion() });
|
|
212
|
+
if (recording) await open();
|
|
200
213
|
|
|
201
214
|
let cursor: string | null = null;
|
|
202
215
|
let rows = 0;
|
|
@@ -242,6 +255,10 @@ export async function backfillPass<Row>(
|
|
|
242
255
|
const iteration = await iterate();
|
|
243
256
|
const next = await iteration.pull.next();
|
|
244
257
|
if (next.done === true) return { cursor: null, rows: 0 };
|
|
258
|
+
if (!recording) {
|
|
259
|
+
recording = true;
|
|
260
|
+
await open();
|
|
261
|
+
}
|
|
245
262
|
await definition.handle({ rows: next.value, ctx, signal, index });
|
|
246
263
|
swept = true;
|
|
247
264
|
return { cursor: iteration.batches.cursor, rows: next.value.length };
|
|
@@ -271,10 +288,10 @@ export async function backfillPass<Row>(
|
|
|
271
288
|
// this converge", asked once, where the answer is finally decidable. Inside the `try` so the
|
|
272
289
|
// ledger records the attempt as `failed`: a pass that left rows behind must not write the
|
|
273
290
|
// completed row that stops the next deploy re-running it.
|
|
274
|
-
await assertConverged(definition, ctx, rows);
|
|
291
|
+
if (recording) await assertConverged(definition, ctx, rows);
|
|
275
292
|
} catch (error) {
|
|
276
293
|
// Control flow, not a failure: a suspended run is parked and will be back on this step.
|
|
277
|
-
if (!isStepSuspension(error)) await markFailed(ledger, runId, rows);
|
|
294
|
+
if (!isStepSuspension(error) && recording) await markFailed(ledger, runId, rows);
|
|
278
295
|
throw error;
|
|
279
296
|
} finally {
|
|
280
297
|
// Whatever the iteration holds belongs to this attempt, and an attempt that failed, was
|
|
@@ -282,7 +299,7 @@ export async function backfillPass<Row>(
|
|
|
282
299
|
await live?.batches.close();
|
|
283
300
|
}
|
|
284
301
|
|
|
285
|
-
await ledger?.finish(runId, { status: 'completed', rows });
|
|
302
|
+
if (recording) await ledger?.finish(runId, { status: 'completed', rows });
|
|
286
303
|
return { name, batches, rows, skipped: false };
|
|
287
304
|
},
|
|
288
305
|
);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The claim round's half of a burial: a row the claim settled `dead` (`ClaimOptions.onExhausted`)
|
|
2
|
+
// has no body to end it and no settle to announce it, so the pass that buried it says so — one log
|
|
3
|
+
// line an operator can alert on, and the job's `onSettled`, exactly as a dead letter a worker
|
|
4
|
+
// settled itself would get.
|
|
5
|
+
|
|
6
|
+
import type { Ctx } from '@ultimat3/core';
|
|
7
|
+
import { logger, renderThrowable } from '@ultimat3/core';
|
|
8
|
+
import type { JobRecord } from './driver';
|
|
9
|
+
import { getJob } from './job';
|
|
10
|
+
import { announceSettled } from './settled';
|
|
11
|
+
|
|
12
|
+
export interface AnnounceExhaustedOptions {
|
|
13
|
+
/** What `onExhausted` handed this pass — every row the claim buried, as it now stands. */
|
|
14
|
+
readonly exhausted: readonly JobRecord[];
|
|
15
|
+
readonly workerId: string;
|
|
16
|
+
/** The worker's own context: what the job's `onSettled` is scoped from, as a body would be. */
|
|
17
|
+
readonly context: () => Ctx;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** What one pass buried, by how each row ended — what the worker's own counters take. */
|
|
21
|
+
export interface ExhaustedCounts {
|
|
22
|
+
readonly deadLettered: number;
|
|
23
|
+
readonly dropped: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Announce every row one claim pass buried, and answer how each ended. Once per row, because the
|
|
28
|
+
* claim buries a row once: the statement that settled it is the only one that hands it to
|
|
29
|
+
* `onExhausted`. A row buried `failed` is a job declaring `retry.deadLetter: false`
|
|
30
|
+
* (`ClaimOptions.dropExhausted`) — `dropped`, as its own nack would have ended it. Never rejects:
|
|
31
|
+
* `announceSettled` owns a hook's failure, and a `context()` that throws is logged.
|
|
32
|
+
*/
|
|
33
|
+
export async function announceExhausted(
|
|
34
|
+
options: AnnounceExhaustedOptions,
|
|
35
|
+
): Promise<ExhaustedCounts> {
|
|
36
|
+
let dropped = 0;
|
|
37
|
+
for (const record of options.exhausted) {
|
|
38
|
+
const error = record.lastError ?? '';
|
|
39
|
+
const outcome = record.state === 'failed' ? 'dropped' : 'dead-lettered';
|
|
40
|
+
if (outcome === 'dropped') dropped += 1;
|
|
41
|
+
// `error`, not `warn`: nobody recovered from this one, and a job that outlives every worker
|
|
42
|
+
// that takes it is a crash loop somebody has to look at.
|
|
43
|
+
logger.error('jobs.claim.exhausted', {
|
|
44
|
+
workerId: options.workerId,
|
|
45
|
+
job: record.name,
|
|
46
|
+
jobId: record.id,
|
|
47
|
+
attempt: record.attempt,
|
|
48
|
+
maxAttempts: record.maxAttempts,
|
|
49
|
+
outcome,
|
|
50
|
+
error,
|
|
51
|
+
});
|
|
52
|
+
const handle = getJob(record.name);
|
|
53
|
+
if (handle === undefined) continue;
|
|
54
|
+
let ctx: Ctx;
|
|
55
|
+
try {
|
|
56
|
+
ctx = options.context();
|
|
57
|
+
} catch (raised) {
|
|
58
|
+
// The app's own function, asked AFTER the row was settled: thrown, it failed the round.
|
|
59
|
+
logger.error('jobs.claim.exhausted-unannounced', {
|
|
60
|
+
workerId: options.workerId,
|
|
61
|
+
job: record.name,
|
|
62
|
+
jobId: record.id,
|
|
63
|
+
error: renderThrowable(raised),
|
|
64
|
+
});
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
await announceSettled({
|
|
68
|
+
handle,
|
|
69
|
+
claimed: record,
|
|
70
|
+
ctx,
|
|
71
|
+
// No code: nothing was thrown, and `JobFailed.code` is never invented.
|
|
72
|
+
settlement: { outcome, error, code: undefined },
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return { deadLettered: options.exhausted.length - dropped, dropped };
|
|
76
|
+
}
|
|
@@ -134,7 +134,7 @@ export function createMemoryOperator(state: MemoryQueueState): MemoryOperator {
|
|
|
134
134
|
const before =
|
|
135
135
|
filter.before === undefined
|
|
136
136
|
? undefined
|
|
137
|
-
: parseJobCursor(filter.before, 'the memory driver list');
|
|
137
|
+
: parseJobCursor(filter.before, 'the memory driver list', 'before');
|
|
138
138
|
const { idPrefix, createdFrom, createdTo, tenantId } = filter;
|
|
139
139
|
const matching = [...jobs.values()]
|
|
140
140
|
.filter((record) => filter.queue === undefined || record.queue === filter.queue)
|
|
@@ -168,16 +168,38 @@ export function createMemoryOperator(state: MemoryQueueState): MemoryOperator {
|
|
|
168
168
|
`requeueMany was asked for ${filter.state} jobs, and only a finished job can be requeued`,
|
|
169
169
|
"pass state: 'dead', 'failed', 'cancelled' or 'done' to requeueMany",
|
|
170
170
|
);
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
171
|
+
// A key a live job holds stays where it is — the single requeue's `X_JOB_DUPLICATE`,
|
|
172
|
+
// answered as "not this one" so one conflict does not fail a thousand rows.
|
|
173
|
+
const free = (record: JobRecord): boolean => state.liveHolder(record) === undefined;
|
|
174
|
+
// `SQL_JOB_REQUEUE_MANY`'s candidates: no live holder, and ONE row per idempotency namespace
|
|
175
|
+
// — the oldest — both decided BEFORE the bound. Decided inside `apply`, a held row spent a
|
|
176
|
+
// slot of `MAX_BULK_ROWS` and was then skipped: a thousand held rows ahead of one free row
|
|
177
|
+
// answered `affected: 0` here and `affected: 1` on Postgres.
|
|
178
|
+
const oldest = new Map<string, JobRecord>();
|
|
179
|
+
for (const record of jobs.values()) {
|
|
180
|
+
if (!inBulk(record, filter) || !free(record)) continue;
|
|
181
|
+
const key = JSON.stringify([record.name, record.tenantId ?? '', record.idempotencyKey]);
|
|
182
|
+
const seen = oldest.get(key);
|
|
183
|
+
if (seen === undefined || newerThan(seen, record)) oldest.set(key, record);
|
|
184
|
+
}
|
|
185
|
+
const candidates = new Set([...oldest.values()].map((record) => record.id));
|
|
186
|
+
const result = await bulk(
|
|
187
|
+
filter,
|
|
188
|
+
(record) => {
|
|
189
|
+
if (!free(record)) return false;
|
|
190
|
+
state.settle(record.id, { state: 'ready', attempt: 0, runAt: nowMs(clock) });
|
|
191
|
+
return true;
|
|
192
|
+
},
|
|
193
|
+
(record) => candidates.has(record.id),
|
|
194
|
+
);
|
|
178
195
|
// The pg driver's `SQL_WAKE`, in-process: an operator's repair starts now.
|
|
179
196
|
if (result.affected > 0) signalEnqueued(filter.queue);
|
|
180
|
-
|
|
197
|
+
// What a second call could still move: rows of the filter whose key no live job holds. A
|
|
198
|
+
// row requeued just now holds its key, so its dead siblings left with it.
|
|
199
|
+
const remaining = [...jobs.values()].filter(
|
|
200
|
+
(record) => inBulk(record, filter) && free(record),
|
|
201
|
+
).length;
|
|
202
|
+
return { affected: result.affected, remaining };
|
|
181
203
|
},
|
|
182
204
|
|
|
183
205
|
async promoteMany(filter) {
|