@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.
@@ -0,0 +1,109 @@
1
+ // The `rate` throttle a `backfill()` sweeps under: batches per second, spent as a wait between one
2
+ // batch and the next. A sweep shares its connection pool with the requests the app is still
3
+ // serving, so a backfill that runs flat out is an outage of its own making — the pacer is what
4
+ // makes "one pass over the table" background work rather than a load test.
5
+
6
+ import type { Clock } from '@ultimat3/core';
7
+ import { assert, systemClock } from '@ultimat3/core';
8
+ import { nowMs } from './clock';
9
+ import { JobAbortedError } from './errors';
10
+
11
+ /**
12
+ * Batches per second, and deliberately slow: at `DEFAULT_BACKFILL_BATCH` (1,000 rows) this is
13
+ * 5,000 rows/sec — one statement every 200ms, so the pool spends the other 199 serving the app,
14
+ * and a million-row table is still swept in under four minutes. A sweep that has to go faster
15
+ * raises the number; there is no unthrottled mode to reach for instead.
16
+ */
17
+ export const DEFAULT_BACKFILL_RATE = 5;
18
+
19
+ /**
20
+ * Under a millisecond there is no timer to wait on — `setTimeout(0.4)` fires a millisecond later,
21
+ * which would leave the pass SLOWER than the rate it declared. So a rate far above what the
22
+ * batches can actually achieve degenerates to no wait at all, which is the point: to sweep faster
23
+ * you raise `rate`, never turn the pacer off.
24
+ */
25
+ const RESOLUTION_MS = 1;
26
+
27
+ export interface PacerOptions {
28
+ /**
29
+ * Batches per second, greater than zero and finite. `backfill()` refuses a bad one at the
30
+ * declaration, where it was written and with the definition's name in the message; this is the
31
+ * same rule at the constructor, for the callers that do not come through a declaration.
32
+ */
33
+ readonly rate: number;
34
+ /** The name a cancellation is reported under — one pacer belongs to exactly one backfill. */
35
+ readonly job: string;
36
+ readonly clock?: Clock | undefined;
37
+ /**
38
+ * The wait itself, injectable for the same reason the clock is: a test asserts what was ASKED
39
+ * for instead of spending it. Must settle when `signal` aborts rather than waiting the rest out.
40
+ */
41
+ readonly sleep?: ((ms: number, signal: AbortSignal) => Promise<void>) | undefined;
42
+ }
43
+
44
+ export interface Pacer {
45
+ /** Batches per second this pacer was built for. */
46
+ readonly rate: number;
47
+ /**
48
+ * Hold the caller until this batch's slot comes round. Rejects with `JobAbortedError` when the
49
+ * attempt was cancelled — before the wait or during it, never after sitting the timer out.
50
+ */
51
+ wait(args: { readonly signal: AbortSignal; readonly step?: string | undefined }): Promise<void>;
52
+ }
53
+
54
+ /** Real timers, cleared on abort so a cancelled pass leaves nothing pending behind it. */
55
+ function timerSleep(ms: number, signal: AbortSignal): Promise<void> {
56
+ return new Promise((resolve) => {
57
+ const done = (): void => {
58
+ clearTimeout(timer);
59
+ signal.removeEventListener('abort', done);
60
+ resolve();
61
+ };
62
+ const timer = setTimeout(done, ms);
63
+ signal.addEventListener('abort', done, { once: true });
64
+ });
65
+ }
66
+
67
+ /**
68
+ * Built once, at declaration, and shared by every attempt of that backfill in this process: the
69
+ * rate is a property of the table being swept and the pool it is swept through, not of whichever
70
+ * attempt happens to hold the run.
71
+ */
72
+ export function createPacer(options: PacerOptions): Pacer {
73
+ // Refused HERE and not only at `backfill()`: `rate: 0` makes `intervalMs` Infinity, which the
74
+ // timer clamps to about a millisecond — so an unvalidated zero reads as "no throttle at all",
75
+ // which is the one setting this module exists to make unreachable. A negative rate is the same
76
+ // bug with a negative wait.
77
+ assert(
78
+ Number.isFinite(options.rate) && options.rate > 0,
79
+ `createPacer({ rate: ${String(options.rate)} }) for "${options.job}" — a rate is batches per second, greater than zero`,
80
+ `pass rate: ${DEFAULT_BACKFILL_RATE} — to sweep faster raise the number, there is no unthrottled mode`,
81
+ );
82
+ const clock = options.clock ?? systemClock;
83
+ const sleep = options.sleep ?? timerSleep;
84
+ const intervalMs = 1000 / options.rate;
85
+ let lastAt: number | undefined;
86
+
87
+ return {
88
+ rate: options.rate,
89
+ async wait({ signal, step }) {
90
+ const aborted = (): JobAbortedError =>
91
+ new JobAbortedError({ job: options.job, ...(step === undefined ? {} : { step }) });
92
+ if (signal.aborted) throw aborted();
93
+
94
+ const at = nowMs(clock);
95
+ // The batch's own time is interval already paid, so a slow page waits for nothing and only a
96
+ // fast one is held back. The first batch has no previous one to be spaced from.
97
+ const remaining = lastAt === undefined ? 0 : intervalMs - (at - lastAt);
98
+ if (remaining < RESOLUTION_MS) {
99
+ lastAt = at;
100
+ return;
101
+ }
102
+ await sleep(remaining, signal);
103
+ // The sleeper settles early on abort, so what it means is "the wait is over", never "the
104
+ // slot arrived" — the attempt no longer owns the run and must not read another page.
105
+ if (signal.aborted) throw aborted();
106
+ lastAt = nowMs(clock);
107
+ },
108
+ };
109
+ }
@@ -0,0 +1,108 @@
1
+ // What the app DECLARED, as against what `x_backfills` recorded. The ledger answers "which passes
2
+ // have run"; until this file nothing answered "which passes exist", so a cleanup that was merged
3
+ // and never enqueued had no ledger row and was invisible on every surface — the incident where
4
+ // four rewrites shipped and simply never happened.
5
+ //
6
+ // The stamp is `task.ts`'s `origin` WeakMap and deliberately not a second mechanism, and not an
7
+ // app-side `registerBackfill()` call either: `backfill()` returns a plain `JobHandle`, so the
8
+ // declaration's own fields have nowhere on the handle to live, and asking an app to register what
9
+ // it already declared is the coupling axiom 8's extension model exists to refuse.
10
+
11
+ import type { Ctx, Environment } from '@ultimat3/core';
12
+ // TYPE-only, and that is what makes it safe: `backfill.ts` imports this module at runtime, and a
13
+ // second declaration of its input here would be a second name for one shape (axiom 1).
14
+ import type { BackfillInput } from './backfill';
15
+ import type { AnyJobHandle, JobHandle } from './job';
16
+ import { isJobHandle, registeredJobs } from './job';
17
+
18
+ /**
19
+ * How many rows still match — the same predicate `source` selects on, counted rather than read.
20
+ * Handed a `Ctx` for the reason `source` is: a tenanted sweep counts within one org or it counts
21
+ * every tenant at once.
22
+ */
23
+ export type BackfillCount = (args: { readonly ctx: Ctx }) => Promise<number> | number;
24
+
25
+ /** Everything `backfill()` knows that a `JobHandle` has no field for. */
26
+ export interface BackfillOrigin {
27
+ readonly checksum: string;
28
+ /** A migration id, checked against `x_migrations` by whoever can read it. See `backfill.ts`. */
29
+ readonly requires: string | undefined;
30
+ /** Absent means every environment — never an implied "production only". See `backfill.ts`. */
31
+ readonly environments: readonly Environment[] | undefined;
32
+ readonly count: BackfillCount | undefined;
33
+ }
34
+
35
+ /**
36
+ * One declaration as every surface reports it: plain JSON, absent as `null`, the shape
37
+ * `BackfillProgress` already holds for a ledger row. `counts` rather than the function itself —
38
+ * a declaration crosses `--json`, and "can this pass say how many rows are left" is the only
39
+ * thing a reader can act on.
40
+ */
41
+ export interface BackfillDeclaration {
42
+ readonly kind: 'backfill';
43
+ readonly name: string;
44
+ readonly checksum: string;
45
+ readonly requires: string | null;
46
+ readonly environments: readonly Environment[] | null;
47
+ readonly counts: boolean;
48
+ }
49
+
50
+ const origin = new WeakMap<object, BackfillOrigin>();
51
+
52
+ /**
53
+ * Called by `backfill()` and by nothing else — not exported from `src/index.ts`, for the reason
54
+ * `registerJob` is not: a second way to make a handle claim it is a backfill would let a plain
55
+ * `job()` inherit the pending diff, the gate and the deploy trigger it was never declared for.
56
+ */
57
+ export function stampBackfill(handle: JobHandle<BackfillInput>, source: BackfillOrigin): void {
58
+ origin.set(handle, source);
59
+ }
60
+
61
+ /**
62
+ * Structural, exactly as `isJobHandle`/`isTaskHandle` are: a job handle plus proof `backfill()`
63
+ * built it. A look-alike carrying the right fields is still a job.
64
+ */
65
+ export function isBackfill(value: unknown): value is JobHandle<BackfillInput> {
66
+ return isJobHandle(value) && origin.has(value);
67
+ }
68
+
69
+ /** What `backfill()` stamped, or nothing. The `count` function lives here and never in JSON. */
70
+ export function backfillOrigin(handle: AnyJobHandle): BackfillOrigin | undefined {
71
+ return origin.get(handle);
72
+ }
73
+
74
+ /** Reads `handle.name` live, never a captured copy: registration rebinds that property in place. */
75
+ export function declarationOf(handle: AnyJobHandle): BackfillDeclaration | undefined {
76
+ const source = origin.get(handle);
77
+ if (source === undefined) return undefined;
78
+ return {
79
+ kind: 'backfill',
80
+ name: handle.name,
81
+ checksum: source.checksum,
82
+ requires: source.requires ?? null,
83
+ environments: source.environments ?? null,
84
+ counts: source.count !== undefined,
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Every backfill this process's modules declared, by name. Derived from `registeredJobs()` rather
90
+ * than from a second registry of its own: a backfill IS a job, and two registries that disagreed
91
+ * about one name would be two answers to "does this pass exist".
92
+ */
93
+ export function registeredBackfills(): readonly BackfillDeclaration[] {
94
+ const declarations: BackfillDeclaration[] = [];
95
+ for (const handle of registeredJobs()) {
96
+ const declaration = declarationOf(handle);
97
+ if (declaration !== undefined) declarations.push(declaration);
98
+ }
99
+ return declarations;
100
+ }
101
+
102
+ /** The handle behind a declared name, for the surface that has to enqueue it. */
103
+ export function getBackfill(name: string): JobHandle<BackfillInput> | undefined {
104
+ for (const handle of registeredJobs()) {
105
+ if (handle.name === name && isBackfill(handle)) return handle;
106
+ }
107
+ return undefined;
108
+ }
@@ -0,0 +1,70 @@
1
+ // Which sweeps run across tenants, and the capability that lets them. ONLY a `backfill()` that
2
+ // declared `tenant: 'none'`, and only for the duration of its own pass.
3
+ //
4
+ // It has to be opened HERE and nowhere else: `source` hands back a lazy chain, so every page's plan
5
+ // is built inside the iteration — long after the declaring frame closed — and `scopedPlan` is
6
+ // applied at plan-build time. An app author holding a `ReadBuilder` has nothing to wrap.
7
+
8
+ import type { Actor, Ctx } from '@ultimat3/core';
9
+ import { runWithContext } from '@ultimat3/core';
10
+ import { CROSS_TENANT_SCOPE, crossTenant } from '@ultimat3/entity';
11
+ import type { BackfillInput } from './backfill';
12
+ import type { JobTenant } from './tenant';
13
+ import { NO_JOB_TENANT } from './tenant';
14
+
15
+ /**
16
+ * Derived and specific, because it lands in the audit trail: `assertCrossTenant` renders it in
17
+ * `X_TENANCY_CROSS_DENIED` and it is what a reader sees when they ask why a plan skipped its
18
+ * tenant. "backfill" alone would name every sweep in the app identically.
19
+ */
20
+ const reasonFor = (name: string): string =>
21
+ `backfill "${name}" declared tenant: 'none', so its pass sweeps every tenant's rows`;
22
+
23
+ /**
24
+ * The capability, added to the run's own actor and to nothing else. `executeJob` already stripped
25
+ * the org for a `'none'` job, so this is the one fact that changes — and it changes on an actor
26
+ * this pass built, inside a context that dies with it.
27
+ */
28
+ const withCrossTenant = (actor: Actor): Actor =>
29
+ actor.scopes.includes(CROSS_TENANT_SCOPE)
30
+ ? actor
31
+ : Object.freeze({ ...actor, scopes: Object.freeze([...actor.scopes, CROSS_TENANT_SCOPE]) });
32
+
33
+ /**
34
+ * Run `pass` with the tenant guard lifted, but ONLY for a backfill that declared `tenant: 'none'`.
35
+ * A backfill that declared a real tenant is handed its context untouched and never sees the
36
+ * escape hatch — granting one to a tenanted sweep would hand every backfill in the app the
37
+ * capability, which is the opposite of what declaring a tenant means.
38
+ *
39
+ * **Why the grant lives here and not on the worker's actor.** The alternative is for boot to mint
40
+ * a worker identity carrying `tenancy:cross` (`packages/cli/src/dev-roles.ts` builds that context).
41
+ * That grants it to EVERY job the worker claims — including a plain `job({ tenant: 'none' })` that
42
+ * declared no sweep at all — and puts the decision in deployment config, where no reviewer sees it
43
+ * and one identity again serves every job. Here it is bounded four ways: only `backfill()`, only
44
+ * on an explicit `tenant: 'none'`, only for this pass, and only on a context that does not outlive
45
+ * it. The declaration is code in the app's own repository, carries a name the `x_backfills` ledger
46
+ * records, and is enumerated by `x db backfill --pending` — so the sweep that gets the capability
47
+ * is the one an operator can already see.
48
+ *
49
+ * `runWithContext` OUTSIDE `crossTenant`, never the other way round: `crossTenant` proves the
50
+ * capability against the AMBIENT actor at the call and `assertCrossTenant` proves it again for
51
+ * every plan built inside, so the scoped context has to be installed before either look.
52
+ *
53
+ * Nesting is safe: an app `handle` that opens its own `crossTenant(reason, fn)` replaces the reason
54
+ * for the plans inside it and re-proves the same capability, which this actor already carries.
55
+ */
56
+ export function withBackfillScope<T>(
57
+ name: string,
58
+ tenant: JobTenant<BackfillInput>,
59
+ ctx: Ctx,
60
+ pass: (ctx: Ctx) => Promise<T>,
61
+ ): Promise<T> {
62
+ if (tenant !== NO_JOB_TENANT) return pass(ctx);
63
+ // Spread and `runWithContext` rather than `withChildContext`, which is the opposite of what
64
+ // `executeJob` does one frame up — and deliberately: the pass is also driven directly by tests and
65
+ // by tooling with no ambient context for a child to derive from, and the only fact that changes
66
+ // here is a SCOPE on an actor `executeJob` already built. The identity, the org and therefore
67
+ // every service instance are the run's own, so there is nothing for a rebuild to correct.
68
+ const scoped: Ctx = Object.freeze({ ...ctx, actor: withCrossTenant(ctx.actor) });
69
+ return runWithContext(scoped, () => crossTenant(reasonFor(name), () => pass(scoped)));
70
+ }
@@ -0,0 +1,213 @@
1
+ // `backfill()` — one pass over every row a chain matches, declared as a `job` and NOT as a ninth
2
+ // primitive. A backfill is durable background work with an input schema, a retry policy, an
3
+ // idempotency key and a queue, which is the definition of a `job` — so this file is a FACTORY over
4
+ // `job()`, exactly as `llm()` is one over `action()`. That is what gives a backfill `.enqueue()`,
5
+ // the worker's cancellation, the dead-letter path, `x jobs show` and a manifest row without a line
6
+ // here, and it is why nothing in the framework grows a ninth kind of thing to hold table sweeps.
7
+ //
8
+ // The declaration lives here and the pass lives in `backfill-pass.ts` — the same split `job.ts`
9
+ // and `execute.ts` already have, and the reason the iteration, the checkpoints and the
10
+ // `x_backfills` ledger are one file's problem rather than this one's.
11
+
12
+ import type { Ctx, Environment } from '@ultimat3/core';
13
+ import { assert } from '@ultimat3/core';
14
+ import type { ReadBuilder } from '@ultimat3/entity';
15
+ import { t } from '@ultimat3/schema';
16
+ import { backfillChecksum } from './backfill-ledger';
17
+ import { backfillPass } from './backfill-pass';
18
+ import { createPacer, DEFAULT_BACKFILL_RATE } from './backfill-rate';
19
+ import type { BackfillCount } from './backfill-registry';
20
+ import { stampBackfill } from './backfill-registry';
21
+ import type { DurationInput } from './clock';
22
+ import type { JobHandle } from './job';
23
+ import { job } from './job';
24
+ import type { RetryPolicy } from './retry';
25
+ import { DEFAULT_RETRY } from './retry';
26
+ import type { JobTenant } from './tenant';
27
+
28
+ /**
29
+ * Rows per statement and per durable step. Not `entity`'s `DEFAULT_PAGE_SIZE`: that number is a
30
+ * page somebody scrolls, and a backfill of a million rows at 50 writes twenty thousand step rows
31
+ * to move the same data. One statement's worth of work that a worker can still finish inside a
32
+ * lease is the size that belongs here.
33
+ */
34
+ export const DEFAULT_BACKFILL_BATCH = 1_000;
35
+
36
+ export interface BackfillBatch<Row> {
37
+ /** The page `page()` would have returned here: tenancy, soft delete, projection, preloads. */
38
+ readonly rows: readonly Row[];
39
+ readonly ctx: Ctx;
40
+ /**
41
+ * The run's cancellation composed with this batch's own ceiling — the same seam `ctx.signal` is
42
+ * elsewhere. Past it this step may no longer write, so hand it to whatever the body calls.
43
+ */
44
+ readonly signal: AbortSignal;
45
+ /** 0-based position in the pass, and the step name this batch is checkpointed under. */
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
+ }
154
+
155
+ export function backfill<Row>(definition: BackfillDefinition<Row>): JobHandle<BackfillInput> {
156
+ const size = definition.batch ?? DEFAULT_BACKFILL_BATCH;
157
+ // Refused where it was written. `inBatches()` refuses the same number one statement in, which
158
+ // for a backfill means a dead-lettered job and a stack trace instead of a failing build.
159
+ assert(
160
+ Number.isSafeInteger(size) && size >= 1,
161
+ `backfill "${definition.name}" declares batch: ${String(size)} — a batch is a whole number of rows, at least one`,
162
+ `set batch: ${DEFAULT_BACKFILL_BATCH} on backfill("${definition.name}") — the rows one statement reads and one durable step handles`,
163
+ );
164
+ const rate = definition.rate ?? DEFAULT_BACKFILL_RATE;
165
+ // Refused in the same voice and for the same reason as `batch` above — except that an unpaced
166
+ // sweep is not a dead-lettered job but a saturated pool, which the app finds out about first.
167
+ assert(
168
+ Number.isFinite(rate) && rate > 0,
169
+ `backfill "${definition.name}" declares rate: ${String(rate)} — a rate is batches per second, greater than zero`,
170
+ `set rate: ${DEFAULT_BACKFILL_RATE} on backfill("${definition.name}"), or leave it out — to sweep faster raise the number, there is no unthrottled mode`,
171
+ );
172
+ // Hashed once, at declaration: the definition cannot change while the process runs, and a hash
173
+ // per attempt would charge every batch of every pass for a fact that is fixed at import.
174
+ // `rate` is NOT in it, for the reason `batch` is not: pacing is a tuning knob, and changing one
175
+ // does not make a completed sweep a different sweep.
176
+ const checksum = backfillChecksum(definition.source, definition.handle);
177
+ // Built here rather than per attempt: the interval belongs to the table and the pool, not to
178
+ // whichever attempt holds the run, so a retrying pass keeps the pace it was declared with.
179
+ const pace = createPacer({ rate, job: definition.name });
180
+
181
+ // Bound to the definition rather than passed bare: `count` is declared as a method, so a
182
+ // reference torn off the object literal would run with `this` undefined the first time an
183
+ // author writes `count: ({ ctx }) => this.something`.
184
+ const declaredCount = definition.count;
185
+ const count: BackfillCount | undefined =
186
+ declaredCount === undefined ? undefined : (args) => declaredCount.call(definition, args);
187
+
188
+ const handle = job<BackfillInput>({
189
+ name: definition.name,
190
+ input: t.object({ force: t.boolean.optional() }),
191
+ // One live run per name, forced or not. A second enqueue while the pass is still going is the
192
+ // same pass, and deduping it is what makes "kick it again" safe rather than a second writer on
193
+ // one table — which is exactly what a `force` in the key would have allowed.
194
+ idempotencyKey: () => definition.name,
195
+ // Forwarded verbatim, like every other job field this factory carries: a backfill that
196
+ // declared its tenant and then ran under somebody else's would be a factory deciding authz.
197
+ tenant: definition.tenant,
198
+ retry: definition.retry ?? DEFAULT_RETRY,
199
+ ...(definition.queue === undefined ? {} : { queue: definition.queue }),
200
+ ...(definition.timeout === undefined ? {} : { timeout: definition.timeout }),
201
+ run: (args) => backfillPass({ definition, size, checksum, pace }, args),
202
+ });
203
+ // Stamped, never registered by the app: this is what makes a declared-but-never-enqueued sweep
204
+ // visible to `x db backfill --pending`, and the `origin` WeakMap is `task.ts`'s mechanism rather
205
+ // than a second one. `job()` above already refused a duplicate name, so this cannot overwrite.
206
+ stampBackfill(handle, {
207
+ checksum,
208
+ requires: definition.requires,
209
+ environments: definition.environments,
210
+ count,
211
+ });
212
+ return handle;
213
+ }
@@ -4,12 +4,15 @@
4
4
 
5
5
  import type { Clock } from '@ultimat3/core';
6
6
  import { assert, systemClock, uuid } from '@ultimat3/core';
7
+ import type { BackfillLedger } from './backfill-ledger';
8
+ import { createMemoryBackfillLedger } from './backfill-ledger';
7
9
  import { nowMs } from './clock';
8
10
  import type {
9
11
  ClaimedJob,
10
12
  ClaimOptions,
11
13
  EnqueueRequest,
12
14
  EnqueueResult,
15
+ HeartbeatOptions,
13
16
  JobDriver,
14
17
  JobFilter,
15
18
  JobIntrospection,
@@ -19,12 +22,18 @@ import type {
19
22
  } from './driver';
20
23
  import { DEFAULT_QUEUE } from './driver';
21
24
  import { JobDuplicateError } from './errors';
25
+ import type { LeaseStore } from './leases';
26
+ import { createMemoryLeaseStore } from './leases';
22
27
  import type { StepStore } from './steps';
23
28
  import { createMemoryStepStore } from './steps';
24
29
 
25
30
  export interface MemoryDriverOptions {
26
31
  readonly clock?: Clock;
27
32
  readonly steps?: StepStore;
33
+ /** Injectable for the same reason `steps` is: two drivers in one test sharing one ledger. */
34
+ readonly backfills?: BackfillLedger;
35
+ /** Injectable so two drivers in one test can share one set of fleet slots. */
36
+ readonly leases?: LeaseStore;
28
37
  }
29
38
 
30
39
  const LIVE_STATES = new Set(['ready', 'delayed', 'running', 'suspended']);
@@ -32,11 +41,19 @@ const LIVE_STATES = new Set(['ready', 'delayed', 'running', 'suspended']);
32
41
  export function createMemoryDriver(options: MemoryDriverOptions = {}): JobDriver {
33
42
  const clock = options.clock ?? systemClock;
34
43
  const steps = options.steps ?? createMemoryStepStore();
44
+ const backfills = options.backfills ?? createMemoryBackfillLedger(clock);
45
+ const leases =
46
+ options.leases ?? createMemoryLeaseStore(options.clock === undefined ? {} : { clock });
35
47
  const jobs = new Map<string, JobRecord>();
36
48
 
37
- const liveByKey = (key: string): JobRecord | undefined => {
49
+ // Keyed by NAME and key, exactly as `x_jobs_name_idempotency_live_idx` is. A global key
50
+ // namespace let two unrelated jobs that derived the same natural key dedupe against each
51
+ // other: the second enqueue returned the first's id and its work never ran.
52
+ const liveByKey = (name: string, key: string): JobRecord | undefined => {
38
53
  for (const record of jobs.values()) {
39
- if (record.idempotencyKey === key && LIVE_STATES.has(record.state)) return record;
54
+ if (record.name === name && record.idempotencyKey === key && LIVE_STATES.has(record.state)) {
55
+ return record;
56
+ }
40
57
  }
41
58
  return undefined;
42
59
  };
@@ -56,7 +73,10 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): JobDriver
56
73
  .filter((record) => filter.queue === undefined || record.queue === filter.queue)
57
74
  .filter((record) => filter.name === undefined || record.name === filter.name)
58
75
  .filter((record) => filter.state === undefined || record.state === filter.state)
59
- .sort((a, b) => a.createdAt - b.createdAt)
76
+ // NEWEST first, as `createPgDriver`'s `order by created_at desc` is. Ascending here meant
77
+ // `x jobs ls` answered one thing against `x dev` and the opposite in production — and,
78
+ // because the limit is applied after the sort, a default page of the hundred OLDEST rows.
79
+ .sort((a, b) => b.createdAt - a.createdAt)
60
80
  .slice(0, filter.limit ?? 100);
61
81
  return Promise.resolve(rows);
62
82
  },
@@ -83,15 +103,28 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): JobDriver
83
103
  const next = jobs.get(jobId);
84
104
  return next ?? record;
85
105
  },
106
+ cancel(jobId, reason) {
107
+ const existing = jobs.get(jobId);
108
+ // `state !== 'done'`, mirroring `SQL_CANCEL`: a job that already finished has nothing to
109
+ // stop, and cancelling it would rewrite a terminal row an operator is reading as success.
110
+ if (existing === undefined || existing.state === 'done') return Promise.resolve(undefined);
111
+ update(jobId, {
112
+ state: 'cancelled',
113
+ ...(reason === undefined ? {} : { lastError: reason }),
114
+ });
115
+ return Promise.resolve(jobs.get(jobId));
116
+ },
86
117
  };
