From 05415aa9e088e0ec330709599eefb83722456a2a Mon Sep 17 00:00:00 2001 From: Matan Baruch Date: Thu, 13 Aug 2026 12:15:23 +0300 Subject: [PATCH 1/2] Bug#102586 ON DELETE CASCADE breaks with RBR and multiple-table DELETE A multi-table DELETE that names both a foreign key parent table and a child table with a cascading delete rule breaks row-based replication. The replica applier stops with ER_KEY_NOT_FOUND. The parent row is deleted while the join is still scanning, so the cascade removes the child rows and logs row events for them. The statement logs row events for the child rows it deletes itself as well. On the replica the parent delete is applied first, its own cascade removes the child rows, and the logged child events then cannot find them. Exclude a delete target from immediate deletion when deleting from it cascades to another table in the same query, which defers the delete until the join has finished. The check is added to both the classic optimizer (GetImmediateDeleteTables) and the hypergraph optimizer (IsImmediateDeleteCandidate). Only ON DELETE CASCADE is considered. ON DELETE SET NULL updates the child rows rather than deleting them, so they stay findable for the logged events and replicate correctly. Deferring those deletes as well would change which rows the statement removes. This is the approach Zsolt Parragi contributed on Bug#80821 in 2019, adapted to the current code: get_cascade_foreign_key_table_list() no longer exists, so the cascade dependency is resolved from TABLE_SHARE::foreign_key_parent instead. --- .../rpl_multi_table_delete_fk_cascade.result | 53 +++++++++++ .../t/rpl_multi_table_delete_fk_cascade.test | 89 +++++++++++++++++++ sql/join_optimizer/join_optimizer.cc | 7 ++ sql/sql_base.cc | 47 ++++++++++ sql/sql_base.h | 2 + sql/sql_delete.cc | 7 +- 6 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 mysql-test/suite/rpl/r/rpl_multi_table_delete_fk_cascade.result create mode 100644 mysql-test/suite/rpl/t/rpl_multi_table_delete_fk_cascade.test diff --git a/mysql-test/suite/rpl/r/rpl_multi_table_delete_fk_cascade.result b/mysql-test/suite/rpl/r/rpl_multi_table_delete_fk_cascade.result new file mode 100644 index 000000000000..0fd33bce8e96 --- /dev/null +++ b/mysql-test/suite/rpl/r/rpl_multi_table_delete_fk_cascade.result @@ -0,0 +1,53 @@ +include/rpl/init_source_replica.inc +Warnings: +Note #### Sending passwords in plain text without SSL/TLS is extremely insecure. +Note #### Storing MySQL user name or password information in the connection metadata repository is not secure and is therefore not recommended. Please consider using the USER and PASSWORD connection options for START REPLICA; see the 'START REPLICA Syntax' in the MySQL Manual for more information. +[connection master] +# +# ON DELETE CASCADE +# +CREATE TABLE t1 (id INT PRIMARY KEY) ENGINE=InnoDB; +CREATE TABLE t2 ( +id INT PRIMARY KEY, +parent_id INT, +FOREIGN KEY (parent_id) REFERENCES t1(id) ON DELETE CASCADE +) ENGINE=InnoDB; +INSERT INTO t1 VALUES (1), (2); +INSERT INTO t2 VALUES (1, 1), (2, 1), (3, 2); +DELETE p, c FROM t1 p LEFT JOIN t2 c ON c.parent_id = p.id WHERE p.id = 1; +SELECT * FROM t1 ORDER BY id; +id +2 +SELECT * FROM t2 ORDER BY id; +id parent_id +3 2 +include/rpl/sync_to_replica.inc +include/diff_tables.inc [master:test.t1, slave:test.t1] +include/diff_tables.inc [master:test.t2, slave:test.t2] +[connection master] +DROP TABLE t2, t1; +# +# ON DELETE SET NULL, which is not deferred and not affected +# +CREATE TABLE t1 (id INT PRIMARY KEY) ENGINE=InnoDB; +CREATE TABLE t2 ( +id INT PRIMARY KEY, +parent_id INT, +FOREIGN KEY (parent_id) REFERENCES t1(id) ON DELETE SET NULL +) ENGINE=InnoDB; +INSERT INTO t1 VALUES (1), (2); +INSERT INTO t2 VALUES (1, 1), (2, 1), (3, 2); +DELETE p, c FROM t1 p LEFT JOIN t2 c ON c.parent_id = p.id WHERE p.id = 1; +SELECT * FROM t1 ORDER BY id; +id +2 +SELECT * FROM t2 ORDER BY id; +id parent_id +2 NULL +3 2 +include/rpl/sync_to_replica.inc +include/diff_tables.inc [master:test.t1, slave:test.t1] +include/diff_tables.inc [master:test.t2, slave:test.t2] +[connection master] +DROP TABLE t2, t1; +include/rpl/deinit.inc diff --git a/mysql-test/suite/rpl/t/rpl_multi_table_delete_fk_cascade.test b/mysql-test/suite/rpl/t/rpl_multi_table_delete_fk_cascade.test new file mode 100644 index 000000000000..008a44c96ef5 --- /dev/null +++ b/mysql-test/suite/rpl/t/rpl_multi_table_delete_fk_cascade.test @@ -0,0 +1,89 @@ +# ==== Purpose ==== +# +# Check that a multi-table DELETE which names both a foreign key parent table +# and a child table with a cascading delete rule does not break row-based +# replication. +# +# ==== Implementation ==== +# +# 1. On the source, run a multi-table DELETE covering a parent table and its +# ON DELETE CASCADE child. +# 2. Synchronize the replica and compare both tables. Before this fix the +# applier stopped with ER_KEY_NOT_FOUND: the parent row was deleted while +# the join was still scanning, so the cascade removed the child rows on the +# replica before the logged child row events were applied. +# 3. Repeat with an ON DELETE SET NULL child, which replicates correctly and +# is covered here so the difference stays visible. +# +# ==== References ==== +# +# Bug#80821: Replication breaks if multi-table DELETE is used in conjunction +# with Foreign Key +# Bug#102586: Foreign Key ON DELETE CASCADE breaks with RBR and multiple-table +# DELETE +# +############################################################################### +--source include/have_binlog_format_row.inc +--source include/rpl/init_source_replica.inc + +--echo # +--echo # ON DELETE CASCADE +--echo # + +CREATE TABLE t1 (id INT PRIMARY KEY) ENGINE=InnoDB; +CREATE TABLE t2 ( + id INT PRIMARY KEY, + parent_id INT, + FOREIGN KEY (parent_id) REFERENCES t1(id) ON DELETE CASCADE +) ENGINE=InnoDB; + +INSERT INTO t1 VALUES (1), (2); +INSERT INTO t2 VALUES (1, 1), (2, 1), (3, 2); + +DELETE p, c FROM t1 p LEFT JOIN t2 c ON c.parent_id = p.id WHERE p.id = 1; + +SELECT * FROM t1 ORDER BY id; +SELECT * FROM t2 ORDER BY id; + +--source include/rpl/sync_to_replica.inc + +--let $diff_tables= master:test.t1, slave:test.t1 +--source include/diff_tables.inc +--let $diff_tables= master:test.t2, slave:test.t2 +--source include/diff_tables.inc + +--let $rpl_connection_name= master +--source include/connection.inc +DROP TABLE t2, t1; + +--echo # +--echo # ON DELETE SET NULL, which is not deferred and not affected +--echo # + +CREATE TABLE t1 (id INT PRIMARY KEY) ENGINE=InnoDB; +CREATE TABLE t2 ( + id INT PRIMARY KEY, + parent_id INT, + FOREIGN KEY (parent_id) REFERENCES t1(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +INSERT INTO t1 VALUES (1), (2); +INSERT INTO t2 VALUES (1, 1), (2, 1), (3, 2); + +DELETE p, c FROM t1 p LEFT JOIN t2 c ON c.parent_id = p.id WHERE p.id = 1; + +SELECT * FROM t1 ORDER BY id; +SELECT * FROM t2 ORDER BY id; + +--source include/rpl/sync_to_replica.inc + +--let $diff_tables= master:test.t1, slave:test.t1 +--source include/diff_tables.inc +--let $diff_tables= master:test.t2, slave:test.t2 +--source include/diff_tables.inc + +--let $rpl_connection_name= master +--source include/connection.inc +DROP TABLE t2, t1; + +--source include/rpl/deinit.inc diff --git a/sql/join_optimizer/join_optimizer.cc b/sql/join_optimizer/join_optimizer.cc index 75afd2a9ca94..b2cca5702fdd 100644 --- a/sql/join_optimizer/join_optimizer.cc +++ b/sql/join_optimizer/join_optimizer.cc @@ -7322,6 +7322,13 @@ bool IsImmediateDeleteCandidate(const Table_ref *table_ref, return false; } + // Cannot delete from the table immediately if the delete cascades to another + // table in the query, as the cascade would remove rows that the query still + // reads and deletes itself. See Bug#80821 and Bug#102586. + if (delete_cascades_to_queried_table(table_ref, query_block->leaf_tables)) { + return false; + } + return true; } diff --git a/sql/sql_base.cc b/sql/sql_base.cc index 2c28c089dd59..b49272ada6fe 100644 --- a/sql/sql_base.cc +++ b/sql/sql_base.cc @@ -2256,6 +2256,53 @@ Table_ref *unique_table(const Table_ref *table, Table_ref *table_list, return dup; } +/** + Test whether deleting a row from the subject table of a multi-table DELETE + can cascade to another table which the same statement reads. + + Deleting from such a table while the join is still scanning is unsafe for + row-based replication: the cascade removes the child rows on the source and + logs row events for them, while the statement also logs the row events for + the child rows it deletes itself. On the replica the cascade has already + removed those rows by the time the logged child events are applied, which + breaks the applier with ER_KEY_NOT_FOUND. Deferring the delete until the + join has finished avoids the overlap. + + Only ON DELETE CASCADE deletes child rows, so only that rule is considered. + ON DELETE SET NULL updates the child rows instead, which leaves them + findable for the logged events and replicates correctly. + + @param table table to be checked (must be updatable base table) + @param leaf_tables leaf tables of the query block to check against + + @retval true Deleting from @p table cascades to one of @p leaf_tables. + @retval false No cascading dependency within the query. +*/ + +bool delete_cascades_to_queried_table(const Table_ref *table, + const Table_ref *leaf_tables) { + assert(table->table != nullptr); + + const TABLE_SHARE *share = table->table->s; + for (const TABLE_SHARE_FOREIGN_KEY_PARENT_INFO *fk_p = + share->foreign_key_parent; + fk_p < share->foreign_key_parent + share->foreign_key_parents; ++fk_p) { + if (fk_p->delete_rule != dd::Foreign_key::RULE_CASCADE) continue; + + for (const Table_ref *tl = leaf_tables; tl != nullptr; tl = tl->next_leaf) { + if (tl->table == nullptr) continue; // View or derived table. + const TABLE_SHARE *child_share = tl->table->s; + if (my_strcasecmp(table_alias_charset, child_share->db.str, + fk_p->referencing_table_db.str) == 0 && + my_strcasecmp(table_alias_charset, child_share->table_name.str, + fk_p->referencing_table_name.str) == 0) + return true; + } + } + + return false; +} + /** Issue correct error message in case we found 2 duplicate tables which prevent some update operation diff --git a/sql/sql_base.h b/sql/sql_base.h index 820dbe88a5d8..4540757f4477 100644 --- a/sql/sql_base.h +++ b/sql/sql_base.h @@ -302,6 +302,8 @@ void close_thread_table(THD *thd, TABLE **table_ptr); bool close_temporary_tables(THD *thd); Table_ref *unique_table(const Table_ref *table, Table_ref *table_list, bool check_alias); +bool delete_cascades_to_queried_table(const Table_ref *table, + const Table_ref *leaf_tables); void drop_temporary_table(THD *thd, Table_ref *table_list); void close_temporary_table(THD *thd, TABLE *table, bool free_share, bool delete_table); diff --git a/sql/sql_delete.cc b/sql/sql_delete.cc index 4b8a602cb5df..8072a1e56ded 100644 --- a/sql/sql_delete.cc +++ b/sql/sql_delete.cc @@ -1333,11 +1333,14 @@ table_map GetImmediateDeleteTables(const JOIN *join, table_map delete_tables) { tr = tr->next_leaf) { if (!tr->is_deleted()) continue; - if (unique_table(tr, join->tables_list, false) != nullptr) { + if (unique_table(tr, join->tables_list, false) != nullptr || + delete_cascades_to_queried_table(tr, join->query_block->leaf_tables)) { /* If the table being deleted from is also referenced in the query, defer delete so that the delete doesn't interfere with reading of this - table. + table. The same applies if deleting from the table cascades to another + table in the query, since the cascade would remove rows that the query + still reads and deletes itself. See Bug#80821 and Bug#102586. */ return 0; } From 6416dfc031d4ca3f36fbd28eccb165600c03c790 Mon Sep 17 00:00:00 2001 From: Matan Baruch Date: Fri, 21 Aug 2026 12:11:29 +0300 Subject: [PATCH 2/2] Fix pre-existing clang-format violations in the files touched by this change Format Check runs clang-format-18 over whole changed files. These three are not clean under 18 on trunk, so the gate fails for any PR touching them. Cosmetic only: a label space in sql_base.cc, one DBUG_LOG argument wrap in sql_delete.cc, and two string literal joins in join_optimizer.cc. --- sql/join_optimizer/join_optimizer.cc | 6 ++---- sql/sql_base.cc | 2 +- sql/sql_delete.cc | 4 ++-- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/sql/join_optimizer/join_optimizer.cc b/sql/join_optimizer/join_optimizer.cc index b2cca5702fdd..037d3ff2ee1c 100644 --- a/sql/join_optimizer/join_optimizer.cc +++ b/sql/join_optimizer/join_optimizer.cc @@ -4757,8 +4757,7 @@ bool CostingReceiver::evaluate_secondary_engine_optimizer_state_request() { m_subgraph_pair_limit = restart_parameters.subgraph_pair_limit; DBUG_EXECUTE_IF("verify_hyp_opt_sg_pair_requested", { if (TraceStarted(m_thd) && m_subgraph_pair_limit > 0) { - Trace(m_thd) << "Hypergraph non zero SG pairs requested" - << "\n"; + Trace(m_thd) << "Hypergraph non zero SG pairs requested" << "\n"; } }); return true; @@ -10038,8 +10037,7 @@ static AccessPath *FindBestQueryPlanInner(THD *thd, Query_block *query_block, DBUG_EXECUTE_IF("verify_hyp_opt_sg_pair_requested", { if (TraceStarted(thd) && root_path_quality_status.subgraph_pair_limit > 0) { - Trace(thd) << "Hypergraph non zero SG pairs reset requested" - << "\n"; + Trace(thd) << "Hypergraph non zero SG pairs reset requested" << "\n"; } }); return nullptr; diff --git a/sql/sql_base.cc b/sql/sql_base.cc index b49272ada6fe..dd6a05a3b17a 100644 --- a/sql/sql_base.cc +++ b/sql/sql_base.cc @@ -3229,7 +3229,7 @@ bool open_table(THD *thd, Table_ref *table_list, Open_table_context *ot_ctx) { } else if (table_list->open_strategy == Table_ref::OPEN_STUB) return false; -retry_share : { +retry_share: { Table_cache *tc = table_cache_manager.get_cache(thd); tc->lock(); diff --git a/sql/sql_delete.cc b/sql/sql_delete.cc index 8072a1e56ded..0e200e8f4bb6 100644 --- a/sql/sql_delete.cc +++ b/sql/sql_delete.cc @@ -631,8 +631,8 @@ bool Sql_cmd_delete::delete_from_single_table(THD *thd) { break; } - DBUG_LOG("jdv_dml", "DML-DELETE: " - << " table_list->field_translation->name: " + DBUG_LOG("jdv_dml", + "DML-DELETE: " << " table_list->field_translation->name: " << table_list->field_translation->name << " ->type():" << table_list->field_translation->item->type());