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

MDEV-36832 UPDATE...IN(SELECT) acquires spurious gap locks on FK index

`UPDATE ticket SET is_valid=0 WHERE ticket_id IN
(SELECT ticket_id FROM ticket WHERE booking_id=1)` acquires S next-key
and gap locks on the FK secondary index `fk_ticket_2_booking`, causing
deadlocks between concurrent transactions operating on non-overlapping
`booking_id` values. The expected behavior — matching
`UPDATE ... WHERE ticket_id IN (1,2)` (explicit PK list) — is to only
lock the PRIMARY key records being updated.

**Root cause**: the optimizer converts the `IN(SELECT)` to a semi-join,
which creates two table references for the same table. The SQL layer
then processes the statement as `SQLCOM_UPDATE_MULTI`. In
`ha_innobase::store_lock()`, the read-only (subquery) table handle
receives `TL_READ`, but the existing `LOCK_NONE` optimization for DML
read tables was gated on `isolation_level <= READ_COMMITTED` and did
not include `SQLCOM_UPDATE_MULTI` at all. In `REPEATABLE READ` (the
default), the subquery's secondary index scan therefore acquired
`LOCK_S` next-key locks, including gap locks spanning into adjacent key
ranges.

**Fix**: add `SQLCOM_UPDATE_MULTI` to the `LOCK_NONE` (consistent read)
condition in `store_lock()`:

1. For `TL_READ` (binlog off or ROW format): use consistent read at
   all isolation levels below `SERIALIZABLE`. This is safe because:
   - The MVCC snapshot is stable within a transaction in
     `REPEATABLE READ`
   - Rows being modified still acquire X locks via the write-side
     table handle (`F_WRLCK` / `LOCK_X`)

   `SERIALIZABLE` is excluded: the `::external_lock()` upgrade of
   `LOCK_NONE` to `LOCK_S` applies only to non-autocommit
   transactions, so an autocommit multi-table UPDATE would otherwise
   read its read-side tables via a consistent read at `SERIALIZABLE`,
   weakening its guarantees.
2. For `TL_READ_NO_INSERT` (statement-based binlog): extend the
   existing `READ COMMITTED` optimization to also cover
   `SQLCOM_UPDATE_MULTI`

The fix is deliberately limited to `SQLCOM_UPDATE_MULTI` (not
`SQLCOM_UPDATE`) because single-table UPDATE with scalar subqueries on
other tables traditionally uses S locks to block concurrent reads of the
subquery table, and broadening the change there would alter observable
behavior for existing applications.

**Lock count before/after** (semi-join UPDATE on `booking_id=1`):
- Before: 5 lock structs, 5 row locks (IS + S×3 on FK index + IX +
  X×2 on PK)
- After: 2 lock structs, 2 row locks (IX + X×2 on PK) — identical to
  explicit `WHERE ticket_id IN (1,2)`

**Tests**:

`update_subquery_fk_gap_lock.test` — verifies that concurrent
`UPDATE...IN(SELECT)` operations on non-overlapping `booking_id` values
do not block each other. Two connections each update their own
`booking_id`'s tickets via semi-join subquery, then insert new tickets
for their `booking_id`. Both operations must succeed without lock wait
timeout, confirming that the spurious FK index gap locks are eliminated.

`update_subquery_fk_gap_lock_isolation.test` — ACID isolation regression
tests (requires ROW binlog format) verifying that replacing S locks with
consistent read (`LOCK_NONE`) on the semi-join read table does not cause
isolation violations at `REPEATABLE READ`:

1. **Phantom insert**: T1 updates `booking_id=1` tickets via semi-join,
   T2 concurrently inserts a new ticket with `booking_id=1`. T2 must
   not block (no S gap locks). T1's MVCC snapshot does not see the new
   row, so T1 updates only the 2 original tickets. Correct under
   snapshot isolation.
2. **Concurrent UPDATE on filter column**: T1 updates `booking_id=1`
   tickets, T2 changes `ticket_id=2`'s `booking_id` from 1 to 2. Both
   need X lock on `ticket_id=2`'s PK record — serialized via PK X
   lock alone, proving FK index S locks are unnecessary.
3. **Concurrent DELETE**: T1 updates `booking_id=1` tickets, T2 deletes
   `ticket_id=1`. Both need PK X lock — serialized, no isolation
   violation.
4. **SERIALIZABLE S locks**: At `SERIALIZABLE`, the read-side table
   keeps `LOCK_S`, so T2's INSERT into the subquery range must block
   with `ER_LOCK_WAIT_TIMEOUT`. Confirms the fix does not weaken
   `SERIALIZABLE` guarantees.
5. **Multi-table UPDATE on different tables**: T1 does
   `UPDATE t_write JOIN t_read ... SET t_write.val = t_read.val`, T2
   concurrently modifies `t_read`. T2 must not block (T1 holds no S
   locks on `t_read`). T1's snapshot values are used. Correct under
   snapshot isolation.
6. **MVCC snapshot consistency**: T1 establishes snapshot, T2 modifies
   `t_read`, T1 does multi-table UPDATE using snapshot then re-reads.
   Re-read must return the same values the UPDATE used, confirming
   MVCC snapshot stability at `REPEATABLE READ`.
