1
0
mirror of https://github.com/MariaDB/server.git synced 2026-10-23 18:49:29 +03:00

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

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

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

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

2. Design

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

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

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

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

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

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

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

In the design, these handlers are called consumers.

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

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

4. Testing

The commit has mtr tests for testing the feature:

  rpl.rpl_fk_cascade_binlog_row                   - feature OFF vs ON
  rpl.rpl_fk_cascade_binlog_row_ordering          - event ordering
  rpl.rpl_fk_cascade_binlog_row_rollback          - discard on rollback
  rpl.rpl_fk_set_null_binlog_row                  - SET NULL capture
  rpl.rpl_fk_cascade_binlog_row_slave_option      - origin OFF / replica ON
  rpl.rpl_fk_cascade_binlog_row_old_slave         - old-replica compatibility
  rpl.rpl_fk_cascade_binlog_row_mixed_eligibility - mixed child eligibility
This commit is contained in:
sjaakola
2026-04-02 21:22:57 +03:00
parent 9d7ab2c009
commit 89c239b72f
37 changed files with 2964 additions and 32 deletions

774
MDEV-38243-design.md Normal file
View File

@@ -0,0 +1,774 @@
# MDEV-38243 — Binlog row events for cascading foreign key operations
## Design document
Status: implemented on branch `MDEV-38243-new_API`, current at commit
`7e4a9d269c4` (third round of review changes: the SE ↔ server boundary is now
the generic FK-cascade service `include/mysql/service_thd_fk_cascade.h`).
---
## 1. Problem statement
Foreign key cascade actions — `ON DELETE CASCADE`, `ON UPDATE CASCADE`,
`ON DELETE SET NULL`, `ON UPDATE SET NULL` — are executed **inside InnoDB**
(`row_ins_foreign_check_on_constraint()`), below the SQL layer. In row-based
replication the SQL layer only logs the row changes it drives directly, i.e.
the change to the *parent* table named in the statement. The cascaded changes
to *child* tables are performed by InnoDB and never surface to the binlogging
layer.
Currently replication handles changes done by the foreign key constraint cascade
execution so that the replication **SQL slave re-executes the cascade**:
it applies the parent-table row event with foreign key checks enabled, and its
own InnoDB reproduces the child-row changes.
Re-executing the cascade on the replica is a source of problems:
- **Non-determinism / divergence** when the replica's schema, indexes or FK
definitions differ, or when `SET NULL` ordering is ambiguous.
- **Parallel-apply hazards**, particularly for Galera appliers, where a cascade
fired during apply interacts unpredictably with other concurrent appliers.
Conflicts in galera applying is the primary reason for the feature.
## 2. Prior Art
MySQL 9.6 has refactored Foreign key constraint handling to happen in server side,
which directly allows recording binlog events for cascading operation changes.
See: https://blogs.oracle.com/mysql/no-more-hidden-changes-how-mysql-9-6-transforms-foreign-key-management
If similar feature is planned to be implemented in MariaDB, then this MDEV-38243
becomes obsolete.
## 3. Goal
Have the **origin** capture the row changes produced by cascading FK operations
and write them into the binary log as **explicit row events**, so that the
**replica applies just those events and does not re-run the cascade**. This
makes applying deterministic and free of cascade-induced parallel-apply hazards.
## 4. Feature switch
- New session variable **`rpl_use_binlog_events_for_fk_cascade`**, default
`OFF`. It cannot be changed inside a transaction or sub-statement
(`error_if_in_trans_or_substatement`); the check is skipped for
`SET GLOBAL`.
- **The session variable is required in all cases, Galera included.** Under
`WSREP_EMULATE_BINLOG` (Galera with the binary log off) two of the *other*
preconditions become moot and are skipped — the row-format test, because a
writeset is always row-based, and the table-eligibility test of §6.3, because
a writeset is not subject to that row-image caveat — but emulation alone does
**not** turn the feature on. Treating `WSREP_EMULATE_BINLOG` as sufficient
would enable cascade capture for every Galera session and break cascade
replication; `thd_fk_cascade_wanted()` carries a comment saying so.
- Row-based binary logging is otherwise required; the capture is a no-op
in statement format.
## 5. Architecture / data flow
```
Statement executes on origin
│
▼
InnoDB parent DML ── cascade ──► row_ins_foreign_check_on_constraint()
│ │
│ ├─ locate the child's open MySQL TABLE
│ ├─ thd_fk_cascade_wanted(thd, child)? ──── no ──► plain cascade
│ ├─ position cascade cursor on the row
│ ├─ thd_fk_cascade_capture(BEFORE) ─┐
│ ├─ row_update_cascade_for_mysql() │ server materialises
│ ├─ position cursor on changed row │ the images, via
│ ├─ thd_fk_cascade_capture(AFTER) ──┘ handler::fk_cascade_fetch_row()
│ └─ thd_fk_cascade_row(DELETE|UPDATE)
│ │
│ ▼ (server side, sql/sql_class.cc)
│ dispatch to consumers; consumer 1 is
│ binlogging: THD::binlog_report_cascade_row()
▼ queues the images on the THD
parent row event logged (flagged)
│
▼
statement end / commit ──► THD::flush_pending_cascade_binlog()
│ emits queued child events in order
▼
binary log: [parent event][derived child events...]
all flagged, applied verbatim on replica
```
The engine/server boundary sits at the *report* step: the engine says only
"a cascade is about to happen / has happened on this child row"; every decision
about what the row image contains, whether it is binlogged, when and how, is
made on the server side. See §6.
## 6. Origin side — capture
### 6.1 The SE ↔ server interface
`include/mysql/service_thd_fk_cascade.h` is a **generic FK-cascade service**,
not a binlog service. Its premise: an engine that performs FK cascades
internally cannot know what the server wants done about them — the row may
need to be binlogged, may need to fire triggers, may need CHECK constraints
re-evaluated. Rather than teach the engine each of those, the engine reports
the action and the server decides.
```c
#define FK_CASCADE_IMAGE_BEFORE 0
#define FK_CASCADE_IMAGE_AFTER 1
#define FK_CASCADE_ACTION_DELETE 0
#define FK_CASCADE_ACTION_UPDATE 1
#define FK_CASCADE_ACTION_SET_NULL 2
int thd_fk_cascade_wanted (MYSQL_THD thd, struct TABLE *table);
int thd_fk_cascade_capture(MYSQL_THD thd, struct TABLE *table, int which);
void thd_fk_cascade_row (MYSQL_THD thd, struct TABLE *table, int action);
void thd_fk_cascade_abort (MYSQL_THD thd);
```
The engine's side of the contract is those four calls **and no row buffers**:
```c
if (thd_fk_cascade_wanted(thd, child_table))
{
<position the cascade cursor on the row about to change>
thd_fk_cascade_capture(thd, child_table, FK_CASCADE_IMAGE_BEFORE);
<perform the cascade>
<position the cascade cursor on the changed row>
thd_fk_cascade_capture(thd, child_table, FK_CASCADE_IMAGE_AFTER);
thd_fk_cascade_row(thd, child_table, FK_CASCADE_ACTION_UPDATE);
}
```
The server owns the row images end to end: it decides which columns make up
the image (the engine does not touch the `TABLE`'s column bitmaps), it owns the
buffers they are materialised into, and it frees them. Capture is *driven* from
the engine — only the engine knows when the row is about to change and when it
has changed — but *performed* by the server.
The engine's one remaining contribution is a new handler method:
```c
/* sql/handler.h */
virtual int handler::fk_cascade_fetch_row(uchar *buf)
{ return HA_ERR_WRONG_COMMAND; }
```
which converts the record the engine's cascade cursor is sitting on into MySQL
row format, honouring the bitmaps the server has already set. Engines that do
not cascade internally never see the call.
### 6.2 Engine side — `storage/innobase/row/row0ins.cc`
In `row_ins_foreign_check_on_constraint()`:
1. **Locate the child table.** `row_ins_find_open_table_for_cascade()` parses
the child's db/table name and calls `find_fk_open_table()`, which returns the
child `TABLE` only if it was opened via FK **prelocking**
(`TABLE_LIST::PRELOCK_FK`). Only the engine can map its own `dict_table_t` to
a name, which is why this much stays here. See §11 for the consequence on the
replica.
2. **Ask the server.** `thd_fk_cascade_wanted(trx->mysql_thd, child_mysql_table)`.
Nothing further happens if it says no.
3. **Capture the before-image.** In a short `mtr` of its own, the cascade cursor
is restored (`cascade->pcur->restore_position(BTR_SEARCH_LEAF, mtr)`, requiring
`SAME_ALL`), the record and clustered index are handed to
`ha_innobase::fk_cascade_set_cursor(rec, index)`, and
`thd_fk_cascade_capture(..., FK_CASCADE_IMAGE_BEFORE)` is called. The cursor
is cleared again immediately (`fk_cascade_set_cursor(NULL, NULL)`): it is
only valid while the caller holds the page latch. Success is tracked in
`need_cascade_binlog`.
4. **Run the actual cascade** via `row_update_cascade_for_mysql()`.
`can_cascade_binlog = (err == DB_SUCCESS && need_cascade_binlog)`.
5. **Capture the after-image** (for `UPDATE` / `SET NULL`; not for
`PLAIN_DELETE`) the same way, from the just-modified record — provided it is
still a user record and not delete-marked. Result in `have_after_image`.
6. **Report or abort.** If a before-image was captured
(`need_cascade_binlog`), exactly one of the two happens:
`thd_fk_cascade_row(..., FK_CASCADE_ACTION_DELETE | FK_CASCADE_ACTION_UPDATE)`
when the cascade succeeded and the required images are present, otherwise
`thd_fk_cascade_abort()`. Either call frees the server-side images, so no
capture can leak because the cascade failed midway.
`ha_innobase::fk_cascade_fetch_row()` (in `ha_innodb.cc`) does the conversion:
it rebuilds the InnoDB row template for the server's bitmaps
(`rebuild_template_for_cascade_binlog_row_image()`), computes `rec_offsets`,
temporarily points `prebuilt->index` at the cascade index, calls
`row_sel_store_mysql_rec()`, and resets the template
(`reset_template_for_cascade_binlog_row_image()`) — the template in force
belongs to the statement being executed and describes a different set of
columns. It returns `HA_ERR_GENERIC` if the record cannot be converted, and the
cursor state (`m_fk_cascade_rec`, `m_fk_cascade_index`) is per-handler and
valid only for the duration of one capture call.
### 6.3 Server side — `sql/sql_class.cc`
`thd_fk_cascade_wanted()` answers for **every consumer at once**, so the engine
can skip the capture cost when nothing is interested. It is cheap enough to call
per cascaded row. Its binlog arm requires, in order: the session variable; row
format *or* `WSREP_EMULATE_BINLOG`; eligibility *or* `WSREP_EMULATE_BINLOG`;
and `table->file->prepare_for_row_logging()`.
`fk_cascade_table_eligible()` — moved here from InnoDB — rejects a table that
has *no primary key* **and** a *virtual column participating in a key*: such a
row cannot be identified unambiguously from a full row image, so we decline to
report the cascade rather than hand a consumer something it would apply to the
wrong row.
Eligibility is then decided **for the statement, not for the table**, by
`fk_cascade_stmt_capturable()`. The reason is that the two halves of the
mechanism have different scopes: eligibility is a property of one table, but
the flag that stops the replica re-running the cascade is a property of the
whole statement — the first captured child row calls
`binlog_mark_fk_cascade_events()`, and from then on *every* row event of the
statement carries `FK_CASCADE_EVENTS_F | NO_FOREIGN_KEY_CHECKS_F` (§8), so the
replica stops cascading for all children, not just the captured one.
A per-child answer therefore lets an eligible child switch the replica's
cascade off on behalf of an ineligible sibling whose rows were never logged:
the replica neither receives those rows nor recreates them, and silently keeps
the orphans. Pinned by `rpl_fk_cascade_binlog_row_mixed_eligibility` (§14),
which diverges without this check.
The decision is made all-or-nothing over the **FK prelocking set**, which is
what makes it decidable before any row is touched:
`prepare_fk_prelocking_list()` (`sql_base.cc:5390`) has already enumerated,
transitively, every child table the statement can write through a cascade, and
opened each one — children needed only for read-only FK checks are `OPEN_STUB`
(`table.h:2603`) and never reach `thd->open_tables`, so a scan of that list for
`PRELOCK_FK` entries with `TL_FIRST_WRITE` is exactly the reachable set. If any
of them is ineligible the feature is declined for the whole statement and the
classic replica-side cascade handles all of it — slower, but correct. The
answer is cached on the `THD` and keyed on `query_id`, so it cannot outlive the
statement it was computed for and needs no reset hook.
`thd_fk_cascade_capture()` allocates the image slot
(`THD::fk_cascade_before_image` / `fk_cascade_after_image`, `reclength` bytes),
switches the table to the full row image via
`fk_cascade_begin_full_row_image()` — `tmp_set` set to all columns minus the
virtual ones, installed as read/write set, and as `rpl_write_set` if that was
NULL — calls `handler::fk_cascade_fetch_row()`, and restores the statement's own
bitmaps with `fk_cascade_end_full_row_image()`. This bitmap policy is *server*
policy, which is why it lives here and not in the engine. On failure the slot is
freed and non-zero returned.
`thd_fk_cascade_row()` consumes the images and dispatches. At most one cascade
action is in flight per THD at a time, which is what lets the images live as two
plain `THD` members: the engine captures before, cascades, captures after, and
reports, all within one call to its cascade routine. An update whose
after-image could not be captured is dropped rather than reported. The images
are freed on the way out (`THD::fk_cascade_free_images()`), and `~THD()` frees
them as a backstop for an in-flight capture the engine never reported.
### 6.4 Consumers
The point of routing cascades through the server is that each known gap in how
FK cascades behave becomes an arm of `thd_fk_cascade_wanted()` plus an arm of
`thd_fk_cascade_row()`, instead of new machinery inside every engine that
cascades. Three consumers are identified; **only the first is implemented**, the
other two are documented in place in `sql_class.cc` as commented-out arms:
| # | Consumer | State | Note |
|---|----------|-------|------|
| 1 | Binary logging of the cascaded rows | **implemented** | deferred: queued now, written at statement end (§7) |
| 2 | Triggers on the child table | not wired up | `AFTER` triggers would have to be queued and run at statement end; `BEFORE` triggers cannot be supported at this call site at all — see §6.4.1 |
| 3 | CHECK constraints on the child table | not wired up | safe to evaluate inline: `TABLE::verify_constraints()` only evaluates expressions over `record[0]` and touches no other table, and InnoDB already evaluates that class of expression in this path — see §6.4.1 |
#### 6.4.1 Why `BEFORE` triggers cannot be honoured here
Worth recording, because it is not a matter of the service growing another
call. Three blockers: the first two are about the shape of the service, the
third about where it is called from. The third is the one a wider API cannot
fix, and also the one most often challenged — see the bound on it below.
**No pre-cascade callback carries the new row.** The only calls that happen
before the child row changes are `thd_fk_cascade_wanted()`
(`row0ins.cc:1408`) and `thd_fk_cascade_capture(FK_CASCADE_IMAGE_BEFORE)`
(`:1416`). The first is a yes/no gate; the second materialises the *old* row.
Neither is given the proposed new values. The only call that carries the
action, `thd_fk_cascade_row()` (`:1469`), runs after
`row_update_cascade_for_mysql()` (`:1424`) has returned — the row is already
rewritten, or for a cascade delete, gone.
**Nothing can carry a trigger's result back.** `thd_fk_cascade_row()` returns
`void`; `thd_fk_cascade_capture()` returns `int`, but the engine uses it only
to set `need_cascade_binlog` and never to abort or redirect the cascade. All
three effects of a `BEFORE` trigger are therefore unrepresentable: rewriting
`NEW` has no return channel, `SIGNAL`-ing an error has no abort path, and
skipping the row has neither. The last is not hypothetical —
`Table_triggers_list::process_triggers()` asserts that a `TRG_ACTION_BEFORE`
invocation *must* supply a `skip_row_indicator` out-param and that a
`TRG_ACTION_AFTER` one must not (`sql/sql_trigger.cc:2799`), so the skip
channel is structurally part of running a `BEFORE` trigger.
**A wider API would not help: this call site cannot run a stored program.**
Both captures execute inside `mtr_start()`/`mtr_commit()` holding a
`BTR_SEARCH_LEAF` page latch on the child's clustered index (`:1411`-`:1421`,
`:1427`-`:1457`), and the whole of `row_ins_foreign_check_on_constraint()`
runs under a shared `dict_sys.latch` — `row_ins_check_foreign_constraint()`,
which calls it at `:1867`, documents that requirement at `:1576`. A trigger
body is arbitrary SQL that opens tables, takes MDL and re-enters the engine,
none of which is admissible under those latches; that holds even at the report
point `:1469`, which is free of page latches (which is why binlogging is safe
there) but still under the dictionary latch and still nested inside the parent
statement's `ha_update_row()`.
##### The bound on that claim: InnoDB *does* call into the server here
The blocker above is specifically about running a **stored program**, not
about calling into the server at all. The broader claim would be wrong, and
visibly so in this very function.
InnoDB evaluates server-side `Item` expressions in place, without unwinding:
`innobase_get_computed_value()` (`ha_innodb.cc:21044`) calls
`TABLE::update_virtual_field()` (`table.cc:9490`), which switches to the
table's `expr_arena` and evaluates the virtual column's expression. That is
reached from `row_ins_foreign_fill_virtual()` (`row0ins.cc:877`, calls at
`:927` and `:946`), which the cascade path itself invokes at `:1319` — *before*
the `mtr_commit(mtr)` at `:1388`. So server expression evaluation already runs
inside `row_ins_foreign_check_on_constraint()` with the mini-transaction
active. Index condition pushdown does the same thing on the read path.
This is why **consumer 3 (CHECK constraints) is the easy one**:
`TABLE::verify_constraints()` (`table.cc:6714`) is the same shape —
`expr->val_bool()` over `record[0]` — so it reuses a mechanism the engine
already has.
What makes that safe is that the expression class is *closed by
construction*, not that expression evaluation is inherently harmless:
- `Item::check_vcol_func_processor` **defaults** to
`mark_unsupported_function(..., VCOL_IMPOSSIBLE)` (`item.h:2453`), so
anything not explicitly whitelisted is rejected;
- `Item_subselect` rejects explicitly (`item_subselect.h:244`);
- `Item_func_sp` does not override the default, so stored function calls are
rejected with it.
Observable directly:
```
CREATE TABLE t1 (a INT, b INT AS (f1(a)) VIRTUAL);
ERROR HY000: Function or expression '`f1`()' cannot be used in the
GENERATED ALWAYS AS clause of `b`
CREATE TABLE t2 (a INT, CHECK (f1(a) > 0));
ERROR HY000: Function or expression '`f1`()' cannot be used in the
CHECK clause of `CONSTRAINT_1`
CREATE TABLE t3 (a INT, CHECK (a IN (SELECT 1)));
ERROR 42000: CHECK does not support subqueries or stored functions
```
A vcol or CHECK expression therefore provably cannot open a table, take MDL or
re-enter the engine. The engine is not granted a general licence to call the
server; it is granted one for a class closed against exactly the hazards a
latch cares about. A trigger body is the opposite of that class: an `sp_head`
of arbitrary statements. There is no `sp_head`, `execute_trigger` or
`process_triggers` anywhere under `storage/innobase/` — no precedent for
running a stored program from inside the engine.
So the question to settle is not "must we leave InnoDB", which the vcol
precedent answers with "not necessarily", but "can `sp_head::execute_trigger`
run under a page latch and a shared `dict_sys.latch`". For arbitrary bodies,
no. For a restricted subset — a body that is only `SET NEW.col = <expression>`
over a vcol-safe expression, classifiable at trigger-creation time much as
vcol expressions already are — plausibly yes, and that would be a real design
worth costing.
Even then it does not on its own yield working `BEFORE` triggers: blockers 1
and 2 are untouched by any of it. There is still no pre-cascade callback
carrying the proposed new row, and still no channel to feed a modified `NEW`,
a `SIGNAL` abort, or a `skip_row_indicator` back into `cascade->update`.
Latching is the third blocker, not the only one.
##### Why deferring is not a workaround either
Running the trigger at statement end, as consumer 2 would have to for `AFTER`,
is exactly what a `BEFORE` trigger cannot tolerate.
Note the near-miss on the data flow. The proposed new values *do* exist before
the cascade runs — `row_ins_cascade_calc_update_vec()` builds
`cascade->update` at `row0ins.cc:1314`, 110 lines ahead of the cascade itself.
Nothing about the ordering of information blocks this. Supporting `BEFORE`
triggers on cascaded rows means either the restricted-subset design sketched
above, or hoisting cascade execution above the engine — the MySQL 9.6 route in
§2 — not another service call of the present shape. §6.4.2 records the main
counter-proposal and why it does not change that.
#### 6.4.2 Considered and rejected: unwind-and-retry
The obvious counter-proposal, recorded here because a reviewer will arrive at
it independently. Sketch: let `thd_fk_cascade_wanted()` return not just yes/no
but "a `BEFORE` trigger must run first"; on seeing that, InnoDB unwinds out of
the cascade back to the point of entry, releasing all latches; the server runs
the trigger with no latches held; the cascade then restarts from the
beginning.
Two things genuinely favour it.
*The latch problem does dissolve.* Unwinding to the engine-entry boundary is
one valid answer to §6.4.1's third blocker — the other being the restricted
trigger-body subset sketched there, which sidesteps the latches instead of
escaping them — and the shape is not foreign to InnoDB: `DB_LOCK_WAIT` is
already handled by `row_mysql_handle_errors()` with a `trx_savept_t` partial
rollback and a re-run of the query graph.
*Trigger prelocking is already in place.* This would normally be fatal — a
trigger body needs its own tables and routines locked, and acquiring locks
mid-statement invites deadlock — but it is already handled.
`prepare_fk_prelocking_list()` adds every *modified* FK child with
`TL_FIRST_WRITE`; only read-only children are reduced to `OPEN_STUB`
(`table.h:2603`). So the child gets its own `handle_table()` pass
(`sql_base.cc:4160`), which calls `add_tables_and_routines_for_triggers()`,
and — since that pass runs `prepare_fk_prelocking_list()` again on the child —
the walk is transitive through grandchildren. The child's triggers are loaded
and everything their bodies touch is already in the prelocking set.
This has been verified by instrumenting `handle_table()`: for a parent with a
cascade child, the child appears with `prelocking_placeholder = PRELOCK_FK`, a
write lock, and a loaded `TABLE::triggers`, and a grandchild reachable only
through the child appears the same way. There is no SQL-observable way to
assert it, so no test pins it.
See §6.4.3 for the event-mapping defect this exposed.
Four things sink it.
**Retries are per child row, not per statement.**
`row_ins_foreign_check_on_constraint()` is called from the scan loop in
`row_ins_check_foreign_constraint()` (`row0ins.cc:1867`), once per matching
child record. N cascaded rows therefore means N unwind/replay cycles, each
redoing the parent row operation and every child cascaded before it, and
recursing into grandchildren — O(N²). Lock-wait retry tolerates this because
it is exceptional; a `BEFORE` trigger on a cascade child is the common case as
soon as it is supported at all.
**The replay has an ordering paradox.** The trigger must run *after* the
rollback, or the rollback discards its writes; the replay then re-enters the
cascade and asks again. That requires memoising "already fired for this child
row" against a row identity that survives a rollback and replay. The failure
modes are an infinite loop or a trigger that fires twice.
**The trigger's output still has nowhere to go.** A `BEFORE UPDATE` trigger
exists to rewrite `NEW`, but after the replay InnoDB rebuilds
`cascade->update` from the parent's new values (`row0ins.cc:1314`), discarding
it. The scheme would additionally need a channel making InnoDB derive the
update vector from a server-supplied image, an abort path for `SIGNAL`, and a
skip-this-child-but-continue mode for `skip_row_indicator`. Building the first
of those yields most of what is needed to drive the cascade from the server
anyway, at which point the retry machinery is redundant.
**The trigger would observe the wrong state.** It runs after the unwind, so a
body reading the parent table sees the pre-statement state rather than what a
`BEFORE` trigger sees in a server-driven cascade.
#### 6.4.3 Child trigger events: what the cascade does, not what the statement does
`prepare_fk_prelocking_list()` used to propagate the *parent's*
`trg_event_map` to the child verbatim. That is right for `ON DELETE CASCADE`,
where a parent delete becomes a child delete, and for every `ON UPDATE`
action, which updates the child. It is wrong for **`ON DELETE SET NULL`** (and
`SET DEFAULT`): the child row is *updated*, not deleted, so the child was
prelocked carrying `TRG_EVENT_DELETE`. Its `UPDATE` triggers' tables and
routines were therefore never added to the prelocking set, and firing one
would have tripped the `DBUG_ASSERT` in
`Table_triggers_list::process_triggers()` (`sql_trigger.cc:2829`), which
requires the event bit to be present in `pos_in_table_list->trg_event_map`.
The child's map is now derived from the cascade action:
| parent event | FK action | child event |
|---|---|---|
| `DELETE` | `ON DELETE CASCADE` | `TRG_EVENT_DELETE` |
| `DELETE` | `ON DELETE SET NULL` / `SET DEFAULT` | `TRG_EVENT_UPDATE` |
| `UPDATE` | `ON UPDATE CASCADE` / `SET NULL` / `SET DEFAULT` | `TRG_EVENT_UPDATE` |
| either | `RESTRICT` / `NO ACTION` | none (read-only child, `TL_READ`) |
A zero map is exactly the read-only case, so it also decides the lock type,
replacing the previous separate `fk_modifies_child()` test.
One related gap closed with it: a child reachable through *several*
constraints undergoes the union of their actions — `ON DELETE CASCADE` on one
column and `ON DELETE SET NULL` on another both apply, deleting some rows and
updating others. `table_already_fk_prelocked()` skipped the second constraint,
leaving the first one's events to stand for both; it now returns the existing
entry so the events can be OR-ed into it.
**No user-visible effect today**, because triggers still do not fire on
cascaded rows — this is groundwork for consumer 2, and a latent assertion
failure removed. What it does change is that a `SET NULL` child's `UPDATE`
triggers now contribute their tables and routines to the prelocking set, so
such statements take a slightly wider set of table locks than before.
##### Salvageable: decide before entering the engine
The detection half of the proposal stands on its own, without any unwinding.
Because modified FK children are prelocked *with their triggers loaded*, the
server can answer "does any reachable cascade child carry a `BEFORE` trigger?"
at statement start, before InnoDB is entered — by walking the prelocking list
for `PRELOCK_FK` entries with `TL_FIRST_WRITE` and a non-NULL
`TABLE::triggers`. No sentinel return, no replay, no latch exposure. That is
enough to refuse the statement or divert it to a server-driven path, instead
of today's behaviour of silently not firing the trigger.
The same observation is already load-bearing elsewhere: `fk_cascade_stmt_capturable()`
(§6.3) uses exactly this walk of the FK prelocking set to decide binlog
eligibility for the whole statement up front. A trigger consumer would reuse
the scan and only change the predicate.
### 6.5 Deferred, ordered logging
Binlog events are **queued, not emitted inline**. The queue preserves *execution
order*. Emitting deletes inline would place every cascade delete ahead of every
deferred update within a statement, reordering events that touch the same row
and potentially making the replica apply an update to an already-deleted row.
## 7. Lifecycle — flush and discard
The queue is `THD::pending_cascade_binlog_row_events`, a
`Dynamic_array<THD::Cascade_binlog_row_event>` holding the child `TABLE`, the
server's own copies of the before/after images, and an `is_delete` flag. The
`THD` owns it outright; no engine structure participates in its lifetime.
`THD::binlog_report_cascade_row()` copies the images into it and calls
`binlog_mark_fk_cascade_events()`, so that the originating statement's own
already-pending row event gets flagged too (§8).
**Flush** (`THD::flush_pending_cascade_binlog()`): emits each queued event via
`handler::binlog_log_row()` with the table's bitmaps temporarily set to
`table->s->all_set`, choosing `Delete_rows_log_event` vs.
`Update_rows_log_event` from `is_delete`, and frees the record copies as it
goes. Temporary tables and entries whose `TABLE`/handler is gone are skipped
(their buffers freed). It is driven from two choke points:
- `binlog_flush_pending_rows_event()` in `sql/log.cc` at **statement end**
(`stmt_end`), guarded by `thd->binlog_fk_cascade_events` and the feature
test; the pending event is re-read afterwards, since flushing the cascade
rows may have created or replaced it, before `STMT_END_F` is stamped;
- `ha_commit_trans()` in `sql/handler.cc` at **commit**, when `all ||
thd->in_active_multi_stmt_transaction()`.
**Discard** (`THD::discard_pending_cascade_binlog()`): frees the queued record
copies and empties the array without writing anything. It runs on:
- `ha_rollback_trans()` — full/statement rollback (`sql/handler.cc`);
- `ha_rollback_to_savepoint()` — rollback to savepoint (events already flushed
into the binlog cache by earlier statements are handled separately, by the
binlog savepoint machinery truncating the cache);
- `~THD()` — backstop only, so that an undrained queue cannot leak.
Discard is **unconditional** with respect to thread type — it must run for
applier threads too, since it only frees memory. Both helpers
(`flush_pending_cascade_binlog_for_thd()`,
`discard_pending_cascade_binlog_for_thd()`) are called under the feature test
`rpl_use_binlog_events_for_fk_cascade || WSREP_EMULATE_BINLOG(thd)`.
### 7.1 Queue lifetime vs. `TABLE` lifetime
A queued entry stores a raw `TABLE *`. Because the queue outlives the engine
transaction (it is reclaimed by the server, not by `trx_t::free()`), the
invariant that matters is that the queue is **drained before the statement
closes its tables**. It holds: in `mysql_execute_command()`'s `finish:` block,
`trans_commit_stmt()` / `trans_rollback_stmt()` run *before*
`close_thread_tables_for_query()`; and for a multi-statement transaction the
`in_active_multi_stmt_transaction()` arm of the `ha_commit_trans()` gate drains
per statement, likewise with tables still open.
This is asserted rather than assumed — `mysql_execute_command()` carries a
`DBUG_ASSERT` immediately after `close_thread_tables_for_query()` that the
queue is empty at the top level (a substatement may legitimately leave rows
queued for the enclosing statement, since the draining commit/rollback is
itself under `! thd->in_sub_stmt`). The assert exists because the
`!table || !table->file` guard inside the flush loop can only catch a *closed*
table, not one that was freed and had its memory reused; a leftover entry would
otherwise be flushed by a later statement against a dangling pointer, silently.
## 8. Event marking
Three flags on `Rows_log_event` participate (`sql/log_event.h`):
| Flag | Bit | Set on | Meaning |
|------|-----|--------|---------|
| `NO_FOREIGN_KEY_CHECKS_F` | 1 | every cascade-logged event | pre-existing flag; disables FK checks on apply |
| `FK_CASCADE_EVENTS_F` | 4 | every cascade-logged event (root + derived) | this statement's cascade rows were logged; suppress re-cascade |
| `FK_CASCADE_DERIVED_F` | 5 | derived (child) events only | distinguishes cascade-derived rows from the originating rows |
Origin-side mechanism:
- `THD::binlog_fk_cascade_events` (bool) is set by
`THD::binlog_mark_fk_cascade_events()` on the first cascade of a statement; it
also stamps the currently-pending row events. It is reset in
`reset_binlog_for_next_statement()`.
- `Event_log::prepare_pending_rows_event()` stamps every newly created event
with `FK_CASCADE_EVENTS_F | NO_FOREIGN_KEY_CHECKS_F` while that THD flag is on.
- `THD::binlog_fk_cascade_derived` (bool) is turned on only around the flush
loop (`binlog_begin_fk_cascade_derived()` / `binlog_end_fk_cascade_derived()`
in `THD::flush_pending_cascade_binlog()`), so events created during
the flush additionally get `FK_CASCADE_DERIVED_F`. The originating (parent)
event is created *outside* the flush loop and therefore stays underived.
Result: the **root** event carries `{EVENTS_F, NO_FK_CHECKS_F}`; each **derived**
event carries `{EVENTS_F, DERIVED_F, NO_FK_CHECKS_F}`.
## 9. Replica side — apply
`Rows_log_event::do_apply_event()` (`sql/log_event_server.cc`): if the event
carries `NO_FOREIGN_KEY_CHECKS_F` **or** `FK_CASCADE_EVENTS_F`, it sets
`OPTION_NO_FOREIGN_KEY_CHECKS`, which InnoDB maps to `trx->check_foreigns =
false` (`ha_innodb.cc`). With FK checks off, `row_ins_check_foreign_constraint()`
returns early and `row_ins_foreign_check_on_constraint()` is never reached — the
replica does **not** re-run the cascade. It applies the explicit parent and
child row events directly, in log order.
No slave-thread guards (`!thd->rgi_slave` / `!thd_is_slave`) are used on the
capture or flush paths. They were removed as redundant: the flag-driven
suppression above already prevents an applier from re-cascading, so capture is
unreachable there for cascade-logged transactions.
## 10. Backward compatibility (older replica, strict mode)
An older MariaDB replica does not understand `FK_CASCADE_EVENTS_F`/
`FK_CASCADE_DERIVED_F` and would ignore them. It **does** understand the ancient
`NO_FOREIGN_KEY_CHECKS_F`. Because the origin sets that flag on every
cascade-logged event, an old replica:
1. applies the parent event with FK checks disabled → does **not** re-cascade;
2. applies the derived child events as the sole source of child changes.
No collision occurs and the data is correct **even under strict
`slave_exec_mode`**. Had `NO_FOREIGN_KEY_CHECKS_F` not been set, an old replica
would re-cascade *and* apply the derived events, colliding on the same keys →
`HA_ERR_KEY_NOT_FOUND` and a stopped replica in strict mode (tolerated only in
idempotent mode).
A newer replica that wants to re-execute the cascade instead of applying the
derived events can still recognise these events via the new flags and override
`NO_FOREIGN_KEY_CHECKS_F` (see §12).
### 10.1 Old-replica emulation for testing
Standard mtr runs every server from one build, so a genuine "old binary as
replica" test cannot run in the default suite. To still exercise the mechanism
in-tree, a debug-only injection point emulates a pre-MDEV-38243 replica:
- `Rows_log_event::do_apply_event()` computes a local `fk_cascade_events`
(initialised from `get_flags(FK_CASCADE_EVENTS_F)`), and
`DBUG_EXECUTE_IF("rpl_emulate_old_slave_fk_cascade", fk_cascade_events= false)`
forces it off. The FK-check decision then depends **only** on
`NO_FOREIGN_KEY_CHECKS_F` — exactly how an old server behaves.
The test `rpl_fk_cascade_binlog_row_old_slave` enables this keyword on a
strict-mode replica and confirms that a feature-ON origin's `CASCADE` and
`SET NULL` transactions apply with `Last_SQL_Errno=0` and correct data. Because
the emulated replica ignores `FK_CASCADE_EVENTS_F`, a passing run proves the
`NO_FOREIGN_KEY_CHECKS_F` stamp alone suffices; equally, if that stamp were ever
dropped, this test would fail (the emulated replica would re-cascade and
collide).
## 11. Known limitations and edge cases
- **Applier capture is a no-op (by design/limitation).** The capture requires
the child table to be open via FK prelocking (`PRELOCK_FK`). A row-based
applier opens only the tables named in the events, not prelocked FK children,
so `find_fk_open_table()` returns NULL on an applier. Therefore, with the
feature enabled **only on a replica** (origin OFF), the replica re-cascades
the classic way and its own binary log contains only parent events — the
option is effectively inert on the applier path. This is pinned by
`rpl_fk_cascade_binlog_row_slave_option`.
- **Row format only.** Statement-based logging is unaffected.
- **Eligibility is all-or-nothing per statement.** A single ineligible child
— no PK plus a virtual column in a key — disables capture for every child of
that statement, not just itself (§6.3); the statement then falls back to the
classic replica-side cascade. Conservative in two ways: the FK prelocking set
covers children the statement *could* cascade into, not only those it
actually does, and the check does not apply at all under
`WSREP_EMULATE_BINLOG`.
- **Only one consumer.** Triggers and CHECK constraints on cascaded child rows
still do not fire; the service is shaped for them (§6.4) but nothing is
wired up. `AFTER` triggers and CHECK constraints are reachable from here;
`BEFORE` triggers are not reachable from this call site at any API width
(§6.4.1).
- **Only InnoDB implements the engine side.** `handler::fk_cascade_fetch_row()`
defaults to `HA_ERR_WRONG_COMMAND`, so any other engine that starts calling
the service must override it.
- **Observability.** `mysqlbinlog` prints only `STMT_END_F` in its verbose
header; the FK cascade flags are not surfaced there. They are observable only
through apply behaviour (or a future print extension).
## 12. Future work — "applier decides"
The `FK_CASCADE_DERIVED_F` marking is groundwork for an optional mode letting an
applier choose between applying the derived events (default) and re-executing
the cascade. Sketch:
- Add a replica variable, e.g. `slave_fk_cascade_mode = {APPLY_EVENTS |
EXECUTE_CASCADE}`.
- `APPLY_EVENTS` (default): current behaviour.
- `EXECUTE_CASCADE`: for events carrying `FK_CASCADE_EVENTS_F`, do **not** honour
`NO_FOREIGN_KEY_CHECKS_F` on the *root* events (those without
`FK_CASCADE_DERIVED_F`), so InnoDB re-cascades; and **skip** the events
carrying `FK_CASCADE_DERIVED_F`.
This reintroduces cascade non-determinism deliberately, so it is a
compatibility/fallback knob, not a routine mode.
## 13. Code map
| Area | File(s) | Key symbols |
|------|---------|-------------|
| Feature var | `sql/sys_vars.cc`, `sql/sql_class.h` | `rpl_use_binlog_events_for_fk_cascade`, `rpl_use_binlog_events_for_fk_cascade_check()` |
| SE ↔ server service | `include/mysql/service_thd_fk_cascade.h` | `thd_fk_cascade_wanted()`, `thd_fk_cascade_capture()`, `thd_fk_cascade_row()`, `thd_fk_cascade_abort()`, `FK_CASCADE_IMAGE_*`, `FK_CASCADE_ACTION_*` |
| Service implementation, consumer dispatch | `sql/sql_class.cc` | `fk_cascade_table_eligible()`, `fk_cascade_stmt_capturable()`, `fk_cascade_begin_full_row_image()`, `fk_cascade_end_full_row_image()`, the four `thd_fk_cascade_*()` |
| Statement-level eligibility cache | `sql/sql_class.h` | `THD::fk_cascade_stmt_query_id`, `THD::fk_cascade_stmt_ok` |
| FK prelocking set | `sql/sql_base.{h,cc}` | `prepare_fk_prelocking_list()` (child `trg_event_map` derived from the cascade action, §6.4.3), `table_already_fk_prelocked()` (returns the existing entry so events can be merged) |
| In-flight images | `sql/sql_class.h`, `sql/sql_class.cc` | `THD::fk_cascade_before_image`, `fk_cascade_after_image`, `fk_cascade_free_images()` |
| Handler hook | `sql/handler.h` | `handler::fk_cascade_fetch_row()` (default `HA_ERR_WRONG_COMMAND`) |
| THD queue, flush / discard, marking | `sql/sql_class.{h,cc}` | `Cascade_binlog_row_event`, `pending_cascade_binlog_row_events`, `binlog_report_cascade_row()`, `flush_pending_cascade_binlog()`, `discard_pending_cascade_binlog()`, `binlog_fk_cascade_events`, `binlog_fk_cascade_derived`, `binlog_mark_fk_cascade_events()`, `binlog_begin/end_fk_cascade_derived()` |
| Engine capture driver | `storage/innobase/row/row0ins.cc` | `row_ins_foreign_check_on_constraint()`, `row_ins_find_open_table_for_cascade()` |
| Engine record conversion | `storage/innobase/handler/ha_innodb.{h,cc}` | `ha_innobase::fk_cascade_set_cursor()`, `ha_innobase::fk_cascade_fetch_row()`, `m_fk_cascade_rec`, `m_fk_cascade_index`, `rebuild_/reset_template_for_cascade_binlog_row_image()` |
| Drain points | `sql/handler.{h,cc}`, `sql/log.cc`, `sql/sql_parse.cc` | `flush_pending_cascade_binlog_for_thd()` / `discard_pending_cascade_binlog_for_thd()` called from `ha_commit_trans`/`ha_rollback_trans`/`ha_rollback_to_savepoint`; `binlog_flush_pending_rows_event()`; drained-queue `DBUG_ASSERT` in `mysql_execute_command()` |
| Event flags / apply | `sql/log_event.h`, `sql/log_event_server.cc`, `sql/log.cc` | `FK_CASCADE_EVENTS_F`, `FK_CASCADE_DERIVED_F`, `do_apply_event()`, `prepare_pending_rows_event()`, `binlog_flush_pending_rows_event()` |
| wsrep | `sql/service_wsrep.cc`, `include/mysql/service_wsrep.h` | `wsrep_emulate_binlog()`, `WSREP_EMULATE_BINLOG()` |
| Test aid | `sql/log_event_server.cc` (`do_apply_event()`) | `DBUG_EXECUTE_IF("rpl_emulate_old_slave_fk_cascade", …)` — emulate a pre-MDEV-38243 replica (see §10.1) |
## 14. Tests (`mysql-test/suite/rpl/`)
- **`rpl_fk_cascade_binlog_row`** — feature OFF vs ON; asserts derived child
row events are absent (OFF) / present (ON) in the origin binlog and replica
data is correct.
- **`rpl_fk_cascade_binlog_row_ordering`** — event ordering of interleaved
cascade delete/update.
- **`rpl_fk_cascade_binlog_row_rollback`** — queued events discarded on
rollback / rollback-to-savepoint; nothing spurious is logged.
- **`rpl_fk_set_null_binlog_row`** — `SET NULL` cascade capture.
- **`rpl_fk_cascade_binlog_row_slave_option`** — origin OFF / replica ON:
replication stays correct and the replica's binlog contains only parent
events (pins the applier no-op of §11).
- **`rpl_fk_cascade_binlog_row_mixed_eligibility`** — a parent with one
eligible and one ineligible child: asserts that no derived events are
written and the replica keeps no orphan (pins the statement-level decision
of §6.3).
- **`rpl_fk_cascade_binlog_row_old_slave`** — cross-version compatibility
(§10): a strict-mode replica emulating an older MariaDB (via the
`rpl_emulate_old_slave_fk_cascade` debug keyword) applies a feature-ON
origin's `CASCADE` / `SET NULL` transactions with no error and correct data.
Debug build only.
**Not yet covered:**
- Cross-version replication against a *real* older `mariadbd` binary. The apply
mechanism itself is covered in-tree by `rpl_fk_cascade_binlog_row_old_slave`
(§10.1).
- The Galera path (`WSREP_EMULATE_BINLOG`), which §4 singles out as the primary
motivation. The `suite/galera` FK tests (`galera_fk_cascade_delete`,
`galera_fk_cascade_delete_debug`, `galera_fk_cascade_update`) never set
`rpl_use_binlog_events_for_fk_cascade`, so they exercise the classic
re-cascade path only; no test in any suite covers cascade capture under
writeset emulation.
All six tests were written against the pre-refactoring interfaces (§6.1 changed
the SE ↔ server boundary twice since) but pass unchanged on the current tree.

