Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
795 changes: 795 additions & 0 deletions MDEV-38243-design.md

Large diffs are not rendered by default.

109 changes: 109 additions & 0 deletions include/mysql/service_thd_fk_cascade.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/* Copyright (c) 2026, MariaDB Corporation.

This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; version 2 of the License.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1335 USA */

#pragma once

/*
Generic server-side handling of foreign-key cascade actions performed
inside a storage engine.

A storage engine that performs FK cascades internally (InnoDB does, in
row_ins_foreign_check_on_constraint()) cannot know what the server wants
done about them: the row may need to be written to the binary log, may need
to fire triggers, may need CHECK constraints re-evaluated, and so on. Rather
than teach the engine each of those, the engine reports the action and the
server decides.

The engine's side of the contract is three calls and no row buffers:

if (thd_fk_cascade_wanted(thd, child_table))
{
<position the cascade cursor on the row about to change>
thd_fk_cascade_capture(thd, child_table, FK_CASCADE_IMAGE_BEFORE);

<perform the cascade>

<position the cascade cursor on the changed row>
thd_fk_cascade_capture(thd, child_table, FK_CASCADE_IMAGE_AFTER);

thd_fk_cascade_row(thd, child_table, FK_CASCADE_ACTION_UPDATE);
}

The server owns the row images end to end: it decides which columns make up
the image (the engine does not touch the TABLE's column bitmaps), it owns the
buffers they are materialised into, and it frees them. The engine's only
contribution is handler::fk_cascade_fetch_row(), which converts the record
the cascade cursor is sitting on into MySQL row format.

Capture is therefore driven from the engine (only the engine knows when the
row is about to change and when it has changed) but performed by the server.
*/