7. **SERIALIZABLE with autocommit**: the `::external_lock()` upgrade
   of `LOCK_NONE` to `LOCK_S` does not apply to autocommit statements,
   so `store_lock()` itself must not choose `LOCK_NONE` at
   `SERIALIZABLE`. An autocommit `UPDATE...IN(SELECT)` stalled
   mid-scan on a PK X lock must hold S next-key locks on the FK index
   entries it has already scanned: an INSERT whose FK index entry
   falls inside that range must block with `ER_LOCK_WAIT_TIMEOUT`.

`mdev-14846.test` depends on the multi-table UPDATEs' read-only
tables being read with S locks to produce the deadlock whose error
handling it verifies. Both transactions taking part in that deadlock
now run at `SERIALIZABLE`, where the read-only tables keep their S
locks, preserving the original deadlock scenario and its
`ER_LOCK_DEADLOCK` expectation unchanged. Tying this to the binary
log format instead would not work everywhere: the embedded server
has no binary log, so its read side always gets `TL_READ`.
This commit is contained in:
Arcadiy Ivanov
2026-02-22 19:00:58 -05:00
committed by Georgi Kodinov
parent d5813edd08
commit f4a3b69bdf
7 changed files with 857 additions and 8 deletions

View File

@@ -22,6 +22,7 @@ UPDATE t1 SET t1.pk = -109 WHERE t1.f1 IN ( SELECT 'a' FROM t4 WHERE f >= 1 );
SET DEBUG_SYNC='now SIGNAL con1_dml';
connect con1,localhost,root,,test;
SET DEBUG_SYNC='now WAIT_FOR con1_dml';
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
begin;
SELECT * FROM t1 for update;
pk f1 f2 f3
@@ -34,6 +35,7 @@ SET DEBUG_SYNC='now SIGNAL default_dml';
SET DEBUG_SYNC='now SIGNAL con2_dml';
connection default;
SET DEBUG_SYNC='now WAIT_FOR default_dml';
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
UPDATE t3 AS alias1 LEFT JOIN t3 AS alias2 ON ( alias1.f1 <> alias1.f2 ) SET alias1.f3 = 59 WHERE ( EXISTS ( SELECT t1.f3 FROM t1 WHERE t1.f1 = alias1.f1 ) ) OR alias2.f1 = 'h';
connect con2,localhost,root,,test;
set debug_sync='now WAIT_FOR con2_dml';

View File

@@ -0,0 +1,72 @@
#
# MDEV-36832: UPDATE...IN(SELECT) acquires spurious gap locks on FK index
#
CREATE TABLE booking (
booking_id INT PRIMARY KEY
) ENGINE=InnoDB;
CREATE TABLE ticket (
ticket_id INT AUTO_INCREMENT PRIMARY KEY,
booking_id INT NOT NULL,
is_valid INT DEFAULT 1,
KEY fk_ticket_2_booking (booking_id),
FOREIGN KEY (booking_id) REFERENCES booking(booking_id)
) ENGINE=InnoDB;
INSERT INTO booking VALUES (1), (2);
INSERT INTO ticket (booking_id) VALUES (1), (1), (2), (2);
connect con1,localhost,root;
SET SESSION innodb_lock_wait_timeout=1;
connect con2,localhost,root;
SET SESSION innodb_lock_wait_timeout=1;
#
# Con1: UPDATE tickets for booking_id=1 via subquery
#
connection con1;
BEGIN;
UPDATE ticket SET is_valid=0
WHERE ticket_id IN (SELECT ticket_id FROM ticket WHERE booking_id=1);
#
# Con2: UPDATE tickets for booking_id=2 via subquery
#
connection con2;
BEGIN;
UPDATE ticket SET is_valid=0
WHERE ticket_id IN (SELECT ticket_id FROM ticket WHERE booking_id=2);
#
# Con1: INSERT into booking_id=1 (should not be blocked by Con2)
#
connection con1;
INSERT INTO ticket (booking_id) VALUES (1);
#
# Con2: INSERT into booking_id=2 (should succeed immediately)
#
connection con2;
INSERT INTO ticket (booking_id) VALUES (2);
#
# Con1: reap INSERT — must succeed without lock wait timeout
#
connection con1;
#
# Commit both transactions
#
connection con1;
COMMIT;
connection con2;
COMMIT;
#
# Verify: each booking has 2 updated + 1 new ticket
#
connection default;
SELECT booking_id, is_valid, COUNT(*) c FROM ticket
GROUP BY booking_id, is_valid ORDER BY booking_id, is_valid;
booking_id is_valid c
1 0 2
1 1 1
2 0 2
2 1 1
#
# Cleanup
#
DROP TABLE ticket;
DROP TABLE booking;
disconnect con1;
disconnect con2;

View File

