[#292] Clear stale MVV mark bits before pruning - #293
Conversation
4dabb6c to
14f35f8
Compare
MVV.prune uses the 0x8000 mark bit in the version length field as transient private state and assumes no version is marked on entry. A volume corrupted by a prune interrupted before the OpenIdentityPlatform#288 fix violates that assumption: a leftover mark on an obsolete version won the primordial-conversion scan, resurrecting the old value (or an undefined value) while silently dropping the most recent committed version with no PrunedVersion accounting for it — leaking the MVV count and, for long records, the page chain. In the multi-version path a stale-marked dead version survived one extra prune round with its accounting deferred. Sweep all mark bits in the region clean before the first pass, so mark state is guaranteed consistent regardless of what is on disk. The sweep never touches bytes outside the MVV region even on corrupt input. Fixes OpenIdentityPlatform#292
maximthomas
left a comment
There was a problem hiding this comment.
The diagnosis and the fix are right, and the three regression tests are real: with the sweep deleted they fail exactly as described (expected:<3> but was:<2>, expected:<3> but was:<0>, both dead versions must be pruned in one round expected:<14> but was:<25>). MVVTest is 42/42 with it. One change requested before merge.
Sweep writes into value bytes of a misaligned MVV (moderate)
persistit/core/src/main/java/com/persistit/MVV.java:453-456 traverses with no alignment validation, unlike pass 1. On a corrupt MVV whose length field misaligns the traversal, unmark() clears bit 15 of a byte that is value payload, not a version header — inside the region, so #288's pruneExceptionMustNotUnmarkPastRegionEnd does not catch it.
This is a new exposure, not inherited. The old finally net is gated on marked > 0; the sweep is unconditional. Probe: offset=7, v1 = 4 bytes, v2 = 20 bytes of 0xFF, both versions ABORTED so pass 1 marks nothing, then MVV.putLength(bytes, offset + 1, 9) to misalign.
| result | |
|---|---|
base d6ce5c9c6 |
CorruptValueException, array unchanged |
this PR 14f35f8f4 |
CorruptValueException, byte at index 35 changed 0xFF → 0x7F |
Trace: from = 8 → unmark (no-op) → from = 8 + 9 + 10 = 27; 27 + 10 <= 52 passes, so unmark(bytes, 27) writes the char at bytes[35..36], which is v2's data. getLength(27) then reads 0x7FFF and from jumps to 32804, where pass 1's guard finally throws — after the write.
Buffer.pruneMvvValuesHelper runs on every cleanup pass and on icheck -p, so an already-corrupt record gets a data byte flipped before the corruption is reported.
MVV.verify (persistit/core/src/main/java/com/persistit/MVV.java:628) is exactly the needed check — it requires exact alignment and masks the mark bit, so a stale-marked but well-formed MVV still passes and still gets swept. prune already calls it at line 427 under Debug.$assert0.t, so hoisting it costs nothing in a Debug build:
final boolean wellFormed = verify(bytes, offset, length);
Debug.$assert0.t(wellFormed); // replaces the existing assert at MVV.java:427
...
if (wellFormed) {
int sweep = offset + 1;
while (sweep + LENGTH_PER_VERSION <= offset + length) {
unmark(bytes, sweep);
sweep += getLength(bytes, sweep) + LENGTH_PER_VERSION;
}
}Skipping the sweep on a malformed region just defers to pass 1's guard, which throws anyway. Worth a test alongside pruneExceptionMustNotUnmarkPastRegionEnd covering intra-region misalignment, since that one only guards the region end.
Nits
fromreuse:persistit/core/src/main/java/com/persistit/MVV.java:453-457borrows pass 1's loop variable and resets it withfrom = offset + 1;. A dedicated local (sweepabove) reads better and drops the reset line.- Comment should state why the sweep is separate: folding
unmark(bytes, from)into pass 1's loop head would be free — pass 1 visits every version andgetLengthalready masks the mark. The separate pass buys one thing: it repairs the whole region even when pass 1 throws early. Worth saying, since it is the only reason not to inline it. - PR description overstates the bound: "the sweep never touches bytes outside the MVV region even on corrupt input" is true of the region end only; see above.
- Stale assertion message:
pruneExceptionUnmarksVersionsAtNonZeroOffsetstill asserts "prune must leave the byte array unchanged when it throws". It passes (nothing is marked on entry) but now claims more than the amended javadoc promises. - Pre-existing, not this PR — worth a line in the description:
Buffer.pruneMvvValuesHelper:3700setschangedonly whennewSize != oldSize, andpruneMvvValues:3624dirties the page only whenchanged. When every version is a keeper, prune clears the stale mark but returns the same size, so the repair is never persisted andicheckkeeps reporting the volume. Pass 3 already behaved this way before the sweep (MVV.java:585-587), so nothing regresses — but since #291 will point users here as the remediation, the limitation is worth naming.
14f35f8 to
63ca4db
Compare
The up-front sweep introduced for issue OpenIdentityPlatform#292 followed length fields with no alignment validation: on an MVV with a corrupt length field the traversal landed inside a value and unmark() cleared bit 15 of a payload byte — inside the region, before the first pass's guard could reject it. Hoist the verify() call that already runs on every prune and sweep only well-formed regions; a malformed region is left to the first pass's guard, which throws without writing anything.
|
All points addressed in d65c96c. Sweep writes into value bytes of a misaligned MVV — confirmed your probe exactly as described before fixing: with the unconditional sweep, Test — added Nits — the sweep uses a dedicated
|
Fixes #292. Stacked on #288 (now merged) — this PR is a clean diff on master. Intended merge order: this PR → #291, so that by the time #291 documents
icheck -pas the remediation path, pruning is already safe on affected volumes.MVV.pruneuses the0x8000mark bit in the version length field as transient private state: pass 1 marks the versions to keep, pass 3 removes the unmarked ones. This assumes no version is marked on entry. A volume corrupted by a prune interrupted before the #288 fix violates that assumption, and themarkedcounter only counts marks set by the current run whileisMarked()sees any bit:prunedVersionList— the MVV count was never decremented and a long record's page chain was never deallocated.The fix sweeps all mark bits in the region clean before pass 1, mirroring the
finally-block safety net, so mark state is guaranteed consistent regardless of what is on disk. Following review, the sweep is gated onMVV.verify, hoisted from the existing debug assert — its argument was already evaluated on every prune call, so the check adds no cost. A stale-marked but well-formed MVV still passesverify(the length accessors mask the mark bit) and still gets swept; a malformed MVV is never swept — it is rejected by pass 1's guard with aCorruptValueExceptionand left byte-for-byte unchanged. Without the gate, a corrupt length field could misalign the sweep's traversal andunmark()would clear bit 15 of a value-payload byte inside the region.Four regression tests: three cover each scenario above (with the sweep disabled they fail exactly as described in the issue — obsolete value promoted, undefined promoted, dead version surviving the prune), and
pruneMustNotSweepMisalignedMvvcovers the misaligned case (with theverifygate removed it fails with a payload byte flipped0xFF → 0x7F). The tests are self-contained — the no-marks-remain assertion walks the versions withMVV.isMarkeddirectly, so this PR does not depend on thecountMarkedVersionsscan introduced in #291.MVVTest43/43; related suites (IntegrityCheckTest,MVCCBasicTest,MVCCPruneTest,MVCCPruneBufferTest,TransactionIndexTest,Bug1017957Test) all green.Pre-existing limitation, unchanged by this PR:
Buffer.pruneMvvValuesHelperdirties the page only when prune changes the value's size (newSize != oldSize), so a mark-only repair is not persisted until the next size-changing prune —icheckkeeps reporting the volume until then. Pass 3 behaved the same way before the sweep.Detection of the corruption is covered by #290/#291 (
icheckfault +MarkedVersionscounter); this PR makes the remediation path itself safe.