Fix flaky WarmupTest.testWarmup (compare resident page set, not slot order)#254
Fix flaky WarmupTest.testWarmup (compare resident page set, not slot order)#254vharseko wants to merge 3 commits into
Conversation
…order WarmupTest.testWarmup asserted that after a bufferinventory+bufferpreload restart every buffer slot holds the same page as before shutdown (getBufferCopy(i) before == getBufferCopy(i) after). That is not what warmup guarantees: preloadBufferInventory() sorts the recorded pages by read order (PageNode.READ_COMPARATOR) and reallocates buffers via the clock algorithm, so a page is reloaded into the pool but not necessarily into its original slot. The per-slot check only happened to pass when the allocation order coincided, and flaked on ubuntu-latest / JDK 17 (expected:<0> but was:<2> - an empty slot where the original held page 2) while passing on JDK 11/21/25/26. Assert the real invariant instead: every valid, recordable page (skipping temporary and lock volumes, matching recordBufferInventory) resident at shutdown is resident again after warmup, comparing the set of pages rather than their slot positions. A genuine failure to preload a page is still caught.
maximthomas
left a comment
There was a problem hiding this comment.
Verdict: right direction, request changes.
Blocking:
beforeis snapshotted from the live pool, not from the inventory that
recordBufferInventoryactually writes.before ⊆ afterholds only because
the unit-test pool (buffer.count.16384 = 20) is under-filled. Both
recordBufferInventory(stores inside the scan loop,BufferPool.java:1478)
andpreloadBufferInventory(count >= _bufferCountbreak, runs after
initializeVolumes) can evict pages once the pool is full.residentPages()claims to mirrorrecordBufferInventorybut omits its
spin-loop guard against torn(page, volume)reads
(BufferPool.java:1464-1473).getBufferCopy(int)is unlocked and the copy
ctor reads_status/_page/_volseparately (Buffer.java:414).- Root-cause story doesn't explain JDK-17-only failure; slot assignment is
deterministic. Real trigger is background-thread timing. Needs a
before/after repro in one environment. - Unacknowledged coverage loss: old assertion was bidirectional per-slot.
Nits: assertFalse over assertTrue(!...); use VOLUME_NAME; getBufferSize()
in the key can't discriminate; string identity key is fragile; ex = null; is
now dead.
Positive (unmentioned by the PR): dropping the getExchange(...) call before the
post-restart snapshot removes pollution of the after pool.
Not verified: did not build/run the module, so the flake was not reproduced.
CI on head 74e7e48 was pending; mergeStateStatus: UNSTABLE.
- assert the pool stays under-filled -- the precondition that makes the live snapshot equal what recordBufferInventory persists and keeps an unlocked buffer copy stable (no eviction); fail loudly if a future dataset fills the pool - residentPages javadoc no longer claims to mirror recordBufferInventory's torn-read spin guard; document why that guard is unnecessary here (pool is under-filled and quiescent at both call sites) - refine the root-cause comment: the per-slot flake came from the timing of background eviction/cleanup between shutdown and the snapshot, not from JDK semantics - document the intentional one-way invariant (before is a subset of after), dropping the old bidirectional per-slot check - drop the non-discriminating :size component from the page key (single buffer-size pool) - nits: assertFalse instead of assertTrue(!...), VOLUME_NAME instead of the "persistit" literal, remove the now-dead `ex = null`
|
Thanks for the detailed review — all fair. Addressed in 944c960. 1. assertTrue("Pool must stay under-filled for this comparison to be meaningful",
before.size() < pool.getBufferCount());so if a future change grows the dataset and fills the pool, the test fails loudly instead of silently comparing an incomplete set. 2. 3. Root-cause didn't explain the JDK-17-only failure. Agreed the "only happened to match" wording was hand-wavy — slot allocation is deterministic given the read order. Reworded the comment to name the real trigger: the interleaving of the preload order with background eviction/cleanup between shutdown and the snapshot (plus the removed post-restart 4. Unacknowledged coverage loss. Right — the old assertion was bidirectional per-slot; the new one is intentionally one-way ( Nits: all applied — Verification: |
maximthomas
left a comment
There was a problem hiding this comment.
Verified from source
Persistit.close()callsrecordBufferPoolInventory()first, before any teardown (Persistit.java:1632).allocBuffer()scans invalid buffers first; clock eviction only when_availablePages == false(BufferPool.java:959-995). Under-filled pool never evicts.residentPages()iterates0..getBufferCount()— same arrayrecordBufferInventoryscans (BufferPool.java:1457). Filters match.- Test pool: 20 buffers, ~10 resident pages. Under-filled by ~2×.
before ⊆ afteris sound. Slot identity is not preserved. Fix direction is correct.
Round-1 status
| # | Item | Status |
|---|---|---|
| 1 | before ≠ what recordBufferInventory persists |
CLOSED — under-fill + allocBuffer invalid-first policy |
| 2 | residentPages() omits torn-read spin guard |
OPEN — see A |
| 3 | Root cause unexplained | OPEN — see B, new comment replaces one wrong claim with another |
| 4 | Coverage loss | CLOSED — now documented as intentional one-way invariant |
Nits: all applied ✓. String-identity key left as-is (harmless for this volume name).
Findings
A. residentPages() relies on assumption test doesn't enforce (blocking)
getBufferCopy(int) returns a detached snapshot with 4 unsynchronized field reads (Buffer.java:414-428). recordBufferInventory guards against torn reads with a spin loop (BufferPool.java:1462-1471); residentPages() does not.
New javadoc claims "pool is quiescent at both call sites" — false at the before site where CLEANUP_MANAGER, CHECKPOINT_WRITER and PAGE_WRITER are running (CleanupManager.java:248,276). Under-fill assertion only covers before, not the post-restart pool where background activity is highest.
Fix: Call disableBackgroundCleanup() at test top (already idiomatic in IntegrityCheckTest, MVCCPruneBufferTest, Bug927701Test). Then javadoc can truthfully say the guard is unnecessary because cleanup is disabled and pool is under-filled. At minimum, add under-fill assertion on the post-restart pool too.
B. Root-cause comment contradicts assert 10 lines below (blocking)
Comment says old assertion depended on background eviction/cleanup. Next block says comparison is valid because "with no eviction" the snapshot equals the inventory. Both can't be true — verified the second is: under-filled pool never reaches eviction (BufferPool.java:959-995).
expected:<0> but was:<2> is plain relocation, not eviction — the post-restart slot was empty where pre-shutdown held page 2. "Between shutdown and the snapshot" is also incoherent — before snapshot precedes shutdown.
Correct story: slot assignment is timing-dependent in both runs due to background threads. Per-slot equality passed by coincidence; JDK 17 is incidental.
C. Under-fill guard can't detect what it claims (non-blocking)
before.size() < pool.getBufferCount() compares filtered/deduped set against total buffer count. A full pool with lock-volume pages satisfies it. Not a problem today (_lockVolume lazy, no locks taken), but unsound for stated purpose of catching future dataset growth. Count valid buffers directly instead.
D. Repro request — downgrade, don't drop (non-blocking)
Testing section shows only new test passing; can't distinguish "fixed" from "still flaky at low probability". Design argument is established by code reading (slot identity not preserved by construction). Worth a 50-run loop on old vs new to confirm, but no longer blocking.
Positives
getVolume(VOLUME_NAME).getStructure().getPool()reuses existing pattern; droppinggetExchange(...)removes post-restart pool pollution (unmentioned in PR).missing=in failure message is a real improvement over opaqueexpected:<0> but was:<2>.
…nale - Finding A: call disableBackgroundCleanup() on both the pre-shutdown and the post-restart instance so residentPages()'s unsynchronized getBufferCopy() snapshots are not taken against a concurrently mutated pool, and assert the post-restart pool is under-filled too. The residentPages javadoc no longer claims the pool is "quiescent"; with cleanup disabled and the pool under-filled no eviction reallocates a buffer, so its identity is stable and the torn-read spin guard is genuinely unnecessary. - Finding B: correct the root-cause comment. The old per-slot assertion did not depend on eviction (an under-filled pool never evicts); slot assignment is timing-dependent in both runs (preload read order + clock reallocation), so the per-slot equality passed only by coincidence and JDK 17 was incidental. - Finding C: gate the under-fill assertion on validPageCount() -- the raw count of occupied buffers -- instead of the filtered/deduplicated resident set, which a full pool of lock-volume pages could satisfy.
|
Thanks for the round-2 review — addressed in ada04fb. Finding A (torn-read guard / false "quiescent" claim). Called Finding B (comment contradicts the assert). Fixed. The comment no longer blames "background eviction/cleanup between shutdown and the snapshot" — an under-filled pool never evicts, and Finding C (under-fill guard). Now gated on Finding D (repro). The original JDK-17 flake does not reproduce locally on macOS (consistent with your own note), so the argument stands on code reading: preload reorders and reallocates buffers by construction, so slot identity is not a property warmup provides. As a stability check I ran |
Summary
Fixes a flaky failure in
com.persistit.WarmupTest.testWarmup(persistit/core) seen onbuild-maven (ubuntu-latest, 17).Root cause
testWarmuprestarts Persistit withbufferinventory+bufferpreloadand asserted a per-slot correspondence — thatpool.getBufferCopy(i)after the restart holds the same page as before shutdown:That is not what warmup guarantees.
BufferPool.preloadBufferInventory()reads the recorded pages back, sorts them by read order (PageNode.READ_COMPARATOR) and reallocates buffers via the clock algorithm, so a page is reloaded into the pool but not necessarily into its original slot index. Slot assignment is timing-dependent in both runs (background threads + reallocation order), so the per-slot equality only matched by coincidence — the JDK 17 runner was incidental (expected:<0> but was:<2>: an empty slot where the original held page 2, the page still resident, just relocated).Fix
Assert the invariant warmup actually provides — every valid, recordable page resident at shutdown is resident again after the restart (a one-way
before ⊆ aftersubset), comparing the set of pages (volume:page:type), not their slot positions. The filter mirrorsBufferPool.recordBufferInventory(skips temporary and lock volumes).Making the set snapshots safe (round-2 review):
residentPages()readsgetBufferCopy(i)withoutrecordBufferInventory's torn-read spin guard. CalldisableBackgroundCleanup()on both the pre-shutdown and post-restart instances, and assert each pool is under-filled (validPageCount() < getBufferCount()): with cleanup off and free buffers available the pool never evicts, so a buffer's(volume, page)identity is stable and the unsynchronized reads cannot tear.A genuine failure to preload a page is still caught (
missing=names it).Testing
mvn -o -pl persistit/core test -Dtest=WarmupTest—Tests run: 2, Failures: 0;testWarmupgreen across repeated consecutive runs (a 50-run loop was used to confirm stability). The original JDK-17 flake does not reproduce locally on macOS; the fix rests on the slot-identity argument established by code reading (preload reorders and reallocates buffers by construction).