@@ -0,0 +1,285 @@
#
# MDEV-36832: Negative isolation tests for UPDATE...IN(SELECT)
# consistent read (LOCK_NONE) at REPEATABLE READ
#
# These tests verify that replacing S locks with consistent read
# (LOCK_NONE) on the semi-join read table does NOT cause ACID
# isolation violations at REPEATABLE READ.
#
CREATE TABLE booking (
booking_id INT PRIMARY KEY
) ENGINE=InnoDB;
CREATE TABLE ticket (
ticket_id INT AUTO_INCREMENT PRIMARY KEY,
booking_id INT NOT NULL,
is_valid INT DEFAULT 1,
KEY fk_ticket_2_booking (booking_id),
FOREIGN KEY (booking_id) REFERENCES booking(booking_id)
) ENGINE=InnoDB;
INSERT INTO booking VALUES (1), (2);
INSERT INTO ticket (booking_id, is_valid) VALUES
(1, 1), (1, 1),
(2, 1), (2, 1);
connect con1,localhost,root;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SET SESSION innodb_lock_wait_timeout=3;
connect con2,localhost,root;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SET SESSION innodb_lock_wait_timeout=3;
#
# Test 1: Phantom insert — concurrent INSERT into subquery range
#
# T1 updates tickets for booking_id=1 via semi-join.
# T2 concurrently inserts a new ticket with booking_id=1.
# Without S gap locks (LOCK_NONE), T2 is NOT blocked.
# T1's MVCC snapshot does not see the new row, so T1 updates
# exactly the 2 original tickets. The new ticket remains is_valid=1.
# This is correct under snapshot isolation (RR).
#
connection con1;
BEGIN;
UPDATE ticket SET is_valid=0
WHERE ticket_id IN (SELECT ticket_id FROM ticket WHERE booking_id=1);
connection con2;
BEGIN;
INSERT INTO ticket (booking_id, is_valid) VALUES (1, 1);
COMMIT;
connection con1;
COMMIT;
connection default;
# Expect: ticket_ids 1,2 updated to is_valid=0; new ticket is_valid=1
SELECT ticket_id, booking_id, is_valid FROM ticket
WHERE booking_id=1 ORDER BY ticket_id;
ticket_id booking_id is_valid
1 1 0
2 1 0
5 1 1
DELETE FROM ticket WHERE ticket_id > 4;
UPDATE ticket SET is_valid=1;
#
# Test 2: Concurrent UPDATE on subquery filter column
#
# T1 updates tickets for booking_id=1. T2 changes ticket_id=2's
# booking_id from 1 to 2. Both need X lock on ticket_id=2's PK
# record, so they serialize regardless of FK index locks.
# Proves: PK X locks alone ensure write-write serialization.
#
connection con1;
BEGIN;
UPDATE ticket SET is_valid=0
WHERE ticket_id IN (SELECT ticket_id FROM ticket WHERE booking_id=1);
connection con2;
BEGIN;
UPDATE ticket SET booking_id=2 WHERE ticket_id=2;
connection con1;
COMMIT;
connection con2;
COMMIT;
connection default;
# Both committed. Serialized via PK X lock.
# ticket_id=1: is_valid=0 (T1), booking_id=1 (unchanged)
# ticket_id=2: is_valid=0 (T1 ran first), booking_id=2 (T2 ran second)
SELECT ticket_id, booking_id, is_valid FROM ticket ORDER BY ticket_id;
ticket_id booking_id is_valid
1 1 0
2 2 0
3 2 1
4 2 1
#
# Test 3: Concurrent DELETE of a subquery-matched row
#
# T1 updates tickets for booking_id=1. T2 deletes ticket_id=1.
# Both need PK X lock → serialized.
#
connection default;
UPDATE ticket SET booking_id=1 WHERE ticket_id <= 2;
UPDATE ticket SET is_valid=1;
connection con1;
BEGIN;
UPDATE ticket SET is_valid=0
WHERE ticket_id IN (SELECT ticket_id FROM ticket WHERE booking_id=1);
connection con2;
BEGIN;
DELETE FROM ticket WHERE ticket_id=1;
connection con1;
COMMIT;
connection con2;
COMMIT;
connection default;
# ticket_id=1 deleted by T2 (ran second), ticket_id=2 updated by T1.
SELECT ticket_id, booking_id, is_valid FROM ticket ORDER BY ticket_id;
ticket_id booking_id is_valid
2 1 0
3 2 1
4 2 1
INSERT INTO ticket (ticket_id, booking_id, is_valid) VALUES (1, 1, 1);
UPDATE ticket SET is_valid=1;
#
# Test 4: SERIALIZABLE must still acquire S locks
#
# At SERIALIZABLE, store_lock() keeps LOCK_S for the read-side
# table, so the subquery's read MUST block concurrent writes.
# T1 does UPDATE...IN(SELECT) at SERIALIZABLE.
# T2 tries to INSERT into same booking_id range — must block.
#
connection con1;
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
connection con2;
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
connection con1;
BEGIN;
UPDATE ticket SET is_valid=0
WHERE ticket_id IN (SELECT ticket_id FROM ticket WHERE booking_id=1);
connection con2;
BEGIN;
INSERT INTO ticket (booking_id, is_valid) VALUES (1, 1);
ERROR HY000: Lock wait timeout exceeded; try restarting transaction
COMMIT;
connection con1;
COMMIT;
connection default;
UPDATE ticket SET is_valid=1;
#
# Test 5: Multi-table UPDATE on different tables — read table
# uses consistent read (LOCK_NONE), no S locks
#
# T1: UPDATE t_write JOIN t_read SET t_write.val = t_read.val
# WHERE t_read.status = 'active'
# T2: UPDATE t_read SET status = 'cancelled' WHERE id = 2
#
# With LOCK_NONE on t_read, T2 must NOT block.
# T1's MVCC snapshot sees 'active' for id=2 and uses val=20.
# This is correct under snapshot isolation: equivalent to T1
# running before T2 in a serial schedule.
#
CREATE TABLE t_read (
id INT PRIMARY KEY,
status VARCHAR(20) NOT NULL,
val INT NOT NULL
) ENGINE=InnoDB;
CREATE TABLE t_write (
id INT PRIMARY KEY,
val INT NOT NULL DEFAULT 0
) ENGINE=InnoDB;
INSERT INTO t_read VALUES (1, 'active', 10), (2, 'active', 20);
INSERT INTO t_write VALUES (1, 0), (2, 0);
connection con1;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SET DEBUG_SYNC='execute_command_after_close_tables SIGNAL t1_updated WAIT_FOR t2_done';
UPDATE t_write w JOIN t_read r ON w.id = r.id SET w.val = r.val WHERE r.status = 'active';
connection con2;
SET DEBUG_SYNC='now WAIT_FOR t1_updated';
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
UPDATE t_read SET status='cancelled' WHERE id=2;
COMMIT;
SET DEBUG_SYNC='now SIGNAL t2_done';
connection con1;
COMMIT;
connection default;
# t_write.val reflects T1's snapshot: val=10 (id=1), val=20 (id=2).
# t_read.id=2 is now 'cancelled' (by T2) but T1's snapshot saw
# 'active'. Correct under snapshot isolation.
SELECT w.id, w.val, r.status AS current_read_status
FROM t_write w JOIN t_read r ON w.id = r.id ORDER BY w.id;
id val current_read_status
1 10 active
2 20 cancelled
#
# Test 6: MVCC snapshot consistency — re-read within same
# transaction after UPDATE must see consistent data
#
# T1 establishes snapshot, T2 modifies t_read, T1 does
# multi-table UPDATE using snapshot, then T1 re-reads t_read.
# The re-read must see the same data as the UPDATE used
# (MVCC snapshot stability at RR).
#
connection default;
UPDATE t_read SET status='active';
UPDATE t_write SET val=0;
connection con1;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SELECT * FROM t_read WHERE status='active';
id status val
1 active 10
2 active 20
connection con2;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
UPDATE t_read SET status='cancelled', val=99 WHERE id=2;
COMMIT;
connection con1;
UPDATE t_write w JOIN t_read r ON w.id = r.id
SET w.val = r.val WHERE r.status = 'active';
SELECT id, status, val FROM t_read WHERE status='active' ORDER BY id;
id status val
1 active 10
2 active 20
COMMIT;
connection default;
# t_write must have snapshot values: val=10 and val=20 (NOT val=99).
# T2's change is invisible to T1's snapshot.
SELECT id, val FROM t_write ORDER BY id;
id val
1 10
2 20
#
# Test 7: SERIALIZABLE with autocommit must also acquire S locks
#
# The external_lock() upgrade of LOCK_NONE to LOCK_S at
# SERIALIZABLE applies only to non-autocommit transactions, so
# store_lock() must not hand out LOCK_NONE at SERIALIZABLE.
# T1 (autocommit=1, SERIALIZABLE) runs UPDATE...IN(SELECT) and is
# stalled on the PK X lock of the last matching row, held by T2.
# By that point T1 has scanned the FK index entries (1,10),(1,20)
# and must hold S next-key locks on them. T3 then inserts a ticket
# whose FK entry (1,15) falls inside that locked range - it must
# block.
#
connection default;
DROP TABLE ticket;
CREATE TABLE ticket (
ticket_id INT AUTO_INCREMENT PRIMARY KEY,
booking_id INT NOT NULL,
is_valid INT DEFAULT 1,
KEY fk_ticket_2_booking (booking_id),
FOREIGN KEY (booking_id) REFERENCES booking(booking_id)
) ENGINE=InnoDB;
INSERT INTO ticket (ticket_id, booking_id, is_valid) VALUES
(10, 1, 1), (20, 1, 1),
(30, 2, 1), (40, 2, 1);
connection con1;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
UPDATE ticket SET is_valid=is_valid WHERE ticket_id=20;
connection con2;
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SET SESSION innodb_lock_wait_timeout=30;
UPDATE ticket SET is_valid=0 WHERE ticket_id IN (SELECT ticket_id FROM ticket WHERE booking_id=1);
connection default;
SET SESSION innodb_lock_wait_timeout=1;
# FK entry (1,15) lands inside con2's S next-key locked range:
INSERT INTO ticket (ticket_id, booking_id) VALUES (15, 1);
ERROR HY000: Lock wait timeout exceeded; try restarting transaction
SET SESSION innodb_lock_wait_timeout=default;
connection con1;
COMMIT;
connection con2;
connection default;
SELECT ticket_id, booking_id, is_valid FROM ticket ORDER BY ticket_id;
ticket_id booking_id is_valid
10 1 0
20 1 0
30 2 1
40 2 1
#
# Cleanup
#
DROP TABLE t_write, t_read;
DROP TABLE ticket;
DROP TABLE booking;
disconnect con1;
disconnect con2;
SET DEBUG_SYNC='RESET';

