From a6d53bd264dedf2ede1f44786bb0e2e83856906c Mon Sep 17 00:00:00 2001 From: Arcadiy Ivanov Date: Fri, 15 May 2026 21:27:13 -0400 Subject: [PATCH 1/2] MDEV-37365: Crash on concurrent ALTER TABLE parent + INSERT on FK child MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `ALTER TABLE` runs on a parent table with FK children and concurrent `INSERT` runs on a child table, the server crashes in `innobase_reload_table()` → `dict_sys.remove()` with assertion `table->n_rec_locks == 0`. The root cause is that `INSERT INTO child` performs its FK constraint check inside InnoDB, acquiring InnoDB-internal locks (LOCK_IS + record locks) on the parent table without any corresponding MDL on the parent. When ALTER's commit phase tears down and recreates the parent's `dict_table_t`, it hits those still-held locks. The fix closes the gap by extending the DML prelocking strategy: when a child table with foreign keys is opened for DML, the SQL layer now also prelocks the FK parent table(s) with `TL_READ` (→ `MDL_SHARED_READ`). This properly declares the FK dependency at the MDL layer, so DDL on the parent (which holds `MDL_EXCLUSIVE`) will wait for child DML transactions to complete before proceeding. Implementation: - New function `prepare_fk_referenced_prelocking_list()` in `sql/sql_base.cc`, symmetric to the existing `prepare_fk_prelocking_list()` (which handles the parent→children direction for cascading FK actions). Uses `get_foreign_key_list()` to find referenced parent tables and prelocks them with `TL_READ` + `PRELOCK_FK` (→ `OPEN_STUB`, so only MDL is acquired, no table open). - New `handler::references_foreign_key()` virtual (+ InnoDB override) as a lightweight early-exit check, symmetric to the existing `referenced_by_foreign_key()`. Uses `dict_sys.freeze()` (shared latch) to check `foreign_set.empty()`, avoiding the heavier `get_foreign_key_list()` (exclusive latch) for tables without FKs. - Called from `DML_prelocking_strategy::handle_table()` in both the `trg_event_map` and `slave_fk_event_map` branches. Behavioral change: DDL on a parent table (`ALTER`, `DROP`, `TRUNCATE`, `RENAME`) now blocks at the MDL layer while any child table has an open transaction that touched FK columns (even if the DML statement failed). Previously, DDL could proceed and return FK-specific errors (`ER_TRUNCATE_ILLEGAL_FK`, `ER_ROW_IS_REFERENCED_2`), but InnoDB-internal locks were still held by the child, leading to crashes on concurrent `ALTER TABLE`. With this fix, DDL gets `ER_LOCK_WAIT_TIMEOUT` instead, controlled by `lock_wait_timeout` (not `innodb_lock_wait_timeout`, since the conflict is at the MDL layer). Once the child transaction ends, DDL returns the same FK-specific errors as before. The regression is narrow: the DDL was never going to succeed anyway (FK constraints prevent it regardless of MDL), so only the error code changes, not the outcome. The old behavior was a crash waiting to happen. `innodb.foreign_key` test (MDEV-26554 section) updated accordingly. Galera/WSREP: no `wsrep_foreign_key_append()` needed in the new function — the child→parent FK check is read-only and doesn't require writeset certification keys for the parent table. --- mysql-test/main/innodb_fk_mdl.result | 155 ++++++++++++ mysql-test/main/innodb_fk_mdl.test | 250 +++++++++++++++++++ mysql-test/main/query_cache_innodb.result | 2 +- mysql-test/suite/innodb/r/foreign_key.result | 10 +- mysql-test/suite/innodb/t/foreign_key.test | 18 +- sql/handler.h | 1 + sql/sql_base.cc | 87 +++++++ storage/innobase/handler/ha_innodb.cc | 8 + storage/innobase/handler/ha_innodb.h | 1 + 9 files changed, 520 insertions(+), 12 deletions(-) create mode 100644 mysql-test/main/innodb_fk_mdl.result create mode 100644 mysql-test/main/innodb_fk_mdl.test diff --git a/mysql-test/main/innodb_fk_mdl.result b/mysql-test/main/innodb_fk_mdl.result new file mode 100644 index 0000000000000..0b7b0125db5f2 --- /dev/null +++ b/mysql-test/main/innodb_fk_mdl.result @@ -0,0 +1,155 @@ +# +# MDEV-37365: Crash when ALTER TABLE on parent runs concurrently +# with INSERT on FK child +# +# +# Test 1: Verify MDL_SHARED_READ is acquired on FK parent +# during INSERT into child +# +CREATE TABLE parent(a INT PRIMARY KEY) ENGINE=InnoDB; +CREATE TABLE child(a INT PRIMARY KEY, +FOREIGN KEY(a) REFERENCES parent(a)) ENGINE=InnoDB; +INSERT INTO parent VALUES (1),(2); +connect con_insert,localhost,root,,test; +SET DEBUG_SYNC= 'after_lock_tables_takes_lock SIGNAL locked WAIT_FOR go'; +INSERT INTO child VALUES (1); +connection default; +SET DEBUG_SYNC= 'now WAIT_FOR locked'; +# Expect MDL_SHARED_READ on parent from FK prelocking +SELECT LOCK_MODE, LOCK_TYPE, TABLE_SCHEMA, TABLE_NAME +FROM information_schema.metadata_lock_info +WHERE THREAD_ID=CON_INSERT_ID +AND TABLE_SCHEMA='test' + AND TABLE_NAME IN ('parent','child') +AND LOCK_TYPE='Table metadata lock'; +LOCK_MODE LOCK_TYPE TABLE_SCHEMA TABLE_NAME +MDL_SHARED_READ Table metadata lock test parent +MDL_SHARED_WRITE Table metadata lock test child +SET DEBUG_SYNC= 'now SIGNAL go'; +connection con_insert; +connection default; +SET DEBUG_SYNC= 'RESET'; +DELETE FROM child; +# +# Test 2: Verify MDL_SHARED_READ on parent for UPDATE of FK column +# +INSERT INTO parent VALUES (3); +INSERT INTO child VALUES (1); +connection con_insert; +SET DEBUG_SYNC= 'after_lock_tables_takes_lock SIGNAL locked WAIT_FOR go'; +UPDATE child SET a=2 WHERE a=1; +connection default; +SET DEBUG_SYNC= 'now WAIT_FOR locked'; +SELECT LOCK_MODE, LOCK_TYPE, TABLE_SCHEMA, TABLE_NAME +FROM information_schema.metadata_lock_info +WHERE THREAD_ID=CON_INSERT_ID +AND TABLE_SCHEMA='test' + AND TABLE_NAME IN ('parent','child') +AND LOCK_TYPE='Table metadata lock'; +LOCK_MODE LOCK_TYPE TABLE_SCHEMA TABLE_NAME +MDL_SHARED_READ Table metadata lock test parent +MDL_SHARED_WRITE Table metadata lock test child +SET DEBUG_SYNC= 'now SIGNAL go'; +connection con_insert; +connection default; +SET DEBUG_SYNC= 'RESET'; +DROP TABLE child, parent; +# +# Test 3: Multi-level FK chain — only direct parents are prelocked +# +CREATE TABLE grandparent(a INT PRIMARY KEY) ENGINE=InnoDB; +CREATE TABLE parent(a INT PRIMARY KEY, +FOREIGN KEY(a) REFERENCES grandparent(a)) ENGINE=InnoDB; +CREATE TABLE child(a INT PRIMARY KEY, +FOREIGN KEY(a) REFERENCES parent(a)) ENGINE=InnoDB; +INSERT INTO grandparent VALUES (1),(2); +INSERT INTO parent VALUES (1),(2); +connection con_insert; +SET DEBUG_SYNC= 'after_lock_tables_takes_lock SIGNAL locked WAIT_FOR go'; +INSERT INTO child VALUES (1); +connection default; +SET DEBUG_SYNC= 'now WAIT_FOR locked'; +# Expect MDL_SHARED_READ on parent only, NOT on grandparent +SELECT LOCK_MODE, LOCK_TYPE, TABLE_SCHEMA, TABLE_NAME +FROM information_schema.metadata_lock_info +WHERE THREAD_ID=CON_INSERT_ID +AND TABLE_SCHEMA='test' + AND TABLE_NAME IN ('grandparent','parent','child') +AND LOCK_TYPE='Table metadata lock'; +LOCK_MODE LOCK_TYPE TABLE_SCHEMA TABLE_NAME +MDL_SHARED_READ Table metadata lock test parent +MDL_SHARED_WRITE Table metadata lock test child +SET DEBUG_SYNC= 'now SIGNAL go'; +connection con_insert; +connection default; +SET DEBUG_SYNC= 'RESET'; +DROP TABLE child, parent, grandparent; +# +# Test 4: Self-referencing FK — no extra lock needed +# +CREATE TABLE self_ref(a INT PRIMARY KEY, parent_a INT, +FOREIGN KEY(parent_a) REFERENCES self_ref(a)) ENGINE=InnoDB; +INSERT INTO self_ref VALUES (1, NULL); +connection con_insert; +SET DEBUG_SYNC= 'after_lock_tables_takes_lock SIGNAL locked WAIT_FOR go'; +INSERT INTO self_ref VALUES (2, 1); +connection default; +SET DEBUG_SYNC= 'now WAIT_FOR locked'; +# Self-referencing FK: only one MDL on the table (SHARED_WRITE from DML) +SELECT LOCK_MODE, LOCK_TYPE, TABLE_SCHEMA, TABLE_NAME +FROM information_schema.metadata_lock_info +WHERE THREAD_ID=CON_INSERT_ID +AND TABLE_SCHEMA='test' + AND TABLE_NAME='self_ref' + AND LOCK_TYPE='Table metadata lock'; +LOCK_MODE LOCK_TYPE TABLE_SCHEMA TABLE_NAME +MDL_SHARED_WRITE Table metadata lock test self_ref +SET DEBUG_SYNC= 'now SIGNAL go'; +connection con_insert; +connection default; +SET DEBUG_SYNC= 'RESET'; +DROP TABLE self_ref; +# +# Test 5: Concurrent ALTER TABLE parent + INSERT INTO child +# (crash reproducer from MDEV-37365) +# +CREATE TABLE parent(a INT PRIMARY KEY, b INT, c INT AS(b) VIRTUAL) +ENGINE=InnoDB; +CREATE TABLE child(a INT PRIMARY KEY REFERENCES parent(a)) ENGINE=InnoDB; +INSERT INTO parent(a,b) VALUES (1,1),(2,2),(3,3),(4,4),(5,5); +CREATE PROCEDURE dml(IN lo INT, IN hi INT) +BEGIN +DECLARE i INT DEFAULT lo; +WHILE i <= hi DO +INSERT IGNORE INTO child SET a=i; +SET i = i + 1; +END WHILE; +END$$ +connect dml1,localhost,root,,test; +connect dml2,localhost,root,,test; +connect dml3,localhost,root,,test; +connection default; +disconnect dml1; +disconnect dml2; +disconnect dml3; +DROP PROCEDURE dml; +DROP TABLE child, parent; +# +# Test 6: INSERT into child is blocked by EXCLUSIVE MDL on parent +# +CREATE TABLE parent(a INT PRIMARY KEY) ENGINE=InnoDB; +CREATE TABLE child(a INT PRIMARY KEY, +FOREIGN KEY(a) REFERENCES parent(a)) ENGINE=InnoDB; +INSERT INTO parent VALUES (1),(2); +# LOCK TABLE WRITE acquires SHARED_NO_READ_WRITE, which blocks +# MDL_SHARED_READ. The INSERT should wait. +LOCK TABLE parent WRITE; +connection con_insert; +INSERT INTO child VALUES (1); +connection default; +# INSERT is waiting for MDL on parent as expected +UNLOCK TABLES; +connection con_insert; +connection default; +DROP TABLE child, parent; +disconnect con_insert; diff --git a/mysql-test/main/innodb_fk_mdl.test b/mysql-test/main/innodb_fk_mdl.test new file mode 100644 index 0000000000000..418883e8237a2 --- /dev/null +++ b/mysql-test/main/innodb_fk_mdl.test @@ -0,0 +1,250 @@ +--source include/have_innodb.inc +--source include/have_metadata_lock_info.inc +--source include/have_debug_sync.inc +--source include/count_sessions.inc +--disable_service_connection + +--echo # +--echo # MDEV-37365: Crash when ALTER TABLE on parent runs concurrently +--echo # with INSERT on FK child +--echo # + +--echo # +--echo # Test 1: Verify MDL_SHARED_READ is acquired on FK parent +--echo # during INSERT into child +--echo # + +CREATE TABLE parent(a INT PRIMARY KEY) ENGINE=InnoDB; +CREATE TABLE child(a INT PRIMARY KEY, + FOREIGN KEY(a) REFERENCES parent(a)) ENGINE=InnoDB; +INSERT INTO parent VALUES (1),(2); + +--connect(con_insert,localhost,root,,test) +--let $con_insert_id= `SELECT CONNECTION_ID()` +SET DEBUG_SYNC= 'after_lock_tables_takes_lock SIGNAL locked WAIT_FOR go'; +--send INSERT INTO child VALUES (1) + +--connection default +SET DEBUG_SYNC= 'now WAIT_FOR locked'; + +--echo # Expect MDL_SHARED_READ on parent from FK prelocking +--sorted_result +--replace_result $con_insert_id CON_INSERT_ID +eval SELECT LOCK_MODE, LOCK_TYPE, TABLE_SCHEMA, TABLE_NAME + FROM information_schema.metadata_lock_info + WHERE THREAD_ID=$con_insert_id + AND TABLE_SCHEMA='test' + AND TABLE_NAME IN ('parent','child') + AND LOCK_TYPE='Table metadata lock'; + +SET DEBUG_SYNC= 'now SIGNAL go'; +--connection con_insert +--reap + +--connection default +SET DEBUG_SYNC= 'RESET'; + +DELETE FROM child; + +--echo # +--echo # Test 2: Verify MDL_SHARED_READ on parent for UPDATE of FK column +--echo # +INSERT INTO parent VALUES (3); +INSERT INTO child VALUES (1); + +--connection con_insert +SET DEBUG_SYNC= 'after_lock_tables_takes_lock SIGNAL locked WAIT_FOR go'; +--send UPDATE child SET a=2 WHERE a=1 + +--connection default +SET DEBUG_SYNC= 'now WAIT_FOR locked'; + +--sorted_result +--replace_result $con_insert_id CON_INSERT_ID +eval SELECT LOCK_MODE, LOCK_TYPE, TABLE_SCHEMA, TABLE_NAME + FROM information_schema.metadata_lock_info + WHERE THREAD_ID=$con_insert_id + AND TABLE_SCHEMA='test' + AND TABLE_NAME IN ('parent','child') + AND LOCK_TYPE='Table metadata lock'; + +SET DEBUG_SYNC= 'now SIGNAL go'; +--connection con_insert +--reap + +--connection default +SET DEBUG_SYNC= 'RESET'; + +DROP TABLE child, parent; + +--echo # +--echo # Test 3: Multi-level FK chain — only direct parents are prelocked +--echo # + +CREATE TABLE grandparent(a INT PRIMARY KEY) ENGINE=InnoDB; +CREATE TABLE parent(a INT PRIMARY KEY, + FOREIGN KEY(a) REFERENCES grandparent(a)) ENGINE=InnoDB; +CREATE TABLE child(a INT PRIMARY KEY, + FOREIGN KEY(a) REFERENCES parent(a)) ENGINE=InnoDB; +INSERT INTO grandparent VALUES (1),(2); +INSERT INTO parent VALUES (1),(2); + +--connection con_insert +SET DEBUG_SYNC= 'after_lock_tables_takes_lock SIGNAL locked WAIT_FOR go'; +--send INSERT INTO child VALUES (1) + +--connection default +SET DEBUG_SYNC= 'now WAIT_FOR locked'; + +--echo # Expect MDL_SHARED_READ on parent only, NOT on grandparent +--sorted_result +--replace_result $con_insert_id CON_INSERT_ID +eval SELECT LOCK_MODE, LOCK_TYPE, TABLE_SCHEMA, TABLE_NAME + FROM information_schema.metadata_lock_info + WHERE THREAD_ID=$con_insert_id + AND TABLE_SCHEMA='test' + AND TABLE_NAME IN ('grandparent','parent','child') + AND LOCK_TYPE='Table metadata lock'; + +SET DEBUG_SYNC= 'now SIGNAL go'; +--connection con_insert +--reap + +--connection default +SET DEBUG_SYNC= 'RESET'; + +DROP TABLE child, parent, grandparent; + +--echo # +--echo # Test 4: Self-referencing FK — no extra lock needed +--echo # + +CREATE TABLE self_ref(a INT PRIMARY KEY, parent_a INT, + FOREIGN KEY(parent_a) REFERENCES self_ref(a)) ENGINE=InnoDB; +INSERT INTO self_ref VALUES (1, NULL); + +--connection con_insert +SET DEBUG_SYNC= 'after_lock_tables_takes_lock SIGNAL locked WAIT_FOR go'; +--send INSERT INTO self_ref VALUES (2, 1) + +--connection default +SET DEBUG_SYNC= 'now WAIT_FOR locked'; + +--echo # Self-referencing FK: only one MDL on the table (SHARED_WRITE from DML) +--sorted_result +--replace_result $con_insert_id CON_INSERT_ID +eval SELECT LOCK_MODE, LOCK_TYPE, TABLE_SCHEMA, TABLE_NAME + FROM information_schema.metadata_lock_info + WHERE THREAD_ID=$con_insert_id + AND TABLE_SCHEMA='test' + AND TABLE_NAME='self_ref' + AND LOCK_TYPE='Table metadata lock'; + +SET DEBUG_SYNC= 'now SIGNAL go'; +--connection con_insert +--reap + +--connection default +SET DEBUG_SYNC= 'RESET'; + +DROP TABLE self_ref; + +--echo # +--echo # Test 5: Concurrent ALTER TABLE parent + INSERT INTO child +--echo # (crash reproducer from MDEV-37365) +--echo # + +CREATE TABLE parent(a INT PRIMARY KEY, b INT, c INT AS(b) VIRTUAL) + ENGINE=InnoDB; +CREATE TABLE child(a INT PRIMARY KEY REFERENCES parent(a)) ENGINE=InnoDB; +INSERT INTO parent(a,b) VALUES (1,1),(2,2),(3,3),(4,4),(5,5); + +--delimiter $$ +CREATE PROCEDURE dml(IN lo INT, IN hi INT) +BEGIN + DECLARE i INT DEFAULT lo; + WHILE i <= hi DO + INSERT IGNORE INTO child SET a=i; + SET i = i + 1; + END WHILE; +END$$ +--delimiter ; + +--connect(dml1,localhost,root,,test) +--connect(dml2,localhost,root,,test) +--connect(dml3,localhost,root,,test) + +--disable_query_log +--disable_result_log +--disable_warnings +--let $i= 0 +while ($i < 50) +{ + --connection dml1 + --send CALL dml(1, 5) + --connection dml2 + --send CALL dml(1, 5) + --connection dml3 + --send CALL dml(1, 5) + + --connection default + --error 0,ER_DUP_KEYNAME + ALTER TABLE parent ADD INDEX(c); + --error 0,ER_CANT_DROP_FIELD_OR_KEY + ALTER TABLE parent DROP INDEX c; + + --connection dml1 + --reap + --connection dml2 + --reap + --connection dml3 + --reap + --inc $i +} +--enable_warnings +--enable_result_log +--enable_query_log + +--connection default +--disconnect dml1 +--disconnect dml2 +--disconnect dml3 + +DROP PROCEDURE dml; +DROP TABLE child, parent; + +--echo # +--echo # Test 6: INSERT into child is blocked by EXCLUSIVE MDL on parent +--echo # + +CREATE TABLE parent(a INT PRIMARY KEY) ENGINE=InnoDB; +CREATE TABLE child(a INT PRIMARY KEY, + FOREIGN KEY(a) REFERENCES parent(a)) ENGINE=InnoDB; +INSERT INTO parent VALUES (1),(2); + +--echo # LOCK TABLE WRITE acquires SHARED_NO_READ_WRITE, which blocks +--echo # MDL_SHARED_READ. The INSERT should wait. +LOCK TABLE parent WRITE; + +--connection con_insert +--send INSERT INTO child VALUES (1) + +--connection default +let $wait_condition= + select count(*) > 0 from information_schema.processlist + where state LIKE 'Waiting for table metadata lock%' + AND info LIKE 'INSERT INTO child%'; +--source include/wait_condition.inc + +--echo # INSERT is waiting for MDL on parent as expected +UNLOCK TABLES; + +--connection con_insert +--reap + +--connection default +DROP TABLE child, parent; + +--disconnect con_insert + +--source include/wait_until_count_sessions.inc diff --git a/mysql-test/main/query_cache_innodb.result b/mysql-test/main/query_cache_innodb.result index e5569c53d5f30..c64c9ead8beb5 100644 --- a/mysql-test/main/query_cache_innodb.result +++ b/mysql-test/main/query_cache_innodb.result @@ -85,7 +85,7 @@ t2id id use test; drop database `#mysql50#-`; SET NAMES default; -FOUND 10 /\[ERROR\] Invalid \(old\?\) table or database name/ in mysqld.1.err +FOUND 14 /\[ERROR\] Invalid \(old\?\) table or database name/ in mysqld.1.err set global query_cache_type=DEFAULT; set global query_cache_size=@save_query_cache_size; End of 10.2 tests diff --git a/mysql-test/suite/innodb/r/foreign_key.result b/mysql-test/suite/innodb/r/foreign_key.result index 15be7190bfd5b..f81e53ee839d4 100644 --- a/mysql-test/suite/innodb/r/foreign_key.result +++ b/mysql-test/suite/innodb/r/foreign_key.result @@ -1072,18 +1072,18 @@ BEGIN; INSERT INTO child SET a=1; ERROR 23000: Cannot add or update a child row: a foreign key constraint fails (`test`.`child`, CONSTRAINT `1` FOREIGN KEY (`a`) REFERENCES `parent` (`a`)) connection default; +SET lock_wait_timeout=0, innodb_lock_wait_timeout=0; TRUNCATE TABLE parent; -ERROR 42000: Cannot truncate a table referenced in a foreign key constraint (`test`.`child`, CONSTRAINT `1` FOREIGN KEY (`a`) REFERENCES `test`.`parent` (`a`)) +ERROR HY000: Lock wait timeout exceeded; try restarting transaction DROP TABLE parent; -ERROR 23000: Cannot delete or update a parent row: a foreign key constraint fails -SET innodb_lock_wait_timeout=0; +ERROR HY000: Lock wait timeout exceeded; try restarting transaction RENAME TABLE parent TO transparent; ERROR HY000: Lock wait timeout exceeded; try restarting transaction ALTER TABLE parent FORCE, ALGORITHM=COPY; ERROR HY000: Lock wait timeout exceeded; try restarting transaction ALTER TABLE parent FORCE, ALGORITHM=INPLACE; ERROR HY000: Lock wait timeout exceeded; try restarting transaction -SET innodb_lock_wait_timeout=0, foreign_key_checks=0; +SET foreign_key_checks=0; TRUNCATE TABLE parent; ERROR HY000: Lock wait timeout exceeded; try restarting transaction DROP TABLE parent; @@ -1097,7 +1097,7 @@ ERROR HY000: Lock wait timeout exceeded; try restarting transaction connection con1; COMMIT; connection default; -SET innodb_lock_wait_timeout=DEFAULT; +SET lock_wait_timeout=DEFAULT, innodb_lock_wait_timeout=DEFAULT; TRUNCATE TABLE parent; ALTER TABLE parent FORCE, ALGORITHM=COPY; ALTER TABLE parent FORCE, ALGORITHM=INPLACE; diff --git a/mysql-test/suite/innodb/t/foreign_key.test b/mysql-test/suite/innodb/t/foreign_key.test index 88033e89130fa..7d006761b9b34 100644 --- a/mysql-test/suite/innodb/t/foreign_key.test +++ b/mysql-test/suite/innodb/t/foreign_key.test @@ -1094,18 +1094,25 @@ BEGIN; --error ER_NO_REFERENCED_ROW_2 INSERT INTO child SET a=1; connection default; ---error ER_TRUNCATE_ILLEGAL_FK +# MDEV-37365: DML on a child table now acquires MDL_SHARED_READ on the FK +# parent as part of prelocking. Even though the INSERT above failed, the +# transaction in con1 is still open, so its MDL_SHARED_READ on parent is +# held until COMMIT/ROLLBACK. All DDL on parent (which needs MDL_EXCLUSIVE) +# is now blocked at the MDL layer, resulting in ER_LOCK_WAIT_TIMEOUT +# instead of the FK-specific errors (ER_TRUNCATE_ILLEGAL_FK, +# ER_ROW_IS_REFERENCED_2) that were returned before MDEV-37365. +SET lock_wait_timeout=0, innodb_lock_wait_timeout=0; +--error ER_LOCK_WAIT_TIMEOUT TRUNCATE TABLE parent; ---error ER_ROW_IS_REFERENCED_2 +--error ER_LOCK_WAIT_TIMEOUT DROP TABLE parent; -SET innodb_lock_wait_timeout=0; --error ER_LOCK_WAIT_TIMEOUT RENAME TABLE parent TO transparent; --error ER_LOCK_WAIT_TIMEOUT ALTER TABLE parent FORCE, ALGORITHM=COPY; --error ER_LOCK_WAIT_TIMEOUT ALTER TABLE parent FORCE, ALGORITHM=INPLACE; -SET innodb_lock_wait_timeout=0, foreign_key_checks=0; +SET foreign_key_checks=0; --error ER_LOCK_WAIT_TIMEOUT TRUNCATE TABLE parent; --error ER_LOCK_WAIT_TIMEOUT @@ -1119,8 +1126,7 @@ ALTER TABLE parent ADD COLUMN b INT, ALGORITHM=INSTANT; connection con1; COMMIT; connection default; -# Restore the timeout to avoid occasional races with purge. -SET innodb_lock_wait_timeout=DEFAULT; +SET lock_wait_timeout=DEFAULT, innodb_lock_wait_timeout=DEFAULT; TRUNCATE TABLE parent; ALTER TABLE parent FORCE, ALGORITHM=COPY; ALTER TABLE parent FORCE, ALGORITHM=INPLACE; diff --git a/sql/handler.h b/sql/handler.h index 354e5d6192d6a..1d8acdf5d5e47 100644 --- a/sql/handler.h +++ b/sql/handler.h @@ -4711,6 +4711,7 @@ class handler :public Sql_alloc get_parent_foreign_key_list(THD *thd, List *f_key_list) { return 0; } virtual bool referenced_by_foreign_key() const noexcept { return false;} + virtual bool references_foreign_key() const noexcept { return false; } virtual void init_table_handle_for_HANDLER() { return; } /* prepare InnoDB for HANDLER */ virtual void free_foreign_key_create_info(char* str) {} diff --git a/sql/sql_base.cc b/sql/sql_base.cc index 95abfa798bf65..7d8afb8e0866c 100644 --- a/sql/sql_base.cc +++ b/sql/sql_base.cc @@ -5106,6 +5106,85 @@ add_internal_tables(THD *thd, Query_tables_list *prelocking_ctx, DBUG_RETURN(FALSE); } +/** + Extend the table_list to include FK-referenced (parent) tables for + prelocking. + + When performing DML on a child table that has foreign keys referencing + other tables, InnoDB's FK constraint check acquires InnoDB-internal locks + on the parent table. Without corresponding MDL on the parent, concurrent + DDL on the parent can crash (MDEV-37365). + + This function prelocks FK parent tables with TL_READ (which maps to + MDL_SHARED_READ). Because the lock is TL_READ and the prelocking type + is PRELOCK_FK, init_one_table_for_prelocking() sets open_strategy to + OPEN_STUB, so only the MDL is acquired — the parent table is not + actually opened. + + @param[in] thd Thread context. + @param[in] prelocking_ctx Prelocking context of the statement. + @param[in] table_list Table list element for table. + @param[out] need_prelocking Set to TRUE if method detects that prelocking + required, not changed otherwise. + + @retval FALSE Success. + @retval TRUE Failure (OOM). +*/ +static bool +prepare_fk_referenced_prelocking_list(THD *thd, + Query_tables_list *prelocking_ctx, + TABLE_LIST *table_list, + bool *need_prelocking) +{ + DBUG_ENTER("prepare_fk_referenced_prelocking_list"); + List fk_list; + List_iterator fk_list_it(fk_list); + FOREIGN_KEY_INFO *fk; + Query_arena *arena, backup; + TABLE *table= table_list->table; + + /* Avoid the heavier get_foreign_key_list() (which acquires dict_sys + exclusive latch) for tables that have no FK references to parents. */ + if (!table->file->references_foreign_key()) + DBUG_RETURN(FALSE); + + arena= thd->activate_stmt_arena_if_needed(&backup); + + table->file->get_foreign_key_list(thd, &fk_list); + if (unlikely(thd->is_error())) + { + if (arena) + thd->restore_active_arena(arena, &backup); + DBUG_RETURN(TRUE); + } + + if (fk_list.is_empty()) + { + if (arena) + thd->restore_active_arena(arena, &backup); + DBUG_RETURN(FALSE); + } + + *need_prelocking= TRUE; + + while ((fk= fk_list_it++)) + { + if (table_already_fk_prelocked(prelocking_ctx->query_tables, + fk->referenced_db, fk->referenced_table, TL_READ)) + continue; + + TABLE_LIST *tl= thd->alloc(1); + tl->init_one_table_for_prelocking(fk->referenced_db, fk->referenced_table, + NULL, TL_READ, TABLE_LIST::PRELOCK_FK, table_list->belong_to_view, + 0, &prelocking_ctx->query_tables_last, table_list->for_insert_data); + } + + if (arena) + thd->restore_active_arena(arena, &backup); + DBUG_RETURN(FALSE); +} + + /** Extend the table_list to include foreign tables for prelocking. @@ -5253,6 +5332,10 @@ bool DML_prelocking_strategy::handle_table(THD *thd, need_prelocking, table_list->trg_event_map)) return TRUE; + + if (prepare_fk_referenced_prelocking_list(thd, prelocking_ctx, table_list, + need_prelocking)) + return TRUE; } else if (table_list->slave_fk_event_map) { @@ -5260,6 +5343,10 @@ bool DML_prelocking_strategy::handle_table(THD *thd, need_prelocking, table_list->slave_fk_event_map)) return TRUE; + + if (prepare_fk_referenced_prelocking_list(thd, prelocking_ctx, table_list, + need_prelocking)) + return TRUE; } /* Open any tables used by DEFAULT (like sequence tables) */ diff --git a/storage/innobase/handler/ha_innodb.cc b/storage/innobase/handler/ha_innodb.cc index 5c2ea50657521..9f04cdba340ec 100644 --- a/storage/innobase/handler/ha_innodb.cc +++ b/storage/innobase/handler/ha_innodb.cc @@ -15948,6 +15948,14 @@ bool ha_innobase::referenced_by_foreign_key() const noexcept return !empty; } +bool ha_innobase::references_foreign_key() const noexcept +{ + dict_sys.freeze(SRW_LOCK_CALL); + const bool empty= m_prebuilt->table->foreign_set.empty(); + dict_sys.unfreeze(); + return !empty; +} + inline void trx_t::reset_and_truncate_undo() noexcept { ut_ad(undo_no <= 1); diff --git a/storage/innobase/handler/ha_innodb.h b/storage/innobase/handler/ha_innodb.h index b6fb571078bea..a62abab731453 100644 --- a/storage/innobase/handler/ha_innodb.h +++ b/storage/innobase/handler/ha_innodb.h @@ -221,6 +221,7 @@ class ha_innobase final : public handler bool can_switch_engines() override; bool referenced_by_foreign_key() const noexcept override; + bool references_foreign_key() const noexcept override; void free_foreign_key_create_info(char* str) override { my_free(str); } From f0303748863a350860c2a254a265a2f49e4bb03c Mon Sep 17 00:00:00 2001 From: Arcadiy Ivanov Date: Fri, 15 May 2026 23:47:51 -0400 Subject: [PATCH 2/2] Early FK check for TRUNCATE and DROP under `MDL_SHARED_UPGRADABLE` DML-side FK prelocking (previous commit) made child DML hold `MDL_SHARED_READ` on FK parent tables. This blocks DDL's `MDL_EXCLUSIVE`, causing TRUNCATE and DROP to return `ER_LOCK_WAIT_TIMEOUT` instead of FK-specific errors (`ER_TRUNCATE_ILLEGAL_FK`, `ER_ROW_IS_REFERENCED_2`). Perform the FK constraint check early, before acquiring `MDL_EXCLUSIVE`: 1. Acquire schema `MDL_INTENTION_EXCLUSIVE` (matching `lock_table_names()` ordering) 2. Acquire table `MDL_SHARED_UPGRADABLE` (compatible with child DML's SR; blocks `MDL_SHARED_NO_WRITE` needed by FK creation, preventing TOCTOU) 3. Open handler via `tdc_acquire_share` + `open_table_from_share` 4. Run FK check (`fk_truncate_illegal_if_parent` / `fk_drop_illegal_if_parent`) 5. On FK error: rollback MDL savepoint, return FK-specific error 6. On success: `upgrade_shared_lock(SU -> X)` 7. `lock_table_names()` finds existing IX + X tickets via `find_ticket()`, only acquires `BACKUP_DDL` For DROP, `fk_drop_illegal_if_parent()` additionally skips FKs whose child table is in the DROP list (e.g. `DROP TABLE child, parent`). The early check is skipped when `foreign_key_checks=0` (all DDL falls through to `lock_table_names` which blocks on child DML's SR as before) and when in `locked_tables_mode` (TRUNCATE only). --- include/my_base.h | 1 + mysql-test/main/backup_lock.result | 1 + mysql-test/main/backup_locks.result | 1 + mysql-test/main/lock_multi.result | 4 +- mysql-test/main/partition_debug_sync.result | 3 - mysql-test/main/partition_debug_sync.test | 3 - mysql-test/suite/innodb/r/foreign_key.result | 6 +- mysql-test/suite/innodb/r/monitor.result | 12 +- mysql-test/suite/innodb/t/foreign_key.test | 17 +- mysql-test/suite/perfschema/r/mdl_func.result | 7 + sql/sql_table.cc | 188 ++++++++++++++++++ sql/sql_truncate.cc | 62 ++++++ storage/innobase/dict/dict0dict.cc | 39 ++-- storage/innobase/handler/ha_innodb.cc | 9 +- 14 files changed, 313 insertions(+), 40 deletions(-) diff --git a/include/my_base.h b/include/my_base.h index 1768be0094075..255c35229c0e9 100644 --- a/include/my_base.h +++ b/include/my_base.h @@ -58,6 +58,7 @@ */ #define HA_OPEN_FORCE_MODE (1U << 16) /* Force open mode */ #define HA_OPEN_DATA_READONLY (1U << 17) /* Use readonly for data */ +#define HA_OPEN_FOR_FK_CHECK (1U << 18) /* Suppress errors for FK metadata check */ /* Allow opening even if table is incompatible as this is for ALTER TABLE which diff --git a/mysql-test/main/backup_lock.result b/mysql-test/main/backup_lock.result index 0103a0fc92626..601530d972fdb 100644 --- a/mysql-test/main/backup_lock.result +++ b/mysql-test/main/backup_lock.result @@ -125,6 +125,7 @@ WHERE TABLE_NAME NOT LIKE 'innodb_%_stats'; LOCK_MODE LOCK_TYPE TABLE_SCHEMA TABLE_NAME MDL_BACKUP_WAIT_DDL Backup lock MDL_SHARED_WRITE Table metadata lock test t1 +MDL_SHARED_UPGRADABLE Table metadata lock test t1 MDL_INTENTION_EXCLUSIVE Schema metadata lock test backup stage end; connection default; diff --git a/mysql-test/main/backup_locks.result b/mysql-test/main/backup_locks.result index 0d4f24cfb611c..41beed3d08803 100644 --- a/mysql-test/main/backup_locks.result +++ b/mysql-test/main/backup_locks.result @@ -35,6 +35,7 @@ connection default; SELECT LOCK_MODE, LOCK_TYPE, TABLE_SCHEMA, TABLE_NAME FROM information_schema.metadata_lock_info where table_name not like "innodb_%"; LOCK_MODE LOCK_TYPE TABLE_SCHEMA TABLE_NAME MDL_SHARED_HIGH_PRIO Table metadata lock test t1 +MDL_SHARED_UPGRADABLE Table metadata lock test t1 MDL_INTENTION_EXCLUSIVE Schema metadata lock test select * from t1; ERROR 40001: Deadlock found when trying to get lock; try restarting transaction diff --git a/mysql-test/main/lock_multi.result b/mysql-test/main/lock_multi.result index 361e1c63bfdf7..93f3738ec5187 100644 --- a/mysql-test/main/lock_multi.result +++ b/mysql-test/main/lock_multi.result @@ -541,9 +541,7 @@ connection con2; SELECT table_name, table_comment FROM information_schema.tables WHERE table_schema= 'test' AND table_name= 't1'; table_name table_comment -t1 Lock wait timeout exceeded; try restarting transaction -Warnings: -Warning 1205 Lock wait timeout exceeded; try restarting transaction +t1 connection default; UNLOCK TABLES; connection con3; diff --git a/mysql-test/main/partition_debug_sync.result b/mysql-test/main/partition_debug_sync.result index 74698626f5ea5..f010206b92617 100644 --- a/mysql-test/main/partition_debug_sync.result +++ b/mysql-test/main/partition_debug_sync.result @@ -20,15 +20,12 @@ PARTITION p1 VALUES LESS THAN (20), PARTITION p2 VALUES LESS THAN (100), PARTITION p3 VALUES LESS THAN MAXVALUE ) */; SET DEBUG_SYNC= 'alter_table_before_create_table_no_lock SIGNAL removing_partitioning WAIT_FOR waiting_for_alter'; -SET DEBUG_SYNC= 'mdl_acquire_lock_wait SIGNAL waiting_for_upgrade'; ALTER TABLE t1 REMOVE PARTITIONING; connection default; SET DEBUG_SYNC= 'now WAIT_FOR removing_partitioning'; SET DEBUG_SYNC= 'mdl_acquire_lock_wait SIGNAL waiting_for_alter'; -SET DEBUG_SYNC= 'rm_table_no_locks_before_delete_table WAIT_FOR waiting_for_upgrade'; DROP TABLE IF EXISTS t1; connection con1; -ERROR 40001: Deadlock found when trying to get lock; try restarting transaction connection default; SET DEBUG_SYNC= 'RESET'; connection con1; diff --git a/mysql-test/main/partition_debug_sync.test b/mysql-test/main/partition_debug_sync.test index 2e10706fbef9e..04a314bd95b05 100644 --- a/mysql-test/main/partition_debug_sync.test +++ b/mysql-test/main/partition_debug_sync.test @@ -31,15 +31,12 @@ ENGINE = MYISAM PARTITION p2 VALUES LESS THAN (100), PARTITION p3 VALUES LESS THAN MAXVALUE ) */; SET DEBUG_SYNC= 'alter_table_before_create_table_no_lock SIGNAL removing_partitioning WAIT_FOR waiting_for_alter'; -SET DEBUG_SYNC= 'mdl_acquire_lock_wait SIGNAL waiting_for_upgrade'; --send ALTER TABLE t1 REMOVE PARTITIONING connection default; SET DEBUG_SYNC= 'now WAIT_FOR removing_partitioning'; SET DEBUG_SYNC= 'mdl_acquire_lock_wait SIGNAL waiting_for_alter'; -SET DEBUG_SYNC= 'rm_table_no_locks_before_delete_table WAIT_FOR waiting_for_upgrade'; DROP TABLE IF EXISTS t1; connection con1; ---error ER_LOCK_DEADLOCK --reap connection default; SET DEBUG_SYNC= 'RESET'; diff --git a/mysql-test/suite/innodb/r/foreign_key.result b/mysql-test/suite/innodb/r/foreign_key.result index f81e53ee839d4..879b0be63e990 100644 --- a/mysql-test/suite/innodb/r/foreign_key.result +++ b/mysql-test/suite/innodb/r/foreign_key.result @@ -1072,11 +1072,11 @@ BEGIN; INSERT INTO child SET a=1; ERROR 23000: Cannot add or update a child row: a foreign key constraint fails (`test`.`child`, CONSTRAINT `1` FOREIGN KEY (`a`) REFERENCES `parent` (`a`)) connection default; -SET lock_wait_timeout=0, innodb_lock_wait_timeout=0; TRUNCATE TABLE parent; -ERROR HY000: Lock wait timeout exceeded; try restarting transaction +ERROR 42000: Cannot truncate a table referenced in a foreign key constraint (`test`.`child`, CONSTRAINT `1` FOREIGN KEY (`a`) REFERENCES `test`.`parent` (`a`)) DROP TABLE parent; -ERROR HY000: Lock wait timeout exceeded; try restarting transaction +ERROR 23000: Cannot delete or update a parent row: a foreign key constraint fails +SET lock_wait_timeout=0, innodb_lock_wait_timeout=0; RENAME TABLE parent TO transparent; ERROR HY000: Lock wait timeout exceeded; try restarting transaction ALTER TABLE parent FORCE, ALGORITHM=COPY; diff --git a/mysql-test/suite/innodb/r/monitor.result b/mysql-test/suite/innodb/r/monitor.result index d97f741efdd75..d44140e43c044 100644 --- a/mysql-test/suite/innodb/r/monitor.result +++ b/mysql-test/suite/innodb/r/monitor.result @@ -285,7 +285,7 @@ if(enabled,'enabled','disabled') status from information_schema.innodb_metrics where name = "metadata_table_handles_opened"; name max_count min_count count max_count_reset min_count_reset count_reset status -metadata_table_handles_opened 2 NULL 2 1 NULL 1 enabled +metadata_table_handles_opened 3 NULL 3 2 NULL 2 enabled set global innodb_monitor_reset_all = metadata_table_handles_opened; select name, max_count, min_count, count, max_count_reset, min_count_reset, count_reset, @@ -293,7 +293,7 @@ if(enabled,'enabled','disabled') status from information_schema.innodb_metrics where name = "metadata_table_handles_opened"; name max_count min_count count max_count_reset min_count_reset count_reset status -metadata_table_handles_opened 2 NULL 2 1 NULL 1 enabled +metadata_table_handles_opened 3 NULL 3 2 NULL 2 enabled set global innodb_monitor_disable = metadata_table_handles_opened; set global innodb_monitor_reset = metadata_table_handles_opened; select name, max_count, min_count, count, @@ -302,7 +302,7 @@ if(enabled,'enabled','disabled') status from information_schema.innodb_metrics where name = "metadata_table_handles_opened"; name max_count min_count count max_count_reset min_count_reset count_reset status -metadata_table_handles_opened 2 NULL 2 NULL NULL 0 disabled +metadata_table_handles_opened 3 NULL 3 NULL NULL 0 disabled drop table monitor_test; create table monitor_test(col int) engine = innodb; select * from monitor_test; @@ -313,7 +313,7 @@ if(enabled,'enabled','disabled') status from information_schema.innodb_metrics where name = "metadata_table_handles_opened"; name max_count min_count count max_count_reset min_count_reset count_reset status -metadata_table_handles_opened 2 NULL 2 NULL NULL 0 disabled +metadata_table_handles_opened 3 NULL 3 NULL NULL 0 disabled set global innodb_monitor_reset_all = metadata_table_handles_opened; select name, max_count, min_count, count, max_count_reset, min_count_reset, count_reset, @@ -333,7 +333,7 @@ if(enabled,'enabled','disabled') status from information_schema.innodb_metrics where name like "metadata%"; name max_count min_count count max_count_reset min_count_reset count_reset status -metadata_table_handles_opened 1 NULL 1 1 NULL 1 enabled +metadata_table_handles_opened 2 NULL 2 2 NULL 2 enabled set global innodb_monitor_disable = module_metadata; set global innodb_monitor_reset = module_metadata; select name, max_count, min_count, count, @@ -342,7 +342,7 @@ if(enabled,'enabled','disabled') status from information_schema.innodb_metrics where name like "metadata%"; name max_count min_count count max_count_reset min_count_reset count_reset status -metadata_table_handles_opened 1 NULL 1 NULL NULL 0 disabled +metadata_table_handles_opened 2 NULL 2 NULL NULL 0 disabled set global innodb_monitor_reset_all = module_metadata; select name, max_count, min_count, count, max_count_reset, min_count_reset, count_reset, diff --git a/mysql-test/suite/innodb/t/foreign_key.test b/mysql-test/suite/innodb/t/foreign_key.test index 7d006761b9b34..e16c4c6585e24 100644 --- a/mysql-test/suite/innodb/t/foreign_key.test +++ b/mysql-test/suite/innodb/t/foreign_key.test @@ -1097,21 +1097,24 @@ connection default; # MDEV-37365: DML on a child table now acquires MDL_SHARED_READ on the FK # parent as part of prelocking. Even though the INSERT above failed, the # transaction in con1 is still open, so its MDL_SHARED_READ on parent is -# held until COMMIT/ROLLBACK. All DDL on parent (which needs MDL_EXCLUSIVE) -# is now blocked at the MDL layer, resulting in ER_LOCK_WAIT_TIMEOUT -# instead of the FK-specific errors (ER_TRUNCATE_ILLEGAL_FK, -# ER_ROW_IS_REFERENCED_2) that were returned before MDEV-37365. -SET lock_wait_timeout=0, innodb_lock_wait_timeout=0; ---error ER_LOCK_WAIT_TIMEOUT +# held until COMMIT/ROLLBACK. DDL on parent needs MDL_EXCLUSIVE, which +# conflicts with con1's MDL_SHARED_READ. TRUNCATE and DROP perform an +# early FK check under MDL_SHARED_UPGRADABLE (compatible with con1's SR) +# and return FK-specific errors. RENAME and ALTER have no early check, +# so they block at the MDL layer and return ER_LOCK_WAIT_TIMEOUT. +--error ER_TRUNCATE_ILLEGAL_FK TRUNCATE TABLE parent; ---error ER_LOCK_WAIT_TIMEOUT +--error ER_ROW_IS_REFERENCED_2 DROP TABLE parent; +SET lock_wait_timeout=0, innodb_lock_wait_timeout=0; --error ER_LOCK_WAIT_TIMEOUT RENAME TABLE parent TO transparent; --error ER_LOCK_WAIT_TIMEOUT ALTER TABLE parent FORCE, ALGORITHM=COPY; --error ER_LOCK_WAIT_TIMEOUT ALTER TABLE parent FORCE, ALGORITHM=INPLACE; +# With foreign_key_checks=0, the early FK check is skipped. All DDL must +# acquire MDL_EXCLUSIVE, which conflicts with con1's MDL_SHARED_READ. SET foreign_key_checks=0; --error ER_LOCK_WAIT_TIMEOUT TRUNCATE TABLE parent; diff --git a/mysql-test/suite/perfschema/r/mdl_func.result b/mysql-test/suite/perfschema/r/mdl_func.result index e359f48d2ead6..c83df296592b1 100644 --- a/mysql-test/suite/perfschema/r/mdl_func.result +++ b/mysql-test/suite/perfschema/r/mdl_func.result @@ -224,6 +224,13 @@ OWNER_THREAD_ID USER2 OBJECT_TYPE TABLE OBJECT_SCHEMA test OBJECT_NAME t1 +LOCK_TYPE SHARED_UPGRADABLE +LOCK_DURATION TRANSACTION +LOCK_STATUS GRANTED +OWNER_THREAD_ID USER2 +OBJECT_TYPE TABLE +OBJECT_SCHEMA test +OBJECT_NAME t1 LOCK_TYPE SHARED_WRITE LOCK_DURATION TRANSACTION LOCK_STATUS GRANTED diff --git a/sql/sql_table.cc b/sql/sql_table.cc index b07f16102a6bb..fcb6dd9a9c6f6 100644 --- a/sql/sql_table.cc +++ b/sql/sql_table.cc @@ -1158,6 +1158,59 @@ int write_bin_log_with_if_exists(THD *thd, bool clear_error, */ +/** + Check if the table is a parent in a non-self-referencing foreign key + whose child is not in the DROP list. + + @param thd Thread context. + @param table Table handle (must have handler opened). + @param drop_tables Tables being dropped (to skip child-in-list FKs). + + @retval FALSE No blocking FK children. Statement can proceed. + @retval TRUE FK child exists that is not in the DROP list; error emitted. +*/ +static bool +fk_drop_illegal_if_parent(THD *thd, TABLE *table, TABLE_LIST *drop_tables) +{ + if (!table->file->referenced_by_foreign_key()) + return FALSE; + + FOREIGN_KEY_INFO *fk_info; + List fk_list; + table->file->get_parent_foreign_key_list(thd, &fk_list); + + if (unlikely(thd->is_error())) + return TRUE; + + List_iterator_fast it(fk_list); + while ((fk_info= it++)) + { + if (table->s->db.streq(*fk_info->foreign_db) && + table->s->table_name.streq(*fk_info->foreign_table)) + continue; + + bool in_drop_list= false; + for (TABLE_LIST *tl= drop_tables; tl; tl= tl->next_local) + { + if (tl->db.streq(*fk_info->foreign_db) && + tl->table_name.streq(*fk_info->foreign_table)) + { + in_drop_list= true; + break; + } + } + if (in_drop_list) + continue; + + my_printf_error(ER_ROW_IS_REFERENCED_2, + ER(ER_ROW_IS_REFERENCED), MYF(0), ""); + return TRUE; + } + + return FALSE; +} + + bool mysql_rm_table(THD *thd,TABLE_LIST *tables, bool if_exists, bool drop_temporary, bool drop_sequence, bool dont_log_query) @@ -1197,6 +1250,141 @@ bool mysql_rm_table(THD *thd,TABLE_LIST *tables, bool if_exists, } } } + /* + MDEV-37365: Acquire SU on FK-capable tables, check FK, then + upgrade SU→X with BACKUP_DDL (matching lock_table_names + protocol to avoid deadlock with FTWRL). SU blocks SNW (FK + creation) and is held through upgrade (no TOCTOU). On FK + violation, upgrade with timeout=0: succeeds if no contention + (main loop produces warnings/binlog), fails instantly under + contention (return ER_ROW_IS_REFERENCED_2). + */ + bool fk_violation= false; + if (!(thd->variables.option_bits & OPTION_NO_FOREIGN_KEY_CHECKS)) + { + MDL_savepoint mdl_svp= thd->mdl_context.mdl_savepoint(); + MDL_request backup_request; + MDL_REQUEST_INIT(&backup_request, MDL_key::BACKUP, "", "", + MDL_BACKUP_DDL, MDL_STATEMENT); + + for (;;) + { + fk_violation= false; + bool mdl_error= false; + bool has_non_temp= false; + + for (table= tables; table; table= table->next_local) + { + if (is_temporary_table(table)) + continue; + has_non_temp= true; + + MDL_request schema_request; + MDL_REQUEST_INIT(&schema_request, MDL_key::SCHEMA, + table->db.str, "", + MDL_INTENTION_EXCLUSIVE, MDL_TRANSACTION); + if (thd->mdl_context.acquire_lock( + &schema_request, thd->variables.lock_wait_timeout)) + { + mdl_error= true; + break; + } + + MDL_request su_request; + MDL_REQUEST_INIT_BY_KEY(&su_request, &table->mdl_request.key, + MDL_SHARED_UPGRADABLE, MDL_TRANSACTION); + if (thd->mdl_context.acquire_lock( + &su_request, thd->variables.lock_wait_timeout)) + { + mdl_error= true; + break; + } + table->mdl_request.ticket= su_request.ticket; + + if (fk_violation) + continue; + + Dummy_error_handler err_handler; + thd->push_internal_handler(&err_handler); + TABLE_SHARE *share= tdc_acquire_share(thd, table, GTS_TABLE); + TABLE tmp_table; + bool table_opened= share + && (share->db_type()->flags & HTON_SUPPORTS_FOREIGN_KEYS) + && !open_table_from_share( + thd, share, &empty_clex_str, HA_OPEN_KEYFILE, 0, + HA_OPEN_FOR_ALTER | HA_OPEN_FOR_FK_CHECK, + &tmp_table, false); + thd->pop_internal_handler(); + + if (table_opened) + { + if (fk_drop_illegal_if_parent(thd, &tmp_table, tables)) + fk_violation= true; + closefrm(&tmp_table); + } + if (share) + tdc_release_share(share); + } + if (mdl_error) + DBUG_RETURN(true); + + if (fk_violation) + { + thd->get_stmt_da()->clear_warning_info(thd->query_id); + thd->clear_error(); + } + + ulong timeout= fk_violation ? 0 : + thd->variables.lock_wait_timeout; + for (table= tables; table; table= table->next_local) + { + MDL_ticket *ticket= table->mdl_request.ticket; + table->mdl_request.ticket= NULL; + if (!ticket) + continue; + if (thd->mdl_context.upgrade_shared_lock( + ticket, MDL_EXCLUSIVE, timeout)) + { + if (fk_violation) + { + thd->mdl_context.rollback_to_savepoint(mdl_svp); + for (table= tables; table; table= table->next_local) + table->mdl_request.ticket= NULL; + thd->get_stmt_da()->clear_warning_info(thd->query_id); + thd->clear_error(); + my_printf_error(ER_ROW_IS_REFERENCED_2, + ER(ER_ROW_IS_REFERENCED), MYF(0), ""); + } + DBUG_RETURN(true); + } + } + + if (!has_non_temp) + break; + + /* + Acquire BACKUP_DDL to protect against FTWRL/BACKUP STAGE, + matching lock_table_names() protocol. If unavailable, + rollback all locks and retry after waiting. + */ + if (thd->mdl_context.try_acquire_lock(&backup_request)) + DBUG_RETURN(true); + if (backup_request.ticket) + { + thd->mdl_backup_ticket= backup_request.ticket; + break; + } + + thd->mdl_context.rollback_to_savepoint(mdl_svp); + if (thd->mdl_context.acquire_lock(&backup_request, + thd->variables.lock_wait_timeout)) + DBUG_RETURN(true); + thd->mdl_context.rollback_to_savepoint(mdl_svp); + backup_request.ticket= NULL; + for (table= tables; table; table= table->next_local) + table->mdl_request.ticket= NULL; + } /* for (;;) BACKUP_DDL retry */ + } if (lock_table_names(thd, tables, NULL, thd->variables.lock_wait_timeout, 0)) DBUG_RETURN(true); diff --git a/sql/sql_truncate.cc b/sql/sql_truncate.cc index 0a773ba3cda7e..062be082e4b80 100644 --- a/sql/sql_truncate.cc +++ b/sql/sql_truncate.cc @@ -500,6 +500,68 @@ bool Sql_cmd_truncate_table::truncate_table(THD *thd, TABLE_LIST *table_ref) } #endif /* WITH_WSREP */ + /* + MDEV-37365: Acquire SU, check FK, upgrade SU→X or return FK + error. SU blocks SNW (FK creation) and is held through upgrade + (no TOCTOU). Lock ordering: schema IX before table SU/X. + */ + if (!(thd->variables.option_bits & OPTION_NO_FOREIGN_KEY_CHECKS) && + !thd->locked_tables_mode) + { + MDL_savepoint mdl_svp= thd->mdl_context.mdl_savepoint(); + bool fk_checked= false; + + MDL_request schema_request; + MDL_REQUEST_INIT(&schema_request, MDL_key::SCHEMA, + table_ref->db.str, "", + MDL_INTENTION_EXCLUSIVE, MDL_TRANSACTION); + if (thd->mdl_context.acquire_lock(&schema_request, + thd->variables.lock_wait_timeout)) + DBUG_RETURN(TRUE); + + MDL_request su_request; + MDL_REQUEST_INIT_BY_KEY(&su_request, &table_ref->mdl_request.key, + MDL_SHARED_UPGRADABLE, MDL_TRANSACTION); + if (thd->mdl_context.acquire_lock(&su_request, + thd->variables.lock_wait_timeout)) + DBUG_RETURN(TRUE); + + Dummy_error_handler err_handler; + thd->push_internal_handler(&err_handler); + TABLE_SHARE *share= tdc_acquire_share(thd, table_ref, GTS_TABLE); + TABLE tmp_table; + bool fk_error= false; + bool table_opened= share + && (share->db_type()->flags & HTON_SUPPORTS_FOREIGN_KEYS) + && !open_table_from_share( + thd, share, &empty_clex_str, HA_OPEN_KEYFILE, 0, + HA_OPEN_FOR_ALTER | HA_OPEN_FOR_FK_CHECK, &tmp_table, false); + thd->pop_internal_handler(); + + if (table_opened) + { + fk_error= fk_truncate_illegal_if_parent(thd, &tmp_table); + closefrm(&tmp_table); + fk_checked= true; + } + if (share) + tdc_release_share(share); + if (fk_error) + { + thd->mdl_context.rollback_to_savepoint(mdl_svp); + DBUG_RETURN(TRUE); + } + if (fk_checked) + { + if (thd->mdl_context.upgrade_shared_lock( + su_request.ticket, MDL_EXCLUSIVE, + thd->variables.lock_wait_timeout)) + DBUG_RETURN(TRUE); + } + else + thd->mdl_context.rollback_to_savepoint(mdl_svp); + } + if (lock_table(thd, table_ref, &hton_can_recreate)) DBUG_RETURN(TRUE); diff --git a/storage/innobase/dict/dict0dict.cc b/storage/innobase/dict/dict0dict.cc index 1d3e5c0e0543a..65b1db77f77ea 100644 --- a/storage/innobase/dict/dict0dict.cc +++ b/storage/innobase/dict/dict0dict.cc @@ -1031,20 +1031,33 @@ dict_table_open_on_name( if (!(ignore_err & ~DICT_ERR_IGNORE_FK_NOKEY) && !table->is_readable() && table->corrupted) { - ulint algo= table->space->get_compression_algo(); - if (algo <= PAGE_ALGORITHM_LAST && !fil_comp_algo_loaded(algo)) - my_printf_error(ER_PROVIDER_NOT_LOADED, - "Table %.*sQ.%sQ is compressed with %s," - " which is not currently loaded. " - "Please load the %s provider plugin" - " to open the table", - MYF(ME_ERROR_LOG), - int(table->name.dblen()), table->name.m_name, - table->name.basename(), - page_compression_algorithms[algo], - page_compression_algorithms[algo]); - else + if (table->space) + { + ulint algo= table->space->get_compression_algo(); + if (algo <= PAGE_ALGORITHM_LAST && !fil_comp_algo_loaded(algo)) + { + my_printf_error(ER_PROVIDER_NOT_LOADED, + "Table %.*sQ.%sQ is compressed with %s," + " which is not currently loaded. " + "Please load the %s provider plugin" + " to open the table", + MYF(ME_ERROR_LOG), + int(table->name.dblen()), table->name.m_name, + table->name.basename(), + page_compression_algorithms[algo], + page_compression_algorithms[algo]); + dict_sys.unfreeze(); + DBUG_RETURN(nullptr); + } dict_table_open_failed(table->name); + } + else + my_printf_error(ER_TABLE_CORRUPT, + "Table %.*sQ.%sQ is corrupted." + " Please drop the table and recreate.", + MYF(0), + int(table->name.dblen()), table->name.m_name, + table->name.basename()); dict_sys.unfreeze(); DBUG_RETURN(nullptr); } diff --git a/storage/innobase/handler/ha_innodb.cc b/storage/innobase/handler/ha_innodb.cc index 9f04cdba340ec..26a6cab560d0f 100644 --- a/storage/innobase/handler/ha_innodb.cc +++ b/storage/innobase/handler/ha_innodb.cc @@ -5851,7 +5851,7 @@ dberr_t ha_innobase::statistics_init(dict_table_t *table, bool recalc) @return error code @retval 0 on success */ int -ha_innobase::open(const char* name, int, uint) +ha_innobase::open(const char* name, int, uint test_if_locked) { char norm_name[FN_REFLEN]; @@ -5868,8 +5868,13 @@ ha_innobase::open(const char* name, int, uint) const char* is_part = dict_is_partition(norm_name); THD* thd = ha_thd(); + const dict_err_ignore_t ignore_err = + (test_if_locked & HA_OPEN_FOR_FK_CHECK) + ? dict_err_ignore_t(DICT_ERR_IGNORE_FK_NOKEY + | DICT_ERR_IGNORE_INDEX) + : DICT_ERR_IGNORE_FK_NOKEY; dict_table_t* ib_table = open_dict_table(name, norm_name, is_part, - DICT_ERR_IGNORE_FK_NOKEY); + ignore_err); DEBUG_SYNC(thd, "ib_open_after_dict_open");