MDEV-40523 Versioned UPDATE on a HEAP table with blobs corrupts the history row#5456
Open
arcivanov wants to merge 1 commit into
Open
MDEV-40523 Versioned UPDATE on a HEAP table with blobs corrupts the history row#5456arcivanov wants to merge 1 commit into
arcivanov wants to merge 1 commit into
Conversation
arcivanov
force-pushed
the
MDEV-40523
branch
3 times, most recently
from
July 25, 2026 19:06
4fee84b to
0900d60
Compare
…istory row A system-versioned `UPDATE` of a blob column on a `HEAP` table stored garbage in the history row, and an `AFTER UPDATE` trigger reading `OLD.<blob>` saw the same garbage. Both values are durable: the history row is what `SELECT ... FOR SYSTEM_TIME ALL` returns, and both reach replicas through the row-based binlog image. No ASAN build is needed to reproduce either. ## Cause `heap_update()` and `heap_delete()` do not free the old blob chain outright. They park it, because the SQL layer keeps reading the pre-update row out of `record[1]` after `ha_update_row()` returns -- `binlog_log_row()` builds the before-image from it, and an `AFTER UPDATE` trigger reads `OLD.<blob>` from it. Those are zero-copy pointers straight into `HP_BLOCK`, so freeing the chain would make them dangle. `heap_write()` redeemed that parking unconditionally, before allocating. For a system-versioned `UPDATE` that is exactly the wrong moment: `vers_insert_history_row()` does `restore_record(table, record[1])`, blob data pointer included, so the row being written sources its blob from the chain the update just parked. The free put those records on the delete list, where the allocation immediately below handed them straight back as the history row's own chain -- with `hp_push_free_block()`'s free-list links already scribbled through the payload. Source and destination of the blob copy overlapped, and `record[1]` was left pointing at reused memory for the rest of the statement. ## What triggers the bug, and what reads the corrupted data Triggering statements are every `vers_insert_history_row()` caller reaching a `HEAP` table whose record buffer was filled by a read: single-table `UPDATE`, multi-table `UPDATE` (both the on-the-fly and the deferred `do_updates()` path), `INSERT ... ON DUPLICATE KEY UPDATE`, and the row-based replication applier. A versioned `UPDATE` that does not change the blob column is unaffected -- `heap_update()` keeps the chain and parks nothing. Three consumers then read corrupted data, all of them out of `record[1]` after the history-row write has recycled the chain: - the history row itself, as returned by `SELECT ... FOR SYSTEM_TIME ALL`; - the row-based binlog before-image, so the corruption reaches replicas; - `AFTER UPDATE` triggers reading `OLD.<blob>`, so whatever the trigger does with that value -- typically writing it to an audit table -- stores wrong data, and that write is itself replicated. The trigger case is the easiest to miss, because `LENGTH(OLD.<blob>)` is still correct: the length lives in the record buffer and survives, and only the payload has been recycled. A `BEFORE UPDATE` trigger on the same table reports the correct value, which localises the damage to the history-row write that happens between the two. ## Fix The parked chain already holds exactly the bytes the history row needs -- it is a verbatim copy of the same record. So instead of freeing it and allocating a duplicate, the new row adopts it: - `hp_flush_unaliased_blob_free()` redeems every parked chain **except** ones the record being written still sources blob data from. - `hp_write_blobs()` takes those over: the stored row points at the parked chain and no new chain is written. The pending slot is cleared only once every column has succeeded, so the rollback path can tell an adopted chain from an allocated one and leaves it parked rather than freeing it. Both record buffers stay valid, because the chain's contents are never disturbed. Adoption also needs no space at all, which matters at `max_heap_table_size`: the history row previously had to find room for a second copy of a blob that was already resident, and the parked chain was often the only reclaimable space. Aliasing is detected by exact pointer equality against the parked chain head. `hp_read_blobs()` hands out zero-copy pointers of exactly two forms -- the chain head, or `chain + recbuffer` -- and reassembles a multi-run chain into `info->blob_buff`, which can never alias, so the two comparisons in `hp_blob_sources_chain()` are complete and need no walk of the `HP_BLOCK` tree. Matching is per blob column, since `pending_blob_chains[i]` is the chain parked for column `i`. `REPLACE` was never affected: `HA_EXTRA_WRITE_CAN_REPLACE` makes `hp_read_blobs()` copy rather than hand out zero-copy pointers. Internal temporary tables never park -- they free their chains outright and do not allocate the array -- so `hp_write_blobs()` guards on it. ## Tests `storage/heap/hp_test_blob_alias-t.c` drives the sequence at the `heap_write()` API for a single-record chain, a zero-copy run and a multi-run chain, and checks both the stored row and the caller's buffer. The multi-run case is reassembled into `info->blob_buff` and so cannot alias; the test asserts which layout it got, so the coverage cannot silently degrade. `blob_vers_trigger` covers `OLD.<blob>` in triggers across single-table `UPDATE`, multiple blob columns, `ON DUPLICATE KEY UPDATE` and multi-table `UPDATE`, with `BEFORE UPDATE` alongside `AFTER UPDATE` on one table, and non-versioned `HEAP` and versioned `MyISAM` controls. `blob_versioning`, `blob_vers_odku`, `blob_vers_multi`, `blob_vers_repl` and `blob_vers_full` cover the history row itself for every `vers_insert_history_row()` caller, replication of the before-image, and the at-capacity case.
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-40523 Versioned UPDATE on a HEAP table with blobs corrupts the history row
A system-versioned
UPDATEof a blob column on aHEAPtable stored garbage in the history row, and anAFTER UPDATEtrigger readingOLD.<blob>saw the same garbage. Both values are durable: the history row is whatSELECT ... FOR SYSTEM_TIME ALLreturns, and both reach replicas through the row-based binlog image. No ASAN build is needed to reproduce either.A non-uniform payload matters: with
REPEAT('x',64)the overlapping copy reproduces the same byte at every position and the corruption hides.Cause
Three individually correct behaviours compose badly.
hp_read_blobs()is zero-copy: it points the record's blob data pointer straight into theHP_BLOCKcontinuation chain rather than copying. Only a multi-run chain is reassembled, intoinfo->blob_buff.heap_update()andheap_delete()therefore do not free the old chain — they park it, because the SQL layer keeps reading the pre-update row out ofrecord[1]afterha_update_row()returns.binlog_log_row()builds the before-image from it, and anAFTER UPDATEtrigger readsOLD.<blob>from it.vers_insert_history_row()doesrestore_record(table, record[1]), blob data pointer included, so the row about to be written sources its blob from the chain the update just parked.heap_write()redeemed that parking unconditionally, as its first action, before allocating. It freed the chain the row it was about to write sources its data from; those records went onto the delete list, and the allocation immediately below handed them straight back as the history row's own chain — withhp_push_free_block()'s free-list links already scribbled through the payload. Source and destination of the blob copy overlapped, andrecord[1]was left pointing at reused memory for the rest of the statement.Fix
The parked chain already holds exactly the bytes the history row needs — it is a verbatim copy of the same record. So instead of freeing it and allocating a duplicate, the new row adopts it:
hp_flush_unaliased_blob_free()redeems every parked chain except ones the record being written still sources blob data from.hp_write_blobs()takes those over: the stored row points at the parked chain and no new chain is written. The pending slot is cleared only once every column has succeeded, so the rollback path can tell an adopted chain from an allocated one and leaves it parked rather than freeing it.Both record buffers stay valid, because the chain's contents are never disturbed. Adoption also needs no space at all, which matters at
max_heap_table_size: the history row previously had to find room for a second copy of a blob that was already resident, and the parked chain was frequently the only reclaimable space. The fix is therefore strictly better than the previous behaviour under memory pressure, not merely correct.Aliasing is detected by exact pointer equality against the parked chain head.
hp_read_blobs()produces zero-copy pointers of exactly two forms — the chain head, orchain + recbuffer— and reassembles a multi-run chain intoinfo->blob_buff, which can never alias, so the two comparisons inhp_blob_sources_chain()are complete and need no walk of theHP_BLOCKtree. Matching is per blob column, sincepending_blob_chains[i]is the chain parked for columni.REPLACEwas never affected:HA_EXTRA_WRITE_CAN_REPLACEmakeshp_read_blobs()copy rather than hand out zero-copy pointers. Internal temporary tables never park — they free their chains outright andhp_open()does not allocate the array — sohp_write_blobs()guards on it.What triggers the bug, and what reads the corrupted data
Triggering statements are every
vers_insert_history_row()caller reaching aHEAPtable whose record buffer was filled by a read: single-tableUPDATE, multi-tableUPDATE(both the on-the-fly and the deferreddo_updates()path),INSERT ... ON DUPLICATE KEY UPDATE, and the row-based replication applier. A versionedUPDATEthat does not change the blob column is unaffected —heap_update()keeps the chain and parks nothing.Three consumers then read corrupted data, because all three read it out of
record[1]after the history-row write has recycled the chain:SELECT ... FOR SYSTEM_TIME ALL.AFTER UPDATEtriggers readingOLD.<blob>— whatever the trigger does with that value, typically writing it to an audit table, stores wrong data, and that write is itself replicated.The trigger case is the one most easily missed, since
LENGTH(OLD.<blob>)is still correct: the length lives in the record buffer and survives, and only the payload has been recycled. ABEFORE UPDATEtrigger on the same table reports the correct value, which is what localises the damage to the history-row write happening between the two:Tests
storage/heap/hp_test_blob_alias-t.cdrives the sequence at theheap_write()API for a single-record chain, a zero-copy run and a multi-run chain, checking both the stored row and the caller's buffer. The multi-run case is reassembled intoinfo->blob_buffand cannot alias, serving as a control; the test asserts which layout it obtained so the coverage cannot silently degrade.heap.blob_vers_triggercoversOLD.<blob>in triggers across single-tableUPDATE, multiple blob columns,ON DUPLICATE KEY UPDATEand multi-tableUPDATE, withBEFORE UPDATEalongsideAFTER UPDATEon one table, plus non-versionedHEAPand versionedMyISAMcontrols.heap.blob_versioning,heap.blob_vers_odku,heap.blob_vers_multicover the history row itself for every caller, including aREPLACEcontrol.heap.blob_vers_replpins the before-image reaching a replica.heap.blob_vers_fullpins the at-capacity case, asserting the versionedUPDATEplainly succeeds and its history row survives.Both tests were written before the fix and verified to fail against it: 3 failing assertions in the unit test, and wrong
OLD.<blob>in every non-control MTR case.Verification
hp_test_blob_alias-tat 47/47.versioning,period,heap: 152/152.main: 1429/1429.