View File

@@ -4,6 +4,14 @@
--source include/innodb_stable_estimates.inc
# This test verifies that a deadlock error during join evaluation of a
# multi-table UPDATE is properly propagated (originally the error was
# ignored, tripping an assertion on trx->state). Both UPDATEs below wait
# on a table they only read, so the deadlock exists only if those reads
# take S locks. At REPEATABLE READ they are consistent (non-locking)
# reads, hence the two transactions involved in the deadlock run at
# SERIALIZABLE, where the read-only tables keep their S locks.
--disable_query_log
call mtr.add_suppression("InnoDB: Transaction was aborted due to ");
--enable_query_log
@@ -38,6 +46,7 @@ SET DEBUG_SYNC='now SIGNAL con1_dml';
--connect (con1,localhost,root,,test)
SET DEBUG_SYNC='now WAIT_FOR con1_dml';
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
begin;
SELECT * FROM t1 for update; # Holds x lock of all records in the table t1
SET DEBUG_SYNC='now SIGNAL default_dml';
@@ -45,6 +54,7 @@ SET DEBUG_SYNC='now SIGNAL con2_dml';
--connection default
SET DEBUG_SYNC='now WAIT_FOR default_dml';
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
--send UPDATE t3 AS alias1 LEFT JOIN t3 AS alias2 ON ( alias1.f1 <> alias1.f2 ) SET alias1.f3 = 59 WHERE ( EXISTS ( SELECT t1.f3 FROM t1 WHERE t1.f1 = alias1.f1 ) ) OR alias2.f1 = 'h'
# It holds the lock of all record in t3 and tries to acquire record lock for the table t1.

