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

MDEV-41398 Stale optimistic btr_pcur_t restore after buffer pool resize

Problem: btr_pcur_t::restore_position() accepts a stored position
optimistically when the block at the stored descriptor address has the
same page id and the same buf_block_t::modify_clock. When
buf_pool_t::resize() shrinks the buffer pool, the withdrawn block
descriptors are freed. When it grows the buffer pool again, the new
descriptors at the same addresses start with modify_clock=0. A cursor
that was stored before the shrink can then find a different instance of
the page with an equal modify_clock, and it restores to a record that
is no longer correct. In a release build, an UPDATE can change a row
that does not match its WHERE condition and skip the rows that match.

Fix: Add buf_pool_t::withdrawn_modify_clock. buf_pool_t::shrink() sets
it to the maximum modify_clock of the blocks that it withdraws.
buf_pool_t::resize() sets the initial modify_clock of each block that
it adds to one more than this. A relocation in shrink() increments only
the modify_clock of the destination block, so a withdrawn block can
keep the value that a cursor stored. Thus the modify_clock at a block
descriptor address never repeats an earlier value, and a stale
optimistic restore fails. The new member uses a hole before
buf_pool_t::flush_list_mutex, so the layout of the other members does
not change.

The test innodb.buf_pool_resize_pcur_restore reads a page into the
first new block after a grow, and makes a stored cursor for a read and
for an UPDATE restore its position across a page reorganization, a
shrink and a grow.
This commit is contained in:
Alessandro Vetere
2026-10-14 18:05:47 +02:00
parent fb641b0cd1
commit c0eea2b8c5
5 changed files with 311 additions and 0 deletions

View File

