diff --git a/MDEV-38243-design.md b/MDEV-38243-design.md new file mode 100644 index 0000000000000..7158df59423a4 --- /dev/null +++ b/MDEV-38243-design.md @@ -0,0 +1,795 @@ +# 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 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 +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. 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 + +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. + +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 +`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 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 + 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 + +- **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 + — 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_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()` | +| 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 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 + 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..7e121f6a5a078 --- /dev/null +++ b/include/mysql/service_thd_fk_cascade.h @@ -0,0 +1,109 @@ +/* Copyright (c) 2026, MariaDB Corporation. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; version 2 of the License. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1335 USA */ + +#pragma once + +/* + Generic server-side handling of foreign-key cascade actions performed + inside a storage engine. + + A storage engine that performs FK cascades internally (InnoDB does, in + row_ins_foreign_check_on_constraint()) cannot know what the server wants + done about them: the row may need to be written to the binary log, may need + to fire triggers, may need CHECK constraints re-evaluated, and so on. Rather + than teach the engine each of those, the engine reports the action and the + server decides. + + The engine's side of the contract is three calls and no row buffers: + + if (thd_fk_cascade_wanted(thd, child_table)) + { + + 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(). + + Capturing FK_CASCADE_IMAGE_BEFORE starts a cascade action, and every such + call, successful or not, must be matched by exactly one thd_fk_cascade_row() + or thd_fk_cascade_abort(). Actions may nest: while one is in flight, the + cascade it performs may start, capture and report actions on the next level + of the FK chain, which must all be finished before the outer one is. +*/ +int thd_fk_cascade_capture(MYSQL_THD thd, struct TABLE *table, int which); + +/* + Report the completed cascade action, consuming the images captured above, + and dispatch it to whichever server-side consumers apply. Frees the images. +*/ +void thd_fk_cascade_row(MYSQL_THD thd, struct TABLE *table, int action); + +/* Abandon a partially captured cascade action, freeing any images. */ +void thd_fk_cascade_abort(MYSQL_THD thd); + +#ifdef __cplusplus +} +#endif 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/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.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_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_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..cb8970f3a3703 --- /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 +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; +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_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_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..f358e2c2508dd --- /dev/null +++ b/mysql-test/suite/rpl/t/rpl_fk_cascade_binlog_row_slave_option.test @@ -0,0 +1,104 @@ +--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. 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; +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 and +# the cascaded child row events the applier captured. +--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 + +# 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= 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= NOT FOUND|FOUND 0|FOUND [2-9] +--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..bfe6a8618344a 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) { @@ -8030,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/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..ea86c07e5b717 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(); } @@ -8286,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 @@ -8295,6 +8310,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..92fc2c66a3191 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,449 @@ 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. +*/ + +/* 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() +{ + while (fk_cascade_image_stack.elements()) + fk_cascade_pop_images(); +} + + +/* + 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; + + /* + 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; + + 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; + 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) + return; + + if (!table || !thd->fk_cascade_image_stack.elements() || + !thd->fk_cascade_image_stack.back()->before) + { + 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 && !images.after) + { + /* An update we could not capture the result of: report nothing. */ + thd->fk_cascade_pop_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, + images.before, images.after); + + /* + 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_pop_images(); +} + + +extern "C" +void thd_fk_cascade_abort(THD *thd) +{ + if (thd) + thd->fk_cascade_pop_images(); +} + + extern "C" void free_user_var(void *entry_) { user_var_entry *entry= static_cast(entry_); @@ -908,6 +1352,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 +2353,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..7e43ce484aa10 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,74 @@ 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 actions currently being reported by a storage + engine, materialised by thd_fk_cascade_capture() and consumed by + 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. + */ + 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(); + + /* + 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 +3992,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..b1a383c0cb12d 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,59 @@ 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) +{ + /* 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; + } + + 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..1ec69e36e53da 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,12 @@ 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 cascade_capture_started = 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 +1392,102 @@ 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); + /* 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); + 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 (cascade_capture_started) { + 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 { + /* Close the frame of an action we are not going to report. */ + 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 +2697,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;