@ultimat3/jobs 27.10.3 → 27.11.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/README.md CHANGED
@@ -416,6 +416,19 @@ The stored event bus is a target too: `eventsPurgeTarget(bus)` answers
416
416
  answers the rows it removed — `x_job_events` only grew while nothing called it. Add it to
417
417
  `targets`: `targets: () => [eventsPurgeTarget(eventBus())]`.
418
418
 
419
+ Finished jobs are one too: `jobsPurgeTarget({ executor, doneRetentionMs, deadRetentionMs })`
420
+ deletes `done` rows past the first window and `dead`, `failed` and `cancelled` rows past the
421
+ second, each with its `x_job_steps` once no row of the run still stands. A pass runs at most
422
+ `maxBatches` (50) batches of `batchSize` (1,000) rows, `skip locked`; a batch is one statement, plus a
423
+ follow-up when a row it could not see deleted still shared a run (two sweeps that split a run settle
424
+ its steps after both commit). After the batches, one more statement removes up to `batchSize` step
425
+ records whose run has no row left, what a sweep that died before its follow-up stranded. A live row is never deleted, nor one an
426
+ unpublished `x_outbox` row or a `running` `x_backfills` row still names. The windows come from
427
+ `app.config.ts` (`jobs.doneRetentionMs`, 14 days, and `jobs.deadRetentionMs`, 90 days).
428
+ `outboxPurgeTarget({ executor, publishedRetentionMs })` is the same for `x_outbox`: published
429
+ rows only, measured against the job's clock (the relay stamps `published_at` from its own), at
430
+ `jobs.outboxRetentionMs` (7 days). An unpublished row is a job not yet handed over, never deleted.
431
+
419
432
  `@ultimat3/cli`'s boot declares both halves over the three tables it owns, so an app gets the sweep
420
433
  without writing any of the above. It needs a `worker` to run it and a `scheduler` to fire it: a
421
434
  deployment with neither has no background work at all, and this is one more thing it does not do.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/jobs",
3
- "version": "27.10.3",
3
+ "version": "27.11.0",
4
4
  "description": "Durable background work: steps, transactional outbox, cron tasks, one driver interface",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,10 +33,10 @@
33
33
  "test": "bun test"
34
34
  },
35
35
  "dependencies": {
36
- "@ultimat3/core": "27.10.3",
37
- "@ultimat3/db": "27.10.3",
38
- "@ultimat3/entity": "27.10.3",
39
- "@ultimat3/schema": "27.10.3",
40
- "@ultimat3/time": "27.10.3"
36
+ "@ultimat3/core": "27.11.0",
37
+ "@ultimat3/db": "27.11.0",
38
+ "@ultimat3/entity": "27.11.0",
39
+ "@ultimat3/schema": "27.11.0",
40
+ "@ultimat3/time": "27.11.0"
41
41
  }
42
42
  }
