From 9482ccc36704c79f674097ce50ba48897eaee581 Mon Sep 17 00:00:00 2001 From: Arcadiy Ivanov Date: Tue, 28 Jul 2026 16:05:52 -0400 Subject: [PATCH] MDEV-40378 heap: roll back key changes when a blob write fails in `heap_update()` `heap_update()` moves all changed key entries to the new key values **before** writing the new blob chains. When a blob chain write then failed (e.g. with `HA_ERR_RECORD_FILE_FULL`), the rollback restored the record bytes and blob chain pointers, but the `err:` label only undid key changes for `HA_ERR_FOUND_DUPP_KEY` -- historically the only possible failure once the key loop had run. The hash/btree entries were left keyed on the new values while pointing at a record holding the old values, corrupting the index: 1. index lookups by the old key value missed the row 2. `CHECK TABLE` reported the table corrupt 3. on debug builds the heap consistency check in `ha_heap::external_lock()` raised a second error into an already-set diagnostics area, firing a `Diagnostics_area` assertion on the next statement Fix: widen the `err:` recovery to run for `HA_ERR_RECORD_FILE_FULL`, `HA_ERR_OUT_OF_MEM` and `ENOMEM` as well, so a failure raised after the key loop also moves every changed key back to its old value. One recovery path now serves every failure that leaves keys moved to their new values, including any future error source in the key loop itself. The `err:` block assumed the failure happened **inside** the key loop, so that `keydef` addresses the partially processed keydef. A blob-chain write fails after that loop has run to completion, and therefore arrives with `keydef == keydef_end`. Reading `info->errkey` and `keydef->algorithm` from there addresses `share->keydef[share->keys]`; as `sizeof(HP_KEYDEF)` (888) far exceeds the key segments and blob descriptors that follow the keydef array, that read runs past the end of the `HP_SHARE` allocation, and the rollback sweep then dereferences a garbage `keydef->seg` in `hp_rec_key_cmp()`. So the recovery distinguishes the two failure sites: with `keydef == keydef_end` there is no partly updated key to repair and none to name in `info->errkey`, and the sweep starts at the last keydef instead. The same branch also covers a table with no keys at all (`share->keys == 0`), where the sweep has nothing to do. `info->errkey` is initialized to `-1` on entry to `err:`, so a failure that is not a key error can never expose a stale key number from an earlier operation. The original errno is captured before the recovery and restored after it, so that a rollback `write_key` failure (which `hp_rb_write_key()` reports as `HA_ERR_FOUND_DUPP_KEY` with a stale `errkey`) cannot mask it. The new test `heap.blob_update_key_rollback` exercises hash, BTREE, two changed indexes, an index on an unchanged column (which the rollback must leave untouched), a partial multi-row UPDATE, and a table with no indexes at all; each asserts the table stays consistent after the failure via `CHECK TABLE` and index lookups. --- .../heap/blob_update_key_rollback.result | 186 ++++++++++++++++++ .../suite/heap/blob_update_key_rollback.test | 179 +++++++++++++++++ storage/heap/hp_update.c | 44 +++-- 3 files changed, 398 insertions(+), 11 deletions(-) create mode 100644 mysql-test/suite/heap/blob_update_key_rollback.result create mode 100644 mysql-test/suite/heap/blob_update_key_rollback.test diff --git a/mysql-test/suite/heap/blob_update_key_rollback.result b/mysql-test/suite/heap/blob_update_key_rollback.result new file mode 100644 index 0000000000000..e47ede0edb1e0 --- /dev/null +++ b/mysql-test/suite/heap/blob_update_key_rollback.result @@ -0,0 +1,186 @@ +# +# Index consistency after a failed UPDATE on a HEAP table with BLOB +# +# heap_update() moves changed key entries to the new key values +# before writing the new blob chains. When a blob chain write then +# fails with "table is full", the key entries must be moved back to +# the old values, otherwise the index refers to records whose +# columns no longer match the indexed values. +# +SET @save_max_heap= @@max_heap_table_size; +SET max_heap_table_size = 16384; +# +# Hash index: single-row UPDATE fails writing the new blob chain +# +CREATE TABLE t1 ( +id INT, +k VARCHAR(20), +b BLOB, +KEY k1 (k) +) ENGINE=HEAP; +INSERT INTO t1 SELECT seq, CONCAT('key', seq), REPEAT('a', 300) +FROM seq_1_to_10; +UPDATE t1 SET k= 'moved', b= REPEAT('z', 60000) WHERE id = 1; +ERROR HY000: The table 't1' is full +CHECK TABLE t1; +Table Op Msg_type Msg_text +test.t1 check status OK +# The row must still be found through the index under the old value +SELECT id, k, LENGTH(b) FROM t1 FORCE INDEX(k1) WHERE k = 'key1'; +id k LENGTH(b) +1 key1 300 +SELECT COUNT(*) FROM t1 FORCE INDEX(k1) WHERE k = 'moved'; +COUNT(*) +0 +DROP TABLE t1; +# +# BTREE index: same failure, rb-tree entries must be restored +# +CREATE TABLE t1 ( +id INT, +k VARCHAR(20), +b BLOB, +KEY k1 (k) USING BTREE +) ENGINE=HEAP; +INSERT INTO t1 SELECT seq, CONCAT('key', seq), REPEAT('a', 300) +FROM seq_1_to_10; +UPDATE t1 SET k= 'moved', b= REPEAT('z', 60000) WHERE id = 1; +ERROR HY000: The table 't1' is full +CHECK TABLE t1; +Table Op Msg_type Msg_text +test.t1 check status OK +SELECT id, k, LENGTH(b) FROM t1 FORCE INDEX(k1) WHERE k = 'key1'; +id k LENGTH(b) +1 key1 300 +SELECT COUNT(*) FROM t1 FORCE INDEX(k1) WHERE k = 'moved'; +COUNT(*) +0 +DROP TABLE t1; +# +# Two indexed columns changed by the same failing UPDATE: +# entries in both indexes must be rolled back +# +CREATE TABLE t1 ( +id INT, +k VARCHAR(20), +m VARCHAR(20), +b BLOB, +KEY k1 (k), +KEY k2 (m) USING BTREE +) ENGINE=HEAP; +INSERT INTO t1 SELECT seq, CONCAT('key', seq), CONCAT('mem', seq), +REPEAT('a', 300) +FROM seq_1_to_10; +UPDATE t1 SET k= 'moved', m= 'gone', b= REPEAT('z', 60000) WHERE id = 2; +ERROR HY000: The table 't1' is full +CHECK TABLE t1; +Table Op Msg_type Msg_text +test.t1 check status OK +SELECT id, k, m FROM t1 FORCE INDEX(k1) WHERE k = 'key2'; +id k m +2 key2 mem2 +SELECT id, k, m FROM t1 FORCE INDEX(k2) WHERE m = 'mem2'; +id k m +2 key2 mem2 +SELECT COUNT(*) FROM t1 FORCE INDEX(k1) WHERE k = 'moved'; +COUNT(*) +0 +SELECT COUNT(*) FROM t1 FORCE INDEX(k2) WHERE m = 'gone'; +COUNT(*) +0 +DROP TABLE t1; +# +# A second index on a column the UPDATE does NOT touch must be left +# alone by the rollback: only the changed key is moved back, the +# unchanged key's entries stay put and keep finding the row. +# +CREATE TABLE t1 ( +id INT, +k VARCHAR(20), +fixed VARCHAR(20), +b BLOB, +KEY k1 (k), +KEY k2 (fixed) +) ENGINE=HEAP; +INSERT INTO t1 SELECT seq, CONCAT('key', seq), CONCAT('fix', seq), +REPEAT('a', 300) +FROM seq_1_to_10; +# Only k and b change; 'fixed' is untouched +UPDATE t1 SET k= 'moved', b= REPEAT('z', 60000) WHERE id = 3; +ERROR HY000: The table 't1' is full +CHECK TABLE t1; +Table Op Msg_type Msg_text +test.t1 check status OK +# Changed key rolled back to old value +SELECT id, k, fixed FROM t1 FORCE INDEX(k1) WHERE k = 'key3'; +id k fixed +3 key3 fix3 +SELECT COUNT(*) FROM t1 FORCE INDEX(k1) WHERE k = 'moved'; +COUNT(*) +0 +# Untouched key still resolves the row through its own index +SELECT id, k, fixed FROM t1 FORCE INDEX(k2) WHERE fixed = 'fix3'; +id k fixed +3 key3 fix3 +DROP TABLE t1; +# +# Multi-row UPDATE that fails part-way: rows updated before the +# failure keep their new key values, the failing row keeps its old +# ones, and every row must be reachable through the index by its +# current key value +# +CREATE TABLE t1 ( +id INT, +k VARCHAR(20), +b BLOB, +KEY k1 (k) +) ENGINE=HEAP; +INSERT INTO t1 SELECT seq, CONCAT('key', seq), REPEAT('a', 300) +FROM seq_1_to_15; +# Each row grows its blob; the table fills up after some rows +UPDATE t1 SET k= CONCAT('new', id), b= REPEAT('z', 1500); +ERROR HY000: The table 't1' is full +CHECK TABLE t1; +Table Op Msg_type Msg_text +test.t1 check status OK +# Every row must be found via the index under its current key value +SELECT t.id FROM t1 t +WHERE NOT EXISTS (SELECT 1 FROM t1 i FORCE INDEX(k1) +WHERE i.k = t.k AND i.id = t.id) +ORDER BY t.id; +id +# Old and new key values must partition the table, with the +# failure hitting neither the first row nor after the last one. +# The exact fill point depends on platform record layout, so only +# the invariants are checked. +SELECT COUNT(*) AS total, +COUNT(IF(k LIKE 'new%', 1, NULL)) > 0 AS some_updated, +COUNT(IF(k LIKE 'key%', 1, NULL)) > 0 AS some_old, +COUNT(IF(k LIKE 'new%', 1, NULL)) + +COUNT(IF(k LIKE 'key%', 1, NULL)) = COUNT(*) AS partitioned +FROM t1; +total some_updated some_old partitioned +15 1 1 1 +DROP TABLE t1; +# +# Table with no indexes at all: the failed blob write must leave the +# record intact with nothing to roll back in the (empty) key set +# +CREATE TABLE t1 ( +id INT, +b BLOB +) ENGINE=HEAP; +INSERT INTO t1 SELECT seq, REPEAT('a', 300) FROM seq_1_to_10; +UPDATE t1 SET b= REPEAT('z', 60000) WHERE id = 1; +ERROR HY000: The table 't1' is full +CHECK TABLE t1; +Table Op Msg_type Msg_text +test.t1 check status OK +SELECT id, LENGTH(b) FROM t1 WHERE id = 1; +id LENGTH(b) +1 300 +SELECT COUNT(*) FROM t1; +COUNT(*) +10 +DROP TABLE t1; +SET max_heap_table_size = @save_max_heap; diff --git a/mysql-test/suite/heap/blob_update_key_rollback.test b/mysql-test/suite/heap/blob_update_key_rollback.test new file mode 100644 index 0000000000000..b2ba7136cdb9a --- /dev/null +++ b/mysql-test/suite/heap/blob_update_key_rollback.test @@ -0,0 +1,179 @@ +--source include/have_sequence.inc + +--echo # +--echo # Index consistency after a failed UPDATE on a HEAP table with BLOB +--echo # +--echo # heap_update() moves changed key entries to the new key values +--echo # before writing the new blob chains. When a blob chain write then +--echo # fails with "table is full", the key entries must be moved back to +--echo # the old values, otherwise the index refers to records whose +--echo # columns no longer match the indexed values. +--echo # + +SET @save_max_heap= @@max_heap_table_size; +SET max_heap_table_size = 16384; + +--echo # +--echo # Hash index: single-row UPDATE fails writing the new blob chain +--echo # + +CREATE TABLE t1 ( + id INT, + k VARCHAR(20), + b BLOB, + KEY k1 (k) +) ENGINE=HEAP; + +INSERT INTO t1 SELECT seq, CONCAT('key', seq), REPEAT('a', 300) +FROM seq_1_to_10; + +--error ER_RECORD_FILE_FULL +UPDATE t1 SET k= 'moved', b= REPEAT('z', 60000) WHERE id = 1; + +CHECK TABLE t1; +--echo # The row must still be found through the index under the old value +SELECT id, k, LENGTH(b) FROM t1 FORCE INDEX(k1) WHERE k = 'key1'; +SELECT COUNT(*) FROM t1 FORCE INDEX(k1) WHERE k = 'moved'; +DROP TABLE t1; + +--echo # +--echo # BTREE index: same failure, rb-tree entries must be restored +--echo # + +CREATE TABLE t1 ( + id INT, + k VARCHAR(20), + b BLOB, + KEY k1 (k) USING BTREE +) ENGINE=HEAP; + +INSERT INTO t1 SELECT seq, CONCAT('key', seq), REPEAT('a', 300) +FROM seq_1_to_10; + +--error ER_RECORD_FILE_FULL +UPDATE t1 SET k= 'moved', b= REPEAT('z', 60000) WHERE id = 1; + +CHECK TABLE t1; +SELECT id, k, LENGTH(b) FROM t1 FORCE INDEX(k1) WHERE k = 'key1'; +SELECT COUNT(*) FROM t1 FORCE INDEX(k1) WHERE k = 'moved'; +DROP TABLE t1; + +--echo # +--echo # Two indexed columns changed by the same failing UPDATE: +--echo # entries in both indexes must be rolled back +--echo # + +CREATE TABLE t1 ( + id INT, + k VARCHAR(20), + m VARCHAR(20), + b BLOB, + KEY k1 (k), + KEY k2 (m) USING BTREE +) ENGINE=HEAP; + +INSERT INTO t1 SELECT seq, CONCAT('key', seq), CONCAT('mem', seq), + REPEAT('a', 300) +FROM seq_1_to_10; + +--error ER_RECORD_FILE_FULL +UPDATE t1 SET k= 'moved', m= 'gone', b= REPEAT('z', 60000) WHERE id = 2; + +CHECK TABLE t1; +SELECT id, k, m FROM t1 FORCE INDEX(k1) WHERE k = 'key2'; +SELECT id, k, m FROM t1 FORCE INDEX(k2) WHERE m = 'mem2'; +SELECT COUNT(*) FROM t1 FORCE INDEX(k1) WHERE k = 'moved'; +SELECT COUNT(*) FROM t1 FORCE INDEX(k2) WHERE m = 'gone'; +DROP TABLE t1; + +--echo # +--echo # A second index on a column the UPDATE does NOT touch must be left +--echo # alone by the rollback: only the changed key is moved back, the +--echo # unchanged key's entries stay put and keep finding the row. +--echo # + +CREATE TABLE t1 ( + id INT, + k VARCHAR(20), + fixed VARCHAR(20), + b BLOB, + KEY k1 (k), + KEY k2 (fixed) +) ENGINE=HEAP; + +INSERT INTO t1 SELECT seq, CONCAT('key', seq), CONCAT('fix', seq), + REPEAT('a', 300) +FROM seq_1_to_10; + +--echo # Only k and b change; 'fixed' is untouched +--error ER_RECORD_FILE_FULL +UPDATE t1 SET k= 'moved', b= REPEAT('z', 60000) WHERE id = 3; + +CHECK TABLE t1; +--echo # Changed key rolled back to old value +SELECT id, k, fixed FROM t1 FORCE INDEX(k1) WHERE k = 'key3'; +SELECT COUNT(*) FROM t1 FORCE INDEX(k1) WHERE k = 'moved'; +--echo # Untouched key still resolves the row through its own index +SELECT id, k, fixed FROM t1 FORCE INDEX(k2) WHERE fixed = 'fix3'; +DROP TABLE t1; + +--echo # +--echo # Multi-row UPDATE that fails part-way: rows updated before the +--echo # failure keep their new key values, the failing row keeps its old +--echo # ones, and every row must be reachable through the index by its +--echo # current key value +--echo # + +CREATE TABLE t1 ( + id INT, + k VARCHAR(20), + b BLOB, + KEY k1 (k) +) ENGINE=HEAP; + +INSERT INTO t1 SELECT seq, CONCAT('key', seq), REPEAT('a', 300) +FROM seq_1_to_15; + +--echo # Each row grows its blob; the table fills up after some rows +--error ER_RECORD_FILE_FULL +UPDATE t1 SET k= CONCAT('new', id), b= REPEAT('z', 1500); + +CHECK TABLE t1; +--echo # Every row must be found via the index under its current key value +SELECT t.id FROM t1 t +WHERE NOT EXISTS (SELECT 1 FROM t1 i FORCE INDEX(k1) + WHERE i.k = t.k AND i.id = t.id) +ORDER BY t.id; +--echo # Old and new key values must partition the table, with the +--echo # failure hitting neither the first row nor after the last one. +--echo # The exact fill point depends on platform record layout, so only +--echo # the invariants are checked. +SELECT COUNT(*) AS total, + COUNT(IF(k LIKE 'new%', 1, NULL)) > 0 AS some_updated, + COUNT(IF(k LIKE 'key%', 1, NULL)) > 0 AS some_old, + COUNT(IF(k LIKE 'new%', 1, NULL)) + + COUNT(IF(k LIKE 'key%', 1, NULL)) = COUNT(*) AS partitioned +FROM t1; +DROP TABLE t1; + +--echo # +--echo # Table with no indexes at all: the failed blob write must leave the +--echo # record intact with nothing to roll back in the (empty) key set +--echo # + +CREATE TABLE t1 ( + id INT, + b BLOB +) ENGINE=HEAP; + +INSERT INTO t1 SELECT seq, REPEAT('a', 300) FROM seq_1_to_10; + +--error ER_RECORD_FILE_FULL +UPDATE t1 SET b= REPEAT('z', 60000) WHERE id = 1; + +CHECK TABLE t1; +SELECT id, LENGTH(b) FROM t1 WHERE id = 1; +SELECT COUNT(*) FROM t1; +DROP TABLE t1; + +SET max_heap_table_size = @save_max_heap; diff --git a/storage/heap/hp_update.c b/storage/heap/hp_update.c index f822c8daaf803..aa40524649497 100644 --- a/storage/heap/hp_update.c +++ b/storage/heap/hp_update.c @@ -20,7 +20,7 @@ int heap_update(HP_INFO *info, const uchar *old, const uchar *heap_new) { - HP_KEYDEF *keydef, *end, *p_lastinx; + HP_KEYDEF *keydef, *keydef_end, *p_lastinx; uchar *pos, *recovery_ptr; struct st_hp_hash_info *recovery_hash_ptr; my_bool auto_key_changed= 0, key_changed= 0; @@ -41,7 +41,9 @@ int heap_update(HP_INFO *info, const uchar *old, const uchar *heap_new) recovery_hash_ptr= info->current_hash_ptr; p_lastinx= share->keydef + info->lastinx; - for (keydef= share->keydef, end= keydef + share->keys; keydef < end; keydef++) + for (keydef= share->keydef, keydef_end= keydef + share->keys; + keydef < keydef_end; + keydef++) { if (hp_rec_key_cmp(keydef, heap_new, old, NULL)) { @@ -253,20 +255,39 @@ int heap_update(HP_INFO *info, const uchar *old, const uchar *heap_new) DBUG_RETURN(0); err: - if (my_errno == HA_ERR_FOUND_DUPP_KEY) + info->errkey= -1; /* If not a key error */ + if (my_errno == HA_ERR_FOUND_DUPP_KEY || my_errno == HA_ERR_OUT_OF_MEM || + my_errno == ENOMEM || my_errno == HA_ERR_RECORD_FILE_FULL) { - info->errkey = (int) (keydef - share->keydef); - if (keydef->algorithm == HA_KEY_ALG_BTREE) + int org_error= my_errno; + if (keydef == keydef_end) { - /* we don't need to delete non-inserted key from rb-tree */ - if ((*keydef->write_key)(info, keydef, old, pos)) + /* + We got a failure after all key parts have been written, like + while writing blobs. Alternatively there are no keys. + Decrement keydef so that it points at the last key part or + before share->keydef. + */ + DBUG_ASSERT(share->keydef); + keydef--; + } + else + { + /* keydef points to the last key that failed */ + info->errkey = (int) (keydef - share->keydef); + if (keydef->algorithm == HA_KEY_ALG_BTREE) { - if (++(share->records) == share->blength) - share->blength+= share->blength; - DBUG_RETURN(my_errno); + /* we don't need to delete non-inserted key from rb-tree */ + if ((*keydef->write_key)(info, keydef, old, pos)) + { + if (++(share->records) == share->blength) + share->blength+= share->blength; + DBUG_RETURN(my_errno); + } + keydef--; } - keydef--; } + /* Restore all modified keys */ while (keydef >= share->keydef) { if (hp_rec_key_cmp(keydef, heap_new, old, NULL)) @@ -279,6 +300,7 @@ int heap_update(HP_INFO *info, const uchar *old, const uchar *heap_new) } info->current_ptr= recovery_ptr; info->current_hash_ptr= recovery_hash_ptr; + my_errno= org_error; } if (++(share->records) == share->blength) share->blength+= share->blength;