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
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.
arcivanov
force-pushed
the
MDEV-40591
branch
3 times, most recently
from
August 5, 2026 07:53
0fec950 to
5953059
Compare
`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_released()` clears it from `ha_heap::external_lock(F_UNLCK)`, which per the description in `sql/lock.cc` the SQL layer reaches while the THR_LOCK is still held, just before `thr_multi_unlock()`; - `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. The grant is set rather than counted. `thr_lock()` does not call `get_status` once per `ha_heap::external_lock()`: a delayed insert is granted `TL_WRITE_DELAYED` and calls it a second time on the same request when `thr_upgrade_write_delay_lock()` promotes that to a real write lock, with no `external_lock()` in between. A count would keep the surplus for the life of the handle; setting is idempotent. 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. Redeeming a parked blob chain puts records back on the shared free list, so it needs the same protection -- but the condition is that no other connection can reach the share, and holding the THR_LOCK is only one way to satisfy it. Chains are parked only by `heap_delete()` and `heap_update()`, and only for a table that is not internal, which is not the set the server locks: `share->internal` is `HA_OPEN_INTERNAL_TABLE`, the optimizer's own temporary table, whereas `get_lock_data()` leaves every non-transactional `TEMPORARY` table out of the lock set entirely. A user `TEMPORARY` MEMORY table therefore parks while holding no THR_LOCK, and owns its chains alone. `copy_data_between_tables()` reaches the same state from the other direction: it locks the `ALTER` copy target with a direct `handler::ha_external_lock()` instead of through the lock set, so `thr_lock()` never grants that handle anything even while an online `ALTER` replays concurrent deletes onto it. Both are private to one session, so `ha_heap::external_lock(F_UNLCK)` and `ha_heap::reset()` keep redeeming unconditionally, and each asserts the property that makes that safe: a parked chain is either lock-protected or on a table no other connection can reach. `hp_test_unlock_check-t` reproduces the lock states deterministically, by driving `thr_multi_lock()`/`thr_multi_unlock()` directly instead of racing. Four MTR tests cover the shapes it cannot reach: blob updates and deletes on a user `TEMPORARY` MEMORY table (`heap.blob_tmp_table`), the repeated `get_status` (`heap.blob_delayed_insert`), the `ALTER` copy target (`heap.blob_online_alter`), and one share locked twice in a lock set (`heap.blob_lock_twice`) -- the last being the only place a handle is locked, released and locked again, which is what makes the release edge observable. No existing test exercised any of them.
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.hp_may_check_heap_on_unlock()gates the verification on that.The grant is set, not counted
thr_lock()does not callget_statusonce perha_heap::external_lock(). A delayed insert is grantedTL_WRITE_DELAYEDand calls it a second time on the same request whenthr_upgrade_write_delay_lock()promotes that to a real write lock, with noexternal_lock()in between --mysys/thr_lock.csays so on the queued branch: "We don't have to do get_status here as we will do it when we change the delayed lock to a real write lock." HEAP is eligible because it advertisesHA_CAN_INSERT_DELAYED.Counting would therefore leave a permanent residue (2+ increments, 1 decrement per delayed cycle) and the second
INSERT DELAYEDaborts onAssertion '!hp_lock_is_held(file)' failed. Setting is idempotent, so N grants and one release are correct by construction.Redeeming parked blob chains: the condition is privacy, not the lock
Redemption puts records back on the shared free list, so it needs the same protection -- but what makes it safe is that no other connection can reach the share, and holding the THR_LOCK is only one way to get there. Two handles are private without ever holding it:
CREATE TEMPORARY TABLE ... ENGINE=MEMORYparks, because the parking gate isHP_SHARE::internal(HA_OPEN_INTERNAL_TABLE, the optimizer's own table) while lock-set membership isTABLE_SHARE::tmp_table--get_lock_data()drops every non-transactionalTEMPORARYtable entirely, so it never runsstore_lock/external_lock/thr_multi_lockat all.copy_data_between_tables()locks theALTERcopy target with a directhandler::ha_external_lock()(sql/sql_table.cc:12641, released at:13219) instead of through the lock set, sothr_lock()never grants that handle anything even while an onlineALTERreplays concurrent deletes onto it.So redemption stays unconditional in both
ha_heap::external_lock(F_UNLCK)andha_heap::reset()(zero behaviour change), and each asserts the property that makes it safe: a parked chain is either lock-protected or on a table no other connection can reach.Testing
hp_test_unlock_check-t(new, 17 assertions, ~2 s) reproduces the lock states 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.thr_multi_lock()sorts its request array byTHR_LOCKaddress, so the test pins which share is contended rather than lettingmallocdecide; otherwise the granted-then-rolled-back row of the table above is only reached on some runs. Each conjunct of the predicate now has a dedicated killer: droppinglock.type != TL_UNLOCKfails assertion 13, droppinglock_grantedfails assertion 16 -- both verified by mutation.Four MTR tests, one per shape, all mutation-verified:
heap.blob_tmp_tableUPDATE/DELETEon a userTEMPORARYMEMORY tableheap.blob_delayed_insertget_statusfromINSERT DELAYEDheap.blob_online_alterALTERcopy targetheap.blob_lock_twiceNone of these shapes had any coverage before: of 46 heap-suite files, zero did a blob
UPDATEorDELETEon a userTEMPORARYMEMORY table.heap.blob_lock_twiceis the only test anywhere in which a handle is locked, released, and locked again, so it is what makes the release edge observable: removehp_lock_released()'s clear and the stale grant from the first cycle aborts the server on the secondLOCK TABLE, at the acquire-side assertion. All four tests fail against that mutant.hp_test_unlock_check-tat 17/17main+heapMTR sweep: 1433/1433, no retriesCHECK TABLEreportingstatus OKevery time -- so what was removed was a false positive, not a real detection