@ultimat3/jobs 8.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 +24 -0
- package/README.md +39 -0
- package/package.json +5 -5
- package/src/index.ts +8 -0
- package/src/purge.ts +150 -0
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,
|
|
@@ -687,6 +710,7 @@ picture from the other side.
|
|
|
687
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` |
|
|
688
711
|
| `run-signal.ts` | the signal ONE run is cancelled by — composition that can be handed back, and that the worker can abort itself |
|
|
689
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 |
|
|
690
714
|
| `task.ts` | the `task()` primitive + registry + the handle's surface + `registerTask` |
|
|
691
715
|
| `scheduler.ts` | `scheduler` role: the dispatch round, catch-up, leader election, the drain |
|
|
692
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
|
}
|
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
|
+
}
|