View File

@@ -0,0 +1,85 @@
--source include/have_innodb.inc
--echo #
--echo # MDEV-36832: UPDATE...IN(SELECT) acquires spurious gap locks on FK index
--echo #
CREATE TABLE booking (
booking_id INT PRIMARY KEY
) ENGINE=InnoDB;
CREATE TABLE ticket (
ticket_id INT AUTO_INCREMENT PRIMARY KEY,
booking_id INT NOT NULL,
is_valid INT DEFAULT 1,
KEY fk_ticket_2_booking (booking_id),
FOREIGN KEY (booking_id) REFERENCES booking(booking_id)
) ENGINE=InnoDB;
INSERT INTO booking VALUES (1), (2);
INSERT INTO ticket (booking_id) VALUES (1), (1), (2), (2);
--connect (con1,localhost,root)
SET SESSION innodb_lock_wait_timeout=1;
--connect (con2,localhost,root)
SET SESSION innodb_lock_wait_timeout=1;
--echo #
--echo # Con1: UPDATE tickets for booking_id=1 via subquery
--echo #
--connection con1
BEGIN;
UPDATE ticket SET is_valid=0
WHERE ticket_id IN (SELECT ticket_id FROM ticket WHERE booking_id=1);
--echo #
--echo # Con2: UPDATE tickets for booking_id=2 via subquery
--echo #
--connection con2
BEGIN;
UPDATE ticket SET is_valid=0
WHERE ticket_id IN (SELECT ticket_id FROM ticket WHERE booking_id=2);
--echo #
--echo # Con1: INSERT into booking_id=1 (should not be blocked by Con2)
--echo #
--connection con1
--send INSERT INTO ticket (booking_id) VALUES (1)
--echo #
--echo # Con2: INSERT into booking_id=2 (should succeed immediately)
--echo #
--connection con2
INSERT INTO ticket (booking_id) VALUES (2);
--echo #
--echo # Con1: reap INSERT — must succeed without lock wait timeout
--echo #
--connection con1
--reap
--echo #
--echo # Commit both transactions
--echo #
--connection con1
COMMIT;
--connection con2
COMMIT;
--echo #
--echo # Verify: each booking has 2 updated + 1 new ticket
--echo #
--connection default
SELECT booking_id, is_valid, COUNT(*) c FROM ticket
GROUP BY booking_id, is_valid ORDER BY booking_id, is_valid;
--echo #
--echo # Cleanup
--echo #
DROP TABLE ticket;
DROP TABLE booking;
--disconnect con1
--disconnect con2

View File

