fix: stop cleanup punching holes in the pad it is cleaning - #8146
fix: stop cleanup punching holes in the pad it is cleaning#8146JohnMcLear wants to merge 1 commit into
Conversation
deleteRevisions() removed every revision 0..head and only then wrote the
replacements. Any failure in that window left the pad with holes -- or
with no history at all -- while the pad record still claimed them.
Worse, the cached Pad was only unloaded on the success path. A failed
cleanup left an object carrying the pre-cleanup head, so one more edit
through it appended at the OLD head and persisted a head far past the
rebuilt history. Measured on develop: a cleanup that fails one write,
plus a single subsequent edit, takes a 12-revision pad to head=13 with
revisions 3..12 all missing. Ten holes, none of them recoverable, and
the pad can then never be cleaned up again.
Two changes:
- Reorder to write-then-swap-then-drop. The rebuilt revisions are
written first, then head is moved onto them, then the now-orphaned
revisions above the new head are removed. Overwriting revisions
0..keepRevisions in place is safe because everything needed to
rebuild them is already in memory; nothing is read back from those
keys. The final delete step touches only orphans, so failing there
wastes space without making the pad inconsistent.
- Unload the cached Pad in a finally, so a failed cleanup cannot leave
a stale object able to append past the rewritten head.
ueberdb offers no transaction across the rewrite, so a failure part-way
can still leave revisions 0..keepRevisions rebuilt while head has not
moved onto them -- check() then fails on a content mismatch. That state
is recoverable: full compaction rebuilds the history from the current
text. Holes are not recoverable, which is why they are the thing worth
eliminating.
Refs #8134
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing |
PR Summary by QodoFix cleanup to avoid creating revision holes and unload stale cached pads
AI Description
Diagram
High-Level Assessment
Files changed (2)
|
| async function () { | ||
| // The 10-hole case. Hold a reference the way a caller would, let | ||
| // cleanup fail, then keep editing. | ||
| const pad = await makePad(); |
Code Review by Qodo
1. Orphan deletion non-retryable
|
| if (oldHead > keepRevisions) { | ||
| await timesLimit(oldHead - keepRevisions, 500, async (i: number) => { | ||
| await db.remove(`pad:${padId}:revs:${keepRevisions + 1 + i}`, null); | ||
| }); |
There was a problem hiding this comment.
1. Orphan deletion non-retryable 🐞 Bug ☼ Reliability
If orphan revision deletion fails, rebuildHistory() throws after already committing pad:<id>.head to keepRevisions, so callers see a cleanup failure even though the compacted history is already live. Subsequent cleanup attempts can short-circuit on pad.head <= keepRevisions, leaving orphaned revisions behind with no normal retry path.
Agent Prompt
### Issue description
`rebuildHistory()` updates the pad record (`pad:<id>.head`) before deleting orphaned revision keys. If the orphan-deletion phase fails, the function rejects and the admin/API surface an error even though the pad now points at the rebuilt history. Because `deleteRevisions()` returns early when `pad.head <= keepRevisions`, a subsequent retry can become a no-op, leaving orphaned revisions permanently.
### Issue Context
This PR intentionally makes orphan deletion safe (wasted space vs. inconsistency). That safety goal is undermined by treating orphan deletion failure as fatal and by having no retry path once `head` has been moved.
### Fix Focus Areas
- src/node/utils/Cleanup.ts[151-158]
- src/node/utils/Cleanup.ts[52-55]
### Suggested change
- Wrap the orphan-deletion block in `try/catch`.
- On failure, log a warning/error with padId and continue to unload + re-read + `newPad.check()`.
- Return `true` if the rebuilt history was written and the pad checks out (even if orphan deletion failed), so the API/admin UI doesn’t report failure for a successful compaction.
- (Optional) If you need to surface partial success, add structured logging/metrics for “orphan cleanup failed” rather than throwing.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
Refs #8134. Second of two: #8145 makes a damaged pad fail gracefully; this stops cleanup creating the damage.
Problem
While investigating #8134 I went looking for hole-forming paths other than
appendRevision, and found two — both inside the cleanup feature itself.1.
deleteRevisionsdeletes the whole history before rewriting it.Any failure in that window leaves the pad with holes, or with no history at all, while the pad record still claims them.
2. The cached
Padis only unloaded on the success path. A failed cleanup leaves an object carrying the pre-cleanup head. One more edit through it appends at the OLD head and persists a head far past the rebuilt history.Measured on
develop, failing a single write and then making one edit:Ten holes from one failed cleanup plus one keystroke — none recoverable, and the pad can then never be cleaned up again. The feature meant to reclaim space is itself a way into the state #8134 reports.
Fix
Write-then-swap-then-drop. Write the rebuilt revisions first, then move
headonto them, then remove the revisions the new head no longer references. Overwriting revisions0..keepRevisionsin place is safe because everything needed to rebuild them is already in memory (changesetandrevisions) — nothing is read back from those keys. The final delete touches only orphans, so failing there wastes space without making the pad inconsistent.Unload the cached Pad in a
finally, so a failed cleanup can't leave a stale object able to append past the rewritten head.What this does not fix
ueberdb offers no transaction across the rewrite. A failure part-way can still leave revisions
0..keepRevisionsrebuilt whileheadhasn't moved onto them, andcheck()then fails on a content mismatch.That state is recoverable — full compaction rebuilds the history from the current text. Holes are not recoverable: a missing revision can't be reconstructed, because every later revision is a delta that assumes its result. That asymmetry is why holes are the thing worth eliminating, and the test says so explicitly rather than pretending the window is gone.
Tests
src/tests/backend/specs/cleanupNoDestructiveWindow.ts— 6 cases: a failed rewrite leaves no holes; a failed rewrite leaves a pad full compaction can repair; a failed cleanup can't leave a stale pad that appends past head; the cached pad is dropped even on failure; and two success-path cases (keeps the last N revisions and checks out; orphans actually removed).4 of the 6 fail on
develop, including both hole assertions above. All 6 pass with the fix. Full backend suite: 1627 passing, 0 failing.tsc --noEmitclean.Note
The repair test asserts no-holes and content rather than
check(), because ondevelopfull compaction still writes an invalid revision 1 (#8139, fixed by #8140). Keeping this PR independent of that one rather than stacking.🤖 Generated with Claude Code