From c6d2c2db1af85c68c071ee9bb08443d39aa40015 Mon Sep 17 00:00:00 2001 From: Przemyslaw Skibinski Date: Tue, 9 Jun 2026 14:43:48 +0200 Subject: [PATCH] InnoDB: make_page_dirty debug command logs MLOG_2BYTES on compressed index pages, crashing crash recovery The debug innodb_interpreter make_page_dirty command (storage/innobase/ut/ut0test.cc) dirties a page by rewriting FIL_PAGE_TYPE with a generic MLOG_2BYTES redo record via mlog_write_ulint(). When the target page is a compressed (ROW_FORMAT=COMPRESSED) index page, crash recovery later replays that generic MLOG_nBYTES record with both page and page_zip set, which violates the redo parser invariant ut_a(!page || !page_zip || !fil_page_index_page_check(page)); checked in mlog_parse_nbytes() (storage/innobase/mtr/mtr0log.cc), asserting and crashing during recovery. For compressed index pages, header modifications must be logged with MLOG_ZIP_WRITE_HEADER so the compressed page image stays consistent on replay. Write the header value with mach_write_to_2() and log it through page_zip_write_header() for compressed index pages, matching how normal compressed-page header updates are logged. The existing MLOG_2BYTES path is retained for uncompressed and non-index pages. A new test, innodb.make_page_dirty_rowcomp, reproduces the crash deterministically: it saves the clean (pre-make_page_dirty) image of the compressed index root page, runs make_page_dirty, kills the server, and restores the saved older image on disk before restart so that recovery is forced to replay the make_page_dirty redo record on top of it. Verified with: ./mysql-test-run.pl --suite=innodb make_page_dirty_rowcomp \ --debug-server --retry=0 --force --parallel=1 --- .../innodb/r/make_page_dirty_rowcomp.result | 50 ++++++++ .../innodb/t/make_page_dirty_rowcomp.test | 121 ++++++++++++++++++ storage/innobase/ut/ut0test.cc | 9 +- 3 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 mysql-test/suite/innodb/r/make_page_dirty_rowcomp.result create mode 100644 mysql-test/suite/innodb/t/make_page_dirty_rowcomp.test diff --git a/mysql-test/suite/innodb/r/make_page_dirty_rowcomp.result b/mysql-test/suite/innodb/r/make_page_dirty_rowcomp.result new file mode 100644 index 000000000000..51d51154414f --- /dev/null +++ b/mysql-test/suite/innodb/r/make_page_dirty_rowcomp.result @@ -0,0 +1,50 @@ +CREATE TABLE t1 (f1 INT, f2 BLOB) ROW_FORMAT=COMPRESSED; +START TRANSACTION; +INSERT INTO t1 VALUES(1, repeat('#',12)); +INSERT INTO t1 VALUES(2, repeat('+',12)); +INSERT INTO t1 VALUES(3, repeat('/',12)); +INSERT INTO t1 VALUES(4, repeat('-',12)); +INSERT INTO t1 VALUES(5, repeat('.',12)); +COMMIT WORK; +SET SESSION innodb_interpreter = 'open_table test/t1'; +# Identify the space_id of the given table. +SELECT space FROM INFORMATION_SCHEMA.INNODB_TABLESPACEs +WHERE name = 'test/t1' INTO @space_id; +# Find the root page number of the given table. +SET SESSION innodb_interpreter = 'find_root_page_no test/t1'; +SELECT @@session.innodb_interpreter_output INTO @page_no; +SET SESSION innodb_interpreter = 'find_tablespace_file_name test/t1'; +SELECT @@session.innodb_interpreter_output INTO @space_file_name; +SET SESSION innodb_interpreter = 'find_tablespace_physical_page_size test/t1'; +SELECT @@session.innodb_interpreter_output INTO @space_page_size; +SET SESSION innodb_interpreter = 'destroy'; +# Flush the clean root page to disk and save its image. +FLUSH TABLES t1 FOR EXPORT; +UNLOCK TABLES; +# Disable checkpointing and background flushing so the make_page_dirty +# redo record is present, but not checkpointed, at recovery time. +SET GLOBAL innodb_master_thread_disabled_debug=1; +SET GLOBAL innodb_checkpoint_disabled = 1; +BEGIN; +INSERT INTO t1 VALUES (6, repeat('%', 12)); +SET SESSION innodb_interpreter = 'open_table test/t1'; +# Dirty the compressed index root page (logs the header rewrite). +SET @cmd = CONCAT('make_page_dirty ', @space_id, ' ', @page_no); +SET SESSION innodb_interpreter = @cmd; +SET SESSION innodb_interpreter = 'destroy'; +# Kill the server +# Restore the older, clean image of the root page on disk so recovery +# replays the make_page_dirty redo record on top of it. +# Restart: crash recovery replays the make_page_dirty redo record. +# restart +CHECK TABLE t1; +Table Op Msg_type Msg_text +test.t1 check status OK +SELECT f1, f2 FROM t1; +f1 f2 +1 ############ +2 ++++++++++++ +3 //////////// +4 ------------ +5 ............ +DROP TABLE t1; diff --git a/mysql-test/suite/innodb/t/make_page_dirty_rowcomp.test b/mysql-test/suite/innodb/t/make_page_dirty_rowcomp.test new file mode 100644 index 000000000000..c53df72625c6 --- /dev/null +++ b/mysql-test/suite/innodb/t/make_page_dirty_rowcomp.test @@ -0,0 +1,121 @@ +# Reproducer for the make_page_dirty debug command logging a generic +# MLOG_2BYTES redo record on a compressed (ROW_FORMAT=COMPRESSED) index page. +# +# make_page_dirty rewrites FIL_PAGE_TYPE to dirty a page. For a compressed +# index page the header update must be logged with MLOG_ZIP_WRITE_HEADER so +# the compressed page image stays consistent on replay. When it is instead +# logged as MLOG_2BYTES, crash recovery applies that generic byte-write +# record to a page that has both page and page_zip set, which violates the +# invariant +# ut_a(!page || !page_zip || !fil_page_index_page_check(page)); +# in mlog_parse_nbytes() (mtr0log.cc) and crashes recovery. +# +# To make recovery deterministically replay the make_page_dirty record, the +# clean (pre-make_page_dirty) image of the root page is saved and restored on +# disk after the server is killed. Recovery then reads an older copy of the +# page (FIL_PAGE_LSN below the make_page_dirty record) and replays the record +# on top of it. + +--source include/have_debug.inc +--source include/have_innodb_16k.inc +--source include/not_valgrind.inc + +CREATE TABLE t1 (f1 INT, f2 BLOB) ROW_FORMAT=COMPRESSED; +START TRANSACTION; +INSERT INTO t1 VALUES(1, repeat('#',12)); +INSERT INTO t1 VALUES(2, repeat('+',12)); +INSERT INTO t1 VALUES(3, repeat('/',12)); +INSERT INTO t1 VALUES(4, repeat('-',12)); +INSERT INTO t1 VALUES(5, repeat('.',12)); +COMMIT WORK; + +SET SESSION innodb_interpreter = 'open_table test/t1'; + +--echo # Identify the space_id of the given table. +SELECT space FROM INFORMATION_SCHEMA.INNODB_TABLESPACEs +WHERE name = 'test/t1' INTO @space_id; + +--echo # Find the root page number of the given table. +SET SESSION innodb_interpreter = 'find_root_page_no test/t1'; +SELECT @@session.innodb_interpreter_output INTO @page_no; + +SET SESSION innodb_interpreter = 'find_tablespace_file_name test/t1'; +SELECT @@session.innodb_interpreter_output INTO @space_file_name; + +SET SESSION innodb_interpreter = 'find_tablespace_physical_page_size test/t1'; +SELECT @@session.innodb_interpreter_output INTO @space_page_size; + +SET SESSION innodb_interpreter = 'destroy'; + +let MYSQLD_DATADIR=`SELECT @@datadir`; +let PAGE_NO=`select @page_no`; +let FILE_NAME=`select @space_file_name`; +let PAGE_SIZE=`select @space_page_size`; + +--echo # Flush the clean root page to disk and save its image. +FLUSH TABLES t1 FOR EXPORT; +UNLOCK TABLES; + +perl; +use IO::Handle; +my $fname= "$ENV{'MYSQLD_DATADIR'}/$ENV{'FILE_NAME'}"; +my $page_size = $ENV{'PAGE_SIZE'}; +my $offset = $ENV{'PAGE_NO'} * $page_size; +open(SRC, "<", $fname) or die "open $fname: $!"; +binmode SRC; +seek(SRC, $offset, 0); +my $buf; +read(SRC, $buf, $page_size) == $page_size or die "short read"; +close SRC; +open(SAVE, ">", "$ENV{'MYSQLTEST_VARDIR'}/tmp/clean_root.page") or die; +binmode SAVE; +print SAVE $buf; +close SAVE; +EOF + +--echo # Disable checkpointing and background flushing so the make_page_dirty +--echo # redo record is present, but not checkpointed, at recovery time. +SET GLOBAL innodb_master_thread_disabled_debug=1; +SET GLOBAL innodb_checkpoint_disabled = 1; + +BEGIN; +INSERT INTO t1 VALUES (6, repeat('%', 12)); + +SET SESSION innodb_interpreter = 'open_table test/t1'; + +--echo # Dirty the compressed index root page (logs the header rewrite). +SET @cmd = CONCAT('make_page_dirty ', @space_id, ' ', @page_no); +SET SESSION innodb_interpreter = @cmd; + +SET SESSION innodb_interpreter = 'destroy'; + +--source include/kill_mysqld.inc + +--echo # Restore the older, clean image of the root page on disk so recovery +--echo # replays the make_page_dirty redo record on top of it. +perl; +use IO::Handle; +my $fname= "$ENV{'MYSQLD_DATADIR'}/$ENV{'FILE_NAME'}"; +my $page_size = $ENV{'PAGE_SIZE'}; +my $offset = $ENV{'PAGE_NO'} * $page_size; +open(SAVE, "<", "$ENV{'MYSQLTEST_VARDIR'}/tmp/clean_root.page") or die; +binmode SAVE; +my $buf; +read(SAVE, $buf, $page_size) == $page_size or die "short read"; +close SAVE; +open(FILE, "+<", $fname) or die "open $fname: $!"; +FILE->autoflush(1); +binmode FILE; +seek(FILE, $offset, 0); +print FILE $buf; +close FILE; +EOF + +--remove_file $MYSQLTEST_VARDIR/tmp/clean_root.page + +--echo # Restart: crash recovery replays the make_page_dirty redo record. +--source include/start_mysqld.inc + +CHECK TABLE t1; +SELECT f1, f2 FROM t1; +DROP TABLE t1; diff --git a/storage/innobase/ut/ut0test.cc b/storage/innobase/ut/ut0test.cc index ca94f4231c40..edf62577436d 100644 --- a/storage/innobase/ut/ut0test.cc +++ b/storage/innobase/ut/ut0test.cc @@ -34,6 +34,7 @@ this program; if not, write to the Free Software Foundation, Inc., #include "dict0dd.h" #include "dict0dict.h" #include "fil0fil.h" +#include "page0zip.h" #include "scope_guard.h" #define CALL_MEMBER_FN(object, ptrToMember) ((object).*(ptrToMember)) @@ -577,7 +578,13 @@ DISPATCH_FUNCTION_DEF(Tester::make_page_dirty) { << "Dirtying page: " << page_id << ", page_type=" << fil_get_page_type_str(page_type); - mlog_write_ulint(page + FIL_PAGE_TYPE, page_type, MLOG_2BYTES, &mtr); + auto page_zip = buf_block_get_page_zip(block); + if (page_zip != nullptr && fil_page_index_page_check(page)) { + mach_write_to_2(page + FIL_PAGE_TYPE, page_type); + page_zip_write_header(page_zip, page + FIL_PAGE_TYPE, 2, &mtr); + } else { + mlog_write_ulint(page + FIL_PAGE_TYPE, page_type, MLOG_2BYTES, &mtr); + } } }