87
118
 
88
119
  return {
89
120
  name: 'memory',
90
121
  steps,
122
+ backfills,
123
+ leases,
91
124
  introspect,
92
125
 
93
126
  enqueue(request: EnqueueRequest): Promise<EnqueueResult> {
94
- const existing = liveByKey(request.idempotencyKey);
127
+ const existing = liveByKey(request.name, request.idempotencyKey);
95
128
  if (existing !== undefined) {
96
129
  if (request.onConflict === 'error') {
97
130
  throw new JobDuplicateError({
@@ -119,6 +152,8 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): JobDriver
119
152
  createdAt: at,
120
153
  updatedAt: at,
121
154
  ...(request.tenantId === undefined ? {} : { tenantId: request.tenantId }),
155
+ ...(request.traceparent === undefined ? {} : { traceparent: request.traceparent }),
156
+ ...(request.enqueuedBy === undefined ? {} : { enqueuedBy: request.enqueuedBy }),
122
157
  };
123
158
  jobs.set(record.id, record);
124
159
  return Promise.resolve({ id: record.id, runId: record.runId, deduped: false });
@@ -156,30 +191,47 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): JobDriver
156
191
  return Promise.resolve(out);
157
192
  },
158
193
 
194
+ // Both settlements are FENCED on `running`, as `SQL_ACK`/`SQL_NACK` are: an ack from a worker
195
+ // whose job was cancelled — or whose lease lapsed and whose job another worker re-claimed —
196
+ // would otherwise overwrite a row it no longer owns.
159
197
  ack(jobId: string): Promise<void> {
198
+ if (jobs.get(jobId)?.state !== 'running') return Promise.resolve();
160
199
  update(jobId, { state: 'done' });
161
200
  return Promise.resolve();
162
201
  },
163
202
 
164
203
  nack(jobId: string, nackOptions: NackOptions): Promise<void> {
165
204
  const record = jobs.get(jobId);
166
- if (record === undefined) return Promise.resolve();
205
+ if (record === undefined || record.state !== 'running') return Promise.resolve();
167
206
  const at = nowMs(clock);
168
207
  const counts = nackOptions.countsAsAttempt !== false;
169
208
  const patch: Partial<JobRecord> = {
170
209
  state: nackOptions.deadLetter === true ? 'dead' : counts ? 'ready' : 'suspended',
171
210
  runAt: at + nackOptions.delayMs,
172
- // A suspension must not burn an attempt, or a 3-day sleep dead-letters the run.
173
- attempt: counts ? record.attempt : record.attempt - 1,
211
+ // A suspension must not burn an attempt, or a 3-day sleep dead-letters the run. Floored
212
+ // where `SQL_NACK` floors it (`greatest(attempt - 1, 0)`): the fence above is what keeps
213
+ // the decrement paired with a claim today, so this is the guard that survives the fence
214
+ // being read as the only one.
215
+ attempt: counts ? record.attempt : Math.max(0, record.attempt - 1),
174
216
  ...(nackOptions.error === undefined ? {} : { lastError: nackOptions.error }),
175
217
  };
176
218
  update(jobId, patch);
177
219
  return Promise.resolve();
178
220
  },
179
221
 
180
- heartbeat(jobId: string, heartbeatOptions): Promise<void> {
222
+ heartbeat(jobId: string, heartbeatOptions: HeartbeatOptions): Promise<boolean> {
223
+ const record = jobs.get(jobId);
224
+ // The same predicate `SQL_HEARTBEAT` carries. `false` is how an external cancel reaches a
225
+ // job that is already running: the worker's next renewal misses and the attempt is aborted.
226
+ if (
227
+ record === undefined ||
228
+ record.state !== 'running' ||
229
+ (heartbeatOptions.workerId !== undefined && record.claimedBy !== heartbeatOptions.workerId)
230
+ ) {
231
+ return Promise.resolve(false);
232
+ }
181
233
  update(jobId, { visibleAt: nowMs(clock) + heartbeatOptions.visibilityTimeoutMs });
182
- return Promise.resolve();
234
+ return Promise.resolve(true);
183
235
  },
184
236
 
185
237
  stats(): Promise<readonly QueueStats[]> {