@ultimat3/jobs 1.2.0 → 2.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 +567 -0
- package/README.md +355 -16
- package/package.json +7 -5
- package/src/backfill-gate.ts +97 -0
- package/src/backfill-inspect.ts +73 -0
- package/src/backfill-ledger.ts +183 -0
- package/src/backfill-pass.ts +276 -0
- package/src/backfill-pending.ts +131 -0
- package/src/backfill-rate.ts +109 -0
- package/src/backfill-registry.ts +108 -0
- package/src/backfill-scope.ts +70 -0
- package/src/backfill.ts +213 -0
- package/src/driver-memory.ts +61 -9
- package/src/driver-nats.ts +2 -1
- package/src/driver-pg-ddl.ts +180 -0
- package/src/driver-pg-rows.ts +123 -0
- package/src/driver-pg-sql.ts +251 -55
- package/src/driver-pg.ts +138 -92
- package/src/driver-redis.ts +2 -1
- package/src/driver.ts +91 -7
- package/src/errors.ts +290 -4
- package/src/events-pg.ts +121 -0
- package/src/events.ts +7 -1
- package/src/execute.ts +289 -0
- package/src/heartbeat.ts +146 -0
- package/src/index.ts +121 -27
- package/src/inspect.ts +43 -2
- package/src/job.ts +72 -2
- package/src/leases.ts +90 -0
- package/src/limits.ts +0 -0
- package/src/metrics.ts +35 -0
- package/src/outbox-pg.ts +137 -0
- package/src/outbox.ts +115 -53
- package/src/register.ts +1 -1
- package/src/retry.ts +6 -1
- package/src/run-signal.ts +50 -0
- package/src/scheduler-pg.ts +103 -0
- package/src/scheduler.ts +159 -245
- package/src/steps.ts +155 -31
- package/src/task.ts +229 -0
- package/src/tenant.ts +61 -0
- package/src/worker-fleet-slots.ts +124 -0
- package/src/worker-run.ts +132 -0
- package/src/worker.ts +207 -190
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// `x_backfills` — what has already been swept, and whether this pass runs at all. The twin of
|
|
2
|
+
// `@ultimat3/db`'s `x_migrations`, one level up: a migration ledger is keyed by migration id
|
|
3
|
+
// because a migration applies once and only once, and this one is keyed by RUN because a backfill
|
|
4
|
+
// may legitimately be run again. `force` therefore writes a NEW row rather than overwriting the
|
|
5
|
+
// one that says what the last pass did — reruns are history, not an edit of it.
|
|
6
|
+
//
|
|
7
|
+
// The row is a REPORT, never a resume source. Where a resumed pass restarts is decided by the step
|
|
8
|
+
// checkpoints in `backfill-pass.ts` and by nothing else; `cursor` here is what an operator reads
|
|
9
|
+
// while a pass is running. A second answer to "where were we" is the one thing this file must not
|
|
10
|
+
// become — the checkpoints are transactional with the work, and this row is not.
|
|
11
|
+
|
|
12
|
+
import type { Clock } from '@ultimat3/core';
|
|
13
|
+
import { systemClock } from '@ultimat3/core';
|
|
14
|
+
import { nowMs } from './clock';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `failed` is the LAST ATTEMPT's verdict, not the pass's: the queue owns retries, so a row here
|
|
18
|
+
* goes back to `running` when the next attempt starts. Only `completed` is terminal, and it is the
|
|
19
|
+
* one status that blocks a re-run.
|
|
20
|
+
*
|
|
21
|
+
* The runtime list is the declaration and `BackfillStatus` is derived from it, the shape
|
|
22
|
+
* `PRIMITIVE_KINDS` already has: `x db backfill --status` has to validate a string it was handed,
|
|
23
|
+
* and a second list spelled out in the CLI is a status the ledger can record and the flag rejects.
|
|
24
|
+
*/
|
|
25
|
+
export const BACKFILL_STATUSES = ['running', 'completed', 'failed'] as const;
|
|
26
|
+
|
|
27
|
+
export type BackfillStatus = (typeof BACKFILL_STATUSES)[number];
|
|
28
|
+
|
|
29
|
+
/** Narrows a string the CLI, MCP or a URL handed over. Never a cast — the list decides. */
|
|
30
|
+
export const isBackfillStatus = (value: string): value is BackfillStatus =>
|
|
31
|
+
(BACKFILL_STATUSES as readonly string[]).includes(value);
|
|
32
|
+
|
|
33
|
+
export interface BackfillRun {
|
|
34
|
+
/** The job run this pass belongs to, and the ledger's primary key. */
|
|
35
|
+
readonly runId: string;
|
|
36
|
+
readonly name: string;
|
|
37
|
+
readonly checksum: string;
|
|
38
|
+
readonly status: BackfillStatus;
|
|
39
|
+
/** The build that STARTED the pass — a redeploy mid-pass does not rewrite it. */
|
|
40
|
+
readonly appVersion: string;
|
|
41
|
+
readonly rows: number;
|
|
42
|
+
/** Where the pass had got to. `null` before the first batch and once it is over. */
|
|
43
|
+
readonly cursor: string | null;
|
|
44
|
+
readonly startedAt: number;
|
|
45
|
+
readonly completedAt?: number | undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface BackfillFilter {
|
|
49
|
+
readonly name?: string | undefined;
|
|
50
|
+
readonly status?: BackfillStatus | undefined;
|
|
51
|
+
/**
|
|
52
|
+
* The one pass this row belongs to. A backfill's run id IS the queue row's, so `x jobs show
|
|
53
|
+
* <id>` can ask how far the sweep behind that job has got without a second lookup table.
|
|
54
|
+
*/
|
|
55
|
+
readonly runId?: string | undefined;
|
|
56
|
+
readonly limit?: number | undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Durable, and therefore behind the queue driver: `x_backfills` ships in the same DDL as `x_jobs`
|
|
61
|
+
* and `x_job_steps` (`driver-pg-sql.ts`), so a ledger a pass cannot write is a queue it could not
|
|
62
|
+
* have been claimed from. Optional on `JobDriver` for the same reason `introspect` is — a driver
|
|
63
|
+
* with no ledger runs backfills with no bookkeeping rather than refusing them.
|
|
64
|
+
*/
|
|
65
|
+
export interface BackfillLedger {
|
|
66
|
+
/** Open this run's row, or adopt the one a previous attempt of the SAME run opened. */
|
|
67
|
+
start(run: {
|
|
68
|
+
readonly runId: string;
|
|
69
|
+
readonly name: string;
|
|
70
|
+
readonly checksum: string;
|
|
71
|
+
readonly appVersion: string;
|
|
72
|
+
}): Promise<void>;
|
|
73
|
+
/** Move the row forward. Absolute position, so a replayed batch reports the same number. */
|
|
74
|
+
progress(
|
|
75
|
+
runId: string,
|
|
76
|
+
at: { readonly rows: number; readonly cursor: string | null },
|
|
77
|
+
): Promise<void>;
|
|
78
|
+
finish(
|
|
79
|
+
runId: string,
|
|
80
|
+
at: { readonly status: 'completed' | 'failed'; readonly rows: number },
|
|
81
|
+
): Promise<void>;
|
|
82
|
+
/** Newest first. `x db backfill --list` and the verdict below read the same method. */
|
|
83
|
+
list(filter?: BackfillFilter): Promise<readonly BackfillRun[]>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Anything callable. `Function` is a banned type and a real signature would pin one definition. */
|
|
87
|
+
type AnyFn = (...args: never[]) => unknown;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Between the two bodies, and a byte no source text carries. Concatenating them raw would hash a
|
|
91
|
+
* BOUNDARY rather than a pair — `ab` + `c` and `a` + `bc` are one string — so a statement moving
|
|
92
|
+
* from `handle` into `source` could leave the checksum where it was.
|
|
93
|
+
*/
|
|
94
|
+
const CHECKSUM_SEPARATOR = '\u0000';
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* What "the same backfill" means: the source text of the two functions that decide which rows a
|
|
98
|
+
* pass visits and what happens to them, which is the whole of what a completed row claims was
|
|
99
|
+
* done. `batch` is deliberately out — paging a sweep differently is a tuning change, and a
|
|
100
|
+
* checksum that moved for it would warn on every one.
|
|
101
|
+
*
|
|
102
|
+
* A code hash is fuzzier than a migration's SQL hash: a bundler that reformats a body moves it
|
|
103
|
+
* with no line of behaviour changing. That is exactly why a mismatch WARNS and never refuses,
|
|
104
|
+
* where `@ultimat3/db`'s `auditLedger` throws on the same fact — SQL text is what it applied.
|
|
105
|
+
*/
|
|
106
|
+
export function backfillChecksum(source: AnyFn, handle: AnyFn): string {
|
|
107
|
+
return new Bun.CryptoHasher('sha256')
|
|
108
|
+
.update(`${source.toString()}${CHECKSUM_SEPARATOR}${handle.toString()}`)
|
|
109
|
+
.digest('hex')
|
|
110
|
+
.slice(0, 32);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface BackfillVerdict {
|
|
114
|
+
readonly run: boolean;
|
|
115
|
+
/** The completed pass this verdict is about, when the ledger holds one. */
|
|
116
|
+
readonly previous?: BackfillRun | undefined;
|
|
117
|
+
/** `previous` completed under a different definition. Warned about, never refused. */
|
|
118
|
+
readonly changed: boolean;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Pure, so the pass, a test and `x db backfill` all read one decision. Only a COMPLETED row
|
|
123
|
+
* blocks: a `running` row is this pass resuming (same run) or a pass another worker holds the one
|
|
124
|
+
* live idempotency key for, and a `failed` one is an attempt the queue is about to retry.
|
|
125
|
+
*/
|
|
126
|
+
export function decideBackfill(
|
|
127
|
+
completed: BackfillRun | undefined,
|
|
128
|
+
checksum: string,
|
|
129
|
+
force: boolean,
|
|
130
|
+
): BackfillVerdict {
|
|
131
|
+
if (completed === undefined) return { run: true, changed: false };
|
|
132
|
+
return { run: force, previous: completed, changed: completed.checksum !== checksum };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function createMemoryBackfillLedger(clock: Clock = systemClock): BackfillLedger {
|
|
136
|
+
const runs = new Map<string, BackfillRun>();
|
|
137
|
+
const patch = (runId: string, fields: Partial<BackfillRun>): void => {
|
|
138
|
+
const existing = runs.get(runId);
|
|
139
|
+
if (existing !== undefined) runs.set(runId, { ...existing, ...fields });
|
|
140
|
+
};
|
|
141
|
+
return {
|
|
142
|
+
start(run) {
|
|
143
|
+
const existing = runs.get(run.runId);
|
|
144
|
+
// A retry adopts its own row: `startedAt` is when the PASS began, not this attempt, and the
|
|
145
|
+
// status goes back to `running` so a row a failed attempt marked stops claiming otherwise.
|
|
146
|
+
// `completedAt` goes with it — `finish` stamps one for `failed` too, and a running pass that
|
|
147
|
+
// kept it would report a completion time in the past on every surface that reads the row.
|
|
148
|
+
runs.set(
|
|
149
|
+
run.runId,
|
|
150
|
+
existing === undefined
|
|
151
|
+
? { ...run, status: 'running', rows: 0, cursor: null, startedAt: nowMs(clock) }
|
|
152
|
+
: { ...existing, status: 'running', completedAt: undefined },
|
|
153
|
+
);
|
|
154
|
+
return Promise.resolve();
|
|
155
|
+
},
|
|
156
|
+
progress(runId, at) {
|
|
157
|
+
patch(runId, { rows: at.rows, cursor: at.cursor });
|
|
158
|
+
return Promise.resolve();
|
|
159
|
+
},
|
|
160
|
+
finish(runId, at) {
|
|
161
|
+
// A failure keeps its cursor — where a pass stopped is the first thing anyone asks.
|
|
162
|
+
patch(runId, {
|
|
163
|
+
status: at.status,
|
|
164
|
+
rows: at.rows,
|
|
165
|
+
completedAt: nowMs(clock),
|
|
166
|
+
...(at.status === 'completed' ? { cursor: null } : {}),
|
|
167
|
+
});
|
|
168
|
+
return Promise.resolve();
|
|
169
|
+
},
|
|
170
|
+
list(filter = {}) {
|
|
171
|
+
// Reversed BEFORE the sort: the test clock is frozen, so two rows share a `startedAt` and a
|
|
172
|
+
// stable sort would hand back the oldest of them first under a "newest first" contract.
|
|
173
|
+
const rows = [...runs.values()]
|
|
174
|
+
.reverse()
|
|
175
|
+
.sort((a, b) => b.startedAt - a.startedAt)
|
|
176
|
+
.filter((run) => filter.name === undefined || run.name === filter.name)
|
|
177
|
+
.filter((run) => filter.status === undefined || run.status === filter.status)
|
|
178
|
+
.filter((run) => filter.runId === undefined || run.runId === filter.runId)
|
|
179
|
+
.slice(0, filter.limit ?? 100);
|
|
180
|
+
return Promise.resolve(rows);
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// One `backfill()` pass: the iteration, its durable checkpoints, and the `x_backfills` row that
|
|
2
|
+
// reports them. Split out of `backfill.ts` for the reason `execute.ts` is split out of `job.ts` —
|
|
3
|
+
// that file declares a backfill, this one runs it.
|
|
4
|
+
//
|
|
5
|
+
// `inBatches()` reads the source one statement per page, and each page is handled inside its own
|
|
6
|
+
// `step.run`, so an attempt killed mid-pass resumes on the page it stopped at instead of reading
|
|
7
|
+
// the table again from the top. What a step persists is the CURSOR and the row count, never the
|
|
8
|
+
// page: `steps.ts` hands a completed step's output back for the whole run, so checkpointing rows
|
|
9
|
+
// would retain every row of every batch already processed until the job ended — the leak that
|
|
10
|
+
// turns a backfill of a large table into an OOM.
|
|
11
|
+
//
|
|
12
|
+
// The ledger is the OTHER half and never a second copy of this one: a step checkpoint is written
|
|
13
|
+
// in step with the work and decides where a resumed pass restarts, while the `x_backfills` row is
|
|
14
|
+
// a report an operator reads and the record that a completed name has already been swept.
|
|
15
|
+
|
|
16
|
+
import { appVersion, assert, logger, resolveEnvironment } from '@ultimat3/core';
|
|
17
|
+
import type { BatchIterator } from '@ultimat3/entity';
|
|
18
|
+
import type { BackfillDefinition, BackfillInput, BackfillReport } from './backfill';
|
|
19
|
+
import { checkBackfillEnvironment } from './backfill-gate';
|
|
20
|
+
import type { BackfillLedger, BackfillRun } from './backfill-ledger';
|
|
21
|
+
import { decideBackfill } from './backfill-ledger';
|
|
22
|
+
import type { Pacer } from './backfill-rate';
|
|
23
|
+
import { withBackfillScope } from './backfill-scope';
|
|
24
|
+
import { jobDriver } from './driver';
|
|
25
|
+
import { BackfillStalledError } from './errors';
|
|
26
|
+
import type { JobRunArgs } from './job';
|
|
27
|
+
import { isStepSuspension } from './steps';
|
|
28
|
+
|
|
29
|
+
/** Every batch is a step, and this is the name it is checkpointed under. See `backfillPass()`. */
|
|
30
|
+
const STEP_PREFIX = 'batch:';
|
|
31
|
+
|
|
32
|
+
/** What a step persists: a bounded position, never the page it came from. See the file header. */
|
|
33
|
+
interface Checkpoint {
|
|
34
|
+
/** Where the next batch starts; `null` once the pass is over. */
|
|
35
|
+
readonly cursor: string | null;
|
|
36
|
+
readonly rows: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One live iteration and the pull that advances it. Rebuilt when the checkpoints disagree. */
|
|
40
|
+
interface Iteration<Row> {
|
|
41
|
+
readonly batches: BatchIterator<Row>;
|
|
42
|
+
readonly pull: AsyncIterator<readonly Row[]>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Everything about the backfill that is fixed at declaration, hashed, sized and paced once. */
|
|
46
|
+
export interface BackfillPlan<Row> {
|
|
47
|
+
readonly definition: BackfillDefinition<Row>;
|
|
48
|
+
readonly size: number;
|
|
49
|
+
readonly checksum: string;
|
|
50
|
+
/** The declared `rate`, already an interval. Built at declaration for the reason `size` is. */
|
|
51
|
+
readonly pace: Pacer;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const fieldOf = (value: unknown, key: string): unknown =>
|
|
55
|
+
typeof value === 'object' && value !== null ? (value as Record<string, unknown>)[key] : undefined;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `steps.ts` hands a completed step's output back through an unchecked `as T`, so a checkpoint
|
|
59
|
+
* READ from storage is this shape by claim and never by check. It is checked here because the
|
|
60
|
+
* failure it would otherwise cause is silent and expensive: an absent cursor is not `null`, so the
|
|
61
|
+
* loop would reopen the source at the top and walk the whole table a second time.
|
|
62
|
+
*/
|
|
63
|
+
function asCheckpoint(value: unknown, step: string): Checkpoint {
|
|
64
|
+
const cursor = fieldOf(value, 'cursor');
|
|
65
|
+
const rows = fieldOf(value, 'rows');
|
|
66
|
+
assert(
|
|
67
|
+
(cursor === null || typeof cursor === 'string') && typeof rows === 'number',
|
|
68
|
+
`step "${step}" replayed ${JSON.stringify(value)}, which is not a backfill checkpoint`,
|
|
69
|
+
`x jobs show <jobId> --json prints the run's steps — a run id whose "${step}" was written by something other than this backfill has to be retired, not resumed`,
|
|
70
|
+
);
|
|
71
|
+
return { cursor, rows };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The newest COMPLETED pass under this name, or nothing. A `running` row is this pass resuming or
|
|
76
|
+
* one another worker holds the single live idempotency key for, and a `failed` one is an attempt
|
|
77
|
+
* the queue is about to retry — neither is a sweep that has already happened.
|
|
78
|
+
*/
|
|
79
|
+
async function completedRun(
|
|
80
|
+
ledger: BackfillLedger | undefined,
|
|
81
|
+
name: string,
|
|
82
|
+
): Promise<BackfillRun | undefined> {
|
|
83
|
+
if (ledger === undefined) return undefined;
|
|
84
|
+
const rows = await ledger.list({ name, status: 'completed', limit: 1 });
|
|
85
|
+
return rows[0];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Record the attempt that ended badly and let the original error through untouched: the queue's
|
|
90
|
+
* retry decision is made on it, so a bookkeeping failure that replaced it would dead-letter — or
|
|
91
|
+
* silently retry — for the wrong reason. Logged rather than swallowed, because a ledger nobody
|
|
92
|
+
* can write is worth exactly one line.
|
|
93
|
+
*/
|
|
94
|
+
async function markFailed(
|
|
95
|
+
ledger: BackfillLedger | undefined,
|
|
96
|
+
runId: string,
|
|
97
|
+
rows: number,
|
|
98
|
+
): Promise<void> {
|
|
99
|
+
try {
|
|
100
|
+
await ledger?.finish(runId, { status: 'failed', rows });
|
|
101
|
+
} catch (error) {
|
|
102
|
+
logger.warn('jobs.backfill.ledger-failed', {
|
|
103
|
+
runId,
|
|
104
|
+
error: error instanceof Error ? error.message : String(error),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The stall detector. `count()` is the same predicate `source` selects on, so a source that ran
|
|
111
|
+
* out while the count still matches rows means the two disagree — the sweep reported success over
|
|
112
|
+
* rows nobody visited, which is an authoring bug in any business and not a condition the queue can
|
|
113
|
+
* retry its way out of. Silent without a declared `count()`: the framework will not guess a number
|
|
114
|
+
* on the author's behalf, because a dry run that lied about convergence is the failure this exists
|
|
115
|
+
* to close.
|
|
116
|
+
*/
|
|
117
|
+
async function assertConverged<Row>(
|
|
118
|
+
definition: BackfillDefinition<Row>,
|
|
119
|
+
ctx: JobRunArgs<BackfillInput>['ctx'],
|
|
120
|
+
swept: number,
|
|
121
|
+
): Promise<void> {
|
|
122
|
+
if (definition.count === undefined) return;
|
|
123
|
+
const remaining = await definition.count({ ctx });
|
|
124
|
+
// Parsed, not trusted: `count()` is app code feeding a framework decision, and both `NaN > 0`
|
|
125
|
+
// and `-1 > 0` are FALSE — an unchecked bad number reads as "converged" and writes the completed
|
|
126
|
+
// ledger row that stops the next deploy ever re-running this sweep. The one failure mode this
|
|
127
|
+
// detector exists to close, arriving through the detector itself.
|
|
128
|
+
assert(
|
|
129
|
+
Number.isSafeInteger(remaining) && remaining >= 0,
|
|
130
|
+
`backfill "${definition.name}" count() returned ${String(remaining)} — a count is a whole number of rows, zero or more`,
|
|
131
|
+
`return the chain's own count from count() on backfill("${definition.name}"), e.g. count: ({ ctx }) => source({ ctx }).count() — a NaN or a negative reads as "nothing left" and completes the sweep`,
|
|
132
|
+
);
|
|
133
|
+
if (remaining > 0) {
|
|
134
|
+
throw new BackfillStalledError({ backfill: definition.name, remaining, swept });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The pass itself: one `step.run` per batch, named by position so a replay finds it. Completed
|
|
140
|
+
* steps are served from storage without touching the database, so a resumed attempt walks its own
|
|
141
|
+
* history to the last checkpoint and opens the source there.
|
|
142
|
+
*/
|
|
143
|
+
export async function backfillPass<Row>(
|
|
144
|
+
plan: BackfillPlan<Row>,
|
|
145
|
+
args: JobRunArgs<BackfillInput>,
|
|
146
|
+
): Promise<BackfillReport> {
|
|
147
|
+
const { definition, size, checksum, pace } = plan;
|
|
148
|
+
const { step, runId } = args;
|
|
149
|
+
const name = definition.name;
|
|
150
|
+
// Before the ledger is even opened: a sweep this deploy may not run must leave no row saying it
|
|
151
|
+
// started. Enforced here and not only in `x db backfill`, because app code that calls
|
|
152
|
+
// `.enqueue()` directly never passes through a command — a rail only the CLI holds is a
|
|
153
|
+
// convention (axiom 3). No ledger row, so the queue's own retry/dead-letter path is what an
|
|
154
|
+
// operator reads, exactly as it is for any other permanently-failing job.
|
|
155
|
+
// `resolveEnvironment()` is asked ONLY when a declaration named environments: it throws on a
|
|
156
|
+
// typo'd `ULTIMATE_ENV`, which is core's contract for its own key — but a sweep that declared
|
|
157
|
+
// nothing must not start failing over a variable it never reads.
|
|
158
|
+
const declaredEnvironments = definition.environments;
|
|
159
|
+
if (declaredEnvironments !== undefined && declaredEnvironments.length > 0) {
|
|
160
|
+
const mismatch = checkBackfillEnvironment(name, declaredEnvironments, resolveEnvironment());
|
|
161
|
+
if (mismatch !== undefined) throw mismatch;
|
|
162
|
+
}
|
|
163
|
+
// Absent on a driver that ships no ledger, which runs the pass with no bookkeeping rather than
|
|
164
|
+
// refusing it — the same degradation `introspect` already has.
|
|
165
|
+
const ledger = jobDriver()?.backfills;
|
|
166
|
+
|
|
167
|
+
const previous = await completedRun(ledger, name);
|
|
168
|
+
const verdict = decideBackfill(previous, checksum, args.input.force === true);
|
|
169
|
+
if (!verdict.run) {
|
|
170
|
+
if (verdict.changed) {
|
|
171
|
+
// A warning and never a refusal, unlike `@ultimat3/db`'s `auditLedger`: this checksum is
|
|
172
|
+
// over function source text, which a bundler can move without a line of behaviour changing.
|
|
173
|
+
logger.warn('jobs.backfill.definition-changed', {
|
|
174
|
+
backfill: name,
|
|
175
|
+
completedAs: previous?.checksum,
|
|
176
|
+
now: checksum,
|
|
177
|
+
fix: `enqueue ${name} with { force: true } to sweep again under the new definition`,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
logger.info('jobs.backfill.skipped', { backfill: name, completedBy: previous?.runId });
|
|
181
|
+
return {
|
|
182
|
+
name,
|
|
183
|
+
batches: 0,
|
|
184
|
+
rows: 0,
|
|
185
|
+
skipped: true,
|
|
186
|
+
...(previous === undefined ? {} : { previousRunId: previous.runId }),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
// The pass itself, run under the tenant the DECLARATION named. Everything below builds a plan
|
|
190
|
+
// — `source()` per page, `count()` at the end — and `scopedPlan` is applied where a plan is
|
|
191
|
+
// built, which is inside this iteration and not where the author wrote the chain. So the scope
|
|
192
|
+
// has to be opened here: `tenant: 'none'` sweeps every tenant and says so, a declared tenant is
|
|
193
|
+
// handed its context untouched. `backfill-scope.ts` says why it is not the worker's actor.
|
|
194
|
+
return withBackfillScope(
|
|
195
|
+
name,
|
|
196
|
+
definition.tenant,
|
|
197
|
+
args.ctx,
|
|
198
|
+
async (ctx): Promise<BackfillReport> => {
|
|
199
|
+
await ledger?.start({ runId, name, checksum, appVersion: appVersion() });
|
|
200
|
+
|
|
201
|
+
let cursor: string | null = null;
|
|
202
|
+
let rows = 0;
|
|
203
|
+
let batches = 0;
|
|
204
|
+
let live: Iteration<Row> | undefined;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The iteration positioned at `cursor`, opened on the first batch this attempt actually runs so
|
|
208
|
+
* a resumed pass sends no statement for a page it already handled.
|
|
209
|
+
*
|
|
210
|
+
* `batches.cursor` IS where the next statement starts, so comparing it with the checkpoint's is
|
|
211
|
+
* the whole staleness test — and it is not academic: `retryFromStep` re-opens ONE step in the
|
|
212
|
+
* middle of a finished run, which moves the checkpoints somewhere this iteration is not. Rebuilt
|
|
213
|
+
* from the checkpoint rather than read from wherever the old one was parked.
|
|
214
|
+
*/
|
|
215
|
+
const iterate = async (): Promise<Iteration<Row>> => {
|
|
216
|
+
if (live !== undefined && live.batches.cursor === cursor) return live;
|
|
217
|
+
await live?.batches.close();
|
|
218
|
+
const opened = definition.source({ ctx }).after(cursor).inBatches(size);
|
|
219
|
+
live = { batches: opened, pull: opened[Symbol.asyncIterator]() };
|
|
220
|
+
return live;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
for (let index = 0; ; index += 1) {
|
|
225
|
+
const stepName = `${STEP_PREFIX}${index}`;
|
|
226
|
+
const checkpoint = asCheckpoint(
|
|
227
|
+
await step.run(stepName, async (signal): Promise<Checkpoint> => {
|
|
228
|
+
// INSIDE the body, which is the whole of it: a completed step is served from storage
|
|
229
|
+
// without its body running, so an attempt resuming at batch 500 replays 500 checkpoints
|
|
230
|
+
// and pays none of their pauses. Paced outside the step, a resumed pass would spend the
|
|
231
|
+
// entire throttle of everything it had already done before touching a new row.
|
|
232
|
+
//
|
|
233
|
+
// The signal is the run's cancellation composed with this step's ceiling, so a cancelled
|
|
234
|
+
// pass unwinds out of the wait instead of sitting in a timer nobody is waiting for.
|
|
235
|
+
await pace.wait({ signal, step: stepName });
|
|
236
|
+
const iteration = await iterate();
|
|
237
|
+
const next = await iteration.pull.next();
|
|
238
|
+
if (next.done === true) return { cursor: null, rows: 0 };
|
|
239
|
+
await definition.handle({ rows: next.value, ctx, signal, index });
|
|
240
|
+
return { cursor: iteration.batches.cursor, rows: next.value.length };
|
|
241
|
+
}),
|
|
242
|
+
stepName,
|
|
243
|
+
);
|
|
244
|
+
rows += checkpoint.rows;
|
|
245
|
+
cursor = checkpoint.cursor;
|
|
246
|
+
// `inBatches()` never yields an empty batch, so rows is what tells a handled page from the
|
|
247
|
+
// one step an exhausted source costs — and what keeps the exhausted step off the ledger,
|
|
248
|
+
// whose last write is `finish` either way.
|
|
249
|
+
if (checkpoint.rows > 0) {
|
|
250
|
+
batches += 1;
|
|
251
|
+
// Absolute, so a replayed batch reports the position it reported the first time.
|
|
252
|
+
await ledger?.progress(runId, { rows, cursor });
|
|
253
|
+
}
|
|
254
|
+
if (cursor === null) break;
|
|
255
|
+
}
|
|
256
|
+
// The source is exhausted; the declaration's own count is the only thing that can say whether
|
|
257
|
+
// that means the work is done. One statement per PASS, not per batch — the question is "did
|
|
258
|
+
// this converge", asked once, where the answer is finally decidable. Inside the `try` so the
|
|
259
|
+
// ledger records the attempt as `failed`: a pass that left rows behind must not write the
|
|
260
|
+
// completed row that stops the next deploy re-running it.
|
|
261
|
+
await assertConverged(definition, ctx, rows);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
// Control flow, not a failure: a suspended run is parked and will be back on this step.
|
|
264
|
+
if (!isStepSuspension(error)) await markFailed(ledger, runId, rows);
|
|
265
|
+
throw error;
|
|
266
|
+
} finally {
|
|
267
|
+
// Whatever the iteration holds belongs to this attempt, and an attempt that failed, was
|
|
268
|
+
// cancelled or finished is done with it either way.
|
|
269
|
+
await live?.batches.close();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
await ledger?.finish(runId, { status: 'completed', rows });
|
|
273
|
+
return { name, batches, rows, skipped: false };
|
|
274
|
+
},
|
|
275
|
+
);
|
|
276
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Declared minus completed — the diff nothing in the framework could compute, because until
|
|
2
|
+
// `backfill-registry.ts` only half of it existed. `inspectBackfills` reads the ledger, so a sweep
|
|
3
|
+
// that was merged and never enqueued had no row and appeared on no surface at all.
|
|
4
|
+
//
|
|
5
|
+
// Pure: declarations in, ledger rows in, verdict out. The reader is `x db backfill --pending`,
|
|
6
|
+
// whose non-zero exit is what lets a cron or a deploy check answer "is anything unswept" without
|
|
7
|
+
// parsing a table.
|
|
8
|
+
|
|
9
|
+
import type { Environment } from '@ultimat3/core';
|
|
10
|
+
import { checkBackfillEnvironment } from './backfill-gate';
|
|
11
|
+
// `BackfillProgress` and not the driver's own `BackfillRun`: `inspectBackfills()` is the ONE
|
|
12
|
+
// projection of the ledger, and this diff is its fifth reader rather than a sixth path into rows.
|
|
13
|
+
import type { BackfillProgress } from './backfill-inspect';
|
|
14
|
+
import type { BackfillDeclaration } from './backfill-registry';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `excluded` is a declaration this environment may not run at all, and it is the reason the diff
|
|
18
|
+
* needs an environment: a production-only cleanup read in staging is not drift, and an alarm that
|
|
19
|
+
* fired on it would be muted within a week.
|
|
20
|
+
*/
|
|
21
|
+
export const BACKFILL_STATES = ['pending', 'running', 'failed', 'completed', 'excluded'] as const;
|
|
22
|
+
|
|
23
|
+
export type BackfillState = (typeof BACKFILL_STATES)[number];
|
|
24
|
+
|
|
25
|
+
/** One declaration, judged against the ledger. Plain JSON: absent is `null`, never `undefined`. */
|
|
26
|
+
export interface BackfillStateRow {
|
|
27
|
+
readonly name: string;
|
|
28
|
+
readonly state: BackfillState;
|
|
29
|
+
readonly checksum: string;
|
|
30
|
+
/** The checksum the newest run under this name recorded, when there is one. */
|
|
31
|
+
readonly ledgerChecksum: string | null;
|
|
32
|
+
/** The completed pass ran under a different definition. Reported, never a refusal. */
|
|
33
|
+
readonly changed: boolean;
|
|
34
|
+
readonly requires: string | null;
|
|
35
|
+
readonly environments: readonly Environment[] | null;
|
|
36
|
+
readonly lastRunId: string | null;
|
|
37
|
+
readonly rows: number | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface BackfillPendingReport {
|
|
41
|
+
readonly environment: Environment;
|
|
42
|
+
/** Every declaration, judged. Newest-first ledger rows decide each verdict. */
|
|
43
|
+
readonly rows: readonly BackfillStateRow[];
|
|
44
|
+
/**
|
|
45
|
+
* The alarm: `pending` and `failed`, never `running` — a pass in flight is progress, and a check
|
|
46
|
+
* that went red for the duration of every sweep is a check nobody leaves wired to a deploy.
|
|
47
|
+
*/
|
|
48
|
+
readonly pending: readonly BackfillStateRow[];
|
|
49
|
+
/** Ledger names no declaration carries — a sweep whose module was deleted after it ran. */
|
|
50
|
+
readonly orphaned: readonly string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The states the alarm is about, declared ONCE. `x db backfill --all` picks its targets by this
|
|
55
|
+
* same predicate, and a second literal there would be a second definition of "pending" — one of
|
|
56
|
+
* which would eventually be wrong while the other stayed right.
|
|
57
|
+
*/
|
|
58
|
+
export const PENDING_BACKFILL_STATES: readonly BackfillState[] = ['pending', 'failed'];
|
|
59
|
+
|
|
60
|
+
export const isPendingBackfillState = (state: BackfillState): boolean =>
|
|
61
|
+
PENDING_BACKFILL_STATES.includes(state);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Newest first is `BackfillLedger.list`'s contract, so within a name the first row is the newest.
|
|
65
|
+
* Grouped ONCE rather than filtered per declaration: `x db backfill --pending` reads the ledger
|
|
66
|
+
* with no limit, so a per-declaration scan is three passes over an unbounded list for every sweep
|
|
67
|
+
* the app declares.
|
|
68
|
+
*/
|
|
69
|
+
function groupByName(
|
|
70
|
+
runs: readonly BackfillProgress[],
|
|
71
|
+
): ReadonlyMap<string, readonly BackfillProgress[]> {
|
|
72
|
+
const byName = new Map<string, BackfillProgress[]>();
|
|
73
|
+
for (const run of runs) {
|
|
74
|
+
const under = byName.get(run.name);
|
|
75
|
+
if (under === undefined) byName.set(run.name, [run]);
|
|
76
|
+
else under.push(run);
|
|
77
|
+
}
|
|
78
|
+
return byName;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function stateOf(
|
|
82
|
+
declaration: BackfillDeclaration,
|
|
83
|
+
under: readonly BackfillProgress[],
|
|
84
|
+
completed: BackfillProgress | undefined,
|
|
85
|
+
environment: Environment,
|
|
86
|
+
): BackfillState {
|
|
87
|
+
if (
|
|
88
|
+
checkBackfillEnvironment(declaration.name, declaration.environments, environment) !== undefined
|
|
89
|
+
) {
|
|
90
|
+
return 'excluded';
|
|
91
|
+
}
|
|
92
|
+
// A completed row anywhere in this name's history is what blocks a re-run, so it decides the
|
|
93
|
+
// state even when a later forced pass failed — `decideBackfill` reads the same fact.
|
|
94
|
+
if (completed !== undefined) return 'completed';
|
|
95
|
+
const newest = under[0];
|
|
96
|
+
if (newest === undefined) return 'pending';
|
|
97
|
+
return newest.status === 'running' ? 'running' : 'failed';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function pendingBackfills(input: {
|
|
101
|
+
readonly declarations: readonly BackfillDeclaration[];
|
|
102
|
+
/** Every `x_backfills` row, newest first — `inspectBackfills`' own order, unfiltered. */
|
|
103
|
+
readonly runs: readonly BackfillProgress[];
|
|
104
|
+
readonly environment: Environment;
|
|
105
|
+
}): BackfillPendingReport {
|
|
106
|
+
const byName = groupByName(input.runs);
|
|
107
|
+
const rows = input.declarations.map((declaration): BackfillStateRow => {
|
|
108
|
+
const under = byName.get(declaration.name) ?? [];
|
|
109
|
+
const newest = under[0];
|
|
110
|
+
const completed = under.find((run) => run.status === 'completed');
|
|
111
|
+
return {
|
|
112
|
+
name: declaration.name,
|
|
113
|
+
state: stateOf(declaration, under, completed, input.environment),
|
|
114
|
+
checksum: declaration.checksum,
|
|
115
|
+
ledgerChecksum: newest?.checksum ?? null,
|
|
116
|
+
changed: completed !== undefined && completed.checksum !== declaration.checksum,
|
|
117
|
+
requires: declaration.requires,
|
|
118
|
+
environments: declaration.environments,
|
|
119
|
+
lastRunId: newest?.runId ?? null,
|
|
120
|
+
rows: newest?.rows ?? null,
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
const declared = new Set(input.declarations.map((declaration) => declaration.name));
|
|
124
|
+
const orphaned = [...byName.keys()].filter((name) => !declared.has(name)).sort();
|
|
125
|
+
return {
|
|
126
|
+
environment: input.environment,
|
|
127
|
+
rows,
|
|
128
|
+
pending: rows.filter((row) => isPendingBackfillState(row.state)),
|
|
129
|
+
orphaned,
|
|
130
|
+
};
|
|
131
|
+
}
|