MDEV-40591 Unexpected ER_NOT_KEYFILE or MSAN error in heap_check_heap - #5481
Open
arcivanov wants to merge 1 commit into
Open
MDEV-40591 Unexpected ER_NOT_KEYFILE or MSAN error in heap_check_heap#5481arcivanov wants to merge 1 commit into
arcivanov wants to merge 1 commit into
Conversation
`ha_heap::external_lock()` verifies the table with `heap_check_heap()` at `F_UNLCK`. That is safe on the ordinary unlock path, where `mysql_unlock_tables()` calls `unlock_external()` before `thr_multi_unlock()` and the THR_LOCK is still held. It is not safe on either path that unlocks after a *failed* lock attempt, where the caller holds nothing at all while another connection is writing: 1. `mysql_lock_tables()` calls `unlock_external()` to balance the external locks it already took, because `thr_multi_lock()` timed out. 2. `lock_external()` unwinds the tables it has already locked, because a later table refused -- all before `thr_multi_lock()` runs at all. `ha_partition::external_lock()` unwinds its partitions the same way. MEMORY has no row-level concurrency control, so a scan taken outside the THR_LOCK sees a writer's intermediate state by construction: `hp_alloc_from_tail()` publishes `total_records` at allocation time, before the slot is written, while the checker scans `[0, total_records + deleted)` and reads every slot's flags byte. Under MSAN that is a use of uninitialised `my_malloc()` memory; otherwise it is a spurious `total_records` mismatch. `heap_check_heap()` ends with `heap_mark_crashed()`, which sets `HEAP_STATE_CRASHED` in the **shared** `HP_SHARE`, so one bogus mid-write observation poisons a healthy table for every connection using it -- the reported `ER_NOT_KEYFILE`. MDEV-21373 disabled this check in 2021 for exactly this reason, by gating it on `EXTRA_DEBUG`. MDEV-38975 changed the gate to `EXTRA_HEAP_DEBUG` and defined that for every debug build, reviving the race. Rather than switch the check off wholesale again, ask whether the handle actually holds the lock. The requested lock type cannot answer that on its own, because `ha_heap::store_lock()` records it at `get_lock_data()` time, before anything is locked: on the second path it is set while nothing is held. So HEAP now records the grant itself: - `hp_lock_granted()`, registered as the `THR_LOCK` `get_status` callback, sets `HP_INFO::lock_granted` when `thr_lock()` gives the lock to the handle; - `hp_lock_request_begin()` clears it from `ha_heap::external_lock()`, which the SQL layer always reaches just before it tries to take the THR_LOCK; - `hp_lock_is_held()` requires both the grant and a lock type that `thr_unlock()` has not reset, the latter covering the lock that `thr_multi_lock()` takes and then rolls back when a later table times out. Deriving this in the engine rather than repairing `lock_external()` also covers `ha_partition`, which reimplements the same unwind. `hp_may_check_heap_on_unlock()` gates the verification on that, and the redemption of parked blob chains -- which puts records back on the shared free list -- is gated on it too. `ha_heap::reset()` gains an assertion that nothing is parked without the lock. It cannot be violated: chains are parked only by `heap_delete()` and `heap_update()`, only for a table that is not internal, and always under the lock; they are redeemed before it is released. `hp_test_unlock_check-t` reproduces all of this deterministically, by driving `thr_multi_lock()`/`thr_multi_unlock()` directly instead of racing.
gkodinov
approved these changes
Aug 3, 2026
gkodinov
left a comment
Member
There was a problem hiding this comment.
Thanks for fixing this! This is a preliminary review.
LGTM. Please stand by for the final review.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
ha_heap::external_lock()verifies the table withheap_check_heap()atF_UNLCK. That is safe on the ordinary unlock path, wheremysql_unlock_tables()callsunlock_external()beforethr_multi_unlock()and the THR_LOCK is still held. It is not safe on either path that unlocks after a failed lock attempt, where the caller holds nothing at all while another connection is writing:mysql_lock_tables()callsunlock_external()to balance the external locks it already took, becausethr_multi_lock()timed out (sql/lock.cc:403-404-- frame Add executable bit to scripts that are supposed to have it. #5 of the reported MSAN stack).lock_external()unwinds the tables it has already locked, because a later table refused (sql/lock.cc:443-452) -- all beforethr_multi_lock()runs at all.ha_partition::external_lock()unwinds its partitions the same way.MEMORY has no row-level concurrency control, so a scan taken outside the THR_LOCK sees a writer's intermediate state by construction:
hp_alloc_from_tail()publishestotal_recordsat allocation time, before the slot is written, while the checker scans[0, total_records + deleted)and reads every slot's flags byte. Under MSAN that is a use of uninitialisedmy_malloc()memory; otherwise it is a spurioustotal_recordsmismatch.heap_check_heap()ends withheap_mark_crashed(), which setsHEAP_STATE_CRASHEDin the sharedHP_SHARE, so one bogus mid-write observation poisons a healthy table for every connection using it -- the reportedER_NOT_KEYFILE.MDEV-21373 disabled this check in 2021 for exactly this reason, by gating it on
EXTRA_DEBUG. MDEV-38975 changed the gate toEXTRA_HEAP_DEBUGand defined that for every debug build, reviving the race.Fix
Rather than switch the check off wholesale again, ask whether the handle actually holds the lock. The requested lock type cannot answer that on its own, because
ha_heap::store_lock()records it atget_lock_data()time, before anything is locked: on path 2 it is set while nothing is held. So HEAP records the grant itself.mysql_unlock_tables->unlock_externalbeforethr_multi_unlockthr_multi_lockfailed, never grantedTL_UNLOCKthr_multi_lockfailed after granting, rolled back bythr_unlockTL_UNLOCKlock_externalunwind, before any lock attemptBoth terms are load-bearing, so
hp_lock_is_held()islock_granted && lock.type != TL_UNLOCK.Deriving this in the engine rather than repairing
lock_external()also coversha_partition, which reimplements the same unwind and bypassessql/lock.ccentirely.The verification is gated on
hp_may_check_heap_on_unlock(), and the redemption of parked blob chains -- which puts records back on the shared free list -- is gated onhp_lock_is_held()too.ha_heap::reset()gains an assertion that nothing is parked without the lock. It cannot be violated: chains are parked only byheap_delete()andheap_update(), only for a table that is notinternal(an internal one is never binlogged, so it frees its chains outright), and always under the lock; they are redeemed before it is released -- byexternal_lock(F_UNLCK)for an ordinary statement, and byheap_reset()itself underLOCK TABLES, reached frommark_used_tables_as_free_for_reuse()with the lock still held.Testing
hp_test_unlock_check-t(new, 15 assertions, ~2 s) reproduces all of this deterministically, by drivingthr_multi_lock()/thr_multi_unlock()directly instead of racing. A holder thread takesTL_WRITEand keeps it until told to let go, so the contending request is guaranteed to time out, and it parks the share in the state a writer passes through mid-row so the verification has something to wrongly find. It covers all four rows of the table above, and asserts that the table is provably consistent once the writer finishes -- i.e. that what was removed was a false positive, not a real detection.main+heapMTR sweep: 1426/1426, no retriesCHECK TABLEreportingstatus OKevery time