@ultimat3/jobs 29.4.0 → 30.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 +68 -89
- package/README.md +304 -10
- package/package.json +18 -6
- package/src/backfill-definition.ts +184 -0
- package/src/backfill-errors.ts +18 -2
- package/src/backfill-pass.ts +43 -40
- package/src/backfill-pending.ts +40 -17
- package/src/backfill-reader.ts +94 -0
- package/src/backfill-registry.ts +5 -0
- package/src/backfill-scope.ts +1 -2
- package/src/backfill-source.ts +113 -0
- package/src/backfill.ts +54 -135
- package/src/consumer-errors.ts +42 -0
- package/src/consumer-guard.ts +19 -0
- package/src/consumer-loop.ts +171 -0
- package/src/consumer-registry.ts +103 -0
- package/src/consumer-types.ts +81 -0
- package/src/consumer.ts +147 -0
- package/src/driver-memory-operator.ts +25 -2
- package/src/driver-memory.ts +35 -1
- package/src/driver-pg-claim-sql.ts +128 -0
- package/src/driver-pg-ddl.ts +11 -2
- package/src/driver-pg-demand-sql.ts +27 -0
- package/src/driver-pg-enqueue-many.ts +153 -0
- package/src/driver-pg-event-wake-sql.ts +51 -0
- package/src/driver-pg-operator-sql.ts +34 -6
- package/src/driver-pg-operator.ts +36 -2
- package/src/driver-pg-outbox-sql.ts +34 -0
- package/src/driver-pg-sql.ts +24 -91
- package/src/driver-pg.ts +17 -1
- package/src/driver-redis-refusal.ts +25 -0
- package/src/driver-redis-scripts.ts +79 -30
- package/src/driver-redis.ts +70 -42
- package/src/driver.ts +38 -1
- package/src/enqueue-record.ts +137 -0
- package/src/error-codes.ts +233 -0
- package/src/errors-concurrency.ts +2 -2
- package/src/errors-declaration.ts +15 -2
- package/src/errors-operator.ts +1 -1
- package/src/errors-requeue.ts +1 -1
- package/src/errors-saga.ts +87 -0
- package/src/errors-steps.ts +35 -0
- package/src/errors-tenant.ts +1 -1
- package/src/errors.ts +25 -165
- package/src/events-pg.ts +20 -8
- package/src/events.ts +14 -3
- package/src/execute-ctx.ts +51 -0
- package/src/execute-unwind.ts +191 -0
- package/src/execute.ts +50 -51
- package/src/export-errors.ts +1 -1
- package/src/failure-row.ts +43 -0
- package/src/import-errors.ts +102 -0
- package/src/import-format.ts +232 -0
- package/src/import-pass.ts +255 -0
- package/src/import-source.ts +49 -0
- package/src/import.ts +175 -0
- package/src/index.ts +163 -7
- package/src/inspect-operator.ts +24 -3
- package/src/inspect.ts +17 -4
- package/src/introspection.ts +24 -1
- package/src/job-enqueue-many.ts +108 -0
- package/src/job-types.ts +229 -0
- package/src/job.ts +19 -208
- package/src/metrics.ts +10 -0
- package/src/outbox-pg.ts +35 -0
- package/src/outbox-relay.ts +58 -19
- package/src/outbox.ts +65 -73
- package/src/purge-entities.ts +92 -0
- package/src/purge.ts +10 -2
- package/src/queue-wake.ts +1 -1
- package/src/refresh-view.ts +76 -0
- package/src/retry-classification.ts +27 -8
- package/src/retry.ts +26 -0
- package/src/rollup-errors.ts +27 -0
- package/src/rollup.ts +190 -0
- package/src/run-deadline.ts +11 -7
- package/src/scheduler.ts +2 -1
- package/src/settled.ts +56 -15
- package/src/step-resolve.ts +37 -0
- package/src/steps-api.ts +111 -0
- package/src/steps-core.ts +26 -0
- package/src/steps-output.ts +142 -0
- package/src/steps-store.ts +68 -0
- package/src/steps-suspension.ts +51 -0
- package/src/steps-timeout.ts +10 -5
- package/src/steps-unwind.ts +94 -0
- package/src/steps-wait.ts +163 -0
- package/src/steps.ts +174 -210
- package/src/subject-erase-pass.ts +134 -0
- package/src/subject-errors.ts +48 -0
- package/src/subject-export-pass.ts +228 -0
- package/src/subject-jobs.ts +121 -0
- package/src/subject-plan.ts +287 -0
- package/src/subject-select.ts +106 -0
- package/src/subject.ts +119 -0
- package/src/tenant.ts +69 -1
- package/src/type-pins.ts +90 -0
- package/src/webhook-errors.ts +1 -1
- package/src/webhook-receiver-errors.ts +59 -0
- package/src/webhook-receiver-route.ts +127 -0
- package/src/webhook-receiver-types.ts +97 -0
- package/src/webhook-receiver.ts +132 -0
- package/src/webhook-target.ts +20 -43
- package/src/webhook.ts +7 -4
- package/src/worker-queue-demand.ts +57 -0
- package/src/worker-queue-depth.ts +15 -3
- package/src/worker-registry.ts +4 -1
- package/src/worker-types.ts +2 -0
- package/src/worker.ts +2 -2
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// What `backfill()` takes and answers: the two source shapes (a local chain, a remote page
|
|
2
|
+
// function), the scoped variant keyed per input, and the payload a queue row carries. Apart from
|
|
3
|
+
// `backfill.ts` so the factory, the pass and the registry read one set of types.
|
|
4
|
+
|
|
5
|
+
import type { Ctx, Environment } from '@ultimat3/core';
|
|
6
|
+
import type { ReadBuilder } from '@ultimat3/entity';
|
|
7
|
+
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
8
|
+
import type { DurationInput } from './clock';
|
|
9
|
+
import type { RetryPolicy } from './retry';
|
|
10
|
+
import type { JobTenant } from './tenant';
|
|
11
|
+
|
|
12
|
+
export interface BackfillBatch<Row, I = undefined> {
|
|
13
|
+
/** The page `page()` would have returned here: tenancy, soft delete, projection, preloads. */
|
|
14
|
+
readonly rows: readonly Row[];
|
|
15
|
+
readonly ctx: Ctx;
|
|
16
|
+
/**
|
|
17
|
+
* The run's cancellation composed with this batch's own ceiling — the same seam `ctx.signal` is
|
|
18
|
+
* elsewhere. Past it this step may no longer write, so hand it to whatever the body calls.
|
|
19
|
+
*/
|
|
20
|
+
readonly signal: AbortSignal;
|
|
21
|
+
/** 0-based position in the pass, and the step name this batch is checkpointed under. */
|
|
22
|
+
readonly index: number;
|
|
23
|
+
/** The scoped pass's own input — `undefined` for a backfill declaring none. */
|
|
24
|
+
readonly input: I;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface BackfillCommon<Row, I, P> {
|
|
28
|
+
/**
|
|
29
|
+
* REQUIRED, unlike a job's. A backfill's name is a durable key — the queue row, the step trace
|
|
30
|
+
* and the ledger of what has already been run all carry it — so it is never left to whichever
|
|
31
|
+
* export name a module happened to use.
|
|
32
|
+
*/
|
|
33
|
+
readonly name: string;
|
|
34
|
+
/**
|
|
35
|
+
* REQUIRED, exactly as it is on `job()` — a backfill IS a job, so it declares the org its pass
|
|
36
|
+
* runs under rather than inheriting the worker's. A payload carries only `force`, so the two
|
|
37
|
+
* honest spellings are `tenant: () => '<org>'` for a sweep declared against one tenant, and
|
|
38
|
+
* `tenant: 'none'` for one that spans every tenant.
|
|
39
|
+
*
|
|
40
|
+
* `'none'` is where a backfill differs from a plain `job`, and the difference is forced by the
|
|
41
|
+
* shape of `source`: it hands back a LAZY chain, so every page's plan is built inside the
|
|
42
|
+
* iteration — after the declaring frame has closed — and there is nothing an author could wrap
|
|
43
|
+
* in `crossTenant(reason, fn)`. So `backfillPass` opens that scope itself, for a `'none'`
|
|
44
|
+
* declaration only (`backfill-scope.ts`). A declared tenant is handed its context untouched and
|
|
45
|
+
* every page is scoped to that org, exactly as a request would be.
|
|
46
|
+
*/
|
|
47
|
+
readonly tenant: JobTenant<P>;
|
|
48
|
+
/**
|
|
49
|
+
* One page, in the batch's own durable step. Deliberately handed no `step`: a step name minted
|
|
50
|
+
* inside this body would have to be unique across the whole run, and the natural spelling
|
|
51
|
+
* (`step.run('rewrite', …)`) collides with itself on the second batch (`X_STEP_DUPLICATE`).
|
|
52
|
+
*
|
|
53
|
+
* At least once, like every other job body — an attempt cancelled between the last row and the
|
|
54
|
+
* checkpoint replays this page. Write through `upsertAll`, `updateWhere` or an idempotent
|
|
55
|
+
* statement; never `count + 1`.
|
|
56
|
+
*/
|
|
57
|
+
handle(batch: BackfillBatch<Row, I>): Promise<void> | void;
|
|
58
|
+
/** Rows per statement and per step. Defaults to `DEFAULT_BACKFILL_BATCH`. */
|
|
59
|
+
readonly batch?: number;
|
|
60
|
+
/**
|
|
61
|
+
* Batches per second. Defaults to `DEFAULT_BACKFILL_RATE`, which is slow on purpose: this pass
|
|
62
|
+
* shares its pool with the requests the app is still serving. Fractions are a rate too —
|
|
63
|
+
* `rate: 0.5` is one batch every two seconds. To sweep faster raise it; there is no way to
|
|
64
|
+
* turn it off, because a backfill that saturates the pool has no correct value here.
|
|
65
|
+
*/
|
|
66
|
+
readonly rate?: number;
|
|
67
|
+
readonly queue?: string;
|
|
68
|
+
readonly retry?: RetryPolicy;
|
|
69
|
+
/** Per attempt, not per pass: a resumed attempt picks up at the last checkpoint. */
|
|
70
|
+
readonly timeout?: DurationInput;
|
|
71
|
+
/**
|
|
72
|
+
* The migration this sweep needs applied first — the id `x db gen` wrote, e.g.
|
|
73
|
+
* `20260814120000_add_publish_at`. Declared DATA, and checked by whoever can read `x_migrations`
|
|
74
|
+
* (`x db backfill`): this package holds no `@ultimat3/db` dependency, and growing one so a queue
|
|
75
|
+
* could read a migration ledger would put the migration engine on tier 3's import graph.
|
|
76
|
+
*
|
|
77
|
+
* Deliberately NOT a `dependsOn` graph over other backfills. The real dependency is almost
|
|
78
|
+
* always "after code that tolerates both shapes is serving", which the framework cannot observe,
|
|
79
|
+
* so a graph would encode an ordering it has no way to be right about.
|
|
80
|
+
*/
|
|
81
|
+
readonly requires?: string;
|
|
82
|
+
/**
|
|
83
|
+
* The environments this sweep may run in. Omitted means EVERY one — never an implied
|
|
84
|
+
* "cleanups are production": a staging rehearsal is correct practice, so which deploys a sweep
|
|
85
|
+
* belongs to is the app's convention and this field is only the mechanism that carries it
|
|
86
|
+
* (axiom 8). A mismatch is `X_BACKFILL_ENVIRONMENT`, refused inside the pass as well as by the
|
|
87
|
+
* CLI, because a backfill enqueued by app code never passes through a command.
|
|
88
|
+
*/
|
|
89
|
+
readonly environments?: readonly Environment[];
|
|
90
|
+
/**
|
|
91
|
+
* How many rows still match — the SAME predicate `source` selects on, counted rather than read.
|
|
92
|
+
* Optional for `pages` too: a remote may not know, and then the stall check is silent.
|
|
93
|
+
* Optional, and what it buys is that a dry run cannot lie and "did it converge" becomes
|
|
94
|
+
* arithmetic: a pass whose source is exhausted while this still answers above zero has two
|
|
95
|
+
* predicates that disagree, which is `X_BACKFILL_STALLED` and an authoring bug in any business.
|
|
96
|
+
*/
|
|
97
|
+
count?(args: { readonly ctx: Ctx; readonly input: I }): Promise<number> | number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** One page of a remote source: its rows, and where the next page starts — `null` ends the pass. */
|
|
101
|
+
export interface BackfillPage<Row> {
|
|
102
|
+
readonly rows: readonly Row[];
|
|
103
|
+
readonly next: string | null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A remote source, one page per durable step. Only `{ cursor, rows: n }` is checkpointed — never
|
|
108
|
+
* the rows (`backfill-pass.ts`) — so a pass killed mid-page asks for THAT page again and no earlier
|
|
109
|
+
* one. `signal` is the run's cancellation composed with the step's ceiling.
|
|
110
|
+
*/
|
|
111
|
+
export type BackfillPages<Row, I> = (args: {
|
|
112
|
+
readonly ctx: Ctx;
|
|
113
|
+
readonly cursor: string | null;
|
|
114
|
+
readonly signal: AbortSignal;
|
|
115
|
+
readonly input: I;
|
|
116
|
+
}) => Promise<BackfillPage<Row>>;
|
|
117
|
+
|
|
118
|
+
interface FromSource<Row> {
|
|
119
|
+
/**
|
|
120
|
+
* The rows to visit, as a chain: `() => db.posts.where({ published: true })`, or one narrowed by
|
|
121
|
+
* the run's own context — `({ ctx }) => db.posts.where({ orgId: ctx.actor.orgId })`. Read once
|
|
122
|
+
* per attempt and never enqueued, so what a run visits cannot drift from what was declared.
|
|
123
|
+
* `orderBy` is optional — the driver's own total order ends in the primary key, which is what
|
|
124
|
+
* lets every batch resume from the last one's cursor.
|
|
125
|
+
*/
|
|
126
|
+
source(args: { readonly ctx: Ctx }): ReadBuilder<Row>;
|
|
127
|
+
readonly pages?: never;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface FromPages<Row, I> {
|
|
131
|
+
readonly source?: never;
|
|
132
|
+
/** A remote source: exactly one of `source` and `pages` (`X_BACKFILL_SOURCE_INVALID`). */
|
|
133
|
+
readonly pages: BackfillPages<Row, I>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** An unscoped sweep: one pass per name, over a local chain or a remote page function. */
|
|
137
|
+
export type BackfillDefinition<Row> = BackfillCommon<Row, undefined, BackfillInput> &
|
|
138
|
+
(FromSource<Row> | FromPages<Row, undefined>) & {
|
|
139
|
+
readonly input?: never;
|
|
140
|
+
readonly scope?: never;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* One pass per SCOPE: `sync-invoices` per account. The ledger key and the live idempotency key are
|
|
145
|
+
* `<name>:<scope(input)>`, so two scopes keep two ledgers and never dedupe onto each other. An
|
|
146
|
+
* `input` with no `scope` is `X_BACKFILL_SOURCE_INVALID`.
|
|
147
|
+
*/
|
|
148
|
+
export type ScopedBackfillDefinition<Row, I> = BackfillCommon<Row, I, ScopedBackfillInput<I>> &
|
|
149
|
+
(FromSource<Row> | FromPages<Row, I>) & {
|
|
150
|
+
readonly input: StandardSchemaV1<unknown, I>;
|
|
151
|
+
scope(input: I): string;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/** What one completed pass reports — bounded, so `x jobs show` can print it. */
|
|
155
|
+
export interface BackfillReport {
|
|
156
|
+
readonly name: string;
|
|
157
|
+
/** Batches THIS pass handled, replayed ones included. `0` when it was skipped. */
|
|
158
|
+
readonly batches: number;
|
|
159
|
+
readonly rows: number;
|
|
160
|
+
/** True when `x_backfills` already held a completed pass under this name and `force` was not set. */
|
|
161
|
+
readonly skipped: boolean;
|
|
162
|
+
/** The completed pass the ledger answered with, when there was one. */
|
|
163
|
+
readonly previousRunId?: string | undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A backfill's payload is its identity plus the one decision a queue row is allowed to carry.
|
|
168
|
+
* What to visit is declared, so nothing here can drift from the definition; `force` changes only
|
|
169
|
+
* whether a name the ledger already records as completed runs a SECOND pass, and never what that
|
|
170
|
+
* pass would do.
|
|
171
|
+
*/
|
|
172
|
+
export interface BackfillInput {
|
|
173
|
+
/**
|
|
174
|
+
* Run even though `x_backfills` holds a completed pass under this name. The rerun is a NEW
|
|
175
|
+
* ledger row — history is never overwritten, so what each pass swept stays readable.
|
|
176
|
+
*/
|
|
177
|
+
readonly force?: boolean | undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** A scoped pass's payload: the force decision, and the input its scope is derived from. */
|
|
181
|
+
export interface ScopedBackfillInput<I> {
|
|
182
|
+
readonly force?: boolean | undefined;
|
|
183
|
+
readonly input: I;
|
|
184
|
+
}
|
package/src/backfill-errors.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// The
|
|
1
|
+
// The eight `X_BACKFILL_*` codes, apart from `errors.ts` for the reason `driver-pg-rows.ts` is
|
|
2
2
|
// apart from `driver-pg.ts`: one file, one job, and `errors.ts` was over the 500-line ceiling
|
|
3
|
-
// `x verify`'s `filesize` step enforces. The registry
|
|
3
|
+
// `x verify`'s `filesize` step enforces. The registry is `error-codes.ts` — `JOB_OWNED_ERROR_CODES`,
|
|
4
4
|
// `JOB_ERROR_TITLES` and the single `registerErrorCodes()` call — because a package's codes are
|
|
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`.
|
|
@@ -128,3 +128,19 @@ export class BackfillUnknownError extends UltimateError {
|
|
|
128
128
|
});
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A declaration whose rows cannot be read as written: both or neither of `source` and `pages`, or
|
|
134
|
+
* an `input` with no `scope` to key one pass per input. Refused at declaration, before the name is
|
|
135
|
+
* taken, so the corrected declaration still can be.
|
|
136
|
+
*/
|
|
137
|
+
export class BackfillSourceInvalidError extends UltimateError {
|
|
138
|
+
constructor(input: { backfill: string; problem: string; fix: string }) {
|
|
139
|
+
super({
|
|
140
|
+
code: 'X_BACKFILL_SOURCE_INVALID',
|
|
141
|
+
cause: `backfill "${input.backfill}" ${input.problem}`,
|
|
142
|
+
fix: input.fix,
|
|
143
|
+
meta: { backfill: input.backfill },
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
package/src/backfill-pass.ts
CHANGED
|
@@ -14,18 +14,26 @@
|
|
|
14
14
|
// a report an operator reads and the record that a completed name has already been swept.
|
|
15
15
|
|
|
16
16
|
import { appVersion, assert, logger, renderThrowable, resolveEnvironment } from '@ultimat3/core';
|
|
17
|
-
import
|
|
18
|
-
import type {
|
|
17
|
+
import { expectedQueryLoop, expectedSeqScan } from '@ultimat3/db';
|
|
18
|
+
import type { BackfillInput, BackfillReport } from './backfill-definition';
|
|
19
19
|
import { BackfillStalledError } from './backfill-errors';
|
|
20
20
|
import { checkBackfillEnvironment } from './backfill-gate';
|
|
21
21
|
import type { BackfillLedger, BackfillRun } from './backfill-ledger';
|
|
22
22
|
import { decideBackfill } from './backfill-ledger';
|
|
23
23
|
import type { Pacer } from './backfill-rate';
|
|
24
|
+
import { pageReader } from './backfill-reader';
|
|
24
25
|
import { withBackfillScope } from './backfill-scope';
|
|
26
|
+
import type { AnyBackfill } from './backfill-source';
|
|
27
|
+
import { ledgerNameFor } from './backfill-source';
|
|
25
28
|
import { jobDriver } from './driver';
|
|
26
29
|
import type { JobRunArgs } from './job';
|
|
27
30
|
import { isStepSuspension } from './steps';
|
|
28
31
|
|
|
32
|
+
/** Why a backfill's page read and its count read every row: the sweep is the job. */
|
|
33
|
+
const BACKFILL_SCAN = 'a backfill visits every row once, in key order';
|
|
34
|
+
/** Why the scale sweep is told a page's writes grow with the page: the rows ARE the work. */
|
|
35
|
+
const BACKFILL_LOOP = 'a backfill writes every row it visits; a page of N rows is N rows of work';
|
|
36
|
+
|
|
29
37
|
/** Every batch is a step, and this is the name it is checkpointed under. See `backfillPass()`. */
|
|
30
38
|
const STEP_PREFIX = 'batch:';
|
|
31
39
|
|
|
@@ -36,15 +44,12 @@ interface Checkpoint {
|
|
|
36
44
|
readonly rows: number;
|
|
37
45
|
}
|
|
38
46
|
|
|
39
|
-
/**
|
|
40
|
-
|
|
41
|
-
readonly batches: BatchIterator<Row>;
|
|
42
|
-
readonly pull: AsyncIterator<readonly Row[]>;
|
|
43
|
-
}
|
|
47
|
+
/** What a run's payload carries: `force`, and the input a scoped pass is keyed by. */
|
|
48
|
+
type PassInput = BackfillInput & { readonly input?: unknown };
|
|
44
49
|
|
|
45
50
|
/** Everything about the backfill that is fixed at declaration, hashed, sized and paced once. */
|
|
46
51
|
export interface BackfillPlan<Row> {
|
|
47
|
-
readonly definition:
|
|
52
|
+
readonly definition: AnyBackfill<Row>;
|
|
48
53
|
readonly size: number;
|
|
49
54
|
readonly checksum: string;
|
|
50
55
|
/** The declared `rate`, already an interval. Built at declaration for the reason `size` is. */
|
|
@@ -115,12 +120,14 @@ async function markFailed(
|
|
|
115
120
|
* to close.
|
|
116
121
|
*/
|
|
117
122
|
async function assertConverged<Row>(
|
|
118
|
-
definition:
|
|
119
|
-
ctx: JobRunArgs<
|
|
123
|
+
definition: AnyBackfill<Row>,
|
|
124
|
+
ctx: JobRunArgs<PassInput>['ctx'],
|
|
125
|
+
input: unknown,
|
|
120
126
|
swept: number,
|
|
121
127
|
): Promise<void> {
|
|
122
|
-
|
|
123
|
-
|
|
128
|
+
const count = definition.count;
|
|
129
|
+
if (count === undefined) return;
|
|
130
|
+
const remaining = await expectedSeqScan(BACKFILL_SCAN, () => count({ ctx, input }));
|
|
124
131
|
// Parsed, not trusted: `count()` is app code feeding a framework decision, and both `NaN > 0`
|
|
125
132
|
// and `-1 > 0` are FALSE — an unchecked bad number reads as "converged" and writes the completed
|
|
126
133
|
// ledger row that stops the next deploy ever re-running this sweep. The one failure mode this
|
|
@@ -142,11 +149,14 @@ async function assertConverged<Row>(
|
|
|
142
149
|
*/
|
|
143
150
|
export async function backfillPass<Row>(
|
|
144
151
|
plan: BackfillPlan<Row>,
|
|
145
|
-
args: JobRunArgs<
|
|
152
|
+
args: JobRunArgs<PassInput>,
|
|
146
153
|
): Promise<BackfillReport> {
|
|
147
154
|
const { definition, size, checksum, pace } = plan;
|
|
148
155
|
const { step, runId } = args;
|
|
149
|
-
const
|
|
156
|
+
const input = args.input.input;
|
|
157
|
+
// The LEDGER's name: the declaration's, or `<name>:<scope>` for a scoped pass — so two scopes
|
|
158
|
+
// keep two histories, and an unscoped pass writes exactly what it always wrote.
|
|
159
|
+
const name = ledgerNameFor(definition.name, definition.scope, args.input);
|
|
150
160
|
// Before the ledger is even opened: a sweep this deploy may not run must leave no row saying it
|
|
151
161
|
// started. Enforced here and not only in `x db backfill`, because app code that calls
|
|
152
162
|
// `.enqueue()` directly never passes through a command — a rail only the CLI holds is a
|
|
@@ -157,7 +167,11 @@ export async function backfillPass<Row>(
|
|
|
157
167
|
// nothing must not start failing over a variable it never reads.
|
|
158
168
|
const declaredEnvironments = definition.environments;
|
|
159
169
|
if (declaredEnvironments !== undefined && declaredEnvironments.length > 0) {
|
|
160
|
-
const mismatch = checkBackfillEnvironment(
|
|
170
|
+
const mismatch = checkBackfillEnvironment(
|
|
171
|
+
definition.name,
|
|
172
|
+
declaredEnvironments,
|
|
173
|
+
resolveEnvironment(),
|
|
174
|
+
);
|
|
161
175
|
if (mismatch !== undefined) throw mismatch;
|
|
162
176
|
}
|
|
163
177
|
// Absent on a driver that ships no ledger, which runs the pass with no bookkeeping rather than
|
|
@@ -214,24 +228,7 @@ export async function backfillPass<Row>(
|
|
|
214
228
|
let cursor: string | null = null;
|
|
215
229
|
let rows = 0;
|
|
216
230
|
let batches = 0;
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* The iteration positioned at `cursor`, opened on the first batch this attempt actually runs so
|
|
221
|
-
* a resumed pass sends no statement for a page it already handled.
|
|
222
|
-
*
|
|
223
|
-
* `batches.cursor` IS where the next statement starts, so comparing it with the checkpoint's is
|
|
224
|
-
* the whole staleness test — and it is not academic: `retryFromStep` re-opens ONE step in the
|
|
225
|
-
* middle of a finished run, which moves the checkpoints somewhere this iteration is not. Rebuilt
|
|
226
|
-
* from the checkpoint rather than read from wherever the old one was parked.
|
|
227
|
-
*/
|
|
228
|
-
const iterate = async (): Promise<Iteration<Row>> => {
|
|
229
|
-
if (live !== undefined && live.batches.cursor === cursor) return live;
|
|
230
|
-
await live?.batches.close();
|
|
231
|
-
const opened = definition.source({ ctx }).after(cursor).inBatches(size);
|
|
232
|
-
live = { batches: opened, pull: opened[Symbol.asyncIterator]() };
|
|
233
|
-
return live;
|
|
234
|
-
};
|
|
231
|
+
const reader = pageReader(definition, ctx, size, input);
|
|
235
232
|
|
|
236
233
|
try {
|
|
237
234
|
for (let index = 0; ; index += 1) {
|
|
@@ -252,16 +249,22 @@ export async function backfillPass<Row>(
|
|
|
252
249
|
// The signal is the run's cancellation composed with this step's ceiling, so a cancelled
|
|
253
250
|
// pass unwinds out of the wait instead of sitting in a timer nobody is waiting for.
|
|
254
251
|
await pace.wait({ signal, step: stepName });
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
252
|
+
// A backfill visits every row once, in key order: the scan is the point, and the query-plan
|
|
253
|
+
// sweep is told so rather than asked to index a column no read filters on.
|
|
254
|
+
const page = await expectedSeqScan(BACKFILL_SCAN, () => reader.read(cursor, signal));
|
|
255
|
+
// An exhausted chain, or a remote page with nothing on it: no body, no ledger write.
|
|
256
|
+
if (page.rows.length === 0) return { cursor: page.next, rows: 0 };
|
|
258
257
|
if (!recording) {
|
|
259
258
|
recording = true;
|
|
260
259
|
await open();
|
|
261
260
|
}
|
|
262
|
-
|
|
261
|
+
// Its writes grow with the page by definition — every row visited is a row of work —
|
|
262
|
+
// so the scale sweep hears the reason instead of judging a loop it was asked to run.
|
|
263
|
+
await expectedQueryLoop(BACKFILL_LOOP, () =>
|
|
264
|
+
definition.handle({ rows: page.rows, ctx, signal, index, input }),
|
|
265
|
+
);
|
|
263
266
|
swept = true;
|
|
264
|
-
return { cursor:
|
|
267
|
+
return { cursor: page.next, rows: page.rows.length };
|
|
265
268
|
}),
|
|
266
269
|
stepName,
|
|
267
270
|
);
|
|
@@ -288,7 +291,7 @@ export async function backfillPass<Row>(
|
|
|
288
291
|
// this converge", asked once, where the answer is finally decidable. Inside the `try` so the
|
|
289
292
|
// ledger records the attempt as `failed`: a pass that left rows behind must not write the
|
|
290
293
|
// completed row that stops the next deploy re-running it.
|
|
291
|
-
if (recording) await assertConverged(definition, ctx, rows);
|
|
294
|
+
if (recording) await assertConverged(definition, ctx, input, rows);
|
|
292
295
|
} catch (error) {
|
|
293
296
|
// Control flow, not a failure: a suspended run is parked and will be back on this step.
|
|
294
297
|
if (!isStepSuspension(error) && recording) await markFailed(ledger, runId, rows);
|
|
@@ -296,7 +299,7 @@ export async function backfillPass<Row>(
|
|
|
296
299
|
} finally {
|
|
297
300
|
// Whatever the iteration holds belongs to this attempt, and an attempt that failed, was
|
|
298
301
|
// cancelled or finished is done with it either way.
|
|
299
|
-
await
|
|
302
|
+
await reader.close();
|
|
300
303
|
}
|
|
301
304
|
|
|
302
305
|
if (recording) await ledger?.finish(runId, { status: 'completed', rows });
|
package/src/backfill-pending.ts
CHANGED
|
@@ -97,6 +97,36 @@ function stateOf(
|
|
|
97
97
|
return newest.status === 'running' ? 'running' : 'failed';
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/** One declaration judged against the ledger rows under one ledger name. */
|
|
101
|
+
function judged(
|
|
102
|
+
declaration: BackfillDeclaration,
|
|
103
|
+
name: string,
|
|
104
|
+
under: readonly BackfillProgress[],
|
|
105
|
+
environment: Environment,
|
|
106
|
+
): BackfillStateRow {
|
|
107
|
+
const newest = under[0];
|
|
108
|
+
const completed = under.find((run) => run.status === 'completed');
|
|
109
|
+
return {
|
|
110
|
+
name,
|
|
111
|
+
state: stateOf(declaration, under, completed, environment),
|
|
112
|
+
checksum: declaration.checksum,
|
|
113
|
+
ledgerChecksum: newest?.checksum ?? null,
|
|
114
|
+
changed: completed !== undefined && completed.checksum !== declaration.checksum,
|
|
115
|
+
requires: declaration.requires,
|
|
116
|
+
environments: declaration.environments,
|
|
117
|
+
lastRunId: newest?.runId ?? null,
|
|
118
|
+
rows: newest?.rows ?? null,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The ledger names a scoped declaration owns: `<name>:<scope>`. A scope is app data — an account,
|
|
124
|
+
* a vendor — that the framework cannot enumerate, so a scoped declaration reports one row per scope
|
|
125
|
+
* it has RUN and is never `pending` on its own: "which accounts should have synced" is the app's.
|
|
126
|
+
*/
|
|
127
|
+
const scopesOf = (name: string, ledgerNames: Iterable<string>): string[] =>
|
|
128
|
+
[...ledgerNames].filter((ledgerName) => ledgerName.startsWith(`${name}:`)).sort();
|
|
129
|
+
|
|
100
130
|
export function pendingBackfills(input: {
|
|
101
131
|
readonly declarations: readonly BackfillDeclaration[];
|
|
102
132
|
/** Every `x_backfills` row, newest first — `inspectBackfills`' own order, unfiltered. */
|
|
@@ -104,24 +134,17 @@ export function pendingBackfills(input: {
|
|
|
104
134
|
readonly environment: Environment;
|
|
105
135
|
}): BackfillPendingReport {
|
|
106
136
|
const byName = groupByName(input.runs);
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
};
|
|
137
|
+
const owned = new Set<string>();
|
|
138
|
+
const rows = input.declarations.flatMap((declaration): BackfillStateRow[] => {
|
|
139
|
+
const names = declaration.scoped
|
|
140
|
+
? scopesOf(declaration.name, byName.keys())
|
|
141
|
+
: [declaration.name];
|
|
142
|
+
for (const name of names) owned.add(name);
|
|
143
|
+
return names.map((name) =>
|
|
144
|
+
judged(declaration, name, byName.get(name) ?? [], input.environment),
|
|
145
|
+
);
|
|
122
146
|
});
|
|
123
|
-
const
|
|
124
|
-
const orphaned = [...byName.keys()].filter((name) => !declared.has(name)).sort();
|
|
147
|
+
const orphaned = [...byName.keys()].filter((name) => !owned.has(name)).sort();
|
|
125
148
|
return {
|
|
126
149
|
environment: input.environment,
|
|
127
150
|
rows,
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// One page of a backfill, from wherever its rows live: a local chain read through `inBatches()`, or
|
|
2
|
+
// the app's remote page function. The pass (`backfill-pass.ts`) asks "the page at this cursor" and
|
|
3
|
+
// checkpoints `{ cursor: next, rows: n }`; this file is the only place the two sources differ.
|
|
4
|
+
|
|
5
|
+
import type { Ctx } from '@ultimat3/core';
|
|
6
|
+
import { assert } from '@ultimat3/core';
|
|
7
|
+
import type { BatchIterator } from '@ultimat3/entity';
|
|
8
|
+
import type { BackfillPage } from './backfill-definition';
|
|
9
|
+
import type { AnyBackfill } from './backfill-source';
|
|
10
|
+
|
|
11
|
+
export interface PageReader<Row> {
|
|
12
|
+
/** The page starting at `cursor`. `next: null` with no rows is an exhausted source. */
|
|
13
|
+
read(cursor: string | null, signal: AbortSignal): Promise<BackfillPage<Row>>;
|
|
14
|
+
close(): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** One live iteration and the pull that advances it. Rebuilt when the checkpoints disagree. */
|
|
18
|
+
interface Iteration<Row> {
|
|
19
|
+
readonly batches: BatchIterator<Row>;
|
|
20
|
+
readonly pull: AsyncIterator<readonly Row[]>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A local chain. The iteration is opened on the first page an attempt actually reads, so a resumed
|
|
25
|
+
* pass sends no statement for a page it already handled, and positioned at `cursor`:
|
|
26
|
+
* `batches.cursor` IS where the next statement starts, so comparing it with the checkpoint's is the
|
|
27
|
+
* whole staleness test — `retryFromStep` re-opens ONE step mid-run, which moves the checkpoints
|
|
28
|
+
* somewhere a parked iteration is not.
|
|
29
|
+
*/
|
|
30
|
+
function sourceReader<Row>(
|
|
31
|
+
source: NonNullable<AnyBackfill<Row>['source']>,
|
|
32
|
+
ctx: Ctx,
|
|
33
|
+
size: number,
|
|
34
|
+
): PageReader<Row> {
|
|
35
|
+
let live: Iteration<Row> | undefined;
|
|
36
|
+
return {
|
|
37
|
+
async read(cursor) {
|
|
38
|
+
if (live === undefined || live.batches.cursor !== cursor) {
|
|
39
|
+
await live?.batches.close();
|
|
40
|
+
const opened = source({ ctx }).after(cursor).inBatches(size);
|
|
41
|
+
live = { batches: opened, pull: opened[Symbol.asyncIterator]() };
|
|
42
|
+
}
|
|
43
|
+
const next = await live.pull.next();
|
|
44
|
+
if (next.done === true) return { rows: [], next: null };
|
|
45
|
+
return { rows: next.value, next: live.batches.cursor };
|
|
46
|
+
},
|
|
47
|
+
async close() {
|
|
48
|
+
await live?.batches.close();
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The app's page function. Parsed, not trusted: an absent `next` is not `null`, and a pass that
|
|
55
|
+
* read it as "more" would ask the vendor for page `undefined` forever.
|
|
56
|
+
*/
|
|
57
|
+
function pagesReader<Row>(
|
|
58
|
+
definition: AnyBackfill<Row>,
|
|
59
|
+
pages: NonNullable<AnyBackfill<Row>['pages']>,
|
|
60
|
+
ctx: Ctx,
|
|
61
|
+
input: unknown,
|
|
62
|
+
): PageReader<Row> {
|
|
63
|
+
return {
|
|
64
|
+
async read(cursor, signal) {
|
|
65
|
+
const page = await pages({ ctx, cursor, signal, input });
|
|
66
|
+
const next: unknown = page?.next;
|
|
67
|
+
assert(
|
|
68
|
+
Array.isArray(page?.rows) && (next === null || typeof next === 'string'),
|
|
69
|
+
`backfill "${definition.name}" pages() answered a value that is not { rows: [], next: string | null }`,
|
|
70
|
+
`pages: async ({ cursor }) => ({ rows: page.items, next: page.nextCursor ?? null }) on backfill({ name: "${definition.name}" })`,
|
|
71
|
+
);
|
|
72
|
+
// An empty page that does not move the cursor would be asked for again forever: it ends.
|
|
73
|
+
if (page.rows.length === 0 && page.next === cursor) return { rows: [], next: null };
|
|
74
|
+
return page;
|
|
75
|
+
},
|
|
76
|
+
close: () => Promise.resolve(),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function pageReader<Row>(
|
|
81
|
+
definition: AnyBackfill<Row>,
|
|
82
|
+
ctx: Ctx,
|
|
83
|
+
size: number,
|
|
84
|
+
input: unknown,
|
|
85
|
+
): PageReader<Row> {
|
|
86
|
+
if (definition.pages !== undefined) return pagesReader(definition, definition.pages, ctx, input);
|
|
87
|
+
const source = definition.source;
|
|
88
|
+
assert(
|
|
89
|
+
source !== undefined,
|
|
90
|
+
`backfill "${definition.name}" reached its pass with neither source nor pages`,
|
|
91
|
+
`pages: async ({ cursor }) => ({ rows, next }) on backfill({ name: "${definition.name}" })`,
|
|
92
|
+
);
|
|
93
|
+
return sourceReader((args) => source.call(definition, args), ctx, size);
|
|
94
|
+
}
|
package/src/backfill-registry.ts
CHANGED
|
@@ -25,6 +25,8 @@ export type BackfillCount = (args: { readonly ctx: Ctx }) => Promise<number> | n
|
|
|
25
25
|
/** Everything `backfill()` knows that a `JobHandle` has no field for. */
|
|
26
26
|
export interface BackfillOrigin {
|
|
27
27
|
readonly checksum: string;
|
|
28
|
+
/** One pass per `scope(input)`: its ledger rows are `<name>:<scope>`, never `<name>`. */
|
|
29
|
+
readonly scoped: boolean;
|
|
28
30
|
/** A migration id, checked against `x_migrations` by whoever can read it. See `backfill.ts`. */
|
|
29
31
|
readonly requires: string | undefined;
|
|
30
32
|
/** Absent means every environment — never an implied "production only". See `backfill.ts`. */
|
|
@@ -45,6 +47,8 @@ export interface BackfillDeclaration {
|
|
|
45
47
|
readonly requires: string | null;
|
|
46
48
|
readonly environments: readonly Environment[] | null;
|
|
47
49
|
readonly counts: boolean;
|
|
50
|
+
/** Its passes are keyed `<name>:<scope>`; `x db backfill` lists one row per scope it has run. */
|
|
51
|
+
readonly scoped: boolean;
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
const origin = new WeakMap<object, BackfillOrigin>();
|
|
@@ -82,6 +86,7 @@ export function declarationOf(handle: AnyJobHandle): BackfillDeclaration | undef
|
|
|
82
86
|
requires: source.requires ?? null,
|
|
83
87
|
environments: source.environments ?? null,
|
|
84
88
|
counts: source.count !== undefined,
|
|
89
|
+
scoped: source.scoped,
|
|
85
90
|
};
|
|
86
91
|
}
|
|
87
92
|
|
package/src/backfill-scope.ts
CHANGED
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
import type { Actor, Ctx } from '@ultimat3/core';
|
|
9
9
|
import { runWithContext } from '@ultimat3/core';
|
|
10
10
|
import { CROSS_TENANT_SCOPE, crossTenant } from '@ultimat3/entity';
|
|
11
|
-
import type { BackfillInput } from './backfill';
|
|
12
11
|
import type { JobTenant } from './tenant';
|
|
13
12
|
import { NO_JOB_TENANT } from './tenant';
|
|
14
13
|
|
|
@@ -55,7 +54,7 @@ const withCrossTenant = (actor: Actor): Actor =>
|
|
|
55
54
|
*/
|
|
56
55
|
export function withBackfillScope<T>(
|
|
57
56
|
name: string,
|
|
58
|
-
tenant: JobTenant<
|
|
57
|
+
tenant: JobTenant<never>,
|
|
59
58
|
ctx: Ctx,
|
|
60
59
|
pass: (ctx: Ctx) => Promise<T>,
|
|
61
60
|
): Promise<T> {
|