#ifdef __cplusplus
extern "C" {
#endif

struct TABLE;

/* Which image thd_fk_cascade_capture() should materialise. */
#define FK_CASCADE_IMAGE_BEFORE 0
#define FK_CASCADE_IMAGE_AFTER 1

/* The action the engine performed on the child row. */
#define FK_CASCADE_ACTION_DELETE 0
#define FK_CASCADE_ACTION_UPDATE 1
#define FK_CASCADE_ACTION_SET_NULL 2

/*
Does the server want to be told about cascade actions on this child table?

Answers for every consumer at once (binary logging, triggers, CHECK
constraints, ...), so the engine can skip the cost of capturing row images
when nothing is interested. Also screens out tables whose row image would be
unsafe, and sessions in which the feature does not apply. Cheap enough to
call per cascaded row.
*/
int thd_fk_cascade_wanted(MYSQL_THD thd, struct TABLE *table);

/*
Materialise one row image of the child row the cascade cursor is currently
positioned on. `which` is FK_CASCADE_IMAGE_BEFORE or FK_CASCADE_IMAGE_AFTER.

The caller must have positioned the engine's cascade cursor on the row (for
InnoDB, via ha_innobase::fk_cascade_set_cursor()) and must hold whatever
latch the engine needs to read it. Returns 0 on success; non-zero means the
image could not be produced, in which case the engine should abandon
reporting this row via thd_fk_cascade_abort().

Capturing FK_CASCADE_IMAGE_BEFORE starts a cascade action, and every such
call, successful or not, must be matched by exactly one thd_fk_cascade_row()
or thd_fk_cascade_abort(). Actions may nest: while one is in flight, the
cascade it performs may start, capture and report actions on the next level
of the FK chain, which must all be finished before the outer one is.
*/
int thd_fk_cascade_capture(MYSQL_THD thd, struct TABLE *table, int which);

/*
Report the completed cascade action, consuming the images captured above,
and dispatch it to whichever server-side consumers apply. Frees the images.
*/
void thd_fk_cascade_row(MYSQL_THD thd, struct TABLE *table, int action);

/* Abandon a partially captured cascade action, freeing any images. */
void thd_fk_cascade_abort(MYSQL_THD thd);

#ifdef __cplusplus
}
#endif
2 changes: 2 additions & 0 deletions include/mysql/service_wsrep.h
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ extern struct wsrep_service_st {
#define wsrep_report_bf_lock_wait(T,I) wsrep_service->wsrep_report_bf_lock_wait(T,I)
#define wsrep_thd_set_PA_unsafe(T) wsrep_service->wsrep_thd_set_PA_unsafe_func(T)
#define wsrep_get_domain_id(T) wsrep_service->wsrep_get_domain_id_func(T)
#define wsrep_emulate_binlog(T) wsrep_service->wsrep_emulate_binlog_func(T)
#else

#define MYSQL_SERVICE_WSREP_STATIC_INCLUDED
Expand Down Expand Up @@ -265,5 +266,6 @@ extern "C" void wsrep_report_bf_lock_wait(const THD *thd,
/* declare parallel applying unsafety for the THD */
extern "C" void wsrep_thd_set_PA_unsafe(MYSQL_THD thd);
extern "C" uint32 wsrep_get_domain_id();
extern "C" my_bool wsrep_emulate_binlog(const MYSQL_THD thd);
#endif
#endif /* MYSQL_SERVICE_WSREP_INCLUDED */
4 changes: 4 additions & 0 deletions mysql-test/main/mysqld--help.result
Original file line number Diff line number Diff line change
Expand Up @@ -1392,6 +1392,9 @@ The following specify which files/extra groups are read (specified before remain
play when stop slave is executed
--rpl-semi-sync-slave-trace-level=#
The tracing level for semi-sync replication
--rpl-use-binlog-events-for-fk-cascade
If enabled, the master will write row events for foreign
key cascade operations
--safe-mode Skip some optimize stages (for testing). Deprecated, will
be removed in a future release.
--safe-user-create Don't allow new user creation by the user who has no
Expand Down Expand Up @@ -2100,6 +2103,7 @@ rpl-semi-sync-slave-delay-master FALSE
rpl-semi-sync-slave-enabled FALSE
rpl-semi-sync-slave-kill-conn-timeout 5
rpl-semi-sync-slave-trace-level 32
rpl-use-binlog-events-for-fk-cascade FALSE
safe-user-create FALSE
secure-file-priv (No default value)
secure-timestamp NO
Expand Down
153 changes: 153 additions & 0 deletions mysql-test/suite/galera/r/galera_fk_cascade_binlog_events.result
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
connection node_2;
connection node_1;
connection node_1;
SELECT @@log_bin;
@@log_bin
0
connection node_1;
SET SESSION rpl_use_binlog_events_for_fk_cascade=1;
#
# Scenario 1: ON DELETE CASCADE through 3 tables
#
CREATE TABLE gp (id INT PRIMARY KEY) ENGINE=InnoDB;
CREATE TABLE p (id INT PRIMARY KEY, gp_id INT,
FOREIGN KEY (gp_id) REFERENCES gp(id) ON DELETE CASCADE) ENGINE=InnoDB;
CREATE TABLE c (id INT PRIMARY KEY, p_id INT,
FOREIGN KEY (p_id) REFERENCES p(id) ON DELETE CASCADE) ENGINE=InnoDB;
INSERT INTO gp VALUES (1),(2);
INSERT INTO p VALUES (1,1),(2,2),(3,1);
INSERT INTO c VALUES (1,1),(2,2),(3,3),(4,1);
DELETE FROM gp WHERE id=1;
connection node_2;
SELECT * FROM gp ORDER BY id;
id
2
SELECT * FROM p ORDER BY id;
id gp_id
2 2
SELECT * FROM c ORDER BY id;
id p_id
2 2
connection node_1;
DROP TABLE c, p, gp;
#
# Scenario 2: ON UPDATE CASCADE and ON UPDATE / DELETE SET NULL
#
CREATE TABLE p (id INT PRIMARY KEY, v INT) ENGINE=InnoDB;
CREATE TABLE cc (id INT PRIMARY KEY, pid INT, KEY(pid),
FOREIGN KEY (pid) REFERENCES p(id) ON DELETE CASCADE ON UPDATE CASCADE) ENGINE=InnoDB;
CREATE TABLE cn (id INT PRIMARY KEY, pid INT, KEY(pid),
FOREIGN KEY (pid) REFERENCES p(id) ON DELETE SET NULL ON UPDATE SET NULL) ENGINE=InnoDB;
INSERT INTO p VALUES (1,10),(2,20),(3,30);
INSERT INTO cc VALUES (100,1),(200,2),(300,3);
INSERT INTO cn VALUES (100,1),(200,2),(300,3);
UPDATE p SET id=11 WHERE id=1;
DELETE FROM p WHERE id=2;
connection node_2;
SELECT * FROM p ORDER BY id;
id v
3 30
11 10
SELECT * FROM cc ORDER BY id;
id pid
100 11
300 3
SELECT * FROM cn ORDER BY id;
id pid
100 NULL
200 NULL
300 3
#
# Scenario 3: rolled back cascades are not replicated
#
connection node_1;
BEGIN;
DELETE FROM p WHERE id=3;
ROLLBACK;
BEGIN;
UPDATE p SET id=33 WHERE id=3;
SAVEPOINT s1;
DELETE FROM p WHERE id=11;
ROLLBACK TO SAVEPOINT s1;
COMMIT;
connection node_2;
SELECT * FROM p ORDER BY id;
id v
11 10
33 30
SELECT * FROM cc ORDER BY id;
id pid
100 11
300 33
SELECT * FROM cn ORDER BY id;
id pid
100 NULL
200 NULL
300 NULL
connection node_1;
DROP TABLE cc, cn, p;
#
# Scenario 4: parallel applying on node_2
#
CREATE TABLE p1 (f1 INT PRIMARY KEY, f2 INT) ENGINE=InnoDB;
CREATE TABLE p2 (f1 INT PRIMARY KEY, f2 INT) ENGINE=InnoDB;
CREATE TABLE c (f1 INT PRIMARY KEY, p1_id INT, p2_id INT, f2 INT,
CONSTRAINT fk_1 FOREIGN KEY (p1_id) REFERENCES p1 (f1) ON DELETE CASCADE,
CONSTRAINT fk_2 FOREIGN KEY (p2_id) REFERENCES p2 (f1) ON DELETE CASCADE) ENGINE=InnoDB;
connection node_2;
SET GLOBAL wsrep_slave_threads=4;
connection node_1;
connection node_2;
SELECT COUNT(*) FROM p1;
COUNT(*)
0
SELECT COUNT(*) FROM p2;
COUNT(*)
0
SELECT COUNT(*) FROM c;
COUNT(*)
0
SET GLOBAL wsrep_slave_threads=DEFAULT;
connection node_1;
DROP TABLE c, p1, p2;
#
# Scenario 5: child without PK and with a virtual column in a key,
# next to an eligible sibling
#
CREATE TABLE p (id INT PRIMARY KEY) ENGINE=InnoDB;
CREATE TABLE ce (id INT PRIMARY KEY, pid INT, KEY(pid),
FOREIGN KEY (pid) REFERENCES p(id) ON DELETE CASCADE ON UPDATE CASCADE) ENGINE=InnoDB;
CREATE TABLE ci (id INT, pid INT, v INT, vc INT AS (v + 1) VIRTUAL, KEY(pid), KEY(vc),
FOREIGN KEY (pid) REFERENCES p(id) ON DELETE CASCADE ON UPDATE CASCADE) ENGINE=InnoDB;
INSERT INTO p VALUES (1),(2),(3);
INSERT INTO ce VALUES (100,1),(200,2),(300,3);
INSERT INTO ci (id,pid,v) VALUES (100,1,1),(200,2,2),(300,3,3);
UPDATE p SET id=11 WHERE id=1;
DELETE FROM p WHERE id=2;
connection node_2;
SELECT * FROM p ORDER BY id;
id
3
11
SELECT * FROM ce ORDER BY id;
id pid
100 11
300 3
SELECT id, pid, v, vc FROM ci ORDER BY id;
id pid v vc
100 11 1 2
300 3 3 4
connection node_1;
DROP TABLE ce, ci, p;
#
# Both nodes are still in the cluster and applied without errors
#
connection node_2;
SELECT VARIABLE_VALUE AS cluster_size FROM INFORMATION_SCHEMA.GLOBAL_STATUS WHERE VARIABLE_NAME = 'wsrep_cluster_size';
cluster_size
2
SELECT VARIABLE_VALUE AS local_state FROM INFORMATION_SCHEMA.GLOBAL_STATUS WHERE VARIABLE_NAME = 'wsrep_local_state_comment';
local_state
Synced
connection node_1;
SET SESSION rpl_use_binlog_events_for_fk_cascade=DEFAULT;
Loading
Loading