@@ -0,0 +1,351 @@
--source include/have_innodb.inc
--source include/have_debug_sync.inc
# The MDEV-36832 fix only takes effect when the read-side table handle
# gets TL_READ (not TL_READ_NO_INSERT). read_lock_type_for_table()
# returns TL_READ only when binlog is OFF or ROW format. With
# STATEMENT or MIXED, TL_READ_NO_INSERT is used and the fix's
# all-isolation-level LOCK_NONE branch does not apply (S locks are
# required for statement-based replication serialization).
--source include/have_binlog_format_row.inc
--echo #
--echo # MDEV-36832: Negative isolation tests for UPDATE...IN(SELECT)
--echo # consistent read (LOCK_NONE) at REPEATABLE READ
--echo #
--echo # These tests verify that replacing S locks with consistent read
--echo # (LOCK_NONE) on the semi-join read table does NOT cause ACID
--echo # isolation violations at REPEATABLE READ.
--echo #
CREATE TABLE booking (
booking_id INT PRIMARY KEY
) ENGINE=InnoDB;
CREATE TABLE ticket (
ticket_id INT AUTO_INCREMENT PRIMARY KEY,
booking_id INT NOT NULL,
is_valid INT DEFAULT 1,
KEY fk_ticket_2_booking (booking_id),
FOREIGN KEY (booking_id) REFERENCES booking(booking_id)
) ENGINE=InnoDB;
INSERT INTO booking VALUES (1), (2);
INSERT INTO ticket (booking_id, is_valid) VALUES
(1, 1), (1, 1),
(2, 1), (2, 1);
--connect (con1,localhost,root)
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SET SESSION innodb_lock_wait_timeout=3;
--connect (con2,localhost,root)
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SET SESSION innodb_lock_wait_timeout=3;
--echo #
--echo # Test 1: Phantom insert — concurrent INSERT into subquery range
--echo #
--echo # T1 updates tickets for booking_id=1 via semi-join.
--echo # T2 concurrently inserts a new ticket with booking_id=1.
--echo # Without S gap locks (LOCK_NONE), T2 is NOT blocked.
--echo # T1's MVCC snapshot does not see the new row, so T1 updates
--echo # exactly the 2 original tickets. The new ticket remains is_valid=1.
--echo # This is correct under snapshot isolation (RR).
--echo #
--connection con1
BEGIN;
UPDATE ticket SET is_valid=0
WHERE ticket_id IN (SELECT ticket_id FROM ticket WHERE booking_id=1);
--connection con2
BEGIN;
# With the fix (ROW binlog), this INSERT must NOT block.
# The read-side FK index scan uses LOCK_NONE, so no S gap locks exist.
INSERT INTO ticket (booking_id, is_valid) VALUES (1, 1);
COMMIT;
--connection con1
COMMIT;
--connection default
--echo # Expect: ticket_ids 1,2 updated to is_valid=0; new ticket is_valid=1
SELECT ticket_id, booking_id, is_valid FROM ticket
WHERE booking_id=1 ORDER BY ticket_id;
DELETE FROM ticket WHERE ticket_id > 4;
UPDATE ticket SET is_valid=1;
--echo #
--echo # Test 2: Concurrent UPDATE on subquery filter column
--echo #
--echo # T1 updates tickets for booking_id=1. T2 changes ticket_id=2's
--echo # booking_id from 1 to 2. Both need X lock on ticket_id=2's PK
--echo # record, so they serialize regardless of FK index locks.
--echo # Proves: PK X locks alone ensure write-write serialization.
--echo #
--connection con1
BEGIN;
UPDATE ticket SET is_valid=0
WHERE ticket_id IN (SELECT ticket_id FROM ticket WHERE booking_id=1);
--connection con2
BEGIN;
# T2 needs X lock on ticket_id=2 PK → blocks on T1's X lock.
--send UPDATE ticket SET booking_id=2 WHERE ticket_id=2
# T1 commits, releasing X locks → T2 proceeds.
--connection con1
COMMIT;
--connection con2
--reap
COMMIT;
--connection default
--echo # Both committed. Serialized via PK X lock.
--echo # ticket_id=1: is_valid=0 (T1), booking_id=1 (unchanged)
--echo # ticket_id=2: is_valid=0 (T1 ran first), booking_id=2 (T2 ran second)
SELECT ticket_id, booking_id, is_valid FROM ticket ORDER BY ticket_id;
--echo #
--echo # Test 3: Concurrent DELETE of a subquery-matched row
--echo #
--echo # T1 updates tickets for booking_id=1. T2 deletes ticket_id=1.
--echo # Both need PK X lock → serialized.
--echo #
--connection default
UPDATE ticket SET booking_id=1 WHERE ticket_id <= 2;
UPDATE ticket SET is_valid=1;
--connection con1
BEGIN;
UPDATE ticket SET is_valid=0
WHERE ticket_id IN (SELECT ticket_id FROM ticket WHERE booking_id=1);
--connection con2
BEGIN;
# T2 needs X lock on ticket_id=1 PK → blocks on T1's X lock.
--send DELETE FROM ticket WHERE ticket_id=1
--connection con1
COMMIT;
--connection con2
--reap
COMMIT;
--connection default
--echo # ticket_id=1 deleted by T2 (ran second), ticket_id=2 updated by T1.
SELECT ticket_id, booking_id, is_valid FROM ticket ORDER BY ticket_id;
INSERT INTO ticket (ticket_id, booking_id, is_valid) VALUES (1, 1, 1);
UPDATE ticket SET is_valid=1;
--echo #
--echo # Test 4: SERIALIZABLE must still acquire S locks
--echo #
--echo # At SERIALIZABLE, store_lock() keeps LOCK_S for the read-side
--echo # table, so the subquery's read MUST block concurrent writes.
--echo # T1 does UPDATE...IN(SELECT) at SERIALIZABLE.
--echo # T2 tries to INSERT into same booking_id range — must block.
--echo #
--connection con1
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
--connection con2
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
--connection con1
BEGIN;
UPDATE ticket SET is_valid=0
WHERE ticket_id IN (SELECT ticket_id FROM ticket WHERE booking_id=1);
--connection con2
BEGIN;
# At SERIALIZABLE, T1 holds S locks on the FK index.
# T2's INSERT needs an insert-intention lock that conflicts.
--error ER_LOCK_WAIT_TIMEOUT
INSERT INTO ticket (booking_id, is_valid) VALUES (1, 1);
COMMIT;
--connection con1
COMMIT;
--connection default
UPDATE ticket SET is_valid=1;
--echo #
--echo # Test 5: Multi-table UPDATE on different tables — read table
--echo # uses consistent read (LOCK_NONE), no S locks
--echo #
--echo # T1: UPDATE t_write JOIN t_read SET t_write.val = t_read.val
--echo # WHERE t_read.status = 'active'
--echo # T2: UPDATE t_read SET status = 'cancelled' WHERE id = 2
--echo #
--echo # With LOCK_NONE on t_read, T2 must NOT block.
--echo # T1's MVCC snapshot sees 'active' for id=2 and uses val=20.
--echo # This is correct under snapshot isolation: equivalent to T1
--echo # running before T2 in a serial schedule.
--echo #
CREATE TABLE t_read (
id INT PRIMARY KEY,
status VARCHAR(20) NOT NULL,
val INT NOT NULL
) ENGINE=InnoDB;
CREATE TABLE t_write (
id INT PRIMARY KEY,
val INT NOT NULL DEFAULT 0
) ENGINE=InnoDB;
INSERT INTO t_read VALUES (1, 'active', 10), (2, 'active', 20);
INSERT INTO t_write VALUES (1, 0), (2, 0);
--connection con1
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SET DEBUG_SYNC='execute_command_after_close_tables SIGNAL t1_updated WAIT_FOR t2_done';
--send UPDATE t_write w JOIN t_read r ON w.id = r.id SET w.val = r.val WHERE r.status = 'active'
--connection con2
SET DEBUG_SYNC='now WAIT_FOR t1_updated';
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
# Must NOT block — T1 holds no S locks on t_read.
UPDATE t_read SET status='cancelled' WHERE id=2;
COMMIT;
SET DEBUG_SYNC='now SIGNAL t2_done';
--connection con1
--reap
COMMIT;
--connection default
--echo # t_write.val reflects T1's snapshot: val=10 (id=1), val=20 (id=2).
--echo # t_read.id=2 is now 'cancelled' (by T2) but T1's snapshot saw
--echo # 'active'. Correct under snapshot isolation.
SELECT w.id, w.val, r.status AS current_read_status
FROM t_write w JOIN t_read r ON w.id = r.id ORDER BY w.id;
--echo #
--echo # Test 6: MVCC snapshot consistency — re-read within same
--echo # transaction after UPDATE must see consistent data
--echo #
--echo # T1 establishes snapshot, T2 modifies t_read, T1 does
--echo # multi-table UPDATE using snapshot, then T1 re-reads t_read.
--echo # The re-read must see the same data as the UPDATE used
--echo # (MVCC snapshot stability at RR).
--echo #
--connection default
UPDATE t_read SET status='active';
UPDATE t_write SET val=0;
--connection con1
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
# Establish MVCC snapshot
SELECT * FROM t_read WHERE status='active';
--connection con2
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
# T2 modifies t_read AFTER T1's snapshot is established
UPDATE t_read SET status='cancelled', val=99 WHERE id=2;
COMMIT;
--connection con1
# T1's multi-table UPDATE uses snapshot — still sees id=2 as active/val=20
UPDATE t_write w JOIN t_read r ON w.id = r.id
SET w.val = r.val WHERE r.status = 'active';
# T1's re-read must still see 'active' for both rows (MVCC snapshot)
SELECT id, status, val FROM t_read WHERE status='active' ORDER BY id;
COMMIT;
--connection default
--echo # t_write must have snapshot values: val=10 and val=20 (NOT val=99).
--echo # T2's change is invisible to T1's snapshot.
SELECT id, val FROM t_write ORDER BY id;
--echo #
--echo # Test 7: SERIALIZABLE with autocommit must also acquire S locks
--echo #
--echo # The external_lock() upgrade of LOCK_NONE to LOCK_S at
--echo # SERIALIZABLE applies only to non-autocommit transactions, so
--echo # store_lock() must not hand out LOCK_NONE at SERIALIZABLE.
--echo # T1 (autocommit=1, SERIALIZABLE) runs UPDATE...IN(SELECT) and is
--echo # stalled on the PK X lock of the last matching row, held by T2.
--echo # By that point T1 has scanned the FK index entries (1,10),(1,20)
--echo # and must hold S next-key locks on them. T3 then inserts a ticket
--echo # whose FK entry (1,15) falls inside that locked range - it must
--echo # block.
--echo #
--connection default
# Recreate the table: DELETE would leave delete-marked records, which
# make the UPDATE's clustered index scan take next-key (gap) X locks.
# The probe INSERT below would then queue behind the UPDATE's waiting
# next-key lock on the PRIMARY index, masking what this test measures
# (S locks on the FK index).
DROP TABLE ticket;
CREATE TABLE ticket (
ticket_id INT AUTO_INCREMENT PRIMARY KEY,
booking_id INT NOT NULL,
is_valid INT DEFAULT 1,
KEY fk_ticket_2_booking (booking_id),
FOREIGN KEY (booking_id) REFERENCES booking(booking_id)
) ENGINE=InnoDB;
INSERT INTO ticket (ticket_id, booking_id, is_valid) VALUES
(10, 1, 1), (20, 1, 1),
(30, 2, 1), (40, 2, 1);
--connection con1
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
# Hold a PK X lock on the last row matching booking_id=1
UPDATE ticket SET is_valid=is_valid WHERE ticket_id=20;
--connection con2
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SET SESSION innodb_lock_wait_timeout=30;
# autocommit=1: no BEGIN. The UPDATE stalls on con1's PK X lock after
# having scanned the FK index entries for booking_id=1.
--send UPDATE ticket SET is_valid=0 WHERE ticket_id IN (SELECT ticket_id FROM ticket WHERE booking_id=1)
--connection default
let $wait_condition=
SELECT COUNT(*)=1 FROM information_schema.innodb_trx
WHERE trx_state='LOCK WAIT';
--source include/wait_condition.inc
SET SESSION innodb_lock_wait_timeout=1;
--echo # FK entry (1,15) lands inside con2's S next-key locked range:
--error ER_LOCK_WAIT_TIMEOUT
INSERT INTO ticket (ticket_id, booking_id) VALUES (15, 1);
SET SESSION innodb_lock_wait_timeout=default;
--connection con1
COMMIT;
--connection con2
--reap
--connection default
SELECT ticket_id, booking_id, is_valid FROM ticket ORDER BY ticket_id;
--echo #
--echo # Cleanup
--echo #
DROP TABLE t_write, t_read;
DROP TABLE ticket;
DROP TABLE booking;
--disconnect con1
--disconnect con2
SET DEBUG_SYNC='RESET';