@@ -0,0 +1,122 @@
create table t1 (pk int primary key, val varchar(255) not null)
engine=innodb stats_persistent=0 charset=latin1;
insert into t1 select seq, repeat('a', 100) from seq_1_to_20 order by seq desc;
create table t2 like t1;
insert into t2 select seq, repeat('a', 100) from seq_1_to_20 order by seq desc;
create table t3 (pk int primary key) engine=innodb stats_persistent=0
partition by hash(pk) partitions 256;
insert into t3 select seq from seq_0_to_255;
create table t4 (a int primary key, b varchar(7000)) engine=innodb
stats_persistent=0;
insert into t4 select seq, repeat('x', 7000) from seq_1_to_3000;
create function hold(x int) returns int
begin
if x = 1 then
set debug_sync= 'now SIGNAL paused WAIT_FOR go';
end if;
return 1;
end|
create procedure read_into_first_new_block(tbl varchar(64))
begin
declare attempts int default 5;
declare blk bigint default -1;
declare p int;
set @read_tbl= concat('select count(*) into @n from ', tbl);
prepare read_tbl from @read_tbl;
while blk != @first_new do
if attempts = 0 then
signal sqlstate '45000' set message_text= 'Wrong placement';
end if;
set attempts= attempts - 1;
if blk != -1 then
set global innodb_buffer_pool_size= @old_size;
end if;
# Evict all pages of t1, t2 and t3.
select sum(length(b)) into @n from t4;
if @first_new = 0 then
set @first_new= (select pool_size
from information_schema.innodb_buffer_pool_stats);
end if;
set global innodb_buffer_pool_size= @new_size;
# A grow appends the new blocks to the end of the free list. Read pages
# of t3 until only the new blocks are free.
set p= 0;
while (select free_buffers > pool_size - @first_new
from information_schema.innodb_buffer_pool_stats) do
if p = 256 then
signal sqlstate '45000' set message_text= 'Out of partitions';
end if;
select pk into @n from t3 where pk= p;
set p= p + 1;
end while;
execute read_tbl;
select block_id into blk from information_schema.innodb_buffer_page
where table_name = concat('`test`.`', tbl, '`');
end while;
deallocate prepare read_tbl;
end|
set global innodb_max_purge_lag_wait= 0;
set @old_size= @@global.innodb_buffer_pool_size;
set @new_size= 12582912;
set @first_new= 0;
set @save_scan_depth= @@global.innodb_lru_scan_depth;
set global innodb_lru_scan_depth= 100;
#
# A read that continues from a stale position
#
# Read the page of t1 into the first new block.
call read_into_first_new_block('t1');
connect con1,localhost,root;
select group_concat(pk) from t1 where hold(pk);
connection default;
set debug_sync= 'now WAIT_FOR paused';
# Reorganize the page while the read holds a stored position on pk=1.
set @save_dbug= @@debug_dbug;
set debug_dbug= '+d,do_page_reorganize';
insert into t1 values (21, repeat('a', 100));
set debug_dbug= @save_dbug;
# Shrink, which moves the page out of the block.
set global innodb_buffer_pool_size= @old_size;
# Read the page of t1 into the same block again.
call read_into_first_new_block('t1');
# Resume the read, which must return pk=2 to pk=20.
set debug_sync= 'now SIGNAL go';
connection con1;
group_concat(pk)
1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20
connection default;
set global innodb_buffer_pool_size= @old_size;
set global innodb_max_purge_lag_wait= 0;
#
# An UPDATE that continues from a stale position
#
# Read the page of t2 into the first new block.
call read_into_first_new_block('t2');
connection con1;
update t2 set val= 'b' where hold(pk);
connection default;
set debug_sync= 'now WAIT_FOR paused';
# Reorganize the page while the UPDATE holds a stored position on pk=1.
set debug_dbug= '+d,do_page_reorganize';
insert into t2 values (21, repeat('a', 100));
set debug_dbug= @save_dbug;
# Shrink, which moves the page out of the block.
set global innodb_buffer_pool_size= @old_size;
# Read the page of t2 into the same block again.
call read_into_first_new_block('t2');
# Resume the UPDATE, which must change pk=1, not pk=20.
set debug_sync= 'now SIGNAL go';
connection con1;
affected rows: 21
info: Rows matched: 21 Changed: 21 Warnings: 0
disconnect con1;
connection default;
select group_concat(pk) from t2 where val = 'b';
group_concat(pk)
1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21
set debug_sync= 'reset';
drop procedure read_into_first_new_block;
drop function hold;
drop table t1, t2, t3, t4;
set global innodb_buffer_pool_size= @old_size;
set global innodb_lru_scan_depth= @save_scan_depth;

View File

@@ -0,0 +1,3 @@
--innodb-buffer-pool-size=8M
--innodb-buffer-pool-size-max=16M
--innodb-read-ahead-threshold=0

View File

