Skip to content

MDEV-38243 Write binlog row events for changes done by cascading FK operations - #5521

Open
sjaakola wants to merge 2 commits into
mainfrom
MDEV-38243-new_API
Open

sjaakola wants to merge 2 commits into
mainfrom
MDEV-38243-new_API

Conversation

@sjaakola

Copy link
Copy Markdown
Contributor

This commit implements a feature which changes the handling of cascading foreign
key operations to write the changes of cascading operations into binlog.
The applying of such transaction, in the slave node, will apply just the binlog
events, and does not execute the actual foreign key cascade operation.
This will simplify the slave side replication applying and make it more predictable
in terms of potential interference with other parallel applying happning
in the node.

This feature can be turned ON/OFF by new variable:
rpl_use_binlog_events_for_fk_cascade, with default value OFF

Since the original version, this PR has changes after two reviews, by Kristian and Serg, mainly to:

  • Supporting slave with old MariaDB version.
    Events logged in cascade operation are additionally flagged with
    the long-standing NO_FOREIGN_KEY_CHECKS_F, so a replica that does
    not understand FK_CASCADE_EVENTS_F still disables foreign key checks
    and does not re-execute the cascade
  • SE/server API now narrows the SE role to just report the changes done by foreign key cascading,
    and server side does most of the work after that.

@maryo

maryo commented Sep 30, 2026 •

Copy link
Copy Markdown

Thanks for this work. It would really help our project.
I tried it in an experiment, resetting the database between the tests of a large test suite by replaying the binlog reversed (like mariadb-binlog --flashback), and ran into the problems below.
Sections 1 and 2 reproduce on main (d45cf75) with this PR (73fe01d) as it is: the replica silently diverges, and nested cascades can crash a debug server.
Section 3 shows once section 2 is fixed. With the attached fix, all tests pass on main, 12.3.3 and 11.8.9, including our project's large test suite.

The code and this report were generated by Claude Opus 5.5; I only tested them, not reviewed, so they probably need some polishing.

1. Nested cascades lose the middle levels and can overrun a buffer

The row images live in two plain THD members, assuming one cascade action in flight per THD (design doc §6.3). But InnoDB runs the next level of an FK chain from inside the outer action (row_update_cascade_for_mysql() → row_ins_foreign_check_on_constraint()), so the inner action overwrites and frees the outer one's images:

SET SESSION rpl_use_binlog_events_for_fk_cascade=1;
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;

The binlog has the deletes of gp and c, but none of p. The events are flagged, so the replica does not re-run the cascade and keeps p 1 and 3. The inner capture also reuses the outer table's buffer: with a longer child row it overruns it (Freeing overrun buffer, then signal 11 in a debug build).

Fix: a stack of image frames, each sized for its own table, opened by the before-image capture and closed by thd_fk_cascade_row() / thd_fk_cascade_abort(). InnoDB now also calls thd_fk_cascade_abort() when the before-image capture fails, as the service contract says.

2. Self-referencing foreign keys are not captured

ha_innobase::fk_cascade_fetch_row() fails when m_prebuilt->mysql_template is NULL, although build_template() right below creates it. The table instance opened for a self-referencing FK has not read anything yet, so it never has a template:

SET SESSION rpl_use_binlog_events_for_fk_cascade=1;
CREATE TABLE s (id INT PRIMARY KEY, parent_id INT, KEY(parent_id),
  FOREIGN KEY (parent_id) REFERENCES s(id) ON DELETE CASCADE) ENGINE=InnoDB;
CREATE TABLE o (id INT PRIMARY KEY, s_id INT, KEY(s_id),
  FOREIGN KEY (s_id) REFERENCES s(id) ON DELETE CASCADE) ENGINE=InnoDB;
INSERT INTO s VALUES (1,NULL),(2,1),(3,2),(4,NULL);
INSERT INTO o VALUES (10,1),(20,2),(30,3),(40,4);
DELETE FROM s WHERE id=1;

The rows of o are captured, those of s 2 and 3 are not, so the replica does not re-run the cascade and keeps s 2 and 3.

Fix: drop the NULL check. Note that this check is also why capture does nothing on an applier (§11, rpl_fk_cascade_binlog_row_slave_option): the FK child is open there via prelocking too. Without the check, a replica with the option ON logs the cascades it re-runs, like any other session. That seems right, and the test is updated accordingly; if the applier should stay inert, it needs an explicit check.

3. Cascaded rows are logged after the statement's own rows

Once section 2 is fixed, this appears: the queue is flushed at statement end (§7), which breaks the order of execution that §6.5 promises:

