@ultimat3/jobs 23.0.0 → 25.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 +72 -75
- package/README.md +83 -38
- package/package.json +7 -7
- package/src/backfill-errors.ts +3 -2
- package/src/backfill-ledger.ts +25 -3
- package/src/backfill-pass.ts +22 -5
- package/src/backfill-rate.ts +2 -2
- package/src/backfill.ts +2 -2
- package/src/claim-exhausted.ts +76 -0
- package/src/counters.ts +1 -1
- package/src/driver-memory-operator.ts +34 -12
- package/src/driver-memory.ts +80 -38
- 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-settle-sql.ts +2 -3
- package/src/driver-pg-sql.ts +99 -30
- package/src/driver-pg.ts +103 -98
- package/src/driver.ts +46 -16
- package/src/errors-concurrency.ts +1 -1
- package/src/errors-operator.ts +12 -4
- package/src/errors-requeue.ts +21 -3
- package/src/errors.ts +9 -17
- package/src/events-pg.ts +11 -21
- package/src/events.ts +26 -15
- package/src/execute.ts +47 -5
- package/src/export.ts +2 -2
- package/src/heartbeat.ts +7 -2
- package/src/index.ts +45 -99
- package/src/inspect-operator.ts +4 -4
- package/src/inspect.ts +5 -4
- package/src/introspection.ts +23 -11
- package/src/job.ts +6 -7
- package/src/leases.ts +1 -1
- package/src/limits.ts +46 -15
- package/src/outbox-lease.ts +1 -1
- package/src/outbox-pg.ts +6 -7
- package/src/outbox-relay.ts +6 -6
- package/src/outbox.ts +47 -19
- package/src/purge.ts +1 -1
- package/src/queue-wake.ts +1 -2
- package/src/renewal-timer.ts +1 -1
- package/src/retry-classification.ts +52 -15
- package/src/scheduler-leader.ts +2 -2
- package/src/scheduler-pg.ts +19 -16
- package/src/scheduler-state.ts +10 -3
- package/src/scheduler.ts +14 -15
- package/src/steps-memory.ts +2 -2
- package/src/steps-suspension.ts +46 -0
- package/src/steps-timeout.ts +33 -0
- package/src/steps.ts +90 -88
- package/src/task.ts +1 -1
- package/src/webhook-attempt.ts +206 -0
- package/src/webhook-ledger.ts +15 -4
- package/src/webhook.ts +64 -118
- package/src/worker-admit.ts +14 -6
- package/src/worker-drain-cutoff.ts +110 -0
- package/src/worker-fleet-slots.ts +82 -45
- package/src/worker-held.ts +100 -0
- package/src/worker-key-busy.ts +38 -10
- package/src/worker-loop.ts +35 -11
- package/src/worker-options.ts +19 -15
- package/src/worker-registry.ts +16 -1
- package/src/worker-run.ts +38 -8
- package/src/worker-tally.ts +53 -0
- package/src/worker-types.ts +7 -4
- package/src/worker.ts +124 -73
- package/src/driver-nats.ts +0 -86
- package/src/driver-redis.ts +0 -89
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/jobs",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "25.0.0",
|
|
4
4
|
"description": "Durable background work: steps, transactional outbox, cron tasks, one driver interface",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -25,17 +25,17 @@
|
|
|
25
25
|
"LICENSE"
|
|
26
26
|
],
|
|
27
27
|
"engines": {
|
|
28
|
-
"bun": ">=1.4.
|
|
28
|
+
"bun": ">=1.4.2"
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
31
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
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": "25.0.0",
|
|
36
|
+
"@ultimat3/db": "25.0.0",
|
|
37
|
+
"@ultimat3/entity": "25.0.0",
|
|
38
|
+
"@ultimat3/schema": "25.0.0",
|
|
39
|
+
"@ultimat3/time": "25.0.0"
|
|
40
40
|
}
|
|
41
41
|
}
|
package/src/backfill-errors.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// declared in ONE place; only the classes that throw them live here, beside `backfill-ledger.ts`,
|
|
6
6
|
// `backfill-pending.ts` and `backfill-registry.ts`.
|
|
7
7
|
|
|
8
|
-
import { UltimateError } from '@ultimat3/core';
|
|
8
|
+
import { renderFixShellArg, UltimateError } from '@ultimat3/core';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* The seven backfill codes below all answer one question — "why is this sweep not running?" — and
|
|
@@ -64,7 +64,8 @@ export class BackfillEnvironmentError extends UltimateError {
|
|
|
64
64
|
fix:
|
|
65
65
|
target === undefined
|
|
66
66
|
? 'x db backfill --pending --json'
|
|
67
|
-
:
|
|
67
|
+
: // Both are the declaration's own strings and the class is public: screened (sweep 1c).
|
|
68
|
+
`ULTIMATE_ENV=${renderFixShellArg(target, "'<environment>'")} x db backfill ${renderFixShellArg(input.backfill, "'<backfill>'")} --write --json`,
|
|
68
69
|
});
|
|
69
70
|
}
|
|
70
71
|
}
|
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
|
|
@@ -132,7 +153,7 @@ export function decideBackfill(
|
|
|
132
153
|
return { run: force, previous: completed, changed: completed.checksum !== checksum };
|
|
133
154
|
}
|
|
134
155
|
|
|
135
|
-
export function
|
|
156
|
+
export function memoryBackfillLedger(clock: Clock = systemClock): BackfillLedger {
|
|
136
157
|
const runs = new Map<string, BackfillRun>();
|
|
137
158
|
const patch = (runId: string, fields: Partial<BackfillRun>): void => {
|
|
138
159
|
const existing = runs.get(runId);
|
|
@@ -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
|
);
|
package/src/backfill-rate.ts
CHANGED
|
@@ -69,14 +69,14 @@ function timerSleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
|
69
69
|
* rate is a property of the table being swept and the pool it is swept through, not of whichever
|
|
70
70
|
* attempt happens to hold the run.
|
|
71
71
|
*/
|
|
72
|
-
export function
|
|
72
|
+
export function backfillPacer(options: PacerOptions): Pacer {
|
|
73
73
|
// Refused HERE and not only at `backfill()`: `rate: 0` makes `intervalMs` Infinity, which the
|
|
74
74
|
// timer clamps to about a millisecond — so an unvalidated zero reads as "no throttle at all",
|
|
75
75
|
// which is the one setting this module exists to make unreachable. A negative rate is the same
|
|
76
76
|
// bug with a negative wait.
|
|
77
77
|
assert(
|
|
78
78
|
Number.isFinite(options.rate) && options.rate > 0,
|
|
79
|
-
`
|
|
79
|
+
`backfillPacer({ rate: ${String(options.rate)} }) for "${options.job}" — a rate is batches per second, greater than zero`,
|
|
80
80
|
`pass rate: ${DEFAULT_BACKFILL_RATE} — to sweep faster raise the number, there is no unthrottled mode`,
|
|
81
81
|
);
|
|
82
82
|
const clock = options.clock ?? systemClock;
|
package/src/backfill.ts
CHANGED
|
@@ -15,7 +15,7 @@ import type { ReadBuilder } from '@ultimat3/entity';
|
|
|
15
15
|
import { t } from '@ultimat3/schema';
|
|
16
16
|
import { backfillChecksum } from './backfill-ledger';
|
|
17
17
|
import { backfillPass } from './backfill-pass';
|
|
18
|
-
import {
|
|
18
|
+
import { backfillPacer, DEFAULT_BACKFILL_RATE } from './backfill-rate';
|
|
19
19
|
import type { BackfillCount } from './backfill-registry';
|
|
20
20
|
import { stampBackfill } from './backfill-registry';
|
|
21
21
|
import type { DurationInput } from './clock';
|
|
@@ -176,7 +176,7 @@ export function backfill<Row>(definition: BackfillDefinition<Row>): JobHandle<Ba
|
|
|
176
176
|
const checksum = backfillChecksum(definition.source, definition.handle);
|
|
177
177
|
// Built here rather than per attempt: the interval belongs to the table and the pool, not to
|
|
178
178
|
// whichever attempt holds the run, so a retrying pass keeps the pace it was declared with.
|
|
179
|
-
const pace =
|
|
179
|
+
const pace = backfillPacer({ rate, job: definition.name });
|
|
180
180
|
|
|
181
181
|
// Bound to the definition rather than passed bare: `count` is declared as a method, so a
|
|
182
182
|
// reference torn off the object literal would run with `this` undefined the first time an
|
|
@@ -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
|
+
}
|
package/src/counters.ts
CHANGED
|
@@ -45,7 +45,7 @@ export interface MemoryCounters {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/** The memory driver's counters. Same buckets, same tiers, same fold as `x_job_counters`. */
|
|
48
|
-
export function
|
|
48
|
+
export function memoryCounters(): MemoryCounters {
|
|
49
49
|
const buckets = new Map<string, JobCounter>();
|
|
50
50
|
const keyOf = (job: string, bucketMs: number, bucketStart: number): string =>
|
|
51
51
|
`${bucketMs}\u0000${bucketStart}\u0000${job}`;
|
|
@@ -7,7 +7,7 @@ import type { Clock } from '@ultimat3/core';
|
|
|
7
7
|
import { assert, finiteOption } from '@ultimat3/core';
|
|
8
8
|
import { nowMs } from './clock';
|
|
9
9
|
import type { MemoryCounters } from './counters';
|
|
10
|
-
import {
|
|
10
|
+
import { memoryCounters } from './counters';
|
|
11
11
|
import type { JobRecord } from './driver';
|
|
12
12
|
import { REQUEUEABLE_STATES } from './driver';
|
|
13
13
|
import { signalEnqueued } from './enqueue-signal';
|
|
@@ -92,9 +92,9 @@ function pauseSet(clock: Clock): {
|
|
|
92
92
|
};
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
export function
|
|
95
|
+
export function memoryOperator(state: MemoryQueueState): MemoryOperator {
|
|
96
96
|
const { jobs, clock } = state;
|
|
97
|
-
const counters =
|
|
97
|
+
const counters = memoryCounters();
|
|
98
98
|
const queues = pauseSet(clock);
|
|
99
99
|
const tasks = pauseSet(clock);
|
|
100
100
|
const workers = new Map<string, WorkerRecord & { readonly expiresAt: number }>();
|
|
@@ -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) {
|
package/src/driver-memory.ts
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// real claim/ack/nack paths rather than a mock that always succeeds.
|
|
4
4
|
|
|
5
5
|
import type { Clock } from '@ultimat3/core';
|
|
6
|
-
import {
|
|
6
|
+
import { finiteCount, systemClock, uuidV7 } from '@ultimat3/core';
|
|
7
7
|
import type { BackfillLedger } from './backfill-ledger';
|
|
8
|
-
import {
|
|
8
|
+
import { memoryBackfillLedger } from './backfill-ledger';
|
|
9
9
|
import { nowMs } from './clock';
|
|
10
10
|
import { nackOutcome } from './counters';
|
|
11
11
|
import type {
|
|
@@ -25,21 +25,24 @@ import {
|
|
|
25
25
|
assertClaimBounds,
|
|
26
26
|
assertClaimQueues,
|
|
27
27
|
DEFAULT_QUEUE,
|
|
28
|
+
LEASE_LAPSED_FINAL_ATTEMPT,
|
|
29
|
+
LIVE_STATES,
|
|
28
30
|
nackState,
|
|
29
31
|
REQUEUEABLE_STATES,
|
|
30
32
|
} from './driver';
|
|
31
|
-
import {
|
|
33
|
+
import { memoryOperator } from './driver-memory-operator';
|
|
32
34
|
import { signalEnqueued } from './enqueue-signal';
|
|
33
|
-
import { JobDuplicateError } from './errors';
|
|
34
|
-
import { JobNotRequeueableError, requeueKeyTaken } from './errors-requeue';
|
|
35
|
+
import { JobDuplicateError, LeaseLostError } from './errors';
|
|
36
|
+
import { JobNotFoundError, JobNotRequeueableError, requeueKeyTaken } from './errors-requeue';
|
|
35
37
|
import type { JobIntrospection } from './introspection';
|
|
36
38
|
import { MAX_ERROR_STACK_LENGTH } from './introspection';
|
|
37
39
|
import type { LeaseStore } from './leases';
|
|
38
|
-
import {
|
|
40
|
+
import { memoryLeaseStore } from './leases';
|
|
41
|
+
import { isFinalAttempt } from './retry';
|
|
39
42
|
import type { StepStore } from './steps';
|
|
40
|
-
import {
|
|
43
|
+
import { memoryStepStore } from './steps-memory';
|
|
41
44
|
|
|
42
|
-
export interface
|
|
45
|
+
export interface MemoryJobDriverOptions {
|
|
43
46
|
readonly clock?: Clock;
|
|
44
47
|
readonly steps?: StepStore;
|
|
45
48
|
/** Injectable for the same reason `steps` is: two drivers in one test sharing one ledger. */
|
|
@@ -48,8 +51,6 @@ export interface MemoryDriverOptions {
|
|
|
48
51
|
readonly leases?: LeaseStore;
|
|
49
52
|
}
|
|
50
53
|
|
|
51
|
-
const LIVE_STATES = new Set(['ready', 'delayed', 'running', 'suspended']);
|
|
52
|
-
|
|
53
54
|
/**
|
|
54
55
|
* The in-memory driver's own type: `JobDriver` with `close` REQUIRED.
|
|
55
56
|
*
|
|
@@ -61,12 +62,25 @@ const LIVE_STATES = new Set(['ready', 'delayed', 'running', 'suspended']);
|
|
|
61
62
|
*/
|
|
62
63
|
export type MemoryJobDriver = JobDriver & { close(): Promise<void> };
|
|
63
64
|
|
|
64
|
-
export function
|
|
65
|
+
export function memoryJobDriver(options: MemoryJobDriverOptions = {}): MemoryJobDriver {
|
|
65
66
|
const clock = options.clock ?? systemClock;
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
67
|
+
const stepStore = options.steps ?? memoryStepStore();
|
|
68
|
+
// `SQL_STEP_PUT`'s fence: a write made under a claim lands only while that claim holds the row.
|
|
69
|
+
const steps: StepStore = {
|
|
70
|
+
...stepStore,
|
|
71
|
+
put(record, by) {
|
|
72
|
+
const row = by === undefined ? undefined : jobs.get(by.jobId);
|
|
73
|
+
if (
|
|
74
|
+
by !== undefined &&
|
|
75
|
+
!(row?.state === 'running' && row.claimedBy === by.workerId && row.claim === by.claim)
|
|
76
|
+
) {
|
|
77
|
+
return Promise.reject(new LeaseLostError({ job: by.job, jobId: by.jobId }));
|
|
78
|
+
}
|
|
79
|
+
return stepStore.put(record);
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
const backfills = options.backfills ?? memoryBackfillLedger(clock);
|
|
83
|
+
const leases = options.leases ?? memoryLeaseStore(options.clock === undefined ? {} : { clock });
|
|
70
84
|
const jobs = new Map<string, JobRecord>();
|
|
71
85
|
|
|
72
86
|
// Keyed by NAME, TENANT and key, exactly as `x_jobs_name_tenant_idempotency_live_idx` is. A
|
|
@@ -104,14 +118,22 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
104
118
|
* lease deadline — which `x jobs show` prints, and which is the very pair the claim scan's
|
|
105
119
|
* lease-expiry branch reads to decide a row was abandoned.
|
|
106
120
|
*/
|
|
107
|
-
const settle = (id: string, patch: Partial<JobRecord
|
|
121
|
+
const settle = (id: string, patch: Partial<JobRecord>, drop?: 'stack'): void => {
|
|
108
122
|
const existing = jobs.get(id);
|
|
109
123
|
if (existing === undefined) return;
|
|
110
124
|
const { visibleAt: _visibleAt, claimedBy: _claimedBy, ...released } = existing;
|
|
111
|
-
|
|
125
|
+
// `drop: 'stack'` is `SQL_NACK`'s `case when $5 is null then last_error_stack else $7 end`
|
|
126
|
+
// with no stack bound: a NEW failure replaces the old one's stack even with nothing — the key
|
|
127
|
+
// is removed, never set to `undefined`, as a null column is absent from a pg record.
|
|
128
|
+
const { lastErrorStack: _stack, ...unstacked } = released;
|
|
129
|
+
jobs.set(id, {
|
|
130
|
+
...(drop === 'stack' ? unstacked : released),
|
|
131
|
+
...patch,
|
|
132
|
+
updatedAt: nowMs(clock),
|
|
133
|
+
});
|
|
112
134
|
};
|
|
113
135
|
|
|
114
|
-
const operator =
|
|
136
|
+
const operator = memoryOperator({
|
|
115
137
|
jobs,
|
|
116
138
|
steps,
|
|
117
139
|
clock,
|
|
@@ -145,13 +167,9 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
145
167
|
return rows;
|
|
146
168
|
},
|
|
147
169
|
async requeue(jobId, requeueOptions) {
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
`no job ${jobId} in the memory driver`,
|
|
152
|
-
'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',
|
|
153
|
-
);
|
|
154
|
-
const record = existing;
|
|
170
|
+
const record = jobs.get(jobId);
|
|
171
|
+
// The one answer both drivers give an id nobody queued — it was `X_INVARIANT` here.
|
|
172
|
+
if (record === undefined) throw new JobNotFoundError({ jobId, driver: 'memory' });
|
|
155
173
|
// The two refusals `SQL_JOB_REQUEUE` and `SQL_JOB_LIVE_HOLDER` give, before anything moves.
|
|
156
174
|
if (!REQUEUEABLE_STATES.has(record.state)) {
|
|
157
175
|
throw new JobNotRequeueableError({ jobId, state: record.state });
|
|
@@ -161,7 +179,7 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
161
179
|
throw requeueKeyTaken({ ...record, holderId: holder.id });
|
|
162
180
|
}
|
|
163
181
|
if (requeueOptions?.fromStep !== undefined) {
|
|
164
|
-
// The target step and every step that started AFTER it — `
|
|
182
|
+
// The target step and every step that started AFTER it — `SQL_JOB_REQUEUE`, whose header
|
|
165
183
|
// says why a tie is kept. Earlier steps stay memoized; a later one replaying the old run's
|
|
166
184
|
// result is what this prevents.
|
|
167
185
|
const all = await steps.list(record.runId);
|
|
@@ -182,9 +200,12 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
182
200
|
},
|
|
183
201
|
cancel(jobId, reason) {
|
|
184
202
|
const existing = jobs.get(jobId);
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
|
|
203
|
+
// LIVE rows only, mirroring `SQL_CANCEL`: a job that already finished has nothing to stop.
|
|
204
|
+
// The fence was `state !== 'done'`, which let a cancel REWRITE a dead letter — the record of
|
|
205
|
+
// a failure, and its `lastError`, became the record of an operator's typo.
|
|
206
|
+
if (existing === undefined || !LIVE_STATES.has(existing.state)) {
|
|
207
|
+
return Promise.resolve(undefined);
|
|
208
|
+
}
|
|
188
209
|
// `settle`, not `update`: a cancellation RELEASES the claim, and `SQL_CANCEL` writes
|
|
189
210
|
// `visible_at = null, claimed_by = null` with the state. Left stamped, a cancelled row named
|
|
190
211
|
// the worker still holding it and carried that attempt's lease deadline — the pair
|
|
@@ -230,7 +251,7 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
230
251
|
const at = nowMs(clock);
|
|
231
252
|
const runAt = request.runAt ?? at;
|
|
232
253
|
const record: JobRecord = {
|
|
233
|
-
id: request.id ??
|
|
254
|
+
id: request.id ?? uuidV7(),
|
|
234
255
|
name: request.name,
|
|
235
256
|
queue: request.queue || DEFAULT_QUEUE,
|
|
236
257
|
// Through JSON, exactly as the pg driver binds it (`JSON.stringify(request.input ?? null)`):
|
|
@@ -238,7 +259,7 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
238
259
|
// queue in production never stores, so a job that read one passed here and failed there.
|
|
239
260
|
input: JSON.parse(JSON.stringify(request.input ?? null)) as unknown,
|
|
240
261
|
idempotencyKey: request.idempotencyKey,
|
|
241
|
-
runId: request.runId ??
|
|
262
|
+
runId: request.runId ?? uuidV7(),
|
|
242
263
|
attempt: 0,
|
|
243
264
|
maxAttempts: request.maxAttempts,
|
|
244
265
|
state: runAt > at ? 'delayed' : 'ready',
|
|
@@ -276,7 +297,24 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
276
297
|
.slice(0, claimOptions.limit);
|
|
277
298
|
|
|
278
299
|
const out: ClaimedJob[] = [];
|
|
300
|
+
const buried: JobRecord[] = [];
|
|
279
301
|
for (const record of claimable) {
|
|
302
|
+
// `SQL_CLAIM`'s second arm. A `running` row reaches this loop only with a lapsed lease, and
|
|
303
|
+
// one that lapsed on its FINAL attempt has nothing left to be re-delivered for: handing
|
|
304
|
+
// it out was an attempt past `maxAttempts`, every lease, forever — a job that kills its
|
|
305
|
+
// worker took one per visibility timeout and never reached the dead-letter queue.
|
|
306
|
+
if (
|
|
307
|
+
record.state === 'running' &&
|
|
308
|
+
isFinalAttempt({ attempts: record.maxAttempts }, record.attempt)
|
|
309
|
+
) {
|
|
310
|
+
// `$5` of the statement: a job declaring `retry.deadLetter: false` is dropped.
|
|
311
|
+
const state = claimOptions.dropExhausted?.includes(record.name) ? 'failed' : 'dead';
|
|
312
|
+
settle(record.id, { state, lastError: LEASE_LAPSED_FINAL_ATTEMPT }, 'stack');
|
|
313
|
+
operator.counters.add(record.name, state, 0, at);
|
|
314
|
+
const dead = jobs.get(record.id);
|
|
315
|
+
if (dead !== undefined) buried.push(dead);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
280
318
|
const claimed: ClaimedJob = {
|
|
281
319
|
...record,
|
|
282
320
|
state: 'running',
|
|
@@ -290,7 +328,8 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
290
328
|
jobs.set(record.id, claimed);
|
|
291
329
|
out.push(claimed);
|
|
292
330
|
}
|
|
293
|
-
|
|
331
|
+
if (buried.length > 0) claimOptions.onExhausted?.(buried);
|
|
332
|
+
return out;
|
|
294
333
|
},
|
|
295
334
|
|
|
296
335
|
// Both settlements are FENCED on `running` AND on the claimer, as `SQL_ACK`/`SQL_NACK` are: an
|
|
@@ -301,9 +340,7 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
301
340
|
const record = heldBy(jobId, by);
|
|
302
341
|
if (record === undefined) return Promise.resolve(false);
|
|
303
342
|
settle(jobId, { state: 'done' });
|
|
304
|
-
|
|
305
|
-
operator.counters.add(record.name, 'done', by.durationMs ?? 0, nowMs(clock));
|
|
306
|
-
}
|
|
343
|
+
operator.counters.add(record.name, 'done', by.durationMs ?? 0, nowMs(clock));
|
|
307
344
|
return Promise.resolve(true);
|
|
308
345
|
},
|
|
309
346
|
|
|
@@ -323,11 +360,12 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
323
360
|
// being read as the only one.
|
|
324
361
|
attempt: counts ? record.attempt : Math.max(0, record.attempt - 1),
|
|
325
362
|
...(nackOptions.error === undefined ? {} : { lastError: nackOptions.error }),
|
|
326
|
-
|
|
363
|
+
// The stack belongs to the ERROR beside it, as `SQL_NACK` binds it: no error, no stack.
|
|
364
|
+
...(nackOptions.error === undefined || nackOptions.stack === undefined
|
|
327
365
|
? {}
|
|
328
366
|
: { lastErrorStack: nackOptions.stack.slice(0, MAX_ERROR_STACK_LENGTH) }),
|
|
329
367
|
};
|
|
330
|
-
settle(jobId, patch);
|
|
368
|
+
settle(jobId, patch, nackOptions.error === undefined ? undefined : 'stack');
|
|
331
369
|
const outcome = nackOutcome(nackOptions);
|
|
332
370
|
if (outcome !== undefined) {
|
|
333
371
|
operator.counters.add(record.name, outcome, nackOptions.durationMs ?? 0, at);
|
|
@@ -382,7 +420,11 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
382
420
|
else if (record.state === 'dead') next.dead += 1;
|
|
383
421
|
byQueue.set(record.queue, next);
|
|
384
422
|
}
|
|
385
|
-
|
|
423
|
+
// Code units, as `collate "C"` orders them in `SQL_STATS` — never `localeCompare`, which
|
|
424
|
+
// answers by the runtime's locale and so differently from the database on the same names.
|
|
425
|
+
return Promise.resolve(
|
|
426
|
+
[...byQueue.values()].sort((a, b) => (a.queue < b.queue ? -1 : a.queue > b.queue ? 1 : 0)),
|
|
427
|
+
);
|
|
386
428
|
},
|
|
387
429
|
|
|
388
430
|
close(): Promise<void> {
|