Bug #88534 XA may lost prepared transaction and cause different between master and slave.
Submitted: 17 Nov 2017 11:04 Modified: 23 Mar 2021 8:17
Reporter: Ze Yang (OCA) Email Updates:
Status: Verified Impact on me:
None 
Category:MySQL Server: XA transactions Severity:S1 (Critical)
Version:5.7, 5.7.20 OS:Any
Assigned to: CPU Architecture:Any

[17 Nov 2017 11:04] Ze Yang
Description:
1 ha_prepare function first prepare the binlog, then the engines.
If there's error or mysqld crashed when the engine prepare, the engine prepared info w
ould lost. But the slave would receive the XA binlog.

2. As ha_prepare function call binlog_prepare first, the thd->durability_property would be HA_IGNORE_DURABILITY. Then the engine's prepare would not flush logs. Event though the client receive success after issue 'XA prepare', the prepared transaction may also lost after server crash. And this would also cause slave replication not consistent.

How to repeat:
--- a/sql/handler.cc
+++ b/sql/handler.cc
@@ -1460,10 +1460,12 @@ int ha_prepare(THD *thd)
       thd->status_var.ha_prepare_count++;
       if (ht->prepare)
       {
-        DBUG_EXECUTE_IF("simulate_xa_failure_prepare", {
-          ha_rollback_trans(thd, true);
-          DBUG_RETURN(1);
-        });
+        if (ht->db_type == DB_TYPE_INNODB) {
+          DBUG_EXECUTE_IF("simulate_xa_failure_prepare", {
+            ha_rollback_trans(thd, true);
+            DBUG_RETURN(1);
+          });
+        }
         if (ht->prepare(ht, thd, true))
         {
           ha_rollback_trans(thd, true);

replication.test:

--disable_warnings
source include/master-slave.inc;
--enable_warnings
connection master;
CREATE TABLE ti (c1 INT) ENGINE=INNODB;
XA START 'x';
INSERT INTO ti VALUES(1);
XA END 'x';
SET @@session.debug = '+d,simulate_xa_failure_prepare';
--error ER_XA_RBROLLBACK
XA PREPARE 'x';
--echo #Master
XA RECOVER;

--sync_slave_with_master
connection slave;
--echo #Slave
XA RECOVER;

replication.result:

include/master-slave.inc
[connection master]
CREATE TABLE ti (c1 INT) ENGINE=INNODB;
XA START 'x';
INSERT INTO ti VALUES(1);
XA END 'x';
SET @@session.debug = '+d,simulate_xa_failure_prepare';
XA PREPARE 'x';
ERROR XA100: XA_RBROLLBACK: Transaction branch was rolled back
#Master
XA RECOVER;
formatID  gtrid_length  bqual_length  data
#Slave
XA RECOVER;
formatID  gtrid_length  bqual_length  data
1 1 0 x

--- a/sql/handler.cc
+++ b/sql/handler.cc
@@ -1479,6 +1479,7 @@ int ha_prepare(THD *thd)
       }
       ha_info= ha_info->next();
     }
+    DBUG_EXECUTE_IF("crash_after_xa_prepare", DBUG_SUICIDE(););

     DBUG_ASSERT(thd->get_transaction()->xid_state()->
                 has_state(XID_STATE::XA_IDLE));

replication.test:

-- source include/have_log_bin.inc
CREATE TABLE ti (c1 INT) ENGINE=INNODB;
XA START 'x';
INSERT INTO ti VALUES(1);
XA END 'x';
SET @@session.debug = '+d,crash_after_xa_prepare';
--exec echo "wait" > $MYSQLTEST_VARDIR/tmp/mysqld.1.expect
--error 2013
XA PREPARE 'x';
--source include/wait_until_disconnected.inc
--let $_expect_file_name= $MYSQLTEST_VARDIR/tmp/mysqld.1.expect
--source include/start_mysqld.inc
XA RECOVER;
show binlog events in 'mysql.000001';

replication.result:
CREATE TABLE ti (c1 INT) ENGINE=INNODB;
XA START 'x';
INSERT INTO ti VALUES(1);
XA END 'x';
SET @@session.debug = '+d,crash_after_xa_prepare';
XA PREPARE 'x';
ERROR HY000: Lost connection to MySQL server during query
# restart
XA RECOVER;
formatID  gtrid_length  bqual_length  data
show binlog events in 'mysql.000001';
Log_name  Pos Event_type  Server_id End_log_pos Info
mysql.000001  4 Format_desc 1 123 Server ver: 5.7.19org-debug-log, Binlog ver: 4
mysql.000001  123 Previous_gtids  1 154
mysql.000001  154 Anonymous_Gtid  1 219 SET @@SESSION.GTID_NEXT= 'ANONYMOUS'
mysql.000001  219 Query 1 331 use `test`; CREATE TABLE ti (c1 INT) ENGINE=INNODB
mysql.000001  331 Anonymous_Gtid  1 396 SET @@SESSION.GTID_NEXT= 'ANONYMOUS'
mysql.000001  396 Query 1 483 XA START X'78',X'',1
mysql.000001  483 Table_map 1 528 table_id: 222 (test.ti)
mysql.000001  528 Write_rows  1 568 table_id: 222 flags: STMT_END_F
mysql.000001  568 Query 1 653 XA END X'78',X'',1
mysql.000001  653 XA_prepare  1 690 XA PREPARE X'78',X'',1

Suggested fix:
diff --git a/sql/handler.cc b/sql/handler.cc
index 37d38e2..94ff1c0 100644
--- a/sql/handler.cc
+++ b/sql/handler.cc
@@ -1436,6 +1436,7 @@ int ha_prepare(THD *thd)
 {
   int error=0;
   Transaction_ctx *trn_ctx= thd->get_transaction();
+  handlerton *binlog_ht= NULL;
   DBUG_ENTER("ha_prepare");

   if (trn_ctx->is_active(Transaction_ctx::SESSION))
@@ -1464,7 +1465,10 @@ int ha_prepare(THD *thd)
           ha_rollback_trans(thd, true);
           DBUG_RETURN(1);
         });
-        if (ht->prepare(ht, thd, true))
+        if (ht->db_type == DB_TYPE_BINLOG)
+        {
+          binlog_ht= ht;
+        } else  if (ht->prepare(ht, thd, true))
         {
           ha_rollback_trans(thd, true);
           error=1;
@@ -1479,6 +1483,15 @@ int ha_prepare(THD *thd)
       }
       ha_info= ha_info->next();
     }
+    if (error == 0 && binlog_ht)
+    {
+      if (binlog_ht->prepare(binlog_ht, thd, true))
+      {
+        ha_rollback_trans(thd, true);
+        error=1;
+      }
+    }
+    DBUG_EXECUTE_IF("crash_after_xa_prepare", DBUG_SUICIDE(););

     DBUG_ASSERT(thd->get_transaction()->xid_state()->
                 has_state(XID_STATE::XA_IDLE));
[20 Nov 2017 9:48] MySQL Verification Team
Hello Michael Yang,

Thank you for the report and test case.
Verified as described with 5.7.20 src build after patching.
Please note that in order to submit contributions you must first sign the Oracle Contribution Agreement (OCA). More details are described in "Contributions" tab, please ensure to re-send the patch via that tab. Otherwise we would not be able to accept it.

Thanks,
Umesh
[20 Nov 2017 9:58] MySQL Verification Team
test results

Attachment: 88534.results (application/octet-stream, text), 9.01 KiB.

[23 Feb 2018 3:51] Ze Yang
Last days I thought this bug again. If we call InnoDB prepare first, the binlog would lost, the master would have a prepared xa transaction but the slave not.

Then we found two methods to solve this problem. the second method may better .

1. Remain 'XA prepare': binlog prepare->engine prepare(ensure engine flush logs), 'XA COMMIT': binlog commit->engine commit. Write 'xa rollback' when we found the storage engine on master lost prepared transaction.

When the server restarted, we search the last binlog to check whether there's a 'xa prepare' which no 'xa commit/rollback' matched. If we find the not matched 'xa prepare x' in binlog, we check whether there's prepared xa 'x' in the storage engine.  If there's no prepared xa transacton, we write 'xa rollback x' to the binlog to cancel prepared transaction on slave.

But this method not friendly to HA. If we change to slave after the master crash, there's error happened.

2. 'XA PREPARE': engine prepare->binlog prepare, 'XA COMMIT' binlog commit-> engine commit. Add one table like the 'mysql.gtid_executed' table to record the xa prepared transactions when rotate binlog. Search the last binlog to decide whether the prepared transaction in storage engine should be rollbacked, committed or remained prepared.

If we don't add one table to recored the xa prepared transaction when rotate binlog, we would not know whether the prepared transaction's binlog was logged as  'xa prepare' may recorded in the purged binlog. So add a table to record xa prepared transaction when rotate binlog, then we could use binlog to decide the transaction state.
[23 Mar 2021 8:17] Ze Yang
My solution, may be helpful for others:
The execution order:

'XA PREPARE':engine->prepare, binlog->prepare.
'XA COMMIT one phase': engine->prepare, binlog->commit, engine->commit.
'XA COMMIT': binlog->commit, engine->commit (ensure engine commit before binary log rotate).
'XA ROLLBACK': binlog->rollback, engine->rollback (ensure engine rollback before binary log rotate).
Add two types of event for external XA recovery.

Previous_prepared_xids_log_event: Containing the xids of all external XA transactions that are in XA-PREPARED state when rotate binary log. Write to the binary log after Previous_gtids_log_event. This event is for getting the state of external XA without traverse all the binary logs.
XA_preparing_log_event: Containing the xid of external XA transaction that execute 'XA PREPARE' before engine->prepare. This event is for recognizing the state of external XA which have the same xid with some one that just execute 'XA COMMIT' successfully.