@@ -0,0 +1,173 @@
#
# A persistent cursor that is restored after the buffer pool shrank and
# grew again
#
# btr_pcur_t::restore_position() accepts the stored position without a
# search when the block descriptor that it remembers holds the same page
# id and the same modify_clock. The descriptors are at fixed addresses,
# and a grow initializes the descriptors that an earlier shrink withdrew.
# If that reset modify_clock, a page that is read into such a descriptor
# after the grow could have the modify_clock that a cursor stored before
# the shrink, when the page had been read into the same descriptor after
# an earlier grow. The page can have changed in between, and the cursor
# would continue from a stale record pointer.
#
--source include/have_innodb.inc
--source include/have_innodb_16k.inc
--source include/have_partition.inc
--source include/have_sequence.inc
--source include/have_debug.inc
--source include/have_debug_sync.inc
--source include/not_embedded.inc
--source include/count_sessions.inc
# One leaf page each, with the records in descending key order in the page.
# A reorganization writes them in key order, so pk=20 moves to the offset
# of pk=1.
create table t1 (pk int primary key, val varchar(255) not null)
engine=innodb stats_persistent=0 charset=latin1;
insert into t1 select seq, repeat('a', 100) from seq_1_to_20 order by seq desc;
create table t2 like t1;
insert into t2 select seq, repeat('a', 100) from seq_1_to_20 order by seq desc;
# One page per partition. A lookup of an evicted partition reads one page.
# The page cleaner keeps about innodb_lru_scan_depth blocks free, and the
# partitions must outnumber them.
create table t3 (pk int primary key) engine=innodb stats_persistent=0
partition by hash(pk) partitions 256;
insert into t3 select seq from seq_0_to_255;
# Three times innodb_buffer_pool_size, to evict everything else.
create table t4 (a int primary key, b varchar(7000)) engine=innodb
stats_persistent=0;
insert into t4 select seq, repeat('x', 7000) from seq_1_to_3000;
delimiter |;
# Stop the statement after it read pk=1 and stored its position.
create function hold(x int) returns int
begin
if x = 1 then
set debug_sync= 'now SIGNAL paused WAIT_FOR go';
end if;
return 1;
end|
# Grow the buffer pool from @old_size to @new_size and read the only page
# of the table tbl into the first block that the grow adds. A concurrent
# page read can take that block; then shrink and try again. The first call
# sets @first_new, the block id of the first new block. Later calls require
# the same @first_new.
create procedure read_into_first_new_block(tbl varchar(64))
begin
declare attempts int default 5;
declare blk bigint default -1;
declare p int;
set @read_tbl= concat('select count(*) into @n from ', tbl);
prepare read_tbl from @read_tbl;
while blk != @first_new do
if attempts = 0 then
signal sqlstate '45000' set message_text= 'Wrong placement';
end if;
set attempts= attempts - 1;
if blk != -1 then
set global innodb_buffer_pool_size= @old_size;
end if;
# Evict all pages of t1, t2 and t3.
select sum(length(b)) into @n from t4;
if @first_new = 0 then
set @first_new= (select pool_size
from information_schema.innodb_buffer_pool_stats);
end if;
set global innodb_buffer_pool_size= @new_size;
# A grow appends the new blocks to the end of the free list. Read pages
# of t3 until only the new blocks are free.
set p= 0;
while (select free_buffers > pool_size - @first_new
from information_schema.innodb_buffer_pool_stats) do
if p = 256 then
signal sqlstate '45000' set message_text= 'Out of partitions';
end if;
select pk into @n from t3 where pk= p;
set p= p + 1;
end while;
execute read_tbl;
select block_id into blk from information_schema.innodb_buffer_page
where table_name = concat('`test`.`', tbl, '`');
end while;
deallocate prepare read_tbl;
end|
delimiter ;|
# Purge resets DB_TRX_ID in the inserted records. Let it finish, so that it
# reads no pages into the free blocks that the procedure counts.
set global innodb_max_purge_lag_wait= 0;
set @old_size= @@global.innodb_buffer_pool_size;
set @new_size= 12582912;
set @first_new= 0;
# The minimum, which keeps fewer blocks free than t3 has partitions.
set @save_scan_depth= @@global.innodb_lru_scan_depth;
set global innodb_lru_scan_depth= 100;
--echo #
--echo # A read that continues from a stale position
--echo #
--echo # Read the page of t1 into the first new block.
call read_into_first_new_block('t1');
connect (con1,localhost,root);
--send select group_concat(pk) from t1 where hold(pk)
connection default;
set debug_sync= 'now WAIT_FOR paused';
--echo # Reorganize the page while the read holds a stored position on pk=1.
set @save_dbug= @@debug_dbug;
set debug_dbug= '+d,do_page_reorganize';
insert into t1 values (21, repeat('a', 100));
set debug_dbug= @save_dbug;
--echo # Shrink, which moves the page out of the block.
set global innodb_buffer_pool_size= @old_size;
--echo # Read the page of t1 into the same block again.
call read_into_first_new_block('t1');
--echo # Resume the read, which must return pk=2 to pk=20.
set debug_sync= 'now SIGNAL go';
connection con1;
--reap
connection default;
set global innodb_buffer_pool_size= @old_size;
# Purge was blocked by the read view of the paused read. Let it finish, so
# that it reads no pages into the new blocks of the next case.
set global innodb_max_purge_lag_wait= 0;
--echo #
--echo # An UPDATE that continues from a stale position
--echo #
--echo # Read the page of t2 into the first new block.
call read_into_first_new_block('t2');
connection con1;
--send update t2 set val= 'b' where hold(pk)
connection default;
set debug_sync= 'now WAIT_FOR paused';
--echo # Reorganize the page while the UPDATE holds a stored position on pk=1.
set debug_dbug= '+d,do_page_reorganize';
insert into t2 values (21, repeat('a', 100));
set debug_dbug= @save_dbug;
--echo # Shrink, which moves the page out of the block.
set global innodb_buffer_pool_size= @old_size;
--echo # Read the page of t2 into the same block again.
call read_into_first_new_block('t2');
--echo # Resume the UPDATE, which must change pk=1, not pk=20.
set debug_sync= 'now SIGNAL go';
connection con1;
--enable_info
--reap
--disable_info
disconnect con1;
connection default;
select group_concat(pk) from t2 where val = 'b';
set debug_sync= 'reset';
drop procedure read_into_first_new_block;
drop function hold;
drop table t1, t2, t3, t4;
set global innodb_buffer_pool_size= @old_size;
set global innodb_lru_scan_depth= @save_scan_depth;
--source include/wait_until_count_sessions.inc