View File

@@ -0,0 +1,103 @@
/* Copyright (c) 2026, MariaDB Corporation.
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; version 2 of the License.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1335 USA */
#pragma once
/*
Generic server-side handling of foreign-key cascade actions performed
inside a storage engine.
A storage engine that performs FK cascades internally (InnoDB does, in
row_ins_foreign_check_on_constraint()) cannot know what the server wants
done about them: the row may need to be written to the binary log, may need
to fire triggers, may need CHECK constraints re-evaluated, and so on. Rather
than teach the engine each of those, the engine reports the action and the
server decides.
The engine's side of the contract is three calls and no row buffers:
if (thd_fk_cascade_wanted(thd, child_table))
{
<position the cascade cursor on the row about to change>
thd_fk_cascade_capture(thd, child_table, FK_CASCADE_IMAGE_BEFORE);
<perform the cascade>
<position the cascade cursor on the changed row>
thd_fk_cascade_capture(thd, child_table, FK_CASCADE_IMAGE_AFTER);
thd_fk_cascade_row(thd, child_table, FK_CASCADE_ACTION_UPDATE);
}
The server owns the row images end to end: it decides which columns make up
the image (the engine does not touch the TABLE's column bitmaps), it owns the
buffers they are materialised into, and it frees them. The engine's only
contribution is handler::fk_cascade_fetch_row(), which converts the record
the cascade cursor is sitting on into MySQL row format.
Capture is therefore driven from the engine (only the engine knows when the
row is about to change and when it has changed) but performed by the server.
*/
#ifdef __cplusplus
extern "C" {
#endif
struct TABLE;
/* Which image thd_fk_cascade_capture() should materialise. */
#define FK_CASCADE_IMAGE_BEFORE 0
#define FK_CASCADE_IMAGE_AFTER 1
/* The action the engine performed on the child row. */
#define FK_CASCADE_ACTION_DELETE 0
#define FK_CASCADE_ACTION_UPDATE 1
#define FK_CASCADE_ACTION_SET_NULL 2
/*
Does the server want to be told about cascade actions on this child table?
Answers for every consumer at once (binary logging, triggers, CHECK
constraints, ...), so the engine can skip the cost of capturing row images
when nothing is interested. Also screens out tables whose row image would be
unsafe, and sessions in which the feature does not apply. Cheap enough to
call per cascaded row.
*/
int thd_fk_cascade_wanted(MYSQL_THD thd, struct TABLE *table);
/*
Materialise one row image of the child row the cascade cursor is currently
positioned on. `which` is FK_CASCADE_IMAGE_BEFORE or FK_CASCADE_IMAGE_AFTER.
The caller must have positioned the engine's cascade cursor on the row (for
InnoDB, via ha_innobase::fk_cascade_set_cursor()) and must hold whatever
latch the engine needs to read it. Returns 0 on success; non-zero means the
image could not be produced, in which case the engine should abandon
reporting this row via thd_fk_cascade_abort().
*/
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

View File

@@ -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 */

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,52 @@
include/master-slave.inc
[connection master]
connection master;
SET SESSION default_storage_engine=InnoDB;
SET SESSION binlog_format=ROW;
SELECT @@session.rpl_use_binlog_events_for_fk_cascade;
@@session.rpl_use_binlog_events_for_fk_cascade
0
CREATE TABLE p (
id INT PRIMARY KEY,
v INT
) ENGINE=InnoDB;
CREATE TABLE c (
id INT PRIMARY KEY,
pid INT,
v INT,
KEY(pid),
CONSTRAINT fk FOREIGN KEY (pid) REFERENCES p(id)
ON DELETE CASCADE
ON UPDATE CASCADE
) ENGINE=InnoDB;
INSERT INTO p VALUES (1, 10);
INSERT INTO c VALUES (100, 1, 20);
connection slave;
connection slave;
include/stop_slave.inc
SET GLOBAL rpl_use_binlog_events_for_fk_cascade=1;
include/start_slave.inc
connection master;
UPDATE p SET id=2 WHERE id=1;
DELETE FROM p WHERE id=2;
connection slave;
connection slave;
SELECT COUNT(*) FROM p;
COUNT(*)
0
SELECT COUNT(*) FROM c;
COUNT(*)
0
FLUSH BINARY LOGS;
FOUND 1 /### DELETE FROM `test`.`p`/ in fk_cascade_slave_option.sql
NOT FOUND /### DELETE FROM `test`.`c`/ in fk_cascade_slave_option.sql
NOT FOUND /### UPDATE `test`.`c`/ in fk_cascade_slave_option.sql
connection slave;
include/stop_slave.inc
SET GLOBAL rpl_use_binlog_events_for_fk_cascade=0;
include/start_slave.inc
connection master;
DROP TABLE c, p;
connection slave;
connection master;
include/rpl_end.inc

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,109 @@
--source include/have_innodb.inc
--source include/have_log_bin.inc
--source include/have_binlog_format_row.inc
--source include/master-slave.inc
#
# MDEV-38243: rpl_use_binlog_events_for_fk_cascade enabled only on the slave.
#
# The master runs with the feature OFF (default), so it binlogs only the
# parent-table row events, not the cascaded child row events. The slave has
# the feature ON. Because the row-based applier does not prelock the FK-child
# tables, find_fk_open_table() finds no open child table on the applier, so no
# cascade row events are captured there. The slave applies the cascade the
# traditional way (InnoDB re-executes it) and its own binary log contains only
# the parent events.
#
# This test pins that behaviour: enabling the option on the slave is currently
# a no-op for the applier path, while replication stays correct. It exists so
# that a future change which makes the option effective on the applier (by
# opening the child table there) will visibly change this recorded result.
#
connection master;
SET SESSION default_storage_engine=InnoDB;
SET SESSION binlog_format=ROW;
# The master keeps the feature OFF (the default).
SELECT @@session.rpl_use_binlog_events_for_fk_cascade;
CREATE TABLE p (
id INT PRIMARY KEY,
v INT
) ENGINE=InnoDB;
CREATE TABLE c (
id INT PRIMARY KEY,
pid INT,
v INT,
KEY(pid),
CONSTRAINT fk FOREIGN KEY (pid) REFERENCES p(id)
ON DELETE CASCADE
ON UPDATE CASCADE
) ENGINE=InnoDB;
INSERT INTO p VALUES (1, 10);
INSERT INTO c VALUES (100, 1, 20);
sync_slave_with_master;
# Turn the feature ON only on the slave, and restart the SQL thread so the
# applier THD picks up the new global value.
connection slave;
--source include/stop_slave.inc
SET GLOBAL rpl_use_binlog_events_for_fk_cascade=1;
--source include/start_slave.inc
connection master;
UPDATE p SET id=2 WHERE id=1;
DELETE FROM p WHERE id=2;
sync_slave_with_master;
connection slave;
SELECT COUNT(*) FROM p;
SELECT COUNT(*) FROM c;
# Inspect the slave's own binary log. It must contain the parent events but
# NOT explicit cascade row events for the child table, because the applier
# does not capture cascades (the child table is not prelocked on the applier).
--let $binlog = query_get_value(SHOW MASTER STATUS, File, 1)
--let $datadir = `SELECT @@datadir`
FLUSH BINARY LOGS;
--exec $MYSQL_BINLOG --verbose --verbose --base64-output=DECODE-ROWS $datadir/$binlog > $MYSQLTEST_VARDIR/tmp/fk_cascade_slave_option.sql
# Parent DELETE is present in the slave's binary log.
--let SEARCH_PATTERN= ### DELETE FROM `test`.`p`
--let SEARCH_FILE= $MYSQLTEST_VARDIR/tmp/fk_cascade_slave_option.sql
--let SEARCH_ABORT= NOT FOUND
--source include/search_pattern_in_file.inc
# No explicit cascade child row events: the option is a no-op on the applier.
--let SEARCH_PATTERN= ### DELETE FROM `test`.`c`
--let SEARCH_FILE= $MYSQLTEST_VARDIR/tmp/fk_cascade_slave_option.sql
--let SEARCH_ABORT= FOUND
--source include/search_pattern_in_file.inc
--let SEARCH_PATTERN= ### UPDATE `test`.`c`
--let SEARCH_FILE= $MYSQLTEST_VARDIR/tmp/fk_cascade_slave_option.sql
--let SEARCH_ABORT= FOUND
--source include/search_pattern_in_file.inc
# Cleanup: restore the slave option and drop the tables.
connection slave;
--source include/stop_slave.inc
SET GLOBAL rpl_use_binlog_events_for_fk_cascade=0;
--source include/start_slave.inc
connection master;
DROP TABLE c, p;
sync_slave_with_master;
connection master;
--remove_file $MYSQLTEST_VARDIR/tmp/fk_cascade_slave_option.sql
--source include/rpl_end.inc

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 @@ end:
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)
{

View File

@@ -5310,7 +5310,24 @@ public:
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()

View File

@@ -8069,7 +8069,19 @@ int binlog_flush_pending_rows_event(THD *thd, bool stmt_end,
*/
if (stmt_end)
{
pending->set_flags(Rows_log_event::STMT_END_F);
if (thd->binlog_fk_cascade_events &&
(thd->variables.rpl_use_binlog_events_for_fk_cascade ||
WSREP_EMULATE_BINLOG(thd)))
thd->flush_pending_cascade_binlog();
/*
Flushing cascaded row events may have created a new pending event or
replaced the current one. Ensure we mark the final pending event as
statement end.
*/
pending= cache_data->pending();
if (pending)
pending->set_flags(Rows_log_event::STMT_END_F);
thd->reset_binlog_for_next_statement();
}
@@ -8295,6 +8307,12 @@ Event_log::prepare_pending_rows_event(THD *thd, TABLE* table,
if (unlikely(!ev))
DBUG_RETURN(NULL);
ev->server_id= serv_id; // I don't like this, it's too easy to forget.
if (thd->binlog_fk_cascade_events)
ev->set_flags(Rows_log_event::FK_CASCADE_EVENTS_F |
Rows_log_event::NO_FOREIGN_KEY_CHECKS_F);
if (thd->binlog_fk_cascade_derived)
ev->set_flags(Rows_log_event::FK_CASCADE_DERIVED_F);
/*
flush the pending event and replace it with the newly created
event...

View File

@@ -4873,6 +4873,23 @@ public:
*/
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)
};

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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().
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;
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 (table_already_fk_prelocked(prelocking_ctx->query_tables,
fk->foreign_db, fk->foreign_table, lock_type))
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 ((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<TABLE_LIST>(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
/*

View File

@@ -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,

View File

@@ -27,6 +27,7 @@
#include "mariadb.h"
#include "sql_priv.h"
#include "sql_class.h"
#include <mysql/service_thd_fk_cascade.h>
#include "sql_cache.h" // query_cache_abort
#include "sql_base.h" // close_thread_tables
#include "sql_time.h" // date_time_format_copy
@@ -93,6 +94,420 @@ extern "C" const uchar *get_var_key(const void *entry_, size_t *length,
return reinterpret_cast<const uchar *>(entry->name.str);
}
void THD::binlog_mark_fk_cascade_events()
{
binlog_fk_cascade_events= true;
if (binlog_cache_mngr *cache_mngr= binlog_get_cache_mngr())
{
/*
Also set the NO_FOREIGN_KEY_CHECKS_F. An older slave does
not understand FK_CASCADE_EVENTS_F, but it does understand
NO_FOREIGN_KEY_CHECKS_F, so setting it makes such a slave disable foreign
key checks for these events.
*/
if (Rows_log_event *pending= binlog_get_pending_rows_event(
cache_mngr, use_trans_cache(this, false)))
pending->set_flags(Rows_log_event::FK_CASCADE_EVENTS_F |
Rows_log_event::NO_FOREIGN_KEY_CHECKS_F);
if (Rows_log_event *pending= binlog_get_pending_rows_event(
cache_mngr, use_trans_cache(this, true)))
pending->set_flags(Rows_log_event::FK_CASCADE_EVENTS_F |
Rows_log_event::NO_FOREIGN_KEY_CHECKS_F);
}
}
/*
Queue an FK-cascade row change reported by a storage engine, for binlogging.
The record image(s) are copied so the caller may reuse or free its buffers
once this returns. Marking the current statement as carrying cascade events
here ensures the originating statement's own pending event is flagged too.
*/
void THD::binlog_report_cascade_row(TABLE *table, bool is_delete,
const uchar *before_record,
const uchar *after_record)
{
DBUG_ASSERT(table);
DBUG_ASSERT(before_record);
DBUG_ASSERT(is_delete == (after_record == NULL));
const size_t len= table->s->reclength;
uchar *before_copy= (uchar *) my_malloc(PSI_INSTRUMENT_ME, len, MYF(MY_WME));
uchar *after_copy= NULL;
if (!is_delete)
after_copy= (uchar *) my_malloc(PSI_INSTRUMENT_ME, len, MYF(MY_WME));
if (!before_copy || (!is_delete && !after_copy))
{
my_free(before_copy);
my_free(after_copy);
return;
}
memcpy(before_copy, before_record, len);
if (!is_delete)
memcpy(after_copy, after_record, len);
Cascade_binlog_row_event ev;
ev.table= table;
ev.before_record= before_copy;
ev.after_record= after_copy;
ev.is_delete= is_delete;
if (pending_cascade_binlog_row_events.append(ev))
{
my_free(before_copy);
my_free(after_copy);
return;
}
/* Flag the originating statement's own (already pending) row event. */
binlog_mark_fk_cascade_events();
}
/*
Flush the queued FK-cascade row changes into the binary log, in the order the
cascade operations executed. Deferring here keeps the root statement's own
events ahead of the derived ones, and keeps interleaved cascade operations
in execution order.
Events written here are additionally marked FK_CASCADE_DERIVED_F.
*/
void THD::flush_pending_cascade_binlog()
{
if (pending_cascade_binlog_row_events.elements() == 0)
return;
binlog_mark_fk_cascade_events();
binlog_begin_fk_cascade_derived();
for (size_t i= 0; i < pending_cascade_binlog_row_events.elements(); i++)
{
Cascade_binlog_row_event &ev= pending_cascade_binlog_row_events.at(i);
TABLE *table= ev.table;
if (!table || !table->file ||
table->s->tmp_table != NO_TMP_TABLE)
{
my_free(ev.before_record);
my_free(ev.after_record);
continue;
}
MY_BITMAP *old_read_set= table->read_set;
MY_BITMAP *old_write_set= table->write_set;
MY_BITMAP *old_rpl_write_set= table->rpl_write_set;
table->column_bitmaps_set_no_signal(&table->s->all_set,
&table->s->all_set);
if (table->rpl_write_set == NULL)
table->rpl_write_set= &table->s->all_set;
Log_func *log_func= ev.is_delete
? Delete_rows_log_event::binlog_row_logging_function
: Update_rows_log_event::binlog_row_logging_function;
table->file->binlog_log_row(ev.before_record, ev.after_record, log_func);
table->column_bitmaps_set_no_signal(old_read_set, old_write_set);
table->rpl_write_set= old_rpl_write_set;
my_free(ev.before_record);
my_free(ev.after_record);
}
binlog_end_fk_cascade_derived();
pending_cascade_binlog_row_events.clear();
}
/*
Drop the queued FK-cascade row changes without logging them.
*/
void THD::discard_pending_cascade_binlog()
{
for (size_t i= 0; i < pending_cascade_binlog_row_events.elements(); i++)
{
Cascade_binlog_row_event &ev= pending_cascade_binlog_row_events.at(i);
my_free(ev.before_record);
my_free(ev.after_record);
}
pending_cascade_binlog_row_events.clear();
}
/*
Server-side handling of FK cascade actions reported by a storage engine.
See include/mysql/service_thd_fk_cascade.h for the contract.
*/
void THD::fk_cascade_free_images()
{
my_free(fk_cascade_before_image);
my_free(fk_cascade_after_image);
fk_cascade_before_image= NULL;
fk_cascade_after_image= NULL;
}
/*
Is this child table's row image safe to reconstruct without cascade context?
A table with no primary key and a virtual column participating in a key
cannot be identified unambiguously from a full row image, so we decline to
report cascades on it rather than produce something a consumer would apply
to the wrong row.
*/
static bool fk_cascade_table_eligible(const TABLE *table)
{
if (!table)
return false;
if (table->s->primary_key != MAX_KEY)
return true;
if (Field **vf= table->vfield)
for (; *vf; vf++)
if ((*vf)->flags & PART_KEY_FLAG)
return false;
return true;
}
/*
Checking if FK cascade events may be captured
Eligibility is a property of one table, but the event flag that stops the
replica re-running the cascade is a property of the whole statement: the
first captured child row calls binlog_mark_fk_cascade_events(), and from
then on every row event of the statement carries FK_CASCADE_EVENTS_F |
NO_FOREIGN_KEY_CHECKS_F. The replica therefore stops cascading for *all*
children of that statement, not just the captured one.
Deciding per child would then let an eligible child switch off the replica's
cascade on behalf of an ineligible sibling whose rows were never logged: the
replica neither receives those rows nor recreates them, and silently keeps
the orphans. The decision has to be all-or-nothing across the statement.
The FK prelocking set is what makes that decidable up front.
prepare_fk_prelocking_list() has already enumerated, transitively, every
child table this statement can write through a cascade, and opened each one
(children needed only for read-only FK checks are OPEN_STUB and never reach
thd->open_tables). If any of them is ineligible we decline the feature for
the whole statement and let the classic replica-side cascade handle all of
it.
*/
static bool fk_cascade_stmt_capturable(THD *thd)
{
if (thd->fk_cascade_stmt_query_id == thd->query_id)
return thd->fk_cascade_stmt_ok;
bool capturable= true;
for (TABLE *t= thd->open_tables; t; t= t->next)
{
TABLE_LIST *tl= t->pos_in_table_list;
if (tl && tl->prelocking_placeholder == TABLE_LIST::PRELOCK_FK &&
tl->lock_type >= TL_FIRST_WRITE &&
!fk_cascade_table_eligible(t))
{
capturable= false;
break;
}
}
thd->fk_cascade_stmt_query_id= thd->query_id;
thd->fk_cascade_stmt_ok= capturable;
return capturable;
}
/*
Switch the table's column bitmaps to the full row image the cascade
consumers need, saving the statement's own bitmaps for restoring afterwards.
Virtual columns are excluded: they are recomputed rather than stored.
This is server policy, not engine policy, which is why it lives here and not
in the engine performing the cascade.
*/
static void fk_cascade_begin_full_row_image(TABLE *table,
MY_BITMAP **saved_read,
MY_BITMAP **saved_write,
MY_BITMAP **saved_rpl_write)
{
*saved_read= table->read_set;
*saved_write= table->write_set;
*saved_rpl_write= table->rpl_write_set;
bitmap_set_all(&table->tmp_set);
if (Field **vf= table->vfield)
for (; *vf; vf++)
bitmap_clear_bit(&table->tmp_set, (*vf)->field_index);
table->column_bitmaps_set_no_signal(&table->tmp_set, &table->tmp_set);
if (table->rpl_write_set == NULL)
table->rpl_write_set= &table->tmp_set;
}
static void fk_cascade_end_full_row_image(TABLE *table,
MY_BITMAP *saved_read,
MY_BITMAP *saved_write,
MY_BITMAP *saved_rpl_write)
{
table->column_bitmaps_set_no_signal(saved_read, saved_write);
table->rpl_write_set= saved_rpl_write;
}
extern "C"
int thd_fk_cascade_wanted(THD *thd, TABLE *table)
{
if (!thd || !table || table->in_use != thd)
return 0;
const bool emulate_binlog= WSREP_EMULATE_BINLOG(thd);
/*
Consumer 1: binary logging of the cascaded rows.
This is the only consumer wired up so far. The remaining ones below are
the point of routing cascades through the server at all: each is a
separate known gap in how FK cascades behave, and each becomes an arm of
this same predicate plus an arm of thd_fk_cascade_row() rather than new
machinery inside every engine that cascades.
Eligibility is checked twice over: this child must be eligible, and so
must every other child the statement can cascade into -- see
fk_cascade_stmt_capturable() for why a per-child answer is not safe.
Note that the session variable is required even under Galera: emulating
the binary log makes the *format* and *eligibility* conditions moot (a
writeset is always row-based, and is not subject to the row-image caveat
fk_cascade_table_eligible() guards against), but it does not by itself
turn the feature on. Widening this to "emulate_binlog is enough" enables
cascade capture for every Galera session and breaks cascade replication.
*/
if (thd->variables.rpl_use_binlog_events_for_fk_cascade &&
(emulate_binlog || thd->is_current_stmt_binlog_format_row()) &&
(emulate_binlog || (fk_cascade_table_eligible(table) &&
fk_cascade_stmt_capturable(thd))) &&
table->file->prepare_for_row_logging())
return 1;
/*
Consumer 2: triggers on the child table (not wired up).
Cannot simply be answered here: firing a trigger runs a stored program,
which may execute arbitrary SQL and re-enter the engine, so it cannot run
at the point the engine reports the cascade. It would have to be queued
and run at statement end, and BEFORE triggers -- which may modify or skip
the row -- cannot be honoured at all once the engine has already performed
the cascade.
if (table->triggers)
return 1;
Consumer 3: CHECK constraints on the child table (not wired up).
Unlike triggers this one is safe to evaluate inline, since
TABLE::verify_constraints() only evaluates expressions over record[0] and
touches no other table.
if (table->check_constraints)
return 1;
*/
return 0;
}
extern "C"
int thd_fk_cascade_capture(THD *thd, TABLE *table, int which)
{
if (!thd || !table)
return 1;
const size_t len= table->s->reclength;
uchar **slot= (which == FK_CASCADE_IMAGE_BEFORE ?
&thd->fk_cascade_before_image : &thd->fk_cascade_after_image);
if (!*slot && !(*slot= (uchar *) my_malloc(PSI_INSTRUMENT_ME, len,
MYF(MY_WME))))
return 1;
MY_BITMAP *saved_read, *saved_write, *saved_rpl_write;
fk_cascade_begin_full_row_image(table, &saved_read, &saved_write,
&saved_rpl_write);
const int rc= table->file->fk_cascade_fetch_row(*slot);
fk_cascade_end_full_row_image(table, saved_read, saved_write,
saved_rpl_write);
if (rc)
{
my_free(*slot);
*slot= NULL;
}
return rc;
}
extern "C"
void thd_fk_cascade_row(THD *thd, TABLE *table, int action)
{
if (!thd || !table || !thd->fk_cascade_before_image)
{
if (thd)
thd->fk_cascade_free_images();
return;
}
const bool is_delete= (action == FK_CASCADE_ACTION_DELETE);
if (!is_delete && !thd->fk_cascade_after_image)
{
/* An update we could not capture the result of: report nothing. */
thd->fk_cascade_free_images();
return;
}
/*
Dispatch to each interested consumer. This is the whole point of the
arrangement: the decisions live here, in one place, rather than in every
storage engine that performs cascades internally.
*/
/* Consumer 1: binary logging. Deferred -- queued now, written to the
binary log at statement end, so that the cascaded rows follow the
originating statement's own row events and keep their execution order. */
if (thd->variables.rpl_use_binlog_events_for_fk_cascade ||
WSREP_EMULATE_BINLOG(thd))
thd->binlog_report_cascade_row(table, is_delete,
thd->fk_cascade_before_image,
thd->fk_cascade_after_image);
/*
Consumer 2: triggers (not wired up). Would queue TRG_EVENT_DELETE /
TRG_EVENT_UPDATE for the child row, to be run at statement end alongside
the binlog flush -- process_triggers() cannot run here, see
thd_fk_cascade_wanted().
Consumer 3: CHECK constraints (not wired up). Would run inline, by
pointing the table's record[0] at the after-image and calling
table->verify_constraints().
*/
thd->fk_cascade_free_images();
}
extern "C"
void thd_fk_cascade_abort(THD *thd)
{
if (thd)
thd->fk_cascade_free_images();
}
extern "C" void free_user_var(void *entry_)
{
user_var_entry *entry= static_cast<user_var_entry *>(entry_);
@@ -908,6 +1323,8 @@ THD::THD(my_thread_id id, bool is_wsrep_applier)
mysys_var=0;
binlog_evt_union.do_union= FALSE;
binlog_table_maps= FALSE;
binlog_fk_cascade_events= FALSE;
binlog_fk_cascade_derived= FALSE;
binlog_xid= 0;
enable_slow_log= 0;
durability_property= HA_REGULAR_DURABILITY;
@@ -1907,6 +2324,10 @@ THD::~THD()
THD *orig_thd= current_thd;
THD_CHECK_SENTRY(this);
DBUG_ENTER("~THD()");
/* Backstop: free any FK-cascade row buffers not drained by commit/rollback */
discard_pending_cascade_binlog();
/* Likewise for an in-flight cascade capture the engine never reported */
fk_cascade_free_images();
/* Make sure threads are not available via server_threads. */
assert_not_linked();
if (m_psi)

View File

@@ -918,6 +918,7 @@ typedef struct system_variables
my_bool sql_log_bin;
my_bool binlog_annotate_row_events;
my_bool binlog_direct_non_trans_update;
my_bool rpl_use_binlog_events_for_fk_cascade;
my_bool column_compression_zlib_wrap;
my_bool sysdate_is_now;
my_bool wsrep_on;
@@ -3916,6 +3917,64 @@ public:
/* 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<Cascade_binlog_row_event>
pending_cascade_binlog_row_events{PSI_INSTRUMENT_MEM};
void binlog_report_cascade_row(TABLE *table, bool is_delete,
const uchar *before_record,
const uchar *after_record);
void flush_pending_cascade_binlog();
void discard_pending_cascade_binlog();
/*
Row images of the FK-cascade action currently being reported by a storage
engine, materialised by thd_fk_cascade_capture() and consumed by
thd_fk_cascade_row(). At most one cascade action is in flight at a time:
the engine captures the before-image, performs the cascade, captures the
after-image, and reports, all within one call to its cascade routine.
See include/mysql/service_thd_fk_cascade.h.
*/
uchar *fk_cascade_before_image= NULL;
uchar *fk_cascade_after_image= NULL;
void fk_cascade_free_images();
/*
Cached statement-level answer to "may the FK cascades of this statement be
captured at all?", computed by fk_cascade_stmt_capturable() over the FK
prelocking set. Keyed on query_id, so the cache cannot outlive the
statement it was computed for and needs no separate reset hook.
*/
query_id_t fk_cascade_stmt_query_id= 0;
bool fk_cascade_stmt_ok= false;
void issue_unsafe_warnings();
void reset_unsafe_warnings()
{ binlog_unsafe_warning_flags= 0; }
@@ -3923,6 +3982,8 @@ public:
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);

View File

@@ -6044,6 +6044,23 @@ finish:
/* 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");

View File

@@ -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)
{

View File

@@ -49,8 +49,9 @@ this program; if not, write to the Free Software Foundation, Inc.,
#include <my_check_opt.h>
#include <my_bitmap.h>
#include <my_sys.h>
#include <mysql/service_thd_alloc.h>
#include <mysql/plugin.h>
#include <mysql/service_thd_wait.h>
#include <mysql/service_thd_alloc.h>
#include <mysql/service_print_check_msg.h>
#include "sql_type_geom.h"
#include "scope.h"
@@ -3101,6 +3102,8 @@ ha_innobase::ha_innobase(
TABLE_SHARE* table_arg)
:handler(hton, table_arg),
m_prebuilt(),
m_fk_cascade_rec(),
m_fk_cascade_index(),
m_user_thd(),
m_int_table_flags(HA_REC_NOT_IN_SEQ
| HA_NULL_IN_KEY
@@ -8695,6 +8698,55 @@ ATTRIBUTE_COLD bool wsrep_append_table_key(MYSQL_THD thd,
}
#endif /* WITH_WSREP */
/** Convert the clustered index record recorded by fk_cascade_set_cursor()
into MySQL row format.
This is the engine's whole contribution to reporting an FK cascade action:
the server decides which columns the image consists of (it has already set
the table's column bitmaps accordingly) and owns 'buf'; all that is left
that only InnoDB can do is the record conversion itself.
The row template is rebuilt for the server's bitmaps for the duration of the
conversion and reset afterwards, because the template in force belongs to the
statement being executed and describes a different set of columns.
@param buf destination row buffer, table->s->reclength bytes
@return 0 on success, HA_ERR_GENERIC if the record could not be converted */
int
ha_innobase::fk_cascade_fetch_row(uchar* buf)
{
if (m_prebuilt == NULL || m_prebuilt->mysql_template == NULL
|| m_fk_cascade_rec == NULL || m_fk_cascade_index == NULL) {
return HA_ERR_GENERIC;
}
rebuild_template_for_cascade_binlog_row_image();
mem_heap_t* offs_heap = NULL;
rec_offs offsets_[REC_OFFS_NORMAL_SIZE];
rec_offs_init(offsets_);
const rec_offs* offsets = rec_get_offsets(
m_fk_cascade_rec, m_fk_cascade_index, offsets_,
m_fk_cascade_index->n_core_fields, ULINT_UNDEFINED, &offs_heap);
dict_index_t* saved_index = m_prebuilt->index;
m_prebuilt->index = m_fk_cascade_index;
const bool ok = row_sel_store_mysql_rec(
buf, m_prebuilt, m_fk_cascade_rec, NULL, true,
m_fk_cascade_index, offsets);
m_prebuilt->index = saved_index;
if (UNIV_LIKELY_NULL(offs_heap)) {
mem_heap_free(offs_heap);
}
reset_template_for_cascade_binlog_row_image();
return ok ? 0 : HA_ERR_GENERIC;
}
/**
Updates a row given as a parameter to a new value. Note that we are given
whole rows, not just the fields which are updated: this incurs some

View File

@@ -517,6 +517,47 @@ protected:
/** 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;

View File

@@ -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 */

View File

@@ -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 @@ public:
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. */

View File

@@ -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 <mysql/plugin.h>
#include <mysql/service_thd_fk_cascade.h>
#ifdef WITH_WSREP
#include <wsrep.h>
#include <mysql/service_wsrep.h>
#include "ha_prototypes.h"
#endif /* WITH_WSREP */
TABLE *find_fk_open_table(THD *thd, const char *db, size_t db_len,
const char *table, size_t table_len);
/** Find the MySQL TABLE of a cascade child table, if the statement has it
open. Only the engine can map its own dict_table_t to a name, which is why
this much stays here; whether the server wants anything done with the table
is answered by thd_fk_cascade_wanted(). */
static TABLE*
row_ins_find_open_table_for_cascade(
trx_t* trx,
dict_table_t* child)
{
THD* thd;
TABLE* mysql_table;
char db_buf[NAME_LEN + 1];
char tbl_buf[NAME_LEN + 1];
ulint db_buf_len;
ulint tbl_buf_len;
thd = trx->mysql_thd;
if (thd == NULL) {
return NULL;
}
if (!child->parse_name(db_buf, tbl_buf, &db_buf_len, &tbl_buf_len)) {
return NULL;
}
mysql_table = find_fk_open_table(thd,
db_buf, db_buf_len,
tbl_buf, tbl_buf_len);
return mysql_table;
}
/*************************************************************************
IMPORTANT NOTE: Any operation that generates redo MUST check that there
is enough space in the redo log before for that operation. This is
@@ -269,7 +310,7 @@ static MY_ATTRIBUTE((nonnull, warn_unused_result))
dberr_t
row_ins_clust_index_entry_by_modify(
/*================================*/
btr_pcur_t* pcur, /*!< in/out: a persistent cursor pointing
btr_pcur_t* pcur, /*!< in/out: a persistent cursor pointing
to the clust_rec that is being modified. */
ulint flags, /*!< in: undo logging and locking flags */
ulint mode, /*!< in: BTR_MODIFY_LEAF or BTR_MODIFY_TREE,
@@ -299,7 +340,6 @@ row_ins_clust_index_entry_by_modify(
/* In delete-marked records, DB_TRX_ID must
always refer to an existing undo log record. */
ut_ad(rec_get_trx_id(rec, cursor->index()));
/* Build an update vector containing all the fields to be modified;
NOTE that this vector may NOT contain system columns trx_id or
roll_ptr */
@@ -1012,6 +1052,11 @@ row_ins_foreign_check_on_constraint(
mem_heap_t* tmp_heap = NULL;
doc_id_t doc_id = FTS_NULL_DOC_ID;
TABLE* child_mysql_table = NULL;
bool need_cascade_binlog = false;
bool can_cascade_binlog = false;
bool have_after_image = false;
DBUG_ENTER("row_ins_foreign_check_on_constraint");
trx = thr_get_trx(thr);
@@ -1346,15 +1391,95 @@ row_ins_foreign_check_on_constraint(
cascade->state = UPD_NODE_UPDATE_CLUSTERED;
/*
Ask the server whether it wants to be told about this cascade action,
and if so let it capture the child row's before-image. Everything the
server does with it -- binary logging, and in future triggers, CHECK
constraints and so on -- is decided there, not here; see
include/mysql/service_thd_fk_cascade.h.
If the master has binlogged the cascaded rows, the events carry
FK_CASCADE_EVENTS_F, causing the applier to run with foreign key
checks disabled, and this cascade function is never reached.
*/
child_mysql_table = row_ins_find_open_table_for_cascade(trx, table);
if (child_mysql_table
&& thd_fk_cascade_wanted(trx->mysql_thd, child_mysql_table)) {
ha_innobase* ib = static_cast<ha_innobase*>(
child_mysql_table->file);
mtr_start(mtr);
if (cascade->pcur->restore_position(BTR_SEARCH_LEAF, mtr)
== btr_pcur_t::SAME_ALL) {
ib->fk_cascade_set_cursor(
btr_pcur_get_rec(cascade->pcur), clust_index);
need_cascade_binlog = !thd_fk_cascade_capture(
trx->mysql_thd, child_mysql_table,
FK_CASCADE_IMAGE_BEFORE);
ib->fk_cascade_set_cursor(NULL, NULL);
}
mtr_commit(mtr);
}
err = row_update_cascade_for_mysql(thr, cascade,
foreign->foreign_table);
mtr_start(mtr);
can_cascade_binlog = (err == DB_SUCCESS && need_cascade_binlog);
have_after_image = false;
if (can_cascade_binlog && cascade->is_delete != PLAIN_DELETE) {
if (cascade->pcur->restore_position(BTR_SEARCH_LEAF, mtr)
== btr_pcur_t::SAME_ALL) {
const rec_t* after_rec = btr_pcur_get_rec(cascade->pcur);
if (page_rec_is_user_rec(after_rec)
&& !rec_get_deleted_flag(after_rec,
dict_table_is_comp(table))) {
ha_innobase* ib = static_cast<ha_innobase*>(
child_mysql_table->file);
ib->fk_cascade_set_cursor(after_rec, clust_index);
have_after_image = !thd_fk_cascade_capture(
trx->mysql_thd, child_mysql_table,
FK_CASCADE_IMAGE_AFTER);
ib->fk_cascade_set_cursor(NULL, NULL);
}
}
}
/* Restore pcur position */
if (pcur->restore_position(BTR_SEARCH_LEAF, mtr)
!= btr_pcur_t::SAME_ALL) {
!= btr_pcur_t::SAME_ALL && err == DB_SUCCESS) {
err = DB_CORRUPTION;
}
mtr_commit(mtr);
if (need_cascade_binlog) {
if (can_cascade_binlog
&& (cascade->is_delete == PLAIN_DELETE
|| have_after_image)) {
/*
Report the completed action. What happens to it is
the server's business; ordering of the resulting
binlog events relative to the originating statement,
in particular, is handled there.
*/
thd_fk_cascade_row(
trx->mysql_thd, child_mysql_table,
cascade->is_delete == PLAIN_DELETE
? FK_CASCADE_ACTION_DELETE
: FK_CASCADE_ACTION_UPDATE);
} else {
/* Captured a before-image we are not going to use. */
thd_fk_cascade_abort(trx->mysql_thd);
}
}
mtr_start(mtr);
if (pcur->restore_position(BTR_SEARCH_LEAF, mtr)
!= btr_pcur_t::SAME_ALL && err == DB_SUCCESS) {
err = DB_CORRUPTION;
}
@@ -2564,7 +2689,7 @@ statement
@return true if it is insert statement */
static bool thd_sql_is_insert(const THD *thd) noexcept
{
switch (thd->lex->sql_command) {
switch (thd_sql_command(thd)) {
case SQLCOM_INSERT:
case SQLCOM_INSERT_SELECT:
return true;

View File

@@ -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,

View File

@@ -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;