@ultimat3/jobs 1.2.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +567 -0
- package/README.md +355 -16
- package/package.json +7 -5
- package/src/backfill-gate.ts +97 -0
- package/src/backfill-inspect.ts +73 -0
- package/src/backfill-ledger.ts +183 -0
- package/src/backfill-pass.ts +276 -0
- package/src/backfill-pending.ts +131 -0
- package/src/backfill-rate.ts +109 -0
- package/src/backfill-registry.ts +108 -0
- package/src/backfill-scope.ts +70 -0
- package/src/backfill.ts +213 -0
- package/src/driver-memory.ts +61 -9
- package/src/driver-nats.ts +2 -1
- package/src/driver-pg-ddl.ts +180 -0
- package/src/driver-pg-rows.ts +123 -0
- package/src/driver-pg-sql.ts +251 -55
- package/src/driver-pg.ts +138 -92
- package/src/driver-redis.ts +2 -1
- package/src/driver.ts +91 -7
- package/src/errors.ts +290 -4
- package/src/events-pg.ts +121 -0
- package/src/events.ts +7 -1
- package/src/execute.ts +289 -0
- package/src/heartbeat.ts +146 -0
- package/src/index.ts +121 -27
- package/src/inspect.ts +43 -2
- package/src/job.ts +72 -2
- package/src/leases.ts +90 -0
- package/src/limits.ts +0 -0
- package/src/metrics.ts +35 -0
- package/src/outbox-pg.ts +137 -0
- package/src/outbox.ts +115 -53
- package/src/register.ts +1 -1
- package/src/retry.ts +6 -1
- package/src/run-signal.ts +50 -0
- package/src/scheduler-pg.ts +103 -0
- package/src/scheduler.ts +159 -245
- package/src/steps.ts +155 -31
- package/src/task.ts +229 -0
- package/src/tenant.ts +61 -0
- package/src/worker-fleet-slots.ts +124 -0
- package/src/worker-run.ts +132 -0
- package/src/worker.ts +207 -190
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @ultimat3/jobs ⚙️
|
|
2
2
|
|
|
3
|
-
Durable background work. Steps that replay, a transactional outbox
|
|
4
|
-
|
|
3
|
+
Durable background work. Steps that replay, a transactional outbox, and one driver interface so
|
|
4
|
+
a job's code never names a backend.
|
|
5
5
|
|
|
6
6
|
```ts
|
|
7
7
|
import { job, t } from '@ultimat3/jobs';
|
|
@@ -9,6 +9,7 @@ import { job, t } from '@ultimat3/jobs';
|
|
|
9
9
|
export const onboardOrg = job({
|
|
10
10
|
input: t.object({ orgId: t.uuid }),
|
|
11
11
|
idempotencyKey: ({ orgId }) => `onboard:${orgId}`, // REQUIRED by the type
|
|
12
|
+
tenant: ({ orgId }) => orgId, // REQUIRED by the type — or tenant: 'none'
|
|
12
13
|
retry: { attempts: 5, backoff: 'exponential' },
|
|
13
14
|
async run({ input, step, ctx }) {
|
|
14
15
|
const org = await step.run('provision', () => ctx.orgs.provision(input.orgId));
|
|
@@ -90,11 +91,37 @@ usually "nobody thought about it", and the bug — two charges, two welcome emai
|
|
|
90
91
|
provisioned orgs — shows up in production under load and never in a test. There is no way
|
|
91
92
|
to define a job in Ultimate that cannot be deduped.
|
|
92
93
|
|
|
94
|
+
## `tenant` is required by the type
|
|
95
|
+
|
|
96
|
+
The same shape, for the same class of bug. A job body runs with no request behind it, so nothing
|
|
97
|
+
can read the acting org off a caller — and `@ultimat3/entity`'s tenant guard derives from the
|
|
98
|
+
**ambient** context, so a job that declared nothing used to run with no tenant at all: a row naming
|
|
99
|
+
another org was refused over HTTP as `X_TENANCY_ACTOR_MISMATCH` and accepted through the queue.
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
tenant: (input) => input.orgId // the run acts under this org
|
|
103
|
+
tenant: 'none' // this job belongs to no tenant
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`executeJob` derives the org, puts it on the run's actor and installs that context, so
|
|
107
|
+
`ctx.actor.orgId` inside the body is the tenant the job declared — and every tenant-scoped read and
|
|
108
|
+
write is checked against it, exactly as it is on every other surface. `'none'` carries **no** org,
|
|
109
|
+
so a tenant-scoped read inside such a `job()` is `X_TENANCY_ACTOR_ORG_REQUIRED`: a job body that
|
|
110
|
+
genuinely spans tenants says so with `crossTenant(reason, fn)` around its own reads. (A
|
|
111
|
+
`backfill()` is the one exception, and it is not a loophole — its `source` is a lazy chain, so the
|
|
112
|
+
author has nothing to wrap and the pass opens the scope itself. See the backfill section below.)
|
|
113
|
+
Omitting the field is a type error, and
|
|
114
|
+
`X_JOB_TENANT_REQUIRED` for generated code and JS callers.
|
|
115
|
+
|
|
116
|
+
A single boot-supplied service actor would have closed the same hole with one identity for every
|
|
117
|
+
job in the app — which is a cross-tenant read waiting for the first job that takes an org id in its
|
|
118
|
+
input. The tenant is a fact about the work, so the job declares it.
|
|
119
|
+
|
|
93
120
|
## Durable steps
|
|
94
121
|
|
|
95
122
|
| Call | Behaviour |
|
|
96
123
|
|---|---|
|
|
97
|
-
| `step.run(name, fn)` | runs once ever; result persisted before the next step starts |
|
|
124
|
+
| `step.run(name, fn)` | runs once ever; result persisted before the next step starts. `fn` receives an `AbortSignal` |
|
|
98
125
|
| `step.sleep(name, '3d')` | suspends the run, requeues it for the wake time |
|
|
99
126
|
| `step.sleep('3d')` | same, step name derived from the duration |
|
|
100
127
|
| `step.waitForEvent(name, event, { match, timeout })` | suspends until `publishEvent()` matches |
|
|
@@ -103,12 +130,197 @@ Step names are the replay key, so they must be deterministic and unique in a run
|
|
|
103
130
|
duplicate is `X_STEP_DUPLICATE`, not a silent overwrite. Suspension is control flow
|
|
104
131
|
(`StepSuspension`), never a failure: it does not burn a retry attempt.
|
|
105
132
|
|
|
106
|
-
##
|
|
133
|
+
## Backfills are jobs
|
|
134
|
+
|
|
135
|
+
`backfill()` is a **factory over `job()`**, not a ninth primitive — one pass over every row a
|
|
136
|
+
chain matches, with the retry policy, the queue, the cancellation and the dead-letter path a job
|
|
137
|
+
already has.
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
import { backfill } from '@ultimat3/jobs';
|
|
141
|
+
import { db } from '@postly/db';
|
|
142
|
+
|
|
143
|
+
export const rewriteSlugs = backfill({
|
|
144
|
+
name: 'rewrite-slugs', // REQUIRED: a durable key
|
|
145
|
+
batch: 1_000, // rows per statement and per step
|
|
146
|
+
rate: 5, // batches/sec — the default
|
|
147
|
+
tenant: 'none', // every tenant — the pass scopes itself
|
|
148
|
+
source: () => db.posts.where({ published: true }),
|
|
149
|
+
async handle({ rows }) {
|
|
150
|
+
await db.posts.upsertAll(rows.map(slugged), { onConflict: ['id'] });
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await rewriteSlugs.enqueue({}); // it is a JobHandle
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The source is read through `inBatches()` — one statement per page, keyset, never OFFSET — and
|
|
158
|
+
each page is handled inside its own `step.run`, named `batch:0`, `batch:1`, … A run killed
|
|
159
|
+
mid-pass therefore **resumes on the page it stopped at**: completed steps replay from storage
|
|
160
|
+
without a statement, and the iteration reopens at the cursor they left behind.
|
|
161
|
+
|
|
162
|
+
| Rule | Why |
|
|
163
|
+
|---|---|
|
|
164
|
+
| the checkpoint is a cursor and a count, never the page | a completed step's output is retained for the whole run — checkpointing rows would hold every processed row until the job ends |
|
|
165
|
+
| `handle` is given no `step` | a step name minted inside it collides with itself on batch 2 (`X_STEP_DUPLICATE`) |
|
|
166
|
+
| `handle` is given a `signal` | the run's deadline composed with the batch's own ceiling — hand it to whatever the body calls |
|
|
167
|
+
| `handle` runs at least once per page | an attempt cancelled between the last row and the checkpoint replays it — write through `upsertAll` / `updateWhere`, never `count + 1` |
|
|
168
|
+
| `idempotencyKey` is the backfill's name | re-enqueueing a live pass is the same pass, not a second writer on one table |
|
|
169
|
+
| `tenant` is required, exactly as on `job()` | a backfill IS a job. `tenant: () => orgId` scopes every page to one org; `tenant: 'none'` sweeps every tenant, and the PASS opens that cross-tenant scope — `source` is a lazy chain, so the author has nothing to wrap |
|
|
170
|
+
| `batch` is refused at declaration | `0`, `1.5` and a `NaN` from an env var fail the build, not the fourth attempt |
|
|
171
|
+
| `rate` throttles, and there is no way off | a sweep shares its pool with the requests the app is still serving; to go faster raise the number |
|
|
172
|
+
| the pause is spent **inside** the step | a resumed attempt replays 500 checkpoints and re-pays none of their pauses |
|
|
173
|
+
|
|
174
|
+
### The throttle
|
|
175
|
+
|
|
176
|
+
`rate` is batches per second, defaulting to `DEFAULT_BACKFILL_RATE` (5) — 5,000 rows/sec at the
|
|
177
|
+
default batch, one statement every 200ms, so the pool spends the rest of each interval on the
|
|
178
|
+
app. A rate above what the batches can actually achieve produces no wait, which is why there is
|
|
179
|
+
no unthrottled mode to reach for: `rate: 200` is the fast sweep. Fractions are rates too
|
|
180
|
+
(`rate: 0.5` is one batch every two seconds), a rate that is not finite and positive is refused
|
|
181
|
+
where it was written, and the wait unwinds on the run's cancellation (`X_ABORTED`) rather than
|
|
182
|
+
sitting in a timer nobody is waiting for. `rate` is **not** part of the definition checksum, for
|
|
183
|
+
the reason `batch` is not: pacing is tuning, and changing it does not make a completed sweep a
|
|
184
|
+
different sweep.
|
|
185
|
+
|
|
186
|
+
### The `x_backfills` ledger
|
|
187
|
+
|
|
188
|
+
What has already been swept, the twin of `x_migrations`. It ships in the same DDL as `x_jobs`,
|
|
189
|
+
hangs off the queue driver as `driver.backfills`, and carries one row per **pass**: name,
|
|
190
|
+
definition checksum, status, app version, rows processed, last cursor, started/completed.
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
// The PASS is the no-op, never the enqueue. The one-live-run index covers `ready`/`delayed`/
|
|
194
|
+
// `running`/`suspended`, and a completed job is in none of them — so this creates a real job row,
|
|
195
|
+
// a worker runs it, it reads the ledger and reports what it found.
|
|
196
|
+
const again = await rewriteSlugs.enqueue({}); // → { deduped: false, … }
|
|
197
|
+
// the run's own result:
|
|
198
|
+
// → { name: 'rewrite-slugs', batches: 0, rows: 0, skipped: true, previousRunId: '…' }
|
|
199
|
+
|
|
200
|
+
await rewriteSlugs.enqueue({ force: true }); // sweeps again, into a NEW row
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
| Rule | Why |
|
|
204
|
+
|---|---|
|
|
205
|
+
| only a **completed** row blocks | a `running` row is this pass resuming, a `failed` one is an attempt the queue is about to retry |
|
|
206
|
+
| `force` writes a new row | reruns are history, never an edit of the row they rerun |
|
|
207
|
+
| a moved checksum **warns** | it hashes function source text, which a bundler moves without behaviour changing — `@ultimat3/db` throws on the same fact because SQL text is what it applied |
|
|
208
|
+
| the row is a report, never a resume source | where a resumed pass restarts is the step checkpoints' answer, and there is only one |
|
|
209
|
+
| a retry adopts its own row | `started_at` is when the pass began, not when this attempt did |
|
|
210
|
+
| a driver without a ledger runs the pass anyway | the same degradation `introspect` has — no bookkeeping, never a refusal |
|
|
211
|
+
|
|
212
|
+
Three surfaces read it, all through one projection (`inspectBackfills()`), so none of them can
|
|
213
|
+
report a different number:
|
|
214
|
+
|
|
215
|
+
| Surface | Shows |
|
|
216
|
+
|---|---|
|
|
217
|
+
| `x db backfill --list` | the whole ledger, `--name` / `--status` / `--limit`, and `--json` |
|
|
218
|
+
| `x jobs ls` | the passes **in flight** — rows so far and cursor, beside the queue depth |
|
|
219
|
+
| `x jobs show <id>` | the ledger row for that run, under `backfill` (`null` for any other job) |
|
|
220
|
+
| `/_x` → jobs | the whole ledger plus a live count, alongside the queues and the step traces |
|
|
221
|
+
|
|
222
|
+
### What was DECLARED — the other half of the ledger
|
|
223
|
+
|
|
224
|
+
`As of 2026-08`.
|
|
225
|
+
|
|
226
|
+
The ledger says which passes have run. `registeredBackfills()` says which ones **exist**, and the
|
|
227
|
+
diff between them is the alarm: a sweep merged, deployed and never enqueued had no row and showed
|
|
228
|
+
up on no surface at all.
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
import { isBackfill, pendingBackfills, registeredBackfills } from '@ultimat3/jobs';
|
|
232
|
+
|
|
233
|
+
registeredBackfills(); // [{ kind: 'backfill', name, checksum, requires, environments, counts }]
|
|
234
|
+
isBackfill(rewriteSlugs); // true — a plain job() is false, and so is a look-alike
|
|
235
|
+
|
|
236
|
+
pendingBackfills({ declarations, runs, environment });
|
|
237
|
+
// → { environment, rows, pending, orphaned }
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
`backfill()` stamps its own handle through the `origin` WeakMap `task()` already uses — an app
|
|
241
|
+
registers nothing, because a declaration that has to be registered a second time is a declaration
|
|
242
|
+
half the apps will forget. `x db backfill --pending` is the command, and it exits **non-zero** when
|
|
243
|
+
anything is unswept so a cron or a deploy check can read the exit code alone.
|
|
244
|
+
|
|
245
|
+
| State | Means | In `pending` |
|
|
246
|
+
|---|---|---|
|
|
247
|
+
| `pending` | declared, no row under this name | yes |
|
|
248
|
+
| `failed` | the newest pass failed — the queue may have dead-lettered it | yes |
|
|
249
|
+
| `running` | a pass is in flight | no — a check red for the whole of every sweep gets muted |
|
|
250
|
+
| `completed` | a completed row exists, so nothing re-runs without `--force` | no |
|
|
251
|
+
| `excluded` | `environments` does not include this one | no |
|
|
252
|
+
|
|
253
|
+
### Three optional declarations, all of them DATA
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
export const dropLegacy = backfill({
|
|
257
|
+
name: 'drop-legacy',
|
|
258
|
+
tenant: 'none', // required, exactly as on job() — see the table above
|
|
259
|
+
requires: '20260814120000_add_publish_at', // a migration id, checked against x_migrations
|
|
260
|
+
environments: ['staging', 'production'], // omitted = every environment
|
|
261
|
+
count: ({ ctx }) => db.posts.where({ publishedAt: null }).count(),
|
|
262
|
+
source: ({ ctx }) => db.posts.where({ publishedAt: null }),
|
|
263
|
+
handle: async ({ rows }) => { /* … */ },
|
|
264
|
+
});
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
| Field | Rail | Enforced where |
|
|
268
|
+
|---|---|---|
|
|
269
|
+
| `requires` | `X_BACKFILL_MIGRATION_PENDING` — the migration is not applied | `x db backfill`, which is where `x_migrations` is readable; this package holds no `@ultimat3/db` dependency and will not grow one to read a ledger |
|
|
270
|
+
| `environments` | `X_BACKFILL_ENVIRONMENT` | **the pass** (`backfillPass`) — the rail, because `.enqueue()` from app code reaches no command — and again in `gateBackfill()` as the CLI's pre-check, so `x db backfill` refuses before it queues work that would only dead-letter |
|
|
271
|
+
| `count` | `X_BACKFILL_STALLED` — the source ran out and this still matches rows | the pass, once, after the last batch |
|
|
272
|
+
|
|
273
|
+
`environments` ships as declared data and never as a hardcoded "cleanups are production": a staging
|
|
274
|
+
rehearsal is correct practice, so which deploys a sweep belongs to is the app's convention and this
|
|
275
|
+
is only the mechanism carrying it. There is deliberately **no** `dependsOn` graph over other
|
|
276
|
+
backfills — the real dependency is almost always "after code tolerating both shapes is serving",
|
|
277
|
+
which the framework cannot observe.
|
|
278
|
+
|
|
279
|
+
`count` is the same predicate `source` selects on, counted. It is what makes a dry run honest and
|
|
280
|
+
"did it converge" arithmetic: a pass that exhausts its source while `count()` still answers above
|
|
281
|
+
zero has two predicates that disagree, which is an authoring bug and not something a retry fixes.
|
|
282
|
+
Its result is parsed rather than trusted — a non-negative safe integer or `X_INVARIANT`, because
|
|
283
|
+
`NaN > 0` and `-1 > 0` are both false and would complete the sweep the detector exists to fail.
|
|
284
|
+
|
|
285
|
+
### Running one
|
|
286
|
+
|
|
287
|
+
```
|
|
288
|
+
x db backfill --pending --json # declared minus completed; non-zero when anything is unswept
|
|
289
|
+
x db backfill drop-legacy # DRY RUN — --write is never implied
|
|
290
|
+
x db backfill drop-legacy --write # gate, then enqueue; the workers sweep
|
|
291
|
+
x db backfill --all --write # every pending one, isolated per name
|
|
292
|
+
x db backfill drop-legacy --write --force # a completed name, again, into a NEW ledger row
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
`--write` **enqueues**; it never runs the pass inline, because the queue is a job's execution
|
|
296
|
+
surface. That is what makes the `backfill` deploy role a trigger rather than a gate: it runs after
|
|
297
|
+
the new pods serve, puts the sweeps on the queue and exits, and a slow UPDATE never holds a release
|
|
298
|
+
open against a database still serving the previous build. `--all` isolates per name and continues
|
|
299
|
+
past a failure, so one wedged cleanup cannot block every later one forever.
|
|
300
|
+
|
|
301
|
+
## The deadline cancels
|
|
302
|
+
|
|
303
|
+
A job's `timeout` aborts `ctx.signal` **before** it fails the attempt, because the nack that
|
|
304
|
+
follows makes the job claimable by another worker — a body still running past it is a second
|
|
305
|
+
copy of one job.
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
run: async ({ input, ctx, step }) => {
|
|
309
|
+
const res = await fetch(url, { signal: ctx.signal }); // stops at the deadline
|
|
310
|
+
await step.run('save', (signal) => save(res, { signal })); // the step's own ceiling too
|
|
311
|
+
},
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
Nothing can kill a body that ignores the signal, so the durable state is fenced: past the
|
|
315
|
+
cancel every step write is refused with `X_ABORTED`, and a run that finishes anyway is logged
|
|
316
|
+
as `jobs.timeout.abandoned` — the one way to find a handler that never reads `ctx.signal`.
|
|
317
|
+
|
|
318
|
+
## The transactional outbox
|
|
107
319
|
|
|
108
320
|
```ts
|
|
109
321
|
await ctx.tx(async (tx) => {
|
|
110
322
|
const post = await ctx.posts.publish(input.postId, tx);
|
|
111
|
-
await notifySubscribers.enqueue({ postId: post.id }); // joins `tx`
|
|
323
|
+
await notifySubscribers.enqueue({ postId: post.id, orgId: input.orgId }); // joins `tx`
|
|
112
324
|
});
|
|
113
325
|
```
|
|
114
326
|
|
|
@@ -123,17 +335,46 @@ it after commit. The bug class this removes:
|
|
|
123
335
|
Both are load-dependent, both pass every test you would write, and both produce "the email
|
|
124
336
|
went out but the order isn't in the database". Joining the transaction closes the window.
|
|
125
337
|
The relay publishes *then* marks published, so a crash re-publishes — collapsed by the
|
|
126
|
-
idempotency key.
|
|
127
|
-
`
|
|
338
|
+
idempotency key. A publish that FAILS stops the batch rather than letting later rows overtake it:
|
|
339
|
+
`claim()` returns rows in `staged_at` order, so an app that stages `createInvoice` then
|
|
340
|
+
`chargeCard` in one transaction must never have the charge run first. Set `mode: 'required'` to
|
|
341
|
+
make an enqueue outside a transaction an `X_OUTBOX_NO_TX` error instead of a direct publish.
|
|
342
|
+
|
|
343
|
+
**It is not on by default, and it is not on until you install it** (`As of 2026-08`). Three
|
|
344
|
+
things have to be true in a process:
|
|
345
|
+
|
|
346
|
+
| Step | Call |
|
|
347
|
+
|---|---|
|
|
348
|
+
| the table exists | ships in `SQL_JOBS_TABLE` — applying the queue DDL is enough |
|
|
349
|
+
| the facade is installed | `setJobsFacade(createJobsFacade({ store, driver }, currentTx))` |
|
|
350
|
+
| the relay is running | `createOutboxRelay({ store, driver }).start()` |
|
|
351
|
+
|
|
352
|
+
with `store = createPgOutboxStore({ executor, txExecutor })`. `txExecutor` is what makes it
|
|
353
|
+
transactional: `stage()` runs on the CALLER'S connection, never the pool. With nothing installed,
|
|
354
|
+
`jobsFacade()` answers a fallback whose `currentTx` is `() => undefined` and every enqueue
|
|
355
|
+
publishes straight to the driver — deliberate, so a script and a test enqueue with no wiring, but
|
|
356
|
+
it is a fallback and not the guarantee.
|
|
357
|
+
|
|
358
|
+
The memory store (`createMemoryOutboxStore`, `x dev` and tests) **drops** a published row —
|
|
359
|
+
`retained()` is the relay's backlog, not a running total; the pg store keeps `published_at` as
|
|
360
|
+
the audit trail this map is not. A relay pass that throws is logged as `jobs.outbox.tick-failed`
|
|
361
|
+
and the loop re-arms: an unobserved rejection would end the process with rows still staged.
|
|
362
|
+
|
|
363
|
+
`relay.stop()` is **async and joins the pass in flight** — `await` it before closing the database,
|
|
364
|
+
the way `worker.stop()` and `scheduler.stop()` are awaited. A pass is a publish followed by a
|
|
365
|
+
`markPublished`, and a caller that returned between the two closed the pool under the row it was
|
|
366
|
+
about to mark.
|
|
128
367
|
|
|
129
368
|
## Drivers
|
|
130
369
|
|
|
131
370
|
One interface: `enqueue`, `claim` (visibility timeout), `ack`, `nack` (backoff),
|
|
132
|
-
`heartbeat`, `stats`. Zero job-code change
|
|
371
|
+
`heartbeat`, `stats`, plus optional `introspect`, `backfills` and `leases`. Zero job-code change
|
|
372
|
+
between them — swapping is `setJobDriver(other)`, and there is **no `jobs.driver` config line**:
|
|
373
|
+
`JobsConfig.driver` has no reader and boot always builds `createPgDriver`.
|
|
133
374
|
|
|
134
375
|
| Driver | Status | Backing | Use |
|
|
135
376
|
|---|---|---|---|
|
|
136
|
-
| `pg` | **default** | `SELECT ... FOR UPDATE SKIP LOCKED`, partial unique index,
|
|
377
|
+
| `pg` | **default** | `SELECT ... FOR UPDATE SKIP LOCKED`, a partial unique index on `(name, idempotency_key)`, lease-based leader, `x_job_leases` | zero-infra start, most apps |
|
|
137
378
|
| `memory` | complete | in-process maps | `x dev`, tests |
|
|
138
379
|
| `redis` | interface-complete, `X_NOT_IMPLEMENTED` | Streams + consumer groups | planned |
|
|
139
380
|
| `nats` | interface-complete, `X_NOT_IMPLEMENTED` | JetStream work queue | planned |
|
|
@@ -145,8 +386,32 @@ debugging a stuck queue can read and run the exact statement.
|
|
|
145
386
|
|
|
146
387
|
| Role | Entry | Behaviour |
|
|
147
388
|
|---|---|---|
|
|
148
|
-
| `worker` | `createWorker({ driver, queues, concurrency })` | per-queue pools, heartbeat, SIGTERM drain: stop claiming → finish in-flight → close |
|
|
149
|
-
| `scheduler` | `createScheduler({ driver, leader })` |
|
|
389
|
+
| `worker` | `createWorker({ driver, context, queues, concurrency })` | per-queue pools, lease heartbeat, SIGTERM drain: stop claiming → finish in-flight → close |
|
|
390
|
+
| `scheduler` | `createScheduler({ driver, leader, state })` | one dispatch round at a time, catch-up policy, SIGTERM drain: stop dispatching → finish the round → release the lock |
|
|
391
|
+
|
|
392
|
+
`driver` and `context` are the two required keys on `WorkerOptions`; everything else defaults.
|
|
393
|
+
`context: () => Ctx` supplies the ambient `Ctx` a job run executes under — the app wires its ALS
|
|
394
|
+
and its tenant there, and a worker with no way to build one would run every handler as nobody.
|
|
395
|
+
|
|
396
|
+
**Pass `state` and `leader` in any real deployment.** The defaults are a `Map` and "always the
|
|
397
|
+
leader", and both fail silently: with no durable watermark a redeployed scheduler arms to tomorrow
|
|
398
|
+
and never detects the occurrence the pod it replaced dropped (`catchUp` and `maxCatchUp` are inert
|
|
399
|
+
— "missed" is relative to a watermark that no longer exists), and with no election a rolling
|
|
400
|
+
update runs two leaders.
|
|
401
|
+
|
|
402
|
+
```ts
|
|
403
|
+
createScheduler({
|
|
404
|
+
driver,
|
|
405
|
+
state: pgSchedulerState(executor),
|
|
406
|
+
leader: createPgLeaseLeader({ executor }),
|
|
407
|
+
});
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
`createPgLeaseLeader` and not `createPgLeader`: `pg_try_advisory_lock` is scoped to a Postgres
|
|
411
|
+
*session*, and the executor this package is handed is a **pool** — the lock is released the moment
|
|
412
|
+
that connection goes back to it, so every node reads itself as leader. The lease is a row with an
|
|
413
|
+
expiry and needs no connection affinity. `acquire()` is also the renewal, called every round, which
|
|
414
|
+
is how a demoted node finds out.
|
|
150
415
|
|
|
151
416
|
```ts
|
|
152
417
|
export const nightlyDigest = task({
|
|
@@ -159,7 +424,17 @@ export const nightlyDigest = task({
|
|
|
159
424
|
`tz` is required by the type *and* validated against the runtime's IANA database, because a
|
|
160
425
|
non-empty string is not a timezone: `tz: 'Bogota'` would resolve every occurrence in UTC and
|
|
161
426
|
run five hours off, silently, forever. `0 3 * * *` in a DST zone runs twice or zero times on
|
|
162
|
-
the switch day. Catch-up after downtime is explicit: `skip` (default)
|
|
427
|
+
the switch day. Catch-up after downtime is explicit: `skip` (default) fires the latest missed
|
|
428
|
+
occurrence and drops the older ones, `run-once` fires the earliest missed one, `run-all` fires
|
|
429
|
+
every one of them. `maxCatchUp` (default 10) bounds the WALK for every mode, not just `run-all`:
|
|
430
|
+
one tick walks at most that many occurrences forward from the last fire, and the policy then
|
|
431
|
+
picks from what that walk found — so after a long outage `skip` fires the latest occurrence
|
|
432
|
+
*within the cap*, not the true latest missed.
|
|
433
|
+
|
|
434
|
+
`run-once` fires **once**, not once per tick. Dropping the rest means the watermark passes them
|
|
435
|
+
too, so a scheduler back up after a day down enqueues one catch-up and then waits for the next
|
|
436
|
+
real occurrence. It used to leave the watermark on the occurrence it had just run, which made an
|
|
437
|
+
hourly task fire 24 catch-ups a second apart.
|
|
163
438
|
|
|
164
439
|
## Retries
|
|
165
440
|
|
|
@@ -174,14 +449,73 @@ retrySchedule({ attempts: 5, backoff: 'exponential', delay: 1000 })
|
|
|
174
449
|
|
|
175
450
|
## Limits
|
|
176
451
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
452
|
+
Two layers, and the difference matters:
|
|
453
|
+
|
|
454
|
+
| Layer | Scope | Where |
|
|
455
|
+
|---|---|---|
|
|
456
|
+
| `LimitConfig` — `perTenant`, `perQueue`, `global`, `ratePerTenant` | **this process only** | `limits.ts`, three `Map`s in one heap |
|
|
457
|
+
| `job.concurrency` | **the fleet** | `JobDriver.leases` over `x_job_leases` |
|
|
458
|
+
|
|
459
|
+
`LimitConfig` is the fast path and is multiplied by your replica count: `perTenant: 2` on twenty
|
|
460
|
+
pods is forty concurrent runs, and `ratePerTenant`'s window is in memory, so a rolling restart
|
|
461
|
+
grants every tenant a fresh full allowance. Size it as a per-pod budget, never as a partner's
|
|
462
|
+
contractual rate.
|
|
463
|
+
|
|
464
|
+
`job.concurrency` is the one that is fleet-wide, and it is enforced by a row every replica can
|
|
465
|
+
see — one per held slot, keyed `job:<name>`, renewed by the same heartbeat that renews the
|
|
466
|
+
visibility lease and reclaimed by TTL when a worker is SIGKILLed. A driver with no lease store
|
|
467
|
+
cannot hold the cap, so `createWorker().start()` **refuses to boot**
|
|
468
|
+
(`X_JOB_CONCURRENCY_UNENFORCEABLE`) rather than let a documented guarantee do nothing.
|
|
469
|
+
|
|
470
|
+
Over any cap the claim is handed straight back without burning an attempt — one org's 50k-row
|
|
471
|
+
import cannot starve the fleet.
|
|
472
|
+
|
|
473
|
+
## Leases
|
|
474
|
+
|
|
475
|
+
A claim buys `visibilityTimeoutMs` of invisibility; the worker renews it every
|
|
476
|
+
`heartbeatIntervalMs` (default a third of the window) for as long as the job runs. Renewal
|
|
477
|
+
failures are not swallowed:
|
|
478
|
+
|
|
479
|
+
| Fact | Signal |
|
|
480
|
+
|---|---|
|
|
481
|
+
| a renewal failed, the window still has room | `jobs.heartbeat.failed` (warn) |
|
|
482
|
+
| a whole window passed with none landing | `jobs.lease.lost` (error) + `job_leases_lost_total{queue}` |
|
|
483
|
+
|
|
484
|
+
The second one means the queue is free to hand that job to another worker while this one is
|
|
485
|
+
still running it — at-least-once turning into twice. Alert on any non-zero rate. The window is
|
|
486
|
+
measured from the last renewal that **landed**, on this process's clock, so a driver whose
|
|
487
|
+
heartbeat hangs is caught the same as one that rejects.
|
|
180
488
|
|
|
181
489
|
## Introspection
|
|
182
490
|
|
|
183
491
|
`inspectQueues`, `inspectJob` (per-step trace), `inspectDeadLetters`, `retryFromStep`,
|
|
184
|
-
`inspectManifest` — all `--json`-shaped, shared by `/_x`, the CLI and the MCP tools.
|
|
492
|
+
`cancelJob`, `inspectManifest` — all `--json`-shaped, shared by `/_x`, the CLI and the MCP tools.
|
|
493
|
+
|
|
494
|
+
`cancelJob(driver, id, reason?)` is the answer to a runaway pass. A queued row becomes `cancelled`
|
|
495
|
+
immediately; a RUNNING one stops at its next heartbeat, which no longer matches its own row and
|
|
496
|
+
aborts the attempt — `steps.ts` then refuses every write. `ack` and `nack` are fenced on
|
|
497
|
+
`state = 'running'`, so the worker that was cancelled cannot un-cancel it on the way out.
|
|
498
|
+
|
|
499
|
+
## Metrics
|
|
500
|
+
|
|
501
|
+
| Series | Kind | Answers |
|
|
502
|
+
|---|---|---|
|
|
503
|
+
| `queue_depth{queue}` | gauge | how much work is waiting — the HPA's signal |
|
|
504
|
+
| `jobs_total{queue,outcome}` | counter | is any of it succeeding |
|
|
505
|
+
| `queue_oldest_ready_seconds{queue}` | gauge | *"page if the oldest job in `payments` is older than 5 minutes"* |
|
|
506
|
+
| `queue_dead_jobs{queue}` | gauge | a dead-letter queue that filled overnight and stopped growing — a counter's rate is flat there |
|
|
507
|
+
|
|
508
|
+
## Trace and actor
|
|
509
|
+
|
|
510
|
+
An enqueue stamps the current span's `traceparent` onto the row, and the worker opens the job's
|
|
511
|
+
span as a **child** of it: a checkout trace shows the HTTP span, the action span and the charge
|
|
512
|
+
that ran two seconds later as one trace.
|
|
513
|
+
|
|
514
|
+
`handle.as(actor, input)` also records `enqueuedBy` — the actor's id. **Attribution, never
|
|
515
|
+
authority.** A job body runs with system authority; the framework does not impersonate the
|
|
516
|
+
enqueuer, because a job that sleeps three days or dead-letters and is retried next quarter would
|
|
517
|
+
act as somebody whose role, org membership or employment has changed since. A job that must act
|
|
518
|
+
FOR a user takes that user's id in its input and re-authorises it in the body.
|
|
185
519
|
|
|
186
520
|
## Errors
|
|
187
521
|
|
|
@@ -194,6 +528,11 @@ burning an attempt — one org's 50k-row import cannot starve the fleet.
|
|
|
194
528
|
| `X_JOB_MAX_ATTEMPTS` | retries exhausted, job dead-lettered |
|
|
195
529
|
| `X_OUTBOX_NO_TX` | enqueue outside a transaction with `mode: 'required'` |
|
|
196
530
|
| `X_DRIVER_UNAVAILABLE` | no `DATABASE_URL` / executor for the pg driver |
|
|
531
|
+
| `X_ABORTED` | a cancelled attempt tried to write a step — core's code, not a second name for it |
|
|
532
|
+
| `X_JOB_LEASE_LOST` | the job was cancelled, or its lease lapsed and the queue re-delivered it, while this worker was still running it |
|
|
533
|
+
| `X_JOB_SLOT_LOST` | the fleet `concurrency` slot this run held was taken by another worker — a different row on a different clock from the lease above |
|
|
534
|
+
| `X_JOB_NOT_CANCELLABLE` | `cancelJob` reached a job that already finished, or a driver with no `cancel` |
|
|
535
|
+
| `X_JOB_CONCURRENCY_UNENFORCEABLE` | a registered job declares `concurrency` and the driver has no lease store |
|
|
197
536
|
| `X_NOT_IMPLEMENTED` | redis / nats driver |
|
|
198
537
|
|
|
199
538
|
## Boundary
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/jobs",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Durable background work: steps, transactional outbox, cron tasks, one driver interface",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"src",
|
|
21
21
|
"!src/**/*.test.ts",
|
|
22
|
+
"!src/**/*-fixture.ts",
|
|
23
|
+
"CLAUDE.md",
|
|
22
24
|
"README.md",
|
|
23
25
|
"LICENSE"
|
|
24
26
|
],
|
|
@@ -30,9 +32,9 @@
|
|
|
30
32
|
"test": "bun test"
|
|
31
33
|
},
|
|
32
34
|
"dependencies": {
|
|
33
|
-
"@ultimat3/core": "
|
|
34
|
-
"@ultimat3/entity": "
|
|
35
|
-
"@ultimat3/schema": "
|
|
36
|
-
"@ultimat3/time": "
|
|
35
|
+
"@ultimat3/core": "2.0.0",
|
|
36
|
+
"@ultimat3/entity": "2.0.0",
|
|
37
|
+
"@ultimat3/schema": "2.0.0",
|
|
38
|
+
"@ultimat3/time": "2.0.0"
|
|
37
39
|
}
|
|
38
40
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// May this named sweep run here, now? One decision, pure, so the pass, the CLI, a test and a
|
|
2
|
+
// deploy container all read the same verdict instead of four almost-identical `if`s. It RETURNS
|
|
3
|
+
// the refusal rather than throwing it: `x db backfill --all` isolates per name and continues past
|
|
4
|
+
// a failure, and a thrown verdict would let one wedged cleanup block every later one forever.
|
|
5
|
+
//
|
|
6
|
+
// The environment half is enforced inside the pass as well, because a backfill enqueued by app
|
|
7
|
+
// code never passes through the CLI — the check has to sit where the work is, or it is a
|
|
8
|
+
// convention rather than a rail (axiom 3).
|
|
9
|
+
|
|
10
|
+
import type { Environment, UltimateError } from '@ultimat3/core';
|
|
11
|
+
// `BackfillProgress`, the one ledger projection every surface already reads — never the driver's
|
|
12
|
+
// own row shape, which would make this a second reader of `x_backfills`.
|
|
13
|
+
import type { BackfillProgress } from './backfill-inspect';
|
|
14
|
+
import type { BackfillDeclaration } from './backfill-registry';
|
|
15
|
+
import {
|
|
16
|
+
BackfillAppliedError,
|
|
17
|
+
BackfillEnvironmentError,
|
|
18
|
+
BackfillMigrationPendingError,
|
|
19
|
+
} from './errors';
|
|
20
|
+
|
|
21
|
+
export type BackfillGate =
|
|
22
|
+
| { readonly run: true }
|
|
23
|
+
| { readonly run: false; readonly error: UltimateError };
|
|
24
|
+
|
|
25
|
+
const ALLOWED: BackfillGate = { run: true };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* An absent `environments` means EVERY environment. Never an implied "production only": a staging
|
|
29
|
+
* rehearsal is correct practice, and a framework that guessed which deploys a cleanup belongs to
|
|
30
|
+
* would be shipping one business's convention to every app (axiom 8).
|
|
31
|
+
*/
|
|
32
|
+
export function checkBackfillEnvironment(
|
|
33
|
+
backfill: string,
|
|
34
|
+
declared: readonly Environment[] | null,
|
|
35
|
+
environment: Environment,
|
|
36
|
+
): BackfillEnvironmentError | undefined {
|
|
37
|
+
if (declared === null || declared.length === 0) return undefined;
|
|
38
|
+
if (declared.includes(environment)) return undefined;
|
|
39
|
+
return new BackfillEnvironmentError({ backfill, environment, declared });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface BackfillGateInput {
|
|
43
|
+
readonly declaration: BackfillDeclaration;
|
|
44
|
+
readonly environment: Environment;
|
|
45
|
+
/**
|
|
46
|
+
* Migration ids `x_migrations` records as applied. `undefined` means the caller could not read
|
|
47
|
+
* the ledger, and an unreadable ledger is deliberately NOT a refusal — a `requires` that blocked
|
|
48
|
+
* on "I could not check" would make every driver with no database an unrunnable backfill.
|
|
49
|
+
*/
|
|
50
|
+
readonly appliedMigrations: readonly string[] | undefined;
|
|
51
|
+
/** The newest COMPLETED pass under this name, when the ledger holds one. */
|
|
52
|
+
readonly completed: BackfillProgress | undefined;
|
|
53
|
+
readonly force: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Order is the order an operator can act in: environment first (nothing else matters if this
|
|
58
|
+
* process may not run it at all), then the migration it waits on, then whether it already ran.
|
|
59
|
+
* A live pass is NOT judged here — that verdict belongs to the enqueue, which is the only thing
|
|
60
|
+
* that can see the one live idempotency key without racing it.
|
|
61
|
+
*/
|
|
62
|
+
export function gateBackfill(input: BackfillGateInput): BackfillGate {
|
|
63
|
+
const { declaration } = input;
|
|
64
|
+
const environment = checkBackfillEnvironment(
|
|
65
|
+
declaration.name,
|
|
66
|
+
declaration.environments,
|
|
67
|
+
input.environment,
|
|
68
|
+
);
|
|
69
|
+
if (environment !== undefined) return { run: false, error: environment };
|
|
70
|
+
|
|
71
|
+
const requires = declaration.requires;
|
|
72
|
+
if (
|
|
73
|
+
requires !== null &&
|
|
74
|
+
input.appliedMigrations !== undefined &&
|
|
75
|
+
!input.appliedMigrations.includes(requires)
|
|
76
|
+
) {
|
|
77
|
+
return {
|
|
78
|
+
run: false,
|
|
79
|
+
error: new BackfillMigrationPendingError({ backfill: declaration.name, migration: requires }),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const completed = input.completed;
|
|
84
|
+
if (completed !== undefined && !input.force) {
|
|
85
|
+
return {
|
|
86
|
+
run: false,
|
|
87
|
+
error: new BackfillAppliedError({
|
|
88
|
+
backfill: declaration.name,
|
|
89
|
+
runId: completed.runId,
|
|
90
|
+
// Verbatim: the projection already rendered it as ISO, and the repo forbids a date
|
|
91
|
+
// formatted without an explicit zone — not formatting at all is the one render with none.
|
|
92
|
+
completedAt: completed.completedAt ?? completed.startedAt,
|
|
93
|
+
}),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return ALLOWED;
|
|
97
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// The `x_backfills` ledger, projected for the surfaces that report it: `x db backfill --list`,
|
|
2
|
+
// `x jobs`, `/_x`'s jobs panel and MCP. Plain JSON-serialisable objects, the same contract
|
|
3
|
+
// `inspect.ts` holds for the queue — one shape, so the dashboard renders what `--json` prints.
|
|
4
|
+
//
|
|
5
|
+
// Nothing here reads a clock. A running row's elapsed time would need one, and "how long has this
|
|
6
|
+
// been going" computed against the reader's wall clock is a different number in every process
|
|
7
|
+
// that asks — so `durationMs` is the pass's own completed span or nothing at all.
|
|
8
|
+
|
|
9
|
+
import type { BackfillFilter, BackfillRun, BackfillStatus } from './backfill-ledger';
|
|
10
|
+
import type { JobDriver } from './driver';
|
|
11
|
+
|
|
12
|
+
/** One ledger row as a surface reports it: epochs become ISO, absent becomes `null`. */
|
|
13
|
+
export interface BackfillProgress {
|
|
14
|
+
/** The pass, and the run id of the job that is sweeping — `x jobs show <id>` joins on it. */
|
|
15
|
+
readonly runId: string;
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly status: BackfillStatus;
|
|
18
|
+
readonly checksum: string;
|
|
19
|
+
readonly appVersion: string;
|
|
20
|
+
/** Rows the pass has handled so far. Absolute, so a replayed batch reports the same number. */
|
|
21
|
+
readonly rows: number;
|
|
22
|
+
/** Where the pass had got to. `null` before the first batch and once it is over. */
|
|
23
|
+
readonly cursor: string | null;
|
|
24
|
+
readonly startedAt: string;
|
|
25
|
+
readonly completedAt: string | null;
|
|
26
|
+
/** How long the pass took. `null` while it is still running — see the file header. */
|
|
27
|
+
readonly durationMs: number | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function toBackfillProgress(run: BackfillRun): BackfillProgress {
|
|
31
|
+
return {
|
|
32
|
+
runId: run.runId,
|
|
33
|
+
name: run.name,
|
|
34
|
+
status: run.status,
|
|
35
|
+
checksum: run.checksum,
|
|
36
|
+
appVersion: run.appVersion,
|
|
37
|
+
rows: run.rows,
|
|
38
|
+
cursor: run.cursor,
|
|
39
|
+
startedAt: new Date(run.startedAt).toISOString(),
|
|
40
|
+
completedAt: run.completedAt === undefined ? null : new Date(run.completedAt).toISOString(),
|
|
41
|
+
durationMs: run.completedAt === undefined ? null : run.completedAt - run.startedAt,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Every pass the ledger holds, newest first. An EMPTY list on a driver that ships no ledger, and
|
|
47
|
+
* deliberately not a throw: `x jobs ls` and the jobs panel report the queue, and a queue that
|
|
48
|
+
* answers everything except "no backfills recorded" is a broken command for a fact nobody asked
|
|
49
|
+
* about. `x db backfill --list` says so in its own summary instead, where it IS the question.
|
|
50
|
+
*/
|
|
51
|
+
export async function inspectBackfills(
|
|
52
|
+
driver: JobDriver,
|
|
53
|
+
// `BackfillFilter` itself, never its fields restated: `ledger.list` takes that type, so a field
|
|
54
|
+
// added to it reaches this surface instead of being silently dropped one call short of it.
|
|
55
|
+
filter: BackfillFilter = {},
|
|
56
|
+
): Promise<readonly BackfillProgress[]> {
|
|
57
|
+
const ledger = driver.backfills;
|
|
58
|
+
if (ledger === undefined) return [];
|
|
59
|
+
return (await ledger.list(filter)).map(toBackfillProgress);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The pass one job run is sweeping, or nothing. Keyed by run rather than by name because `force`
|
|
64
|
+
* writes a NEW row for a name that already has one — the row this job wrote is the only one that
|
|
65
|
+
* describes this job.
|
|
66
|
+
*/
|
|
67
|
+
export async function backfillForRun(
|
|
68
|
+
driver: JobDriver,
|
|
69
|
+
runId: string,
|
|
70
|
+
): Promise<BackfillProgress | undefined> {
|
|
71
|
+
const [row] = await inspectBackfills(driver, { runId, limit: 1 });
|
|
72
|
+
return row;
|
|
73
|
+
}
|