From 28b044a4f13b4f89d8cc57a8716b624245d4b2e8 Mon Sep 17 00:00:00 2001 From: Raul Almeida Date: Fri, 19 Dec 2025 16:29:48 -0300 Subject: [PATCH] Add TransactionScope support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit implements System.Transactions.TransactionScope support for AseConnection: - Add AseEnlistedTransaction class implementing IEnlistmentNotification for two-phase commit - Add EnlistTransaction(Transaction) method to AseConnection for manual enlistment - Add auto-enlistment in Transaction.Current when connection is opened (configurable via Enlist connection string parameter) - Add Enlist connection string parameter (default: true) to control auto-enlistment behavior - Add System.Transactions reference for net46 and enable for netstandard2.0/netcoreapp2.x Fixes #237 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- build/common.props | 15 +- src/AdoNetCore.AseClient/AseConnection.cs | 69 +++++- .../AseEnlistedTransaction.cs | 205 ++++++++++++++++++ .../Interface/IConnectionParameters.cs | 1 + .../Internal/ConnectionParameters.cs | 19 ++ 5 files changed, 302 insertions(+), 7 deletions(-) create mode 100644 src/AdoNetCore.AseClient/AseEnlistedTransaction.cs diff --git a/build/common.props b/build/common.props index f8bca093..37a3c20e 100644 --- a/build/common.props +++ b/build/common.props @@ -1,7 +1,7 @@ netcoreapp1.0;netcoreapp1.1;netcoreapp2.0;netcoreapp2.1;netcoreapp2.2;net46;netstandard2.0 - 0.19.2 + 0.19.3 0.11.0.0 $(VersionPrefix) dataaction @@ -9,7 +9,7 @@ icon.png https://github.com/DataAction/AdoNetCore.AseClient Apache-2.0 - Refer to GitHub - https://github.com/DataAction/AdoNetCore.AseClient/releases/tag/0.19.2 + Refer to GitHub - https://github.com/DataAction/AdoNetCore.AseClient/releases https://github.com/DataAction/AdoNetCore.AseClient git @@ -23,7 +23,7 @@ $(DefineConstants);ENABLE_DB_DATAPERMISSION - $(DefineConstants);ENABLE_SYSTEM_DATA_COMMON_EXTENSIONS;ENABLE_CLONEABLE_INTERFACE;ENABLE_SYSTEMEXCEPTION + $(DefineConstants);ENABLE_SYSTEM_DATA_COMMON_EXTENSIONS;ENABLE_CLONEABLE_INTERFACE;ENABLE_SYSTEMEXCEPTION;ENABLE_SYSTEM_TRANSACTIONS $(DefineConstants);ENABLE_ARRAY_POOL @@ -38,9 +38,12 @@ - - 4.5.0 - + + + + + + diff --git a/src/AdoNetCore.AseClient/AseConnection.cs b/src/AdoNetCore.AseClient/AseConnection.cs index 2c5283ee..d169e1e9 100644 --- a/src/AdoNetCore.AseClient/AseConnection.cs +++ b/src/AdoNetCore.AseClient/AseConnection.cs @@ -3,6 +3,10 @@ using System.Data; using System.Data.Common; using System.Net.Security; +#if ENABLE_SYSTEM_TRANSACTIONS +using System.Transactions; +using IsolationLevel = System.Data.IsolationLevel; +#endif using AdoNetCore.AseClient.Interface; using AdoNetCore.AseClient.Internal; @@ -26,6 +30,9 @@ public sealed class AseConnection : DbConnection private AseTransaction _transaction; private readonly IEventNotifier _eventNotifier; private bool? _namedParameters; +#if ENABLE_SYSTEM_TRANSACTIONS + private AseEnlistedTransaction _enlistedTransaction; +#endif /// /// Initializes a new instance of the class. @@ -271,9 +278,10 @@ public override void Open() InternalState = ConnectionState.Connecting; + IConnectionParameters parameters = null; try { - var parameters = ConnectionParameters.Parse(_connectionString); + parameters = ConnectionParameters.Parse(_connectionString); _internal = _connectionPoolManager.Reserve(_connectionString, parameters, _eventNotifier, UserCertificateValidationCallback); @@ -286,6 +294,14 @@ public override void Open() } InternalState = ConnectionState.Open; + +#if ENABLE_SYSTEM_TRANSACTIONS + // Auto-enlist in ambient transaction if Enlist=true (default) + if (parameters.Enlist && System.Transactions.Transaction.Current != null) + { + EnlistTransaction(System.Transactions.Transaction.Current); + } +#endif } /// @@ -631,6 +647,57 @@ public AseTransaction Transaction /// public RemoteCertificateValidationCallback UserCertificateValidationCallback { get; set; } + +#if ENABLE_SYSTEM_TRANSACTIONS + /// + /// Enlists in the specified transaction as a volatile resource manager. + /// + /// The transaction to enlist in, or null to unenlist. + /// + /// This method allows the connection to participate in a System.Transactions transaction. + /// If the connection is already enlisted in a different transaction, an exception is thrown. + /// + public override void EnlistTransaction(System.Transactions.Transaction transaction) + { + if (_isDisposed) + { + throw new ObjectDisposedException(nameof(AseConnection)); + } + + if (transaction == null) + { + // Unenlist from current transaction + _enlistedTransaction = null; + return; + } + + if (_enlistedTransaction != null) + { + if (_enlistedTransaction.SystemTransaction == transaction) + { + // Already enlisted in this transaction + return; + } + throw new InvalidOperationException("Connection is already enlisted in a transaction. Complete the current transaction before enlisting in another."); + } + + if (State != ConnectionState.Open) + { + throw new InvalidOperationException("Cannot enlist in a transaction when the connection is not open."); + } + + _enlistedTransaction = new AseEnlistedTransaction(this, transaction); + _enlistedTransaction.Begin(); + } + + /// + /// Called by AseEnlistedTransaction when the transaction completes. + /// + internal void OnEnlistedTransactionCompleted() + { + _enlistedTransaction = null; + } +#endif } /// diff --git a/src/AdoNetCore.AseClient/AseEnlistedTransaction.cs b/src/AdoNetCore.AseClient/AseEnlistedTransaction.cs new file mode 100644 index 00000000..438ed89b --- /dev/null +++ b/src/AdoNetCore.AseClient/AseEnlistedTransaction.cs @@ -0,0 +1,205 @@ +#if ENABLE_SYSTEM_TRANSACTIONS +using System; +using System.Data; +using System.Transactions; +using DataIsolationLevel = System.Data.IsolationLevel; + +namespace AdoNetCore.AseClient +{ + /// + /// Handles enlistment in a System.Transactions transaction for AseConnection. + /// This class implements IEnlistmentNotification to participate in the two-phase commit protocol. + /// + internal sealed class AseEnlistedTransaction : IEnlistmentNotification + { + private readonly AseConnection _connection; + private readonly Transaction _systemTransaction; + private AseTransaction _localTransaction; + private bool _isCompleted; + + /// + /// Gets the System.Transactions.Transaction that this enlistment is associated with. + /// + internal Transaction SystemTransaction => _systemTransaction; + + /// + /// Creates a new enlisted transaction wrapper. + /// + /// The AseConnection to enlist. + /// The System.Transactions.Transaction to enlist in. + internal AseEnlistedTransaction(AseConnection connection, Transaction transaction) + { + _connection = connection ?? throw new ArgumentNullException(nameof(connection)); + _systemTransaction = transaction ?? throw new ArgumentNullException(nameof(transaction)); + _isCompleted = false; + } + + /// + /// Begins a local transaction on the connection to participate in the distributed transaction. + /// + internal void Begin() + { + if (_connection.State != ConnectionState.Open) + { + throw new InvalidOperationException("Connection must be open to enlist in a transaction."); + } + + // Map System.Transactions isolation level to ADO.NET isolation level + var isolationLevel = MapIsolationLevel(_systemTransaction.IsolationLevel); + + // Start a local transaction that will be committed/rolled back based on the distributed transaction outcome + _localTransaction = new AseTransaction(_connection, isolationLevel); + _localTransaction.Begin(); + + // Enlist in the System.Transactions transaction + _systemTransaction.EnlistVolatile(this, EnlistmentOptions.None); + } + + /// + /// Maps System.Transactions.IsolationLevel to System.Data.IsolationLevel. + /// + private static DataIsolationLevel MapIsolationLevel(System.Transactions.IsolationLevel isolationLevel) + { + switch (isolationLevel) + { + case System.Transactions.IsolationLevel.Serializable: + return DataIsolationLevel.Serializable; + case System.Transactions.IsolationLevel.RepeatableRead: + return DataIsolationLevel.RepeatableRead; + case System.Transactions.IsolationLevel.ReadCommitted: + return DataIsolationLevel.ReadCommitted; + case System.Transactions.IsolationLevel.ReadUncommitted: + return DataIsolationLevel.ReadUncommitted; + case System.Transactions.IsolationLevel.Snapshot: + return DataIsolationLevel.Serializable; + case System.Transactions.IsolationLevel.Chaos: + return DataIsolationLevel.ReadUncommitted; + case System.Transactions.IsolationLevel.Unspecified: + default: + return DataIsolationLevel.ReadCommitted; + } + } + + /// + /// Called by the transaction manager during phase 1 of the two-phase commit protocol. + /// + public void Prepare(PreparingEnlistment preparingEnlistment) + { + if (_isCompleted) + { + preparingEnlistment.Prepared(); + return; + } + + try + { + preparingEnlistment.Prepared(); + } + catch (Exception ex) + { + preparingEnlistment.ForceRollback(ex); + } + } + + /// + /// Called by the transaction manager during phase 2 when the transaction should be committed. + /// + public void Commit(Enlistment enlistment) + { + if (_isCompleted) + { + enlistment.Done(); + return; + } + + try + { + if (_localTransaction != null && !_localTransaction.IsDisposed) + { + _localTransaction.Commit(); + } + _isCompleted = true; + enlistment.Done(); + } + catch (Exception) + { + _isCompleted = true; + enlistment.Done(); + throw; + } + finally + { + Cleanup(); + } + } + + /// + /// Called by the transaction manager when the transaction should be rolled back. + /// + public void Rollback(Enlistment enlistment) + { + if (_isCompleted) + { + enlistment.Done(); + return; + } + + try + { + if (_localTransaction != null && !_localTransaction.IsDisposed) + { + _localTransaction.Rollback(); + } + } + catch (Exception) + { + // Swallow rollback exceptions + } + finally + { + _isCompleted = true; + Cleanup(); + enlistment.Done(); + } + } + + /// + /// Called by the transaction manager when the transaction outcome is in doubt. + /// + public void InDoubt(Enlistment enlistment) + { + try + { + if (_localTransaction != null && !_localTransaction.IsDisposed) + { + _localTransaction.Rollback(); + } + } + catch (Exception) + { + // Swallow exceptions during in-doubt handling + } + finally + { + _isCompleted = true; + Cleanup(); + enlistment.Done(); + } + } + + /// + /// Cleans up resources after transaction completion. + /// + private void Cleanup() + { + if (_localTransaction != null && !_localTransaction.IsDisposed) + { + _localTransaction.Dispose(); + } + _localTransaction = null; + + _connection.OnEnlistedTransactionCompleted(); + } + } +} +#endif diff --git a/src/AdoNetCore.AseClient/Interface/IConnectionParameters.cs b/src/AdoNetCore.AseClient/Interface/IConnectionParameters.cs index 46f0b966..71edf317 100644 --- a/src/AdoNetCore.AseClient/Interface/IConnectionParameters.cs +++ b/src/AdoNetCore.AseClient/Interface/IConnectionParameters.cs @@ -28,5 +28,6 @@ internal interface IConnectionParameters bool AnsiNull { get; } bool EnableServerPacketSize { get; } bool NamedParameters { get; } + bool Enlist { get; } } } diff --git a/src/AdoNetCore.AseClient/Internal/ConnectionParameters.cs b/src/AdoNetCore.AseClient/Internal/ConnectionParameters.cs index 19a283b9..713e670e 100644 --- a/src/AdoNetCore.AseClient/Internal/ConnectionParameters.cs +++ b/src/AdoNetCore.AseClient/Internal/ConnectionParameters.cs @@ -62,6 +62,7 @@ internal class ConnectionParameters : IConnectionParameters {"AnsiNull", ParseAnsiNull}, {"EnableServerPacketSize", ParseEnableServerPacketSize}, {"NamedParameters", ParseNamedParameters}, + {"Enlist", ParseEnlist}, }; public static ConnectionParameters Parse(string connectionString) @@ -309,6 +310,18 @@ private static void ParseNamedParameters(ConnectionStringItem item, ConnectionPa } } + private static void ParseEnlist(ConnectionStringItem item, ConnectionParameters result) + { + if (int.TryParse(item.PropertyValue?.Trim(), out var intValue)) + { + result.Enlist = intValue != 0; + } + else if (bool.TryParse(item.PropertyValue?.Trim(), out var boolValue)) + { + result.Enlist = boolValue; + } + } + // ReSharper disable once ParameterOnlyUsedForPreconditionCheck.Local private static void ValidateConnectionParameters(ConnectionParameters result) { @@ -391,5 +404,11 @@ private static void ValidateConnectionParameters(ConnectionParameters result) public bool EnableServerPacketSize { get; private set; } = true; public bool NamedParameters { get; private set; } = true; + + /// + /// Gets a value indicating whether to automatically enlist in ambient System.Transactions transactions. + /// Default is true. + /// + public bool Enlist { get; private set; } = true; } }