@purposeinplay/payload-version-retention 0.1.1
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/LICENSE +21 -0
- package/README.md +553 -0
- package/dist/defaults.d.ts +93 -0
- package/dist/defaults.js +74 -0
- package/dist/exports/index.d.ts +2 -0
- package/dist/exports/index.js +1 -0
- package/dist/hooks/dedup-versions.d.ts +28 -0
- package/dist/hooks/dedup-versions.js +123 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/lib/dedup-cursor.d.ts +34 -0
- package/dist/lib/dedup-cursor.js +94 -0
- package/dist/lib/entities.d.ts +43 -0
- package/dist/lib/entities.js +98 -0
- package/dist/lib/retention-plan.d.ts +72 -0
- package/dist/lib/retention-plan.js +158 -0
- package/dist/lib/run-lock.d.ts +25 -0
- package/dist/lib/run-lock.js +105 -0
- package/dist/lib/snapshot-compare.d.ts +37 -0
- package/dist/lib/snapshot-compare.js +86 -0
- package/dist/lib/startup-checks.d.ts +55 -0
- package/dist/lib/startup-checks.js +225 -0
- package/dist/lib/state.d.ts +27 -0
- package/dist/lib/state.js +87 -0
- package/dist/lib/sweep.d.ts +72 -0
- package/dist/lib/sweep.js +799 -0
- package/dist/lib/version-row.d.ts +19 -0
- package/dist/lib/version-row.js +77 -0
- package/dist/plugin.d.ts +3 -0
- package/dist/plugin.js +91 -0
- package/dist/tasks/janitor.d.ts +8 -0
- package/dist/tasks/janitor.js +116 -0
- package/dist/types.d.ts +239 -0
- package/dist/types.js +4 -0
- package/package.json +56 -0
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
import { DELETE_CHUNK_SIZE, FULL_BODY_PAGE_SIZE, LOG_PREFIX, MAX_VERSIONS_PER_DOCUMENT, PARENT_PAGE_SIZE } from '../defaults.js';
|
|
2
|
+
import { chunkIds, collectProtectedIds, planVersionDeletions, retentionCutoff, sameStatus, toCandidate } from './retention-plan.js';
|
|
3
|
+
import { versionBodiesMatch } from './snapshot-compare.js';
|
|
4
|
+
import { toParentIds, toVersionRows } from './version-row.js';
|
|
5
|
+
/** `greater_than` semantics for a parent id, which may be numeric or a uuid. */ function isAfter(candidate, cursor) {
|
|
6
|
+
if (typeof candidate === 'number' && typeof cursor === 'number') {
|
|
7
|
+
return candidate > cursor;
|
|
8
|
+
}
|
|
9
|
+
return String(candidate) > String(cursor);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Columns the retention decision reads. Anything else on a version row is a
|
|
13
|
+
* full document body — the reason these tables are 54% of the database — and
|
|
14
|
+
* pulling it into the janitor would defeat the point.
|
|
15
|
+
*
|
|
16
|
+
* A drafts-less entity has no `_status`, so `version` is left out entirely
|
|
17
|
+
* rather than asking the adapter for a field that does not exist.
|
|
18
|
+
*/ export function narrowVersionSelect(hasDrafts) {
|
|
19
|
+
const base = {
|
|
20
|
+
id: true,
|
|
21
|
+
latest: true,
|
|
22
|
+
updatedAt: true
|
|
23
|
+
};
|
|
24
|
+
// `snapshot` only exists on drafts-enabled entities of a localized config;
|
|
25
|
+
// Payload's select traversal drives from the field list, so asking for it
|
|
26
|
+
// elsewhere is ignored rather than an error.
|
|
27
|
+
return hasDrafts ? {
|
|
28
|
+
...base,
|
|
29
|
+
snapshot: true,
|
|
30
|
+
version: {
|
|
31
|
+
_status: true
|
|
32
|
+
}
|
|
33
|
+
} : base;
|
|
34
|
+
}
|
|
35
|
+
function staleWhere(cutoff, cursor) {
|
|
36
|
+
const stale = {
|
|
37
|
+
updatedAt: {
|
|
38
|
+
less_than: cutoff.toISOString()
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
if (cursor === undefined) {
|
|
42
|
+
return stale;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
and: [
|
|
46
|
+
stale,
|
|
47
|
+
{
|
|
48
|
+
parent: {
|
|
49
|
+
greater_than: cursor
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* True when the adapter actually honoured the nested `version._status` select.
|
|
57
|
+
* Every adapter shipped with Payload does; a third-party one that quietly
|
|
58
|
+
* drops nested selects would otherwise make every row look status-less and
|
|
59
|
+
* strip the last-publish protection, so the caller re-reads without `select`.
|
|
60
|
+
*/ function nestedSelectHonoured(rows) {
|
|
61
|
+
return rows.length === 0 || rows.some((row)=>row.version !== undefined);
|
|
62
|
+
}
|
|
63
|
+
/** Drops everything but `_status` from a row's body. */ function withoutBody(row) {
|
|
64
|
+
const status = row.version?._status;
|
|
65
|
+
return {
|
|
66
|
+
...row,
|
|
67
|
+
version: status === undefined ? {} : {
|
|
68
|
+
_status: status
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Newest first, **with `id` as the tiebreak**.
|
|
74
|
+
*
|
|
75
|
+
* `updatedAt` alone is not a total order: a burst of saves inside the same
|
|
76
|
+
* millisecond, or a backfill, gives rows identical timestamps. Postgres is
|
|
77
|
+
* then free to return them in any order per page, and an OFFSET page can hand
|
|
78
|
+
* back a row the previous page already returned — which, in the dedup walk,
|
|
79
|
+
* meant comparing a row against itself, matching trivially, and deleting a
|
|
80
|
+
* unique version. The tiebreak makes every page deterministic and disjoint.
|
|
81
|
+
*/ const NEWEST_FIRST = [
|
|
82
|
+
'-updatedAt',
|
|
83
|
+
'-id'
|
|
84
|
+
];
|
|
85
|
+
async function findVersionPage({ entity, isGlobal, parentId, payload }, args) {
|
|
86
|
+
const shared = {
|
|
87
|
+
limit: args.limit,
|
|
88
|
+
// Skips the COUNT(*) that would otherwise run alongside every page.
|
|
89
|
+
pagination: false,
|
|
90
|
+
sort: [
|
|
91
|
+
...NEWEST_FIRST
|
|
92
|
+
],
|
|
93
|
+
...args.page ? {
|
|
94
|
+
page: args.page
|
|
95
|
+
} : {},
|
|
96
|
+
...args.select ? {
|
|
97
|
+
select: args.select
|
|
98
|
+
} : {}
|
|
99
|
+
};
|
|
100
|
+
const { docs } = isGlobal ? await payload.db.findGlobalVersions({
|
|
101
|
+
...shared,
|
|
102
|
+
global: entity.slug
|
|
103
|
+
}) : await payload.db.findVersions({
|
|
104
|
+
...shared,
|
|
105
|
+
collection: entity.slug,
|
|
106
|
+
where: {
|
|
107
|
+
parent: {
|
|
108
|
+
equals: parentId
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
return toVersionRows(docs);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Fallback for an adapter that drops the nested `version._status` select:
|
|
116
|
+
* read full rows, but a page at a time, reducing each page to the retention
|
|
117
|
+
* columns before fetching the next. Peak memory is `FULL_BODY_PAGE_SIZE`
|
|
118
|
+
* bodies rather than `MAX_VERSIONS_PER_DOCUMENT` of them.
|
|
119
|
+
*/ async function readNarrowedViaFullBodies(args) {
|
|
120
|
+
const rows = [];
|
|
121
|
+
for(let page = 1; rows.length < MAX_VERSIONS_PER_DOCUMENT; page++){
|
|
122
|
+
const chunk = await findVersionPage(args, {
|
|
123
|
+
limit: FULL_BODY_PAGE_SIZE,
|
|
124
|
+
page
|
|
125
|
+
});
|
|
126
|
+
for (const row of chunk){
|
|
127
|
+
rows.push(withoutBody(row));
|
|
128
|
+
}
|
|
129
|
+
if (chunk.length < FULL_BODY_PAGE_SIZE) {
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return rows;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The newest `MAX_VERSIONS_PER_DOCUMENT` rows for one document (or one
|
|
137
|
+
* global), narrowed to the retention columns.
|
|
138
|
+
*/ async function readDocumentVersions(args, useNarrowSelect) {
|
|
139
|
+
if (!useNarrowSelect) {
|
|
140
|
+
return {
|
|
141
|
+
rows: await readNarrowedViaFullBodies(args),
|
|
142
|
+
useNarrowSelect: false
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
const rows = await findVersionPage(args, {
|
|
146
|
+
limit: MAX_VERSIONS_PER_DOCUMENT,
|
|
147
|
+
select: narrowVersionSelect(args.entity.hasDrafts)
|
|
148
|
+
});
|
|
149
|
+
if (args.entity.hasDrafts && !nestedSelectHonoured(rows)) {
|
|
150
|
+
args.payload.logger.warn(`${LOG_PREFIX} ${args.entity.slug}: the adapter dropped the nested \`version._status\` ` + 'select. Falling back to paged full-body reads for the rest of this run.');
|
|
151
|
+
return {
|
|
152
|
+
rows: await readNarrowedViaFullBodies(args),
|
|
153
|
+
useNarrowSelect: false
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
rows,
|
|
158
|
+
useNarrowSelect: true
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Deletes the planned rows in `IN (...)`-sized chunks.
|
|
163
|
+
*
|
|
164
|
+
* Chunks are small on purpose: drizzle's `deleteVersions` runs a `findMany`
|
|
165
|
+
* with no select over the chunk before deleting it, materialising every body
|
|
166
|
+
* in the chunk.
|
|
167
|
+
*
|
|
168
|
+
* Deliberately no `req`: today the job req arrives without a transaction, so
|
|
169
|
+
* each chunk commits on its own. Passing it would enroll a whole backlog pass
|
|
170
|
+
* in one transaction the moment a caller does hand us a transactional req —
|
|
171
|
+
* hours of open transaction and an unbounded rollback segment on the first run.
|
|
172
|
+
*/ async function deleteVersionRows({ guardLatest, ids, isGlobal, payload, slug }) {
|
|
173
|
+
for (const chunk of chunkIds(ids, DELETE_CHUNK_SIZE)){
|
|
174
|
+
const where = guardLatest ? {
|
|
175
|
+
and: [
|
|
176
|
+
{
|
|
177
|
+
id: {
|
|
178
|
+
in: chunk
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
latest: {
|
|
183
|
+
not_equals: true
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
]
|
|
187
|
+
} : {
|
|
188
|
+
id: {
|
|
189
|
+
in: chunk
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
if (isGlobal) {
|
|
193
|
+
await payload.db.deleteVersions({
|
|
194
|
+
globalSlug: slug,
|
|
195
|
+
where
|
|
196
|
+
});
|
|
197
|
+
} else {
|
|
198
|
+
await payload.db.deleteVersions({
|
|
199
|
+
collection: slug,
|
|
200
|
+
where
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Moves the `latest` flag onto a surviving row.
|
|
207
|
+
*
|
|
208
|
+
* `versionData` carries **only** `latest`. That matters: drizzle's
|
|
209
|
+
* `shouldUseOptimizedUpsertRow` takes the plain `UPDATE ... SET latest` path
|
|
210
|
+
* when nothing in the payload is localized, a block or an array — adding
|
|
211
|
+
* `version`, `parent` or `createdAt` would instead rewrite the whole row,
|
|
212
|
+
* deleting and reinserting its `_locales`, block and relationship rows.
|
|
213
|
+
*/ async function reflagLatest(payload, entity, isGlobal, id) {
|
|
214
|
+
// Payload types `versionData.version` as required even though the whole
|
|
215
|
+
// point of the optimized path is a partial payload. Asserting here is the
|
|
216
|
+
// narrow price of NOT sending `version` — sending it is the destructive
|
|
217
|
+
// branch this function exists to avoid.
|
|
218
|
+
const latestOnly = {
|
|
219
|
+
latest: true
|
|
220
|
+
};
|
|
221
|
+
if (isGlobal) {
|
|
222
|
+
await payload.db.updateGlobalVersion({
|
|
223
|
+
global: entity.slug,
|
|
224
|
+
id,
|
|
225
|
+
// Nothing needs the row back, and reading it would pull the full body
|
|
226
|
+
// through the very path this function exists to avoid.
|
|
227
|
+
returning: false,
|
|
228
|
+
versionData: latestOnly
|
|
229
|
+
});
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
await payload.db.updateVersion({
|
|
233
|
+
collection: entity.slug,
|
|
234
|
+
id,
|
|
235
|
+
returning: false,
|
|
236
|
+
versionData: latestOnly
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* A protected-row fingerprint, used to detect a concurrent write between the
|
|
241
|
+
* read that produced a plan and the deletes that act on it.
|
|
242
|
+
*/ function protectionFingerprint(rows) {
|
|
243
|
+
const candidates = rows.map(toCandidate);
|
|
244
|
+
const latest = candidates.find((candidate)=>candidate.latest);
|
|
245
|
+
const ids = [
|
|
246
|
+
...collectProtectedIds(candidates)
|
|
247
|
+
].map(String).sort();
|
|
248
|
+
return `${String(latest?.id ?? 'none')}|${ids.join(',')}`;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Collapses consecutive byte-identical rows for one document, oldest survivor
|
|
252
|
+
* wins.
|
|
253
|
+
*
|
|
254
|
+
* Rows are compared in `updatedAt` order. A pair collapses only when both
|
|
255
|
+
* carry the same `_status` (a publish that follows an identical draft is two
|
|
256
|
+
* real states, not a duplicate) and their bodies match once `createdAt`, `id`
|
|
257
|
+
* and `updatedAt` are stripped **from the top level only** — nested ids inside
|
|
258
|
+
* blocks and array rows are content, so reordering two blocks is a real change.
|
|
259
|
+
*
|
|
260
|
+
* When the newer row of a collapsing pair is `latest`, the flag moves to the
|
|
261
|
+
* survivor **before** the delete, so no window exists with zero `latest` rows.
|
|
262
|
+
*/ async function dedupDocument(payload, entity, isGlobal, parentId, budget, /** Row count from the narrow read, so "did we see them all" is exact. */ totalRows) {
|
|
263
|
+
const readArgs = {
|
|
264
|
+
entity,
|
|
265
|
+
isGlobal,
|
|
266
|
+
parentId,
|
|
267
|
+
payload
|
|
268
|
+
};
|
|
269
|
+
const doomed = [];
|
|
270
|
+
// The row the `latest` flag currently sits on. It moves to the survivor
|
|
271
|
+
// every time the row carrying it is collapsed away, so a run of identical
|
|
272
|
+
// rows costs exactly one `updateVersion` at the end.
|
|
273
|
+
let latestId;
|
|
274
|
+
let originalLatestId;
|
|
275
|
+
// Read in pages, holding only the current page plus the previous row, so
|
|
276
|
+
// peak memory is FULL_BODY_PAGE_SIZE bodies whatever the history depth.
|
|
277
|
+
// Nothing is deleted during the walk — offset paging would drift under it.
|
|
278
|
+
let carry;
|
|
279
|
+
let reachedEnd = false;
|
|
280
|
+
let bodiesRead = 0;
|
|
281
|
+
let budgetLeft = Math.min(MAX_VERSIONS_PER_DOCUMENT, budget.dedupBodiesRemaining);
|
|
282
|
+
for(let page = 1; budgetLeft >= 1; page++){
|
|
283
|
+
const limit = Math.min(FULL_BODY_PAGE_SIZE, budgetLeft);
|
|
284
|
+
const chunk = await findVersionPage(readArgs, {
|
|
285
|
+
limit,
|
|
286
|
+
page
|
|
287
|
+
});
|
|
288
|
+
if (chunk.length === 0) {
|
|
289
|
+
reachedEnd = true;
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
budget.dedupBodiesRemaining -= chunk.length;
|
|
293
|
+
budgetLeft -= chunk.length;
|
|
294
|
+
bodiesRead += chunk.length;
|
|
295
|
+
// The narrow read already counted the rows, so a budget that ran out on
|
|
296
|
+
// the document's last row finished the job and is not unfinished work.
|
|
297
|
+
if (bodiesRead >= totalRows) {
|
|
298
|
+
reachedEnd = true;
|
|
299
|
+
}
|
|
300
|
+
const sequence = carry ? [
|
|
301
|
+
carry,
|
|
302
|
+
...chunk
|
|
303
|
+
] : chunk;
|
|
304
|
+
for (const row of sequence){
|
|
305
|
+
if (row.latest === true && originalLatestId === undefined) {
|
|
306
|
+
originalLatestId = row.id;
|
|
307
|
+
latestId = row.id;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// `sequence` is newest-first. Comparing i against i+1 and dropping i
|
|
311
|
+
// collapses a run of identical rows down to its oldest member.
|
|
312
|
+
for(let index = 0; index < sequence.length - 1; index++){
|
|
313
|
+
const newer = sequence[index];
|
|
314
|
+
const older = sequence[index + 1];
|
|
315
|
+
// A row is never its own duplicate. The sort tiebreak should make this
|
|
316
|
+
// unreachable; it stays as the last line of defence, because what it
|
|
317
|
+
// prevents is deleting a unique version.
|
|
318
|
+
if (newer && older && newer.id === older.id) {
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (!(newer && older) || newer.autosave === true || older.autosave === true) {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (!sameStatus(newer.version, older.version)) {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (!versionBodiesMatch(newer.version, older.version)) {
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
doomed.push(newer.id);
|
|
331
|
+
if (latestId === newer.id) {
|
|
332
|
+
latestId = older.id;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
carry = sequence.at(-1);
|
|
336
|
+
if (chunk.length < limit) {
|
|
337
|
+
// A short page is the end of the document, whatever the budget says.
|
|
338
|
+
reachedEnd = true;
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (doomed.length === 0) {
|
|
343
|
+
return {
|
|
344
|
+
deferred: false,
|
|
345
|
+
deletedIds: [],
|
|
346
|
+
stoppedEarly: !reachedEnd
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
// Dedup deletes rows like any other pass, so they draw on the same cap.
|
|
350
|
+
const affordable = doomed.slice(0, Math.max(0, budget.deletionsRemaining));
|
|
351
|
+
if (affordable.length < doomed.length && latestId !== originalLatestId) {
|
|
352
|
+
// The re-flag target may be one of the rows we can no longer afford to
|
|
353
|
+
// delete, so the collapse has to be all or nothing. `deferred` tells the
|
|
354
|
+
// caller to step past this document instead of resuming inside it: with
|
|
355
|
+
// `maxDedupBodiesPerRun` set above `maxDeletionsPerRun`, dedup can keep
|
|
356
|
+
// reaching documents it has no deletion budget left for, and resuming on
|
|
357
|
+
// one of those every run is a livelock that never deletes a row.
|
|
358
|
+
return {
|
|
359
|
+
deferred: true,
|
|
360
|
+
deletedIds: [],
|
|
361
|
+
stoppedEarly: false
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
const reflagged = latestId !== undefined && latestId !== originalLatestId ? latestId : undefined;
|
|
365
|
+
// Flag the survivor BEFORE deleting, so no window exists in which the
|
|
366
|
+
// document has zero rows carrying `latest`.
|
|
367
|
+
if (reflagged !== undefined) {
|
|
368
|
+
await reflagLatest(payload, entity, isGlobal, reflagged);
|
|
369
|
+
}
|
|
370
|
+
await deleteVersionRows({
|
|
371
|
+
ids: affordable,
|
|
372
|
+
isGlobal,
|
|
373
|
+
payload,
|
|
374
|
+
slug: entity.slug
|
|
375
|
+
});
|
|
376
|
+
budget.deletionsRemaining -= affordable.length;
|
|
377
|
+
return {
|
|
378
|
+
deferred: false,
|
|
379
|
+
deletedIds: affordable,
|
|
380
|
+
reflaggedId: reflagged,
|
|
381
|
+
stoppedEarly: !reachedEnd || affordable.length < doomed.length
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
const NOTHING = {
|
|
385
|
+
dedupDeferred: false,
|
|
386
|
+
dedupRan: false,
|
|
387
|
+
dedupStoppedEarly: false,
|
|
388
|
+
dedupedCount: 0,
|
|
389
|
+
deletedCount: 0,
|
|
390
|
+
raced: false,
|
|
391
|
+
truncatedByBudget: false,
|
|
392
|
+
truncatedByWindow: false
|
|
393
|
+
};
|
|
394
|
+
async function sweepDocument({ budget, cutoff, entity, isGlobal, parentId, payload, useNarrowSelect }) {
|
|
395
|
+
const readArgs = {
|
|
396
|
+
entity,
|
|
397
|
+
isGlobal,
|
|
398
|
+
parentId,
|
|
399
|
+
payload
|
|
400
|
+
};
|
|
401
|
+
const first = await readDocumentVersions(readArgs, useNarrowSelect);
|
|
402
|
+
const truncatedByWindow = first.rows.length >= MAX_VERSIONS_PER_DOCUMENT;
|
|
403
|
+
const wantsDedup = entity.dedup && first.rows.length >= 2 && budget.dedupBodiesRemaining >= 2;
|
|
404
|
+
const wantsAgeSweep = planVersionDeletions(first.rows.map(toCandidate), cutoff, entity.minVersionsPerDocument).length > 0;
|
|
405
|
+
if (!(wantsDedup || wantsAgeSweep)) {
|
|
406
|
+
return {
|
|
407
|
+
...NOTHING,
|
|
408
|
+
truncatedByWindow,
|
|
409
|
+
useNarrowSelect: first.useNarrowSelect
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
// `updateLatestVersion` rewrites the latest row in place on unpublish and on
|
|
413
|
+
// autosave, so a plan built from a stale read can delete the runner-up
|
|
414
|
+
// published row and leave the document with none. Re-read and compare the
|
|
415
|
+
// protected set; anything different and this document waits for the next run.
|
|
416
|
+
const verify = await readDocumentVersions(readArgs, first.useNarrowSelect);
|
|
417
|
+
if (protectionFingerprint(first.rows) !== protectionFingerprint(verify.rows)) {
|
|
418
|
+
payload.logger.warn(`${LOG_PREFIX} ${entity.slug}${parentId === undefined ? '' : ` document ${String(parentId)}`}: ` + 'version rows changed mid-pass (a concurrent publish or unpublish). Skipped this run.');
|
|
419
|
+
return {
|
|
420
|
+
...NOTHING,
|
|
421
|
+
raced: true,
|
|
422
|
+
truncatedByWindow,
|
|
423
|
+
useNarrowSelect: verify.useNarrowSelect
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
let dedupedCount = 0;
|
|
427
|
+
let dedupDeferred = false;
|
|
428
|
+
let dedupStoppedEarly = false;
|
|
429
|
+
let rows = verify.rows;
|
|
430
|
+
if (wantsDedup) {
|
|
431
|
+
const deduped = await dedupDocument(payload, entity, isGlobal, parentId, budget, first.rows.length);
|
|
432
|
+
dedupedCount = deduped.deletedIds.length;
|
|
433
|
+
dedupStoppedEarly = deduped.stoppedEarly;
|
|
434
|
+
dedupDeferred = deduped.deferred;
|
|
435
|
+
if (dedupedCount > 0) {
|
|
436
|
+
const gone = new Set(deduped.deletedIds);
|
|
437
|
+
rows = rows.filter((row)=>!gone.has(row.id)).map((row)=>deduped.reflaggedId === undefined ? row : {
|
|
438
|
+
...row,
|
|
439
|
+
latest: row.id === deduped.reflaggedId
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
// Recomputed after dedup: collapsing duplicates changes which rows the floor
|
|
444
|
+
// has to hold back.
|
|
445
|
+
const planned = planVersionDeletions(rows.map(toCandidate), cutoff, entity.minVersionsPerDocument);
|
|
446
|
+
// `planned` is oldest-first, so a budget-truncated plan still deletes the
|
|
447
|
+
// oldest rows rather than eating into recent history.
|
|
448
|
+
const ids = planned.slice(0, Math.max(0, budget.deletionsRemaining));
|
|
449
|
+
const truncatedByBudget = ids.length < planned.length;
|
|
450
|
+
if (ids.length > 0) {
|
|
451
|
+
await deleteVersionRows({
|
|
452
|
+
guardLatest: true,
|
|
453
|
+
ids,
|
|
454
|
+
isGlobal,
|
|
455
|
+
payload,
|
|
456
|
+
slug: entity.slug
|
|
457
|
+
});
|
|
458
|
+
budget.deletionsRemaining -= ids.length;
|
|
459
|
+
}
|
|
460
|
+
return {
|
|
461
|
+
dedupDeferred,
|
|
462
|
+
dedupRan: wantsDedup,
|
|
463
|
+
dedupStoppedEarly,
|
|
464
|
+
dedupedCount,
|
|
465
|
+
deletedCount: ids.length + dedupedCount,
|
|
466
|
+
raced: false,
|
|
467
|
+
truncatedByBudget,
|
|
468
|
+
truncatedByWindow,
|
|
469
|
+
useNarrowSelect: verify.useNarrowSelect
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
function budgetSpent(budget) {
|
|
473
|
+
return budget.deletionsRemaining <= 0 || budget.documentsRemaining <= 0;
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* `hasMore` means **deletable work is known to remain** — nothing weaker.
|
|
477
|
+
*
|
|
478
|
+
* A stale version row is not work: on a settled corpus most of them are the
|
|
479
|
+
* protected rows or held back by the floor, and every run would meet them
|
|
480
|
+
* again. Reporting that as "more to do" made the signal permanently true at
|
|
481
|
+
* steady state (2,115 documents scanned, 0 deleted, `hasMore: true`, ten runs
|
|
482
|
+
* running), which is the same as no signal at all.
|
|
483
|
+
*
|
|
484
|
+
* So it is true only when a budget actually cut something short:
|
|
485
|
+
*
|
|
486
|
+
* - a deletion or document budget ran out **in a run that deleted rows** —
|
|
487
|
+
* the cap, not the corpus, ended the pass;
|
|
488
|
+
* - the dedup body budget ran out **with documents still unread**;
|
|
489
|
+
* - a document was skipped as raced or failed, which is deferred work by
|
|
490
|
+
* definition and cannot recur every night on a settled corpus.
|
|
491
|
+
*/ function computeHasMore(args) {
|
|
492
|
+
const cappedWhileWorking = args.deletedCount > 0 && budgetSpent(args.budget);
|
|
493
|
+
return cappedWhileWorking || args.dedupStarved || args.skippedRaced > 0 || args.failedDocuments > 0;
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Sweeps one versioned collection.
|
|
497
|
+
*
|
|
498
|
+
* Rather than walking every document, the pass walks the stale-version rows
|
|
499
|
+
* themselves and visits only the parents that still hold deletable history.
|
|
500
|
+
* Paging is by an ascending `parent` cursor, not an offset, so deleting rows
|
|
501
|
+
* mid-pass cannot make it skip a document.
|
|
502
|
+
*/ export async function sweepCollection(payload, entity, budget, now = new Date(), /** Resume dedup after this parent id; see `EntitySweepResult.dedupCursor`. */ dedupCursorAfter) {
|
|
503
|
+
const cutoff = retentionCutoff(entity.days, now);
|
|
504
|
+
let dedupedCount = 0;
|
|
505
|
+
let deletedCount = 0;
|
|
506
|
+
let failedDocuments = 0;
|
|
507
|
+
let scannedDocuments = 0;
|
|
508
|
+
let skippedRaced = 0;
|
|
509
|
+
let windowTruncatedWithWork = false;
|
|
510
|
+
let useNarrowSelect = true;
|
|
511
|
+
let cursor;
|
|
512
|
+
let lastDedupedParent;
|
|
513
|
+
let dedupStarved = false;
|
|
514
|
+
if (budgetSpent(budget)) {
|
|
515
|
+
// Another entity used the run's budget. Whether that leaves work here is
|
|
516
|
+
// that entity's story to tell — probing for a stale row would only report
|
|
517
|
+
// rows this pass would refuse to delete anyway.
|
|
518
|
+
return {
|
|
519
|
+
dedupResumeAfter: dedupCursorAfter,
|
|
520
|
+
dedupStarved: entity.dedup,
|
|
521
|
+
dedupedCount: 0,
|
|
522
|
+
deletedCount: 0,
|
|
523
|
+
failedDocuments: 0,
|
|
524
|
+
hasMore: false,
|
|
525
|
+
scannedDocuments: 0,
|
|
526
|
+
skippedRaced: 0
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
walk: while(!budgetSpent(budget)){
|
|
530
|
+
const { docs } = await payload.db.findVersions({
|
|
531
|
+
collection: entity.slug,
|
|
532
|
+
limit: PARENT_PAGE_SIZE,
|
|
533
|
+
pagination: false,
|
|
534
|
+
select: {
|
|
535
|
+
parent: true
|
|
536
|
+
},
|
|
537
|
+
sort: [
|
|
538
|
+
'parent',
|
|
539
|
+
'id'
|
|
540
|
+
],
|
|
541
|
+
where: staleWhere(cutoff, cursor)
|
|
542
|
+
});
|
|
543
|
+
const parents = toParentIds(docs);
|
|
544
|
+
if (parents.length === 0) {
|
|
545
|
+
break;
|
|
546
|
+
}
|
|
547
|
+
for (const parentId of parents){
|
|
548
|
+
if (budgetSpent(budget)) {
|
|
549
|
+
break walk;
|
|
550
|
+
}
|
|
551
|
+
scannedDocuments++;
|
|
552
|
+
// Documents already deduped by the previous run are skipped for dedup
|
|
553
|
+
// (they are still age-swept), so the body budget moves down the list
|
|
554
|
+
// instead of being spent on the same first documents every night.
|
|
555
|
+
const dedupThisDocument = entity.dedup && (dedupCursorAfter === undefined || isAfter(parentId, dedupCursorAfter));
|
|
556
|
+
// A document we would have deduped but cannot afford to read is the one
|
|
557
|
+
// honest form of "dedup has unfinished business".
|
|
558
|
+
if (dedupThisDocument && budget.dedupBodiesRemaining < 2) {
|
|
559
|
+
dedupStarved = true;
|
|
560
|
+
}
|
|
561
|
+
let result;
|
|
562
|
+
try {
|
|
563
|
+
result = await sweepDocument({
|
|
564
|
+
budget,
|
|
565
|
+
cutoff,
|
|
566
|
+
entity: dedupThisDocument ? entity : {
|
|
567
|
+
...entity,
|
|
568
|
+
dedup: false
|
|
569
|
+
},
|
|
570
|
+
isGlobal: false,
|
|
571
|
+
parentId,
|
|
572
|
+
payload,
|
|
573
|
+
useNarrowSelect
|
|
574
|
+
});
|
|
575
|
+
} catch (error) {
|
|
576
|
+
// One bad document must not cost the rest of the entity, or the
|
|
577
|
+
// entities queued behind it.
|
|
578
|
+
failedDocuments++;
|
|
579
|
+
payload.logger.error({
|
|
580
|
+
err: error
|
|
581
|
+
}, `${LOG_PREFIX} ${entity.slug} document ${String(parentId)}: sweep failed; skipped. ` + 'The next scheduled run retries it.');
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
useNarrowSelect = result.useNarrowSelect;
|
|
585
|
+
dedupedCount += result.dedupedCount;
|
|
586
|
+
deletedCount += result.deletedCount;
|
|
587
|
+
// Only advance past a document dedup actually finished. A document cut
|
|
588
|
+
// short must be resumed *inside*, so the cursor stays on its predecessor
|
|
589
|
+
// — unless the collapse was merely unaffordable, in which case resuming
|
|
590
|
+
// on it forever is the livelock and stepping past it is the fix.
|
|
591
|
+
if (result.dedupRan && (!result.dedupStoppedEarly || result.dedupDeferred)) {
|
|
592
|
+
lastDedupedParent = parentId;
|
|
593
|
+
}
|
|
594
|
+
if (result.dedupStoppedEarly || result.dedupDeferred) {
|
|
595
|
+
dedupStarved = true;
|
|
596
|
+
}
|
|
597
|
+
if (result.dedupDeferred) {
|
|
598
|
+
payload.logger.warn(`${LOG_PREFIX} ${entity.slug} document ${String(parentId)}: found duplicate versions ` + 'but the run is out of deletion budget; stepping past it. Raise ' + '`maxDeletionsPerRun`, or lower `maxDedupBodiesPerRun` to match it, if this recurs.');
|
|
599
|
+
}
|
|
600
|
+
if (result.raced) {
|
|
601
|
+
skippedRaced++;
|
|
602
|
+
}
|
|
603
|
+
// Only a document that actually gave up rows draws on the document
|
|
604
|
+
// budget. Documents whose stale rows are all protected or floor-kept
|
|
605
|
+
// reappear on every run and must not consume it.
|
|
606
|
+
if (result.deletedCount > 0) {
|
|
607
|
+
budget.documentsRemaining--;
|
|
608
|
+
}
|
|
609
|
+
if (result.truncatedByWindow && result.deletedCount > 0) {
|
|
610
|
+
// Only unfinished if the window actually yielded rows: a window that
|
|
611
|
+
// gave up nothing will give up nothing next time either.
|
|
612
|
+
windowTruncatedWithWork = true;
|
|
613
|
+
}
|
|
614
|
+
if (result.truncatedByWindow) {
|
|
615
|
+
payload.logger.warn(`${LOG_PREFIX} ${entity.slug} document ${String(parentId)} holds at least ` + `${MAX_VERSIONS_PER_DOCUMENT} versions — only the newest were inspected this pass. ` + 'The next scheduled run continues.');
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
const lastParent = parents.at(-1);
|
|
619
|
+
if (lastParent === undefined) {
|
|
620
|
+
break;
|
|
621
|
+
}
|
|
622
|
+
cursor = lastParent;
|
|
623
|
+
if (docs.length < PARENT_PAGE_SIZE) {
|
|
624
|
+
break;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
const hasMore = windowTruncatedWithWork || computeHasMore({
|
|
628
|
+
budget,
|
|
629
|
+
dedupStarved,
|
|
630
|
+
deletedCount,
|
|
631
|
+
failedDocuments,
|
|
632
|
+
skippedRaced
|
|
633
|
+
});
|
|
634
|
+
if (scannedDocuments > 0) {
|
|
635
|
+
payload.logger.info(`${LOG_PREFIX} ${entity.slug}: ` + (deletedCount === 0 && !hasMore ? `nothing to do — ${scannedDocuments} document(s) scanned, every stale row is ` + `protected or held by the floor (window ${entity.days} day(s))` : `deleted ${deletedCount} version row(s) (${dedupedCount} identical) across ` + `${scannedDocuments} document(s), window ${entity.days} day(s)` + (skippedRaced > 0 ? `, ${skippedRaced} skipped as raced` : '') + (failedDocuments > 0 ? `, ${failedDocuments} failed` : '') + (hasMore ? ' — work remains, the next scheduled run continues' : '')));
|
|
636
|
+
}
|
|
637
|
+
return {
|
|
638
|
+
// Only carry a resume point when dedup genuinely stopped short. A
|
|
639
|
+
// completed walk reports none, and the run then moves on to the next
|
|
640
|
+
// entity — that is what makes the rotation a cycle, not a dead end.
|
|
641
|
+
dedupResumeAfter: dedupStarved ? lastDedupedParent : undefined,
|
|
642
|
+
dedupStarved,
|
|
643
|
+
dedupedCount,
|
|
644
|
+
deletedCount,
|
|
645
|
+
failedDocuments,
|
|
646
|
+
hasMore,
|
|
647
|
+
scannedDocuments,
|
|
648
|
+
skippedRaced
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
/** Sweeps one versioned global. A global is a single "document". */ export async function sweepGlobal(payload, entity, budget, now = new Date()) {
|
|
652
|
+
const empty = {
|
|
653
|
+
dedupStarved: false,
|
|
654
|
+
dedupedCount: 0,
|
|
655
|
+
deletedCount: 0,
|
|
656
|
+
failedDocuments: 0,
|
|
657
|
+
hasMore: false,
|
|
658
|
+
scannedDocuments: 0,
|
|
659
|
+
skippedRaced: 0
|
|
660
|
+
};
|
|
661
|
+
const cutoff = retentionCutoff(entity.days, now);
|
|
662
|
+
if (budgetSpent(budget)) {
|
|
663
|
+
// Another entity spent the budget. That entity reports whether work
|
|
664
|
+
// remains; a stale row here proves nothing on its own.
|
|
665
|
+
return {
|
|
666
|
+
...empty,
|
|
667
|
+
dedupStarved: entity.dedup
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
let result;
|
|
671
|
+
try {
|
|
672
|
+
result = await sweepDocument({
|
|
673
|
+
budget,
|
|
674
|
+
cutoff,
|
|
675
|
+
entity,
|
|
676
|
+
isGlobal: true,
|
|
677
|
+
payload,
|
|
678
|
+
useNarrowSelect: true
|
|
679
|
+
});
|
|
680
|
+
} catch (error) {
|
|
681
|
+
payload.logger.error({
|
|
682
|
+
err: error
|
|
683
|
+
}, `${LOG_PREFIX} ${entity.slug} (global): sweep failed; skipped. ` + 'The next scheduled run retries it.');
|
|
684
|
+
return {
|
|
685
|
+
...empty,
|
|
686
|
+
failedDocuments: 1,
|
|
687
|
+
hasMore: true
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
if (result.deletedCount > 0) {
|
|
691
|
+
budget.documentsRemaining--;
|
|
692
|
+
}
|
|
693
|
+
const skippedRaced = result.raced ? 1 : 0;
|
|
694
|
+
const hasMore = result.truncatedByWindow && result.deletedCount > 0 || computeHasMore({
|
|
695
|
+
budget,
|
|
696
|
+
dedupStarved: result.dedupStoppedEarly,
|
|
697
|
+
deletedCount: result.deletedCount,
|
|
698
|
+
failedDocuments: 0,
|
|
699
|
+
skippedRaced
|
|
700
|
+
});
|
|
701
|
+
payload.logger.info(`${LOG_PREFIX} ${entity.slug} (global): ` + (result.deletedCount === 0 && !hasMore ? `nothing to do (window ${entity.days} day(s))` : `deleted ${result.deletedCount} version row(s) (${result.dedupedCount} identical), ` + `window ${entity.days} day(s)`));
|
|
702
|
+
return {
|
|
703
|
+
dedupStarved: result.dedupStoppedEarly,
|
|
704
|
+
dedupedCount: result.dedupedCount,
|
|
705
|
+
deletedCount: result.deletedCount,
|
|
706
|
+
failedDocuments: 0,
|
|
707
|
+
hasMore,
|
|
708
|
+
scannedDocuments: 1,
|
|
709
|
+
skippedRaced
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* One full janitor pass. Globals go first: there are a handful of them and
|
|
714
|
+
* they are cheap, and letting a large collection's backlog eat the budget
|
|
715
|
+
* would otherwise starve them run after run.
|
|
716
|
+
*
|
|
717
|
+
* Dedup rides a single cursor over this whole order rather than one per
|
|
718
|
+
* entity. That is what guarantees progress: a run resumes at the entity the
|
|
719
|
+
* last one stopped in, skips everything already covered in this cycle, and
|
|
720
|
+
* carries on into the entities behind it. With a per-entity cursor the
|
|
721
|
+
* entities *before* the stalled one were re-read from scratch every night and
|
|
722
|
+
* consumed the entire body budget, so the pass never advanced — measured on
|
|
723
|
+
* the production copy, where dedup collapsed pairs only in the four
|
|
724
|
+
* collections ahead of `categories` and never reached `pages` or `games`.
|
|
725
|
+
*/ export async function runRetentionSweep({ collections, dedupCursor, globals, maxDedupBodiesPerRun, maxDeletionsPerRun, maxDocumentsPerRun, now = new Date(), payload }) {
|
|
726
|
+
const budget = {
|
|
727
|
+
dedupBodiesRemaining: maxDedupBodiesPerRun,
|
|
728
|
+
deletionsRemaining: maxDeletionsPerRun,
|
|
729
|
+
documentsRemaining: maxDocumentsPerRun
|
|
730
|
+
};
|
|
731
|
+
const order = [
|
|
732
|
+
...globals.map((entity)=>({
|
|
733
|
+
entity,
|
|
734
|
+
isGlobal: true
|
|
735
|
+
})),
|
|
736
|
+
...collections.map((entity)=>({
|
|
737
|
+
entity,
|
|
738
|
+
isGlobal: false
|
|
739
|
+
}))
|
|
740
|
+
];
|
|
741
|
+
// A cursor naming a slug that is no longer configured restarts the cycle
|
|
742
|
+
// rather than skipping everything.
|
|
743
|
+
const found = dedupCursor ? order.findIndex((item)=>item.entity.slug === dedupCursor.slug) : -1;
|
|
744
|
+
const resumeIndex = found === -1 ? 0 : found;
|
|
745
|
+
const output = {
|
|
746
|
+
dedupCursor: null,
|
|
747
|
+
dedupedCount: 0,
|
|
748
|
+
deletedCount: 0,
|
|
749
|
+
failedDocuments: 0,
|
|
750
|
+
hasMore: false,
|
|
751
|
+
scannedDocuments: 0,
|
|
752
|
+
skippedRaced: 0
|
|
753
|
+
};
|
|
754
|
+
let stopped;
|
|
755
|
+
for (const [index, { entity, isGlobal }] of order.entries()){
|
|
756
|
+
// Everything before the resume point was deduped earlier in this cycle.
|
|
757
|
+
// It is still age-swept; only the body reads are skipped.
|
|
758
|
+
const dedupHere = index >= resumeIndex;
|
|
759
|
+
const effective = dedupHere ? entity : {
|
|
760
|
+
...entity,
|
|
761
|
+
dedup: false
|
|
762
|
+
};
|
|
763
|
+
// A cursor whose slug is gone restarts the cycle, so its parent id must
|
|
764
|
+
// not leak into the first entity and silently skip its early documents.
|
|
765
|
+
const resumeAfter = index === resumeIndex && found !== -1 ? dedupCursor?.parentId : undefined;
|
|
766
|
+
let result;
|
|
767
|
+
try {
|
|
768
|
+
result = isGlobal ? await sweepGlobal(payload, effective, budget, now) : await sweepCollection(payload, effective, budget, now, resumeAfter);
|
|
769
|
+
} catch (error) {
|
|
770
|
+
// An entity that blows up entirely (a dropped table, a permission
|
|
771
|
+
// change) must not take the rest of the run down with it.
|
|
772
|
+
output.failedDocuments++;
|
|
773
|
+
output.hasMore = true;
|
|
774
|
+
payload.logger.error({
|
|
775
|
+
err: error
|
|
776
|
+
}, `${LOG_PREFIX} ${entity.slug}: sweep aborted; the remaining entities still ran.`);
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
output.dedupedCount += result.dedupedCount;
|
|
780
|
+
output.deletedCount += result.deletedCount;
|
|
781
|
+
output.failedDocuments += result.failedDocuments;
|
|
782
|
+
output.scannedDocuments += result.scannedDocuments;
|
|
783
|
+
output.skippedRaced += result.skippedRaced;
|
|
784
|
+
output.hasMore = output.hasMore || result.hasMore;
|
|
785
|
+
// The FIRST entity that ran short is where the next run picks up.
|
|
786
|
+
if (stopped === undefined && dedupHere && result.dedupStarved) {
|
|
787
|
+
stopped = {
|
|
788
|
+
slug: entity.slug,
|
|
789
|
+
...result.dedupResumeAfter === undefined ? {} : {
|
|
790
|
+
parentId: result.dedupResumeAfter
|
|
791
|
+
}
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
output.dedupCursor = stopped ?? null;
|
|
796
|
+
// A live cursor IS unfinished work, whatever else the run concluded.
|
|
797
|
+
output.hasMore = output.hasMore || output.dedupCursor !== null;
|
|
798
|
+
return output;
|
|
799
|
+
}
|