1
0
mirror of https://github.com/MariaDB/server.git synced 2026-10-23 18:49:29 +03:00

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.
This commit is contained in:
Arcadiy Ivanov
2026-07-28 16:05:52 -04:00
committed by Monty
parent f1a72e2cca
commit 45fec44293
3 changed files with 399 additions and 12 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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))
{
@@ -260,20 +262,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))
{
if (++(share->records) == share->blength)
share->blength+= share->blength;
DBUG_RETURN(my_errno);
}
/*
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)
{
/* 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--;
}
}
/* Restore all modified keys */
while (keydef >= share->keydef)
{
if (hp_rec_key_cmp(keydef, heap_new, old, NULL))
@@ -286,6 +307,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;