@ultimat3/jobs 7.0.0 → 9.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 +45 -1
- package/README.md +39 -0
- package/package.json +5 -5
- package/src/driver-pg-jobs-sql.ts +45 -0
- package/src/driver-pg-sql.ts +30 -1
- package/src/driver-pg.ts +15 -23
- package/src/index.ts +8 -0
- package/src/purge.ts +150 -0
- package/src/scheduler.ts +9 -4
- package/src/worker-run.ts +36 -26
package/CLAUDE.md
CHANGED
|
@@ -334,6 +334,29 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
|
|
|
334
334
|
jumps over a page the live iteration never read. A checkpoint READ back is checked rather than
|
|
335
335
|
trusted — `step.run` replays it through an unchecked `as T`, and an absent cursor is not `null`,
|
|
336
336
|
so the pass would silently reopen the source at the top and walk the whole table again.
|
|
337
|
+
- **`purge()` is a FACTORY over `job()` too, and it is the ONE caller every `purgeExpired()` in
|
|
338
|
+
the framework was missing** (`As of 2026-08-22`). Three stores shipped one —
|
|
339
|
+
`postgresIdempotencyStore` (`x_idempotency`), `postgresRateLimitStore` (`x_rate_limit`) and
|
|
340
|
+
`postgresAuthLimiter` (`x_auth_failures`/`x_auth_lockouts`) — each documented as "an app runs
|
|
341
|
+
this from a `task`", and a task only ENQUEUES, so there was no job for one to enqueue and every
|
|
342
|
+
row written was a row kept. `x_rate_limit` takes one upsert per HTTP request the web role serves,
|
|
343
|
+
assets included.
|
|
344
|
+
|
|
345
|
+
`PurgeTarget` is STRUCTURAL (`{ name, purgeExpired(nowMs) }`) for the reason `PgExecutor` is: two
|
|
346
|
+
of those three packages are below this one and one is beside it, and a sweep that needed their
|
|
347
|
+
types would put the HTTP pipeline on this package's import graph. `targets()` is a THUNK, read
|
|
348
|
+
once per attempt: a host declares the sweep at boot and the auth limiter does not exist yet —
|
|
349
|
+
`defineAuth` builds it when the app's modules import. One table per `step.run`, so a killed
|
|
350
|
+
attempt resumes at the table it stopped on; a purge is idempotent by nature, so the replay that
|
|
351
|
+
at-least-once guarantees deletes rows that are already gone. **One clock reading for the whole
|
|
352
|
+
pass**, handed to every target: `postgresRateLimitStore.purgeExpired(nowMs)` requires the
|
|
353
|
+
CALLER's clock, and reading the server's computed a 20,000,000-second refill against a frozen
|
|
354
|
+
test clock and deleted a bucket holding 0 of 4 tokens — a free limit reset, handed out by the
|
|
355
|
+
cleanup. Two targets under one name are refused (`X_INVARIANT`) before the first delete rather
|
|
356
|
+
than discovered as `X_STEP_DUPLICATE` after one table is already empty.
|
|
357
|
+
|
|
358
|
+
It declares no schedule of its own: `DEFAULT_PURGE_CRON` is the hourly cron a host's `task()`
|
|
359
|
+
uses, and `@ultimat3/cli`'s `dev-purge.ts` is the one that declares both halves at boot.
|
|
337
360
|
- **`handle` is AT LEAST ONCE, and the ordering that makes it so is deliberate.** The body runs
|
|
338
361
|
inside the step and the record is written after it returns, so an attempt killed, cancelled or
|
|
339
362
|
lease-expired between the two hands that page to the next attempt — which is why the doc comment,
|
|
@@ -567,6 +590,25 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
|
|
|
567
590
|
The shed also wrote `last_error = 'limited: …'`, so `x jobs show` reported a failure for a job
|
|
568
591
|
that never ran — it is a `jobs.worker.shed` log field now, and `worker.ts`'s one `shed()` is
|
|
569
592
|
where both sheds go. `driver-parity.test.ts` pins which bucket each lands in, in both drivers.
|
|
593
|
+
- **No read returns a WHOLE row, and `driver-pg-sql.test.ts` is what enforces it** (`As of
|
|
594
|
+
2026-08-22`). `PgExecutor` is an injected seam over any client that speaks `(text, values)`, and a
|
|
595
|
+
client with no type map decodes `timestamptz` as TEXT — so `toJobRecord`/`toStepRecord` read
|
|
596
|
+
`Number('2026-01-01 00:00:00+00')` and answered `NaN`. Six statements were `select *` /
|
|
597
|
+
`returning *` and shipped that way: `pgStepStore.list`, `introspect.job`, `introspect.list`,
|
|
598
|
+
`introspect.deadLetters`, `introspect.requeue` and `SQL_CANCEL`. Every one of them feeds a
|
|
599
|
+
surface an operator reads — `x jobs ls`, `x jobs show`, `x jobs cancel` — and `SQL_CLAIM` had
|
|
600
|
+
projected epoch ms all along, so the driver disagreed with itself. The guard is a scan of every
|
|
601
|
+
production file in this directory, DISCOVERED rather than listed and with comments stripped: a
|
|
602
|
+
`select *` anywhere in them, in either case, is a failing test rather than a review note. It
|
|
603
|
+
reads the directory because the hand-kept list it replaces had already missed one
|
|
604
|
+
(`driver-pg-ddl.ts`) — a registry a new SQL source opts out of by simply not joining it is a
|
|
605
|
+
guard with the shape of a rule and none of the force.
|
|
606
|
+
- **A run's acquisitions are handed back even when the wiring throws.** `worker-run.ts` starts the
|
|
607
|
+
lease heartbeat first, so every line between it and the `try` can leak an interval renewing the
|
|
608
|
+
lease of a job that never ran, with nothing left holding a reference to stop it. `context()` was
|
|
609
|
+
moved ABOVE the heartbeat for that reason; `createRunSignal` and `fleetSlots.startRenewal` — the
|
|
610
|
+
second an injected seam whose production implementation reaches a lease store — are INSIDE the
|
|
611
|
+
`try`, with `undefined` meaning "never taken" and both handbacks idempotent.
|
|
570
612
|
- Step results are persisted BEFORE the step returns. Keep it that way or replay breaks.
|
|
571
613
|
- All time is epoch ms from an injected `Clock`, read via `nowMs()` in `clock.ts`.
|
|
572
614
|
- Drivers implement exactly the six `JobDriver` methods plus optional `introspect`, `backfills`
|
|
@@ -653,8 +695,9 @@ picture from the other side.
|
|
|
653
695
|
| `scheduler-pg.ts` | `pgSchedulerState` (the durable watermark) + `createPgLeaseLeader` |
|
|
654
696
|
| `events-pg.ts` | `createPgEventBus` — `step.waitForEvent` across processes |
|
|
655
697
|
| `driver.ts` | `JobDriver` contract + wire records |
|
|
656
|
-
| `driver-pg.ts` | default driver, real SQL constants, advisory-lock
|
|
698
|
+
| `driver-pg.ts` | default driver, real SQL constants, and `createPgLeader` — the advisory-lock election that is **not** what a scheduler uses; `scheduler-pg.ts` above owns the lease-row one boot wires |
|
|
657
699
|
| `driver-pg-ddl.ts` | `SQL_JOBS_TABLE` + `SQL_OUTBOX_TABLE` — the schema the driver installs. Whichever file holds the DDL is the one whose comments may carry no `;` and no `'` |
|
|
700
|
+
| `driver-pg-jobs-sql.ts` | every statement returning a whole `x_jobs` row, and the `JOB_ROW_COLUMNS` projection they share. Split off at `driver-pg-sql.ts`'s size ceiling and re-exported from it |
|
|
658
701
|
| `driver-pg-rows.ts` | a Postgres row → a wire record: `JobRow`/`StepRow`/`BackfillRow` and their mappings |
|
|
659
702
|
| `driver-memory.ts` | `x dev` / tests |
|
|
660
703
|
| `driver-redis.ts`, `driver-nats.ts` | honest `X_NOT_IMPLEMENTED` stubs |
|
|
@@ -667,6 +710,7 @@ picture from the other side.
|
|
|
667
710
|
| `worker-run.ts` | one claimed job, wired: its heartbeat, its slot renewal, its run signal and its span, started together and handed back in one `finally` |
|
|
668
711
|
| `run-signal.ts` | the signal ONE run is cancelled by — composition that can be handed back, and that the worker can abort itself |
|
|
669
712
|
| `worker-fleet-slots.ts` | the fleet slot an in-flight job holds — take, renew, hand back. The claim loop asks "may I start this one?"; this answers it across the fleet |
|
|
713
|
+
| `purge.ts` | `purge()` — a factory over `job()`: the retention sweep, its structural target seam and the hourly cron a host schedules it on |
|
|
670
714
|
| `task.ts` | the `task()` primitive + registry + the handle's surface + `registerTask` |
|
|
671
715
|
| `scheduler.ts` | `scheduler` role: the dispatch round, catch-up, leader election, the drain |
|
|
672
716
|
| `limits.ts` | per-tenant / per-queue / global concurrency + rate |
|
package/README.md
CHANGED
|
@@ -298,6 +298,45 @@ the new pods serve, puts the sweeps on the queue and exits, and a slow UPDATE ne
|
|
|
298
298
|
open against a database still serving the previous build. `--all` isolates per name and continues
|
|
299
299
|
past a failure, so one wedged cleanup cannot block every later one forever.
|
|
300
300
|
|
|
301
|
+
## Retention sweeps are jobs too
|
|
302
|
+
|
|
303
|
+
`purge()` is the **second factory over `job()`**, and it exists because three framework stores
|
|
304
|
+
shipped a `purgeExpired()` with no caller — `x_idempotency`, `x_rate_limit` and the auth pair each
|
|
305
|
+
kept every row they ever took. `x_rate_limit` takes one upsert per HTTP request the web role
|
|
306
|
+
serves, assets included, so its growth follows total traffic and not traffic that hit a limit.
|
|
307
|
+
|
|
308
|
+
```ts
|
|
309
|
+
import { DEFAULT_PURGE_CRON, purge, task } from '@ultimat3/jobs';
|
|
310
|
+
|
|
311
|
+
declare const store: { purgeExpired(nowMs: number): Promise<number> };
|
|
312
|
+
|
|
313
|
+
export const sweep = purge({
|
|
314
|
+
name: 'x.purge',
|
|
315
|
+
// Read once per ATTEMPT, never captured: a host declares the sweep at boot, and some of the
|
|
316
|
+
// stores behind it are built later.
|
|
317
|
+
targets: () => [{ name: 'x_rate_limit', purgeExpired: (nowMs) => store.purgeExpired(nowMs) }],
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
export const hourly = task({
|
|
321
|
+
name: 'x.purge.hourly',
|
|
322
|
+
cron: DEFAULT_PURGE_CRON,
|
|
323
|
+
tz: 'UTC',
|
|
324
|
+
enqueue: () => [[sweep, {}]],
|
|
325
|
+
});
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
| Rule | Why |
|
|
329
|
+
|---|---|
|
|
330
|
+
| `PurgeTarget` is structural | the stores live in `@ultimat3/action`, `@ultimat3/http` and `@ultimat3/auth`; importing them would put the HTTP pipeline on this package's graph |
|
|
331
|
+
| one `step.run` per target | a killed attempt resumes at the table it stopped on, not at the first |
|
|
332
|
+
| one clock reading per pass | `postgresRateLimitStore.purgeExpired(nowMs)` needs the CALLER's clock — the server's read a 20,000,000-second refill against a frozen one and deleted a live bucket |
|
|
333
|
+
| at least once is safe here | a replayed delete removes rows that are already gone, and a row a purge deleted answers exactly as one that was never there |
|
|
334
|
+
| two targets under one name | `X_INVARIANT`, before the first delete — `step.run` would raise `X_STEP_DUPLICATE` after one table was already empty |
|
|
335
|
+
|
|
336
|
+
`@ultimat3/cli`'s boot declares both halves over the three tables it owns, so an app gets the sweep
|
|
337
|
+
without writing any of the above. It needs a `worker` to run it and a `scheduler` to fire it: a
|
|
338
|
+
deployment with neither has no background work at all, and this is one more thing it does not do.
|
|
339
|
+
|
|
301
340
|
## The deadline cancels
|
|
302
341
|
|
|
303
342
|
A job's `timeout` aborts `ctx.signal` **before** it fails the attempt, because the nack that
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/jobs",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "9.0.0",
|
|
4
4
|
"description": "Durable background work: steps, transactional outbox, cron tasks, one driver interface",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
"test": "bun test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/entity": "
|
|
37
|
-
"@ultimat3/schema": "
|
|
38
|
-
"@ultimat3/time": "
|
|
35
|
+
"@ultimat3/core": "9.0.0",
|
|
36
|
+
"@ultimat3/entity": "9.0.0",
|
|
37
|
+
"@ultimat3/schema": "9.0.0",
|
|
38
|
+
"@ultimat3/time": "9.0.0"
|
|
39
39
|
}
|
|
40
40
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Every statement returning a WHOLE `x_jobs` row, and the one column list they share. They ask
|
|
2
|
+
// Postgres for epoch ms because `select *` left the decoding to the client's type map: one with
|
|
3
|
+
// none decodes `timestamptz` as TEXT, so `toJobRecord` read `Number('2026-01-01 00:00:00+00')`
|
|
4
|
+
// and `x jobs ls` / `show` / `cancel` printed `NaN` for every timestamp.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `JobRow` shape as a projection. Asking Postgres for epoch ms is what makes the decoding
|
|
8
|
+
* independent of the client's type map — never `select *`, whose correctness is the driver's
|
|
9
|
+
* opinion about `timestamptz` rather than this statement's.
|
|
10
|
+
*/
|
|
11
|
+
export const JOB_ROW_COLUMNS = `id, name, queue, input, idempotency_key, run_id, attempt,
|
|
12
|
+
max_attempts, state, tenant_id, last_error, claimed_by,
|
|
13
|
+
traceparent, enqueued_by,
|
|
14
|
+
(extract(epoch from run_at) * 1000)::bigint as run_at,
|
|
15
|
+
(extract(epoch from visible_at) * 1000)::bigint as visible_at,
|
|
16
|
+
(extract(epoch from created_at) * 1000)::bigint as created_at,
|
|
17
|
+
(extract(epoch from updated_at) * 1000)::bigint as updated_at`;
|
|
18
|
+
|
|
19
|
+
export const SQL_JOB_GET = `
|
|
20
|
+
select ${JOB_ROW_COLUMNS}
|
|
21
|
+
from x_jobs where id = $1
|
|
22
|
+
`.trim();
|
|
23
|
+
|
|
24
|
+
export const SQL_JOB_LIST = `
|
|
25
|
+
select ${JOB_ROW_COLUMNS}
|
|
26
|
+
from x_jobs
|
|
27
|
+
where ($1::text is null or queue = $1)
|
|
28
|
+
and ($2::text is null or name = $2)
|
|
29
|
+
and ($3::text is null or state = $3)
|
|
30
|
+
order by created_at desc
|
|
31
|
+
limit $4
|
|
32
|
+
`.trim();
|
|
33
|
+
|
|
34
|
+
export const SQL_JOB_DEAD_LETTERS = `
|
|
35
|
+
select ${JOB_ROW_COLUMNS}
|
|
36
|
+
from x_jobs where state = 'dead' order by updated_at desc limit $1
|
|
37
|
+
`.trim();
|
|
38
|
+
|
|
39
|
+
/** `run_at = now()` makes the requeued job due immediately; the attempt counter starts over. */
|
|
40
|
+
export const SQL_JOB_REQUEUE = `
|
|
41
|
+
update x_jobs
|
|
42
|
+
set state = 'ready', attempt = 0, run_at = now(), updated_at = now()
|
|
43
|
+
where id = $1
|
|
44
|
+
returning ${JOB_ROW_COLUMNS}
|
|
45
|
+
`.trim();
|
package/src/driver-pg-sql.ts
CHANGED
|
@@ -8,6 +8,19 @@
|
|
|
8
8
|
// point keeps the ONE import path every caller already uses.
|
|
9
9
|
|
|
10
10
|
export { SQL_JOBS_TABLE, SQL_OUTBOX_TABLE } from './driver-pg-ddl';
|
|
11
|
+
// The whole-`x_jobs`-row reads, split off at this file's size ceiling and re-exported for the
|
|
12
|
+
// same reason the DDL is. `JOB_ROW_COLUMNS` is imported as a value because `SQL_CANCEL` also
|
|
13
|
+
// returns a whole row and must project it identically — two spellings of one row shape is how
|
|
14
|
+
// one of them goes back to `returning *`.
|
|
15
|
+
export {
|
|
16
|
+
JOB_ROW_COLUMNS,
|
|
17
|
+
SQL_JOB_DEAD_LETTERS,
|
|
18
|
+
SQL_JOB_GET,
|
|
19
|
+
SQL_JOB_LIST,
|
|
20
|
+
SQL_JOB_REQUEUE,
|
|
21
|
+
} from './driver-pg-jobs-sql';
|
|
22
|
+
|
|
23
|
+
import { JOB_ROW_COLUMNS } from './driver-pg-jobs-sql';
|
|
11
24
|
|
|
12
25
|
export const SQL_ENQUEUE = `
|
|
13
26
|
insert into x_jobs
|
|
@@ -112,7 +125,7 @@ update x_jobs
|
|
|
112
125
|
set state = 'cancelled', visible_at = null, claimed_by = null,
|
|
113
126
|
last_error = coalesce($2, last_error), updated_at = now()
|
|
114
127
|
where id = $1 and state <> 'done'
|
|
115
|
-
returning
|
|
128
|
+
returning ${JOB_ROW_COLUMNS}
|
|
116
129
|
`.trim();
|
|
117
130
|
|
|
118
131
|
/**
|
|
@@ -370,6 +383,22 @@ select run_id, name, status, output, attempts, error,
|
|
|
370
383
|
from x_job_steps where run_id = $1 and name = $2
|
|
371
384
|
`.trim();
|
|
372
385
|
|
|
386
|
+
/**
|
|
387
|
+
* `list`'s projection is `SQL_STEP_GET`'s, minus the name predicate. It exists as its own constant
|
|
388
|
+
* because `select *` was what `list` issued: a client that decodes `timestamptz` as text — most of
|
|
389
|
+
* them, without a type map — handed `toStepRecord` a date string, `Number()` made it `NaN`, and
|
|
390
|
+
* `x jobs show` printed one per step. Every other statement in this file asks Postgres to do the
|
|
391
|
+
* conversion; this is that rule applied to the one statement that had opted out of it.
|
|
392
|
+
*/
|
|
393
|
+
export const SQL_STEP_LIST = `
|
|
394
|
+
select run_id, name, status, output, attempts, error,
|
|
395
|
+
(extract(epoch from started_at) * 1000)::bigint as started_at,
|
|
396
|
+
(extract(epoch from completed_at) * 1000)::bigint as completed_at,
|
|
397
|
+
(extract(epoch from wake_at) * 1000)::bigint as wake_at,
|
|
398
|
+
event, correlation_key
|
|
399
|
+
from x_job_steps where run_id = $1 order by started_at
|
|
400
|
+
`.trim();
|
|
401
|
+
|
|
373
402
|
export const SQL_STEP_PUT = `
|
|
374
403
|
insert into x_job_steps
|
|
375
404
|
(run_id, name, status, output, started_at, completed_at, wake_at, event,
|
package/src/driver-pg.ts
CHANGED
|
@@ -35,12 +35,17 @@ import {
|
|
|
35
35
|
SQL_ENQUEUE,
|
|
36
36
|
SQL_FIND_LIVE_BY_KEY,
|
|
37
37
|
SQL_HEARTBEAT,
|
|
38
|
+
SQL_JOB_DEAD_LETTERS,
|
|
39
|
+
SQL_JOB_GET,
|
|
40
|
+
SQL_JOB_LIST,
|
|
41
|
+
SQL_JOB_REQUEUE,
|
|
38
42
|
SQL_LEASE_ACQUIRE,
|
|
39
43
|
SQL_LEASE_RELEASE,
|
|
40
44
|
SQL_LEASE_RENEW,
|
|
41
45
|
SQL_NACK,
|
|
42
46
|
SQL_STATS,
|
|
43
47
|
SQL_STEP_GET,
|
|
48
|
+
SQL_STEP_LIST,
|
|
44
49
|
SQL_STEP_PUT,
|
|
45
50
|
SQL_TRY_ADVISORY_LOCK,
|
|
46
51
|
} from './driver-pg-sql';
|
|
@@ -102,10 +107,7 @@ function pgStepStore(exec: () => PgExecutor): StepStore {
|
|
|
102
107
|
]);
|
|
103
108
|
},
|
|
104
109
|
async list(runId) {
|
|
105
|
-
const rows = await exec().query<StepRow>(
|
|
106
|
-
`select * from x_job_steps where run_id = $1 order by started_at`,
|
|
107
|
-
[runId],
|
|
108
|
-
);
|
|
110
|
+
const rows = await exec().query<StepRow>(SQL_STEP_LIST, [runId]);
|
|
109
111
|
return rows.map(toStepRecord);
|
|
110
112
|
},
|
|
111
113
|
async del(runId, name) {
|
|
@@ -189,27 +191,21 @@ export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
|
|
|
189
191
|
|
|
190
192
|
const introspect: JobIntrospection = {
|
|
191
193
|
async job(jobId) {
|
|
192
|
-
const rows = await exec().query<JobRow>(
|
|
194
|
+
const rows = await exec().query<JobRow>(SQL_JOB_GET, [jobId]);
|
|
193
195
|
const row = rows[0];
|
|
194
196
|
return row === undefined ? undefined : toJobRecord(row);
|
|
195
197
|
},
|
|
196
198
|
async list(filter: JobFilter = {}) {
|
|
197
|
-
const rows = await exec().query<JobRow>(
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
limit $4`,
|
|
204
|
-
[filter.queue ?? null, filter.name ?? null, filter.state ?? null, filter.limit ?? 100],
|
|
205
|
-
);
|
|
199
|
+
const rows = await exec().query<JobRow>(SQL_JOB_LIST, [
|
|
200
|
+
filter.queue ?? null,
|
|
201
|
+
filter.name ?? null,
|
|
202
|
+
filter.state ?? null,
|
|
203
|
+
filter.limit ?? 100,
|
|
204
|
+
]);
|
|
206
205
|
return rows.map(toJobRecord);
|
|
207
206
|
},
|
|
208
207
|
async deadLetters(limit = 100) {
|
|
209
|
-
const rows = await exec().query<JobRow>(
|
|
210
|
-
`select * from x_jobs where state = 'dead' order by updated_at desc limit $1`,
|
|
211
|
-
[limit],
|
|
212
|
-
);
|
|
208
|
+
const rows = await exec().query<JobRow>(SQL_JOB_DEAD_LETTERS, [limit]);
|
|
213
209
|
return rows.map(toJobRecord);
|
|
214
210
|
},
|
|
215
211
|
async requeue(jobId, requeueOptions) {
|
|
@@ -222,11 +218,7 @@ export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
|
|
|
222
218
|
]);
|
|
223
219
|
}
|
|
224
220
|
}
|
|
225
|
-
const rows = await exec().query<JobRow>(
|
|
226
|
-
`update x_jobs set state = 'ready', attempt = 0, run_at = now(), updated_at = now()
|
|
227
|
-
where id = $1 returning *`,
|
|
228
|
-
[jobId],
|
|
229
|
-
);
|
|
221
|
+
const rows = await exec().query<JobRow>(SQL_JOB_REQUEUE, [jobId]);
|
|
230
222
|
const row = rows[0];
|
|
231
223
|
if (row === undefined) {
|
|
232
224
|
throw new DriverUnavailableError({
|
package/src/index.ts
CHANGED
|
@@ -221,6 +221,14 @@ export { DEFAULT_OUTBOX_CLAIM_LEASE_MS } from './outbox-lease';
|
|
|
221
221
|
export type { PgOutboxOptions } from './outbox-pg';
|
|
222
222
|
export { createPgOutboxStore } from './outbox-pg';
|
|
223
223
|
|
|
224
|
+
export type {
|
|
225
|
+
PurgeDefinition,
|
|
226
|
+
PurgeInput,
|
|
227
|
+
PurgeReport,
|
|
228
|
+
PurgeSweep,
|
|
229
|
+
PurgeTarget,
|
|
230
|
+
} from './purge';
|
|
231
|
+
export { DEFAULT_PURGE_CRON, purge } from './purge';
|
|
224
232
|
export type { BackoffStrategy, Random, RetryDecision, RetryPolicy } from './retry';
|
|
225
233
|
export { backoffDelayMs, DEFAULT_RETRY, nextRetry, retrySchedule } from './retry';
|
|
226
234
|
export type { JobRetryDecision, JobStopReason } from './retry-classification';
|
package/src/purge.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// `purge()` — the framework's retention sweep, declared as a `job` and NOT as a ninth primitive.
|
|
2
|
+
// Deleting expired rows on a schedule is durable background work with an input schema, a retry
|
|
3
|
+
// policy, an idempotency key and a queue, which is the definition of a `job` — so this file is a
|
|
4
|
+
// FACTORY over `job()`, exactly as `backfill()` is one and `llm()` is one over `action()`. That is
|
|
5
|
+
// what gives a retention sweep `.enqueue()`, the worker's cancellation, the dead-letter path,
|
|
6
|
+
// `x jobs show` and a manifest row without a line here.
|
|
7
|
+
//
|
|
8
|
+
// WHY it exists: `postgresIdempotencyStore`, `postgresRateLimitStore` and `postgresAuthLimiter`
|
|
9
|
+
// each shipped a `purgeExpired()` and NOTHING called any of them, so every row those three tables
|
|
10
|
+
// ever took was a row kept. `x_rate_limit` takes one upsert per HTTP request a web role serves,
|
|
11
|
+
// assets included, so its growth is proportional to total traffic rather than to traffic that hit
|
|
12
|
+
// a limit. A `task` could not fix it: a task only ENQUEUES, which is this package's design.
|
|
13
|
+
|
|
14
|
+
import type { Clock } from '@ultimat3/core';
|
|
15
|
+
import { assert, logger } from '@ultimat3/core';
|
|
16
|
+
import { t } from '@ultimat3/schema';
|
|
17
|
+
import type { DurationInput } from './clock';
|
|
18
|
+
import { nowMs } from './clock';
|
|
19
|
+
import type { JobHandle } from './job';
|
|
20
|
+
import { job } from './job';
|
|
21
|
+
import type { RetryPolicy } from './retry';
|
|
22
|
+
import { DEFAULT_RETRY } from './retry';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* One table's worth of expired rows, behind the narrowest possible seam.
|
|
26
|
+
*
|
|
27
|
+
* Structural, exactly like `JobActor` and `PgExecutor`: the three stores this was written for live
|
|
28
|
+
* in `@ultimat3/action` (this tier), `@ultimat3/http` and `@ultimat3/auth` — none of them
|
|
29
|
+
* importable here — and a sweep that needed their types would put the whole HTTP pipeline on this
|
|
30
|
+
* package's import graph. A store satisfies this by having the method it already has.
|
|
31
|
+
*/
|
|
32
|
+
export interface PurgeTarget {
|
|
33
|
+
/**
|
|
34
|
+
* What this sweep is called in its durable step, its log line and its report. A table name is
|
|
35
|
+
* the natural spelling (`x_rate_limit`); a target that clears a SET of tables names their common
|
|
36
|
+
* prefix (`x_auth`, for `x_auth_failures` and `x_auth_lockouts`). Unique within one definition —
|
|
37
|
+
* the name is the step key, and two steps under one name is `X_STEP_DUPLICATE` mid-run.
|
|
38
|
+
*/
|
|
39
|
+
readonly name: string;
|
|
40
|
+
/**
|
|
41
|
+
* Delete every expired row and answer how many went.
|
|
42
|
+
*
|
|
43
|
+
* `nowMs` is the JOB's clock, and a store that writes its instants from the caller MUST measure
|
|
44
|
+
* against it rather than against `now()` on the server. That mismatch is not theoretical: the
|
|
45
|
+
* http store's purge read `extract(epoch from now())` against a `last_ms` written by the caller
|
|
46
|
+
* and, on a frozen test clock, computed a 20,000,000-second refill and deleted a bucket holding
|
|
47
|
+
* 0 of 4 tokens — a free limit reset, handed out by the cleanup. A store that holds its own
|
|
48
|
+
* clock (because its host handed it one) may ignore this argument; a store that holds none
|
|
49
|
+
* may not.
|
|
50
|
+
*/
|
|
51
|
+
purgeExpired(nowMs: number): Promise<number>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What one target's sweep removed. Bounded and JSON-safe, so it survives as a step's output. */
|
|
55
|
+
export interface PurgeSweep {
|
|
56
|
+
readonly name: string;
|
|
57
|
+
readonly removed: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** What one pass reports — bounded, so `x jobs show` can print it. */
|
|
61
|
+
export interface PurgeReport {
|
|
62
|
+
readonly swept: readonly PurgeSweep[];
|
|
63
|
+
readonly removed: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A purge decides nothing, so its payload carries nothing. Deliberately not a `force` flag like
|
|
68
|
+
* `BackfillInput`'s: a backfill is a ONE-PASS sweep whose ledger says it already ran, and this is
|
|
69
|
+
* a recurring one with no ledger and nothing to override.
|
|
70
|
+
*/
|
|
71
|
+
export type PurgeInput = Readonly<Record<string, never>>;
|
|
72
|
+
|
|
73
|
+
export interface PurgeDefinition {
|
|
74
|
+
/**
|
|
75
|
+
* Omit it and `defineApi({ jobs })` assigns the export name. A framework-owned sweep pins one,
|
|
76
|
+
* the way `mail.send` does, because the queue key is what rows already carry.
|
|
77
|
+
*/
|
|
78
|
+
readonly name?: string;
|
|
79
|
+
/**
|
|
80
|
+
* The tables to sweep, read ONCE PER ATTEMPT rather than captured at declaration. Lazy because
|
|
81
|
+
* a host declares the sweep at boot and the stores behind it are not all resolved yet — an
|
|
82
|
+
* app's `defineAuth` runs after the boot that installed the limiter factory, so the auth target
|
|
83
|
+
* does not exist until later. An empty list is a pass that removes nothing, which is the honest
|
|
84
|
+
* answer for a process whose boot has already stopped.
|
|
85
|
+
*/
|
|
86
|
+
targets(): readonly PurgeTarget[];
|
|
87
|
+
/**
|
|
88
|
+
* The clock every target is measured against. Defaults to the system clock, and it must be the
|
|
89
|
+
* SAME clock the stores write their instants from — see `PurgeTarget.purgeExpired`.
|
|
90
|
+
*/
|
|
91
|
+
readonly clock?: Clock;
|
|
92
|
+
readonly queue?: string;
|
|
93
|
+
readonly retry?: RetryPolicy;
|
|
94
|
+
/** Per attempt. A killed attempt resumes at the first table it had not yet checkpointed. */
|
|
95
|
+
readonly timeout?: DurationInput;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The cron a framework-shipped sweep runs on when its host has no opinion. */
|
|
99
|
+
export const DEFAULT_PURGE_CRON = '23 * * * *';
|
|
100
|
+
|
|
101
|
+
export function purge(definition: PurgeDefinition): JobHandle<PurgeInput> {
|
|
102
|
+
const clock = definition.clock;
|
|
103
|
+
|
|
104
|
+
return job<PurgeInput>({
|
|
105
|
+
...(definition.name === undefined ? {} : { name: definition.name }),
|
|
106
|
+
input: t.object({}),
|
|
107
|
+
// One live sweep, forever: a second enqueue while a pass is still running is the same pass,
|
|
108
|
+
// and two deletes racing over one table buy nothing but lock contention. The scheduler's own
|
|
109
|
+
// key is occurrence-scoped on top of this, so the hourly runs are still distinct.
|
|
110
|
+
idempotencyKey: () => 'purge',
|
|
111
|
+
// Framework tables, not an org's rows. Every statement behind a target is raw SQL over the
|
|
112
|
+
// whole table, so there is no tenant-scoped read here to fail closed.
|
|
113
|
+
tenant: 'none',
|
|
114
|
+
retry: definition.retry ?? DEFAULT_RETRY,
|
|
115
|
+
...(definition.queue === undefined ? {} : { queue: definition.queue }),
|
|
116
|
+
...(definition.timeout === undefined ? {} : { timeout: definition.timeout }),
|
|
117
|
+
async run({ step }): Promise<PurgeReport> {
|
|
118
|
+
// ONE reading for every target in the pass. Two readings would let two tables be measured
|
|
119
|
+
// against instants a round trip apart, which is the same class of mismatch as reading the
|
|
120
|
+
// server's clock — smaller, and just as unnecessary.
|
|
121
|
+
const at = nowMs(clock);
|
|
122
|
+
const targets = definition.targets();
|
|
123
|
+
const names = new Set(targets.map((target) => target.name));
|
|
124
|
+
// Refused before the first delete, not discovered at the second step: `step.run` raises
|
|
125
|
+
// `X_STEP_DUPLICATE` on the repeat, which dead-letters a sweep AFTER it has already emptied
|
|
126
|
+
// one table. The list is lazy, so this cannot be checked at declaration.
|
|
127
|
+
assert(
|
|
128
|
+
names.size === targets.length,
|
|
129
|
+
`purge targets repeat a name: ${[...names].sort().join(', ')} across ${targets.length} targets`,
|
|
130
|
+
'give every PurgeTarget its own name — the name is the durable step key, and two steps under one name is X_STEP_DUPLICATE',
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const swept: PurgeSweep[] = [];
|
|
134
|
+
for (const target of targets) {
|
|
135
|
+
// One durable step per table, so a killed attempt resumes at the table it stopped on
|
|
136
|
+
// rather than sweeping the ones already done a second time. At least once either way, and
|
|
137
|
+
// a purge is idempotent by nature: a replayed delete removes the rows that are already
|
|
138
|
+
// gone, which is none, and a row this deletes answers exactly as a row that was never
|
|
139
|
+
// there — no decision anywhere changes.
|
|
140
|
+
const removed = await step.run(target.name, () => target.purgeExpired(at));
|
|
141
|
+
swept.push({ name: target.name, removed });
|
|
142
|
+
}
|
|
143
|
+
const removed = swept.reduce((total, sweep) => total + sweep.removed, 0);
|
|
144
|
+
// Ops reads this to size the cadence: a sweep that removes hundreds of thousands every hour
|
|
145
|
+
// is a table that wants a shorter window, not a longer cron.
|
|
146
|
+
if (removed > 0) logger.info('jobs.purge.swept', { removed, tables: swept.length });
|
|
147
|
+
return { swept, removed };
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
}
|
package/src/scheduler.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
// The `scheduler` role: one node walks every registered task's cron, dispatches the occurrences
|
|
2
2
|
// it owes and enqueues their jobs. The `task` primitive it reads lives in `task.ts`.
|
|
3
3
|
//
|
|
4
|
-
// Exactly one node dispatches per tick, enforced by
|
|
4
|
+
// Exactly one node dispatches per tick, enforced by leader election. Multi-node that is an
|
|
5
|
+
// EXPIRING LEASE ROW in `x_scheduler_leader` (`createPgLeaseLeader`), never an advisory lock: an
|
|
6
|
+
// advisory lock is held by the SESSION, not by this process — it outlives every transaction and is
|
|
7
|
+
// released only by an explicit unlock, the pool's reset on release, or the connection dying, and
|
|
8
|
+
// the next round may run on a different connection. So a node can neither renew it nor prove it
|
|
9
|
+
// still holds one, and leadership passes to a second node while the first is still dispatching. Two schedulers
|
|
5
10
|
// double-enqueue every task; the idempotency key would absorb it, but leader election means
|
|
6
11
|
// the queue never sees the duplicate at all. One ROUND at a time is the same rule inside one
|
|
7
12
|
// process: the loop re-arms on the round it just finished, and any other caller joins that
|
|
@@ -40,7 +45,7 @@ export interface LeaderElection {
|
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
/** Single-node default: always the leader. Multi-node uses `createPgLeaseLeader()` — never
|
|
43
|
-
* `createPgLeader()`, whose advisory lock
|
|
48
|
+
* `createPgLeader()`, whose advisory lock is owned by a pooled session this process cannot name. */
|
|
44
49
|
export function soleLeader(): LeaderElection {
|
|
45
50
|
return {
|
|
46
51
|
acquire: () => Promise.resolve(true),
|
|
@@ -276,10 +281,10 @@ export function createScheduler(options: SchedulerOptions): Scheduler {
|
|
|
276
281
|
timer = undefined;
|
|
277
282
|
logger.info('jobs.scheduler.draining', { reason, dispatching: round !== undefined });
|
|
278
283
|
try {
|
|
279
|
-
// The round this stop races runs to the end first. Releasing the
|
|
284
|
+
// The round this stop races runs to the end first. Releasing the lease under a
|
|
280
285
|
// live dispatch hands the next node a task this one is still enqueueing for, and both
|
|
281
286
|
// then own the same occurrence — the exact double-fire leader election exists to prevent.
|
|
282
|
-
// Settled, not awaited: a round that failed is its own caller's to see, and the
|
|
287
|
+
// Settled, not awaited: a round that failed is its own caller's to see, and the lease still
|
|
283
288
|
// has to go back.
|
|
284
289
|
await Promise.allSettled([round]);
|
|
285
290
|
if (isLeader) await leader.release();
|
package/src/worker-run.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { JobExecution } from './execute';
|
|
|
11
11
|
import { executeJob } from './execute';
|
|
12
12
|
import { startLeaseHeartbeat } from './heartbeat';
|
|
13
13
|
import { getJob } from './job';
|
|
14
|
+
import type { RunSignal } from './run-signal';
|
|
14
15
|
import { createRunSignal } from './run-signal';
|
|
15
16
|
import type { EventLookup } from './steps';
|
|
16
17
|
import type { FleetSlots } from './worker-fleet-slots';
|
|
@@ -77,33 +78,42 @@ export async function runClaimedJob(options: RunClaimedOptions): Promise<JobExec
|
|
|
77
78
|
...(options.clock === undefined ? {} : { clock: options.clock }),
|
|
78
79
|
});
|
|
79
80
|
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
81
|
+
// Everything from here on is INSIDE the `finally`, because the heartbeat is now running.
|
|
82
|
+
// `createRunSignal` and `fleetSlots.startRenewal` are ordinary calls — the second is an injected
|
|
83
|
+
// seam whose production implementation reaches a lease store — and a throw from either used to
|
|
84
|
+
// land outside the `try`, leaving an interval renewing the lease of a job that never ran with
|
|
85
|
+
// nothing holding a reference to stop it. The same defect `context()` was moved ABOVE the
|
|
86
|
+
// heartbeat for; the acquisitions after it need the `finally` rather than a reordering, since
|
|
87
|
+
// each one depends on the last. `undefined` is "never taken", and both handbacks are idempotent.
|
|
88
|
+
let runSignal: RunSignal | undefined;
|
|
89
|
+
let stopSlotRenewal: (() => void) | undefined;
|
|
89
90
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
);
|
|
99
|
-
|
|
91
|
+
try {
|
|
92
|
+
// The caller's context plus this lease's cancellation, so a job cancelled from outside — or
|
|
93
|
+
// one this worker lost the lease on — stops at the next renewal. `steps.ts` refuses every write
|
|
94
|
+
// past the signal, which is what unwinds a body that never reads it. Composed through a
|
|
95
|
+
// controller this worker owns rather than `AbortSignal.any`, for two reasons: it is handed BACK
|
|
96
|
+
// when the run settles (an app whose `context()` carries a process-lifetime signal was
|
|
97
|
+
// accumulating one composite per job), and the worker can abort it itself — which is the only
|
|
98
|
+
// way a fleet slot taken by somebody else reaches the body running under it.
|
|
99
|
+
runSignal = createRunSignal([base.signal, heartbeat.signal]);
|
|
100
|
+
const signal = runSignal;
|
|
101
|
+
const ctx: Ctx = { ...base, signal: signal.signal };
|
|
100
102
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
// The fleet slot this job already holds, kept alive for as long as the lease is and stopped in
|
|
104
|
+
// the same `finally`: one clock for "this worker still owns the job" and "this worker still
|
|
105
|
+
// owns the slot" is one fewer way for them to disagree. A renewal answering "not yours" means
|
|
106
|
+
// another worker is already running this job under a cap of one, so it CANCELS — discarding
|
|
107
|
+
// that boolean made `job.concurrency` a number the framework prints and does not hold.
|
|
108
|
+
stopSlotRenewal = fleetSlots.startRenewal(claimed.id, (slot) => {
|
|
109
|
+
signal.abort(new JobSlotLostError({ job: claimed.name, jobId: claimed.id, slot: slot.slot }));
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// The job's span is a CHILD of the request that queued it when the row carries a trace. That
|
|
113
|
+
// link is what `04-jobs.md` promised and no column existed to hold: without it a checkout's
|
|
114
|
+
// `chargeCard` opens a fresh root two seconds later with nothing pointing back.
|
|
115
|
+
const parent = parseTraceparent(claimed.traceparent);
|
|
105
116
|
|
|
106
|
-
try {
|
|
107
117
|
return await withSpan(
|
|
108
118
|
`job.${handle.name}`,
|
|
109
119
|
() =>
|
|
@@ -126,10 +136,10 @@ export async function runClaimedJob(options: RunClaimedOptions): Promise<JobExec
|
|
|
126
136
|
},
|
|
127
137
|
);
|
|
128
138
|
} finally {
|
|
129
|
-
stopSlotRenewal();
|
|
139
|
+
stopSlotRenewal?.();
|
|
130
140
|
heartbeat.stop();
|
|
131
141
|
// Nothing of the caller's is held past the run: `dispose` is what makes the composition above
|
|
132
142
|
// reversible, and it is the whole reason this is not `AbortSignal.any`.
|
|
133
|
-
runSignal
|
|
143
|
+
runSignal?.dispose();
|
|
134
144
|
}
|
|
135
145
|
}
|