View File

@@ -1661,12 +1661,15 @@ ATTRIBUTE_COLD buf_pool_t::shrink_status buf_pool_t::shrink(size_t size)
{
withdraw_done:
first_to_withdraw= nullptr;
uint64_t base{0};
while (buf_page_t *b= UT_LIST_GET_FIRST(withdrawn))
{
UT_LIST_REMOVE(withdrawn, b);
base= std::max(base, reinterpret_cast<buf_block_t*>(b)->modify_clock);
/* satisfy the check in lazy_allocate() */
ut_d(memset((void*) b, 0, sizeof(buf_block_t)));
}
withdrawn_modify_clock= std::max(withdrawn_modify_clock, base);
return SHRINK_DONE;
}
@@ -1976,6 +1979,7 @@ ATTRIBUTE_COLD void buf_pool_t::resize(size_t size, THD *thd) noexcept
const size_t ssize= srv_page_size_shift - UNIV_PAGE_SIZE_SHIFT_MIN;
const size_t pages= pages_in_extent[ssize];
const size_t first_extent= n_blocks / pages;
const uint64_t modify_clock= withdrawn_modify_clock + 1;
char *extent= memory + first_extent * innodb_buffer_pool_extent_size;
@@ -1997,6 +2001,7 @@ ATTRIBUTE_COLD void buf_pool_t::resize(size_t size, THD *thd) noexcept
{
block->page.frame= frame;
block->page.lock.init();
block->modify_clock= modify_clock;
UT_LIST_ADD_LAST(free, &block->page);
ut_d(block->page.in_free_list= true);
}
@@ -2020,6 +2025,7 @@ ATTRIBUTE_COLD void buf_pool_t::resize(size_t size, THD *thd) noexcept
{
block->page.frame= frame;
block->page.lock.init();
block->modify_clock= modify_clock;
UT_LIST_ADD_LAST(free, &block->page);
ut_d(block->page.in_free_list= true);
}

View File

@@ -1715,6 +1715,13 @@ public:
/** number of index page splits */
Atomic_counter<ulint> pages_split;
/** the maximum modify_clock of any block that shrink() withdrew;
resize() initializes the modify_clock of the blocks that it adds to
one more than this, so that the modify_clock at a block descriptor
address never repeats an earlier value and a stale
btr_pcur_t::restore_position() cannot succeed optimistically;
protected by mutex */
uint64_t withdrawn_modify_clock;
/** @name Page flushing algorithm fields */
/* @{ */