SET SESSION rpl_use_binlog_events_for_fk_cascade=1;
CREATE TABLE s (id INT PRIMARY KEY, parent_id INT, KEY(parent_id),
  FOREIGN KEY (parent_id) REFERENCES s(id) ON DELETE SET NULL) ENGINE=InnoDB;
INSERT INTO s VALUES (1,NULL),(2,1);
DELETE FROM s WHERE id IN (1,2);

The binlog has DELETE s(1), DELETE s(2), then the cascaded UPDATE s(2), and the replica stops with ERROR 1032.

Fix: flush the queue in handler::binlog_log_row() before each of the statement's own rows, and start a new row event in prepare_pending_rows_event() when FK_CASCADE_DERIVED_F changes.

Smaller things

  • The design doc refers to rpl_fk_cascade_binlog_row_mixed_eligibility, which is not in the PR. One written along its description is attached.
  • §6.3 eligibility checks every key part in table->vfield, which also covers STORED generated columns and the hidden hash of UNIQUE ... USING HASH. Safe, but maybe more than intended.
  • Cascade row images leave out virtual columns, unlike regular events with binlog_row_image=FULL. Fine for replicas, worth knowing for binlog tools.
  • int prepare_for_insert(bool do_create); added to handler in sql/handler.h has no definition or callers.

Attached, both applying on top of this PR on main:

@sjaakola
sjaakola force-pushed the MDEV-38243-new_API branch from 29bd10d to 721cc42 Compare October 7, 2026 06:22
sjaakola and others added 2 commits October 7, 2026 15:37
…perations

1. Overview
This commit implements a feature which changes the handling of cascading foreign
key operations to write the changes of cascading operations into binlog.
The applying of such transaction, in the slave node, will apply just the binlog
events, and does not execute the actual foreign key cascade operation.

Having cascaded FK changes in binlog simplifies the slave side applying and makes
it more predictable in terms of potential interference with other parallel applying
happening in the node.

This feature can be turned ON/OFF by new variable:
rpl_use_binlog_events_for_fk_cascade, with default value OFF

2. Design

The engine reports cascade actions through a new generic service,
include/mysql/service_thd_fk_cascade.h, and carries no binlog knowledge of its
own. It asks whether the server wants to be notified of cascade FK, lets the
server capture the before and after row images at the points where cursor is
positioned, and reports the completed action. The server owns the row
images, the column bitmaps that define them, and every decision about what is
done with them. The engine's only contribution is handler::fk_cascade_fetch_row(),
implemented by ha_innobase, which converts the record the cascade cursor sits on
into MySQL row format.

Server side, the reported rows are queued on the THD in execution order and
written to the binary log at statement end or commit, after the originating
statement's own events; they are discarded on rollback and on rollback to
savepoint. Logging them inline would place every cascade delete ahead of every
deferred update within a statement.

The cascaded FK binlog events have new event flag: FK_CASCADE_EVENTS_F, which
tells the slave applier to skip FK cascading.
Binlog events have also flags to mark both original and derived (cascaded FK) events.
This will make it possible for the slave to autonomously choose whether to use
the derived events in applying or to execute the cascade operation.

Binlogged cascade FK events are also correctly handled in slaves with old MariaDB
version. This is because the events logged in cascade operation are additionally
flagged with the NO_FOREIGN_KEY_CHECKS_F flag, so replica that does not understand
the new FK_CASCADE_EVENTS_F flag, still disables foreign key checks in applying
and does not re-execute the cascade

Conplete design is presented in a design document MDEV-38243-design.md, which can
be removed if PR is merged.

3. Trigger support
The SE/server API delegates the actual work to be done in server side. SE (innodb)
has to call 4 handler functions and prepare row images for the arguments.
With this API, server is notified of cascade FK action to happen, and can react
accordingly.

This commit has implemented the binlogging of cascade FK actions, but
there are two other possible uses as well:
- checking FK constraints after FK child table delete
- calling trigger for the child table delete/update

In the design, these handlers are called consumers.

For supporting triggers, it turned out that BEFORE row type of triggers are
too hard to support. This is because the the handler is called from inside
innodb, having a number of innodb resouces locked and execution residing
inside a innodb mtr. Note that the cascade operation may dive deeper in the
FK constraint chain, and callig BEFORE row trigger would require all this
work to be rolled back to isolate innodb for the trigger call.

AFTER row type of triggers, otoh can be supported  with this API. For these,
the trigger call requests can be queued and called after the statement execution
is complete.

4. Testing

