From a4c9d3157291d519df613867fc9010f0a2871f05 Mon Sep 17 00:00:00 2001 From: Arcadiy Ivanov Date: Fri, 17 Jul 2026 14:36:05 -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: give the blob-write failure its own recovery label (`err_blob:`) that moves every changed key back to the old value (`delete_key(new)` + `write_key(old)`), mirroring the existing `HA_ERR_FOUND_DUPP_KEY` recovery. All keys were fully moved before the blob section runs, so a plain sweep over all changed keys suffices, and the record at `pos` has already been restored to the old image -- exactly the state the `DUPP_KEY` recovery path operates in. The blob failure's errno is captured before the rollback loop 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 the original error. `err_blob:` then falls through into `err:` to share the record-count/blength epilogue, which is safe because the restored errno is never `HA_ERR_FOUND_DUPP_KEY`. 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), and a partial multi-row UPDATE; each asserts the index stays consistent after the failure via `CHECK TABLE` and index lookups. --- .../heap/blob_update_key_rollback.result | 165 ++++++++++++++++++ .../suite/heap/blob_update_key_rollback.test | 159 +++++++++++++++++ storage/heap/hp_update.c | 28 ++- 3 files changed, 351 insertions(+), 1 deletion(-) 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..bf175d52f4d5f --- /dev/null +++ b/mysql-test/suite/heap/blob_update_key_rollback.result @@ -0,0 +1,165 @@ +# +# 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; +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..1f9857cd7f4c7 --- /dev/null +++ b/mysql-test/suite/heap/blob_update_key_rollback.test @@ -0,0 +1,159 @@ +--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; + +SET max_heap_table_size = @save_max_heap; diff --git a/storage/heap/hp_update.c b/storage/heap/hp_update.c index f822c8daaf803..a5945faeb3475 100644 --- a/storage/heap/hp_update.c +++ b/storage/heap/hp_update.c @@ -173,7 +173,7 @@ int heap_update(HP_INFO *info, const uchar *old, const uchar *heap_new) pos[share->visible]|= HP_ROW_HAS_CONT; } my_safe_afree(saved_chains, alloc_size); - goto err; + goto err_blob; } memcpy(pos + desc->offset + desc->packlength, &first_run, sizeof(first_run)); @@ -252,6 +252,32 @@ int heap_update(HP_INFO *info, const uchar *old, const uchar *heap_new) share->key_version++; DBUG_RETURN(0); + err_blob: + { + /* + A blob chain write failed after the key loop above had already + moved all changed keys to the new values. The record at pos has + been restored to the old image; move every changed key back as + well, else the index refers to a record that no longer matches it. + Report the blob failure even if a rollback step fails, so the + caller never sees a misleading error from the recovery itself. + */ + int blob_errno= my_errno; + for (keydef= share->keydef; keydef < end; keydef++) + { + if (hp_rec_key_cmp(keydef, heap_new, old, NULL)) + { + if ((*keydef->delete_key)(info, keydef, heap_new, pos, 0) || + (*keydef->write_key)(info, keydef, old, pos)) + break; + } + } + info->current_ptr= recovery_ptr; + info->current_hash_ptr= recovery_hash_ptr; + my_errno= blob_errno; + } + /* fall through: blob_errno is never HA_ERR_FOUND_DUPP_KEY */ + err: if (my_errno == HA_ERR_FOUND_DUPP_KEY) {