@ultimat3/jobs 20.2.1 → 22.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 +198 -927
- package/README.md +56 -6
- package/package.json +6 -6
- package/src/driver-memory.ts +31 -6
- package/src/driver-pg-jobs-sql.ts +34 -3
- package/src/driver-pg-sql.ts +5 -4
- package/src/driver-pg.ts +25 -13
- package/src/driver.ts +16 -1
- package/src/errors-declaration.ts +34 -0
- package/src/errors-requeue.ts +34 -0
- package/src/errors.ts +18 -4
- package/src/index.ts +2 -0
- package/src/job.ts +22 -3
- package/src/scheduler.ts +59 -38
- package/src/steps-memory.ts +5 -1
- package/src/steps.ts +21 -2
- package/src/task.ts +6 -1
- package/src/webhook-errors.ts +5 -2
- package/src/webhook-target.ts +98 -0
- package/src/webhook.ts +57 -17
- package/src/worker-hand-back.ts +30 -0
- package/src/worker-queue-depth.ts +61 -0
- package/src/worker.ts +47 -62
package/README.md
CHANGED
|
@@ -679,7 +679,7 @@ between them — swapping is `setJobDriver(other)`, and there is **no `jobs.driv
|
|
|
679
679
|
|
|
680
680
|
| Driver | Status | Backing | Use |
|
|
681
681
|
|---|---|---|---|
|
|
682
|
-
| `pg` | **default** | `SELECT ... FOR UPDATE SKIP LOCKED`, a partial unique index on `(name, idempotency_key)
|
|
682
|
+
| `pg` | **default** | `SELECT ... FOR UPDATE SKIP LOCKED`, a partial unique index on `(name, coalesce(tenant_id, ''), idempotency_key)` over live rows (`x_jobs_name_tenant_idempotency_live_idx`), lease-based leader, `x_job_leases` | zero-infra start, most apps |
|
|
683
683
|
| `memory` | complete | in-process maps | `x dev`, tests |
|
|
684
684
|
| `redis` | interface-complete, `X_NOT_IMPLEMENTED` | Streams + consumer groups | planned |
|
|
685
685
|
| `nats` | interface-complete, `X_NOT_IMPLEMENTED` | JetStream work queue | planned |
|
|
@@ -764,10 +764,10 @@ non-empty string is not a timezone: `tz: 'Bogota'` would resolve every occurrenc
|
|
|
764
764
|
run five hours off, silently, forever. `0 3 * * *` in a DST zone runs twice or zero times on
|
|
765
765
|
the switch day. Catch-up after downtime is explicit: `skip` (default) fires the latest missed
|
|
766
766
|
occurrence and drops the older ones, `run-once` fires the earliest missed one, `run-all` fires
|
|
767
|
-
every one of them. `maxCatchUp` (default 10) bounds the WALK for
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
767
|
+
every one of them. `maxCatchUp` (default 10) bounds the WALK for `run-all` and `run-once`: one
|
|
768
|
+
tick walks at most that many occurrences forward from the last fire. `skip` is not bounded by it —
|
|
769
|
+
after a long outage it fires the TRUE latest missed occurrence, found by bisection over the gap
|
|
770
|
+
(about 25 cron evaluations for three hours, 35 for a year), and moves the watermark there.
|
|
771
771
|
|
|
772
772
|
`run-once` fires **once**, not once per tick. Dropping the rest means the watermark passes them
|
|
773
773
|
too, so a scheduler back up after a day down enqueues one catch-up and then waits for the next
|
|
@@ -778,7 +778,10 @@ hourly task fire 24 catch-ups a second apart.
|
|
|
778
778
|
|
|
779
779
|
`{ attempts, backoff: 'exponential' | 'linear' | 'fixed', delay, maxDelay, jitter }`.
|
|
780
780
|
Equal jitter is on by default so a burst of failures does not retry in lockstep.
|
|
781
|
-
Exhausted jobs are dead-lettered, never dropped: `x jobs retry <id>`.
|
|
781
|
+
Exhausted jobs are dead-lettered, never dropped: `x jobs retry <id>`. Only a finished job — dead,
|
|
782
|
+
cancelled, failed or done — can be requeued: a live one is `X_JOB_NOT_REQUEUEABLE` (requeueing a
|
|
783
|
+
running job used to run it twice), and one whose idempotency key a live job now holds is
|
|
784
|
+
`X_JOB_DUPLICATE`. `--from-step <name>` drops that step and every step that started after it.
|
|
782
785
|
|
|
783
786
|
```
|
|
784
787
|
retrySchedule({ attempts: 5, backoff: 'exponential', delay: 1000 })
|
|
@@ -902,6 +905,53 @@ FOR a user takes that user's id in its input and re-authorises it in the body.
|
|
|
902
905
|
| `X_JOB_CONCURRENCY_UNENFORCEABLE` | a registered job declares `concurrency` and the driver has no lease store |
|
|
903
906
|
| `X_NOT_IMPLEMENTED` | redis / nats driver |
|
|
904
907
|
|
|
908
|
+
### Error classes
|
|
909
|
+
|
|
910
|
+
Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
|
|
911
|
+
a job boundary the class is gone and the `code` is what survives — match on that.
|
|
912
|
+
|
|
913
|
+
| Class | Code | Declared in |
|
|
914
|
+
|---|---|---|
|
|
915
|
+
| `ActionJobUnbridgedError` | `X_ACTION_JOB_UNBRIDGED` | `src/errors.ts` |
|
|
916
|
+
| `BackfillAppliedError` | `X_BACKFILL_APPLIED` | `src/backfill-errors.ts` |
|
|
917
|
+
| `BackfillEnvironmentError` | `X_BACKFILL_ENVIRONMENT` | `src/backfill-errors.ts` |
|
|
918
|
+
| `BackfillMigrationPendingError` | `X_BACKFILL_MIGRATION_PENDING` | `src/backfill-errors.ts` |
|
|
919
|
+
| `BackfillPendingError` | `X_BACKFILL_PENDING` | `src/backfill-errors.ts` |
|
|
920
|
+
| `BackfillRunningError` | `X_BACKFILL_RUNNING` | `src/backfill-errors.ts` |
|
|
921
|
+
| `BackfillStalledError` | `X_BACKFILL_STALLED` | `src/backfill-errors.ts` |
|
|
922
|
+
| `BackfillUnknownError` | `X_BACKFILL_UNKNOWN` | `src/backfill-errors.ts` |
|
|
923
|
+
| `CancelUnsupportedError` | `X_JOB_NOT_CANCELLABLE` | `src/errors.ts` |
|
|
924
|
+
| `ClaimQueuesEmptyError` | `X_JOB_CLAIM_QUEUES_EMPTY` | `src/errors.ts` |
|
|
925
|
+
| `ConcurrencyUnenforceableError` | `X_JOB_CONCURRENCY_UNENFORCEABLE` | `src/errors.ts` |
|
|
926
|
+
| `DriverUnavailableError` | `X_DRIVER_UNAVAILABLE` | `src/errors.ts` |
|
|
927
|
+
| `ExportPartTooLargeError` | `X_EXPORT_PART_TOO_LARGE` | `src/export-errors.ts` |
|
|
928
|
+
| `ExportRowInvalidError` | `X_EXPORT_ROW_INVALID` | `src/export-errors.ts` |
|
|
929
|
+
| `IdempotencyRequiredError` | `X_IDEMPOTENCY_REQUIRED` | `src/errors.ts` |
|
|
930
|
+
| `JobAbortedError` | `X_ABORTED` | `src/errors.ts` |
|
|
931
|
+
| `JobDeclarationInvalidError` | `X_JOB_DECLARATION_INVALID` | `src/errors-declaration.ts` |
|
|
932
|
+
| `JobDrainedError` | `X_DRAINING` | `src/errors.ts` |
|
|
933
|
+
| `JobDuplicateError` | `X_JOB_DUPLICATE` | `src/errors.ts` |
|
|
934
|
+
| `JobMaxAttemptsError` | `X_JOB_MAX_ATTEMPTS` | `src/errors.ts` |
|
|
935
|
+
| `JobNameTakenError` | `X_JOB_DUPLICATE` | `src/errors.ts` |
|
|
936
|
+
| `JobNotCancellableError` | `X_JOB_NOT_CANCELLABLE` | `src/errors.ts` |
|
|
937
|
+
| `JobNotRequeueableError` | `X_JOB_NOT_REQUEUEABLE` | `src/errors-requeue.ts` |
|
|
938
|
+
| `JobRowStatusUnknownError` | `X_JOB_ROW_STATUS_UNKNOWN` | `src/errors.ts` |
|
|
939
|
+
| `JobSlotLostError` | `X_JOB_SLOT_LOST` | `src/errors.ts` |
|
|
940
|
+
| `JobsNotImplementedError` | `X_NOT_IMPLEMENTED` | `src/errors.ts` |
|
|
941
|
+
| `JobTenantRequiredError` | `X_JOB_TENANT_REQUIRED` | `src/errors.ts` |
|
|
942
|
+
| `JobTimeoutError` | `X_JOB_TIMEOUT` | `src/errors.ts` |
|
|
943
|
+
| `LeaseLostError` | `X_JOB_LEASE_LOST` | `src/errors.ts` |
|
|
944
|
+
| `OutboxNoTxError` | `X_OUTBOX_NO_TX` | `src/errors.ts` |
|
|
945
|
+
| `StepDuplicateError` | `X_STEP_DUPLICATE` | `src/errors.ts` |
|
|
946
|
+
| `WebhookDeliveryFailedError` | `X_WEBHOOK_DELIVERY_FAILED` | `src/webhook-errors.ts` |
|
|
947
|
+
| `WebhookDeliveryRejectedError` | `X_WEBHOOK_DELIVERY_REJECTED` | `src/webhook-errors.ts` |
|
|
948
|
+
| `WebhookDeliveryThrottledError` | `X_WEBHOOK_DELIVERY_THROTTLED` | `src/webhook-errors.ts` |
|
|
949
|
+
| `WebhookEndpointDisabledError` | `X_WEBHOOK_ENDPOINT_DISABLED` | `src/webhook-errors.ts` |
|
|
950
|
+
| `WebhookEndpointInvalidError` | `X_WEBHOOK_ENDPOINT_INVALID` | `src/webhook-errors.ts` |
|
|
951
|
+
| `WebhookEndpointUnknownError` | `X_WEBHOOK_ENDPOINT_UNKNOWN` | `src/webhook-errors.ts` |
|
|
952
|
+
| `WebhookEventInvalidError` | `X_WEBHOOK_EVENT_INVALID` | `src/webhook-errors.ts` |
|
|
953
|
+
| `WebhookEventUnknownError` | `X_WEBHOOK_EVENT_UNKNOWN` | `src/webhook-errors.ts` |
|
|
954
|
+
|
|
905
955
|
## Boundary
|
|
906
956
|
|
|
907
957
|
Tier 3. Imports `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/entity`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/jobs",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "22.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": "22.0.0",
|
|
36
|
+
"@ultimat3/db": "22.0.0",
|
|
37
|
+
"@ultimat3/entity": "22.0.0",
|
|
38
|
+
"@ultimat3/schema": "22.0.0",
|
|
39
|
+
"@ultimat3/time": "22.0.0"
|
|
40
40
|
}
|
|
41
41
|
}
|
package/src/driver-memory.ts
CHANGED
|
@@ -20,8 +20,9 @@ import type {
|
|
|
20
20
|
NackOptions,
|
|
21
21
|
QueueStats,
|
|
22
22
|
} from './driver';
|
|
23
|
-
import { assertClaimBounds, assertClaimQueues, DEFAULT_QUEUE } from './driver';
|
|
23
|
+
import { assertClaimBounds, assertClaimQueues, DEFAULT_QUEUE, REQUEUEABLE_STATES } from './driver';
|
|
24
24
|
import { JobDuplicateError } from './errors';
|
|
25
|
+
import { JobNotRequeueableError, requeueKeyTaken } from './errors-requeue';
|
|
25
26
|
import type { LeaseStore } from './leases';
|
|
26
27
|
import { createMemoryLeaseStore } from './leases';
|
|
27
28
|
import type { StepStore } from './steps';
|
|
@@ -133,11 +134,30 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
133
134
|
'requeue a job id returned by enqueue() or stats() — the memory driver holds no state across processes, so an id from another run will not resolve',
|
|
134
135
|
);
|
|
135
136
|
const record = existing;
|
|
137
|
+
// The two refusals `SQL_JOB_REQUEUE` and `SQL_JOB_LIVE_HOLDER` give, before anything moves.
|
|
138
|
+
if (!REQUEUEABLE_STATES.has(record.state)) {
|
|
139
|
+
throw new JobNotRequeueableError({ jobId, state: record.state });
|
|
140
|
+
}
|
|
141
|
+
const holder = liveByKey(record.name, record.idempotencyKey, record.tenantId);
|
|
142
|
+
if (holder !== undefined && holder.id !== jobId) {
|
|
143
|
+
throw requeueKeyTaken({ ...record, holderId: holder.id });
|
|
144
|
+
}
|
|
136
145
|
if (requeueOptions?.fromStep !== undefined) {
|
|
137
|
-
//
|
|
138
|
-
|
|
146
|
+
// The target step and every step that started AFTER it — `SQL_STEPS_FROM`, whose header
|
|
147
|
+
// says why a tie is kept. Earlier steps stay memoized; a later one replaying the old run's
|
|
148
|
+
// result is what this prevents.
|
|
149
|
+
const all = await steps.list(record.runId);
|
|
150
|
+
const target = all.find((step) => step.name === requeueOptions.fromStep);
|
|
151
|
+
if (target !== undefined) {
|
|
152
|
+
for (const step of all) {
|
|
153
|
+
if (step === target || step.startedAt > target.startedAt) {
|
|
154
|
+
await steps.del(record.runId, step.name);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
139
158
|
}
|
|
140
|
-
|
|
159
|
+
// `settle` releases the claim — `SQL_JOB_REQUEUE` writes `claimed_by = null` too.
|
|
160
|
+
settle(jobId, { state: 'ready', attempt: 0, runAt: nowMs(clock) });
|
|
141
161
|
const next = jobs.get(jobId);
|
|
142
162
|
return next ?? record;
|
|
143
163
|
},
|
|
@@ -306,10 +326,15 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
306
326
|
oldestReadyMs: 0,
|
|
307
327
|
};
|
|
308
328
|
const next = { ...current };
|
|
309
|
-
|
|
329
|
+
// Due is due, whatever state the row was WRITTEN in: a job enqueued `delayed` stays
|
|
330
|
+
// `delayed` once its `runAt` passes (the claim scan reads `runAt`, nothing rewrites the
|
|
331
|
+
// state), and counted by state it never reached `ready` or `oldestReadyMs` — the backlog
|
|
332
|
+
// the autoscaler reads. `SQL_STATS` makes the same split.
|
|
333
|
+
const waiting = record.state === 'ready' || record.state === 'delayed';
|
|
334
|
+
if (waiting && record.runAt <= at) {
|
|
310
335
|
next.ready += 1;
|
|
311
336
|
next.oldestReadyMs = Math.max(next.oldestReadyMs, at - record.runAt);
|
|
312
|
-
} else if (
|
|
337
|
+
} else if (waiting) next.delayed += 1;
|
|
313
338
|
else if (record.state === 'running') next.running += 1;
|
|
314
339
|
else if (record.state === 'suspended') next.suspended += 1;
|
|
315
340
|
else if (record.state === 'dead') next.dead += 1;
|
|
@@ -36,10 +36,41 @@ select ${JOB_ROW_COLUMNS}
|
|
|
36
36
|
from x_jobs where state = 'dead' order by updated_at desc limit $1
|
|
37
37
|
`.trim();
|
|
38
38
|
|
|
39
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* `run_at = now()` makes the requeued job due immediately; the attempt counter starts over. Fenced
|
|
41
|
+
* to FINISHED states and releasing the claim: requeueing a running row left `claimed_by` set with
|
|
42
|
+
* state `ready`, and a second worker claimed it — the job ran twice. `REQUEUEABLE_STATES`
|
|
43
|
+
* (`driver.ts`) is the same set; a row this matches nothing for was refused before it was sent,
|
|
44
|
+
* or raced to live in between.
|
|
45
|
+
*/
|
|
40
46
|
export const SQL_JOB_REQUEUE = `
|
|
41
47
|
update x_jobs
|
|
42
|
-
set state = 'ready', attempt = 0, run_at = now(), updated_at = now()
|
|
43
|
-
|
|
48
|
+
set state = 'ready', attempt = 0, run_at = now(), updated_at = now(),
|
|
49
|
+
claimed_by = null, visible_at = null
|
|
50
|
+
where id = $1 and state in ('dead', 'cancelled', 'done', 'failed')
|
|
44
51
|
returning ${JOB_ROW_COLUMNS}
|
|
45
52
|
`.trim();
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The LIVE row holding a requeued row's key, if any — `x_jobs_name_tenant_idempotency_live_idx`'s
|
|
56
|
+
* predicate, read before the update so the answer is `X_JOB_DUPLICATE` rather than a raw 23505.
|
|
57
|
+
*/
|
|
58
|
+
export const SQL_JOB_LIVE_HOLDER = `
|
|
59
|
+
select id from x_jobs
|
|
60
|
+
where name = $1 and coalesce(tenant_id, '') = coalesce($2, '') and idempotency_key = $3
|
|
61
|
+
and id <> $4 and state in ('ready', 'delayed', 'running', 'suspended')
|
|
62
|
+
limit 1
|
|
63
|
+
`.trim();
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* "From that step onward": the step `fromStep` names and every step that started AFTER it.
|
|
67
|
+
* Deleting the named step alone left later steps replaying results computed from the old run.
|
|
68
|
+
* Strictly after, plus the step itself: a step sharing the target's millisecond is not provably
|
|
69
|
+
* later, and re-running an EARLIER step — the charge before the receipt — is the worse mistake.
|
|
70
|
+
*/
|
|
71
|
+
export const SQL_STEPS_FROM = `
|
|
72
|
+
delete from x_job_steps
|
|
73
|
+
where run_id = $1
|
|
74
|
+
and (name = $2
|
|
75
|
+
or started_at > (select started_at from x_job_steps where run_id = $1 and name = $2))
|
|
76
|
+
`.trim();
|
package/src/driver-pg-sql.ts
CHANGED
|
@@ -17,7 +17,9 @@ export {
|
|
|
17
17
|
SQL_JOB_DEAD_LETTERS,
|
|
18
18
|
SQL_JOB_GET,
|
|
19
19
|
SQL_JOB_LIST,
|
|
20
|
+
SQL_JOB_LIVE_HOLDER,
|
|
20
21
|
SQL_JOB_REQUEUE,
|
|
22
|
+
SQL_STEPS_FROM,
|
|
21
23
|
} from './driver-pg-jobs-sql';
|
|
22
24
|
|
|
23
25
|
import { JOB_ROW_COLUMNS } from './driver-pg-jobs-sql';
|
|
@@ -150,14 +152,13 @@ returning id
|
|
|
150
152
|
*/
|
|
151
153
|
export const SQL_STATS = `
|
|
152
154
|
select queue,
|
|
153
|
-
count(*) filter (where state
|
|
154
|
-
count(*) filter (where state
|
|
155
|
-
or (state = 'ready' and run_at > now())) as delayed,
|
|
155
|
+
count(*) filter (where state in ('ready', 'delayed') and run_at <= now()) as ready,
|
|
156
|
+
count(*) filter (where state in ('ready', 'delayed') and run_at > now()) as delayed,
|
|
156
157
|
count(*) filter (where state = 'running') as running,
|
|
157
158
|
count(*) filter (where state = 'suspended') as suspended,
|
|
158
159
|
count(*) filter (where state = 'dead') as dead,
|
|
159
160
|
coalesce(max(extract(epoch from now() - run_at)) filter
|
|
160
|
-
(where state
|
|
161
|
+
(where state in ('ready', 'delayed') and run_at <= now()), 0) * 1000 as oldest_ready_ms
|
|
161
162
|
from x_jobs
|
|
162
163
|
group by queue
|
|
163
164
|
order by queue
|
package/src/driver-pg.ts
CHANGED
|
@@ -20,7 +20,7 @@ import type {
|
|
|
20
20
|
NackOptions,
|
|
21
21
|
QueueStats,
|
|
22
22
|
} from './driver';
|
|
23
|
-
import { assertClaimBounds, assertClaimQueues, DEFAULT_QUEUE } from './driver';
|
|
23
|
+
import { assertClaimBounds, assertClaimQueues, DEFAULT_QUEUE, REQUEUEABLE_STATES } from './driver';
|
|
24
24
|
import type { BackfillRow, JobRow, StepRow } from './driver-pg-rows';
|
|
25
25
|
import { num, toBackfillRun, toJobRecord, toStepRecord } from './driver-pg-rows';
|
|
26
26
|
import {
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
SQL_JOB_DEAD_LETTERS,
|
|
39
39
|
SQL_JOB_GET,
|
|
40
40
|
SQL_JOB_LIST,
|
|
41
|
+
SQL_JOB_LIVE_HOLDER,
|
|
41
42
|
SQL_JOB_REQUEUE,
|
|
42
43
|
SQL_LEASE_ACQUIRE,
|
|
43
44
|
SQL_LEASE_RELEASE,
|
|
@@ -47,9 +48,11 @@ import {
|
|
|
47
48
|
SQL_STEP_GET,
|
|
48
49
|
SQL_STEP_LIST,
|
|
49
50
|
SQL_STEP_PUT,
|
|
51
|
+
SQL_STEPS_FROM,
|
|
50
52
|
SQL_TRY_ADVISORY_LOCK,
|
|
51
53
|
} from './driver-pg-sql';
|
|
52
54
|
import { DriverUnavailableError, JobDuplicateError } from './errors';
|
|
55
|
+
import { JobNotRequeueableError, requeueKeyTaken } from './errors-requeue';
|
|
53
56
|
import type { HeldLease, LeaseStore } from './leases';
|
|
54
57
|
import type { StepStore } from './steps';
|
|
55
58
|
|
|
@@ -213,24 +216,33 @@ export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
|
|
|
213
216
|
return rows.map(toJobRecord);
|
|
214
217
|
},
|
|
215
218
|
async requeue(jobId, requeueOptions) {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
if (current !== undefined) {
|
|
219
|
-
await exec().query(`delete from x_job_steps where run_id = $1 and name = $2`, [
|
|
220
|
-
current.runId,
|
|
221
|
-
requeueOptions.fromStep,
|
|
222
|
-
]);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
const rows = await exec().query<JobRow>(SQL_JOB_REQUEUE, [jobId]);
|
|
226
|
-
const row = rows[0];
|
|
227
|
-
if (row === undefined) {
|
|
219
|
+
const current = await this.job(jobId);
|
|
220
|
+
if (current === undefined) {
|
|
228
221
|
throw new DriverUnavailableError({
|
|
229
222
|
driver: 'pg',
|
|
230
223
|
cause: `job ${jobId} does not exist`,
|
|
231
224
|
fix: 'x jobs ls --state dead --json',
|
|
232
225
|
});
|
|
233
226
|
}
|
|
227
|
+
// Both refusals BEFORE anything is deleted or updated — a refused requeue changes nothing.
|
|
228
|
+
if (!REQUEUEABLE_STATES.has(current.state)) {
|
|
229
|
+
throw new JobNotRequeueableError({ jobId, state: current.state });
|
|
230
|
+
}
|
|
231
|
+
const holders = await exec().query<{ id: string }>(SQL_JOB_LIVE_HOLDER, [
|
|
232
|
+
current.name,
|
|
233
|
+
current.tenantId ?? null,
|
|
234
|
+
current.idempotencyKey,
|
|
235
|
+
jobId,
|
|
236
|
+
]);
|
|
237
|
+
const holder = holders[0];
|
|
238
|
+
if (holder !== undefined) throw requeueKeyTaken({ ...current, holderId: holder.id });
|
|
239
|
+
if (requeueOptions?.fromStep !== undefined) {
|
|
240
|
+
await exec().query(SQL_STEPS_FROM, [current.runId, requeueOptions.fromStep]);
|
|
241
|
+
}
|
|
242
|
+
const rows = await exec().query<JobRow>(SQL_JOB_REQUEUE, [jobId]);
|
|
243
|
+
const row = rows[0];
|
|
244
|
+
// Read as finished a moment ago and live now: another requeue won the race.
|
|
245
|
+
if (row === undefined) throw new JobNotRequeueableError({ jobId, state: 'live' });
|
|
234
246
|
return toJobRecord(row);
|
|
235
247
|
},
|
|
236
248
|
async cancel(jobId, reason) {
|
package/src/driver.ts
CHANGED
|
@@ -32,6 +32,17 @@ export const JOB_STATES = [
|
|
|
32
32
|
|
|
33
33
|
export type JobState = (typeof JOB_STATES)[number];
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* The states `requeue` — `x jobs retry` — accepts. Everything else is live: requeueing a running
|
|
37
|
+
* row ran it twice, and a queued one has nothing to retry. `SQL_JOB_REQUEUE` fences on the same set.
|
|
38
|
+
*/
|
|
39
|
+
export const REQUEUEABLE_STATES: ReadonlySet<JobState> = new Set<JobState>([
|
|
40
|
+
'dead',
|
|
41
|
+
'cancelled',
|
|
42
|
+
'done',
|
|
43
|
+
'failed',
|
|
44
|
+
]);
|
|
45
|
+
|
|
35
46
|
/** Narrows a state read back off a queue row. Never a cast — the list decides. */
|
|
36
47
|
export const isJobState = (value: string): value is JobState =>
|
|
37
48
|
(JOB_STATES as readonly string[]).includes(value);
|
|
@@ -175,7 +186,11 @@ export interface JobIntrospection {
|
|
|
175
186
|
job(jobId: string): Promise<JobRecord | undefined>;
|
|
176
187
|
list(filter?: JobFilter): Promise<readonly JobRecord[]>;
|
|
177
188
|
deadLetters(limit?: number): Promise<readonly JobRecord[]>;
|
|
178
|
-
/**
|
|
189
|
+
/**
|
|
190
|
+
* Re-queue a finished job (`REQUEUEABLE_STATES`); a live one is `X_JOB_NOT_REQUEUEABLE`, and a
|
|
191
|
+
* key a live job holds is `X_JOB_DUPLICATE`. `fromStep` drops that step and every step that
|
|
192
|
+
* started after it.
|
|
193
|
+
*/
|
|
179
194
|
requeue(jobId: string, options?: { readonly fromStep?: string }): Promise<JobRecord>;
|
|
180
195
|
/**
|
|
181
196
|
* Stop a job from outside. The only answer to a runaway pass that was otherwise "scale the
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Single responsibility: the refusal a `job()` declaration earns when it lacks a field every job
|
|
2
|
+
// declares. Its own module because `errors.ts` sits at the size ceiling; the CODE is registered
|
|
3
|
+
// there, beside every other jobs code, and only the constructor lives here.
|
|
4
|
+
|
|
5
|
+
import { UltimateError } from '@ultimat3/core';
|
|
6
|
+
|
|
7
|
+
/** The edit each required `job()` field takes, for `JobDeclarationInvalidError`'s fix line. */
|
|
8
|
+
const REQUIRED_FIELD_FIX = Object.freeze<Record<string, string>>({
|
|
9
|
+
input: 'input: t.object({ … })',
|
|
10
|
+
idempotencyKey: `idempotencyKey: (input) => \`<name>:\${input.id}\``,
|
|
11
|
+
tenant: "tenant: (input) => input.orgId — or tenant: 'none' for a job no org owns",
|
|
12
|
+
retry: "retry: { attempts: 5, backoff: 'exponential' }",
|
|
13
|
+
run: 'run: async ({ input, step }) => { … }',
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `job()` called without one or more fields the type requires — generated code and JS callers.
|
|
18
|
+
* EVERY missing field in one refusal: a missing `retry` used to crash the app loader with a bare
|
|
19
|
+
* `TypeError … definition.retry.attempts`, reported as `X_CLI_UNEXPECTED`, and fixing fields one
|
|
20
|
+
* restart at a time is the slow way to learn a declaration.
|
|
21
|
+
*/
|
|
22
|
+
export class JobDeclarationInvalidError extends UltimateError {
|
|
23
|
+
constructor(input: { job: string; missing: readonly string[] }) {
|
|
24
|
+
const edits = input.missing.map((field) =>
|
|
25
|
+
Object.hasOwn(REQUIRED_FIELD_FIX, field) ? REQUIRED_FIELD_FIX[field] : field,
|
|
26
|
+
);
|
|
27
|
+
super({
|
|
28
|
+
code: 'X_JOB_DECLARATION_INVALID',
|
|
29
|
+
cause: `job "${input.job}" is missing ${input.missing.join(', ')}, which every job declares`,
|
|
30
|
+
fix: `set ${edits.join('; ')} on job('${input.job}')`,
|
|
31
|
+
meta: { job: input.job, missing: [...input.missing] },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Single responsibility: the two refusals `requeue` — `x jobs retry` — gives before it moves a
|
|
2
|
+
// row. Its own module because `errors.ts` sits at the size ceiling; the codes are registered there.
|
|
3
|
+
|
|
4
|
+
import { renderFixShellArg, UltimateError } from '@ultimat3/core';
|
|
5
|
+
import { JobDuplicateError } from './errors';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `x jobs retry` on a job that is still live. Requeueing a RUNNING row left `claimed_by` set with
|
|
9
|
+
* state `ready`, so a second worker claimed it and it ran twice; a queued one has nothing to retry.
|
|
10
|
+
*/
|
|
11
|
+
export class JobNotRequeueableError extends UltimateError {
|
|
12
|
+
constructor(input: { jobId: string; state: string }) {
|
|
13
|
+
super({
|
|
14
|
+
code: 'X_JOB_NOT_REQUEUEABLE',
|
|
15
|
+
cause: `job ${input.jobId} is ${input.state}, and only a dead, cancelled, failed or done job can be requeued — a live one would run twice`,
|
|
16
|
+
fix: `x jobs cancel ${renderFixShellArg(input.jobId, '<job id>')} first, then x jobs retry ${renderFixShellArg(input.jobId, '<job id>')}`,
|
|
17
|
+
meta: { jobId: input.jobId, state: input.state },
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The error a requeue gives when a LIVE job already holds the requeued row's key. */
|
|
23
|
+
export const requeueKeyTaken = (record: {
|
|
24
|
+
readonly id: string;
|
|
25
|
+
readonly name: string;
|
|
26
|
+
readonly idempotencyKey: string;
|
|
27
|
+
readonly holderId: string;
|
|
28
|
+
}): JobDuplicateError =>
|
|
29
|
+
new JobDuplicateError({
|
|
30
|
+
job: record.name,
|
|
31
|
+
idempotencyKey: record.idempotencyKey,
|
|
32
|
+
existingId: record.holderId,
|
|
33
|
+
fix: `x jobs show ${renderFixShellArg(record.holderId, '<live job id>')} — that is the live run of this key; cancel it first (x jobs cancel ${renderFixShellArg(record.holderId, '<live job id>')}) only if this one must run instead`,
|
|
34
|
+
});
|
package/src/errors.ts
CHANGED
|
@@ -36,6 +36,8 @@ export const JOB_OWNED_ERROR_CODES = [
|
|
|
36
36
|
'X_WEBHOOK_DELIVERY_REJECTED',
|
|
37
37
|
'X_EXPORT_ROW_INVALID',
|
|
38
38
|
'X_EXPORT_PART_TOO_LARGE',
|
|
39
|
+
'X_JOB_DECLARATION_INVALID',
|
|
40
|
+
'X_JOB_NOT_REQUEUEABLE',
|
|
39
41
|
] as const;
|
|
40
42
|
|
|
41
43
|
/**
|
|
@@ -86,6 +88,8 @@ export const JOB_ERROR_TITLES: Readonly<Record<JobOwnedErrorCode, string>> = {
|
|
|
86
88
|
X_WEBHOOK_DELIVERY_REJECTED: 'the endpoint refused the delivery in a way a retry cannot change',
|
|
87
89
|
X_EXPORT_ROW_INVALID: 'row() answered something the declared columns do not carry',
|
|
88
90
|
X_EXPORT_PART_TOO_LARGE: 'one page encoded to more bytes than a part may hold',
|
|
91
|
+
X_JOB_DECLARATION_INVALID: 'the job declaration is missing a required field',
|
|
92
|
+
X_JOB_NOT_REQUEUEABLE: 'the job is still live and cannot be requeued',
|
|
89
93
|
};
|
|
90
94
|
|
|
91
95
|
// One unconditional call, so a second package claiming one of jobs' codes throws
|
|
@@ -148,11 +152,12 @@ registerErrorRetry({
|
|
|
148
152
|
|
|
149
153
|
/** An enqueue collided with a live job holding the same idempotency key under `onConflict: 'error'`. */
|
|
150
154
|
export class JobDuplicateError extends UltimateError {
|
|
151
|
-
|
|
155
|
+
/** `fix` for a caller the enqueue-time instruction does not fit — a requeue, where it is moot. */
|
|
156
|
+
constructor(input: { job: string; idempotencyKey: string; existingId: string; fix?: string }) {
|
|
152
157
|
super({
|
|
153
158
|
code: 'X_JOB_DUPLICATE',
|
|
154
159
|
cause: `job "${input.job}" already queued as ${input.existingId} with idempotencyKey "${input.idempotencyKey}"`,
|
|
155
|
-
fix: 'pass onConflict: "dedupe" to enqueue, or make idempotencyKey narrower',
|
|
160
|
+
fix: input.fix ?? 'pass onConflict: "dedupe" to enqueue, or make idempotencyKey narrower',
|
|
156
161
|
});
|
|
157
162
|
}
|
|
158
163
|
}
|
|
@@ -320,11 +325,20 @@ export class DriverUnavailableError extends UltimateError {
|
|
|
320
325
|
* generated code and JS callers, so the guarantee holds at both ends.
|
|
321
326
|
*/
|
|
322
327
|
export class IdempotencyRequiredError extends UltimateError {
|
|
323
|
-
|
|
328
|
+
/**
|
|
329
|
+
* `positional` is a name `job()` minted from a counter (`anonymous-job-3`). Spliced into the key
|
|
330
|
+
* template it would be written into persisted idempotency keys — and it MOVES when an import is
|
|
331
|
+
* added above the declaration, so every key written before the move stops matching. The fix
|
|
332
|
+
* names the edit instead of the counter.
|
|
333
|
+
*/
|
|
334
|
+
constructor(input: { job: string; positional?: boolean }) {
|
|
324
335
|
super({
|
|
325
336
|
code: 'X_IDEMPOTENCY_REQUIRED',
|
|
326
337
|
cause: `job "${input.job}" has no idempotencyKey — at-least-once delivery would run it twice`,
|
|
327
|
-
fix:
|
|
338
|
+
fix:
|
|
339
|
+
input.positional === true
|
|
340
|
+
? `give the job a stable name first — job({ name: 'syncInvoices', … }), or register it in apps/web/api/index.ts so its export name is stamped — then add idempotencyKey: (input) => \`syncInvoices:\${input.id}\``
|
|
341
|
+
: `add idempotencyKey: (input) => \`${input.job}:\${input.id}\` to the job definition`,
|
|
328
342
|
});
|
|
329
343
|
}
|
|
330
344
|
}
|
package/src/index.ts
CHANGED
|
@@ -173,6 +173,8 @@ export {
|
|
|
173
173
|
OutboxNoTxError,
|
|
174
174
|
StepDuplicateError,
|
|
175
175
|
} from './errors';
|
|
176
|
+
export { JobDeclarationInvalidError } from './errors-declaration';
|
|
177
|
+
export { JobNotRequeueableError } from './errors-requeue';
|
|
176
178
|
export type { EventBus, JobEvent, MemoryEventBusOptions, PublishOptions } from './events';
|
|
177
179
|
export { createMemoryEventBus, eventBus, publishEvent, setEventBus } from './events';
|
|
178
180
|
export type { PgEventBusOptions } from './events-pg';
|
package/src/job.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { describeJob } from './describe';
|
|
|
18
18
|
import type { EnqueueResult } from './driver';
|
|
19
19
|
import { DEFAULT_QUEUE } from './driver';
|
|
20
20
|
import { IdempotencyRequiredError, JobNameTakenError } from './errors';
|
|
21
|
+
import { JobDeclarationInvalidError } from './errors-declaration';
|
|
21
22
|
import { NO_TENANT, tenantKeyFrom } from './limits';
|
|
22
23
|
import type { EnqueueOptions } from './outbox';
|
|
23
24
|
import { jobsFacade } from './outbox';
|
|
@@ -178,13 +179,31 @@ interface JobOrigin {
|
|
|
178
179
|
|
|
179
180
|
const origin = new WeakMap<object, JobOrigin>();
|
|
180
181
|
|
|
182
|
+
/** The required fields a definition lacks, in declaration order. Read off the value, not the type. */
|
|
183
|
+
function missingJobFields(definition: object): string[] {
|
|
184
|
+
const loose = definition as unknown as Readonly<Record<string, unknown>>;
|
|
185
|
+
const missing: string[] = [];
|
|
186
|
+
if (typeof loose['input'] !== 'object' || loose['input'] === null) missing.push('input');
|
|
187
|
+
if (typeof loose['idempotencyKey'] !== 'function') missing.push('idempotencyKey');
|
|
188
|
+
if (loose['tenant'] === undefined) missing.push('tenant');
|
|
189
|
+
if (typeof loose['retry'] !== 'object' || loose['retry'] === null) missing.push('retry');
|
|
190
|
+
if (typeof loose['run'] !== 'function') missing.push('run');
|
|
191
|
+
return missing;
|
|
192
|
+
}
|
|
193
|
+
|
|
181
194
|
export function job<I>(definition: JobDefinition<I>): JobHandle<I> {
|
|
182
195
|
anonymous += 1;
|
|
183
196
|
const name = definition.name ?? `anonymous-job-${anonymous}`;
|
|
184
197
|
|
|
185
|
-
// Runtime backstops for generated code and JS callers; TS already forbids omitting
|
|
186
|
-
|
|
187
|
-
|
|
198
|
+
// Runtime backstops for generated code and JS callers; TS already forbids omitting any of them.
|
|
199
|
+
// Collected, never one at a time — see `JobDeclarationInvalidError`. The two fields with a code
|
|
200
|
+
// of their own keep it when they are the only thing missing: shipped codes keep their meaning.
|
|
201
|
+
const missing = missingJobFields(definition);
|
|
202
|
+
if (missing.length === 1 && missing[0] === 'idempotencyKey') {
|
|
203
|
+
throw new IdempotencyRequiredError({ job: name, positional: definition.name === undefined });
|
|
204
|
+
}
|
|
205
|
+
if (missing.length > 0 && !(missing.length === 1 && missing[0] === 'tenant')) {
|
|
206
|
+
throw new JobDeclarationInvalidError({ job: name, missing });
|
|
188
207
|
}
|
|
189
208
|
assertJobTenant(name, definition.tenant);
|
|
190
209
|
assert(
|