View File

@@ -16872,31 +16872,75 @@ ha_innobase::store_lock(
unexpected if an obsolete consistent read view would be
used. */
/* Use consistent read for checksum table */
/* Use consistent read for checksum table
and for read-only table accesses in multi-table
UPDATE.
Multi-table UPDATE modifies some tables and only
reads others. The read-only tables do not need
S locks: the MVCC snapshot provides read
consistency, and rows being modified still get
X locks via the write-side table handle.
For TL_READ we use consistent read (LOCK_NONE) at
all isolation levels below SERIALIZABLE. This is
safe because:
- The MVCC snapshot is stable within a transaction
in REPEATABLE READ.
- Rows being modified still get X locks via the
write-side table handle (F_WRLCK / LOCK_X).
SERIALIZABLE must be excluded here: the
::external_lock() upgrade of LOCK_NONE to LOCK_S
applies only to non-autocommit transactions, so an
autocommit multi-table UPDATE would otherwise do a
consistent read at SERIALIZABLE.
We limit this to SQLCOM_UPDATE_MULTI (not
SQLCOM_UPDATE) because single-table UPDATE with
scalar subqueries on other tables traditionally
uses S locks to block concurrent modifications to
those tables, and changing that would alter
observable behavior for existing applications.
For TL_READ_NO_INSERT we keep the pre-existing
behavior: consistent read only at READ COMMITTED
and below, S locks at REPEATABLE READ and above,
because statement-based replication needs locking
reads for serializable execution ordering. */
if (sql_command == SQLCOM_CHECKSUM
|| sql_command == SQLCOM_CREATE_SEQUENCE
|| (sql_command == SQLCOM_ANALYZE && lock_type == TL_READ)
|| (lock_type == TL_READ
&& trx->isolation_level != TRX_ISO_SERIALIZABLE
&& sql_command == SQLCOM_UPDATE_MULTI)
|| (trx->isolation_level <= TRX_ISO_READ_COMMITTED
&& (lock_type == TL_READ
|| lock_type == TL_READ_NO_INSERT)
&& (sql_command == SQLCOM_INSERT_SELECT
|| sql_command == SQLCOM_REPLACE_SELECT
|| sql_command == SQLCOM_UPDATE
|| sql_command == SQLCOM_UPDATE_MULTI
|| sql_command == SQLCOM_CREATE_SEQUENCE
|| sql_command == SQLCOM_CREATE_TABLE))) {
/* If the transaction isolation level is
READ UNCOMMITTED or READ COMMITTED and we are executing
INSERT INTO...SELECT or REPLACE INTO...SELECT
or UPDATE ... = (SELECT ...) or CREATE ...
SELECT... without FOR UPDATE or IN SHARE
MODE in select, then we use consistent read
for select. */
DBUG_PRINT("ib_lock",
("consistent read for DML: "
"lock_type=%d sql_command=%d "
"isolation_level=%u",
lock_type, sql_command,
trx->isolation_level));
m_prebuilt->select_lock_type = LOCK_NONE;
m_prebuilt->stored_select_lock_type = LOCK_NONE;
} else {
DBUG_PRINT("ib_lock",
("LOCK_S for DML read: "
"lock_type=%d sql_command=%d "
"isolation_level=%u",
lock_type, sql_command,
trx->isolation_level));
m_prebuilt->select_lock_type = LOCK_S;
m_prebuilt->stored_select_lock_type = LOCK_S;
}