fix: don't let a failed revision write leave a hole in pad history - #8141
fix: don't let a failed revision write leave a hole in pad history#8141JohnMcLear wants to merge 2 commits into
Conversation
|
ⓘ 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 appendRevision() atomicity to prevent missing revisions in pad history
AI Description
Diagram
High-Level Assessment
Files changed (2)
|
Code Review by Qodo
1. Rollback can be overwritten
|
| await storageWrites; | ||
| } catch (err) { | ||
| await this._rollbackFailedRevision(newRev, prevHead, prevAText); | ||
| throw err; |
There was a problem hiding this comment.
1. Rollback can be overwritten 🐞 Bug ≡ Correctness
If the revision write fails, appendRevision() rolls back head/atext and re-saves the pad record, but the original in-flight saveToDatabase() from storageWrites can still complete afterwards and re-persist head=newRev. This can leave the persisted pad record pointing past stored history, recreating the missing-revision gap despite the rollback.
Agent Prompt
## Issue description
`appendRevision()` starts two independent writes concurrently (`revs:<newRev>` and `pad:<id>` via `saveToDatabase()`). On failure it immediately calls `_rollbackFailedRevision()` which writes `pad:<id>` again with the rolled-back `head`. Because the original `saveToDatabase()` promise is not canceled, it can still complete after the rollback write and overwrite the pad record with `head=newRev`, reintroducing the missing-revision hole.
## Issue Context
- The failing path relies on `_rollbackFailedRevision()` to ensure the persisted pad record never claims an unstored revision.
- `Promise.all()` does not cancel the other in-flight promise when one rejects.
## Fix Focus Areas
- Ensure the original pad-record write has *settled* (or is otherwise prevented from writing later) before performing the rollback write.
- Prefer keeping explicit handles to the individual promises (revWrite, padWrite), and in the error path `await`/`allSettled` them as needed before re-saving.
- Add/adjust a regression test that forces the first `pad:<id>` write to resolve *after* the rollback save, and assert the final stored `pad:<id>.head` is rolled back.
### Code pointers
- src/node/db/Pad.ts[313-375]
- src/node/db/Pad.ts[388-404]
- src/node/db/Pad.ts[415-419]
- src/tests/backend/specs/appendRevisionAtomicity.ts[30-122]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
appendRevision() writes the revision record and the pad record (which carries `head`) as two independent writes in one Promise.all. When the revision write failed and the pad record landed, the pad claimed a revision that was never stored. The next successful append then wrote head+1 straight over it, so the gap became permanent, and every later pad.check() tripped on the missing revision -- which blocks cleanup and compaction forever, with no way back. That is what #8134 reports: revisions 599 and 601 present, 600 absent, "Cleanup revisions" refusing to run. Revision 600 is a key revision (`Math.floor(rev/100)*100`), so it embeds the whole attribute pool and atext -- by far the largest record written, and the one most likely to exceed max_allowed_packet or time out. Keep the two writes concurrent (sequencing them would add a write round-trip to every commit on the editing hot path) and instead roll the in-memory head and atext back on failure, then re-persist the pad record, so the pad never points past its own history. If the rollback write also fails we log loudly rather than leaving a silent hole for an admin to discover months later via a failed cleanup run. Hook and author-index calls move out of the storage Promise.all so a throwing padUpdate hook cannot roll back a revision that was stored successfully. They still run concurrently with the writes. The attribute pool is deliberately not rolled back: pool entries are addressed by position, so removing one would invalidate the attribute numbers in every changeset already written. A pool author with no revisions is harmless -- pad.check() derives both sides of its author comparison from the pool. Fixes #8134 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3789912 to
4417623
Compare
The Windows backend-test jobs failed on AssertionError: the failed revision should not exist + actual: undefined - expected: null `assert.strict.equal(rec, null)` distinguishes null from undefined, and the storage driver yields null for an absent key on Linux but undefined on Windows. The assertion, not the behaviour, was platform-specific -- the revision is absent either way, and the code under test compares with `== null` throughout. Assert nullish-ness instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes #8134.
Problem
appendRevision()writes the revision record and the pad record (which carrieshead) as two independent writes in onePromise.all, after++this.head:If the revision write fails and the pad record lands, the pad claims a revision that was never stored. Nothing rolls back —
this.headstays advanced, the rejection just propagates up and disconnects the client — so the next successful append writeshead+1straight over the gap. It is permanent.Every later
pad.check()then trips on the missing revision:deleteRevisions()callscheck()before touching anything, so the pad can never be cleaned up or compacted again, and there is no supported way back.That is exactly #8134: revisions 599 and 601 present, 600 absent, "Cleanup revisions" refusing to run. Revision 600 being the casualty fits — it is a key revision (
Math.floor(rev/100)*100), so it embeds the entire attribute pool and atext. It is by far the largest record written, and so the one most likely to exceedmax_allowed_packetor time out. The reporter runsep_image_upload, which defaults tostorageType: 'base64'and puts full data URIs in the pool.Fix
The two writes stay concurrent — sequencing them would add a write round-trip to every commit on the editing hot path. Instead, on storage failure:
headandatextback to their pre-append values,headmatches the stored history,Hook and author-index calls move out of the storage
Promise.allso a throwingpadUpdatehook cannot roll back a revision that was stored successfully. They still start immediately and run concurrently with the writes.The attribute pool is deliberately not rolled back: pool entries are addressed by position, so removing one would invalidate the attribute numbers in every changeset already written. A pool author with no revisions is harmless —
pad.check()derives both sides of its author comparison from the pool, so they still agree.Tests
src/tests/backend/specs/appendRevisionAtomicity.tsfails the specific write and asserts the outcome:headboth roll back;atextrolls back; the next append reuses the revision number rather than skipping it; and the pad still passescheck()check()only walks0..head)padUpdatehook does not roll back a stored revision5 of the 7 fail without the
Pad.tschange. Full backend suite: 1628 passing, 0 failing.tsc --noEmitclean.Scope
This stops new holes forming. It does not repair pads that already have one — including the reporter's. Their revision 600 is genuinely unrecoverable: an identity changeset in its place just moves the failure to 601, because 601's changeset assumes 600's result. The realistic repair is rebuilding the history via full compaction, which needs #8139 fixed first (#8140).
🤖 Generated with Claude Code