@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.
@@ -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
+ }