MDEV-40568 Assertion `read_only' failed in mariadb_error_read_only - #5474
Open
arcivanov wants to merge 1 commit into
Open
MDEV-40568 Assertion `read_only' failed in mariadb_error_read_only#5474arcivanov wants to merge 1 commit into
arcivanov wants to merge 1 commit into
Conversation
`opt_readonly` was read up to three times for a single read-only rejection: once by the gate that decides to deny, once more while dispatching, and a third time inside `mariadb_error_read_only()`, which formats the message from it and asserts that it is non-zero. `SET GLOBAL read_only=OFF` takes the fast path in `fix_read_only()` and stores `opt_readonly= 0` while holding only `LOCK_global_system_variables`, which none of these readers take. When that store lands between two of the reads, the statement is denied on one value and the error is built from another, and if the last read sees `OFF` then `DBUG_ASSERT(read_only)` fires. Release builds took the `read_only == 0 -> 1` fallback and reported `--read-only=ON` regardless of which level was actually rejected. Before MDEV-36425 every site emitted the constant string `--read-only` from a single read, so the reads could not disagree. Centralizing the message construction into a function that reads the global again is what added the second and third read, and the assertion. **Fix**: `mariadb_error_read_only()` takes the value as a parameter, and every caller reads `opt_readonly` once into a local and passes that on, so that the decision and its message are always based on the same value: 1. `mysql_execute_command()`, where the read is hoisted out of `deny_updates_if_read_only_option()`. That function read the global twice itself and so could decide from two different states. 2. `lock_tables_check()`, read per table. Deliberately not hoisted out of the loop: a `read_only` that is turned on while we are looping must still stop the remaining tables. 3. `trans_begin()`. 4. `THD::check_read_only_with_error()`, which serves `ha_commit_trans()`, the XA paths and `sql_trigger.cc`. Both existing assertions are kept, and now state something true: the emitter asserts a parameter that its caller has already tested, and `case READONLY_OFF: DBUG_ASSERT(0)` is unreachable because the `if` above it tested the same local. The `read_only == 0 -> 1` fallback is removed together with the race it was covering. The read-only checks are staged on purpose, at statement start, at lock acquisition and at commit, so that a completed `SET GLOBAL read_only=ON` also stops work that is already in flight. The value read is therefore kept for exactly one decision through to its own error message, and is never cached in `THD`, per statement or per transaction. The local in `mysql_execute_command()` is wrapped in a block because with `EMBEDDED_LIBRARY` the enclosing `else` is compiled away, which would put the declaration in the function scope that holds the `error:` and `finish:` labels. **Test**: `main.read_only_debug` covers four races with `DEBUG_SYNC`: the statement gate with `read_only` turned off, the statement gate with `read_only` changed to another level, the lock gate, and `START TRANSACTION READ WRITE`. The second case is the one that shows the message comes from the value that was tested rather than from the global: it changes `NO_LOCK` to `ON` instead of to `OFF`, so an implementation that still formats from the global reports the wrong level instead of silently agreeing.
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.
MDEV-40568
Assertion 'read_only' failedinmariadb_error_read_onlyA statement rejected because the server is read-only can crash a debug build, or report the wrong read-only level in a release build, if another connection changes
read_onlyat the same moment.Reported by Elena Stepanova against a branch carrying the HEAP blob work, but it is not related to it: the statement is rejected before any table is opened. The bug is reproducible on a plain
InnoDBtable with no blobs and no temporary tables involved, and reaches back to 12.0.1.Cause
opt_readonlyis read up to three times for a single rejection: once by the gate that decides to deny, once more while dispatching, and a third time insidemariadb_error_read_only(), which formats the message from it and asserts that it is non-zero.SET GLOBAL read_only=OFFtakes the fast path infix_read_only()and storesopt_readonly= 0while holding onlyLOCK_global_system_variables, which none of these readers take:Turning read-only on is serialized behind
lock_global_read_lock()andmake_global_read_lock_block_commit(), which is why the variable looks protected. Turning it off is a bare store, and every race here is in that direction. Holding locks does not help the reader:ha_commit_trans()holdsMDL_BACKUP_COMMIT, which does block a concurrentread_only=ON, and anOFFstill walks past it.So when the store lands between two of the reads, the statement is denied on one value and the error is built from another. If the last read sees
OFF, the assertion fires. Release builds took the fallback above and reported--read-only=ONregardless of which level was actually rejected.The fallback and its comment show the race was anticipated; the assertion one line above contradicts them.
Before MDEV-36425 every site emitted a constant string from a single read:
so the reads could not disagree. Centralizing message construction into a function that reads the global again is what added the second and third read, and the assertion.
Fix
mariadb_error_read_only()takes the value as a parameter, and every caller readsopt_readonlyonce into a local and passes that on, so the decision and its message are always based on the same value:mysql_execute_command(), where the read is hoisted out ofdeny_updates_if_read_only_option(). That function read the global twice itself, at the!opt_readonlyearly return and at thePRIV_IGNORE_READ_ONLYcheck, and so could decide from two different states.lock_tables_check(), read per table.trans_begin().THD::check_read_only_with_error(), which servesha_commit_trans(), the XA paths andsql_trigger.cc.The read in
lock_tables_check()is deliberately not hoisted out of the per-table loop. The read-only checks are staged on purpose, at statement start, at lock acquisition and at commit, so that a completedSET GLOBAL read_only=ONalso stops work that is already in flight. Hoisting would make a mid-loop change toONget ignored for the whole lock request. For the same reason the value is kept for exactly one decision through to its own error message, and is never cached inTHD, per statement or per transaction.Both existing assertions are kept, and now state something true: the emitter asserts a parameter that its caller has already tested, and
case READONLY_OFF: DBUG_ASSERT(0)is unreachable because theifabove it tested the same local. Theread_only == 0 -> 1fallback is removed together with the race it was covering.The local in
mysql_execute_command()is wrapped in a block because withEMBEDDED_LIBRARYthe enclosingelseis compiled away, which would otherwise put the declaration in the function scope that holds theerror:andfinish:labels.Tests
main.read_only_debugcovers four races withDEBUG_SYNC: the statement gate withread_onlyturned off, the statement gate withread_onlychanged to another level, the lock gate, andSTART TRANSACTION READ WRITE. The test user is grantedSUPERso it can arm the sync points, which since 10.11 does not implyREAD ONLY ADMINand so leaves it subject toread_only.The second case is the one that shows the message comes from the value that was tested rather than from the global: it changes
NO_LOCKtoONinstead of toOFF, so an implementation that still formats from the global reports the wrong level instead of silently agreeing.Tests were written before the fix and verified against it, each on its own build:
lock.cc:113with the reported stack.lock_tables_check()local: the lock case crashes oncase READONLY_OFF: DBUG_ASSERT(0), the statement cases still pass.trans_begin()local: the transaction case crashes inmariadb_error_read_only(), the other three still pass.--read-only=ONwhere--read-only=NO_LOCKwas rejected, with no crash.One path is fixed but not covered:
THD::check_read_only_with_error()is a header inline andsql/sql_class.hdoes not includedebug_sync.h, so the commit and XA sites cannot be raced from a test without out-lining it. The pre-existingha_commit_trans_after_acquire_commit_locksync point does not substitute, as it parks before the function is entered.Verification
main: 1416/1416,main.read_only_debugstable over--repeat=5.read_only,read_only_innodb,grant_read_only,trans_read_only,lock,lock_tables_lost_commit,xa,flush_read_lock,commit: 10/10.Basing this PR
Cut against
mainas requested. The bug was introduced byce8a74f2352(MDEV-36425, 2025-03-31) and is present in 12.0.1 through 13.0.1, so it may warrant a lower base if it should be fixed on a released series.