@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,113 @@
|
|
|
1
|
+
// Where a backfill's rows come from, and which ledger a pass writes: exactly one of `source` (a
|
|
2
|
+
// local chain) and `pages` (a remote page function), and — for a scoped declaration — the key
|
|
3
|
+
// `<name>:<scope(input)>` that both the ledger and the live idempotency key carry.
|
|
4
|
+
|
|
5
|
+
import type { Ctx, Environment } from '@ultimat3/core';
|
|
6
|
+
import { assert, describeValue } from '@ultimat3/core';
|
|
7
|
+
import type { ReadBuilder } from '@ultimat3/entity';
|
|
8
|
+
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
9
|
+
import { validate } from '@ultimat3/schema';
|
|
10
|
+
import type { BackfillBatch, BackfillPages } from './backfill-definition';
|
|
11
|
+
import { BackfillSourceInvalidError } from './backfill-errors';
|
|
12
|
+
import type { JobTenant } from './tenant';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Every declaration shape as the pass reads it. Methods, so a typed declaration of either shape
|
|
16
|
+
* is assignable without a cast per field.
|
|
17
|
+
*/
|
|
18
|
+
export interface AnyBackfill<Row> {
|
|
19
|
+
readonly name: string;
|
|
20
|
+
readonly tenant: JobTenant<never>;
|
|
21
|
+
source?(args: { readonly ctx: Ctx }): ReadBuilder<Row>;
|
|
22
|
+
readonly pages?: BackfillPages<Row, unknown> | undefined;
|
|
23
|
+
handle(batch: BackfillBatch<Row, unknown>): Promise<void> | void;
|
|
24
|
+
count?(args: { readonly ctx: Ctx; readonly input: unknown }): Promise<number> | number;
|
|
25
|
+
readonly environments?: readonly Environment[] | undefined;
|
|
26
|
+
scope?(input: unknown): string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const invalid = (backfill: string, problem: string, fix: string): BackfillSourceInvalidError =>
|
|
30
|
+
new BackfillSourceInvalidError({ backfill, problem, fix });
|
|
31
|
+
|
|
32
|
+
/** Refused where it was written: both or neither source, or an input no scope can key. */
|
|
33
|
+
export function assertBackfillSource(definition: {
|
|
34
|
+
readonly name: string;
|
|
35
|
+
readonly source?: unknown;
|
|
36
|
+
readonly pages?: unknown;
|
|
37
|
+
readonly input?: unknown;
|
|
38
|
+
readonly scope?: unknown;
|
|
39
|
+
}): void {
|
|
40
|
+
const name = definition.name;
|
|
41
|
+
const hasSource = typeof definition.source === 'function';
|
|
42
|
+
const hasPages = typeof definition.pages === 'function';
|
|
43
|
+
if (hasSource === hasPages) {
|
|
44
|
+
throw invalid(
|
|
45
|
+
name,
|
|
46
|
+
hasSource
|
|
47
|
+
? 'declares both source and pages, and a pass reads exactly one of them'
|
|
48
|
+
: 'declares neither source nor pages, so it has no rows to visit',
|
|
49
|
+
`pages: async ({ ctx, cursor, signal, input }) => ({ rows, next }) on backfill({ name: "${name}" }) — or source: ({ ctx }) => db.<table>.where(…) — never both`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (definition.input !== undefined && typeof definition.scope !== 'function') {
|
|
53
|
+
throw invalid(
|
|
54
|
+
name,
|
|
55
|
+
'declares an input and no scope, so every input would share one ledger row and one live pass',
|
|
56
|
+
`scope: (input) => input.accountId beside input on backfill({ name: "${name}" })`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The ledger key and the live idempotency key. Unscoped it is the name — byte-identical to every
|
|
63
|
+
* pass recorded before scopes existed. A scope is app data, so an empty one is refused rather than
|
|
64
|
+
* colliding with the unscoped name.
|
|
65
|
+
*/
|
|
66
|
+
export function ledgerNameFor(
|
|
67
|
+
name: string,
|
|
68
|
+
scope: ((input: never) => string) | undefined,
|
|
69
|
+
payload: { readonly input?: unknown },
|
|
70
|
+
): string {
|
|
71
|
+
if (scope === undefined) return name;
|
|
72
|
+
const key: unknown = (scope as (input: unknown) => unknown)(payload.input);
|
|
73
|
+
assert(
|
|
74
|
+
typeof key === 'string' && key.length > 0,
|
|
75
|
+
`backfill "${name}" scope(input) answered ${describeValue(key)} — a scope is a non-empty string`,
|
|
76
|
+
`scope: (input) => input.accountId on backfill({ name: "${name}" })`,
|
|
77
|
+
);
|
|
78
|
+
return `${name}:${key}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** `{ force?, input }`, the input checked by the declaration's own schema, synchronously. */
|
|
82
|
+
export function scopedPayload<I>(
|
|
83
|
+
name: string,
|
|
84
|
+
input: StandardSchemaV1<unknown, I>,
|
|
85
|
+
): StandardSchemaV1<unknown, { readonly force?: boolean; readonly input: I }> {
|
|
86
|
+
return {
|
|
87
|
+
'~standard': {
|
|
88
|
+
version: 1,
|
|
89
|
+
vendor: 'ultimate-backfill',
|
|
90
|
+
validate(value: unknown) {
|
|
91
|
+
if (typeof value !== 'object' || value === null) {
|
|
92
|
+
return { issues: [{ message: `backfill "${name}" payload must be { force?, input }` }] };
|
|
93
|
+
}
|
|
94
|
+
const raw = value as { readonly force?: unknown; readonly input?: unknown };
|
|
95
|
+
if (raw.force !== undefined && typeof raw.force !== 'boolean') {
|
|
96
|
+
return { issues: [{ message: 'force must be a boolean', path: ['force'] }] };
|
|
97
|
+
}
|
|
98
|
+
const parsed = validate(input, raw.input);
|
|
99
|
+
if (parsed.issues !== undefined) {
|
|
100
|
+
return {
|
|
101
|
+
issues: parsed.issues.map((issue) => ({
|
|
102
|
+
message: issue.message,
|
|
103
|
+
path: ['input', ...(issue.path ?? [])],
|
|
104
|
+
})),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
value: { ...(raw.force === undefined ? {} : { force: raw.force }), input: parsed.value },
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
package/src/backfill.ts
CHANGED
|
@@ -9,19 +9,23 @@
|
|
|
9
9
|
// and `execute.ts` already have, and the reason the iteration, the checkpoints and the
|
|
10
10
|
// `x_backfills` ledger are one file's problem rather than this one's.
|
|
11
11
|
|
|
12
|
-
import type { Ctx, Environment } from '@ultimat3/core';
|
|
13
12
|
import { assert } from '@ultimat3/core';
|
|
14
|
-
import type { ReadBuilder } from '@ultimat3/entity';
|
|
15
13
|
import { t } from '@ultimat3/schema';
|
|
14
|
+
import type {
|
|
15
|
+
BackfillDefinition,
|
|
16
|
+
BackfillInput,
|
|
17
|
+
ScopedBackfillDefinition,
|
|
18
|
+
ScopedBackfillInput,
|
|
19
|
+
} from './backfill-definition';
|
|
16
20
|
import { backfillChecksum } from './backfill-ledger';
|
|
17
21
|
import { backfillPass } from './backfill-pass';
|
|
18
22
|
import { backfillPacer, DEFAULT_BACKFILL_RATE } from './backfill-rate';
|
|
19
23
|
import type { BackfillCount } from './backfill-registry';
|
|
20
24
|
import { stampBackfill } from './backfill-registry';
|
|
21
|
-
import type {
|
|
25
|
+
import type { AnyBackfill } from './backfill-source';
|
|
26
|
+
import { assertBackfillSource, ledgerNameFor, scopedPayload } from './backfill-source';
|
|
22
27
|
import type { JobHandle } from './job';
|
|
23
28
|
import { job } from './job';
|
|
24
|
-
import type { RetryPolicy } from './retry';
|
|
25
29
|
import { DEFAULT_RETRY } from './retry';
|
|
26
30
|
import type { JobTenant } from './tenant';
|
|
27
31
|
|
|
@@ -33,126 +37,26 @@ import type { JobTenant } from './tenant';
|
|
|
33
37
|
*/
|
|
34
38
|
export const DEFAULT_BACKFILL_BATCH = 1_000;
|
|
35
39
|
|
|
36
|
-
export
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
readonly index: number;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export interface BackfillDefinition<Row> {
|
|
50
|
-
/**
|
|
51
|
-
* REQUIRED, unlike a job's. A backfill's name is a durable key — the queue row, the step trace
|
|
52
|
-
* and the ledger of what has already been run all carry it — so it is never left to whichever
|
|
53
|
-
* export name a module happened to use.
|
|
54
|
-
*/
|
|
55
|
-
readonly name: string;
|
|
56
|
-
/**
|
|
57
|
-
* REQUIRED, exactly as it is on `job()` — a backfill IS a job, so it declares the org its pass
|
|
58
|
-
* runs under rather than inheriting the worker's. A payload carries only `force`, so the two
|
|
59
|
-
* honest spellings are `tenant: () => '<org>'` for a sweep declared against one tenant, and
|
|
60
|
-
* `tenant: 'none'` for one that spans every tenant.
|
|
61
|
-
*
|
|
62
|
-
* `'none'` is where a backfill differs from a plain `job`, and the difference is forced by the
|
|
63
|
-
* shape of `source`: it hands back a LAZY chain, so every page's plan is built inside the
|
|
64
|
-
* iteration — after the declaring frame has closed — and there is nothing an author could wrap
|
|
65
|
-
* in `crossTenant(reason, fn)`. So `backfillPass` opens that scope itself, for a `'none'`
|
|
66
|
-
* declaration only (`backfill-scope.ts`). A declared tenant is handed its context untouched and
|
|
67
|
-
* every page is scoped to that org, exactly as a request would be.
|
|
68
|
-
*/
|
|
69
|
-
readonly tenant: JobTenant<BackfillInput>;
|
|
70
|
-
/**
|
|
71
|
-
* The rows to visit, as a chain: `() => db.posts.where({ published: true })`, or one narrowed by
|
|
72
|
-
* the run's own context — `({ ctx }) => db.posts.where({ orgId: ctx.actor.orgId })`. Read once
|
|
73
|
-
* per attempt and never enqueued, so what a run visits cannot drift from what was declared.
|
|
74
|
-
* `orderBy` is optional — the driver's own total order ends in the primary key, which is what
|
|
75
|
-
* lets every batch resume from the last one's cursor.
|
|
76
|
-
*/
|
|
77
|
-
source(args: { readonly ctx: Ctx }): ReadBuilder<Row>;
|
|
78
|
-
/**
|
|
79
|
-
* One page, in the batch's own durable step. Deliberately handed no `step`: a step name minted
|
|
80
|
-
* inside this body would have to be unique across the whole run, and the natural spelling
|
|
81
|
-
* (`step.run('rewrite', …)`) collides with itself on the second batch (`X_STEP_DUPLICATE`).
|
|
82
|
-
*
|
|
83
|
-
* At least once, like every other job body — an attempt cancelled between the last row and the
|
|
84
|
-
* checkpoint replays this page. Write through `upsertAll`, `updateWhere` or an idempotent
|
|
85
|
-
* statement; never `count + 1`.
|
|
86
|
-
*/
|
|
87
|
-
handle(batch: BackfillBatch<Row>): Promise<void> | void;
|
|
88
|
-
/** Rows per statement and per step. Defaults to `DEFAULT_BACKFILL_BATCH`. */
|
|
89
|
-
readonly batch?: number;
|
|
90
|
-
/**
|
|
91
|
-
* Batches per second. Defaults to `DEFAULT_BACKFILL_RATE`, which is slow on purpose: this pass
|
|
92
|
-
* shares its pool with the requests the app is still serving. Fractions are a rate too —
|
|
93
|
-
* `rate: 0.5` is one batch every two seconds. To sweep faster raise it; there is no way to
|
|
94
|
-
* turn it off, because a backfill that saturates the pool has no correct value here.
|
|
95
|
-
*/
|
|
96
|
-
readonly rate?: number;
|
|
97
|
-
readonly queue?: string;
|
|
98
|
-
readonly retry?: RetryPolicy;
|
|
99
|
-
/** Per attempt, not per pass: a resumed attempt picks up at the last checkpoint. */
|
|
100
|
-
readonly timeout?: DurationInput;
|
|
101
|
-
/**
|
|
102
|
-
* The migration this sweep needs applied first — the id `x db gen` wrote, e.g.
|
|
103
|
-
* `20260814120000_add_publish_at`. Declared DATA, and checked by whoever can read `x_migrations`
|
|
104
|
-
* (`x db backfill`): this package holds no `@ultimat3/db` dependency, and growing one so a queue
|
|
105
|
-
* could read a migration ledger would put the migration engine on tier 3's import graph.
|
|
106
|
-
*
|
|
107
|
-
* Deliberately NOT a `dependsOn` graph over other backfills. The real dependency is almost
|
|
108
|
-
* always "after code that tolerates both shapes is serving", which the framework cannot observe,
|
|
109
|
-
* so a graph would encode an ordering it has no way to be right about.
|
|
110
|
-
*/
|
|
111
|
-
readonly requires?: string;
|
|
112
|
-
/**
|
|
113
|
-
* The environments this sweep may run in. Omitted means EVERY one — never an implied
|
|
114
|
-
* "cleanups are production": a staging rehearsal is correct practice, so which deploys a sweep
|
|
115
|
-
* belongs to is the app's convention and this field is only the mechanism that carries it
|
|
116
|
-
* (axiom 8). A mismatch is `X_BACKFILL_ENVIRONMENT`, refused inside the pass as well as by the
|
|
117
|
-
* CLI, because a backfill enqueued by app code never passes through a command.
|
|
118
|
-
*/
|
|
119
|
-
readonly environments?: readonly Environment[];
|
|
120
|
-
/**
|
|
121
|
-
* How many rows still match — the SAME predicate `source` selects on, counted rather than read.
|
|
122
|
-
* Optional, and what it buys is that a dry run cannot lie and "did it converge" becomes
|
|
123
|
-
* arithmetic: a pass whose source is exhausted while this still answers above zero has two
|
|
124
|
-
* predicates that disagree, which is `X_BACKFILL_STALLED` and an authoring bug in any business.
|
|
125
|
-
*/
|
|
126
|
-
count?(args: { readonly ctx: Ctx }): Promise<number> | number;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** What one completed pass reports — bounded, so `x jobs show` can print it. */
|
|
130
|
-
export interface BackfillReport {
|
|
131
|
-
readonly name: string;
|
|
132
|
-
/** Batches THIS pass handled, replayed ones included. `0` when it was skipped. */
|
|
133
|
-
readonly batches: number;
|
|
134
|
-
readonly rows: number;
|
|
135
|
-
/** True when `x_backfills` already held a completed pass under this name and `force` was not set. */
|
|
136
|
-
readonly skipped: boolean;
|
|
137
|
-
/** The completed pass the ledger answered with, when there was one. */
|
|
138
|
-
readonly previousRunId?: string | undefined;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* A backfill's payload is its identity plus the one decision a queue row is allowed to carry.
|
|
143
|
-
* What to visit is declared, so nothing here can drift from the definition; `force` changes only
|
|
144
|
-
* whether a name the ledger already records as completed runs a SECOND pass, and never what that
|
|
145
|
-
* pass would do.
|
|
146
|
-
*/
|
|
147
|
-
export interface BackfillInput {
|
|
148
|
-
/**
|
|
149
|
-
* Run even though `x_backfills` holds a completed pass under this name. The rerun is a NEW
|
|
150
|
-
* ledger row — history is never overwritten, so what each pass swept stays readable.
|
|
151
|
-
*/
|
|
152
|
-
readonly force?: boolean | undefined;
|
|
153
|
-
}
|
|
40
|
+
export type {
|
|
41
|
+
BackfillBatch,
|
|
42
|
+
BackfillDefinition,
|
|
43
|
+
BackfillInput,
|
|
44
|
+
BackfillPage,
|
|
45
|
+
BackfillPages,
|
|
46
|
+
BackfillReport,
|
|
47
|
+
ScopedBackfillDefinition,
|
|
48
|
+
ScopedBackfillInput,
|
|
49
|
+
} from './backfill-definition';
|
|
154
50
|
|
|
155
|
-
export function backfill<Row>(definition: BackfillDefinition<Row>): JobHandle<BackfillInput
|
|
51
|
+
export function backfill<Row>(definition: BackfillDefinition<Row>): JobHandle<BackfillInput>;
|
|
52
|
+
export function backfill<Row, I>(
|
|
53
|
+
definition: ScopedBackfillDefinition<Row, I>,
|
|
54
|
+
): JobHandle<ScopedBackfillInput<I>>;
|
|
55
|
+
export function backfill<Row, I>(
|
|
56
|
+
definition: BackfillDefinition<Row> | ScopedBackfillDefinition<Row, I>,
|
|
57
|
+
): JobHandle<BackfillInput> | JobHandle<ScopedBackfillInput<I>> {
|
|
58
|
+
// Before anything is registered under the name, so the corrected declaration still can be.
|
|
59
|
+
assertBackfillSource(definition);
|
|
156
60
|
const size = definition.batch ?? DEFAULT_BACKFILL_BATCH;
|
|
157
61
|
// Refused where it was written. `inBatches()` refuses the same number one statement in, which
|
|
158
62
|
// for a backfill means a dead-lettered job and a stack trace instead of a failing build.
|
|
@@ -173,7 +77,8 @@ export function backfill<Row>(definition: BackfillDefinition<Row>): JobHandle<Ba
|
|
|
173
77
|
// per attempt would charge every batch of every pass for a fact that is fixed at import.
|
|
174
78
|
// `rate` is NOT in it, for the reason `batch` is not: pacing is a tuning knob, and changing one
|
|
175
79
|
// does not make a completed sweep a different sweep.
|
|
176
|
-
|
|
80
|
+
// `pages` is hashed where `source` would be: either is "which rows this pass visits".
|
|
81
|
+
const checksum = backfillChecksum(definition.pages ?? definition.source, definition.handle);
|
|
177
82
|
// Built here rather than per attempt: the interval belongs to the table and the pool, not to
|
|
178
83
|
// whichever attempt holds the run, so a retrying pass keeps the pace it was declared with.
|
|
179
84
|
const pace = backfillPacer({ rate, job: definition.name });
|
|
@@ -181,33 +86,47 @@ export function backfill<Row>(definition: BackfillDefinition<Row>): JobHandle<Ba
|
|
|
181
86
|
// Bound to the definition rather than passed bare: `count` is declared as a method, so a
|
|
182
87
|
// reference torn off the object literal would run with `this` undefined the first time an
|
|
183
88
|
// author writes `count: ({ ctx }) => this.something`.
|
|
184
|
-
|
|
89
|
+
// A scoped count needs the scope's input, which `x db backfill` has none of: not stamped.
|
|
90
|
+
const declaredCount = definition.scope === undefined ? definition.count : undefined;
|
|
185
91
|
const count: BackfillCount | undefined =
|
|
186
|
-
declaredCount === undefined
|
|
92
|
+
declaredCount === undefined
|
|
93
|
+
? undefined
|
|
94
|
+
: ({ ctx }) =>
|
|
95
|
+
(declaredCount as NonNullable<AnyBackfill<Row>['count']>).call(definition, {
|
|
96
|
+
ctx,
|
|
97
|
+
input: undefined,
|
|
98
|
+
});
|
|
187
99
|
|
|
188
|
-
const
|
|
100
|
+
const scope = definition.scope;
|
|
101
|
+
const handle = job<BackfillInput & { readonly input?: unknown }>({
|
|
189
102
|
name: definition.name,
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
103
|
+
// Unscoped: byte-identical to before scopes existed. Scoped: the force flag and the input.
|
|
104
|
+
input:
|
|
105
|
+
scope === undefined
|
|
106
|
+
? t.object({ force: t.boolean.optional() })
|
|
107
|
+
: scopedPayload(definition.name, definition.input),
|
|
108
|
+
// One live run per name — per scope for a scoped one — forced or not. A second enqueue while
|
|
109
|
+
// the pass is still going is the same pass, and deduping it is what makes "kick it again" safe
|
|
110
|
+
// rather than a second writer on one table — which a `force` in the key would have allowed.
|
|
111
|
+
idempotencyKey: (payload) => ledgerNameFor(definition.name, scope, payload),
|
|
195
112
|
// Forwarded verbatim, like every other job field this factory carries: a backfill that
|
|
196
113
|
// declared its tenant and then ran under somebody else's would be a factory deciding authz.
|
|
197
|
-
tenant: definition.tenant
|
|
114
|
+
tenant: definition.tenant as JobTenant<BackfillInput & { readonly input?: unknown }>,
|
|
198
115
|
retry: definition.retry ?? DEFAULT_RETRY,
|
|
199
116
|
...(definition.queue === undefined ? {} : { queue: definition.queue }),
|
|
200
117
|
...(definition.timeout === undefined ? {} : { timeout: definition.timeout }),
|
|
201
|
-
run: (args) =>
|
|
118
|
+
run: (args) =>
|
|
119
|
+
backfillPass({ definition: definition as AnyBackfill<Row>, size, checksum, pace }, args),
|
|
202
120
|
});
|
|
203
121
|
// Stamped, never registered by the app: this is what makes a declared-but-never-enqueued sweep
|
|
204
122
|
// visible to `x db backfill --pending`, and the `origin` WeakMap is `task.ts`'s mechanism rather
|
|
205
123
|
// than a second one. `job()` above already refused a duplicate name, so this cannot overwrite.
|
|
206
124
|
stampBackfill(handle, {
|
|
207
125
|
checksum,
|
|
126
|
+
scoped: scope !== undefined,
|
|
208
127
|
requires: definition.requires,
|
|
209
128
|
environments: definition.environments,
|
|
210
129
|
count,
|
|
211
130
|
});
|
|
212
|
-
return handle
|
|
131
|
+
return handle as JobHandle<BackfillInput>;
|
|
213
132
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// The two `X_CONSUMER_*` codes and the classes that throw them. The registry is `error-codes.ts`;
|
|
2
|
+
// this file holds only the refusals, beside the factory (`consumer.ts`) that raises the first and
|
|
3
|
+
// the enqueue guard (`consumer-guard.ts`) that raises the second.
|
|
4
|
+
|
|
5
|
+
import { renderFixShellArg, UltimateError } from '@ultimat3/core';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A `consumer()` that could not be assembled correctly. Every one of these is a field whose
|
|
9
|
+
* absence has no other build error: a consumer with no cursor re-reads its stream from the top on
|
|
10
|
+
* every restart, and one with a `timeout` is killed mid-stream on a schedule nobody chose.
|
|
11
|
+
*/
|
|
12
|
+
export class ConsumerInvalidError extends UltimateError {
|
|
13
|
+
constructor(input: {
|
|
14
|
+
readonly consumer: string;
|
|
15
|
+
readonly problem: string;
|
|
16
|
+
readonly fix: string;
|
|
17
|
+
}) {
|
|
18
|
+
super({
|
|
19
|
+
code: 'X_CONSUMER_INVALID',
|
|
20
|
+
cause: `consumer "${input.consumer}" ${input.problem}`,
|
|
21
|
+
fix: input.fix,
|
|
22
|
+
meta: { consumer: input.consumer },
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A consumer's own body enqueued the consumer. That enqueue is deduped onto the row that is
|
|
29
|
+
* running it, so it arms nothing — the trap `recipes.job.test.ts` pins. Refused instead of
|
|
30
|
+
* answered: the supervisor task already re-arms every key.
|
|
31
|
+
*/
|
|
32
|
+
export class ConsumerSelfEnqueueError extends UltimateError {
|
|
33
|
+
constructor(input: { readonly consumer: string }) {
|
|
34
|
+
const name = renderFixShellArg(input.consumer, "'<consumer>'");
|
|
35
|
+
super({
|
|
36
|
+
code: 'X_CONSUMER_SELF_ENQUEUE',
|
|
37
|
+
cause: `consumer "${input.consumer}" enqueued itself from inside its own run; that enqueue dedupes onto the running row and arms nothing, and the consumer's .supervisor task already re-arms every key`,
|
|
38
|
+
fix: `grep -rn ${name} apps/web --include=*.ts # delete the .enqueue call inside the consumer's handle or open`,
|
|
39
|
+
meta: { consumer: input.consumer },
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Which consumer's run this async context is inside, and the refusal of that consumer enqueuing
|
|
2
|
+
// itself. A self-enqueue dedupes onto the running row and arms nothing (`recipes.job.test.ts`
|
|
3
|
+
// pins the trap), so it is made unreachable here rather than documented.
|
|
4
|
+
|
|
5
|
+
import { asyncContext } from '@ultimat3/core';
|
|
6
|
+
import { ConsumerSelfEnqueueError } from './consumer-errors';
|
|
7
|
+
|
|
8
|
+
/** Core's one async-context seam: server-only, like every job body. */
|
|
9
|
+
const running = asyncContext<string>('a consumer() run');
|
|
10
|
+
|
|
11
|
+
/** Run `fn` as consumer `name`'s body: every enqueue of `name` inside it is refused. */
|
|
12
|
+
export function asConsumer<T>(name: string, fn: () => T): T {
|
|
13
|
+
return running.run(name, fn);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Throws `X_CONSUMER_SELF_ENQUEUE` when the caller is consumer `name`'s own run. */
|
|
17
|
+
export function refuseSelfEnqueue(name: string): void {
|
|
18
|
+
if (running.get() === name) throw new ConsumerSelfEnqueueError({ consumer: name });
|
|
19
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// One consumer run: open the stream at the saved cursor, then turn after turn — read a batch,
|
|
2
|
+
// handle it and save the cursor past it in ONE transaction, report progress, check the drain.
|
|
3
|
+
// Split from `consumer.ts` for the reason `backfill-pass.ts` is split from `backfill.ts`.
|
|
4
|
+
//
|
|
5
|
+
// No step per batch, unlike a backfill: the cursor lives in the app's own row, committed with the
|
|
6
|
+
// batch's effect, so a resumed run starts from `cursor.load` and steps would only retain history.
|
|
7
|
+
|
|
8
|
+
import type { Ctx } from '@ultimat3/core';
|
|
9
|
+
import { assert, logger, renderThrowable, throwIfAborted } from '@ultimat3/core';
|
|
10
|
+
import { withTransaction } from '@ultimat3/db';
|
|
11
|
+
import type { ConsumerDefinition, ConsumerInput, ConsumerReport } from './consumer-types';
|
|
12
|
+
import type { JobRunArgs } from './job';
|
|
13
|
+
|
|
14
|
+
/** Rows per transaction when a declaration names none. Each batch is one commit. */
|
|
15
|
+
export const DEFAULT_CONSUMER_BATCH = 100;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* How long a partly filled batch waits for its next item before it commits what it holds. Without
|
|
19
|
+
* it a quiet stream would keep a batch in hand until `batch` items arrived — items read and never
|
|
20
|
+
* committed, for as long as the stream stays quiet.
|
|
21
|
+
*/
|
|
22
|
+
export const CONSUMER_LINGER_MS = 50;
|
|
23
|
+
|
|
24
|
+
/** The note a consumer's progress carries, so `x jobs show` can tell its row from any job's. */
|
|
25
|
+
export const CONSUMER_PROGRESS_NOTE = 'consumer';
|
|
26
|
+
|
|
27
|
+
export interface ConsumerPlan<Item, C> {
|
|
28
|
+
readonly definition: ConsumerDefinition<Item, C>;
|
|
29
|
+
readonly size: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Answers `undefined` when `promise` has not settled within `ms`; the promise stays usable. */
|
|
33
|
+
async function within<T>(promise: Promise<T>, ms: number): Promise<T | undefined> {
|
|
34
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
35
|
+
const lapse = new Promise<undefined>((resolve) => {
|
|
36
|
+
timer = setTimeout(() => resolve(undefined), ms);
|
|
37
|
+
});
|
|
38
|
+
try {
|
|
39
|
+
return await Promise.race([promise, lapse]);
|
|
40
|
+
} finally {
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Waits for `promise`, or throws the run's abort the moment the drain fires. */
|
|
46
|
+
async function untilAborted<T>(promise: Promise<T>, ctx: Ctx): Promise<T> {
|
|
47
|
+
throwIfAborted(ctx);
|
|
48
|
+
let stop: (() => void) | undefined;
|
|
49
|
+
const aborted = new Promise<'aborted'>((resolve) => {
|
|
50
|
+
stop = () => resolve('aborted');
|
|
51
|
+
ctx.signal.addEventListener('abort', stop, { once: true });
|
|
52
|
+
});
|
|
53
|
+
try {
|
|
54
|
+
const first = await Promise.race([promise, aborted]);
|
|
55
|
+
if (first === 'aborted') throwIfAborted(ctx);
|
|
56
|
+
return first as T;
|
|
57
|
+
} finally {
|
|
58
|
+
if (stop !== undefined) ctx.signal.removeEventListener('abort', stop);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Groups the stream into batches of at most `size`. The pull in flight when a batch closes is
|
|
64
|
+
* carried into the next one, never dropped: an item already read is an item the next batch owns.
|
|
65
|
+
*/
|
|
66
|
+
function batcher<Item>(iterator: AsyncIterator<Item>, size: number, ctx: Ctx) {
|
|
67
|
+
let pending: Promise<IteratorResult<Item>> | undefined;
|
|
68
|
+
let ended = false;
|
|
69
|
+
const advance = (): Promise<IteratorResult<Item>> => {
|
|
70
|
+
if (pending === undefined) {
|
|
71
|
+
pending = iterator.next();
|
|
72
|
+
// A pull still in flight when a drain ends the run is awaited by nobody: its rejection is
|
|
73
|
+
// the stream's to report, not an unhandled one in the worker.
|
|
74
|
+
pending.catch(() => undefined);
|
|
75
|
+
}
|
|
76
|
+
return pending;
|
|
77
|
+
};
|
|
78
|
+
return {
|
|
79
|
+
inFlight: (): boolean => pending !== undefined,
|
|
80
|
+
async next(): Promise<readonly Item[] | null> {
|
|
81
|
+
if (ended) return null;
|
|
82
|
+
const first = await untilAborted(advance(), ctx);
|
|
83
|
+
pending = undefined;
|
|
84
|
+
if (first.done === true) {
|
|
85
|
+
ended = true;
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const items: Item[] = [first.value];
|
|
89
|
+
while (items.length < size) {
|
|
90
|
+
const step = await within(advance(), CONSUMER_LINGER_MS);
|
|
91
|
+
if (step === undefined) break;
|
|
92
|
+
pending = undefined;
|
|
93
|
+
if (step.done === true) {
|
|
94
|
+
ended = true;
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
items.push(step.value);
|
|
98
|
+
}
|
|
99
|
+
return items;
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Closes the stream. Awaited only when no pull is in flight: an async generator queues `return()`
|
|
106
|
+
* behind a pending `next()`, so awaiting it on a quiet stream would hold a drained run open.
|
|
107
|
+
*/
|
|
108
|
+
async function close<Item>(iterator: AsyncIterator<Item>, inFlight: boolean, name: string) {
|
|
109
|
+
const closing = iterator.return?.();
|
|
110
|
+
if (closing === undefined) return;
|
|
111
|
+
const logged = closing.then(
|
|
112
|
+
() => undefined,
|
|
113
|
+
(error: unknown) => {
|
|
114
|
+
logger.warn('jobs.consumer.close-failed', { consumer: name, error: renderThrowable(error) });
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
if (!inFlight) await logged;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function lagOf<Item, C>(
|
|
121
|
+
definition: ConsumerDefinition<Item, C>,
|
|
122
|
+
key: string,
|
|
123
|
+
cursor: C | null,
|
|
124
|
+
): Promise<number> {
|
|
125
|
+
if (definition.lag === undefined) return 0;
|
|
126
|
+
const lag = await definition.lag({ key, cursor });
|
|
127
|
+
assert(
|
|
128
|
+
Number.isFinite(lag) && lag >= 0,
|
|
129
|
+
`consumer "${definition.name}" lag() returned ${String(lag)} for key "${key}" — a lag is a finite number, zero or more`,
|
|
130
|
+
`lag: ({ key, cursor }) => Math.max(0, head(key) - position(cursor)) on consumer({ name: "${definition.name}" })`,
|
|
131
|
+
);
|
|
132
|
+
return lag;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The run: until the stream ends (a normal return; the next arm reopens it) or a drain stops it. */
|
|
136
|
+
export async function consumerRun<Item, C>(
|
|
137
|
+
plan: ConsumerPlan<Item, C>,
|
|
138
|
+
args: JobRunArgs<ConsumerInput>,
|
|
139
|
+
): Promise<ConsumerReport> {
|
|
140
|
+
const { definition, size } = plan;
|
|
141
|
+
const { ctx } = args;
|
|
142
|
+
const { key } = args.input;
|
|
143
|
+
const signal = ctx.signal;
|
|
144
|
+
let cursor = await definition.cursor.load(key);
|
|
145
|
+
const iterator = definition.open({ key, cursor, ctx, signal })[Symbol.asyncIterator]();
|
|
146
|
+
const pull = batcher(iterator, size, ctx);
|
|
147
|
+
let batches = 0;
|
|
148
|
+
let items = 0;
|
|
149
|
+
try {
|
|
150
|
+
for (;;) {
|
|
151
|
+
// The drain check, between turns: a turn is whole or absent, so the cursor stays on the
|
|
152
|
+
// last batch that committed.
|
|
153
|
+
throwIfAborted(ctx);
|
|
154
|
+
const batch = await pull.next();
|
|
155
|
+
if (batch === null) break;
|
|
156
|
+
const next = await withTransaction(async () => {
|
|
157
|
+
const after = await definition.handle({ items: batch, key, ctx, signal });
|
|
158
|
+
await definition.cursor.save(key, after);
|
|
159
|
+
return after;
|
|
160
|
+
});
|
|
161
|
+
cursor = next;
|
|
162
|
+
batches += 1;
|
|
163
|
+
items += batch.length;
|
|
164
|
+
// `total - done` is the lag: the job row's progress is the one place a run reports itself.
|
|
165
|
+
args.progress(items, items + (await lagOf(definition, key, cursor)), CONSUMER_PROGRESS_NOTE);
|
|
166
|
+
}
|
|
167
|
+
} finally {
|
|
168
|
+
await close(iterator, pull.inFlight(), definition.name);
|
|
169
|
+
}
|
|
170
|
+
return { key, batches, items };
|
|
171
|
+
}
|