Skip to content

[#290] Report leftover MVV mark bits in IntegrityCheck#291

Open
vharseko wants to merge 3 commits into
OpenIdentityPlatform:masterfrom
vharseko:persistit/icheck-leftover-mvv-marks-290
Open

[#290] Report leftover MVV mark bits in IntegrityCheck#291
vharseko wants to merge 3 commits into
OpenIdentityPlatform:masterfrom
vharseko:persistit/icheck-leftover-mvv-marks-290

Conversation

@vharseko

@vharseko vharseko commented Jul 21, 2026

Copy link
Copy Markdown
Member

Summary

Implements the proposal from #290 (split out of PR #288, suggested by @maximthomas in the #288 review): IntegrityCheck now detects and reports MVV versions whose length field still carries a leftover 0x8000 mark bit — the on-disk residue of a prune interrupted before the #286 fix. Since PR #288 the read paths strip the mark bit silently, so such versions read normally and icheck reported affected volumes as clean; the mark survived until some future prune rewrote that version.

Note: stacked on PR #288 and PR #293 — the first two commits here are theirs. Only the last commit (Report leftover MVV mark bits in IntegrityCheck) is new; this PR becomes a one-commit diff once they merge. Merge order: #288#293 → this PR, so pruning is already safe (#292) by the time this PR documents icheck -p as the remediation.

Changes

  • MVV.countMarkedVersions(bytes, offset, length): a dedicated scan counting versions with the mark bit set. A separate scan rather than an extension of MVV.VersionVisitor: the interface has six implementations, and sawVersion hands out the post-header value offset (with no header at all for primordial values), so a visitor cannot see the mark anyway.
  • IntegrityCheck: visitDataRecord scans each record for marks after visitAllVersions has validated the version structure, so a structurally corrupt MVV cannot produce a bogus marked-version fault next to its genuine CorruptValueException fault. Marked versions feed a new MarkedVersions counter (plain report, CSV, and a getMarkedVersionCount() getter) and add a Fault ("MVV has N versions with a leftover mark bit") with the page address and record position, so hasFaults() flags affected volumes.
  • icheck -p remains the remediation path: verifyPage runs buffer.verify before pruning, so marks are reported first and then rewritten by the prune ([persistit] MVV.prune can promote a stale-marked old version during primordial conversion #292 makes prune clear stale marks unconditionally).
  • icheck -P (pruneAndClear): marked-version faults are reported but do not block resetMVVCounts — by the time the run completes, the prune pass has already rewritten the marks, so only other faults and incomplete pruning count as failures. Without this, -P could never succeed on an affected volume.

Output format note: the plain report and the icheck -c CSV gain a trailing MarkedVersions column — breaking for anything parsing icheck output. The CSV example in doc/Management.rst is updated accordingly.

Tests

  • MVVTest: countMarkedVersions (mark/unmark transitions over a two-version MVV) and countMarkedVersionsNonMVV (non-MVV, zero and negative lengths).
  • IntegrityCheckTest:
    • corrupt2/corrupt5 share a corruptMVVs helper taking the mutation as a callback, with try/finally around ignoreMVCCFetch. corrupt5 marks every version of each corrupted MVV, so the marked-version count exceeds the fault count (one fault per record) and the plural fault message is exercised.
    • testLeftoverMarkBitsReported asserts a clean tree reports nothing and a corrupted tree reports exactly one fault per corrupted record plus the exact marked-version total.
    • testPruneClearsLeftoverMarkBits snapshots the visible key/value contents before corruption and asserts remediation (icheck with pruning) leaves them unchanged, then that a subsequent icheck is clean.
    • testPruneAndClearWithLeftoverMarkBits runs the real task path (CLI.parseTask("icheck trees=* -u -P")) and asserts the faults are reported while the TransactionIndex is still cleared ("aborted transactions were cleared by pruning", not "PruneAndClear failed").

Local runs: IntegrityCheckTest 11/11, MVVTest 44/44, MVCCBasicTest + MVCCPruneTest + MVCCPruneBufferTest + TransactionIndexTest + Bug1017957Test all green (1 pre-existing skip).

Fixes #290

@maximthomas maximthomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The detection half is solid — countMarkedVersions terminates safely (getLength() masks to 0x7FFF, so from advances by at least LENGTH_PER_VERSION), the dedicated scan is the right call over extending VersionVisitor, and MVVTest (40/40) + IntegrityCheckTest (10/10) are green on the branch.

The problem is the remediation the PR prescribes. Requesting changes on that.

icheck -p remediation silently resurrects deleted keys (high)

The PR says "icheck -p remains the remediation path … marks are reported first and then rewritten by the prune." That is not safe: MVV.prune uses the same bit as its private "keep this version" flag and never clears pre-existing marks before pass 1. A leftover mark is therefore read as prune's own decision to keep:

  • persistit/core/src/main/java/com/persistit/MVV.java:530-546 — the primordial branch promotes the first marked version it finds. With a stale mark on an older version, the obsolete version wins. Note the branch is still entered: marked counts only prune's own mark() calls (1), while two versions actually carry the bit.
  • persistit/core/src/main/java/com/persistit/MVV.java:508-524 — pass 2 registers only !isMarked versions in prunedVersionList. The marked version that pass 3 doesn't promote is therefore discarded without ever being registered, so its long-record page (lines 515-520) is never deallocated. Observed: prunedVersionList is empty in the stale-mark run vs. one entry in the control.

Reproduced on this branch at unit level (two committed versions, mark on the older one, MVV.prune called directly):

leftoverMark=false  survivor=[22 22]     prunedVersionList.size=1   <- correct, newest version
leftoverMark=true   survivor=[11 11 11]  prunedVersionList.size=0   <- stale version promoted

And end-to-end through the exact path the PR recommends — testPruneClearsLeftoverMarkBits plus a before/after snapshot of the visible key set, with a control run that calls corrupt5's ex.store() but skips MVV.mark:

>>> doMark=false  markedVersions=0   faults=0   keysBefore=500  keysAfter=500
>>> doMark=true   markedVersions=10  faults=10  keysBefore=500  keysAfter=505

icheck -p undeleted 5 transactionally removed keys. The mark sat on the value version, so prune promoted it instead of the AntiValue. The control rules out corrupt5's non-transactional store as the cause.

The bug lives in MVV.prune and predates this PR, but this PR is what tells operators to run icheck -p on affected volumes, and testPruneClearsLeftoverMarkBits asserts remediation "works" while only checking counters. Suggested:

  1. Make prune defensive about its own invariant — clear any pre-existing marks before pass 1 (you already have the scan):
// MVV.prune, before the first pass
if (countMarkedVersions(bytes, offset, length) > 0) {
    int from = offset + 1;
    while (from + LENGTH_PER_VERSION <= offset + length) {
        final int vlength = getLength(bytes, from);
        unmark(bytes, from);
        from += vlength + LENGTH_PER_VERSION;
    }
}
  1. Add the data-preservation assertion to testPruneClearsLeftoverMarkBits in
    persistit/core/src/test/java/com/persistit/IntegrityCheckTest.java:132 — snapshot the visible key/value set before corruption, compare after remediation. That single assertion is what surfaces this.

icheck -P (pruneAndClear) regresses on affected volumes (medium)

_faults is never cleared (reset() at persistit/core/src/main/java/com/persistit/IntegrityCheck.java:786 doesn't touch it), and the new fault is added during buffer.verify — i.e. before the prune in the same verifyPage call. So the gate at persistit/core/src/main/java/com/persistit/IntegrityCheck.java:315 can never pass on an affected volume: resetMVVCounts is skipped even though the prune fully rewrote the marks.

Confirmed by running the real runTask() path (icheck trees=* -u -P -v), against a control that stores the same records without setting the mark bit:

>>> doMark=false  marked=0   faults=0   -> "0 aborted transactions were cleared by pruning"
>>> doMark=true   marked=10  faults=10  -> "PruneAndClear failed to remove all aborted MMVs"

Before this PR the same volume ran -P to completion.

Options: keep marked-version faults in a separate list excluded from the -P gate; re-verify after pruning and drop the fault when the count reaches zero; or at minimum document "run icheck -P twice".

Marked-version count runs on an unvalidated MVV (low)

persistit/core/src/main/java/com/persistit/IntegrityCheck.java:206 calls countMarkedVersions before visitAllVersions validates the record, so a structurally corrupt MVV is walked with garbage lengths. Confirmed — an MVV with only a damaged length field and no mark bit anywhere:

intact  -> marked=0
corrupt -> marked=1

That yields a bogus MVV has 1 version with a leftover mark bit fault and an inflated MarkedVersions counter next to the genuine CorruptValueException fault — misleading exactly when an operator is using the counter to answer "am I affected by #286?". The inline comment says the count "must be counted before visitAllVersions", but there is no such constraint — visitAllVersions does not mutate bytes. Counting after a clean traversal (or sharing one traversal) fixes it.

Nits

  • Doc drift: persistit/doc/Management.rst:136 documents the icheck -c CSV header verbatim and isn't updated with MarkedVersions. Worth noting in the PR description that both the CSV columns and the plain-report format changed — breaking for anything parsing icheck output.
  • Redundant page tracking: verifyPage already holds page at persistit/core/src/main/java/com/persistit/IntegrityCheck.java:1187; a field set there would remove the visitPage override and the duplicated _currentPage state.
  • Copyright header: MVV.java and MVVTest.java carry Portions Copyrighted 2026 3A Systems, LLC; persistit/core/src/test/java/com/persistit/IntegrityCheckTest.java is modified without it (recent commits 3f8c98909, cca1422d7 do add it).
  • corrupt5 duplication: it is a byte-for-byte copy of corrupt2 except for the mutation — a shared helper taking the mutation would be cleaner. Both also leak ignoreMVCCFetch(true) if the loop throws (no finally).
  • Test granularity: testLeftoverMarkBitsReported marks one version per record, so faults == markedVersionCount by construction. Marking two versions in one record would cover the counter/fault distinction and the plural() branch.
  • Line length: persistit/core/src/main/java/com/persistit/IntegrityCheck.java:209 is 121 chars, one over the file's 120-col wrap (cosmetic — no formatter config in the repo).

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
@vharseko
vharseko force-pushed the persistit/icheck-leftover-mvv-marks-290 branch from 69b3e44 to d9ed39f Compare July 21, 2026 16:44
vharseko added 2 commits July 21, 2026 20:15
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
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
@vharseko
vharseko force-pushed the persistit/icheck-leftover-mvv-marks-290 branch from d9ed39f to 79b2cd7 Compare July 21, 2026 17:17
@vharseko

Copy link
Copy Markdown
Member Author

Thanks for the thorough review — the prune reproduction especially. All points addressed; the branch is force-pushed as 79b2cd7 on a reordered stack.

icheck -p resurrects deleted keys (high) — confirmed and split into its own issue/PR, since the bug lives in MVV.prune and predates this PR: #292 / #293. The fix is your suggestion, minus the pre-scan: prune now unconditionally sweeps all mark bits in the region clean before pass 1, so mark state is consistent regardless of what is on disk. The stack is reordered to #288#293#291, so pruning is already safe by the time this PR documents icheck -p as the remediation. The data-preservation assertion you asked for is in testPruneClearsLeftoverMarkBits: it snapshots the visible key/value contents before corruption and asserts remediation leaves them unchanged — exactly the assertion that failed before #293 (500 → 505 keys).

icheck -P regression (medium) — fixed. addFault now reports whether the fault was recorded, marked-version faults are counted separately, and the resetMVVCounts gate treats a run as successful when the only faults are marked-version faults (the prune pass has already rewritten those marks). Other faults and incomplete pruning still block. New testPruneAndClearWithLeftoverMarkBits runs the real task path (CLI.parseTask("icheck trees=* -u -P")) and asserts the faults are reported while the output says "aborted transactions were cleared by pruning" rather than "PruneAndClear failed".

Count on an unvalidated MVV (low) — fixed: countMarkedVersions now runs after visitAllVersions has validated the version structure, so a structurally corrupt MVV produces only its genuine CorruptValueException fault. You were right that the "must be counted before" comment claimed a constraint that doesn't exist; it's rewritten.

Nits:

  • Doc driftManagement.rst CSV example updated with the MarkedVersions column; the PR description now calls out the report/CSV format change as breaking for parsers.
  • Redundant page tracking — the visitPage override is gone; verifyPage sets a _currentPage field directly.
  • Copyright headerIntegrityCheckTest.java already carries the Portions line (added in Enable persistit tests on JDK 26 and re-enable passing ignored tests #240, line 4), so no change was needed there.
  • corrupt5 duplicationcorrupt2/corrupt5 now share a corruptMVVs helper taking the mutation as a callback, with try/finally around ignoreMVCCFetch.
  • Test granularitycorrupt5 now marks every version of each corrupted MVV, so the marked-version count strictly exceeds the fault count and the plural fault message is exercised. (Attempting "mark the first two versions" surfaced that a first-store MVV has a single version slot — no undefined initial slot — so per-record version counts vary between inserted and removed keys.)
  • Line length — the 121-char line went away with the refactor; the three remaining >120 lines in the file predate this PR.

Local runs on the new head: IntegrityCheckTest 11/11, MVVTest 44/44, MVCCBasicTest + MVCCPruneTest + MVCCPruneBufferTest + TransactionIndexTest + Bug1017957Test all green (1 pre-existing skip).

@vharseko
vharseko requested a review from maximthomas July 21, 2026 17:20
@vharseko

Copy link
Copy Markdown
Member Author

@maximthomas ready for another look — all review points are addressed, see the point-by-point summary above (#291 (comment)). New head is 79b2cd7.

Highlights:

#293 would need your review as well — it carries the actual MVV.prune fix and is intended to merge right after #288.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement tests Test code changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[persistit] IntegrityCheck should report leftover MVV mark bits as a fault

2 participants