@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.
Files changed (109) hide show
  1. package/CLAUDE.md +68 -89
  2. package/README.md +304 -10
  3. package/package.json +18 -6
  4. package/src/backfill-definition.ts +184 -0
  5. package/src/backfill-errors.ts +18 -2
  6. package/src/backfill-pass.ts +43 -40
  7. package/src/backfill-pending.ts +40 -17
  8. package/src/backfill-reader.ts +94 -0
  9. package/src/backfill-registry.ts +5 -0
  10. package/src/backfill-scope.ts +1 -2
  11. package/src/backfill-source.ts +113 -0
  12. package/src/backfill.ts +54 -135
  13. package/src/consumer-errors.ts +42 -0
  14. package/src/consumer-guard.ts +19 -0
  15. package/src/consumer-loop.ts +171 -0
  16. package/src/consumer-registry.ts +103 -0
  17. package/src/consumer-types.ts +81 -0
  18. package/src/consumer.ts +147 -0
  19. package/src/driver-memory-operator.ts +25 -2
  20. package/src/driver-memory.ts +35 -1
  21. package/src/driver-pg-claim-sql.ts +128 -0
  22. package/src/driver-pg-ddl.ts +11 -2
  23. package/src/driver-pg-demand-sql.ts +27 -0
  24. package/src/driver-pg-enqueue-many.ts +153 -0
  25. package/src/driver-pg-event-wake-sql.ts +51 -0
  26. package/src/driver-pg-operator-sql.ts +34 -6
  27. package/src/driver-pg-operator.ts +36 -2
  28. package/src/driver-pg-outbox-sql.ts +34 -0
  29. package/src/driver-pg-sql.ts +24 -91
  30. package/src/driver-pg.ts +17 -1
  31. package/src/driver-redis-refusal.ts +25 -0
  32. package/src/driver-redis-scripts.ts +79 -30
  33. package/src/driver-redis.ts +70 -42
  34. package/src/driver.ts +38 -1
  35. package/src/enqueue-record.ts +137 -0
  36. package/src/error-codes.ts +233 -0
  37. package/src/errors-concurrency.ts +2 -2
  38. package/src/errors-declaration.ts +15 -2
  39. package/src/errors-operator.ts +1 -1
  40. package/src/errors-requeue.ts +1 -1
  41. package/src/errors-saga.ts +87 -0
  42. package/src/errors-steps.ts +35 -0
  43. package/src/errors-tenant.ts +1 -1
  44. package/src/errors.ts +25 -165
  45. package/src/events-pg.ts +20 -8
  46. package/src/events.ts +14 -3
  47. package/src/execute-ctx.ts +51 -0
  48. package/src/execute-unwind.ts +191 -0
  49. package/src/execute.ts +50 -51
  50. package/src/export-errors.ts +1 -1
  51. package/src/failure-row.ts +43 -0
  52. package/src/import-errors.ts +102 -0
  53. package/src/import-format.ts +232 -0
  54. package/src/import-pass.ts +255 -0
  55. package/src/import-source.ts +49 -0
  56. package/src/import.ts +175 -0
  57. package/src/index.ts +163 -7
  58. package/src/inspect-operator.ts +24 -3
  59. package/src/inspect.ts +17 -4
  60. package/src/introspection.ts +24 -1
  61. package/src/job-enqueue-many.ts +108 -0
  62. package/src/job-types.ts +229 -0
  63. package/src/job.ts +19 -208
  64. package/src/metrics.ts +10 -0
  65. package/src/outbox-pg.ts +35 -0
  66. package/src/outbox-relay.ts +58 -19
  67. package/src/outbox.ts +65 -73
  68. package/src/purge-entities.ts +92 -0
  69. package/src/purge.ts +10 -2
  70. package/src/queue-wake.ts +1 -1
  71. package/src/refresh-view.ts +76 -0
  72. package/src/retry-classification.ts +27 -8
  73. package/src/retry.ts +26 -0
  74. package/src/rollup-errors.ts +27 -0
  75. package/src/rollup.ts +190 -0
  76. package/src/run-deadline.ts +11 -7
  77. package/src/scheduler.ts +2 -1
  78. package/src/settled.ts +56 -15
  79. package/src/step-resolve.ts +37 -0
  80. package/src/steps-api.ts +111 -0
  81. package/src/steps-core.ts +26 -0
  82. package/src/steps-output.ts +142 -0
  83. package/src/steps-store.ts +68 -0
  84. package/src/steps-suspension.ts +51 -0
  85. package/src/steps-timeout.ts +10 -5
  86. package/src/steps-unwind.ts +94 -0
  87. package/src/steps-wait.ts +163 -0
  88. package/src/steps.ts +174 -210
  89. package/src/subject-erase-pass.ts +134 -0
  90. package/src/subject-errors.ts +48 -0
  91. package/src/subject-export-pass.ts +228 -0
  92. package/src/subject-jobs.ts +121 -0
  93. package/src/subject-plan.ts +287 -0
  94. package/src/subject-select.ts +106 -0
  95. package/src/subject.ts +119 -0
  96. package/src/tenant.ts +69 -1
  97. package/src/type-pins.ts +90 -0
  98. package/src/webhook-errors.ts +1 -1
  99. package/src/webhook-receiver-errors.ts +59 -0
  100. package/src/webhook-receiver-route.ts +127 -0
  101. package/src/webhook-receiver-types.ts +97 -0
  102. package/src/webhook-receiver.ts +132 -0
  103. package/src/webhook-target.ts +20 -43
  104. package/src/webhook.ts +7 -4
  105. package/src/worker-queue-demand.ts +57 -0
  106. package/src/worker-queue-depth.ts +15 -3
  107. package/src/worker-registry.ts +4 -1
  108. package/src/worker-types.ts +2 -0
  109. 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 { DurationInput } from './clock';
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 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
- }
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
- const checksum = backfillChecksum(definition.source, definition.handle);
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
- const declaredCount = definition.count;
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 ? undefined : (args) => declaredCount.call(definition, args);
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 handle = job<BackfillInput>({
100
+ const scope = definition.scope;
101
+ const handle = job<BackfillInput & { readonly input?: unknown }>({
189
102
  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,
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) => backfillPass({ definition, size, checksum, pace }, 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
+ }