@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 +13 -0
- package/package.json +6 -6
- package/src/driver-pg-ddl.ts +16 -0
- package/src/index.ts +5 -0
- package/src/jobs-retention.ts +159 -0
- package/src/outbox-retention.ts +57 -0
- package/src/purge-batches.ts +68 -0
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.
|
|
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.
|
|
37
|
-
"@ultimat3/db": "27.
|
|
38
|
-
"@ultimat3/entity": "27.
|
|
39
|
-
"@ultimat3/schema": "27.
|
|
40
|
-
"@ultimat3/time": "27.
|
|
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
|
}
|
package/src/driver-pg-ddl.ts
CHANGED
|
@@ -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
|
+
}
|