The commit has mtr tests for testing the feature:

  rpl.rpl_fk_cascade_binlog_row                   - feature OFF vs ON
  rpl.rpl_fk_cascade_binlog_row_ordering          - event ordering
  rpl.rpl_fk_cascade_binlog_row_rollback          - discard on rollback
  rpl.rpl_fk_set_null_binlog_row                  - SET NULL capture
  rpl.rpl_fk_cascade_binlog_row_slave_option      - origin OFF / replica ON
  rpl.rpl_fk_cascade_binlog_row_old_slave         - old-replica compatibility
  rpl.rpl_fk_cascade_binlog_row_mixed_eligibility - mixed child eligibility
This is a patch posted by Marek Stipek's review, with following comment:

The row images of a reported cascade action lived in two plain THD
members, on the assumption that at most one cascade action is in flight
per THD. InnoDB performs the next level of an FK chain from inside the
outer cascade action, though (row_update_cascade_for_mysql() recurses
into row_ins_foreign_check_on_constraint()). For a chain like
gp -> p -> c:

- the nested action for c overwrote the before-image captured for p,
  reported c and freed the images, so thd_fk_cascade_row() for p found
  no image and silently dropped the row. Only the deepest level reached
  the binary log, and since its events carry FK_CASCADE_EVENTS_F the
  replica did not re-run the cascade either: it kept the rows of p,
  silently diverging from the primary;
- the nested capture reused the buffer allocated for p's row length, so
  a child with a longer row than its parent overran it (debug builds
  report "Freeing overrun buffer" and crash).

Keep the images on a stack instead: capturing a before-image opens a
frame with buffers sized for its own table, and thd_fk_cascade_row() or
thd_fk_cascade_abort() closes it. InnoDB now closes the frame also when
capturing the before-image failed, as the service contract already
asked for, so every frame is closed exactly once.

Self-referencing foreign keys: the child of such a cascade is the table
the statement itself changes, through the instance of it opened for the
foreign key. That handler has not read anything, so it has no row
template yet, and ha_innobase::fk_cascade_fetch_row() refused to convert
the row, although it rebuilds the template anyway, allocating it if
needed. The cascaded rows were not logged; when the statement also
cascaded into another table, whose rows were, the replica did not re-run
the cascade either and kept the self-referencing rows. Drop the check.

The same check made capture fail on a replica, for the handler of the
child opened for the foreign key there has not read anything either: the
pull request documents the feature as inert on the applier and pins it
in rpl_fk_cascade_binlog_row_slave_option, but the child is open via FK
prelocking there too. With the check gone, a replica with the feature
ON (origin OFF) captures the cascades it re-runs and logs them in its own
binary log, as any other session does; the test and the design document
now say so.

Order of the cascaded rows: the queue was emitted at statement end, after
all rows of the statement itself. When a cascade changes a row that the
statement changes after it - with the self-referencing cascades now
captured, e.g. DELETE FROM s WHERE id IN (1,2) with s.parent_id -> s.id
ON DELETE SET NULL - the binlog had DELETE 2 before the cascaded UPDATE
of 2, and the replica stopped with ERROR 1032. handler::binlog_log_row()
now emits the queue before each row of the statement is logged: the
cascades that row change ran precede it, as they did in execution.
Derived rows and the statement's own go to separate row events, so that
FK_CASCADE_DERIVED_F keeps marking exactly the derived ones.

- rpl_fk_cascade_binlog_row_mixed_eligibility: a statement with an
  ineligible child table is not captured at all (an eligible and an
  ineligible sibling, an ineligible grandchild, an all-eligible control
  case), checked on the replica and in the binlog. The design document
  refers to it, but it is not in the pull request.
- rpl_fk_cascade_binlog_row_multilevel: cascades through several levels
  up to the InnoDB maximum of 14 below the parent, and one level deeper
  (the statement fails, nothing is logged); UPDATE and SET NULL chains,
  a diamond, savepoints, self-referencing foreign keys with and without
  another child, and the order of the cascaded rows relative to the
  statement's own. Each case is checked on the replica.
- galera_fk_cascade_binlog_events, ..._log_bin: the same in Galera,
  without and with a binary log, with parallel applying.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@sjaakola
sjaakola force-pushed the MDEV-38243-new_API branch from 721cc42 to a6cef7a Compare October 7, 2026 12:38
@sjaakola

sjaakola commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

@maryo thanks for your review. I added your patch verbatim to this PR, it appears to fix the 3 vulnerabilities you found in this work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Development

Successfully merging this pull request may close these issues.

3 participants