[#286] Fix MVV marked-version corruption after interrupted prune#288
[#286] Fix MVV marked-version corruption after interrupted prune#288vharseko wants to merge 1 commit into
Conversation
maximthomas
left a comment
There was a problem hiding this comment.
Good fix — root cause is correct and I verified it against the tree: Buffer.pruneMvvValuesHelper (persistit/core/src/main/java/com/persistit/Buffer.java:3697) does pass a large in-page offset, so the old while (index < length) ran zero iterations there. No other file in core/src/main reads MVV lengths with signed Util.getShort, so the read-side fix is complete.
One issue I'd like addressed before merge, plus some smaller items.
1. The new finally bound is stricter than pass 1 — it can now leave a mark behind
persistit/core/src/main/java/com/persistit/MVV.java — prune()
Pass 1 iterates on from < offset + length; the new safety net iterates on index + LENGTH_PER_VERSION <= offset + length. Those are not the same set of indices, and the safety net must undo exactly what pass 1 did.
For a misaligned (corrupt) MVV — precisely the case this path exists for — pass 1's guard only rejects from > offset + length, so from can legally land inside the last 10 bytes. Example, offset = 0, length = 20, first version has vlength = 4:
- pass 1:
from = 1 + 4 + 10 = 15;15 > 20is false, so it iterates again, and maymark(bytes, 15)(writing bytes 23–24, i.e. outside the MVV region) before throwing"Multiple uncommitted versions". - new
finally:15 + 10 <= 20is false → loop exits → mark left set. - old code:
15 < 20was true → it did unmark. So this is a regression atoffset == 0.
Suggested bound — mirrors pass 1's iteration, still never reads past the array:
if (marked > 0) {
int index = offset + 1;
while (index < offset + length && index + LENGTH_PER_VERSION <= bytes.length) {
final int vlength = getLength(bytes, index);
unmark(bytes, index);
index += vlength + LENGTH_PER_VERSION;
}
}Alternative (bigger, but removes the whole class of problem): tighten pass 1's guard so a misaligned traversal is impossible, then both bounds agree:
from += vlength + LENGTH_PER_VERSION;
if (from != offset + length && from + LENGTH_PER_VERSION > offset + length) {
throw new CorruptValueException("MVV Value is corrupt at index: " + from);
}Note Debug.ENABLED == false, so the Debug.$assert0.t(...) in that block is a no-op and catches none of this.
2. Unfixed twin of the storeVersion bug
persistit/core/src/main/java/com/persistit/MVV.java:170 — exactRequiredLength has the same non-zero-offset defect you just fixed in storeVersion:
int offset = targetOffset + 1;
while (offset < targetLength) { // should be: offset < targetOffset + targetLength…and the return targetLength - valueLength + newVersionLength likewise ignores targetOffset.
Same reachability caveat as storeVersion: only via ValueHelper.MVVValueWriter, and Exchange._mvvValueWriter (persistit/core/src/main/java/com/persistit/Exchange.java:302) is declared but never used anywhere — I checked case-insensitively, declaration only. But persistit/core/src/main/java/com/persistit/Buffer.java:1636/1681/1991 do pass non-zero offsets into requiredLength/storeVersion through the ValueHelper interface; it's safe only because the MVV implementation is dead. Since you're already fixing one half, please fix both — or leave a // requires targetOffset == 0 note.
3. fetchVersionByOffset — reuse the canonical accessor
persistit/core/src/main/java/com/persistit/MVV.java
// current
final int length = (offset == 0) ? sourceLength : (Util.getChar(source, offset - LENGTH_VALUE_LENGTH) & MAX_LENGTH_MASK);
// suggested
final int length = (offset == 0) ? sourceLength : getLength(source, offset - LENGTH_PER_VERSION);Exactly equivalent (getLength reads at o + LENGTH_VERSION, and offset - 10 + 8 == offset - LENGTH_VALUE_LENGTH), and it uses the accessor whose consistent use is the whole point of this PR. Also drops the line from ~129 chars back under the file's ~120-col norm — it's currently the longest line in MVV.java.
4. visitAllVersions — trailing check is now dead
if (offset != end) {
throw new CorruptValueException("invalid length in MVV at offset/length=" + ...);
}The new per-version guard enforces offset + valueLength <= end, and the loop exits only when offset >= end, so offset == end always. Harmless, but it's no longer what catches truncation — worth a comment or removal so the next reader doesn't assume it's live.
5. visitOverrunningLengthThrows doesn't fail on the old code
persistit/core/src/test/java/com/persistit/MVVTest.java
The description says all five new tests fail before the fix; this one doesn't. Old code: offset += 50 → 61 != 14 → the trailing check threw the same CorruptValueException. (TestVisitor.sawVersion only writes to a map, so nothing throws earlier.)
The behaviour actually fixed is that sawVersion(version, 11, 50) is no longer invoked with an out-of-bounds length before throwing — which matters, because Exchange.MvvVisitor records that offset/length and fetchVersionByOffset would then copy 50 bytes. Suggest asserting that:
final TestVisitor visitor = new TestVisitor();
try {
MVV.visitAllVersions(visitor, source, 0, source.length);
fail("expected CorruptValueException");
} catch (final CorruptValueException expected) {
}
assertTrue("visitor must not see an out-of-bounds version", visitor.versions.isEmpty());6. TransactionTest2 — make the failure diagnosable
persistit/core/src/test/java/com/persistit/TransactionTest2.java
The new assertion is a genuine tightening — I confirmed _failedTransactionCount is incremented only for unexpected throwables (lines 317, 324), not for ordinary interrupt handling, so it shouldn't be flaky on its own.
Two small things:
-
"see stack traces above"isn't much help in a CI log. Record the first throwable so the assertion carries it:static AtomicReference<Throwable> _firstFailure = new AtomicReference<Throwable>(); // in the catch blocks that count a failure: _firstFailure.compareAndSet(null, throwable); // in the assertion: assertEquals("Worker threads died with unexpected exceptions; first was " + _firstFailure.get(), 0, _failedTransactionCount.get());
-
The counters are static and shared;
transactions()and the stranding test don't reset them. An@Beforereset (or instance fields) stops the next person re-stepping on this.
7. Optional follow-up — IntegrityCheck loses sight of leftover marks
persistit/core/src/main/java/com/persistit/IntegrityCheck.java:188 goes through visitAllVersions, which now strips marks silently. Pages already on disk carrying stale marks will read clean and icheck reports nothing; the mark survives in the length field until some future prune rewrites that version. Making marks readable is right; making them invisible is a small step back. Consider counting MVV.isMarked versions as a fault so affected volumes can be identified.
19fb065 to
b669a2f
Compare
|
Thanks for the thorough review — all points addressed in 19fb065...b669a2f (amended into the single commit). Point by point: 1. 2. 3. 4. Trailing check in 5. 6. 7. Verified locally on JDK 11: |
| /* | ||
| * Make sure all marks are removed even if this method exits via an | ||
| * Exception. | ||
| * Exception. Marks left behind in a live buffer page are read back | ||
| * later as part of the version length and corrupt the MVV (see | ||
| * issue #286). A zero-length version is legal (undefined value), | ||
| * so the loop must not stop on vlength == 0; it always advances by | ||
| * at least LENGTH_PER_VERSION. The iteration must mirror the first | ||
| * pass exactly — on a corrupt MVV that pass can visit a version | ||
| * starting inside the trailing LENGTH_PER_VERSION bytes, so this | ||
| * loop may not stop earlier; the only hard bound is the array end, | ||
| * past which mark() could not have written. | ||
| */ | ||
| if (marked > 0) { | ||
| int index = offset + 1; | ||
| while (index < length) { | ||
| while (index < offset + length && index + LENGTH_PER_VERSION <= bytes.length) { | ||
| final int vlength = getLength(bytes, index); | ||
| Debug.$assert0.t(vlength + index + LENGTH_PER_VERSION <= offset + length); | ||
| unmark(bytes, index); | ||
| index += vlength + LENGTH_PER_VERSION; | ||
| if (vlength <= 0) { | ||
| break; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Pass 1's tightened guard means it can't mark past offset + length, so the bytes.length
bound now lets this loop unmark() — write — outside the MVV region. At
offset=0, length=20, vlength=4: pass 1 marks at 1 and throws at from=15, then the net
writes bytes 23–24 (ff -> 7f) — the next record in the page. Mirror pass 1's
advance-then-check instead; the array-end bound is only needed for its unguarded first
iteration. 98 tests green locally, byte 23 untouched.
| /* | |
| * Make sure all marks are removed even if this method exits via an | |
| * Exception. | |
| * Exception. Marks left behind in a live buffer page are read back | |
| * later as part of the version length and corrupt the MVV (see | |
| * issue #286). A zero-length version is legal (undefined value), | |
| * so the loop must not stop on vlength == 0; it always advances by | |
| * at least LENGTH_PER_VERSION. The iteration must mirror the first | |
| * pass exactly — on a corrupt MVV that pass can visit a version | |
| * starting inside the trailing LENGTH_PER_VERSION bytes, so this | |
| * loop may not stop earlier; the only hard bound is the array end, | |
| * past which mark() could not have written. | |
| */ | |
| if (marked > 0) { | |
| int index = offset + 1; | |
| while (index < length) { | |
| while (index < offset + length && index + LENGTH_PER_VERSION <= bytes.length) { | |
| final int vlength = getLength(bytes, index); | |
| Debug.$assert0.t(vlength + index + LENGTH_PER_VERSION <= offset + length); | |
| unmark(bytes, index); | |
| index += vlength + LENGTH_PER_VERSION; | |
| if (vlength <= 0) { | |
| break; | |
| } | |
| } | |
| } | |
| /* | |
| * Remove marks left by pass 1 even when this method exits via an | |
| * exception: a mark left in a live page is read back as part of | |
| * the version length and corrupts the MVV (issue #286). Mirrors | |
| * pass 1 - same advance, same guard - so it touches exactly the | |
| * indices that pass could have marked, and nothing else. A | |
| * zero-length version is legal, hence no break on vlength == 0. | |
| * The array-end bound covers pass 1's unguarded first iteration. | |
| */ | |
| if (marked > 0) { | |
| int index = offset + 1; | |
| while (index + LENGTH_PER_VERSION <= bytes.length) { | |
| final int vlength = getLength(bytes, index); | |
| unmark(bytes, index); | |
| index += vlength + LENGTH_PER_VERSION; | |
| if (index + LENGTH_PER_VERSION > offset + length) { | |
| break; | |
| } | |
| } | |
| } |
There was a problem hiding this comment.
Applied as suggested — amended into d6ce5c9. You're right: the tightened pass-1 guard made the mirrored bound unsafe in the opposite direction — pass 1 now throws before marking the misaligned index, while the net still visited it, so its unmark() became a stray write outside the region. The advance-then-check net touches exactly the indices pass 1 could have marked.
Also pinned the scenario with a regression test, pruneExceptionMustNotUnmarkPastRegionEnd: one uncommitted version (so pass 1 marks it) plus a claimed region length that leaves the next version header straddling the region end, in a 0xFF-filled array. On the previous net it fails with the stray write at the region end (arrays first differed at element [139]: 0xff -> 0x7f); with this fix prune leaves the array byte-for-byte unchanged. Local runs: MVVTest 39/39; with MVCCBasicTest, MVCCPruneTest, MVCCPruneBufferTest, MVCCConcurrentTest, TransactionIndexTest, TransactionTest2 — 103 tests, 0 failures, 1 pre-existing skip.
MVV.prune's finally-block safety net used the wrong loop bound (index < length instead of index < offset + length), so a prune that exited via an exception - e.g. PersistitInterruptedException while resolving a commit status under interrupt - left mark bits set in the live buffer page whenever the MVV sat at a non-zero in-page offset. The fetch paths (visitAllVersions, fetchVersion, fetchVersionByOffset) then read the version length signed and with the mark bit included, driving the scan offset negative and crashing with ArrayIndexOutOfBoundsException, as seen in TransactionTest2.transactionsWithInterrupts on Windows JDK 11. - Make the finally-block unmark loop in MVV.prune mirror the first pass exactly - same advance, same guard, with the array-end bound covering the unguarded first iteration - and remove its early break on zero-length versions (a legal undefined value) - Tighten the first pass's corruption guard so a misaligned traversal throws CorruptValueException before it can read or mark outside the MVV region - Read version lengths unsigned with the mark bit stripped in visitAllVersions, fetchVersion and fetchVersionByOffset - Throw CorruptValueException instead of AIOOBE when a version header or value extends past the MVV bounds - Fix storeVersion reading target[0] instead of target[targetOffset] and exactRequiredLength's loop bound ignoring targetOffset when the MVV sits at a non-zero offset - Add deterministic regression tests for marked-version reads and for the interrupted-prune unmark safety net; make transactionsWithInterrupts assert no worker died with an unexpected exception, reporting the first failure, with shared counters reset in @before Fixes OpenIdentityPlatform#286
b669a2f to
d6ce5c9
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 Round 2 addressed in d6ce5c9 (details in the inline thread): the safety net is now the advance-then-check mirror of pass 1 you suggested, and a new regression test Local runs on the amended commit: |
A prune interrupted before the issue OpenIdentityPlatform#286 fix could leave 0x8000 mark bits behind in on-disk version length fields. Since PR OpenIdentityPlatform#288 the read paths strip the mark bit silently, so such versions read normally and icheck reported affected volumes as clean. Add MVV.countMarkedVersions and scan every data record during icheck: marked versions are counted in a new MarkedVersions counter (report, CSV and getMarkedVersionCount) and reported as a fault so affected volumes can be identified. The scan runs only after visitAllVersions has validated the version structure, so a structurally corrupt MVV cannot produce a bogus marked-version fault. icheck -p remains the remediation: pruning rewrites the marked versions (issue OpenIdentityPlatform#292 makes prune clear stale marks unconditionally). With icheck -P the marked-version faults are reported but do not block clearing the TransactionIndex, since the prune pass has already rewritten the marks. The icheck -c CSV example in Management.rst is updated for the new MarkedVersions column. Fixes OpenIdentityPlatform#290
Summary
Fixes the residual interrupt-corruption failure from #286:
TransactionTest2.transactionsWithInterruptsintermittently crashed withArrayIndexOutOfBoundsException: Index -32745 out of boundsinMVV.visitAllVersionsduring an MVCC fetch (Windows / JDK 11, interrupt stress).Root cause
Two cooperating defects in
MVV:Write side (the actual corruption).
MVV.prune'sfinally-block safety net, which is supposed to remove transient mark bits when prune exits via an exception, used the wrong loop bound:while (index < length)instead ofwhile (index < offset + length). For in-page pruning (Buffer.pruneMvvValuesHelperpasses a large in-pageoffset), the loop body never ran, so an interrupted prune (PersistitInterruptedExceptionout ofTransactionIndex.commitStatus, aTimeoutException, or aCorruptValueException) left0x8000mark bits set in the version length fields of the live buffer page. The exclusive claim held during pruning means this leftover state — not any transient race — is the only way readers can ever observe marks. The observed stack (the crash surfaced in the main thread's final balance scan, after all workers had died) confirms the marks persisted in the page.Read side (the crash). The fetch paths —
visitAllVersions,fetchVersion,fetchVersionByOffset— read the per-version length with signedUtil.getShortand without stripping the mark bit, unlike the canonicalgetLength()accessor (Util.getChar & MAX_LENGTH_MASK). A marked version therefore produced a negative length (L | 0x8000sign-extended), drove the scan offset negative, and the nextUtil.getLongthrew a raw AIOOBE. The observed index-32745matches this arithmetic exactly (offset_ver + 10 + L - 32768withoffset_ver + L = 13).Changes
MVV.prune: make thefinally-block unmark loop mirror the first pass exactly (same advance, same guard, with the array-end bound covering the unguarded first iteration) and tighten the first pass's corruption guard so a misaligned traversal throwsCorruptValueExceptionbefore it can read or mark outside the MVV region; drop the earlybreakon zero-length versions (a zero-length version is a legal undefined value, and the loop always advances by at leastLENGTH_PER_VERSION).MVV.visitAllVersions/fetchVersion/fetchVersionByOffset: read version lengths unsigned with the mark bit stripped, matchinggetLength(). Leftover marks (including any already persisted to disk) become harmless — the true length is read.CorruptValueExceptioninstead of escaping as an AIOOBE.MVV.storeVersion: fix a latent bug found while writing the regression test — the existing-MVV detection readtarget[0]instead oftarget[targetOffset], so at a non-zero offset an existing MVV was treated as a primordial value and nested. (Unreachable in production today:Exchangemerges MVVs in spare buffers at offset 0 and writes pages viaRawValueWriter.)Tests
MVVTest: six new deterministic regression tests — marked-version reads through all three fetch paths, corrupt-length inputs now throwingCorruptValueException, and two prune safety-net tests:pruneExceptionUnmarksVersionsAtNonZeroOffsetreproduces the interrupted-prune scenario (two uncommitted versions at a non-zero offset → prune throws after marking) and asserts the byte array is left unchanged, whilepruneExceptionMustNotUnmarkPastRegionEndcovers the misaligned-MVV case where the net must stop at the same point as the first pass's corruption guard instead of writing past the region end. All six fail on the previous code;visitMarkedVersionreproduces the CI signature (ArrayIndexOutOfBoundsException: Index -32747).TransactionTest2.transactionsWithInterrupts: reset the shared static counters at the start and assert_failedTransactionCount == 0at the end, so a worker dying with an unexpected exception fails the test loudly instead of only printing a stack trace.Local runs:
MVVTest39/39,MVCCBasicTest+MVCCPruneTest+MVCCPruneBufferTest+MVCCConcurrentTest+TransactionIndexTest97 green (1 pre-existing skip),TransactionTest25/5.Fixes #286