Bug #116067 | Upsert not visible within own transaction when querying by unique index | ||
---|---|---|---|
Submitted: | 11 Sep 2024 21:51 | Modified: | 13 Sep 2024 5:56 |
Reporter: | Aaron Staley | Email Updates: | |
Status: | Verified | Impact on me: | |
Category: | MySQL Server: InnoDB storage engine | Severity: | S3 (Non-critical) |
Version: | 8.0.28 | OS: | Any |
Assigned to: | CPU Architecture: | Any |
[11 Sep 2024 21:51]
Aaron Staley
[12 Sep 2024 17:30]
Aaron Staley
I studied this a bit more and I think this is a MVCC bug where upserts (INSERT INTO.. ON DUPLICATE KEY) are no-ops with respect to the ground-truth of the database. If I modify some other column in the ON DUPLICATE KEY UPDATE, the row is visible. But when no columns are modified (with respect to committed state of db), read-after-write consistency is off. This behavior is fine for a pure UPDATE function (the snapshot can't see rows before-hand anyway), but not for an upsert, because if the transaction cannot see the rows, from its POV, there should be an insert. i.e. a record should be there after execution.
[12 Sep 2024 17:42]
Jakub Lopuszanski
Hello Aaron, I am not sure what it is, but it is definitely an interesting behaviour and edge-case! Thank you for reporting it. Some observations: 1. this "upsert" is not "really modifying" anything, because the new value it tries to assign is the same as the old one, therefore there's no "physical change" to the row 2. perhaps it adds to the complexity of this edge-case that the column you "try" to update is the one which is the reason for uniqueness conflict. Anyway, in my testing replacing that part with status=VALUES(status), doesn't change the merit of the report: the read-view doesn't see the row 3. this particular "no-op" upsert seems conceptually equivalent to INSERT IGNORE. Anyway, I've tried replacing the upsert with `INSERT IGNORE INTO simple_table (id, unique_col, status) VALUES ('id1', 'unique1', 'in_progress')` and it doesn't change the merit of the report: the read-view doesn't see the row 4. it is also *conceputally* equivalent to `REPLACE INTO simple_table (id, unique_col, status) VALUES ('id1', 'unique1', 'in_progress')`. Surprisingly (given above findings), this time the conn1 can see the row! I do understand why you, or your app can expect the row to be visible, given it just did the INSERT. I do understand your preferred reading of the documentation. Still, the reality is: the documentation tries to capture what the code does, and what the code does is it checks who last modified the row, and it so happens that conn1 did not, in fact, authored this row. In particular, if you change the scenario to use something like: ``` INSERT INTO simple_table (id, unique_col, status) VALUES ('id1', 'unique1', 'in_progress') ON DUPLICATE KEY UPDATE status = 'more progress!'; ``` Then conn1 will see the row: ``` mysql> SELECT * FROM simple_table WHERE unique_col = 'unique1'; +-----+------------+----------------+ | id | unique_col | status | +-----+------------+----------------+ | id1 | unique1 | more progress! | +-----+------------+----------------+ ``` because now there is no doubt that it has authored a new version of it, so it is "a change". So, the way I see it, this bug report is about a grey area of: what exactly constitutes "a change"? There are clear cases like "in_progress" -> "more progress!", where it is clearly "a change", and InnoDB will show you your "change". There are cases like "unique1" -> "unique1", which might (INSERT ON DUPLICATE, INSERT IGNORE) or might not (REPLACE INTO) be recognized by InnoDB as not really requiring a physical change of the row, so InnoDB takse a short cut and doesn't touch anything, and then there is no "change". I think this (admittedly messy) situation is one more reason for my recommendation from other bug reports: Do not mix non-locking SELECTs with locking operations such as: INSERT,UPDATE,DELETE, SELECT..FOR UPDATE/SHARE. I think we should update the documentation to be more frank about this. REPEATABLE READ is good at pretending to be like SERIALIZABLE as long as you stick to either of these groups of statements, but terrible at mixing them. Note that the problem would not appear if you: a) used SERIALIZABLE instead of REPEATABLE READ b) used SELECT...FOR UPDATE instead of SELECT c) split the transaction into separate parts, so that one uses UPDATE and the other uses SELECT
[13 Sep 2024 1:02]
Aaron Staley
Thanks for the thoughts! > 4. it is also *conceputally* equivalent to `REPLACE INTO simple_table (id, unique_col, status) VALUES ('id1', 'unique1', 'in_progress')`. Surprisingly (given above findings), this time the conn1 can see the row! REPLACE INTO isn't quite the same as an op as by definition it is a DELETE plus an INSERT. That is it has to modify the underlying row in the db. (For this reason I also cannot use REPLACE INTO within my application). Agreed on the workaround being to change the isolation level (which I can't do as this is part of a large system), add a "dummy column" to force a write (viable I suppose) or ensure anywhere I read in this transaction I use FOR SHARE to read committed state. Personally, I think this is a bug in the MVCC. It seems to determine row visibility based on "was the underlying row modified by this transaction", when IMO the more reasonable interpretation of read-after-write consistency should be "from the POV of this transaction was the underlying row modified".