@@ -123,6 +123,10 @@ create table if not exists x_outbox (
123
123
  create index if not exists x_outbox_unpublished_idx
124
124
  on x_outbox (staged_at) where published_at is null;
125
125
 
126
+ -- What the retention sweep reads, the other half of the table: published rows, oldest first.
127
+ create index if not exists x_outbox_published_idx
128
+ on x_outbox (published_at) where published_at is not null;
129
+
126
130
  -- The relays claim lease. A claim stamped in the same statement that locks the row is what stops
127
131
  -- two relays publishing one row twice: for update skip locked holds its locks only until that
128
132
  -- statement ends, which under autocommit is before claim returns. claimed_at is also what gives
@@ -207,6 +211,18 @@ alter table x_jobs add column if not exists claims int not null default 0;
207
211
  -- range and never a sort of the whole table.
208
212
  create index if not exists x_jobs_created_idx on x_jobs (created_at desc, id desc);
209
213
 
214
+ -- What the retention sweep reads. Partial over the four terminal states, so a live row never pays
215
+ -- for it and the oldest finished rows are an index range rather than a scan of the whole queue.
216
+ create index if not exists x_jobs_finished_idx
217
+ on x_jobs (updated_at)
218
+ where state in ('done', 'failed', 'dead', 'cancelled');
219
+
220
+ -- The rows of one run, by run id: the retention sweep asks, per deleted row, whether another row of
221
+ -- the run still stands before it deletes the step records of that run, and its follow-up and its
222
+ -- orphan pass ask again. Without it each question is a scan of the whole queue. Not partial: run_id
223
+ -- is not null, so a "where run_id is not null" predicate would index the same rows for nothing.
224
+ create index if not exists x_jobs_run_idx on x_jobs (run_id);
225
+
210
226
  -- A paused queue or task, one row each. A queue pause is a fact about the QUEUE and not a column
211
227
  -- on every one of its rows: the claim reads this table with not exists, and a pause that had to
212
228
  -- rewrite a million rows would not take effect within one poll interval.
package/src/index.ts CHANGED
@@ -235,6 +235,8 @@ export type {
235
235
  JobRunArgs,
236
236
  } from './job';
237
237
  export { describeJobs, getJob, isJobHandle, job, registeredJobs, resetJobs } from './job';
238
+ export type { JobsRetentionOptions } from './jobs-retention';
239
+ export { jobsPurgeTarget } from './jobs-retention';
238
240
  export type { HeldLease, LeaseStore, MemoryLeaseStore, MemoryLeaseStoreOptions } from './leases';
239
241
  export { jobLeaseKey, memoryLeaseStore } from './leases';
240
242
  export type {
@@ -277,6 +279,8 @@ export type { PostgresOutboxStoreOptions } from './outbox-pg';
277
279
  export { postgresOutboxStore } from './outbox-pg';
278
280
  export type { OutboxRelay, RelayOptions } from './outbox-relay';
279
281
  export { outboxRelay } from './outbox-relay';
282
+ export type { OutboxRetentionOptions } from './outbox-retention';
283
+ export { outboxPurgeTarget } from './outbox-retention';
280
284
  export type { ProgressFn } from './progress';
281
285
  export type {
282
286
  PurgeDefinition,
@@ -286,6 +290,7 @@ export type {
286
290
  PurgeTarget,
287
291
  } from './purge';
288
292
  export { DEFAULT_PURGE_CRON, purge } from './purge';
293
+ export type { PurgeBatching } from './purge-batches';
289
294
  export type { PgListener, QueueWake, QueueWakeOptions } from './queue-wake';
290
295
  export { startQueueWake } from './queue-wake';
291
296
  export type { IntervalScheduler } from './renewal-timer';
@@ -0,0 +1,159 @@
1
+ // Retention for FINISHED jobs: the `x_jobs` rows whose work is over, and the `x_job_steps` records
2
+ // under their runs, as one `PurgeTarget` the boot's hourly `purge()` sweeps. Until this existed
3
+ // nothing deleted a finished row, so the table grew by every job ever run — and a `mail.send` row's
4
+ // input is a recipient address and a rendered body, personal data kept for the life of the database.
5
+
6
+ import type { PgExecutor } from '@ultimat3/core';
7
+ import { finiteCount } from '@ultimat3/core';
8
+ import type { PurgeTarget } from './purge';
9
+ import {
10
+ type PurgeBatch,
11
+ type PurgeBatching,
12
+ purgeBatching,
13
+ purgeInBatches,
14
+ } from './purge-batches';
15
+
16
+ /**
17
+ * One batch, counted in the statement (`PgExecutor` answers rows, never a command tag).
18
+ *
19
+ * Aged by `updated_at`, which every settle stamps with the server's `now()` — so the cutoff is the
20
+ * server's clock too, and the job's `nowMs` is not read: measuring a server-written instant against
21
+ * a caller's clock is the two-clock mismatch `PurgeTarget.purgeExpired` warns about.
22
+ *
23
+ * Only the four TERMINAL states, never a live one (`LIVE_STATES`). Two vetoes on top, each a row
24
+ * that still names the job as unfinished work: an `x_outbox` row not yet published under this id
25
+ * (deleting the job would let the relay insert it again, a second run) and an `x_backfills` ledger
26
+ * row still `running` under this run. `skip locked` so a sweep never waits on a worker or an
27
+ * operator holding a row.
28
+ *
29
+ * A run's step records go in the same statement when no row outside this batch shares the run.
30
+ * When one does (`held`), they stay and the run is answered: that row may be another sweep's,
31
+ * deleted but not yet committed — invisible as gone to this snapshot, just as this batch's rows are
32
+ * to that one — so each statement alone would keep the steps forever. `SQL_JOBS_RETENTION_STEPS`
33
+ * settles a held run after this commits.
34
+ *
35
+ * `held` is a LATERAL probe with a `limit`, not an `exists`: the planner flattens an `exists` into
36
+ * a semi-join and, at a 1,000-row batch, hashes the whole of `x_jobs` for it; the lateral form is one
37
+ * `x_jobs_run_idx` lookup per deleted run (EXPLAIN, 63,000 rows, 2026-10-17).
38
+ */
39
+ export const SQL_JOBS_RETENTION_BATCH = `
40
+ with doomed as (
41
+ select j.id, j.run_id from x_jobs j
42
+ where ((j.state = 'done'
43
+ and j.updated_at < statement_timestamp() - ($1::bigint * interval '1 millisecond'))
44
+ or (j.state in ('failed', 'dead', 'cancelled')
45
+ and j.updated_at < statement_timestamp() - ($2::bigint * interval '1 millisecond')))
46
+ and not exists (select 1 from x_outbox o where o.id = j.id and o.published_at is null)
47
+ and not exists (select 1 from x_backfills b where b.run_id = j.run_id and b.status = 'running')
48
+ order by j.updated_at
49
+ limit $3
50
+ for update of j skip locked
51
+ ), gone as (
52
+ delete from x_jobs where id in (select id from doomed) returning run_id
53
+ ), held as (
54
+ select g.run_id from (select distinct run_id from gone) g
55
+ cross join lateral (
56
+ select 1 from x_jobs l
57
+ where l.run_id = g.run_id and l.id not in (select id from doomed)
58
+ limit 1
59
+ ) standing
60
+ ), steps as (
61
+ delete from x_job_steps s
62
+ where s.run_id in (select run_id from gone)
63
+ and s.run_id not in (select run_id from held)
64
+ )
65
+ select (select count(*) from gone)::int as removed,
66
+ (select coalesce(json_agg(run_id), '[]'::json) from held)::text as held
67
+ `.trim();
68
+
69
+ /**
70
+ * The held runs' step records, in a statement of its own — so its snapshot is taken AFTER the batch
71
+ * that answered them committed. Of two sweeps that each deleted one row of a shared run, the one
72
+ * whose follow-up starts last sees both deletes, and removes the steps. A run with a row still
73
+ * standing (a retry, a younger terminal row) keeps them, for the sweep that takes that row.
74
+ * The ids travel as JSON text, never a bound array (issue #384).
75
+ */
76
+ export const SQL_JOBS_RETENTION_STEPS = `
77
+ delete from x_job_steps s
78
+ where s.run_id in (select value::uuid from json_array_elements_text($1::json))
79
+ and not exists (select 1 from x_jobs j where j.run_id = s.run_id)
80
+ `.trim();
81
+
82
+ /**
83
+ * Step records whose run has NO `x_jobs` row left — what the follow-up above would have removed had
84
+ * its process lived to send it. A sweep that commits a batch and dies before the follow-up strands
85
+ * a held run's steps, and no later batch names that run again: this is the pass that does. Once
86
+ * per pass, after the batches, at most `$2` records.
87
+ *
88
+ * Only records started before the shorter window (`$1`): a job is deleted no sooner than that after
89
+ * its last settle, and every step of it started before that settle, so a true orphan always
90
+ * qualifies — and a record younger than the window belongs to a run whose row cannot have been
91
+ * swept yet. Each candidate is one `x_jobs_run_idx` probe, a LATERAL with a `limit` for the reason
92
+ * `held` is one: as `not exists` the planner hashes the whole of `x_jobs` for a few thousand
93
+ * candidates (EXPLAIN, 2026-10-17), a cost that grows with the queue rather than with the old step
94
+ * records. The delete is the `x_job_steps` primary key. `skip locked` for the reason the batch gives.
95
+ */
96
+ export const SQL_JOBS_RETENTION_ORPHANS = `
97
+ with orphaned as (
98
+ select s.run_id, s.name from x_job_steps s
99
+ left join lateral (
100
+ select 1 as standing from x_jobs j where j.run_id = s.run_id limit 1
101
+ ) l on true
102
+ where s.started_at < statement_timestamp() - ($1::bigint * interval '1 millisecond')
103
+ and l.standing is null
104
+ limit $2
105
+ for update of s skip locked
106
+ ), gone as (
107
+ delete from x_job_steps s using orphaned o
108
+ where s.run_id = o.run_id and s.name = o.name
109
+ returning 1
110
+ )
111
+ select count(*)::int as removed from gone
112
+ `.trim();
113
+
114
+ /**
115
+ * The two windows are REQUIRED here: their defaults are `app.config.ts`'s (`jobs.doneRetentionMs`,
116
+ * `jobs.deadRetentionMs`, in `@ultimat3/core`), and a second default in this file would be a second
117
+ * answer to "how long is a finished job kept".
118
+ */
119
+ export interface JobsRetentionOptions extends PurgeBatching {
120
+ readonly executor: PgExecutor;
121
+ /** Age past which a `done` row goes. */
122
+ readonly doneRetentionMs: number;
123
+ /** Age past which a `dead`, `failed` or `cancelled` row goes. */
124
+ readonly deadRetentionMs: number;
125
+ }
126
+
127
+ const SUBJECT = 'jobsPurgeTarget';
128
+
129
+ /** `x_jobs` as a purge target, batched (`purge-batches.ts`). */
130
+ export function jobsPurgeTarget(options: JobsRetentionOptions): PurgeTarget {
131
+ const done = finiteCount(SUBJECT, 'doneRetentionMs', options.doneRetentionMs, 1);
132
+ const dead = finiteCount(SUBJECT, 'deadRetentionMs', options.deadRetentionMs, 1);
133
+ const bounds = purgeBatching(SUBJECT, options);
134
+ return {
135
+ name: 'x_jobs',
136
+ // The count is `x_jobs` rows, the target's name; orphaned step records are its upkeep.
137
+ purgeExpired: async (): Promise<number> => {
138
+ const removed = await purgeInBatches(jobsBatch(options.executor, done, dead), bounds);
139
+ await options.executor.query(SQL_JOBS_RETENTION_ORPHANS, [
140
+ Math.min(done, dead),
141
+ bounds.batch,
142
+ ]);
143
+ return removed;
144
+ },
145
+ };
146
+ }
147
+
148
+ /** One batch of `SQL_JOBS_RETENTION_BATCH`, then the held runs' steps if it answered any. */
149
+ function jobsBatch(executor: PgExecutor, done: number, dead: number): PurgeBatch {
150
+ return async (size) => {
151
+ const rows = await executor.query<{ removed: number | string; held: string | null }>(
152
+ SQL_JOBS_RETENTION_BATCH,
153
+ [done, dead, size],
154
+ );
155
+ const held = rows[0]?.held ?? '[]';
156
+ if (held !== '[]') await executor.query(SQL_JOBS_RETENTION_STEPS, [held]);
157
+ return Number(rows[0]?.removed ?? 0);
158
+ };
159
+ }
@@ -0,0 +1,57 @@
1
+ // Retention for PUBLISHED `x_outbox` rows, as one `PurgeTarget` the boot's hourly `purge()` sweeps.
2
+ // A published row's work is over — the job it staged is in `x_jobs` — and nothing deleted it, so
3
+ // the table kept every enqueue a transaction ever staged, `mail.send`'s address and body included.
4
+
5
+ import type { PgExecutor } from '@ultimat3/core';
6
+ import { finiteCount } from '@ultimat3/core';
7
+ import type { PurgeTarget } from './purge';
8
+ import { countedBatch, type PurgeBatching, purgeBatching, purgeInBatches } from './purge-batches';
9
+
10
+ /**
11
+ * One batch, counted. Only `published_at is not null`: an unpublished row is a job the relay has
12
+ * not handed over yet, and deleting it is losing that job. The `x_jobs` sweep vetoes a job whose
13
+ * outbox row is still unpublished; this one never touches such a row, so the two cannot disagree.
14
+ *
15
+ * The cutoff is the JOB's clock (`$1`): `SQL_OUTBOX_MARK_PUBLISHED` stamps `published_at` from the
16
+ * relay's clock, so measuring it against the server's `now()` is the two-clock mismatch
17
+ * `PurgeTarget.purgeExpired` warns about.
18
+ */
19
+ export const SQL_OUTBOX_RETENTION_BATCH = `
20
+ with doomed as (
21
+ select id from x_outbox
22
+ where published_at is not null
23
+ and published_at < to_timestamp(($1::bigint - $2::bigint) / 1000.0)
24
+ order by published_at
25
+ limit $3
26
+ for update skip locked
27
+ ), gone as (
28
+ delete from x_outbox where id in (select id from doomed) returning 1
29
+ )
30
+ select count(*)::int as removed from gone
31
+ `.trim();
32
+
33
+ /**
34
+ * The window is REQUIRED: its default is `app.config.ts`'s (`jobs.outboxRetentionMs`, in
35
+ * `@ultimat3/core`), for the reason `JobsRetentionOptions` gives.
36
+ */
37
+ export interface OutboxRetentionOptions extends PurgeBatching {
38
+ readonly executor: PgExecutor;
39
+ /** Age of `published_at` past which a published row goes. */
40
+ readonly publishedRetentionMs: number;
41
+ }
42
+
43
+ const SUBJECT = 'outboxPurgeTarget';
44
+
45
+ /** `x_outbox` as a purge target, batched (`purge-batches.ts`). */
46
+ export function outboxPurgeTarget(options: OutboxRetentionOptions): PurgeTarget {
47
+ const window = finiteCount(SUBJECT, 'publishedRetentionMs', options.publishedRetentionMs, 1);
48
+ const bounds = purgeBatching(SUBJECT, options);
49
+ return {
50
+ name: 'x_outbox',
51
+ purgeExpired: (nowMs: number): Promise<number> =>
52
+ purgeInBatches(
53
+ countedBatch(options.executor, SQL_OUTBOX_RETENTION_BATCH, [Math.trunc(nowMs), window]),
54
+ bounds,
55
+ ),
56
+ };
57
+ }
@@ -0,0 +1,68 @@
1
+ // The batch loop every row-count retention target shares: one counted DELETE per `batchSize` rows,
2
+ // repeated until a batch comes back short or `maxBatches` ran. A backlog of months is drained over
3
+ // several passes rather than in one statement that locks every row it will delete.
4
+
5
+ import type { PgExecutor } from '@ultimat3/core';
6
+ import { finiteCount } from '@ultimat3/core';
7
+
8
+ /** Rows per DELETE. Small enough that one statement holds its row locks for milliseconds. */
9
+ export const DEFAULT_RETENTION_BATCH = 1_000;
10
+ /**
11
+ * Batches per pass — a cap on BATCHES, not statements: a target may send more than one statement
12
+ * per batch (`jobsPurgeTarget`'s follow-up). An hourly pass clears 50,000 rows, and a backlog
13
+ * drains over hours.
14
+ */
15
+ export const DEFAULT_RETENTION_MAX_BATCHES = 50;
16
+
17
+ export interface PurgeBatching {
18
+ readonly batchSize?: number;
19
+ readonly maxBatches?: number;
20
+ }
21
+
22
+ /** The two bounds, screened once at construction under the factory's name. */
23
+ export function purgeBatching(
24
+ subject: string,
25
+ options: PurgeBatching,
26
+ ): { readonly batch: number; readonly rounds: number } {
27
+ return {
28
+ batch: finiteCount(subject, 'batchSize', options.batchSize ?? DEFAULT_RETENTION_BATCH, 1),
29
+ rounds: finiteCount(
30
+ subject,
31
+ 'maxBatches',
32
+ options.maxBatches ?? DEFAULT_RETENTION_MAX_BATCHES,
33
+ 1,
34
+ ),
35
+ };
36
+ }
37
+
38
+ /** One batch: up to `size` rows removed, answering how many were. */
39
+ export type PurgeBatch = (size: number) => Promise<number>;
40
+
41
+ /**
42
+ * The batch most targets are: one statement answering one row `{ removed }`, taking `params` with
43
+ * the batch size appended last.
44
+ */
45
+ export function countedBatch(
46
+ executor: PgExecutor,
47
+ sql: string,
48
+ params: readonly unknown[],
49
+ ): PurgeBatch {
50
+ return async (size) => {
51
+ const rows = await executor.query<{ removed: number | string }>(sql, [...params, size]);
52
+ return Number(rows[0]?.removed ?? 0);
53
+ };
54
+ }
55
+
56
+ /** Run `batch` until it removes fewer than `bounds.batch` rows or `rounds` ran; answer the total. */
57
+ export async function purgeInBatches(
58
+ batch: PurgeBatch,
59
+ bounds: { readonly batch: number; readonly rounds: number },
60
+ ): Promise<number> {
61
+ let removed = 0;
62
+ for (let round = 0; round < bounds.rounds; round += 1) {
63
+ const count = await batch(bounds.batch);
64
+ removed += count;
65
+ if (count < bounds.batch) break;
66
+ }
67
+ return removed;
68
+ }