@ultimat3/jobs 2.0.0 → 4.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 +146 -15
- package/README.md +80 -4
- package/package.json +5 -5
- package/src/backfill-errors.ts +137 -0
- package/src/backfill-gate.ts +5 -5
- package/src/backfill-pass.ts +1 -1
- package/src/describe.ts +17 -1
- package/src/driver-memory.ts +34 -8
- package/src/driver-pg-ddl.ts +31 -6
- package/src/driver-pg-rows.ts +34 -6
- package/src/driver-pg-sql.ts +79 -9
- package/src/driver-pg.ts +15 -5
- package/src/driver.ts +36 -12
- package/src/errors.ts +81 -130
- package/src/execute.ts +33 -9
- package/src/heartbeat.ts +15 -13
- package/src/index.ts +23 -8
- package/src/job.ts +55 -1
- package/src/metrics.ts +1 -1
- package/src/outbox-lease.ts +29 -0
- package/src/outbox-pg.ts +58 -7
- package/src/outbox.ts +91 -8
- package/src/register.ts +25 -1
- package/src/renewal-timer.ts +35 -0
- package/src/retry-classification.ts +112 -0
- package/src/retry.ts +4 -3
- package/src/steps.ts +14 -1
- package/src/task.ts +29 -2
- package/src/worker-fleet-slots.ts +16 -11
- package/src/worker-run.ts +3 -0
- package/src/worker.ts +34 -9
package/src/errors.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The X_* codes owned by @ultimat3/jobs. Every one names the command or code change that
|
|
2
2
|
// fixes it — a job failure an agent cannot act on is a job failure that gets retried forever.
|
|
3
|
-
import { registerErrorCodes, UltimateError } from '@ultimat3/core';
|
|
3
|
+
import { registerErrorCodes, registerErrorRetry, UltimateError } from '@ultimat3/core';
|
|
4
4
|
|
|
5
5
|
/** Codes this package declares and owns. */
|
|
6
6
|
export const JOB_OWNED_ERROR_CODES = [
|
|
@@ -23,6 +23,8 @@ export const JOB_OWNED_ERROR_CODES = [
|
|
|
23
23
|
'X_BACKFILL_RUNNING',
|
|
24
24
|
'X_BACKFILL_STALLED',
|
|
25
25
|
'X_BACKFILL_UNKNOWN',
|
|
26
|
+
'X_JOB_ROW_STATUS_UNKNOWN',
|
|
27
|
+
'X_ACTION_JOB_UNBRIDGED',
|
|
26
28
|
] as const;
|
|
27
29
|
|
|
28
30
|
/**
|
|
@@ -58,6 +60,8 @@ export const JOB_ERROR_TITLES: Readonly<Record<JobOwnedErrorCode, string>> = {
|
|
|
58
60
|
X_BACKFILL_RUNNING: 'a pass under this name is already live',
|
|
59
61
|
X_BACKFILL_STALLED: 'the sweep ended with rows its own count still matches',
|
|
60
62
|
X_BACKFILL_UNKNOWN: 'no declaration carries this backfill name',
|
|
63
|
+
X_JOB_ROW_STATUS_UNKNOWN: 'a queue row carries a status this build does not know',
|
|
64
|
+
X_ACTION_JOB_UNBRIDGED: 'an action projection was registered as a job',
|
|
61
65
|
};
|
|
62
66
|
|
|
63
67
|
// One unconditional call, so a second package claiming one of jobs' codes throws
|
|
@@ -66,7 +70,31 @@ registerErrorCodes(
|
|
|
66
70
|
Object.fromEntries(Object.entries(JOB_ERROR_TITLES).map(([code, title]) => [code, { title }])),
|
|
67
71
|
);
|
|
68
72
|
|
|
69
|
-
|
|
73
|
+
/**
|
|
74
|
+
* The codes of this package's that can be thrown INSIDE a job body, classified — `executeJob`
|
|
75
|
+
* reads this, so a `terminal` one dead-letters on the attempt it happened instead of spending the
|
|
76
|
+
* whole policy on an answer that cannot change. Same rule every package uses: retryable means the
|
|
77
|
+
* same code, run again, has a real chance of a different answer.
|
|
78
|
+
*
|
|
79
|
+
* Two are deliberately absent. `X_JOB_LEASE_LOST` and `X_JOB_SLOT_LOST` mean the row is somebody
|
|
80
|
+
* else's now, so this attempt's verdict is not this attempt's to give: dead-lettering would settle
|
|
81
|
+
* a job another worker is running. They keep the attempt-count path, which ends in the queue
|
|
82
|
+
* re-delivering — the honest outcome for "we stopped owning it".
|
|
83
|
+
*/
|
|
84
|
+
registerErrorRetry({
|
|
85
|
+
X_JOB_TIMEOUT: 'retryable',
|
|
86
|
+
X_DRIVER_UNAVAILABLE: 'retryable',
|
|
87
|
+
// A second `step.run` under one name is a defect in the handler, replayed identically forever.
|
|
88
|
+
X_STEP_DUPLICATE: 'terminal',
|
|
89
|
+
// A sweep whose source ran dry while its own count still matches rows: the next attempt resumes
|
|
90
|
+
// at the cursor that just ran dry and diverges again.
|
|
91
|
+
X_BACKFILL_STALLED: 'terminal',
|
|
92
|
+
X_BACKFILL_ENVIRONMENT: 'terminal',
|
|
93
|
+
X_BACKFILL_APPLIED: 'terminal',
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/** Shared with `backfill-errors.ts`, which holds the seven `X_BACKFILL_*` classes. */
|
|
97
|
+
export const docsFor = (code: JobErrorCode): string => `https://ultimate.dev/errors/${code}`;
|
|
70
98
|
|
|
71
99
|
/** An enqueue collided with a live job holding the same idempotency key under `onConflict: 'error'`. */
|
|
72
100
|
export class JobDuplicateError extends UltimateError {
|
|
@@ -101,6 +129,57 @@ export class JobNameTakenError extends UltimateError {
|
|
|
101
129
|
}
|
|
102
130
|
}
|
|
103
131
|
|
|
132
|
+
/**
|
|
133
|
+
* A `text` status column holds a value outside the vocabulary this build compiled.
|
|
134
|
+
*
|
|
135
|
+
* Refused rather than passed through, because the alternative is what used to happen: the three
|
|
136
|
+
* decoders in `driver-pg-rows.ts` cast the column, and `stepRun`'s `existing?.status ===
|
|
137
|
+
* 'completed'` then read false for the laundered value and RE-EXECUTED the step. A second charge
|
|
138
|
+
* is a worse answer than a failed attempt, and "an unrecognised fact is never a satisfied one" is
|
|
139
|
+
* the rule the rest of the framework already follows.
|
|
140
|
+
*
|
|
141
|
+
* Almost always a NEWER deploy's row, not corruption: a status string only reaches the table
|
|
142
|
+
* because some version of this framework wrote it. A rolling deploy that only ADDS a status is
|
|
143
|
+
* safe in the normal direction — the new build knows every old value — and it is the old build
|
|
144
|
+
* reading the new build's row that lands here, on that one job, loudly.
|
|
145
|
+
*/
|
|
146
|
+
export class JobRowStatusUnknownError extends UltimateError {
|
|
147
|
+
constructor(input: { table: string; column: string; value: string; known: readonly string[] }) {
|
|
148
|
+
super({
|
|
149
|
+
code: 'X_JOB_ROW_STATUS_UNKNOWN',
|
|
150
|
+
cause:
|
|
151
|
+
`${input.table}.${input.column} holds "${input.value}", which this build does not know — ` +
|
|
152
|
+
`it reads ${input.known.join(', ')}`,
|
|
153
|
+
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
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* `registerJobs()` was handed `someAction.job()`.
|
|
161
|
+
*
|
|
162
|
+
* That call answers an `ActionJobHandle` — `kind: 'action-job'`, deliberately a different literal
|
|
163
|
+
* from `'job'` — which is the four fields `job()` takes, not a job. It cannot be one: `action` and
|
|
164
|
+
* `jobs` are both tier 3, so neither may import the other, and only `job()` seats a handle the
|
|
165
|
+
* queue, the worker and the manifest accept.
|
|
166
|
+
*
|
|
167
|
+
* Refused BY NAME rather than skipped, which is what used to happen. `registerJobs(module)` is
|
|
168
|
+
* handed a whole module namespace, so silently ignoring a constant or a helper exported beside a
|
|
169
|
+
* job is right — but ignoring this one meant `registerJobs({ publishPost: publishPost.job() })`
|
|
170
|
+
* registered nothing, returned `[]`, and the job never ran, with nothing failing anywhere.
|
|
171
|
+
*/
|
|
172
|
+
export class ActionJobUnbridgedError extends UltimateError {
|
|
173
|
+
constructor(input: { export: string; job: string }) {
|
|
174
|
+
super({
|
|
175
|
+
code: 'X_ACTION_JOB_UNBRIDGED',
|
|
176
|
+
cause: `export "${input.export}" is the action projection "${input.job}", which is not a job handle and cannot be registered as one`,
|
|
177
|
+
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
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
104
183
|
/** Two steps in one run share a name, so replay cannot tell their persisted results apart. */
|
|
105
184
|
export class StepDuplicateError extends UltimateError {
|
|
106
185
|
constructor(input: { job: string; step: string }) {
|
|
@@ -311,134 +390,6 @@ export class OutboxNoTxError extends UltimateError {
|
|
|
311
390
|
}
|
|
312
391
|
}
|
|
313
392
|
|
|
314
|
-
/**
|
|
315
|
-
* The seven backfill codes below all answer one question — "why is this sweep not running?" — and
|
|
316
|
-
* each is here because it sends the reader somewhere different: run it, force it, change
|
|
317
|
-
* environment, migrate first, wait, fix the predicates, or fix the name. A code that shared a fix
|
|
318
|
-
* line with another one would be code inflation, which is why `X_BACKFILL_WRITE_UNCONFIRMED` was
|
|
319
|
-
* considered and rejected: a dry run that wrote nothing did exactly what it was asked to.
|
|
320
|
-
*
|
|
321
|
-
* Every `fix` below is ONE line something can execute — a shell command, or the edit to make.
|
|
322
|
-
* Never a command with prose appended: a `fix:` is copied and run verbatim, so a trailing clause
|
|
323
|
-
* turns a working command into a syntax error at the one moment the reader is following it
|
|
324
|
-
* literally. Explanations belong in `cause`, which is read and never run.
|
|
325
|
-
*/
|
|
326
|
-
|
|
327
|
-
/**
|
|
328
|
-
* Declared and never completed. The alarm the framework did not have: an author could
|
|
329
|
-
* `x g backfill`, merge and deploy, and nothing anywhere said the pass had not run.
|
|
330
|
-
*/
|
|
331
|
-
export class BackfillPendingError extends UltimateError {
|
|
332
|
-
constructor(input: { backfill: string; environment: string }) {
|
|
333
|
-
super({
|
|
334
|
-
code: 'X_BACKFILL_PENDING',
|
|
335
|
-
cause: `backfill "${input.backfill}" is declared and x_backfills holds no completed pass for it in ${input.environment}`,
|
|
336
|
-
fix: `x db backfill ${input.backfill} --write --json`,
|
|
337
|
-
docs: docsFor('X_BACKFILL_PENDING'),
|
|
338
|
-
});
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
/** Already swept. A rerun is legitimate, so this names the flag rather than refusing outright. */
|
|
343
|
-
export class BackfillAppliedError extends UltimateError {
|
|
344
|
-
constructor(input: { backfill: string; runId: string; completedAt: string }) {
|
|
345
|
-
super({
|
|
346
|
-
code: 'X_BACKFILL_APPLIED',
|
|
347
|
-
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`,
|
|
348
|
-
fix: `x db backfill ${input.backfill} --write --force --json`,
|
|
349
|
-
docs: docsFor('X_BACKFILL_APPLIED'),
|
|
350
|
-
});
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/**
|
|
355
|
-
* The declaration names the environments it belongs to and this is not one. Declared DATA, never a
|
|
356
|
-
* hardcoded "cleanups are production": a staging rehearsal is correct practice, so which
|
|
357
|
-
* environments a sweep belongs to is the app's convention and this is only the mechanism carrying
|
|
358
|
-
* it (axiom 8).
|
|
359
|
-
*/
|
|
360
|
-
export class BackfillEnvironmentError extends UltimateError {
|
|
361
|
-
constructor(input: { backfill: string; environment: string; declared: readonly string[] }) {
|
|
362
|
-
// The first declared environment, because the fix has to be ONE runnable line and the list is
|
|
363
|
-
// ordered by the author. The empty case cannot arise from `checkBackfillEnvironment`, which
|
|
364
|
-
// treats an empty list as "every environment" — but this constructor is public, so it answers
|
|
365
|
-
// with the command that lists what IS declared rather than an `ULTIMATE_ENV=undefined`.
|
|
366
|
-
const target = input.declared[0];
|
|
367
|
-
super({
|
|
368
|
-
code: 'X_BACKFILL_ENVIRONMENT',
|
|
369
|
-
cause: `backfill "${input.backfill}" declares environments: ${input.declared.join(', ')} and this process resolved ${input.environment} — add "${input.environment}" to that list if this deploy should sweep too`,
|
|
370
|
-
fix:
|
|
371
|
-
target === undefined
|
|
372
|
-
? 'x db backfill --pending --json'
|
|
373
|
-
: `ULTIMATE_ENV=${target} x db backfill ${input.backfill} --write --json`,
|
|
374
|
-
docs: docsFor('X_BACKFILL_ENVIRONMENT'),
|
|
375
|
-
});
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
/**
|
|
380
|
-
* `requires` names a migration the ledger has not applied. Checked where `x_migrations` is
|
|
381
|
-
* readable — this package holds no `@ultimat3/db` dependency and growing one to read a ledger
|
|
382
|
-
* would put the migration engine on the tier-3 queue's import graph.
|
|
383
|
-
*/
|
|
384
|
-
export class BackfillMigrationPendingError extends UltimateError {
|
|
385
|
-
constructor(input: { backfill: string; migration: string }) {
|
|
386
|
-
super({
|
|
387
|
-
code: 'X_BACKFILL_MIGRATION_PENDING',
|
|
388
|
-
cause: `backfill "${input.backfill}" requires migration ${input.migration}, which x_migrations does not record as applied`,
|
|
389
|
-
fix: 'x db migrate --json',
|
|
390
|
-
docs: docsFor('X_BACKFILL_MIGRATION_PENDING'),
|
|
391
|
-
});
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
/**
|
|
396
|
-
* The enqueue deduped: one live pass per name, so this run is the one already going. Distinct from
|
|
397
|
-
* `X_BACKFILL_APPLIED`, which is a pass that finished — the response there is `--force`, and the
|
|
398
|
-
* response here is to look at the run that is holding the key.
|
|
399
|
-
*/
|
|
400
|
-
export class BackfillRunningError extends UltimateError {
|
|
401
|
-
constructor(input: { backfill: string; jobId: string }) {
|
|
402
|
-
super({
|
|
403
|
-
code: 'X_BACKFILL_RUNNING',
|
|
404
|
-
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`,
|
|
405
|
-
fix: `x jobs show ${input.jobId} --json`,
|
|
406
|
-
docs: docsFor('X_BACKFILL_RUNNING'),
|
|
407
|
-
});
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
/**
|
|
412
|
-
* The source ran out of rows and the declaration's own `count()` still matches some. Two
|
|
413
|
-
* predicates that disagree is an authoring bug in any business — the sweep reported success over
|
|
414
|
-
* rows it never visited — so the pass fails rather than writing a completed row nobody can trust.
|
|
415
|
-
*/
|
|
416
|
-
export class BackfillStalledError extends UltimateError {
|
|
417
|
-
constructor(input: { backfill: string; remaining: number; swept: number }) {
|
|
418
|
-
super({
|
|
419
|
-
code: 'X_BACKFILL_STALLED',
|
|
420
|
-
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`,
|
|
421
|
-
fix: `make count() select on exactly what source() selects on in backfill("${input.backfill}")`,
|
|
422
|
-
docs: docsFor('X_BACKFILL_STALLED'),
|
|
423
|
-
});
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
/** A name no declaration in this app carries — a typo, or a backfill whose module was deleted. */
|
|
428
|
-
export class BackfillUnknownError extends UltimateError {
|
|
429
|
-
constructor(input: { backfill: string; known: readonly string[] }) {
|
|
430
|
-
super({
|
|
431
|
-
code: 'X_BACKFILL_UNKNOWN',
|
|
432
|
-
cause:
|
|
433
|
-
input.known.length === 0
|
|
434
|
-
? `no backfill named "${input.backfill}" is declared, and this app declares none at all`
|
|
435
|
-
: `no backfill named "${input.backfill}" is declared (declared: ${input.known.join(', ')})`,
|
|
436
|
-
fix: 'x db backfill --pending --json',
|
|
437
|
-
docs: docsFor('X_BACKFILL_UNKNOWN'),
|
|
438
|
-
});
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
|
|
442
393
|
export class JobsNotImplementedError extends UltimateError {
|
|
443
394
|
constructor(input: { feature: string; fix: string }) {
|
|
444
395
|
super({
|
package/src/execute.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// One claimed job run to completion, suspension or failure and settled with the driver — the
|
|
2
|
-
// single execution path the worker loop
|
|
2
|
+
// single execution path, shared by the worker loop (`worker-run.ts`) and by the one caller outside
|
|
3
|
+
// this package, `@ultimat3/testing`'s job fixture. There is no `x jobs run` to share it with: the
|
|
4
|
+
// subcommands are `ls`, `show`, `retry`, `cancel`, `drain`. It owns the run's deadline, and
|
|
3
5
|
// a deadline here means CANCEL: the nack that follows makes the job claimable again, so a body
|
|
4
6
|
// still running past it would be a second copy of one job, racing the attempt that replaced it.
|
|
5
7
|
|
|
@@ -18,7 +20,8 @@ import type { ClaimedJob, JobDriver } from './driver';
|
|
|
18
20
|
import { JobAbortedError, JobTimeoutError } from './errors';
|
|
19
21
|
import { eventBus } from './events';
|
|
20
22
|
import type { AnyJobHandle } from './job';
|
|
21
|
-
import {
|
|
23
|
+
import type { JobStopReason } from './retry-classification';
|
|
24
|
+
import { nextRetryForError, recordedFailure } from './retry-classification';
|
|
22
25
|
import type { EventLookup, StepRecord } from './steps';
|
|
23
26
|
import { createStepRunner, isStepSuspension } from './steps';
|
|
24
27
|
import { jobRunActor } from './tenant';
|
|
@@ -69,6 +72,8 @@ export interface JobExecution {
|
|
|
69
72
|
readonly durationMs: number;
|
|
70
73
|
readonly resumeAt?: number;
|
|
71
74
|
readonly error?: string;
|
|
75
|
+
/** Why this attempt was the last. Absent while the job is still being retried. */
|
|
76
|
+
readonly stopReason?: JobStopReason;
|
|
72
77
|
readonly steps: readonly StepRecord[];
|
|
73
78
|
readonly replayed: readonly string[];
|
|
74
79
|
}
|
|
@@ -84,7 +89,8 @@ export interface ExecuteJobOptions {
|
|
|
84
89
|
|
|
85
90
|
/**
|
|
86
91
|
* Run one claimed job to completion, suspension or failure, and settle it with the driver.
|
|
87
|
-
* Shared by the worker loop and `
|
|
92
|
+
* Shared by the worker loop and by `@ultimat3/testing`'s job fixture, so a job under test takes
|
|
93
|
+
* exactly the code path the worker takes.
|
|
88
94
|
*/
|
|
89
95
|
export async function executeJob(options: ExecuteJobOptions): Promise<JobExecution> {
|
|
90
96
|
const { driver, claimed, handle } = options;
|
|
@@ -105,6 +111,11 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
|
|
|
105
111
|
jobName: handle.name,
|
|
106
112
|
store: driver.steps,
|
|
107
113
|
signal,
|
|
114
|
+
// The DECLARED per-step ceiling and event poll. Passed here or nowhere: this is the only
|
|
115
|
+
// production construction of a runner, so a `StepRunnerOptions` field it omits is a feature
|
|
116
|
+
// no `job()` can reach — which both of these were until 2026-08.
|
|
117
|
+
...(handle.stepTimeoutMs === undefined ? {} : { stepTimeoutMs: handle.stepTimeoutMs }),
|
|
118
|
+
...(handle.eventPollMs === undefined ? {} : { eventPollMs: handle.eventPollMs }),
|
|
108
119
|
...(options.clock === undefined ? {} : { clock: options.clock }),
|
|
109
120
|
events: options.events ?? eventBus(),
|
|
110
121
|
});
|
|
@@ -151,8 +162,10 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
|
|
|
151
162
|
} catch (error) {
|
|
152
163
|
if (isStepSuspension(error)) {
|
|
153
164
|
const delayMs = Math.max(0, error.resumeAt - nowMs(options.clock));
|
|
154
|
-
//
|
|
155
|
-
|
|
165
|
+
// `park: true` is the suspension itself — the row leaves the ready bucket — and
|
|
166
|
+
// `countsAsAttempt: false` only says not to burn an attempt on it. A limiter shed passes the
|
|
167
|
+
// second and not the first: it is a job still waiting, and it belongs in `queue_depth`.
|
|
168
|
+
await driver.nack(claimed.id, { delayMs, countsAsAttempt: false, park: true });
|
|
156
169
|
return settle({
|
|
157
170
|
outcome: 'suspended',
|
|
158
171
|
jobId: claimed.id,
|
|
@@ -166,10 +179,16 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
|
|
|
166
179
|
}
|
|
167
180
|
|
|
168
181
|
const message = error instanceof Error ? error.message : String(error);
|
|
169
|
-
|
|
182
|
+
// The ERROR decides too, not only the attempt count. A `terminal` code — a rotated password,
|
|
183
|
+
// a schema mismatch, a permission denial — fails identically on every remaining attempt, so
|
|
184
|
+
// spending them is a queue slot, a provider bill and, at a site that locks an account after
|
|
185
|
+
// three wrong passwords, the framework destroying what it was asked to read. A code nobody
|
|
186
|
+
// classified keeps the attempt-count path exactly as it was.
|
|
187
|
+
const decision = nextRetryForError(handle.retry, claimed.attempt, error);
|
|
188
|
+
const stop = decision.stoppedBy;
|
|
170
189
|
await driver.nack(claimed.id, {
|
|
171
190
|
delayMs: decision.delayMs,
|
|
172
|
-
error: message,
|
|
191
|
+
error: recordedFailure(message, decision),
|
|
173
192
|
countsAsAttempt: true,
|
|
174
193
|
deadLetter: !decision.retry && decision.deadLetter,
|
|
175
194
|
});
|
|
@@ -178,13 +197,16 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
|
|
|
178
197
|
jobId: claimed.id,
|
|
179
198
|
attempt: claimed.attempt,
|
|
180
199
|
retry: decision.retry,
|
|
200
|
+
// "stopped because terminal" and "stopped because the attempts ran out" are different
|
|
201
|
+
// incidents with the same `retry: false`, and only one of them is fixed by raising attempts.
|
|
202
|
+
...(stop === undefined ? {} : { stop }),
|
|
181
203
|
error: message,
|
|
182
204
|
});
|
|
183
205
|
// This package's ONE error-reporting call site, and it is here rather than in the loop because
|
|
184
206
|
// this is the only frame that still holds the thrown value — the loop sees a message string.
|
|
185
207
|
// A retry is a failure the framework recovered from, so it is a `warning`; a dead letter is
|
|
186
|
-
// one nobody recovered from.
|
|
187
|
-
// execution path means one place a failed job
|
|
208
|
+
// one nobody recovered from. A job driven by `@ultimat3/testing`'s fixture takes this path
|
|
209
|
+
// too, which is the point: one execution path means one place a failed job becomes visible.
|
|
188
210
|
reportError(error, {
|
|
189
211
|
source: 'job',
|
|
190
212
|
severity: decision.retry ? 'warning' : 'error',
|
|
@@ -195,6 +217,7 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
|
|
|
195
217
|
runId: claimed.runId,
|
|
196
218
|
attempt: claimed.attempt,
|
|
197
219
|
retry: decision.retry,
|
|
220
|
+
...(stop === undefined ? {} : { stop }),
|
|
198
221
|
},
|
|
199
222
|
},
|
|
200
223
|
});
|
|
@@ -205,6 +228,7 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
|
|
|
205
228
|
attempt: claimed.attempt,
|
|
206
229
|
durationMs: nowMs(options.clock) - startedAt,
|
|
207
230
|
error: message,
|
|
231
|
+
...(stop === undefined ? {} : { stopReason: stop }),
|
|
208
232
|
steps: [],
|
|
209
233
|
replayed: [],
|
|
210
234
|
});
|
package/src/heartbeat.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { logger, recordLeaseLost } from '@ultimat3/core';
|
|
|
8
8
|
import { nowMs } from './clock';
|
|
9
9
|
import type { ClaimedJob, JobDriver } from './driver';
|
|
10
10
|
import { LeaseLostError } from './errors';
|
|
11
|
+
import { startRenewalTimer } from './renewal-timer';
|
|
11
12
|
|
|
12
13
|
export interface LeaseHeartbeatOptions {
|
|
13
14
|
/** Only `heartbeat` is used — a lease renews itself and settles nothing. */
|
|
@@ -49,14 +50,8 @@ export function startLeaseHeartbeat(options: LeaseHeartbeatOptions): LeaseHeartb
|
|
|
49
50
|
let renewedAt = now();
|
|
50
51
|
let renewing = false;
|
|
51
52
|
let lost = false;
|
|
52
|
-
let timer: ReturnType<typeof setInterval> | undefined;
|
|
53
53
|
const gone = new AbortController();
|
|
54
54
|
|
|
55
|
-
const stop = (): void => {
|
|
56
|
-
if (timer !== undefined) clearInterval(timer);
|
|
57
|
-
timer = undefined;
|
|
58
|
-
};
|
|
59
|
-
|
|
60
55
|
const lapsed = (): boolean => now() - renewedAt >= visibilityTimeoutMs;
|
|
61
56
|
|
|
62
57
|
/**
|
|
@@ -65,9 +60,14 @@ export function startLeaseHeartbeat(options: LeaseHeartbeatOptions): LeaseHeartb
|
|
|
65
60
|
* ticked once per interval would count intervals, not jobs.
|
|
66
61
|
*/
|
|
67
62
|
const reportLost = (error?: unknown, reason?: 'expired' | 'not-ours'): void => {
|
|
68
|
-
|
|
63
|
+
// `stopped()` as well as `lost`, and it is the difference between a page and a fact: a clean
|
|
64
|
+
// completion acks the row out of `running` and `worker-run.ts` stops the heartbeat, so a
|
|
65
|
+
// renewal already in flight comes back `false` for a job that FINISHED. Reported, that is
|
|
66
|
+
// `jobs.lease.lost` at error plus `recordLeaseLost(queue)` — the one signal meaning the queue
|
|
67
|
+
// re-delivered a job this process was still running — raised for a run nobody re-delivered.
|
|
68
|
+
if (lost || timer.stopped()) return;
|
|
69
69
|
lost = true;
|
|
70
|
-
stop();
|
|
70
|
+
timer.stop();
|
|
71
71
|
logger.error('jobs.lease.lost', {
|
|
72
72
|
workerId,
|
|
73
73
|
job: claimed.name,
|
|
@@ -86,7 +86,7 @@ export function startLeaseHeartbeat(options: LeaseHeartbeatOptions): LeaseHeartb
|
|
|
86
86
|
};
|
|
87
87
|
|
|
88
88
|
const renew = async (): Promise<void> => {
|
|
89
|
-
if (lost) return;
|
|
89
|
+
if (lost || timer.stopped()) return;
|
|
90
90
|
// Expiry is decided BEFORE the driver is asked, because the failure that loses a lease most
|
|
91
91
|
// quietly is the one that never answers: a heartbeat hung on a dead connection neither
|
|
92
92
|
// resolves nor rejects, so a check that ran only on rejection would never run at all.
|
|
@@ -100,6 +100,10 @@ export function startLeaseHeartbeat(options: LeaseHeartbeatOptions): LeaseHeartb
|
|
|
100
100
|
renewing = true;
|
|
101
101
|
try {
|
|
102
102
|
const held = await options.driver.heartbeat(claimed.id, { visibilityTimeoutMs, workerId });
|
|
103
|
+
// Re-read AFTER the await, never only before it: the whole point of the flag is the answer
|
|
104
|
+
// that lands past `stop()`. Every branch below decides something about a lease this process
|
|
105
|
+
// may no longer be running under.
|
|
106
|
+
if (timer.stopped()) return;
|
|
103
107
|
// The driver answered, and it said the row is not ours. That is a DIFFERENT fact from an
|
|
104
108
|
// expired window and the only one an operator can cause on purpose: `x jobs cancel` writes
|
|
105
109
|
// a terminal state, and the renewal that misses it is what tells this attempt to stop. It
|
|
@@ -138,9 +142,7 @@ export function startLeaseHeartbeat(options: LeaseHeartbeatOptions): LeaseHeartb
|
|
|
138
142
|
}
|
|
139
143
|
};
|
|
140
144
|
|
|
141
|
-
timer =
|
|
142
|
-
void renew();
|
|
143
|
-
}, options.intervalMs);
|
|
145
|
+
const timer = startRenewalTimer(options.intervalMs, renew);
|
|
144
146
|
|
|
145
|
-
return { renew, lost: () => lost, signal: gone.signal, stop };
|
|
147
|
+
return { renew, lost: () => lost, signal: gone.signal, stop: () => timer.stop() };
|
|
146
148
|
}
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,15 @@ export type {
|
|
|
17
17
|
BackfillReport,
|
|
18
18
|
} from './backfill';
|
|
19
19
|
export { backfill, DEFAULT_BACKFILL_BATCH } from './backfill';
|
|
20
|
+
export {
|
|
21
|
+
BackfillAppliedError,
|
|
22
|
+
BackfillEnvironmentError,
|
|
23
|
+
BackfillMigrationPendingError,
|
|
24
|
+
BackfillPendingError,
|
|
25
|
+
BackfillRunningError,
|
|
26
|
+
BackfillStalledError,
|
|
27
|
+
BackfillUnknownError,
|
|
28
|
+
} from './backfill-errors';
|
|
20
29
|
export type { BackfillGate, BackfillGateInput } from './backfill-gate';
|
|
21
30
|
export { checkBackfillEnvironment, gateBackfill } from './backfill-gate';
|
|
22
31
|
export type { BackfillProgress } from './backfill-inspect';
|
|
@@ -77,11 +86,13 @@ export type {
|
|
|
77
86
|
export {
|
|
78
87
|
DEFAULT_QUEUE,
|
|
79
88
|
DEFAULT_VISIBILITY_TIMEOUT_MS,
|
|
89
|
+
isJobState,
|
|
90
|
+
JOB_STATES,
|
|
80
91
|
jobDriver,
|
|
81
92
|
resetJobDriver,
|
|
82
93
|
setJobDriver,
|
|
83
94
|
} from './driver';
|
|
84
|
-
export type { MemoryDriverOptions } from './driver-memory';
|
|
95
|
+
export type { MemoryDriverOptions, MemoryJobDriver } from './driver-memory';
|
|
85
96
|
export { createMemoryDriver } from './driver-memory';
|
|
86
97
|
export type { NatsDriverOptions } from './driver-nats';
|
|
87
98
|
export { createNatsDriver } from './driver-nats';
|
|
@@ -107,6 +118,7 @@ export {
|
|
|
107
118
|
SQL_NACK,
|
|
108
119
|
SQL_OUTBOX_CLAIM,
|
|
109
120
|
SQL_OUTBOX_MARK_PUBLISHED,
|
|
121
|
+
SQL_OUTBOX_RELEASE,
|
|
110
122
|
SQL_OUTBOX_STAGE,
|
|
111
123
|
SQL_OUTBOX_TABLE,
|
|
112
124
|
SQL_SCHEDULER_STATE_GET,
|
|
@@ -120,13 +132,7 @@ export type { RedisDriverOptions } from './driver-redis';
|
|
|
120
132
|
export { createRedisDriver } from './driver-redis';
|
|
121
133
|
export type { JobErrorCode } from './errors';
|
|
122
134
|
export {
|
|
123
|
-
|
|
124
|
-
BackfillEnvironmentError,
|
|
125
|
-
BackfillMigrationPendingError,
|
|
126
|
-
BackfillPendingError,
|
|
127
|
-
BackfillRunningError,
|
|
128
|
-
BackfillStalledError,
|
|
129
|
-
BackfillUnknownError,
|
|
135
|
+
ActionJobUnbridgedError,
|
|
130
136
|
CancelUnsupportedError,
|
|
131
137
|
ConcurrencyUnenforceableError,
|
|
132
138
|
DriverUnavailableError,
|
|
@@ -138,6 +144,7 @@ export {
|
|
|
138
144
|
JobMaxAttemptsError,
|
|
139
145
|
JobNameTakenError,
|
|
140
146
|
JobNotCancellableError,
|
|
147
|
+
JobRowStatusUnknownError,
|
|
141
148
|
JobSlotLostError,
|
|
142
149
|
JobsNotImplementedError,
|
|
143
150
|
JobTenantRequiredError,
|
|
@@ -191,6 +198,7 @@ export {
|
|
|
191
198
|
export type {
|
|
192
199
|
EnqueueOptions,
|
|
193
200
|
JobsFacade,
|
|
201
|
+
MemoryOutboxOptions,
|
|
194
202
|
MemoryOutboxStore,
|
|
195
203
|
OutboxDeps,
|
|
196
204
|
OutboxRecord,
|
|
@@ -207,11 +215,16 @@ export {
|
|
|
207
215
|
resetJobsFacade,
|
|
208
216
|
setJobsFacade,
|
|
209
217
|
} from './outbox';
|
|
218
|
+
// One definition of the lease, consumed by both stores — a memory default and a pg default that
|
|
219
|
+
// could drift are two answers to "how long is a claim mine for", and the shorter one duplicates.
|
|
220
|
+
export { DEFAULT_OUTBOX_CLAIM_LEASE_MS } from './outbox-lease';
|
|
210
221
|
export type { PgOutboxOptions } from './outbox-pg';
|
|
211
222
|
export { createPgOutboxStore } from './outbox-pg';
|
|
212
223
|
|
|
213
224
|
export type { BackoffStrategy, Random, RetryDecision, RetryPolicy } from './retry';
|
|
214
225
|
export { backoffDelayMs, DEFAULT_RETRY, nextRetry, retrySchedule } from './retry';
|
|
226
|
+
export type { JobRetryDecision, JobStopReason } from './retry-classification';
|
|
227
|
+
export { classifyThrown, nextRetryForError } from './retry-classification';
|
|
215
228
|
export type {
|
|
216
229
|
CronResolver,
|
|
217
230
|
DispatchedOccurrence,
|
|
@@ -241,8 +254,10 @@ export type {
|
|
|
241
254
|
export {
|
|
242
255
|
createMemoryStepStore,
|
|
243
256
|
createStepRunner,
|
|
257
|
+
isStepStatus,
|
|
244
258
|
isStepSuspension,
|
|
245
259
|
MAX_TRACE_NAMES,
|
|
260
|
+
STEP_STATUSES,
|
|
246
261
|
StepSuspension,
|
|
247
262
|
} from './steps';
|
|
248
263
|
export type {
|
package/src/job.ts
CHANGED
|
@@ -80,6 +80,22 @@ export interface JobDefinition<I> {
|
|
|
80
80
|
*/
|
|
81
81
|
readonly concurrency?: number;
|
|
82
82
|
readonly timeout?: DurationInput;
|
|
83
|
+
/**
|
|
84
|
+
* Ceiling for ONE `step.run`, where `timeout` is the ceiling for the whole attempt. Folded into
|
|
85
|
+
* the signal the step body is handed, so a body reads one signal and sees whichever deadline
|
|
86
|
+
* lands first — and it ABORTS before it rejects, because the attempt that replaces this one is
|
|
87
|
+
* claimable the moment the nack lands.
|
|
88
|
+
*
|
|
89
|
+
* Declared here and nowhere else: the runner has implemented this ceiling since 1.0 and no
|
|
90
|
+
* declaration could ask for it, which is a documented guarantee that does nothing.
|
|
91
|
+
*/
|
|
92
|
+
readonly stepTimeout?: DurationInput;
|
|
93
|
+
/**
|
|
94
|
+
* How long a `step.waitForEvent` parks between polls. Default 30s. Lower it for a wait a user
|
|
95
|
+
* is watching; the step suspends for exactly this long each time, so it is also the resolution
|
|
96
|
+
* of the resume, never a busy loop.
|
|
97
|
+
*/
|
|
98
|
+
readonly eventPoll?: DurationInput;
|
|
83
99
|
run(args: JobRunArgs<I>): Promise<unknown>;
|
|
84
100
|
}
|
|
85
101
|
|
|
@@ -114,6 +130,10 @@ export interface JobHandle<I = unknown> {
|
|
|
114
130
|
readonly retry: RetryPolicy;
|
|
115
131
|
readonly concurrency: number | undefined;
|
|
116
132
|
readonly timeoutMs: number | undefined;
|
|
133
|
+
/** The declared per-step ceiling in ms; `executeJob` hands it to the step runner. */
|
|
134
|
+
readonly stepTimeoutMs: number | undefined;
|
|
135
|
+
/** The declared event-poll interval in ms; `undefined` leaves the runner's 30s default. */
|
|
136
|
+
readonly eventPollMs: number | undefined;
|
|
117
137
|
readonly input: StandardSchemaV1<unknown, I>;
|
|
118
138
|
parse(raw: unknown): I;
|
|
119
139
|
idempotencyKeyFor(input: I): string;
|
|
@@ -183,6 +203,28 @@ export function job<I>(definition: JobDefinition<I>): JobHandle<I> {
|
|
|
183
203
|
`set a whole concurrency of 1 or more on job("${name}"), or omit the field for no cap at all`,
|
|
184
204
|
);
|
|
185
205
|
|
|
206
|
+
const stepTimeoutMs =
|
|
207
|
+
definition.stepTimeout === undefined ? undefined : toMs(definition.stepTimeout);
|
|
208
|
+
const eventPollMs = definition.eventPoll === undefined ? undefined : toMs(definition.eventPoll);
|
|
209
|
+
// `withStepTimeout` reads `<= 0` as "no ceiling at all" and a poll of zero is a suspension that
|
|
210
|
+
// resumes immediately, forever. Both are an author who asked for a limit and got the opposite,
|
|
211
|
+
// so they are refused where they are written — the same answer `concurrency: 0` gets.
|
|
212
|
+
//
|
|
213
|
+
// FINITE, not merely positive: `> 0` admits `Infinity`, which is the same defect spelled the
|
|
214
|
+
// other way. `eventPoll: Infinity` parks a waiting step and schedules the poll that would wake
|
|
215
|
+
// it for never; `stepTimeout: Infinity` is a ceiling no step can reach. `NaN` fails `> 0` on its
|
|
216
|
+
// own, and is covered here so the predicate says what it means rather than passing by accident.
|
|
217
|
+
assert(
|
|
218
|
+
stepTimeoutMs === undefined || (Number.isFinite(stepTimeoutMs) && stepTimeoutMs > 0),
|
|
219
|
+
`job "${name}" declares stepTimeout ${String(definition.stepTimeout)}, which is no ceiling at all`,
|
|
220
|
+
`set a finite positive stepTimeout on job("${name}") — "30s" or 30_000 — or omit the field for no per-step ceiling`,
|
|
221
|
+
);
|
|
222
|
+
assert(
|
|
223
|
+
eventPollMs === undefined || (Number.isFinite(eventPollMs) && eventPollMs > 0),
|
|
224
|
+
`job "${name}" declares eventPoll ${String(definition.eventPoll)}, which parks a waiting step for no time at all`,
|
|
225
|
+
`set a finite positive eventPoll on job("${name}") — "5s" or 5_000 — or omit the field for the 30s default`,
|
|
226
|
+
);
|
|
227
|
+
|
|
186
228
|
const handle: JobHandle<I> = {
|
|
187
229
|
kind: 'job',
|
|
188
230
|
name,
|
|
@@ -190,6 +232,8 @@ export function job<I>(definition: JobDefinition<I>): JobHandle<I> {
|
|
|
190
232
|
retry: { ...DEFAULT_RETRY, ...definition.retry },
|
|
191
233
|
concurrency: definition.concurrency,
|
|
192
234
|
timeoutMs: definition.timeout === undefined ? undefined : toMs(definition.timeout),
|
|
235
|
+
stepTimeoutMs,
|
|
236
|
+
eventPollMs,
|
|
193
237
|
input: definition.input,
|
|
194
238
|
parse(raw: unknown): I {
|
|
195
239
|
return parse(definition.input, raw) as I;
|
|
@@ -311,8 +355,18 @@ export function getJob(name: string): AnyJobHandle | undefined {
|
|
|
311
355
|
return registry.get(name);
|
|
312
356
|
}
|
|
313
357
|
|
|
358
|
+
/**
|
|
359
|
+
* Code-unit compare, never `localeCompare`. This list is projected into `x.manifest.json`, which
|
|
360
|
+
* both tracked apps COMMIT and `x verify`'s drift step diffs byte for byte — and `localeCompare`
|
|
361
|
+
* with no locale argument answers from the runtime's ICU default and collation version, so the
|
|
362
|
+
* same source could sort two ways on two machines. `@ultimat3/http`'s `describeRoutes` states the
|
|
363
|
+
* same rule; the comparator is restated rather than imported because `http` is not below this
|
|
364
|
+
* package on the tier table.
|
|
365
|
+
*/
|
|
366
|
+
const byName = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
|
367
|
+
|
|
314
368
|
export function registeredJobs(): readonly AnyJobHandle[] {
|
|
315
|
-
return [...registry.values()].sort((a, b) => a.name
|
|
369
|
+
return [...registry.values()].sort((a, b) => byName(a.name, b.name));
|
|
316
370
|
}
|
|
317
371
|
|
|
318
372
|
export function resetJobs(): void {
|
package/src/metrics.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { gauge } from '@ultimat3/core';
|
|
|
18
18
|
/** Seconds and not milliseconds: every Prometheus duration is seconds, and the alert is `> 300`. */
|
|
19
19
|
export const queueOldestReady: Gauge = gauge('queue_oldest_ready_seconds', {
|
|
20
20
|
unit: 's',
|
|
21
|
-
description: 'Age of the oldest
|
|
21
|
+
description: 'Age of the oldest job that is ready and due, by queue — 0 when none is',
|
|
22
22
|
});
|
|
23
23
|
|
|
24
24
|
export const queueDeadJobs: Gauge = gauge('queue_dead_jobs', {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The claim lease's one definition and its one normalisation. Both outbox stores read it, because
|
|
2
|
+
// a lease the memory store defaults and the pg store validates is two answers to "how long is a
|
|
3
|
+
// claim mine for" — and the shorter of the two is a row published twice.
|
|
4
|
+
|
|
5
|
+
import { assert } from '@ultimat3/core';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* How long a claimed row stays its claimant's. Long enough that no healthy pass loses a batch it
|
|
9
|
+
* is still publishing, short enough that a relay killed mid-batch does not strand one for minutes.
|
|
10
|
+
*/
|
|
11
|
+
export const DEFAULT_OUTBOX_CLAIM_LEASE_MS = 30_000;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Refused where it is written, the way `concurrency: 0` and `stepTimeout: 0` are. A lease of `0`
|
|
15
|
+
* expires before `claim()` resolves, so every relay reclaims every row on every tick and the lease
|
|
16
|
+
* buys nothing; a fractional one is compared against `now()` in Postgres and against whole ms
|
|
17
|
+
* here; `Infinity` never expires, so the rows of a relay that died are stranded forever — the one
|
|
18
|
+
* failure the lease exists to bound. `X_INVARIANT` because this is a caller-argument check with no
|
|
19
|
+
* dedicated code, the generic `@ultimat3/db` already borrows for the same shape.
|
|
20
|
+
*/
|
|
21
|
+
export function resolveClaimLeaseMs(value: number | undefined): number {
|
|
22
|
+
if (value === undefined) return DEFAULT_OUTBOX_CLAIM_LEASE_MS;
|
|
23
|
+
assert(
|
|
24
|
+
Number.isInteger(value) && value > 0,
|
|
25
|
+
`outbox claimLeaseMs is ${String(value)}, which is not a positive whole number of milliseconds`,
|
|
26
|
+
'pass a positive whole claimLeaseMs: 30_000 — createPgOutboxStore({ executor, txExecutor, claimLeaseMs: 30_000 }) — or omit the field for the 30s default',
|
|
27
|
+
);
|
|
28
|
+
return value;
|
|
29
|
+
}
|