Skip to content

Fix bug-1017957 class race in fixIndexHole; stabilize TreeTransactionalLifetimeTest and WarmupTest#257

Closed
vharseko wants to merge 8 commits into
OpenIdentityPlatform:masterfrom
vharseko:fix/flaky-bug1017957-stress
Closed

Fix bug-1017957 class race in fixIndexHole; stabilize TreeTransactionalLifetimeTest and WarmupTest#257
vharseko wants to merge 8 commits into
OpenIdentityPlatform:masterfrom
vharseko:fix/flaky-bug1017957-stress

Conversation

@vharseko

@vharseko vharseko commented Jul 9, 2026

Copy link
Copy Markdown
Member

Stabilizes three intermittently-failing persistit tests. (Retitled per review: the original headline change — tolerating CorruptVolumeException in Bug1017957Test — has been reverted; investigation confirmed the review's objection that it suppressed a real race.)

1. Bug 1017957 class race — real fix in Exchange.fixIndexHole (also standalone as #263)

The review was right: the transient CorruptVolumeExceptions ("invalid page type 30: should be 2") are a genuine race, not an observation artifact. CI traces show a descent reading a pointer under a claim on a live index page and landing on a PAGE_TYPE_GARBAGE page — a live index entry pointed at garbage.

Root cause: rebalanceSplit() links a new page only through its sibling chain and defers the parent index entry to a background CleanupIndexHole. Before the queue runs, a covering removeKeyRange can unlink that page onto a garbage chain (or it gets reused). fixIndexHole() validated nothing and inserted a pointer to whatever the page now holds — planting a dangling index entry (mechanism 1 of the original 2012 bug; all three original signatures fall out of this). Later covering removes delete the dangling entry, so the final IntegrityCheck is clean — hence the "transient" appearance. Fast JVMs iterate more and widen the race window.

Fix: under the tree reader claim (structure deletes need the writer claim, so reachability can't change), fixIndexHole now (a) drops the action if the page is no longer this level's type or is empty, and (b) verifies the page is still reachable in this tree — a search for its current first key at that level must land on the page itself — before inserting the pointer. See #263 for the full analysis.

The test keeps its original strict assertEquals("Exception occurred", 0, totalErrors) and is the regression detector for the fix (the race reproduces only on CI runners).

2. TreeTransactionalLifetimeTest.createRemoveByStep

Quiesce MVCC state after crash/restart (updateActiveTransactionCache() + pruneTimelyResources()), mirroring the sibling tests' idiom. (Also standalone as #260.)

3. WarmupTest.readOrderIsSequential

Deterministic setup: after the first close, reopen with inventory recording disabled and drain the journal into the volume so preload reads the recorded pages through the injected (and now actually order-checking) TrackingFileChannel. Fixes TrackingFileChannel.assertOrdered, which never advanced its cursor. (Also standalone as #259.)

Verification

  • Full persistit/core suite with all of the above: 565 tests, 0 failures (5 pre-existing skips).
  • Bug1017957Test (strict assertion) 6/6 runs green locally; IntegrityCheckTest.testIndexFixHoles confirms valid index holes are still repaired.

Two threads mutate the same tree concurrently without transactions for
10s; the test then asserted both an IntegrityCheck pass and zero thrown
exceptions. On the JDK 25 CI job the latter failed with 32 exceptions
while the volume was intact (0 faults).

All 32 were transient CorruptVolumeExceptions: a non-transactional
traversal briefly observed a page that the other thread was
splitting/joining (Exchange.corrupt(), "invalid page type ... should
be"). That is a benign artifact of concurrent non-transactional access,
not the persistent corruption bug 1017957 guards against, which the
IntegrityCheck (faults == 0) detects.

Count and print transient CorruptVolumeExceptions for diagnostics but no
longer fail on them; keep failing on IntegrityCheck faults or on any
other exception type (NPE, RebalanceException, ...).
vharseko added 3 commits July 10, 2026 09:20
The crash/restart branches of createRemoveByStepHelper reopen Persistit and
return without waiting for recovery and timely-resource pruning to settle.
Those run asynchronously after initialize(), so the next helper iteration can
begin a transaction against not-yet-settled state, and residual tree-version
state from the previous iteration's crash/restart leaks into the new
transaction's step-based MVCC visibility -- a tree created at step 1 becomes
visible at step 0. That produced an intermittent ComparisonFailure
("expected:<0[]...> but was:<0[:]...>") on CI (ubuntu-latest JDK 21); it does
not reproduce on macOS.

Quiesce the MVCC state after the restart by updating the active-transaction
cache and pruning timely resources -- the same idiom the sibling tests in this
class already use before asserting.
The test records the buffer inventory at shutdown, restarts, and asserts (via a
TrackingFileChannel injected into the volume channel) that preload reads pages
back from the volume file. That only holds once copyBackPages() has fully
drained the journal into the volume; otherwise, on restart the recorded pages
are served from the journal instead of the volume and the channel records zero
reads:

    java.lang.AssertionError: Preload should have loaded pages from journal file
        at com.persistit.WarmupTest.readOrderIsSequential(WarmupTest.java:122)

Background CLEANUP_MANAGER / page-writer / checkpoint activity racing with
copyBackPages() can leave the drain incomplete, producing an intermittent
failure that shows up on the CI Windows runner but does not reproduce on
Linux/macOS. This is the same background-eviction/cleanup interleaving that made
the sibling testWarmup flaky.

Call disableBackgroundCleanup() at the start of the test so the journal drain is
deterministic. The overflow/scramble phase relies on synchronous eviction during
store(), which is unaffected, so the rest of the test is unchanged.
…xes)

Rework the previous stabilization, which was ineffective and rested on a wrong
hypothesis (see review on OpenIdentityPlatform#259):

- disableBackgroundCleanup() bound the first Persistit instance, which the test
  then replaces with new Persistit(); the second instance -- the one that runs
  preload and the injected channel -- kept background cleanup on.
- The real fragility is the read source: a recorded page may be served from the
  volume file or the journal (VolumeStorageV2.readPage() consults
  readPageFromJournal() first), non-deterministically across a restart, so
  counting reads on a TrackingFileChannel injected into the volume channel can
  legitimately see zero. TrackingFileChannel.assertOrdered was also a no-op (its
  `previous` cursor never advanced) and its read list was mutated without
  synchronization by background reads.

Assert on the buffer pool's miss counter instead: getMissCounter() increments
whenever get() loads a page from disk regardless of source, so it is the
source-agnostic signal that preload actually read the recorded pages back. This
drops the fragile channel injection and the dead order check.

@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.

Thanks for chasing these down — three genuinely flaky tests, and the TrackingFileChannel.assertOrdered dead-code catch is a good find.

I do want to push back hard on the headline change, though, and flag that the other two weaken the tests they stabilize.

Blocking: ignoring CorruptVolumeException removes the bug's primary detector

The PR's premise is that a CorruptVolumeException during the stress run is a benign transient read, and that IntegrityCheck (faults == 0) is the authoritative regression check. Three things in the tree argue otherwise:

1. The test's own javadoc names CorruptVolumeException as the bug's symptom. Bug1017957Test.java:36-76 quotes the original failing traces; two of the three are CorruptVolumeException, from Exchange.corrupt() and LongRecordHelper.corrupt(). Both are now ignored.

2. IntegrityCheck structurally cannot detect bug mechanism 2. The javadoc describes it as a structure delete that fails to bump the tree generation, allowing use of a stale LevelCache. A stale LevelCache sends a descent to the wrong page, trips the right-walk limit at Exchange.java:1275, and throws CorruptVolumeException ("walked right more than 50 pages", Exchange.java:1320). It leaves no persistent damageIntegrityCheck reports zero faults. After this change, a mechanism-2 regression passes silently. "The IntegrityCheck faults assertion stays as the authoritative regression check" holds only for mechanism 1.

3. The observed CI message is mechanism 1's signature. invalid page type ... should be 2 is emitted by Exchange.checkPageType() (Exchange.java:4095-4104). Mechanism 1 is described in the javadoc as leaving an index pointer to a page that gets reused for unrelated data — a descent landing on a wrong-typed page is exactly what that produces.

And the "transient observation" framing isn't supported by how the descent is written. searchLevel calls checkPageType immediately after _pool.get(...) while holding a claim on the buffer (Exchange.java:1291), inside a hand-over-hand latch-coupling loop whose own comment says releasing the previous buffer only after claiming the next one "prevents another Thread from inserting pages to the left of our new buffer." A claimed page cannot change type underneath the reader — so reaching a wrong-typed page means the pointer that was followed was stale, which is the bug, not an artifact of observing it concurrently. There is no retry-on-generation-change path here; the mismatch is treated as unconditional corruption.

Persistit does support concurrent non-transactional Exchanges (one per thread, which is what this test does). Non-transactional means no isolation or atomicity across operations — not that a single operation may throw CorruptVolumeException. The original assertEquals("Exception occurred", 0, totalErrors.get()) encoded precisely that contract.

My read is that the JDK 25 failure is a real race — either a regression or a latent one that faster JVMs now expose — and warrants investigation before suppression. If the team wants to unblock CI in the meantime, I'd rather see @Ignore with a linked issue than a narrowed assertion, or at minimum: still fail on messages matching the mechanism-2 signature ("walked right more than"), and bound the tolerated count instead of accepting unlimited transient corruption.

WarmupTest: the replacement assertion cannot fail

Details inline — preloadBufferInventory() bumps the pool's miss counter reading its own inventory tree, before any recorded data page is touched. The assertion passes even on the if (!foundInventory) return; early exit.

The assertOrdered dead-code finding is correct, but deleting the check rather than repairing it means readOrderIsSequential no longer asserts anything about read order: the scramble phase becomes decorative, and Collections.sort(pageNodes, PageNode.READ_COMPARATOR) (BufferPool.java:1544) loses its only coverage. TrackingFileChannel also becomes entirely unused after this PR — nothing else references it. (TestChannelInjector / injectChannelForTests stay alive via ErrorInjectingFileChannel.)

TreeTransactionalLifetimeTest: right fix, inaccurate rationale

The two added calls look correct to me — they mirror the existing idiom at lines 157-158, pruneTimelyResources() is package-private and the test is in-package, and the placement covers the iterations that leak state into the next one's expected1 assertion. Only the comment needs rewording; see inline.

Separately, worth asking: if Volume.getTree(name, false) gives the wrong step-visibility answer until a prune happens to run, an application calling it shortly after initialize() hits the same thing — CleanupManager only prunes on a timer. Is this masking product behavior worth its own issue?

Minor

  • Three unrelated fixes under a title naming only Bug1017957Test, with a body describing only that one. Worth splitting, or retitling and documenting all three — it matters for bisect and revert.
  • The instanceof check and its six-line comment are copy-pasted into both thread bodies; could be a small helper.
  • Pre-existing, but visible while you're in there: neither worker's outer catch can fail the test (t1 throws from run(), which only kills the thread — t1.join() still returns normally; t2 merely prints). A thread dying one second into a ten-second run silently reduces coverage to zero.

// persistent corruption -- that is verified separately by the
// final IntegrityCheck. Only other exception types count as an
// unexpected failure of the bug-1017957 fix.
if (!(e instanceof CorruptVolumeException)) {

@maximthomas maximthomas Jul 10, 2026

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.

This exempts the exception class that bug 1017957 actually produces.

Two of the three stack traces in this file's own javadoc (lines 36-76) are CorruptVolumeException — one from Exchange.corrupt() via searchLevel, one from LongRecordHelper.corrupt().

The comment says a CorruptVolumeException here "does not imply persistent corruption -- that is verified separately by the final IntegrityCheck." That is true, and it is the problem: bug mechanism 2 leaves no persistent corruption. Per the javadoc (lines 88-90) it is a structure delete that fails to bump the tree generation, allowing use of a stale LevelCache. The stale cache sends a descent to the wrong page, which trips the right-walk limit at Exchange.java:1275 and throws CorruptVolumeException ("walked right more than 50 pages"). The volume is structurally sound afterwards, so IntegrityCheck reports 0 faults. With this change, a mechanism-2 regression is undetectable by this test.

On the "transient read" framing: searchLevel calls checkPageType immediately after _pool.get(...) while holding a claim on the buffer (Exchange.java:1291), inside a hand-over-hand latch-coupling loop. A claimed page cannot change type underneath the reader. Reaching a wrong-typed page therefore means the pointer that was followed was stale — mechanism 1 — not that a concurrent split was caught mid-flight.

If some CorruptVolumeExceptions here really are benign, that needs to be established from the failing messages rather than assumed from the class. As written this hides both mechanisms the test exists to catch.

* transient exceptions are counted and printed for diagnostics but are not
* treated as failures. Any other exception type still fails the test.
*/
assertEquals("Unexpected exception occurred", 0, unexpectedErrors.get());

@maximthomas maximthomas Jul 10, 2026

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.

Follow-on from the comment above: assertEquals("Corrupt volume", 0, icheck.getFaults().length) is not a sufficient substitute for the exception assertion, because bug mechanism 2 (stale LevelCache) produces a CorruptVolumeException without leaving any fault for IntegrityCheck to find.

If the goal is to unblock CI now, @Ignore with a linked issue keeps the coverage gap visible. If you want to keep the test running, please at least (a) keep failing on messages matching the mechanism-2 signature (walked right more than), and (b) bound totalErrors rather than tolerating an unlimited number of transient corruption reports — 32 in one 10-second run is a lot to call benign.

pool.preloadBufferInventory();
assertTrue("Preload should have loaded pages from journal file", tfc.getReadPositionList().size() > 0);
tfc.assertOrdered(true, true);
assertTrue("Preload should have read the recorded pages back from disk",

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.

This assertion can't fail for the reason the test cares about — it is satisfied before a single recorded page is preloaded.

preloadBufferInventory() (BufferPool.java:1503) opens getBufferInventoryExchange() first (BufferPool.java:1579-1582), which uses _persistit.getSystemVolume(). WarmupTest inherits PersistitUnitTestCase.getProperties(), which declares a single volume (volume.1 = persistit) and no sysvolume property — so getSpecialVolume() (Persistit.java:1479-1484) returns that same volume, served by the same 16384-byte pool this test measures.

Traversing the _buffers tree (exchange.previous() / exchange.next(), BufferPool.java:1516,1536) reads directory and inventory pages through BufferPool.get(), which bumps _missCounter (BufferPool.java:866). On a freshly-initialized pool those are misses. So getMissCounter() > missesBefore holds even on the if (!foundInventory) return; early-exit path at BufferPool.java:1522.

Easy to confirm: drop a System.out.println(pool.getMissCounter()) right after getBufferInventoryExchange() returns inside preloadBufferInventory().

Suggest asserting on the recorded pages instead — testWarmup already shows the idiom: snapshot page addresses via pool.getBufferCopy(i) before shutdown, then assert those addresses are resident after preload. Alternatively have preloadBufferInventory() return its count and assert on that.

Two other things about this hunk:

  • The test no longer tests its name. You're right that assertOrdered was dead (TrackingFileChannel.java:164-174final long previous is never advanced, so it only ever asserts position > -1). But repairing it seems better than deleting it: as it stands the scramble phase above (breaks > 0) is decorative, and Collections.sort(pageNodes, PageNode.READ_COMPARATOR) at BufferPool.java:1544 — the sequential-read optimization this test was written to protect — loses its only coverage. If order coverage is genuinely being dropped, the test should be renamed and the PR body should say so.
  • TrackingFileChannel is now dead code. This was its only caller. Either delete TrackingFileChannel.java or keep using it. (MediatedFileChannel.TestChannelInjector / injectChannelForTests remain in use via ErrorInjectingFileChannel.)

_persistit = new Persistit(_config);
_persistit.initialize();
/*
* Recovery and the pruning of timely tree resources run

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 fix itself looks right to me, but this sentence isn't: recovery is not asynchronous. initialize() calls _recoveryManager.applyAllRecoveredTransactions(...) and then _recoveryManager.close() inline (Persistit.java:717-719), so it has fully completed by the time this line runs.

What is actually asynchronous is (a) TransactionIndex's active-transaction-cache updater thread and (b) CleanupManager's periodic call to pruneTimelyResources() — which is exactly why forcing those two here works, and works deterministically. Could you reword? As written it will send the next maintainer looking for a recovery race that doesn't exist.

…rdered

The getMissCounter() assertion was vacuous: preloadBufferInventory reads the
shared _buffer_inventory_ tree before loading any recorded page, and that read
alone bumps the pool's single _missCounter, so the test passed even when zero
recorded pages loaded.

Make the channel-based check deterministic through setup instead: after the
first close, reopen with buffer inventory recording disabled, run copyBackPages()
to drain the journal into the volume, and close again, so the recorded pages
(including the inventory tree's own pages the closing checkpoint flushed to the
journal) live in the volume file. Preload then reads them through the injected
volume channel -- verified as 18 reads in strictly ascending page order.

A strict getPageMapSize() == 0 precondition turned out unachievable -- the final
close always leaves one checkpoint page in the journal (not a data page) -- so
the assertions are the channel read count plus read order.

Also fix TrackingFileChannel.assertOrdered, which never advanced `previous` and
so only checked `position > -1`; it now verifies read order.
vharseko added 2 commits July 10, 2026 18:11
…e (bug 1017957 class race)

rebalanceSplit() links a new page only through its sibling chain and defers the
parent index entry to a background CleanupIndexHole action. By the time the
CleanupManager runs it, the action may be arbitrarily stale: a covering
removeKeyRange can have unlinked the page onto a garbage chain (content and
type intact, or retyped PAGE_TYPE_GARBAGE if it became a chain root), or the
page may have been reused. fixIndexHole() performed no validation and inserted
a key-pointer pair for whatever the page now holds, planting a dangling index
entry to a garbage or reused page -- the bug 1017957 failure mode. Concurrent
descents following such an entry throw transient CorruptVolumeExceptions
("invalid page type 30: should be 2" observed on CI under
Bug1017957Test.induceCorruptionByStress; "walked right more than 50 pages" and
"LONG_RECORD chain is invalid" are the same race's other signatures). The
volume looks consistent afterwards because later covering removes delete the
dangling entry before IntegrityCheck runs, which is why the corruption appeared
transient. Faster JVMs widen the window: more iterations produce more
rebalance splits racing the background queue.

Guard the insertion. Under the tree reader claim (structure deletes require
the writer claim, so reachability cannot change while it is held): (a) drop
the action if the page is no longer this level's type or is empty; (b) verify
the page is still reachable in this tree -- a search for its current first key
at that level must land on the page itself via the B-link right-walk -- and
drop the stale action otherwise. No claim is held across the verification
descent, preserving the parent-then-child claim order.

Verified: full persistit/core suite 565/565 green; IntegrityCheckTest
testIndexFixHoles confirms valid holes are still repaired;
Bug1017957Test.induceCorruptionByStress with its original strict zero-exception
assertion green across repeated runs. The race itself reproduces only on CI
runners, which is where this fix is ultimately validated.
@vharseko vharseko changed the title Fix flaky Bug1017957Test.induceCorruptionByStress on fast JVMs Fix bug-1017957 class race in fixIndexHole; stabilize TreeTransactionalLifetimeTest and WarmupTest Jul 10, 2026
@vharseko vharseko added the bug label Jul 10, 2026
@vharseko

Copy link
Copy Markdown
Member Author

You were right to push back hard — investigated instead of suppressing, and the blocking objection is confirmed: this is a real race of exactly the bug-1017957 mechanism-1 class. The relaxation is reverted (7bc8cc9); the test keeps its original strict assertEquals("Exception occurred", 0, totalErrors).

Root cause (evidence + code path). The CI traces were the give-away: Thread-2084's own fresh descent read the pointer to page 21 under a writer claim on live index page 17 and got PAGE_TYPE_GARBAGE — so a live index entry pointed at a garbage page; not an observation artifact (matching your latch-coupling argument). Working back: rebalanceSplit() links its new page only through the sibling chain and defers the parent entry to a background CleanupIndexHole. Before the CleanupManager runs it, a covering removeKeyRange joins the range and the hole page lands on a garbage chain (content/type intact, or retyped GARBAGE as a chain root) or gets reused. fixIndexHole() validated nothing — it read the first key of whatever the page now holds and inserted the pointer, planting the dangling entry. Later covering removes delete that entry, which is why the final IntegrityCheck is clean and the corruption looked transient; your "mechanism 2 leaves no persistent damage" observation applies the same way here. All three 2012 signatures (invalid page type / walked right >50 / LONG_RECORD chain) fall out of this one hole. Fast JVMs iterate more → more rebalance splits racing the ~1 s background window — your "faster JVMs expose it" read, verbatim.

Fix (8f9ead3 here; standalone #263): fixIndexHole now validates under the tree reader claim it already holds — structure deletes need the writer claim, so reachability can't change during validation: (a) drop the action if the page is no longer this level's type or is empty; (b) verify the page is still reachable in this tree (a search for its current first key at that level must land on the page itself via the B-link right-walk) before inserting. No claim held across the verification descent, so parent-then-child claim order is preserved. Validation: full persistit/core suite 565/565; IntegrityCheckTest.testIndexFixHoles confirms legitimate holes are still repaired; strict Bug1017957Test 6/6 locally. The race only reproduces on CI runners, so this PR's CI — with the strict assertion restored — is the real validator.

WarmupTest section: superseded by the rework already in this branch (d9c3eca): the miss-counter assertion is gone, the channel injection is back with the drain-to-volume setup, and assertOrdered advances its cursor — read-order coverage restored, TrackingFileChannel no longer orphaned.

TreeTransactionalLifetimeTest: agreed on the inline comment — recovery is synchronous in initialize(); the asynchronous actors are the ATC updater and the CleanupManager pruner. Will reword the comment accordingly (here and in #260). On your product question — getTree(name, false) giving stale step-visibility until a prune runs — agreed it deserves its own issue; happy to file it with a repro sketch.

Minor: PR retitled and the body now documents all three changes. The duplicated instanceof blocks went away with the revert. The pre-existing "worker's outer catch can't fail the test" point stands — noting it as a known gap rather than smuggling more scope into this PR.

vharseko added a commit to vharseko/commons that referenced this pull request Jul 10, 2026
… the ATC updater and CleanupManager pruning

Per review on OpenIdentityPlatform#257: initialize() applies recovered transactions inline, so
blaming asynchronous recovery was wrong and would send a maintainer hunting a
race that does not exist. The asynchronous actors that make the settle calls
necessary are the TransactionIndex active-transaction-cache updater thread and
the CleanupManager's timer-driven pruneTimelyResources(). Comment-only change.
… the ATC updater and CleanupManager pruning

Per review on OpenIdentityPlatform#257: initialize() applies recovered transactions inline, so
blaming asynchronous recovery was wrong and would send a maintainer hunting a
race that does not exist. The asynchronous actors that make the settle calls
necessary are the TransactionIndex active-transaction-cache updater thread and
the CleanupManager's timer-driven pruneTimelyResources(). Comment-only change.
@vharseko

Copy link
Copy Markdown
Member Author

Closing as superseded: every change in this PR now lives in a dedicated single-concern PR with byte-identical content:

@vharseko vharseko closed this Jul 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug tests Test code changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants