diff --git a/docs/content/tutorials/migrating-from-connector-net.md b/docs/content/tutorials/migrating-from-connector-net.md index a6e4e7888..b523fe483 100644 --- a/docs/content/tutorials/migrating-from-connector-net.md +++ b/docs/content/tutorials/migrating-from-connector-net.md @@ -39,8 +39,16 @@ MySqlConnector has some different default connection string options: Some command line options that are supported in Connector/NET are not supported in MySqlConnector. For a full list of options that are supported in MySqlConnector, see the [Connection Options](connection-options) +### TransactionScope + +MySqlConnector adds full distributed transaction support (for client code using [`TransactionScope`](https://msdn.microsoft.com/en-us/library/system.transactions.transactionscope.aspx)), +while Connector/NET uses regular database transactions. As a result, code that uses `TransactionScope` +may execute differently with MySqlConnector. To get Connector/NET-compatible behavior, remove +`TransactionScope` and use `BeginTransaction`/`Commit` directly. + ### Bugs present in Connector/NET that are fixed in MySqlConnector +* [#37283](https://bugs.mysql.com/bug.php?id=37283), [#70587](https://bugs.mysql.com/bug.php?id=70587): Distributed transactions are not supported * [#66476](https://bugs.mysql.com/bug.php?id=66476): Connection pool uses queue instead of stack * [#70111](https://bugs.mysql.com/bug.php?id=70111): `Async` methods execute synchronously * [#70686](https://bugs.mysql.com/bug.php?id=70686): `TIME(3)` and `TIME(6)` fields serialize milliseconds incorrectly diff --git a/src/MySqlConnector/MySqlClient/MySqlConnection.cs b/src/MySqlConnector/MySqlClient/MySqlConnection.cs index 227f5dbe0..5330c1415 100644 --- a/src/MySqlConnector/MySqlClient/MySqlConnection.cs +++ b/src/MySqlConnector/MySqlClient/MySqlConnection.cs @@ -37,6 +37,10 @@ private async Task BeginDbTransactionAsync(IsolationLevel isol throw new InvalidOperationException("Connection is not open."); if (CurrentTransaction != null) throw new InvalidOperationException("Transactions may not be nested."); +#if !NETSTANDARD1_3 + if (m_xaTransaction != null) + throw new InvalidOperationException("Cannot begin a transaction when already enlisted in a transaction."); +#endif string isolationLevelValue; switch (isolationLevel) @@ -76,8 +80,32 @@ private async Task BeginDbTransactionAsync(IsolationLevel isol #if !NETSTANDARD1_3 public override void EnlistTransaction(System.Transactions.Transaction transaction) { - throw new NotSupportedException("System.Transactions.Transaction is not supported. Use BeginTransaction instead."); + if (m_xaTransaction != null) + throw new MySqlException("Already enlisted in a Transaction."); + if (CurrentTransaction != null) + throw new InvalidOperationException("Can't enlist in a Transaction when there is an active MySqlTransaction."); + + if (transaction != null) + { + m_xaTransaction = new MySqlXaTransaction(this); + m_xaTransaction.Start(transaction); + } } + + internal void UnenlistTransaction(MySqlXaTransaction xaTransaction) + { + if (!object.ReferenceEquals(xaTransaction, m_xaTransaction)) + throw new InvalidOperationException("Active transaction is not the one being unenlisted from."); + m_xaTransaction = null; + + if (m_shouldCloseWhenUnenlisted) + { + m_shouldCloseWhenUnenlisted = false; + Close(); + } + } + + MySqlXaTransaction m_xaTransaction; #endif public override void Close() => DoClose(); @@ -111,10 +139,6 @@ private async Task OpenAsync(IOBehavior ioBehavior, CancellationToken cancellati VerifyNotDisposed(); if (State != ConnectionState.Closed) throw new InvalidOperationException("Cannot Open when State is {0}.".FormatInvariant(State)); -#if !NETSTANDARD1_3 - if (System.Transactions.Transaction.Current != null) - throw new NotSupportedException("Ambient transactions are not supported. Use BeginTransaction instead."); -#endif SetState(ConnectionState.Connecting); @@ -135,6 +159,11 @@ private async Task OpenAsync(IOBehavior ioBehavior, CancellationToken cancellati SetState(ConnectionState.Closed); throw new MySqlException("Unable to connect to any of the specified MySQL hosts.", ex); } + +#if !NETSTANDARD1_3 + if (System.Transactions.Transaction.Current != null) + EnlistTransaction(System.Transactions.Transaction.Current); +#endif } public override string ConnectionString @@ -195,7 +224,7 @@ protected override void Dispose(bool disposing) } finally { - m_isDisposed = true; + m_isDisposed = !m_shouldCloseWhenUnenlisted; base.Dispose(disposing); } } @@ -316,6 +345,19 @@ private void VerifyNotDisposed() private void DoClose() { +#if !NETSTANDARD1_3 + // If participating in a distributed transaction, keep the connection open so we can commit or rollback. + // This handles the common pattern of disposing a connection before disposing a TransactionScope (e.g., nested using blocks) + if (m_xaTransaction != null) + { + m_shouldCloseWhenUnenlisted = true; + return; + } +#else + // fix "field is never assigned" compiler error + m_shouldCloseWhenUnenlisted = false; +#endif + if (m_connectionState != ConnectionState.Closed) { try @@ -355,6 +397,7 @@ private void CloseDatabase() ConnectionState m_connectionState; bool m_hasBeenOpened; bool m_isDisposed; + bool m_shouldCloseWhenUnenlisted; Dictionary m_cachedProcedures; } } diff --git a/src/MySqlConnector/MySqlClient/MySqlXaTransaction.cs b/src/MySqlConnector/MySqlClient/MySqlXaTransaction.cs new file mode 100644 index 000000000..1ab329911 --- /dev/null +++ b/src/MySqlConnector/MySqlClient/MySqlXaTransaction.cs @@ -0,0 +1,65 @@ +#if !NETSTANDARD1_3 +using System; +using System.Globalization; +using System.Threading; +using System.Transactions; + +namespace MySql.Data.MySqlClient +{ + internal sealed class MySqlXaTransaction : IEnlistmentNotification + { + public MySqlXaTransaction(MySqlConnection connection) => m_connection = connection; + + public void Start(Transaction transaction) + { + // generate an "xid" with "gtrid" (Global TRansaction ID) from the .NET Transaction and "bqual" (Branch QUALifier) + // unique to this object + var id = Interlocked.Increment(ref s_currentId); + m_xid = "'" + transaction.TransactionInformation.LocalIdentifier + "', '" + id.ToString(CultureInfo.InvariantCulture) + "'"; + + ExecuteXaCommand("START"); + + // TODO: Support EnlistDurable and enable recovery via "XA RECOVER" + transaction.EnlistVolatile(this, EnlistmentOptions.None); + } + + public void Prepare(PreparingEnlistment enlistment) + { + ExecuteXaCommand("END"); + ExecuteXaCommand("PREPARE"); + enlistment.Prepared(); + } + + public void Commit(Enlistment enlistment) + { + ExecuteXaCommand("COMMIT"); + enlistment.Done(); + m_connection.UnenlistTransaction(this); + } + + public void Rollback(Enlistment enlistment) + { + ExecuteXaCommand("END"); + ExecuteXaCommand("ROLLBACK"); + enlistment.Done(); + m_connection.UnenlistTransaction(this); + } + + public void InDoubt(Enlistment enlistment) => throw new NotSupportedException(); + + private void ExecuteXaCommand(string statement) + { + using (var cmd = m_connection.CreateCommand()) + { + cmd.CommandText = "XA " + statement + " " + m_xid; + cmd.ExecuteNonQuery(); + } + } + + static int s_currentId; + + readonly MySqlConnection m_connection; + string m_xid; + } +} +#endif diff --git a/tests/SideBySide/SideBySide.csproj b/tests/SideBySide/SideBySide.csproj index e172cb501..9ead87257 100644 --- a/tests/SideBySide/SideBySide.csproj +++ b/tests/SideBySide/SideBySide.csproj @@ -47,6 +47,7 @@ + diff --git a/tests/SideBySide/TransactionScopeTests.cs b/tests/SideBySide/TransactionScopeTests.cs new file mode 100644 index 000000000..d936825aa --- /dev/null +++ b/tests/SideBySide/TransactionScopeTests.cs @@ -0,0 +1,231 @@ +#if !NETCOREAPP1_1_1 +using System; +using System.Linq; +using System.Transactions; +using Dapper; +using MySql.Data.MySqlClient; +using Xunit; +using SysTransaction = System.Transactions.Transaction; + +namespace SideBySide +{ + public class TransactionScopeTests : IClassFixture + { + public TransactionScopeTests(DatabaseFixture database) + { + m_database = database; + } + + [Fact] + public void EnlistTwoTransactions() + { + using (var connection = new MySqlConnection(AppConfig.ConnectionString)) + { + connection.Open(); + + using (var transactionScope = new TransactionScope()) + { + connection.EnlistTransaction(SysTransaction.Current); + + using (var transactionScope2 = new TransactionScope(TransactionScopeOption.RequiresNew)) + { + Assert.Throws(() => connection.EnlistTransaction(SysTransaction.Current)); + } + } + } + } + + [Fact] + public void BeginTransactionInScope() + { + using (var transactionScope = new TransactionScope()) + using (var connection = new MySqlConnection(AppConfig.ConnectionString)) + { + connection.Open(); + Assert.Throws(() => connection.BeginTransaction()); + } + } + + [Fact] + public void BeginTransactionThenEnlist() + { + using (var connection = new MySqlConnection(AppConfig.ConnectionString)) + { + connection.Open(); + using (var dbTransaction = connection.BeginTransaction()) + using (var transactionScope = new TransactionScope()) + { + Assert.Throws(() => connection.EnlistTransaction(SysTransaction.Current)); + } + } + } + + [Fact] + public void CommitOneTransaction() + { + m_database.Connection.Execute(@"drop table if exists transaction_scope_test; + create table transaction_scope_test(value integer not null);"); + + using (var transactionScope = new TransactionScope()) + { + using (var conn = new MySqlConnection(AppConfig.ConnectionString)) + { + conn.Open(); + conn.Execute("insert into transaction_scope_test(value) values(1), (2);"); + + transactionScope.Complete(); + } + } + + var values = m_database.Connection.Query(@"select value from transaction_scope_test order by value;").ToList(); + Assert.Equal(new[] { 1, 2 }, values); + } + + [Fact] + public void RollBackOneTransaction() + { + m_database.Connection.Execute(@"drop table if exists transaction_scope_test; + create table transaction_scope_test(value integer not null);"); + + using (var transactionScope = new TransactionScope()) + { + using (var conn = new MySqlConnection(AppConfig.ConnectionString)) + { + conn.Open(); + conn.Execute("insert into transaction_scope_test(value) values(1), (2);"); + } + } + + var values = m_database.Connection.Query(@"select value from transaction_scope_test order by value;").ToList(); + Assert.Equal(new int[0], values); + } + + [Fact] + public void ThrowExceptionInTransaction() + { + m_database.Connection.Execute(@"drop table if exists transaction_scope_test; + create table transaction_scope_test(value integer not null);"); + + try + { + using (var transactionScope = new TransactionScope()) + { + using (var conn = new MySqlConnection(AppConfig.ConnectionString)) + { + conn.Open(); + conn.Execute("insert into transaction_scope_test(value) values(1), (2);"); + + throw new ApplicationException(); + } + } + } + catch (ApplicationException) + { + } + + var values = m_database.Connection.Query(@"select value from transaction_scope_test order by value;").ToList(); + Assert.Equal(new int[0], values); + } + + + [Fact] + public void ThrowExceptionAfterCompleteInTransaction() + { + m_database.Connection.Execute(@"drop table if exists transaction_scope_test; + create table transaction_scope_test(value integer not null);"); + + try + { + using (var transactionScope = new TransactionScope()) + { + using (var conn = new MySqlConnection(AppConfig.ConnectionString)) + { + conn.Open(); + conn.Execute("insert into transaction_scope_test(value) values(1), (2);"); + + transactionScope.Complete(); + + throw new ApplicationException(); + } + } + } + catch (ApplicationException) + { + } + + var values = m_database.Connection.Query(@"select value from transaction_scope_test order by value;").ToList(); + Assert.Equal(new[] { 1, 2 }, values); + } + + [Fact +#if BASELINE + (Skip = "Multiple simultaneous connections or connections with different connection strings inside the same transaction are not currently supported.") +#endif + ] + public void CommitTwoTransactions() + { + m_database.Connection.Execute(@"drop table if exists transaction_scope_test_1; + drop table if exists transaction_scope_test_2; + create table transaction_scope_test_1(value integer not null); + create table transaction_scope_test_2(value integer not null);"); + + using (var transactionScope = new TransactionScope()) + { + using (var conn1 = new MySqlConnection(AppConfig.ConnectionString)) + { + conn1.Open(); + conn1.Execute("insert into transaction_scope_test_1(value) values(1), (2);"); + + using (var conn2 = new MySqlConnection(AppConfig.ConnectionString)) + { + conn2.Open(); + conn2.Execute("insert into transaction_scope_test_2(value) values(3), (4);"); + + transactionScope.Complete(); + } + } + } + + var values1 = m_database.Connection.Query(@"select value from transaction_scope_test_1 order by value;").ToList(); + var values2 = m_database.Connection.Query(@"select value from transaction_scope_test_2 order by value;").ToList(); + Assert.Equal(new[] { 1, 2 }, values1); + Assert.Equal(new[] { 3, 4 }, values2); + } + + + [Fact +#if BASELINE + (Skip = "Multiple simultaneous connections or connections with different connection strings inside the same transaction are not currently supported.") +#endif + ] + public void RollBackTwoTransactions() + { + m_database.Connection.Execute(@"drop table if exists transaction_scope_test_1; + drop table if exists transaction_scope_test_2; + create table transaction_scope_test_1(value integer not null); + create table transaction_scope_test_2(value integer not null);"); + + using (var transactionScope = new TransactionScope()) + { + using (var conn1 = new MySqlConnection(AppConfig.ConnectionString)) + { + conn1.Open(); + conn1.Execute("insert into transaction_scope_test_1(value) values(1), (2);"); + + using (var conn2 = new MySqlConnection(AppConfig.ConnectionString)) + { + conn2.Open(); + conn2.Execute("insert into transaction_scope_test_2(value) values(3), (4);"); + } + } + } + + var values1 = m_database.Connection.Query(@"select value from transaction_scope_test_1 order by value;").ToList(); + var values2 = m_database.Connection.Query(@"select value from transaction_scope_test_2 order by value;").ToList(); + Assert.Equal(new int[0], values1); + Assert.Equal(new int[0], values2); + } + DatabaseFixture m_database; + } +} +#endif