From 89c239b72f40d8a2025c30cb52a8cbca99bb54e5 Mon Sep 17 00:00:00 2001 From: sjaakola Date: Thu, 2 Apr 2026 21:22:57 +0300 Subject: [PATCH 1/2] MDEV-38243 Write binlog row events for changes done by cascading FK operations 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 --- MDEV-38243-design.md | 774 ++++++++++++++++++ include/mysql/service_thd_fk_cascade.h | 103 +++ include/mysql/service_wsrep.h | 2 + mysql-test/main/mysqld--help.result | 4 + .../rpl/r/rpl_fk_cascade_binlog_row.result | 85 ++ ...rpl_fk_cascade_binlog_row_old_slave.result | 53 ++ .../rpl_fk_cascade_binlog_row_ordering.result | 37 + .../rpl_fk_cascade_binlog_row_rollback.result | 140 ++++ ..._fk_cascade_binlog_row_slave_option.result | 52 ++ .../rpl/r/rpl_fk_set_null_binlog_row.result | 61 ++ .../rpl/t/rpl_fk_cascade_binlog_row.test | 133 +++ .../rpl_fk_cascade_binlog_row_old_slave.test | 84 ++ .../t/rpl_fk_cascade_binlog_row_ordering.test | 69 ++ .../t/rpl_fk_cascade_binlog_row_rollback.test | 201 +++++ ...pl_fk_cascade_binlog_row_slave_option.test | 109 +++ .../rpl/t/rpl_fk_set_null_binlog_row.test | 105 +++ .../sys_vars/r/sysvars_server_embedded.result | 10 + .../r/sysvars_server_notembedded.result | 10 + sql/handler.cc | 61 +- sql/handler.h | 19 +- sql/log.cc | 20 +- sql/log_event.h | 17 + sql/log_event_server.cc | 16 +- sql/service_wsrep.cc | 5 + sql/sql_base.cc | 58 +- sql/sql_base.h | 2 +- sql/sql_class.cc | 421 ++++++++++ sql/sql_class.h | 61 ++ sql/sql_parse.cc | 17 + sql/sys_vars.cc | 20 + storage/innobase/handler/ha_innodb.cc | 54 +- storage/innobase/handler/ha_innodb.h | 41 + storage/innobase/include/row0sel.h | 12 +- storage/innobase/include/trx0trx.h | 2 + storage/innobase/row/row0ins.cc | 133 ++- storage/innobase/row/row0sel.cc | 2 +- storage/innobase/trx/trx0trx.cc | 3 +- 37 files changed, 2964 insertions(+), 32 deletions(-) create mode 100644 MDEV-38243-design.md create mode 100644 include/mysql/service_thd_fk_cascade.h create mode 100644 mysql-test/suite/rpl/r/rpl_fk_cascade_binlog_row.result create mode 100644 mysql-test/suite/rpl/r/rpl_fk_cascade_binlog_row_old_slave.result create mode 100644 mysql-test/suite/rpl/r/rpl_fk_cascade_binlog_row_ordering.result create mode 100644 mysql-test/suite/rpl/r/rpl_fk_cascade_binlog_row_rollback.result create mode 100644 mysql-test/suite/rpl/r/rpl_fk_cascade_binlog_row_slave_option.result create mode 100644 mysql-test/suite/rpl/r/rpl_fk_set_null_binlog_row.result create mode 100644 mysql-test/suite/rpl/t/rpl_fk_cascade_binlog_row.test create mode 100644 mysql-test/suite/rpl/t/rpl_fk_cascade_binlog_row_old_slave.test create mode 100644 mysql-test/suite/rpl/t/rpl_fk_cascade_binlog_row_ordering.test create mode 100644 mysql-test/suite/rpl/t/rpl_fk_cascade_binlog_row_rollback.test create mode 100644 mysql-test/suite/rpl/t/rpl_fk_cascade_binlog_row_slave_option.test create mode 100644 mysql-test/suite/rpl/t/rpl_fk_set_null_binlog_row.test diff --git a/MDEV-38243-design.md b/MDEV-38243-design.md new file mode 100644 index 0000000000000..35adf067dcc7f --- /dev/null +++ b/MDEV-38243-design.md @@ -0,0 +1,774 @@ +# MDEV-38243 — Binlog row events for cascading foreign key operations + +## Design document + +Status: implemented on branch `MDEV-38243-new_API`, current at commit +`7e4a9d269c4` (third round of review changes: the SE ↔ server boundary is now +the generic FK-cascade service `include/mysql/service_thd_fk_cascade.h`). + +--- + +## 1. Problem statement + +Foreign key cascade actions — `ON DELETE CASCADE`, `ON UPDATE CASCADE`, +`ON DELETE SET NULL`, `ON UPDATE SET NULL` — are executed **inside InnoDB** +(`row_ins_foreign_check_on_constraint()`), below the SQL layer. In row-based +replication the SQL layer only logs the row changes it drives directly, i.e. +the change to the *parent* table named in the statement. The cascaded changes +to *child* tables are performed by InnoDB and never surface to the binlogging +layer. + +Currently replication handles changes done by the foreign key constraint cascade +execution so that the replication **SQL slave re-executes the cascade**: +it applies the parent-table row event with foreign key checks enabled, and its +own InnoDB reproduces the child-row changes. + +Re-executing the cascade on the replica is a source of problems: + +- **Non-determinism / divergence** when the replica's schema, indexes or FK + definitions differ, or when `SET NULL` ordering is ambiguous. +- **Parallel-apply hazards**, particularly for Galera appliers, where a cascade + fired during apply interacts unpredictably with other concurrent appliers. + Conflicts in galera applying is the primary reason for the feature. + +## 2. Prior Art + +MySQL 9.6 has refactored Foreign key constraint handling to happen in server side, +which directly allows recording binlog events for cascading operation changes. +See: https://blogs.oracle.com/mysql/no-more-hidden-changes-how-mysql-9-6-transforms-foreign-key-management + +If similar feature is planned to be implemented in MariaDB, then this MDEV-38243 +becomes obsolete. + +## 3. Goal + +Have the **origin** capture the row changes produced by cascading FK operations +and write them into the binary log as **explicit row events**, so that the +**replica applies just those events and does not re-run the cascade**. This +makes applying deterministic and free of cascade-induced parallel-apply hazards. + +## 4. Feature switch + +- New session variable **`rpl_use_binlog_events_for_fk_cascade`**, default + `OFF`. It cannot be changed inside a transaction or sub-statement + (`error_if_in_trans_or_substatement`); the check is skipped for + `SET GLOBAL`. +- **The session variable is required in all cases, Galera included.** Under + `WSREP_EMULATE_BINLOG` (Galera with the binary log off) two of the *other* + preconditions become moot and are skipped — the row-format test, because a + writeset is always row-based, and the table-eligibility test of §6.3, because + a writeset is not subject to that row-image caveat — but emulation alone does + **not** turn the feature on. Treating `WSREP_EMULATE_BINLOG` as sufficient + would enable cascade capture for every Galera session and break cascade + replication; `thd_fk_cascade_wanted()` carries a comment saying so. +- Row-based binary logging is otherwise required; the capture is a no-op + in statement format. + +## 5. Architecture / data flow + +``` + Statement executes on origin + │ + ▼ + InnoDB parent DML ── cascade ──► row_ins_foreign_check_on_constraint() + │ │ + │ ├─ locate the child's open MySQL TABLE + │ ├─ thd_fk_cascade_wanted(thd, child)? ──── no ──► plain cascade + │ ├─ position cascade cursor on the row + │ ├─ thd_fk_cascade_capture(BEFORE) ─┐ + │ ├─ row_update_cascade_for_mysql() │ server materialises + │ ├─ position cursor on changed row │ the images, via + │ ├─ thd_fk_cascade_capture(AFTER) ──┘ handler::fk_cascade_fetch_row() + │ └─ thd_fk_cascade_row(DELETE|UPDATE) + │ │ + │ ▼ (server side, sql/sql_class.cc) + │ dispatch to consumers; consumer 1 is + │ binlogging: THD::binlog_report_cascade_row() + ▼ queues the images on the THD + parent row event logged (flagged) + │ + ▼ + statement end / commit ──► THD::flush_pending_cascade_binlog() + │ emits queued child events in order + ▼ + binary log: [parent event][derived child events...] + all flagged, applied verbatim on replica +``` + +The engine/server boundary sits at the *report* step: the engine says only +"a cascade is about to happen / has happened on this child row"; every decision +about what the row image contains, whether it is binlogged, when and how, is +made on the server side. See §6. + +## 6. Origin side — capture + +### 6.1 The SE ↔ server interface + +`include/mysql/service_thd_fk_cascade.h` is a **generic FK-cascade service**, +not a binlog service. Its premise: an engine that performs FK cascades +internally cannot know what the server wants done about them — the row may +need to be binlogged, may need to fire triggers, may need CHECK constraints +re-evaluated. Rather than teach the engine each of those, the engine reports +the action and the server decides. + +```c +#define FK_CASCADE_IMAGE_BEFORE 0 +#define FK_CASCADE_IMAGE_AFTER 1 + +#define FK_CASCADE_ACTION_DELETE 0 +#define FK_CASCADE_ACTION_UPDATE 1 +#define FK_CASCADE_ACTION_SET_NULL 2 + +int thd_fk_cascade_wanted (MYSQL_THD thd, struct TABLE *table); +int thd_fk_cascade_capture(MYSQL_THD thd, struct TABLE *table, int which); +void thd_fk_cascade_row (MYSQL_THD thd, struct TABLE *table, int action); +void thd_fk_cascade_abort (MYSQL_THD thd); +``` + +The engine's side of the contract is those four calls **and no row buffers**: + +```c + if (thd_fk_cascade_wanted(thd, child_table)) + { + + thd_fk_cascade_capture(thd, child_table, FK_CASCADE_IMAGE_BEFORE); + + + + + 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. Capture is *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. + +The engine's one remaining contribution is a new handler method: + +```c + /* sql/handler.h */ + virtual int handler::fk_cascade_fetch_row(uchar *buf) + { return HA_ERR_WRONG_COMMAND; } +``` + +which converts the record the engine's cascade cursor is sitting on into MySQL +row format, honouring the bitmaps the server has already set. Engines that do +not cascade internally never see the call. + +### 6.2 Engine side — `storage/innobase/row/row0ins.cc` + +In `row_ins_foreign_check_on_constraint()`: + +1. **Locate the child table.** `row_ins_find_open_table_for_cascade()` parses + the child's db/table name and calls `find_fk_open_table()`, which returns the + child `TABLE` only if it was opened via FK **prelocking** + (`TABLE_LIST::PRELOCK_FK`). Only the engine can map its own `dict_table_t` to + a name, which is why this much stays here. See §11 for the consequence on the + replica. + +2. **Ask the server.** `thd_fk_cascade_wanted(trx->mysql_thd, child_mysql_table)`. + Nothing further happens if it says no. + +3. **Capture the before-image.** In a short `mtr` of its own, the cascade cursor + is restored (`cascade->pcur->restore_position(BTR_SEARCH_LEAF, mtr)`, requiring + `SAME_ALL`), the record and clustered index are handed to + `ha_innobase::fk_cascade_set_cursor(rec, index)`, and + `thd_fk_cascade_capture(..., FK_CASCADE_IMAGE_BEFORE)` is called. The cursor + is cleared again immediately (`fk_cascade_set_cursor(NULL, NULL)`): it is + only valid while the caller holds the page latch. Success is tracked in + `need_cascade_binlog`. + +4. **Run the actual cascade** via `row_update_cascade_for_mysql()`. + `can_cascade_binlog = (err == DB_SUCCESS && need_cascade_binlog)`. + +5. **Capture the after-image** (for `UPDATE` / `SET NULL`; not for + `PLAIN_DELETE`) the same way, from the just-modified record — provided it is + still a user record and not delete-marked. Result in `have_after_image`. + +6. **Report or abort.** If a before-image was captured + (`need_cascade_binlog`), exactly one of the two happens: + `thd_fk_cascade_row(..., FK_CASCADE_ACTION_DELETE | FK_CASCADE_ACTION_UPDATE)` + when the cascade succeeded and the required images are present, otherwise + `thd_fk_cascade_abort()`. Either call frees the server-side images, so no + capture can leak because the cascade failed midway. + +`ha_innobase::fk_cascade_fetch_row()` (in `ha_innodb.cc`) does the conversion: +it rebuilds the InnoDB row template for the server's bitmaps +(`rebuild_template_for_cascade_binlog_row_image()`), computes `rec_offsets`, +temporarily points `prebuilt->index` at the cascade index, calls +`row_sel_store_mysql_rec()`, and resets the template +(`reset_template_for_cascade_binlog_row_image()`) — the template in force +belongs to the statement being executed and describes a different set of +columns. It returns `HA_ERR_GENERIC` if the record cannot be converted, and the +cursor state (`m_fk_cascade_rec`, `m_fk_cascade_index`) is per-handler and +valid only for the duration of one capture call. + +### 6.3 Server side — `sql/sql_class.cc` + +`thd_fk_cascade_wanted()` answers for **every consumer at once**, so the engine +can skip the capture cost when nothing is interested. It is cheap enough to call +per cascaded row. Its binlog arm requires, in order: the session variable; row +format *or* `WSREP_EMULATE_BINLOG`; eligibility *or* `WSREP_EMULATE_BINLOG`; +and `table->file->prepare_for_row_logging()`. + +`fk_cascade_table_eligible()` — moved here from InnoDB — rejects a table that +has *no primary key* **and** a *virtual column participating in a key*: such a +row cannot be identified unambiguously from a full row image, so we decline to +report the cascade rather than hand a consumer something it would apply to the +wrong row. + +Eligibility is then decided **for the statement, not for the table**, by +`fk_cascade_stmt_capturable()`. The reason is that the two halves of the +mechanism have different scopes: eligibility is a property of one table, but +the flag that stops the replica re-running the cascade is a property of the +whole statement — the first captured child row calls +`binlog_mark_fk_cascade_events()`, and from then on *every* row event of the +statement carries `FK_CASCADE_EVENTS_F | NO_FOREIGN_KEY_CHECKS_F` (§8), so the +replica stops cascading for all children, not just the captured one. + +A per-child answer therefore lets an eligible child switch the replica's +cascade off on behalf of an ineligible sibling whose rows were never logged: +the replica neither receives those rows nor recreates them, and silently keeps +the orphans. Pinned by `rpl_fk_cascade_binlog_row_mixed_eligibility` (§14), +which diverges without this check. + +The decision is made all-or-nothing over the **FK prelocking set**, which is +what makes it decidable before any row is touched: +`prepare_fk_prelocking_list()` (`sql_base.cc:5390`) has already enumerated, +transitively, every child table the statement can write through a cascade, and +opened each one — children needed only for read-only FK checks are `OPEN_STUB` +(`table.h:2603`) and never reach `thd->open_tables`, so a scan of that list for +`PRELOCK_FK` entries with `TL_FIRST_WRITE` is exactly the reachable set. If any +of them is ineligible the feature is declined for the whole statement and the +classic replica-side cascade handles all of it — slower, but correct. The +answer is cached on the `THD` and keyed on `query_id`, so it cannot outlive the +statement it was computed for and needs no reset hook. + +`thd_fk_cascade_capture()` allocates the image slot +(`THD::fk_cascade_before_image` / `fk_cascade_after_image`, `reclength` bytes), +switches the table to the full row image via +`fk_cascade_begin_full_row_image()` — `tmp_set` set to all columns minus the +virtual ones, installed as read/write set, and as `rpl_write_set` if that was +NULL — calls `handler::fk_cascade_fetch_row()`, and restores the statement's own +bitmaps with `fk_cascade_end_full_row_image()`. This bitmap policy is *server* +policy, which is why it lives here and not in the engine. On failure the slot is +freed and non-zero returned. + +`thd_fk_cascade_row()` consumes the images and dispatches. At most one cascade +action is in flight per THD at a time, which is what lets the images live as two +plain `THD` members: the engine captures before, cascades, captures after, and +reports, all within one call to its cascade routine. An update whose +after-image could not be captured is dropped rather than reported. The images +are freed on the way out (`THD::fk_cascade_free_images()`), and `~THD()` frees +them as a backstop for an in-flight capture the engine never reported. + +### 6.4 Consumers + +The point of routing cascades through the server is that each known gap in how +FK cascades behave becomes an arm of `thd_fk_cascade_wanted()` plus an arm of +`thd_fk_cascade_row()`, instead of new machinery inside every engine that +cascades. Three consumers are identified; **only the first is implemented**, the +other two are documented in place in `sql_class.cc` as commented-out arms: + +| # | Consumer | State | Note | +|---|----------|-------|------| +| 1 | Binary logging of the cascaded rows | **implemented** | deferred: queued now, written at statement end (§7) | +| 2 | Triggers on the child table | not wired up | `AFTER` triggers would have to be queued and run at statement end; `BEFORE` triggers cannot be supported at this call site at all — see §6.4.1 | +| 3 | CHECK constraints on the child table | not wired up | safe to evaluate inline: `TABLE::verify_constraints()` only evaluates expressions over `record[0]` and touches no other table, and InnoDB already evaluates that class of expression in this path — see §6.4.1 | + +#### 6.4.1 Why `BEFORE` triggers cannot be honoured here + +Worth recording, because it is not a matter of the service growing another +call. Three blockers: the first two are about the shape of the service, the +third about where it is called from. The third is the one a wider API cannot +fix, and also the one most often challenged — see the bound on it below. + +**No pre-cascade callback carries the new row.** The only calls that happen +before the child row changes are `thd_fk_cascade_wanted()` +(`row0ins.cc:1408`) and `thd_fk_cascade_capture(FK_CASCADE_IMAGE_BEFORE)` +(`:1416`). The first is a yes/no gate; the second materialises the *old* row. +Neither is given the proposed new values. The only call that carries the +action, `thd_fk_cascade_row()` (`:1469`), runs after +`row_update_cascade_for_mysql()` (`:1424`) has returned — the row is already +rewritten, or for a cascade delete, gone. + +**Nothing can carry a trigger's result back.** `thd_fk_cascade_row()` returns +`void`; `thd_fk_cascade_capture()` returns `int`, but the engine uses it only +to set `need_cascade_binlog` and never to abort or redirect the cascade. All +three effects of a `BEFORE` trigger are therefore unrepresentable: rewriting +`NEW` has no return channel, `SIGNAL`-ing an error has no abort path, and +skipping the row has neither. The last is not hypothetical — +`Table_triggers_list::process_triggers()` asserts that a `TRG_ACTION_BEFORE` +invocation *must* supply a `skip_row_indicator` out-param and that a +`TRG_ACTION_AFTER` one must not (`sql/sql_trigger.cc:2799`), so the skip +channel is structurally part of running a `BEFORE` trigger. + +**A wider API would not help: this call site cannot run a stored program.** +Both captures execute inside `mtr_start()`/`mtr_commit()` holding a +`BTR_SEARCH_LEAF` page latch on the child's clustered index (`:1411`-`:1421`, +`:1427`-`:1457`), and the whole of `row_ins_foreign_check_on_constraint()` +runs under a shared `dict_sys.latch` — `row_ins_check_foreign_constraint()`, +which calls it at `:1867`, documents that requirement at `:1576`. A trigger +body is arbitrary SQL that opens tables, takes MDL and re-enters the engine, +none of which is admissible under those latches; that holds even at the report +point `:1469`, which is free of page latches (which is why binlogging is safe +there) but still under the dictionary latch and still nested inside the parent +statement's `ha_update_row()`. + +##### The bound on that claim: InnoDB *does* call into the server here + +The blocker above is specifically about running a **stored program**, not +about calling into the server at all. The broader claim would be wrong, and +visibly so in this very function. + +InnoDB evaluates server-side `Item` expressions in place, without unwinding: +`innobase_get_computed_value()` (`ha_innodb.cc:21044`) calls +`TABLE::update_virtual_field()` (`table.cc:9490`), which switches to the +table's `expr_arena` and evaluates the virtual column's expression. That is +reached from `row_ins_foreign_fill_virtual()` (`row0ins.cc:877`, calls at +`:927` and `:946`), which the cascade path itself invokes at `:1319` — *before* +the `mtr_commit(mtr)` at `:1388`. So server expression evaluation already runs +inside `row_ins_foreign_check_on_constraint()` with the mini-transaction +active. Index condition pushdown does the same thing on the read path. + +This is why **consumer 3 (CHECK constraints) is the easy one**: +`TABLE::verify_constraints()` (`table.cc:6714`) is the same shape — +`expr->val_bool()` over `record[0]` — so it reuses a mechanism the engine +already has. + +What makes that safe is that the expression class is *closed by +construction*, not that expression evaluation is inherently harmless: + +- `Item::check_vcol_func_processor` **defaults** to + `mark_unsupported_function(..., VCOL_IMPOSSIBLE)` (`item.h:2453`), so + anything not explicitly whitelisted is rejected; +- `Item_subselect` rejects explicitly (`item_subselect.h:244`); +- `Item_func_sp` does not override the default, so stored function calls are + rejected with it. + +Observable directly: + +``` +CREATE TABLE t1 (a INT, b INT AS (f1(a)) VIRTUAL); +ERROR HY000: Function or expression '`f1`()' cannot be used in the + GENERATED ALWAYS AS clause of `b` +CREATE TABLE t2 (a INT, CHECK (f1(a) > 0)); +ERROR HY000: Function or expression '`f1`()' cannot be used in the + CHECK clause of `CONSTRAINT_1` +CREATE TABLE t3 (a INT, CHECK (a IN (SELECT 1))); +ERROR 42000: CHECK does not support subqueries or stored functions +``` + +A vcol or CHECK expression therefore provably cannot open a table, take MDL or +re-enter the engine. The engine is not granted a general licence to call the +server; it is granted one for a class closed against exactly the hazards a +latch cares about. A trigger body is the opposite of that class: an `sp_head` +of arbitrary statements. There is no `sp_head`, `execute_trigger` or +`process_triggers` anywhere under `storage/innobase/` — no precedent for +running a stored program from inside the engine. + +So the question to settle is not "must we leave InnoDB", which the vcol +precedent answers with "not necessarily", but "can `sp_head::execute_trigger` +run under a page latch and a shared `dict_sys.latch`". For arbitrary bodies, +no. For a restricted subset — a body that is only `SET NEW.col = ` +over a vcol-safe expression, classifiable at trigger-creation time much as +vcol expressions already are — plausibly yes, and that would be a real design +worth costing. + +Even then it does not on its own yield working `BEFORE` triggers: blockers 1 +and 2 are untouched by any of it. There is still no pre-cascade callback +carrying the proposed new row, and still no channel to feed a modified `NEW`, +a `SIGNAL` abort, or a `skip_row_indicator` back into `cascade->update`. +Latching is the third blocker, not the only one. + +##### Why deferring is not a workaround either + +Running the trigger at statement end, as consumer 2 would have to for `AFTER`, +is exactly what a `BEFORE` trigger cannot tolerate. + +Note the near-miss on the data flow. The proposed new values *do* exist before +the cascade runs — `row_ins_cascade_calc_update_vec()` builds +`cascade->update` at `row0ins.cc:1314`, 110 lines ahead of the cascade itself. +Nothing about the ordering of information blocks this. Supporting `BEFORE` +triggers on cascaded rows means either the restricted-subset design sketched +above, or hoisting cascade execution above the engine — the MySQL 9.6 route in +§2 — not another service call of the present shape. §6.4.2 records the main +counter-proposal and why it does not change that. + +#### 6.4.2 Considered and rejected: unwind-and-retry + +The obvious counter-proposal, recorded here because a reviewer will arrive at +it independently. Sketch: let `thd_fk_cascade_wanted()` return not just yes/no +but "a `BEFORE` trigger must run first"; on seeing that, InnoDB unwinds out of +the cascade back to the point of entry, releasing all latches; the server runs +the trigger with no latches held; the cascade then restarts from the +beginning. + +Two things genuinely favour it. + +*The latch problem does dissolve.* Unwinding to the engine-entry boundary is +one valid answer to §6.4.1's third blocker — the other being the restricted +trigger-body subset sketched there, which sidesteps the latches instead of +escaping them — and the shape is not foreign to InnoDB: `DB_LOCK_WAIT` is +already handled by `row_mysql_handle_errors()` with a `trx_savept_t` partial +rollback and a re-run of the query graph. + +*Trigger prelocking is already in place.* This would normally be fatal — a +trigger body needs its own tables and routines locked, and acquiring locks +mid-statement invites deadlock — but it is already handled. +`prepare_fk_prelocking_list()` adds every *modified* FK child with +`TL_FIRST_WRITE`; only read-only children are reduced to `OPEN_STUB` +(`table.h:2603`). So the child gets its own `handle_table()` pass +(`sql_base.cc:4160`), which calls `add_tables_and_routines_for_triggers()`, +and — since that pass runs `prepare_fk_prelocking_list()` again on the child — +the walk is transitive through grandchildren. The child's triggers are loaded +and everything their bodies touch is already in the prelocking set. + +This has been verified by instrumenting `handle_table()`: for a parent with a +cascade child, the child appears with `prelocking_placeholder = PRELOCK_FK`, a +write lock, and a loaded `TABLE::triggers`, and a grandchild reachable only +through the child appears the same way. There is no SQL-observable way to +assert it, so no test pins it. + +See §6.4.3 for the event-mapping defect this exposed. + +Four things sink it. + +**Retries are per child row, not per statement.** +`row_ins_foreign_check_on_constraint()` is called from the scan loop in +`row_ins_check_foreign_constraint()` (`row0ins.cc:1867`), once per matching +child record. N cascaded rows therefore means N unwind/replay cycles, each +redoing the parent row operation and every child cascaded before it, and +recursing into grandchildren — O(N²). Lock-wait retry tolerates this because +it is exceptional; a `BEFORE` trigger on a cascade child is the common case as +soon as it is supported at all. + +**The replay has an ordering paradox.** The trigger must run *after* the +rollback, or the rollback discards its writes; the replay then re-enters the +cascade and asks again. That requires memoising "already fired for this child +row" against a row identity that survives a rollback and replay. The failure +modes are an infinite loop or a trigger that fires twice. + +**The trigger's output still has nowhere to go.** A `BEFORE UPDATE` trigger +exists to rewrite `NEW`, but after the replay InnoDB rebuilds +`cascade->update` from the parent's new values (`row0ins.cc:1314`), discarding +it. The scheme would additionally need a channel making InnoDB derive the +update vector from a server-supplied image, an abort path for `SIGNAL`, and a +skip-this-child-but-continue mode for `skip_row_indicator`. Building the first +of those yields most of what is needed to drive the cascade from the server +anyway, at which point the retry machinery is redundant. + +**The trigger would observe the wrong state.** It runs after the unwind, so a +body reading the parent table sees the pre-statement state rather than what a +`BEFORE` trigger sees in a server-driven cascade. + +#### 6.4.3 Child trigger events: what the cascade does, not what the statement does + +`prepare_fk_prelocking_list()` used to propagate the *parent's* +`trg_event_map` to the child verbatim. That is right for `ON DELETE CASCADE`, +where a parent delete becomes a child delete, and for every `ON UPDATE` +action, which updates the child. It is wrong for **`ON DELETE SET NULL`** (and +`SET DEFAULT`): the child row is *updated*, not deleted, so the child was +prelocked carrying `TRG_EVENT_DELETE`. Its `UPDATE` triggers' tables and +routines were therefore never added to the prelocking set, and firing one +would have tripped the `DBUG_ASSERT` in +`Table_triggers_list::process_triggers()` (`sql_trigger.cc:2829`), which +requires the event bit to be present in `pos_in_table_list->trg_event_map`. + +The child's map is now derived from the cascade action: + +| parent event | FK action | child event | +|---|---|---| +| `DELETE` | `ON DELETE CASCADE` | `TRG_EVENT_DELETE` | +| `DELETE` | `ON DELETE SET NULL` / `SET DEFAULT` | `TRG_EVENT_UPDATE` | +| `UPDATE` | `ON UPDATE CASCADE` / `SET NULL` / `SET DEFAULT` | `TRG_EVENT_UPDATE` | +| either | `RESTRICT` / `NO ACTION` | none (read-only child, `TL_READ`) | + +A zero map is exactly the read-only case, so it also decides the lock type, +replacing the previous separate `fk_modifies_child()` test. + +One related gap closed with it: a child reachable through *several* +constraints undergoes the union of their actions — `ON DELETE CASCADE` on one +column and `ON DELETE SET NULL` on another both apply, deleting some rows and +updating others. `table_already_fk_prelocked()` skipped the second constraint, +leaving the first one's events to stand for both; it now returns the existing +entry so the events can be OR-ed into it. + +**No user-visible effect today**, because triggers still do not fire on +cascaded rows — this is groundwork for consumer 2, and a latent assertion +failure removed. What it does change is that a `SET NULL` child's `UPDATE` +triggers now contribute their tables and routines to the prelocking set, so +such statements take a slightly wider set of table locks than before. + +##### Salvageable: decide before entering the engine + +The detection half of the proposal stands on its own, without any unwinding. +Because modified FK children are prelocked *with their triggers loaded*, the +server can answer "does any reachable cascade child carry a `BEFORE` trigger?" +at statement start, before InnoDB is entered — by walking the prelocking list +for `PRELOCK_FK` entries with `TL_FIRST_WRITE` and a non-NULL +`TABLE::triggers`. No sentinel return, no replay, no latch exposure. That is +enough to refuse the statement or divert it to a server-driven path, instead +of today's behaviour of silently not firing the trigger. + +The same observation is already load-bearing elsewhere: `fk_cascade_stmt_capturable()` +(§6.3) uses exactly this walk of the FK prelocking set to decide binlog +eligibility for the whole statement up front. A trigger consumer would reuse +the scan and only change the predicate. + +### 6.5 Deferred, ordered logging + +Binlog events are **queued, not emitted inline**. The queue preserves *execution +order*. Emitting deletes inline would place every cascade delete ahead of every +deferred update within a statement, reordering events that touch the same row +and potentially making the replica apply an update to an already-deleted row. + +## 7. Lifecycle — flush and discard + +The queue is `THD::pending_cascade_binlog_row_events`, a +`Dynamic_array` holding the child `TABLE`, the +server's own copies of the before/after images, and an `is_delete` flag. The +`THD` owns it outright; no engine structure participates in its lifetime. +`THD::binlog_report_cascade_row()` copies the images into it and calls +`binlog_mark_fk_cascade_events()`, so that the originating statement's own +already-pending row event gets flagged too (§8). + +**Flush** (`THD::flush_pending_cascade_binlog()`): emits each queued event via +`handler::binlog_log_row()` with the table's bitmaps temporarily set to +`table->s->all_set`, choosing `Delete_rows_log_event` vs. +`Update_rows_log_event` from `is_delete`, and frees the record copies as it +goes. Temporary tables and entries whose `TABLE`/handler is gone are skipped +(their buffers freed). It is driven from two choke points: + +- `binlog_flush_pending_rows_event()` in `sql/log.cc` at **statement end** + (`stmt_end`), guarded by `thd->binlog_fk_cascade_events` and the feature + test; the pending event is re-read afterwards, since flushing the cascade + rows may have created or replaced it, before `STMT_END_F` is stamped; +- `ha_commit_trans()` in `sql/handler.cc` at **commit**, when `all || + thd->in_active_multi_stmt_transaction()`. + +**Discard** (`THD::discard_pending_cascade_binlog()`): frees the queued record +copies and empties the array without writing anything. It runs on: + +- `ha_rollback_trans()` — full/statement rollback (`sql/handler.cc`); +- `ha_rollback_to_savepoint()` — rollback to savepoint (events already flushed + into the binlog cache by earlier statements are handled separately, by the + binlog savepoint machinery truncating the cache); +- `~THD()` — backstop only, so that an undrained queue cannot leak. + +Discard is **unconditional** with respect to thread type — it must run for +applier threads too, since it only frees memory. Both helpers +(`flush_pending_cascade_binlog_for_thd()`, +`discard_pending_cascade_binlog_for_thd()`) are called under the feature test +`rpl_use_binlog_events_for_fk_cascade || WSREP_EMULATE_BINLOG(thd)`. + +### 7.1 Queue lifetime vs. `TABLE` lifetime + +A queued entry stores a raw `TABLE *`. Because the queue outlives the engine +transaction (it is reclaimed by the server, not by `trx_t::free()`), the +invariant that matters is that the queue is **drained before the statement +closes its tables**. It holds: in `mysql_execute_command()`'s `finish:` block, +`trans_commit_stmt()` / `trans_rollback_stmt()` run *before* +`close_thread_tables_for_query()`; and for a multi-statement transaction the +`in_active_multi_stmt_transaction()` arm of the `ha_commit_trans()` gate drains +per statement, likewise with tables still open. + +This is asserted rather than assumed — `mysql_execute_command()` carries a +`DBUG_ASSERT` immediately after `close_thread_tables_for_query()` that the +queue is empty at the top level (a substatement may legitimately leave rows +queued for the enclosing statement, since the draining commit/rollback is +itself under `! thd->in_sub_stmt`). The assert exists because the +`!table || !table->file` guard inside the flush loop can only catch a *closed* +table, not one that was freed and had its memory reused; a leftover entry would +otherwise be flushed by a later statement against a dangling pointer, silently. + +## 8. Event marking + +Three flags on `Rows_log_event` participate (`sql/log_event.h`): + +| Flag | Bit | Set on | Meaning | +|------|-----|--------|---------| +| `NO_FOREIGN_KEY_CHECKS_F` | 1 | every cascade-logged event | pre-existing flag; disables FK checks on apply | +| `FK_CASCADE_EVENTS_F` | 4 | every cascade-logged event (root + derived) | this statement's cascade rows were logged; suppress re-cascade | +| `FK_CASCADE_DERIVED_F` | 5 | derived (child) events only | distinguishes cascade-derived rows from the originating rows | + +Origin-side mechanism: + +- `THD::binlog_fk_cascade_events` (bool) is set by + `THD::binlog_mark_fk_cascade_events()` on the first cascade of a statement; it + also stamps the currently-pending row events. It is reset in + `reset_binlog_for_next_statement()`. +- `Event_log::prepare_pending_rows_event()` stamps every newly created event + with `FK_CASCADE_EVENTS_F | NO_FOREIGN_KEY_CHECKS_F` while that THD flag is on. +- `THD::binlog_fk_cascade_derived` (bool) is turned on only around the flush + loop (`binlog_begin_fk_cascade_derived()` / `binlog_end_fk_cascade_derived()` + in `THD::flush_pending_cascade_binlog()`), so events created during + the flush additionally get `FK_CASCADE_DERIVED_F`. The originating (parent) + event is created *outside* the flush loop and therefore stays underived. + +Result: the **root** event carries `{EVENTS_F, NO_FK_CHECKS_F}`; each **derived** +event carries `{EVENTS_F, DERIVED_F, NO_FK_CHECKS_F}`. + +## 9. Replica side — apply + +`Rows_log_event::do_apply_event()` (`sql/log_event_server.cc`): if the event +carries `NO_FOREIGN_KEY_CHECKS_F` **or** `FK_CASCADE_EVENTS_F`, it sets +`OPTION_NO_FOREIGN_KEY_CHECKS`, which InnoDB maps to `trx->check_foreigns = +false` (`ha_innodb.cc`). With FK checks off, `row_ins_check_foreign_constraint()` +returns early and `row_ins_foreign_check_on_constraint()` is never reached — the +replica does **not** re-run the cascade. It applies the explicit parent and +child row events directly, in log order. + +No slave-thread guards (`!thd->rgi_slave` / `!thd_is_slave`) are used on the +capture or flush paths. They were removed as redundant: the flag-driven +suppression above already prevents an applier from re-cascading, so capture is +unreachable there for cascade-logged transactions. + +## 10. Backward compatibility (older replica, strict mode) + +An older MariaDB replica does not understand `FK_CASCADE_EVENTS_F`/ +`FK_CASCADE_DERIVED_F` and would ignore them. It **does** understand the ancient +`NO_FOREIGN_KEY_CHECKS_F`. Because the origin sets that flag on every +cascade-logged event, an old replica: + +1. applies the parent event with FK checks disabled → does **not** re-cascade; +2. applies the derived child events as the sole source of child changes. + +No collision occurs and the data is correct **even under strict +`slave_exec_mode`**. Had `NO_FOREIGN_KEY_CHECKS_F` not been set, an old replica +would re-cascade *and* apply the derived events, colliding on the same keys → +`HA_ERR_KEY_NOT_FOUND` and a stopped replica in strict mode (tolerated only in +idempotent mode). + +A newer replica that wants to re-execute the cascade instead of applying the +derived events can still recognise these events via the new flags and override +`NO_FOREIGN_KEY_CHECKS_F` (see §12). + +### 10.1 Old-replica emulation for testing + +Standard mtr runs every server from one build, so a genuine "old binary as +replica" test cannot run in the default suite. To still exercise the mechanism +in-tree, a debug-only injection point emulates a pre-MDEV-38243 replica: + +- `Rows_log_event::do_apply_event()` computes a local `fk_cascade_events` + (initialised from `get_flags(FK_CASCADE_EVENTS_F)`), and + `DBUG_EXECUTE_IF("rpl_emulate_old_slave_fk_cascade", fk_cascade_events= false)` + forces it off. The FK-check decision then depends **only** on + `NO_FOREIGN_KEY_CHECKS_F` — exactly how an old server behaves. + +The test `rpl_fk_cascade_binlog_row_old_slave` enables this keyword on a +strict-mode replica and confirms that a feature-ON origin's `CASCADE` and +`SET NULL` transactions apply with `Last_SQL_Errno=0` and correct data. Because +the emulated replica ignores `FK_CASCADE_EVENTS_F`, a passing run proves the +`NO_FOREIGN_KEY_CHECKS_F` stamp alone suffices; equally, if that stamp were ever +dropped, this test would fail (the emulated replica would re-cascade and +collide). + +## 11. Known limitations and edge cases + +- **Applier capture is a no-op (by design/limitation).** The capture requires + the child table to be open via FK prelocking (`PRELOCK_FK`). A row-based + applier opens only the tables named in the events, not prelocked FK children, + so `find_fk_open_table()` returns NULL on an applier. Therefore, with the + feature enabled **only on a replica** (origin OFF), the replica re-cascades + the classic way and its own binary log contains only parent events — the + option is effectively inert on the applier path. This is pinned by + `rpl_fk_cascade_binlog_row_slave_option`. +- **Row format only.** Statement-based logging is unaffected. +- **Eligibility is all-or-nothing per statement.** A single ineligible child + — no PK plus a virtual column in a key — disables capture for every child of + that statement, not just itself (§6.3); the statement then falls back to the + classic replica-side cascade. Conservative in two ways: the FK prelocking set + covers children the statement *could* cascade into, not only those it + actually does, and the check does not apply at all under + `WSREP_EMULATE_BINLOG`. +- **Only one consumer.** Triggers and CHECK constraints on cascaded child rows + still do not fire; the service is shaped for them (§6.4) but nothing is + wired up. `AFTER` triggers and CHECK constraints are reachable from here; + `BEFORE` triggers are not reachable from this call site at any API width + (§6.4.1). +- **Only InnoDB implements the engine side.** `handler::fk_cascade_fetch_row()` + defaults to `HA_ERR_WRONG_COMMAND`, so any other engine that starts calling + the service must override it. +- **Observability.** `mysqlbinlog` prints only `STMT_END_F` in its verbose + header; the FK cascade flags are not surfaced there. They are observable only + through apply behaviour (or a future print extension). + +## 12. Future work — "applier decides" + +The `FK_CASCADE_DERIVED_F` marking is groundwork for an optional mode letting an +applier choose between applying the derived events (default) and re-executing +the cascade. Sketch: + +- Add a replica variable, e.g. `slave_fk_cascade_mode = {APPLY_EVENTS | + EXECUTE_CASCADE}`. +- `APPLY_EVENTS` (default): current behaviour. +- `EXECUTE_CASCADE`: for events carrying `FK_CASCADE_EVENTS_F`, do **not** honour + `NO_FOREIGN_KEY_CHECKS_F` on the *root* events (those without + `FK_CASCADE_DERIVED_F`), so InnoDB re-cascades; and **skip** the events + carrying `FK_CASCADE_DERIVED_F`. + +This reintroduces cascade non-determinism deliberately, so it is a +compatibility/fallback knob, not a routine mode. + +## 13. Code map + +| Area | File(s) | Key symbols | +|------|---------|-------------| +| Feature var | `sql/sys_vars.cc`, `sql/sql_class.h` | `rpl_use_binlog_events_for_fk_cascade`, `rpl_use_binlog_events_for_fk_cascade_check()` | +| SE ↔ server service | `include/mysql/service_thd_fk_cascade.h` | `thd_fk_cascade_wanted()`, `thd_fk_cascade_capture()`, `thd_fk_cascade_row()`, `thd_fk_cascade_abort()`, `FK_CASCADE_IMAGE_*`, `FK_CASCADE_ACTION_*` | +| Service implementation, consumer dispatch | `sql/sql_class.cc` | `fk_cascade_table_eligible()`, `fk_cascade_stmt_capturable()`, `fk_cascade_begin_full_row_image()`, `fk_cascade_end_full_row_image()`, the four `thd_fk_cascade_*()` | +| Statement-level eligibility cache | `sql/sql_class.h` | `THD::fk_cascade_stmt_query_id`, `THD::fk_cascade_stmt_ok` | +| FK prelocking set | `sql/sql_base.{h,cc}` | `prepare_fk_prelocking_list()` (child `trg_event_map` derived from the cascade action, §6.4.3), `table_already_fk_prelocked()` (returns the existing entry so events can be merged) | +| In-flight images | `sql/sql_class.h`, `sql/sql_class.cc` | `THD::fk_cascade_before_image`, `fk_cascade_after_image`, `fk_cascade_free_images()` | +| Handler hook | `sql/handler.h` | `handler::fk_cascade_fetch_row()` (default `HA_ERR_WRONG_COMMAND`) | +| THD queue, flush / discard, marking | `sql/sql_class.{h,cc}` | `Cascade_binlog_row_event`, `pending_cascade_binlog_row_events`, `binlog_report_cascade_row()`, `flush_pending_cascade_binlog()`, `discard_pending_cascade_binlog()`, `binlog_fk_cascade_events`, `binlog_fk_cascade_derived`, `binlog_mark_fk_cascade_events()`, `binlog_begin/end_fk_cascade_derived()` | +| Engine capture driver | `storage/innobase/row/row0ins.cc` | `row_ins_foreign_check_on_constraint()`, `row_ins_find_open_table_for_cascade()` | +| Engine record conversion | `storage/innobase/handler/ha_innodb.{h,cc}` | `ha_innobase::fk_cascade_set_cursor()`, `ha_innobase::fk_cascade_fetch_row()`, `m_fk_cascade_rec`, `m_fk_cascade_index`, `rebuild_/reset_template_for_cascade_binlog_row_image()` | +| Drain points | `sql/handler.{h,cc}`, `sql/log.cc`, `sql/sql_parse.cc` | `flush_pending_cascade_binlog_for_thd()` / `discard_pending_cascade_binlog_for_thd()` called from `ha_commit_trans`/`ha_rollback_trans`/`ha_rollback_to_savepoint`; `binlog_flush_pending_rows_event()`; drained-queue `DBUG_ASSERT` in `mysql_execute_command()` | +| Event flags / apply | `sql/log_event.h`, `sql/log_event_server.cc`, `sql/log.cc` | `FK_CASCADE_EVENTS_F`, `FK_CASCADE_DERIVED_F`, `do_apply_event()`, `prepare_pending_rows_event()`, `binlog_flush_pending_rows_event()` | +| wsrep | `sql/service_wsrep.cc`, `include/mysql/service_wsrep.h` | `wsrep_emulate_binlog()`, `WSREP_EMULATE_BINLOG()` | +| Test aid | `sql/log_event_server.cc` (`do_apply_event()`) | `DBUG_EXECUTE_IF("rpl_emulate_old_slave_fk_cascade", …)` — emulate a pre-MDEV-38243 replica (see §10.1) | + +## 14. Tests (`mysql-test/suite/rpl/`) + +- **`rpl_fk_cascade_binlog_row`** — feature OFF vs ON; asserts derived child + row events are absent (OFF) / present (ON) in the origin binlog and replica + data is correct. +- **`rpl_fk_cascade_binlog_row_ordering`** — event ordering of interleaved + cascade delete/update. +- **`rpl_fk_cascade_binlog_row_rollback`** — queued events discarded on + rollback / rollback-to-savepoint; nothing spurious is logged. +- **`rpl_fk_set_null_binlog_row`** — `SET NULL` cascade capture. +- **`rpl_fk_cascade_binlog_row_slave_option`** — origin OFF / replica ON: + replication stays correct and the replica's binlog contains only parent + events (pins the applier no-op of §11). +- **`rpl_fk_cascade_binlog_row_mixed_eligibility`** — a parent with one + eligible and one ineligible child: asserts that no derived events are + written and the replica keeps no orphan (pins the statement-level decision + of §6.3). +- **`rpl_fk_cascade_binlog_row_old_slave`** — cross-version compatibility + (§10): a strict-mode replica emulating an older MariaDB (via the + `rpl_emulate_old_slave_fk_cascade` debug keyword) applies a feature-ON + origin's `CASCADE` / `SET NULL` transactions with no error and correct data. + Debug build only. + +**Not yet covered:** + +- Cross-version replication against a *real* older `mariadbd` binary. The apply + mechanism itself is covered in-tree by `rpl_fk_cascade_binlog_row_old_slave` + (§10.1). +- The Galera path (`WSREP_EMULATE_BINLOG`), which §4 singles out as the primary + motivation. The `suite/galera` FK tests (`galera_fk_cascade_delete`, + `galera_fk_cascade_delete_debug`, `galera_fk_cascade_update`) never set + `rpl_use_binlog_events_for_fk_cascade`, so they exercise the classic + re-cascade path only; no test in any suite covers cascade capture under + writeset emulation. + +All six tests were written against the pre-refactoring interfaces (§6.1 changed +the SE ↔ server boundary twice since) but pass unchanged on the current tree. diff --git a/include/mysql/service_thd_fk_cascade.h b/include/mysql/service_thd_fk_cascade.h new file mode 100644 index 0000000000000..e2ebba8cae3eb --- /dev/null +++ b/include/mysql/service_thd_fk_cascade.h @@ -0,0 +1,103 @@ +/* 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)) + { + + thd_fk_cascade_capture(thd, child_table, FK_CASCADE_IMAGE_BEFORE); + + + + + 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(). +*/ +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 diff --git a/include/mysql/service_wsrep.h b/include/mysql/service_wsrep.h index 8c001ca147063..9d0114ac04f0e 100644 --- a/include/mysql/service_wsrep.h +++ b/include/mysql/service_wsrep.h @@ -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 @@ -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 */ diff --git a/mysql-test/main/mysqld--help.result b/mysql-test/main/mysqld--help.result index 15ffb8cbcf7f6..0bc6aeb577955 100644 --- a/mysql-test/main/mysqld--help.result +++ b/mysql-test/main/mysqld--help.result @@ -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 @@ -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 diff --git a/mysql-test/suite/rpl/r/rpl_fk_cascade_binlog_row.result b/mysql-test/suite/rpl/r/rpl_fk_cascade_binlog_row.result new file mode 100644 index 0000000000000..5eb0ef025a8bf --- /dev/null +++ b/mysql-test/suite/rpl/r/rpl_fk_cascade_binlog_row.result @@ -0,0 +1,85 @@ +include/master-slave.inc +[connection master] +SET @old_binlog_format := @@session.binlog_format; +SET @old_default_storage_engine := @@session.default_storage_engine; +SET @old_rpl_use_binlog_events_for_fk_cascade := @@session.rpl_use_binlog_events_for_fk_cascade; +SET SESSION default_storage_engine=InnoDB; +SET SESSION binlog_format=ROW; +SET SESSION rpl_use_binlog_events_for_fk_cascade=0; +CREATE TABLE p ( +id INT PRIMARY KEY, +v INT +) ENGINE=InnoDB; +CREATE TABLE c ( +id INT PRIMARY KEY, +pid INT, +v INT, +KEY(pid), +CONSTRAINT fk FOREIGN KEY (pid) REFERENCES p(id) +ON DELETE CASCADE +ON UPDATE CASCADE +) ENGINE=InnoDB; +INSERT INTO p VALUES (1, 10); +INSERT INTO c VALUES (100, 1, 20); +UPDATE p SET id=2 WHERE id=1; +connection slave; +connection slave; +SELECT pid FROM c ORDER BY id; +pid +2 +connection master; +DELETE FROM p WHERE id=2; +connection slave; +connection slave; +SELECT COUNT(*) FROM p; +COUNT(*) +0 +SELECT COUNT(*) FROM c; +COUNT(*) +0 +connection master; +FLUSH BINARY LOGS; +NOT FOUND /### UPDATE `test`.`c`/ in fk_cascade_binlog_row_off.sql +NOT FOUND /### DELETE FROM `test`.`c`/ in fk_cascade_binlog_row_off.sql +DROP TABLE c, p; +SET SESSION rpl_use_binlog_events_for_fk_cascade=1; +CREATE TABLE p ( +id INT PRIMARY KEY, +v INT +) ENGINE=InnoDB; +CREATE TABLE c ( +id INT PRIMARY KEY, +pid INT, +v INT, +KEY(pid), +CONSTRAINT fk FOREIGN KEY (pid) REFERENCES p(id) +ON DELETE CASCADE +ON UPDATE CASCADE +) ENGINE=InnoDB; +INSERT INTO p VALUES (1, 10); +INSERT INTO c VALUES (100, 1, 20); +UPDATE p SET id=2 WHERE id=1; +connection slave; +connection slave; +SELECT pid FROM c ORDER BY id; +pid +2 +connection master; +DELETE FROM p WHERE id=2; +connection slave; +connection slave; +SELECT COUNT(*) FROM p; +COUNT(*) +0 +SELECT COUNT(*) FROM c; +COUNT(*) +0 +connection master; +FLUSH BINARY LOGS; +FOUND 1 /### UPDATE `test`.`c`/ in fk_cascade_binlog_row_on.sql +FOUND 1 /### DELETE FROM `test`.`c`/ in fk_cascade_binlog_row_on.sql +DROP TABLE c, p; +SET SESSION default_storage_engine=@old_default_storage_engine; +SET SESSION binlog_format=@old_binlog_format; +SET SESSION rpl_use_binlog_events_for_fk_cascade=@old_rpl_use_binlog_events_for_fk_cascade; +include/rpl_end.inc diff --git a/mysql-test/suite/rpl/r/rpl_fk_cascade_binlog_row_old_slave.result b/mysql-test/suite/rpl/r/rpl_fk_cascade_binlog_row_old_slave.result new file mode 100644 index 0000000000000..6da08373b8d3d --- /dev/null +++ b/mysql-test/suite/rpl/r/rpl_fk_cascade_binlog_row_old_slave.result @@ -0,0 +1,53 @@ +include/master-slave.inc +[connection master] +connection slave; +include/stop_slave.inc +SET @old_dbug= @@GLOBAL.debug_dbug; +SET @old_exec_mode= @@GLOBAL.slave_exec_mode; +SET GLOBAL slave_exec_mode= STRICT; +SET GLOBAL debug_dbug= "+d,rpl_emulate_old_slave_fk_cascade"; +include/start_slave.inc +connection master; +SET SESSION default_storage_engine=InnoDB; +SET SESSION binlog_format=ROW; +SET SESSION rpl_use_binlog_events_for_fk_cascade=1; +CREATE TABLE p (id INT PRIMARY KEY, v INT) ENGINE=InnoDB; +CREATE TABLE c ( +id INT PRIMARY KEY, pid INT, v INT, KEY(pid), +CONSTRAINT fk_c FOREIGN KEY (pid) REFERENCES p(id) +ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB; +CREATE TABLE cn ( +id INT PRIMARY KEY, pid INT, v INT, KEY(pid), +CONSTRAINT fk_cn 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 c VALUES (100,1,1000),(200,2,2000),(300,3,3000); +INSERT INTO cn VALUES (100,1,1000),(200,2,2000),(300,3,3000); +UPDATE p SET id=11 WHERE id=1; +DELETE FROM p WHERE id=2; +connection slave; +connection slave; +# Last_SQL_Errno = 0 (expected 0) +SELECT * FROM p ORDER BY id; +id v +3 30 +11 10 +SELECT * FROM c ORDER BY id; +id pid v +100 11 1000 +300 3 3000 +SELECT * FROM cn ORDER BY id; +id pid v +100 NULL 1000 +200 NULL 2000 +300 3 3000 +connection slave; +include/stop_slave.inc +SET GLOBAL debug_dbug= @old_dbug; +SET GLOBAL slave_exec_mode= @old_exec_mode; +include/start_slave.inc +connection master; +DROP TABLE c, cn, p; +include/rpl_end.inc diff --git a/mysql-test/suite/rpl/r/rpl_fk_cascade_binlog_row_ordering.result b/mysql-test/suite/rpl/r/rpl_fk_cascade_binlog_row_ordering.result new file mode 100644 index 0000000000000..40000d8c75e2c --- /dev/null +++ b/mysql-test/suite/rpl/r/rpl_fk_cascade_binlog_row_ordering.result @@ -0,0 +1,37 @@ +include/master-slave.inc +[connection master] +SET @old_binlog_format := @@session.binlog_format; +SET @old_rpl_use_binlog_events_for_fk_cascade := @@session.rpl_use_binlog_events_for_fk_cascade; +SET SESSION binlog_format=ROW; +SET SESSION rpl_use_binlog_events_for_fk_cascade=1; +CREATE TABLE p ( +id INT PRIMARY KEY +) ENGINE=InnoDB; +CREATE TABLE c ( +id INT PRIMARY KEY, +a INT, +b INT, +KEY(a), +KEY(b), +CONSTRAINT fk_a FOREIGN KEY (a) REFERENCES p(id) ON DELETE SET NULL, +CONSTRAINT fk_b FOREIGN KEY (b) REFERENCES p(id) ON DELETE CASCADE +) ENGINE=InnoDB; +INSERT INTO p VALUES (1); +INSERT INTO c VALUES (10, 1, 1); +connection slave; +connection master; +DELETE FROM p WHERE id=1; +# master: child row cascade-deleted +SELECT * FROM c; +id a b +connection slave; +connection slave; +# slave must match master (row deleted); a wrong DELETE-before-UPDATE +# ordering would have stopped the SQL thread on the UPDATE event +SELECT * FROM c; +id a b +connection master; +DROP TABLE c, p; +SET SESSION binlog_format=@old_binlog_format; +SET SESSION rpl_use_binlog_events_for_fk_cascade=@old_rpl_use_binlog_events_for_fk_cascade; +include/rpl_end.inc diff --git a/mysql-test/suite/rpl/r/rpl_fk_cascade_binlog_row_rollback.result b/mysql-test/suite/rpl/r/rpl_fk_cascade_binlog_row_rollback.result new file mode 100644 index 0000000000000..25eee590001fa --- /dev/null +++ b/mysql-test/suite/rpl/r/rpl_fk_cascade_binlog_row_rollback.result @@ -0,0 +1,140 @@ +include/master-slave.inc +[connection master] +SET @old_binlog_format := @@session.binlog_format; +SET @old_default_storage_engine := @@session.default_storage_engine; +SET @old_rpl_use_binlog_events_for_fk_cascade := @@session.rpl_use_binlog_events_for_fk_cascade; +SET SESSION default_storage_engine=InnoDB; +SET SESSION binlog_format=ROW; +SET SESSION rpl_use_binlog_events_for_fk_cascade=1; +CREATE TABLE p ( +id INT PRIMARY KEY, +v INT +) ENGINE=InnoDB; +CREATE TABLE c ( +id INT PRIMARY KEY, +pid INT, +v INT, +KEY(pid), +CONSTRAINT fk FOREIGN KEY (pid) REFERENCES p(id) +ON DELETE CASCADE +ON UPDATE CASCADE +) ENGINE=InnoDB; +INSERT INTO p VALUES (1, 10); +INSERT INTO c VALUES (100, 1, 20); +connection slave; +connection master; +# +# Phase 1: full transaction ROLLBACK +# +FLUSH BINARY LOGS; +BEGIN; +UPDATE p SET id=2 WHERE id=1; +ROLLBACK; +# master: parent and child unchanged +SELECT * FROM p ORDER BY id; +id v +1 10 +SELECT * FROM c ORDER BY id; +id pid v +100 1 20 +connection slave; +connection slave; +# slave: child unchanged +SELECT * FROM c ORDER BY id; +id pid v +100 1 20 +connection master; +FLUSH BINARY LOGS; +# no cascade child row event must be present +NOT FOUND /### UPDATE `test`.`c`/ in fk_cascade_rollback_full.sql +# +# Phase 2: ROLLBACK TO SAVEPOINT (surviving work before the savepoint) +# +FLUSH BINARY LOGS; +BEGIN; +INSERT INTO p VALUES (3, 30); +SAVEPOINT sp1; +UPDATE p SET id=2 WHERE id=1; +ROLLBACK TO SAVEPOINT sp1; +COMMIT; +# master: cascade undone, INSERT before savepoint kept +SELECT * FROM p ORDER BY id; +id v +1 10 +3 30 +SELECT * FROM c ORDER BY id; +id pid v +100 1 20 +connection slave; +connection slave; +# slave: child unchanged, parent has surviving row +SELECT * FROM p ORDER BY id; +id v +1 10 +3 30 +SELECT * FROM c ORDER BY id; +id pid v +100 1 20 +connection master; +FLUSH BINARY LOGS; +# cascade child row event must be absent +NOT FOUND /### UPDATE `test`.`c`/ in fk_cascade_rollback_sp.sql +# surviving INSERT before the savepoint must be replicated +FOUND 1 /### INSERT INTO `test`.`p`/ in fk_cascade_rollback_sp.sql +DROP TABLE c, p; +# +# Phase 3: statement rollback, transaction continues and commits +# +CREATE TABLE p ( +id INT PRIMARY KEY, +u INT, +UNIQUE KEY uq_u(u) +) ENGINE=InnoDB; +CREATE TABLE c ( +cid INT PRIMARY KEY, +pid INT, +KEY(pid), +CONSTRAINT fk FOREIGN KEY (pid) REFERENCES p(id) +ON UPDATE CASCADE +) ENGINE=InnoDB; +INSERT INTO p VALUES (1, 100), (2, 200); +INSERT INTO c VALUES (11, 1); +connection slave; +connection master; +FLUSH BINARY LOGS; +BEGIN; +UPDATE p SET id=id+10, u=100 WHERE id IN (1,2) ORDER BY id; +ERROR 23000: Duplicate entry '100' for key 'uq_u' +INSERT INTO p VALUES (9, 900); +COMMIT; +# master: failed UPDATE rolled back, INSERT committed +SELECT * FROM p ORDER BY id; +id u +1 100 +2 200 +9 900 +SELECT * FROM c ORDER BY cid; +cid pid +11 1 +connection slave; +connection slave; +# slave: child unchanged, parent has committed row +SELECT * FROM p ORDER BY id; +id u +1 100 +2 200 +9 900 +SELECT * FROM c ORDER BY cid; +cid pid +11 1 +connection master; +FLUSH BINARY LOGS; +# discarded cascade child row event must be absent +NOT FOUND /### UPDATE `test`.`c`/ in fk_cascade_rollback_stmt.sql +# committed INSERT must be replicated +FOUND 1 /### INSERT INTO `test`.`p`/ in fk_cascade_rollback_stmt.sql +DROP TABLE c, p; +SET SESSION default_storage_engine=@old_default_storage_engine; +SET SESSION binlog_format=@old_binlog_format; +SET SESSION rpl_use_binlog_events_for_fk_cascade=@old_rpl_use_binlog_events_for_fk_cascade; +include/rpl_end.inc diff --git a/mysql-test/suite/rpl/r/rpl_fk_cascade_binlog_row_slave_option.result b/mysql-test/suite/rpl/r/rpl_fk_cascade_binlog_row_slave_option.result new file mode 100644 index 0000000000000..9fa0419649b6e --- /dev/null +++ b/mysql-test/suite/rpl/r/rpl_fk_cascade_binlog_row_slave_option.result @@ -0,0 +1,52 @@ +include/master-slave.inc +[connection master] +connection master; +SET SESSION default_storage_engine=InnoDB; +SET SESSION binlog_format=ROW; +SELECT @@session.rpl_use_binlog_events_for_fk_cascade; +@@session.rpl_use_binlog_events_for_fk_cascade +0 +CREATE TABLE p ( +id INT PRIMARY KEY, +v INT +) ENGINE=InnoDB; +CREATE TABLE c ( +id INT PRIMARY KEY, +pid INT, +v INT, +KEY(pid), +CONSTRAINT fk FOREIGN KEY (pid) REFERENCES p(id) +ON DELETE CASCADE +ON UPDATE CASCADE +) ENGINE=InnoDB; +INSERT INTO p VALUES (1, 10); +INSERT INTO c VALUES (100, 1, 20); +connection slave; +connection slave; +include/stop_slave.inc +SET GLOBAL rpl_use_binlog_events_for_fk_cascade=1; +include/start_slave.inc +connection master; +UPDATE p SET id=2 WHERE id=1; +DELETE FROM p WHERE id=2; +connection slave; +connection slave; +SELECT COUNT(*) FROM p; +COUNT(*) +0 +SELECT COUNT(*) FROM c; +COUNT(*) +0 +FLUSH BINARY LOGS; +FOUND 1 /### DELETE FROM `test`.`p`/ in fk_cascade_slave_option.sql +NOT FOUND /### DELETE FROM `test`.`c`/ in fk_cascade_slave_option.sql +NOT FOUND /### UPDATE `test`.`c`/ in fk_cascade_slave_option.sql +connection slave; +include/stop_slave.inc +SET GLOBAL rpl_use_binlog_events_for_fk_cascade=0; +include/start_slave.inc +connection master; +DROP TABLE c, p; +connection slave; +connection master; +include/rpl_end.inc diff --git a/mysql-test/suite/rpl/r/rpl_fk_set_null_binlog_row.result b/mysql-test/suite/rpl/r/rpl_fk_set_null_binlog_row.result new file mode 100644 index 0000000000000..43a6c65f91b69 --- /dev/null +++ b/mysql-test/suite/rpl/r/rpl_fk_set_null_binlog_row.result @@ -0,0 +1,61 @@ +include/master-slave.inc +[connection master] +SET @old_binlog_format := @@session.binlog_format; +SET @old_default_storage_engine := @@session.default_storage_engine; +SET @old_rpl_use_binlog_events_for_fk_cascade := @@session.rpl_use_binlog_events_for_fk_cascade; +SET SESSION default_storage_engine=InnoDB; +SET SESSION binlog_format=ROW; +SET SESSION rpl_use_binlog_events_for_fk_cascade=0; +CREATE TABLE p ( +id INT PRIMARY KEY, +v INT +) ENGINE=InnoDB; +CREATE TABLE c ( +id INT PRIMARY KEY, +pid INT NULL, +v INT, +KEY(pid), +CONSTRAINT fk FOREIGN KEY (pid) REFERENCES p(id) +ON DELETE SET NULL +ON UPDATE SET NULL +) ENGINE=InnoDB; +INSERT INTO p VALUES (1, 10); +INSERT INTO c VALUES (100, 1, 20); +UPDATE p SET id=2 WHERE id=1; +connection slave; +SELECT pid FROM c ORDER BY id; +pid +NULL +connection master; +FLUSH BINARY LOGS; +NOT FOUND /### UPDATE `test`.`c`/ in fk_set_null_binlog_row_off.sql +DROP TABLE c, p; +SET SESSION rpl_use_binlog_events_for_fk_cascade=1; +CREATE TABLE p ( +id INT PRIMARY KEY, +v INT +) ENGINE=InnoDB; +CREATE TABLE c ( +id INT PRIMARY KEY, +pid INT NULL, +v INT, +KEY(pid), +CONSTRAINT fk FOREIGN KEY (pid) REFERENCES p(id) +ON DELETE SET NULL +ON UPDATE SET NULL +) ENGINE=InnoDB; +INSERT INTO p VALUES (1, 10); +INSERT INTO c VALUES (100, 1, 20); +UPDATE p SET id=2 WHERE id=1; +connection slave; +SELECT pid FROM c ORDER BY id; +pid +NULL +connection master; +FLUSH BINARY LOGS; +FOUND 1 /### UPDATE `test`.`c`/ in fk_set_null_binlog_row_on.sql +DROP TABLE c, p; +SET SESSION default_storage_engine=@old_default_storage_engine; +SET SESSION binlog_format=@old_binlog_format; +SET SESSION rpl_use_binlog_events_for_fk_cascade=@old_rpl_use_binlog_events_for_fk_cascade; +include/rpl_end.inc diff --git a/mysql-test/suite/rpl/t/rpl_fk_cascade_binlog_row.test b/mysql-test/suite/rpl/t/rpl_fk_cascade_binlog_row.test new file mode 100644 index 0000000000000..73d083a66dda5 --- /dev/null +++ b/mysql-test/suite/rpl/t/rpl_fk_cascade_binlog_row.test @@ -0,0 +1,133 @@ +--source include/have_innodb.inc +--source include/have_log_bin.inc +--source include/have_binlog_format_row.inc + +--source include/master-slave.inc + +SET @old_binlog_format := @@session.binlog_format; +SET @old_default_storage_engine := @@session.default_storage_engine; +SET @old_rpl_use_binlog_events_for_fk_cascade := @@session.rpl_use_binlog_events_for_fk_cascade; + +SET SESSION default_storage_engine=InnoDB; +SET SESSION binlog_format=ROW; + +# Phase A: feature OFF (no explicit FK cascade row events in binlog) +SET SESSION rpl_use_binlog_events_for_fk_cascade=0; + +CREATE TABLE p ( + id INT PRIMARY KEY, + v INT +) ENGINE=InnoDB; + +CREATE TABLE c ( + id INT PRIMARY KEY, + pid INT, + v INT, + KEY(pid), + CONSTRAINT fk FOREIGN KEY (pid) REFERENCES p(id) + ON DELETE CASCADE + ON UPDATE CASCADE +) ENGINE=InnoDB; + +INSERT INTO p VALUES (1, 10); +INSERT INTO c VALUES (100, 1, 20); + +UPDATE p SET id=2 WHERE id=1; + +sync_slave_with_master; +connection slave; +SELECT pid FROM c ORDER BY id; + +connection master; +DELETE FROM p WHERE id=2; + +sync_slave_with_master; +connection slave; +SELECT COUNT(*) FROM p; +SELECT COUNT(*) FROM c; + +connection master; + +--let $binlog = query_get_value(SHOW MASTER STATUS, File, 1) +--let $datadir = `SELECT @@datadir` + +FLUSH BINARY LOGS; + +--exec $MYSQL_BINLOG --verbose --verbose --base64-output=DECODE-ROWS $datadir/$binlog > $MYSQLTEST_VARDIR/tmp/fk_cascade_binlog_row_off.sql + +--let SEARCH_PATTERN= ### UPDATE `test`.`c` +--let SEARCH_FILE= $MYSQLTEST_VARDIR/tmp/fk_cascade_binlog_row_off.sql +--let SEARCH_ABORT= FOUND +--source include/search_pattern_in_file.inc + +--let SEARCH_PATTERN= ### DELETE FROM `test`.`c` +--let SEARCH_FILE= $MYSQLTEST_VARDIR/tmp/fk_cascade_binlog_row_off.sql +--let SEARCH_ABORT= FOUND +--source include/search_pattern_in_file.inc + +DROP TABLE c, p; + +# Phase B: feature ON (explicit FK cascade row events in binlog) +SET SESSION rpl_use_binlog_events_for_fk_cascade=1; + +CREATE TABLE p ( + id INT PRIMARY KEY, + v INT +) ENGINE=InnoDB; + +CREATE TABLE c ( + id INT PRIMARY KEY, + pid INT, + v INT, + KEY(pid), + CONSTRAINT fk FOREIGN KEY (pid) REFERENCES p(id) + ON DELETE CASCADE + ON UPDATE CASCADE +) ENGINE=InnoDB; + +INSERT INTO p VALUES (1, 10); +INSERT INTO c VALUES (100, 1, 20); + +UPDATE p SET id=2 WHERE id=1; + +sync_slave_with_master; +connection slave; +SELECT pid FROM c ORDER BY id; + +connection master; +DELETE FROM p WHERE id=2; + +sync_slave_with_master; +connection slave; +SELECT COUNT(*) FROM p; +SELECT COUNT(*) FROM c; + +connection master; + +--let $binlog = query_get_value(SHOW MASTER STATUS, File, 1) +--let $datadir = `SELECT @@datadir` + +FLUSH BINARY LOGS; + +--exec $MYSQL_BINLOG --verbose --verbose --base64-output=DECODE-ROWS $datadir/$binlog > $MYSQLTEST_VARDIR/tmp/fk_cascade_binlog_row_on.sql + +--let SEARCH_PATTERN= ### UPDATE `test`.`c` +--let SEARCH_FILE= $MYSQLTEST_VARDIR/tmp/fk_cascade_binlog_row_on.sql +--let SEARCH_ABORT= NOT FOUND|FOUND 0|FOUND [2-9] +--source include/search_pattern_in_file.inc + +--let SEARCH_PATTERN= ### DELETE FROM `test`.`c` +--let SEARCH_FILE= $MYSQLTEST_VARDIR/tmp/fk_cascade_binlog_row_on.sql +--let SEARCH_ABORT= NOT FOUND|FOUND 0|FOUND [2-9] +--source include/search_pattern_in_file.inc + +DROP TABLE c, p; + +SET SESSION default_storage_engine=@old_default_storage_engine; +SET SESSION binlog_format=@old_binlog_format; +SET SESSION rpl_use_binlog_events_for_fk_cascade=@old_rpl_use_binlog_events_for_fk_cascade; + +--remove_file $MYSQLTEST_VARDIR/tmp/fk_cascade_binlog_row_off.sql +--remove_file $MYSQLTEST_VARDIR/tmp/fk_cascade_binlog_row_on.sql + +--source include/rpl_end.inc diff --git a/mysql-test/suite/rpl/t/rpl_fk_cascade_binlog_row_old_slave.test b/mysql-test/suite/rpl/t/rpl_fk_cascade_binlog_row_old_slave.test new file mode 100644 index 0000000000000..cd3a0af6e476d --- /dev/null +++ b/mysql-test/suite/rpl/t/rpl_fk_cascade_binlog_row_old_slave.test @@ -0,0 +1,84 @@ +--source include/have_innodb.inc +--source include/have_log_bin.inc +--source include/have_binlog_format_row.inc +# The old-replica emulation relies on a DBUG_EXECUTE_IF hook, so a debug build +# is required. +--source include/have_debug.inc + +--source include/master-slave.inc + +# +# MDEV-38243 / FR-COMPAT-1: a cascade-logged transaction produced by a new +# origin (rpl_use_binlog_events_for_fk_cascade=ON) must apply correctly on a +# replica running an OLDER MariaDB version that does not understand +# FK_CASCADE_EVENTS_F, WITHOUT any apply error, in strict slave_exec_mode. +# +# Such an old replica is emulated here with the debug keyword +# rpl_emulate_old_slave_fk_cascade, which forces the applier to ignore +# FK_CASCADE_EVENTS_F and rely solely on the long-standing +# NO_FOREIGN_KEY_CHECKS_F flag - exactly a pre-MDEV-38243 server would. +# The compatibility therefore holds only because the origin also stamps +# NO_FOREIGN_KEY_CHECKS_F on the cascade-logged events. +# +# NOTE: without the DBUG hook this test would still pass on a new build (the +# new applier disables FK checks via FK_CASCADE_EVENTS_F), but would not be +# exercising the old-replica path. It is meaningful only with the hook present. +# + +# --- Emulate an old, strict-mode replica ------------------------------------ +connection slave; +--source include/stop_slave.inc +SET @old_dbug= @@GLOBAL.debug_dbug; +SET @old_exec_mode= @@GLOBAL.slave_exec_mode; +SET GLOBAL slave_exec_mode= STRICT; +SET GLOBAL debug_dbug= "+d,rpl_emulate_old_slave_fk_cascade"; +--source include/start_slave.inc + +# --- Origin: feature ON ------------------------------------------------------ +connection master; +SET SESSION default_storage_engine=InnoDB; +SET SESSION binlog_format=ROW; +SET SESSION rpl_use_binlog_events_for_fk_cascade=1; + +CREATE TABLE p (id INT PRIMARY KEY, v INT) ENGINE=InnoDB; +CREATE TABLE c ( + id INT PRIMARY KEY, pid INT, v INT, KEY(pid), + CONSTRAINT fk_c FOREIGN KEY (pid) REFERENCES p(id) + ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB; +CREATE TABLE cn ( + id INT PRIMARY KEY, pid INT, v INT, KEY(pid), + CONSTRAINT fk_cn 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 c VALUES (100,1,1000),(200,2,2000),(300,3,3000); +INSERT INTO cn VALUES (100,1,1000),(200,2,2000),(300,3,3000); + +# ON UPDATE CASCADE (c.pid 1->11) and ON UPDATE SET NULL (cn.pid 1->NULL) +UPDATE p SET id=11 WHERE id=1; +# ON DELETE CASCADE (c row 200 gone) and ON DELETE SET NULL (cn.pid 2->NULL) +DELETE FROM p WHERE id=2; + +# --- The emulated old replica must apply without error ---------------------- +# sync_slave_with_master fails the test if the SQL thread has stopped on error. +sync_slave_with_master; + +connection slave; +--let $errno= query_get_value(SHOW SLAVE STATUS, Last_SQL_Errno, 1) +--echo # Last_SQL_Errno = $errno (expected 0) +SELECT * FROM p ORDER BY id; +SELECT * FROM c ORDER BY id; +SELECT * FROM cn ORDER BY id; + +# --- Cleanup ---------------------------------------------------------------- +connection slave; +--source include/stop_slave.inc +SET GLOBAL debug_dbug= @old_dbug; +SET GLOBAL slave_exec_mode= @old_exec_mode; +--source include/start_slave.inc + +connection master; +DROP TABLE c, cn, p; +--source include/rpl_end.inc diff --git a/mysql-test/suite/rpl/t/rpl_fk_cascade_binlog_row_ordering.test b/mysql-test/suite/rpl/t/rpl_fk_cascade_binlog_row_ordering.test new file mode 100644 index 0000000000000..9276eb659548d --- /dev/null +++ b/mysql-test/suite/rpl/t/rpl_fk_cascade_binlog_row_ordering.test @@ -0,0 +1,69 @@ +# +# MDEV-38243 +# +# When rpl_use_binlog_events_for_fk_cascade is enabled, all cascade row events +# (both DELETE and UPDATE/SET NULL) must be written to the binary log in the +# same order the cascade operations were executed. Earlier the DELETE cascade +# events were logged immediately while UPDATE/SET NULL events were deferred to +# statement end, so every cascade delete was emitted ahead of every cascade +# update. When a single statement both SET NULLs and deletes the *same* child +# row (a child with two foreign keys to the same parent, one ON DELETE SET NULL +# and one ON DELETE CASCADE), the executed order is UPDATE-then-DELETE but the +# binlog order became DELETE-then-UPDATE. On the replica (which applies these +# events with foreign_key_checks disabled) the UPDATE event then could not find +# its row, because the DELETE had already removed it -> the SQL thread stops or +# the data diverges. +# +--source include/have_innodb.inc +--source include/have_log_bin.inc +--source include/have_binlog_format_row.inc + +--source include/master-slave.inc + +SET @old_binlog_format := @@session.binlog_format; +SET @old_rpl_use_binlog_events_for_fk_cascade := @@session.rpl_use_binlog_events_for_fk_cascade; + +SET SESSION binlog_format=ROW; +SET SESSION rpl_use_binlog_events_for_fk_cascade=1; + +CREATE TABLE p ( + id INT PRIMARY KEY +) ENGINE=InnoDB; + +# Child with two FKs to p: fk_a (SET NULL -> cascade UPDATE) is declared before +# fk_b (CASCADE -> cascade DELETE) so that on DELETE FROM p the same child row +# is first SET NULL and then deleted. +CREATE TABLE c ( + id INT PRIMARY KEY, + a INT, + b INT, + KEY(a), + KEY(b), + CONSTRAINT fk_a FOREIGN KEY (a) REFERENCES p(id) ON DELETE SET NULL, + CONSTRAINT fk_b FOREIGN KEY (b) REFERENCES p(id) ON DELETE CASCADE +) ENGINE=InnoDB; + +INSERT INTO p VALUES (1); +INSERT INTO c VALUES (10, 1, 1); + +sync_slave_with_master; +connection master; + +DELETE FROM p WHERE id=1; + +--echo # master: child row cascade-deleted +SELECT * FROM c; + +sync_slave_with_master; +connection slave; +--echo # slave must match master (row deleted); a wrong DELETE-before-UPDATE +--echo # ordering would have stopped the SQL thread on the UPDATE event +SELECT * FROM c; + +connection master; +DROP TABLE c, p; + +SET SESSION binlog_format=@old_binlog_format; +SET SESSION rpl_use_binlog_events_for_fk_cascade=@old_rpl_use_binlog_events_for_fk_cascade; + +--source include/rpl_end.inc diff --git a/mysql-test/suite/rpl/t/rpl_fk_cascade_binlog_row_rollback.test b/mysql-test/suite/rpl/t/rpl_fk_cascade_binlog_row_rollback.test new file mode 100644 index 0000000000000..b28060718ece0 --- /dev/null +++ b/mysql-test/suite/rpl/t/rpl_fk_cascade_binlog_row_rollback.test @@ -0,0 +1,201 @@ +# +# MDEV-38243 +# +# When rpl_use_binlog_events_for_fk_cascade is enabled the master collects +# row events for FK cascade operations. If the row changes those events +# describe are rolled back, the events must be discarded (freed) and must not +# end up in the binary log. This test exercises the three rollback paths that +# discard the queued cascade events: +# +# 1. full transaction ROLLBACK (ha_rollback_trans, all=true) +# 2. ROLLBACK TO SAVEPOINT (ha_rollback_to_savepoint) +# 3. statement rollback, transaction kept (ha_rollback_trans, all=false) +# +# In every case the cascaded child row event (### UPDATE `test`.`c`) must be +# absent from the binary log, the master and slave data must stay consistent, +# and any surviving work in the same transaction must still replicate. +# +--source include/have_innodb.inc +--source include/have_log_bin.inc +--source include/have_binlog_format_row.inc + +--source include/master-slave.inc + +SET @old_binlog_format := @@session.binlog_format; +SET @old_default_storage_engine := @@session.default_storage_engine; +SET @old_rpl_use_binlog_events_for_fk_cascade := @@session.rpl_use_binlog_events_for_fk_cascade; + +SET SESSION default_storage_engine=InnoDB; +SET SESSION binlog_format=ROW; +SET SESSION rpl_use_binlog_events_for_fk_cascade=1; + +CREATE TABLE p ( + id INT PRIMARY KEY, + v INT +) ENGINE=InnoDB; + +CREATE TABLE c ( + id INT PRIMARY KEY, + pid INT, + v INT, + KEY(pid), + CONSTRAINT fk FOREIGN KEY (pid) REFERENCES p(id) + ON DELETE CASCADE + ON UPDATE CASCADE +) ENGINE=InnoDB; + +INSERT INTO p VALUES (1, 10); +INSERT INTO c VALUES (100, 1, 20); + +sync_slave_with_master; +connection master; + +--echo # +--echo # Phase 1: full transaction ROLLBACK +--echo # +FLUSH BINARY LOGS; +--let $binlog = query_get_value(SHOW MASTER STATUS, File, 1) + +BEGIN; +UPDATE p SET id=2 WHERE id=1; +ROLLBACK; + +--echo # master: parent and child unchanged +SELECT * FROM p ORDER BY id; +SELECT * FROM c ORDER BY id; + +sync_slave_with_master; +connection slave; +--echo # slave: child unchanged +SELECT * FROM c ORDER BY id; + +connection master; +FLUSH BINARY LOGS; +--let $datadir = `SELECT @@datadir` +--exec $MYSQL_BINLOG --verbose --verbose --base64-output=DECODE-ROWS $datadir/$binlog > $MYSQLTEST_VARDIR/tmp/fk_cascade_rollback_full.sql + +--echo # no cascade child row event must be present +--let SEARCH_PATTERN= ### UPDATE `test`.`c` +--let SEARCH_FILE= $MYSQLTEST_VARDIR/tmp/fk_cascade_rollback_full.sql +--let SEARCH_ABORT= FOUND +--source include/search_pattern_in_file.inc + +--echo # +--echo # Phase 2: ROLLBACK TO SAVEPOINT (surviving work before the savepoint) +--echo # +FLUSH BINARY LOGS; +--let $binlog = query_get_value(SHOW MASTER STATUS, File, 1) + +BEGIN; +INSERT INTO p VALUES (3, 30); +SAVEPOINT sp1; +UPDATE p SET id=2 WHERE id=1; +ROLLBACK TO SAVEPOINT sp1; +COMMIT; + +--echo # master: cascade undone, INSERT before savepoint kept +SELECT * FROM p ORDER BY id; +SELECT * FROM c ORDER BY id; + +sync_slave_with_master; +connection slave; +--echo # slave: child unchanged, parent has surviving row +SELECT * FROM p ORDER BY id; +SELECT * FROM c ORDER BY id; + +connection master; +FLUSH BINARY LOGS; +--let $datadir = `SELECT @@datadir` +--exec $MYSQL_BINLOG --verbose --verbose --base64-output=DECODE-ROWS $datadir/$binlog > $MYSQLTEST_VARDIR/tmp/fk_cascade_rollback_sp.sql + +--echo # cascade child row event must be absent +--let SEARCH_PATTERN= ### UPDATE `test`.`c` +--let SEARCH_FILE= $MYSQLTEST_VARDIR/tmp/fk_cascade_rollback_sp.sql +--let SEARCH_ABORT= FOUND +--source include/search_pattern_in_file.inc + +--echo # surviving INSERT before the savepoint must be replicated +--let SEARCH_PATTERN= ### INSERT INTO `test`.`p` +--let SEARCH_FILE= $MYSQLTEST_VARDIR/tmp/fk_cascade_rollback_sp.sql +--let SEARCH_ABORT= NOT FOUND +--source include/search_pattern_in_file.inc + +DROP TABLE c, p; + +--echo # +--echo # Phase 3: statement rollback, transaction continues and commits +--echo # +# The parent has a secondary UNIQUE key. A multi-row UPDATE cascades on the +# first row (queuing a child cascade event) and then hits a duplicate-key +# error on the second row, which rolls back the statement. The transaction +# stays open and commits later work. The discarded cascade event must not be +# flushed into the binary log at COMMIT. +CREATE TABLE p ( + id INT PRIMARY KEY, + u INT, + UNIQUE KEY uq_u(u) +) ENGINE=InnoDB; + +CREATE TABLE c ( + cid INT PRIMARY KEY, + pid INT, + KEY(pid), + CONSTRAINT fk FOREIGN KEY (pid) REFERENCES p(id) + ON UPDATE CASCADE +) ENGINE=InnoDB; + +INSERT INTO p VALUES (1, 100), (2, 200); +INSERT INTO c VALUES (11, 1); + +sync_slave_with_master; +connection master; + +FLUSH BINARY LOGS; +--let $binlog = query_get_value(SHOW MASTER STATUS, File, 1) + +BEGIN; +# row id=1: id 1->11 cascades c.pid 1->11 (event queued), u 100->100 (no change) +# row id=2: u 200->100 duplicates row 1's u -> ER_DUP_ENTRY -> statement rollback +--error ER_DUP_ENTRY +UPDATE p SET id=id+10, u=100 WHERE id IN (1,2) ORDER BY id; +INSERT INTO p VALUES (9, 900); +COMMIT; + +--echo # master: failed UPDATE rolled back, INSERT committed +SELECT * FROM p ORDER BY id; +SELECT * FROM c ORDER BY cid; + +sync_slave_with_master; +connection slave; +--echo # slave: child unchanged, parent has committed row +SELECT * FROM p ORDER BY id; +SELECT * FROM c ORDER BY cid; + +connection master; +FLUSH BINARY LOGS; +--let $datadir = `SELECT @@datadir` +--exec $MYSQL_BINLOG --verbose --verbose --base64-output=DECODE-ROWS $datadir/$binlog > $MYSQLTEST_VARDIR/tmp/fk_cascade_rollback_stmt.sql + +--echo # discarded cascade child row event must be absent +--let SEARCH_PATTERN= ### UPDATE `test`.`c` +--let SEARCH_FILE= $MYSQLTEST_VARDIR/tmp/fk_cascade_rollback_stmt.sql +--let SEARCH_ABORT= FOUND +--source include/search_pattern_in_file.inc + +--echo # committed INSERT must be replicated +--let SEARCH_PATTERN= ### INSERT INTO `test`.`p` +--let SEARCH_FILE= $MYSQLTEST_VARDIR/tmp/fk_cascade_rollback_stmt.sql +--let SEARCH_ABORT= NOT FOUND +--source include/search_pattern_in_file.inc + +DROP TABLE c, p; + +SET SESSION default_storage_engine=@old_default_storage_engine; +SET SESSION binlog_format=@old_binlog_format; +SET SESSION rpl_use_binlog_events_for_fk_cascade=@old_rpl_use_binlog_events_for_fk_cascade; + +--remove_file $MYSQLTEST_VARDIR/tmp/fk_cascade_rollback_full.sql +--remove_file $MYSQLTEST_VARDIR/tmp/fk_cascade_rollback_sp.sql +--remove_file $MYSQLTEST_VARDIR/tmp/fk_cascade_rollback_stmt.sql + +--source include/rpl_end.inc diff --git a/mysql-test/suite/rpl/t/rpl_fk_cascade_binlog_row_slave_option.test b/mysql-test/suite/rpl/t/rpl_fk_cascade_binlog_row_slave_option.test new file mode 100644 index 0000000000000..831394ab52d93 --- /dev/null +++ b/mysql-test/suite/rpl/t/rpl_fk_cascade_binlog_row_slave_option.test @@ -0,0 +1,109 @@ +--source include/have_innodb.inc +--source include/have_log_bin.inc +--source include/have_binlog_format_row.inc + +--source include/master-slave.inc + +# +# MDEV-38243: rpl_use_binlog_events_for_fk_cascade enabled only on the slave. +# +# The master runs with the feature OFF (default), so it binlogs only the +# parent-table row events, not the cascaded child row events. The slave has +# the feature ON. Because the row-based applier does not prelock the FK-child +# tables, find_fk_open_table() finds no open child table on the applier, so no +# cascade row events are captured there. The slave applies the cascade the +# traditional way (InnoDB re-executes it) and its own binary log contains only +# the parent events. +# +# This test pins that behaviour: enabling the option on the slave is currently +# a no-op for the applier path, while replication stays correct. It exists so +# that a future change which makes the option effective on the applier (by +# opening the child table there) will visibly change this recorded result. +# + +connection master; +SET SESSION default_storage_engine=InnoDB; +SET SESSION binlog_format=ROW; + +# The master keeps the feature OFF (the default). +SELECT @@session.rpl_use_binlog_events_for_fk_cascade; + +CREATE TABLE p ( + id INT PRIMARY KEY, + v INT +) ENGINE=InnoDB; + +CREATE TABLE c ( + id INT PRIMARY KEY, + pid INT, + v INT, + KEY(pid), + CONSTRAINT fk FOREIGN KEY (pid) REFERENCES p(id) + ON DELETE CASCADE + ON UPDATE CASCADE +) ENGINE=InnoDB; + +INSERT INTO p VALUES (1, 10); +INSERT INTO c VALUES (100, 1, 20); + +sync_slave_with_master; + +# Turn the feature ON only on the slave, and restart the SQL thread so the +# applier THD picks up the new global value. +connection slave; +--source include/stop_slave.inc +SET GLOBAL rpl_use_binlog_events_for_fk_cascade=1; +--source include/start_slave.inc + +connection master; +UPDATE p SET id=2 WHERE id=1; +DELETE FROM p WHERE id=2; + +sync_slave_with_master; + +connection slave; +SELECT COUNT(*) FROM p; +SELECT COUNT(*) FROM c; + +# Inspect the slave's own binary log. It must contain the parent events but +# NOT explicit cascade row events for the child table, because the applier +# does not capture cascades (the child table is not prelocked on the applier). +--let $binlog = query_get_value(SHOW MASTER STATUS, File, 1) +--let $datadir = `SELECT @@datadir` + +FLUSH BINARY LOGS; + +--exec $MYSQL_BINLOG --verbose --verbose --base64-output=DECODE-ROWS $datadir/$binlog > $MYSQLTEST_VARDIR/tmp/fk_cascade_slave_option.sql + +# Parent DELETE is present in the slave's binary log. +--let SEARCH_PATTERN= ### DELETE FROM `test`.`p` +--let SEARCH_FILE= $MYSQLTEST_VARDIR/tmp/fk_cascade_slave_option.sql +--let SEARCH_ABORT= NOT FOUND +--source include/search_pattern_in_file.inc + +# No explicit cascade child row events: the option is a no-op on the applier. +--let SEARCH_PATTERN= ### DELETE FROM `test`.`c` +--let SEARCH_FILE= $MYSQLTEST_VARDIR/tmp/fk_cascade_slave_option.sql +--let SEARCH_ABORT= FOUND +--source include/search_pattern_in_file.inc + +--let SEARCH_PATTERN= ### UPDATE `test`.`c` +--let SEARCH_FILE= $MYSQLTEST_VARDIR/tmp/fk_cascade_slave_option.sql +--let SEARCH_ABORT= FOUND +--source include/search_pattern_in_file.inc + +# Cleanup: restore the slave option and drop the tables. +connection slave; +--source include/stop_slave.inc +SET GLOBAL rpl_use_binlog_events_for_fk_cascade=0; +--source include/start_slave.inc + +connection master; +DROP TABLE c, p; + +sync_slave_with_master; + +connection master; +--remove_file $MYSQLTEST_VARDIR/tmp/fk_cascade_slave_option.sql + +--source include/rpl_end.inc diff --git a/mysql-test/suite/rpl/t/rpl_fk_set_null_binlog_row.test b/mysql-test/suite/rpl/t/rpl_fk_set_null_binlog_row.test new file mode 100644 index 0000000000000..42c7278e45ee3 --- /dev/null +++ b/mysql-test/suite/rpl/t/rpl_fk_set_null_binlog_row.test @@ -0,0 +1,105 @@ +--source include/have_innodb.inc +--source include/have_log_bin.inc +--source include/have_binlog_format_row.inc + +--source include/master-slave.inc + +SET @old_binlog_format := @@session.binlog_format; +SET @old_default_storage_engine := @@session.default_storage_engine; +SET @old_rpl_use_binlog_events_for_fk_cascade := @@session.rpl_use_binlog_events_for_fk_cascade; + +SET SESSION default_storage_engine=InnoDB; +SET SESSION binlog_format=ROW; + +# Phase A: feature OFF (no explicit FK SET NULL row events in binlog) +SET SESSION rpl_use_binlog_events_for_fk_cascade=0; + +CREATE TABLE p ( + id INT PRIMARY KEY, + v INT +) ENGINE=InnoDB; + +CREATE TABLE c ( + id INT PRIMARY KEY, + pid INT NULL, + v INT, + KEY(pid), + CONSTRAINT fk FOREIGN KEY (pid) REFERENCES p(id) + ON DELETE SET NULL + ON UPDATE SET NULL +) ENGINE=InnoDB; + +INSERT INTO p VALUES (1, 10); +INSERT INTO c VALUES (100, 1, 20); + +UPDATE p SET id=2 WHERE id=1; + +sync_slave_with_master; +SELECT pid FROM c ORDER BY id; + +connection master; + +--let $binlog = query_get_value(SHOW MASTER STATUS, File, 1) +--let $datadir = `SELECT @@datadir` + +FLUSH BINARY LOGS; + +--exec $MYSQL_BINLOG --verbose --verbose --base64-output=DECODE-ROWS $datadir/$binlog > $MYSQLTEST_VARDIR/tmp/fk_set_null_binlog_row_off.sql + +--let SEARCH_PATTERN= ### UPDATE `test`.`c` +--let SEARCH_FILE= $MYSQLTEST_VARDIR/tmp/fk_set_null_binlog_row_off.sql +--let SEARCH_ABORT= FOUND +--source include/search_pattern_in_file.inc + +DROP TABLE c, p; + +# Phase B: feature ON (explicit FK SET NULL row events in binlog) +SET SESSION rpl_use_binlog_events_for_fk_cascade=1; + +CREATE TABLE p ( + id INT PRIMARY KEY, + v INT +) ENGINE=InnoDB; + +CREATE TABLE c ( + id INT PRIMARY KEY, + pid INT NULL, + v INT, + KEY(pid), + CONSTRAINT fk FOREIGN KEY (pid) REFERENCES p(id) + ON DELETE SET NULL + ON UPDATE SET NULL +) ENGINE=InnoDB; + +INSERT INTO p VALUES (1, 10); +INSERT INTO c VALUES (100, 1, 20); + +UPDATE p SET id=2 WHERE id=1; + +sync_slave_with_master; +SELECT pid FROM c ORDER BY id; + +connection master; + +--let $binlog = query_get_value(SHOW MASTER STATUS, File, 1) +--let $datadir = `SELECT @@datadir` + +FLUSH BINARY LOGS; + +--exec $MYSQL_BINLOG --verbose --verbose --base64-output=DECODE-ROWS $datadir/$binlog > $MYSQLTEST_VARDIR/tmp/fk_set_null_binlog_row_on.sql + +--let SEARCH_PATTERN= ### UPDATE `test`.`c` +--let SEARCH_FILE= $MYSQLTEST_VARDIR/tmp/fk_set_null_binlog_row_on.sql +--let SEARCH_ABORT= NOT FOUND|FOUND 0|FOUND [2-9] +--source include/search_pattern_in_file.inc + +DROP TABLE c, p; + +SET SESSION default_storage_engine=@old_default_storage_engine; +SET SESSION binlog_format=@old_binlog_format; +SET SESSION rpl_use_binlog_events_for_fk_cascade=@old_rpl_use_binlog_events_for_fk_cascade; + +--remove_file $MYSQLTEST_VARDIR/tmp/fk_set_null_binlog_row_off.sql +--remove_file $MYSQLTEST_VARDIR/tmp/fk_set_null_binlog_row_on.sql + +--source include/rpl_end.inc diff --git a/mysql-test/suite/sys_vars/r/sysvars_server_embedded.result b/mysql-test/suite/sys_vars/r/sysvars_server_embedded.result index a2efaba2652a9..c292afd672d38 100644 --- a/mysql-test/suite/sys_vars/r/sysvars_server_embedded.result +++ b/mysql-test/suite/sys_vars/r/sysvars_server_embedded.result @@ -3482,6 +3482,16 @@ NUMERIC_BLOCK_SIZE 1 ENUM_VALUE_LIST NULL READ_ONLY NO COMMAND_LINE_ARGUMENT REQUIRED +VARIABLE_NAME RPL_USE_BINLOG_EVENTS_FOR_FK_CASCADE +VARIABLE_SCOPE SESSION +VARIABLE_TYPE BOOLEAN +VARIABLE_COMMENT If enabled, the master will write row events for foreign key cascade operations +NUMERIC_MIN_VALUE NULL +NUMERIC_MAX_VALUE NULL +NUMERIC_BLOCK_SIZE NULL +ENUM_VALUE_LIST OFF,ON +READ_ONLY NO +COMMAND_LINE_ARGUMENT OPTIONAL VARIABLE_NAME SECURE_FILE_PRIV VARIABLE_SCOPE GLOBAL VARIABLE_TYPE VARCHAR diff --git a/mysql-test/suite/sys_vars/r/sysvars_server_notembedded.result b/mysql-test/suite/sys_vars/r/sysvars_server_notembedded.result index 005ee68cfb923..ddd50f0ede786 100644 --- a/mysql-test/suite/sys_vars/r/sysvars_server_notembedded.result +++ b/mysql-test/suite/sys_vars/r/sysvars_server_notembedded.result @@ -4052,6 +4052,16 @@ NUMERIC_BLOCK_SIZE 1 ENUM_VALUE_LIST NULL READ_ONLY NO COMMAND_LINE_ARGUMENT REQUIRED +VARIABLE_NAME RPL_USE_BINLOG_EVENTS_FOR_FK_CASCADE +VARIABLE_SCOPE SESSION +VARIABLE_TYPE BOOLEAN +VARIABLE_COMMENT If enabled, the master will write row events for foreign key cascade operations +NUMERIC_MIN_VALUE NULL +NUMERIC_MAX_VALUE NULL +NUMERIC_BLOCK_SIZE NULL +ENUM_VALUE_LIST OFF,ON +READ_ONLY NO +COMMAND_LINE_ARGUMENT OPTIONAL VARIABLE_NAME SECURE_FILE_PRIV VARIABLE_SCOPE GLOBAL VARIABLE_TYPE VARCHAR diff --git a/sql/handler.cc b/sql/handler.cc index 1ff0c1729f11a..959b43c44ede4 100644 --- a/sql/handler.cc +++ b/sql/handler.cc @@ -118,6 +118,23 @@ static handlerton *installed_htons[128]; KEY_CREATE_INFO default_key_create_info= { HA_KEY_ALG_UNDEF, 0, 0, {NullS, 0}, {NullS, 0}, false }; +static void flush_pending_cascade_binlog_for_thd(THD *thd) +{ + if (thd) + thd->flush_pending_cascade_binlog(); +} + +static void discard_pending_cascade_binlog_for_thd(THD *thd) +{ + /* + Unlike flushing, discarding is pure cleanup: it only frees the queued + cascade row events, it never writes to the binary log. So it must run for + slave/applier threads too. Hence no thd->rgi_slave check here. + */ + if (thd) + thd->discard_pending_cascade_binlog(); +} + /* number of entries in handlertons[] */ ulong total_ha= 0; /* number of storage engines (from handlertons[]) that support 2pc */ @@ -2142,6 +2159,14 @@ int ha_commit_trans(THD *thd, bool all) thd->mdl_context.release_lock(thd->backup_commit_lock); thd->backup_commit_lock= 0; } + + if (!error) + { + if ((thd->variables.rpl_use_binlog_events_for_fk_cascade || + WSREP_EMULATE_BINLOG(thd)) && + (all || thd->in_active_multi_stmt_transaction())) + flush_pending_cascade_binlog_for_thd(thd); + } #ifdef WITH_WSREP if (wsrep_is_active(thd) && is_real_trans && !error && (rw_ha_count == 0 || all) && @@ -2335,12 +2360,11 @@ int ha_rollback_trans(THD *thd, bool all) rollback without signalling following transactions. And in release builds, we explicitly do the signalling before rolling back. */ - DBUG_ASSERT( - !(thd->rgi_slave && - !thd->rgi_slave->worker_error && - thd->rgi_slave->did_mark_start_commit) || - (thd->transaction->xid_state.is_explicit_XA() || - (thd->rgi_slave->gtid_ev_flags2 & Gtid_log_event::FL_PREPARED_XA))); + if (!(thd->rgi_slave && thd->rgi_slave->worker_error)) + DBUG_ASSERT( + !(thd->rgi_slave && thd->rgi_slave->did_mark_start_commit) || + (thd->transaction->xid_state.is_explicit_XA() || + (thd->rgi_slave->gtid_ev_flags2 & Gtid_log_event::FL_PREPARED_XA))); if (thd->rgi_slave && !thd->rgi_slave->worker_error && @@ -2422,6 +2446,19 @@ int ha_rollback_trans(THD *thd, bool all) binlog_post_rollback(thd, all); } + /* + The engines have rolled back (the whole transaction when all==true, or the + current statement to its implicit savepoint when all==false). Any queued + FK-cascade row events describe row changes that were just undone, so + discard and free them. + The queue is owned by the THD, so it is not reclaimed by the engine + releasing its transaction; discarding here is what keeps both the + full-rollback and the statement-rollback case correct. + */ + if (thd->variables.rpl_use_binlog_events_for_fk_cascade || + WSREP_EMULATE_BINLOG(thd)) + discard_pending_cascade_binlog_for_thd(thd); + #ifdef WITH_WSREP if (WSREP(thd) && thd->is_error()) { @@ -3389,6 +3426,18 @@ int ha_rollback_to_savepoint(THD *thd, SAVEPOINT *sv) my_error(ER_ERROR_DURING_ROLLBACK, MYF(0), err); error=1; } + + /* + The cascade row events queued for this transaction describe row + changes that are being rolled back to the savepoint (or the queue is + empty because earlier statements already flushed their events into the + binlog cache, which the binlog savepoint machinery truncates + separately). Either way we must not write these events, so discard and + free them rather than flushing them into the binary log. + */ + if (thd->variables.rpl_use_binlog_events_for_fk_cascade || + WSREP_EMULATE_BINLOG(thd)) + discard_pending_cascade_binlog_for_thd(thd); #ifdef WITH_WSREP if (WSREP(thd) && ht->flags & HTON_WSREP_REPLICATION) { diff --git a/sql/handler.h b/sql/handler.h index fa9196f189a8f..d3097b7f199e6 100644 --- a/sql/handler.h +++ b/sql/handler.h @@ -5310,7 +5310,24 @@ class handler :public Sql_alloc bool check_table_binlog_row_based(); bool prepare_for_row_logging(); int prepare_for_modify(bool can_set_fields, bool can_lookup); - int binlog_log_row(const uchar *before_record, const uchar *after_record, + + /* + Convert the row that the engine's FK-cascade cursor is currently + positioned on into MySQL row format, into 'buf'. + + Called by the server (thd_fk_cascade_capture()) while reporting a cascade + action the engine performed on a child table; see + include/mysql/service_thd_fk_cascade.h. The server has already set the + table's column bitmaps to the image it wants, so the engine only has to + honour them. Engines that do not perform FK cascades internally never see + this call. + */ + virtual int fk_cascade_fetch_row(uchar *buf) + { return HA_ERR_WRONG_COMMAND; } + + int prepare_for_insert(bool do_create); + int binlog_log_row(const uchar *before_record, + const uchar *after_record, Log_func *log_func); inline void clear_cached_table_binlog_row_based_flag() diff --git a/sql/log.cc b/sql/log.cc index c6dbd354d1973..eeb9e455d7d5d 100644 --- a/sql/log.cc +++ b/sql/log.cc @@ -8069,7 +8069,19 @@ int binlog_flush_pending_rows_event(THD *thd, bool stmt_end, */ if (stmt_end) { - pending->set_flags(Rows_log_event::STMT_END_F); + if (thd->binlog_fk_cascade_events && + (thd->variables.rpl_use_binlog_events_for_fk_cascade || + WSREP_EMULATE_BINLOG(thd))) + thd->flush_pending_cascade_binlog(); + + /* + Flushing cascaded row events may have created a new pending event or + replaced the current one. Ensure we mark the final pending event as + statement end. + */ + pending= cache_data->pending(); + if (pending) + pending->set_flags(Rows_log_event::STMT_END_F); thd->reset_binlog_for_next_statement(); } @@ -8295,6 +8307,12 @@ Event_log::prepare_pending_rows_event(THD *thd, TABLE* table, if (unlikely(!ev)) DBUG_RETURN(NULL); ev->server_id= serv_id; // I don't like this, it's too easy to forget. + + if (thd->binlog_fk_cascade_events) + ev->set_flags(Rows_log_event::FK_CASCADE_EVENTS_F | + Rows_log_event::NO_FOREIGN_KEY_CHECKS_F); + if (thd->binlog_fk_cascade_derived) + ev->set_flags(Rows_log_event::FK_CASCADE_DERIVED_F); /* flush the pending event and replace it with the newly created event... diff --git a/sql/log_event.h b/sql/log_event.h index bf3f1718691ae..a3e76d70c1759 100644 --- a/sql/log_event.h +++ b/sql/log_event.h @@ -4873,6 +4873,23 @@ class Rows_log_event : public Log_event */ COMPLETE_ROWS_F = (1U << 3), + /* + Set on every row event that belongs to a statement whose FK cascade + changes were logged as explicit row events (originating rows and cascade + derived rows alike). The applier uses it to suppress re-cascading. + */ + FK_CASCADE_EVENTS_F = (1U << 4), + + /* + Set only on the cascade-derived row events (the child-row changes that + the master produced by executing the FK cascade), and NOT on the + originating statement's own row events. Combined with + FK_CASCADE_EVENTS_F it lets the applier tell "root" rows from + "cascade-derived" rows, e.g. to optionally re-execute the cascade + instead of applying the derived events. + */ + FK_CASCADE_DERIVED_F = (1U << 5), + /* Value of the OPTION_NO_CHECK_CONSTRAINT_CHECKS flag in thd->options */ NO_CHECK_CONSTRAINT_CHECKS_F = (1U << 7) }; diff --git a/sql/log_event_server.cc b/sql/log_event_server.cc index e1a275c5ff1ad..b9f056c42116f 100644 --- a/sql/log_event_server.cc +++ b/sql/log_event_server.cc @@ -5062,7 +5062,21 @@ int Rows_log_event::do_apply_event(rpl_group_info *rgi) Make sure to set/clear them before executing the main body of the event. */ - if (get_flags(NO_FOREIGN_KEY_CHECKS_F)) + /* + FK_CASCADE_EVENTS_F marks row events that the master emitted for the + changes performed by cascading foreign key operations. The applier must + not re-run the cascade, so foreign key checks are disabled for these + events just as they are for NO_FOREIGN_KEY_CHECKS_F. + */ + bool fk_cascade_events= get_flags(FK_CASCADE_EVENTS_F); + /* + emulate a pre-MDEV-38243 replica that does not understand + FK_CASCADE_EVENTS_F, so that the decision below relies solely on the + long-standing NO_FOREIGN_KEY_CHECKS_F. Used by the cross-version + compatibility test rpl.rpl_fk_cascade_binlog_row_old_slave. + */ + DBUG_EXECUTE_IF("rpl_emulate_old_slave_fk_cascade", fk_cascade_events= false;); + if (get_flags(NO_FOREIGN_KEY_CHECKS_F) || fk_cascade_events) thd->variables.option_bits|= OPTION_NO_FOREIGN_KEY_CHECKS; else thd->variables.option_bits&= ~OPTION_NO_FOREIGN_KEY_CHECKS; diff --git a/sql/service_wsrep.cc b/sql/service_wsrep.cc index 3f09e9d8b3e47..705d64921c372 100644 --- a/sql/service_wsrep.cc +++ b/sql/service_wsrep.cc @@ -441,3 +441,8 @@ extern "C" my_bool wsrep_thd_is_local_transaction(const THD *thd) return (wsrep_thd_is_local(thd) && thd->wsrep_cs().transaction().active()); } + +extern "C" my_bool wsrep_emulate_binlog(const THD *thd) +{ + return WSREP_EMULATE_BINLOG_NNULL(thd); +} diff --git a/sql/sql_base.cc b/sql/sql_base.cc index a73251d0d7b26..4f7636af641fd 100644 --- a/sql/sql_base.cc +++ b/sql/sql_base.cc @@ -5268,8 +5268,9 @@ bool DML_prelocking_strategy::handle_routine(THD *thd, @note this can be changed to use a hash, instead of scanning the linked list, if the performance of this function will ever become an issue */ -bool table_already_fk_prelocked(TABLE_LIST *tl, LEX_CSTRING *db, - LEX_CSTRING *table, thr_lock_type lock_type) +TABLE_LIST *table_already_fk_prelocked(TABLE_LIST *tl, LEX_CSTRING *db, + LEX_CSTRING *table, + thr_lock_type lock_type) { for (; tl; tl= tl->next_global ) { @@ -5277,9 +5278,9 @@ bool table_already_fk_prelocked(TABLE_LIST *tl, LEX_CSTRING *db, tl->prelocking_placeholder == TABLE_LIST::PRELOCK_FK && strcmp(tl->db.str, db->str) == 0 && strcmp(tl->table_name.str, table->str) == 0) - return true; + return tl; } - return false; + return NULL; } @@ -5407,23 +5408,52 @@ prepare_fk_prelocking_list(THD *thd, Query_tables_list *prelocking_ctx, while ((fk= fk_list_it++)) { - // FK_OPTION_RESTRICT and FK_OPTION_NO_ACTION only need read access - thr_lock_type lock_type; + /* + Which trigger event the cascade raises on the *child*, which is not + always the one the statement raises on the parent: only ON DELETE + CASCADE turns a parent delete into a child delete. ON DELETE SET NULL + (and SET DEFAULT) update the child row instead, and every ON UPDATE + action that touches the child updates it. Propagating the parent's + event verbatim would prelock the wrong set of triggers for those, and + trip the DBUG_ASSERT in Table_triggers_list::process_triggers(). + + A zero map means the child is only read (FK_OPTION_RESTRICT and + FK_OPTION_NO_ACTION need read access only), which is also what decides + the lock type below. + */ + uint8 child_op= 0; - if ((op & trg2bit(TRG_EVENT_DELETE) && fk_modifies_child(fk->delete_method)) - || (op & trg2bit(TRG_EVENT_UPDATE) && fk_modifies_child(fk->update_method))) - lock_type= TL_FIRST_WRITE; - else - lock_type= TL_READ; + if ((op & trg2bit(TRG_EVENT_DELETE)) && + fk_modifies_child(fk->delete_method)) + child_op|= (fk->delete_method == FK_OPTION_CASCADE) + ? trg2bit(TRG_EVENT_DELETE) : trg2bit(TRG_EVENT_UPDATE); - if (table_already_fk_prelocked(prelocking_ctx->query_tables, - fk->foreign_db, fk->foreign_table, lock_type)) + if ((op & trg2bit(TRG_EVENT_UPDATE)) && + fk_modifies_child(fk->update_method)) + child_op|= trg2bit(TRG_EVENT_UPDATE); + + thr_lock_type lock_type= child_op ? TL_FIRST_WRITE : TL_READ; + + if (TABLE_LIST *prelocked= + table_already_fk_prelocked(prelocking_ctx->query_tables, + fk->foreign_db, fk->foreign_table, lock_type)) + { + /* + The same child is reachable through more than one constraint, and it + undergoes the union of their actions -- e.g. ON DELETE CASCADE on one + column and ON DELETE SET NULL on another, which delete and update it + respectively. Add this constraint's events to the entry rather than + letting the first constraint's events stand for all of them. + */ + prelocked->trg_event_map|= child_op; continue; + } TABLE_LIST *tl= thd->alloc(1); tl->init_one_table_for_prelocking(fk->foreign_db, fk->foreign_table, NULL, lock_type, TABLE_LIST::PRELOCK_FK, table_list->belong_to_view, - op, &prelocking_ctx->query_tables_last, table_list->for_insert_data); + child_op, &prelocking_ctx->query_tables_last, + table_list->for_insert_data); #ifdef WITH_WSREP /* diff --git a/sql/sql_base.h b/sql/sql_base.h index 031e615266341..ec9ac86cc9bfc 100644 --- a/sql/sql_base.h +++ b/sql/sql_base.h @@ -159,7 +159,7 @@ my_bool mysql_rm_tmp_tables(void); void close_tables_for_reopen(THD *thd, TABLE_LIST **tables, const MDL_savepoint &start_of_statement_svp, bool remove_indirect); -bool table_already_fk_prelocked(TABLE_LIST *tl, LEX_CSTRING *db, +TABLE_LIST *table_already_fk_prelocked(TABLE_LIST *tl, LEX_CSTRING *db, LEX_CSTRING *table, thr_lock_type lock_type); TABLE_LIST *find_table_in_list(TABLE_LIST *table, TABLE_LIST *TABLE_LIST::*link, diff --git a/sql/sql_class.cc b/sql/sql_class.cc index b35368484751c..ecd3a0d731300 100644 --- a/sql/sql_class.cc +++ b/sql/sql_class.cc @@ -27,6 +27,7 @@ #include "mariadb.h" #include "sql_priv.h" #include "sql_class.h" +#include #include "sql_cache.h" // query_cache_abort #include "sql_base.h" // close_thread_tables #include "sql_time.h" // date_time_format_copy @@ -93,6 +94,420 @@ extern "C" const uchar *get_var_key(const void *entry_, size_t *length, return reinterpret_cast(entry->name.str); } +void THD::binlog_mark_fk_cascade_events() +{ + binlog_fk_cascade_events= true; + + if (binlog_cache_mngr *cache_mngr= binlog_get_cache_mngr()) + { + /* + Also set the NO_FOREIGN_KEY_CHECKS_F. An older slave does + not understand FK_CASCADE_EVENTS_F, but it does understand + NO_FOREIGN_KEY_CHECKS_F, so setting it makes such a slave disable foreign + key checks for these events. + */ + if (Rows_log_event *pending= binlog_get_pending_rows_event( + cache_mngr, use_trans_cache(this, false))) + pending->set_flags(Rows_log_event::FK_CASCADE_EVENTS_F | + Rows_log_event::NO_FOREIGN_KEY_CHECKS_F); + + if (Rows_log_event *pending= binlog_get_pending_rows_event( + cache_mngr, use_trans_cache(this, true))) + pending->set_flags(Rows_log_event::FK_CASCADE_EVENTS_F | + Rows_log_event::NO_FOREIGN_KEY_CHECKS_F); + } +} + +/* + Queue an FK-cascade row change reported by a storage engine, for binlogging. + The record image(s) are copied so the caller may reuse or free its buffers + once this returns. Marking the current statement as carrying cascade events + here ensures the originating statement's own pending event is flagged too. +*/ +void THD::binlog_report_cascade_row(TABLE *table, bool is_delete, + const uchar *before_record, + const uchar *after_record) +{ + DBUG_ASSERT(table); + DBUG_ASSERT(before_record); + DBUG_ASSERT(is_delete == (after_record == NULL)); + + const size_t len= table->s->reclength; + uchar *before_copy= (uchar *) my_malloc(PSI_INSTRUMENT_ME, len, MYF(MY_WME)); + uchar *after_copy= NULL; + if (!is_delete) + after_copy= (uchar *) my_malloc(PSI_INSTRUMENT_ME, len, MYF(MY_WME)); + + if (!before_copy || (!is_delete && !after_copy)) + { + my_free(before_copy); + my_free(after_copy); + return; + } + + memcpy(before_copy, before_record, len); + if (!is_delete) + memcpy(after_copy, after_record, len); + + Cascade_binlog_row_event ev; + ev.table= table; + ev.before_record= before_copy; + ev.after_record= after_copy; + ev.is_delete= is_delete; + + if (pending_cascade_binlog_row_events.append(ev)) + { + my_free(before_copy); + my_free(after_copy); + return; + } + + /* Flag the originating statement's own (already pending) row event. */ + binlog_mark_fk_cascade_events(); +} + +/* + Flush the queued FK-cascade row changes into the binary log, in the order the + cascade operations executed. Deferring here keeps the root statement's own + events ahead of the derived ones, and keeps interleaved cascade operations + in execution order. + Events written here are additionally marked FK_CASCADE_DERIVED_F. +*/ +void THD::flush_pending_cascade_binlog() +{ + if (pending_cascade_binlog_row_events.elements() == 0) + return; + + binlog_mark_fk_cascade_events(); + binlog_begin_fk_cascade_derived(); + + for (size_t i= 0; i < pending_cascade_binlog_row_events.elements(); i++) + { + Cascade_binlog_row_event &ev= pending_cascade_binlog_row_events.at(i); + TABLE *table= ev.table; + if (!table || !table->file || + table->s->tmp_table != NO_TMP_TABLE) + { + my_free(ev.before_record); + my_free(ev.after_record); + continue; + } + + MY_BITMAP *old_read_set= table->read_set; + MY_BITMAP *old_write_set= table->write_set; + MY_BITMAP *old_rpl_write_set= table->rpl_write_set; + + table->column_bitmaps_set_no_signal(&table->s->all_set, + &table->s->all_set); + if (table->rpl_write_set == NULL) + table->rpl_write_set= &table->s->all_set; + + Log_func *log_func= ev.is_delete + ? Delete_rows_log_event::binlog_row_logging_function + : Update_rows_log_event::binlog_row_logging_function; + table->file->binlog_log_row(ev.before_record, ev.after_record, log_func); + + table->column_bitmaps_set_no_signal(old_read_set, old_write_set); + table->rpl_write_set= old_rpl_write_set; + + my_free(ev.before_record); + my_free(ev.after_record); + } + + binlog_end_fk_cascade_derived(); + pending_cascade_binlog_row_events.clear(); +} + +/* + Drop the queued FK-cascade row changes without logging them. +*/ +void THD::discard_pending_cascade_binlog() +{ + for (size_t i= 0; i < pending_cascade_binlog_row_events.elements(); i++) + { + Cascade_binlog_row_event &ev= pending_cascade_binlog_row_events.at(i); + my_free(ev.before_record); + my_free(ev.after_record); + } + pending_cascade_binlog_row_events.clear(); +} + +/* + Server-side handling of FK cascade actions reported by a storage engine. + See include/mysql/service_thd_fk_cascade.h for the contract. +*/ + +void THD::fk_cascade_free_images() +{ + my_free(fk_cascade_before_image); + my_free(fk_cascade_after_image); + fk_cascade_before_image= NULL; + fk_cascade_after_image= NULL; +} + + +/* + Is this child table's row image safe to reconstruct without cascade context? + + A table with no primary key and a virtual column participating in a key + cannot be identified unambiguously from a full row image, so we decline to + report cascades on it rather than produce something a consumer would apply + to the wrong row. +*/ +static bool fk_cascade_table_eligible(const TABLE *table) +{ + if (!table) + return false; + + if (table->s->primary_key != MAX_KEY) + return true; + + if (Field **vf= table->vfield) + for (; *vf; vf++) + if ((*vf)->flags & PART_KEY_FLAG) + return false; + + return true; +} + + +/* + Checking if FK cascade events may be captured + + Eligibility is a property of one table, but the event flag that stops the + replica re-running the cascade is a property of the whole statement: the + first captured child row calls binlog_mark_fk_cascade_events(), and from + then on every row event of the statement carries FK_CASCADE_EVENTS_F | + NO_FOREIGN_KEY_CHECKS_F. The replica therefore stops cascading for *all* + children of that statement, not just the captured one. + + Deciding per child would then let an eligible child switch off the replica's + cascade on behalf of an ineligible sibling whose rows were never logged: the + replica neither receives those rows nor recreates them, and silently keeps + the orphans. The decision has to be all-or-nothing across the statement. + + The FK prelocking set is what makes that decidable up front. + prepare_fk_prelocking_list() has already enumerated, transitively, every + child table this statement can write through a cascade, and opened each one + (children needed only for read-only FK checks are OPEN_STUB and never reach + thd->open_tables). If any of them is ineligible we decline the feature for + the whole statement and let the classic replica-side cascade handle all of + it. + +*/ +static bool fk_cascade_stmt_capturable(THD *thd) +{ + if (thd->fk_cascade_stmt_query_id == thd->query_id) + return thd->fk_cascade_stmt_ok; + + bool capturable= true; + for (TABLE *t= thd->open_tables; t; t= t->next) + { + TABLE_LIST *tl= t->pos_in_table_list; + if (tl && tl->prelocking_placeholder == TABLE_LIST::PRELOCK_FK && + tl->lock_type >= TL_FIRST_WRITE && + !fk_cascade_table_eligible(t)) + { + capturable= false; + break; + } + } + + thd->fk_cascade_stmt_query_id= thd->query_id; + thd->fk_cascade_stmt_ok= capturable; + return capturable; +} + + +/* + Switch the table's column bitmaps to the full row image the cascade + consumers need, saving the statement's own bitmaps for restoring afterwards. + Virtual columns are excluded: they are recomputed rather than stored. + + This is server policy, not engine policy, which is why it lives here and not + in the engine performing the cascade. +*/ +static void fk_cascade_begin_full_row_image(TABLE *table, + MY_BITMAP **saved_read, + MY_BITMAP **saved_write, + MY_BITMAP **saved_rpl_write) +{ + *saved_read= table->read_set; + *saved_write= table->write_set; + *saved_rpl_write= table->rpl_write_set; + + bitmap_set_all(&table->tmp_set); + if (Field **vf= table->vfield) + for (; *vf; vf++) + bitmap_clear_bit(&table->tmp_set, (*vf)->field_index); + + table->column_bitmaps_set_no_signal(&table->tmp_set, &table->tmp_set); + if (table->rpl_write_set == NULL) + table->rpl_write_set= &table->tmp_set; +} + + +static void fk_cascade_end_full_row_image(TABLE *table, + MY_BITMAP *saved_read, + MY_BITMAP *saved_write, + MY_BITMAP *saved_rpl_write) +{ + table->column_bitmaps_set_no_signal(saved_read, saved_write); + table->rpl_write_set= saved_rpl_write; +} + + +extern "C" +int thd_fk_cascade_wanted(THD *thd, TABLE *table) +{ + if (!thd || !table || table->in_use != thd) + return 0; + + const bool emulate_binlog= WSREP_EMULATE_BINLOG(thd); + + /* + Consumer 1: binary logging of the cascaded rows. + + This is the only consumer wired up so far. The remaining ones below are + the point of routing cascades through the server at all: each is a + separate known gap in how FK cascades behave, and each becomes an arm of + this same predicate plus an arm of thd_fk_cascade_row() rather than new + machinery inside every engine that cascades. + + Eligibility is checked twice over: this child must be eligible, and so + must every other child the statement can cascade into -- see + fk_cascade_stmt_capturable() for why a per-child answer is not safe. + + Note that the session variable is required even under Galera: emulating + the binary log makes the *format* and *eligibility* conditions moot (a + writeset is always row-based, and is not subject to the row-image caveat + fk_cascade_table_eligible() guards against), but it does not by itself + turn the feature on. Widening this to "emulate_binlog is enough" enables + cascade capture for every Galera session and breaks cascade replication. + */ + if (thd->variables.rpl_use_binlog_events_for_fk_cascade && + (emulate_binlog || thd->is_current_stmt_binlog_format_row()) && + (emulate_binlog || (fk_cascade_table_eligible(table) && + fk_cascade_stmt_capturable(thd))) && + table->file->prepare_for_row_logging()) + return 1; + + /* + Consumer 2: triggers on the child table (not wired up). + + Cannot simply be answered here: firing a trigger runs a stored program, + which may execute arbitrary SQL and re-enter the engine, so it cannot run + at the point the engine reports the cascade. It would have to be queued + and run at statement end, and BEFORE triggers -- which may modify or skip + the row -- cannot be honoured at all once the engine has already performed + the cascade. + + if (table->triggers) + return 1; + + Consumer 3: CHECK constraints on the child table (not wired up). + + Unlike triggers this one is safe to evaluate inline, since + TABLE::verify_constraints() only evaluates expressions over record[0] and + touches no other table. + + if (table->check_constraints) + return 1; + */ + + return 0; +} + + +extern "C" +int thd_fk_cascade_capture(THD *thd, TABLE *table, int which) +{ + if (!thd || !table) + return 1; + + const size_t len= table->s->reclength; + uchar **slot= (which == FK_CASCADE_IMAGE_BEFORE ? + &thd->fk_cascade_before_image : &thd->fk_cascade_after_image); + + if (!*slot && !(*slot= (uchar *) my_malloc(PSI_INSTRUMENT_ME, len, + MYF(MY_WME)))) + return 1; + + MY_BITMAP *saved_read, *saved_write, *saved_rpl_write; + fk_cascade_begin_full_row_image(table, &saved_read, &saved_write, + &saved_rpl_write); + + const int rc= table->file->fk_cascade_fetch_row(*slot); + + fk_cascade_end_full_row_image(table, saved_read, saved_write, + saved_rpl_write); + + if (rc) + { + my_free(*slot); + *slot= NULL; + } + return rc; +} + + +extern "C" +void thd_fk_cascade_row(THD *thd, TABLE *table, int action) +{ + if (!thd || !table || !thd->fk_cascade_before_image) + { + if (thd) + thd->fk_cascade_free_images(); + return; + } + + const bool is_delete= (action == FK_CASCADE_ACTION_DELETE); + + if (!is_delete && !thd->fk_cascade_after_image) + { + /* An update we could not capture the result of: report nothing. */ + thd->fk_cascade_free_images(); + return; + } + + /* + Dispatch to each interested consumer. This is the whole point of the + arrangement: the decisions live here, in one place, rather than in every + storage engine that performs cascades internally. + */ + + /* Consumer 1: binary logging. Deferred -- queued now, written to the + binary log at statement end, so that the cascaded rows follow the + originating statement's own row events and keep their execution order. */ + if (thd->variables.rpl_use_binlog_events_for_fk_cascade || + WSREP_EMULATE_BINLOG(thd)) + thd->binlog_report_cascade_row(table, is_delete, + thd->fk_cascade_before_image, + thd->fk_cascade_after_image); + + /* + Consumer 2: triggers (not wired up). Would queue TRG_EVENT_DELETE / + TRG_EVENT_UPDATE for the child row, to be run at statement end alongside + the binlog flush -- process_triggers() cannot run here, see + thd_fk_cascade_wanted(). + + Consumer 3: CHECK constraints (not wired up). Would run inline, by + pointing the table's record[0] at the after-image and calling + table->verify_constraints(). + */ + + thd->fk_cascade_free_images(); +} + + +extern "C" +void thd_fk_cascade_abort(THD *thd) +{ + if (thd) + thd->fk_cascade_free_images(); +} + + extern "C" void free_user_var(void *entry_) { user_var_entry *entry= static_cast(entry_); @@ -908,6 +1323,8 @@ THD::THD(my_thread_id id, bool is_wsrep_applier) mysys_var=0; binlog_evt_union.do_union= FALSE; binlog_table_maps= FALSE; + binlog_fk_cascade_events= FALSE; + binlog_fk_cascade_derived= FALSE; binlog_xid= 0; enable_slow_log= 0; durability_property= HA_REGULAR_DURABILITY; @@ -1907,6 +2324,10 @@ THD::~THD() THD *orig_thd= current_thd; THD_CHECK_SENTRY(this); DBUG_ENTER("~THD()"); + /* Backstop: free any FK-cascade row buffers not drained by commit/rollback */ + discard_pending_cascade_binlog(); + /* Likewise for an in-flight cascade capture the engine never reported */ + fk_cascade_free_images(); /* Make sure threads are not available via server_threads. */ assert_not_linked(); if (m_psi) diff --git a/sql/sql_class.h b/sql/sql_class.h index 1a8ab91e413c8..3256e740dfeb3 100644 --- a/sql/sql_class.h +++ b/sql/sql_class.h @@ -918,6 +918,7 @@ typedef struct system_variables my_bool sql_log_bin; my_bool binlog_annotate_row_events; my_bool binlog_direct_non_trans_update; + my_bool rpl_use_binlog_events_for_fk_cascade; my_bool column_compression_zlib_wrap; my_bool sysdate_is_now; my_bool wsrep_on; @@ -3916,6 +3917,64 @@ class THD: public THD_count, /* this must be first */ /* 1 if binlog table maps has been written */ bool binlog_table_maps; + bool binlog_fk_cascade_events; + + /* + True only while the queued FK-cascade row events are being flushed into + the binlog cache, so that the events created during that flush are marked + as cascade-derived (FK_CASCADE_DERIVED_F). + */ + bool binlog_fk_cascade_derived; + + void binlog_mark_fk_cascade_events(); + void binlog_begin_fk_cascade_derived() { binlog_fk_cascade_derived= true; } + void binlog_end_fk_cascade_derived() { binlog_fk_cascade_derived= false; } + + /* + Queue of FK-cascade row changes reported by a storage engine during the + current statement, awaiting binlogging. The server owns this queue and its + lifecycle: the engine only reports rows via binlog_report_cascade_row(); + flush_pending_cascade_binlog() writes them to the binlog cache at statement + end / commit, and discard_pending_cascade_binlog() drops them on rollback. + Buffers are server-allocated copies of the engine-supplied record images. + */ + struct Cascade_binlog_row_event + { + TABLE *table; + uchar *before_record; + uchar *after_record; /* NULL for a cascade delete */ + bool is_delete; + }; + Dynamic_array + pending_cascade_binlog_row_events{PSI_INSTRUMENT_MEM}; + + void binlog_report_cascade_row(TABLE *table, bool is_delete, + const uchar *before_record, + const uchar *after_record); + void flush_pending_cascade_binlog(); + void discard_pending_cascade_binlog(); + + /* + Row images of the FK-cascade action currently being reported by a storage + engine, materialised by thd_fk_cascade_capture() and consumed by + thd_fk_cascade_row(). At most one cascade action is in flight at a time: + the engine captures the before-image, performs the cascade, captures the + after-image, and reports, all within one call to its cascade routine. + See include/mysql/service_thd_fk_cascade.h. + */ + uchar *fk_cascade_before_image= NULL; + uchar *fk_cascade_after_image= NULL; + void fk_cascade_free_images(); + + /* + Cached statement-level answer to "may the FK cascades of this statement be + captured at all?", computed by fk_cascade_stmt_capturable() over the FK + prelocking set. Keyed on query_id, so the cache cannot outlive the + statement it was computed for and needs no separate reset hook. + */ + query_id_t fk_cascade_stmt_query_id= 0; + bool fk_cascade_stmt_ok= false; + void issue_unsafe_warnings(); void reset_unsafe_warnings() { binlog_unsafe_warning_flags= 0; } @@ -3923,6 +3982,8 @@ class THD: public THD_count, /* this must be first */ void reset_binlog_for_next_statement() { binlog_table_maps= 0; + binlog_fk_cascade_events= false; + binlog_fk_cascade_derived= false; } bool binlog_table_should_be_logged(const LEX_CSTRING *db); diff --git a/sql/sql_parse.cc b/sql/sql_parse.cc index 02f3fa90dad64..d5b0ba644f18a 100644 --- a/sql/sql_parse.cc +++ b/sql/sql_parse.cc @@ -6044,6 +6044,23 @@ mysql_execute_command(THD *thd, bool is_called_from_prepared_stmt) /* Free tables. Set stage 'closing tables' */ close_thread_tables_for_query(thd); + /* + The FK-cascade row queue holds raw TABLE pointers into the tables that + were just closed. It must already have been drained, either by the + statement-end row-event flush (binlog_flush_pending_rows_event() -> + THD::flush_pending_cascade_binlog()) or by the commit/rollback above. + A leftover entry would be flushed by some later statement against a freed + TABLE, and the "!table || !table->file" guard in + THD::flush_pending_cascade_binlog() cannot detect a TABLE that has been + freed and its memory reused. + + Restricted to the top level: the commit/rollback that drains the queue is + itself under "! thd->in_sub_stmt", so a substatement legitimately leaves + its rows queued for the enclosing statement to flush. + */ + DBUG_ASSERT(thd->in_sub_stmt || + thd->pending_cascade_binlog_row_events.elements() == 0); + #ifndef DBUG_OFF if (lex->sql_command != SQLCOM_SET_OPTION && ! thd->in_sub_stmt) DEBUG_SYNC(thd, "execute_command_after_close_tables"); diff --git a/sql/sys_vars.cc b/sql/sys_vars.cc index 4900acef66a74..d72daf9e66286 100644 --- a/sql/sys_vars.cc +++ b/sql/sys_vars.cc @@ -781,6 +781,26 @@ Sys_binlog_create_tmptable_format( NO_MUTEX_GUARD, NOT_IN_BINLOG, ON_CHECK(binlog_create_tmp_format_check)); +static bool rpl_use_binlog_events_for_fk_cascade_check(sys_var *self, THD *thd, set_var *var) +{ + if (var->type == OPT_GLOBAL) + return false; + + if (unlikely(error_if_in_trans_or_substatement(thd, + ER_STORED_FUNCTION_PREVENTS_SWITCH_SQL_LOG_BIN, + ER_INSIDE_TRANSACTION_PREVENTS_SWITCH_SQL_LOG_BIN))) + return true; + + return false; +} + +static Sys_var_mybool Sys_rpl_use_binlog_events_for_fk_cascade( + "rpl_use_binlog_events_for_fk_cascade", + "If enabled, the master will write row events for foreign key cascade operations", + SESSION_VAR(rpl_use_binlog_events_for_fk_cascade), + CMD_LINE(OPT_ARG), DEFAULT(FALSE), + NO_MUTEX_GUARD, NOT_IN_BINLOG, ON_CHECK(rpl_use_binlog_events_for_fk_cascade_check)); + static bool deprecated_explicit_defaults_for_timestamp(sys_var *self, THD *thd, set_var *var) { diff --git a/storage/innobase/handler/ha_innodb.cc b/storage/innobase/handler/ha_innodb.cc index b60155f995683..46f8696b87085 100644 --- a/storage/innobase/handler/ha_innodb.cc +++ b/storage/innobase/handler/ha_innodb.cc @@ -49,8 +49,9 @@ this program; if not, write to the Free Software Foundation, Inc., #include #include #include -#include +#include #include +#include #include #include "sql_type_geom.h" #include "scope.h" @@ -3101,6 +3102,8 @@ ha_innobase::ha_innobase( TABLE_SHARE* table_arg) :handler(hton, table_arg), m_prebuilt(), + m_fk_cascade_rec(), + m_fk_cascade_index(), m_user_thd(), m_int_table_flags(HA_REC_NOT_IN_SEQ | HA_NULL_IN_KEY @@ -8695,6 +8698,55 @@ ATTRIBUTE_COLD bool wsrep_append_table_key(MYSQL_THD thd, } #endif /* WITH_WSREP */ +/** Convert the clustered index record recorded by fk_cascade_set_cursor() +into MySQL row format. + +This is the engine's whole contribution to reporting an FK cascade action: +the server decides which columns the image consists of (it has already set +the table's column bitmaps accordingly) and owns 'buf'; all that is left +that only InnoDB can do is the record conversion itself. + +The row template is rebuilt for the server's bitmaps for the duration of the +conversion and reset afterwards, because the template in force belongs to the +statement being executed and describes a different set of columns. + +@param buf destination row buffer, table->s->reclength bytes +@return 0 on success, HA_ERR_GENERIC if the record could not be converted */ +int +ha_innobase::fk_cascade_fetch_row(uchar* buf) +{ + if (m_prebuilt == NULL || m_prebuilt->mysql_template == NULL + || m_fk_cascade_rec == NULL || m_fk_cascade_index == NULL) { + return HA_ERR_GENERIC; + } + + rebuild_template_for_cascade_binlog_row_image(); + + mem_heap_t* offs_heap = NULL; + rec_offs offsets_[REC_OFFS_NORMAL_SIZE]; + rec_offs_init(offsets_); + const rec_offs* offsets = rec_get_offsets( + m_fk_cascade_rec, m_fk_cascade_index, offsets_, + m_fk_cascade_index->n_core_fields, ULINT_UNDEFINED, &offs_heap); + + dict_index_t* saved_index = m_prebuilt->index; + m_prebuilt->index = m_fk_cascade_index; + + const bool ok = row_sel_store_mysql_rec( + buf, m_prebuilt, m_fk_cascade_rec, NULL, true, + m_fk_cascade_index, offsets); + + m_prebuilt->index = saved_index; + + if (UNIV_LIKELY_NULL(offs_heap)) { + mem_heap_free(offs_heap); + } + + reset_template_for_cascade_binlog_row_image(); + + return ok ? 0 : HA_ERR_GENERIC; +} + /** Updates a row given as a parameter to a new value. Note that we are given whole rows, not just the fields which are updated: this incurs some diff --git a/storage/innobase/handler/ha_innodb.h b/storage/innobase/handler/ha_innodb.h index d47a52e5061ea..a3bba2d09911f 100644 --- a/storage/innobase/handler/ha_innodb.h +++ b/storage/innobase/handler/ha_innodb.h @@ -517,6 +517,47 @@ class ha_innobase final : public handler /** Save CPU time with prebuilt/cached data structures */ row_prebuilt_t* m_prebuilt; + /** Clustered index record that fk_cascade_fetch_row() should convert, + and the index it belongs to. Set by fk_cascade_set_cursor() and only + valid for the duration of one server capture call. */ + const rec_t* m_fk_cascade_rec; + dict_index_t* m_fk_cascade_index; + +public: + row_prebuilt_t* innobase_prebuilt() const { return m_prebuilt; } + + void rebuild_template_for_cascade_binlog_row_image() + { + reset_template(); + build_template(true); + } + + void reset_template_for_cascade_binlog_row_image() + { + reset_template(); + } + + /** Record the FK-cascade cursor position that the next + fk_cascade_fetch_row() should materialise. + + Set by row_ins_foreign_check_on_constraint() immediately before it + asks the server to capture a row image; the pointers are only valid + for the duration of that call, while the caller holds the latch on + the page and the cursor is positioned. + @param rec clustered index record to convert + @param index the clustered index 'rec' belongs to */ + void fk_cascade_set_cursor(const rec_t* rec, dict_index_t* index) + { + m_fk_cascade_rec = rec; + m_fk_cascade_index = index; + } + + /** Convert the record set by fk_cascade_set_cursor() into MySQL row + format, honouring the column bitmaps the server has already set up. + @param buf destination row buffer, table->s->reclength bytes + @return 0 on success, HA_ERR_* otherwise */ + int fk_cascade_fetch_row(uchar* buf) override; + /** Thread handle of the user currently using the handler; this is set in external_lock function */ THD* m_user_thd; diff --git a/storage/innobase/include/row0sel.h b/storage/innobase/include/row0sel.h index 8939fe704a3cf..7c37bd06e40d6 100644 --- a/storage/innobase/include/row0sel.h +++ b/storage/innobase/include/row0sel.h @@ -27,7 +27,7 @@ Created 12/19/1997 Heikki Tuuri #pragma once #include "data0data.h" -#include "que0types.h" +#include "dict0dict.h" #include "trx0types.h" #include "read0types.h" #include "row0types.h" @@ -37,6 +37,16 @@ Created 12/19/1997 Heikki Tuuri #include "row0mysql.h" #include "row0query.h" +MY_ATTRIBUTE((warn_unused_result)) +bool row_sel_store_mysql_rec( + byte* mysql_rec, + row_prebuilt_t* prebuilt, + const rec_t* rec, + const dtuple_t* vrow, + bool rec_clust, + const dict_index_t* index, + const rec_offs* offsets); + /*********************************************************************//** Creates a select node struct. @return own: select node struct */ diff --git a/storage/innobase/include/trx0trx.h b/storage/innobase/include/trx0trx.h index bef9311b81b32..b8c1de3e471a4 100644 --- a/storage/innobase/include/trx0trx.h +++ b/storage/innobase/include/trx0trx.h @@ -44,6 +44,7 @@ Created 3/26/1996 Heikki Tuuri struct mtr_t; struct rw_trx_hash_element_t; class ha_handler_stats; +struct TABLE; /******************************************************************//** Set detailed error message for the transaction. */ @@ -962,6 +963,7 @@ struct trx_t : ilist_node<> transaction branch */ trx_mod_tables_t mod_tables; /*!< List of tables that were modified by this transaction */ + /*------------------------------*/ char* detailed_error; /*!< detailed error message for last error, or empty. */ diff --git a/storage/innobase/row/row0ins.cc b/storage/innobase/row/row0ins.cc index 1920289945f27..55e545af09859 100644 --- a/storage/innobase/row/row0ins.cc +++ b/storage/innobase/row/row0ins.cc @@ -40,6 +40,10 @@ Created 4/20/1996 Heikki Tuuri #include "log0log.h" #include "eval0eval.h" #include "data0data.h" +#include "log0recv.h" +#include "handler.h" +#include "table.h" +#include "ha_innodb.h" #include "buf0lru.h" #include "fts0fts.h" #include "fts0types.h" @@ -47,12 +51,49 @@ Created 4/20/1996 Heikki Tuuri # include "btr0sea.h" #endif #include "sql_class.h" // THD +#include +#include #ifdef WITH_WSREP #include #include #include "ha_prototypes.h" #endif /* WITH_WSREP */ +TABLE *find_fk_open_table(THD *thd, const char *db, size_t db_len, + const char *table, size_t table_len); + +/** Find the MySQL TABLE of a cascade child table, if the statement has it +open. Only the engine can map its own dict_table_t to a name, which is why +this much stays here; whether the server wants anything done with the table +is answered by thd_fk_cascade_wanted(). */ +static TABLE* +row_ins_find_open_table_for_cascade( + trx_t* trx, + dict_table_t* child) +{ + THD* thd; + TABLE* mysql_table; + char db_buf[NAME_LEN + 1]; + char tbl_buf[NAME_LEN + 1]; + ulint db_buf_len; + ulint tbl_buf_len; + + thd = trx->mysql_thd; + if (thd == NULL) { + return NULL; + } + + if (!child->parse_name(db_buf, tbl_buf, &db_buf_len, &tbl_buf_len)) { + return NULL; + } + + mysql_table = find_fk_open_table(thd, + db_buf, db_buf_len, + tbl_buf, tbl_buf_len); + + return mysql_table; +} + /************************************************************************* IMPORTANT NOTE: Any operation that generates redo MUST check that there is enough space in the redo log before for that operation. This is @@ -269,7 +310,7 @@ static MY_ATTRIBUTE((nonnull, warn_unused_result)) dberr_t row_ins_clust_index_entry_by_modify( /*================================*/ - btr_pcur_t* pcur, /*!< in/out: a persistent cursor pointing + btr_pcur_t* pcur, /*!< in/out: a persistent cursor pointing to the clust_rec that is being modified. */ ulint flags, /*!< in: undo logging and locking flags */ ulint mode, /*!< in: BTR_MODIFY_LEAF or BTR_MODIFY_TREE, @@ -299,7 +340,6 @@ row_ins_clust_index_entry_by_modify( /* In delete-marked records, DB_TRX_ID must always refer to an existing undo log record. */ ut_ad(rec_get_trx_id(rec, cursor->index())); - /* Build an update vector containing all the fields to be modified; NOTE that this vector may NOT contain system columns trx_id or roll_ptr */ @@ -1012,6 +1052,11 @@ row_ins_foreign_check_on_constraint( mem_heap_t* tmp_heap = NULL; doc_id_t doc_id = FTS_NULL_DOC_ID; + TABLE* child_mysql_table = NULL; + bool need_cascade_binlog = false; + bool can_cascade_binlog = false; + bool have_after_image = false; + DBUG_ENTER("row_ins_foreign_check_on_constraint"); trx = thr_get_trx(thr); @@ -1346,15 +1391,95 @@ row_ins_foreign_check_on_constraint( cascade->state = UPD_NODE_UPDATE_CLUSTERED; + /* + Ask the server whether it wants to be told about this cascade action, + and if so let it capture the child row's before-image. Everything the + server does with it -- binary logging, and in future triggers, CHECK + constraints and so on -- is decided there, not here; see + include/mysql/service_thd_fk_cascade.h. + + If the master has binlogged the cascaded rows, the events carry + FK_CASCADE_EVENTS_F, causing the applier to run with foreign key + checks disabled, and this cascade function is never reached. + */ + child_mysql_table = row_ins_find_open_table_for_cascade(trx, table); + + if (child_mysql_table + && thd_fk_cascade_wanted(trx->mysql_thd, child_mysql_table)) { + ha_innobase* ib = static_cast( + child_mysql_table->file); + mtr_start(mtr); + if (cascade->pcur->restore_position(BTR_SEARCH_LEAF, mtr) + == btr_pcur_t::SAME_ALL) { + ib->fk_cascade_set_cursor( + btr_pcur_get_rec(cascade->pcur), clust_index); + need_cascade_binlog = !thd_fk_cascade_capture( + trx->mysql_thd, child_mysql_table, + FK_CASCADE_IMAGE_BEFORE); + ib->fk_cascade_set_cursor(NULL, NULL); + } + mtr_commit(mtr); + } + err = row_update_cascade_for_mysql(thr, cascade, foreign->foreign_table); mtr_start(mtr); + can_cascade_binlog = (err == DB_SUCCESS && need_cascade_binlog); + have_after_image = false; + + if (can_cascade_binlog && cascade->is_delete != PLAIN_DELETE) { + if (cascade->pcur->restore_position(BTR_SEARCH_LEAF, mtr) + == btr_pcur_t::SAME_ALL) { + const rec_t* after_rec = btr_pcur_get_rec(cascade->pcur); + if (page_rec_is_user_rec(after_rec) + && !rec_get_deleted_flag(after_rec, + dict_table_is_comp(table))) { + ha_innobase* ib = static_cast( + child_mysql_table->file); + ib->fk_cascade_set_cursor(after_rec, clust_index); + have_after_image = !thd_fk_cascade_capture( + trx->mysql_thd, child_mysql_table, + FK_CASCADE_IMAGE_AFTER); + ib->fk_cascade_set_cursor(NULL, NULL); + } + } + } + /* Restore pcur position */ if (pcur->restore_position(BTR_SEARCH_LEAF, mtr) - != btr_pcur_t::SAME_ALL) { + != btr_pcur_t::SAME_ALL && err == DB_SUCCESS) { + err = DB_CORRUPTION; + } + + mtr_commit(mtr); + + if (need_cascade_binlog) { + if (can_cascade_binlog + && (cascade->is_delete == PLAIN_DELETE + || have_after_image)) { + /* + Report the completed action. What happens to it is + the server's business; ordering of the resulting + binlog events relative to the originating statement, + in particular, is handled there. + */ + thd_fk_cascade_row( + trx->mysql_thd, child_mysql_table, + cascade->is_delete == PLAIN_DELETE + ? FK_CASCADE_ACTION_DELETE + : FK_CASCADE_ACTION_UPDATE); + } else { + /* Captured a before-image we are not going to use. */ + thd_fk_cascade_abort(trx->mysql_thd); + } + } + + mtr_start(mtr); + if (pcur->restore_position(BTR_SEARCH_LEAF, mtr) + != btr_pcur_t::SAME_ALL && err == DB_SUCCESS) { err = DB_CORRUPTION; } @@ -2564,7 +2689,7 @@ statement @return true if it is insert statement */ static bool thd_sql_is_insert(const THD *thd) noexcept { - switch (thd->lex->sql_command) { + switch (thd_sql_command(thd)) { case SQLCOM_INSERT: case SQLCOM_INSERT_SELECT: return true; diff --git a/storage/innobase/row/row0sel.cc b/storage/innobase/row/row0sel.cc index 6866619b7c121..79cbf06c369dc 100644 --- a/storage/innobase/row/row0sel.cc +++ b/storage/innobase/row/row0sel.cc @@ -3134,7 +3134,7 @@ be needed in the query. @retval true on success @retval false if not all columns could be retrieved */ MY_ATTRIBUTE((warn_unused_result)) -static bool row_sel_store_mysql_rec( +bool row_sel_store_mysql_rec( byte* mysql_rec, row_prebuilt_t* prebuilt, const rec_t* rec, diff --git a/storage/innobase/trx/trx0trx.cc b/storage/innobase/trx/trx0trx.cc index 3409c66ea0796..5632981f1a2f5 100644 --- a/storage/innobase/trx/trx0trx.cc +++ b/storage/innobase/trx/trx0trx.cc @@ -410,6 +410,7 @@ void trx_t::free() noexcept trx_sys.deregister_trx(this); check_unique_secondary= true; check_foreigns= true; + assert_freed(); trx_sys.rw_trx_hash.put_pins(this); mysql_thd= nullptr; @@ -930,7 +931,7 @@ trx_start_low( /* Check whether it is an AUTOCOMMIT SELECT */ if (const THD* thd = trx->mysql_thd) { trx->auto_commit = !(thd->variables.option_bits - & (OPTION_NOT_AUTOCOMMIT | OPTION_BEGIN)) + & (OPTION_NOT_AUTOCOMMIT | OPTION_BEGIN)) && thd->lex->sql_command == SQLCOM_SELECT; trx->read_only = (!trx->dict_operation && thd->tx_read_only) || srv_read_only_mode; From a6cef7a9cc6139f7eea2fc2ee423bedfc88136fa Mon Sep 17 00:00:00 2001 From: sjaakola Date: Wed, 7 Oct 2026 15:28:00 +0300 Subject: [PATCH 2/2] MDEV-38243 Tests for nested and self-referencing FK cascades 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) --- MDEV-38243-design.md | 61 ++- include/mysql/service_thd_fk_cascade.h | 6 + .../r/galera_fk_cascade_binlog_events.result | 153 +++++++ ...ra_fk_cascade_binlog_events_log_bin.result | 153 +++++++ .../t/galera_fk_cascade_binlog_events.inc | 153 +++++++ .../t/galera_fk_cascade_binlog_events.test | 11 + ...alera_fk_cascade_binlog_events_log_bin.cnf | 9 + ...lera_fk_cascade_binlog_events_log_bin.test | 10 + ...ascade_binlog_row_mixed_eligibility.result | 138 ++++++ ...pl_fk_cascade_binlog_row_multilevel.result | 399 ++++++++++++++++ ..._fk_cascade_binlog_row_slave_option.result | 4 +- ..._cascade_binlog_row_mixed_eligibility.test | 218 +++++++++ .../rpl_fk_cascade_binlog_row_multilevel.test | 430 ++++++++++++++++++ ...pl_fk_cascade_binlog_row_slave_option.test | 27 +- sql/handler.cc | 11 + sql/log.cc | 5 +- sql/sql_class.cc | 65 ++- sql/sql_class.h | 22 +- storage/innobase/handler/ha_innodb.cc | 6 +- storage/innobase/row/row0ins.cc | 12 +- 20 files changed, 1827 insertions(+), 66 deletions(-) create mode 100644 mysql-test/suite/galera/r/galera_fk_cascade_binlog_events.result create mode 100644 mysql-test/suite/galera/r/galera_fk_cascade_binlog_events_log_bin.result create mode 100644 mysql-test/suite/galera/t/galera_fk_cascade_binlog_events.inc create mode 100644 mysql-test/suite/galera/t/galera_fk_cascade_binlog_events.test create mode 100644 mysql-test/suite/galera/t/galera_fk_cascade_binlog_events_log_bin.cnf create mode 100644 mysql-test/suite/galera/t/galera_fk_cascade_binlog_events_log_bin.test create mode 100644 mysql-test/suite/rpl/r/rpl_fk_cascade_binlog_row_mixed_eligibility.result create mode 100644 mysql-test/suite/rpl/r/rpl_fk_cascade_binlog_row_multilevel.result create mode 100644 mysql-test/suite/rpl/t/rpl_fk_cascade_binlog_row_mixed_eligibility.test create mode 100644 mysql-test/suite/rpl/t/rpl_fk_cascade_binlog_row_multilevel.test diff --git a/MDEV-38243-design.md b/MDEV-38243-design.md index 35adf067dcc7f..7158df59423a4 100644 --- a/MDEV-38243-design.md +++ b/MDEV-38243-design.md @@ -249,8 +249,8 @@ classic replica-side cascade handles all of it — slower, but correct. The answer is cached on the `THD` and keyed on `query_id`, so it cannot outlive the statement it was computed for and needs no reset hook. -`thd_fk_cascade_capture()` allocates the image slot -(`THD::fk_cascade_before_image` / `fk_cascade_after_image`, `reclength` bytes), +`thd_fk_cascade_capture()` allocates the image slot in the innermost frame of +`THD::fk_cascade_image_stack` (`reclength` bytes of the table being captured), switches the table to the full row image via `fk_cascade_begin_full_row_image()` — `tmp_set` set to all columns minus the virtual ones, installed as read/write set, and as `rpl_write_set` if that was @@ -259,13 +259,18 @@ bitmaps with `fk_cascade_end_full_row_image()`. This bitmap policy is *server* policy, which is why it lives here and not in the engine. On failure the slot is freed and non-zero returned. -`thd_fk_cascade_row()` consumes the images and dispatches. At most one cascade -action is in flight per THD at a time, which is what lets the images live as two -plain `THD` members: the engine captures before, cascades, captures after, and -reports, all within one call to its cascade routine. An update whose -after-image could not be captured is dropped rather than reported. The images -are freed on the way out (`THD::fk_cascade_free_images()`), and `~THD()` frees -them as a backstop for an in-flight capture the engine never reported. +`thd_fk_cascade_row()` consumes the images and dispatches. The engine captures +before, cascades, captures after, and reports, all within one call to its +cascade routine. Performing the cascade may recurse into the next level of the +FK chain (InnoDB tracks this as `thr->fk_cascade_depth`), which captures and +reports its own rows while the outer action is still in flight. The images +therefore form a stack: capturing a before-image opens a frame, and +`thd_fk_cascade_row()` or `thd_fk_cascade_abort()` closes it, also when the +capture failed. An update whose after-image could not be captured is dropped +rather than reported. A frame's images are freed when it is closed +(`THD::fk_cascade_pop_images()`), and `~THD()` frees any left over +(`THD::fk_cascade_free_images()`) as a backstop for an in-flight capture the +engine never reported. ### 6.4 Consumers @@ -528,6 +533,19 @@ order*. Emitting deletes inline would place every cascade delete ahead of every deferred update within a statement, reordering events that touch the same row and potentially making the replica apply an update to an already-deleted row. +The queue is emitted before each row change of the statement itself is logged, +in `handler::binlog_log_row()`: the cascades that change ran happened inside +it, before the change was logged. So the cascaded rows precede the row whose +change ran them, and the binlog keeps the order of execution also relative to +the statement's own rows. That matters when a cascade changes a row the +statement changes after it, as with a self-referencing foreign key: for +`DELETE FROM s WHERE id IN (1,2)` with `s.parent_id -> s.id ON DELETE SET +NULL`, the cascade sets the parent of row 2 to NULL before row 2 is deleted, +and a replica applying the delete first would not find the row to update. +Cascade-derived rows and the statement's own go to separate row events +(`Event_log::prepare_pending_rows_event()`), so that `FK_CASCADE_DERIVED_F` +still marks exactly the derived ones. + ## 7. Lifecycle — flush and discard The queue is `THD::pending_cascade_binlog_row_events`, a @@ -543,8 +561,11 @@ already-pending row event gets flagged too (§8). `table->s->all_set`, choosing `Delete_rows_log_event` vs. `Update_rows_log_event` from `is_delete`, and frees the record copies as it goes. Temporary tables and entries whose `TABLE`/handler is gone are skipped -(their buffers freed). It is driven from two choke points: +(their buffers freed). It is driven from three choke points: +- `handler::binlog_log_row()` in `sql/handler.cc`, before each row of the + statement itself is logged (§6.5), unless the queued rows themselves are + being logged; - `binlog_flush_pending_rows_event()` in `sql/log.cc` at **statement end** (`stmt_end`), guarded by `thd->binlog_fk_cascade_events` and the feature test; the pending event is re-read afterwards, since flushing the cascade @@ -671,13 +692,13 @@ collide). ## 11. Known limitations and edge cases -- **Applier capture is a no-op (by design/limitation).** The capture requires - the child table to be open via FK prelocking (`PRELOCK_FK`). A row-based - applier opens only the tables named in the events, not prelocked FK children, - so `find_fk_open_table()` returns NULL on an applier. Therefore, with the - feature enabled **only on a replica** (origin OFF), the replica re-cascades - the classic way and its own binary log contains only parent events — the - option is effectively inert on the applier path. This is pinned by +- **An applier captures the cascades it runs.** With the feature enabled + **only on a replica** (origin OFF), the parent events carry no + `FK_CASCADE_EVENTS_F`, so the replica re-cascades the classic way; the child + it cascades into is open via FK prelocking there too, and the cascade is + captured as in any other session. Its own binary log then contains the + parent events and the cascaded child rows, and a replica of it need not + re-run the cascade. This is pinned by `rpl_fk_cascade_binlog_row_slave_option`. - **Row format only.** Statement-based logging is unaffected. - **Eligibility is all-or-nothing per statement.** A single ineligible child @@ -725,7 +746,7 @@ compatibility/fallback knob, not a routine mode. | Service implementation, consumer dispatch | `sql/sql_class.cc` | `fk_cascade_table_eligible()`, `fk_cascade_stmt_capturable()`, `fk_cascade_begin_full_row_image()`, `fk_cascade_end_full_row_image()`, the four `thd_fk_cascade_*()` | | Statement-level eligibility cache | `sql/sql_class.h` | `THD::fk_cascade_stmt_query_id`, `THD::fk_cascade_stmt_ok` | | FK prelocking set | `sql/sql_base.{h,cc}` | `prepare_fk_prelocking_list()` (child `trg_event_map` derived from the cascade action, §6.4.3), `table_already_fk_prelocked()` (returns the existing entry so events can be merged) | -| In-flight images | `sql/sql_class.h`, `sql/sql_class.cc` | `THD::fk_cascade_before_image`, `fk_cascade_after_image`, `fk_cascade_free_images()` | +| In-flight images | `sql/sql_class.h`, `sql/sql_class.cc` | `THD::fk_cascade_image_stack`, `fk_cascade_pop_images()`, `fk_cascade_free_images()` | | Handler hook | `sql/handler.h` | `handler::fk_cascade_fetch_row()` (default `HA_ERR_WRONG_COMMAND`) | | THD queue, flush / discard, marking | `sql/sql_class.{h,cc}` | `Cascade_binlog_row_event`, `pending_cascade_binlog_row_events`, `binlog_report_cascade_row()`, `flush_pending_cascade_binlog()`, `discard_pending_cascade_binlog()`, `binlog_fk_cascade_events`, `binlog_fk_cascade_derived`, `binlog_mark_fk_cascade_events()`, `binlog_begin/end_fk_cascade_derived()` | | Engine capture driver | `storage/innobase/row/row0ins.cc` | `row_ins_foreign_check_on_constraint()`, `row_ins_find_open_table_for_cascade()` | @@ -746,8 +767,8 @@ compatibility/fallback knob, not a routine mode. rollback / rollback-to-savepoint; nothing spurious is logged. - **`rpl_fk_set_null_binlog_row`** — `SET NULL` cascade capture. - **`rpl_fk_cascade_binlog_row_slave_option`** — origin OFF / replica ON: - replication stays correct and the replica's binlog contains only parent - events (pins the applier no-op of §11). + replication stays correct and the replica's binlog contains the parent + events and the cascaded child rows it captured (§11). - **`rpl_fk_cascade_binlog_row_mixed_eligibility`** — a parent with one eligible and one ineligible child: asserts that no derived events are written and the replica keeps no orphan (pins the statement-level decision diff --git a/include/mysql/service_thd_fk_cascade.h b/include/mysql/service_thd_fk_cascade.h index e2ebba8cae3eb..7e121f6a5a078 100644 --- a/include/mysql/service_thd_fk_cascade.h +++ b/include/mysql/service_thd_fk_cascade.h @@ -86,6 +86,12 @@ int thd_fk_cascade_wanted(MYSQL_THD thd, struct TABLE *table); 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); diff --git a/mysql-test/suite/galera/r/galera_fk_cascade_binlog_events.result b/mysql-test/suite/galera/r/galera_fk_cascade_binlog_events.result new file mode 100644 index 0000000000000..58b551fff8589 --- /dev/null +++ b/mysql-test/suite/galera/r/galera_fk_cascade_binlog_events.result @@ -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; diff --git a/mysql-test/suite/galera/r/galera_fk_cascade_binlog_events_log_bin.result b/mysql-test/suite/galera/r/galera_fk_cascade_binlog_events_log_bin.result new file mode 100644 index 0000000000000..f174e127d7d1a --- /dev/null +++ b/mysql-test/suite/galera/r/galera_fk_cascade_binlog_events_log_bin.result @@ -0,0 +1,153 @@ +connection node_2; +connection node_1; +connection node_1; +SELECT @@log_bin; +@@log_bin +1 +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; diff --git a/mysql-test/suite/galera/t/galera_fk_cascade_binlog_events.inc b/mysql-test/suite/galera/t/galera_fk_cascade_binlog_events.inc new file mode 100644 index 0000000000000..2d038c257042a --- /dev/null +++ b/mysql-test/suite/galera/t/galera_fk_cascade_binlog_events.inc @@ -0,0 +1,153 @@ +# +# MDEV-38243: FK cascade changes written as row events into the write set +# (rpl_use_binlog_events_for_fk_cascade=ON) must be applied on the other node +# without re-running the cascade, and the nodes must stay consistent. +# +# If node_2 both applied the cascaded row events and re-executed the cascade, +# applying would fail on already deleted/updated rows and the node would drop +# out of the cluster, so a consistent node_2 is the observable here. +# + +--connection node_1 +SET SESSION rpl_use_binlog_events_for_fk_cascade=1; + +--echo # +--echo # Scenario 1: ON DELETE CASCADE through 3 tables +--echo # +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; +SELECT * FROM p ORDER BY id; +SELECT * FROM c ORDER BY id; + +--connection node_1 +DROP TABLE c, p, gp; + +--echo # +--echo # Scenario 2: ON UPDATE CASCADE and ON UPDATE / DELETE SET NULL +--echo # +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; +SELECT * FROM cc ORDER BY id; +SELECT * FROM cn ORDER BY id; + +--echo # +--echo # Scenario 3: rolled back cascades are not replicated +--echo # +--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; +SELECT * FROM cc ORDER BY id; +SELECT * FROM cn ORDER BY id; + +--connection node_1 +DROP TABLE cc, cn, p; + +--echo # +--echo # Scenario 4: parallel applying on node_2 +--echo # +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; + +--let $count = 200 +--disable_query_log +while ($count) +{ + --eval INSERT INTO p1 VALUES ($count, 0) + --eval INSERT INTO p2 VALUES ($count, 0) + --eval INSERT INTO c VALUES ($count, $count, $count, 0) + --dec $count +} +--enable_query_log + +--connection node_2 +SET GLOBAL wsrep_slave_threads=4; + +--connection node_1 +--let $count = 200 +--disable_query_log +while ($count) +{ + --eval DELETE FROM p2 WHERE f1=$count + --eval DELETE FROM p1 WHERE f1=$count + --dec $count +} +--enable_query_log + +--connection node_2 +SELECT COUNT(*) FROM p1; +SELECT COUNT(*) FROM p2; +SELECT COUNT(*) FROM c; +SET GLOBAL wsrep_slave_threads=DEFAULT; + +--connection node_1 +DROP TABLE c, p1, p2; + +--echo # +--echo # Scenario 5: child without PK and with a virtual column in a key, +--echo # next to an eligible sibling +--echo # +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; +SELECT * FROM ce ORDER BY id; +SELECT id, pid, v, vc FROM ci ORDER BY id; + +--connection node_1 +DROP TABLE ce, ci, p; + +--echo # +--echo # Both nodes are still in the cluster and applied without errors +--echo # +--connection node_2 +SELECT VARIABLE_VALUE AS cluster_size FROM INFORMATION_SCHEMA.GLOBAL_STATUS WHERE VARIABLE_NAME = 'wsrep_cluster_size'; +SELECT VARIABLE_VALUE AS local_state FROM INFORMATION_SCHEMA.GLOBAL_STATUS WHERE VARIABLE_NAME = 'wsrep_local_state_comment'; + +--connection node_1 +SET SESSION rpl_use_binlog_events_for_fk_cascade=DEFAULT; diff --git a/mysql-test/suite/galera/t/galera_fk_cascade_binlog_events.test b/mysql-test/suite/galera/t/galera_fk_cascade_binlog_events.test new file mode 100644 index 0000000000000..b04eeb9c193ff --- /dev/null +++ b/mysql-test/suite/galera/t/galera_fk_cascade_binlog_events.test @@ -0,0 +1,11 @@ +# +# MDEV-38243: FK cascade row events in Galera without a binary log +# (WSREP_EMULATE_BINLOG path). +# +--source include/galera_cluster.inc +--source include/have_innodb.inc + +--connection node_1 +SELECT @@log_bin; + +--source galera_fk_cascade_binlog_events.inc diff --git a/mysql-test/suite/galera/t/galera_fk_cascade_binlog_events_log_bin.cnf b/mysql-test/suite/galera/t/galera_fk_cascade_binlog_events_log_bin.cnf new file mode 100644 index 0000000000000..1f723fc6c0775 --- /dev/null +++ b/mysql-test/suite/galera/t/galera_fk_cascade_binlog_events_log_bin.cnf @@ -0,0 +1,9 @@ +!include ../galera_2nodes.cnf + +[mysqld.1] +log-bin=mysqld-bin +log-slave-updates + +[mysqld.2] +log-bin=mysqld-bin +log-slave-updates diff --git a/mysql-test/suite/galera/t/galera_fk_cascade_binlog_events_log_bin.test b/mysql-test/suite/galera/t/galera_fk_cascade_binlog_events_log_bin.test new file mode 100644 index 0000000000000..10aa957114578 --- /dev/null +++ b/mysql-test/suite/galera/t/galera_fk_cascade_binlog_events_log_bin.test @@ -0,0 +1,10 @@ +# +# MDEV-38243: FK cascade row events in Galera with the binary log enabled. +# +--source include/galera_cluster.inc +--source include/have_innodb.inc + +--connection node_1 +SELECT @@log_bin; + +--source galera_fk_cascade_binlog_events.inc diff --git a/mysql-test/suite/rpl/r/rpl_fk_cascade_binlog_row_mixed_eligibility.result b/mysql-test/suite/rpl/r/rpl_fk_cascade_binlog_row_mixed_eligibility.result new file mode 100644 index 0000000000000..57aa6f32f509b --- /dev/null +++ b/mysql-test/suite/rpl/r/rpl_fk_cascade_binlog_row_mixed_eligibility.result @@ -0,0 +1,138 @@ +include/master-slave.inc +[connection master] +SET @old_rpl_use_binlog_events_for_fk_cascade := @@session.rpl_use_binlog_events_for_fk_cascade; +SET SESSION default_storage_engine=InnoDB; +SET SESSION binlog_format=ROW; +SET SESSION rpl_use_binlog_events_for_fk_cascade=1; +# +# Case 1: eligible and ineligible siblings of the same parent +# +CREATE TABLE p (id INT PRIMARY KEY, v INT) ENGINE=InnoDB; +CREATE TABLE ce ( +id INT PRIMARY KEY, pid INT, v INT, KEY(pid), +CONSTRAINT fk_ce 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), +CONSTRAINT fk_ci FOREIGN KEY (pid) REFERENCES p(id) +ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB; +CREATE TABLE cn ( +id INT, pid INT, v INT, vc INT AS (v + 1) VIRTUAL, +KEY(pid), KEY(vc), +CONSTRAINT fk_cn 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),(4,40); +INSERT INTO ce (id,pid,v) VALUES (100,1,1),(200,2,2),(300,3,3),(400,4,4); +INSERT INTO ci (id,pid,v) VALUES (100,1,1),(200,2,2),(300,3,3),(400,4,4); +INSERT INTO cn (id,pid,v) VALUES (100,1,1),(200,2,2),(300,3,3),(400,4,4); +connection slave; +connection master; +FLUSH BINARY LOGS; +UPDATE p SET id=11 WHERE id=1; +DELETE FROM p WHERE id=2; +BEGIN; +UPDATE p SET id=33 WHERE id=3; +DELETE FROM p WHERE id=4; +COMMIT; +connection slave; +# Last_SQL_Errno = 0 (expected 0) +SELECT id, pid, v FROM ce ORDER BY id; +id pid v +100 11 1 +300 33 3 +SELECT id, pid, v, vc FROM ci ORDER BY id; +id pid v vc +100 11 1 2 +300 33 3 4 +SELECT id, pid, v, vc FROM cn ORDER BY id; +id pid v vc +100 NULL 1 2 +200 NULL 2 3 +300 NULL 3 4 +400 NULL 4 5 +include/diff_tables.inc [master:p, slave:p] +include/diff_tables.inc [master:ce, slave:ce] +include/diff_tables.inc [master:ci, slave:ci] +include/diff_tables.inc [master:cn, slave:cn] +connection master; +FLUSH BINARY LOGS; +NOT FOUND /### (UPDATE|DELETE FROM) `test`.`ce`/ in fk_cascade_mixed_eligibility.sql +NOT FOUND /### (UPDATE|DELETE FROM) `test`.`ci`/ in fk_cascade_mixed_eligibility.sql +NOT FOUND /### (UPDATE|DELETE FROM) `test`.`cn`/ in fk_cascade_mixed_eligibility.sql +DROP TABLE ce, ci, cn, p; +connection slave; +# +# Case 2: ineligible table reachable only transitively (grandchild) +# +connection master; +CREATE TABLE p (id INT PRIMARY KEY, v INT) ENGINE=InnoDB; +CREATE TABLE ce ( +id INT PRIMARY KEY, pid INT, v INT, KEY(pid), +CONSTRAINT fk_ce FOREIGN KEY (pid) REFERENCES p(id) +ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB; +CREATE TABLE gi ( +id INT, cid INT, v INT, vc INT AS (v + 1) VIRTUAL, +KEY(cid), KEY(vc), +CONSTRAINT fk_gi FOREIGN KEY (cid) REFERENCES ce(id) +ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB; +INSERT INTO p VALUES (1,10),(2,20); +INSERT INTO ce (id,pid,v) VALUES (100,1,1),(101,1,2),(200,2,3); +INSERT INTO gi (id,cid,v) VALUES (1,100,1),(2,101,2),(3,200,3); +connection slave; +connection master; +FLUSH BINARY LOGS; +DELETE FROM p WHERE id=1; +connection slave; +# Last_SQL_Errno = 0 (expected 0) +SELECT id, pid, v FROM ce ORDER BY id; +id pid v +200 2 3 +SELECT id, cid, v, vc FROM gi ORDER BY id; +id cid v vc +3 200 3 4 +include/diff_tables.inc [master:ce, slave:ce] +include/diff_tables.inc [master:gi, slave:gi] +connection master; +FLUSH BINARY LOGS; +NOT FOUND /### (UPDATE|DELETE FROM) `test`.`ce`/ in fk_cascade_mixed_eligibility.sql +NOT FOUND /### (UPDATE|DELETE FROM) `test`.`gi`/ in fk_cascade_mixed_eligibility.sql +DROP TABLE gi, ce, p; +connection slave; +# +# Case 3 (control): all children eligible, cascade rows are logged +# +connection master; +CREATE TABLE p (id INT PRIMARY KEY, v INT) ENGINE=InnoDB; +CREATE TABLE ce ( +id INT PRIMARY KEY, pid INT, v INT, KEY(pid), +CONSTRAINT fk_ce FOREIGN KEY (pid) REFERENCES p(id) +ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB; +CREATE TABLE ce2 ( +id INT, pid INT, v INT, KEY(pid), +CONSTRAINT fk_ce2 FOREIGN KEY (pid) REFERENCES p(id) +ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB; +INSERT INTO p VALUES (1,10),(2,20); +INSERT INTO ce VALUES (100,1,1),(200,2,2); +INSERT INTO ce2 VALUES (100,1,1),(200,2,2); +connection slave; +connection master; +FLUSH BINARY LOGS; +DELETE FROM p WHERE id=1; +connection slave; +include/diff_tables.inc [master:ce, slave:ce] +include/diff_tables.inc [master:ce2, slave:ce2] +connection master; +FLUSH BINARY LOGS; +FOUND 1 /### DELETE FROM `test`.`ce`[^2]/ in fk_cascade_mixed_eligibility.sql +FOUND 1 /### DELETE FROM `test`.`ce2`/ in fk_cascade_mixed_eligibility.sql +DROP TABLE ce, ce2, p; +SET SESSION rpl_use_binlog_events_for_fk_cascade=@old_rpl_use_binlog_events_for_fk_cascade; +include/rpl_end.inc diff --git a/mysql-test/suite/rpl/r/rpl_fk_cascade_binlog_row_multilevel.result b/mysql-test/suite/rpl/r/rpl_fk_cascade_binlog_row_multilevel.result new file mode 100644 index 0000000000000..b22f52614566b --- /dev/null +++ b/mysql-test/suite/rpl/r/rpl_fk_cascade_binlog_row_multilevel.result @@ -0,0 +1,399 @@ +include/master-slave.inc +[connection master] +SET @old_rpl_use_binlog_events_for_fk_cascade := @@session.rpl_use_binlog_events_for_fk_cascade; +SET SESSION default_storage_engine=InnoDB; +SET SESSION binlog_format=ROW; +SET SESSION rpl_use_binlog_events_for_fk_cascade=1; +# +# Case 1: ON DELETE CASCADE through 4 levels, growing row width +# +CREATE TABLE l0 (id INT PRIMARY KEY) ENGINE=InnoDB; +CREATE TABLE l1 (id INT PRIMARY KEY, pid INT, pad CHAR(100) DEFAULT 'l1', KEY(pid), +FOREIGN KEY (pid) REFERENCES l0(id) ON DELETE CASCADE) ENGINE=InnoDB; +CREATE TABLE l2 (id INT PRIMARY KEY, pid INT, pad CHAR(200) DEFAULT 'l2', KEY(pid), +FOREIGN KEY (pid) REFERENCES l1(id) ON DELETE CASCADE) ENGINE=InnoDB; +CREATE TABLE l3 (id INT PRIMARY KEY, pid INT, pad CHAR(255) DEFAULT 'l3', +pad2 CHAR(255) DEFAULT 'l3', KEY(pid), +FOREIGN KEY (pid) REFERENCES l2(id) ON DELETE CASCADE) ENGINE=InnoDB; +INSERT INTO l0 VALUES (1),(2); +INSERT INTO l1 (id,pid) VALUES (10,1),(11,1),(20,2); +INSERT INTO l2 (id,pid) VALUES (100,10),(101,10),(110,11),(200,20); +INSERT INTO l3 (id,pid) VALUES (1000,100),(1001,101),(1100,110),(2000,200); +connection slave; +connection master; +FLUSH BINARY LOGS; +DELETE FROM l0 WHERE id=1; +connection slave; +# Last_SQL_Errno = 0 (expected 0) +SELECT id, pid FROM l1 ORDER BY id; +id pid +20 2 +SELECT id, pid FROM l2 ORDER BY id; +id pid +200 20 +SELECT id, pid FROM l3 ORDER BY id; +id pid +2000 200 +include/diff_tables.inc [master:l1, slave:l1] +include/diff_tables.inc [master:l2, slave:l2] +include/diff_tables.inc [master:l3, slave:l3] +connection master; +FLUSH BINARY LOGS; +FOUND 2 /### DELETE FROM `test`.`l1`/ in fk_cascade_multilevel.sql +FOUND 3 /### DELETE FROM `test`.`l2`/ in fk_cascade_multilevel.sql +FOUND 3 /### DELETE FROM `test`.`l3`/ in fk_cascade_multilevel.sql +DROP TABLE l3, l2, l1, l0; +connection slave; +# +# Case 2: ON UPDATE CASCADE through 3 levels +# +connection master; +CREATE TABLE u0 (id INT PRIMARY KEY) ENGINE=InnoDB; +CREATE TABLE u1 (id INT PRIMARY KEY, pid INT, pad CHAR(100) DEFAULT 'u1', KEY(pid), +FOREIGN KEY (pid) REFERENCES u0(id) ON UPDATE CASCADE) ENGINE=InnoDB; +CREATE TABLE u2 (id INT PRIMARY KEY, ppid INT, pad CHAR(255) DEFAULT 'u2', KEY(ppid), +FOREIGN KEY (ppid) REFERENCES u1(pid) ON UPDATE CASCADE) ENGINE=InnoDB; +INSERT INTO u0 VALUES (1),(2); +INSERT INTO u1 (id,pid) VALUES (10,1),(20,2); +INSERT INTO u2 (id,ppid) VALUES (100,1),(101,1),(200,2); +connection slave; +connection master; +UPDATE u0 SET id=5 WHERE id=1; +connection slave; +# Last_SQL_Errno = 0 (expected 0) +SELECT id, pid FROM u1 ORDER BY id; +id pid +10 5 +20 2 +SELECT id, ppid FROM u2 ORDER BY id; +id ppid +100 5 +101 5 +200 2 +include/diff_tables.inc [master:u1, slave:u1] +include/diff_tables.inc [master:u2, slave:u2] +connection master; +DROP TABLE u2, u1, u0; +connection slave; +# +# Case 3: CASCADE into SET NULL, and a diamond +# +connection master; +CREATE TABLE d0 (id INT PRIMARY KEY) ENGINE=InnoDB; +CREATE TABLE da (id INT PRIMARY KEY, pid INT, KEY(pid), +FOREIGN KEY (pid) REFERENCES d0(id) ON DELETE CASCADE) ENGINE=InnoDB; +CREATE TABLE db (id INT PRIMARY KEY, pid INT, KEY(pid), +FOREIGN KEY (pid) REFERENCES d0(id) ON DELETE CASCADE) ENGINE=InnoDB; +CREATE TABLE dc (id INT PRIMARY KEY, a_id INT, b_id INT, pad CHAR(255) DEFAULT 'dc', +KEY(a_id), KEY(b_id), +FOREIGN KEY (a_id) REFERENCES da(id) ON DELETE CASCADE, +FOREIGN KEY (b_id) REFERENCES db(id) ON DELETE SET NULL) ENGINE=InnoDB; +INSERT INTO d0 VALUES (1),(2); +INSERT INTO da VALUES (10,1),(20,2); +INSERT INTO db VALUES (10,1),(20,2); +INSERT INTO dc (id,a_id,b_id) VALUES (1,10,10),(2,20,10),(3,10,20),(4,20,20); +connection slave; +connection master; +DELETE FROM d0 WHERE id=1; +connection slave; +# Last_SQL_Errno = 0 (expected 0) +SELECT * FROM da ORDER BY id; +id pid +20 2 +SELECT * FROM db ORDER BY id; +id pid +20 2 +SELECT id, a_id, b_id FROM dc ORDER BY id; +id a_id b_id +2 20 NULL +4 20 20 +include/diff_tables.inc [master:da, slave:da] +include/diff_tables.inc [master:db, slave:db] +include/diff_tables.inc [master:dc, slave:dc] +connection master; +DROP TABLE dc, da, db, d0; +connection slave; +# +# Case 4: multi-level cascades in a transaction with a savepoint rollback +# +connection master; +CREATE TABLE t0 (id INT PRIMARY KEY) ENGINE=InnoDB; +CREATE TABLE t1 (id INT PRIMARY KEY, pid INT, KEY(pid), +FOREIGN KEY (pid) REFERENCES t0(id) ON DELETE CASCADE) ENGINE=InnoDB; +CREATE TABLE t2 (id INT PRIMARY KEY, pid INT, pad CHAR(255) DEFAULT 't2', KEY(pid), +FOREIGN KEY (pid) REFERENCES t1(id) ON DELETE CASCADE) ENGINE=InnoDB; +INSERT INTO t0 VALUES (1),(2),(3); +INSERT INTO t1 VALUES (10,1),(20,2),(30,3); +INSERT INTO t2 (id,pid) VALUES (100,10),(200,20),(300,30); +connection slave; +connection master; +BEGIN; +DELETE FROM t0 WHERE id=1; +SAVEPOINT s1; +DELETE FROM t0 WHERE id=2; +ROLLBACK TO SAVEPOINT s1; +DELETE FROM t0 WHERE id=3; +COMMIT; +connection slave; +# Last_SQL_Errno = 0 (expected 0) +SELECT * FROM t1 ORDER BY id; +id pid +20 2 +SELECT id, pid FROM t2 ORDER BY id; +id pid +200 20 +include/diff_tables.inc [master:t1, slave:t1] +include/diff_tables.inc [master:t2, slave:t2] +connection master; +DROP TABLE t2, t1, t0; +# +# Case 5: the deepest chain InnoDB allows, 14 cascade levels below the +# parent (FK_MAX_CASCADE_DEL is 15, counting the parent) +# +connection master; +CREATE TABLE c0 (id INT PRIMARY KEY) ENGINE=InnoDB; +INSERT INTO c0 VALUES (1),(2); +CREATE TABLE c1 (id INT PRIMARY KEY, pid INT, pad CHAR(20) DEFAULT 'c1', KEY(pid), FOREIGN KEY (pid) REFERENCES c0(id) ON DELETE CASCADE) ENGINE=InnoDB; +INSERT INTO c1 (id,pid) VALUES (1,1),(2,2); +CREATE TABLE c2 (id INT PRIMARY KEY, pid INT, pad CHAR(20) DEFAULT 'c2', KEY(pid), FOREIGN KEY (pid) REFERENCES c1(id) ON DELETE CASCADE) ENGINE=InnoDB; +INSERT INTO c2 (id,pid) VALUES (1,1),(2,2); +CREATE TABLE c3 (id INT PRIMARY KEY, pid INT, pad CHAR(20) DEFAULT 'c3', KEY(pid), FOREIGN KEY (pid) REFERENCES c2(id) ON DELETE CASCADE) ENGINE=InnoDB; +INSERT INTO c3 (id,pid) VALUES (1,1),(2,2); +CREATE TABLE c4 (id INT PRIMARY KEY, pid INT, pad CHAR(20) DEFAULT 'c4', KEY(pid), FOREIGN KEY (pid) REFERENCES c3(id) ON DELETE CASCADE) ENGINE=InnoDB; +INSERT INTO c4 (id,pid) VALUES (1,1),(2,2); +CREATE TABLE c5 (id INT PRIMARY KEY, pid INT, pad CHAR(20) DEFAULT 'c5', KEY(pid), FOREIGN KEY (pid) REFERENCES c4(id) ON DELETE CASCADE) ENGINE=InnoDB; +INSERT INTO c5 (id,pid) VALUES (1,1),(2,2); +CREATE TABLE c6 (id INT PRIMARY KEY, pid INT, pad CHAR(20) DEFAULT 'c6', KEY(pid), FOREIGN KEY (pid) REFERENCES c5(id) ON DELETE CASCADE) ENGINE=InnoDB; +INSERT INTO c6 (id,pid) VALUES (1,1),(2,2); +CREATE TABLE c7 (id INT PRIMARY KEY, pid INT, pad CHAR(20) DEFAULT 'c7', KEY(pid), FOREIGN KEY (pid) REFERENCES c6(id) ON DELETE CASCADE) ENGINE=InnoDB; +INSERT INTO c7 (id,pid) VALUES (1,1),(2,2); +CREATE TABLE c8 (id INT PRIMARY KEY, pid INT, pad CHAR(20) DEFAULT 'c8', KEY(pid), FOREIGN KEY (pid) REFERENCES c7(id) ON DELETE CASCADE) ENGINE=InnoDB; +INSERT INTO c8 (id,pid) VALUES (1,1),(2,2); +CREATE TABLE c9 (id INT PRIMARY KEY, pid INT, pad CHAR(20) DEFAULT 'c9', KEY(pid), FOREIGN KEY (pid) REFERENCES c8(id) ON DELETE CASCADE) ENGINE=InnoDB; +INSERT INTO c9 (id,pid) VALUES (1,1),(2,2); +CREATE TABLE c10 (id INT PRIMARY KEY, pid INT, pad CHAR(20) DEFAULT 'c10', KEY(pid), FOREIGN KEY (pid) REFERENCES c9(id) ON DELETE CASCADE) ENGINE=InnoDB; +INSERT INTO c10 (id,pid) VALUES (1,1),(2,2); +CREATE TABLE c11 (id INT PRIMARY KEY, pid INT, pad CHAR(20) DEFAULT 'c11', KEY(pid), FOREIGN KEY (pid) REFERENCES c10(id) ON DELETE CASCADE) ENGINE=InnoDB; +INSERT INTO c11 (id,pid) VALUES (1,1),(2,2); +CREATE TABLE c12 (id INT PRIMARY KEY, pid INT, pad CHAR(20) DEFAULT 'c12', KEY(pid), FOREIGN KEY (pid) REFERENCES c11(id) ON DELETE CASCADE) ENGINE=InnoDB; +INSERT INTO c12 (id,pid) VALUES (1,1),(2,2); +CREATE TABLE c13 (id INT PRIMARY KEY, pid INT, pad CHAR(20) DEFAULT 'c13', KEY(pid), FOREIGN KEY (pid) REFERENCES c12(id) ON DELETE CASCADE) ENGINE=InnoDB; +INSERT INTO c13 (id,pid) VALUES (1,1),(2,2); +CREATE TABLE c14 (id INT PRIMARY KEY, pid INT, pad CHAR(20) DEFAULT 'c14', KEY(pid), FOREIGN KEY (pid) REFERENCES c13(id) ON DELETE CASCADE) ENGINE=InnoDB; +INSERT INTO c14 (id,pid) VALUES (1,1),(2,2); +connection slave; +connection master; +FLUSH BINARY LOGS; +DELETE FROM c0 WHERE id=1; +connection slave; +# Last_SQL_Errno = 0 (expected 0) +SELECT id, pid FROM c14 ORDER BY id; +id pid +2 2 +include/diff_tables.inc [master:c1, slave:c1] +include/diff_tables.inc [master:c2, slave:c2] +include/diff_tables.inc [master:c3, slave:c3] +include/diff_tables.inc [master:c4, slave:c4] +include/diff_tables.inc [master:c5, slave:c5] +include/diff_tables.inc [master:c6, slave:c6] +include/diff_tables.inc [master:c7, slave:c7] +include/diff_tables.inc [master:c8, slave:c8] +include/diff_tables.inc [master:c9, slave:c9] +include/diff_tables.inc [master:c10, slave:c10] +include/diff_tables.inc [master:c11, slave:c11] +include/diff_tables.inc [master:c12, slave:c12] +include/diff_tables.inc [master:c13, slave:c13] +include/diff_tables.inc [master:c14, slave:c14] +connection master; +FLUSH BINARY LOGS; +FOUND 15 /### DELETE FROM `test`.`c[0-9]+`/ in fk_cascade_multilevel.sql +DROP TABLE c14; +DROP TABLE c13; +DROP TABLE c12; +DROP TABLE c11; +DROP TABLE c10; +DROP TABLE c9; +DROP TABLE c8; +DROP TABLE c7; +DROP TABLE c6; +DROP TABLE c5; +DROP TABLE c4; +DROP TABLE c3; +DROP TABLE c2; +DROP TABLE c1; +DROP TABLE c0; +connection slave; +# +# Case 6: one level too deep. The statement fails inside the nested +# cascades; nothing may be logged or changed, and the session must go +# on capturing cascades correctly afterwards. +# +connection master; +call mtr.add_suppression("InnoDB: Cannot delete/update rows with cascading foreign key constraints that exceed max depth of 15"); +CREATE TABLE e0 (id INT PRIMARY KEY) ENGINE=InnoDB; +INSERT INTO e0 VALUES (1),(2); +CREATE TABLE e1 (id INT PRIMARY KEY, pid INT, pad CHAR(20) DEFAULT 'e1', KEY(pid), FOREIGN KEY (pid) REFERENCES e0(id) ON DELETE CASCADE) ENGINE=InnoDB; +INSERT INTO e1 (id,pid) VALUES (1,1),(2,2); +CREATE TABLE e2 (id INT PRIMARY KEY, pid INT, pad CHAR(20) DEFAULT 'e2', KEY(pid), FOREIGN KEY (pid) REFERENCES e1(id) ON DELETE CASCADE) ENGINE=InnoDB; +INSERT INTO e2 (id,pid) VALUES (1,1),(2,2); +CREATE TABLE e3 (id INT PRIMARY KEY, pid INT, pad CHAR(20) DEFAULT 'e3', KEY(pid), FOREIGN KEY (pid) REFERENCES e2(id) ON DELETE CASCADE) ENGINE=InnoDB; +INSERT INTO e3 (id,pid) VALUES (1,1),(2,2); +CREATE TABLE e4 (id INT PRIMARY KEY, pid INT, pad CHAR(20) DEFAULT 'e4', KEY(pid), FOREIGN KEY (pid) REFERENCES e3(id) ON DELETE CASCADE) ENGINE=InnoDB; +INSERT INTO e4 (id,pid) VALUES (1,1),(2,2); +CREATE TABLE e5 (id INT PRIMARY KEY, pid INT, pad CHAR(20) DEFAULT 'e5', KEY(pid), FOREIGN KEY (pid) REFERENCES e4(id) ON DELETE CASCADE) ENGINE=InnoDB; +INSERT INTO e5 (id,pid) VALUES (1,1),(2,2); +CREATE TABLE e6 (id INT PRIMARY KEY, pid INT, pad CHAR(20) DEFAULT 'e6', KEY(pid), FOREIGN KEY (pid) REFERENCES e5(id) ON DELETE CASCADE) ENGINE=InnoDB; +INSERT INTO e6 (id,pid) VALUES (1,1),(2,2); +CREATE TABLE e7 (id INT PRIMARY KEY, pid INT, pad CHAR(20) DEFAULT 'e7', KEY(pid), FOREIGN KEY (pid) REFERENCES e6(id) ON DELETE CASCADE) ENGINE=InnoDB; +INSERT INTO e7 (id,pid) VALUES (1,1),(2,2); +CREATE TABLE e8 (id INT PRIMARY KEY, pid INT, pad CHAR(20) DEFAULT 'e8', KEY(pid), FOREIGN KEY (pid) REFERENCES e7(id) ON DELETE CASCADE) ENGINE=InnoDB; +INSERT INTO e8 (id,pid) VALUES (1,1),(2,2); +CREATE TABLE e9 (id INT PRIMARY KEY, pid INT, pad CHAR(20) DEFAULT 'e9', KEY(pid), FOREIGN KEY (pid) REFERENCES e8(id) ON DELETE CASCADE) ENGINE=InnoDB; +INSERT INTO e9 (id,pid) VALUES (1,1),(2,2); +CREATE TABLE e10 (id INT PRIMARY KEY, pid INT, pad CHAR(20) DEFAULT 'e10', KEY(pid), FOREIGN KEY (pid) REFERENCES e9(id) ON DELETE CASCADE) ENGINE=InnoDB; +INSERT INTO e10 (id,pid) VALUES (1,1),(2,2); +CREATE TABLE e11 (id INT PRIMARY KEY, pid INT, pad CHAR(20) DEFAULT 'e11', KEY(pid), FOREIGN KEY (pid) REFERENCES e10(id) ON DELETE CASCADE) ENGINE=InnoDB; +INSERT INTO e11 (id,pid) VALUES (1,1),(2,2); +CREATE TABLE e12 (id INT PRIMARY KEY, pid INT, pad CHAR(20) DEFAULT 'e12', KEY(pid), FOREIGN KEY (pid) REFERENCES e11(id) ON DELETE CASCADE) ENGINE=InnoDB; +INSERT INTO e12 (id,pid) VALUES (1,1),(2,2); +CREATE TABLE e13 (id INT PRIMARY KEY, pid INT, pad CHAR(20) DEFAULT 'e13', KEY(pid), FOREIGN KEY (pid) REFERENCES e12(id) ON DELETE CASCADE) ENGINE=InnoDB; +INSERT INTO e13 (id,pid) VALUES (1,1),(2,2); +CREATE TABLE e14 (id INT PRIMARY KEY, pid INT, pad CHAR(20) DEFAULT 'e14', KEY(pid), FOREIGN KEY (pid) REFERENCES e13(id) ON DELETE CASCADE) ENGINE=InnoDB; +INSERT INTO e14 (id,pid) VALUES (1,1),(2,2); +CREATE TABLE e15 (id INT PRIMARY KEY, pid INT, pad CHAR(20) DEFAULT 'e15', KEY(pid), FOREIGN KEY (pid) REFERENCES e14(id) ON DELETE CASCADE) ENGINE=InnoDB; +INSERT INTO e15 (id,pid) VALUES (1,1),(2,2); +connection slave; +connection master; +FLUSH BINARY LOGS; +DELETE FROM e0 WHERE id=1; +ERROR HY000: Got error 193 '`test`.`e15`, CONSTRAINT `e15_ibfk_1` FOREIGN KEY (`pid`) REFERENCES `e14` (`id`) ON DELETE CASCADE' from InnoDB +SELECT COUNT(*) FROM e15; +COUNT(*) +2 +DELETE FROM e4 WHERE id=2; +connection slave; +# Last_SQL_Errno = 0 (expected 0) +SELECT id, pid FROM e15 ORDER BY id; +id pid +1 1 +include/diff_tables.inc [master:e0, slave:e0] +include/diff_tables.inc [master:e1, slave:e1] +include/diff_tables.inc [master:e2, slave:e2] +include/diff_tables.inc [master:e3, slave:e3] +include/diff_tables.inc [master:e4, slave:e4] +include/diff_tables.inc [master:e5, slave:e5] +include/diff_tables.inc [master:e6, slave:e6] +include/diff_tables.inc [master:e7, slave:e7] +include/diff_tables.inc [master:e8, slave:e8] +include/diff_tables.inc [master:e9, slave:e9] +include/diff_tables.inc [master:e10, slave:e10] +include/diff_tables.inc [master:e11, slave:e11] +include/diff_tables.inc [master:e12, slave:e12] +include/diff_tables.inc [master:e13, slave:e13] +include/diff_tables.inc [master:e14, slave:e14] +include/diff_tables.inc [master:e15, slave:e15] +connection master; +FLUSH BINARY LOGS; +FOUND 12 /### DELETE FROM `test`.`e[0-9]+`/ in fk_cascade_multilevel.sql +NOT FOUND /### DELETE FROM `test`.`e[0-3]`/ in fk_cascade_multilevel.sql +DROP TABLE e15; +DROP TABLE e14; +DROP TABLE e13; +DROP TABLE e12; +DROP TABLE e11; +DROP TABLE e10; +DROP TABLE e9; +DROP TABLE e8; +DROP TABLE e7; +DROP TABLE e6; +DROP TABLE e5; +DROP TABLE e4; +DROP TABLE e3; +DROP TABLE e2; +DROP TABLE e1; +DROP TABLE e0; +connection slave; +# +# Case 7: a self-referencing foreign key. The child is the table the +# statement itself changes, through its own instance opened for the +# foreign key. +# +connection master; +CREATE TABLE s (id INT PRIMARY KEY, parent_id INT, pad CHAR(100) DEFAULT 's', +KEY(parent_id), +FOREIGN KEY (parent_id) REFERENCES s(id) ON DELETE CASCADE) ENGINE=InnoDB; +INSERT INTO s (id, parent_id) VALUES (1,NULL),(2,1),(3,2),(4,2),(5,NULL),(6,5); +connection slave; +connection master; +FLUSH BINARY LOGS; +DELETE FROM s WHERE id=1; +connection slave; +# Last_SQL_Errno = 0 (expected 0) +SELECT id, parent_id FROM s ORDER BY id; +id parent_id +5 NULL +6 5 +include/diff_tables.inc [master:s, slave:s] +connection master; +FLUSH BINARY LOGS; +FOUND 4 /### DELETE FROM `test`.`s`/ in fk_cascade_multilevel.sql +DROP TABLE s; +connection slave; +# +# Case 8: a self-referencing table with another child. The cascade into +# the other child flags the statement, so the replica does not re-run +# any cascade of it: the self-referencing rows must be logged too. +# +connection master; +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); +connection slave; +connection master; +DELETE FROM s WHERE id=1; +connection slave; +# Last_SQL_Errno = 0 (expected 0) +SELECT id, parent_id FROM s ORDER BY id; +id parent_id +4 NULL +SELECT id, s_id FROM o ORDER BY id; +id s_id +40 4 +include/diff_tables.inc [master:s, slave:s] +include/diff_tables.inc [master:o, slave:o] +connection master; +DROP TABLE o, s; +connection slave; +# +# Case 9: a cascade changes a row the statement changes after it. The +# cascaded rows must precede the row whose change ran the cascade, or +# the replica updates a row it has already deleted. +# +connection master; +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),(3,1),(4,NULL); +connection slave; +connection master; +FLUSH BINARY LOGS; +DELETE FROM s WHERE id IN (1,2); +connection slave; +# Last_SQL_Errno = 0 (expected 0) +SELECT id, parent_id FROM s ORDER BY id; +id parent_id +3 NULL +4 NULL +include/diff_tables.inc [master:s, slave:s] +# The row events in the order of execution +connection master; +FLUSH BINARY LOGS; +UPDATE s id=2 +UPDATE s id=3 +DELETE FROM s id=1 +DELETE FROM s id=2 +DROP TABLE s; +SET SESSION rpl_use_binlog_events_for_fk_cascade=@old_rpl_use_binlog_events_for_fk_cascade; +include/rpl_end.inc diff --git a/mysql-test/suite/rpl/r/rpl_fk_cascade_binlog_row_slave_option.result b/mysql-test/suite/rpl/r/rpl_fk_cascade_binlog_row_slave_option.result index 9fa0419649b6e..cb8970f3a3703 100644 --- a/mysql-test/suite/rpl/r/rpl_fk_cascade_binlog_row_slave_option.result +++ b/mysql-test/suite/rpl/r/rpl_fk_cascade_binlog_row_slave_option.result @@ -39,8 +39,8 @@ COUNT(*) 0 FLUSH BINARY LOGS; FOUND 1 /### DELETE FROM `test`.`p`/ in fk_cascade_slave_option.sql -NOT FOUND /### DELETE FROM `test`.`c`/ in fk_cascade_slave_option.sql -NOT FOUND /### UPDATE `test`.`c`/ in fk_cascade_slave_option.sql +FOUND 1 /### DELETE FROM `test`.`c`/ in fk_cascade_slave_option.sql +FOUND 1 /### UPDATE `test`.`c`/ in fk_cascade_slave_option.sql connection slave; include/stop_slave.inc SET GLOBAL rpl_use_binlog_events_for_fk_cascade=0; diff --git a/mysql-test/suite/rpl/t/rpl_fk_cascade_binlog_row_mixed_eligibility.test b/mysql-test/suite/rpl/t/rpl_fk_cascade_binlog_row_mixed_eligibility.test new file mode 100644 index 0000000000000..9ddfc03b4f36b --- /dev/null +++ b/mysql-test/suite/rpl/t/rpl_fk_cascade_binlog_row_mixed_eligibility.test @@ -0,0 +1,218 @@ +--source include/have_innodb.inc +--source include/have_log_bin.inc +--source include/have_binlog_format_row.inc + +--source include/master-slave.inc + +# +# MDEV-38243: eligibility for cascade capture is decided per statement, not +# per child table (fk_cascade_stmt_capturable()). +# +# A child table without a primary key that has a virtual column participating +# in a key is not eligible (fk_cascade_table_eligible()): its rows cannot be +# identified unambiguously from a row image, so its cascade rows are never +# logged. Once any cascade row of a statement is captured, every row event of +# that statement carries FK_CASCADE_EVENTS_F | NO_FOREIGN_KEY_CHECKS_F and the +# replica stops cascading for ALL children. If an eligible sibling were +# captured, the replica would therefore neither receive nor recreate the +# ineligible sibling's cascade changes and silently keep orphans. +# +# The feature must hence be declined for the whole statement as soon as any +# child reachable through a cascade is ineligible, and the classic replica-side +# cascade then handles all children. +# + +SET @old_rpl_use_binlog_events_for_fk_cascade := @@session.rpl_use_binlog_events_for_fk_cascade; +SET SESSION default_storage_engine=InnoDB; +SET SESSION binlog_format=ROW; +SET SESSION rpl_use_binlog_events_for_fk_cascade=1; + +--let $datadir = `SELECT @@datadir` +--let $tmpfile = $MYSQLTEST_VARDIR/tmp/fk_cascade_mixed_eligibility.sql + +--echo # +--echo # Case 1: eligible and ineligible siblings of the same parent +--echo # + +CREATE TABLE p (id INT PRIMARY KEY, v INT) ENGINE=InnoDB; + +# Eligible: has a primary key. +CREATE TABLE ce ( + id INT PRIMARY KEY, pid INT, v INT, KEY(pid), + CONSTRAINT fk_ce FOREIGN KEY (pid) REFERENCES p(id) + ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB; + +# Ineligible: no primary key (and no NOT NULL unique key that would be +# promoted to one) and a virtual column participating in a key. +CREATE TABLE ci ( + id INT, pid INT, v INT, vc INT AS (v + 1) VIRTUAL, + KEY(pid), KEY(vc), + CONSTRAINT fk_ci FOREIGN KEY (pid) REFERENCES p(id) + ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB; + +# Ineligible, SET NULL action. +CREATE TABLE cn ( + id INT, pid INT, v INT, vc INT AS (v + 1) VIRTUAL, + KEY(pid), KEY(vc), + CONSTRAINT fk_cn 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),(4,40); +INSERT INTO ce (id,pid,v) VALUES (100,1,1),(200,2,2),(300,3,3),(400,4,4); +INSERT INTO ci (id,pid,v) VALUES (100,1,1),(200,2,2),(300,3,3),(400,4,4); +INSERT INTO cn (id,pid,v) VALUES (100,1,1),(200,2,2),(300,3,3),(400,4,4); +sync_slave_with_master; + +connection master; +FLUSH BINARY LOGS; +--let $binlog = query_get_value(SHOW MASTER STATUS, File, 1) + +UPDATE p SET id=11 WHERE id=1; +DELETE FROM p WHERE id=2; +# Multi-statement transaction +BEGIN; +UPDATE p SET id=33 WHERE id=3; +DELETE FROM p WHERE id=4; +COMMIT; + +sync_slave_with_master; +--let $errno= query_get_value(SHOW SLAVE STATUS, Last_SQL_Errno, 1) +--echo # Last_SQL_Errno = $errno (expected 0) +SELECT id, pid, v FROM ce ORDER BY id; +SELECT id, pid, v, vc FROM ci ORDER BY id; +SELECT id, pid, v, vc FROM cn ORDER BY id; + +--let $diff_tables= master:p, slave:p +--source include/diff_tables.inc +--let $diff_tables= master:ce, slave:ce +--source include/diff_tables.inc +--let $diff_tables= master:ci, slave:ci +--source include/diff_tables.inc +--let $diff_tables= master:cn, slave:cn +--source include/diff_tables.inc + +# The feature was declined for these statements: no cascade row events at +# all, not even for the eligible sibling. +connection master; +FLUSH BINARY LOGS; +--exec $MYSQL_BINLOG --verbose --verbose --base64-output=DECODE-ROWS $datadir/$binlog > $tmpfile +--let SEARCH_FILE= $tmpfile +--let SEARCH_ABORT= FOUND [1-9] +--let SEARCH_PATTERN= ### (UPDATE|DELETE FROM) `test`.`ce` +--source include/search_pattern_in_file.inc +--let SEARCH_PATTERN= ### (UPDATE|DELETE FROM) `test`.`ci` +--source include/search_pattern_in_file.inc +--let SEARCH_PATTERN= ### (UPDATE|DELETE FROM) `test`.`cn` +--source include/search_pattern_in_file.inc +--remove_file $tmpfile + +DROP TABLE ce, ci, cn, p; +sync_slave_with_master; + +--echo # +--echo # Case 2: ineligible table reachable only transitively (grandchild) +--echo # + +connection master; +CREATE TABLE p (id INT PRIMARY KEY, v INT) ENGINE=InnoDB; +CREATE TABLE ce ( + id INT PRIMARY KEY, pid INT, v INT, KEY(pid), + CONSTRAINT fk_ce FOREIGN KEY (pid) REFERENCES p(id) + ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB; +CREATE TABLE gi ( + id INT, cid INT, v INT, vc INT AS (v + 1) VIRTUAL, + KEY(cid), KEY(vc), + CONSTRAINT fk_gi FOREIGN KEY (cid) REFERENCES ce(id) + ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB; + +INSERT INTO p VALUES (1,10),(2,20); +INSERT INTO ce (id,pid,v) VALUES (100,1,1),(101,1,2),(200,2,3); +INSERT INTO gi (id,cid,v) VALUES (1,100,1),(2,101,2),(3,200,3); +sync_slave_with_master; + +connection master; +FLUSH BINARY LOGS; +--let $binlog = query_get_value(SHOW MASTER STATUS, File, 1) + +DELETE FROM p WHERE id=1; + +sync_slave_with_master; +--let $errno= query_get_value(SHOW SLAVE STATUS, Last_SQL_Errno, 1) +--echo # Last_SQL_Errno = $errno (expected 0) +SELECT id, pid, v FROM ce ORDER BY id; +SELECT id, cid, v, vc FROM gi ORDER BY id; + +--let $diff_tables= master:ce, slave:ce +--source include/diff_tables.inc +--let $diff_tables= master:gi, slave:gi +--source include/diff_tables.inc + +connection master; +FLUSH BINARY LOGS; +--exec $MYSQL_BINLOG --verbose --verbose --base64-output=DECODE-ROWS $datadir/$binlog > $tmpfile +--let SEARCH_FILE= $tmpfile +--let SEARCH_ABORT= FOUND [1-9] +--let SEARCH_PATTERN= ### (UPDATE|DELETE FROM) `test`.`ce` +--source include/search_pattern_in_file.inc +--let SEARCH_PATTERN= ### (UPDATE|DELETE FROM) `test`.`gi` +--source include/search_pattern_in_file.inc +--remove_file $tmpfile + +DROP TABLE gi, ce, p; +sync_slave_with_master; + +--echo # +--echo # Case 3 (control): all children eligible, cascade rows are logged +--echo # + +connection master; +CREATE TABLE p (id INT PRIMARY KEY, v INT) ENGINE=InnoDB; +CREATE TABLE ce ( + id INT PRIMARY KEY, pid INT, v INT, KEY(pid), + CONSTRAINT fk_ce FOREIGN KEY (pid) REFERENCES p(id) + ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB; +# No primary key, but no virtual column in a key either: eligible. +CREATE TABLE ce2 ( + id INT, pid INT, v INT, KEY(pid), + CONSTRAINT fk_ce2 FOREIGN KEY (pid) REFERENCES p(id) + ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB; + +INSERT INTO p VALUES (1,10),(2,20); +INSERT INTO ce VALUES (100,1,1),(200,2,2); +INSERT INTO ce2 VALUES (100,1,1),(200,2,2); +sync_slave_with_master; + +connection master; +FLUSH BINARY LOGS; +--let $binlog = query_get_value(SHOW MASTER STATUS, File, 1) + +DELETE FROM p WHERE id=1; + +sync_slave_with_master; +--let $diff_tables= master:ce, slave:ce +--source include/diff_tables.inc +--let $diff_tables= master:ce2, slave:ce2 +--source include/diff_tables.inc + +connection master; +FLUSH BINARY LOGS; +--exec $MYSQL_BINLOG --verbose --verbose --base64-output=DECODE-ROWS $datadir/$binlog > $tmpfile +--let SEARCH_FILE= $tmpfile +--let SEARCH_ABORT= NOT FOUND|FOUND 0|FOUND [2-9] +--let SEARCH_PATTERN= ### DELETE FROM `test`.`ce`[^2] +--source include/search_pattern_in_file.inc +--let SEARCH_PATTERN= ### DELETE FROM `test`.`ce2` +--source include/search_pattern_in_file.inc +--remove_file $tmpfile + +DROP TABLE ce, ce2, p; + +SET SESSION rpl_use_binlog_events_for_fk_cascade=@old_rpl_use_binlog_events_for_fk_cascade; +--source include/rpl_end.inc diff --git a/mysql-test/suite/rpl/t/rpl_fk_cascade_binlog_row_multilevel.test b/mysql-test/suite/rpl/t/rpl_fk_cascade_binlog_row_multilevel.test new file mode 100644 index 0000000000000..639943c610bb8 --- /dev/null +++ b/mysql-test/suite/rpl/t/rpl_fk_cascade_binlog_row_multilevel.test @@ -0,0 +1,430 @@ +--source include/have_innodb.inc +--source include/have_log_bin.inc +--source include/have_binlog_format_row.inc + +--source include/master-slave.inc + +# +# MDEV-38243: cascades that recurse through more than one level. +# +# InnoDB performs a nested cascade from inside the outer one: while the +# before-image of the outer child row is captured and not yet reported, the +# cascade into the grandchild captures and reports its own rows. Every level +# must end up in the binary log; since the events carry FK_CASCADE_EVENTS_F +# the replica does not re-run the cascade, so any missing level would silently +# diverge. +# +# The child tables have wider rows than their parents so that a nested capture +# reusing the outer row buffer would overrun it. +# + +SET @old_rpl_use_binlog_events_for_fk_cascade := @@session.rpl_use_binlog_events_for_fk_cascade; +SET SESSION default_storage_engine=InnoDB; +SET SESSION binlog_format=ROW; +SET SESSION rpl_use_binlog_events_for_fk_cascade=1; + +--let $datadir = `SELECT @@datadir` +--let $tmpfile = $MYSQLTEST_VARDIR/tmp/fk_cascade_multilevel.sql + +--echo # +--echo # Case 1: ON DELETE CASCADE through 4 levels, growing row width +--echo # + +CREATE TABLE l0 (id INT PRIMARY KEY) ENGINE=InnoDB; +CREATE TABLE l1 (id INT PRIMARY KEY, pid INT, pad CHAR(100) DEFAULT 'l1', KEY(pid), + FOREIGN KEY (pid) REFERENCES l0(id) ON DELETE CASCADE) ENGINE=InnoDB; +CREATE TABLE l2 (id INT PRIMARY KEY, pid INT, pad CHAR(200) DEFAULT 'l2', KEY(pid), + FOREIGN KEY (pid) REFERENCES l1(id) ON DELETE CASCADE) ENGINE=InnoDB; +CREATE TABLE l3 (id INT PRIMARY KEY, pid INT, pad CHAR(255) DEFAULT 'l3', + pad2 CHAR(255) DEFAULT 'l3', KEY(pid), + FOREIGN KEY (pid) REFERENCES l2(id) ON DELETE CASCADE) ENGINE=InnoDB; + +INSERT INTO l0 VALUES (1),(2); +INSERT INTO l1 (id,pid) VALUES (10,1),(11,1),(20,2); +INSERT INTO l2 (id,pid) VALUES (100,10),(101,10),(110,11),(200,20); +INSERT INTO l3 (id,pid) VALUES (1000,100),(1001,101),(1100,110),(2000,200); +sync_slave_with_master; + +connection master; +FLUSH BINARY LOGS; +--let $binlog = query_get_value(SHOW MASTER STATUS, File, 1) + +DELETE FROM l0 WHERE id=1; + +sync_slave_with_master; +--let $errno= query_get_value(SHOW SLAVE STATUS, Last_SQL_Errno, 1) +--echo # Last_SQL_Errno = $errno (expected 0) +SELECT id, pid FROM l1 ORDER BY id; +SELECT id, pid FROM l2 ORDER BY id; +SELECT id, pid FROM l3 ORDER BY id; +--let $diff_tables= master:l1, slave:l1 +--source include/diff_tables.inc +--let $diff_tables= master:l2, slave:l2 +--source include/diff_tables.inc +--let $diff_tables= master:l3, slave:l3 +--source include/diff_tables.inc + +# Every level is logged: 2 rows of l1, 3 of l2, 3 of l3. +connection master; +FLUSH BINARY LOGS; +--exec $MYSQL_BINLOG --verbose --base64-output=DECODE-ROWS $datadir/$binlog > $tmpfile +--let SEARCH_FILE= $tmpfile +--let SEARCH_PATTERN= ### DELETE FROM `test`.`l1` +--source include/search_pattern_in_file.inc +--let SEARCH_PATTERN= ### DELETE FROM `test`.`l2` +--source include/search_pattern_in_file.inc +--let SEARCH_PATTERN= ### DELETE FROM `test`.`l3` +--source include/search_pattern_in_file.inc +--remove_file $tmpfile + +DROP TABLE l3, l2, l1, l0; +sync_slave_with_master; + +--echo # +--echo # Case 2: ON UPDATE CASCADE through 3 levels +--echo # + +connection master; +CREATE TABLE u0 (id INT PRIMARY KEY) ENGINE=InnoDB; +CREATE TABLE u1 (id INT PRIMARY KEY, pid INT, pad CHAR(100) DEFAULT 'u1', KEY(pid), + FOREIGN KEY (pid) REFERENCES u0(id) ON UPDATE CASCADE) ENGINE=InnoDB; +# References the non-unique u1.pid, so updating u0.id cascades two levels. +CREATE TABLE u2 (id INT PRIMARY KEY, ppid INT, pad CHAR(255) DEFAULT 'u2', KEY(ppid), + FOREIGN KEY (ppid) REFERENCES u1(pid) ON UPDATE CASCADE) ENGINE=InnoDB; + +INSERT INTO u0 VALUES (1),(2); +INSERT INTO u1 (id,pid) VALUES (10,1),(20,2); +INSERT INTO u2 (id,ppid) VALUES (100,1),(101,1),(200,2); +sync_slave_with_master; + +connection master; +UPDATE u0 SET id=5 WHERE id=1; + +sync_slave_with_master; +--let $errno= query_get_value(SHOW SLAVE STATUS, Last_SQL_Errno, 1) +--echo # Last_SQL_Errno = $errno (expected 0) +SELECT id, pid FROM u1 ORDER BY id; +SELECT id, ppid FROM u2 ORDER BY id; +--let $diff_tables= master:u1, slave:u1 +--source include/diff_tables.inc +--let $diff_tables= master:u2, slave:u2 +--source include/diff_tables.inc + +connection master; +DROP TABLE u2, u1, u0; +sync_slave_with_master; + +--echo # +--echo # Case 3: CASCADE into SET NULL, and a diamond +--echo # + +connection master; +CREATE TABLE d0 (id INT PRIMARY KEY) ENGINE=InnoDB; +CREATE TABLE da (id INT PRIMARY KEY, pid INT, KEY(pid), + FOREIGN KEY (pid) REFERENCES d0(id) ON DELETE CASCADE) ENGINE=InnoDB; +CREATE TABLE db (id INT PRIMARY KEY, pid INT, KEY(pid), + FOREIGN KEY (pid) REFERENCES d0(id) ON DELETE CASCADE) ENGINE=InnoDB; +# Reached from d0 both through da (CASCADE) and through db (SET NULL). +CREATE TABLE dc (id INT PRIMARY KEY, a_id INT, b_id INT, pad CHAR(255) DEFAULT 'dc', + KEY(a_id), KEY(b_id), + FOREIGN KEY (a_id) REFERENCES da(id) ON DELETE CASCADE, + FOREIGN KEY (b_id) REFERENCES db(id) ON DELETE SET NULL) ENGINE=InnoDB; + +INSERT INTO d0 VALUES (1),(2); +INSERT INTO da VALUES (10,1),(20,2); +INSERT INTO db VALUES (10,1),(20,2); +INSERT INTO dc (id,a_id,b_id) VALUES (1,10,10),(2,20,10),(3,10,20),(4,20,20); +sync_slave_with_master; + +connection master; +DELETE FROM d0 WHERE id=1; + +sync_slave_with_master; +--let $errno= query_get_value(SHOW SLAVE STATUS, Last_SQL_Errno, 1) +--echo # Last_SQL_Errno = $errno (expected 0) +SELECT * FROM da ORDER BY id; +SELECT * FROM db ORDER BY id; +SELECT id, a_id, b_id FROM dc ORDER BY id; +--let $diff_tables= master:da, slave:da +--source include/diff_tables.inc +--let $diff_tables= master:db, slave:db +--source include/diff_tables.inc +--let $diff_tables= master:dc, slave:dc +--source include/diff_tables.inc + +connection master; +DROP TABLE dc, da, db, d0; +sync_slave_with_master; + +--echo # +--echo # Case 4: multi-level cascades in a transaction with a savepoint rollback +--echo # + +connection master; +CREATE TABLE t0 (id INT PRIMARY KEY) ENGINE=InnoDB; +CREATE TABLE t1 (id INT PRIMARY KEY, pid INT, KEY(pid), + FOREIGN KEY (pid) REFERENCES t0(id) ON DELETE CASCADE) ENGINE=InnoDB; +CREATE TABLE t2 (id INT PRIMARY KEY, pid INT, pad CHAR(255) DEFAULT 't2', KEY(pid), + FOREIGN KEY (pid) REFERENCES t1(id) ON DELETE CASCADE) ENGINE=InnoDB; + +INSERT INTO t0 VALUES (1),(2),(3); +INSERT INTO t1 VALUES (10,1),(20,2),(30,3); +INSERT INTO t2 (id,pid) VALUES (100,10),(200,20),(300,30); +sync_slave_with_master; + +connection master; +BEGIN; +DELETE FROM t0 WHERE id=1; +SAVEPOINT s1; +DELETE FROM t0 WHERE id=2; +ROLLBACK TO SAVEPOINT s1; +DELETE FROM t0 WHERE id=3; +COMMIT; + +sync_slave_with_master; +--let $errno= query_get_value(SHOW SLAVE STATUS, Last_SQL_Errno, 1) +--echo # Last_SQL_Errno = $errno (expected 0) +SELECT * FROM t1 ORDER BY id; +SELECT id, pid FROM t2 ORDER BY id; +--let $diff_tables= master:t1, slave:t1 +--source include/diff_tables.inc +--let $diff_tables= master:t2, slave:t2 +--source include/diff_tables.inc + +connection master; +DROP TABLE t2, t1, t0; + +--echo # +--echo # Case 5: the deepest chain InnoDB allows, 14 cascade levels below the +--echo # parent (FK_MAX_CASCADE_DEL is 15, counting the parent) +--echo # + +connection master; +CREATE TABLE c0 (id INT PRIMARY KEY) ENGINE=InnoDB; +INSERT INTO c0 VALUES (1),(2); +--let $i= 1 +while ($i <= 14) +{ + --let $p= `SELECT $i - 1` + --eval CREATE TABLE c$i (id INT PRIMARY KEY, pid INT, pad CHAR(20) DEFAULT 'c$i', KEY(pid), FOREIGN KEY (pid) REFERENCES c$p(id) ON DELETE CASCADE) ENGINE=InnoDB + --eval INSERT INTO c$i (id,pid) VALUES (1,1),(2,2) + --inc $i +} +sync_slave_with_master; + +connection master; +FLUSH BINARY LOGS; +--let $binlog = query_get_value(SHOW MASTER STATUS, File, 1) + +DELETE FROM c0 WHERE id=1; + +sync_slave_with_master; +--let $errno= query_get_value(SHOW SLAVE STATUS, Last_SQL_Errno, 1) +--echo # Last_SQL_Errno = $errno (expected 0) +SELECT id, pid FROM c14 ORDER BY id; +--let $i= 1 +while ($i <= 14) +{ + --let $diff_tables= master:c$i, slave:c$i + --source include/diff_tables.inc + --inc $i +} + +# One row of each of the 15 tables: c0 itself and the 14 cascade levels. +connection master; +FLUSH BINARY LOGS; +--exec $MYSQL_BINLOG --verbose --base64-output=DECODE-ROWS $datadir/$binlog > $tmpfile +--let SEARCH_FILE= $tmpfile +--let SEARCH_PATTERN= ### DELETE FROM `test`.`c[0-9]+` +--source include/search_pattern_in_file.inc +--remove_file $tmpfile + +--let $i= 14 +while ($i >= 0) +{ + --eval DROP TABLE c$i + --dec $i +} +sync_slave_with_master; + +--echo # +--echo # Case 6: one level too deep. The statement fails inside the nested +--echo # cascades; nothing may be logged or changed, and the session must go +--echo # on capturing cascades correctly afterwards. +--echo # + +connection master; +call mtr.add_suppression("InnoDB: Cannot delete/update rows with cascading foreign key constraints that exceed max depth of 15"); +CREATE TABLE e0 (id INT PRIMARY KEY) ENGINE=InnoDB; +INSERT INTO e0 VALUES (1),(2); +--let $i= 1 +while ($i <= 15) +{ + --let $p= `SELECT $i - 1` + --eval CREATE TABLE e$i (id INT PRIMARY KEY, pid INT, pad CHAR(20) DEFAULT 'e$i', KEY(pid), FOREIGN KEY (pid) REFERENCES e$p(id) ON DELETE CASCADE) ENGINE=InnoDB + --eval INSERT INTO e$i (id,pid) VALUES (1,1),(2,2) + --inc $i +} +sync_slave_with_master; + +connection master; +FLUSH BINARY LOGS; +--let $binlog = query_get_value(SHOW MASTER STATUS, File, 1) + +# Unnamed constraints are named e15_ibfk_1 in 11.8 and 1 in newer versions +--replace_regex /CONSTRAINT `[^`]*`/CONSTRAINT `e15_ibfk_1`/ +--error ER_GET_ERRMSG +DELETE FROM e0 WHERE id=1; +SELECT COUNT(*) FROM e15; + +# 11 levels below e4 are within the limit +DELETE FROM e4 WHERE id=2; + +sync_slave_with_master; +--let $errno= query_get_value(SHOW SLAVE STATUS, Last_SQL_Errno, 1) +--echo # Last_SQL_Errno = $errno (expected 0) +SELECT id, pid FROM e15 ORDER BY id; +--let $i= 0 +while ($i <= 15) +{ + --let $diff_tables= master:e$i, slave:e$i + --source include/diff_tables.inc + --inc $i +} + +# Only the second statement is logged: one row of each of e4 .. e15. +connection master; +FLUSH BINARY LOGS; +--exec $MYSQL_BINLOG --verbose --base64-output=DECODE-ROWS $datadir/$binlog > $tmpfile +--let SEARCH_FILE= $tmpfile +--let SEARCH_PATTERN= ### DELETE FROM `test`.`e[0-9]+` +--source include/search_pattern_in_file.inc +--let SEARCH_PATTERN= ### DELETE FROM `test`.`e[0-3]` +--source include/search_pattern_in_file.inc +--remove_file $tmpfile + +--let $i= 15 +while ($i >= 0) +{ + --eval DROP TABLE e$i + --dec $i +} + +sync_slave_with_master; + +--echo # +--echo # Case 7: a self-referencing foreign key. The child is the table the +--echo # statement itself changes, through its own instance opened for the +--echo # foreign key. +--echo # + +connection master; +CREATE TABLE s (id INT PRIMARY KEY, parent_id INT, pad CHAR(100) DEFAULT 's', + KEY(parent_id), + FOREIGN KEY (parent_id) REFERENCES s(id) ON DELETE CASCADE) ENGINE=InnoDB; +INSERT INTO s (id, parent_id) VALUES (1,NULL),(2,1),(3,2),(4,2),(5,NULL),(6,5); +sync_slave_with_master; + +connection master; +FLUSH BINARY LOGS; +--let $binlog = query_get_value(SHOW MASTER STATUS, File, 1) + +DELETE FROM s WHERE id=1; + +sync_slave_with_master; +--let $errno= query_get_value(SHOW SLAVE STATUS, Last_SQL_Errno, 1) +--echo # Last_SQL_Errno = $errno (expected 0) +SELECT id, parent_id FROM s ORDER BY id; +--let $diff_tables= master:s, slave:s +--source include/diff_tables.inc + +# The row the statement deletes and its 3 descendants. +connection master; +FLUSH BINARY LOGS; +--exec $MYSQL_BINLOG --verbose --base64-output=DECODE-ROWS $datadir/$binlog > $tmpfile +--let SEARCH_FILE= $tmpfile +--let SEARCH_PATTERN= ### DELETE FROM `test`.`s` +--source include/search_pattern_in_file.inc +--remove_file $tmpfile + +DROP TABLE s; +sync_slave_with_master; + +--echo # +--echo # Case 8: a self-referencing table with another child. The cascade into +--echo # the other child flags the statement, so the replica does not re-run +--echo # any cascade of it: the self-referencing rows must be logged too. +--echo # + +connection master; +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); +sync_slave_with_master; + +connection master; +DELETE FROM s WHERE id=1; + +sync_slave_with_master; +--let $errno= query_get_value(SHOW SLAVE STATUS, Last_SQL_Errno, 1) +--echo # Last_SQL_Errno = $errno (expected 0) +SELECT id, parent_id FROM s ORDER BY id; +SELECT id, s_id FROM o ORDER BY id; +--let $diff_tables= master:s, slave:s +--source include/diff_tables.inc +--let $diff_tables= master:o, slave:o +--source include/diff_tables.inc + +connection master; +DROP TABLE o, s; +sync_slave_with_master; + +--echo # +--echo # Case 9: a cascade changes a row the statement changes after it. The +--echo # cascaded rows must precede the row whose change ran the cascade, or +--echo # the replica updates a row it has already deleted. +--echo # + +connection master; +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),(3,1),(4,NULL); +sync_slave_with_master; + +connection master; +FLUSH BINARY LOGS; +--let $binlog = query_get_value(SHOW MASTER STATUS, File, 1) + +DELETE FROM s WHERE id IN (1,2); + +sync_slave_with_master; +--let $errno= query_get_value(SHOW SLAVE STATUS, Last_SQL_Errno, 1) +--echo # Last_SQL_Errno = $errno (expected 0) +SELECT id, parent_id FROM s ORDER BY id; +--let $diff_tables= master:s, slave:s +--source include/diff_tables.inc + +--echo # The row events in the order of execution +connection master; +FLUSH BINARY LOGS; +--exec $MYSQL_BINLOG --verbose --base64-output=DECODE-ROWS $datadir/$binlog > $tmpfile +--let ROW_EVENTS_FILE= $tmpfile +--perl +open(my $f, '<', $ENV{ROW_EVENTS_FILE}) or die "cannot open: $!"; +my $event= ''; +while (<$f>) +{ + if (/^### (DELETE FROM|UPDATE) \S+/) { $event= "$1 s"; } + elsif ($event && /^### \@1=(\d+)/) { print "$event id=$1\n"; $event= ''; } +} +close($f); +EOF +--remove_file $tmpfile + +DROP TABLE s; + +SET SESSION rpl_use_binlog_events_for_fk_cascade=@old_rpl_use_binlog_events_for_fk_cascade; +--source include/rpl_end.inc diff --git a/mysql-test/suite/rpl/t/rpl_fk_cascade_binlog_row_slave_option.test b/mysql-test/suite/rpl/t/rpl_fk_cascade_binlog_row_slave_option.test index 831394ab52d93..f358e2c2508dd 100644 --- a/mysql-test/suite/rpl/t/rpl_fk_cascade_binlog_row_slave_option.test +++ b/mysql-test/suite/rpl/t/rpl_fk_cascade_binlog_row_slave_option.test @@ -9,16 +9,11 @@ # # The master runs with the feature OFF (default), so it binlogs only the # parent-table row events, not the cascaded child row events. The slave has -# the feature ON. Because the row-based applier does not prelock the FK-child -# tables, find_fk_open_table() finds no open child table on the applier, so no -# cascade row events are captured there. The slave applies the cascade the -# traditional way (InnoDB re-executes it) and its own binary log contains only -# the parent events. -# -# This test pins that behaviour: enabling the option on the slave is currently -# a no-op for the applier path, while replication stays correct. It exists so -# that a future change which makes the option effective on the applier (by -# opening the child table there) will visibly change this recorded result. +# the feature ON. It applies the cascade the traditional way (InnoDB +# re-executes it, as the events carry no FK_CASCADE_EVENTS_F), and captures +# the cascade like any other session with the feature ON: its own binary log +# contains the parent events and the cascaded child row events, so that a +# replica of the slave need not re-run the cascade. # connection master; @@ -65,9 +60,8 @@ connection slave; SELECT COUNT(*) FROM p; SELECT COUNT(*) FROM c; -# Inspect the slave's own binary log. It must contain the parent events but -# NOT explicit cascade row events for the child table, because the applier -# does not capture cascades (the child table is not prelocked on the applier). +# Inspect the slave's own binary log. It must contain the parent events and +# the cascaded child row events the applier captured. --let $binlog = query_get_value(SHOW MASTER STATUS, File, 1) --let $datadir = `SELECT @@datadir` @@ -81,15 +75,16 @@ FLUSH BINARY LOGS; --let SEARCH_ABORT= NOT FOUND --source include/search_pattern_in_file.inc -# No explicit cascade child row events: the option is a no-op on the applier. +# The cascaded child row events, one each for the ON UPDATE CASCADE and the +# ON DELETE CASCADE. --let SEARCH_PATTERN= ### DELETE FROM `test`.`c` --let SEARCH_FILE= $MYSQLTEST_VARDIR/tmp/fk_cascade_slave_option.sql ---let SEARCH_ABORT= FOUND +--let SEARCH_ABORT= NOT FOUND|FOUND 0|FOUND [2-9] --source include/search_pattern_in_file.inc --let SEARCH_PATTERN= ### UPDATE `test`.`c` --let SEARCH_FILE= $MYSQLTEST_VARDIR/tmp/fk_cascade_slave_option.sql ---let SEARCH_ABORT= FOUND +--let SEARCH_ABORT= NOT FOUND|FOUND 0|FOUND [2-9] --source include/search_pattern_in_file.inc # Cleanup: restore the slave option and drop the tables. diff --git a/sql/handler.cc b/sql/handler.cc index 959b43c44ede4..bfe6a8618344a 100644 --- a/sql/handler.cc +++ b/sql/handler.cc @@ -8079,8 +8079,19 @@ int handler::binlog_log_row(const uchar *before_record, int error = 0; if (row_logging) + { + THD *thd= ha_thd(); + /* + The FK cascades this row change ran happened before it: log the rows + they changed first, so that the binary log has the order of execution. + Not while those rows are being logged, which comes through here too. + */ + if (unlikely(thd->pending_cascade_binlog_row_events.elements()) && + !thd->binlog_fk_cascade_derived) + thd->flush_pending_cascade_binlog(); error= binlog_log_row_to_binlog(table, before_record, after_record, log_func, row_logging_has_trans); + } #ifdef HAVE_REPLICATION if (unlikely(!error && table->s->online_alter_binlog && is_root_handler())) diff --git a/sql/log.cc b/sql/log.cc index eeb9e455d7d5d..ea86c07e5b717 100644 --- a/sql/log.cc +++ b/sql/log.cc @@ -8298,7 +8298,10 @@ Event_log::prepare_pending_rows_event(THD *thd, TABLE* table, pending->get_table_id() != table->s->table_map_id || pending->get_general_type_code() != event_factory.type_code || pending->get_data_size() + needed > opt_binlog_rows_event_max_size || - pending->read_write_bitmaps_cmp(table) == FALSE) + pending->read_write_bitmaps_cmp(table) == FALSE || + /* Cascade-derived rows and the statement's own go to separate events */ + (pending->get_flags(Rows_log_event::FK_CASCADE_DERIVED_F) != 0) != + thd->binlog_fk_cascade_derived) { /* Create a new RowsEventT... */ Rows_log_event* const diff --git a/sql/sql_class.cc b/sql/sql_class.cc index ecd3a0d731300..92fc2c66a3191 100644 --- a/sql/sql_class.cc +++ b/sql/sql_class.cc @@ -237,12 +237,22 @@ void THD::discard_pending_cascade_binlog() See include/mysql/service_thd_fk_cascade.h for the contract. */ +/* Close the innermost cascade action's frame, freeing its images. */ +void THD::fk_cascade_pop_images() +{ + if (!fk_cascade_image_stack.elements()) + return; + Fk_cascade_images &top= fk_cascade_image_stack.pop(); + my_free(top.before); + my_free(top.after); +} + + +/* Close every frame; a backstop for actions the engine never reported. */ void THD::fk_cascade_free_images() { - my_free(fk_cascade_before_image); - my_free(fk_cascade_after_image); - fk_cascade_before_image= NULL; - fk_cascade_after_image= NULL; + while (fk_cascade_image_stack.elements()) + fk_cascade_pop_images(); } @@ -425,12 +435,28 @@ int thd_fk_cascade_capture(THD *thd, TABLE *table, int which) if (!thd || !table) return 1; - const size_t len= table->s->reclength; - uchar **slot= (which == FK_CASCADE_IMAGE_BEFORE ? - &thd->fk_cascade_before_image : &thd->fk_cascade_after_image); + /* + A before-image starts a new cascade action. It gets a frame of its own, + even if an outer action is still in flight: performing the outer cascade + is what reaches this nested one. The frame is closed by + thd_fk_cascade_row() or thd_fk_cascade_abort(), also when this capture + fails. + */ + if (which == FK_CASCADE_IMAGE_BEFORE) + { + THD::Fk_cascade_images frame= {NULL, NULL}; + if (thd->fk_cascade_image_stack.append(frame)) + return 1; + } + else if (!thd->fk_cascade_image_stack.elements()) + return 1; - if (!*slot && !(*slot= (uchar *) my_malloc(PSI_INSTRUMENT_ME, len, - MYF(MY_WME)))) + THD::Fk_cascade_images &top= *thd->fk_cascade_image_stack.back(); + uchar **slot= (which == FK_CASCADE_IMAGE_BEFORE ? &top.before : &top.after); + + my_free(*slot); + if (!(*slot= (uchar *) my_malloc(PSI_INSTRUMENT_ME, table->s->reclength, + MYF(MY_WME)))) return 1; MY_BITMAP *saved_read, *saved_write, *saved_rpl_write; @@ -454,19 +480,23 @@ int thd_fk_cascade_capture(THD *thd, TABLE *table, int which) extern "C" void thd_fk_cascade_row(THD *thd, TABLE *table, int action) { - if (!thd || !table || !thd->fk_cascade_before_image) + if (!thd) + return; + + if (!table || !thd->fk_cascade_image_stack.elements() || + !thd->fk_cascade_image_stack.back()->before) { - if (thd) - thd->fk_cascade_free_images(); + thd->fk_cascade_pop_images(); return; } + const THD::Fk_cascade_images &images= *thd->fk_cascade_image_stack.back(); const bool is_delete= (action == FK_CASCADE_ACTION_DELETE); - if (!is_delete && !thd->fk_cascade_after_image) + if (!is_delete && !images.after) { /* An update we could not capture the result of: report nothing. */ - thd->fk_cascade_free_images(); + thd->fk_cascade_pop_images(); return; } @@ -482,8 +512,7 @@ void thd_fk_cascade_row(THD *thd, TABLE *table, int action) if (thd->variables.rpl_use_binlog_events_for_fk_cascade || WSREP_EMULATE_BINLOG(thd)) thd->binlog_report_cascade_row(table, is_delete, - thd->fk_cascade_before_image, - thd->fk_cascade_after_image); + images.before, images.after); /* Consumer 2: triggers (not wired up). Would queue TRG_EVENT_DELETE / @@ -496,7 +525,7 @@ void thd_fk_cascade_row(THD *thd, TABLE *table, int action) table->verify_constraints(). */ - thd->fk_cascade_free_images(); + thd->fk_cascade_pop_images(); } @@ -504,7 +533,7 @@ extern "C" void thd_fk_cascade_abort(THD *thd) { if (thd) - thd->fk_cascade_free_images(); + thd->fk_cascade_pop_images(); } diff --git a/sql/sql_class.h b/sql/sql_class.h index 3256e740dfeb3..7e43ce484aa10 100644 --- a/sql/sql_class.h +++ b/sql/sql_class.h @@ -3955,15 +3955,25 @@ class THD: public THD_count, /* this must be first */ void discard_pending_cascade_binlog(); /* - Row images of the FK-cascade action currently being reported by a storage + Row images of the FK-cascade actions currently being reported by a storage engine, materialised by thd_fk_cascade_capture() and consumed by - thd_fk_cascade_row(). At most one cascade action is in flight at a time: - the engine captures the before-image, performs the cascade, captures the - after-image, and reports, all within one call to its cascade routine. + thd_fk_cascade_row(). The engine captures the before-image, performs the + cascade, captures the after-image, and reports, all within one call to its + cascade routine. Performing the cascade may recurse into the next level of + the FK chain, which captures and reports its own rows while the outer + action is still in flight, so the images form a stack: capturing a + before-image opens a frame, thd_fk_cascade_row() / thd_fk_cascade_abort() + close it. Each frame's buffers are sized for its own table. See include/mysql/service_thd_fk_cascade.h. */ - uchar *fk_cascade_before_image= NULL; - uchar *fk_cascade_after_image= NULL; + struct Fk_cascade_images + { + uchar *before; + uchar *after; + }; + Dynamic_array + fk_cascade_image_stack{PSI_INSTRUMENT_MEM}; + void fk_cascade_pop_images(); void fk_cascade_free_images(); /* diff --git a/storage/innobase/handler/ha_innodb.cc b/storage/innobase/handler/ha_innodb.cc index 46f8696b87085..b1a383c0cb12d 100644 --- a/storage/innobase/handler/ha_innodb.cc +++ b/storage/innobase/handler/ha_innodb.cc @@ -8715,7 +8715,11 @@ statement being executed and describes a different set of columns. int ha_innobase::fk_cascade_fetch_row(uchar* buf) { - if (m_prebuilt == NULL || m_prebuilt->mysql_template == NULL + /* No check of m_prebuilt->mysql_template: the template is rebuilt + below, allocated if needed. A handler that has not read anything yet + has none, e.g. the one opened for a self-referencing foreign key, whose + child is the table the statement itself changes. */ + if (m_prebuilt == NULL || m_fk_cascade_rec == NULL || m_fk_cascade_index == NULL) { return HA_ERR_GENERIC; } diff --git a/storage/innobase/row/row0ins.cc b/storage/innobase/row/row0ins.cc index 55e545af09859..1ec69e36e53da 100644 --- a/storage/innobase/row/row0ins.cc +++ b/storage/innobase/row/row0ins.cc @@ -1054,6 +1054,7 @@ row_ins_foreign_check_on_constraint( TABLE* child_mysql_table = NULL; bool need_cascade_binlog = false; + bool cascade_capture_started = false; bool can_cascade_binlog = false; bool have_after_image = false; @@ -1413,6 +1414,13 @@ row_ins_foreign_check_on_constraint( == btr_pcur_t::SAME_ALL) { ib->fk_cascade_set_cursor( btr_pcur_get_rec(cascade->pcur), clust_index); + /* Opens a frame for this action on the server side, + closed below by thd_fk_cascade_row() or + thd_fk_cascade_abort() even if the capture fails. + row_update_cascade_for_mysql() may recurse into the + next level of the FK chain, which reports its own rows + in frames nested inside this one. */ + cascade_capture_started = true; need_cascade_binlog = !thd_fk_cascade_capture( trx->mysql_thd, child_mysql_table, FK_CASCADE_IMAGE_BEFORE); @@ -1456,7 +1464,7 @@ row_ins_foreign_check_on_constraint( mtr_commit(mtr); - if (need_cascade_binlog) { + if (cascade_capture_started) { if (can_cascade_binlog && (cascade->is_delete == PLAIN_DELETE || have_after_image)) { @@ -1472,7 +1480,7 @@ row_ins_foreign_check_on_constraint( ? FK_CASCADE_ACTION_DELETE : FK_CASCADE_ACTION_UPDATE); } else { - /* Captured a before-image we are not going to use. */ + /* Close the frame of an action we are not going to report. */ thd_fk_cascade_abort(trx->mysql_thd); } }